diff --git a/api/http.ts b/api/http.ts index b409b12..73172d2 100644 --- a/api/http.ts +++ b/api/http.ts @@ -1,24 +1,43 @@ export class ApiError extends Error { status: number; body: string; + errors: unknown[]; - constructor(message: string, status: number, body: string) { + constructor(message: string, status: number, body: string, errors: unknown[] = []) { super(message); this.name = "ApiError"; this.status = status; this.body = body; + this.errors = errors; } } +type ApiEnvelope = { + status: "success" | "error" | string; + data: T; + message: string; + errors: unknown[]; +}; + export async function requestJson(input: RequestInfo | URL, init?: RequestInit): Promise { const res = await fetch(input, init); + const payload = await parseJsonResponse(res); + const envelope = isApiEnvelope(payload) ? payload : null; if (!res.ok) { - const text = await res.text(); - throw new ApiError(`Request failed with status ${res.status}`, res.status, text); + const message = envelope?.message || `Request failed with status ${res.status}`; + const body = envelope ? message : stringifyPayload(payload); + throw new ApiError(message, res.status, body, envelope?.errors || []); } - return (await res.json()) as T; + if (envelope) { + if (envelope.status === "error") { + throw new ApiError(envelope.message || "Request failed", res.status, JSON.stringify(envelope), envelope.errors); + } + return envelope.data; + } + + return payload as T; } export function jsonRequestInit(method: string, body: unknown): RequestInit { @@ -28,3 +47,33 @@ export function jsonRequestInit(method: string, body: unknown): RequestInit { body: JSON.stringify(body), }; } + +async function parseJsonResponse(res: Response): Promise { + const text = await res.text(); + if (!text.length) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function isApiEnvelope(value: unknown): value is ApiEnvelope { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return ( + "status" in source && + "data" in source && + "message" in source && + "errors" in source + ); +} + +function stringifyPayload(payload: unknown): string { + if (typeof payload === "string") return payload; + try { + return JSON.stringify(payload); + } catch { + return String(payload); + } +} diff --git a/api/sections.ts b/api/sections.ts index bbb8820..2a8d165 100644 --- a/api/sections.ts +++ b/api/sections.ts @@ -144,7 +144,7 @@ export async function restoreSectionCommit( export async function submitSection( sectionId: string, - input: { commit_id?: string; submitted_by?: string; user_id?: string } + input: { submitted_by?: string; user_id?: string } ): Promise { return requestJson(sectionUrl(sectionId, "submit"), jsonRequestInit("POST", input)); } diff --git a/app/editor/page.tsx b/app/editor/page.tsx index f714084..43a4b10 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -933,7 +933,7 @@ export default function Page() { const handleSubmitSection = async () => { if (!activeSection || !sectionState?.head_commit_id) { - setEntityStatus("Chưa có commit để submit."); + setEntityStatus("Section hiện tại chưa có head để submit."); return; } if (pendingSaveCount > 0) { @@ -946,7 +946,6 @@ export default function Page() { try { const submission = await submitSection(activeSection.id, { submitted_by: editorUserId, - commit_id: sectionState.head_commit_id, }); setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev); setEntityStatus(`Đã submit section, submission ${submission.id}.`); @@ -1001,6 +1000,9 @@ export default function Page() { }; const pendingSaveCount = editor.changeCount + pendingEntityCreates.length; + const headCommit = sectionState?.head_commit_id + ? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null + : null; const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0; const timelineStatusText = pendingSaveCount > 0 @@ -1043,7 +1045,9 @@ export default function Page() { onOpenSection={handleOpenSelectedSection} onCreateSection={handleCreateAndOpenSection} commitCount={sectionCommits.length} - latestCommitLabel={sectionCommits[0] ? `Head #${sectionCommits[0].commit_no}` : null} + hasHeadCommit={Boolean(sectionState?.head_commit_id)} + headCommitId={sectionState?.head_commit_id || null} + latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null} commits={sectionCommits} changesCount={pendingSaveCount} undoStack={editor.undoStack} @@ -1398,6 +1402,10 @@ function isYearNumber(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value); } +function formatCommitTitle(commit: SectionCommit): string { + return commit.title?.trim() || `Commit #${commit.commit_no}`; +} + function getDefaultTypeIdForFeature(feature: Feature): string { const preset = feature.properties.geometry_preset; if (preset === "line") return "defense_line"; diff --git a/app/submited/page.tsx b/app/submited/page.tsx index 916e420..1088df4 100644 --- a/app/submited/page.tsx +++ b/app/submited/page.tsx @@ -11,7 +11,7 @@ import { Section, SectionSubmission, } from "@/api/sections"; -import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; +import { HIDDEN_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; type SubmissionStatusFilter = "pending" | "all"; @@ -37,6 +37,11 @@ const EMPTY_FEATURE_COLLECTION: FeatureCollection = { type: "FeatureCollection", features: [], }; +const REVIEW_BACKGROUND_LAYER_VISIBILITY = { + ...HIDDEN_BACKGROUND_LAYER_VISIBILITY, + "bg-countries-fill": true, + "bg-country-borders-line": true, +}; const STATUS_LABELS: Record = { pending: "Chờ duyệt", @@ -54,6 +59,7 @@ export default function SubmittedPage() { const [isLoading, setIsLoading] = useState(true); const [reviewingSubmissionId, setReviewingSubmissionId] = useState(null); const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState(null); + const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false); const [statusMessage, setStatusMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -130,6 +136,7 @@ export default function SubmittedPage() { useEffect(() => { setSelectedPreviewFeatureId(null); + setIsJsonPopupOpen(false); }, [selectedRow?.submission.id]); const handleReview = async (action: ReviewAction) => { @@ -336,6 +343,16 @@ export default function SubmittedPage() { +
+ +
+ {selectedRow.submission.review_note ? (
Review note @@ -351,14 +368,16 @@ export default function SubmittedPage() {
@@ -399,15 +418,16 @@ export default function SubmittedPage() {
) : (
- Snapshot này không có editor_feature_collection để render bản đồ. + Submission này không có geometry renderable trong phần cần duyệt. + Nếu đây là thay đổi delete-only, hãy xem phần operation summary bên dưới.
)}

Snapshot

- - - + + +
@@ -447,6 +467,13 @@ export default function SubmittedPage() { {isReviewingSelected ? "Đang xử lý..." : "Duyệt"}
+ + setIsJsonPopupOpen(false)} + /> )}
@@ -473,6 +500,56 @@ function Info({ label, value }: { label: string; value: string }) { ); } +function JsonDataPopup({ + open, + title, + data, + onClose, + }: { + open: boolean; + title: string; + data: unknown; + onClose: () => void; +}) { + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + style={styles.jsonDialog} + > +
+
+

{title}

+

Raw data của submission đang duyệt.

+
+ +
+
{JSON.stringify(data, null, 2)}
+
+
+ ); +} + function StatusBadge({ status }: { status: SectionSubmission["status"] }) { return ( ; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const reviewGeometryIds = getReviewGeometryIds(snapshot); + const renderableGeometryIds = new Set(); + for (const item of geometries) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const source = item as Record; + const id = normalizeSnapshotId(source.id); + if (!id || !reviewGeometryIds.has(id)) continue; + if (normalizeOperationName(source.operation) === "delete") continue; + renderableGeometryIds.add(id); + } + const editorCollection = snapshot.editor_feature_collection; if (isFeatureCollection(editorCollection)) { return { type: "FeatureCollection", - features: editorCollection.features.map(cloneFeature), + features: editorCollection.features + .filter((feature) => renderableGeometryIds.has(String(feature.properties.id))) + .map(cloneFeature), }; } - const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; const features = geometries .map((item): Feature | null => { if (!item || typeof item !== "object" || Array.isArray(item)) return null; const source = item as Record; - if (source.operation === "delete") return null; - const id = source.id; + const id = normalizeSnapshotId(source.id); + if (!id || !renderableGeometryIds.has(id)) return null; const geometry = source.draw_geometry || source.geometry; if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) { return null; @@ -570,25 +660,72 @@ function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null { } const snapshot = rawSnapshot as Record; - const featureCollection = snapshot.editor_feature_collection as Record | undefined; - const features = Array.isArray(featureCollection?.features) - ? featureCollection.features - : []; const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + const reviewGeometryIds = getReviewGeometryIds(snapshot); + const reviewEntities = entities.filter((item) => + normalizeOperationName(getRecord(item)?.operation) !== "reference" + ); + const reviewGeometries = geometries.filter((item) => { + const record = getRecord(item); + if (!record) return false; + const id = normalizeSnapshotId(record.id); + return Boolean(id && reviewGeometryIds.has(id)); + }); return { - featureCount: features.length, - entityCount: entities.length, - geometryCount: geometries.length, + featureCount: extractSnapshotFeatureCollection(rawSnapshot).features.length, + entityCount: reviewEntities.length, + geometryCount: reviewGeometries.length, linkScopeCount: linkScopes.length, - entityOperations: countOperations(entities), - geometryOperations: countOperations(geometries), + entityOperations: countOperations(reviewEntities), + geometryOperations: countOperations(reviewGeometries), linkOperations: countOperations(linkScopes), }; } +function getReviewGeometryIds(snapshot: Record): Set { + const ids = new Set(); + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + + for (const item of geometries) { + const record = getRecord(item); + if (!record) continue; + const id = normalizeSnapshotId(record.id); + if (!id) continue; + const operation = normalizeOperationName(record.operation); + if (operation && operation !== "reference") { + ids.add(id); + } + } + + for (const item of linkScopes) { + const record = getRecord(item); + if (!record) continue; + const geometryId = normalizeSnapshotId(record.geometry_id); + if (geometryId) ids.add(geometryId); + } + + return ids; +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeSnapshotId(value: unknown): string | null { + if (typeof value !== "string" && typeof value !== "number") return null; + const normalized = String(value).trim(); + return normalized.length ? normalized : null; +} + +function normalizeOperationName(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + function isFeatureCollection(value: unknown): value is FeatureCollection { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const source = value as Record; @@ -928,7 +1065,7 @@ const styles: Record = { cursor: "pointer", }, submissionItemSelected: { - borderColor: "#71a66a", + border: "1px solid #71a66a", background: "#f0f8ee", }, itemTopLine: { @@ -1010,6 +1147,21 @@ const styles: Record = { lineHeight: 1.5, overflowWrap: "anywhere", }, + dataActions: { + display: "flex", + justifyContent: "flex-end", + marginBottom: "16px", + }, + secondaryButton: { + minHeight: "38px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "8px 14px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 800, + cursor: "pointer", + }, sectionHeading: { margin: "18px 0 10px", fontSize: "18px", @@ -1100,4 +1252,66 @@ const styles: Record = { fontWeight: 800, cursor: "pointer", }, + jsonOverlay: { + position: "fixed", + inset: 0, + zIndex: 1000, + padding: "24px", + background: "rgba(31, 36, 31, 0.72)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + jsonDialog: { + width: "min(980px, calc(100vw - 48px))", + maxHeight: "min(760px, calc(100vh - 48px))", + border: "1px solid #9da891", + borderRadius: "8px", + background: "#ffffff", + color: "#1f241f", + boxShadow: "0 24px 70px rgba(0, 0, 0, 0.32)", + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + jsonDialogHeader: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "16px", + padding: "14px 16px", + borderBottom: "1px solid #dfe5d6", + background: "#f7f8f3", + }, + jsonDialogTitle: { + margin: "0 0 5px", + fontSize: "18px", + overflowWrap: "anywhere", + }, + jsonDialogSubtitle: { + margin: 0, + color: "#66705f", + fontSize: "13px", + }, + jsonCloseButton: { + minHeight: "34px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "7px 12px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 800, + cursor: "pointer", + }, + jsonPre: { + margin: 0, + padding: "16px", + overflow: "auto", + background: "#10140f", + color: "#edf7e7", + fontSize: "12px", + lineHeight: 1.55, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + tabSize: 2, + }, }; diff --git a/components/CommitTreePopup.tsx b/components/CommitTreePopup.tsx new file mode 100644 index 0000000..fcb42fc --- /dev/null +++ b/components/CommitTreePopup.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useEffect, useMemo, type ComponentProps } from "react"; +import Tree from "react-d3-tree"; + +export type CommitTreeItem = { + id: string; + parent_commit_id: string | null; + restored_from_commit_id: string | null; + commit_no: number; + kind: string; + created_by: string; + created_at: string; + title: string | null; +}; + +type CommitTreeNode = { + commit: CommitTreeItem; + children: CommitTreeNode[]; +}; + +type CommitTreeDatum = { + name: string; + commit: CommitTreeItem; + isHead: boolean; + detail: string; + restoredFromLabel: string | null; + children?: CommitTreeDatum[]; +}; + +type Props = { + open: boolean; + commits: CommitTreeItem[]; + headCommitId: string | null; + onClose: () => void; +}; + +type TreeRenderNode = NonNullable["renderCustomNodeElement"]>; + +export default function CommitTreePopup({ + open, + commits, + headCommitId, + onClose, + }: Props) { + const { roots, commitById } = useMemo(() => buildCommitTree(commits), [commits]); + const treeData = useMemo( + () => roots.map((node) => toTreeDatum(node, commitById, headCommitId)), + [roots, commitById, headCommitId] + ); + + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + style={{ + width: "min(1120px, calc(100vw - 48px))", + maxHeight: "min(720px, calc(100vh - 48px))", + overflow: "hidden", + border: "1px solid #334155", + borderRadius: "8px", + background: "#0f172a", + color: "#e2e8f0", + boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)", + display: "flex", + flexDirection: "column", + }} + > + +
+
+
+ Commit tree +
+
+ {commits.length} commit{commits.length === 1 ? "" : "s"} +
+
+ +
+ +
+ {treeData.length === 0 ? ( +
+ Chưa có commit. +
+ ) : ( +
+ "commit-tree-link"} + /> +
+ )} +
+
+
+ ); +} + +const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) { + const datum = nodeDatum as unknown as CommitTreeDatum; + const commit = datum.commit; + const isHead = datum.isHead; + + return ( + + + +
+
+ + #{commit.commit_no} + + {isHead ? ( + + HEAD + + ) : null} +
+
+ {formatCommitTitle(commit)} +
+
+ {datum.detail} +
+ {datum.restoredFromLabel ? ( +
+ {datum.restoredFromLabel} +
+ ) : null} +
+
+
+ ); +}; + +function buildCommitTree(commits: CommitTreeItem[]) { + const commitById = new Map(); + const nodeById = new Map(); + + for (const commit of commits) { + commitById.set(commit.id, commit); + nodeById.set(commit.id, { commit, children: [] }); + } + + const roots: CommitTreeNode[] = []; + for (const node of nodeById.values()) { + const parentId = getDisplayParentCommitId(node.commit); + const parent = parentId ? nodeById.get(parentId) : null; + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } + + const sortNodes = (nodes: CommitTreeNode[]) => { + nodes.sort((a, b) => a.commit.commit_no - b.commit.commit_no); + for (const node of nodes) { + sortNodes(node.children); + } + }; + sortNodes(roots); + + return { roots, commitById }; +} + +function toTreeDatum( + node: CommitTreeNode, + commitById: Map, + headCommitId: string | null +): CommitTreeDatum { + const commit = node.commit; + const restoredFromCommit = commit.restored_from_commit_id + ? commitById.get(commit.restored_from_commit_id) || null + : null; + const children = node.children.map((child) => toTreeDatum(child, commitById, headCommitId)); + + return { + name: formatCommitTitle(commit), + commit, + isHead: headCommitId === commit.id, + detail: `${commit.kind} by ${commit.created_by} - ${formatDateTime(commit.created_at)}`, + restoredFromLabel: restoredFromCommit + ? `restored from #${restoredFromCommit.commit_no} ${formatCommitTitle(restoredFromCommit)}` + : null, + children: children.length ? children : undefined, + }; +} + +function getDisplayParentCommitId(commit: CommitTreeItem): string | null { + if (commit.kind === "restore" && commit.restored_from_commit_id) { + return commit.restored_from_commit_id; + } + return commit.parent_commit_id; +} + +function formatCommitTitle(commit: CommitTreeItem): string { + return commit.title?.trim() || `Commit #${commit.commit_no}`; +} + +function formatDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} diff --git a/components/Editor.tsx b/components/Editor.tsx index deb63de..e221aa3 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; +import CommitTreePopup from "@/components/CommitTreePopup"; import { UndoAction } from "@/lib/useEditorState"; type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle"; @@ -37,11 +39,16 @@ type Props = { onOpenSection: () => void; onCreateSection: () => void; commitCount: number; + hasHeadCommit: boolean; + headCommitId: string | null; latestCommitLabel: string | null; commits: Array<{ id: string; + parent_commit_id: string | null; + restored_from_commit_id: string | null; commit_no: number; kind: string; + created_by: string; created_at: string; title: string | null; }>; @@ -87,6 +94,8 @@ export default function Editor({ onOpenSection, onCreateSection, commitCount, + hasHeadCommit, + headCommitId, latestCommitLabel, commits, changesCount, @@ -94,6 +103,11 @@ export default function Editor({ createdEntities, createdGeometries, }: Props) { + const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false); + + const formatCommitTitle = (commit: Props["commits"][number]) => + commit.title?.trim() || `Commit #${commit.commit_no}`; + const toggleMode = (newMode: Mode) => { if (mode === newMode) { setMode("idle"); // bấm lại → tắt @@ -393,6 +407,40 @@ export default function Editor({ fontFamily: "inherit", }} /> +
+ + +
- @@ -454,7 +486,17 @@ export default function Editor({ color: "#e2e8f0", }} > -
+
+ {formatCommitTitle(commit)} +
+
#{commit.commit_no} {commit.kind}
+ + setIsCommitTreeOpen(false)} + /> ); } diff --git a/components/Map.tsx b/components/Map.tsx index afe99de..aafe2b6 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -12,7 +12,7 @@ import { initLine } from "@/lib/lineEngine"; import { initPath } from "@/lib/pathEngine"; import { initCircle } from "@/lib/circleEngine"; import { createEditingEngine } from "@/lib/editingEngine"; -import { FeatureCollection, Geometry } from "@/lib/useEditorState"; +import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers"; type MapProps = { @@ -27,6 +27,8 @@ type MapProps = { allowGeometryEditing?: boolean; respectBindingFilter?: boolean; height?: CSSProperties["height"]; + fitToDraftBounds?: boolean; + fitBoundsKey?: string | number | null; }; const DEFAULT_POINT_ICON_ID = "point-icon-default"; @@ -37,6 +39,12 @@ const MAP_MAX_ZOOM = 10; const RASTER_BASE_SOURCE_ID = "rasterBase"; const RASTER_BASE_LAYER_ID = "raster-base-layer"; const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line"; +const PATH_ARROW_SOURCE_ID = "path-arrow-shapes"; +const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const; +const EMPTY_FEATURE_COLLECTION: FeatureCollection = { + type: "FeatureCollection", + features: [], +}; const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [ "coalesce", ["get", "MAPCOLOR7"], @@ -122,6 +130,8 @@ export default function Map({ allowGeometryEditing = true, respectBindingFilter = true, height = "100vh", + fitToDraftBounds = false, + fitBoundsKey = null, }: MapProps) { const mapRef = useRef(null); const modeRef = useRef(mode); @@ -136,6 +146,8 @@ export default function Map({ const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); const editingEngineRef = useRef | null>(null); + const fitBoundsAppliedRef = useRef(false); + const mapCleanupFnsRef = useRef void>>([]); useEffect(() => { modeRef.current = mode; @@ -172,6 +184,10 @@ export default function Map({ selectedFeatureIdRef.current = selectedFeatureId; }, [selectedFeatureId]); + useEffect(() => { + fitBoundsAppliedRef.current = false; + }, [fitBoundsKey]); + useEffect(() => { onSelectFeatureIdRef.current = onSelectFeatureId; }, [onSelectFeatureId]); @@ -218,16 +234,33 @@ export default function Map({ if (!countriesSource || !placesSource) return; // clear all feature-state (selection) to prevent ghost layers after undo - map.removeFeatureState({ source: "countries" }); + for (const sourceId of FEATURE_STATE_SOURCE_IDS) { + if (map.getSource(sourceId)) { + map.removeFeatureState({ source: sourceId }); + } + } const visibleDraft = respectBindingFilter ? filterDraftByBinding(fc, selectedFeatureIdRef.current) : fc; const { polygons, points } = splitDraftFeatures(visibleDraft); + const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft); countriesSource.setData(polygons); placesSource.setData(points); - }, [respectBindingFilter]); + (map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined) + ?.setData(pathArrowShapes); + + const selectedId = selectedFeatureIdRef.current; + setSelectedFeatureState(map, selectedId, true); + requestAnimationFrame(() => { + if (mapRef.current !== map) return; + setSelectedFeatureState(map, selectedId, true); + }); + if (fitToDraftBounds && !fitBoundsAppliedRef.current) { + fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft); + } + }, [fitToDraftBounds, respectBindingFilter]); useEffect(() => { const map = new maplibregl.Map({ @@ -510,6 +543,12 @@ export default function Map({ }); + map.addSource(PATH_ARROW_SOURCE_ID, { + type: "geojson", + data: EMPTY_FEATURE_COLLECTION, + promoteId: "id", + }); + map.addLayer({ id: "countries-fill", type: "fill", @@ -557,7 +596,11 @@ export default function Map({ id: "routes-line", type: "line", source: "countries", - filter: ["==", ["geometry-type"], "LineString"], + filter: [ + "all", + ["==", ["geometry-type"], "LineString"], + ["!=", buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false), true], + ], paint: { "line-color": [ "case", @@ -579,26 +622,73 @@ export default function Map({ }, }); - if (hasPathArrowIcon) { - map.addLayer({ - id: "routes-arrow", - type: "symbol", - source: "countries", - filter: [ - "all", - ["==", ["geometry-type"], "LineString"], - buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false), + map.addLayer({ + id: "routes-path-arrow-fill", + type: "fill", + source: PATH_ARROW_SOURCE_ID, + paint: { + "fill-color": [ + "case", + ["boolean", ["feature-state", "selected"], false], + "#22c55e", + ["==", ["coalesce", ["get", "entity_id"], ""], ""], + "#ef4444", + buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"), ], - layout: { - "symbol-placement": "line", - "symbol-spacing": 60, - "icon-image": PATH_ARROW_ICON_ID, - "icon-size": 0.5, - "icon-allow-overlap": true, - "icon-ignore-placement": true, - }, - }); - } + "fill-opacity": [ + "case", + ["boolean", ["feature-state", "selected"], false], + 0.92, + 0.82, + ], + }, + }); + + map.addLayer({ + id: "routes-path-arrow-line", + type: "line", + source: PATH_ARROW_SOURCE_ID, + paint: { + "line-color": [ + "case", + ["boolean", ["feature-state", "selected"], false], + "#14532d", + "#0f172a", + ], + "line-width": [ + "interpolate", + ["linear"], + ["zoom"], + 1, 0.45, + 4, 0.8, + 6, 1.2, + ], + "line-opacity": 0.9, + }, + }); + + map.addLayer({ + id: "routes-path-hit", + type: "line", + source: "countries", + filter: [ + "all", + ["==", ["geometry-type"], "LineString"], + buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false), + ], + paint: { + "line-color": "#ffffff", + "line-width": [ + "interpolate", + ["linear"], + ["zoom"], + 1, 12, + 4, 18, + 6, 24, + ], + "line-opacity": 0, + }, + }); map.addSource("places", { type: "geojson", @@ -606,6 +696,7 @@ export default function Map({ type: "FeatureCollection", features: [], }, + promoteId: "id", }); // editing overlays @@ -646,11 +737,54 @@ export default function Map({ type: "circle", source: "places", paint: { - "circle-color": "#ef4444", - "circle-radius": 4, - "circle-stroke-color": "#ffffff", - "circle-stroke-width": 1, - "circle-opacity": 0.85, + "circle-color": [ + "case", + ["boolean", ["feature-state", "selected"], false], + "#22c55e", + "#ef4444", + ], + "circle-radius": [ + "case", + ["boolean", ["feature-state", "selected"], false], + 8, + 4, + ], + "circle-stroke-color": [ + "case", + ["boolean", ["feature-state", "selected"], false], + "#14532d", + "#ffffff", + ], + "circle-stroke-width": [ + "case", + ["boolean", ["feature-state", "selected"], false], + 3, + 1, + ], + "circle-opacity": 0.9, + }, + }); + + map.addLayer({ + id: "places-selected-halo", + type: "circle", + source: "places", + paint: { + "circle-color": "#22c55e", + "circle-radius": 13, + "circle-opacity": [ + "case", + ["boolean", ["feature-state", "selected"], false], + 0.28, + 0, + ], + "circle-stroke-color": "#14532d", + "circle-stroke-width": [ + "case", + ["boolean", ["feature-state", "selected"], false], + 2, + 0, + ], }, }); @@ -784,15 +918,15 @@ export default function Map({ } ); - map.on("remove", cleanupCircle); - map.on("remove", cleanupPath); - map.on("remove", cleanupLine); - map.on("remove", cleanupPoint); - - map.on("remove", cleanupSelect); - - map.on("remove", cleanup); - map.on("remove", () => map.off("zoom", syncZoomLevel)); + mapCleanupFnsRef.current = [ + cleanupCircle, + cleanupPath, + cleanupLine, + cleanupPoint, + cleanupSelect, + cleanup, + () => map.off("zoom", syncZoomLevel), + ]; // after everything mounted, push current draft to sources applyDraftToMap(draftRef.current); @@ -803,6 +937,10 @@ export default function Map({ }); return () => { + for (const cleanupFn of mapCleanupFnsRef.current) { + cleanupFn(); + } + mapCleanupFnsRef.current = []; if (mapRef.current === map) { mapRef.current = null; } @@ -1041,6 +1179,291 @@ function splitDraftFeatures(fc: FeatureCollection) { return { polygons, points }; } +function setSelectedFeatureState( + map: maplibregl.Map, + id: string | number | null, + selected: boolean +) { + if (id === null) return; + for (const sourceId of FEATURE_STATE_SOURCE_IDS) { + if (!map.getSource(sourceId)) continue; + map.setFeatureState({ source: sourceId, id }, { selected }); + } +} + +function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection): boolean { + const bbox = getFeatureCollectionBBox(fc); + if (!bbox) return false; + + const lngSpan = Math.abs(bbox.maxLng - bbox.minLng); + const latSpan = Math.abs(bbox.maxLat - bbox.minLat); + if (lngSpan < 0.000001 && latSpan < 0.000001) { + map.easeTo({ + center: [bbox.minLng, bbox.minLat], + zoom: 6, + duration: 0, + }); + return true; + } + + map.fitBounds( + [ + [bbox.minLng, bbox.minLat], + [bbox.maxLng, bbox.maxLat], + ], + { + padding: 58, + maxZoom: 7, + duration: 0, + } + ); + return true; +} + +function getFeatureCollectionBBox( + fc: FeatureCollection +): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null { + const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates)); + if (!points.length) return null; + + let minLng = Number.POSITIVE_INFINITY; + let minLat = Number.POSITIVE_INFINITY; + let maxLng = Number.NEGATIVE_INFINITY; + let maxLat = Number.NEGATIVE_INFINITY; + + for (const [lng, lat] of points) { + minLng = Math.min(minLng, lng); + minLat = Math.min(minLat, lat); + maxLng = Math.max(maxLng, lng); + maxLat = Math.max(maxLat, lat); + } + + return { minLng, minLat, maxLng, maxLat }; +} + +function collectCoordinatePairs(value: unknown): Array<[number, number]> { + if (!Array.isArray(value)) return []; + if ( + value.length >= 2 && + typeof value[0] === "number" && + typeof value[1] === "number" && + Number.isFinite(value[0]) && + Number.isFinite(value[1]) + ) { + return [[value[0], value[1]]]; + } + return value.flatMap((item) => collectCoordinatePairs(item)); +} + +function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection { + const features = fc.features + .map((feature) => { + if (!isPathFeature(feature) || feature.geometry.type !== "LineString") return null; + const geometry = buildPathArrowGeometry(feature.geometry.coordinates); + if (!geometry) return null; + return { + type: "Feature" as const, + properties: { ...feature.properties }, + geometry, + }; + }) + .filter((feature): feature is Feature => feature !== null); + + return { + type: "FeatureCollection", + features, + }; +} + +function isPathFeature(feature: Feature): boolean { + const featureType = getFeatureSemanticType(feature); + return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]); +} + +function getFeatureSemanticType(feature: Feature): string | null { + const value = feature.properties.type || feature.properties.entity_type_id || null; + if (!value) return null; + const normalized = String(value).trim().toLowerCase(); + return normalized.length ? normalized : null; +} + +function buildPathArrowGeometry(coords: [number, number][]): Geometry | null { + const sourceCoords = removeDuplicatePathCoords(coords); + if (sourceCoords.length < 2) return null; + + const origin = sourceCoords[0]; + const originLatRad = toRadians(origin[1]); + const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001); + const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat)); + const measured = buildMeasuredPath(projected); + const totalLength = measured[measured.length - 1]?.distance || 0; + if (totalLength <= 0) return null; + + const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45); + const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35); + const bodyPoints = measured + .filter((point) => point.distance < bodyEndDistance) + .map(({ x, y, distance }) => ({ x, y, distance })); + bodyPoints.push(pointAtDistance(measured, bodyEndDistance)); + + if (bodyPoints.length < 2) return null; + + const tailWidth = clampNumber(totalLength * 0.018, 25000, 140000); + const shoulderWidth = clampNumber(totalLength * 0.055, 60000, 420000); + const headWidth = shoulderWidth * 1.65; + + const leftBody: ProjectedPoint[] = []; + const rightBody: ProjectedPoint[] = []; + + for (let i = 0; i < bodyPoints.length; i += 1) { + const point = bodyPoints[i]; + const normal = normalAt(bodyPoints, i); + const progress = bodyEndDistance > 0 + ? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9) + : 0; + const width = tailWidth + (shoulderWidth - tailWidth) * progress; + const half = width / 2; + leftBody.push({ + x: point.x + normal.x * half, + y: point.y + normal.y * half, + }); + rightBody.push({ + x: point.x - normal.x * half, + y: point.y - normal.y * half, + }); + } + + const base = bodyPoints[bodyPoints.length - 1]; + const tip = pointAtDistance(measured, totalLength); + const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1); + const headHalf = headWidth / 2; + const headBaseLeft = { + x: base.x + headNormal.x * headHalf, + y: base.y + headNormal.y * headHalf, + }; + const headBaseRight = { + x: base.x - headNormal.x * headHalf, + y: base.y - headNormal.y * headHalf, + }; + + const ring = [ + ...leftBody, + headBaseLeft, + { x: tip.x, y: tip.y }, + headBaseRight, + ...rightBody.reverse(), + leftBody[0], + ].map((point) => unprojectLngLat(point, origin, cosOriginLat)); + + if (ring.length < 4) return null; + return { + type: "Polygon", + coordinates: [ring], + }; +} + +type ProjectedPoint = { + x: number; + y: number; +}; + +type MeasuredPoint = ProjectedPoint & { + distance: number; +}; + +function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] { + const result: [number, number][] = []; + for (const coord of coords) { + const last = result[result.length - 1]; + if (last && last[0] === coord[0] && last[1] === coord[1]) continue; + result.push(coord); + } + return result; +} + +function projectLngLat( + coord: [number, number], + origin: [number, number], + cosOriginLat: number +): ProjectedPoint { + const earthRadiusMeters = 6371008.8; + return { + x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat, + y: toRadians(coord[1] - origin[1]) * earthRadiusMeters, + }; +} + +function unprojectLngLat( + point: ProjectedPoint, + origin: [number, number], + cosOriginLat: number +): [number, number] { + const earthRadiusMeters = 6371008.8; + return [ + origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)), + origin[1] + toDegrees(point.y / earthRadiusMeters), + ]; +} + +function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] { + let distance = 0; + return points.map((point, index) => { + if (index > 0) { + distance += distanceProjected(points[index - 1], point); + } + return { + ...point, + distance, + }; + }); +} + +function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint { + if (targetDistance <= 0) return points[0]; + for (let i = 1; i < points.length; i += 1) { + const prev = points[i - 1]; + const next = points[i]; + if (targetDistance > next.distance) continue; + const segmentLength = next.distance - prev.distance; + const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0; + return { + x: prev.x + (next.x - prev.x) * t, + y: prev.y + (next.y - prev.y) * t, + distance: targetDistance, + }; + } + return points[points.length - 1]; +} + +function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint { + const prev = points[Math.max(0, index - 1)]; + const next = points[Math.min(points.length - 1, index + 1)]; + return normalFromSegment(prev, next) || { x: 0, y: 1 }; +} + +function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null { + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy); + if (length <= 0) return null; + return { + x: -dy / length, + y: dx / length, + }; +} + +function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +function toRadians(value: number): number { + return (value * Math.PI) / 180; +} + +function toDegrees(value: number): number { + return (value * 180) / Math.PI; +} + function ensurePathArrowIcon(map: maplibregl.Map): boolean { if (map.hasImage(PATH_ARROW_ICON_ID)) return true; const imageData = createPathArrowImageData(); diff --git a/lib/selectingEngine.ts b/lib/selectingEngine.ts index 11772da..789c4f6 100644 --- a/lib/selectingEngine.ts +++ b/lib/selectingEngine.ts @@ -14,9 +14,17 @@ export function initSelect( "countries-fill", "countries-line", "routes-line", + "routes-path-arrow-fill", + "routes-path-arrow-line", + "routes-path-hit", "places-circle", "places-symbol", ] as const; + const FEATURE_STATE_SOURCES = [ + "countries", + "places", + "path-arrow-shapes", + ] as const; const selectedIds = new Set(); const hasContextActions = Boolean(onDelete || onEdit); let contextMenu: HTMLDivElement | null = null; @@ -25,9 +33,7 @@ export function initSelect( // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn. function clearSelection() { if (!selectedIds.size) return; - selectedIds.forEach((id) => { - map.setFeatureState({ source: "countries", id }, { selected: false }); - }); + selectedIds.forEach((id) => setSelectionStateForId(id, false)); selectedIds.clear(); onSelectId?.(null); } @@ -43,13 +49,13 @@ export function initSelect( if (additive && selectedIds.has(id)) { // Alt + click on an already selected feature removes it from the selection - map.setFeatureState({ source: "countries", id }, { selected: false }); + setSelectionStateForId(id, false); selectedIds.delete(id); onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null); return; } - map.setFeatureState({ source: "countries", id }, { selected: true }); + setSelectionStateForId(id, true); selectedIds.add(id); onSelectId?.(selectedIds.size === 1 ? id : null); } @@ -125,6 +131,13 @@ export function initSelect( return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId))); } + function setSelectionStateForId(id: string | number, selected: boolean) { + for (const source of FEATURE_STATE_SOURCES) { + if (!map.getSource(source)) continue; + map.setFeatureState({ source, id }, { selected }); + } + } + map.on("click", onClick); map.on("mousemove", onMove); if (hasContextActions) { @@ -192,7 +205,12 @@ export function initSelect( const selectedCount = selectedIds.size || 1; let hasMenuItems = false; - if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon" && onEdit) { + if ( + selectedCount === 1 && + clickedFeature.source === "countries" && + clickedFeature.geometry?.type === "Polygon" && + onEdit + ) { const single = clickedFeature; menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); hasMenuItems = true; diff --git a/package-lock.json b/package-lock.json index 694b37d..7076a63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "maplibre-gl": "^5.20.2", "next": "16.1.7", "react": "19.2.3", + "react-d3-tree": "^3.6.6", "react-dom": "19.2.3" }, "devDependencies": { @@ -230,6 +231,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -278,6 +288,24 @@ "node": ">=6.9.0" } }, + "node_modules/@bkrem/react-transition-group": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@bkrem/react-transition-group/-/react-transition-group-1.3.5.tgz", + "integrity": "sha512-lbBYhC42sxAeFEopxzd9oWdkkV0zirO5E9WyeOBxOrpXsf7m30Aj8vnbayZxFOwD9pvUQ2Pheb1gO79s0Qap3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "chain-function": "^1.0.0", + "dom-helpers": "^3.3.1", + "loose-envify": "^1.3.1", + "prop-types": "^15.5.6", + "react-lifecycles-compat": "^3.0.4", + "warning": "^3.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -1631,6 +1659,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-hierarchy": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz", + "integrity": "sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2708,6 +2742,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chain-function": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.1.tgz", + "integrity": "sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg==", + "license": "MIT" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2731,6 +2771,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2787,6 +2836,133 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2909,6 +3085,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2932,6 +3117,15 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4579,7 +4773,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5002,7 +5195,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -5286,7 +5478,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5603,7 +5794,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5664,6 +5854,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-d3-tree": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/react-d3-tree/-/react-d3-tree-3.6.6.tgz", + "integrity": "sha512-E9ByUdeqvlxLlF9BSL7KWQH3ikYHtHO+g1rAPcVgj6mu92tjRUCan2AWxoD4eTSzzAATf8BZtf+CXGSoSd6ioQ==", + "license": "MIT", + "dependencies": { + "@bkrem/react-transition-group": "^1.3.5", + "@types/d3-hierarchy": "^1.1.8", + "clone": "^2.1.1", + "d3-hierarchy": "^1.1.9", + "d3-selection": "^3.0.0", + "d3-shape": "^1.3.7", + "d3-zoom": "^3.0.0", + "dequal": "^2.0.2", + "uuid": "^8.3.1" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x", + "react-dom": "16.x || 17.x || 18.x || 19.x" + } + }, "node_modules/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", @@ -5681,7 +5892,12 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, "node_modules/reflect.getprototypeof": { @@ -6686,6 +6902,24 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", + "integrity": "sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 5c07c21..a769269 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "maplibre-gl": "^5.20.2", "next": "16.1.7", "react": "19.2.3", + "react-d3-tree": "^3.6.6", "react-dom": "19.2.3" }, "devDependencies": {