demo 20-4-2026
This commit is contained in:
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,6 +407,40 @@ export default function Editor({
|
||||
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
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -400,29 +448,13 @@ export default function Editor({
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: isSaving || isSubmitting || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
||||
background: isSaving || isSubmitting || sectionStatus === "submitted" ? "#555" : "#0f766e",
|
||||
cursor: isSubmitting || !hasHeadCommit || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
||||
background: isSubmitting || !hasHeadCommit || sectionStatus === "submitted" ? "#555" : "#16a34a",
|
||||
color: "white",
|
||||
}}
|
||||
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,
|
||||
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",
|
||||
source: "countries",
|
||||
filter: [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
|
||||
map.addLayer({
|
||||
id: "routes-path-arrow-fill",
|
||||
type: "fill",
|
||||
source: PATH_ARROW_SOURCE_ID,
|
||||
paint: {
|
||||
"fill-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#22c55e",
|
||||
["==", ["coalesce", ["get", "entity_id"], ""], ""],
|
||||
"#ef4444",
|
||||
buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"),
|
||||
],
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 60,
|
||||
"icon-image": PATH_ARROW_ICON_ID,
|
||||
"icon-size": 0.5,
|
||||
"icon-allow-overlap": true,
|
||||
"icon-ignore-placement": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
"fill-opacity": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
0.92,
|
||||
0.82,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "routes-path-arrow-line",
|
||||
type: "line",
|
||||
source: PATH_ARROW_SOURCE_ID,
|
||||
paint: {
|
||||
"line-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#14532d",
|
||||
"#0f172a",
|
||||
],
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
1, 0.45,
|
||||
4, 0.8,
|
||||
6, 1.2,
|
||||
],
|
||||
"line-opacity": 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "routes-path-hit",
|
||||
type: "line",
|
||||
source: "countries",
|
||||
filter: [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
|
||||
],
|
||||
paint: {
|
||||
"line-color": "#ffffff",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
1, 12,
|
||||
4, 18,
|
||||
6, 24,
|
||||
],
|
||||
"line-opacity": 0,
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("places", {
|
||||
type: "geojson",
|
||||
@@ -606,6 +696,7 @@ export default function Map({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
promoteId: "id",
|
||||
});
|
||||
|
||||
// editing overlays
|
||||
@@ -646,11 +737,54 @@ export default function Map({
|
||||
type: "circle",
|
||||
source: "places",
|
||||
paint: {
|
||||
"circle-color": "#ef4444",
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 1,
|
||||
"circle-opacity": 0.85,
|
||||
"circle-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#22c55e",
|
||||
"#ef4444",
|
||||
],
|
||||
"circle-radius": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
8,
|
||||
4,
|
||||
],
|
||||
"circle-stroke-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#14532d",
|
||||
"#ffffff",
|
||||
],
|
||||
"circle-stroke-width": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
3,
|
||||
1,
|
||||
],
|
||||
"circle-opacity": 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "places-selected-halo",
|
||||
type: "circle",
|
||||
source: "places",
|
||||
paint: {
|
||||
"circle-color": "#22c55e",
|
||||
"circle-radius": 13,
|
||||
"circle-opacity": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
0.28,
|
||||
0,
|
||||
],
|
||||
"circle-stroke-color": "#14532d",
|
||||
"circle-stroke-width": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
2,
|
||||
0,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -784,15 +918,15 @@ export default function Map({
|
||||
}
|
||||
);
|
||||
|
||||
map.on("remove", cleanupCircle);
|
||||
map.on("remove", cleanupPath);
|
||||
map.on("remove", cleanupLine);
|
||||
map.on("remove", cleanupPoint);
|
||||
|
||||
map.on("remove", cleanupSelect);
|
||||
|
||||
map.on("remove", cleanup);
|
||||
map.on("remove", () => map.off("zoom", syncZoomLevel));
|
||||
mapCleanupFnsRef.current = [
|
||||
cleanupCircle,
|
||||
cleanupPath,
|
||||
cleanupLine,
|
||||
cleanupPoint,
|
||||
cleanupSelect,
|
||||
cleanup,
|
||||
() => map.off("zoom", syncZoomLevel),
|
||||
];
|
||||
|
||||
// after everything mounted, push current draft to sources
|
||||
applyDraftToMap(draftRef.current);
|
||||
@@ -803,6 +937,10 @@ export default function Map({
|
||||
});
|
||||
|
||||
return () => {
|
||||
for (const cleanupFn of mapCleanupFnsRef.current) {
|
||||
cleanupFn();
|
||||
}
|
||||
mapCleanupFnsRef.current = [];
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
@@ -1041,6 +1179,291 @@ function splitDraftFeatures(fc: FeatureCollection) {
|
||||
return { polygons, points };
|
||||
}
|
||||
|
||||
function setSelectedFeatureState(
|
||||
map: maplibregl.Map,
|
||||
id: string | number | null,
|
||||
selected: boolean
|
||||
) {
|
||||
if (id === null) return;
|
||||
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
|
||||
if (!map.getSource(sourceId)) continue;
|
||||
map.setFeatureState({ source: sourceId, id }, { selected });
|
||||
}
|
||||
}
|
||||
|
||||
function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection): boolean {
|
||||
const bbox = getFeatureCollectionBBox(fc);
|
||||
if (!bbox) return false;
|
||||
|
||||
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
|
||||
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
|
||||
if (lngSpan < 0.000001 && latSpan < 0.000001) {
|
||||
map.easeTo({
|
||||
center: [bbox.minLng, bbox.minLat],
|
||||
zoom: 6,
|
||||
duration: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
[
|
||||
[bbox.minLng, bbox.minLat],
|
||||
[bbox.maxLng, bbox.maxLat],
|
||||
],
|
||||
{
|
||||
padding: 58,
|
||||
maxZoom: 7,
|
||||
duration: 0,
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getFeatureCollectionBBox(
|
||||
fc: FeatureCollection
|
||||
): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||
const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates));
|
||||
if (!points.length) return null;
|
||||
|
||||
let minLng = Number.POSITIVE_INFINITY;
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLng = Number.NEGATIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const [lng, lat] of points) {
|
||||
minLng = Math.min(minLng, lng);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
|
||||
return { minLng, minLat, maxLng, maxLat };
|
||||
}
|
||||
|
||||
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number" &&
|
||||
Number.isFinite(value[0]) &&
|
||||
Number.isFinite(value[1])
|
||||
) {
|
||||
return [[value[0], value[1]]];
|
||||
}
|
||||
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||
}
|
||||
|
||||
function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
|
||||
const features = fc.features
|
||||
.map((feature) => {
|
||||
if (!isPathFeature(feature) || feature.geometry.type !== "LineString") return null;
|
||||
const geometry = buildPathArrowGeometry(feature.geometry.coordinates);
|
||||
if (!geometry) return null;
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
properties: { ...feature.properties },
|
||||
geometry,
|
||||
};
|
||||
})
|
||||
.filter((feature): feature is Feature => feature !== null);
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathFeature(feature: Feature): boolean {
|
||||
const featureType = getFeatureSemanticType(feature);
|
||||
return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]);
|
||||
}
|
||||
|
||||
function getFeatureSemanticType(feature: Feature): string | null {
|
||||
const value = feature.properties.type || feature.properties.entity_type_id || null;
|
||||
if (!value) return null;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
|
||||
const sourceCoords = removeDuplicatePathCoords(coords);
|
||||
if (sourceCoords.length < 2) return null;
|
||||
|
||||
const origin = sourceCoords[0];
|
||||
const originLatRad = toRadians(origin[1]);
|
||||
const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001);
|
||||
const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat));
|
||||
const measured = buildMeasuredPath(projected);
|
||||
const totalLength = measured[measured.length - 1]?.distance || 0;
|
||||
if (totalLength <= 0) return null;
|
||||
|
||||
const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45);
|
||||
const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35);
|
||||
const bodyPoints = measured
|
||||
.filter((point) => point.distance < bodyEndDistance)
|
||||
.map(({ x, y, distance }) => ({ x, y, distance }));
|
||||
bodyPoints.push(pointAtDistance(measured, bodyEndDistance));
|
||||
|
||||
if (bodyPoints.length < 2) return null;
|
||||
|
||||
const tailWidth = clampNumber(totalLength * 0.018, 25000, 140000);
|
||||
const shoulderWidth = clampNumber(totalLength * 0.055, 60000, 420000);
|
||||
const headWidth = shoulderWidth * 1.65;
|
||||
|
||||
const leftBody: ProjectedPoint[] = [];
|
||||
const rightBody: ProjectedPoint[] = [];
|
||||
|
||||
for (let i = 0; i < bodyPoints.length; i += 1) {
|
||||
const point = bodyPoints[i];
|
||||
const normal = normalAt(bodyPoints, i);
|
||||
const progress = bodyEndDistance > 0
|
||||
? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
|
||||
: 0;
|
||||
const width = tailWidth + (shoulderWidth - tailWidth) * progress;
|
||||
const half = width / 2;
|
||||
leftBody.push({
|
||||
x: point.x + normal.x * half,
|
||||
y: point.y + normal.y * half,
|
||||
});
|
||||
rightBody.push({
|
||||
x: point.x - normal.x * half,
|
||||
y: point.y - normal.y * half,
|
||||
});
|
||||
}
|
||||
|
||||
const base = bodyPoints[bodyPoints.length - 1];
|
||||
const tip = pointAtDistance(measured, totalLength);
|
||||
const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
|
||||
const headHalf = headWidth / 2;
|
||||
const headBaseLeft = {
|
||||
x: base.x + headNormal.x * headHalf,
|
||||
y: base.y + headNormal.y * headHalf,
|
||||
};
|
||||
const headBaseRight = {
|
||||
x: base.x - headNormal.x * headHalf,
|
||||
y: base.y - headNormal.y * headHalf,
|
||||
};
|
||||
|
||||
const ring = [
|
||||
...leftBody,
|
||||
headBaseLeft,
|
||||
{ x: tip.x, y: tip.y },
|
||||
headBaseRight,
|
||||
...rightBody.reverse(),
|
||||
leftBody[0],
|
||||
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
|
||||
|
||||
if (ring.length < 4) return null;
|
||||
return {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
};
|
||||
}
|
||||
|
||||
type ProjectedPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type MeasuredPoint = ProjectedPoint & {
|
||||
distance: number;
|
||||
};
|
||||
|
||||
function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] {
|
||||
const result: [number, number][] = [];
|
||||
for (const coord of coords) {
|
||||
const last = result[result.length - 1];
|
||||
if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
|
||||
result.push(coord);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectLngLat(
|
||||
coord: [number, number],
|
||||
origin: [number, number],
|
||||
cosOriginLat: number
|
||||
): ProjectedPoint {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
return {
|
||||
x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat,
|
||||
y: toRadians(coord[1] - origin[1]) * earthRadiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
function unprojectLngLat(
|
||||
point: ProjectedPoint,
|
||||
origin: [number, number],
|
||||
cosOriginLat: number
|
||||
): [number, number] {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
return [
|
||||
origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)),
|
||||
origin[1] + toDegrees(point.y / earthRadiusMeters),
|
||||
];
|
||||
}
|
||||
|
||||
function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] {
|
||||
let distance = 0;
|
||||
return points.map((point, index) => {
|
||||
if (index > 0) {
|
||||
distance += distanceProjected(points[index - 1], point);
|
||||
}
|
||||
return {
|
||||
...point,
|
||||
distance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint {
|
||||
if (targetDistance <= 0) return points[0];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const prev = points[i - 1];
|
||||
const next = points[i];
|
||||
if (targetDistance > next.distance) continue;
|
||||
const segmentLength = next.distance - prev.distance;
|
||||
const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0;
|
||||
return {
|
||||
x: prev.x + (next.x - prev.x) * t,
|
||||
y: prev.y + (next.y - prev.y) * t,
|
||||
distance: targetDistance,
|
||||
};
|
||||
}
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint {
|
||||
const prev = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
return normalFromSegment(prev, next) || { x: 0, y: 1 };
|
||||
}
|
||||
|
||||
function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= 0) return null;
|
||||
return {
|
||||
x: -dy / length,
|
||||
y: dx / length,
|
||||
};
|
||||
}
|
||||
|
||||
function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
function toRadians(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function toDegrees(value: number): number {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function ensurePathArrowIcon(map: maplibregl.Map): boolean {
|
||||
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
||||
const imageData = createPathArrowImageData();
|
||||
|
||||
Reference in New Issue
Block a user