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

@@ -3,18 +3,10 @@
import { UndoAction } from "@/lib/useEditorState";
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
type EntityOption = {
id: string;
name: string;
geometry_count?: number;
};
type Props = {
mode: Mode;
setMode: (mode: Mode) => void;
entities: EntityOption[];
selectedEntityId: string | null;
onSelectEntityId: (entityId: string | null) => void;
entityStatus?: string | null;
onUndo: () => void;
onSave: () => void;
@@ -26,9 +18,6 @@ type Props = {
export default function Editor({
mode,
setMode,
entities,
selectedEntityId,
onSelectEntityId,
entityStatus,
onUndo,
onSave,
@@ -148,48 +137,21 @@ export default function Editor({
</div>
) : null}
<div
style={{
marginTop: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "13px", color: "#e2e8f0" }}>
Entity mặc đnh cho geometry mới
</div>
<select
value={selectedEntityId || ""}
onChange={(event) => onSelectEntityId(event.target.value || null)}
{entityStatus ? (
<div
style={{
width: "100%",
padding: "6px 8px",
borderRadius: "4px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
fontSize: "13px",
marginTop: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
color: "#fca5a5",
fontSize: "12px",
}}
>
<option value="">Không gắn entity</option>
{entities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name}
{typeof entity.geometry_count === "number" ? ` (${entity.geometry_count})` : ""}
</option>
))}
</select>
<div style={{ marginTop: "6px", color: "#94a3b8", fontSize: "12px" }}>
Geometry mới tạo sẽ gắn sẵn entity này, bạn thể thêm nhiều entity panel bên phải.
{entityStatus}
</div>
{entityStatus ? (
<div style={{ marginTop: "6px", color: "#fca5a5", fontSize: "12px" }}>
{entityStatus}
</div>
) : null}
</div>
) : null}
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
<button

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;
}

View File

@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
import { Entity } from "@/api/entities";
import { Feature } from "@/lib/useEditorState";
import {
EntityGeometryPreset,
EntityTypeGroupId,
EntityTypeOption,
findEntityTypeOption,
@@ -74,20 +75,22 @@ export default function SelectedGeometryPanel({
entityFormStatus,
}: Props) {
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const allowedGroupIds = selectedFeature
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type)
const featureGeometryPreset = selectedFeature
? resolveFeatureGeometryPreset(selectedFeature)
: null;
const allowedGroupIds = featureGeometryPreset
? getAllowedGroupIdsForPreset(featureGeometryPreset)
: [];
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;
const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) =>
group.options.some((option) => option.value === entityForm.type_id)
);
const isGeometryCompatible = selectedTypeOption
? allowedGroupIds.includes(selectedTypeOption.groupId)
: true;
return (
<div
@@ -117,6 +120,9 @@ export default function SelectedGeometryPanel({
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
@@ -166,60 +172,10 @@ export default function SelectedGeometryPanel({
</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",
@@ -230,80 +186,146 @@ export default function SelectedGeometryPanel({
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Entity type presets
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Metadata geometry (áp dụng cho cả 2 luồng bên dưới)
</div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Chọn nhanh theo nhóm hình học bạn cần vẽ.
`time_start`, `time_end`, `binding` sẽ đưc dùng cho cả luồng gắn entity sẵn
luồng tạo entity mới.
</div>
<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}
/>
</div>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1f3b5a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 1: Gắn entity sẵn
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
</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}
>
Thêm entity đã chọn vào danh sách gắn
</button>
{isEntitySearchLoading ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
Đang tìm entity...
</div>
) : null}
<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,
fontWeight: 600,
}}
>
Áp dụng danh sách entity + metadata
</button>
</div>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 2: Tạo entity mới rồi gắn
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi chưa entity phù hợp. Điền form bên dưới rồi bấm nút tạo + gắn.
</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>
<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={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Chọn loại entity
</div>
<select
value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentTypeOption && entityForm.type_id ? (
{!hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
@@ -334,32 +356,10 @@ export default function SelectedGeometryPanel({
{!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}.
Type <b>{selectedTypeOption.label}</b> thuộc nhóm <b>{selectedTypeOption.groupLabel}</b>, nhưng geometry hiện tại <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</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}
@@ -371,26 +371,12 @@ export default function SelectedGeometryPanel({
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
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
Tạo entity mới + gắn + áp metadata
</button>
</div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
@@ -438,80 +424,42 @@ const secondaryActionButtonStyle: CSSProperties = {
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;
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
if (explicitPreset) return explicitPreset;
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (semanticType) {
const option = findEntityTypeOption(semanticType);
if (option) return option.geometryPreset;
}
> = {
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";
return mapGeometryTypeToPreset(feature.geometry.type);
}
function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket {
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
normalized === "point" ||
normalized === "line" ||
normalized === "polygon" ||
normalized === "circle-area"
) {
return normalized;
}
return null;
}
function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): EntityGeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
@@ -521,26 +469,28 @@ function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBu
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"]
function getAllowedGroupIdsForPreset(
geometryPreset: EntityGeometryPreset
): EntityTypeGroupId[] {
if (geometryType === "Point" || geometryType === "MultiPoint") {
if (geometryPreset === "point") {
return ["point"];
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return ["split", "route"];
if (geometryPreset === "line") {
return ["line"];
}
return ["area_polygon", "area_circle"];
if (geometryPreset === "circle-area") {
return ["circle"];
}
return ["polygon"];
}
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
}