demo 20-4-2026

This commit is contained in:
taDuc
2026-04-19 23:43:31 +07:00
parent 57a7843d80
commit 2508172489
10 changed files with 1443 additions and 98 deletions

View File

@@ -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<T> = {
status: "success" | "error" | string;
data: T;
message: string;
errors: unknown[];
};
export async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const res = await fetch(input, init);
const payload = await parseJsonResponse(res);
const envelope = isApiEnvelope<T>(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<unknown> {
const text = await res.text();
if (!text.length) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
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);
}
}

View File

@@ -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<SectionSubmission> {
return requestJson<SectionSubmission>(sectionUrl(sectionId, "submit"), jsonRequestInit("POST", input));
}

View File

@@ -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";

View File

@@ -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<SectionSubmission["status"], string> = {
pending: "Chờ duyệt",
@@ -54,6 +59,7 @@ export default function SubmittedPage() {
const [isLoading, setIsLoading] = useState(true);
const [reviewingSubmissionId, setReviewingSubmissionId] = useState<string | null>(null);
const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState<string | number | null>(null);
const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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() {
<Info label="Reviewed at" value={formatDateTime(selectedRow.submission.reviewed_at)} />
</div>
<div style={styles.dataActions}>
<button
type="button"
onClick={() => setIsJsonPopupOpen(true)}
style={styles.secondaryButton}
>
View commit JSON
</button>
</div>
{selectedRow.submission.review_note ? (
<div style={styles.noteBox}>
<strong>Review note</strong>
@@ -351,14 +368,16 @@ export default function SubmittedPage() {
<div style={styles.mapPreview}>
<Map
key={selectedRow.submission.id}
mode="idle"
mode="select"
draft={selectedFeatureCollection}
backgroundVisibility={DEFAULT_BACKGROUND_LAYER_VISIBILITY}
backgroundVisibility={REVIEW_BACKGROUND_LAYER_VISIBILITY}
selectedFeatureId={selectedPreviewFeatureId}
onSelectFeatureId={setSelectedPreviewFeatureId}
allowGeometryEditing={false}
respectBindingFilter={false}
height="420px"
fitToDraftBounds
fitBoundsKey={selectedRow.submission.id}
/>
</div>
<div style={styles.featurePanel}>
@@ -399,15 +418,16 @@ export default function SubmittedPage() {
</div>
) : (
<div style={styles.emptyState}>
Snapshot này không editor_feature_collection đ render bản đ.
Submission này không geometry renderable trong phần cần duyệt.
Nếu đây thay đi delete-only, hãy xem phần operation summary bên dưới.
</div>
)}
<h3 style={styles.sectionHeading}>Snapshot</h3>
<div style={styles.summaryGrid}>
<Metric label="Editor features" value={selectedSummary.featureCount} />
<Metric label="Entities" value={selectedSummary.entityCount} />
<Metric label="Geometries" value={selectedSummary.geometryCount} />
<Metric label="Preview features" value={selectedSummary.featureCount} />
<Metric label="Entities in review" value={selectedSummary.entityCount} />
<Metric label="Geometries in review" value={selectedSummary.geometryCount} />
<Metric label="Link scopes" value={selectedSummary.linkScopeCount} />
</div>
<div style={styles.operationGrid}>
@@ -447,6 +467,13 @@ export default function SubmittedPage() {
{isReviewingSelected ? "Đang xử lý..." : "Duyệt"}
</button>
</div>
<JsonDataPopup
open={isJsonPopupOpen}
title={`Commit ${selectedRow.submission.commit_id}`}
data={selectedRow.submission.snapshot ?? null}
onClose={() => setIsJsonPopupOpen(false)}
/>
</>
)}
</div>
@@ -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 (
<div role="presentation" onClick={onClose} style={styles.jsonOverlay}>
<section
role="dialog"
aria-modal="true"
aria-label="Commit JSON data"
onClick={(event) => event.stopPropagation()}
style={styles.jsonDialog}
>
<div style={styles.jsonDialogHeader}>
<div>
<h3 style={styles.jsonDialogTitle}>{title}</h3>
<p style={styles.jsonDialogSubtitle}>Raw data của submission đang duyệt.</p>
</div>
<button type="button" onClick={onClose} style={styles.jsonCloseButton}>
Close
</button>
</div>
<pre style={styles.jsonPre}>{JSON.stringify(data, null, 2)}</pre>
</section>
</div>
);
}
function StatusBadge({ status }: { status: SectionSubmission["status"] }) {
return (
<span
@@ -518,22 +595,35 @@ function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollecti
}
const snapshot = rawSnapshot as Record<string, unknown>;
const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
const reviewGeometryIds = getReviewGeometryIds(snapshot);
const renderableGeometryIds = new Set<string>();
for (const item of geometries) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const source = item as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const featureCollection = snapshot.editor_feature_collection as Record<string, unknown> | 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<string, unknown>): Set<string> {
const ids = new Set<string>();
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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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<string, unknown>;
@@ -928,7 +1065,7 @@ const styles: Record<string, CSSProperties> = {
cursor: "pointer",
},
submissionItemSelected: {
borderColor: "#71a66a",
border: "1px solid #71a66a",
background: "#f0f8ee",
},
itemTopLine: {
@@ -1010,6 +1147,21 @@ const styles: Record<string, CSSProperties> = {
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<string, CSSProperties> = {
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,
},
};

View File

@@ -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<ComponentProps<typeof Tree>["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 (
<div
role="presentation"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(2, 6, 23, 0.72)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="Commit tree"
onClick={(event) => 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",
}}
>
<style>
{`
.commit-tree-link {
fill: none;
stroke: #ffffff;
stroke-width: 4px;
stroke-opacity: 1;
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.75));
}
`}
</style>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "14px 16px",
borderBottom: "1px solid #1f2937",
}}
>
<div>
<div style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
Commit tree
</div>
<div style={{ marginTop: "3px", fontSize: "12px", color: "#94a3b8" }}>
{commits.length} commit{commits.length === 1 ? "" : "s"}
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
padding: "7px 10px",
border: "1px solid #475569",
borderRadius: "4px",
background: "#111827",
color: "#f8fafc",
cursor: "pointer",
}}
>
Close
</button>
</div>
<div
style={{
padding: "16px",
overflow: "auto",
}}
>
{treeData.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "14px" }}>
Chưa commit.
</div>
) : (
<div
style={{
width: "100%",
minWidth: "640px",
height: "540px",
border: "1px solid #64748b",
borderRadius: "6px",
background: "#111827",
overflow: "hidden",
}}
>
<Tree
data={treeData}
orientation="vertical"
translate={{ x: 520, y: 56 }}
nodeSize={{ x: 300, y: 165 }}
separation={{ siblings: 1.15, nonSiblings: 1.45 }}
pathFunc="step"
collapsible={false}
zoomable
draggable
scaleExtent={{ min: 0.45, max: 1.4 }}
renderCustomNodeElement={renderCommitTreeNode}
pathClassFunc={() => "commit-tree-link"}
/>
</div>
)}
</div>
</section>
</div>
);
}
const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) {
const datum = nodeDatum as unknown as CommitTreeDatum;
const commit = datum.commit;
const isHead = datum.isHead;
return (
<g>
<circle
r={8}
fill={isHead ? "#16a34a" : "#111827"}
stroke={isHead ? "#bbf7d0" : "#f8fafc"}
strokeWidth={3}
/>
<foreignObject x={-115} y={18} width={230} height={96}>
<div
style={{
width: "220px",
minHeight: "78px",
padding: "8px 9px",
border: isHead ? "2px solid #86efac" : "2px solid #e2e8f0",
borderRadius: "6px",
background: isHead ? "#14532d" : "#1f2937",
color: "#f8fafc",
fontSize: "12px",
lineHeight: 1.35,
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
minWidth: 0,
}}
>
<span style={{ color: "#f8fafc", fontWeight: 700 }}>
#{commit.commit_no}
</span>
{isHead ? (
<span
style={{
padding: "1px 5px",
border: "1px solid #22c55e",
borderRadius: "4px",
color: "#bbf7d0",
fontSize: "10px",
fontWeight: 700,
}}
>
HEAD
</span>
) : null}
</div>
<div
title={formatCommitTitle(commit)}
style={{
marginTop: "4px",
color: "#f8fafc",
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: "4px", color: "#94a3b8" }}>
{datum.detail}
</div>
{datum.restoredFromLabel ? (
<div
title={datum.restoredFromLabel}
style={{
marginTop: "3px",
color: "#93c5fd",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{datum.restoredFromLabel}
</div>
) : null}
</div>
</foreignObject>
</g>
);
};
function buildCommitTree(commits: CommitTreeItem[]) {
const commitById = new Map<string, CommitTreeItem>();
const nodeById = new Map<string, CommitTreeNode>();
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<string, CommitTreeItem>,
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();
}

View File

@@ -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,10 +407,10 @@ export default function Editor({
fontFamily: "inherit",
}}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 68px", gap: "8px", marginTop: "8px" }}>
<button
style={{
width: "100%",
marginTop: "8px",
padding: "8px",
borderRadius: "4px",
border: "none",
@@ -409,6 +423,24 @@ export default function Editor({
>
Commit ({changesCount})
</button>
<button
type="button"
style={{
width: "100%",
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: commitCount === 0 ? "not-allowed" : "pointer",
background: commitCount === 0 ? "#555" : "#334155",
color: "white",
opacity: commitCount === 0 ? 0.6 : 1,
}}
onClick={() => setIsCommitTreeOpen(true)}
disabled={commitCount === 0}
>
View
</button>
</div>
<button
style={{
width: "100%",
@@ -416,13 +448,13 @@ export default function Editor({
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: isSubmitting || commitCount === 0 || sectionStatus === "submitted" ? "not-allowed" : "pointer",
background: isSubmitting || commitCount === 0 || sectionStatus === "submitted" ? "#555" : "#16a34a",
cursor: isSubmitting || !hasHeadCommit || sectionStatus === "submitted" ? "not-allowed" : "pointer",
background: isSubmitting || !hasHeadCommit || sectionStatus === "submitted" ? "#555" : "#16a34a",
color: "white",
opacity: commitCount === 0 ? 0.6 : 1,
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={onSubmit}
disabled={isSubmitting || commitCount === 0 || sectionStatus === "submitted"}
disabled={isSubmitting || !hasHeadCommit || sectionStatus === "submitted"}
>
Submit
</button>
@@ -454,7 +486,17 @@ export default function Editor({
color: "#e2e8f0",
}}
>
<div>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 600,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: "2px", color: "#94a3b8" }}>
#{commit.commit_no} {commit.kind}
</div>
<button
@@ -566,6 +608,13 @@ export default function Editor({
</ul>
)}
</div>
<CommitTreePopup
open={isCommitTreeOpen}
commits={commits}
headCommitId={headCommitId}
onClose={() => setIsCommitTreeOpen(false)}
/>
</div>
);
}

View File

@@ -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<maplibregl.Map | null>(null);
const modeRef = useRef<MapProps["mode"]>(mode);
@@ -136,6 +146,8 @@ export default function Map({
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const fitBoundsAppliedRef = useRef(false);
const mapCleanupFnsRef = useRef<Array<() => 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",
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"),
],
"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),
],
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,
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();

View File

@@ -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<number | string>();
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;

244
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {