finish refactor pre merge
This commit is contained in:
@@ -14,9 +14,31 @@ import { initCircle } from "@/lib/engine/circleEngine";
|
||||
import { createEditingEngine } from "@/lib/engine/editingEngine";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
|
||||
import { EMPTY_FEATURE_COLLECTION } from "@/lib/geo/constants";
|
||||
import {
|
||||
DEFAULT_POINT_ICON_ID,
|
||||
FEATURE_STATE_SOURCE_IDS,
|
||||
MAP_MAX_ZOOM,
|
||||
MAP_MIN_ZOOM,
|
||||
PATH_ARROW_ICON_ID,
|
||||
PATH_ARROW_SOURCE_ID,
|
||||
POINT_ICON_URL,
|
||||
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
|
||||
RASTER_BASE_LAYER_ID,
|
||||
RASTER_BASE_SOURCE_ID,
|
||||
} from "@/lib/map/constants";
|
||||
import {
|
||||
COUNTRY_FILL_COLOR_EXPRESSION,
|
||||
LINE_COLOR_BY_TYPE,
|
||||
PATH_RENDER_BY_TYPE,
|
||||
POLYGON_FILL_BY_TYPE,
|
||||
POLYGON_OPACITY_BY_TYPE,
|
||||
POLYGON_STROKE_BY_TYPE,
|
||||
} from "@/lib/map/style";
|
||||
|
||||
type MapProps = {
|
||||
mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
mode: EditorMode;
|
||||
draft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
selectedFeatureId: string | number | null;
|
||||
@@ -37,93 +59,6 @@ type EngineBinding = {
|
||||
clearSelection?: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||
const POINT_ICON_URL = "/point.png";
|
||||
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 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"],
|
||||
["get", "MAPCOLOR9"],
|
||||
["get", "scalerank"],
|
||||
0,
|
||||
];
|
||||
const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||
"match",
|
||||
COUNTRY_COLOR_KEY_EXPRESSION,
|
||||
1, "#ef4444",
|
||||
2, "#f97316",
|
||||
3, "#f59e0b",
|
||||
4, "#22c55e",
|
||||
5, "#06b6d4",
|
||||
6, "#3b82f6",
|
||||
7, "#8b5cf6",
|
||||
8, "#a855f7",
|
||||
9, "#d946ef",
|
||||
10, "#14b8a6",
|
||||
"#64748b",
|
||||
];
|
||||
|
||||
const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
||||
country: "#2563eb",
|
||||
state: "#0ea5e9",
|
||||
empire: "#f59e0b",
|
||||
kingdom: "#d97706",
|
||||
war: "#dc2626",
|
||||
battle: "#f43f5e",
|
||||
civilization: "#14b8a6",
|
||||
rebellion_zone: "#7c3aed",
|
||||
};
|
||||
|
||||
const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
||||
country: "#1e3a8a",
|
||||
state: "#0c4a6e",
|
||||
empire: "#7c2d12",
|
||||
kingdom: "#9a3412",
|
||||
war: "#7f1d1d",
|
||||
battle: "#9f1239",
|
||||
civilization: "#134e4a",
|
||||
rebellion_zone: "#4c1d95",
|
||||
};
|
||||
|
||||
const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
||||
war: 0.3,
|
||||
battle: 0.34,
|
||||
civilization: 0.38,
|
||||
rebellion_zone: 0.32,
|
||||
};
|
||||
|
||||
const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||
defense_line: "#f97316",
|
||||
attack_route: "#ef4444",
|
||||
retreat_route: "#94a3b8",
|
||||
invasion_route: "#b91c1c",
|
||||
migration_route: "#0ea5e9",
|
||||
refugee_route: "#06b6d4",
|
||||
trade_route: "#eab308",
|
||||
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,
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
mode,
|
||||
draft,
|
||||
@@ -139,24 +74,57 @@ export default function Map({
|
||||
fitToDraftBounds = false,
|
||||
fitBoundsKey = null,
|
||||
}: MapProps) {
|
||||
// DOM container của map (dùng ref để tránh collision khi render nhiều map).
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
// Nếu init map fail (throw trong onLoad), show overlay thay vì crash âm thầm.
|
||||
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
|
||||
|
||||
// Mirror các flags props để tránh phải re-create map khi props thay đổi.
|
||||
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||
|
||||
// Instance maplibre (được tạo 1 lần khi component mount).
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
// Mirror của props mode để event handlers/engines đọc giá trị mới nhất (tránh stale closure).
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
// Mirror của draft để engines đọc dữ liệu mới nhất trong callbacks.
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
// Mirror của backgroundVisibility để sync visibility khi map đã load.
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
|
||||
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
||||
// Mirror của callback onSelectFeatureId.
|
||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||
// Mirror của callback onCreateFeature.
|
||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||
// Mirror của callback onDeleteFeature.
|
||||
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
||||
// Mirror của callback onUpdateFeature.
|
||||
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
||||
// Zoom hiện tại để render UI zoom control.
|
||||
const [zoomLevel, setZoomLevel] = useState(2);
|
||||
// Min/max zoom dùng cho slider và clamp thao tác zoom.
|
||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||
|
||||
// Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần.
|
||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||
// Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp).
|
||||
const fitBoundsAppliedRef = useRef(false);
|
||||
// Danh sách cleanup fns để dọn listeners/engines khi unmount map.
|
||||
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||
// Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode.
|
||||
const engineBindingsRef = useRef<Partial<Record<MapProps["mode"], EngineBinding>>>({});
|
||||
// Lưu mode trước đó để cancel engine đúng lúc khi switch mode.
|
||||
const previousModeRef = useRef<MapProps["mode"]>(mode);
|
||||
|
||||
useEffect(() => {
|
||||
fitToDraftBoundsRef.current = fitToDraftBounds;
|
||||
}, [fitToDraftBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
respectBindingFilterRef.current = respectBindingFilter;
|
||||
}, [respectBindingFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
modeRef.current = mode;
|
||||
}, [mode]);
|
||||
@@ -266,7 +234,7 @@ export default function Map({
|
||||
}
|
||||
}
|
||||
|
||||
const visibleDraft = respectBindingFilter
|
||||
const visibleDraft = respectBindingFilterRef.current
|
||||
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
||||
: fc;
|
||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||
@@ -283,14 +251,17 @@ export default function Map({
|
||||
if (mapRef.current !== map) return;
|
||||
setSelectedFeatureState(map, selectedId, true);
|
||||
});
|
||||
if (fitToDraftBounds && !fitBoundsAppliedRef.current) {
|
||||
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
||||
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
||||
}
|
||||
}, [fitToDraftBounds, respectBindingFilter]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: "map",
|
||||
container,
|
||||
attributionControl: false,
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
@@ -434,24 +405,25 @@ export default function Map({
|
||||
mapRef.current = map;
|
||||
|
||||
map.on("load", async () => {
|
||||
const syncZoomLevel = () => {
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
};
|
||||
try {
|
||||
const syncZoomLevel = () => {
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
};
|
||||
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
|
||||
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||
syncZoomLevel();
|
||||
map.on("zoom", syncZoomLevel);
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
|
||||
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||
syncZoomLevel();
|
||||
map.on("zoom", syncZoomLevel);
|
||||
|
||||
// preview (drawing)
|
||||
map.addSource("draw-preview", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
// preview (drawing)
|
||||
map.addSource("draw-preview", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-preview-fill",
|
||||
@@ -821,7 +793,7 @@ export default function Map({
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
@@ -860,7 +832,7 @@ export default function Map({
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
@@ -882,7 +854,7 @@ export default function Map({
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
@@ -904,7 +876,7 @@ export default function Map({
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
@@ -926,7 +898,7 @@ export default function Map({
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
@@ -968,6 +940,10 @@ export default function Map({
|
||||
if (allowGeometryEditing) {
|
||||
editingEngineRef.current?.bindEditEvents(map);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Map initialization failed", err);
|
||||
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -1011,7 +987,39 @@ export default function Map({
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height, position: "relative" }}>
|
||||
<div id="map" style={{ width: "100%", height: "100%" }} />
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
|
||||
{fatalInitError ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: "24px",
|
||||
background: "rgba(2, 6, 23, 0.78)",
|
||||
color: "#e2e8f0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "680px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(15, 23, 42, 0.92)",
|
||||
padding: "14px 16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 800, marginBottom: "6px" }}>
|
||||
Map khong khoi tao duoc
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
{fatalInitError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -1203,12 +1211,16 @@ function normalizeBindingIds(rawBinding: unknown): string[] {
|
||||
function splitDraftFeatures(fc: FeatureCollection) {
|
||||
const polygons = {
|
||||
type: "FeatureCollection",
|
||||
features: fc.features.filter((f) => f.geometry.type !== "Point"),
|
||||
features: fc.features.filter((f) =>
|
||||
f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
|
||||
),
|
||||
} as FeatureCollection;
|
||||
|
||||
const points = {
|
||||
type: "FeatureCollection",
|
||||
features: fc.features.filter((f) => f.geometry.type === "Point"),
|
||||
features: fc.features.filter((f) =>
|
||||
f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
|
||||
),
|
||||
} as FeatureCollection;
|
||||
|
||||
return { polygons, points };
|
||||
@@ -1540,22 +1552,27 @@ function createPathArrowImageData(): ImageData | null {
|
||||
|
||||
function addPointSymbolLayer(map: maplibregl.Map) {
|
||||
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
||||
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
||||
try {
|
||||
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
||||
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
layout: {
|
||||
"icon-image": DEFAULT_POINT_ICON_ID,
|
||||
"icon-size": 0.06,
|
||||
"icon-anchor": "center",
|
||||
"icon-allow-overlap": true,
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
layout: {
|
||||
"icon-image": DEFAULT_POINT_ICON_ID,
|
||||
"icon-size": 0.06,
|
||||
"icon-anchor": "center",
|
||||
"icon-allow-overlap": true,
|
||||
},
|
||||
});
|
||||
|
||||
if (map.getLayer("places-circle")) {
|
||||
map.setLayoutProperty("places-circle", "visibility", "none");
|
||||
if (map.getLayer("places-circle")) {
|
||||
map.setLayoutProperty("places-circle", "visibility", "none");
|
||||
}
|
||||
} catch (err) {
|
||||
// Map might have been removed while icon was loading.
|
||||
console.warn("Add point symbol layer skipped", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1602,6 +1619,14 @@ function roundZoom(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function buildClientFeatureId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback đảm bảo tránh collision khi user tạo nhiều feature trong cùng 1ms.
|
||||
return `feature-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
|
||||
Reference in New Issue
Block a user