feat: add dynamic entity color generation and support for reassigning geometry IDs
This commit is contained in:
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||
{String(activeEntity.id)}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
|
||||
{String(activeEntity.id)}
|
||||
</div>
|
||||
{activeEntity.source === "inline" && onRerollEntityId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextId = newId();
|
||||
onRerollEntityId(String(activeEntity.id), nextId);
|
||||
setActiveEntityId(nextId);
|
||||
}}
|
||||
disabled={isEntitySubmitting}
|
||||
title="Đổi mã ID để sinh ngẫu nhiên màu sắc mới cho thực thể này"
|
||||
style={{
|
||||
border: "1px solid #0f766e",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
background: "transparent",
|
||||
color: "#14b8a6",
|
||||
fontSize: "11px",
|
||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Đổi màu (Reroll ID)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
value={editName}
|
||||
|
||||
@@ -20,6 +20,7 @@ type Props = {
|
||||
onReplayEdit?: (id: string | number) => 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({
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||
Thuộc tính GEO
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
|
||||
{isBulkMode ? `Đang chọn ${selectedFeatures.length} geometries` : `ID: ${representativeFeature.properties.id}`}
|
||||
</div>
|
||||
{!isBulkMode && onRerollGeometryId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRerollGeometryId(representativeFeature.properties.id)}
|
||||
title="Đổi mã ID để sinh ngẫu nhiên màu sắc mới cho hình học này"
|
||||
style={{
|
||||
border: "1px solid #0f766e",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
background: "transparent",
|
||||
color: "#14b8a6",
|
||||
fontSize: "11px",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Đổi màu (Reroll ID)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMultiEditValid ? (
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<NodeJS.Timeout | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastTriggeredYearRef = useRef<number | null>(null);
|
||||
const lastTriggerTimeRef = useRef<number>(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({
|
||||
<span className={styles.labelBoundsRight}>
|
||||
{formatYear(upper)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeYear}
|
||||
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||
disabled={effectiveDisabled}
|
||||
className={styles.numberInput}
|
||||
aria-label="Timeline exact year"
|
||||
/>
|
||||
<div className={styles.numberWrapper}>
|
||||
<input
|
||||
type="number"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={localYear}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<div className={styles.adjustGroup}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => startChangingYear(-1)}
|
||||
onMouseUp={stopChangingYear}
|
||||
onMouseLeave={stopChangingYear}
|
||||
onTouchStart={() => startChangingYear(-1)}
|
||||
onTouchEnd={stopChangingYear}
|
||||
disabled={effectiveDisabled}
|
||||
className={styles.adjustBtn}
|
||||
title="Giảm 1 năm"
|
||||
aria-label="Giảm 1 năm"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => startChangingYear(1)}
|
||||
onMouseUp={stopChangingYear}
|
||||
onMouseLeave={stopChangingYear}
|
||||
onTouchStart={() => startChangingYear(1)}
|
||||
onTouchEnd={stopChangingYear}
|
||||
disabled={effectiveDisabled}
|
||||
className={styles.adjustBtn}
|
||||
title="Tăng 1 năm"
|
||||
aria-label="Tăng 1 năm"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{typeof timeRange === "number" && onTimeRangeChange ? (
|
||||
<label
|
||||
title="time_range (0-30)"
|
||||
|
||||
Reference in New Issue
Block a user