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 {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
body: string;
|
body: string;
|
||||||
|
errors: unknown[];
|
||||||
|
|
||||||
constructor(message: string, status: number, body: string) {
|
constructor(message: string, status: number, body: string, errors: unknown[] = []) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiError";
|
this.name = "ApiError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.body = body;
|
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> {
|
export async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(input, init);
|
const res = await fetch(input, init);
|
||||||
|
const payload = await parseJsonResponse(res);
|
||||||
|
const envelope = isApiEnvelope<T>(payload) ? payload : null;
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const message = envelope?.message || `Request failed with status ${res.status}`;
|
||||||
throw new ApiError(`Request failed with status ${res.status}`, res.status, text);
|
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 {
|
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
||||||
@@ -28,3 +47,33 @@ export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
|||||||
body: JSON.stringify(body),
|
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(
|
export async function submitSection(
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
input: { commit_id?: string; submitted_by?: string; user_id?: string }
|
input: { submitted_by?: string; user_id?: string }
|
||||||
): Promise<SectionSubmission> {
|
): Promise<SectionSubmission> {
|
||||||
return requestJson<SectionSubmission>(sectionUrl(sectionId, "submit"), jsonRequestInit("POST", input));
|
return requestJson<SectionSubmission>(sectionUrl(sectionId, "submit"), jsonRequestInit("POST", input));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const handleSubmitSection = async () => {
|
const handleSubmitSection = async () => {
|
||||||
if (!activeSection || !sectionState?.head_commit_id) {
|
if (!activeSection || !sectionState?.head_commit_id) {
|
||||||
setEntityStatus("Chưa có commit để submit.");
|
setEntityStatus("Section hiện tại chưa có head để submit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pendingSaveCount > 0) {
|
if (pendingSaveCount > 0) {
|
||||||
@@ -946,7 +946,6 @@ export default function Page() {
|
|||||||
try {
|
try {
|
||||||
const submission = await submitSection(activeSection.id, {
|
const submission = await submitSection(activeSection.id, {
|
||||||
submitted_by: editorUserId,
|
submitted_by: editorUserId,
|
||||||
commit_id: sectionState.head_commit_id,
|
|
||||||
});
|
});
|
||||||
setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
|
setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
|
||||||
setEntityStatus(`Đã submit section, submission ${submission.id}.`);
|
setEntityStatus(`Đã submit section, submission ${submission.id}.`);
|
||||||
@@ -1001,6 +1000,9 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
|
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 timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
|
||||||
const timelineStatusText =
|
const timelineStatusText =
|
||||||
pendingSaveCount > 0
|
pendingSaveCount > 0
|
||||||
@@ -1043,7 +1045,9 @@ export default function Page() {
|
|||||||
onOpenSection={handleOpenSelectedSection}
|
onOpenSection={handleOpenSelectedSection}
|
||||||
onCreateSection={handleCreateAndOpenSection}
|
onCreateSection={handleCreateAndOpenSection}
|
||||||
commitCount={sectionCommits.length}
|
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}
|
commits={sectionCommits}
|
||||||
changesCount={pendingSaveCount}
|
changesCount={pendingSaveCount}
|
||||||
undoStack={editor.undoStack}
|
undoStack={editor.undoStack}
|
||||||
@@ -1398,6 +1402,10 @@ function isYearNumber(value: number | null | undefined): value is number {
|
|||||||
return typeof value === "number" && Number.isFinite(value);
|
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 {
|
function getDefaultTypeIdForFeature(feature: Feature): string {
|
||||||
const preset = feature.properties.geometry_preset;
|
const preset = feature.properties.geometry_preset;
|
||||||
if (preset === "line") return "defense_line";
|
if (preset === "line") return "defense_line";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
SectionSubmission,
|
SectionSubmission,
|
||||||
} from "@/api/sections";
|
} 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";
|
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type SubmissionStatusFilter = "pending" | "all";
|
type SubmissionStatusFilter = "pending" | "all";
|
||||||
@@ -37,6 +37,11 @@ const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
|||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: [],
|
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> = {
|
const STATUS_LABELS: Record<SectionSubmission["status"], string> = {
|
||||||
pending: "Chờ duyệt",
|
pending: "Chờ duyệt",
|
||||||
@@ -54,6 +59,7 @@ export default function SubmittedPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [reviewingSubmissionId, setReviewingSubmissionId] = useState<string | null>(null);
|
const [reviewingSubmissionId, setReviewingSubmissionId] = useState<string | null>(null);
|
||||||
const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState<string | number | null>(null);
|
const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState<string | number | null>(null);
|
||||||
|
const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -130,6 +136,7 @@ export default function SubmittedPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedPreviewFeatureId(null);
|
setSelectedPreviewFeatureId(null);
|
||||||
|
setIsJsonPopupOpen(false);
|
||||||
}, [selectedRow?.submission.id]);
|
}, [selectedRow?.submission.id]);
|
||||||
|
|
||||||
const handleReview = async (action: ReviewAction) => {
|
const handleReview = async (action: ReviewAction) => {
|
||||||
@@ -336,6 +343,16 @@ export default function SubmittedPage() {
|
|||||||
<Info label="Reviewed at" value={formatDateTime(selectedRow.submission.reviewed_at)} />
|
<Info label="Reviewed at" value={formatDateTime(selectedRow.submission.reviewed_at)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.dataActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsJsonPopupOpen(true)}
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
>
|
||||||
|
View commit JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedRow.submission.review_note ? (
|
{selectedRow.submission.review_note ? (
|
||||||
<div style={styles.noteBox}>
|
<div style={styles.noteBox}>
|
||||||
<strong>Review note</strong>
|
<strong>Review note</strong>
|
||||||
@@ -351,14 +368,16 @@ export default function SubmittedPage() {
|
|||||||
<div style={styles.mapPreview}>
|
<div style={styles.mapPreview}>
|
||||||
<Map
|
<Map
|
||||||
key={selectedRow.submission.id}
|
key={selectedRow.submission.id}
|
||||||
mode="idle"
|
mode="select"
|
||||||
draft={selectedFeatureCollection}
|
draft={selectedFeatureCollection}
|
||||||
backgroundVisibility={DEFAULT_BACKGROUND_LAYER_VISIBILITY}
|
backgroundVisibility={REVIEW_BACKGROUND_LAYER_VISIBILITY}
|
||||||
selectedFeatureId={selectedPreviewFeatureId}
|
selectedFeatureId={selectedPreviewFeatureId}
|
||||||
onSelectFeatureId={setSelectedPreviewFeatureId}
|
onSelectFeatureId={setSelectedPreviewFeatureId}
|
||||||
allowGeometryEditing={false}
|
allowGeometryEditing={false}
|
||||||
respectBindingFilter={false}
|
respectBindingFilter={false}
|
||||||
height="420px"
|
height="420px"
|
||||||
|
fitToDraftBounds
|
||||||
|
fitBoundsKey={selectedRow.submission.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.featurePanel}>
|
<div style={styles.featurePanel}>
|
||||||
@@ -399,15 +418,16 @@ export default function SubmittedPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.emptyState}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3 style={styles.sectionHeading}>Snapshot</h3>
|
<h3 style={styles.sectionHeading}>Snapshot</h3>
|
||||||
<div style={styles.summaryGrid}>
|
<div style={styles.summaryGrid}>
|
||||||
<Metric label="Editor features" value={selectedSummary.featureCount} />
|
<Metric label="Preview features" value={selectedSummary.featureCount} />
|
||||||
<Metric label="Entities" value={selectedSummary.entityCount} />
|
<Metric label="Entities in review" value={selectedSummary.entityCount} />
|
||||||
<Metric label="Geometries" value={selectedSummary.geometryCount} />
|
<Metric label="Geometries in review" value={selectedSummary.geometryCount} />
|
||||||
<Metric label="Link scopes" value={selectedSummary.linkScopeCount} />
|
<Metric label="Link scopes" value={selectedSummary.linkScopeCount} />
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.operationGrid}>
|
<div style={styles.operationGrid}>
|
||||||
@@ -447,6 +467,13 @@ export default function SubmittedPage() {
|
|||||||
{isReviewingSelected ? "Đang xử lý..." : "Duyệt"}
|
{isReviewingSelected ? "Đang xử lý..." : "Duyệt"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<JsonDataPopup
|
||||||
|
open={isJsonPopupOpen}
|
||||||
|
title={`Commit ${selectedRow.submission.commit_id}`}
|
||||||
|
data={selectedRow.submission.snapshot ?? null}
|
||||||
|
onClose={() => setIsJsonPopupOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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"] }) {
|
function StatusBadge({ status }: { status: SectionSubmission["status"] }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -518,22 +595,35 @@ function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollecti
|
|||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = rawSnapshot as Record<string, unknown>;
|
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;
|
const editorCollection = snapshot.editor_feature_collection;
|
||||||
if (isFeatureCollection(editorCollection)) {
|
if (isFeatureCollection(editorCollection)) {
|
||||||
return {
|
return {
|
||||||
type: "FeatureCollection",
|
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
|
const features = geometries
|
||||||
.map((item): Feature | null => {
|
.map((item): Feature | null => {
|
||||||
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||||
const source = item as Record<string, unknown>;
|
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;
|
const geometry = source.draw_geometry || source.geometry;
|
||||||
if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) {
|
if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -570,25 +660,72 @@ function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = rawSnapshot as Record<string, unknown>;
|
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 entities = Array.isArray(snapshot.entities) ? snapshot.entities : [];
|
||||||
const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
|
const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
|
||||||
const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : [];
|
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 {
|
return {
|
||||||
featureCount: features.length,
|
featureCount: extractSnapshotFeatureCollection(rawSnapshot).features.length,
|
||||||
entityCount: entities.length,
|
entityCount: reviewEntities.length,
|
||||||
geometryCount: geometries.length,
|
geometryCount: reviewGeometries.length,
|
||||||
linkScopeCount: linkScopes.length,
|
linkScopeCount: linkScopes.length,
|
||||||
entityOperations: countOperations(entities),
|
entityOperations: countOperations(reviewEntities),
|
||||||
geometryOperations: countOperations(geometries),
|
geometryOperations: countOperations(reviewGeometries),
|
||||||
linkOperations: countOperations(linkScopes),
|
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 {
|
function isFeatureCollection(value: unknown): value is FeatureCollection {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
const source = value as Record<string, unknown>;
|
const source = value as Record<string, unknown>;
|
||||||
@@ -928,7 +1065,7 @@ const styles: Record<string, CSSProperties> = {
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
submissionItemSelected: {
|
submissionItemSelected: {
|
||||||
borderColor: "#71a66a",
|
border: "1px solid #71a66a",
|
||||||
background: "#f0f8ee",
|
background: "#f0f8ee",
|
||||||
},
|
},
|
||||||
itemTopLine: {
|
itemTopLine: {
|
||||||
@@ -1010,6 +1147,21 @@ const styles: Record<string, CSSProperties> = {
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
overflowWrap: "anywhere",
|
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: {
|
sectionHeading: {
|
||||||
margin: "18px 0 10px",
|
margin: "18px 0 10px",
|
||||||
fontSize: "18px",
|
fontSize: "18px",
|
||||||
@@ -1100,4 +1252,66 @@ const styles: Record<string, CSSProperties> = {
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
cursor: "pointer",
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import CommitTreePopup from "@/components/CommitTreePopup";
|
||||||
import { UndoAction } from "@/lib/useEditorState";
|
import { UndoAction } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
@@ -37,11 +39,16 @@ type Props = {
|
|||||||
onOpenSection: () => void;
|
onOpenSection: () => void;
|
||||||
onCreateSection: () => void;
|
onCreateSection: () => void;
|
||||||
commitCount: number;
|
commitCount: number;
|
||||||
|
hasHeadCommit: boolean;
|
||||||
|
headCommitId: string | null;
|
||||||
latestCommitLabel: string | null;
|
latestCommitLabel: string | null;
|
||||||
commits: Array<{
|
commits: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
parent_commit_id: string | null;
|
||||||
|
restored_from_commit_id: string | null;
|
||||||
commit_no: number;
|
commit_no: number;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
created_by: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -87,6 +94,8 @@ export default function Editor({
|
|||||||
onOpenSection,
|
onOpenSection,
|
||||||
onCreateSection,
|
onCreateSection,
|
||||||
commitCount,
|
commitCount,
|
||||||
|
hasHeadCommit,
|
||||||
|
headCommitId,
|
||||||
latestCommitLabel,
|
latestCommitLabel,
|
||||||
commits,
|
commits,
|
||||||
changesCount,
|
changesCount,
|
||||||
@@ -94,6 +103,11 @@ export default function Editor({
|
|||||||
createdEntities,
|
createdEntities,
|
||||||
createdGeometries,
|
createdGeometries,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false);
|
||||||
|
|
||||||
|
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
||||||
|
commit.title?.trim() || `Commit #${commit.commit_no}`;
|
||||||
|
|
||||||
const toggleMode = (newMode: Mode) => {
|
const toggleMode = (newMode: Mode) => {
|
||||||
if (mode === newMode) {
|
if (mode === newMode) {
|
||||||
setMode("idle"); // bấm lại → tắt
|
setMode("idle"); // bấm lại → tắt
|
||||||
@@ -393,6 +407,40 @@ export default function Editor({
|
|||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 68px", gap: "8px", marginTop: "8px" }}>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
cursor: isSaving || isSubmitting || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
||||||
|
background: isSaving || isSubmitting || sectionStatus === "submitted" ? "#555" : "#0f766e",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
onClick={onCommit}
|
||||||
|
disabled={isSaving || isSubmitting || sectionStatus === "submitted"}
|
||||||
|
>
|
||||||
|
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
|
<button
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -400,29 +448,13 @@ export default function Editor({
|
|||||||
padding: "8px",
|
padding: "8px",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: isSaving || isSubmitting || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
cursor: isSubmitting || !hasHeadCommit || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
||||||
background: isSaving || isSubmitting || sectionStatus === "submitted" ? "#555" : "#0f766e",
|
background: isSubmitting || !hasHeadCommit || sectionStatus === "submitted" ? "#555" : "#16a34a",
|
||||||
color: "white",
|
color: "white",
|
||||||
}}
|
opacity: !hasHeadCommit ? 0.6 : 1,
|
||||||
onClick={onCommit}
|
|
||||||
disabled={isSaving || isSubmitting || sectionStatus === "submitted"}
|
|
||||||
>
|
|
||||||
Commit ({changesCount})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
marginTop: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "none",
|
|
||||||
cursor: isSubmitting || commitCount === 0 || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
|
||||||
background: isSubmitting || commitCount === 0 || sectionStatus === "submitted" ? "#555" : "#16a34a",
|
|
||||||
color: "white",
|
|
||||||
opacity: commitCount === 0 ? 0.6 : 1,
|
|
||||||
}}
|
}}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={isSubmitting || commitCount === 0 || sectionStatus === "submitted"}
|
disabled={isSubmitting || !hasHeadCommit || sectionStatus === "submitted"}
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
@@ -454,7 +486,17 @@ export default function Editor({
|
|||||||
color: "#e2e8f0",
|
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}
|
#{commit.commit_no} {commit.kind}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -566,6 +608,13 @@ export default function Editor({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CommitTreePopup
|
||||||
|
open={isCommitTreeOpen}
|
||||||
|
commits={commits}
|
||||||
|
headCommitId={headCommitId}
|
||||||
|
onClose={() => setIsCommitTreeOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { initLine } from "@/lib/lineEngine";
|
|||||||
import { initPath } from "@/lib/pathEngine";
|
import { initPath } from "@/lib/pathEngine";
|
||||||
import { initCircle } from "@/lib/circleEngine";
|
import { initCircle } from "@/lib/circleEngine";
|
||||||
import { createEditingEngine } from "@/lib/editingEngine";
|
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";
|
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
@@ -27,6 +27,8 @@ type MapProps = {
|
|||||||
allowGeometryEditing?: boolean;
|
allowGeometryEditing?: boolean;
|
||||||
respectBindingFilter?: boolean;
|
respectBindingFilter?: boolean;
|
||||||
height?: CSSProperties["height"];
|
height?: CSSProperties["height"];
|
||||||
|
fitToDraftBounds?: boolean;
|
||||||
|
fitBoundsKey?: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
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_SOURCE_ID = "rasterBase";
|
||||||
const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||||
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
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 = [
|
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
"coalesce",
|
"coalesce",
|
||||||
["get", "MAPCOLOR7"],
|
["get", "MAPCOLOR7"],
|
||||||
@@ -122,6 +130,8 @@ export default function Map({
|
|||||||
allowGeometryEditing = true,
|
allowGeometryEditing = true,
|
||||||
respectBindingFilter = true,
|
respectBindingFilter = true,
|
||||||
height = "100vh",
|
height = "100vh",
|
||||||
|
fitToDraftBounds = false,
|
||||||
|
fitBoundsKey = null,
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
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 [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
|
||||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
modeRef.current = mode;
|
modeRef.current = mode;
|
||||||
@@ -172,6 +184,10 @@ export default function Map({
|
|||||||
selectedFeatureIdRef.current = selectedFeatureId;
|
selectedFeatureIdRef.current = selectedFeatureId;
|
||||||
}, [selectedFeatureId]);
|
}, [selectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fitBoundsAppliedRef.current = false;
|
||||||
|
}, [fitBoundsKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSelectFeatureIdRef.current = onSelectFeatureId;
|
onSelectFeatureIdRef.current = onSelectFeatureId;
|
||||||
}, [onSelectFeatureId]);
|
}, [onSelectFeatureId]);
|
||||||
@@ -218,16 +234,33 @@ export default function Map({
|
|||||||
if (!countriesSource || !placesSource) return;
|
if (!countriesSource || !placesSource) return;
|
||||||
|
|
||||||
// clear all feature-state (selection) to prevent ghost layers after undo
|
// 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
|
const visibleDraft = respectBindingFilter
|
||||||
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
||||||
: fc;
|
: fc;
|
||||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||||
|
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
||||||
|
|
||||||
countriesSource.setData(polygons);
|
countriesSource.setData(polygons);
|
||||||
placesSource.setData(points);
|
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(() => {
|
useEffect(() => {
|
||||||
const map = new maplibregl.Map({
|
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({
|
map.addLayer({
|
||||||
id: "countries-fill",
|
id: "countries-fill",
|
||||||
type: "fill",
|
type: "fill",
|
||||||
@@ -557,7 +596,11 @@ export default function Map({
|
|||||||
id: "routes-line",
|
id: "routes-line",
|
||||||
type: "line",
|
type: "line",
|
||||||
source: "countries",
|
source: "countries",
|
||||||
filter: ["==", ["geometry-type"], "LineString"],
|
filter: [
|
||||||
|
"all",
|
||||||
|
["==", ["geometry-type"], "LineString"],
|
||||||
|
["!=", buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false), true],
|
||||||
|
],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": [
|
"line-color": [
|
||||||
"case",
|
"case",
|
||||||
@@ -579,26 +622,73 @@ export default function Map({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasPathArrowIcon) {
|
map.addLayer({
|
||||||
map.addLayer({
|
id: "routes-path-arrow-fill",
|
||||||
id: "routes-arrow",
|
type: "fill",
|
||||||
type: "symbol",
|
source: PATH_ARROW_SOURCE_ID,
|
||||||
source: "countries",
|
paint: {
|
||||||
filter: [
|
"fill-color": [
|
||||||
"all",
|
"case",
|
||||||
["==", ["geometry-type"], "LineString"],
|
["boolean", ["feature-state", "selected"], false],
|
||||||
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
|
"#22c55e",
|
||||||
|
["==", ["coalesce", ["get", "entity_id"], ""], ""],
|
||||||
|
"#ef4444",
|
||||||
|
buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"),
|
||||||
],
|
],
|
||||||
layout: {
|
"fill-opacity": [
|
||||||
"symbol-placement": "line",
|
"case",
|
||||||
"symbol-spacing": 60,
|
["boolean", ["feature-state", "selected"], false],
|
||||||
"icon-image": PATH_ARROW_ICON_ID,
|
0.92,
|
||||||
"icon-size": 0.5,
|
0.82,
|
||||||
"icon-allow-overlap": true,
|
],
|
||||||
"icon-ignore-placement": true,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
}
|
map.addLayer({
|
||||||
|
id: "routes-path-arrow-line",
|
||||||
|
type: "line",
|
||||||
|
source: PATH_ARROW_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
"line-color": [
|
||||||
|
"case",
|
||||||
|
["boolean", ["feature-state", "selected"], false],
|
||||||
|
"#14532d",
|
||||||
|
"#0f172a",
|
||||||
|
],
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
1, 0.45,
|
||||||
|
4, 0.8,
|
||||||
|
6, 1.2,
|
||||||
|
],
|
||||||
|
"line-opacity": 0.9,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "routes-path-hit",
|
||||||
|
type: "line",
|
||||||
|
source: "countries",
|
||||||
|
filter: [
|
||||||
|
"all",
|
||||||
|
["==", ["geometry-type"], "LineString"],
|
||||||
|
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
|
||||||
|
],
|
||||||
|
paint: {
|
||||||
|
"line-color": "#ffffff",
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
1, 12,
|
||||||
|
4, 18,
|
||||||
|
6, 24,
|
||||||
|
],
|
||||||
|
"line-opacity": 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
map.addSource("places", {
|
map.addSource("places", {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
@@ -606,6 +696,7 @@ export default function Map({
|
|||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: [],
|
features: [],
|
||||||
},
|
},
|
||||||
|
promoteId: "id",
|
||||||
});
|
});
|
||||||
|
|
||||||
// editing overlays
|
// editing overlays
|
||||||
@@ -646,11 +737,54 @@ export default function Map({
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: "places",
|
source: "places",
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#ef4444",
|
"circle-color": [
|
||||||
"circle-radius": 4,
|
"case",
|
||||||
"circle-stroke-color": "#ffffff",
|
["boolean", ["feature-state", "selected"], false],
|
||||||
"circle-stroke-width": 1,
|
"#22c55e",
|
||||||
"circle-opacity": 0.85,
|
"#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);
|
mapCleanupFnsRef.current = [
|
||||||
map.on("remove", cleanupPath);
|
cleanupCircle,
|
||||||
map.on("remove", cleanupLine);
|
cleanupPath,
|
||||||
map.on("remove", cleanupPoint);
|
cleanupLine,
|
||||||
|
cleanupPoint,
|
||||||
map.on("remove", cleanupSelect);
|
cleanupSelect,
|
||||||
|
cleanup,
|
||||||
map.on("remove", cleanup);
|
() => map.off("zoom", syncZoomLevel),
|
||||||
map.on("remove", () => map.off("zoom", syncZoomLevel));
|
];
|
||||||
|
|
||||||
// after everything mounted, push current draft to sources
|
// after everything mounted, push current draft to sources
|
||||||
applyDraftToMap(draftRef.current);
|
applyDraftToMap(draftRef.current);
|
||||||
@@ -803,6 +937,10 @@ export default function Map({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
for (const cleanupFn of mapCleanupFnsRef.current) {
|
||||||
|
cleanupFn();
|
||||||
|
}
|
||||||
|
mapCleanupFnsRef.current = [];
|
||||||
if (mapRef.current === map) {
|
if (mapRef.current === map) {
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -1041,6 +1179,291 @@ function splitDraftFeatures(fc: FeatureCollection) {
|
|||||||
return { polygons, points };
|
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 {
|
function ensurePathArrowIcon(map: maplibregl.Map): boolean {
|
||||||
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
||||||
const imageData = createPathArrowImageData();
|
const imageData = createPathArrowImageData();
|
||||||
|
|||||||
@@ -14,9 +14,17 @@ export function initSelect(
|
|||||||
"countries-fill",
|
"countries-fill",
|
||||||
"countries-line",
|
"countries-line",
|
||||||
"routes-line",
|
"routes-line",
|
||||||
|
"routes-path-arrow-fill",
|
||||||
|
"routes-path-arrow-line",
|
||||||
|
"routes-path-hit",
|
||||||
"places-circle",
|
"places-circle",
|
||||||
"places-symbol",
|
"places-symbol",
|
||||||
] as const;
|
] as const;
|
||||||
|
const FEATURE_STATE_SOURCES = [
|
||||||
|
"countries",
|
||||||
|
"places",
|
||||||
|
"path-arrow-shapes",
|
||||||
|
] as const;
|
||||||
const selectedIds = new Set<number | string>();
|
const selectedIds = new Set<number | string>();
|
||||||
const hasContextActions = Boolean(onDelete || onEdit);
|
const hasContextActions = Boolean(onDelete || onEdit);
|
||||||
let contextMenu: HTMLDivElement | null = null;
|
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.
|
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
selectedIds.forEach((id) => {
|
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||||
map.setFeatureState({ source: "countries", id }, { selected: false });
|
|
||||||
});
|
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
onSelectId?.(null);
|
onSelectId?.(null);
|
||||||
}
|
}
|
||||||
@@ -43,13 +49,13 @@ export function initSelect(
|
|||||||
|
|
||||||
if (additive && selectedIds.has(id)) {
|
if (additive && selectedIds.has(id)) {
|
||||||
// Alt + click on an already selected feature removes it from the selection
|
// 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);
|
selectedIds.delete(id);
|
||||||
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
map.setFeatureState({ source: "countries", id }, { selected: true });
|
setSelectionStateForId(id, true);
|
||||||
selectedIds.add(id);
|
selectedIds.add(id);
|
||||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||||
}
|
}
|
||||||
@@ -125,6 +131,13 @@ export function initSelect(
|
|||||||
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
|
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("click", onClick);
|
||||||
map.on("mousemove", onMove);
|
map.on("mousemove", onMove);
|
||||||
if (hasContextActions) {
|
if (hasContextActions) {
|
||||||
@@ -192,7 +205,12 @@ export function initSelect(
|
|||||||
const selectedCount = selectedIds.size || 1;
|
const selectedCount = selectedIds.size || 1;
|
||||||
let hasMenuItems = false;
|
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;
|
const single = clickedFeature;
|
||||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||||
hasMenuItems = true;
|
hasMenuItems = true;
|
||||||
|
|||||||
244
package-lock.json
generated
244
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"maplibre-gl": "^5.20.2",
|
"maplibre-gl": "^5.20.2",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
"react-d3-tree": "^3.6.6",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -230,6 +231,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -278,6 +288,24 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||||
@@ -1631,6 +1659,12 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2708,6 +2742,12 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -2731,6 +2771,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2787,6 +2836,133 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -2909,6 +3085,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2932,6 +3117,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -4579,7 +4773,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -5002,7 +5195,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -5286,7 +5478,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5603,7 +5794,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -5664,6 +5854,27 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
@@ -5681,7 +5892,12 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
@@ -6686,6 +6902,24 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"maplibre-gl": "^5.20.2",
|
"maplibre-gl": "^5.20.2",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
"react-d3-tree": "^3.6.6",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user