preview map editor 60%
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user