Files
History-user/src/uhm/components/editor/GeometryBindingPanel.tsx
T

689 lines
22 KiB
TypeScript

"use client";
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?: 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;
selectedGeometryBindingIds: string[];
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
onFocusGeometry?: (geometryId: string) => void;
};
export default function GeometryBindingPanel({
geometries,
selectedGeometryId,
selectedGeometryBindingIds,
onToggleBindGeometryForSelectedGeometry,
onFocusGeometry,
}: Props) {
const {
selectedFeatureIds,
statusText,
bindingFilterEnabled,
setGeometryBindingFilterEnabled,
geometryVisibility,
setGeometryVisibility,
} = useEditorStore(
useShallow((state) => ({
selectedFeatureIds: state.selectedFeatureIds,
statusText: state.geoBindingStatus,
bindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
geometryVisibility: state.geometryVisibility,
setGeometryVisibility: state.setGeometryVisibility,
}))
);
const effectiveSelectedGeometryId =
selectedGeometryId ??
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
const canBindToggle =
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
const canFocusGeometry = typeof onFocusGeometry === "function";
const [collapsed, setCollapsed] = useState(false);
const rows = useMemo(() => {
const cleaned = (geometries || [])
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
.map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
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));
return cleaned;
}, [geometries]);
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
const selectedGeometry = useMemo(() => {
if (!effectiveSelectedGeometryId) return null;
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
}, [effectiveSelectedGeometryId, rows]);
const visibleRows = useMemo(() => {
return rows
.filter((g) => g.id !== effectiveSelectedGeometryId)
.sort((a, b) => {
const aBound = bindingSet.has(a.id);
const bBound = bindingSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1;
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;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onFocusGeometry?.(geometryId);
};
const handleFocusGeometry = (geometryId: string) => {
onFocusGeometry?.(geometryId);
};
const toggleGeometryVisibility = (geometryId: string) => {
setGeometryVisibility((prev) => ({
...prev,
[geometryId]: prev[geometryId] === false,
}));
};
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex",flexDirection: "column", gap: 10, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
userSelect: "none",
}}
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
>
<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, 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)}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
title={collapsed ? "Mở panel" : "Thu gọn panel"}
aria-label={collapsed ? "Mở panel Geometry Binding" : "Thu gọn panel Geometry Binding"}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const isBound = bindingSet.has(selectedGeometry.id);
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
return (
<div
style={{
marginTop: 10,
padding: "8px",
borderRadius: "6px",
border:
"1px solid rgba(59, 130, 246, 0.45)",
background: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default",
opacity: isHidden ? 0.58 : 1,
boxShadow: "none",
}}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
>
<div
style={{
fontSize: 10,
color: "#93c5fd",
fontWeight: 900,
textTransform: "uppercase",
lineHeight: 1,
marginBottom: 5,
}}
>
Selected
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<GeometryLabel row={selectedGeometry} color="#dbeafe" />
{selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(selectedGeometry.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${selectedGeometry.id}` : `Hide geometry ${selectedGeometry.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<StatusChips row={selectedGeometry} isHidden={isHidden} isBound={isBound} />
</div>
);
})()
) : null}
{collapsed ? null : rows.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{visibleRows
.map((g) => {
const isBound = bindingSet.has(g.id);
const isHidden = geometryVisibility[g.id] === false;
const title = buildGeometryTitle(g, isHidden, isBound);
return (
<div
key={g.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isBound
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isBound
? "rgba(20, 184, 166, 0.12)"
: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
cursor: canFocusGeometry ? "pointer" : "default",
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: "none",
}}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
minWidth: 0,
}}
>
<GeometryLabel row={g} />
{g.isNew ? <NewBadge /> : null}
</div>
<StatusChips row={g} isHidden={isHidden} isBound={isBound} />
</div>
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(g.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${g.id}` : `Hide geometry ${g.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
{canBindToggle ? (
<button
type="button"
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={(event) => {
event.stopPropagation();
onToggleBindGeometryForSelectedGeometry!(g.id, !isBound);
}}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
isBound
? `Unbind geometry ${g.id} from selected geometry`
: `Bind geometry ${g.id} to selected geometry`
}
>
{isBound ? <UnlockIcon /> : <LockIcon />}
</button>
) : null}
</div>
);
})}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
No geometry yet for this project.
</div>
)}
{collapsed ? null : statusText ? (
<div style={{ marginTop: 10, fontSize: 12, color: "#93c5fd" }}>
{statusText}
</div>
) : null}
</div>
);
}
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",
flex: "0 0 auto",
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",
};
const hiddenBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
};
const iconButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
};
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="#cbd5e1" strokeWidth="2" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18" stroke="#fca5a5" strokeWidth="2" strokeLinecap="round" />
<path
d="M10.6 6.2A10.5 10.5 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.1 2.8M6.2 8.1A17 17 0 0 0 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.5-.7"
stroke="#fca5a5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 10V8a5 5 0 0 1 10 0v2"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#cbd5e1"
strokeWidth="2"
/>
</svg>
);
}
function UnlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M17 10V8a5 5 0 0 0-9.5-2"
stroke="#a7f3d0"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#a7f3d0"
strokeWidth="2"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}