Compare commits

..

5 Commits

42 changed files with 2799 additions and 626 deletions
+281
View File
@@ -0,0 +1,281 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { EntityGeometriesSearchItem, EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import type { Wiki } from "@/uhm/api/wikis";
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
type EditorSearchResultsProps = {
searchKind: UnifiedSearchKind;
onSearchKindChange: (kind: UnifiedSearchKind) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onLocalSearchQueryChange: (query: string) => void;
searchQueryDraft: string;
entitySearchResults: Entity[];
isEntitySearchLoading: boolean;
onAddEntityRefToProject: (entity: Entity) => void;
wikiSearchResults: Wiki[];
isWikiSearching: boolean;
onAddWikiRefToProject: (wiki: Wiki) => void;
geoSearchResults: EntityGeometriesSearchItem[];
isGeoSearching: boolean;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
};
export function EditorSearchResults({
searchKind,
onSearchKindChange,
searchQuery,
onSearchQueryChange,
onLocalSearchQueryChange,
searchQueryDraft,
entitySearchResults,
isEntitySearchLoading,
onAddEntityRefToProject,
wikiSearchResults,
isWikiSearching,
onAddWikiRefToProject,
geoSearchResults,
isGeoSearching,
onImportGeoFromSearch,
}: EditorSearchResultsProps) {
// Draft query quyết định có render kết quả hay không; query chính đã debounce ở page.
const hasQuery = searchQueryDraft.trim().length > 0;
return (
<>
<UnifiedSearchBar
kind={searchKind}
onKindChange={onSearchKindChange}
query={searchQuery}
onQueryChange={onSearchQueryChange}
onLocalQueryChange={onLocalSearchQueryChange}
/>
{searchKind === "entity" && hasQuery ? (
<SearchBox
title="Entity Results"
status={isEntitySearchLoading ? "Searching..." : `${entitySearchResults.length} results`}
>
{entitySearchResults.slice(0, 8).map((entity) => (
<ResultRow
key={entity.id}
title={entity.name}
subtitle={entity.id}
actionLabel="Add"
actionTitle="Add entity ref to project snapshot"
onAction={() => onAddEntityRefToProject(entity)}
/>
))}
{!isEntitySearchLoading && entitySearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "wiki" && hasQuery ? (
<SearchBox
title="Wiki Results"
status={isWikiSearching ? "Searching..." : `${wikiSearchResults.length} results`}
>
{wikiSearchResults.slice(0, 8).map((wiki) => (
<ResultRow
key={wiki.id}
title={(wiki.title || "").trim() || "Untitled wiki"}
subtitle={wiki.id}
actionLabel="Add"
actionTitle="Add wiki ref to project snapshot"
onAction={() => onAddWikiRefToProject(wiki)}
/>
))}
{!isWikiSearching && wikiSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "geo" && hasQuery ? (
<SearchBox
title="Geo Results"
status={isGeoSearching ? "Searching..." : `${geoSearchResults.length} entities`}
>
{geoSearchResults.slice(0, 6).map((item) => (
<GeoResultGroup
key={item.entity_id}
item={item}
onImportGeoFromSearch={onImportGeoFromSearch}
/>
))}
{!isGeoSearching && geoSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
</>
);
}
function SearchBox({
title,
status,
children,
}: {
title: string;
status: string;
children: ReactNode;
}) {
return (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>{title}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{status}</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>{children}</div>
</div>
);
}
function ResultRow({
title,
subtitle,
actionLabel,
actionTitle,
onAction,
}: {
title: string;
subtitle: string;
actionLabel: string;
actionTitle: string;
onAction: () => void;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{title}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{subtitle}
</div>
</div>
<button
type="button"
onClick={onAction}
style={actionButtonStyle}
title={actionTitle}
>
{actionLabel}
</button>
</div>
);
}
function GeoResultGroup({
item,
onImportGeoFromSearch,
}: {
item: EntityGeometriesSearchItem;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
}) {
const geometries = Array.isArray(item.geometries) ? item.geometries : [];
return (
<div
style={{
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
display: "grid",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.name?.trim() || item.entity_id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.entity_id}
</div>
</div>
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
{geometries.length} geos
</div>
</div>
{item.description?.trim() ? (
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
{item.description.trim()}
</div>
) : null}
{geometries.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{geometries.map((geo) => (
<div
key={geo.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #243244",
background: "#0f172a",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
#{geo.id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => onImportGeoFromSearch(item, geo)}
style={{ ...actionButtonStyle, flex: "0 0 auto" }}
title="Import geometry into current editor draft"
>
Import
</button>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No geometry linked.</div>
)}
</div>
);
}
function EmptyResult() {
return <div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>;
}
const actionButtonStyle: CSSProperties = {
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
};
+49
View File
@@ -0,0 +1,49 @@
"use client";
import type { PointerEvent as ReactPointerEvent } from "react";
type ResizeHandleProps = {
onDrag: (deltaX: number) => void;
title: string;
};
export function ResizeHandle({ onDrag, title }: ResizeHandleProps) {
// Theo dõi pointer toàn window để resize vẫn mượt khi cursor đi ra khỏi handle.
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
let lastX = startX;
const onMove = (e: PointerEvent) => {
const deltaX = e.clientX - lastX;
if (deltaX !== 0) {
onDrag(deltaX);
lastX = e.clientX;
}
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<div
role="separator"
aria-orientation="vertical"
title={title}
onPointerDown={handlePointerDown}
style={{
width: 6,
cursor: "col-resize",
background: "rgba(148, 163, 184, 0.08)",
borderLeft: "1px solid rgba(148, 163, 184, 0.18)",
borderRight: "1px solid rgba(148, 163, 184, 0.18)",
flex: "0 0 auto",
}}
/>
);
}
+141
View File
@@ -0,0 +1,141 @@
import type { ProjectCommit } from "@/uhm/api/projects";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, Geometry } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
// Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ.
export function clampNumber(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Tạo label ngắn cho commit history, ưu tiên summary người dùng nhập.
export function formatCommitTitle(commit: ProjectCommit): string {
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
}
// Kiểm tra feature có nằm trong năm timeline đang active hay không.
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
const start = feature.properties.time_start;
const end = feature.properties.time_end;
if (typeof start === "number" && Number.isFinite(start) && year < start) return false;
if (typeof end === "number" && Number.isFinite(end) && year > end) return false;
return true;
}
// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng.
export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.filter((w) => {
if (w.source === "ref") return true;
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
const title = typeof w.title === "string" ? w.title.trim() : "";
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
return title.length > 0 || (w.doc !== null && doc.length > 0);
})
.map((w) => ({
id: w.id,
source: w.source,
title: typeof w.title === "string" ? w.title.trim() : "",
slug: typeof w.slug === "string" ? w.slug : null,
doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null,
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
// Chuẩn hóa entity snapshot để phát hiện thay đổi name/description/source.
export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
.map((e) => ({
id: String(e.id),
source: e.source,
name: typeof e.name === "string" ? e.name.trim() : "",
description: e.description == null ? null : String(e.description),
time_start: typeof e.time_start === "number" ? e.time_start : null,
time_end: typeof e.time_end === "number" ? e.time_end : null,
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
// Chuẩn hóa binding entity-wiki để dirty check không bị nhiễu bởi thứ tự.
export function normalizeEntityWikiLinksForCompare(
input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined
) {
const list = Array.isArray(input) ? input : [];
return list
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
.map((l) => ({
entity_id: l.entity_id,
wiki_id: l.wiki_id,
operation: l.operation === "delete" ? "delete" : "binding",
}))
.sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id));
}
// Chuẩn hóa replay để phát hiện thay đổi script/target geometry.
export function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
.map((replay) => ({
id: typeof replay.id === "string" ? replay.id : replay.geometry_id,
geometry_id: replay.geometry_id,
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
replay.target_geometry_ids,
replay.geometry_id
),
detail: Array.isArray(replay.detail) ? replay.detail : [],
}))
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
}
// Bảo toàn geometry chính ở vị trí đầu và loại bỏ id trùng trong replay target list.
function normalizeReplayTargetGeometryIdsForCompare(
input: string[] | null | undefined,
geometryId: string
) {
const orderedIds: string[] = [];
const seen = new Set<string>();
const pushId = (rawId: string | number | null | undefined) => {
if (rawId == null) return;
const id = String(rawId).trim();
if (!id || seen.has(id)) return;
seen.add(id);
orderedIds.push(id);
};
pushId(geometryId);
for (const rawId of input || []) pushId(rawId);
return orderedIds;
}
// Validate tối thiểu geometry trả về từ search trước khi đưa vào draft.
export function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
if (!value || typeof value !== "object") return null;
const geometry = value as Record<string, unknown>;
if (typeof geometry.type !== "string") return null;
if (!("coordinates" in geometry)) return null;
return value as Geometry;
}
// Chuẩn hóa danh sách binding id từ API search GEO.
export function normalizeGeoSearchBindingIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of value) {
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
const id = String(rawId).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
+2 -6
View File
@@ -43,6 +43,7 @@ export function useFeatureCommands(options: Options) {
setEntityFormStatus, setEntityFormStatus,
} = options; } = options;
// Áp metadata GEO (type/time/binding) cho toàn bộ selectedFeatures.
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => { const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
if (!selectedFeatures || selectedFeatures.length === 0) { if (!selectedFeatures || selectedFeatures.length === 0) {
const msg = "Hãy chọn ít nhất một geometry trước."; const msg = "Hãy chọn ít nhất một geometry trước.";
@@ -50,12 +51,6 @@ export function useFeatureCommands(options: Options) {
return { ok: false, error: msg }; return { ok: false, error: msg };
} }
if (!geometryMetaForm.time_start.trim() || !geometryMetaForm.time_end.trim()) {
const msg = "time_start và time_end là bắt buộc.";
setEntityFormStatus(msg);
return { ok: false, error: msg };
}
let metadata; let metadata;
try { try {
metadata = buildGeometryMetadataPatch(geometryMetaForm); metadata = buildGeometryMetadataPatch(geometryMetaForm);
@@ -90,6 +85,7 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting, setIsEntitySubmitting,
]); ]);
// Áp danh sách entity đã chọn vào toàn bộ selectedFeatures.
const applyEntitiesToSelectedGeometry = useCallback(async () => { const applyEntitiesToSelectedGeometry = useCallback(async () => {
if (!selectedFeatures || selectedFeatures.length === 0) { if (!selectedFeatures || selectedFeatures.length === 0) {
setEntityFormStatus("Hãy chọn ít nhất một geometry trước."); setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
File diff suppressed because it is too large Load Diff
+128 -4
View File
@@ -1,7 +1,131 @@
import { redirect } from "next/navigation"; "use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ApiError } from "@/uhm/api/http";
import { fetchProjects, type Project } from "@/uhm/api/projects";
export default function EditorIndexPage() { export default function EditorIndexPage() {
// Editor must be opened from a specific project (see /user/projects). const router = useRouter();
redirect("/user/projects"); // State danh sách project mà user hiện tại có quyền mở trong editor.
} const [projects, setProjects] = useState<Project[]>([]);
// State loading cho lần tải đầu của route /editor.
const [isLoading, setIsLoading] = useState(true);
// State lỗi hiển thị trực tiếp khi API hoặc auth không hợp lệ.
const [error, setError] = useState<string | null>(null);
// Sắp xếp project mới cập nhật lên đầu để user mở nhanh project đang làm.
const sortedProjects = useMemo(() => {
return [...projects].sort((a, b) => {
const aTime = Date.parse(a.updated_at || a.created_at || "");
const bTime = Date.parse(b.updated_at || b.created_at || "");
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
});
}, [projects]);
// Route /editor là landing page: tải project list và để /editor/[id] xử lý editor đầy đủ.
useEffect(() => {
let disposed = false;
async function loadProjects() {
try {
setIsLoading(true);
setError(null);
const rows = await fetchProjects();
if (!disposed) setProjects(rows || []);
} catch (err) {
if (disposed) return;
if (err instanceof ApiError && err.status === 401) {
router.replace("/signin");
return;
}
setError(err instanceof Error ? err.message : "Không tải được danh sách project.");
} finally {
if (!disposed) setIsLoading(false);
}
}
void loadProjects();
return () => {
disposed = true;
};
}, [router]);
return (
<main style={{ minHeight: "100vh", background: "#0b1220", color: "#e5e7eb", padding: 24 }}>
<div style={{ maxWidth: 960, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800 }}>Editor</h1>
<p style={{ margin: "8px 0 0", color: "#94a3b8", fontSize: 14 }}>
Chọn project đ mở route <code>/editor/[id]</code>.
</p>
</div>
<button
type="button"
onClick={() => router.push("/user/projects")}
style={{
border: "1px solid #334155",
background: "#111827",
color: "#e5e7eb",
borderRadius: 10,
padding: "10px 12px",
cursor: "pointer",
fontWeight: 700,
}}
>
Quản project
</button>
</div>
<section
style={{
marginTop: 24,
border: "1px solid #1f2937",
borderRadius: 16,
background: "#111827",
overflow: "hidden",
}}
>
{isLoading ? (
<div style={{ padding: 24, color: "#94a3b8" }}>Đang tải project...</div>
) : error ? (
<div style={{ padding: 24, color: "#fecaca" }}>{error}</div>
) : sortedProjects.length === 0 ? (
<div style={{ padding: 24, color: "#94a3b8" }}>
Chưa project. Vào trang quản project đ tạo mới.
</div>
) : (
<div style={{ display: "grid" }}>
{sortedProjects.map((project) => (
<button
key={project.id}
type="button"
onClick={() => router.push(`/editor/${project.id}`)}
style={{
display: "grid",
gap: 6,
textAlign: "left",
border: 0,
borderBottom: "1px solid #1f2937",
background: "transparent",
color: "#e5e7eb",
padding: 16,
cursor: "pointer",
}}
>
<span style={{ fontSize: 15, fontWeight: 800 }}>
{project.title || `Project ${project.id}`}
</span>
<span style={{ fontSize: 12, color: "#94a3b8" }}>
{project.project_status || "ACTIVE"} · {project.id}
</span>
</button>
))}
</div>
)}
</section>
</div>
</main>
);
}
+113 -8
View File
@@ -8,7 +8,7 @@ import TimelineBar from "@/uhm/components/ui/TimelineBar";
import { fetchEntities, type Entity } from "@/uhm/api/entities"; import { fetchEntities, type Entity } from "@/uhm/api/entities";
import { fetchGeometriesByBBox } from "@/uhm/api/geometries"; import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
import { ApiError } from "@/uhm/api/http"; import { ApiError } from "@/uhm/api/http";
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { fetchWikiBySlug, getContentByVersionWikiId, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
import { import {
BACKGROUND_LAYER_OPTIONS, BACKGROUND_LAYER_OPTIONS,
type BackgroundLayerId, type BackgroundLayerId,
@@ -88,6 +88,34 @@ export default function Page() {
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null); const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
const [entityFocusToken, setEntityFocusToken] = useState(0); const [entityFocusToken, setEntityFocusToken] = useState(0);
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("public-wiki-sidebar-width");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) {
return parsed;
}
}
}
return 420;
});
const [isLargeScreen, setIsLargeScreen] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const handleResize = () => {
setIsLargeScreen(window.innerWidth >= 1024);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const maxDragWidth = typeof window !== "undefined"
? Math.min(800, window.innerWidth - 340)
: 800;
const timelineFetchRequestRef = useRef(0); const timelineFetchRequestRef = useRef(0);
const hoverHideTimerRef = useRef<number | null>(null); const hoverHideTimerRef = useRef<number | null>(null);
const hoverPopupHoveredRef = useRef(false); const hoverPopupHoveredRef = useRef(false);
@@ -249,6 +277,10 @@ export default function Page() {
const activeEntityGeometries = activeEntityId const activeEntityGeometries = activeEntityId
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION ? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
: EMPTY_FEATURE_COLLECTION; : EMPTY_FEATURE_COLLECTION;
const mapLabelContextDraft = useMemo(
() => buildEntityLabelContextDraft(data, relations.geometryEntityIds, relations.entitiesById),
[data, relations.entitiesById, relations.geometryEntityIds]
);
const activeWiki = useMemo(() => { const activeWiki = useMemo(() => {
if (!activeWikiSlug) return null; if (!activeWikiSlug) return null;
@@ -335,7 +367,7 @@ export default function Page() {
selectEntity(onlyEntityId, { selectEntity(onlyEntityId, {
sourceFeatureId: selectedFeatureIds[0], sourceFeatureId: selectedFeatureIds[0],
focusMap: false, focusMap: true,
selectGeometry: false, selectGeometry: false,
}); });
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]); }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
@@ -382,6 +414,8 @@ export default function Page() {
}; };
}, [linkEntityPopup]); }, [linkEntityPopup]);
const cachedWiki = activeWikiSlug ? (wikiCache[activeWikiSlug] as Wiki & { __fetched?: boolean }) : undefined;
useEffect(() => { useEffect(() => {
if (!activeWikiSlug) { if (!activeWikiSlug) {
setIsActiveWikiLoading(false); setIsActiveWikiLoading(false);
@@ -389,10 +423,13 @@ export default function Page() {
return; return;
} }
const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null; if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) {
if (cached?.content) {
setIsActiveWikiLoading(false); setIsActiveWikiLoading(false);
if (cachedWiki.id === "__not_found__") {
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
} else {
setActiveWikiError(null); setActiveWikiError(null);
}
return; return;
} }
@@ -403,9 +440,30 @@ export default function Page() {
try { try {
const row = await fetchWikiBySlug(activeWikiSlug); const row = await fetchWikiBySlug(activeWikiSlug);
if (disposed) return; if (disposed) return;
if (row) { if (row) {
setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row })); let versionContent = row.content;
try {
if (row.content_sample?.[0]?.id) {
const res = await getContentByVersionWikiId(row.content_sample[0].id);
if (res?.data?.content) {
versionContent = res.data.content;
}
}
} catch (err) {
console.error("Failed to fetch version content:", err);
}
if (disposed) return;
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true } as any,
}));
} else { } else {
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
}));
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn."); setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
} }
} catch (err) { } catch (err) {
@@ -419,7 +477,7 @@ export default function Page() {
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]); }, [activeWikiSlug, cachedWiki]);
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => { const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || []; const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
@@ -466,6 +524,8 @@ export default function Page() {
<Map <Map
mode="select" mode="select"
draft={data} draft={data}
labelContextDraft={mapLabelContextDraft}
labelTimelineYear={timelineDraftYear}
selectedFeatureIds={selectedFeatureIds} selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
@@ -476,7 +536,7 @@ export default function Page() {
highlightFeatures={activeEntityGeometries} highlightFeatures={activeEntityGeometries}
focusFeatureCollection={activeEntityGeometries} focusFeatureCollection={activeEntityGeometries}
focusRequestKey={entityFocusToken} focusRequestKey={entityFocusToken}
focusPadding={activeEntityId ? { top: 84, right: 500, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }} focusPadding={activeEntityId && isLargeScreen ? { top: 84, right: sidebarWidth + 80, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
/> />
) : ( ) : (
<div className="h-screen w-full bg-[#0b1220]" /> <div className="h-screen w-full bg-[#0b1220]" />
@@ -490,6 +550,7 @@ export default function Page() {
isLoading={isTimelineLoading} isLoading={isTimelineLoading}
disabled={false} disabled={false}
statusText={timelineStatus} statusText={timelineStatus}
style={activeEntityId && isLargeScreen ? { right: `${sidebarWidth + 32}px` } : undefined}
/> />
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur"> <div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
@@ -626,7 +687,7 @@ export default function Page() {
) : null} ) : null}
{activeEntity ? ( {activeEntity ? (
<aside className="absolute bottom-4 right-4 top-4 z-20 w-[420px] max-w-[calc(100vw-2rem)]"> <aside className="absolute bottom-4 right-4 top-4 z-20 max-w-[calc(100vw-2rem)]">
<PublicWikiSidebar <PublicWikiSidebar
entity={activeEntity} entity={activeEntity}
wiki={activeWiki} wiki={activeWiki}
@@ -637,8 +698,12 @@ export default function Page() {
setActiveWikiSlug(null); setActiveWikiSlug(null);
setActiveWikiError(null); setActiveWikiError(null);
setLinkEntityPopup(null); setLinkEntityPopup(null);
setSelectedFeatureIds([]);
}} }}
onWikiLinkRequest={handleWikiLinkRequest} onWikiLinkRequest={handleWikiLinkRequest}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={setSidebarWidth}
maxDragWidth={maxDragWidth}
/> />
</aside> </aside>
) : null} ) : null}
@@ -768,6 +833,46 @@ function normalizeRelationArrays(target: Record<string, string[]>) {
} }
} }
function buildEntityLabelContextDraft(
draft: FeatureCollection,
geometryEntityIds: Record<string, string[]>,
entitiesById: Record<string, Entity>
): FeatureCollection {
if (!draft.features.length) return draft;
return {
...draft,
features: draft.features.map((feature) => {
const entityIds = geometryEntityIds[String(feature.properties.id)] || [];
if (!entityIds.length) return feature;
const candidates = entityIds.map((id) => {
const entity = entitiesById[id] || null;
const name = String(entity?.name || id).trim();
if (!name) return null;
return {
id,
name,
time_start: entity?.time_start ?? null,
time_end: entity?.time_end ?? null,
};
}).filter((candidate) => candidate !== null);
return {
...feature,
properties: {
...feature.properties,
entity_id: entityIds[0] || null,
entity_ids: entityIds,
entity_name: candidates[0]?.name || null,
entity_names: candidates.map((candidate) => candidate.name),
entity_label_candidates: candidates,
},
};
}),
};
}
function clampNumber(value: number, min: number, max: number): number { function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min; if (!Number.isFinite(value)) return min;
if (value < min) return min; if (value < min) return min;
+2 -2
View File
@@ -61,13 +61,13 @@ export default function LandingPage() {
<div className="fixed inset-0 bg-gradient-to-b from-[#FDFBF7]/80 via-[#FDFBF7]/70 to-[#FDFBF7]/90 -z-10 pointer-events-none"></div> <div className="fixed inset-0 bg-gradient-to-b from-[#FDFBF7]/80 via-[#FDFBF7]/70 to-[#FDFBF7]/90 -z-10 pointer-events-none"></div>
{/* --- HEADER NAVBAR --- */} {/* --- HEADER NAVBAR --- */}
<header className="fixed top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-50 border-b border-[#A88B4C]/20"> <header className="sticky top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-40 border-b border-[#A88B4C]/20">
<div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase"> <div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase">
<span className="text-[#A88B4C]">Geo</span>History <span className="text-[#A88B4C]">Geo</span>History
</div> </div>
</header> </header>
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 flex flex-col gap-32 w-full relative"> <main className="max-w-6xl mx-auto px-6 pt-8 pb-24 flex flex-col gap-32 w-full relative">
{/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */} {/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */}
<section className="min-h-[70vh] flex flex-col justify-center relative"> <section className="min-h-[70vh] flex flex-col justify-center relative">
<h1 className="text-5xl md:text-7xl font-black leading-tight tracking-tight mb-6"> <h1 className="text-5xl md:text-7xl font-black leading-tight tracking-tight mb-6">
+1 -1
View File
@@ -34,7 +34,7 @@ export default function AdminLayout({
? "ml-0" ? "ml-0"
: isExpanded || isHovered : isExpanded || isHovered
? "lg:ml-[290px]" ? "lg:ml-[290px]"
: "lg:ml-[0px]"; : "lg:ml-[88px]";
return ( return (
<div className="min-h-screen xl:flex"> <div className="min-h-screen xl:flex">
+5
View File
@@ -63,19 +63,24 @@ export default function Editor({
undoStack, undoStack,
width = 280, width = 280,
}: Props) { }: Props) {
// State đóng/mở modal submit project.
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
// State nội dung submit gửi lên backend khi user xác nhận.
const [submitContent, setSubmitContent] = useState(""); const [submitContent, setSubmitContent] = useState("");
// Mở modal submit với nội dung sạch cho lần submit mới.
const handleOpenSubmitModal = () => { const handleOpenSubmitModal = () => {
setSubmitContent(""); setSubmitContent("");
setIsSubmitModalOpen(true); setIsSubmitModalOpen(true);
}; };
// Xác nhận submit: đóng modal trước rồi chuyển content cho command cha.
const handleConfirmSubmit = () => { const handleConfirmSubmit = () => {
setIsSubmitModalOpen(false); setIsSubmitModalOpen(false);
onSubmit(submitContent); onSubmit(submitContent);
}; };
// Hủy submit mà không thay đổi draft/commit.
const handleCancelSubmit = () => { const handleCancelSubmit = () => {
setIsSubmitModalOpen(false); setIsSubmitModalOpen(false);
}; };
+68
View File
@@ -11,6 +11,7 @@ import { useMapInstance } from "./map/useMapInstance";
import { setupMapLayers } from "./map/useMapLayers"; import { setupMapLayers } from "./map/useMapLayers";
import { useMapInteraction } from "./map/useMapInteraction"; import { useMapInteraction } from "./map/useMapInteraction";
import { useMapSync } from "./map/useMapSync"; import { useMapSync } from "./map/useMapSync";
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
export type MapHoverPayload = { export type MapHoverPayload = {
featureId: string | number; featureId: string | number;
@@ -39,8 +40,10 @@ type MapProps = {
onSelectFeatureIds: (ids: (string | number)[]) => void; onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void; onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
labelContextDraft?: FeatureCollection; labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void; onDeleteFeature?: (id: string | number) => void;
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean; allowGeometryEditing?: boolean;
respectBindingFilter?: boolean; respectBindingFilter?: boolean;
@@ -52,6 +55,8 @@ type MapProps = {
focusFeatureCollection?: FeatureCollection | null; focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null; focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions; focusPadding?: number | import("maplibre-gl").PaddingOptions;
imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
}; };
const Map = forwardRef<MapHandle, MapProps>(function Map({ const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -63,8 +68,10 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
selectedFeatureIds, selectedFeatureIds,
onSelectFeatureIds, onSelectFeatureIds,
labelContextDraft, labelContextDraft,
labelTimelineYear,
onCreateFeature, onCreateFeature,
onDeleteFeature, onDeleteFeature,
onHideFeature,
onUpdateFeature, onUpdateFeature,
allowGeometryEditing = true, allowGeometryEditing = true,
respectBindingFilter = true, respectBindingFilter = true,
@@ -76,15 +83,31 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
focusFeatureCollection = null, focusFeatureCollection = null,
focusRequestKey = null, focusRequestKey = null,
focusPadding, focusPadding,
imageOverlay = null,
onImageOverlayChange,
}, ref) { }, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
// Ref giữ draft mới nhất để engine đọc không bị stale closure.
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
// Ref callback select feature mới nhất cho event click trên map.
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
const onSetModeRef = useRef(onSetMode); const onSetModeRef = useRef(onSetMode);
// Ref callback hover mới nhất cho tooltip/panel ngoài map.
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange); const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
// Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature); const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback delete mới nhất khi editing engine xóa feature.
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature); const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
// Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map.
const onHideRef = useRef<MapProps["onHideFeature"]>(onHideFeature);
// Ref callback update mới nhất khi editing engine đổi geometry.
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature); const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
// Ref giữ overlay mới nhất cho right-drag controls.
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
// Ref callback update overlay mới nhất để interaction không stale.
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { draftRef.current = draft; }, [draft]);
@@ -93,8 +116,12 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]);
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]);
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const { const {
mapRef, mapRef,
containerRef, containerRef,
@@ -107,14 +134,18 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
geolocationCenteredRef, geolocationCenteredRef,
handleZoomByStep, handleZoomByStep,
handleZoomSliderChange, handleZoomSliderChange,
beginZoomSliderDrag,
endZoomSliderDrag,
getViewState, getViewState,
} = useMapInstance(); } = useMapInstance();
// Public API cho parent đọc map instance/view state mà không expose implementation nội bộ.
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getViewState, getViewState,
getMap: () => mapRef.current, getMap: () => mapRef.current,
}), [getViewState, mapRef]); }), [getViewState, mapRef]);
// Hook gắn/dọn các interaction vẽ, chọn, sửa geometry.
const { const {
editingEngineRef, editingEngineRef,
setupMapInteractions, setupMapInteractions,
@@ -130,18 +161,22 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onSetModeRef, onSetModeRef,
onCreateRef, onCreateRef,
onDeleteRef, onDeleteRef,
onHideRef,
onUpdateRef, onUpdateRef,
onHoverFeatureChangeRef, onHoverFeatureChangeRef,
}); });
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const { const {
applyDraftToMap, applyDraftToMap,
applyHighlightToMap, applyHighlightToMap,
applyImageOverlayToMap,
tryCenterToUserLocation, tryCenterToUserLocation,
} = useMapSync({ } = useMapSync({
mapRef, mapRef,
draft, draft,
labelContextDraft, labelContextDraft,
labelTimelineYear,
backgroundVisibility, backgroundVisibility,
geometryVisibility, geometryVisibility,
selectedFeatureIds, selectedFeatureIds,
@@ -152,6 +187,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
focusFeatureCollection, focusFeatureCollection,
focusRequestKey, focusRequestKey,
focusPadding, focusPadding,
imageOverlay,
allowGeometryEditing, allowGeometryEditing,
editingEngineRef, editingEngineRef,
geolocationCenteredRef, geolocationCenteredRef,
@@ -162,6 +198,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
if (!map || !isMapLoaded) return; if (!map || !isMapLoaded) return;
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap); setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
applyImageOverlayToMap();
setupMapInteractions(map); setupMapInteractions(map);
applyDraftToMap(draftRef.current); applyDraftToMap(draftRef.current);
tryCenterToUserLocation(); tryCenterToUserLocation();
@@ -180,6 +217,17 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
} }
}, [mode, isMapLoaded, mapRef]); }, [mode, isMapLoaded, mapRef]);
const hasImageOverlay = Boolean(imageOverlay);
useEffect(() => {
const map = mapRef.current;
if (!map || !isMapLoaded || !hasImageOverlay) return;
return bindImageOverlayInteractions(
map,
() => imageOverlayRef.current,
(nextOverlay) => onImageOverlayChangeRef.current?.(nextOverlay)
);
}, [hasImageOverlay, isMapLoaded, mapRef]);
return ( return (
<div style={{ width: "100%", height, position: "relative" }}> <div style={{ width: "100%", height, position: "relative" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} /> <div ref={containerRef} style={{ width: "100%", height: "100%" }} />
@@ -320,6 +368,26 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
max={zoomBounds.max} max={zoomBounds.max}
step={0.1} step={0.1}
value={zoomLevel} value={zoomLevel}
onPointerDown={(event) => {
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Browser may reject capture for non-primary pointers; drag lock still works.
}
beginZoomSliderDrag();
}}
onPointerUp={(event) => {
event.stopPropagation();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore if capture was already released.
}
endZoomSliderDrag();
}}
onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))} onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
style={{ style={{
flex: 1, flex: 1,
@@ -8,6 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = { type GeometryChoice = {
id: string; id: string;
label?: string; label?: string;
time_start?: number | null;
time_end?: number | null;
isTimelineVisible?: boolean;
isNew?: boolean; isNew?: boolean;
}; };
@@ -31,16 +34,16 @@ export default function GeometryBindingPanel({
statusText, statusText,
bindingFilterEnabled, bindingFilterEnabled,
setGeometryBindingFilterEnabled, setGeometryBindingFilterEnabled,
hoveredGeometryId, geometryVisibility,
setHoveredGeometryId, setGeometryVisibility,
} = useEditorStore( } = useEditorStore(
useShallow((state) => ({ useShallow((state) => ({
selectedFeatureIds: state.selectedFeatureIds, selectedFeatureIds: state.selectedFeatureIds,
statusText: state.geoBindingStatus, statusText: state.geoBindingStatus,
bindingFilterEnabled: state.geometryBindingFilterEnabled, bindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled, setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
hoveredGeometryId: state.hoveredGeometryId, geometryVisibility: state.geometryVisibility,
setHoveredGeometryId: state.setHoveredGeometryId, setGeometryVisibility: state.setGeometryVisibility,
})) }))
); );
const effectiveSelectedGeometryId = const effectiveSelectedGeometryId =
@@ -55,7 +58,14 @@ export default function GeometryBindingPanel({
const rows = useMemo(() => { const rows = useMemo(() => {
const cleaned = (geometries || []) const cleaned = (geometries || [])
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0) .filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), isNew: Boolean(g.isNew) })); .map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
time_start: typeof g.time_start === "number" ? g.time_start : null,
time_end: typeof g.time_end === "number" ? g.time_end : null,
isTimelineVisible: Boolean(g.isTimelineVisible),
isNew: Boolean(g.isNew),
}));
cleaned.sort((a, b) => a.id.localeCompare(b.id)); cleaned.sort((a, b) => a.id.localeCompare(b.id));
return cleaned; return cleaned;
}, [geometries]); }, [geometries]);
@@ -80,15 +90,20 @@ export default function GeometryBindingPanel({
if (!canFocusGeometry) return; if (!canFocusGeometry) return;
if (event.key !== "Enter" && event.key !== " ") return; if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault(); event.preventDefault();
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId); onFocusGeometry?.(geometryId);
}; };
const handleFocusGeometry = (geometryId: string) => { const handleFocusGeometry = (geometryId: string) => {
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId); onFocusGeometry?.(geometryId);
}; };
const toggleGeometryVisibility = (geometryId: string) => {
setGeometryVisibility((prev) => ({
...prev,
[geometryId]: prev[geometryId] === false,
}));
};
return ( return (
<div <div
style={{ style={{
@@ -97,7 +112,6 @@ export default function GeometryBindingPanel({
borderRadius: "8px", borderRadius: "8px",
border: "1px solid #1f2937", border: "1px solid #1f2937",
}} }}
onMouseLeave={() => setHoveredGeometryId(null)}
> >
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
@@ -148,31 +162,28 @@ export default function GeometryBindingPanel({
</div> </div>
{collapsed ? null : selectedGeometry ? ( {collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const idColor = getGeometryIdColor(selectedGeometry);
const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb";
return (
<div <div
style={{ style={{
marginTop: 10, marginTop: 10,
padding: "8px", padding: "8px",
borderRadius: "6px", borderRadius: "6px",
border: border:
hoveredGeometryId === selectedGeometry.id "1px solid rgba(59, 130, 246, 0.45)",
? "1px solid rgba(245, 158, 11, 0.95)" background: "rgba(37, 99, 235, 0.12)",
: "1px solid rgba(59, 130, 246, 0.45)",
background:
hoveredGeometryId === selectedGeometry.id
? "rgba(245, 158, 11, 0.18)"
: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default", cursor: canFocusGeometry ? "pointer" : "default",
boxShadow: opacity: isHidden ? 0.58 : 1,
hoveredGeometryId === selectedGeometry.id boxShadow: "none",
? "0 0 0 2px rgba(251, 191, 36, 0.18)"
: "none",
}} }}
title={selectedGeometry.id} title={selectedGeometry.id}
role={canFocusGeometry ? "button" : undefined} role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined} tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)} onClick={() => handleFocusGeometry(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)} onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)}
> >
<div <div
style={{ style={{
@@ -190,7 +201,7 @@ export default function GeometryBindingPanel({
<span <span
style={{ style={{
fontSize: "12px", fontSize: "12px",
color: "#e5e7eb", color: labelColor,
fontWeight: 700, fontWeight: 700,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
@@ -199,13 +210,26 @@ export default function GeometryBindingPanel({
> >
{selectedGeometry.label || selectedGeometry.id} {selectedGeometry.label || selectedGeometry.id}
</span> </span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{selectedGeometry.isNew ? <NewBadge /> : null} {selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(selectedGeometry.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${selectedGeometry.id}` : `Hide geometry ${selectedGeometry.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div> </div>
<div <div
style={{ style={{
marginTop: 3, marginTop: 3,
fontSize: "11px", fontSize: "11px",
color: "#94a3b8", color: idColor,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
@@ -214,6 +238,8 @@ export default function GeometryBindingPanel({
{selectedGeometry.id} {selectedGeometry.id}
</div> </div>
</div> </div>
);
})()
) : null} ) : null}
{collapsed ? null : rows.length ? ( {collapsed ? null : rows.length ? (
@@ -221,36 +247,33 @@ export default function GeometryBindingPanel({
{visibleRows {visibleRows
.map((g) => { .map((g) => {
const isBound = bindingSet.has(g.id); const isBound = bindingSet.has(g.id);
const isHovered = hoveredGeometryId === g.id; const isHidden = geometryVisibility[g.id] === false;
const idColor = getGeometryIdColor(g);
const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb";
return ( return (
<div <div
key={g.id} key={g.id}
style={{ style={{
padding: "8px", padding: "8px",
borderRadius: "6px", borderRadius: "6px",
border: isHovered border: isBound
? "1px solid rgba(245, 158, 11, 0.95)"
: isBound
? "1px solid rgba(20, 184, 166, 0.65)" ? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937", : "1px solid #1f2937",
background: isHovered background: isBound
? "rgba(245, 158, 11, 0.18)"
: isBound
? "rgba(20, 184, 166, 0.12)" ? "rgba(20, 184, 166, 0.12)"
: "transparent", : "transparent",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
cursor: canFocusGeometry ? "pointer" : "default", cursor: canFocusGeometry ? "pointer" : "default",
opacity: canBindToggle ? 1 : 0.75, opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none", boxShadow: "none",
}} }}
title={g.id} title={g.id}
role={canFocusGeometry ? "button" : undefined} role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined} tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)} onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)} onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
onMouseEnter={() => setHoveredGeometryId(g.id)}
> >
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div <div
@@ -264,7 +287,7 @@ export default function GeometryBindingPanel({
<span <span
style={{ style={{
fontSize: "12px", fontSize: "12px",
color: "#e5e7eb", color: labelColor,
fontWeight: 700, fontWeight: 700,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
@@ -273,13 +296,14 @@ export default function GeometryBindingPanel({
> >
{g.label || g.id} {g.label || g.id}
</span> </span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null} {isBound ? <span style={boundBadgeStyle}>bound</span> : null}
{g.isNew ? <NewBadge /> : null} {g.isNew ? <NewBadge /> : null}
</div> </div>
<div <div
style={{ style={{
fontSize: "11px", fontSize: "11px",
color: "#94a3b8", color: idColor,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
@@ -289,6 +313,19 @@ export default function GeometryBindingPanel({
</div> </div>
</div> </div>
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(g.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${g.id}` : `Hide geometry ${g.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
{canBindToggle ? ( {canBindToggle ? (
<button <button
type="button" type="button"
@@ -356,6 +393,74 @@ const boundBadgeStyle: CSSProperties = {
letterSpacing: 0, letterSpacing: 0,
}; };
const hiddenBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
const iconButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
};
function getGeometryIdColor(geometry: GeometryChoice): string {
const hasStart = typeof geometry.time_start === "number";
const hasEnd = typeof geometry.time_end === "number";
if (!hasStart && !hasEnd) return "#f87171";
if (!hasStart || !hasEnd) return "#facc15";
return "#94a3b8";
}
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="#cbd5e1" strokeWidth="2" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18" stroke="#fca5a5" strokeWidth="2" strokeLinecap="round" />
<path
d="M10.6 6.2A10.5 10.5 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.1 2.8M6.2 8.1A17 17 0 0 0 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.5-.7"
stroke="#fca5a5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon() { function LockIcon() {
return ( return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -0,0 +1,127 @@
"use client";
import type { ChangeEvent } from "react";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
overlay: MapImageOverlay | null;
onPickImage: (file: File | null) => void;
onPasteImage: () => void;
keyboardEnabled: boolean;
onKeyboardEnabledChange: (enabled: boolean) => void;
onOpacityChange: (opacity: number) => void;
onRemove: () => void;
};
export default function ImageOverlayPanel({
overlay,
onPickImage,
onPasteImage,
keyboardEnabled,
onKeyboardEnabledChange,
onOpacityChange,
onRemove,
}: Props) {
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
onPickImage(file);
event.target.value = "";
};
return (
<section style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 13, color: "white" }}>Trace Image</div>
<div style={{ marginTop: 2, fontSize: 11, color: "#94a3b8" }}>
Chuột phải kéo điểm vàng đ di chuyển, điểm xanh đ kéo dãn giữ ratio.
{keyboardEnabled ? " WASD di chuyển, Q/E phóng to/thu nhỏ." : ""}
</div>
</div>
{overlay ? (
<button type="button" onClick={onRemove} style={dangerButtonStyle}>
Remove
</button>
) : null}
</div>
<div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
<label style={uploadButtonStyle}>
{overlay ? "Đổi ảnh" : "Thêm ảnh"}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: "none" }}
/>
</label>
<button type="button" onClick={onPasteImage} style={uploadButtonStyle}>
Paste nh
</button>
<button
type="button"
onClick={() => onKeyboardEnabledChange(!keyboardEnabled)}
disabled={!overlay}
style={{
...uploadButtonStyle,
opacity: overlay ? 1 : 0.5,
color: keyboardEnabled ? "#86efac" : "#93c5fd",
cursor: overlay ? "pointer" : "not-allowed",
}}
title="Bật/tắt điều khiển ảnh bằng WASD và Q/E"
>
Keys: {keyboardEnabled ? "On" : "Off"}
</button>
</div>
{overlay ? (
<div style={{ marginTop: 10, display: "grid", gap: 8 }}>
<div style={{ fontSize: 12, color: "#cbd5e1", overflowWrap: "anywhere" }}>
{overlay.name}
</div>
<label style={{ display: "grid", gap: 6, fontSize: 12, color: "#cbd5e1" }}>
<span>Opacity: {Math.round(overlay.opacity * 100)}%</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={overlay.opacity}
onChange={(event) => onOpacityChange(Number(event.target.value))}
style={{ width: "100%", accentColor: "#38bdf8" }}
/>
</label>
</div>
) : (
<div style={{ marginTop: 8, fontSize: 12, color: "#94a3b8" }}>
Chưa nh overlay.
</div>
)}
</section>
);
}
const uploadButtonStyle = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #334155",
borderRadius: 6,
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "7px 10px",
} as const;
const dangerButtonStyle = {
border: "1px solid #7f1d1d",
borderRadius: 6,
background: "#1f1111",
color: "#fecaca",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "6px 8px",
} as const;
@@ -8,8 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore";
type Props = { type Props = {
onCreateEntityOnly: () => void; onCreateEntityOnly: () => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void; onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => void;
hasSelectedGeometry?: boolean; hasSelectedGeometry?: boolean;
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void; onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
}; };
@@ -17,6 +18,7 @@ export default function ProjectEntityRefsPanel({
onCreateEntityOnly, onCreateEntityOnly,
onUpdateEntity, onUpdateEntity,
hasSelectedGeometry, hasSelectedGeometry,
selectedGeometryTime,
onToggleBindEntityForSelectedGeometry, onToggleBindEntityForSelectedGeometry,
}: Props) { }: Props) {
const { const {
@@ -78,15 +80,35 @@ export default function ProjectEntityRefsPanel({
); );
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState(""); const [editDescription, setEditDescription] = useState("");
const [editTimeStart, setEditTimeStart] = useState("");
const [editTimeEnd, setEditTimeEnd] = useState("");
const canCopySelectedGeometryTime =
selectedGeometryTime != null &&
(selectedGeometryTime.time_start != null || selectedGeometryTime.time_end != null);
const openEntityEditor = (entity: EntitySnapshot) => { const openEntityEditor = (entity: EntitySnapshot) => {
setActiveEntityId(String(entity.id)); setActiveEntityId(String(entity.id));
setEditName(typeof entity.name === "string" ? entity.name : ""); setEditName(typeof entity.name === "string" ? entity.name : "");
setEditDescription(entity.description == null ? "" : String(entity.description)); setEditDescription(entity.description == null ? "" : String(entity.description));
setEditTimeStart(entity.time_start != null ? String(entity.time_start) : "");
setEditTimeEnd(entity.time_end != null ? String(entity.time_end) : "");
}; };
const handleEntityFormChange = (key: "name" | "description", value: string) => { const handleEntityFormChange = (key: "name" | "description" | "time_start" | "time_end", value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value })); setEntityForm((prev) => ({ ...prev, [key]: value }));
}; };
const copySelectedGeometryTimeToCreateForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEntityForm((prev) => ({
...prev,
time_start: selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "",
time_end: selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "",
}));
};
const copySelectedGeometryTimeToEditForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEditTimeStart(selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "");
setEditTimeEnd(selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "");
};
return ( return (
<div <div
@@ -237,28 +259,28 @@ export default function ProjectEntityRefsPanel({
</span> </span>
{isNewEntityRef(activeEntity) ? <NewBadge /> : null} {isNewEntityRef(activeEntity) ? <NewBadge /> : null}
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button
type="button"
onClick={copySelectedGeometryTimeToEditForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity dang sua"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
<button <button
type="button" type="button"
onClick={() => setActiveEntityId(null)} onClick={() => setActiveEntityId(null)}
title="Dong" title="Dong"
aria-label="Dong sua entity" aria-label="Dong sua entity"
style={{ style={iconButtonStyle(false)}
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
</div>
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}> <div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
{String(activeEntity.id)} {String(activeEntity.id)}
@@ -277,10 +299,31 @@ export default function ProjectEntityRefsPanel({
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
/> />
<div style={timeInputGridStyle}>
<input
value={editTimeStart}
onChange={(event) => setEditTimeStart(event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={editTimeEnd}
onChange={(event) => setEditTimeEnd(event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button <button
type="button" type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), { name: editName, description: editDescription.trim().length ? editDescription : null })} onClick={() => onUpdateEntity!(String(activeEntity.id), {
name: editName,
description: editDescription.trim().length ? editDescription : null,
time_start: editTimeStart,
time_end: editTimeEnd,
})}
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={{ style={{
border: "none", border: "none",
@@ -315,30 +358,31 @@ export default function ProjectEntityRefsPanel({
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}> <div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới Tạo entity mới
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{isCreateOpen ? (
<button
type="button"
onClick={copySelectedGeometryTimeToCreateForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity moi"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
) : null}
<button <button
type="button" type="button"
onClick={() => setIsCreateOpen((v) => !v)} onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"} title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"} aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={{ style={iconButtonStyle(isEntitySubmitting)}
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
opacity: isEntitySubmitting ? 0.6 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
> >
{isCreateOpen ? <CloseIcon /> : <PlusIcon />} {isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button> </button>
</div> </div>
</div>
{isCreateOpen ? ( {isCreateOpen ? (
<> <>
@@ -356,6 +400,22 @@ export default function ProjectEntityRefsPanel({
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
/> />
<div style={timeInputGridStyle}>
<input
value={entityForm.time_start}
onChange={(event) => handleEntityFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.time_end}
onChange={(event) => handleEntityFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button <button
type="button" type="button"
@@ -403,6 +463,29 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px", fontSize: "13px",
}; };
const timeInputGridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
};
function iconButtonStyle(disabled: boolean): CSSProperties {
return {
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
};
}
const boundBadgeStyle: CSSProperties = { const boundBadgeStyle: CSSProperties = {
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
@@ -488,3 +571,12 @@ function CloseIcon() {
</svg> </svg>
); );
} }
function ClockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8" stroke="#fbbf24" strokeWidth="2" />
<path d="M12 7v5l3 2" stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+465
View File
@@ -0,0 +1,465 @@
import maplibregl from "maplibre-gl";
export type MapImageOverlay = {
url: string;
name: string;
opacity: number;
aspectRatio: number;
coordinates: maplibregl.Coordinates;
};
const IMAGE_OVERLAY_SOURCE_ID = "uhm-image-overlay-source";
const IMAGE_OVERLAY_LAYER_ID = "uhm-image-overlay-layer";
const IMAGE_OVERLAY_CONTROL_SOURCE_ID = "uhm-image-overlay-control-source";
const IMAGE_OVERLAY_HANDLE_LAYER_ID = "uhm-image-overlay-handles";
const IMAGE_OVERLAY_CENTER_LAYER_ID = "uhm-image-overlay-center";
type OverlayControlAction = "move" | "resize";
type OverlayResizeEdge = "top" | "right" | "bottom" | "left";
type OverlayControlFeature = GeoJSON.Feature<GeoJSON.Point, {
action: OverlayControlAction;
edge?: OverlayResizeEdge;
}>;
export function applyImageOverlay(
map: maplibregl.Map,
overlay: MapImageOverlay | null | undefined
) {
if (!overlay) {
removeImageOverlay(map);
return;
}
const existingSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
if (existingSource) {
if (existingSource.url === overlay.url) {
existingSource.setCoordinates(overlay.coordinates);
} else {
existingSource.updateImage({
url: overlay.url,
coordinates: overlay.coordinates,
});
}
} else {
map.addSource(IMAGE_OVERLAY_SOURCE_ID, {
type: "image",
url: overlay.url,
coordinates: overlay.coordinates,
});
}
if (!map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_LAYER_ID,
type: "raster",
source: IMAGE_OVERLAY_SOURCE_ID,
paint: {
"raster-opacity": clampOpacity(overlay.opacity),
"raster-fade-duration": 0,
"raster-resampling": "linear",
},
});
} else {
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-opacity", clampOpacity(overlay.opacity));
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-fade-duration", 0);
}
// Không truyền beforeId để layer được đưa lên trên cùng, phục vụ trace khi vẽ.
map.moveLayer(IMAGE_OVERLAY_LAYER_ID);
applyImageOverlayControls(map, overlay);
}
export function removeImageOverlay(map: maplibregl.Map) {
removeImageOverlayControls(map);
if (map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_LAYER_ID);
}
if (map.getSource(IMAGE_OVERLAY_SOURCE_ID)) {
map.removeSource(IMAGE_OVERLAY_SOURCE_ID);
}
}
export function getViewportImageCoordinates(
map: maplibregl.Map,
aspectRatio: number
): maplibregl.Coordinates {
const canvas = map.getCanvas();
const canvasWidth = Math.max(canvas.clientWidth || canvas.width || 800, 1);
const canvasHeight = Math.max(canvas.clientHeight || canvas.height || 600, 1);
const safeAspectRatio = normalizeAspectRatio(aspectRatio);
let width = canvasWidth * 0.72;
let height = width / safeAspectRatio;
const maxHeight = canvasHeight * 0.72;
if (height > maxHeight) {
height = maxHeight;
width = height * safeAspectRatio;
}
return buildCoordinatesFromScreenBox(
map,
{ x: canvasWidth / 2, y: canvasHeight / 2 },
width,
height
);
}
export function moveImageOverlayCoordinatesByPixels(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
deltaX: number,
deltaY: number
): maplibregl.Coordinates {
return moveCoordinates(map, coordinates, new maplibregl.Point(deltaX, deltaY));
}
export function scaleImageOverlayCoordinatesByFactor(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
factor: number,
aspectRatio: number
): maplibregl.Coordinates {
const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
const screenBox = getScreenBox(map, coordinates);
const minimumSize = 48;
const width = Math.max(screenBox.width * safeFactor, minimumSize);
const height = width / normalizeAspectRatio(aspectRatio);
return buildCoordinatesFromScreenBox(map, screenBox.center, width, height);
}
export function bindImageOverlayInteractions(
map: maplibregl.Map,
getOverlay: () => MapImageOverlay | null,
onChange: (overlay: MapImageOverlay) => void
) {
let rafId: number | null = null;
let pendingCoordinates: maplibregl.Coordinates | null = null;
let latestOverlay: MapImageOverlay | null = null;
let activeDrag: {
action: OverlayControlAction;
edge: OverlayResizeEdge | null;
startPoint: maplibregl.Point;
startCoordinates: maplibregl.Coordinates;
startBox: ScreenBox;
aspectRatio: number;
wasDragPanEnabled: boolean;
} | null = null;
const startDrag = (event: maplibregl.MapLayerMouseEvent) => {
if ((event.originalEvent as MouseEvent | undefined)?.button !== 2) return;
const overlay = getOverlay();
const feature = event.features?.[0] as OverlayControlFeature | undefined;
if (!overlay || !feature?.properties?.action) return;
event.preventDefault();
event.originalEvent.preventDefault();
event.originalEvent.stopPropagation();
activeDrag = {
action: feature.properties.action,
edge: feature.properties.edge || null,
startPoint: event.point,
startCoordinates: overlay.coordinates,
startBox: getScreenBox(map, overlay.coordinates),
aspectRatio: normalizeAspectRatio(overlay.aspectRatio),
wasDragPanEnabled: map.dragPan.isEnabled(),
};
latestOverlay = overlay;
map.dragPan.disable();
map.getCanvas().style.cursor = activeDrag.action === "move" ? "grabbing" : "nwse-resize";
};
const moveDrag = (event: maplibregl.MapMouseEvent) => {
if (!activeDrag) return;
const overlay = getOverlay();
if (!overlay) return;
event.preventDefault();
const nextCoordinates = activeDrag.action === "move"
? moveCoordinates(map, activeDrag.startCoordinates, event.point.sub(activeDrag.startPoint))
: resizeCoordinates(map, activeDrag.startBox, event.point, activeDrag.edge, activeDrag.aspectRatio);
latestOverlay = {
...overlay,
coordinates: nextCoordinates,
};
scheduleImageOverlayCoordinateUpdate(map, nextCoordinates);
};
const endDrag = () => {
if (!activeDrag) return;
const finishedDrag = activeDrag;
activeDrag = null;
flushImageOverlayCoordinateUpdate(map);
if (latestOverlay) {
onChange(latestOverlay);
latestOverlay = null;
}
if (finishedDrag.wasDragPanEnabled && !map.dragPan.isEnabled()) {
map.dragPan.enable();
}
map.getCanvas().style.cursor = "";
};
const preventContextMenu = (event: maplibregl.MapLayerMouseEvent) => {
event.preventDefault();
event.originalEvent.preventDefault();
event.originalEvent.stopPropagation();
};
map.on("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
map.on("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
map.on("mousemove", moveDrag);
map.on("mouseup", endDrag);
map.on("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
map.on("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
return () => {
endDrag();
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
pendingCoordinates = null;
map.off("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
map.off("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
map.off("mousemove", moveDrag);
map.off("mouseup", endDrag);
map.off("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
map.off("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
};
function scheduleImageOverlayCoordinateUpdate(
targetMap: maplibregl.Map,
coordinates: maplibregl.Coordinates
) {
pendingCoordinates = coordinates;
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
flushImageOverlayCoordinateUpdate(targetMap);
});
}
function flushImageOverlayCoordinateUpdate(targetMap: maplibregl.Map) {
if (!pendingCoordinates) return;
updateImageOverlayCoordinates(targetMap, pendingCoordinates);
pendingCoordinates = null;
}
}
export function getImageOverlayInteractiveLayerIds() {
return [IMAGE_OVERLAY_HANDLE_LAYER_ID, IMAGE_OVERLAY_CENTER_LAYER_ID];
}
function applyImageOverlayControls(map: maplibregl.Map, overlay: MapImageOverlay) {
const data = buildControlFeatureCollection(overlay.coordinates);
const existingSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (existingSource) {
existingSource.setData(data);
} else {
map.addSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID, {
type: "geojson",
data,
});
}
if (!map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_HANDLE_LAYER_ID,
type: "circle",
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
filter: ["==", ["get", "action"], "resize"],
paint: {
"circle-color": "#38bdf8",
"circle-radius": 7,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 2,
},
});
}
if (!map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_CENTER_LAYER_ID,
type: "circle",
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
filter: ["==", ["get", "action"], "move"],
paint: {
"circle-color": "#fbbf24",
"circle-radius": 8,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 2,
},
});
}
map.moveLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
map.moveLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
}
function updateImageOverlayCoordinates(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates
) {
const imageSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
imageSource?.setCoordinates(coordinates);
const controlSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
controlSource?.setData(buildControlFeatureCollection(coordinates));
}
function removeImageOverlayControls(map: maplibregl.Map) {
if (map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
}
if (map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
}
if (map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID)) {
map.removeSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID);
}
}
function buildControlFeatureCollection(
coordinates: maplibregl.Coordinates
): GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]> {
const [topLeft, topRight, bottomRight, bottomLeft] = coordinates;
const center = averageCoordinates(coordinates);
return [
createControlFeature(center, { action: "move" }),
createControlFeature(midpoint(topLeft, topRight), { action: "resize", edge: "top" }),
createControlFeature(midpoint(topRight, bottomRight), { action: "resize", edge: "right" }),
createControlFeature(midpoint(bottomRight, bottomLeft), { action: "resize", edge: "bottom" }),
createControlFeature(midpoint(bottomLeft, topLeft), { action: "resize", edge: "left" }),
].reduce<GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]>>(
(collection, feature) => {
collection.features.push(feature);
return collection;
},
{ type: "FeatureCollection", features: [] }
);
}
function createControlFeature(
coordinates: [number, number],
properties: OverlayControlFeature["properties"]
): OverlayControlFeature {
return {
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates,
},
};
}
type ScreenPoint = { x: number; y: number };
type ScreenBox = {
center: ScreenPoint;
width: number;
height: number;
};
function getScreenBox(map: maplibregl.Map, coordinates: maplibregl.Coordinates): ScreenBox {
const points = coordinates.map((coordinate) => map.project(coordinate));
const minX = Math.min(...points.map((point) => point.x));
const maxX = Math.max(...points.map((point) => point.x));
const minY = Math.min(...points.map((point) => point.y));
const maxY = Math.max(...points.map((point) => point.y));
return {
center: {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
},
width: Math.max(maxX - minX, 40),
height: Math.max(maxY - minY, 40),
};
}
function moveCoordinates(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
delta: maplibregl.Point
): maplibregl.Coordinates {
return coordinates.map((coordinate) => {
const point = map.project(coordinate);
return lngLatToCoordinate(map.unproject([point.x + delta.x, point.y + delta.y]));
}) as maplibregl.Coordinates;
}
function resizeCoordinates(
map: maplibregl.Map,
startBox: ScreenBox,
currentPoint: maplibregl.Point,
edge: OverlayResizeEdge | null,
aspectRatio: number
): maplibregl.Coordinates {
const minimumSize = 48;
let width = startBox.width;
let height = startBox.height;
if (edge === "left" || edge === "right") {
width = Math.max(Math.abs(currentPoint.x - startBox.center.x) * 2, minimumSize);
height = width / aspectRatio;
} else {
height = Math.max(Math.abs(currentPoint.y - startBox.center.y) * 2, minimumSize);
width = height * aspectRatio;
}
return buildCoordinatesFromScreenBox(map, startBox.center, width, height);
}
function buildCoordinatesFromScreenBox(
map: maplibregl.Map,
center: ScreenPoint,
width: number,
height: number
): maplibregl.Coordinates {
const halfWidth = width / 2;
const halfHeight = height / 2;
return [
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y - halfHeight])),
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y - halfHeight])),
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y + halfHeight])),
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y + halfHeight])),
];
}
function averageCoordinates(coordinates: maplibregl.Coordinates): [number, number] {
const total = coordinates.reduce(
(sum, coordinate) => ({
lng: sum.lng + coordinate[0],
lat: sum.lat + coordinate[1],
}),
{ lng: 0, lat: 0 }
);
return [total.lng / coordinates.length, total.lat / coordinates.length];
}
function midpoint(a: [number, number], b: [number, number]): [number, number] {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}
function lngLatToCoordinate(lngLat: maplibregl.LngLat): [number, number] {
return [lngLat.lng, lngLat.lat];
}
function normalizeAspectRatio(value: number) {
if (!Number.isFinite(value) || value <= 0) return 1;
return value;
}
function clampOpacity(value: number) {
if (!Number.isFinite(value)) return 0.55;
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
+112 -12
View File
@@ -13,12 +13,14 @@ import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
import { getBackgroundRasterSourceSpecification } from "@/uhm/api/tiles"; import { getBackgroundRasterSourceSpecification } from "@/uhm/api/tiles";
import { newId } from "@/uhm/lib/utils/id"; import { newId } from "@/uhm/lib/utils/id";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
import type { EntityLabelCandidate } from "@/uhm/types/geo";
type Coordinate = [number, number]; type Coordinate = [number, number];
type PolygonCoordinates = Coordinate[][]; type PolygonCoordinates = Coordinate[][];
type FeatureLabelInfo = { type FeatureLabelInfo = {
entityId: string; entityId: string;
label: string; label: string;
timeEnd: number | null;
}; };
export function applyBackgroundLayerVisibility( export function applyBackgroundLayerVisibility(
@@ -230,8 +232,12 @@ export function splitDraftFeatures(fc: FeatureCollection) {
return { polygons, points }; return { polygons, points };
} }
export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection { export function decoratePointFeaturesWithLabels(
const getLabel = createFeatureLabelResolver(labelContext); fc: FeatureCollection,
labelContext: FeatureCollection = fc,
timelineYear?: number | null
): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
return { return {
...fc, ...fc,
features: fc.features.map((feature) => ({ features: fc.features.map((feature) => ({
@@ -244,8 +250,12 @@ export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelCont
}; };
} }
export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection { export function decorateLineFeaturesWithLabels(
const getLabel = createFeatureLabelResolver(labelContext); fc: FeatureCollection,
labelContext: FeatureCollection = fc,
timelineYear?: number | null
): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
return { return {
...fc, ...fc,
features: fc.features.map((feature) => ({ features: fc.features.map((feature) => ({
@@ -258,8 +268,12 @@ export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelConte
}; };
} }
export function buildPolygonLabelFeatureCollection(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection { export function buildPolygonLabelFeatureCollection(
const getLabel = createFeatureLabelResolver(labelContext); fc: FeatureCollection,
labelContext: FeatureCollection = fc,
timelineYear?: number | null
): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
const features: Feature[] = []; const features: Feature[] = [];
for (const feature of fc.features) { for (const feature of fc.features) {
@@ -432,8 +446,8 @@ export function buildPathArrowGeometry(coords: [number, number][]): Geometry | n
if (bodyPoints.length < 2) return null; if (bodyPoints.length < 2) return null;
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000); const tailWidth = clampNumber(totalLength * 0.02, 5, 40000);
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000); const shoulderWidth = clampNumber(totalLength * 0.1, 10, 100000);
const headWidth = shoulderWidth * 2.0; const headWidth = shoulderWidth * 2.0;
const leftBody: ProjectedPoint[] = []; const leftBody: ProjectedPoint[] = [];
@@ -655,12 +669,15 @@ export function roundZoom(value: number): number {
return Math.round(value * 10) / 10; return Math.round(value * 10) / 10;
} }
function createFeatureLabelResolver(fc: FeatureCollection): (feature: Feature) => string | null { function createFeatureLabelResolver(
fc: FeatureCollection,
timelineYear?: number | null
): (feature: Feature) => string | null {
const directLabelsByFeatureId = new Map<string, FeatureLabelInfo>(); const directLabelsByFeatureId = new Map<string, FeatureLabelInfo>();
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>(); const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
for (const feature of fc.features) { for (const feature of fc.features) {
const labelInfo = getSingleEntityFeatureLabelInfo(feature); const labelInfo = getSingleEntityFeatureLabelInfo(feature, timelineYear);
if (!labelInfo) continue; if (!labelInfo) continue;
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo); directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
} }
@@ -710,14 +727,97 @@ function mergeInheritedFeatureLabel(
} }
} }
function getSingleEntityFeatureLabelInfo(feature: Feature): FeatureLabelInfo | null { function getSingleEntityFeatureLabelInfo(
feature: Feature,
timelineYear?: number | null
): FeatureLabelInfo | null {
const candidates = getFeatureEntityLabelCandidates(feature);
if (candidates.length > 0) {
const timelineCandidate = getLatestTimelineEntityCandidate(candidates, timelineYear);
if (!timelineCandidate) return null;
return {
entityId: timelineCandidate.id,
label: timelineCandidate.name,
timeEnd: normalizeLabelYear(timelineCandidate.time_end),
};
}
const entityIds = getFeatureEntityIds(feature); const entityIds = getFeatureEntityIds(feature);
if (entityIds.length !== 1) return null; if (entityIds.length !== 1) return null;
const label = getSingleEntityName(feature); const label = getSingleEntityName(feature);
if (!label) return null; if (!label) return null;
return { entityId: entityIds[0], label }; return { entityId: entityIds[0], label, timeEnd: null };
}
function getLatestTimelineEntityCandidate(
candidates: EntityLabelCandidate[],
timelineYear?: number | null
): EntityLabelCandidate | null {
if (!candidates.length) return null;
const activeCandidates = candidates.filter((candidate) =>
isEntityCandidateVisibleAtYear(candidate, timelineYear)
);
if (!activeCandidates.length) return null;
return activeCandidates.sort(compareEntityLabelCandidates)[0] || null;
}
function getFeatureEntityLabelCandidates(feature: Feature): EntityLabelCandidate[] {
const rawCandidates = feature.properties.entity_label_candidates;
if (!Array.isArray(rawCandidates)) return [];
const byId = new Map<string, EntityLabelCandidate>();
for (const raw of rawCandidates) {
if (!raw || typeof raw !== "object") continue;
const candidate = raw as EntityLabelCandidate;
const id = String(candidate.id || "").trim();
const name = String(candidate.name || "").trim();
if (!id || !name) continue;
byId.set(id, {
id,
name,
time_start: normalizeLabelYear(candidate.time_start),
time_end: normalizeLabelYear(candidate.time_end),
});
}
return Array.from(byId.values());
}
function isEntityCandidateVisibleAtYear(
candidate: EntityLabelCandidate,
timelineYear?: number | null
): boolean {
if (typeof timelineYear !== "number" || !Number.isFinite(timelineYear)) return true;
const start = normalizeLabelYear(candidate.time_start);
const end = normalizeLabelYear(candidate.time_end);
if (start != null && timelineYear < start) return false;
if (end != null && timelineYear > end) return false;
return true;
}
function compareEntityLabelCandidates(a: EntityLabelCandidate, b: EntityLabelCandidate): number {
const endA = normalizeLabelYear(a.time_end);
const endB = normalizeLabelYear(b.time_end);
const endScoreA = endA == null ? Number.NEGATIVE_INFINITY : endA;
const endScoreB = endB == null ? Number.NEGATIVE_INFINITY : endB;
if (endScoreA !== endScoreB) return endScoreB - endScoreA;
const startA = normalizeLabelYear(a.time_start);
const startB = normalizeLabelYear(b.time_start);
const startScoreA = startA == null ? Number.NEGATIVE_INFINITY : startA;
const startScoreB = startB == null ? Number.NEGATIVE_INFINITY : startB;
if (startScoreA !== startScoreB) return startScoreB - startScoreA;
return a.name.localeCompare(b.name);
}
function normalizeLabelYear(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
} }
function getFeatureEntityIds(feature: Feature): string[] { function getFeatureEntityIds(feature: Feature): string[] {
+20 -2
View File
@@ -29,6 +29,8 @@ export function useMapInstance() {
const [isMapLoaded, setIsMapLoaded] = useState(false); const [isMapLoaded, setIsMapLoaded] = useState(false);
const geolocationCenteredRef = useRef(false); const geolocationCenteredRef = useRef(false);
// Ref khóa sync zoom từ MapLibre trong lúc user kéo slider để tránh value bị animate ghi ngược.
const isZoomSliderDraggingRef = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -60,6 +62,7 @@ export function useMapInstance() {
mapRef.current = map; mapRef.current = map;
const syncZoomLevel = () => { const syncZoomLevel = () => {
if (isZoomSliderDraggingRef.current) return;
setZoomLevel(roundZoom(map.getZoom())); setZoomLevel(roundZoom(map.getZoom()));
}; };
@@ -80,7 +83,8 @@ export function useMapInstance() {
}; };
} catch (err) { } catch (err) {
console.error("Map initialization failed", err); console.error("Map initialization failed", err);
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed."); const message = err instanceof Error ? err.message : "Map initialization failed.";
window.setTimeout(() => setFatalInitError(message), 0);
} }
}, []); }, []);
@@ -121,10 +125,22 @@ export function useMapInstance() {
const map = mapRef.current; const map = mapRef.current;
if (!map || !Number.isFinite(nextRaw)) return; if (!map || !Number.isFinite(nextRaw)) return;
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max); const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
map.easeTo({ zoom: next, duration: 80 }); // Slider cần phản hồi trực tiếp theo pointer; easeTo liên tục sẽ làm thumb bị nhảy ngược.
map.jumpTo({ zoom: next });
setZoomLevel(next); setZoomLevel(next);
}, [zoomBounds]); }, [zoomBounds]);
const beginZoomSliderDrag = useCallback(() => {
isZoomSliderDraggingRef.current = true;
}, []);
const endZoomSliderDrag = useCallback(() => {
const map = mapRef.current;
isZoomSliderDraggingRef.current = false;
if (!map) return;
setZoomLevel(roundZoom(map.getZoom()));
}, []);
const getViewState = useCallback(() => { const getViewState = useCallback(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return null; if (!map) return null;
@@ -152,6 +168,8 @@ export function useMapInstance() {
geolocationCenteredRef, geolocationCenteredRef,
handleZoomByStep, handleZoomByStep,
handleZoomSliderChange, handleZoomSliderChange,
beginZoomSliderDrag,
endZoomSliderDrag,
getViewState, getViewState,
}; };
} }
+48 -1
View File
@@ -29,6 +29,7 @@ type UseMapInteractionProps = {
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>; onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>; onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>; onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
}; };
@@ -44,6 +45,7 @@ export function useMapInteraction({
onSetModeRef, onSetModeRef,
onCreateRef, onCreateRef,
onDeleteRef, onDeleteRef,
onHideRef,
onUpdateRef, onUpdateRef,
onHoverFeatureChangeRef, onHoverFeatureChangeRef,
}: UseMapInteractionProps) { }: UseMapInteractionProps) {
@@ -149,8 +151,26 @@ export function useMapInteraction({
); );
} }
: undefined, : undefined,
allowGeometryEditing
? (id: string | number) => {
const originalFeature = draftRef.current.features.find(
(item) => String(item.properties.id) === String(id)
);
if (!originalFeature) return;
const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature);
onCreateRef.current?.(nextFeature);
}
: undefined,
allowGeometryEditing
? (id: string | number) => {
onHideRef.current?.(id);
onSelectFeatureIdsRef.current?.([]);
}
: undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids), (ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id) (id: string | number) => onSetModeRef.current?.("replay", id),
() => Boolean(editingEngineRef.current?.editingRef.current)
); );
const cleanupPoint = initPoint( const cleanupPoint = initPoint(
@@ -324,3 +344,30 @@ export function useMapInteraction({
cleanupMapInteractions, cleanupMapInteractions,
}; };
} }
function buildDuplicatedFeatureShapeOnly(
feature: FeatureCollection["features"][number]
): FeatureCollection["features"][number] {
const geometry = cloneGeometry(feature.geometry);
return {
type: "Feature",
properties: {
id: buildClientFeatureId(),
type: feature.properties.type ?? null,
geometry_preset: feature.properties.geometry_preset ?? null,
entity_id: null,
entity_ids: [],
entity_name: null,
entity_names: [],
binding: [],
},
geometry,
};
}
function cloneGeometry(geometry: Geometry): Geometry {
if (typeof structuredClone === "function") {
return structuredClone(geometry);
}
return JSON.parse(JSON.stringify(geometry)) as Geometry;
}
+42 -6
View File
@@ -16,11 +16,13 @@ import {
setSelectedFeatureState, setSelectedFeatureState,
splitDraftFeatures, splitDraftFeatures,
} from "./mapUtils"; } from "./mapUtils";
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
type UseMapSyncProps = { type UseMapSyncProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>; mapRef: React.MutableRefObject<maplibregl.Map | null>;
draft: FeatureCollection; draft: FeatureCollection;
labelContextDraft?: FeatureCollection; labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
backgroundVisibility: BackgroundLayerVisibility; backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>; geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[]; selectedFeatureIds: (string | number)[];
@@ -31,6 +33,7 @@ type UseMapSyncProps = {
focusFeatureCollection?: FeatureCollection | null; focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null; focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions; focusPadding?: number | maplibregl.PaddingOptions;
imageOverlay?: MapImageOverlay | null;
allowGeometryEditing: boolean; allowGeometryEditing: boolean;
editingEngineRef: React.MutableRefObject<{ editingEngineRef: React.MutableRefObject<{
editingRef: React.MutableRefObject<{ id: string | number } | null>; editingRef: React.MutableRefObject<{ id: string | number } | null>;
@@ -43,6 +46,7 @@ export function useMapSync({
mapRef, mapRef,
draft, draft,
labelContextDraft, labelContextDraft,
labelTimelineYear,
backgroundVisibility, backgroundVisibility,
geometryVisibility, geometryVisibility,
selectedFeatureIds, selectedFeatureIds,
@@ -53,29 +57,38 @@ export function useMapSync({
focusFeatureCollection, focusFeatureCollection,
focusRequestKey, focusRequestKey,
focusPadding, focusPadding,
imageOverlay,
allowGeometryEditing, allowGeometryEditing,
editingEngineRef, editingEngineRef,
geolocationCenteredRef, geolocationCenteredRef,
}: UseMapSyncProps) { }: UseMapSyncProps) {
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft); const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility); const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility); const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds); const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const respectBindingFilterRef = useRef(respectBindingFilter); const respectBindingFilterRef = useRef(respectBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds); const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null); const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
const fitBoundsAppliedRef = useRef(false); const fitBoundsAppliedRef = useRef(false);
useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]); useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]); useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]); useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]); useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]); useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]); useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]); useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
useEffect(() => { useEffect(() => {
fitBoundsAppliedRef.current = false; fitBoundsAppliedRef.current = false;
@@ -102,10 +115,11 @@ export function useMapSync({
: fc; : fc;
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current); const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const labelContext = labelContextDraftRef.current || fc; const labelContext = labelContextDraftRef.current || fc;
const labelTimelineYear = labelTimelineYearRef.current;
const { polygons, points } = splitDraftFeatures(visibleDraft); const { polygons, points } = splitDraftFeatures(visibleDraft);
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext); const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext); const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext); const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft); const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
countriesSource.setData(labeledGeometries); countriesSource.setData(labeledGeometries);
@@ -135,6 +149,7 @@ export function useMapSync({
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined; const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
if (!source) return; if (!source) return;
source.setData(fc); source.setData(fc);
moveHighlightLayersToTop(map);
}, [mapRef]); }, [mapRef]);
const tryCenterToUserLocation = useCallback(() => { const tryCenterToUserLocation = useCallback(() => {
@@ -175,6 +190,12 @@ export function useMapSync({
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION); source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
}, [highlightFeatures, mapRef]); }, [highlightFeatures, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlay);
}, [imageOverlay, mapRef]);
useEffect(() => { useEffect(() => {
applyDraftToMap(draft); applyDraftToMap(draft);
const editingId = editingEngineRef.current?.editingRef?.current?.id; const editingId = editingEngineRef.current?.editingRef?.current?.id;
@@ -188,6 +209,7 @@ export function useMapSync({
allowGeometryEditing, allowGeometryEditing,
draft, draft,
labelContextDraft, labelContextDraft,
labelTimelineYear,
selectedFeatureIds, selectedFeatureIds,
respectBindingFilter, respectBindingFilter,
geometryVisibility, geometryVisibility,
@@ -199,7 +221,7 @@ export function useMapSync({
useEffect(() => { useEffect(() => {
if (focusRequestKey === null || focusRequestKey === undefined) return; if (focusRequestKey === null || focusRequestKey === undefined) return;
const map = mapRef.current; const map = mapRef.current;
const target = focusFeatureCollection; const target = focusFeatureCollectionRef.current;
if (!target || !target.features.length) return; if (!target || !target.features.length) return;
if (!map) return; if (!map) return;
@@ -208,7 +230,7 @@ export function useMapSync({
const focus = () => { const focus = () => {
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return; if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
fitMapToFeatureCollection(map, target, focusPadding, { fitMapToFeatureCollection(map, target, focusPaddingRef.current, {
duration: 550, duration: 550,
maxZoom: 10, maxZoom: 10,
pointZoom: 9, pointZoom: 9,
@@ -225,11 +247,25 @@ export function useMapSync({
cancelled = true; cancelled = true;
if (rafId !== null) cancelAnimationFrame(rafId); if (rafId !== null) cancelAnimationFrame(rafId);
}; };
}, [focusFeatureCollection, focusPadding, focusRequestKey, mapRef]); }, [focusRequestKey, mapRef]);
return { return {
applyDraftToMap, applyDraftToMap,
applyHighlightToMap, applyHighlightToMap,
tryCenterToUserLocation, tryCenterToUserLocation,
applyImageOverlayToMap: () => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlayRef.current);
},
}; };
} }
function moveHighlightLayersToTop(map: maplibregl.Map) {
const layerIds = ["entity-focus-fill", "entity-focus-line", "entity-focus-points"];
for (const layerId of layerIds) {
if (map.getLayer(layerId)) {
map.moveLayer(layerId);
}
}
}
+7 -2
View File
@@ -12,6 +12,7 @@ type Props = {
statusText?: string | null; statusText?: string | null;
filterEnabled?: boolean; filterEnabled?: boolean;
onFilterEnabledChange?: (enabled: boolean) => void; onFilterEnabledChange?: (enabled: boolean) => void;
style?: React.CSSProperties;
}; };
export default function TimelineBar({ export default function TimelineBar({
@@ -24,6 +25,7 @@ export default function TimelineBar({
statusText, statusText,
filterEnabled, filterEnabled,
onFilterEnabledChange, onFilterEnabledChange,
style,
}: Props) { }: Props) {
const lower = FIXED_TIMELINE_START_YEAR; const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR; const upper = FIXED_TIMELINE_END_YEAR;
@@ -58,6 +60,7 @@ export default function TimelineBar({
padding: "10px 12px", padding: "10px 12px",
color: "#e2e8f0", color: "#e2e8f0",
backdropFilter: "blur(2px)", backdropFilter: "blur(2px)",
...style,
}} }}
title={helperText || undefined} title={helperText || undefined}
> >
@@ -65,7 +68,9 @@ export default function TimelineBar({
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "10px", flexWrap: "wrap",
rowGap: "8px",
columnGap: "10px",
fontSize: "12px", fontSize: "12px",
}} }}
> >
@@ -129,7 +134,7 @@ export default function TimelineBar({
aria-label="Timeline year" aria-label="Timeline year"
style={{ style={{
flex: 1, flex: 1,
minWidth: 0, minWidth: "120px",
accentColor: "#22c55e", accentColor: "#22c55e",
cursor: effectiveDisabled ? "not-allowed" : "pointer", cursor: effectiveDisabled ? "not-allowed" : "pointer",
opacity: effectiveDisabled ? 0.6 : 1, opacity: effectiveDisabled ? 0.6 : 1,
+90 -5
View File
@@ -18,6 +18,9 @@ type Props = {
error?: string | null; error?: string | null;
onClose: () => void; onClose: () => void;
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void; onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxDragWidth?: number;
}; };
function escapeHtml(input: string): string { function escapeHtml(input: string): string {
@@ -30,9 +33,12 @@ function escapeHtml(input: string): string {
} }
function normalizeWikiContentToHtml(raw: string | null | undefined): string { function normalizeWikiContentToHtml(raw: string | null | undefined): string {
const value = String(raw || "").trim(); let value = String(raw || "").trim();
if (!value.length) return ""; if (!value.length) return "";
// Replace non-breaking spaces to allow text wrap
value = value.replaceAll("&nbsp;", " ").replaceAll("\u00a0", " ");
if (value[0] === "<") return value; if (value[0] === "<") return value;
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`; return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
@@ -122,8 +128,52 @@ export default function PublicWikiSidebar({
error, error,
onClose, onClose,
onWikiLinkRequest, onWikiLinkRequest,
sidebarWidth,
onSidebarWidthChange,
maxDragWidth,
}: Props) { }: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null); const contentRootRef = useRef<HTMLDivElement | null>(null);
const [localWidth, setLocalWidth] = useState<number>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("public-wiki-sidebar-width");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) {
return parsed;
}
}
}
return 420;
});
const width = sidebarWidth ?? localWidth;
const setWidth = onSidebarWidthChange ?? setLocalWidth;
const maxDragWidthLimit = maxDragWidth ?? 800;
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
const onMove = (e: PointerEvent) => {
const deltaX = e.clientX - startX;
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
setWidth(nextWidth);
if (typeof window !== "undefined") {
localStorage.setItem("public-wiki-sidebar-width", String(nextWidth));
}
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null); const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const processedWiki = useMemo(() => { const processedWiki = useMemo(() => {
if (!wiki) return { html: "", toc: [] as TocItem[] }; if (!wiki) return { html: "", toc: [] as TocItem[] };
@@ -187,7 +237,19 @@ export default function PublicWikiSidebar({
}, [onWikiLinkRequest, renderHtml]); }, [onWikiLinkRequest, renderHtml]);
return ( return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"> <div
style={{ width: `${width}px` }}
className="relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
>
{/* Drag Handle on the left edge */}
<div
onPointerDown={handlePointerDown}
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-50 group select-none hover:bg-black/[0.03] dark:hover:bg-white/[0.02]"
title="Kéo để chỉnh kích thước"
>
{/* Visual drag line overlay */}
<div className="absolute left-[2px] top-0 bottom-0 w-[2px] bg-transparent group-hover:bg-brand-500/50 group-active:bg-brand-500 transition-colors" />
</div>
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800"> <div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
@@ -271,6 +333,11 @@ export default function PublicWikiSidebar({
height: auto; height: auto;
overflow-y: visible; overflow-y: visible;
padding: 18px 18px 22px; padding: 18px 18px 22px;
line-height: 1.6;
font-size: 14.5px;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
} }
.uhm-wiki-sidebar-view.ql-editor p { .uhm-wiki-sidebar-view.ql-editor p {
margin: 0 0 0.75em; margin: 0 0 0.75em;
@@ -317,16 +384,22 @@ export default function PublicWikiSidebar({
border: 1px solid rgba(226, 232, 240, 1); border: 1px solid rgba(226, 232, 240, 1);
border-radius: 10px; border-radius: 10px;
background: rgba(248, 250, 252, 1); background: rgba(248, 250, 252, 1);
overflow: auto; overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
} }
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre { :is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
border-color: rgba(51, 65, 85, 1); border-color: rgba(51, 65, 85, 1);
background: rgba(2, 6, 23, 0.4); background: rgba(2, 6, 23, 0.4);
} }
.uhm-wiki-sidebar-view.ql-editor img { .uhm-wiki-sidebar-view.ql-editor img {
max-width: 100%; display: block !important;
height: auto; max-width: 100% !important;
height: auto !important;
border-radius: 8px; border-radius: 8px;
float: none !important;
margin: 1.25em auto !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
} }
.uhm-wiki-sidebar-view.ql-editor a { .uhm-wiki-sidebar-view.ql-editor a {
text-decoration: underline; text-decoration: underline;
@@ -352,6 +425,18 @@ export default function PublicWikiSidebar({
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] { :is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #f87171; color: #f87171;
} }
@media (max-width: 640px) {
.uhm-wiki-sidebar-view.ql-editor {
padding: 14px 14px 20px;
font-size: 13.5px;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
font-size: 1.4em;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
font-size: 1.2em;
}
}
`}</style> `}</style>
</div> </div>
); );
+1
View File
@@ -60,6 +60,7 @@ Các type đang được register:
- `state` - `state`
- `empire` - `empire`
- `kingdom` - `kingdom`
- `faction`
- `war` - `war`
- `battle` - `battle`
- `civilization` - `civilization`
@@ -51,11 +51,26 @@ export function buildFeatureEntityPatch(
const entityNames = entityIds const entityNames = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || "") .map((id) => entities.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0); .filter((name) => name.length > 0);
const entityLabelCandidates = entityIds
.map((id) => {
const entity = entities.find((item) => item.id === id) || null;
if (!entity) return null;
const name = String(entity.name || "").trim();
if (!name) return null;
return {
id,
name,
time_start: entity.time_start ?? null,
time_end: entity.time_end ?? null,
};
})
.filter((candidate) => candidate !== null);
return { return {
entity_id: primaryEntityId, entity_id: primaryEntityId,
entity_ids: entityIds, entity_ids: entityIds,
entity_name: primaryEntity?.name || null, entity_name: primaryEntity?.name || null,
entity_names: entityNames, entity_names: entityNames,
entity_label_candidates: entityLabelCandidates,
}; };
} }
@@ -311,6 +311,8 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
operation: "reference", operation: "reference",
name: typeof e.name === "string" ? e.name : undefined, name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description ?? null, description: typeof e.description === "string" ? e.description : e.description ?? null,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
}; };
}); });
} }
@@ -19,6 +19,8 @@ export type TimelineRange = {
export type EntityFormState = { export type EntityFormState = {
name: string; name: string;
description: string; description: string;
time_start: string;
time_end: string;
}; };
export type GeometryMetaFormState = { export type GeometryMetaFormState = {
@@ -20,6 +20,8 @@ export function useEntitySessionState() {
const [entityForm, setEntityForm] = useState<EntityFormState>({ const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "", name: "",
description: "", description: "",
time_start: "",
time_end: "",
}); });
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại. // Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]); const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
@@ -31,6 +31,8 @@ interface RawEntityRow extends UnknownRecord {
ref?: { id?: string }; ref?: { id?: string };
name?: string; name?: string;
description?: string; description?: string;
time_start?: unknown;
time_end?: unknown;
} }
interface RawWikiRow extends UnknownRecord { interface RawWikiRow extends UnknownRecord {
@@ -124,6 +126,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
operation, operation,
name: typeof e.name === "string" ? e.name : undefined, name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined, description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start == null ? undefined : undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end == null ? undefined : undefined,
}; };
}) })
: undefined; : undefined;
@@ -266,12 +270,17 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
byGeom.set(row.geometry_id, list); byGeom.set(row.geometry_id, list);
} }
const entityNameById = new Map<string, string>(); const entityNameById = new Map<string, string>();
const entityTimeById = new Map<string, { time_start: number | null; time_end: number | null }>();
for (const r of entities || []) { for (const r of entities || []) {
const row = r as RawEntityRow; const row = r as RawEntityRow;
const id = typeof row?.id === "string" ? row.id : ""; const id = typeof row?.id === "string" ? row.id : "";
if (!id) continue; if (!id) continue;
const name = typeof row.name === "string" ? String(row.name).trim() : ""; const name = typeof row.name === "string" ? String(row.name).trim() : "";
if (name) entityNameById.set(id, name); if (name) entityNameById.set(id, name);
entityTimeById.set(id, {
time_start: typeof row.time_start === "number" ? row.time_start : null,
time_end: typeof row.time_end === "number" ? row.time_end : null,
});
} }
const geometryById = new Map<string, GeometrySnapshot>(); const geometryById = new Map<string, GeometrySnapshot>();
for (const row of geometries || []) { for (const row of geometries || []) {
@@ -299,6 +308,19 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0); const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
p.entity_name = primaryName || null; p.entity_name = primaryName || null;
p.entity_names = names; p.entity_names = names;
p.entity_label_candidates = entity_ids
.map((id) => {
const name = entityNameById.get(id) || "";
if (!name) return null;
const time = entityTimeById.get(id) || { time_start: null, time_end: null };
return {
id,
name,
time_start: time.time_start,
time_end: time.time_end,
};
})
.filter((candidate) => candidate !== null);
} }
// Generate geometry metadata onto feature properties (optional in persisted snapshot). // Generate geometry metadata onto feature properties (optional in persisted snapshot).
@@ -388,6 +410,8 @@ export function buildEditorSnapshot(options: {
operation: "reference", operation: "reference",
name: typeof cloned.name === "string" ? cloned.name : undefined, name: typeof cloned.name === "string" ? cloned.name : undefined,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
}); });
} }
for (const row of options.snapshotEntities || []) { for (const row of options.snapshotEntities || []) {
@@ -411,6 +435,8 @@ export function buildEditorSnapshot(options: {
name, name,
operation, operation,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
}); });
} }
@@ -537,6 +563,7 @@ export function buildEditorSnapshot(options: {
delete p.entity_ids; delete p.entity_ids;
delete p.entity_name; delete p.entity_name;
delete p.entity_names; delete p.entity_names;
delete p.entity_label_candidates;
delete p.entity_type_id; delete p.entity_type_id;
} }
@@ -662,6 +689,8 @@ export function buildEditorSnapshot(options: {
operation: e.operation, operation: e.operation,
name: typeof e.name === "string" ? e.name : undefined, name: typeof e.name === "string" ? e.name : undefined,
description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null, description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
})) }))
.sort((a, b) => String(a.id).localeCompare(String(b.id))), .sort((a, b) => String(a.id).localeCompare(String(b.id))),
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))), geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
+1 -1
View File
@@ -1,7 +1,7 @@
export const PATH_ARROW_ICON_ID = "path-arrow-icon"; export const PATH_ARROW_ICON_ID = "path-arrow-icon";
export const MAP_MIN_ZOOM = 2; export const MAP_MIN_ZOOM = 2;
export const MAP_MAX_ZOOM = 10; export const MAP_MAX_ZOOM = 14;
export const RASTER_BASE_SOURCE_ID = "rasterBase"; export const RASTER_BASE_SOURCE_ID = "rasterBase";
export const RASTER_BASE_LAYER_ID = "raster-base-layer"; export const RASTER_BASE_LAYER_ID = "raster-base-layer";
+141 -62
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath"; import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
export type EditingHandle = { export type EditingHandle = {
id: string | number; id: string | number;
@@ -25,12 +26,16 @@ export function createEditingEngine(options: {
const { mapRef, onUpdate } = options; const { mapRef, onUpdate } = options;
const editingRef = { current: null as EditingHandle | null }; const editingRef = { current: null as EditingHandle | null };
const dragStateRef = { current: null as { idx: number } | null }; const dragStateRef = { current: null as { idx: number } | null };
const modifierRef = { current: { ctrl: false, meta: false } }; const deleteVertexModeRef = { current: false };
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit. // Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
const clearEditing = () => { const clearEditing = () => {
editingRef.current = null; editingRef.current = null;
dragStateRef.current = null; dragStateRef.current = null;
setDeleteVertexMode(false);
hideContextMenu();
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] }; const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
@@ -135,6 +140,14 @@ export function createEditingEngine(options: {
clearEditing(); clearEditing();
}; };
const setDeleteVertexMode = (enabled: boolean) => {
deleteVertexModeRef.current = enabled;
const map = mapRef.current;
if (!map?.getLayer("edit-handles-circle")) return;
map.setPaintProperty("edit-handles-circle", "circle-color", enabled ? "#ef4444" : "#f97316");
map.setPaintProperty("edit-handles-circle", "circle-stroke-color", enabled ? "#7f1d1d" : "#0f172a");
};
// Bắt đầu chỉnh sửa từ feature polygon được chọn. // Bắt đầu chỉnh sửa từ feature polygon được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => { const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return; if (feature.geometry.type !== "Polygon") return;
@@ -154,18 +167,19 @@ export function createEditingEngine(options: {
circleCenter: geom.circle_center, circleCenter: geom.circle_center,
circleRadius: geom.circle_radius, circleRadius: geom.circle_radius,
}; };
setDeleteVertexMode(false);
updateEditSources(); updateEditSources();
}; };
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh. const hideContextMenu = () => {
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => { if (contextMenu) {
const oe = e?.originalEvent as MouseEvent | undefined; contextMenu.remove();
return ( contextMenu = null;
modifierRef.current.ctrl || }
modifierRef.current.meta || if (docClickHandler) {
!!oe?.ctrlKey || document.removeEventListener("click", docClickHandler);
!!oe?.metaKey docClickHandler = null;
); }
}; };
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình. // Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
@@ -173,10 +187,16 @@ export function createEditingEngine(options: {
// Bắt đầu kéo một handle point. // Bắt đầu kéo một handle point.
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => { const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return; if (!editingRef.current) return;
if (e.originalEvent.button === 2) return;
const feature = e.features?.[0]; const feature = e.features?.[0];
const idx = feature?.properties?.idx; const idx = Number(feature?.properties?.idx);
if (idx === undefined) return; if (!Number.isInteger(idx)) return;
e.preventDefault(); e.preventDefault();
if (deleteVertexModeRef.current) {
e.originalEvent.stopPropagation();
deleteVertex(idx);
return;
}
dragStateRef.current = { idx }; dragStateRef.current = { idx };
map.getCanvas().style.cursor = "grabbing"; map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable(); map.dragPan.disable();
@@ -188,19 +208,21 @@ export function createEditingEngine(options: {
const editing = editingRef.current; const editing = editingRef.current;
if (!drag || !editing) return; if (!drag || !editing) return;
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
const nextCoordinate: [number, number] = [lngLat.lng, lngLat.lat];
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) { if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
if (drag.idx === 0) { if (drag.idx === 0) {
// Move center // Move center
editing.circleCenter = [e.lngLat.lng, e.lngLat.lat]; editing.circleCenter = nextCoordinate;
} else if (drag.idx === 1) { } else if (drag.idx === 1) {
// Change radius // Change radius
editing.circleRadius = distanceMeters(editing.circleCenter, [ editing.circleRadius = distanceMeters(editing.circleCenter, nextCoordinate);
e.lngLat.lng,
e.lngLat.lat,
]);
} }
} else { } else {
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat]; editing.ring[drag.idx] = nextCoordinate;
} }
updateEditSources(); updateEditSources();
}; };
@@ -212,55 +234,39 @@ export function createEditingEngine(options: {
map.dragPan.enable(); map.dragPan.enable();
}; };
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags). // Bắt phím điều khiển phiên chỉnh sửa.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Control") { const editing = editingRef.current;
modifierRef.current.ctrl = true; if (!editing) return;
} else if (e.key === "Meta") {
modifierRef.current.meta = true;
}
if (!editingRef.current) return;
if (e.key === "Enter") { if (e.key === "Enter") {
finishEditing(); finishEditing();
} else if (e.key === "Delete" && !editing.isCircle) {
e.preventDefault();
setDeleteVertexMode(!deleteVertexModeRef.current);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
if (deleteVertexModeRef.current) {
e.preventDefault();
setDeleteVertexMode(false);
return;
}
cancelEditing(); cancelEditing();
} }
}; };
// Hạ cờ modifier khi nhả phím. // Chuột phải vào handle để mở menu xóa/thêm đỉnh.
const onKeyUp = (e: KeyboardEvent) => { const onHandleContextMenu = (e: maplibregl.MapLayerMouseEvent) => {
if (e.key === "Control") {
modifierRef.current.ctrl = false;
} else if (e.key === "Meta") {
modifierRef.current.meta = false;
}
};
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current || editingRef.current.isCircle) return;
if (!isModifierPressed(e)) return;
e.preventDefault();
const editing = editingRef.current; const editing = editingRef.current;
const ring = editing.ring; if (!editing || editing.isCircle) return;
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number]; e.preventDefault();
let nearestIdx = 0; e.originalEvent.stopPropagation();
let bestDist = Number.POSITIVE_INFINITY; const feature = e.features?.[0];
ring.forEach((pt, idx) => { const idx = Number(feature?.properties?.idx);
const dx = pt[0] - click[0]; if (!Number.isInteger(idx)) return;
const dy = pt[1] - click[1]; showHandleContextMenu(
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt. e.originalEvent.clientX,
if (d < bestDist) { e.originalEvent.clientY,
bestDist = d; idx
nearestIdx = idx; );
}
});
const insertIdx = nearestIdx + 1;
ring.splice(insertIdx, 0, click);
dragStateRef.current = { idx: insertIdx };
map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable();
updateEditSources();
}; };
// Ngắt kéo nếu con trỏ rời canvas. // Ngắt kéo nếu con trỏ rời canvas.
@@ -269,24 +275,97 @@ export function createEditingEngine(options: {
}; };
map.on("mousedown", "edit-handles-circle", onHandleDown); map.on("mousedown", "edit-handles-circle", onHandleDown);
map.on("mousedown", "edit-shape-line", onInsertHandle); map.on("contextmenu", "edit-handles-circle", onHandleContextMenu);
map.on("mousemove", onHandleMove); map.on("mousemove", onHandleMove);
map.on("mouseup", stopDragging); map.on("mouseup", stopDragging);
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
map.getCanvas().addEventListener("mouseleave", onCanvasLeave); map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
map.on("remove", () => { map.on("remove", () => {
map.off("mousedown", "edit-handles-circle", onHandleDown); map.off("mousedown", "edit-handles-circle", onHandleDown);
map.off("mousedown", "edit-shape-line", onInsertHandle); map.off("contextmenu", "edit-handles-circle", onHandleContextMenu);
map.off("mousemove", onHandleMove); map.off("mousemove", onHandleMove);
map.off("mouseup", stopDragging); map.off("mouseup", stopDragging);
document.removeEventListener("keydown", onKeyDown); document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave); map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
hideContextMenu();
}); });
}; };
const showHandleContextMenu = (x: number, y: number, idx: number) => {
hideContextMenu();
const menu = document.createElement("div");
menu.style.position = "fixed";
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.background = "#0f172a";
menu.style.color = "white";
menu.style.border = "1px solid #1f2937";
menu.style.borderRadius = "6px";
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.minWidth = "120px";
menu.style.fontSize = "14px";
menu.style.padding = "4px 0";
const createItem = (label: string, onClick: () => void, disabled = false) => {
const item = document.createElement("div");
item.textContent = label;
item.style.padding = "8px 12px";
item.style.cursor = disabled ? "not-allowed" : "pointer";
item.style.opacity = disabled ? "0.45" : "1";
item.onmouseenter = () => {
if (!disabled) item.style.background = "#1f2937";
};
item.onmouseleave = () => (item.style.background = "transparent");
item.onclick = () => {
if (disabled) return;
onClick();
hideContextMenu();
};
return item;
};
const editing = editingRef.current;
const canDelete = Boolean(editing && !editing.isCircle && editing.ring.length > 3);
menu.appendChild(createItem("Xóa đỉnh", () => deleteVertex(idx), !canDelete));
menu.appendChild(createItem("Thêm đỉnh", () => insertVertexAfter(idx)));
document.body.appendChild(menu);
contextMenu = menu;
const onDocClick = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
hideContextMenu();
}
};
docClickHandler = onDocClick;
setTimeout(() => document.addEventListener("click", onDocClick), 0);
};
const deleteVertex = (idx: number) => {
const editing = editingRef.current;
if (!editing || editing.isCircle || editing.ring.length <= 3) return;
if (idx < 0 || idx >= editing.ring.length) return;
editing.ring.splice(idx, 1);
updateEditSources();
};
const insertVertexAfter = (idx: number) => {
const editing = editingRef.current;
if (!editing || editing.isCircle || editing.ring.length < 2) return;
if (idx < 0 || idx >= editing.ring.length) return;
const current = editing.ring[idx];
const next = editing.ring[(idx + 1) % editing.ring.length];
const midpoint: [number, number] = [
(current[0] + next[0]) / 2,
(current[1] + next[1]) / 2,
];
editing.ring.splice(idx + 1, 0, midpoint);
updateEditSources();
};
return { return {
beginEditing, beginEditing,
clearEditing, clearEditing,
+11 -2
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection", type: "FeatureCollection",
@@ -74,7 +75,11 @@ export function initLine(
const onClick = (e: maplibregl.MapLayerMouseEvent) => { const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-line") return; if (getMode() !== "add-line") return;
coords.push([e.lngLat.lng, e.lngLat.lat]); const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
coords.push([lngLat.lng, lngLat.lat]);
updatePreview(coords); updatePreview(coords);
}; };
@@ -94,7 +99,11 @@ export function initLine(
canvas.style.cursor = "crosshair"; canvas.style.cursor = "crosshair";
if (coords.length === 0) return; if (coords.length === 0) return;
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
}; };
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line. // Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
+10 -2
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection", type: "FeatureCollection",
@@ -75,7 +76,11 @@ export function initPath(
const onClick = (e: maplibregl.MapLayerMouseEvent) => { const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-path") return; if (getMode() !== "add-path") return;
coords.push([e.lngLat.lng, e.lngLat.lat]); const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
coords.push([lngLat.lng, lngLat.lat]);
updatePreview(coords); updatePreview(coords);
}; };
@@ -96,7 +101,10 @@ export function initPath(
canvas.style.cursor = "crosshair"; canvas.style.cursor = "crosshair";
if (coords.length === 0) return; if (coords.length === 0) return;
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]); const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
}; };
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path. // Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
+6 -1
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
// Khởi tạo engine thêm point bằng click đơn. // Khởi tạo engine thêm point bằng click đơn.
export function initPoint( export function initPoint(
@@ -12,9 +13,13 @@ export function initPoint(
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "add-point") return; if (getMode() !== "add-point") return;
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
const geometry: Geometry = { const geometry: Geometry = {
type: "Point", type: "Point",
coordinates: [e.lngLat.lng, e.lngLat.lat], coordinates: [lngLat.lng, lngLat.lat],
}; };
onComplete?.(geometry); onComplete?.(geometry);
+28 -3
View File
@@ -7,8 +7,11 @@ export function initSelect(
getMode: ModeGetter, getMode: ModeGetter,
onDelete?: (id: string | number) => void, onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onDuplicate?: (id: string | number) => void,
onHide?: (id: string | number) => void,
onSelectIds?: (ids: (string | number)[]) => void, onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean
) { ) {
const FEATURE_STATE_SOURCES = [ const FEATURE_STATE_SOURCES = [
@@ -17,7 +20,7 @@ export function initSelect(
"path-arrow-shapes", "path-arrow-shapes",
] as const; ] as const;
const selectedIds = new Set<number | string>(); const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit || onReplayEdit); const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit);
let contextMenu: HTMLDivElement | null = null; let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -56,6 +59,7 @@ export function initSelect(
// Chọn feature theo click trái, hỗ trợ additive bằng Alt. // Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return; if (getMode() !== "select" && getMode() !== "replay") return;
if (isEditSessionActive?.()) return;
const selectableLayers = getSelectableLayers(); const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return; if (!selectableLayers.length) return;
@@ -81,6 +85,7 @@ export function initSelect(
e.preventDefault(); // block browser menu e.preventDefault(); // block browser menu
if (getMode() === "replay") return; if (getMode() === "replay") return;
if (isEditSessionActive?.()) return;
const features = map.queryRenderedFeatures(e.point, { const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers, layers: selectableLayers,
@@ -125,7 +130,11 @@ export function initSelect(
const style = map.getStyle(); const style = map.getStyle();
if (!style || !style.layers) return []; if (!style || !style.layers) return [];
return style.layers return style.layers
.filter((layer) => "source" in layer && FEATURE_STATE_SOURCES.includes(layer.source as any)) .filter((layer) =>
"source" in layer &&
typeof layer.source === "string" &&
FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number])
)
.map((layer) => layer.id); .map((layer) => layer.id);
} }
@@ -236,6 +245,22 @@ export function initSelect(
hasMenuItems = true; hasMenuItems = true;
} }
if (selectedCount === 1 && onDuplicate) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
hasMenuItems = true;
}
}
if (selectedCount === 1 && onHide) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Hide", () => onHide(featureId)));
hasMenuItems = true;
}
}
if (onReplayEdit) { if (onReplayEdit) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id; const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) { if (featureId) {
+99 -17
View File
@@ -1,6 +1,16 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
const SNAP_THRESHOLD_PX = 15; // SHIFT/ALT snap should be forgiving while drawing quickly.
// Vertices get a larger radius and always win over edges when both are available.
const VERTEX_SNAP_THRESHOLD_PX = 34;
const EDGE_SNAP_THRESHOLD_PX = 24;
const QUERY_THRESHOLD_PX = Math.max(VERTEX_SNAP_THRESHOLD_PX, EDGE_SNAP_THRESHOLD_PX);
type Coordinate = [number, number];
type GeometryWithCoordinates = Exclude<GeoJSON.Geometry, GeoJSON.GeometryCollection> & {
coordinates: unknown;
};
export function snapToNearestGeometry( export function snapToNearestGeometry(
map: maplibregl.Map, map: maplibregl.Map,
@@ -8,14 +18,21 @@ export function snapToNearestGeometry(
pointPx: maplibregl.Point pointPx: maplibregl.Point
): maplibregl.LngLat { ): maplibregl.LngLat {
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [ const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[pointPx.x - SNAP_THRESHOLD_PX, pointPx.y - SNAP_THRESHOLD_PX], [pointPx.x - QUERY_THRESHOLD_PX, pointPx.y - QUERY_THRESHOLD_PX],
[pointPx.x + SNAP_THRESHOLD_PX, pointPx.y + SNAP_THRESHOLD_PX], [pointPx.x + QUERY_THRESHOLD_PX, pointPx.y + QUERY_THRESHOLD_PX],
]; ];
const features = map.queryRenderedFeatures(bbox); const snapLayerIds = getSnapLayerIds(map);
if (!snapLayerIds.length) return lngLat;
let nearestDist = Infinity; const features = map.queryRenderedFeatures(bbox, {
let nearestLngLat: maplibregl.LngLat | null = null; layers: snapLayerIds,
});
let nearestVertexDist = Infinity;
let nearestVertexLngLat: maplibregl.LngLat | null = null;
let nearestEdgeDist = Infinity;
let nearestEdgeLngLat: maplibregl.LngLat | null = null;
const getDistSq = (p1: maplibregl.Point, p2: maplibregl.Point) => { const getDistSq = (p1: maplibregl.Point, p2: maplibregl.Point) => {
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2; return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
@@ -34,24 +51,49 @@ export function snapToNearestGeometry(
return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t); return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t);
}; };
const processVertex = (coordinate: Coordinate) => {
const vertexLngLat = new maplibregl.LngLat(coordinate[0], coordinate[1]);
const vertexPx = map.project(vertexLngLat);
const distSq = getDistSq(pointPx, vertexPx);
if (
distSq < nearestVertexDist &&
distSq <= VERTEX_SNAP_THRESHOLD_PX ** 2
) {
nearestVertexDist = distSq;
nearestVertexLngLat = vertexLngLat;
}
};
const processLineString = (line: number[][]) => { const processLineString = (line: number[][]) => {
if (!line || line.length < 2) return; if (!line || line.length < 2) return;
for (let i = 0; i < line.length - 1; i++) { for (let i = 0; i < line.length - 1; i++) {
const p1LngLat = new maplibregl.LngLat(line[i][0], line[i][1]); const start = toCoordinate(line[i]);
const p2LngLat = new maplibregl.LngLat(line[i + 1][0], line[i + 1][1]); const end = toCoordinate(line[i + 1]);
if (!start || !end) continue;
processVertex(start);
if (i === line.length - 2) processVertex(end);
const p1LngLat = new maplibregl.LngLat(start[0], start[1]);
const p2LngLat = new maplibregl.LngLat(end[0], end[1]);
const p1 = map.project(p1LngLat); const p1 = map.project(p1LngLat);
const p2 = map.project(p2LngLat); const p2 = map.project(p2LngLat);
const closestPx = getClosestPointOnSegment(pointPx, p1, p2); const closestPx = getClosestPointOnSegment(pointPx, p1, p2);
const distSq = getDistSq(pointPx, closestPx); const distSq = getDistSq(pointPx, closestPx);
if (distSq < nearestDist && distSq <= SNAP_THRESHOLD_PX ** 2) { if (distSq < nearestEdgeDist && distSq <= EDGE_SNAP_THRESHOLD_PX ** 2) {
nearestDist = distSq; nearestEdgeDist = distSq;
nearestLngLat = map.unproject(closestPx); nearestEdgeLngLat = map.unproject(closestPx);
} }
} }
}; };
const processPoint = (coordinate: unknown) => {
const point = toCoordinate(coordinate);
if (point) processVertex(point);
};
for (const feature of features) { for (const feature of features) {
if (!feature.geometry) continue; if (!feature.geometry) continue;
@@ -62,21 +104,61 @@ export function snapToNearestGeometry(
const type = feature.geometry.type; const type = feature.geometry.type;
if (type === "GeometryCollection") continue; if (type === "GeometryCollection") continue;
const coords = (feature.geometry as any).coordinates; const coords = (feature.geometry as GeometryWithCoordinates).coordinates;
// Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString // Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
if (type === "Polygon") { if (type === "Polygon") {
for (const ring of coords) processLineString(ring); for (const ring of asCoordinateMatrix(coords)) processLineString(ring);
} else if (type === "MultiPolygon") { } else if (type === "MultiPolygon") {
for (const poly of coords) { for (const poly of asCoordinateTensor(coords)) {
for (const ring of poly) processLineString(ring); for (const ring of poly) processLineString(ring);
} }
} else if (type === "LineString") { } else if (type === "LineString") {
processLineString(coords); processLineString(asCoordinateArray(coords));
} else if (type === "MultiLineString") { } else if (type === "MultiLineString") {
for (const line of coords) processLineString(line); for (const line of asCoordinateMatrix(coords)) processLineString(line);
} else if (type === "Point") {
processPoint(coords);
} else if (type === "MultiPoint") {
for (const point of asCoordinateArray(coords)) processPoint(point);
} }
} }
return nearestLngLat || lngLat; return nearestVertexLngLat || nearestEdgeLngLat || lngLat;
}
function getSnapLayerIds(map: maplibregl.Map): string[] {
const systemGeometrySources = new Set(["countries", "places", PATH_ARROW_SOURCE_ID]);
const style = map.getStyle();
if (!style?.layers?.length) return [];
return style.layers
.filter((layer) => {
if (!("source" in layer)) return false;
if (!systemGeometrySources.has(String(layer.source))) return false;
if (layer.id.includes("preview") || layer.id.includes("edit-")) return false;
return true;
})
.map((layer) => layer.id)
.filter((layerId) => Boolean(map.getLayer(layerId)));
}
function toCoordinate(value: unknown): Coordinate | null {
if (!Array.isArray(value) || value.length < 2) return null;
const lng = Number(value[0]);
const lat = Number(value[1]);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
return [lng, lat];
}
function asCoordinateArray(value: unknown): number[][] {
return Array.isArray(value) ? value as number[][] : [];
}
function asCoordinateMatrix(value: unknown): number[][][] {
return Array.isArray(value) ? value as number[][][] : [];
}
function asCoordinateTensor(value: unknown): number[][][][] {
return Array.isArray(value) ? value as number[][][][] : [];
} }
+2 -2
View File
@@ -8,10 +8,11 @@
{ "type_key": "trade_route", "geo_type_code": 7 }, { "type_key": "trade_route", "geo_type_code": 7 },
{ "type_key": "shipping_route", "geo_type_code": 8 }, { "type_key": "shipping_route", "geo_type_code": 8 },
{ "type_key": "country", "geo_type_code": 9 }, { "type_key": "country", "geo_type_code": 9, "fixed": true },
{ "type_key": "state", "geo_type_code": 10 }, { "type_key": "state", "geo_type_code": 10 },
{ "type_key": "empire", "geo_type_code": 11 }, { "type_key": "empire", "geo_type_code": 11 },
{ "type_key": "kingdom", "geo_type_code": 12 }, { "type_key": "kingdom", "geo_type_code": 12 },
{ "type_key": "faction", "geo_type_code": 28 },
{ "type_key": "war", "geo_type_code": 13 }, { "type_key": "war", "geo_type_code": 13 },
{ "type_key": "battle", "geo_type_code": 14 }, { "type_key": "battle", "geo_type_code": 14 },
@@ -30,4 +31,3 @@
{ "type_key": "port", "geo_type_code": 26 }, { "type_key": "port", "geo_type_code": 26 },
{ "type_key": "bridge", "geo_type_code": 27 } { "type_key": "bridge", "geo_type_code": 27 }
] ]
@@ -75,6 +75,7 @@ const RAW_GEOMETRY_TYPE_OPTIONS: Array<{
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" }, { value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" }, { value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" }, { value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
{ value: "faction", label: "Faction", groupId: "polygon", geometryPreset: "polygon" },
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" }, { value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" }, { value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
+2
View File
@@ -14,6 +14,7 @@ import { getCountryLayers } from "./geotypes/country";
import { getStateLayers } from "./geotypes/state"; import { getStateLayers } from "./geotypes/state";
import { getEmpireLayers } from "./geotypes/empire"; import { getEmpireLayers } from "./geotypes/empire";
import { getKingdomLayers } from "./geotypes/kingdom"; import { getKingdomLayers } from "./geotypes/kingdom";
import { getFactionLayers } from "./geotypes/faction";
import { getWarLayers } from "./geotypes/war"; import { getWarLayers } from "./geotypes/war";
import { getBattleLayers } from "./geotypes/battle"; import { getBattleLayers } from "./geotypes/battle";
import { getCivilizationLayers } from "./geotypes/civilization"; import { getCivilizationLayers } from "./geotypes/civilization";
@@ -40,6 +41,7 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId), ...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId), ...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
...getKingdomLayers(sourceId, pathArrowSourceId, pointSourceId), ...getKingdomLayers(sourceId, pathArrowSourceId, pointSourceId),
...getFactionLayers(sourceId, pathArrowSourceId, pointSourceId),
...getWarLayers(sourceId, pathArrowSourceId, pointSourceId), ...getWarLayers(sourceId, pathArrowSourceId, pointSourceId),
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId), ...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId), ...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
@@ -0,0 +1,15 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getFactionLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
void pointSourceId;
return buildPolygonGeotypeLayers(sourceId, {
typeId: "faction",
fillColor: "#f97316",
strokeColor: "#9a3412",
fillOpacity: 0.3,
strokeWidth: { z1: 1.6, z4: 2.3, z6: 3.1 },
dasharray: [2, 1.5],
});
}
+12 -3
View File
@@ -102,7 +102,7 @@ export function buildLineGeotypeLayers(
source: pathArrowSourceId, source: pathArrowSourceId,
filter: ["==", TYPE_MATCH_EXPR, style.typeId], filter: ["==", TYPE_MATCH_EXPR, style.typeId],
paint: { paint: {
"fill-color": statusColor(style.color), "fill-color": statusFillColor(style.color),
"fill-opacity": [ "fill-opacity": [
"case", "case",
SELECTED_EXPR, SELECTED_EXPR,
@@ -140,7 +140,7 @@ export function buildPolygonGeotypeLayers(
source: sourceId, source: sourceId,
filter: polygonFilter(style.typeId), filter: polygonFilter(style.typeId),
paint: { paint: {
"fill-color": statusColor(style.fillColor), "fill-color": statusFillColor(style.fillColor),
"fill-opacity": [ "fill-opacity": [
"case", "case",
SELECTED_EXPR, SELECTED_EXPR,
@@ -183,13 +183,22 @@ function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
return [ return [
"case", "case",
SELECTED_EXPR, SELECTED_EXPR,
SELECTED_STROKE, SELECTED_COLOR,
DRAFT_ENTITY_EXPR, DRAFT_ENTITY_EXPR,
DRAFT_STROKE, DRAFT_STROKE,
normalColor, normalColor,
]; ];
} }
function statusFillColor(normalColor: string): maplibregl.ExpressionSpecification {
return [
"case",
DRAFT_ENTITY_EXPR,
DRAFT_COLOR,
normalColor,
];
}
function lineFilter(typeId: string): maplibregl.ExpressionSpecification { function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
return ["all", LINE_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]]; return ["all", LINE_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
} }
+26 -3
View File
@@ -35,11 +35,14 @@ export type GeometryFocusRequest = {
}; };
type EditorStoreValues = { type EditorStoreValues = {
// Editor mode + draft seed.
mode: EditorMode; mode: EditorMode;
initialData: FeatureCollection; initialData: FeatureCollection;
// Task flags; setTaskFlag ensures only one blocking task is active at a time.
isSaving: boolean; isSaving: boolean;
isSubmitting: boolean; isSubmitting: boolean;
isOpeningSection: boolean; isOpeningSection: boolean;
// Project/commit state loaded from backend.
availableSections: Project[]; availableSections: Project[];
selectedProjectId: string; selectedProjectId: string;
newSectionTitle: string; newSectionTitle: string;
@@ -49,6 +52,7 @@ type EditorStoreValues = {
projectState: ProjectState | null; projectState: ProjectState | null;
sectionCommits: ProjectCommit[]; sectionCommits: ProjectCommit[];
baselineSnapshot: EditorSnapshot | null; baselineSnapshot: EditorSnapshot | null;
// Entity state: backend catalog plus snapshot-local rows and form/search status.
entityCatalog: Entity[]; entityCatalog: Entity[];
snapshotEntities: EntitySnapshot[]; snapshotEntities: EntitySnapshot[];
entityStatus: string | null; entityStatus: string | null;
@@ -60,12 +64,15 @@ type EditorStoreValues = {
entityFormStatus: string | null; entityFormStatus: string | null;
entitySearchResults: Entity[]; entitySearchResults: Entity[];
isEntitySearchLoading: boolean; isEntitySearchLoading: boolean;
// Timeline + background layer state for map filtering/rendering.
timelineDraftYear: number; timelineDraftYear: number;
backgroundVisibility: BackgroundLayerVisibility; backgroundVisibility: BackgroundLayerVisibility;
isBackgroundVisibilityReady: boolean; isBackgroundVisibilityReady: boolean;
// Wiki state and entity-wiki relationship snapshot.
snapshotWikis: WikiSnapshot[]; snapshotWikis: WikiSnapshot[];
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]; snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
blockedPendingSubmissionId: string | null; blockedPendingSubmissionId: string | null;
// Unified search state shared by entity/wiki/geo search UI.
searchKind: UnifiedSearchKind; searchKind: UnifiedSearchKind;
searchQuery: string; searchQuery: string;
searchQueryDraft: string; searchQueryDraft: string;
@@ -74,12 +81,14 @@ type EditorStoreValues = {
geoSearchResults: EntityGeometriesSearchItem[]; geoSearchResults: EntityGeometriesSearchItem[];
isGeoSearching: boolean; isGeoSearching: boolean;
requestedActiveWikiId: string | null; requestedActiveWikiId: string | null;
// Layout sizing and panel filter state.
leftPanelWidth: number; leftPanelWidth: number;
rightPanelWidth: number; rightPanelWidth: number;
timelineFilterEnabled: boolean; timelineFilterEnabled: boolean;
geometryBindingFilterEnabled: boolean; geometryBindingFilterEnabled: boolean;
geoBindingStatus: string | null; geoBindingStatus: string | null;
hoveredGeometryId: string | null; hoveredGeometryId: string | null;
// Map focus/replay visibility state.
geometryFocusRequest: GeometryFocusRequest | null; geometryFocusRequest: GeometryFocusRequest | null;
replayFeatureId: string | number | null; replayFeatureId: string | number | null;
hideOutside: boolean; hideOutside: boolean;
@@ -148,10 +157,12 @@ export type EditorStoreOptions = {
currentYear: number; currentYear: number;
}; };
// Hỗ trợ setter nhận cả value trực tiếp và updater function giống React setState.
function resolveNextState<T>(next: SetStateAction<T>, prev: T): T { function resolveNextState<T>(next: SetStateAction<T>, prev: T): T {
return typeof next === "function" ? (next as (prevState: T) => T)(prev) : next; return typeof next === "function" ? (next as (prevState: T) => T)(prev) : next;
} }
// Khởi tạo visibility mặc định cho từng geo type trong map.
function buildInitialGeometryVisibility() { function buildInitialGeometryVisibility() {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = {};
for (const key of GEO_TYPE_KEYS) { for (const key of GEO_TYPE_KEYS) {
@@ -161,6 +172,7 @@ function buildInitialGeometryVisibility() {
} }
export function createEditorStore(options: EditorStoreOptions): EditorStoreApi { export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
// Năm timeline ban đầu luôn nằm trong fixed range để slider/map nhất quán.
const initialTimelineYear = clampYearValue( const initialTimelineYear = clampYearValue(
options.currentYear, options.currentYear,
options.fallbackTimelineRange.min, options.fallbackTimelineRange.min,
@@ -168,15 +180,21 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
); );
return createStore<EditorStoreState>()((set) => { return createStore<EditorStoreState>()((set) => {
// Setter generic để tránh lặp boilerplate cho từng field trong store.
const setValue = <K extends keyof EditorStoreValues>( const setValue = <K extends keyof EditorStoreValues>(
key: K, key: K,
next: SetStateAction<EditorStoreValues[K]> next: SetStateAction<EditorStoreValues[K]>
) => { ) => {
set((state) => ({ set((state) => {
[key]: resolveNextState(next, state[key]), const nextValue = resolveNextState(next, state[key]);
} as Pick<EditorStoreValues, K>)); if (Object.is(nextValue, state[key])) return state;
return {
[key]: nextValue,
} as Pick<EditorStoreValues, K>;
});
}; };
// Setter riêng cho task flags vì saving/submitting/opening không nên đồng thời true.
const setTaskFlag = ( const setTaskFlag = (
task: "saving" | "submitting" | "opening-project", task: "saving" | "submitting" | "opening-project",
next: SetStateAction<boolean> next: SetStateAction<boolean>
@@ -230,6 +248,8 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
entityForm: { entityForm: {
name: "", name: "",
description: "", description: "",
time_start: "",
time_end: "",
}, },
selectedGeometryEntityIds: [], selectedGeometryEntityIds: [],
geometryMetaForm: { geometryMetaForm: {
@@ -327,6 +347,7 @@ type EditorStoreProviderProps = {
}; };
export function EditorStoreProvider({ children, options }: EditorStoreProviderProps) { export function EditorStoreProvider({ children, options }: EditorStoreProviderProps) {
// State giữ store instance ổn định trong suốt vòng đời provider.
const [store] = useState(() => createEditorStore(options)); const [store] = useState(() => createEditorStore(options));
return ( return (
@@ -337,6 +358,7 @@ export function EditorStoreProvider({ children, options }: EditorStoreProviderPr
} }
export function useEditorStore<T>(selector: (state: EditorStoreState) => T) { export function useEditorStore<T>(selector: (state: EditorStoreState) => T) {
// Hook đọc store bằng selector để component chỉ re-render theo slice cần thiết.
const store = useContext(EditorStoreContext); const store = useContext(EditorStoreContext);
if (!store) { if (!store) {
throw new Error("useEditorStore must be used within EditorStoreProvider."); throw new Error("useEditorStore must be used within EditorStoreProvider.");
@@ -346,6 +368,7 @@ export function useEditorStore<T>(selector: (state: EditorStoreState) => T) {
} }
export function useEditorStoreApi() { export function useEditorStoreApi() {
// Hook lấy raw StoreApi cho command layer cần getState/setState ngoài render.
const store = useContext(EditorStoreContext); const store = useContext(EditorStoreContext);
if (!store) { if (!store) {
throw new Error("useEditorStoreApi must be used within EditorStoreProvider."); throw new Error("useEditorStoreApi must be used within EditorStoreProvider.");
+4
View File
@@ -12,6 +12,8 @@ export type Entity = {
slug?: string | null; slug?: string | null;
type_id?: string | null; type_id?: string | null;
geometry_count?: number; geometry_count?: number;
time_start?: number | null;
time_end?: number | null;
}; };
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference"; export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
@@ -29,4 +31,6 @@ export type EntitySnapshot = {
operation?: EntitySnapshotOperation; operation?: EntitySnapshotOperation;
name?: string; name?: string;
description?: string | null; description?: string | null;
time_start?: number | null;
time_end?: number | null;
}; };
+8
View File
@@ -26,12 +26,20 @@ export type FeatureProperties = {
entity_ids?: string[]; entity_ids?: string[];
entity_name?: string | null; entity_name?: string | null;
entity_names?: string[]; entity_names?: string[];
entity_label_candidates?: EntityLabelCandidate[];
entity_type_id?: string | null; entity_type_id?: string | null;
point_label?: string | null; point_label?: string | null;
line_label?: string | null; line_label?: string | null;
polygon_label?: string | null; polygon_label?: string | null;
}; };
export type EntityLabelCandidate = {
id: string;
name: string;
time_start?: number | null;
time_end?: number | null;
};
export type Feature = { export type Feature = {
type: "Feature"; type: "Feature";
properties: FeatureProperties; properties: FeatureProperties;