Merge branch 'master' of https://github.com/Pregnant-Guild/FE_User_history_web
This commit is contained in:
+23
-14
@@ -33,20 +33,23 @@ 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;
|
||||
onDeleteFeature?: (id: string | number) => void;
|
||||
onDeleteFeature?: (id: string | number | (string | number)[]) => void;
|
||||
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;
|
||||
@@ -57,12 +60,13 @@ type MapProps = {
|
||||
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
||||
imageOverlay?: MapImageOverlay | null;
|
||||
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
|
||||
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
|
||||
};
|
||||
|
||||
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
mode,
|
||||
onSetMode,
|
||||
draft,
|
||||
renderDraft,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
@@ -74,7 +78,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
onHideFeature,
|
||||
onUpdateFeature,
|
||||
allowGeometryEditing = true,
|
||||
respectBindingFilter = true,
|
||||
applyGeometryBindingFilter = true,
|
||||
height = "100vh",
|
||||
fitToDraftBounds = false,
|
||||
fitBoundsKey = null,
|
||||
@@ -85,11 +89,12 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
focusPadding,
|
||||
imageOverlay = null,
|
||||
onImageOverlayChange,
|
||||
onBindGeometries,
|
||||
}, 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.
|
||||
@@ -108,9 +113,11 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
|
||||
// Ref callback update overlay mới nhất để interaction không stale.
|
||||
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
|
||||
|
||||
// Ref callback bind geometry mới nhất để interaction không stale.
|
||||
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]);
|
||||
@@ -120,6 +127,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
|
||||
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]);
|
||||
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]);
|
||||
useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]);
|
||||
|
||||
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
|
||||
const {
|
||||
@@ -154,7 +162,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
mapRef,
|
||||
mode,
|
||||
modeRef,
|
||||
draftRef,
|
||||
renderDraftRef,
|
||||
allowGeometryEditing,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIdsRef,
|
||||
@@ -164,23 +172,24 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
onHideRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
onBindGeometriesRef,
|
||||
});
|
||||
|
||||
// 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,
|
||||
@@ -200,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) => {
|
||||
|
||||
@@ -18,6 +18,8 @@ type Props = {
|
||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||
changeCount: number;
|
||||
onReplayEdit?: (id: string | number) => void;
|
||||
onDeleteFeatures?: (ids: (string | number)[]) => void;
|
||||
onDeselectAll?: () => void;
|
||||
};
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
@@ -25,6 +27,8 @@ export default function SelectedGeometryPanel({
|
||||
onApplyGeometryMetadata,
|
||||
changeCount,
|
||||
onReplayEdit,
|
||||
onDeleteFeatures,
|
||||
onDeselectAll,
|
||||
}: Props) {
|
||||
const {
|
||||
geometryMetaForm,
|
||||
@@ -74,6 +78,13 @@ export default function SelectedGeometryPanel({
|
||||
const visibleGeoApplyFeedback =
|
||||
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
|
||||
|
||||
const isBulkMode = selectedFeatures.length >= 2;
|
||||
const isMultiEditValid = useMemo(() => {
|
||||
if (selectedFeatures.length <= 1) return true;
|
||||
const firstShape = selectedFeatures[0].geometry.type;
|
||||
return selectedFeatures.every((f) => f.geometry.type === firstShape);
|
||||
}, [selectedFeatures]);
|
||||
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||
const representativeFeature = selectedFeatures[0];
|
||||
|
||||
@@ -99,7 +110,7 @@ export default function SelectedGeometryPanel({
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>
|
||||
Geometry property
|
||||
{isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -125,7 +136,78 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
|
||||
{collapsed ? null : (
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
{isBulkMode && (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#1e293b",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#93c5fd", fontWeight: 700, fontSize: "12px" }}>
|
||||
HÀNH ĐỘNG NHANH
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
cursor: "pointer",
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
fontWeight: 700,
|
||||
fontSize: "13px",
|
||||
textAlign: "center",
|
||||
gridColumn: "span 2",
|
||||
}}
|
||||
>
|
||||
Vào Replay ({selectedFeatures.length} geo)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 10px",
|
||||
cursor: "pointer",
|
||||
background: "#dc2626",
|
||||
color: "#ffffff",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Xóa ({selectedFeatures.length} geo)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeselectAll?.()}
|
||||
style={{
|
||||
border: "1px solid #475569",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 10px",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: "#cbd5e1",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Bỏ chọn tất cả
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -142,111 +224,113 @@ export default function SelectedGeometryPanel({
|
||||
<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>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||
Loại GEO
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
||||
<option value={geometryMetaForm.type_key}>
|
||||
Custom Type ({geometryMetaForm.type_key})
|
||||
</option>
|
||||
) : null}
|
||||
{groupedGeoTypeOptions.map((group) => (
|
||||
<optgroup
|
||||
key={group.id}
|
||||
label={`${group.label} (${group.geometryLabel})`}
|
||||
|
||||
{!isMultiEditValid ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px", padding: "8px", border: "1px solid #7f1d1d", borderRadius: "6px", background: "#450a0a", marginTop: "4px" }}>
|
||||
Không thể chỉnh sửa thuộc tính cho các geometry không cùng loại hình dạng (Point, Line, Polygon).
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||
Loại GEO
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
||||
<option value={geometryMetaForm.type_key}>
|
||||
Custom Type ({geometryMetaForm.type_key})
|
||||
</option>
|
||||
) : null}
|
||||
{groupedGeoTypeOptions.map((group) => (
|
||||
<optgroup
|
||||
key={group.id}
|
||||
label={`${group.label} (${group.geometryLabel})`}
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{selectedTypeOption ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||
</div>
|
||||
) : geometryMetaForm.type_key ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{geometryMetaForm.type_key}</b>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_start: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
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 (geometry ids, comma separated)"*/}
|
||||
{/* disabled={isEntitySubmitting}*/}
|
||||
{/* style={entityInputStyle}*/}
|
||||
{/*/>*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyGeoMeta}
|
||||
disabled={isEntitySubmitting}
|
||||
style={primaryGeometryButtonStyle}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
{onReplayEdit && selectedFeatures.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||
style={{
|
||||
...primaryGeometryButtonStyle,
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
color: "#38bdf8",
|
||||
}}
|
||||
>
|
||||
Replay Edit
|
||||
</button>
|
||||
</select>
|
||||
{selectedTypeOption ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||
</div>
|
||||
) : geometryMetaForm.type_key ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{geometryMetaForm.type_key}</b>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_start: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_end: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyGeoMeta}
|
||||
disabled={isEntitySubmitting}
|
||||
style={primaryGeometryButtonStyle}
|
||||
>
|
||||
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
|
||||
</button>
|
||||
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||
style={{
|
||||
...primaryGeometryButtonStyle,
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
color: "#38bdf8",
|
||||
}}
|
||||
>
|
||||
Replay Edit
|
||||
</button>
|
||||
)}
|
||||
{visibleGeoApplyFeedback ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{visibleGeoApplyFeedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{visibleGeoApplyFeedback ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{visibleGeoApplyFeedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
@@ -254,7 +338,7 @@ export default function SelectedGeometryPanel({
|
||||
Thay đổi sẽ vào lịch sử khi Commit.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,29 +16,33 @@ type EngineBinding = {
|
||||
cleanup: () => void;
|
||||
cancel?: () => void;
|
||||
clearSelection?: (skipNotify?: boolean) => void;
|
||||
syncSelection?: (ids: (string | number)[]) => void;
|
||||
};
|
||||
|
||||
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>;
|
||||
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
|
||||
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
|
||||
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||
onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>;
|
||||
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
|
||||
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
|
||||
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
|
||||
};
|
||||
|
||||
export function useMapInteraction({
|
||||
mapRef,
|
||||
mode,
|
||||
modeRef,
|
||||
draftRef,
|
||||
renderDraftRef,
|
||||
allowGeometryEditing,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIdsRef,
|
||||
@@ -48,6 +52,7 @@ export function useMapInteraction({
|
||||
onHideRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
onBindGeometriesRef,
|
||||
}: UseMapInteractionProps) {
|
||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
|
||||
@@ -72,6 +77,13 @@ export function useMapInteraction({
|
||||
}
|
||||
}, [mode, selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectEngine = engineBindingsRef.current.select;
|
||||
if (selectEngine?.syncSelection) {
|
||||
selectEngine.syncSelection(selectedFeatureIds);
|
||||
}
|
||||
}, [selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousMode = previousModeRef.current;
|
||||
if (previousMode !== mode) {
|
||||
@@ -134,7 +146,7 @@ export function useMapInteraction({
|
||||
map,
|
||||
() => modeRef.current,
|
||||
allowGeometryEditing
|
||||
? (id: string | number) => {
|
||||
? (id: string | number | (string | number)[]) => {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
onSelectFeatureIdsRef.current?.([]);
|
||||
onDeleteRef.current?.(id);
|
||||
@@ -143,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(
|
||||
@@ -153,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;
|
||||
@@ -170,7 +182,8 @@ export function useMapInteraction({
|
||||
: undefined,
|
||||
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||
(id: string | number) => onSetModeRef.current?.("replay", id),
|
||||
() => Boolean(editingEngineRef.current?.editingRef.current)
|
||||
() => Boolean(editingEngineRef.current?.editingRef.current),
|
||||
(targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds)
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
@@ -301,7 +314,7 @@ export function useMapInteraction({
|
||||
}
|
||||
|
||||
const currentFeature =
|
||||
draftRef.current.features.find(
|
||||
renderDraftRef.current.features.find(
|
||||
(item) => String(item.properties.id) === String(rawFeatureId)
|
||||
) || null;
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user