From c4f159c5af2fbc3d6f5ba35ff5309514e657a90b Mon Sep 17 00:00:00 2001 From: CMaintz Date: Fri, 25 Sep 2026 22:15:09 +0200 Subject: [PATCH 1/4] fix(explorer): backbone over the shown terms, so no term is stranded --- app/src/lib/explorer-2d.ts | 14 ++++++++++---- app/src/lib/explorer-3d.ts | 5 +++-- app/src/lib/graph-layout.test.ts | 23 +++++++++++++++++++++++ app/src/lib/graph-layout.ts | 10 ++++++++-- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/app/src/lib/explorer-2d.ts b/app/src/lib/explorer-2d.ts index 0fd6836c..0488479b 100644 --- a/app/src/lib/explorer-2d.ts +++ b/app/src/lib/explorer-2d.ts @@ -609,8 +609,11 @@ export function createMap2D(opts: Map2DOptions) { /** Elements currently displayed — hover fades only these. */ let shown = cy.collection(); - /** The backbone over the families switched on, recomputed when they change. */ - let spineFor: ReadonlySet | null = null; + /** + * The backbone over the families switched on and the terms shown, recomputed when + * either changes: a term whose strongest links went to hidden terms keeps a visible one. + */ + let spineFor: [ReadonlySet, ReadonlySet] | null = null; /** * Which edges are drawn, and how (backbone / all / focus). The families filter the * overview only: a selected term shows every one of its relationships. @@ -620,8 +623,11 @@ export function createMap2D(opts: Map2DOptions) { const v = view; const sel = v.selected; const hl = v.highlight; - const spine = spineFor === v.families ? null : backboneOf(graph.nodes, graph.links, v.families); - spineFor = v.families; + const spine = + spineFor?.[0] === v.families && spineFor[1] === v.nodes + ? null + : backboneOf(graph.nodes, graph.links, v.families, v.nodes); + spineFor = [v.families, v.nodes]; cy.batch(() => { links.forEach((e) => { const s = e.data('source'); diff --git a/app/src/lib/explorer-3d.ts b/app/src/lib/explorer-3d.ts index 032785fc..87776b4b 100644 --- a/app/src/lib/explorer-3d.ts +++ b/app/src/lib/explorer-3d.ts @@ -648,8 +648,9 @@ export async function createMap3D(opts: { apply(next: View3D) { const prev = view; view = next; - if (prev?.families !== next.families) { - const spine = backboneOf(graph.nodes, graph.links, next.families); + 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); for (const l of links) l.bb = spine.has(l.i); } const nodesChanged = !prev || prev.nodes !== next.nodes; diff --git a/app/src/lib/graph-layout.test.ts b/app/src/lib/graph-layout.test.ts index 3bb4333b..3577a4f3 100644 --- a/app/src/lib/graph-layout.test.ts +++ b/app/src/lib/graph-layout.test.ts @@ -94,6 +94,29 @@ describe('backboneOf', () => { expect(backboneOf(nodes, links, new Set(['association', 'contrast'])).has(4)).toBe(false); expect([...backboneOf(nodes, links, new Set(['contrast']))]).toEqual([4]); }); + it('strands no visible term: hide AI and data poisoning keeps a visible edge', () => { + const t = (id: string, domain: string[]) => ({ id, domain, cluster: 'k' }); + const terms = [ + t('ai/data-poisoning', ['ai', 'security']), + t('ai/training-data', ['ai']), + t('ai/model-training', ['ai']), + t('ai/prompt-injection', ['ai', 'security']), + t('ai/llm', ['ai']), + t('ai/jailbreak', ['ai']), + ]; + const edges = [ + L('ai/data-poisoning', 'ai/training-data', 6.3, 'security'), + L('ai/data-poisoning', 'ai/model-training', 5.9, 'dependency'), + L('ai/data-poisoning', 'ai/prompt-injection', 2.8, 'security'), + L('ai/prompt-injection', 'ai/llm', 9, 'security'), + L('ai/prompt-injection', 'ai/jailbreak', 9, 'security'), + ]; + const types = new Set(['security', 'dependency']); + // Over the whole graph its two slots go to AI-only terms: hidden with AI off. + expect(backboneOf(terms, edges, types).has(2)).toBe(false); + const securityOnly = new Set(['ai/data-poisoning', 'ai/prompt-injection']); + expect([...backboneOf(terms, edges, types, securityOnly)]).toEqual([2]); + }); }); describe('OVERVIEW_FAMILIES (A95)', () => { diff --git a/app/src/lib/graph-layout.ts b/app/src/lib/graph-layout.ts index 0ca8bf38..4f5c0db8 100644 --- a/app/src/lib/graph-layout.ts +++ b/app/src/lib/graph-layout.ts @@ -137,15 +137,21 @@ export const OVERVIEW_FAMILIES: ReadonlySet = new Set([ * The backbone over only the relationship families switched on (by default the * overview's, `OVERVIEW_FAMILIES`), as indices into the full `links`: a family that is * off no longer takes a term's strongest-link slots, so the families left on fill them. + * Given the `visible` terms, it is computed over them alone: a term whose strongest links + * went to hidden terms keeps its strongest visible one, so no connected term is stranded. */ export function backboneOf( nodes: Node[], links: (WeightedLink & { type?: string; primary?: boolean })[], families: ReadonlySet = OVERVIEW_FAMILIES, + visible?: ReadonlySet, ): Set { - const on = links.flatMap((l, i) => (families.has(l.family) ? [i] : [])); + const shown = (id: string) => !visible || visible.has(id); + const on = links.flatMap((l, i) => + families.has(l.family) && shown(l.source) && shown(l.target) ? [i] : [], + ); const chosen = backbone( - nodes, + visible ? nodes.filter((n) => visible.has(n.id)) : nodes, on.map((i) => links[i]), ); return new Set([...chosen].map((j) => on[j])); From 0938d4d7314d447af54e30cd2067cbddc0da2a1c Mon Sep 17 00:00:00 2001 From: CMaintz Date: Fri, 25 Sep 2026 22:45:27 +0200 Subject: [PATCH 2/4] feat(explorer): responsive bar, wider legend, 3D domain names, staggered edge toggles (WIP) --- app/src/components/Explorer.tsx | 81 +++++++--- app/src/lib/explorer-2d.ts | 184 +++++++++++++++++----- app/src/lib/explorer-3d.ts | 116 ++++++++++++-- app/src/lib/explorer-config.ts | 8 + app/src/pages/[lang]/explorer/index.astro | 23 +++ 5 files changed, 340 insertions(+), 72 deletions(-) diff --git a/app/src/components/Explorer.tsx b/app/src/components/Explorer.tsx index 4f68a3e0..d5804b3c 100644 --- a/app/src/components/Explorer.tsx +++ b/app/src/components/Explorer.tsx @@ -1,5 +1,5 @@ import type { ComponentChildren } from 'preact'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'; import { prerequisitesOf, shortestPath, type Graph, type GraphNode } from '../lib/graph-model'; import { loadLearner, type Learner } from '../lib/learner'; import { @@ -51,6 +51,18 @@ type Mode = '2d' | '3d'; type ColourMode = 'cluster' | 'knowledge'; /** The control bar's popovers; only one is open at a time ('sheet' = phones' Controls). */ type Pop = 'links' | 'route' | 'sheet'; +/** + * How much of the control bar fits (A93b): named domain pills and an open search field + * when there is room, dot chips next, then short labels and a search icon, and — when + * even that would wrap — the phones' "Controls" sheet. Measured, never a second row. + */ +type BarSize = 'full' | 'medium' | 'compact' | 'sheet'; +const SMALLER: Record = { + full: 'medium', + medium: 'compact', + compact: 'sheet', + sheet: 'sheet', +}; /** A term under a resting pointer, in map pixels (the hover card's anchor). */ type Point = { id: string; x: number; y: number }; @@ -136,6 +148,31 @@ export default function Explorer(props: Props) { return () => mq.removeEventListener('change', on); }, []); const bar = useRef(null); + /** The space the bar is centred in (between the legend and the "i"). */ + const slot = useRef(null); + const [barSize, setBarSize] = useState('full'); + // Start from the fullest bar whenever the space (or what the bar holds) changes... + useEffect(() => { + const el = slot.current; + if (!el) return; + let width = el.clientWidth; + const ro = new ResizeObserver(() => { + if (el.clientWidth === width) return; + width = el.clientWidth; + setBarSize('full'); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + /** Phones, or a desktop bar too tight for one row: the "Controls" sheet. */ + const sheet = narrow || barSize === 'sheet'; + // ...and step down, before paint, while its one row overflows. + useLayoutEffect(() => { + const b = bar.current; + // An opened search field in a compact bar may overflow a little: never a sheet for it. + if (barSize === 'sheet' || (barSize === 'compact' && findOpen)) return; + if (b && !narrow && b.scrollWidth > b.clientWidth + 1) setBarSize(SMALLER[barSize]); + }); // A popover closes on Esc (focus back on its button) or a press outside the bar. The // term panel's Esc handler stands down while a [data-map-popover] is open. useEffect(() => { @@ -350,6 +387,7 @@ export default function Explorer(props: Props) { onHover: (id) => prefetchTerm(props.panel.apiBase, id), onPoint, theme, + domainLabels: props.domainLabels, }), ) .then((m) => { @@ -362,6 +400,7 @@ export default function Explorer(props: Props) { }, [mode, graph]); useEffect(() => () => map3d?.destroy(), [map3d]); useEffect(() => map3d?.spin(spin), [map3d, spin]); + useEffect(() => setBarSize('full'), [mode, legendOpen, lang]); useEffect(() => map3d?.retheme(theme), [map3d, theme]); useEffect(() => { onPoint(null); @@ -540,10 +579,10 @@ export default function Explorer(props: Props) { {ui.autoRotate} ); - /** Domain toggles: labelled pills in the phone sheet, dot-only chips in the bar. */ - const domainPills = (compact: boolean) => ( + /** Domain toggles: labelled pills (the sheet, a full bar) or dot-only chips (a tighter bar). */ + const domainPills = (compact: boolean, wrap = true) => (
); - // In the bar, "Find a term" is a search icon that opens the field (keeps the bar one row). + // In a compact bar, "Find a term" is a search icon that opens the field (keeps one row). const search = - !narrow && !findOpen ? ( + barSize === 'compact' && !findOpen ? (