demo 20-4-2026
This commit is contained in:
57
api/http.ts
57
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 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.
|
||||
</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,
|
||||
},
|
||||
};
|
||||
|
||||
349
components/CommitTreePopup.tsx
Normal file
349
components/CommitTreePopup.tsx
Normal 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 có 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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
244
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user