From 0ebf8e1c65b1600cf6f5df6979fbba5afc2e667a Mon Sep 17 00:00:00 2001 From: taDuc Date: Sun, 24 May 2026 11:57:40 +0700 Subject: [PATCH] feat: add dynamic entity color generation and support for reassigning geometry IDs --- src/app/editor/[id]/page.tsx | 55 ++++++ src/styles/TimelineBar.module.css | 45 +++++ .../editor/ProjectEntityRefsPanel.tsx | 34 +++- .../editor/SelectedGeometryPanel.tsx | 28 +++- src/uhm/components/map/mapUtils.ts | 53 ++++++ src/uhm/components/map/useMapSync.ts | 4 +- src/uhm/components/ui/TimelineBar.tsx | 156 ++++++++++++++++-- src/uhm/lib/editor/state/useEditorState.ts | 51 ++++++ src/uhm/lib/map/styles/shared/pointStyle.ts | 66 +++----- .../lib/map/styles/shared/styleBuilders.ts | 12 +- 10 files changed, 437 insertions(+), 67 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 44b61e5..ba75fcc 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -39,6 +39,7 @@ import { mergeEntitySearchResults, } from "@/uhm/lib/editor/entity/entityBinding"; import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; +import { newId } from "@/uhm/lib/utils/id"; import { loadBackgroundLayerVisibilityFromStorage, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; @@ -1862,6 +1863,58 @@ function EditorPageContent() { setEntityFormStatus, }); + const handleRerollGeometryId = useCallback((oldId: string | number) => { + const nextId = newId(); + editor.changeFeatureId(oldId, nextId); + setSelectedFeatureIds((prev) => prev.map((id) => String(id) === String(oldId) ? nextId : id)); + }, [editor, setSelectedFeatureIds]); + + const handleRerollEntityId = useCallback((oldId: string, nextId: string) => { + const activeEntity = entities.find(e => e.id === oldId); + if (!activeEntity) return; + + // 1. Update snapshotEntityRows + editor.setSnapshotEntityRows((prev) => prev.map((e) => { + if (e && String(e.id) === oldId) { + return { ...e, id: nextId }; + } + return e; + }), `Reroll Entity ID #${oldId} -> #${nextId}`); + + // 2. Update entityCatalog + setEntityCatalog((prev) => prev.map((e) => { + if (e && String(e.id) === oldId) { + return { ...e, id: nextId }; + } + return e; + })); + + // 3. Update selectedGeometryEntityIds + setSelectedGeometryEntityIds((prev) => prev.map((id) => id === oldId ? nextId : id)); + + // 4. Update features bound to this entity ID + const featuresToPatch = editor.draft.features.filter((feature) => { + const entityIds = feature.properties.entity_ids || []; + return feature.properties.entity_id === oldId || entityIds.includes(oldId); + }); + if (featuresToPatch.length > 0) { + editor.patchFeaturePropertiesBatch( + featuresToPatch.map((feature) => { + const prevEntityIds = feature.properties.entity_ids || []; + const nextEntityIds = prevEntityIds.map((id) => id === oldId ? nextId : id); + return { + id: feature.properties.id, + patch: buildFeatureEntityPatch(feature, nextEntityIds, [ + ...entities.filter(e => e.id !== oldId), + { id: nextId, name: activeEntity.name, time_start: activeEntity.time_start ?? null, time_end: activeEntity.time_end ?? null } + ]) + }; + }), + "Cập nhật entity ID mới cho các GEO" + ); + } + }, [editor, entities, setEntityCatalog, setSelectedGeometryEntityIds]); + // Tạo entity inline chỉ trong snapshot local, chưa gọi backend cho tới khi commit. const handleCreateEntityOnly = async () => { const name = entityForm.name.trim(); @@ -2311,6 +2364,7 @@ function EditorPageContent() { hasSelectedGeometry={Boolean(selectedFeature)} selectedGeometryTime={selectedGeometryTime} onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry} + onRerollEntityId={handleRerollEntityId} /> setSelectedFeatureIds([])} changeCount={editor.changeCount} onReplayEdit={(id) => setMode("replay", id)} + onRerollGeometryId={handleRerollGeometryId} /> ) : null} diff --git a/src/styles/TimelineBar.module.css b/src/styles/TimelineBar.module.css index 14167f4..5769965 100644 --- a/src/styles/TimelineBar.module.css +++ b/src/styles/TimelineBar.module.css @@ -291,3 +291,48 @@ cursor: not-allowed !important; pointer-events: none !important; } + +.numberWrapper { + display: flex; + align-items: center; + gap: 6px; +} + +.adjustGroup { + display: flex; + align-items: center; + gap: 4px; +} + +.adjustBtn { + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: 600; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + outline: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; +} + +.adjustBtn:hover { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.15); +} + +.adjustBtn:active { + background: rgba(16, 185, 129, 0.25); + border-color: #10b981; +} + +.adjustBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/uhm/components/editor/ProjectEntityRefsPanel.tsx b/src/uhm/components/editor/ProjectEntityRefsPanel.tsx index 81f0daa..ba92585 100644 --- a/src/uhm/components/editor/ProjectEntityRefsPanel.tsx +++ b/src/uhm/components/editor/ProjectEntityRefsPanel.tsx @@ -5,6 +5,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities"; import { useShallow } from "zustand/react/shallow"; import NewBadge from "@/uhm/components/editor/NewBadge"; import { useEditorStore } from "@/uhm/store/editorStore"; +import { newId } from "@/uhm/lib/utils/id"; type Props = { onCreateEntityOnly: () => void; @@ -12,6 +13,7 @@ type Props = { hasSelectedGeometry?: boolean; selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null; onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void; + onRerollEntityId?: (oldId: string, nextId: string) => void; }; export default function ProjectEntityRefsPanel({ @@ -20,6 +22,7 @@ export default function ProjectEntityRefsPanel({ hasSelectedGeometry, selectedGeometryTime, onToggleBindEntityForSelectedGeometry, + onRerollEntityId, }: Props) { const { snapshotEntityRows, @@ -282,8 +285,35 @@ export default function ProjectEntityRefsPanel({ -
- {String(activeEntity.id)} +
+
+ {String(activeEntity.id)} +
+ {activeEntity.source === "inline" && onRerollEntityId && ( + + )}
void; onDeleteFeatures?: (ids: (string | number)[]) => void; onDeselectAll?: () => void; + onRerollGeometryId?: (oldId: string | number) => void; }; export default function SelectedGeometryPanel({ @@ -29,6 +30,7 @@ export default function SelectedGeometryPanel({ onReplayEdit, onDeleteFeatures, onDeselectAll, + onRerollGeometryId, }: Props) { const { geometryMetaForm, @@ -221,8 +223,30 @@ export default function SelectedGeometryPanel({
Thuộc tính GEO
-
- Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity. +
+
+ {isBulkMode ? `Đang chọn ${selectedFeatures.length} geometries` : `ID: ${representativeFeature.properties.id}`} +
+ {!isBulkMode && onRerollGeometryId && ( + + )}
{!isMultiEditValid ? ( diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts index 86a2f5e..e8382d6 100644 --- a/src/uhm/components/map/mapUtils.ts +++ b/src/uhm/components/map/mapUtils.ts @@ -940,3 +940,56 @@ export function clampNumber(value: number, min: number, max: number): number { if (value > max) return max; return value; } + +export function hashStringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue}, 70%, 50%)`; +} + +export function decorateFeaturesWithEntityColors(fc: FeatureCollection): FeatureCollection { + return { + ...fc, + features: fc.features.map((feature) => { + const geomType = feature.geometry?.type; + if (geomType === "Point" || geomType === "MultiPoint") { + // Point - giữ nguyên màu của preset/icon + return feature; + } + + if (geomType === "LineString" || geomType === "MultiLineString") { + const entityIds = getFeatureEntityIds(feature); + if (entityIds.length > 0) { + const sortedCombined = [...entityIds].sort().join("+"); + return { + ...feature, + properties: { + ...feature.properties, + entity_color: hashStringToColor(sortedCombined), + }, + }; + } + return feature; + } + + if (geomType === "Polygon" || geomType === "MultiPolygon") { + const geoId = String(feature.properties?.id || ""); + if (geoId) { + return { + ...feature, + properties: { + ...feature.properties, + entity_color: hashStringToColor(geoId), + }, + }; + } + return feature; + } + + return feature; + }), + }; +} diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index 7afe209..e6389cb 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -15,6 +15,7 @@ import { fitMapToFeatureCollection, setSelectedFeatureState, splitDraftFeatures, + decorateFeaturesWithEntityColors, } from "./mapUtils"; import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay"; @@ -128,7 +129,8 @@ export function useMapSync({ const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current ? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal) : renderFc; - const mapSourceDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current); + const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current); + const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft); const labelTimelineYear = labelTimelineYearRef.current; const { polygons, points } = splitDraftFeatures(mapSourceDraft); const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear); diff --git a/src/uhm/components/ui/TimelineBar.tsx b/src/uhm/components/ui/TimelineBar.tsx index 8fda89c..46c28b5 100644 --- a/src/uhm/components/ui/TimelineBar.tsx +++ b/src/uhm/components/ui/TimelineBar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback, useEffect, useRef, useState } from "react"; import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline"; import styles from "@/styles/TimelineBar.module.css"; @@ -33,14 +34,95 @@ export default function TimelineBar({ const effectiveDisabled = disabled; const safeYear = clampYearValue(year, lower, upper); + const [localYear, setLocalYear] = useState(safeYear); + + // Đồng bộ prop year với localYear khi prop year thay đổi từ bên ngoài + useEffect(() => { + setLocalYear(safeYear); + }, [safeYear]); + + const localYearRef = useRef(localYear); + localYearRef.current = localYear; + + const onYearChangeRef = useRef(onYearChange); + onYearChangeRef.current = onYearChange; + + const debounceTimerRef = useRef(null); + const intervalRef = useRef(null); + const timeoutRef = useRef(null); + const lastTriggeredYearRef = useRef(null); + const lastTriggerTimeRef = useRef(0); + + const commitYearChange = useCallback((nextVal: number) => { + if (nextVal === lastTriggeredYearRef.current) return; + lastTriggeredYearRef.current = nextVal; + lastTriggerTimeRef.current = Date.now(); + onYearChangeRef.current(nextVal); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }, []); + + const handleLocalYearChange = useCallback((nextVal: number) => { + const clamped = clampYearValue(Math.trunc(nextVal), lower, upper); + setLocalYear(clamped); + + const now = Date.now(); + if (now - lastTriggerTimeRef.current >= 1000) { + commitYearChange(clamped); + } else { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + commitYearChange(clamped); + }, 1000); + } + }, [lower, upper, commitYearChange]); + + const startChangingYear = (direction: number) => { + if (effectiveDisabled) return; + const nextVal = localYearRef.current + direction; + handleLocalYearChange(nextVal); + + timeoutRef.current = setTimeout(() => { + intervalRef.current = setInterval(() => { + const currentVal = localYearRef.current; + const targetVal = currentVal + direction; + if (targetVal < lower || targetVal > upper) { + stopChangingYear(); + return; + } + handleLocalYearChange(targetVal); + }, 80); + }, 400); + }; + + const stopChangingYear = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + commitYearChange(localYearRef.current); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (intervalRef.current) clearInterval(intervalRef.current); + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + }; + }, []); + const helperText = isLoading ? "Đang tải geometry theo mốc thời gian..." : statusText || null; - const handleYearChange = (nextYear: number) => { - onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper)); - }; - const handleTimeRangeChange = (nextValue: number) => { if (!onTimeRangeChange) return; const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0; @@ -81,8 +163,10 @@ export default function TimelineBar({ min={lower} max={upper} step={1} - value={safeYear} - onChange={(event) => handleYearChange(Number(event.target.value))} + value={localYear} + onChange={(event) => handleLocalYearChange(Number(event.target.value))} + onMouseUp={() => commitYearChange(localYearRef.current)} + onTouchEnd={() => commitYearChange(localYearRef.current)} disabled={effectiveDisabled} className={styles.slider} aria-label="Timeline year" @@ -90,17 +174,55 @@ export default function TimelineBar({ {formatYear(upper)} - handleYearChange(Number(event.target.value))} - disabled={effectiveDisabled} - className={styles.numberInput} - aria-label="Timeline exact year" - /> +
+ handleLocalYearChange(Number(event.target.value))} + onBlur={() => commitYearChange(localYearRef.current)} + onKeyDown={(e) => { + if (e.key === "Enter") { + commitYearChange(localYearRef.current); + } + }} + disabled={effectiveDisabled} + className={styles.numberInput} + aria-label="Timeline exact year" + /> +
+ + +
+
{typeof timeRange === "number" && onTimeRangeChange ? (