refactor: undo feature cover every single part of editor
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user