refactor: undo feature cover every single part of editor

This commit is contained in:
taDuc
2026-05-23 12:23:01 +07:00
parent 3b4ff71b9a
commit 282b365287
47 changed files with 2184 additions and 3311 deletions
+15 -12
View File
@@ -33,12 +33,15 @@ export type MapHandle = {
type MapProps = {
mode: EditorMode;
draft: FeatureCollection;
// FeatureCollection that should actually be rendered/interacted with on the map.
// Callers should apply timeline/replay filters before passing it here.
renderDraft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
// Label lookup context only. It may include non-rendered geometries for entity label resolution.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
@@ -46,7 +49,7 @@ type MapProps = {
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
respectBindingFilter?: boolean;
applyGeometryBindingFilter?: boolean;
height?: CSSProperties["height"];
fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null;
@@ -63,7 +66,7 @@ type MapProps = {
const Map = forwardRef<MapHandle, MapProps>(function Map({
mode,
onSetMode,
draft,
renderDraft,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
@@ -75,7 +78,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onHideFeature,
onUpdateFeature,
allowGeometryEditing = true,
respectBindingFilter = true,
applyGeometryBindingFilter = true,
height = "100vh",
fitToDraftBounds = false,
fitBoundsKey = null,
@@ -90,8 +93,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
// Ref giữ draft mới nhất để engine đọc không bị stale closure.
const draftRef = useRef<FeatureCollection>(draft);
// Ref giữ render draft mới nhất để map engines đọc không bị stale closure.
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
// Ref callback select feature mới nhất cho event click trên map.
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
@@ -114,7 +117,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
@@ -159,7 +162,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
mapRef,
mode,
modeRef,
draftRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
@@ -174,19 +177,19 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const {
applyDraftToMap,
applyRenderDraftToMap,
applyHighlightToMap,
applyImageOverlayToMap,
tryCenterToUserLocation,
} = useMapSync({
mapRef,
draft,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
respectBindingFilter,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
@@ -206,7 +209,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
applyImageOverlayToMap();
setupMapInteractions(map);
applyDraftToMap(draftRef.current);
applyRenderDraftToMap(renderDraftRef.current);
tryCenterToUserLocation();
return () => {
@@ -31,13 +31,13 @@ function wikiTitle(w: WikiSnapshot): string {
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
const {
entityCatalog,
snapshotEntities,
snapshotEntityRows,
wikis,
links,
} = useEditorStore(
useShallow((state) => ({
entityCatalog: state.entityCatalog,
snapshotEntities: state.snapshotEntities,
snapshotEntityRows: state.snapshotEntityRows,
wikis: state.snapshotWikis,
links: state.snapshotEntityWikiLinks,
}))
@@ -59,18 +59,18 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) {
);
const entityChoices = useMemo<EntityChoice[]>(() => {
const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
for (const ref of snapshotEntities || []) {
const visibleSnapshotEntityRows = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue;
visibleSnapshotEntities.set(id, {
if (!id || ref?.operation === "delete" || visibleSnapshotEntityRows.has(id)) continue;
visibleSnapshotEntityRows.set(id, {
id,
name: String(ref?.name || id),
isNew: ref?.source === "inline" && ref?.operation === "create",
});
}
const rows = Array.from(visibleSnapshotEntities.values()).map((entity) => {
const rows = Array.from(visibleSnapshotEntityRows.values()).map((entity) => {
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
return {
id: entity.id,
@@ -80,7 +80,7 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) {
});
rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}, [entityCatalog, snapshotEntities]);
}, [entityCatalog, snapshotEntityRows]);
const activeLinks = useMemo(() => {
const set = new Set<string>();
@@ -3,17 +3,29 @@
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = {
id: string;
label?: string;
time_start?: number | null;
time_end?: number | null;
time_start?: unknown;
time_end?: unknown;
isTimelineVisible?: boolean;
isOrphan?: boolean;
timeStatus?: GeometryTimeStatus;
timelineStatus?: GeometryTimelineStatus;
isNew?: boolean;
};
type GeometryTimeStatus = "missing" | "partial" | "complete";
type GeometryTimelineStatus = "off" | "visible" | "filteredOut";
type GeometryRow = Required<Pick<GeometryChoice, "id" | "label" | "isOrphan" | "timeStatus" | "timelineStatus" | "isNew">> & {
time_start: number | null;
time_end: number | null;
isTimelineVisible: boolean;
};
type Props = {
geometries: GeometryChoice[];
selectedGeometryId?: string | null;
@@ -61,9 +73,12 @@ export default function GeometryBindingPanel({
.map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
time_start: typeof g.time_start === "number" ? g.time_start : null,
time_end: typeof g.time_end === "number" ? g.time_end : null,
time_start: normalizeTimelineYearValue(g.time_start),
time_end: normalizeTimelineYearValue(g.time_end),
isTimelineVisible: Boolean(g.isTimelineVisible),
isOrphan: Boolean(g.isOrphan),
timeStatus: resolveTimeStatus(g),
timelineStatus: resolveTimelineStatus(g),
isNew: Boolean(g.isNew),
}));
cleaned.sort((a, b) => a.id.localeCompare(b.id));
@@ -85,6 +100,31 @@ export default function GeometryBindingPanel({
return a.id.localeCompare(b.id);
});
}, [bindingSet, effectiveSelectedGeometryId, rows]);
const summary = useMemo(() => {
let orphan = 0;
let missingTime = 0;
let partialTime = 0;
let filteredOut = 0;
let hidden = 0;
for (const row of rows) {
if (row.isOrphan) orphan += 1;
if (row.timeStatus === "missing") missingTime += 1;
if (row.timeStatus === "partial") partialTime += 1;
if (row.timelineStatus === "filteredOut") filteredOut += 1;
if (geometryVisibility[row.id] === false) hidden += 1;
}
return {
total: rows.length,
orphan,
missingTime,
partialTime,
timeIssues: missingTime + partialTime,
filteredOut,
hidden,
};
}, [geometryVisibility, rows]);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return;
@@ -114,29 +154,72 @@ export default function GeometryBindingPanel({
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<div style={{ display: "flex",flexDirection: "column", gap: 10, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
<label
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
userSelect: "none",
}}
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
>
<input
type="checkbox"
checked={bindingFilterEnabled}
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
style={{ width: 14, height: 14 }}
/>
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
</label>
<button
type="button"
role="switch"
aria-checked={bindingFilterEnabled}
aria-label="Toggle geometry binding filter"
onClick={() => setGeometryBindingFilterEnabled(!bindingFilterEnabled)}
style={{
width: 32,
height: 18,
padding: 2,
borderRadius: 999,
border: bindingFilterEnabled ? "1px solid #38bdf8" : "1px solid #334155",
background: bindingFilterEnabled ? "rgba(14, 165, 233, 0.32)" : "#111827",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: bindingFilterEnabled ? "flex-end" : "flex-start",
transition: "background 140ms ease, border-color 140ms ease",
}}
>
<span
style={{
width: 12,
height: 12,
borderRadius: 999,
background: bindingFilterEnabled ? "#67e8f9" : "#94a3b8",
boxShadow: bindingFilterEnabled ? "0 0 8px rgba(103, 232, 249, 0.45)" : "none",
transition: "background 140ms ease, box-shadow 140ms ease",
}}
/>
</button>
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter binding</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<div style={summaryWrapStyle}>
<span style={summaryBadgeStyle} title="Total geometry count">all {summary.total}</span>
{summary.orphan > 0 ? (
<span style={summaryDangerBadgeStyle} title="Geometry without any bound entity">entity {summary.orphan}</span>
) : null}
{summary.timeIssues > 0 ? (
<span
style={summaryWarningBadgeStyle}
title={`Missing time: ${summary.missingTime}; partial time: ${summary.partialTime}`}
>
time {summary.timeIssues}
</span>
) : null}
{summary.filteredOut > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry filtered out by timeline">out {summary.filteredOut}</span>
) : null}
{summary.hidden > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry hidden manually">hidden {summary.hidden}</span>
) : null}
</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
@@ -164,8 +247,8 @@ export default function GeometryBindingPanel({
{collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const idColor = getGeometryIdColor(selectedGeometry);
const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb";
const isBound = bindingSet.has(selectedGeometry.id);
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
return (
<div
style={{
@@ -179,7 +262,7 @@ export default function GeometryBindingPanel({
opacity: isHidden ? 0.58 : 1,
boxShadow: "none",
}}
title={selectedGeometry.id}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)}
@@ -198,19 +281,7 @@ export default function GeometryBindingPanel({
Selected
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
fontSize: "12px",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{selectedGeometry.label || selectedGeometry.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
<GeometryLabel row={selectedGeometry} color="#dbeafe" />
{selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
@@ -225,18 +296,7 @@ export default function GeometryBindingPanel({
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<div
style={{
marginTop: 3,
fontSize: "11px",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{selectedGeometry.id}
</div>
<StatusChips row={selectedGeometry} isHidden={isHidden} isBound={isBound} />
</div>
);
})()
@@ -248,8 +308,7 @@ export default function GeometryBindingPanel({
.map((g) => {
const isBound = bindingSet.has(g.id);
const isHidden = geometryVisibility[g.id] === false;
const idColor = getGeometryIdColor(g);
const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb";
const title = buildGeometryTitle(g, isHidden, isBound);
return (
<div
key={g.id}
@@ -269,7 +328,7 @@ export default function GeometryBindingPanel({
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: "none",
}}
title={g.id}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)}
@@ -284,33 +343,10 @@ export default function GeometryBindingPanel({
minWidth: 0,
}}
>
<span
style={{
fontSize: "12px",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{g.label || g.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
<GeometryLabel row={g} />
{g.isNew ? <NewBadge /> : null}
</div>
<div
style={{
fontSize: "11px",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{g.id}
</div>
<StatusChips row={g} isHidden={isHidden} isBound={isBound} />
</div>
<button
@@ -375,7 +411,86 @@ export default function GeometryBindingPanel({
);
}
const boundBadgeStyle: CSSProperties = {
function GeometryLabel({ row, color = "#e5e7eb" }: { row: GeometryRow; color?: string }) {
return (
<span
style={{
fontSize: "12px",
color,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.label || "Geometry"}
</span>
);
}
function StatusChips({ row, isHidden, isBound }: { row: GeometryRow; isHidden: boolean; isBound: boolean }) {
return (
<div style={statusChipRowStyle}>
{row.isOrphan ? <span style={dangerBadgeStyle}>no entity</span> : null}
{row.timeStatus === "missing" ? <span style={dangerBadgeStyle}>no time</span> : null}
{row.timeStatus === "partial" ? <span style={warningBadgeStyle}>partial time</span> : null}
{row.timelineStatus === "visible" ? <span style={timelineBadgeStyle}>timeline</span> : null}
{row.timelineStatus === "filteredOut" ? <span style={mutedBadgeStyle}>out timeline</span> : null}
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
</div>
);
}
function resolveTimeStatus(geometry: GeometryChoice): GeometryTimeStatus {
if (geometry.timeStatus === "missing" || geometry.timeStatus === "partial" || geometry.timeStatus === "complete") {
return geometry.timeStatus;
}
const hasStart = normalizeTimelineYearValue(geometry.time_start) !== null;
const hasEnd = normalizeTimelineYearValue(geometry.time_end) !== null;
if (!hasStart && !hasEnd) return "missing";
if (!hasStart || !hasEnd) return "partial";
return "complete";
}
function resolveTimelineStatus(geometry: GeometryChoice): GeometryTimelineStatus {
if (
geometry.timelineStatus === "off" ||
geometry.timelineStatus === "visible" ||
geometry.timelineStatus === "filteredOut"
) {
return geometry.timelineStatus;
}
return geometry.isTimelineVisible ? "visible" : "off";
}
function buildGeometryTitle(row: GeometryRow, isHidden: boolean, isBound: boolean): string {
const parts = [`ID: ${row.id}`];
if (row.isOrphan) parts.push("Orphan");
if (row.timeStatus === "missing") parts.push("Missing time");
if (row.timeStatus === "partial") parts.push("Partial time");
if (row.timelineStatus === "visible") parts.push("Timeline visible");
if (row.timelineStatus === "filteredOut") parts.push("Filtered out by timeline");
if (isHidden) parts.push("Hidden");
if (isBound) parts.push("Bound");
if (row.isNew) parts.push("New");
return parts.join(" | ");
}
const summaryWrapStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
minWidth: 0,
flexWrap: "wrap",
};
const baseBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
@@ -383,32 +498,91 @@ const boundBadgeStyle: CSSProperties = {
height: 17,
padding: "0 6px",
borderRadius: 999,
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
whiteSpace: "nowrap",
};
const summaryBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.35)",
background: "rgba(15, 23, 42, 0.9)",
color: "#cbd5e1",
};
const summaryDangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.32)",
color: "#fecaca",
};
const summaryWarningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.48)",
background: "rgba(113, 63, 18, 0.3)",
color: "#fde68a",
};
const summaryMutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.4)",
background: "rgba(51, 65, 85, 0.32)",
color: "#cbd5e1",
};
const statusChipRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
marginTop: 5,
minHeight: 17,
};
const dangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.28)",
color: "#fecaca",
};
const warningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.5)",
background: "rgba(113, 63, 18, 0.28)",
color: "#fde68a",
};
const timelineBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(34, 197, 94, 0.5)",
background: "rgba(20, 83, 45, 0.3)",
color: "#bbf7d0",
};
const mutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.28)",
color: "#cbd5e1",
};
const boundBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
const hiddenBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
const iconButtonStyle: CSSProperties = {
@@ -424,14 +598,6 @@ const iconButtonStyle: CSSProperties = {
flex: "0 0 auto",
};
function getGeometryIdColor(geometry: GeometryChoice): string {
const hasStart = typeof geometry.time_start === "number";
const hasEnd = typeof geometry.time_end === "number";
if (!hasStart && !hasEnd) return "#f87171";
if (!hasStart || !hasEnd) return "#facc15";
return "#94a3b8";
}
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -22,7 +22,7 @@ export default function ProjectEntityRefsPanel({
onToggleBindEntityForSelectedGeometry,
}: Props) {
const {
snapshotEntities,
snapshotEntityRows,
entityForm,
setEntityForm,
isEntitySubmitting,
@@ -30,7 +30,7 @@ export default function ProjectEntityRefsPanel({
selectedGeometryEntityIds,
} = useEditorStore(
useShallow((state) => ({
snapshotEntities: state.snapshotEntities,
snapshotEntityRows: state.snapshotEntityRows,
entityForm: state.entityForm,
setEntityForm: state.setEntityForm,
isEntitySubmitting: state.isEntitySubmitting,
@@ -53,14 +53,14 @@ export default function ProjectEntityRefsPanel({
);
const entityRefs = useMemo(() => {
const byId = new globalThis.Map<string, EntitySnapshot>();
for (const ref of snapshotEntities || []) {
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || byId.has(id)) continue;
if (ref.operation === "delete") continue;
byId.set(id, ref);
}
return Array.from(byId.values());
}, [snapshotEntities]);
}, [snapshotEntityRows]);
const sortedEntityRefs = useMemo(() => {
const rows = [...(entityRefs || [])];
rows.sort((a, b) => {
@@ -49,6 +49,7 @@ export function formatUndoLabel(action: UndoAction) {
case "snapshot_wikis":
case "snapshot_entity_wiki":
case "replay":
case "replays":
case "replay_session":
case "group":
return action.label;
+7 -5
View File
@@ -23,7 +23,9 @@ type UseMapInteractionProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
mode: EditorMode;
modeRef: React.MutableRefObject<EditorMode>;
draftRef: React.MutableRefObject<FeatureCollection>;
// Rendered/interacted FeatureCollection from Map.tsx. This may already be filtered by
// replay/timeline state, so do not treat it as the canonical commit/edit draft.
renderDraftRef: React.MutableRefObject<FeatureCollection>;
allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
@@ -40,7 +42,7 @@ export function useMapInteraction({
mapRef,
mode,
modeRef,
draftRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
@@ -153,7 +155,7 @@ export function useMapInteraction({
allowGeometryEditing
? (feature) => {
const rawId = feature.id ?? feature.properties?.id;
const originalFeature = draftRef.current.features.find(
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
editingEngineRef.current?.beginEditing(
@@ -163,7 +165,7 @@ export function useMapInteraction({
: undefined,
allowGeometryEditing
? (id: string | number) => {
const originalFeature = draftRef.current.features.find(
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(id)
);
if (!originalFeature) return;
@@ -312,7 +314,7 @@ export function useMapInteraction({
}
const currentFeature =
draftRef.current.features.find(
renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawFeatureId)
) || null;
+28 -24
View File
@@ -20,13 +20,17 @@ import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
type UseMapSyncProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
draft: FeatureCollection;
// Already-filtered FeatureCollection that should be written to MapLibre sources.
// Timeline/replay filters must be applied before this hook receives it.
renderDraft: FeatureCollection;
// Lookup-only context for labels. It may contain geometries that are not rendered.
// Never use it to decide which geometries appear on the map.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
respectBindingFilter: boolean;
applyGeometryBindingFilter: boolean;
fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null;
highlightFeatures?: FeatureCollection | null;
@@ -44,13 +48,13 @@ type UseMapSyncProps = {
export function useMapSync({
mapRef,
draft,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
respectBindingFilter,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
@@ -62,13 +66,13 @@ export function useMapSync({
editingEngineRef,
geolocationCenteredRef,
}: UseMapSyncProps) {
const draftRef = useRef<FeatureCollection>(draft);
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const respectBindingFilterRef = useRef(respectBindingFilter);
const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
@@ -77,13 +81,13 @@ export function useMapSync({
const fitBoundsAppliedRef = useRef(false);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
@@ -94,8 +98,8 @@ export function useMapSync({
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
const applyDraftToMap = useCallback((
fc: FeatureCollection,
const applyRenderDraftToMap = useCallback((
renderFc: FeatureCollection,
labelContextOverride?: FeatureCollection,
selectedIdsOverride?: (string | number)[],
highlightFeaturesOverride?: FeatureCollection | null
@@ -115,22 +119,22 @@ export function useMapSync({
}
}
const labelContext = labelContextOverride || labelContextDraftRef.current || fc;
const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc;
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
const highlightFeaturesVal = highlightFeaturesOverride !== undefined
? highlightFeaturesOverride
: highlightFeaturesRef.current;
const visibleDraftRaw = respectBindingFilterRef.current
? filterDraftByBinding(labelContext, currentSelectedIds, highlightFeaturesVal)
: labelContext;
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal)
: renderFc;
const mapSourceDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
const labelTimelineYear = labelTimelineYearRef.current;
const { polygons, points } = splitDraftFeatures(visibleDraft);
const { polygons, points } = splitDraftFeatures(mapSourceDraft);
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft);
countriesSource.setData(labeledGeometries);
placesSource.setData(labeledPoints);
@@ -147,7 +151,7 @@ export function useMapSync({
});
});
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, mapSourceDraft);
}
}, [mapRef]);
@@ -206,24 +210,24 @@ export function useMapSync({
}, [imageOverlay, mapRef]);
useEffect(() => {
applyDraftToMap(draft, labelContextDraft, selectedFeatureIds, highlightFeatures);
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds, highlightFeatures);
const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId);
const stillExists = renderDraft.features.some((f) => f.properties.id === editingId);
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [
allowGeometryEditing,
draft,
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
respectBindingFilter,
applyGeometryBindingFilter,
geometryVisibility,
highlightFeatures,
applyDraftToMap,
applyRenderDraftToMap,
editingEngineRef,
]);
@@ -259,7 +263,7 @@ export function useMapSync({
}, [focusRequestKey, mapRef]);
return {
applyDraftToMap,
applyRenderDraftToMap,
applyHighlightToMap,
tryCenterToUserLocation,
applyImageOverlayToMap: () => {
+11 -1
View File
@@ -214,11 +214,21 @@
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
}
.toggleContainer:focus-visible {
outline: 2px solid rgba(52, 211, 153, 0.8);
outline-offset: 3px;
border-radius: 999px;
}
.toggleTrack {
width: 38px;
height: 20px;
@@ -280,4 +290,4 @@
.disabled .toggleTrack {
cursor: not-allowed !important;
pointer-events: none !important;
}
}
+8 -11
View File
@@ -55,9 +55,15 @@ export default function TimelineBar({
>
<div className={styles.flexWrapper}>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<label
<button
type="button"
role="switch"
aria-checked={filterEnabled}
aria-label="Toggle timeline filter"
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
onClick={() => onFilterEnabledChange(!filterEnabled)}
disabled={effectiveDisabled}
>
<span
aria-hidden="true"
@@ -67,15 +73,7 @@ export default function TimelineBar({
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
/>
</span>
<input
type="checkbox"
checked={filterEnabled}
onChange={(e) => onFilterEnabledChange(e.target.checked)}
disabled={effectiveDisabled}
aria-label="Toggle timeline filter"
style={{ display: "none" }}
/>
</label>
</button>
) : null}
<span className={styles.labelBounds}>{formatYear(lower)}</span>
<input
@@ -133,4 +131,3 @@ function formatYear(year: number): string {
}
return `${year}`;
}
+7 -2
View File
@@ -56,6 +56,7 @@ let quillLinkSanitizePatched = false;
type Props = {
projectId: string;
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
onRemoveWiki?: (wikiId: string) => void;
};
function clampTitle(title: string) {
@@ -63,7 +64,7 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
const { wikis, requestedActiveId } = useEditorStore(
useShallow((state) => ({
wikis: state.snapshotWikis,
@@ -252,7 +253,11 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
};
const removeWiki = (id: string) => {
setWikis((prev) => prev.filter((w) => w.id !== id));
if (onRemoveWiki) {
onRemoveWiki(id);
} else {
setWikis((prev) => prev.filter((w) => w.id !== id));
}
if (activeId === id) setActiveId(null);
};