From 3f6624b540bacde5b03bbaa9664c38160187281b Mon Sep 17 00:00:00 2001 From: CMaintz Date: Sat, 26 Sep 2026 23:51:35 +0200 Subject: [PATCH 1/3] fix(explorer): selection wins over hover, no hover while the map moves; name lit links --- app/src/components/Explorer.tsx | 9 +- app/src/lib/explorer-2d.ts | 158 +++++++++++++++++++- app/src/lib/explorer-3d.ts | 228 ++++++++++++++++++++++++++--- app/src/lib/explorer-config.ts | 13 ++ app/src/lib/explorer-focus.test.ts | 90 ++++++++++++ app/src/lib/explorer-focus.ts | 114 +++++++++++++++ 6 files changed, 585 insertions(+), 27 deletions(-) create mode 100644 app/src/lib/explorer-focus.test.ts create mode 100644 app/src/lib/explorer-focus.ts diff --git a/app/src/components/Explorer.tsx b/app/src/components/Explorer.tsx index f3d71f70..57244b2b 100644 --- a/app/src/components/Explorer.tsx +++ b/app/src/components/Explorer.tsx @@ -350,6 +350,12 @@ export default function Explorer(props: Props) { [graph, domains], ); + /** Relationship names for the lit links, read from either end (A99). */ + const relationNames = useMemo( + () => ({ label: props.panel.edgeLabels, inverse: props.panel.edgeInverse }), + [props.panel], + ); + // ---- 2D: built once, then only told what to show -------------------------------------- useEffect(() => { if (!graph || !box2d.current) return; @@ -368,6 +374,7 @@ export default function Explorer(props: Props) { onHover: (id) => prefetchTerm(props.panel.apiBase, id), onPoint, theme, + relationNames, }); map2d.current = m; return () => { @@ -407,6 +414,7 @@ export default function Explorer(props: Props) { onPoint, theme, domainLabels: props.domainLabels, + relationNames, }), ) .then((m) => { @@ -461,7 +469,6 @@ export default function Explorer(props: Props) { const labAll = props.lab?.showAll; useEffect(() => void (labAll !== undefined && setShowAll(labAll)), [labAll]); useEffect(() => props.lab?.onMaps({ map2d: map2d.current, map3d }), [graph, map3d]); - // ---- Tools ----------------------------------------------------------- const findRoute = () => { if (!visible) return; diff --git a/app/src/lib/explorer-2d.ts b/app/src/lib/explorer-2d.ts index 60629409..fe206f6c 100644 --- a/app/src/lib/explorer-2d.ts +++ b/app/src/lib/explorer-2d.ts @@ -47,6 +47,15 @@ import { import { edgeData, graphStyle, reducedMotion, smoothFit } from './graph-cytoscape'; import { createDragFeedback } from './drag-feedback'; import type { Axes } from './explorer-keys'; +import { + createMotionGate, + cullBoxes, + effectiveFocus, + labelPov, + relationLabel, + rotatedSize, + type RelationNames, +} from './explorer-focus'; import { startDots, type DotsConfig } from './explorer-flow'; cytoscape.use(fcose); @@ -74,6 +83,8 @@ export type Map2DOptions = { onPoint?: (hit: { id: string; x: number; y: number } | null) => void; /** The map's palette (A92); change it later with `retheme`. */ theme?: MapTheme; + /** Relationship names, written on the lit links (A99); none without. */ + relationNames?: RelationNames; }; /** What the map shows; every field is applied in place. */ @@ -214,6 +225,30 @@ const extraStyle = (theme: MapTheme) => [ style: { 'text-opacity': 1, 'min-zoomed-font-size': 0, 'z-index': 20 }, }, { selector: 'node.far.nb', style: { 'font-size': 'data(hoverFont)' } }, + // A term hovered over a selection or route (A99): it and its link to the selection + // come forward; the selection's look stays. + { + selector: 'node.pv', + style: { opacity: 1, 'text-opacity': 1, 'min-zoomed-font-size': 0, 'z-index': 21 }, + }, + { selector: 'edge.pv', style: { opacity: 1, 'z-index': 19 } }, + // Relationship names on the lit links (A99), upright along the line, just above it. + { + selector: 'edge.rl, edge.rlh', + style: { + label: 'data(rel)', + 'font-size': 'data(relFont)', + 'font-weight': 500, + color: MAP_INK[theme].label, + 'text-opacity': 1, + 'text-rotation': 'autorotate', + 'text-margin-y': -6, + 'text-outline-color': MAP_INK[theme].halo, + 'text-outline-width': 2, + 'text-outline-opacity': 0.9, + 'min-zoomed-font-size': 0, + }, + }, ]; export function createMap2D(opts: Map2DOptions) { @@ -752,6 +787,8 @@ export function createMap2D(opts: Map2DOptions) { }; // ---- 5. Hover: light the neighbourhood, fade the rest (with a little intent) -------- + // With a term selected (or a route shown) the selection's look stays and hover only + // brings the hovered term forward (A99). let hoverTimer = 0; const unhover = () => { window.clearTimeout(hoverTimer); @@ -759,17 +796,35 @@ export function createMap2D(opts: Map2DOptions) { const was = hovered; hovered = null; cy.batch(() => { + cy.elements('.pv').removeClass('pv'); shown.removeClass('faded lit'); cy.nodes('.hoverhide').removeClass('hoverhide'); // Edges revealed only for the hover go back to hidden. was.connectedEdges('.hoverlink').removeClass('hoverlink').addClass('off'); cy.nodes('.tag').removeClass('faded'); }); + paintLabels(); opts.container.style.cursor = 'grab'; }; const hover = (n: cytoscape.NodeSingular) => { hovered = n; const v = view!; + opts.container.style.cursor = 'pointer'; + const f = effectiveFocus({ + selected: v.selected, + route: v.highlight.size > 0, + hovered: n.id(), + moving: false, + }); + if (!f.hood) { + const sel = v.selected; + if (f.preview) + cy.batch(() => { + n.addClass('pv'); + if (sel) n.edgesWith(cy.getElementById(sel)).not('.off').addClass('pv'); + }); + return; + } const extra = n .connectedEdges('.off') .filter( @@ -797,8 +852,59 @@ export function createMap2D(opts: Map2DOptions) { hood.addClass('lit'); lit.filter((m) => hidden.has(m.id())).addClass('hoverhide'); }); - opts.container.style.cursor = 'pointer'; + paintLabels(); }; + + // ---- 5a. Relationship names on the lit links (A99) -------------------------------- + // The selected term's links, or a hovered term's when it has few, each named from + // that term's side; names that would overlap give way to heavier links, and the link + // under the pointer shows its own. A constant size on screen when zoomed out. + let named = cy.collection() as cytoscape.EdgeCollection; + const paintLabels = () => + cy.batch(() => { + cy.edges('.rl, .rlh').removeClass('rl rlh'); + named = cy.collection() as cytoscape.EdgeCollection; + const names = opts.relationNames; + const v = view; + if (!names || !v) return; + const f = effectiveFocus({ + selected: v.selected, + route: v.highlight.size > 0, + hovered: hovered?.id() ?? null, + moving: false, + }); + const litOf = (id: string) => + cy.getElementById(id).connectedEdges().intersection(links).not('.off'); + const pov = labelPov( + f, + v.selected, + f.hood ? litOf(f.hood).length : 0, + EXPLORER.edgeLabels.hoverMax, + ); + const p = pov ? cy.getElementById(pov) : null; + if (!pov || !p || p.empty() || p.hasClass('gone')) return; + const size = EXPLORER.edgeLabels.px2d / Math.min(1, cy.zoom()); + const edges = litOf(pov) + .toArray() + .map((e) => ({ e, l: graph.links[Number(e.id().slice(1))] })) + .sort((a, b) => b.l.weight - a.l.weight); + const boxes = edges.map(({ e, l }) => { + const text = relationLabel(l, pov, names.label, names.inverse); + e.data({ rel: text, relFont: size }); + const a = e.source().position(); + const b = e.target().position(); + const r = rotatedSize( + text.length * size * 0.55, + size * 1.3, + Math.atan2(b.y - a.y, b.x - a.x), + ); + return { id: e.id(), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 - 6, ...r }; + }); + const kept = cullBoxes(boxes); + named = cy.collection(edges.map(({ e }) => e)) as cytoscape.EdgeCollection; + named.filter((e) => kept.has(e.id())).addClass('rl'); + }); + // No hover while a button is down: restyling mid-pan throws away the viewport snapshot. let pressing = false; // The background can be dragged: an open hand at rest, a closed one and a ring while @@ -819,10 +925,31 @@ export function createMap2D(opts: Map2DOptions) { pressing = false; drag.end(); }); - cy.on('viewport', () => opts.onPoint?.(null)); - cy.on('mouseover', 'node[size]', (e) => { - if (pressing) return; - const n = e.target as cytoscape.NodeSingular; + // No hover while the map moves (A99): a pan, the wheel, a glide to a term, keys or a + // layout change; and none again until the pointer itself moves once it has stopped + // (Cytoscape does not re-report a term the view slid under a resting pointer). + const gate = createMotionGate(); + let quietTimer = 0; + /** The term under the pointer, hovered or not. */ + let under: cytoscape.NodeSingular | null = null; + const moved = () => { + if (gate.motion(true)) { + unhover(); + opts.onPoint?.(null); + } + window.clearTimeout(quietTimer); + quietTimer = window.setTimeout(() => { + if (moving) return moved(); + gate.motion(false); + // Names are sized for the zoom: re-place them once it settles. + if (named.nonempty()) paintLabels(); + }, EXPLORER.edgeLabels.quietMs); + }; + cy.on('viewport', () => { + opts.onPoint?.(null); + moved(); + }); + const showHover = (n: cytoscape.NodeSingular) => { opts.onHover?.(n.id()); const at = n.renderedPosition(); opts.onPoint?.({ id: n.id(), x: at.x, y: at.y }); @@ -832,11 +959,27 @@ export function createMap2D(opts: Map2DOptions) { unhover(); hover(n); }, EXPLORER.hoverDelayMs); + }; + cy.on('mouseover', 'node[size]', (e) => { + under = e.target as cytoscape.NodeSingular; + if (pressing || !gate.open) return; + showHover(under); }); cy.on('mouseout', 'node[size]', () => { + under = null; unhover(); opts.onPoint?.(null); }); + const onPointerMove = () => { + const was = gate.open; + gate.pointer(); + if (!was && gate.open && under && !pressing) showHover(under); + }; + opts.container.addEventListener('pointermove', onPointerMove); + cy.on('mouseover', 'edge', (e) => { + if (gate.open && !pressing && named.contains(e.target)) e.target.addClass('rlh'); + }); + cy.on('mouseout', 'edge', (e) => void e.target.removeClass('rlh')); cy.on('tap', 'node[size]', (e) => opts.onSelect(e.target.id())); cy.on('tap', (e) => e.target === cy && opts.onSelect(null)); cy.on('dbltap', 'node[size]', (e) => opts.onOpen(e.target.id())); @@ -899,6 +1042,7 @@ export function createMap2D(opts: Map2DOptions) { setBundledRoutes(v.layout === 'force' && v.showAll, t.centre); recull(true); frame(); + paintLabels(); }; if (!animate || reducedMotion()) { cy.batch(() => move.forEach((n) => void n.position(t.positions[n.id()]))); @@ -908,6 +1052,7 @@ export function createMap2D(opts: Map2DOptions) { // Straight-line routes bend badly mid-flight; drop them until nodes land. setBundledRoutes(false); moving = true; + moved(); move .layout({ name: 'preset', @@ -1112,6 +1257,7 @@ export function createMap2D(opts: Map2DOptions) { } } if (next.selected) cy.getElementById(next.selected).removeClass('dim').addClass('sel'); + paintLabels(); }); if (fading.nonempty()) fading.animate( @@ -1213,6 +1359,8 @@ export function createMap2D(opts: Map2DOptions) { pending = null; window.clearTimeout(cullTimer); window.clearTimeout(hoverTimer); + window.clearTimeout(quietTimer); + opts.container.removeEventListener('pointermove', onPointerMove); drag.destroy(); cy.destroy(); }, diff --git a/app/src/lib/explorer-3d.ts b/app/src/lib/explorer-3d.ts index 7cb7bb7b..db722e76 100644 --- a/app/src/lib/explorer-3d.ts +++ b/app/src/lib/explorer-3d.ts @@ -21,6 +21,16 @@ import { backboneOf, galaxyLayout, linkVisible, pageRank, separate } from './gra import { reducedMotion } from './graph-cytoscape'; import { createDragFeedback, orbitDragKind } from './drag-feedback'; import type { Axes } from './explorer-keys'; +import { + createMotionGate, + cullBoxes, + effectiveFocus, + labelPov, + relationLabel, + type Focus, + type LabelBox, + type RelationNames, +} from './explorer-focus'; export type View3D = { nodes: ReadonlySet; @@ -44,7 +54,7 @@ const hexRgb = (hex: string) => { const rgba = (hex: string, a: number) => `rgba(${hexRgb(hex).join(',')},${a})`; /** Transparent draw order (three.js sorts by renderOrder before distance). */ -const DRAW = { glow: -1, solid: 0, links: 1, receded: 2, flow: 3 } as const; +const DRAW = { glow: -1, solid: 0, links: 1, receded: 2, flow: 3, tags: 4 } as const; /** A 3d-force-graph object as the per-frame draw-order pass sees it. */ type Obj3 = { __graphObjType?: string; @@ -67,6 +77,8 @@ export async function createMap3D(opts: { theme?: MapTheme; /** Each domain's name, written large and faint across its galaxy (A93b). */ domainLabels?: Record; + /** Relationship names, written on the lit links (A99); none without. */ + relationNames?: RelationNames; }) { const [{ default: ForceGraph3D }, THREE] = await Promise.all([ import('3d-force-graph'), @@ -104,24 +116,40 @@ export async function createMap3D(opts: { } let view: View3D | null = null; - let hover: Set | null = null; + /** The hovered term the map shows (hover is gated: never while the map moves). */ + let hoverId: string | null = null; + const gate = createMotionGate(); + /** What hover adds (A99): a whole neighbourhood with nothing selected, else a preview. */ + let fx: Focus = { hood: null, preview: null }; + const refocus = () => { + fx = effectiveFocus({ + selected: view?.selected ?? null, + route: !!view?.highlight.size, + hovered: hoverId, + moving: !gate.open, + }); + }; + const hoodOf = () => (fx.hood ? neighbours.get(fx.hood) : undefined); const focusOf = (l: Link3) => { if (!view) return false; const s = endId(l.source); const t = endId(l.target); - if (hover) return hover.has(s) && hover.has(t) && (hoverId === s || hoverId === t); + const hood = hoodOf(); + if (hood) return hood.has(s) && hood.has(t) && (fx.hood === s || fx.hood === t); return ( s === view.selected || t === view.selected || (view.highlight.has(s) && view.highlight.has(t)) ); }; - let hoverId: string | null = null; /** * Receded terms: outside the hovered neighbourhood, else outside a route, else — - * with a term selected — everything not connected to it (A86). + * with a term selected — everything not connected to it (A86). A term hovered over a + * selection or route comes forward on its own (A99). */ const faded = (id: string) => { - if (hover) return !hover.has(id); + const hood = hoodOf(); + if (hood) return !hood.has(id); if (!view) return false; + if (id === fx.preview) return false; if (view.highlight.size) return !view.highlight.has(id); if (view.selected) return !neighbours.get(view.selected)?.has(id); return false; @@ -182,7 +210,10 @@ export async function createMap3D(opts: { // solid spheres (they write depth, so they still hide what is behind them), the // lines, the receded spheres (no depth write: the lines show through), the comets. // The sphere materials are 3d-force-graph's, swapped on its schedule: every frame. - scene.onBeforeRender = () => + // The same pass watches the camera (A99): any change since the last frame is motion. + const lastPos = new THREE.Vector3(NaN, NaN, NaN); + const lastTurn = new THREE.Quaternion(); + scene.onBeforeRender = () => { scene.traverse((obj) => { const o = obj as unknown as Obj3; if (o.__graphObjType === 'link') o.renderOrder = DRAW.links; @@ -191,6 +222,16 @@ export async function createMap3D(opts: { if (o.material) o.material.depthWrite = solid; o.renderOrder = solid ? DRAW.solid : DRAW.receded; }); + const cam = fg.camera(); + const moved = + !(cam.position.distanceToSquared(lastPos) < 1e-6) || + 1 - Math.abs(cam.quaternion.dot(lastTurn)) > 1e-10; + lastPos.copy(cam.position); + lastTurn.copy(cam.quaternion); + // Restyling re-evaluates 3d-force-graph's objects: not in the middle of a render. + if (gate.motion(moved)) window.setTimeout(stopHover, 0); + placeTags(); + }; // ---- Glow: one additive point cloud for every term ---------------------------------- const glowTexture = (() => { @@ -603,6 +644,120 @@ export async function createMap3D(opts: { }; runFlow(true); + // ---- Relationship names on the lit links (A99) -------------------------------------- + // A billboarded sprite per lit link of the selected (or a lightly linked hovered) term, + // a constant size on screen just above the link's midpoint, read from that term's side. + // Each frame the names are projected and those that would overlap give way to heavier + // links; the link under the pointer always shows its name. + type Tag = { + i: number; + weight: number; + aspect: number; + sprite: InstanceType; + }; + const tagPx = EXPLORER.edgeLabels.px3d; + const tagArt = new Map< + string, + { tex: InstanceType; aspect: number } + >(); + const artFor = (text: string) => { + const had = tagArt.get(text); + if (had) return had; + const fpx = 36; + const c = document.createElement('canvas'); + const g = c.getContext('2d')!; + const font = `500 ${fpx}px system-ui, sans-serif`; + g.font = font; + c.width = Math.ceil(g.measureText(text).width) + 16; + c.height = Math.ceil(fpx * 1.4); + g.font = font; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.lineJoin = 'round'; + g.lineWidth = 8; + g.strokeStyle = ink().halo; + g.strokeText(text, c.width / 2, c.height / 2); + g.fillStyle = ink().label3d; + g.fillText(text, c.width / 2, c.height / 2); + const art = { tex: new THREE.CanvasTexture(c), aspect: c.width / c.height }; + tagArt.set(text, art); + return art; + }; + const tagPool: InstanceType[] = []; + let tags: Tag[] = []; + const paintTags = () => { + for (const t of tags) t.sprite.visible = false; + tags = []; + const names = opts.relationNames; + if (!view || !names) return; + const lit = links.filter((l) => endsShown(l) && focusOf(l)); + const pov = labelPov(fx, view.selected, lit.length, EXPLORER.edgeLabels.hoverMax); + if (!pov || !view.nodes.has(pov)) return; + for (const l of lit) { + const s = endId(l.source); + const t = endId(l.target); + if (s !== pov && t !== pov) continue; + const art = artFor( + relationLabel({ source: s, target: t, type: l.type }, pov, names.label, names.inverse), + ); + let sprite = tagPool[tags.length]; + if (!sprite) { + sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + transparent: true, + depthTest: false, + depthWrite: false, + fog: false, + sizeAttenuation: false, + }), + ); + // Anchored below its centre, so the name sits just above the link. + sprite.center.set(0.5, -0.25); + sprite.renderOrder = DRAW.tags; + sprite.frustumCulled = false; + tagPool.push(sprite); + scene.add(sprite); + } + sprite.material.map = art.tex; + sprite.material.needsUpdate = true; + const o = l.i * 9; + // The curve's midpoint: a quarter of each end and half of the bend. + sprite.position.set( + 0.25 * curve[o] + 0.5 * curve[o + 3] + 0.25 * curve[o + 6], + 0.25 * curve[o + 1] + 0.5 * curve[o + 4] + 0.25 * curve[o + 7], + 0.25 * curve[o + 2] + 0.5 * curve[o + 5] + 0.25 * curve[o + 8], + ); + tags.push({ i: l.i, weight: l.weight, aspect: art.aspect, sprite }); + } + tags.sort((a, b) => b.weight - a.weight); + }; + const tagAt = new THREE.Vector3(); + /** Per frame: size every name for the camera, and cull the overlapping ones. */ + const placeTags = () => { + if (!tags.length) return; + const cam = fg.camera() as InstanceType; + const w = el.clientWidth; + const h = el.clientHeight; + // With sizeAttenuation off a sprite's height on screen is scale × P[5] × h / 2. + const k = (2 * tagPx) / (cam.projectionMatrix.elements[5] * h); + const boxes: LabelBox[] = []; + const first = tags.filter((t) => t.i === underLink); + for (const t of [...first, ...tags.filter((x) => x.i !== underLink)]) { + t.sprite.scale.set(k * t.aspect, k, 1); + tagAt.copy(t.sprite.position).project(cam); + if (tagAt.z > 1 || Math.abs(tagAt.x) > 1.2 || Math.abs(tagAt.y) > 1.2) continue; + boxes.push({ + id: String(t.i), + x: ((tagAt.x + 1) / 2) * w, + y: ((1 - tagAt.y) / 2) * h - tagPx * 0.9, + w: tagPx * t.aspect, + h: tagPx, + }); + } + const kept = cullBoxes(boxes); + for (const t of tags) t.sprite.visible = kept.has(String(t.i)); + }; + /** Additive on the night map; multiplied into the cream map (a light on white is lost). */ const setBlend = () => { const blending = light() ? THREE.MultiplyBlending : THREE.AdditiveBlending; @@ -638,11 +793,12 @@ export async function createMap3D(opts: { s.material.opacity = faded(id) ? 0.12 : 1; } // A domain's name shows while any of its own terms does; it recedes with a selection. - const quiet = !!(hover || view?.selected || view?.highlight.size); + const quiet = !!(fx.hood || view?.selected || view?.highlight.size); for (const a of domainArt) { a.sprite.visible = a.ids.some((id) => view?.nodes.has(id)); a.sprite.material.opacity = cfg.domainLabelAlpha[theme] * (quiet ? 0.35 : 1); } + paintTags(); }; /** Re-evaluate the accessors (3d-force-graph's idiom) and land any new objects. */ @@ -658,26 +814,52 @@ export async function createMap3D(opts: { fg.d3ReheatSimulation(); }; + // ---- Hover (A99): three-render-objects re-raycasts the last pointer position every + // frame, so while the camera moves terms drift under a resting pointer. The term under + // the pointer is tracked always, but shown only through the motion gate: never while + // the camera moves (auto-rotate, a glide, a drag, the wheel, keys), and not again until + // the pointer itself moves once it has stopped. let hoverTimer = 0; - /** Auto-rotating: terms drift under a still pointer, so no hover card. */ + /** The term under the pointer, as 3d-force-graph last reported it. */ + let under: string | null = null; + /** The link under the pointer: its name shows even where names were culled. */ + let underLink: number | null = null; let spinning = false; - fg.onNodeHover((n: GraphNode | null) => { - el.style.cursor = n ? 'pointer' : 'grab'; - if (n) opts.onHover?.(n.id); - const p = n && !spinning ? byId.get(n.id) : undefined; + const setHover = (id: string | null) => { + if (id === hoverId) return; + hoverId = id; + refocus(); + refresh(); + }; + const showHover = () => { + const p = under ? byId.get(under) : undefined; + el.style.cursor = p ? 'pointer' : 'grab'; if (p) { + opts.onHover?.(p.id); const at = fg.graph2ScreenCoords(p.x, p.y, p.z); opts.onPoint?.({ id: p.id, x: at.x, y: at.y }); } else opts.onPoint?.(null); window.clearTimeout(hoverTimer); - hoverTimer = window.setTimeout(() => { - const next = n ? n.id : null; - if (next === hoverId) return; - hoverId = next; - hover = n ? (neighbours.get(n.id) ?? new Set([n.id])) : null; - refresh(); - }, EXPLORER.hoverDelayMs); + hoverTimer = window.setTimeout(() => gate.open && setHover(under), EXPLORER.hoverDelayMs); + }; + fg.onNodeHover((n: GraphNode | null) => { + under = n ? n.id : null; + if (gate.open) showHover(); }); + fg.onLinkHover((l: Link3 | null) => void (underLink = l && gate.open ? l.i : null)); + /** The camera started moving: drop the hover and its card at once. */ + const stopHover = () => { + window.clearTimeout(hoverTimer); + underLink = null; + opts.onPoint?.(null); + setHover(null); + }; + const onPointerMove = () => { + const was = gate.open; + gate.pointer(); + if (!was && gate.open) showHover(); + }; + el.addEventListener('pointermove', onPointerMove); el.style.cursor = 'grab'; const drag = createDragFeedback(el); @@ -754,6 +936,7 @@ export async function createMap3D(opts: { apply(next: View3D) { const prev = view; view = next; + refocus(); if (prev?.families !== next.families || prev?.nodes !== next.nodes) { // Over the shown terms only, so none is stranded by a hidden domain. const spine = backboneOf(graph.nodes, graph.links, next.families, next.nodes); @@ -767,7 +950,7 @@ export async function createMap3D(opts: { const toggleOnly = !!prev && !nodesChanged && - !hover && + !fx.hood && prev.selected === next.selected && prev.highlight === next.highlight && !next.highlight.size && @@ -857,6 +1040,8 @@ export async function createMap3D(opts: { fog.color.set(ink().bg3d); tints = tintsFor(); familyColour = familyColoursFor(); + for (const a of tagArt.values()) a.tex.dispose(); + tagArt.clear(); for (const a of labelArt) { drawLabel(a.text, a.c); a.tex.needsUpdate = true; @@ -884,6 +1069,7 @@ export async function createMap3D(opts: { window.clearTimeout(hoverTimer); window.removeEventListener('resize', resize); window.removeEventListener('pointerup', onRelease); + el.removeEventListener('pointermove', onPointerMove); el.removeEventListener('pointerdown', onPress, true); drag.destroy(); fg._destructor(); diff --git a/app/src/lib/explorer-config.ts b/app/src/lib/explorer-config.ts index eb7484a6..ca0868f1 100644 --- a/app/src/lib/explorer-config.ts +++ b/app/src/lib/explorer-config.ts @@ -80,6 +80,19 @@ export const EXPLORER = { /** The pointer must rest on a term this long before its hover card appears (A93). */ hoverCardMs: 1200, + /** + * Relationship names on lit links (A99): the selected term's, or a hovered term's when + * it has at most `hoverMax` lit links. Text size on screen (2D keeps it from shrinking + * when zoomed out); overlapping names give way to heavier links. + */ + edgeLabels: { + hoverMax: 12, + px2d: 10, + px3d: 12, + /** 2D: the view counts as still this long after its last pan or zoom. */ + quietMs: 150, + }, + /** Marching dashes on the term page's hovered edges (graph-cytoscape `startFlow`). */ flow: { /** Pixels per second the dash pattern moves, source → target. */ diff --git a/app/src/lib/explorer-focus.test.ts b/app/src/lib/explorer-focus.test.ts new file mode 100644 index 00000000..dea27177 --- /dev/null +++ b/app/src/lib/explorer-focus.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + createMotionGate, + cullBoxes, + effectiveFocus, + labelPov, + relationLabel, + rotatedSize, +} from './explorer-focus'; + +const at = (selected: string | null, hovered: string | null, moving = false, route = false) => + effectiveFocus({ selected, route, hovered, moving }); + +describe('effectiveFocus (A99): selection wins over hover', () => { + it('hover drives the look with nothing selected', () => { + expect(at(null, 'mfa')).toEqual({ hood: 'mfa', preview: null }); + expect(at(null, null)).toEqual({ hood: null, preview: null }); + }); + it('a selection keeps its look; hover only previews another term', () => { + expect(at('phishing', 'mfa')).toEqual({ hood: null, preview: 'mfa' }); + expect(at('phishing', 'phishing')).toEqual({ hood: null, preview: null }); + expect(at('phishing', null)).toEqual({ hood: null, preview: null }); + }); + it('a route holds the look like a selection', () => { + expect(at(null, 'mfa', false, true)).toEqual({ hood: null, preview: 'mfa' }); + }); + it('ignores hover entirely while the map moves', () => { + expect(at(null, 'mfa', true)).toEqual({ hood: null, preview: null }); + expect(at('phishing', 'mfa', true)).toEqual({ hood: null, preview: null }); + }); +}); + +describe('createMotionGate (A99)', () => { + it('is open at rest', () => { + expect(createMotionGate().open).toBe(true); + }); + it('closes while moving and stays closed until the pointer moves after it stops', () => { + const g = createMotionGate(); + expect(g.motion(true)).toBe(true); + expect(g.motion(true)).toBe(false); + expect(g.open).toBe(false); + g.pointer(); // moved during motion: does not count + g.motion(false); + expect(g.open).toBe(false); + g.pointer(); + expect(g.open).toBe(true); + }); + it('stays open through still frames', () => { + const g = createMotionGate(); + expect(g.motion(false)).toBe(false); + expect(g.open).toBe(true); + }); +}); + +describe('relationLabel (A99)', () => { + const labels = { requires: 'Requires', unlocks: 'Unlocks', 'used-with': 'Used with' }; + const inverse = { requires: 'unlocks', 'used-with': 'used-with' }; + const l = { source: 'tls', target: 'pki', type: 'requires' }; + it("reads the link in the arrow's direction from the term's side", () => { + expect(relationLabel(l, 'tls', labels, inverse)).toBe('requires'); + expect(relationLabel(l, 'pki', labels, inverse)).toBe('unlocks'); + }); + it('keeps symmetric types and falls back to the type', () => { + expect(relationLabel({ ...l, type: 'used-with' }, 'pki', labels, inverse)).toBe('used with'); + expect(relationLabel({ ...l, type: 'mandates' }, 'tls', labels, inverse)).toBe('mandates'); + }); +}); + +describe('label placement (A99)', () => { + it('keeps the first of two overlapping boxes, and boxes clear of it', () => { + const kept = cullBoxes([ + { id: 'a', x: 0, y: 0, w: 40, h: 10 }, + { id: 'b', x: 20, y: 4, w: 40, h: 10 }, + { id: 'c', x: 0, y: 30, w: 40, h: 10 }, + ]); + expect([...kept].sort()).toEqual(['a', 'c']); + }); + it('sizes a rotated box', () => { + expect(rotatedSize(40, 10, 0)).toEqual({ w: 40, h: 10 }); + const r = rotatedSize(40, 10, Math.PI / 2); + expect(r.w).toBeCloseTo(10); + expect(r.h).toBeCloseTo(40); + }); + it("labels the selection's links, a small hover neighbourhood's, else none", () => { + expect(labelPov(at('phishing', 'mfa'), 'phishing', 99, 12)).toBe('phishing'); + expect(labelPov(at(null, 'mfa'), null, 12, 12)).toBe('mfa'); + expect(labelPov(at(null, 'mfa'), null, 13, 12)).toBeNull(); + expect(labelPov(at(null, 'mfa', false, true), null, 3, 12)).toBeNull(); + }); +}); diff --git a/app/src/lib/explorer-focus.ts b/app/src/lib/explorer-focus.ts new file mode 100644 index 00000000..b4d268d4 --- /dev/null +++ b/app/src/lib/explorer-focus.ts @@ -0,0 +1,114 @@ +/** + * What the Explorer's maps light up, as pure state (A99): a selection (or a route) + * always wins over hover, hover is ignored while the map moves, and the relationship + * labels on lit links. Shared by the 2D and 3D maps; no DOM, so it is unit-tested. + */ + +export type FocusInput = { + selected: string | null; + /** A route is highlighted. */ + route: boolean; + /** The term under the pointer. */ + hovered: string | null; + /** The map is moving, or has not seen the pointer move since it stopped. */ + moving: boolean; +}; + +export type Focus = { + /** Hover drives the look: this term's neighbourhood lit, the rest faded. */ + hood: string | null; + /** Hovered while a selection or route holds the look: only this term brightens. */ + preview: string | null; +}; + +/** Selection wins: with a term selected (or a route shown) hover only adds a preview. */ +export function effectiveFocus({ selected, route, hovered: h, moving }: FocusInput): Focus { + const hovered = moving ? null : h; + if (selected || route) return { hood: null, preview: hovered === selected ? null : hovered }; + return { hood: hovered, preview: null }; +} + +/** + * Hover is off while the map moves (auto-rotate, a camera glide, a drag, the wheel, + * keys) and stays off until the pointer itself moves again once it has stopped: a + * resting pointer that terms drifted under is not a hover. + */ +export function createMotionGate() { + let moving = false; + let stale = false; + return { + /** Report whether the map is moving now. Returns true when motion just started. */ + motion(on: boolean) { + const started = on && !moving; + moving = on; + if (on) stale = true; + return started; + }, + /** The pointer moved. */ + pointer() { + if (!moving) stale = false; + }, + get open() { + return !moving && !stale; + }, + }; +} +export type MotionGate = ReturnType; + +/** + * A relationship's name read from `pov`'s side, e.g. "requires" from its source and + * "unlocks" from its target; lower-cased to sit on a line. `label` maps a type (or an + * inverse type) to its text, `inverse` a type to its inverse type. + */ +export function relationLabel( + link: { source: string; target: string; type: string }, + pov: string, + label: Readonly>, + inverse: Readonly>, +) { + const type = link.source === pov ? link.type : (inverse[link.type] ?? link.type); + const text = label[type] ?? type; + return text.charAt(0).toLocaleLowerCase() + text.slice(1); +} + +/** Relationship names by type (and inverse type), and each type's inverse type. */ +export type RelationNames = { + label: Readonly>; + inverse: Readonly>; +}; + +export type LabelBox = { id: string; x: number; y: number; w: number; h: number }; + +/** Greedy placement: boxes in priority order, each kept unless it overlaps a kept one. */ +export function cullBoxes(boxes: readonly LabelBox[], pad = 2): Set { + const kept: LabelBox[] = []; + for (const b of boxes) { + const hit = kept.some( + (k) => Math.abs(k.x - b.x) * 2 < k.w + b.w + pad && Math.abs(k.y - b.y) * 2 < k.h + b.h + pad, + ); + if (!hit) kept.push(b); + } + return new Set(kept.map((k) => k.id)); +} + +/** The axis-aligned size of a w × h box rotated by `angle` (radians). */ +export function rotatedSize(w: number, h: number, angle: number) { + const c = Math.abs(Math.cos(angle)); + const s = Math.abs(Math.sin(angle)); + return { w: w * c + h * s, h: w * s + h * c }; +} + +/** + * Whose relationships are labelled: the selected term's, else a hovered term's when its + * lit links are few (`hoverMax`), else nobody's (a route alone has none). + */ +export function labelPov( + focus: Focus, + selected: string | null, + hoverLinks: number, + hoverMax: number, +): string | null { + if (selected) return selected; + if (focus.hood && hoverLinks <= hoverMax) return focus.hood; + return null; +} From 3d073a62d4a99e193d83152eddbd52767d1c3414 Mon Sep 17 00:00:00 2001 From: CMaintz Date: Sat, 26 Sep 2026 23:56:38 +0200 Subject: [PATCH 2/3] docs(decisions): A99 selection wins over hover, relationship names --- design/AUTONOMOUS_DECISIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/design/AUTONOMOUS_DECISIONS.md b/design/AUTONOMOUS_DECISIONS.md index b5e7b1fc..9dcb6c6f 100644 --- a/design/AUTONOMOUS_DECISIONS.md +++ b/design/AUTONOMOUS_DECISIONS.md @@ -157,6 +157,7 @@ Closed Vocabulary lint (E1) + full rule set; GitHub Pages deploy. | A96 | **A hidden visual lab for the Explorer** at `//lab/5f69d757b126/2d/` and `/3d/` (EN + DA; noindex so out of the sitemap, linked from nowhere, no tour, like A91), so the owner can see and measure old visual effects live without deploying test branches. Each page renders the real `Explorer` (bar, legend, About "i", term panel unchanged) and a floating **Lab** panel whose toggles restyle in place, never relayout. **2D**: backbone curve (straight haystack, today / bezier / unbundled-bezier from the edge's own offset × a strength slider), source-to-target domain-colour gradients, one-way flow (overlay dots, today / the old Cytoscape `line-dash-offset` marching dashes on every visible one-way edge / none) with a speed slider, hover (today / the old `attachHover` that restyles every element, plus the old fade transitions), node glow, "show all", label density (none / hubs / culled, today / all) and the viewport snapshot. **3D**: merged lines (today) or a tube and arrow cone per link (old), link curvature (re-bends the merged web and the comets too), flow (comets, today / the old per-link particles / none) with speed, glow (point cloud, today / a sprite per term, old), bloom (`UnrealBloomPass` + `OutputPass` and an opaque scene background, else the transparent canvas turns grey), auto-rotate, fog, node spacing (the scene scales about its centre while terms and labels keep their size). **Meter**: rAF-based, current fps, 1% low (1000 / 99th-percentile frame time) and the longest frame over the last 5 s; **Benchmark** runs a scripted 5 s pan and zoom (2D, straight through `cy.viewport`, so every frame is a full redraw: the snapshot only applies to user gestures) or one orbit (3D) and reports the average fps and the active toggles. Toggles start from the address (`?curve=bezier&gradient=1&bench=1`), so a headless run is a list of URLs; nothing is stored (no new storage keys). **Production hook, optional and inert by default**: `Explorer` takes `lab?: { mode, showAll, onMaps }`; `createMap2D` returns `lab.dots` (a live copy of `EXPLORER.dots`) and `setDots`, `startDots` takes that copy; `createMap3D` returns `lab` (its three.js objects, the flow settings copy, the curve buffer and its link predicates). Old effects live in `ExplorerLab.tsx` / `explorer-lab.ts`, not behind production flags. **Measured** (headless Edge, 1440×900, 2× CPU throttle, RTX 3080; the PR has the table): 2D scripted pan and zoom (full redraws) ≈13 fps today, curves or gradients ≈11, marching dashes ≈9, all three ≈7, "show all" + dashes ≈5, no flow ≈16; 3D orbit 60 today, tubes ≈13, per-link particles ≈25, sprite glow ≈33, bloom 59 (1% low 30), tubes + particles + sprites ≈8. **Later additions (owner)**: the lab follows the page theme through the maps' own `retheme` (it re-reads the restyled base and lays its rules over it again; bloom is off on the cream map, where it only washes out); **emphasis by importance** (both views): importance = a per-type rank (requires, kind of, part of 1.0; mitigates, exploits, causes, mandates 0.85; implements, supersedes 0.7; contrasts with, alternative to 0.5; used with 0.35) × the square root of the link's own `weight` normalised to the heaviest, shown as opacity, colour (saturation and lightness towards the background), width (2D and 3D tubes) or all three, with a spread slider (importance ^ spread); **relayout** (lab only): a minimum-distance slider that re-runs the spacing pass (`createMap2D` keeps each island's fcose result and exposes `lab.relayout`; 3D re-spaces from the original layout) and sub-domain clusters (2D: islands 0.8 × and 70 px further apart; 3D: clusters 1.4 × out from their domain's centre and 0.7 × tighter, with optional faint cluster names); **cream-map contrast** sliders (term saturation / lightness, edge darkness / opacity, shadow strength, 2D label weight / halo) with a "Copy values" button. 3D exposes inert `webGain` / `webTint` per link, `linkLength`, `radius` and `labels` for these. | The owner wants to judge effects by eye and by frame rate before choosing; a lab that reuses the real component measures the real cost, and keeping the old code paths out of production keeps the Explorer diff to a few inert lines. | | A97 | **Explorer: keyboard navigation (WASD) and 3D lines behind receded terms** (owner requests). **3D draw order**: every sphere is transparent (`nodeOpacity` 0.95), so three.js sorted each against the one merged web by distance, and a receded sphere (dimmed by a selection, hover or route) that happened to draw first wrote depth and erased every line behind it; only comets and glow showed through. Now a fixed `renderOrder`: glow, solid spheres (they still write depth, so they hide what is behind them), lines (the web and 3d-force-graph's focused links), receded spheres (no depth write, below `EXPLORER.three.solidOpacity`), comets. The sphere materials are 3d-force-graph's and swapped on its schedule, so a `scene.onBeforeRender` pass sets this each frame. **Keys** (`explorer-keys.ts`, unit-tested): held keys give a target velocity (opposite keys cancel, Shift ×3), eased exponentially (frame-rate independent; no easing under reduced motion). 2D: W A S D / arrows pan, Q/E or -/+ zoom about the centre of the part the panel leaves clear. 3D: W/S forward and back (towards the orbit centre, pushing it on ahead once `near`), A/D sideways and Q/E (Space: up) down and up, both moving the orbit centre with the camera, arrows orbit; the OrbitControls target stays in sync, so a mouse orbit afterwards turns about what is in front. Letters and arrows by physical key (`code`), + and - by character (Danish layout). **Ctrl as "down" is left out**: Ctrl+W closes the tab and Ctrl+drag pans. Keys act only when focus is inside the map host (now `tabindex=0`, `role="application"`, a "use W A S D" `aria-label`) or nothing has focus and the pointer is over the map; never with Alt/Ctrl/Cmd, and never in a field, the bar, a popover or the term panel (whose ←/→ keep stepping through connections). Held keys clear on blur and when the tab is hidden. The open legend ends with a one-line key hint for the current view (EN+DA; the term page's legend has none). Tunables in `EXPLORER.keys`. **Measured** (headless Edge, 1440×900, 2× CPU throttle, before/after): 2D pan 46/49, zoom 46/54, hover 59/59; 3D orbit 49/60, idle 59/59, hover 59/54 (run-to-run noise). | The owner asked for game-like movement; physical keys keep WASD on any layout. The focus/pointer guard keeps the map from taking keys meant for anything else. | | A98 | **Mobile pass: 44px tap targets below `sm`/`md`, a BETA badge instead of the ribbon on phones, dvh + safe areas, 16px inputs.** An emulated audit (Edge, 390x844 / 360x800 / 768x1024, touch, both themes and languages) found no horizontal overflow but undersized controls everywhere. (1) **BETA**: the fixed corner ribbon is `md` and up only; below that a small amber badge sits next to the logo, so nothing covers content, the tour pill or the timeline popover. (2) **Tap targets**: header buttons, menu items, chips, quiz and status buttons, footer links and page links get `min-h-11` (44px) on phones and are reset at `sm`/`md`, so desktop is unchanged. Footer links use a `[data-site-footer]` rule in `global.css`, not `[&>*]`, which would also display the `