diff --git a/app/src/components/Explorer.tsx b/app/src/components/Explorer.tsx index f3d71f70..f2abebb1 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 (A97a). */ + 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..09f6367c 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 (A97a); 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 (A97a): 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 (A97a), 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 (A97a). 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 (A97a) -------------------------------- + // 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 (A97a): 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..833d7f45 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 (A97a); 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 (A97a): 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 (A97a). */ 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 (A97a): 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 (A97a) -------------------------------------- + // 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 (A97a): 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..a19d6db7 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 (A97a): 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..cd047ff9 --- /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 (A97a): 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 (A97a)', () => { + 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 (A97a)', () => { + 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 (A97a)', () => { + 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..f49f53b5 --- /dev/null +++ b/app/src/lib/explorer-focus.ts @@ -0,0 +1,114 @@ +/** + * What the Explorer's maps light up, as pure state (A97a): 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; +} diff --git a/design/AUTONOMOUS_DECISIONS.md b/design/AUTONOMOUS_DECISIONS.md index b5e7b1fc..d117b503 100644 --- a/design/AUTONOMOUS_DECISIONS.md +++ b/design/AUTONOMOUS_DECISIONS.md @@ -156,6 +156,7 @@ Closed Vocabulary lint (E1) + full rule set; GitHub Pages deploy. | A95 | **Explorer overview types, meaning search in "Find a term", drag feedback** (owner-approved types; amends A93a). **Overview**: with nothing selected the map draws only kind-of, part-of, implements, requires, mitigates, exploits, causes, mandates and supersedes (`OVERVIEW_FAMILIES` in graph-layout.ts = the structure, dependency, security, regulation and lineage families; `backboneOf`'s default). *Used with*, *contrasts with* and *alternative to* start unticked in the Relationships popover (its one line says which types the overview starts with, EN+DA); a selected term still shows all its relationships. **Find a term** behaves like the home search: instant name and alias matches (aliases now ride in `graph.json` when a term has any), and for a question or description the `semantic-search` function's near-best hits, fused by RRF and labelled "by meaning"; the debounced, abortable client is one hook (`use-semantic.ts`) and the fusion one pure function (`mergeHits`), both shared with the home search; without a backend, or on failure or timeout, names only. Picking a result selects the term and brings it into view (also when it is already selected; the 3D camera cuts instead of gliding under reduced motion). **Drag feedback** (`drag-feedback.ts`): an open hand at rest, a closed hand while panning (2D, 3D right or modified drag), a light rotate-arrow cursor while orbiting (3D), and a light ring with a crosshair and a dark outline following the mouse while dragging (not on touch; no fade under reduced motion); Cytoscape's dark press disc is switched off. Tunables in `EXPLORER.drag`. **About "i"**: hidden while the term panel is expanded, so it never covers the panel's own controls. | The overview read as a hairball of symmetric links; the owner picked the types that carry structure. A search box that only knew names disagreed with the home search. The old press marker was a dark disc on a dark map: dragging gave no visible feedback. | | 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. | +| A97a | **Explorer: a selection wins over hover, no hover while the map moves, relationship names on lit links** (owner-approved; amends A97, A86 hover and A93 hover card). Found while recording the README GIF: in 3D, hover overrode the selection (`focusOf`/`faded` read the hover first) and three-render-objects re-raycasts the last pointer position every frame, so during auto-rotate or a camera glide terms drifted under a resting pointer and the selected term's lit links switched off while another neighbourhood lit up. **Selection wins** (2D and 3D): with a term selected (or a route shown) its look (its links lit, the rest dimmed) always stays; hovering another term only brings that term forward (3D: un-receded; 2D: `.pv` on the term and its link to the selection). With nothing selected, hover works as before. **No hover while moving**: a motion gate ignores hover (no restyle, no hover card) while the map moves and until the pointer itself moves once it has stopped (a resting pointer that terms drifted under is not a hover). 3D counts any camera change between frames as motion (auto-rotate, the fly-in, a glide to a term, orbit, pan, wheel, WASD); 2D counts viewport changes (pan, wheel, glides, keys; still after `edgeLabels.quietMs`) and layout moves. Each map tracks the term under the pointer separately from the hover it shows, because neither library re-reports a term it already considers hovered. **Relationship names**: the selected term's lit links carry the relationship's name read from that term's side (the panel's `edgeLabels` and `edgeInverse`, e.g. "requires", "mitigated by"; EN + DA), small, in the map's label ink with a halo; 2D along the line and upright (Cytoscape `autorotate`), a constant size on screen when zoomed out; 3D a billboarded sprite of constant screen size just above the curve's midpoint. Names that would overlap give way to heavier links (greedy, 3D re-culled every frame); hovering a link shows its own name. A hovered term (nothing selected) gets names only when it has at most `edgeLabels.hoverMax` (12) lit links. Pure logic in `explorer-focus.ts` (`effectiveFocus`, `createMotionGate`, `relationLabel`, `cullBoxes`, `labelPov`), unit-tested. **Checked** (headless Edge, 1440x900, dev server): Phishing selected, auto-rotate on, pointer resting on a term near the middle for 10 s, sampled every 500 ms: all 16 of Phishing's links lit and nothing else in every sample (7 of 16 names placed); on main the same run lost them in 2 of 20 samples. 2D, pointer on a neighbour of the selected Phishing: all 16 focus links and 15 neighbours stay lit (main faded 15 and dimmed 10), 11 names shown. After auto-rotate stops and the pointer moves, a hovered receded term comes forward with the selection intact. | The selection is what the reader chose; a pointer that happens to rest where terms drift must not take it away. Names on the lines say what the lit links are without opening the panel. | | 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 `