"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> & { 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, 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 (
Geometry Binding
Filter binding
all {summary.total} {summary.orphan > 0 ? ( entity {summary.orphan} ) : null} {summary.timeIssues > 0 ? ( time {summary.timeIssues} ) : null} {summary.filteredOut > 0 ? ( out {summary.filteredOut} ) : null} {summary.hidden > 0 ? ( hidden {summary.hidden} ) : null}
{collapsed ? null : selectedGeometry ? ( (() => { const isHidden = geometryVisibility[selectedGeometry.id] === false; const isBound = bindingSet.has(selectedGeometry.id); const title = buildGeometryTitle(selectedGeometry, isHidden, isBound); return (
handleFocusGeometry(selectedGeometry.id)} onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)} >
Selected
{selectedGeometry.isNew ? : null}
); })() ) : null} {collapsed ? null : rows.length ? (
{visibleRows .map((g) => { const isBound = bindingSet.has(g.id); const isHidden = geometryVisibility[g.id] === false; const title = buildGeometryTitle(g, isHidden, isBound); return (
handleFocusGeometry(g.id)} onKeyDown={(event) => handleFocusKeyDown(event, g.id)} >
{g.isNew ? : null}
{canBindToggle ? ( ) : null}
); })}
) : (
No geometry yet for this project.
)} {collapsed ? null : statusText ? (
{statusText}
) : null}
); } function GeometryLabel({ row, color = "#e5e7eb" }: { row: GeometryRow; color?: string }) { return ( {row.label || "Geometry"} ); } function StatusChips({ row, isHidden, isBound }: { row: GeometryRow; isHidden: boolean; isBound: boolean }) { return (
{row.isOrphan ? no entity : null} {row.timeStatus === "missing" ? no time : null} {row.timeStatus === "partial" ? partial time : null} {row.timelineStatus === "visible" ? timeline : null} {row.timelineStatus === "filteredOut" ? out timeline : null} {isHidden ? hidden : null} {isBound ? bound : null}
); } 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 ( ); } function EyeOffIcon() { return ( ); } function LockIcon() { return ( ); } function UnlockIcon() { return ( ); } function PlusIcon() { return ( ); } function MinusIcon() { return ( ); }