preview map editor 60%

This commit is contained in:
taDuc
2026-04-13 21:47:28 +07:00
parent 3023fa947c
commit 458de8dadc
16 changed files with 1664 additions and 1149 deletions

View File

@@ -20,13 +20,12 @@ type MapProps = {
draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
selectedFeatureId: string | number | null;
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;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
respectBindingFilter?: boolean;
};
type PointIconSpec = {
@@ -40,6 +39,9 @@ const DEFAULT_POINT_ICON_ID = "point-icon-default";
const PATH_ARROW_ICON_ID = "path-arrow-icon";
const MAP_MIN_ZOOM = 1;
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 COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
"coalesce",
["get", "MAPCOLOR7"],
@@ -103,6 +105,16 @@ const LINE_COLOR_BY_TYPE: Record<string, string> = {
shipping_route: "#2563eb",
};
const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
attack_route: true,
retreat_route: true,
invasion_route: true,
migration_route: true,
refugee_route: true,
trade_route: true,
shipping_route: true,
};
const POINT_COLOR_BY_TYPE: Record<string, string> = {
person_deathplace: "#dc2626",
person_birthplace: "#2563eb",
@@ -130,7 +142,7 @@ const POINT_ICON_SPECS: PointIconSpec[] = [
{ id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" },
{ id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" },
// Backward-compatible icon ids used by older type_id values.
// Backward-compatible icon ids used by older naming values.
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
@@ -161,26 +173,22 @@ export default function Map({
draft,
backgroundVisibility,
selectedFeatureId,
selectedEntityId,
selectedEntityName,
selectedEntityTypeId,
onSelectFeatureId,
onCreateFeature,
onDeleteFeature,
onUpdateFeature,
allowGeometryEditing = true,
respectBindingFilter = true,
}: MapProps) {
const mapRef = useRef<maplibregl.Map | null>(null);
const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
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);
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
const [zoomLevel, setZoomLevel] = useState(2);
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
@@ -192,7 +200,7 @@ export default function Map({
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!map || !map.isStyleLoaded()) return;
if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
@@ -221,18 +229,6 @@ export default function Map({
selectedFeatureIdRef.current = selectedFeatureId;
}, [selectedFeatureId]);
useEffect(() => {
selectedEntityIdRef.current = selectedEntityId;
}, [selectedEntityId]);
useEffect(() => {
selectedEntityNameRef.current = selectedEntityName;
}, [selectedEntityName]);
useEffect(() => {
selectedEntityTypeIdRef.current = selectedEntityTypeId;
}, [selectedEntityTypeId]);
useEffect(() => {
onSelectFeatureIdRef.current = onSelectFeatureId;
}, [onSelectFeatureId]);
@@ -281,12 +277,14 @@ export default function Map({
// clear all feature-state (selection) to prevent ghost layers after undo
map.removeFeatureState({ source: "countries" });
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current);
const visibleDraft = respectBindingFilter
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
: fc;
const { polygons, points } = splitDraftFeatures(visibleDraft);
countriesSource.setData(polygons);
placesSource.setData(points);
}, []);
}, [respectBindingFilter]);
useEffect(() => {
const map = new maplibregl.Map({
@@ -297,13 +295,6 @@ export default function Map({
style: {
version: 8,
sources: {
rasterBase: {
type: "raster",
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
},
base: {
type: "vector",
tiles: [getVectorTileTemplateUrl()],
@@ -319,15 +310,6 @@ export default function Map({
"background-color": "#0b1220",
},
},
{
id: "raster-base-layer",
type: "raster",
source: "rasterBase",
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear",
},
},
{
id: "graticules-line",
type: "line",
@@ -450,7 +432,6 @@ export default function Map({
mapRef.current = map;
map.on("load", async () => {
const geometryMinZoom = 5;
const syncZoomLevel = () => {
setZoomLevel(roundZoom(map.getZoom()));
};
@@ -633,7 +614,6 @@ export default function Map({
id: "routes-line",
type: "line",
source: "countries",
minzoom: geometryMinZoom,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": [
@@ -661,11 +641,10 @@ export default function Map({
id: "routes-arrow",
type: "symbol",
source: "countries",
minzoom: geometryMinZoom,
filter: [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["coalesce", ["get", "line_mode"], "path"], "path"],
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
],
layout: {
"symbol-placement": "line",
@@ -723,7 +702,6 @@ export default function Map({
id: "places-circle",
type: "circle",
source: "places",
minzoom: geometryMinZoom,
paint: {
"circle-color": [
"case",
@@ -749,7 +727,6 @@ export default function Map({
id: "places-symbol",
type: "symbol",
source: "places",
minzoom: geometryMinZoom,
layout: {
"icon-image": buildPointIconExpression(),
"icon-size": 0.5,
@@ -765,14 +742,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
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,
type: null,
geometry_preset: "polygon",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -783,13 +762,17 @@ export default function Map({
const cleanupSelect = initSelect(
map,
() => modeRef.current,
(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),
allowGeometryEditing
? (id: string | number) => {
// ensure edit overlays are cleared when a feature gets removed
editingEngineRef.current?.clearEditing();
onSelectFeatureIdRef.current?.(null);
onDeleteRef.current?.(id);
}
: undefined,
allowGeometryEditing
? (feature) => editingEngineRef.current?.beginEditing(feature)
: undefined,
(id) => onSelectFeatureIdRef.current?.(id)
);
@@ -798,14 +781,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
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,
type: null,
geometry_preset: "point",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -818,15 +803,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
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,
line_mode: "line",
type: "defense_line",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -839,15 +825,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
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,
line_mode: "path",
type: "attack_route",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -860,14 +847,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
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,
type: null,
geometry_preset: "circle-area",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -888,11 +877,18 @@ export default function Map({
// after everything mounted, push current draft to sources
applyDraftToMap(draftRef.current);
editingEngineRef.current?.bindEditEvents(map);
if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
}
});
return () => map.remove();
}, [applyDraftToMap]);
return () => {
if (mapRef.current === map) {
mapRef.current = null;
}
map.remove();
};
}, [allowGeometryEditing, applyDraftToMap]);
const handleZoomByStep = (delta: number) => {
const map = mapRef.current;
@@ -912,13 +908,13 @@ export default function Map({
useEffect(() => {
applyDraftToMap(draft);
const editingId = editingEngineRef.current?.editingRef.current?.id;
if (editingId !== undefined && editingId !== null) {
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId);
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [draft, selectedFeatureId, applyDraftToMap]);
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
return (
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
@@ -1004,7 +1000,10 @@ function applyBackgroundLayerVisibility(
map: maplibregl.Map,
visibility: BackgroundLayerVisibility
) {
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
for (const layer of BACKGROUND_LAYER_OPTIONS) {
if (layer.id === RASTER_BASE_LAYER_ID) continue;
if (!map.getLayer(layer.id)) continue;
map.setLayoutProperty(
layer.id,
@@ -1014,6 +1013,62 @@ function applyBackgroundLayerVisibility(
}
}
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
if (shouldShow) {
ensureRasterBaseLayer(map);
return;
}
removeRasterBaseLayer(map);
}
function ensureRasterBaseLayer(map: maplibregl.Map) {
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
}
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
: undefined;
map.addLayer(createRasterBaseLayer(), beforeId);
}
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
}
function removeRasterBaseLayer(map: maplibregl.Map) {
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
map.removeLayer(RASTER_BASE_LAYER_ID);
}
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
map.removeSource(RASTER_BASE_SOURCE_ID);
}
}
function createRasterBaseSource() {
return {
type: "raster" as const,
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
};
}
function createRasterBaseLayer() {
return {
id: RASTER_BASE_LAYER_ID,
type: "raster" as const,
source: RASTER_BASE_SOURCE_ID,
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear" as const,
},
};
}
function filterDraftByBinding(
fc: FeatureCollection,
selectedFeatureId: string | number | null
@@ -1158,7 +1213,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
}
function buildPointIconExpression(): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
const expression: unknown[] = ["match", getFeatureTypeExpression()];
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
expression.push(typeId, iconId);
@@ -1169,10 +1224,10 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
}
function buildTypeMatchExpression(
valueByType: Record<string, string | number>,
fallback: string | number
valueByType: Record<string, string | number | boolean>,
fallback: string | number | boolean
): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
const expression: unknown[] = ["match", getFeatureTypeExpression()];
for (const [typeId, value] of Object.entries(valueByType)) {
expression.push(typeId, value);
@@ -1182,6 +1237,15 @@ function buildTypeMatchExpression(
return expression as maplibregl.ExpressionSpecification;
}
function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
return [
"coalesce",
["get", "type"],
["get", "entity_type_id"],
"",
] as maplibregl.ExpressionSpecification;
}
function roundZoom(value: number): number {
return Math.round(value * 10) / 10;
}