diff --git a/PanTS-Demo/src/components/LiveWireOverlay.tsx b/PanTS-Demo/src/components/LiveWireOverlay.tsx index f9bfa666..69e4fac8 100644 --- a/PanTS-Demo/src/components/LiveWireOverlay.tsx +++ b/PanTS-Demo/src/components/LiveWireOverlay.tsx @@ -5,6 +5,11 @@ type Props = { anchorPointsCanvas: Array<[number, number]>; // dense — used for the fill path only cornerPointsCanvas: Array<[number, number]>; // one per click — used for the dots livePreviewPath: Array<[number, number]> | null; + // True once the cursor is within closing range of the first point. Used + // only to soften/highlight the closing anchor here — the actual + // "click here to close" nudge is a separate in + // VisualizationPage.tsx, positioned off the same corner. + nearClose?: boolean; }; @@ -15,7 +20,7 @@ function pathToD(points: Array<[number, number]>, close: boolean): string { } -function LiveWireOverlay({ anchorPointsCanvas, cornerPointsCanvas, livePreviewPath }: Props) { +function LiveWireOverlay({ anchorPointsCanvas, cornerPointsCanvas, livePreviewPath, nearClose }: Props) { return ( )} - {cornerPointsCanvas.map((p, i) => ( - - ))} + {cornerPointsCanvas.map((p, i) => { + const isClosingAnchor = i === 0; + return ( + + ); + })} ); } diff --git a/PanTS-Demo/src/components/segmentation/SegmentsPopup.css b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css index af3b7bcc..c49f902a 100644 --- a/PanTS-Demo/src/components/segmentation/SegmentsPopup.css +++ b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css @@ -29,6 +29,11 @@ .segpop.is-open { transform: translateX(0); + /* Faint blue-tinted left edge — same accent used by AnnotationToolbar's + ribbon border when open — so the two read as one coordinated + "annotation mode is on" surface, distinct from the plain-black main + toolbar and other panels. */ + border-left-color: rgba(104, 172, 229, 0.3); } .segpop.is-closed { @@ -148,6 +153,12 @@ background: rgba(104, 172, 229, 0.14); } +.segpop__row.is-editing-target { + background: rgba(104, 172, 229, 0.1); + outline: 1px solid rgba(104, 172, 229, 0.35); + outline-offset: -1px; +} + .segpop__vis { background: transparent; border: none; @@ -305,7 +316,9 @@ /* Shared active/targeting indicator — icon only, used identically in the Existing-organ and Custom tabs so both read the same way and cost the - row as little width as possible, leaving more room for the name. */ + row as little width as possible, leaving more room for the name. Now a + real button (see TargetBadge in SegmentsPopup.tsx): clicking it untargets + the class directly, so the icon itself is the "remove target" control. */ .segpop__target-badge { display: flex; align-items: center; @@ -318,6 +331,19 @@ background: rgba(104, 172, 229, 0.16); } +.segpop__target-badge--btn { + border: none; + cursor: pointer; + padding: 0; + transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease; +} + +.segpop__target-badge--btn:hover { + background: rgba(232, 93, 93, 0.22); + color: #E85D5D; + transform: scale(1.08); +} + .segpop__row.is-deleting { opacity: 0; transform: scale(0.97); @@ -352,58 +378,64 @@ flex-shrink: 0; } -.segpop__edit-btn:hover { +.segpop__edit-btn:hover, +.segpop__edit-btn.is-active { color: #68ACE5; } -/* .segpop__collapse (shared by both the editing row and the add-form below) - is the Collapse component's own wrapper — its `max-height` is driven - directly from JS (see the Collapse component in SegmentsPopup.tsx), - measured off the content's real scrollHeight rather than guessed via a - CSS grid-track trick. That's what fixes the old "stuck halfway then - disappears" bug: the previous grid-template-rows(0fr/1fr) approach - animated a grid track's size independently of the content's actual - height, so an error line appearing/disappearing (or any other height - change) could desync the two, and the close was finalized by a JS - setTimeout guessing the transition's duration rather than the - transition's own completion — a slow frame could unmount the row well - before the animation had actually finished, reading as a stuck-then-gone - snap. Only `max-height` needs a transition here; opacity/transform ride - along on the same one shared curve so the fade and the collapse read as - one continuous motion instead of two racing animations. */ -.segpop__collapse { - overflow: hidden; +/* Floating add/edit form (see FormFlyout in SegmentsPopup.tsx). Portaled to + and positioned via inline `fixed` coords computed off the trigger + button's own rect — it never participates in the row list's flex flow, so + opening it never shifts any existing row or icon. Open/close is driven by + this component's own JS timer rather than waiting on a transitionend, so + (unlike the old inline collapse) it can't get stuck: closing always + finishes and calls back, every time. */ +.segpop__form-flyout { + position: relative; + z-index: 2000; + width: 260px; + max-width: calc(100vw - 16px); + display: flex; + flex-direction: column; + padding: 10px; + border-radius: 10px; + background: #16181d; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55); + transform-origin: top left; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.segpop__form-flyout.is-open { opacity: 1; - transform: none; - transform-origin: top center; - transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1), - transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), - margin 0.28s cubic-bezier(0.4, 0, 0.2, 1); + transform: scale(1) translateY(0); } -.segpop__collapse.is-closing { +.segpop__form-flyout.is-closing { opacity: 0; - /* Subtler than a bouncier scale — a barely-there shrink reads as - "settling away" rather than a visible pop, which suits a form - dismiss better than the treatment used for e.g. a toast or modal. */ - transform: scale(0.98); - margin: 0; + transform: scale(0.96) translateY(-4px); pointer-events: none; } -.segpop__collapse-inner { - min-height: 0; +/* Little pointer connecting the flyout back to whichever button opened it + (the "Add class" button, or a row's pencil icon) — left offset is set + inline, computed from that trigger's own center. */ +.segpop__form-flyout-arrow { + position: absolute; + top: -6px; + width: 11px; + height: 11px; + background: #16181d; + border-left: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid rgba(255, 255, 255, 0.1); + transform: translateX(-50%) rotate(45deg); } -.segpop__row--editing-inner { +.segpop__form-flyout-inner { display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - padding: 8px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.05); + flex-direction: column; + align-items: stretch; + gap: 8px; position: relative; } @@ -515,28 +547,14 @@ margin-top: 4px; } -.segpop__new:hover { +.segpop__new:hover, +.segpop__new.is-active { color: #fff; border-color: rgba(104, 172, 229, 0.45); } -.segpop__add-form { - margin-top: 4px; - animation: segpop-row-in 0.2s ease both; -} - -.segpop__add-form.is-closing { - margin-top: 0; -} - -.segpop__add-form-inner { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 8px; - padding: 8px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.04); +.segpop__new.is-active { + background: rgba(104, 172, 229, 0.1); } /* Now rendered via the shared ApplyButton component (see SegmentsPopup.tsx) diff --git a/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx b/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx index 71c7e51d..dd226f79 100644 --- a/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx +++ b/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx @@ -3,7 +3,7 @@ import { createPortal } from "react-dom"; import { IconEye, IconEyeOff, IconTrash, IconPlus, IconStack2, IconSparkles, IconPencil, - IconLoader2, IconTargetArrow, + IconLoader2, } from "@tabler/icons-react"; import type { CheckBoxData } from "../../types"; import "./SegmentsPopup.css"; @@ -188,112 +188,115 @@ function ColorPickerPopover({ value, onChange, onClose, anchorRef }: ColorPicker ); } -// Alternative to the old grid-template-rows(0fr/1fr) collapse trick, which -// read as "stuck halfway then vanishes" — that approach animates a CSS grid -// track, but the *content itself* wasn't clipped to match at every instant -// (an error line appearing/disappearing changes the natural height out from -// under the grid animation, and the two could visibly desync), and the -// close was driven by a JS setTimeout guessing the transition's real -// duration rather than the transition itself, so a slow frame or a -// mid-flight re-render could unmount the row before (or well after) it had -// actually finished animating. +// Replaces the old in-row Collapse (grid-template-rows / max-height) trick. +// That approach animated the *content's own* box open and closed inline in +// the list, which had two problems in practice: every existing row below it +// physically shifted up/down as the form expanded and collapsed (jarring +// next to a list the person is actively scanning), and its "done animating" +// signal was a CSS `transitionend` on `max-height` — which never fires if +// the browser coalesces the open→close flip within a frame, if the content's +// measured height doesn't actually change between states, or if a re-render +// interrupts the transition mid-flight. When that happened the row got stuck +// permanently in its "closing" bookkeeping state, and since the very next +// "Add class" button is gated on that same bookkeeping having cleared, it +// would silently stop appearing at all. // -// This instead measures the *real* pixel height of the content (via the -// inner ref's scrollHeight) and animates `max-height` directly to that -// number, then releases it to `none` once open so dynamic content (an -// error message showing up, the character-count line, etc.) can still grow -// freely without being clipped. Closing reverses that: pin the current -// height to a concrete px first (you can't transition away from `none`), -// then flip to 0 on the next frame. Either direction's completion is driven -// by the transition's own `onTransitionEnd`, not a timer, so there's no -// duration to keep in sync with the CSS and no way for it to fire early or -// late. -interface CollapseProps { - /** true = expanded/open, false = animate closed. The caller keeps this - * component mounted for a beat after flipping to false (see - * `onExited`) so the close transition is visible instead of the row - * just disappearing. */ - in: boolean; - /** Fires once the close transition has genuinely finished — the right - * moment for the caller to actually unmount this row/form. */ - onExited?: () => void; - children: React.ReactNode; - className?: string; +// This instead portals the add/edit form to as a small floating +// panel anchored (fixed position, computed off the trigger button's own +// rect) next to whatever it's editing — same mechanism already proven out +// by ColorPickerPopover above. Existing rows and icons never move, because +// the form isn't part of their flex flow at all. And open/close is driven +// entirely by this component's own JS timer (mirroring +// ColorPickerPopover's `requestClose`), never by waiting on a transition +// event, so there's no path left where it can get stuck. +interface FormFlyoutProps { + /** The button this flyout is anchored to and points at with its little + * pointer/arrow — the "Add class" button, or a row's pencil icon. */ + anchorEl: HTMLElement | null; + /** Called once the close animation has actually finished — the right + * moment for the caller to unmount this flyout / clear its target id. */ + onClose: () => void; + /** Render prop so Cancel / successful-Enter / successful-Apply inside + * the form can all trigger the same gradual close by calling this, + * instead of each needing its own copy of the animate-then-unmount + * logic. */ + children: (requestClose: () => void) => React.ReactNode; } -function Collapse({ in: open, onExited, children, className = "" }: CollapseProps) { - const innerRef = useRef(null); - const [maxHeight, setMaxHeight] = useState(open ? "none" : 0); - const rafRef = useRef(null); - const mountedRef = useRef(false); +function FormFlyout({ anchorEl, onClose, children }: FormFlyoutProps) { + const [closing, setClosing] = useState(false); + const panelRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number; arrowLeft: number } | null>(null); + + const requestClose = () => { + if (closing) return; + setClosing(true); + window.setTimeout(onClose, EXIT_ANIM_MS); + }; useEffect(() => { - const el = innerRef.current; - if (!el) return; - - // First paint: just reflect the initial state, nothing to animate - // yet (avoids an unwanted transition from 0 the instant a row that - // starts out open first mounts). - if (!mountedRef.current) { - mountedRef.current = true; - return; - } + const compute = () => { + if (!anchorEl) return; + const rect = anchorEl.getBoundingClientRect(); + const panelW = panelRef.current?.offsetWidth ?? 260; + const panelH = panelRef.current?.offsetHeight ?? 0; + const margin = 8; + // Prefer opening just below the trigger, left-aligned to it; + // clamp horizontally so it never runs off the viewport edge, + // and flip above the trigger if there isn't room below. + let left = rect.left; + left = Math.max(margin, Math.min(left, window.innerWidth - panelW - margin)); + let top = rect.bottom + 10; + if (panelH && top + panelH > window.innerHeight - margin) { + top = rect.top - panelH - 10; + } + // Point the little pointer at the trigger's own center, clamped + // to stay within the panel's own width. + const arrowLeft = Math.max(14, Math.min(rect.left + rect.width / 2 - left, panelW - 14)); + setPos({ top, left, arrowLeft }); + }; + compute(); + window.addEventListener("resize", compute); + window.addEventListener("scroll", compute, true); + return () => { + window.removeEventListener("resize", compute); + window.removeEventListener("scroll", compute, true); + }; + }, [anchorEl]); - if (open) { - // Opening: animate from wherever we are (0, most commonly) up to - // the content's real measured height. - setMaxHeight(el.scrollHeight); - } else { - // Closing: if we're currently sitting at `none` (fully open, - // content free to grow), pin that down to today's actual pixel - // height first — `none` can't be transitioned away from - // directly — then let the next frame drop it to 0 so the - // browser has a real numeric start point to animate from. - setMaxHeight(el.scrollHeight); - rafRef.current = requestAnimationFrame(() => setMaxHeight(0)); - } + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (panelRef.current?.contains(e.target as Node)) return; + if (anchorEl?.contains(e.target as Node)) return; + requestClose(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") requestClose(); }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); return () => { - if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - - const handleTransitionEnd = (e: React.TransitionEvent) => { - if (e.target !== e.currentTarget || e.propertyName !== "max-height") return; - if (open) { - // Release the cap so content that changes height while open - // (error text, char-count line) isn't clipped or fighting a - // stale measured number. - setMaxHeight("none"); - } else { - onExited?.(); - } - }; + }, [anchorEl]); - return ( + if (typeof document === "undefined" || !pos) return null; + + return createPortal(
e.stopPropagation()} > -
- {children} -
-
+ + {children(requestClose)} + , + document.body ); } -// Single compact "this is the current target" indicator, shared by both the -// custom-class rows and the existing-organ rows so the two tabs read the -// same way. Icon-only (with a tooltip) so it costs as little row width as -// possible, leaving more room for the name itself. -function TargetBadge() { - return ( - - - - ); -} + // Gap kept clear between the docked panel's top edge and the reserved // annotation area above it (topbar + ribbon + flyout strip — see @@ -364,14 +367,13 @@ export default function SegmentsPopup({ root.style.setProperty("--atb-segpanel-w", open ? `${width}px` : "0px"); }, [open, width]); - const [tab, setTab] = useState(activeCatalogOrganId != null ? "existing" : "custom"); - - // `adding` drives the Collapse's `in` prop (expanded vs. animating - // closed); `addFormMounted` stays true for the extra beat it takes the - // close transition to actually finish, cleared only by Collapse's own - // `onExited` — so the form is never yanked out mid-animation. + const [tab, setTab] = useState("existing"); + // `adding` drives whether the Add-class FormFlyout is mounted at all — + // no separate "still animating closed" bookkeeping needed anymore since + // FormFlyout owns its own close animation/timer internally and only + // calls back once it's genuinely done. const [adding, setAdding] = useState(false); - const [addFormMounted, setAddFormMounted] = useState(false); + const [addAnchorEl, setAddAnchorEl] = useState(null); const [draftName, setDraftName] = useState(""); const [draftColor, setDraftColor] = useState(NEXT_COLOR_POOL[segments.length % NEXT_COLOR_POOL.length]); const [createError, setCreateError] = useState(""); @@ -379,18 +381,17 @@ export default function SegmentsPopup({ // Combined name+color editor, opened via the pen icon (replaces the old // double-click-to-rename-only flow — both fields are changed and - // confirmed together, in one place). - // `editingId` drives Collapse's `in` prop; `editRowMountedId` stays set - // (same pattern as addFormMounted above) until Collapse's `onExited` - // confirms the close transition has actually finished. + // confirmed together, in one place). Same "no separate mounted flag" + // simplification as `adding` above — the FormFlyout itself tracks its + // close animation. const [editingId, setEditingId] = useState(null); - const [editRowMountedId, setEditRowMountedId] = useState(null); + const [editAnchorEl, setEditAnchorEl] = useState(null); const [editNameDraft, setEditNameDraft] = useState(""); const [editColorDraft, setEditColorDraft] = useState("#ffffff"); const [renameError, setRenameError] = useState(null); const [editColorPopoverOpen, setEditColorPopoverOpen] = useState(false); // Anchors for the portaled ColorPickerPopover — one swatch button lives - // in the add-form, the other in the inline edit row, and only one of + // in the add-form flyout, the other in the edit flyout, and only one of // either is ever mounted at a time, but keeping separate refs avoids // them fighting over a single ref across renders. const addColorBtnRef = useRef(null); @@ -440,29 +441,28 @@ export default function SegmentsPopup({ const switchTab = (next: PopupTab) => { setTab(next); setAdding(false); - setAddFormMounted(false); setCreateError(""); setEditingId(null); - setEditRowMountedId(null); setConfirmDeleteId(null); }; - const startAdd = () => { + const startAdd = (e: React.MouseEvent) => { + setAddAnchorEl(e.currentTarget); setAdding(true); - setAddFormMounted(true); setDraftName(""); setCreateError(""); setDraftColor(NEXT_COLOR_POOL[segments.length % NEXT_COLOR_POOL.length]); }; - // Kicks off the add-form's collapse-out transition — shared by both - // Cancel and a successful Add so closing always reads the same way - // regardless of why it's closing. The form stays mounted (see - // `addFormMounted`) until Collapse's `onExited` fires below, once the - // animation has genuinely finished. + // Fully closes the add flyout immediately — used when something else + // (switching tabs, deleting) needs it gone right away, with no need for + // its own gradual close animation. The FormFlyout's own Cancel/Enter/ + // Apply paths instead call the `requestClose` it hands them, which + // plays the close animation first and calls this once it's done. const closeAddForm = () => { setAddColorPopoverOpen(false); setAdding(false); + setAddAnchorEl(null); }; const commitAdd = (): boolean => { @@ -490,20 +490,19 @@ export default function SegmentsPopup({ return true; }; - const startEdit = (id: number, currentName: string, currentColor: string) => { + const startEdit = (id: number, currentName: string, currentColor: string, anchorEl: HTMLElement) => { setEditingId(id); - setEditRowMountedId(id); + setEditAnchorEl(anchorEl); setEditNameDraft(currentName); setEditColorDraft(currentColor); setRenameError(null); }; - // Kicks off the edit row's collapse-out transition, shared by Save and - // Cancel so both close the same way. The row stays mounted until - // Collapse's `onExited` fires (see the render below), once the close - // transition has actually finished — no more setTimeout guessing. + // Immediately closes the edit flyout — see closeAddForm's note above for + // why this is separate from the FormFlyout's own animated requestClose. const closeEdit = (id: number) => { setEditColorPopoverOpen(false); setEditingId((cur) => (cur === id ? null : cur)); + setEditAnchorEl(null); }; const cancelEdit = () => { if (editingId == null) return; @@ -597,7 +596,7 @@ export default function SegmentsPopup({ onClick={() => handleSelectExisting(o.id)} > {toTitleCase(o.label)} - {activeCatalogOrganId === o.id && } + ))} @@ -613,80 +612,11 @@ export default function SegmentsPopup({ const active = isCustomActive(s.id); const hex = colors[s.id] ?? "#ffffff"; const isEditing = editingId === s.id; - const isEditRowMounted = editRowMountedId === s.id; - - if (isEditRowMounted) { - const remaining = MAX_SEGMENT_NAME_LENGTH - editNameDraft.length; - return ( - setEditRowMountedId((cur) => (cur === s.id ? null : cur))} - className="segpop__row segpop__row--editing" - > -
e.stopPropagation()} - > - {/* Swatch button opens the gradual color popover instead of the - OS's own abrupt native picker — same swatch look as the static - row, just clickable while editing. */} -
-
- { setEditNameDraft(e.target.value); setRenameError(null); }} - onKeyDown={(e) => { - // Same fix as the add-form input above: close on a - // successful Enter-commit instead of leaving the row - // stuck open in edit mode. - if (e.key === "Enter") { if (commitEdit(s.id)) closeEdit(s.id); } - if (e.key === "Escape") cancelEdit(); - }} - /> - {renameError === s.id && Name in use} - {renameError !== s.id && remaining <= 10 && ( - {remaining} characters left - )} - {/* Same Apply/Cancel row arrangement as the "Add class" - form below — one standardized commit control across the - popup instead of this row's own bespoke Save/X buttons. */} -
- commitEdit(s.id)} onDone={() => closeEdit(s.id)} label="Save" applyingLabel="Saving…" successLabel="Saved" /> - -
-
-
- ); - } - const isDeleting = deletingIds.has(s.id); return (
{ if (!isDeleting) { onSelect(active ? null : s.id); } }} >
+ { setEditNameDraft(e.target.value); setRenameError(null); }} + onKeyDown={(e) => { + if (e.key === "Enter") { if (commitEdit(s.id)) requestClose(); } + if (e.key === "Escape") requestClose(); + }} + /> + + {renameError === s.id && Name in use} + {renameError !== s.id && remaining <= 10 && ( + {remaining} characters left + )} +
+ commitEdit(s.id)} onDone={requestClose} label="Save" applyingLabel="Saving…" successLabel="Saved" /> + +
+ + )} + + ); + })()} + {confirmDeleteId != null && ( )} - {addFormMounted ? ( - setAddFormMounted(false)} - className="segpop__add-form" - > -
-
-
- +
+ {adding && ( + + {(requestClose) => ( +
+
+
+
+ { setDraftName(e.target.value); setCreateError(""); }} + onKeyDown={(e) => { + if (e.key === "Enter") { if (commitAdd()) requestClose(); } + if (e.key === "Escape") requestClose(); + }} /> - )} +
+ {createError && {createError}} +
+ + +
- { setDraftName(e.target.value); setCreateError(""); }} - onKeyDown={(e) => { - // Mirrors the ApplyButton path below: on a successful add, - // close the form the same way clicking "Add class" would - // (previously this just called commitAdd() and left the - // form sitting open with no visible next step). - if (e.key === "Enter") { if (commitAdd()) closeAddForm(); } - if (e.key === "Escape") closeAddForm(); - }} - /> -
- {createError && {createError}} -
- {/* Same ApplyButton used by every flyout's "do this now" - action — one consistent commit control across the panel. */} - - -
-
-
- ) : ( -
- -
+ )} + )} )} diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.css b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css index 042c604c..4e701b97 100644 --- a/PanTS-Demo/src/components/viewer/AnnotationToolbar.css +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css @@ -7,7 +7,9 @@ border-radius: 18px; /* Same plain translucent black as SegmentsPopup.css's .segpop, kept in sync with it, so this ribbon reads as a distinct surface from the - main toolbar's dark-gray tint (.vp-topbar in VisualizationPage.css). */ + main toolbar's dark-gray tint (.vp-topbar in VisualizationPage.css). + A faint blue-tinted top border (below) is what actually tells the + two apart at a glance once it's open — see .atb--horizontal. */ background: rgba(34, 34, 42, 0.62); backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%); @@ -285,19 +287,21 @@ align-items: stretch; pointer-events: none; /* Gradual open/close — the ribbon stays mounted and only toggles the - is-open/is-closed class below, same slide+fade pattern as - SegmentsPopup.css's panel. */ - transition: opacity 0.2s ease, transform 0.2s ease; + is-open/is-closed class below. clip-path is what actually produces + the "branches off the Annotate button" reveal (see the inline style + set in AnnotationToolbar.tsx, keyed off the button's measured + x-position); opacity rides along so a browser that can't animate + clip-path smoothly still gets a graceful fade instead of a hard cut. */ + transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1), + clip-path 0.38s cubic-bezier(0.22, 1, 0.36, 1); } .atb-shell.is-open { opacity: 1; - transform: translateY(0); } .atb-shell.is-closed { opacity: 0; - transform: translateY(-8px); pointer-events: none; } @@ -305,6 +309,52 @@ pointer-events: auto; } +/* Small triangle pointing straight up at the Annotate button, positioned + via --atb-anchor-x (set inline from the button's measured center) so it + visually reads as "this ribbon came from that button" rather than an + unconnected bar that happens to be docked below it. Fades with the same + timing as the ribbon itself. */ +/* Small triangle pointing straight up at the Annotate button. Deliberately + NOT a child of .atb-shell (see the JSX comment at the connector's render + site) — .atb-shell is capped at z-index 50 so .vp-topbar (55) can render + its own tooltips over the open ribbon, and that same cap was hiding this + pointer's tip right where it needed to reach up into the topbar. Kept as + a sibling with its own higher z-index instead, positioned the same way + (fixed, keyed off the same --vp-topbar-h), with its own is-open/is-closed + fade so it still tracks the ribbon's own open/close timing exactly. */ +.atb-shell__connector { + position: fixed; + top: var(--vp-topbar-h, 0px); + left: 0; + right: 0; + height: 0; + /* Above .vp-topbar's 55 — see the block comment above and the one on + .vp-topbar itself in VisualizationPage.css. */ + z-index: 56; + pointer-events: none; + opacity: 0; + transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +.atb-shell__connector.is-open { + opacity: 1; +} + +.atb-shell__pointer { + position: absolute; + top: -8px; + width: 16px; + height: 16px; + background: rgba(34, 34, 42, 0.62); + backdrop-filter: blur(20px) saturate(150%); + -webkit-backdrop-filter: blur(20px) saturate(150%); + border-left: 2px solid #68ACE5; + border-top: 2px solid #68ACE5; + border-radius: 4px 0 0 0; + transform: translateX(-50%) rotate(45deg); + transition: left 0.2s ease; +} + /* Exposed so the viewer container can reserve exactly this much top padding/margin and guarantee the ribbon (in either state) never sits over the canvas. Update both values together if button sizing changes. */ @@ -345,13 +395,17 @@ against the ribbon edge / masking-select divider. */ padding-right: 24px; border-radius: 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.09); + /* A faint blue-tinted bottom edge (instead of the same neutral white + hairline every other panel uses) is what makes it obvious at a + glance that this specific bar is the one currently "open" — + distinct from the plain-black main toolbar above it. */ + border-bottom: 1px solid rgba(104, 172, 229, 0.28); width: 100%; height: auto; min-height: var(--atb-ribbon-h); max-width: 100vw; box-sizing: border-box; - box-shadow: 0 18px 44px -20px rgba(0, 0, 0, 0.75); + box-shadow: 0 18px 44px -20px rgba(0, 0, 0, 0.75), 0 1px 0 rgba(104, 172, 229, 0.22) inset; overflow-x: auto; overflow-y: visible; } @@ -681,4 +735,262 @@ .atb-flyout__magnet-toggle:has(input:checked) { background: rgba(104, 172, 229, 0.14); border-color: rgba(104, 172, 229, 0.4); +} + +/* ============================================================================ + Guided-flow Exit / Start over buttons (Grow-from-seeds, Copy/Fill-across- + slices) — previously plain inline styles with no hover/press feedback, so + they read as flat/dead next to every other interactive control in the + ribbon. Same pill shape and sizing as before, now with real states. + ============================================================================ */ +.atb-guided__btn { + border-radius: 999px; + cursor: pointer; + font-size: 11.5px; + font-weight: 700; + padding: 6px 12px; + white-space: nowrap; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.08s ease; +} + +.atb-guided__btn:active { + transform: scale(0.96); +} + +.atb-guided__btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(104, 172, 229, 0.55); +} + +.atb-guided__btn--startover { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + color: rgba(255, 255, 255, 0.8); +} + +.atb-guided__btn--startover:hover { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.24); + color: #ffffff; +} + +.atb-guided__btn--exit { + background: rgba(0, 0, 0, 0.16); + border: 1px solid rgba(0, 0, 0, 0.45); + color: #ffffff; + font-weight: 800; +} + +.atb-guided__btn--exit:hover { + background: rgba(2, 31, 163, 0.22); + border-color: rgba(105, 121, 241, 0.55); +} + +.atb-guided__btn--continue { + background: #002D72; + border: 1px solid #002D72; + color: #ffffff; +} + +.atb-guided__btn--continue:hover { + background: #013a91; + border-color: #013a91; +} + +/* ============================================================================ + First-use shortcuts popup — shown once, the first time any tool icon in + the horizontal ribbon is clicked, then never again (see + SHORTCUTS_INTRO_SEEN_KEY). Same dark translucent card language as the + ribbon itself, portaled to and centered over the viewer so it + isn't clipped by the ribbon's own bounds. + ============================================================================ */ +.atb-shortcuts-overlay { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(8, 9, 11, 0.55); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + animation: atb-shortcuts-fade-in 0.16s ease; +} + +@keyframes atb-shortcuts-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.atb-shortcuts-card { + width: min(440px, calc(100vw - 48px)); + max-height: min(560px, calc(100vh - 48px)); + /* Header and footer (incl. the "Got it" button) are outside the + scrolling area below, so the button is always reachable without + scrolling — only the shortcut list itself scrolls if it's tall. */ + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 16px; + background: #16181d; + border: 1px solid rgba(255, 255, 255, 0.09); + box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.8), 0 1px 0 rgba(104, 172, 229, 0.22) inset; + font-family: "Space Grotesk", system-ui, sans-serif; + color: #fff; + animation: atb-shortcuts-pop-in 0.18s cubic-bezier(0.2, 0.8, 0.3, 1); +} + +@keyframes atb-shortcuts-pop-in { + from { + opacity: 0; + transform: scale(0.96) translateY(6px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.atb-shortcuts-card__header { + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 18px 20px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.atb-shortcuts-card__title { + font-size: 15px; + font-weight: 800; + margin: 0 0 2px; +} + +.atb-shortcuts-card__subtitle { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.55); + margin: 0; +} + +.atb-shortcuts-card__close { + flex-shrink: 0; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: none; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease; +} + +.atb-shortcuts-card__close:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.atb-shortcuts-card__body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 8px 20px 4px; +} + +.atb-shortcuts-group { + padding: 10px 0; +} + +.atb-shortcuts-group+.atb-shortcuts-group { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.atb-shortcuts-group__label { + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--jhu-blue-light, #68ACE5); + margin: 0 0 8px; +} + +.atb-shortcuts-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 5px 0; +} + +.atb-shortcuts-row__label { + font-size: 12.5px; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); +} + +.atb-shortcuts-row__keys { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; +} + +.atb-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-bottom-width: 2px; + font-family: "Space Grotesk", system-ui, sans-serif; + font-size: 11px; + font-weight: 700; + color: rgba(255, 255, 255, 0.9); +} + +.atb-shortcuts-row__keys-sep { + font-size: 10.5px; + color: rgba(255, 255, 255, 0.4); + margin: 0 1px; +} + +.atb-shortcuts-card__footer { + flex-shrink: 0; + padding: 14px 20px 18px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.atb-shortcuts-card__got-it { + width: 100%; + padding: 10px 0; + border-radius: 999px; + background: var(--jhu-blue, #002D72); + border: 1px solid var(--jhu-blue-light, #68ACE5); + color: #ffffff; + font-size: 12.5px; + font-weight: 800; + cursor: pointer; + transition: background 0.14s ease, transform 0.08s ease; +} + +.atb-shortcuts-card__got-it:hover { + background: #003a91; +} + +.atb-shortcuts-card__got-it:active { + transform: scale(0.98); } \ No newline at end of file diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx index 462c8035..9677fba2 100644 --- a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx @@ -19,12 +19,53 @@ import "./AnnotationToolbar.css"; import MaskingSelect, { type MaskingArea } from "../segmentation/MaskingSelect"; import NumberSliderField from "../NumberSliderField"; import { FlyoutArrow, FlyoutPanel, MenuColumn, MenuRow, MenuDivider, useFlyout } from "./FlyoutPrimitives"; -import { PickErrorHint, type GuidedFlowControls } from "../segmentation/SliceAnchorPickerUI"; +import type { GuidedFlowControls } from "../segmentation/SliceAnchorPickerUI"; -// localStorage keys: once the overview tour (or first-target hint) has been -// seen, it won't auto-open again. +// sessionStorage keys: once the overview tour (or first-target hint) has +// been seen, it won't auto-open again FOR THE REST OF THIS TAB SESSION. +// Deliberately sessionStorage rather than localStorage — these are meant to +// re-appear on every fresh page load/reload, not just once ever per browser. const OVERVIEW_WALKTHROUGH_SEEN_KEY = "mm_annotation_walkthrough_seen"; const FIRST_TARGET_HINT_SEEN_KEY = "mm_annotation_first_target_hint_seen"; +const SHORTCUTS_INTRO_SEEN_KEY = "mm_annotation_shortcuts_intro_seen"; +// Guided-flow (Continue / Start over / Exit) explainer. Each guided tool +// (Grow from Seeds, Copy across slices, Fill between slices, Islands) gets +// its OWN "seen" flag — so seeing the explainer for one doesn't suppress it +// for the others — even though several of them share the same wording. +const GUIDED_HINT_SEEN_KEY_PREFIX = "mm_annotation_guided_hint_seen_"; + +// Grouped keyboard shortcuts shown once, the moment the annotation toolbar +// itself is opened (see SHORTCUTS_INTRO_SEEN_KEY below). Kept as plain data +// rather than JSX so the popup's layout stays entirely in one component. +const SHORTCUT_GROUPS: Array<{ label: string; rows: Array<{ label: string; keys: string[] }> }> = [ + { + label: "Navigation", + rows: [ + { label: "Step one slice", keys: ["["] }, + { label: "Step 10 slices", keys: ["Shift", "["] }, + { label: "Zoom in to cursor", keys: ["+"] }, + { label: "Zoom out from cursor", keys: ["-"] }, + { label: "Reset zoom to fit", keys: ["Ctrl", "0"] }, + { label: "First / last slice", keys: ["Home"] }, + ], + }, + { + label: "While drawing a shape", + rows: [ + { label: "Close the shape", keys: ["Enter"] }, + { label: "Cancel the shape", keys: ["Esc"] }, + { label: "Undo last point", keys: ["Ctrl", "Z"] }, + ], + }, + { + label: "Editing", + rows: [ + { label: "Undo / redo edit", keys: ["Ctrl", "Z"] }, + { label: "Redo edit", keys: ["Shift", "Ctrl", "Z"] }, + { label: "Resize brush ±2mm", keys: ["Shift", "["] }, + ], + }, +]; export type PrimaryEditTool = | "paint" | "erase" | "scissors" | "levelTracing" @@ -91,7 +132,14 @@ interface AnnotationToolbarProps { popupDragRef?: React.RefObject; popupMinRef?: React.RefObject; sliceJumpRef?: React.RefObject; - + + /** The main toolbar's own Annotate/pencil button — this ribbon measures + * its horizontal center and opens with a "branching off" reveal + * anchored there (see the clip-path reveal + pointer triangle in the + * render below), instead of just fading in as a flat full-width bar + * with no visual link back to the button that opened it. */ + anchorRef?: React.RefObject; + } const TOOL_DEFS: Array<{ id: Exclude; label: string; Icon: typeof IconBrush; description: string }> = [ @@ -123,6 +171,24 @@ const LIVE_COMMIT_TOOLS: Exclude[] = ["paint", "erase", " const MIN_DIAMETER_MM = 2; const MAX_DIAMETER_MM = 40; +// Which "explain Continue / Start over / Exit" message a guided tool falls +// under. Grow from Seeds gets its own copy; the slice-range tools (Copy/Fill +// across slices) and Islands' pick-based ops (Remove picked/Keep picked) +// all drive the exact same three controls, so they share one message keyed +// off a single "seen" flag rather than repeating the popup three times. +type GuidedHintGroup = "growSeeds" | "sliceOps"; +function guidedHintGroup(tool: PrimaryEditTool): GuidedHintGroup | null { + if (tool === "growFromSeeds") return "growSeeds"; + if (tool === "copyAcrossSlices" || tool === "fillBetweenSlices" || tool === "islands") return "sliceOps"; + return null; +} +const GUIDED_HINT_COPY: Record = { + growSeeds: + "Continue moves on once you've placed your seed scribbles. Start over clears every seed and lets you begin again. Exit leaves Grow from Seeds without changing anything.", + sliceOps: + "Continue (labeled Remove picked/Keep picked/etc. depending on the tool) applies your picks. Start over clears them and lets you pick again. Exit leaves the tool without changing anything.", +}; + // Ribbon height, matches --atb-ribbon-h in CSS. Exported so SegmentsPopup // can dock directly beneath the ribbon without duplicating the constant. export const ANNOTATION_DOCK_WIDTH = 60; @@ -291,7 +357,7 @@ function IconTooltip({ // tool icon) rather than inside any one tool's own settings flyout, so it // stays visible — and in one consistent place — no matter which tool is // running or whether that tool's settings happen to be open right now. -function RenderingIndicator({ label, visible }: { label?: string; visible: boolean }) { +function RenderingIndicator({ label: _label, visible }: { label?: string; visible: boolean }) { return ( - {label ? `${label} — applying…` : "Applying…"} + {/* Plain "Applying…" — no tool-name prefix (e.g. "Islands — + applying…"), same reasoning as the guided-flow label being + hidden once busy above: the dot itself already lives right + next to whichever tool triggered it, so naming it again here + was redundant. `label` is still accepted for callers that + pass one, just no longer rendered. */} + Applying… ); } + +/** First-use popup listing the annotation keyboard shortcuts, portaled to + * and centered over the whole viewer (not anchored to the ribbon — + * it's a one-time orientation card, not a per-tool flyout). Shown once + * (see SHORTCUTS_INTRO_SEEN_KEY) the moment the annotation toolbar opens. */ +function ShortcutsIntroPopup({ onDismiss }: { onDismiss: () => void }) { + return createPortal( +
{ if (e.target === e.currentTarget) onDismiss(); }} + > +
+
+
+

Keyboard shortcuts

+

Some shortcuts to help you while annotating.

+
+ +
+
+ {SHORTCUT_GROUPS.map((group) => ( +
+

{group.label}

+ {group.rows.map((row) => ( +
+ {row.label} + + {row.keys.map((k, i) => ( + + {i > 0 && +} + {k} + + ))} + +
+ ))} +
+ ))} +
+
+ +
+
+
, + document.body + ); +} export default function AnnotationToolbar({ open, hasSegments, hasActiveTarget, activeTool, onToolChange, diameterMm, onDiameterChange, onDiameterPreviewChange, scissorsOptions, onScissorsOptionsChange, renderFlyout, scissorsPointCount, onScissorsCancel, maskingArea, onMaskingAreaChange, hasAnySegments, scopeLocked, isRendering, isDeletingSegment, targetKey, showOnlyTargetMask, onShowOnlyTargetMaskChange, - popupRef, popupDragRef, popupMinRef, + popupRef, popupDragRef, popupMinRef, anchorRef, }: AnnotationToolbarProps) { const [hoveredTool, setHoveredTool] = useState(null); const [hoveredRect, setHoveredRect] = useState(null); const iconRefs = useRef>({}); + // Just the icon - {continueWarning && continueWarningPos && ( - - )} -
+ )} @@ -1111,7 +1359,7 @@ export default function AnnotationToolbar({ }} >
- Select an existing class or create a custom one to start annotating. + Select an existing class or create a custom one, then start annotating.
+ + + )} + + {/* Small blue rectangle pinned near the cursor when Continue is + clicked while the guided flow still has nothing to continue + with (e.g. no seed point marked yet). Same visual language as + SliceAnchorPickerUI's PickErrorHint, kept local here since this + fires from the ribbon's Continue button, not from a canvas + click. */} + {continueBlockedHint && ( +
+ {continueBlockedHint.message} +
+ )} + , document.body ); diff --git a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css index 316574a4..fa11384b 100644 --- a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css +++ b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css @@ -70,6 +70,43 @@ animation: atb-pop-grow-in 0.16s cubic-bezier(0.16, 1, 0.3, 1); } +/* ============================================================ + POINTER — the little arrow connecting a flyout panel back to the + ribbon icon it opened from, so it's unambiguous which button in the + horizontal toolbar produced it (same rotated-square technique as + SegmentsPopup's .segpop__form-flyout-arrow, but with a Hopkins-blue + border instead of a plain hairline one, since here it's doing double + duty as an "originates from this button" cue rather than just a + cosmetic connector). Sits on the panel itself, positioned per-instance + via the inline `top`/`left` FlyoutPanel computes off the anchor's own + center — see the `pointer` calc in FlyoutPanel's compute(). + ============================================================ */ +.atb-pop__pointer { + position: absolute; + width: 12px; + height: 12px; + background: #202124; + border-left: 2px solid #68ACE5; + border-top: 2px solid #68ACE5; + border-radius: 3px 0 0 0; + z-index: 1; +} + +/* Below-anchored panel: pointer sits on the top edge, rotated to point + straight up at the icon above it. */ +.atb-pop__pointer--top { + top: -6.5px; + transform: translateX(-50%) rotate(45deg); +} + +/* Right-anchored (grandchild) panel: pointer sits on the left edge, + rotated a quarter turn further so the same top-left border pair now + points left, at the row that spawned it. */ +.atb-pop__pointer--left { + left: -6.5px; + transform: translateY(-50%) rotate(-45deg); +} + .atb-pop__panel.is-closing { opacity: 0; transform: scale(0.9); diff --git a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx index 5dc089c6..9c9d9ee9 100644 --- a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx +++ b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx @@ -122,6 +122,21 @@ export function FlyoutArrow({ type="button" className={`atb-pop__arrow ${open ? "is-open" : ""}`} onClick={onClick} + // The panel this arrow toggles anchors to the tool's ICON, not the + // arrow itself (so the flyout centers under the icon rather than + // the wider icon+arrow wrapper) — so the arrow sits outside the + // anchorRef/panelRef boundary useFlyout's outside-click listener + // checks. Without this, pressing the arrow to CLOSE an open + // flyout raced with that listener: its `mousedown` handler fired + // first (mousedown bubbles to `document` before `click` fires), + // saw the arrow wasn't inside the boundary, and closed the panel + // right there — then this button's own `onClick` ran a beat + // later against an already-`open:false` render and reopened it, + // so the arrow appeared to do nothing on the second press. + // Stopping propagation here keeps that mousedown from ever + // reaching the document listener, so this button's own toggle + // logic is the only thing that decides open/closed. + onMouseDown={(e) => e.stopPropagation()} aria-label={label} aria-expanded={open} > @@ -174,7 +189,12 @@ export function FlyoutPanel({ * tree — and the overlay it portals to — stays alive. */ keepMounted?: boolean; }) { - const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + // `pointer` is the offset (along the panel's top edge for "below", or + // its left edge for "right") of the little blue-bordered arrow that + // connects the panel back to the ribbon icon it opened from — see + // .atb-pop__pointer. Computed off the anchor's own center, same + // approach as SegmentsPopup's FormFlyout arrowLeft. + const [pos, setPos] = useState<{ top: number; left: number; pointer: number } | null>(null); // How long the panel's grow-in/shrink-out transition takes — mirrors the // entrance animation's own duration (see .atb-pop__panel's @@ -245,7 +265,15 @@ export function FlyoutPanel({ } left = Math.max(margin, left); top = Math.min(top, vh - margin - 40); - setPos({ top, left }); + // Point the arrow at the anchor's own center, clamped to stay + // within the panel's (estimated) bounds — same clamping idea as + // SegmentsPopup's FormFlyout arrowLeft, just axis-swapped for + // "right" panels (offset down their left edge instead of across + // their top edge). + const pointer = placement === "right" + ? Math.max(14, Math.min(r.top + r.height / 2 - top, (panelRef.current?.offsetHeight ?? 200) - 14)) + : Math.max(14, Math.min(r.left + r.width / 2 - left, estWidth - 14)); + setPos({ top, left, pointer }); }; compute(); window.addEventListener("resize", compute); @@ -284,6 +312,16 @@ export function FlyoutPanel({ display: keepMounted && !open && !closing ? "none" : undefined, }} > + {/* Blue-bordered pointer connecting this panel back to the + * ribbon icon (or row) it opened from — same rotated-square + * technique as SegmentsPopup's .segpop__form-flyout-arrow, + * but with a Hopkins-blue border so it doubles as a clear + * "this panel belongs to that button" cue. Sits on the top + * edge for a "below" panel, the left edge for a "right" one. */} + {children} , document.body diff --git a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx index 5bf004e6..0dafc81b 100644 --- a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx +++ b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx @@ -2425,6 +2425,43 @@ export function canvasPointToVoxel(pane: CinePane, canvasPos: Point2): [number, return null; } } +// Canvas-pixel positions only mean what they mean for the camera that was +// active the instant they were captured — zooming/panning afterward remaps +// every world location to a different canvas pixel, so anything stashed as +// a raw canvas coordinate (a lasso/scissors corner, a smart-fill seed dot) +// silently drifts off the anatomy it was placed on the moment the camera +// changes, both visually and — if it's later fed back into +// canvasPointToVoxel — in the actual voxels the tool acts on. World-space +// (mm) points don't have that problem: a world coordinate names the same +// physical location regardless of zoom/pan. Anything that needs to survive +// a camera change between "placed" and "drawn/committed" should be stored +// via canvasPointToWorld and turned back into a canvas pixel via +// worldToCanvasPoint at the moment it's actually drawn or committed. +export function canvasPointToWorld(pane: CinePane, canvasPos: Point2): Point3 | null { + const engine = getRenderingEngine(renderingEngineId); + if (!engine) return null; + const viewport = engine.getViewport(CINE_VIEWPORT_BY_PANE[pane]) as any; + if (!viewport) return null; + try { + return viewport.canvasToWorld(canvasPos) as Point3; + } catch { + return null; + } +} + +export function worldToCanvasPoint(pane: CinePane, world: Point3): [number, number] | null { + const engine = getRenderingEngine(renderingEngineId); + if (!engine) return null; + const viewport = engine.getViewport(CINE_VIEWPORT_BY_PANE[pane]) as any; + if (!viewport) return null; + try { + const [x, y] = viewport.worldToCanvas(world) as Point2; + return [x, y]; + } catch { + return null; + } +} + function _sliceAxisForPane(pane: CinePane): 0 | 1 | 2 { return pane === "sagittal" ? 0 : pane === "coronal" ? 1 : 2; } @@ -2671,9 +2708,83 @@ export function computeLiveWirePath( if (pathLocal[pathLocal.length - 1] !== seedLocal) return null; // unreachable pathLocal.reverse(); + // --- Sub-voxel edge snap --------------------------------------------- + // The raw Dijkstra path above is grid-locked (every point sits on an + // integer voxel) and, on any CONVEX stretch of boundary, is quietly + // biased toward the INSIDE of the intensity transition: a real CT edge + // is a ramp several voxels wide (partial-volume blur), not a single- + // pixel step, and going around the inside of that ramp is a shorter + // route than going around the outside. Since the search also minimizes + // path length (linkLen + the bending penalty), that small length + // advantage quietly wins the tie-break across a whole curved stretch — + // this is what shows up as the contour consistently sitting a few mm + // inside the true boundary. Fix: for every interior point (the two + // fastened endpoints are left exactly where the user clicked), walk a + // short distance along the LOCAL intensity gradient — i.e. + // perpendicular to the edge — and re-center the point on the actual + // gradient-magnitude peak, located with sub-voxel precision via a + // parabolic fit rather than whichever integer voxel the graph search + // happened to land on. Bilinear-sampled, so it isn't limited to the + // same coarse grid that caused the bias in the first place. + const sampleHUf = (a: number, b: number): number => { + const a0 = Math.floor(a), b0 = Math.floor(b); + const fa = a - a0, fb = b - b0; + const v00 = huAt(a0, b0), v10 = huAt(a0 + 1, b0); + const v01 = huAt(a0, b0 + 1), v11 = huAt(a0 + 1, b0 + 1); + return v00 * (1 - fa) * (1 - fb) + v10 * fa * (1 - fb) + v01 * (1 - fa) * fb + v11 * fa * fb; + }; + const H = 0.5; // sub-voxel differencing step, in voxels + const gradAtf = (a: number, b: number): [number, number] => [ + (sampleHUf(a + H, b) - sampleHUf(a - H, b)) / (2 * H), + (sampleHUf(a, b + H) - sampleHUf(a, b - H)) / (2 * H), + ]; + const gradMagAtf = (a: number, b: number): number => { + const [gxf, gyf] = gradAtf(a, b); + return Math.hypot(gxf, gyf); + }; + + const REFINE_RADIUS = 1.75; // voxels either side of the raw path point to search + const REFINE_STEP = 0.25; + const refinePoint = (a: number, b: number): [number, number] => { + const [gxf, gyf] = gradAtf(a, b); + const gmag = Math.hypot(gxf, gyf); + if (gmag < 1e-6) return [a, b]; // flat locally — nothing to snap to, leave it + const ux = gxf / gmag, uy = gyf / gmag; // unit vector along the gradient, i.e. perpendicular to the edge + + // Coarse search for the strongest gradient magnitude along that + // normal. Starts from (and only ever improves on) the raw point's own + // magnitude, so this can only pull toward a genuinely stronger nearby + // edge — never introduces a large jump toward an unrelated feature. + let bestT = 0, bestMag = gmag; + for (let t = -REFINE_RADIUS; t <= REFINE_RADIUS; t += REFINE_STEP) { + if (t === 0) continue; + const m = gradMagAtf(a + ux * t, b + uy * t); + if (m > bestMag) { bestMag = m; bestT = t; } + } + // Parabolic sub-step refinement around the winning sample so the final + // point isn't itself grid-locked to REFINE_STEP increments. + const mMinus = gradMagAtf(a + ux * (bestT - REFINE_STEP), b + uy * (bestT - REFINE_STEP)); + const mPlus = gradMagAtf(a + ux * (bestT + REFINE_STEP), b + uy * (bestT + REFINE_STEP)); + const denom = mMinus - 2 * bestMag + mPlus; + const delta = Math.abs(denom) > 1e-6 ? (0.5 * (mMinus - mPlus)) / denom : 0; + const tRefined = bestT + Math.max(-REFINE_STEP, Math.min(REFINE_STEP, delta * REFINE_STEP)); + + return [a + ux * tRefined, b + uy * tRefined]; + }; + const points: Array<[number, number]> = []; - for (const li of pathLocal) { - const a = winA0 + (li % winW), b = winB0 + Math.floor(li / winW); + for (let idx = 0; idx < pathLocal.length; idx++) { + const li = pathLocal[idx]; + let a = winA0 + (li % winW), b = winB0 + Math.floor(li / winW); + // Leave the two fastened endpoints exactly where the user clicked — + // only interior points get snapped to the refined edge location. + if (idx > 0 && idx < pathLocal.length - 1) { + [a, b] = refinePoint(a, b); + } + // sliceOf works fine with fractional a/b (it just slots them into the + // fixed-axis tuple) — passing the refined, non-rounded values straight + // through is what actually preserves the sub-voxel correction; feeding + // it Math.round(a)/Math.round(b) here would throw the refinement away. const [i, j, k] = sliceOf(a, b); try { const world = ctVolume.imageData.indexToWorld([i, j, k]); diff --git a/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts b/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts index aafbe2b9..7611baff 100644 --- a/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts +++ b/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts @@ -1,5 +1,5 @@ // helpers/viewer/useLevelTracing.ts -import { useRef, useState, type MouseEvent } from "react"; +import { useEffect, useRef, useState, type MouseEvent } from "react"; import { canvasPointToVoxel, computeLevelTraceMask, @@ -21,6 +21,15 @@ interface UseLevelTracingArgs { operation: LevelTraceOperation; activeSegmentIndex: number | null; maskFilter: MaskFilter; + /** Bump this (e.g. pass the toolbar's zoom slider value) whenever the + * pane's camera changes. The traced mask itself is voxel-space and + * camera-independent, but its cached preview OUTLINE is a canvas-pixel + * path computed the moment the mouse last moved — without this, zooming + * without also moving the mouse leaves that outline drawn at the old + * zoom level (visually detached from the anatomy) until the next + * mousemove happens to refresh it. Re-deriving the outline from the + * still-valid traced mask on every camera change keeps it pinned. */ + cameraVersion?: number; onLog?: (detail: string) => void; } @@ -28,7 +37,7 @@ interface UseLevelTracingArgs { * region under the cursor on the current slice and preview its outline; on * click, commit it into (or out of) the active segment per `operation`. */ export function useLevelTracing({ - enabled, toleranceHu, operation, activeSegmentIndex, maskFilter, onLog, + enabled, toleranceHu, operation, activeSegmentIndex, maskFilter, cameraVersion, onLog, }: UseLevelTracingArgs) { const [previewPane, setPreviewPane] = useState(null); const [previewPath, setPreviewPath] = useState | null>(null); @@ -82,6 +91,16 @@ export function useLevelTracing({ ); }; + // Re-derive the outline from the still-valid (voxel-space) traced mask + // whenever the camera changes, instead of leaving it drawn at whatever + // canvas pixels it happened to occupy at the last mousemove. + useEffect(() => { + const traced = tracedRef.current; + if (!traced) return; + setPreviewPath(levelTraceMaskToCanvasPath(traced.pane, traced.mask)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cameraVersion]); + return { handleClick, handleMouseMove, diff --git a/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts index e7d66f20..4dd55c76 100644 --- a/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts +++ b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts @@ -1,6 +1,11 @@ // helpers/viewer/usePolygonDraw.ts import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; -import type { CinePane } from "../CornerstoneNifti2"; +import { + canvasPointToWorld, + worldToCanvasPoint, + type CinePane, +} from "../CornerstoneNifti2"; +import type { Point3 } from "@cornerstonejs/core/types"; const CLOSE_CLICK_RADIUS_PX = 10; @@ -8,16 +13,19 @@ interface UsePolygonDrawArgs { enabled: boolean; /** Called with the DENSE fill path (every pixel along every leg, including * any live-wire detours) once the shape is closed — this is what actually - * gets rasterized/filled. */ + * gets rasterized/filled. Given as CURRENT-camera canvas points, freshly + * reprojected from the stored world-space path, so a zoom/pan mid-draw + * can't shift which voxels the commit lands on. */ onClose: (pane: CinePane, points: Array<[number, number]>) => void; /** Optional "live wire" hook (e.g. the magnetic edge-snap tool). Given the - * pane and the last fastening point + the current cursor point, returns a - * dense path from `from` to `to` (inclusive) that hugs nearby intensity - * edges — or null/undefined to fall back to a straight line between the - * two points. Used both for the live preview between clicks and to bake - * the actual leg in once the user clicks to drop the next fastening - * point, exactly like Photoshop's magnetic lasso: the cursor doesn't need - * to trace the boundary exactly, the path snaps to it between clicks. */ + * pane and the last fastening point + the current cursor point (both in + * CURRENT canvas-pixel space), returns a dense path from `from` to `to` + * (inclusive) that hugs nearby intensity edges — or null/undefined to + * fall back to a straight line between the two points. Used both for the + * live preview between clicks and to bake the actual leg in once the + * user clicks to drop the next fastening point, exactly like Photoshop's + * magnetic lasso: the cursor doesn't need to trace the boundary exactly, + * the path snaps to it between clicks. */ computeLivePath?: ( pane: CinePane, from: [number, number], @@ -25,16 +33,33 @@ interface UsePolygonDrawArgs { ) => Array<[number, number]> | null | undefined; } +// Reproject a world-space path back into the CURRENT camera's canvas-pixel +// space. Called on every render (cheap — just a matrix multiply per point) +// so the drawn overlay and the eventual commit always reflect whatever +// zoom/pan is active right now, not whatever was active when each point was +// originally clicked. +function toCanvas(pane: CinePane, world: Point3[]): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (const w of world) { + const p = worldToCanvasPoint(pane, w); + if (p) out.push(p); + } + return out; +} + export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygonDrawArgs) { // Dense fill path — every pixel along every committed leg (corners plus, // when computeLivePath is active, whatever detour the live wire took to - // hug an edge between two corners). This is what gets filled/cut. - const [points, setPoints] = useState>([]); + // hug an edge between two corners) — stored in WORLD space so it stays + // anchored to the same anatomy across zoom/pan. Reprojected to canvas + // pixels on demand via `points` below. + const [pointsWorld, setPointsWorld] = useState([]); // Just the clicked "fastening points" — used for the corner dots and for - // screen-space close-click detection, independent of any live-wire detour. - const [corners, setCorners] = useState>([]); + // close-click detection, independent of any live-wire detour. Also + // world-space for the same reason. + const [cornersWorld, setCornersWorld] = useState([]); // How many dense points each leg (ending at corners[i+1]) contributed to - // `points`, so undo() can pop exactly one leg's worth back off. + // `pointsWorld`, so undo() can pop exactly one leg's worth back off. const legLengthsRef = useRef([]); const [livePreview, setLivePreview] = useState<[number, number] | null>(null); const [livePreviewPath, setLivePreviewPath] = useState | null>(null); @@ -44,8 +69,8 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon const paneRef = useRef(null); const reset = useCallback(() => { - setPoints([]); - setCorners([]); + setPointsWorld([]); + setCornersWorld([]); legLengthsRef.current = []; setLivePreview(null); setLivePreviewPath(null); @@ -57,51 +82,69 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon if (!enabled) reset(); }, [enabled, reset]); + // Canvas-space views of the world-space state, reprojected against + // whatever camera (zoom/pan) is active on THIS render. This is what + // drives the overlay, so it visually tracks the anatomy through any + // zoom/pan instead of staying pinned to old pixel coordinates. + const pane = paneRef.current; + const points = pane ? toCanvas(pane, pointsWorld) : []; + const corners = pane ? toCanvas(pane, cornersWorld) : []; + const close = useCallback(() => { const pane = paneRef.current; - if (!pane || corners.length < 3) return; - // Bake the closing leg (last corner back to the start) into the dense - // path too, so a magnetic/live-wire close hugs the boundary just like - // every other leg instead of snapping back with a straight line. - const last = corners[corners.length - 1]; - const first = corners[0]; + if (!pane || cornersWorld.length < 3) return; + // Reproject the stored world-space corners/path to CURRENT canvas + // pixels right before committing — so if the user zoomed/panned + // partway through drawing, the polygon handed to the commit (and the + // canvasPointToVoxel conversion it does internally) is consistent + // with itself, rather than a mix of old and new camera pixels. + const currentCorners = toCanvas(pane, cornersWorld); + const currentPoints = toCanvas(pane, pointsWorld); + const last = currentCorners[currentCorners.length - 1]; + const first = currentCorners[0]; const closingLeg = computeLivePath?.(pane, last, first); const closingPoints = closingLeg && closingLeg.length >= 2 ? closingLeg.slice(1) : [first]; - onClose(pane, [...points, ...closingPoints]); + onClose(pane, [...currentPoints, ...closingPoints]); reset(); - }, [points, corners, computeLivePath, onClose, reset]); + }, [pointsWorld, cornersWorld, computeLivePath, onClose, reset]); const handleClick = (pane: CinePane) => (e: MouseEvent) => { if (!enabled) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rawPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; + const rawWorld = canvasPointToWorld(pane, rawPos); + if (!rawWorld) return; if (!paneRef.current) { paneRef.current = pane; - setPoints([rawPos]); - setCorners([rawPos]); + setPointsWorld([rawWorld]); + setCornersWorld([rawWorld]); legLengthsRef.current = []; return; } if (paneRef.current !== pane) return; // Close-click detection always uses the raw cursor position against the - // raw start corner — a live-wire detour shouldn't change where "click - // here to close" actually is on screen. - if (corners.length >= 3) { - const [fx, fy] = corners[0]; - if (Math.hypot(rawPos[0] - fx, rawPos[1] - fy) < CLOSE_CLICK_RADIUS_PX) { + // start corner reprojected to CURRENT canvas space — a live-wire detour + // (or an intervening zoom) shouldn't change where "click here to close" + // actually is on screen right now. + if (cornersWorld.length >= 3) { + const first = worldToCanvasPoint(pane, cornersWorld[0]); + if (first && Math.hypot(rawPos[0] - first[0], rawPos[1] - first[1]) < CLOSE_CLICK_RADIUS_PX) { close(); return; } } - const last = corners[corners.length - 1]; - const leg = computeLivePath?.(pane, last, rawPos); - const legPoints = leg && leg.length >= 2 ? leg.slice(1) : [rawPos]; - legLengthsRef.current = [...legLengthsRef.current, legPoints.length]; - setPoints((prev) => [...prev, ...legPoints]); - setCorners((prev) => [...prev, rawPos]); + const lastCanvas = worldToCanvasPoint(pane, cornersWorld[cornersWorld.length - 1]); + const leg = lastCanvas ? computeLivePath?.(pane, lastCanvas, rawPos) : undefined; + const legPointsCanvas = leg && leg.length >= 2 ? leg.slice(1) : [rawPos]; + const legPointsWorld = legPointsCanvas + .map((cp) => canvasPointToWorld(pane, cp)) + .filter((w): w is Point3 => !!w); + legLengthsRef.current = [...legLengthsRef.current, legPointsWorld.length]; + setPointsWorld((prev) => [...prev, ...legPointsWorld]); + setCornersWorld((prev) => [...prev, rawWorld]); }; const handleDoubleClick = (pane: CinePane) => (e: MouseEvent) => { @@ -111,27 +154,27 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon }; const handleMouseMove = (pane: CinePane) => (e: MouseEvent) => { - if (!enabled || paneRef.current !== pane || !corners.length) return; + if (!enabled || paneRef.current !== pane || !cornersWorld.length) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rawPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; setLivePreview(rawPos); - const last = corners[corners.length - 1]; - const preview = computeLivePath?.(pane, last, rawPos); - setLivePreviewPath(preview && preview.length >= 2 ? preview : [last, rawPos]); + const lastCanvas = worldToCanvasPoint(pane, cornersWorld[cornersWorld.length - 1]); + const preview = lastCanvas ? computeLivePath?.(pane, lastCanvas, rawPos) : undefined; + setLivePreviewPath(preview && preview.length >= 2 ? preview : (lastCanvas ? [lastCanvas, rawPos] : null)); - if (corners.length >= 3) { - const [fx, fy] = corners[0]; - setNearClose(Math.hypot(rawPos[0] - fx, rawPos[1] - fy) < CLOSE_CLICK_RADIUS_PX); + if (cornersWorld.length >= 3) { + const first = worldToCanvasPoint(pane, cornersWorld[0]); + setNearClose(!!first && Math.hypot(rawPos[0] - first[0], rawPos[1] - first[1]) < CLOSE_CLICK_RADIUS_PX); } }; const undo = () => { - if (corners.length <= 1) { reset(); return; } + if (cornersWorld.length <= 1) { reset(); return; } const lastLegLen = legLengthsRef.current[legLengthsRef.current.length - 1] ?? 0; legLengthsRef.current = legLengthsRef.current.slice(0, -1); - setPoints((prev) => prev.slice(0, prev.length - lastLegLen)); - setCorners((prev) => prev.slice(0, -1)); + setPointsWorld((prev) => prev.slice(0, prev.length - lastLegLen)); + setCornersWorld((prev) => prev.slice(0, -1)); }; useEffect(() => { @@ -139,19 +182,19 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; - if (e.key === "Escape" && corners.length) { e.preventDefault(); reset(); } - else if (e.key === "Enter" && corners.length >= 3) { e.preventDefault(); close(); } + if (e.key === "Escape" && cornersWorld.length) { e.preventDefault(); reset(); } + else if (e.key === "Enter" && cornersWorld.length >= 3) { e.preventDefault(); close(); } // Ctrl/Cmd+Z removes the last placed point, one at a time — replaces // the old "Undo point" button in the flyout with the shortcut users // actually reach for. - else if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z" && corners.length) { + else if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z" && cornersWorld.length) { e.preventDefault(); undo(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [enabled, corners, reset, close]); + }, [enabled, cornersWorld, reset, close]); return { pane: paneRef.current, diff --git a/PanTS-Demo/src/helpers/viewer/useSmartFill.ts b/PanTS-Demo/src/helpers/viewer/useSmartFill.ts index aa12f929..4d54039c 100644 --- a/PanTS-Demo/src/helpers/viewer/useSmartFill.ts +++ b/PanTS-Demo/src/helpers/viewer/useSmartFill.ts @@ -1,14 +1,25 @@ import { useRef, useState, type MouseEvent } from "react"; import { canvasPointToVoxel, + canvasPointToWorld, + worldToCanvasPoint, runDualScribbleFill, pushEditHistory, type CinePane, type SliceInfo, type MaskFilter, } from "../CornerstoneNifti2"; - -type ScribblePoint = { pos: [number, number]; slice: number }; +import type { Point3 } from "@cornerstonejs/core/types"; + +// World-space, not canvas-pixel — a canvas position only means what it means +// for the camera active the instant it was clicked, so a dot stored that way +// visually drifts off the marked anatomy the moment the user zooms/pans. +// Storing world coordinates and reprojecting to canvas pixels on every +// render keeps the preview pinned to the same spot on the slice regardless +// of zoom. (The actual fill algorithm below already works in voxel space via +// fgVoxelsRef/bgVoxelsRef, so it was never affected — only the preview dots +// were drifting.) +type ScribblePoint = { posWorld: Point3; slice: number }; type PanePreview = { fg: ScribblePoint[]; bg: ScribblePoint[] }; const EMPTY_PREVIEW: Record = { @@ -38,19 +49,19 @@ interface UseSmartFillArgs { export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSmartFillArgs) { const [markMode, setMarkMode] = useState<"fg" | "bg">("fg"); const [scope, setScope] = useState<"slice" | "volume">("slice"); - const [preview, setPreview] = useState>(EMPTY_PREVIEW); + const [previewWorld, setPreviewWorld] = useState>(EMPTY_PREVIEW); const scribbleActiveRef = useRef(false); const fgVoxelsRef = useRef<[number, number, number][]>([]); const bgVoxelsRef = useRef<[number, number, number][]>([]); const paneRef = useRef(null); - // Mirrors `preview` so stroke bookkeeping can read the latest value + // Mirrors `previewWorld` so stroke bookkeeping can read the latest value // synchronously (state updates are async/batched, refs aren't). - const previewRef = useRef(preview); + const previewRef = useRef(previewWorld); const updatePreview = (next: Record) => { previewRef.current = next; - setPreview(next); + setPreviewWorld(next); }; // Captures everything needed to undo/redo one whole click-and-drag @@ -76,6 +87,8 @@ export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSm const canvasPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; const voxel = canvasPointToVoxel(pane, canvasPos); if (!voxel) return; + const world = canvasPointToWorld(pane, canvasPos); + if (!world) return; paneRef.current = pane; (markMode === "fg" ? fgVoxelsRef : bgVoxelsRef).current.push(voxel); @@ -85,7 +98,7 @@ export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSm ...previewRef.current, [pane]: { ...previewRef.current[pane], - [markMode]: [...previewRef.current[pane][markMode], { pos: canvasPos, slice: sliceIdx }], + [markMode]: [...previewRef.current[pane][markMode], { posWorld: world, slice: sliceIdx }], }, }); }; @@ -147,6 +160,24 @@ export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSm }); }; + // Canvas-pixel view of the world-space preview dots, reprojected against + // whatever camera (zoom/pan) is active on THIS render — this is what the + // overlay should actually draw from, so the dots track the marked + // anatomy through zoom instead of staying pinned to old pixel positions. + const preview: Record; bg: Array<{ pos: [number, number]; slice: number }> }> = { + axial: { fg: [], bg: [] }, + sagittal: { fg: [], bg: [] }, + coronal: { fg: [], bg: [] }, + }; + (Object.keys(previewWorld) as CinePane[]).forEach((pane) => { + (["fg", "bg"] as const).forEach((mode) => { + for (const pt of previewWorld[pane][mode]) { + const pos = worldToCanvasPoint(pane, pt.posWorld); + if (pos) preview[pane][mode].push({ pos, slice: pt.slice }); + } + }); + }); + return { markMode, setMarkMode, diff --git a/PanTS-Demo/src/routes/VisualizationPage.css b/PanTS-Demo/src/routes/VisualizationPage.css index 928609ae..621ee134 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.css +++ b/PanTS-Demo/src/routes/VisualizationPage.css @@ -441,6 +441,22 @@ /* Above .atb-shell's z-index (50) so this bar's downward-pointing tool tooltips render on top of the annotation ribbon when it's open. */ z-index: 55; + transition: background 0.25s ease, border-color 0.25s ease; +} + +/* While the annotation ribbon is open, the main toolbar steps back — + lower background contrast and dimmer inactive icons — so attention + reads as "focused on the annotation toolbar now open below" instead of + two equally-loud bars competing for it. The Annotate button itself + (and anything else already flagged active) stays at full strength so + it doesn't look broken or disabled. */ +.VisualizationPage.annotation-open .vp-topbar { + background: rgba(12, 13, 16, 0.6); + border-bottom-color: rgba(255, 255, 255, 0.05); +} + +.VisualizationPage.annotation-open .vp-topbar .vp-tool:not(.vp-tool--active) { + opacity: 0.5; } /* Floating gear shown only when the toolbar is hidden. */ @@ -701,7 +717,7 @@ border: 1px solid var(--vp-border); color: var(--vp-text); cursor: pointer; - transition: background 0.15s, border-color 0.15s; + transition: background 0.15s, border-color 0.15s, opacity 0.25s ease; } .vp-tool:hover { diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index 45af9ac1..0c2b6e7f 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -410,6 +410,86 @@ function useToolbarFlyout() { return { open, pos, groupRef, btnRef, menuRef, toggle, close }; } +// One-time "click here to close" nudge shown next to the closing anchor +// (the highlighted/red first point) on the Lasso/Scissors live-wire +// overlay, the moment the cursor first gets close enough to actually close +// the loop. Fades away automatically after a few seconds rather than +// needing a dismiss click, since by the time it fades the person has +// almost always already seen the highlighted anchor itself. Resets the +// moment the loop is closed/cancelled (anchor goes away), so it can show +// again on the next shape. +const CLOSE_LOOP_HINT_VISIBLE_MS = 3000; +const CLOSE_LOOP_HINT_FADE_MS = 300; +function CloseLoopHint({ nearClose, anchor }: { nearClose: boolean; anchor: [number, number] | undefined }) { + const [visible, setVisible] = useState(false); + const [fading, setFading] = useState(false); + // Tracks whether this hint has already been shown for the CURRENT loop + // in progress, so it only ever fires once per shape rather than + // re-triggering every time the cursor wanders in and out of range. + const shownThisLoopRef = useRef(false); + const fadeTimerRef = useRef(null); + const hideTimerRef = useRef(null); + + const clearTimers = () => { + if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current); + if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); + fadeTimerRef.current = null; + hideTimerRef.current = null; + }; + + // No anchor means there's no shape in progress (just closed, cancelled, + // or not started yet) — reset so the next shape can show the hint again. + useEffect(() => { + if (anchor) return; + shownThisLoopRef.current = false; + setVisible(false); + setFading(false); + clearTimers(); + }, [anchor]); + + useEffect(() => { + if (!nearClose || !anchor || shownThisLoopRef.current) return; + shownThisLoopRef.current = true; + setFading(false); + setVisible(true); + fadeTimerRef.current = window.setTimeout(() => setFading(true), CLOSE_LOOP_HINT_VISIBLE_MS); + hideTimerRef.current = window.setTimeout(() => setVisible(false), CLOSE_LOOP_HINT_VISIBLE_MS + CLOSE_LOOP_HINT_FADE_MS); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nearClose, anchor]); + + useEffect(() => () => clearTimers(), []); + + if (!visible || !anchor) return null; + return ( + + ); +} + function VisualizationPage() { // References and state const params = useParams(); @@ -757,6 +837,21 @@ function VisualizationPage() { const setActiveSegment = (id: number | null) => setActiveSegmentState(id); + // Shared by both "select a custom class" and "select an existing organ" + // (see onSelect/handleSelectCatalogOrgan below): moves both the 2D MPR + // crosshair and the 3D crosshair to the centroid of whatever class was + // just targeted, on axial/sagittal/coronal at once — same mechanism the + // sidebar's "jump to organ" already uses (handleJumpToOrgan below), just + // triggered from the popup's own row click instead. No-ops quietly if + // the class has no voxels yet (nothing painted into it) or centroid data + // isn't available for it. + const jumpCrosshairToSegmentCentroid = (label: number) => { + const centroid = getOrganCentroids()?.[label]; + if (!centroid) return; + moveCornerstoneCrosshairToMm(centroid); + setCrosshairMm(centroid); + }; + // Selecting an existing organ from the dropdown targets the brush at it // exactly like clicking a custom-segment row does. const handleSelectCatalogOrgan = (id: number | null) => { @@ -765,6 +860,7 @@ function VisualizationPage() { // (id === null) must clear activeSegment too, or a stale id lingers // and SegmentsPopup keeps showing a target as active. setActiveSegmentState(id); + if (id != null) jumpCrosshairToSegmentCentroid(id); }; const handleRenameSegment = (id: number, name: string): boolean => { const dup = checkBoxData.some((s) => s.id !== id && s.label.toLowerCase() === name.toLowerCase()); @@ -1151,6 +1247,11 @@ function VisualizationPage() { operation: levelTraceOperation, activeSegmentIndex: activeSegment, maskFilter, + // Lets the hook re-derive its cached preview outline (voxel-space, + // camera-independent) against the new camera the instant zoom changes, + // instead of leaving it drawn at old canvas pixels until the next + // mousemove happens to refresh it. + cameraVersion: zoomLevel, onLog: (detail) => sessionRef.current?.log("edit", detail, 1500), }); @@ -1276,6 +1377,23 @@ function VisualizationPage() { setActiveMeasurementTool(null); setActiveMaskEditTool(null); releasePrimaryMouseTools(); + } else if (editMode === "lasso" || activeToolbarTool === "levelTracing") { + // Scissors/lasso (editMode "lasso") and level tracing (its own hook, + // keyed off activeToolbarTool rather than editMode) all place their + // points via plain clicks on the pane, same as smart fill's + // scribbling — so the crosshair tool needs to be OFF here too, not + // just left to whatever `crosshairToolActive` (the user's saved + // navigation preference) happens to be. Previously this fell + // through to the plain `else` below, which re-enabled Crosshairs + // whenever `crosshairToolActive` was true (the default) — so the + // crosshair stayed live and interactive under the polygon/trace + // clicks even though the toolbar's own crosshair button visually + // showed itself as deselected (its active-state check already + // excludes any editMode) — nothing in the UI hinted navigation was + // still armed underneath. + setActiveMeasurementTool(null); + setActiveMaskEditTool(null); + toggleCrosshairTool(false); } else if (activeMeasureTool) { setActiveMaskEditTool(null); setActiveMeasurementTool(activeMeasureTool); @@ -1284,7 +1402,7 @@ function VisualizationPage() { setActiveMeasurementTool(null); toggleCrosshairTool(crosshairToolActive); } - }, [editMode, activeMeasureTool, crosshairToolActive]); + }, [editMode, activeToolbarTool, activeMeasureTool, crosshairToolActive]); @@ -2302,6 +2420,25 @@ function VisualizationPage() { } }; + const handleToggleAnnotationToolbar = () => { + const opening = !showAnnotationToolbar; + setShowAnnotationToolbar(opening); + if (!opening) { + // Closing (deselecting the Annotate button): drop whatever class + // was targeted — the isolation effect above reacts to + // activeCatalogOrganId/activeSegment both going null by putting + // every segmentation mask back to visible — and back out of + // whatever tool/edit mode was active, so the toolbar and popup + // (both driven by the same `open`/`showAnnotationToolbar` prop) + // close together instead of the target/tool state lingering + // invisibly after the UI has visually gone away. + setActiveCatalogOrganId(null); + setActiveSegmentState(null); + setEditMode(null); + setActiveToolbarTool(null); + } + }; + const handleToggleStats = () => { // The right-side slot is shared by stats / metadata / measurements / mask editing. setShowMetadata(false); @@ -3035,7 +3172,7 @@ const aiAvailableOrgans = useMemo(() => {