add line | add circle | zoom | selectable geometry | geometry define entities type

This commit is contained in:
taDuc
2026-04-11 10:48:59 +07:00
parent 4969c8cc57
commit 3023fa947c
15 changed files with 1435 additions and 212 deletions

View File

@@ -2,7 +2,7 @@
import { UndoAction } from "@/lib/useEditorState";
type Mode = "draw" | "select" | "idle" | "add-point" | "add-path" | "add-circle";
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
type EntityOption = {
id: string;
name: string;
@@ -108,6 +108,13 @@ export default function Editor({
Add point
</button>
<button
style={getButtonStyle("add-line")}
onClick={() => setMode("add-line")}
>
Add line
</button>
<button
style={getButtonStyle("add-path")}
onClick={() => setMode("add-path")}
@@ -125,6 +132,11 @@ export default function Editor({
<div style={{ marginTop: "12px", fontSize: "14px" }}>
Mode: <b>{mode}</b>
</div>
{mode === "add-line" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
) : null}
{mode === "add-path" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useState } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
@@ -8,6 +8,7 @@ import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles"
import { initDrawing } from "@/lib/drawingEngine";
import { initSelect } from "@/lib/selectingEngine";
import { initPoint } from "@/lib/pointEngine";
import { initLine } from "@/lib/lineEngine";
import { initPath } from "@/lib/pathEngine";
import { initCircle } from "@/lib/circleEngine";
import { createEditingEngine } from "@/lib/editingEngine";
@@ -15,9 +16,10 @@ import { FeatureCollection, Geometry } from "@/lib/useEditorState";
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
type MapProps = {
mode: "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
selectedFeatureId: string | number | null;
selectedEntityId: string | null;
selectedEntityName: string | null;
selectedEntityTypeId: string | null;
@@ -36,22 +38,120 @@ type PointIconSpec = {
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 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 POINT_COLOR_BY_TYPE: Record<string, string> = {
person_deathplace: "#dc2626",
person_birthplace: "#2563eb",
person_activity: "#14b8a6",
temple: "#7c3aed",
capital: "#f59e0b",
city: "#0f766e",
fortress: "#b91c1c",
castle: "#6d28d9",
ruin: "#57534e",
port: "#0c4a6e",
bridge: "#9a3412",
};
const POINT_ICON_SPECS: PointIconSpec[] = [
{ id: "point-icon-person-deathplace", fill: "#dc2626", stroke: "#7f1d1d", label: "D" },
{ id: "point-icon-person-birthplace", fill: "#2563eb", stroke: "#1e3a8a", label: "B" },
{ id: "point-icon-person-activity", fill: "#14b8a6", stroke: "#134e4a", label: "A" },
{ id: "point-icon-temple", fill: "#7c3aed", stroke: "#4c1d95", label: "T" },
{ id: "point-icon-capital", fill: "#f59e0b", stroke: "#92400e", label: "Ca" },
{ id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "Ci" },
{ id: "point-icon-fortress", fill: "#b91c1c", stroke: "#7f1d1d", label: "F" },
{ id: "point-icon-castle", fill: "#6d28d9", stroke: "#4c1d95", label: "Cs" },
{ id: "point-icon-ruin", fill: "#57534e", stroke: "#292524", label: "R" },
{ 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.
{ 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> = {
person_deathplace: "point-icon-person-deathplace",
person_birthplace: "point-icon-person-birthplace",
person_activity: "point-icon-person-activity",
temple: "point-icon-temple",
capital: "point-icon-capital",
city: "point-icon-city",
fortress: "point-icon-fortress",
country: "point-icon-country",
castle: "point-icon-castle",
kingdom: "point-icon-kingdom",
city: "point-icon-city",
ruin: "point-icon-ruin",
port: "point-icon-port",
bridge: "point-icon-bridge",
region: "point-icon-region",
event: "point-icon-event",
};
@@ -60,6 +160,7 @@ export default function Map({
mode,
draft,
backgroundVisibility,
selectedFeatureId,
selectedEntityId,
selectedEntityName,
selectedEntityTypeId,
@@ -72,6 +173,7 @@ export default function Map({
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);
@@ -79,6 +181,8 @@ export default function Map({
const onCreateRef = useRef(onCreateFeature);
const onDeleteRef = useRef(onDeleteFeature);
const onUpdateRef = useRef(onUpdateFeature);
const [zoomLevel, setZoomLevel] = useState(2);
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
@@ -89,6 +193,12 @@ export default function Map({
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-path") {
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
@@ -107,6 +217,10 @@ export default function Map({
draftRef.current = draft;
}, [draft]);
useEffect(() => {
selectedFeatureIdRef.current = selectedFeatureId;
}, [selectedFeatureId]);
useEffect(() => {
selectedEntityIdRef.current = selectedEntityId;
}, [selectedEntityId]);
@@ -167,7 +281,8 @@ export default function Map({
// clear all feature-state (selection) to prevent ghost layers after undo
map.removeFeatureState({ source: "countries" });
const { polygons, points } = splitDraftFeatures(fc);
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current);
const { polygons, points } = splitDraftFeatures(visibleDraft);
countriesSource.setData(polygons);
placesSource.setData(points);
@@ -177,6 +292,8 @@ export default function Map({
const map = new maplibregl.Map({
container: "map",
attributionControl: false,
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
style: {
version: 8,
sources: {
@@ -245,8 +362,8 @@ export default function Map({
source: "base",
"source-layer": "countries",
paint: {
"fill-color": "#334155",
"fill-opacity": 0.28,
"fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
"fill-opacity": 0.38,
},
},
{
@@ -333,10 +450,16 @@ export default function Map({
mapRef.current = map;
map.on("load", async () => {
const placesMinZoom = 5;
const geometryMinZoom = 5;
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);
// preview (drawing)
map.addSource("draw-preview", {
@@ -396,6 +519,26 @@ export default function Map({
},
});
map.addSource("draw-line-preview", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [],
},
});
map.addLayer({
id: "draw-line-preview-line",
type: "line",
source: "draw-line-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
map.addSource("draw-path-preview", {
type: "geojson",
data: {
@@ -459,9 +602,14 @@ export default function Map({
"",
],
"#ef4444", // no entity
"#f59e0b", // linked entity
buildTypeMatchExpression(POLYGON_FILL_BY_TYPE, "#f59e0b"),
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false],
0.6,
buildTypeMatchExpression(POLYGON_OPACITY_BY_TYPE, 0.5),
],
"fill-opacity": 0.5,
},
});
@@ -471,7 +619,12 @@ export default function Map({
source: "countries",
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"line-color": "#fbbf24",
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false],
"#14532d",
buildTypeMatchExpression(POLYGON_STROKE_BY_TYPE, "#fbbf24"),
],
"line-width": 2,
},
});
@@ -480,6 +633,7 @@ export default function Map({
id: "routes-line",
type: "line",
source: "countries",
minzoom: geometryMinZoom,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": [
@@ -488,7 +642,7 @@ export default function Map({
"#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""],
"#ef4444",
"#38bdf8",
buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"),
],
"line-width": [
"interpolate",
@@ -507,7 +661,12 @@ export default function Map({
id: "routes-arrow",
type: "symbol",
source: "countries",
filter: ["==", ["geometry-type"], "LineString"],
minzoom: geometryMinZoom,
filter: [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["coalesce", ["get", "line_mode"], "path"], "path"],
],
layout: {
"symbol-placement": "line",
"symbol-spacing": 60,
@@ -564,7 +723,7 @@ export default function Map({
id: "places-circle",
type: "circle",
source: "places",
minzoom: placesMinZoom,
minzoom: geometryMinZoom,
paint: {
"circle-color": [
"case",
@@ -574,7 +733,7 @@ export default function Map({
"",
],
"#ef4444",
"#10b981",
buildTypeMatchExpression(POINT_COLOR_BY_TYPE, "#10b981"),
],
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
@@ -590,7 +749,7 @@ export default function Map({
id: "places-symbol",
type: "symbol",
source: "places",
minzoom: placesMinZoom,
minzoom: geometryMinZoom,
layout: {
"icon-image": buildPointIconExpression(),
"icon-size": 0.5,
@@ -614,6 +773,7 @@ export default function Map({
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
binding: [],
},
geometry,
});
@@ -646,6 +806,28 @@ export default function Map({
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
binding: [],
},
geometry,
});
}
);
const cleanupLine = initLine(
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,
line_mode: "line",
binding: [],
},
geometry,
});
@@ -665,6 +847,8 @@ export default function Map({
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
line_mode: "path",
binding: [],
},
geometry,
});
@@ -684,6 +868,7 @@ export default function Map({
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
binding: [],
},
geometry,
});
@@ -692,11 +877,13 @@ 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));
// after everything mounted, push current draft to sources
applyDraftToMap(draftRef.current);
@@ -707,6 +894,20 @@ export default function Map({
return () => map.remove();
}, [applyDraftToMap]);
const handleZoomByStep = (delta: number) => {
const map = mapRef.current;
if (!map) return;
const next = clampNumber(zoomLevel + delta, zoomBounds.min, zoomBounds.max);
map.easeTo({ zoom: next, duration: 120 });
};
const handleZoomSliderChange = (nextRaw: number) => {
const map = mapRef.current;
if (!map || !Number.isFinite(nextRaw)) return;
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
map.easeTo({ zoom: next, duration: 80 });
};
// sync draft -> map sources and drop edit overlays if feature vanished
useEffect(() => {
applyDraftToMap(draft);
@@ -717,9 +918,86 @@ export default function Map({
editingEngineRef.current?.clearEditing();
}
}
}, [draft, applyDraftToMap]);
}, [draft, selectedFeatureId, applyDraftToMap]);
return <div id="map" style={{ width: "100%", height: "100vh" }} />;
return (
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
<div id="map" style={{ width: "100%", height: "100%" }} />
<div
style={{
position: "absolute",
top: "10px",
left: "16px",
right: "16px",
zIndex: 12,
pointerEvents: "none",
}}
>
<div
style={{
maxWidth: "520px",
margin: "0 auto",
display: "flex",
alignItems: "center",
gap: "10px",
background: "rgba(15, 23, 42, 0.88)",
border: "1px solid rgba(148, 163, 184, 0.38)",
borderRadius: "999px",
padding: "8px 12px",
color: "#e2e8f0",
backdropFilter: "blur(3px)",
pointerEvents: "auto",
}}
>
<button
type="button"
onClick={() => handleZoomByStep(-0.8)}
style={zoomButtonStyle}
aria-label="Zoom out"
>
-
</button>
<input
type="range"
min={zoomBounds.min}
max={zoomBounds.max}
step={0.1}
value={zoomLevel}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
style={{
flex: 1,
accentColor: "#38bdf8",
cursor: "pointer",
}}
aria-label="Map zoom"
/>
<button
type="button"
onClick={() => handleZoomByStep(0.8)}
style={zoomButtonStyle}
aria-label="Zoom in"
>
+
</button>
<div
style={{
minWidth: "56px",
textAlign: "right",
fontSize: "12px",
color: "#cbd5e1",
fontVariantNumeric: "tabular-nums",
}}
>
{zoomLevel.toFixed(1)}x
</div>
</div>
</div>
</div>
);
}
function applyBackgroundLayerVisibility(
@@ -736,6 +1014,44 @@ function applyBackgroundLayerVisibility(
}
}
function filterDraftByBinding(
fc: FeatureCollection,
selectedFeatureId: string | number | null
): FeatureCollection {
const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null;
if (selectedId === null) {
return {
...fc,
features: fc.features.filter((feature) => !normalizeBindingIds(feature.properties.binding).length),
};
}
return {
...fc,
features: fc.features.filter((feature) => {
const featureId = String(feature.properties.id);
if (featureId === selectedId) return true;
const bindingIds = normalizeBindingIds(feature.properties.binding);
if (!bindingIds.length) return true;
return bindingIds.includes(selectedId);
}),
};
}
function normalizeBindingIds(rawBinding: unknown): string[] {
if (!Array.isArray(rawBinding)) return [];
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of rawBinding) {
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
const id = String(rawId).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
function splitDraftFeatures(fc: FeatureCollection) {
const polygons = {
type: "FeatureCollection",
@@ -835,7 +1151,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "700 20px sans-serif";
ctx.font = spec.label.length > 1 ? "700 15px sans-serif" : "700 20px sans-serif";
ctx.fillText(spec.label, size / 2, size / 2 + 0.5);
return ctx.getImageData(0, 0, size, size);
@@ -851,3 +1167,41 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
expression.push(DEFAULT_POINT_ICON_ID);
return expression as maplibregl.ExpressionSpecification;
}
function buildTypeMatchExpression(
valueByType: Record<string, string | number>,
fallback: string | number
): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
for (const [typeId, value] of Object.entries(valueByType)) {
expression.push(typeId, value);
}
expression.push(fallback);
return expression as maplibregl.ExpressionSpecification;
}
function roundZoom(value: number): number {
return Math.round(value * 10) / 10;
}
function clampNumber(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
const zoomButtonStyle: React.CSSProperties = {
width: "28px",
height: "28px",
borderRadius: "999px",
border: "1px solid #334155",
background: "#1e293b",
color: "#f8fafc",
fontSize: "18px",
lineHeight: "1",
cursor: "pointer",
display: "grid",
placeItems: "center",
};

View File

@@ -0,0 +1,546 @@
"use client";
import { type CSSProperties } from "react";
import { Entity } from "@/api/entities";
import { Feature } from "@/lib/useEditorState";
import {
EntityTypeGroupId,
EntityTypeOption,
findEntityTypeOption,
groupEntityTypeOptions,
} from "@/lib/entityTypeOptions";
type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
type Props = {
selectedFeature: Feature | null;
selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string;
entities: Entity[];
selectedGeometryEntityIds: string[];
onEntityIdsChange: (values: string[]) => void;
entitySearchQuery: string;
onEntitySearchQueryChange: (value: string) => void;
entitySearchResults: Entity[];
selectedSearchEntityId: string | null;
onSelectSearchEntityId: (value: string | null) => void;
onAddSelectedSearchEntity: () => void;
isEntitySearchLoading: boolean;
entityForm: EntityFormState;
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
entityTypeOptions: EntityTypeOption[];
geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
isEntitySubmitting: boolean;
onCreateEntityAndAttach: () => void;
onApplyEntitiesForSelectedGeometry: () => void;
changeCount: number;
entityFormStatus: string | null;
};
export default function SelectedGeometryPanel({
selectedFeature,
selectedFeatureEntitySummary,
selectedFeatureBindingSummary,
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
entitySearchQuery,
onEntitySearchQueryChange,
entitySearchResults,
selectedSearchEntityId,
onSelectSearchEntityId,
onAddSelectedSearchEntity,
isEntitySearchLoading,
entityForm,
onEntityFormChange,
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
isEntitySubmitting,
onCreateEntityAndAttach,
onApplyEntitiesForSelectedGeometry,
changeCount,
entityFormStatus,
}: Props) {
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const allowedGroupIds = selectedFeature
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type)
: [];
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
const hasCurrentTypeOption = entityTypeOptions.some((option) => option.value === entityForm.type_id);
const geometryBucket = selectedFeature ? toGeometryBucket(selectedFeature.geometry.type) : null;
const selectedTypeBucket = selectedTypeOption ? toTypeBucket(selectedTypeOption) : null;
const isGeometryCompatible =
geometryBucket && selectedTypeBucket
? geometryBucket === selectedTypeBucket
: true;
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Selected Geometry
</div>
{!selectedFeature ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
Vào mode Select chọn 1 geometry đ điền entity.
</div>
) : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
</div>
{selectedGeometryEntityIds.length ? (
<div style={{ display: "grid", gap: "6px" }}>
{selectedGeometryEntityIds.map((entityId) => {
const entity = entities.find((item) => item.id === entityId) || null;
const label = entity?.name
? `${entity.name} (${entityId})`
: entityId;
return (
<div
key={entityId}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
background: "#111827",
border: "1px solid #334155",
borderRadius: "6px",
padding: "6px 8px",
}}
>
<span style={{ color: "#e2e8f0" }}>{label}</span>
<button
type="button"
onClick={() =>
onEntityIdsChange(
selectedGeometryEntityIds.filter((id) => id !== entityId)
)
}
disabled={isEntitySubmitting}
style={removeButtonStyle}
>
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<input
value={entitySearchQuery}
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
placeholder="Search entity theo name..."
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<select
value={selectedSearchEntityId || ""}
onChange={(event) =>
onSelectSearchEntityId(event.target.value ? event.target.value : null)
}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={entityInputStyle}
>
<option value="">-- Chọn entity từ kết quả search --</option>
{entitySearchResults.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name} ({entity.id})
</option>
))}
</select>
<button
type="button"
onClick={onAddSelectedSearchEntity}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={secondaryActionButtonStyle}
>
Chọn đ thêm
</button>
{isEntitySearchLoading ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
Đang tìm entity...
</div>
) : null}
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Geometry phải ít nhất 1 entity đ Save.
</div>
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => onEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Entity type presets
</div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Chọn nhanh theo nhóm hình học bạn cần vẽ.
</div>
{visibleGroupedEntityTypeOptions.map((group) => {
const color = GROUP_COLORS[group.id];
return (
<div
key={group.id}
style={{
border: `1px solid ${color.border}`,
borderRadius: "8px",
padding: "8px",
background: color.background,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: "8px",
alignItems: "center",
marginBottom: "6px",
}}
>
<span style={{ color: color.text, fontWeight: 600, fontSize: "12px" }}>
{group.label}
</span>
<span style={{ color: color.mutedText, fontSize: "11px" }}>
{group.geometryLabel}
</span>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{group.options.map((option) => {
const active = option.value === entityForm.type_id;
return (
<button
key={option.value}
type="button"
onClick={() => onEntityFormChange("type_id", option.value)}
disabled={isEntitySubmitting}
style={{
border: active
? `1px solid ${color.activeBorder}`
: `1px solid ${color.chipBorder}`,
borderRadius: "999px",
padding: "4px 9px",
background: active ? color.activeBackground : color.chipBackground,
color: active ? color.activeText : color.text,
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
fontSize: "11px",
fontWeight: active ? 600 : 500,
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
{option.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
<select
value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
) : null}
{visibleGroupedEntityTypeOptions.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : entityForm.type_id ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{entityForm.type_id}</b>
</div>
) : null}
{!isGeometryCompatible && selectedTypeOption ? (
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
Type <b>{selectedTypeOption.label}</b> hợp với {selectedTypeBucket}, nhưng geometry hiện tại {geometryBucket}.
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.binding}
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
onClick={onCreateEntityAndAttach}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Tạo Entity + Gắn
</button>
<button
onClick={onApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Áp dụng Entities + Metadata
</button>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Geometry mới sẽ lưu entity khi bấm Save.
</div>
) : null}
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
{entityFormStatus}
</div>
) : null}
</div>
)}
</div>
);
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const removeButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "4px 8px",
cursor: "pointer",
background: "#7f1d1d",
color: "#ffffff",
fontSize: "12px",
};
const secondaryActionButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#1d4ed8",
color: "#ffffff",
};
const GROUP_COLORS: Record<
EntityTypeGroupId,
{
background: string;
border: string;
text: string;
mutedText: string;
chipBackground: string;
chipBorder: string;
activeBackground: string;
activeBorder: string;
activeText: string;
}
> = {
split: {
background: "#3f1d24",
border: "#7f1d1d",
text: "#fecaca",
mutedText: "#fda4af",
chipBackground: "#4c1d27",
chipBorder: "#7f1d1d",
activeBackground: "#7f1d1d",
activeBorder: "#fecaca",
activeText: "#ffffff",
},
route: {
background: "#082f49",
border: "#0c4a6e",
text: "#bae6fd",
mutedText: "#7dd3fc",
chipBackground: "#0c4a6e",
chipBorder: "#155e75",
activeBackground: "#0369a1",
activeBorder: "#bae6fd",
activeText: "#f0f9ff",
},
area_polygon: {
background: "#1f3a2a",
border: "#166534",
text: "#bbf7d0",
mutedText: "#86efac",
chipBackground: "#14532d",
chipBorder: "#166534",
activeBackground: "#15803d",
activeBorder: "#bbf7d0",
activeText: "#f0fdf4",
},
area_circle: {
background: "#3f2b05",
border: "#92400e",
text: "#fde68a",
mutedText: "#fcd34d",
chipBackground: "#78350f",
chipBorder: "#92400e",
activeBackground: "#b45309",
activeBorder: "#fde68a",
activeText: "#fffbeb",
},
point: {
background: "#2e1065",
border: "#6d28d9",
text: "#ddd6fe",
mutedText: "#c4b5fd",
chipBackground: "#4c1d95",
chipBorder: "#6d28d9",
activeBackground: "#7c3aed",
activeBorder: "#ddd6fe",
activeText: "#f5f3ff",
},
};
type GeometryBucket = "point" | "line" | "polygon";
function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "line";
}
return "polygon";
}
function toTypeBucket(option: EntityTypeOption): GeometryBucket {
if (option.geometryPreset === "point") {
return "point";
}
if (option.geometryPreset === "line") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForGeometry(
geometryType: Feature["geometry"]["type"]
): EntityTypeGroupId[] {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return ["point"];
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return ["split", "route"];
}
return ["area_polygon", "area_circle"];
}