add somenew UI editor feature for more effêcncy
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
|||||||
+401
-398
File diff suppressed because it is too large
Load Diff
+128
-4
@@ -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 lý 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 có project. Vào trang quản lý 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,6 +249,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;
|
||||||
@@ -466,6 +470,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}
|
||||||
@@ -768,6 +774,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 có ả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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,34 @@ 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 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(() => {
|
useEffect(() => {
|
||||||
fitBoundsAppliedRef.current = false;
|
fitBoundsAppliedRef.current = false;
|
||||||
@@ -102,10 +111,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 +145,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 +186,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 +205,7 @@ export function useMapSync({
|
|||||||
allowGeometryEditing,
|
allowGeometryEditing,
|
||||||
draft,
|
draft,
|
||||||
labelContextDraft,
|
labelContextDraft,
|
||||||
|
labelTimelineYear,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
respectBindingFilter,
|
respectBindingFilter,
|
||||||
geometryVisibility,
|
geometryVisibility,
|
||||||
@@ -231,5 +249,19 @@ export function useMapSync({
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[][][][] : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user