feat: add dynamic entity color generation and support for reassigning geometry IDs
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
|||||||
mergeEntitySearchResults,
|
mergeEntitySearchResults,
|
||||||
} from "@/uhm/lib/editor/entity/entityBinding";
|
} from "@/uhm/lib/editor/entity/entityBinding";
|
||||||
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
||||||
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
import {
|
import {
|
||||||
loadBackgroundLayerVisibilityFromStorage,
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
@@ -1862,6 +1863,58 @@ function EditorPageContent() {
|
|||||||
setEntityFormStatus,
|
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.
|
// Tạo entity inline chỉ trong snapshot local, chưa gọi backend cho tới khi commit.
|
||||||
const handleCreateEntityOnly = async () => {
|
const handleCreateEntityOnly = async () => {
|
||||||
const name = entityForm.name.trim();
|
const name = entityForm.name.trim();
|
||||||
@@ -2311,6 +2364,7 @@ function EditorPageContent() {
|
|||||||
hasSelectedGeometry={Boolean(selectedFeature)}
|
hasSelectedGeometry={Boolean(selectedFeature)}
|
||||||
selectedGeometryTime={selectedGeometryTime}
|
selectedGeometryTime={selectedGeometryTime}
|
||||||
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
||||||
|
onRerollEntityId={handleRerollEntityId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WikiSidebarPanel
|
<WikiSidebarPanel
|
||||||
@@ -2333,6 +2387,7 @@ function EditorPageContent() {
|
|||||||
onDeselectAll={() => setSelectedFeatureIds([])}
|
onDeselectAll={() => setSelectedFeatureIds([])}
|
||||||
changeCount={editor.changeCount}
|
changeCount={editor.changeCount}
|
||||||
onReplayEdit={(id) => setMode("replay", id)}
|
onReplayEdit={(id) => setMode("replay", id)}
|
||||||
|
onRerollGeometryId={handleRerollGeometryId}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -291,3 +291,48 @@
|
|||||||
cursor: not-allowed !important;
|
cursor: not-allowed !important;
|
||||||
pointer-events: none !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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onCreateEntityOnly: () => void;
|
onCreateEntityOnly: () => void;
|
||||||
@@ -12,6 +13,7 @@ type Props = {
|
|||||||
hasSelectedGeometry?: boolean;
|
hasSelectedGeometry?: boolean;
|
||||||
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
|
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
|
||||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||||
|
onRerollEntityId?: (oldId: string, nextId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectEntityRefsPanel({
|
export default function ProjectEntityRefsPanel({
|
||||||
@@ -20,6 +22,7 @@ export default function ProjectEntityRefsPanel({
|
|||||||
hasSelectedGeometry,
|
hasSelectedGeometry,
|
||||||
selectedGeometryTime,
|
selectedGeometryTime,
|
||||||
onToggleBindEntityForSelectedGeometry,
|
onToggleBindEntityForSelectedGeometry,
|
||||||
|
onRerollEntityId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
snapshotEntityRows,
|
snapshotEntityRows,
|
||||||
@@ -282,8 +285,35 @@ export default function ProjectEntityRefsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
{String(activeEntity.id)}
|
<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>
|
</div>
|
||||||
<input
|
<input
|
||||||
value={editName}
|
value={editName}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Props = {
|
|||||||
onReplayEdit?: (id: string | number) => void;
|
onReplayEdit?: (id: string | number) => void;
|
||||||
onDeleteFeatures?: (ids: (string | number)[]) => void;
|
onDeleteFeatures?: (ids: (string | number)[]) => void;
|
||||||
onDeselectAll?: () => void;
|
onDeselectAll?: () => void;
|
||||||
|
onRerollGeometryId?: (oldId: string | number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SelectedGeometryPanel({
|
export default function SelectedGeometryPanel({
|
||||||
@@ -29,6 +30,7 @@ export default function SelectedGeometryPanel({
|
|||||||
onReplayEdit,
|
onReplayEdit,
|
||||||
onDeleteFeatures,
|
onDeleteFeatures,
|
||||||
onDeselectAll,
|
onDeselectAll,
|
||||||
|
onRerollGeometryId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
@@ -221,8 +223,30 @@ export default function SelectedGeometryPanel({
|
|||||||
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
Thuộc tính GEO
|
Thuộc tính GEO
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
|
<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>
|
</div>
|
||||||
|
|
||||||
{!isMultiEditValid ? (
|
{!isMultiEditValid ? (
|
||||||
|
|||||||
@@ -940,3 +940,56 @@ export function clampNumber(value: number, min: number, max: number): number {
|
|||||||
if (value > max) return max;
|
if (value > max) return max;
|
||||||
return value;
|
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,
|
fitMapToFeatureCollection,
|
||||||
setSelectedFeatureState,
|
setSelectedFeatureState,
|
||||||
splitDraftFeatures,
|
splitDraftFeatures,
|
||||||
|
decorateFeaturesWithEntityColors,
|
||||||
} from "./mapUtils";
|
} from "./mapUtils";
|
||||||
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
|
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
|
||||||
|
|
||||||
@@ -128,7 +129,8 @@ export function useMapSync({
|
|||||||
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
|
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
|
||||||
? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal)
|
? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal)
|
||||||
: renderFc;
|
: renderFc;
|
||||||
const mapSourceDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
|
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
|
||||||
|
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
|
||||||
const labelTimelineYear = labelTimelineYearRef.current;
|
const labelTimelineYear = labelTimelineYearRef.current;
|
||||||
const { polygons, points } = splitDraftFeatures(mapSourceDraft);
|
const { polygons, points } = splitDraftFeatures(mapSourceDraft);
|
||||||
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
|
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"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 { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline";
|
||||||
import styles from "@/styles/TimelineBar.module.css";
|
import styles from "@/styles/TimelineBar.module.css";
|
||||||
|
|
||||||
@@ -33,14 +34,95 @@ export default function TimelineBar({
|
|||||||
const effectiveDisabled = disabled;
|
const effectiveDisabled = disabled;
|
||||||
const safeYear = clampYearValue(year, lower, upper);
|
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
|
const helperText = isLoading
|
||||||
? "Đang tải geometry theo mốc thời gian..."
|
? "Đang tải geometry theo mốc thời gian..."
|
||||||
: statusText || null;
|
: statusText || null;
|
||||||
|
|
||||||
const handleYearChange = (nextYear: number) => {
|
|
||||||
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimeRangeChange = (nextValue: number) => {
|
const handleTimeRangeChange = (nextValue: number) => {
|
||||||
if (!onTimeRangeChange) return;
|
if (!onTimeRangeChange) return;
|
||||||
const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0;
|
const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0;
|
||||||
@@ -81,8 +163,10 @@ export default function TimelineBar({
|
|||||||
min={lower}
|
min={lower}
|
||||||
max={upper}
|
max={upper}
|
||||||
step={1}
|
step={1}
|
||||||
value={safeYear}
|
value={localYear}
|
||||||
onChange={(event) => handleYearChange(Number(event.target.value))}
|
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
|
||||||
|
onMouseUp={() => commitYearChange(localYearRef.current)}
|
||||||
|
onTouchEnd={() => commitYearChange(localYearRef.current)}
|
||||||
disabled={effectiveDisabled}
|
disabled={effectiveDisabled}
|
||||||
className={styles.slider}
|
className={styles.slider}
|
||||||
aria-label="Timeline year"
|
aria-label="Timeline year"
|
||||||
@@ -90,17 +174,55 @@ export default function TimelineBar({
|
|||||||
<span className={styles.labelBoundsRight}>
|
<span className={styles.labelBoundsRight}>
|
||||||
{formatYear(upper)}
|
{formatYear(upper)}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<div className={styles.numberWrapper}>
|
||||||
type="number"
|
<input
|
||||||
min={lower}
|
type="number"
|
||||||
max={upper}
|
min={lower}
|
||||||
step={1}
|
max={upper}
|
||||||
value={safeYear}
|
step={1}
|
||||||
onChange={(event) => handleYearChange(Number(event.target.value))}
|
value={localYear}
|
||||||
disabled={effectiveDisabled}
|
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
|
||||||
className={styles.numberInput}
|
onBlur={() => commitYearChange(localYearRef.current)}
|
||||||
aria-label="Timeline exact year"
|
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 ? (
|
{typeof timeRange === "number" && onTimeRangeChange ? (
|
||||||
<label
|
<label
|
||||||
title="time_range (0-30)"
|
title="time_range (0-30)"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { WikiSnapshot } from "@/uhm/types/wiki";
|
|||||||
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
|
|
||||||
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
@@ -599,6 +600,55 @@ export function useEditorState(
|
|||||||
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeFeatureId(oldId: FeatureProperties["id"], newId: FeatureProperties["id"]) {
|
||||||
|
if (mode === "replay") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, oldId));
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const nextFeatures = [...mainDraftRef.current.features];
|
||||||
|
const prevFeature = nextFeatures[idx];
|
||||||
|
const newFeature = {
|
||||||
|
...prevFeature,
|
||||||
|
id: newId,
|
||||||
|
properties: {
|
||||||
|
...prevFeature.properties,
|
||||||
|
id: newId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
nextFeatures[idx] = newFeature;
|
||||||
|
|
||||||
|
// Cập nhật replays nếu có
|
||||||
|
const prevReplays = replaysRef.current || [];
|
||||||
|
const nextReplays = prevReplays.map((replay) => {
|
||||||
|
if (replay.geometry_id === String(oldId)) {
|
||||||
|
return {
|
||||||
|
...deepClone(replay),
|
||||||
|
id: String(newId),
|
||||||
|
geometry_id: String(newId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return replay;
|
||||||
|
});
|
||||||
|
if (JSON.stringify(prevReplays) !== JSON.stringify(nextReplays)) {
|
||||||
|
updateReplaysState(nextReplays);
|
||||||
|
}
|
||||||
|
|
||||||
|
pushMainUndo({
|
||||||
|
type: "group",
|
||||||
|
label: `Đổi ID GEO #${oldId} -> #${newId}`,
|
||||||
|
actions: [
|
||||||
|
{ type: "replays", label: "Phục hồi replay cũ", prevReplays: deepClone(prevReplays) },
|
||||||
|
{ type: "create", id: newId },
|
||||||
|
{ type: "delete", feature: deepClone(prevFeature), index: idx },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
function pruneReplaysForDeletedGeometryIds(
|
function pruneReplaysForDeletedGeometryIds(
|
||||||
ids: Array<FeatureProperties["id"]>,
|
ids: Array<FeatureProperties["id"]>,
|
||||||
label: string
|
label: string
|
||||||
@@ -822,6 +872,7 @@ export function useEditorState(
|
|||||||
updateFeature,
|
updateFeature,
|
||||||
deleteFeature,
|
deleteFeature,
|
||||||
deleteFeatures,
|
deleteFeatures,
|
||||||
|
changeFeatureId,
|
||||||
undo,
|
undo,
|
||||||
buildPayload,
|
buildPayload,
|
||||||
clearChanges,
|
clearChanges,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const POINT_GEOTYPE_ICON_PATHS: Partial<Record<PointGeotypeId, string>> =
|
|||||||
city: "/images/mapIcon/point/city.png",
|
city: "/images/mapIcon/point/city.png",
|
||||||
fortification: "/images/mapIcon/point/castle.png",
|
fortification: "/images/mapIcon/point/castle.png",
|
||||||
ruin: "/images/mapIcon/point/ruin.png",
|
ruin: "/images/mapIcon/point/ruin.png",
|
||||||
|
port: "/images/mapIcon/point/port.png",
|
||||||
};
|
};
|
||||||
|
|
||||||
type PointIconVariant = "default" | "draft";
|
type PointIconVariant = "default" | "draft";
|
||||||
@@ -442,50 +443,33 @@ function drawRuinGlyph(ctx: CanvasRenderingContext2D) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawAnchorGlyph(ctx: CanvasRenderingContext2D) {
|
function drawAnchorGlyph(ctx: CanvasRenderingContext2D) {
|
||||||
ctx.lineWidth = 3;
|
const img = preloadedImages["port"];
|
||||||
ctx.beginPath();
|
if (img && loadedImageKeys.has("port")) {
|
||||||
ctx.arc(32, 22.5, 3.5, 0, Math.PI * 2);
|
ctx.drawImage(img, 0, 0, ICON_CANVAS_SIZE, ICON_CANVAS_SIZE);
|
||||||
ctx.stroke();
|
} else {
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(32, 22.5, 3.5, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(32, 26.5);
|
ctx.moveTo(32, 26.5);
|
||||||
ctx.lineTo(32, 41);
|
ctx.lineTo(32, 41);
|
||||||
ctx.moveTo(24, 31.5);
|
ctx.moveTo(24, 31.5);
|
||||||
ctx.lineTo(40, 31.5);
|
ctx.lineTo(40, 31.5);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(32, 35.5, 9, 0.2 * Math.PI, 0.8 * Math.PI);
|
ctx.arc(32, 35.5, 9, 0.2 * Math.PI, 0.8 * Math.PI);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(24.5, 38);
|
ctx.moveTo(24.5, 38);
|
||||||
ctx.lineTo(21.5, 34);
|
ctx.lineTo(21.5, 34);
|
||||||
ctx.moveTo(39.5, 38);
|
ctx.moveTo(39.5, 38);
|
||||||
ctx.lineTo(42.5, 34);
|
ctx.lineTo(42.5, 34);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBridgeGlyph(ctx: CanvasRenderingContext2D) {
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(21, 31);
|
|
||||||
ctx.lineTo(43, 31);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(22, 40);
|
|
||||||
ctx.quadraticCurveTo(32, 26, 42, 40);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(26, 37);
|
|
||||||
ctx.lineTo(26, 31);
|
|
||||||
ctx.moveTo(32, 33.5);
|
|
||||||
ctx.lineTo(32, 31);
|
|
||||||
ctx.moveTo(38, 37);
|
|
||||||
ctx.lineTo(38, 31);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function statusColor(normalColor: string): maplibregl.ExpressionSpecification {
|
|||||||
"case",
|
"case",
|
||||||
SELECTED_EXPR,
|
SELECTED_EXPR,
|
||||||
SELECTED_COLOR,
|
SELECTED_COLOR,
|
||||||
normalColor,
|
["coalesce", ["get", "entity_color"], normalColor],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,12 +178,16 @@ function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
|
|||||||
"case",
|
"case",
|
||||||
SELECTED_EXPR,
|
SELECTED_EXPR,
|
||||||
SELECTED_COLOR,
|
SELECTED_COLOR,
|
||||||
normalColor,
|
["coalesce", ["get", "entity_color"], normalColor],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusFillColor(normalColor: string): string {
|
function statusFillColor(normalColor: string): maplibregl.ExpressionSpecification {
|
||||||
return normalColor;
|
return [
|
||||||
|
"coalesce",
|
||||||
|
["get", "entity_color"],
|
||||||
|
normalColor,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
|
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
|
||||||
|
|||||||
Reference in New Issue
Block a user