draw path | draw area | localstorage layer state | add entities property parallel | timeline bar
This commit is contained in:
@@ -8,23 +8,62 @@ import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles"
|
||||
import { initDrawing } from "@/lib/drawingEngine";
|
||||
import { initSelect } from "@/lib/selectingEngine";
|
||||
import { initPoint } from "@/lib/pointEngine";
|
||||
import { initPath } from "@/lib/pathEngine";
|
||||
import { initCircle } from "@/lib/circleEngine";
|
||||
import { createEditingEngine } from "@/lib/editingEngine";
|
||||
import { FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||
|
||||
type MapProps = {
|
||||
mode: "idle" | "draw" | "select" | "add-point";
|
||||
mode: "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
draft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
selectedEntityId: string | null;
|
||||
selectedEntityName: string | null;
|
||||
selectedEntityTypeId: string | null;
|
||||
onSelectFeatureId: (id: string | number | null) => void;
|
||||
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature: (id: string | number) => void;
|
||||
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
|
||||
};
|
||||
|
||||
type PointIconSpec = {
|
||||
id: string;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||
|
||||
const POINT_ICON_SPECS: PointIconSpec[] = [
|
||||
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
|
||||
{ id: "point-icon-castle", fill: "#7c3aed", stroke: "#4c1d95", label: "F" },
|
||||
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
|
||||
{ id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "T" },
|
||||
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
||||
{ id: "point-icon-event", fill: "#be123c", stroke: "#881337", label: "E" },
|
||||
{ id: DEFAULT_POINT_ICON_ID, fill: "#475569", stroke: "#1e293b", label: "P" },
|
||||
];
|
||||
|
||||
const POINT_ICON_BY_TYPE: Record<string, string> = {
|
||||
country: "point-icon-country",
|
||||
castle: "point-icon-castle",
|
||||
kingdom: "point-icon-kingdom",
|
||||
city: "point-icon-city",
|
||||
region: "point-icon-region",
|
||||
event: "point-icon-event",
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
mode,
|
||||
draft,
|
||||
backgroundVisibility,
|
||||
selectedEntityId,
|
||||
selectedEntityName,
|
||||
selectedEntityTypeId,
|
||||
onSelectFeatureId,
|
||||
onCreateFeature,
|
||||
onDeleteFeature,
|
||||
onUpdateFeature,
|
||||
@@ -33,6 +72,10 @@ export default function Map({
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
|
||||
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
|
||||
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
|
||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||
const onCreateRef = useRef(onCreateFeature);
|
||||
const onDeleteRef = useRef(onDeleteFeature);
|
||||
const onUpdateRef = useRef(onUpdateFeature);
|
||||
@@ -43,10 +86,43 @@ export default function Map({
|
||||
modeRef.current = mode;
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (mode !== "add-path") {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
if (mode !== "add-circle") {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityIdRef.current = selectedEntityId;
|
||||
}, [selectedEntityId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityNameRef.current = selectedEntityName;
|
||||
}, [selectedEntityName]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityTypeIdRef.current = selectedEntityTypeId;
|
||||
}, [selectedEntityTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectFeatureIdRef.current = onSelectFeatureId;
|
||||
}, [onSelectFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
backgroundVisibilityRef.current = backgroundVisibility;
|
||||
const map = mapRef.current;
|
||||
@@ -260,6 +336,7 @@ export default function Map({
|
||||
const placesMinZoom = 5;
|
||||
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
|
||||
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||
|
||||
// preview (drawing)
|
||||
map.addSource("draw-preview", {
|
||||
@@ -290,6 +367,71 @@ export default function Map({
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("draw-circle-preview", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-circle-preview-fill",
|
||||
type: "fill",
|
||||
source: "draw-circle-preview",
|
||||
paint: {
|
||||
"fill-color": "#0ea5e9",
|
||||
"fill-opacity": 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-circle-preview-line",
|
||||
type: "line",
|
||||
source: "draw-circle-preview",
|
||||
paint: {
|
||||
"line-color": "#0284c7",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.95,
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("draw-path-preview", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-path-preview-line",
|
||||
type: "line",
|
||||
source: "draw-path-preview",
|
||||
paint: {
|
||||
"line-color": "#38bdf8",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0.9,
|
||||
"line-dasharray": [1.2, 0.9],
|
||||
},
|
||||
});
|
||||
|
||||
if (hasPathArrowIcon) {
|
||||
map.addLayer({
|
||||
id: "draw-path-preview-arrows",
|
||||
type: "symbol",
|
||||
source: "draw-path-preview",
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 56,
|
||||
"icon-image": PATH_ARROW_ICON_ID,
|
||||
"icon-size": 0.45,
|
||||
"icon-allow-overlap": true,
|
||||
"icon-ignore-placement": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// data thật
|
||||
map.addSource("countries", {
|
||||
type: "geojson",
|
||||
@@ -305,12 +447,19 @@ export default function Map({
|
||||
id: "countries-fill",
|
||||
type: "fill",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"fill-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#22c55e", // selected
|
||||
"#f59e0b", // normal
|
||||
[
|
||||
"==",
|
||||
["coalesce", ["get", "entity_id"], ""],
|
||||
"",
|
||||
],
|
||||
"#ef4444", // no entity
|
||||
"#f59e0b", // linked entity
|
||||
],
|
||||
"fill-opacity": 0.5,
|
||||
},
|
||||
@@ -320,12 +469,56 @@ export default function Map({
|
||||
id: "countries-line",
|
||||
type: "line",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"line-color": "#fbbf24",
|
||||
"line-width": 2,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "routes-line",
|
||||
type: "line",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
paint: {
|
||||
"line-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#22c55e",
|
||||
["==", ["coalesce", ["get", "entity_id"], ""], ""],
|
||||
"#ef4444",
|
||||
"#38bdf8",
|
||||
],
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
1, 2.2,
|
||||
4, 3.2,
|
||||
6, 4.2,
|
||||
],
|
||||
"line-opacity": 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
if (hasPathArrowIcon) {
|
||||
map.addLayer({
|
||||
id: "routes-arrow",
|
||||
type: "symbol",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
map.addSource("places", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
@@ -373,37 +566,38 @@ export default function Map({
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
paint: {
|
||||
"circle-color": "#ef4444",
|
||||
"circle-radius": 3,
|
||||
"circle-color": [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
["coalesce", ["get", "entity_id"], ""],
|
||||
"",
|
||||
],
|
||||
"#ef4444",
|
||||
"#10b981",
|
||||
],
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 1,
|
||||
"circle-opacity": 0.85,
|
||||
},
|
||||
});
|
||||
|
||||
// load icon from /public and hide circle fallback when available
|
||||
try {
|
||||
const imageResponse = await map.loadImage("/point.png");
|
||||
if (!map.hasImage("point-icon")) {
|
||||
map.addImage("point-icon", imageResponse.data);
|
||||
}
|
||||
|
||||
if (!map.getLayer("places-symbol")) {
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
layout: {
|
||||
"icon-image": "point-icon",
|
||||
"icon-size": 0.25,
|
||||
"icon-anchor": "bottom",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
map.setLayoutProperty("places-circle", "visibility", "none");
|
||||
} catch (err) {
|
||||
console.warn("Failed to load point icon, using circle fallback.", err);
|
||||
// Add type-specific point icons (country/castle/kingdom/...) and render with symbol layer.
|
||||
const hasTypeIcons = ensurePointIcons(map);
|
||||
if (hasTypeIcons && !map.getLayer("places-symbol")) {
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
layout: {
|
||||
"icon-image": buildPointIconExpression(),
|
||||
"icon-size": 0.5,
|
||||
"icon-anchor": "center",
|
||||
"icon-allow-overlap": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// init drawing
|
||||
@@ -414,7 +608,13 @@ export default function Map({
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
type: "Feature",
|
||||
properties: { id, kind: "country" },
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
@@ -426,9 +626,11 @@ export default function Map({
|
||||
(id: string | number) => {
|
||||
// ensure edit overlays are cleared when a feature gets removed
|
||||
editingEngineRef.current?.clearEditing();
|
||||
onSelectFeatureIdRef.current?.(null);
|
||||
onDeleteRef.current(id);
|
||||
},
|
||||
(feature) => editingEngineRef.current?.beginEditing(feature)
|
||||
(feature) => editingEngineRef.current?.beginEditing(feature),
|
||||
(id) => onSelectFeatureIdRef.current?.(id)
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
@@ -438,12 +640,58 @@ export default function Map({
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
type: "Feature",
|
||||
properties: { id, kind: "place" },
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupPath = initPath(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupCircle = initCircle(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
map.on("remove", cleanupCircle);
|
||||
map.on("remove", cleanupPath);
|
||||
map.on("remove", cleanupPoint);
|
||||
|
||||
map.on("remove", cleanupSelect);
|
||||
@@ -471,7 +719,7 @@ export default function Map({
|
||||
}
|
||||
}, [draft, applyDraftToMap]);
|
||||
|
||||
return <div id="map" style={{ flex: 1, height: "100vh" }} />;
|
||||
return <div id="map" style={{ width: "100%", height: "100vh" }} />;
|
||||
}
|
||||
|
||||
function applyBackgroundLayerVisibility(
|
||||
@@ -501,3 +749,105 @@ function splitDraftFeatures(fc: FeatureCollection) {
|
||||
|
||||
return { polygons, points };
|
||||
}
|
||||
|
||||
function ensurePathArrowIcon(map: maplibregl.Map): boolean {
|
||||
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
||||
const imageData = createPathArrowImageData();
|
||||
if (!imageData) return false;
|
||||
map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 });
|
||||
return true;
|
||||
}
|
||||
|
||||
function createPathArrowImageData(): ImageData | null {
|
||||
const size = 56;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
ctx.strokeStyle = "#0f172a";
|
||||
ctx.fillStyle = "#38bdf8";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8, 16);
|
||||
ctx.lineTo(28, 16);
|
||||
ctx.lineTo(28, 10);
|
||||
ctx.lineTo(46, 28);
|
||||
ctx.lineTo(28, 46);
|
||||
ctx.lineTo(28, 40);
|
||||
ctx.lineTo(8, 40);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
function ensurePointIcons(map: maplibregl.Map): boolean {
|
||||
let added = false;
|
||||
for (const spec of POINT_ICON_SPECS) {
|
||||
if (map.hasImage(spec.id)) {
|
||||
added = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageData = createPointIconImageData(spec);
|
||||
if (!imageData) continue;
|
||||
map.addImage(spec.id, imageData, { pixelRatio: 2 });
|
||||
added = true;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function createPointIconImageData(spec: PointIconSpec): ImageData | null {
|
||||
const size = 64;
|
||||
const radius = 18;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// soft shadow
|
||||
ctx.fillStyle = "rgba(2, 6, 23, 0.28)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2 + 4, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// icon body
|
||||
ctx.fillStyle = spec.fill;
|
||||
ctx.strokeStyle = spec.stroke;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// short type mark
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font = "700 20px sans-serif";
|
||||
ctx.fillText(spec.label, size / 2, size / 2 + 0.5);
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
||||
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
|
||||
|
||||
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
|
||||
expression.push(typeId, iconId);
|
||||
}
|
||||
|
||||
expression.push(DEFAULT_POINT_ICON_ID);
|
||||
return expression as maplibregl.ExpressionSpecification;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user