From af19e866dc4b407a5df0ef93955852aee077fd67 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 10 Nov 2026 22:48:30 -0800 Subject: [PATCH 01/25] fix(graph): place relationship labels on their turned boxes A relationship label follows its edge, but the placement checked it as an upright box, so a turned label could be refused next to a card it did not touch, or let through over another turned label. Labels are now checked as oriented boxes (separating axes; touching is clear), each candidate spot along the edge is tried nearest-first, and the search budget covers a whole organized graph (64 labels, 160 attempts) so labels are not dropped for running out of tries. Co-Authored-By: Claude Opus 5.5 --- .../lib/__tests__/edgeRouteGeometry.test.ts | 45 ++++ packages/web/src/lib/edgeLabelLayout.ts | 2 + packages/web/src/lib/edgeLabelPlacement.ts | 27 ++- packages/web/src/lib/edgeRouteGeometry.ts | 197 +++++++++++------- 4 files changed, 186 insertions(+), 85 deletions(-) diff --git a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts index 6bbb5373..377136ee 100644 --- a/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeRouteGeometry.test.ts @@ -6,6 +6,7 @@ import { edgeRouteArrowBounds, edgeSegmentHitsBox, edgeSegmentHitsRotatedBox, + orientedBoxesOverlap, pointOnEdgeRoute, projectEdgeRoutePointer, } from '../edgeRouteGeometry'; @@ -347,3 +348,47 @@ it.each([45, 135, -45, -135])( ).toBe(true); } ); + +describe('turned label boxes', () => { + it('tests a turned label with its real footprint, not the box around it', () => { + const label = { x: 0, y: 0, width: 200, height: 20, rotation: 45 }; + // Inside the label's axis-aligned bounds, but off the turned label itself. + const corner = { x: 60, y: -60, width: 20, height: 20 }; + const halfBounds = ((200 + 20) * Math.SQRT1_2) / 2; + expect(Math.abs(corner.x) + corner.width / 2).toBeLessThan(halfBounds); + expect(Math.abs(corner.y) + corner.height / 2).toBeLessThan(halfBounds); + expect(orientedBoxesOverlap(label, corner)).toBe(false); + expect(orientedBoxesOverlap(label, { x: 50, y: 50, width: 20, height: 20 })).toBe(true); + }); + + it('counts boxes that only touch as clear, and padding grows the second box', () => { + const a = { x: 0, y: 0, width: 100, height: 20 }; + expect(orientedBoxesOverlap(a, { x: 100, y: 0, width: 100, height: 20 })).toBe(false); + expect(orientedBoxesOverlap(a, { x: 99, y: 0, width: 100, height: 20 })).toBe(true); + expect(orientedBoxesOverlap(a, { x: 108, y: 0, width: 100, height: 20 }, 8)).toBe(false); + expect(orientedBoxesOverlap(a, { x: 107, y: 0, width: 100, height: 20 }, 8)).toBe(true); + // Order does not matter for turned pairs. + const b = { x: 70, y: 10, width: 60, height: 10, rotation: 30 }; + expect(orientedBoxesOverlap(a, b)).toBe(orientedBoxesOverlap(b, a)); + }); + + it('slides a label along its edge past blocked middles and ends instead of hiding it', () => { + const route = createEdgeRoute([ + { x: 0, y: 0 }, + { x: 800, y: 0 }, + ])!; + const obstacles = [ + { x: 400, y: 0, width: 200, height: 200 }, + { x: 48, y: 0, width: 60, height: 200 }, + { x: 752, y: 0, width: 60, height: 200 }, + ]; + const dims = { width: 80, height: 20 }; + const label = edgeRouteLabel(route, { dims, obstacles })!; + expect(label).not.toBeNull(); + for (const box of obstacles) expect(orientedBoxesOverlap({ ...label, ...dims }, box, 8)).toBe(false); + // The clear spot nearest the middle, the earlier one of a tie. + expect(label.x).toBeLessThan(400); + expect(label.x).toBeGreaterThan(200); + }); +}); + diff --git a/packages/web/src/lib/edgeLabelLayout.ts b/packages/web/src/lib/edgeLabelLayout.ts index 4c8e572e..8fb4ec43 100644 --- a/packages/web/src/lib/edgeLabelLayout.ts +++ b/packages/web/src/lib/edgeLabelLayout.ts @@ -127,6 +127,8 @@ export interface ObstacleBox { y: number; width: number; height: number; + /** Turn about the centre in degrees (a placed label); none is axis-aligned. */ + rotation?: number; } export interface ChooseTInput extends PlacementInput { diff --git a/packages/web/src/lib/edgeLabelPlacement.ts b/packages/web/src/lib/edgeLabelPlacement.ts index d2f6bc6a..b572d217 100644 --- a/packages/web/src/lib/edgeLabelPlacement.ts +++ b/packages/web/src/lib/edgeLabelPlacement.ts @@ -18,29 +18,32 @@ export interface ItemLabelCandidate { } export const ITEM_LABEL_BUDGET = { - labels: 32, - attempts: 128, + labels: 64, + attempts: 160, checks: 200000, } as const; +export type ItemLabelBudget = { labels: number; attempts: number; checks: number }; + export function placeItemLabels( candidates: readonly ItemLabelCandidate[], options: { viewport: ObstacleBox; obstacles: readonly ObstacleBox[]; segments: readonly Pick[]; - } + }, + limits: ItemLabelBudget = ITEM_LABEL_BUDGET ) { const placements = new Map(); const occupied: ObstacleBox[] = []; - const budget = { remaining: ITEM_LABEL_BUDGET.checks as number }; + const budget = { remaining: limits.checks }; let attempts = 0; for (const candidate of [...candidates].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0) || a.id.localeCompare(b.id) )) { if ( - placements.size >= ITEM_LABEL_BUDGET.labels || - attempts >= ITEM_LABEL_BUDGET.attempts || + placements.size >= limits.labels || + attempts >= limits.attempts || budget.remaining <= 0 ) break; @@ -65,21 +68,17 @@ export function placeItemLabels( }); if (!placement) continue; placements.set(candidate.id, placement); - const angle = (placement.rotation * Math.PI) / 180; occupied.push({ x: placement.x, y: placement.y, - width: - Math.abs(Math.cos(angle)) * candidate.dims.width + - Math.abs(Math.sin(angle)) * candidate.dims.height, - height: - Math.abs(Math.sin(angle)) * candidate.dims.width + - Math.abs(Math.cos(angle)) * candidate.dims.height, + width: candidate.dims.width, + height: candidate.dims.height, + rotation: placement.rotation, }); } return { placements, attempts, - checks: ITEM_LABEL_BUDGET.checks - budget.remaining, + checks: limits.checks - budget.remaining, }; } diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts index 268b73c4..1b758327 100644 --- a/packages/web/src/lib/edgeRouteGeometry.ts +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -236,6 +236,46 @@ export function edgeSegmentHitsRotatedBox( ); } +/** + * Whether two boxes overlap, each centred on (x, y) and turned by its + * `rotation` in degrees (an obstacle without one is axis-aligned). `b` grows + * by `padding` on every side. Separating-axis test on both boxes' axes, so a + * turned label is tested with its real footprint, not the box around it. + */ +export function orientedBoxesOverlap( + a: ObstacleBox, + b: ObstacleBox, + padding = 0 +) { + const axes = (rotation = 0) => { + const r = (rotation * Math.PI) / 180; + return [ + { x: Math.cos(r), y: Math.sin(r) }, + { x: -Math.sin(r), y: Math.cos(r) }, + ]; + }; + const [au, av] = axes(a.rotation), + [bu, bv] = axes(b.rotation); + const aw = a.width / 2, + ah = a.height / 2, + bw = b.width / 2 + padding, + bh = b.height / 2 + padding; + const dx = b.x - a.x, + dy = b.y - a.y; + for (const n of [au, av, bu, bv]) { + const reach = + Math.abs(aw * (au.x * n.x + au.y * n.y)) + + Math.abs(ah * (av.x * n.x + av.y * n.y)) + + Math.abs(bw * (bu.x * n.x + bu.y * n.y)) + + Math.abs(bh * (bv.x * n.x + bv.y * n.y)); + if (Math.abs(dx * n.x + dy * n.y) >= reach - 1e-9) return false; + } + return true; +} + +/** Positions tried along each straight part of a route, besides its ends and middle. */ +export const LABEL_SAMPLES = 12; + export function edgeRouteLabel( route: EdgeRouteGeometry, options: { @@ -261,8 +301,10 @@ export function edgeRouteLabel( ) return null; const desired = fraction(options.t ?? 0.5) * route.length; - let best: EdgeRouteLabel | null = null; - let bestDistance = Infinity; + // Every candidate spot, nearest the desired one first (then the earlier + // one); the first that clears everything wins. A label slides along its + // edge, or flips to the other side, before it gives up. + const candidates: (EdgeRouteLabel & { delta: number })[] = []; for (const segment of route.segments) { const margin = dims.width / 2 + padding; if (segment.length < 2 * margin) continue; @@ -312,82 +354,95 @@ export function edgeRouteLabel( } } if (visibleLow > visibleHigh) continue; - for (const distance of new Set([ + const distances = new Set([ clamp(desired, visibleLow, visibleHigh), (visibleLow + visibleHigh) / 2, visibleLow, visibleHigh, - ])) { + ]); + for (let step = 1; step < LABEL_SAMPLES; step++) + distances.add( + visibleLow + ((visibleHigh - visibleLow) * step) / LABEL_SAMPLES + ); + for (const distance of distances) { const local = (distance - segment.start) / segment.length; - const x = - segment.source.x + - (segment.target.x - segment.source.x) * local + - Math.sin(radians) * candidateOffset; - const y = - segment.source.y + - (segment.target.y - segment.source.y) * local - - Math.cos(radians) * candidateOffset; - const viewport = options.viewport; - if ( - viewport && - (Math.abs(x - viewport.x) + width / 2 + padding > - viewport.width / 2 || - Math.abs(y - viewport.y) + height / 2 + padding > - viewport.height / 2) + candidates.push({ + x: + segment.source.x + + (segment.target.x - segment.source.x) * local + + Math.sin(radians) * candidateOffset, + y: + segment.source.y + + (segment.target.y - segment.source.y) * local - + Math.cos(radians) * candidateOffset, + t: distance / route.length, + rotation, + iconRotation: flipped ? 180 : 0, + offset: candidateOffset, + delta: + Math.abs(distance - desired) + + Math.abs(candidateOffset - offset) * 0.01, + }); + } + } + } + candidates.sort((a, b) => a.delta - b.delta || a.t - b.t); + for (const candidate of candidates) { + const { x, y, rotation } = candidate; + const radians = (rotation * Math.PI) / 180; + const width = + Math.abs(Math.cos(radians)) * dims.width + + Math.abs(Math.sin(radians)) * dims.height; + const height = + Math.abs(Math.sin(radians)) * dims.width + + Math.abs(Math.cos(radians)) * dims.height; + const viewport = options.viewport; + if ( + viewport && + (Math.abs(x - viewport.x) + width / 2 + padding > viewport.width / 2 || + Math.abs(y - viewport.y) + height / 2 + padding > + viewport.height / 2) + ) + continue; + const label = { x, y, width: dims.width, height: dims.height, rotation }; + let blocked = false; + for (const box of obstacles) { + if (options.budget) { + if (options.budget.remaining <= 0) return null; + options.budget.remaining--; + } + if (orientedBoxesOverlap(label, box, padding)) { + blocked = true; + break; + } + } + if (blocked) continue; + for (const part of options.segments ?? []) { + if (options.budget) { + if (options.budget.remaining <= 0) return null; + options.budget.remaining--; + } + if ( + edgeSegmentHitsRotatedBox( + part, + { x, y, width: dims.width, height: dims.height }, + rotation, + padding ) - continue; - let blocked = false; - for (const box of obstacles) { - if (options.budget) { - if (options.budget.remaining <= 0) return best; - options.budget.remaining--; - } - if ( - Math.abs(x - box.x) < (width + box.width) / 2 + padding && - Math.abs(y - box.y) < (height + box.height) / 2 + padding - ) { - blocked = true; - break; - } - } - if (blocked) continue; - for (const part of options.segments ?? []) { - if (options.budget) { - if (options.budget.remaining <= 0) return best; - options.budget.remaining--; - } - if ( - edgeSegmentHitsRotatedBox( - part, - { x, y, width: dims.width, height: dims.height }, - rotation, - padding - ) - ) { - blocked = true; - break; - } - } - if (blocked) continue; - const delta = - Math.abs(distance - desired) + - Math.abs(candidateOffset - offset) * 0.01; - if ( - delta < bestDistance || - (delta === bestDistance && distance / route.length < best!.t) - ) { - bestDistance = delta; - best = { - x, - y, - t: distance / route.length, - rotation, - iconRotation: flipped ? 180 : 0, - offset: candidateOffset, - }; - } + ) { + blocked = true; + break; } } + if (!blocked) + return { + x, + y, + t: candidate.t, + rotation, + iconRotation: candidate.iconRotation, + offset: candidate.offset, + }; } - return best; + return null; } From 898d6a4c23c12c859f45c010f459af7554316281 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 10 Nov 2026 22:48:30 -0800 Subject: [PATCH 02/25] feat(graph): let Organize reserve a gap per connection organizeTopology takes an optional edgeGap (the visible length an edge needs between its two cards) and spread hook. Chains, cycles, rings and hubs use the gap of each pair instead of one fixed 64 px; with no options the positions are exactly the old ones. Co-Authored-By: Claude Opus 5.5 --- .../web/src/lib/topologyOrganization.test.ts | 28 ++++ packages/web/src/lib/topologyOrganization.ts | 126 +++++++++++++++--- 2 files changed, 137 insertions(+), 17 deletions(-) diff --git a/packages/web/src/lib/topologyOrganization.test.ts b/packages/web/src/lib/topologyOrganization.test.ts index 202aeed4..0b4ee962 100644 --- a/packages/web/src/lib/topologyOrganization.test.ts +++ b/packages/web/src/lib/topologyOrganization.test.ts @@ -349,3 +349,31 @@ it('packs loose cards side by side on a landscape phone instead of stacking them const three = organizeTopology([...nodes, { id: 'third' }], [], () => ({ width: 240, height: 140 }), { width: 390, height: 844 }); expect(new Set([...three.positions.values()].map(point => Math.round(point.x))).size).toBe(1); }); + +it('gives every edge the visible length its edgeGap asks for, in chains, cycles and hub rings', () => { + const visible = (result: ReturnType, a: string, b: string, nodes: { id: string }[]) => { + const p = result.positions.get(a)!, q = result.positions.get(b)!; + const da = size(nodes.find(node => node.id === a)!), db = size(nodes.find(node => node.id === b)!); + const dx = q.x - p.x, dy = q.y - p.y, length = Math.hypot(dx, dy); + const reach = (d: { width: number; height: number }) => { + const s = Math.min(dx ? d.width / 2 / Math.abs(dx) : Infinity, dy ? d.height / 2 / Math.abs(dy) : Infinity); + return Math.hypot(dx * s, dy * s); + }; + return length - reach(da) - reach(db); + }; + const shapes = { + chain: { nodes: ['a', 'b', 'c', 'd'], edges: [edge('a', 'b'), edge('b', 'c'), edge('c', 'd')] }, + cycle: { nodes: ['a', 'b', 'c', 'd', 'e'], edges: [edge('a', 'b'), edge('b', 'c'), edge('c', 'd'), edge('d', 'e'), edge('e', 'a')] }, + hub: { nodes: ['hub', ...Array.from({ length: 9 }, (_, i) => `leaf-${i}`)], edges: Array.from({ length: 9 }, (_, i) => edge('hub', `leaf-${i}`)) }, + }; + for (const [name, shape] of Object.entries(shapes)) { + const nodes = shape.nodes.map(id => ({ id })); + const plain = organizeTopology(nodes, shape.edges, size, view); + const roomy = organizeTopology(nodes, shape.edges, size, view, 64, { edgeGap: () => 260 }); + for (const e of shape.edges) { + expect(visible(roomy, e.source, e.target, nodes), `${name} ${e.source}-${e.target}`).toBeGreaterThanOrEqual(259.5); + } + expect(Math.min(...shape.edges.map(e => visible(plain, e.source, e.target, nodes)))).toBeLessThan(259.5); + checkClearance(roomy, nodes); + } +}); diff --git a/packages/web/src/lib/topologyOrganization.ts b/packages/web/src/lib/topologyOrganization.ts index ad667184..991bff53 100644 --- a/packages/web/src/lib/topologyOrganization.ts +++ b/packages/web/src/lib/topologyOrganization.ts @@ -7,9 +7,20 @@ export interface OrganizationEdge { target: string | { id: string }; } +import { borderReach } from './edgeGeometry'; + type Point = { x: number; y: number }; type Size = { width: number; height: number }; +/** Length of the line between two cards' borders (the part a label can use). */ +function visibleLength(from: Point, a: Size, to: Point, b: Size) { + const dx = to.x - from.x, + dy = to.y - from.y; + return ( + Math.hypot(dx, dy) - borderReach(a, dx, dy) - borderReach(b, dx, dy) + ); +} + const endpoint = (value: OrganizationEdge['source']) => typeof value === 'string' ? value : value.id; @@ -40,7 +51,8 @@ function compactHubRing( ring: readonly string[], sizes: ReadonlyMap, viewport: Size, - spacing: number + spacing: number, + linkGap: (a: string, b: string) => number ) { const ids = [root, ...ring]; let best: Map | undefined; @@ -75,6 +87,17 @@ function compactHubRing( (a.height + b.height) / 2 ); if (Math.hypot(dx, dy) < spacing) return false; + const need = linkGap(ids[i], ids[j]); + if ( + need > 0 && + visibleLength( + { x: units[i].x * radius, y: units[i].y * radius }, + a, + { x: units[j].x * radius, y: units[j].y * radius }, + b + ) < need + ) + return false; } return true; }; @@ -121,14 +144,39 @@ function compactHubRing( return best!; } -export function organizeTopology( +export interface OrganizationOptions { + /** Smallest border-to-border gap an edge needs, e.g. room for its label. */ + edgeGap?: (edge: E) => number; + /** + * How much to spread one finished component about its first card before + * packing (1 keeps it). Called with the component's own coordinates. + */ + spread?: (ids: readonly string[], points: ReadonlyMap) => number; +} + +export function organizeTopology< + T extends OrganizationNode, + E extends OrganizationEdge = OrganizationEdge, +>( nodes: readonly T[], - edges: readonly OrganizationEdge[], + edges: readonly E[], dimensions: (node: T) => Size, viewport: Size, - gap = 64 + gap = 64, + options: OrganizationOptions = {} ) { const adjacency = organizationAdjacency(nodes, edges); + // Per connected pair, the widest gap any of its edges asks for. + const pairGaps = new Map(); + const pairKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`); + if (options.edgeGap) + for (const edge of edges) { + const need = options.edgeGap(edge); + if (!(need > 0)) continue; + const key = pairKey(endpoint(edge.source), endpoint(edge.target)); + pairGaps.set(key, Math.max(pairGaps.get(key) ?? 0, need)); + } + const pairGap = (a: string, b: string) => pairGaps.get(pairKey(a, b)) ?? 0; const sizes = new Map( nodes.map(node => { const size = dimensions(node); @@ -159,6 +207,7 @@ export function organizeTopology( rectangleEvaluations: 0, maxRectangleEvaluations: 40000, clearanceRejections: 0, + spreadComponents: 0, }; for (const start of adjacency.keys()) { if (visited.has(start)) continue; @@ -207,18 +256,23 @@ export function organizeTopology( } const maxWidth = Math.max(...ids.map(id => sizes.get(id)!.width)); const maxHeight = Math.max(...ids.map(id => sizes.get(id)!.height)); + // Neighbours along a chain or cycle leave room for the widest link. + const linkGap = Math.max( + spacing, + ...order.map((id, i) => pairGap(id, order[(i + 1) % order.length])) + ); if (kind === 'chain') { // Snake the chain into the column count whose extent the viewport can // show at the largest (capped) scale, so a short chain on a phone does // not become one unreadably wide row. Ties keep the wider reading row. - const cellWidth = maxWidth + spacing, - cellHeight = maxHeight + spacing; + const cellWidth = maxWidth + linkGap, + cellHeight = maxHeight + linkGap; const readableScale = (count: number) => Math.min( 1.25, - viewport.width / (count * cellWidth - spacing), + viewport.width / (count * cellWidth - linkGap), viewport.height / - (Math.ceil(ids.length / count) * cellHeight - spacing) + (Math.ceil(ids.length / count) * cellHeight - linkGap) ); let columns = Math.ceil( Math.sqrt(((ids.length * cellHeight) / cellWidth) * 1.5) @@ -234,14 +288,14 @@ export function organizeTopology( const row = Math.floor(index / columns), col = index % columns; points.set(id, { - x: (row % 2 ? columns - 1 - col : col) * (maxWidth + spacing), - y: row * (maxHeight + spacing), + x: (row % 2 ? columns - 1 - col : col) * cellWidth, + y: row * cellHeight, }); }); } else { focus = order[0]; const radius = - (Math.hypot(maxWidth, maxHeight) + spacing) / + (Math.hypot(maxWidth, maxHeight) + linkGap) / (2 * Math.sin(Math.PI / ids.length)); order.forEach((id, index) => points.set(id, { @@ -273,17 +327,29 @@ export function organizeTopology( sizes.get(root)!.width, sizes.get(root)!.height ); - for (const ring of levels.slice(1)) { + for (const [index, ring] of levels.slice(1).entries()) { const diameter = Math.max( ...ring.map(id => Math.hypot(sizes.get(id)!.width, sizes.get(id)!.height) ) ); + // Room for the links into this ring, and between its own members. + let radialGap = spacing, + ringGap = spacing; + const inner = new Set(levels[index]), + members = new Set(ring); + for (const id of ring) + for (const neighbor of adjacency.get(id)!) { + if (inner.has(neighbor)) + radialGap = Math.max(radialGap, pairGap(id, neighbor)); + else if (members.has(neighbor)) + ringGap = Math.max(ringGap, pairGap(id, neighbor)); + } const radius = Math.max( - previousRadius + (previousDiameter + diameter) / 2 + spacing, + previousRadius + (previousDiameter + diameter) / 2 + radialGap, ring.length < 2 ? 0 - : (diameter + spacing) / (2 * Math.sin(Math.PI / ring.length)) + : (diameter + ringGap) / (2 * Math.sin(Math.PI / ring.length)) ); ring.forEach((id, index) => points.set(id, { @@ -309,7 +375,8 @@ export function organizeTopology( levels[1], sizes, viewport, - spacing + spacing, + pairGap )) points.set(id, point); } @@ -369,8 +436,24 @@ export function organizeTopology( return Math.hypot(dx, dy) >= spacing; }) ); - if (!clear) work.clearanceRejections++; - if (clear && score() + 0.01 < before) { + const roomy = + clear && + [a, b].every(id => + adjacency.get(id)!.every(neighbor => { + const need = pairGap(id, neighbor); + return ( + !(need > 0) || + visibleLength( + points.get(id)!, + sizes.get(id)!, + points.get(neighbor)!, + sizes.get(neighbor)! + ) >= need + ); + }) + ); + if (!roomy) work.clearanceRejections++; + if (roomy && score() + 0.01 < before) { ring[index] = b; ring[(index + 1) % ring.length] = a; improved = true; @@ -383,6 +466,15 @@ export function organizeTopology( if (!improved) break; } } + const factor = options.spread?.(ids, points) ?? 1; + if (factor > 1) { + const anchor = { ...points.get(root)! }; + for (const point of points.values()) { + point.x = anchor.x + (point.x - anchor.x) * factor; + point.y = anchor.y + (point.y - anchor.y) * factor; + } + work.spreadComponents++; + } const left = Math.min( ...ids.map(id => points.get(id)!.x - sizes.get(id)!.width / 2) ); From d24871b7c46bd18b5660b619ae0d3d7fdeef1e50 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 10 Nov 2026 22:48:30 -0800 Subject: [PATCH 03/25] feat(graph): hierarchical rows with room for relationship labels computeCompactLayout takes an optional edgeGap. Linked cards in a row and rows joined by edges get the gap their labels need; only rows with no edge to another row wrap (chains snake), rows are ordered by barycentre sweeps to cut crossings, and each card is aligned under its neighbours with isotonic regression so edges stay short. Co-Authored-By: Claude Opus 5.5 --- .../lib/__tests__/layoutAlgorithms.test.ts | 56 ++++ packages/web/src/lib/layoutAlgorithms.ts | 293 ++++++++++++++++-- 2 files changed, 324 insertions(+), 25 deletions(-) diff --git a/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts b/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts index 2c28cc90..6b81ed3a 100644 --- a/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts +++ b/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts @@ -150,3 +150,59 @@ it('wraps a wide set of independent tasks instead of making one unreadable strip expect(Math.abs(points[i].x-points[j].x)>=194 || Math.abs(points[i].y-points[j].y)>=144).toBe(true); } }); + +describe('hierarchical rows with room for relationship labels', () => { + const card = () => ({ width: 170, height: 120 }); + const link = (source: string, target: string, type = 'RELATES_TO') => ({ source, target, type }); + const view = { width: 1440, height: 900 }; + const borderGapX = (points: Map, a: string, b: string) => Math.abs(points.get(b)!.x - points.get(a)!.x) - 170; + + it('keeps linked cards in a row apart by what the edge between them asks, and only them', async () => { + const { computeCompactLayout } = await import('../layoutAlgorithms'); + const edges = [link('A', 'B'), link('B', 'C')]; + const points = computeCompactLayout([n('A'), n('B'), n('C')], edges, view, card, { + edgeGap: edge => (edge.source === 'A' ? 200 : 100), + }); + expect(new Set([...points.values()].map(p => p.y)).size).toBe(1); + expect(borderGapX(points, 'A', 'B')).toBeCloseTo(200); + expect(borderGapX(points, 'B', 'C')).toBeCloseTo(100); + const plain = computeCompactLayout([n('A'), n('B'), n('C')], edges, view, card); + expect(borderGapX(plain, 'A', 'B')).toBeCloseTo(36); + }); + + it('keeps rows apart by what the edges across them ask', async () => { + const { computeCompactLayout } = await import('../layoutAlgorithms'); + const points = computeCompactLayout([n('A'), n('B')], [dep('A', 'B')], view, card, { edgeGap: () => 250 }); + expect(points.get('A')!.y - points.get('B')!.y - 120).toBeCloseTo(250); + }); + + it('puts a card under the prerequisite it depends on, not in the middle of its row', async () => { + const { computeCompactLayout } = await import('../layoutAlgorithms'); + const nodes = ['P1', 'P2', 'P3', 'P4', 'X'].map(n); + const points = computeCompactLayout(nodes, [dep('X', 'P4')], view, card); + expect(points.get('X')!.y).toBeGreaterThan(points.get('P4')!.y); + expect(points.get('X')!.x).toBeCloseTo(points.get('P4')!.x); + }); + + it('splits cards linked inside one dependency layer into rows instead of wiring through a row', async () => { + const { computeCompactLayout } = await import('../layoutAlgorithms'); + // A hub with four spokes: the spokes go on their own row under the hub. + const edges = ['S1', 'S2', 'S3', 'S4'].map(id => link('H', id)); + const points = computeCompactLayout(['H', 'S1', 'S2', 'S3', 'S4'].map(n), edges, view, card); + for (const id of ['S1', 'S2', 'S3', 'S4']) expect(points.get(id)!.y).toBeGreaterThan(points.get('H')!.y); + }); + + it('wraps a long chain back and forth so every link stays short', async () => { + const { computeCompactLayout } = await import('../layoutAlgorithms'); + const ids = Array.from({ length: 12 }, (_, i) => `step-${String(i).padStart(2, '0')}`); + const edges = ids.slice(1).map((id, i) => link(ids[i], id, 'FOLLOWS')); + const points = computeCompactLayout(ids.map(n), edges, { width: 390, height: 800 }, card, { edgeGap: () => 150 }); + expect(new Set([...points.values()].map(p => p.y)).size).toBeGreaterThan(1); + for (const edge of edges) { + const a = points.get(edge.source)!, b = points.get(edge.target)!; + // Next to each other in a row, or straight down at a wrap. + if (a.y === b.y) expect(borderGapX(points, edge.source, edge.target)).toBeCloseTo(150); + else expect(Math.abs(a.x - b.x)).toBeLessThan(170); + } + }); +}); diff --git a/packages/web/src/lib/layoutAlgorithms.ts b/packages/web/src/lib/layoutAlgorithms.ts index 4a8b397a..aba55411 100644 --- a/packages/web/src/lib/layoutAlgorithms.ts +++ b/packages/web/src/lib/layoutAlgorithms.ts @@ -102,44 +102,287 @@ export function computeNodeLayerY( return out; } +/** + * Rows for the hierarchical layout. Dependency layers come first (prerequisite + * higher). Inside a layer, cards joined by other relationships would otherwise + * share a row and their edges would run through the cards between them, so a + * connected group is split into sub-rows by breadth-first distance from its + * busiest card; a simple path stays in one row in path order. Cards in a row + * are ordered by where their neighbours in the rows above sit. Cards with no + * relationship at all wrap like a reading list; computeCompactLayout wraps + * the other rows once it knows their gaps. + */ +function hierarchyRows(nodes: T[], edges: EdgeLike[], width: (node: T) => number, maxRowWidth: number): T[][] { + const layers = assignLayersByDependencyDirection(nodes, edges); + const byId = new Map(nodes.map(node => [node.id, node])); + const linked = new Map>(nodes.map(node => [node.id, new Set()])); + for (const edge of edges) { + const a = idOf(edge.source), b = idOf(edge.target); + if (a === b || !linked.has(a) || !linked.has(b)) continue; + linked.get(a)!.add(b); + linked.get(b)!.add(a); + } + const sortedIds = (ids: Iterable) => [...ids].sort((a, b) => a.localeCompare(b)); + const rows: T[][] = []; + const position = new Map(); + const place = (row: T[]) => { + row.forEach((node, index) => position.set(node.id, index - (row.length - 1) / 2)); + rows.push(row); + }; + // Mean position of a card's neighbours already placed; unplaced cards keep id order. + const barycentre = (id: string) => { + const placed = [...linked.get(id)!].filter(other => position.has(other)).map(other => position.get(other)!); + return placed.length ? placed.reduce((sum, value) => sum + value, 0) / placed.length : Infinity; + }; + const byBarycentre = (ids: string[]) => ids + .map((id, index) => ({ id, key: barycentre(id), index })) + .sort((a, b) => (a.key === b.key ? a.index - b.index : a.key - b.key)) + .map(entry => entry.id); + const layerIds = new Map(); + for (const node of nodes) { + const layer = layers.get(node.id)!; + if (!layerIds.has(layer)) layerIds.set(layer, []); + layerIds.get(layer)!.push(node.id); + } + for (const [, members] of [...layerIds].sort(([a], [b]) => a - b)) { + const inLayer = new Set(members); + const inner = (id: string) => sortedIds([...linked.get(id)!].filter(other => inLayer.has(other))); + const subRows: string[][] = []; + const loose: string[] = []; + const seen = new Set(); + for (const start of sortedIds(members)) { + if (seen.has(start)) continue; + const group = [start]; + seen.add(start); + for (let i = 0; i < group.length; i++) + for (const next of inner(group[i])) if (!seen.has(next)) { seen.add(next); group.push(next); } + if (group.length === 1) { + (linked.get(start)!.size ? (subRows[0] ??= []) : loose).push(start); + continue; + } + const degree = (id: string) => inner(id).length; + const links = group.reduce((sum, id) => sum + degree(id), 0) / 2; + if (group.every(id => degree(id) <= 2) && links === group.length - 1) { + // A path: one row, in path order, from its smaller end. + const ends = sortedIds(group.filter(id => degree(id) === 1)); + const path: string[] = []; + for (let at: string | undefined = ends[0]; at; at = inner(at).find(next => !path.includes(next))) path.push(at); + (subRows[0] ??= []).push(...path); + continue; + } + const root = [...group].sort((a, b) => degree(b) - degree(a) || a.localeCompare(b))[0]; + const depth = new Map([[root, 0]]); + const queue = [root]; + for (let i = 0; i < queue.length; i++) + for (const next of inner(queue[i])) if (!depth.has(next)) { depth.set(next, depth.get(queue[i])! + 1); queue.push(next); } + for (const id of queue) (subRows[depth.get(id)!] ??= []).push(id); + } + for (const ids of subRows) { + if (!ids?.length) continue; + // Keep each path together; order the rest by their neighbours above. + const paths: string[][] = []; + const singles: string[] = []; + for (let i = 0; i < ids.length; i++) { + const run = [ids[i]]; + while (i + 1 < ids.length && linked.get(ids[i])!.has(ids[i + 1]) && inLayer.has(ids[i + 1])) run.push(ids[++i]); + if (run.length > 1) paths.push(run); else singles.push(run[0]); + } + const units = [...paths.map(run => ({ ids: run, key: Math.min(...run.map(barycentre)) })), ...byBarycentre(singles).map(id => ({ ids: [id], key: barycentre(id) }))]; + units.sort((a, b) => (a.key === b.key ? 0 : a.key - b.key)); + place(units.flatMap(unit => unit.ids).map(id => byId.get(id)!)); + } + // Cards with no relationship wrap like a reading list. + let row: T[] = [], used = 0; + for (const id of sortedIds(loose)) { + const node = byId.get(id)!; + if (row.length && used + 36 + width(node) > maxRowWidth) { place(row); row = []; used = 0; } + used += (row.length ? 36 : 0) + width(node); + row.push(node); + } + if (row.length) place(row); + } + return rows; +} + +/** + * Fewer crossings: sweep down and up the rows a few times, ordering each row + * by where its cards' neighbours sit in the rows above (down) or below (up), + * as fractions of their row. A run of cards linked inside the row (a chain) + * moves as one; a card with no neighbour on that side keeps its place. + */ +function orderRows(rows: T[][], edges: EdgeLike[]) { + const rowOf = new Map(); + rows.forEach((row, index) => row.forEach(node => rowOf.set(node.id, index))); + const across = new Map(); + const inRow = new Set(); + for (const edge of edges) { + const a = idOf(edge.source), b = idOf(edge.target); + const ra = rowOf.get(a), rb = rowOf.get(b); + if (ra === undefined || rb === undefined) continue; + if (ra === rb) { inRow.add(`${a}\u0000${b}`).add(`${b}\u0000${a}`); continue; } + if (!across.has(a)) across.set(a, []); + if (!across.has(b)) across.set(b, []); + across.get(a)!.push(b); + across.get(b)!.push(a); + } + const where = new Map(); + const measure = (row: T[]) => row.forEach((node, i) => where.set(node.id, (i + 0.5) / row.length)); + rows.forEach(measure); + for (let sweep = 0; sweep < 4; sweep++) { + const down = sweep % 2 === 0; + const order = [...rows.keys()]; + if (!down) order.reverse(); + for (const index of order) { + const row = rows[index]; + const units: { nodes: T[]; key: number; at: number }[] = []; + for (const node of row) { + const last = units[units.length - 1]; + if (last && inRow.has(`${last.nodes[last.nodes.length - 1].id}\u0000${node.id}`)) last.nodes.push(node); + else units.push({ nodes: [node], key: 0, at: units.length }); + } + for (const unit of units) { + const sides = unit.nodes.flatMap(node => (across.get(node.id) ?? []).filter(id => (down ? rowOf.get(id)! < index : rowOf.get(id)! > index))); + const own = unit.nodes.reduce((sum, node) => sum + where.get(node.id)!, 0) / unit.nodes.length; + unit.key = sides.length ? sides.reduce((sum, id) => sum + where.get(id)!, 0) / sides.length : own; + } + units.sort((a, b) => a.key - b.key || a.at - b.at); + rows[index] = units.flatMap(unit => unit.nodes); + measure(rows[index]); + } + } + return rows; +} + +/** Closest non-decreasing sequence to `values` (weighted least squares). */ +function isotonic(values: number[], weights: number[]): number[] { + const blocks: { mean: number; weight: number; count: number }[] = []; + values.forEach((value, i) => { + blocks.push({ mean: value, weight: weights[i], count: 1 }); + while (blocks.length > 1 && blocks[blocks.length - 2].mean > blocks[blocks.length - 1].mean) { + const last = blocks.pop()!, prev = blocks[blocks.length - 1]; + const weight = prev.weight + last.weight; + prev.mean = (prev.mean * prev.weight + last.mean * last.weight) / weight; + prev.weight = weight; + prev.count += last.count; + } + }); + return blocks.flatMap(block => Array(block.count).fill(block.mean)); +} + /** Compact rows use actual card sizes, independent of viewport expansion. * Pin both axes so repulsion cannot stretch a row after it has been arranged. + * `edgeGap` widens the gap between two rows (or two cards in a row) to the + * largest border-to-border room any edge across it asks for, e.g. its label. */ -export function computeCompactLayout( - nodes: T[], edges: EdgeLike[], viewport: { width: number; height: number }, +export function computeCompactLayout( + nodes: T[], edges: E[], viewport: { width: number; height: number }, dimensions: (node: T) => { width: number; height: number }, + options: { edgeGap?: (edge: E) => number } = {}, ): Map { - const layers = assignLayersByDependencyDirection(nodes, edges); - const rows = new Map(); - for (const node of nodes) { - const layer = layers.get(node.id)!; - rows.set(layer, [...(rows.get(layer) || []), node]); + const maxRowWidth = Math.max(560, Math.min(viewport.width - 64, 1100)); + const pairKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`); + const pairNeed = new Map(); + for (const edge of edges) { + const need = options.edgeGap?.(edge) ?? 0; + const key = pairKey(idOf(edge.source), idOf(edge.target)); + pairNeed.set(key, Math.max(pairNeed.get(key) ?? 0, need)); + } + // Two neighbours in a row sit 36 apart, or further when an edge between them asks for it. + const gapBetween = (a: T, b: T) => Math.max(36, pairNeed.get(pairKey(a.id, b.id)) ?? 0); + // A row wider than the screen wraps when no edge leaves it (edges from other + // rows would run through the wrapped cards); a row of linked cards (a chain) + // wraps back and forth so each wrap edge stays short. + const rows = orderRows(hierarchyRows(nodes, edges, node => dimensions(node).width, maxRowWidth), edges); + const firstRow = new Map(); + rows.forEach((row, index) => row.forEach(node => firstRow.set(node.id, index))); + const leaves = new Set(); + for (const edge of edges) { + const a = firstRow.get(idOf(edge.source)), b = firstRow.get(idOf(edge.target)); + if (a !== undefined && b !== undefined && a !== b) leaves.add(a).add(b); } const ordered: T[][] = []; - const maxRowWidth = Math.max(560, Math.min(viewport.width - 64, 1100)); - for (const [, layer] of [...rows.entries()].sort(([a], [b]) => a - b)) { - let row: T[] = [], width = 0; - for (const node of layer.sort((a, b) => a.id.localeCompare(b.id))) { - const nextWidth = width + (row.length ? 36 : 0) + dimensions(node).width; - if (row.length && nextWidth > maxRowWidth) { ordered.push(row); row = []; width = 0; } - width += (row.length ? 36 : 0) + dimensions(node).width; - row.push(node); + for (const [index, row] of rows.entries()) { + if (leaves.has(index)) { + ordered.push(row); + continue; + } + const chunks: T[][] = []; + let chunk: T[] = [], used = 0; + for (const node of row) { + const gap = chunk.length ? gapBetween(chunk[chunk.length - 1], node) : 0; + if (chunk.length && used + gap + dimensions(node).width > maxRowWidth) { + chunks.push(chunk); + chunk = []; + used = 0; + } + used += (chunk.length ? gap : 0) + dimensions(node).width; + chunk.push(node); } - if (row.length) ordered.push(row); + if (chunk.length) chunks.push(chunk); + const chain = row.some((node, i) => i > 0 && pairNeed.has(pairKey(row[i - 1].id, node.id))); + chunks.forEach((part, i) => ordered.push(chain && i % 2 ? [...part].reverse() : part)); + } + const rowOf = new Map(); + ordered.forEach((row, index) => row.forEach(node => rowOf.set(node.id, index))); + const rowGaps = ordered.map(() => 64); + for (const edge of edges) { + const need = options.edgeGap?.(edge) ?? 0; + const a = rowOf.get(idOf(edge.source)), b = rowOf.get(idOf(edge.target)); + if (!(need > 0) || a === undefined || b === undefined) continue; + for (let gap = Math.min(a, b); gap < Math.max(a, b); gap++) rowGaps[gap] = Math.max(rowGaps[gap], need); } const heights = ordered.map(row => Math.max(...row.map(n => dimensions(n).height))); - const totalHeight = heights.reduce((sum, h) => sum + h, 0) + Math.max(0, ordered.length - 1) * 64; + const totalHeight = heights.reduce((sum, h) => sum + h, 0) + rowGaps.slice(0, -1).reduce((sum, gap) => sum + gap, 0); + // Centre distance each card keeps from the first card of its row. + const offsets = ordered.map(row => { + const out = [0]; + for (let i = 1; i < row.length; i++) + out.push(out[i - 1] + (dimensions(row[i - 1]).width + dimensions(row[i]).width) / 2 + gapBetween(row[i - 1], row[i])); + return out; + }); + const xs = new Map(); + ordered.forEach((row, index) => { + const width = offsets[index][row.length - 1] + (dimensions(row[0]).width + dimensions(row[row.length - 1]).width) / 2; + const left = (viewport.width - width) / 2 + dimensions(row[0]).width / 2; + row.forEach((node, i) => xs.set(node.id, left + offsets[index][i])); + }); + // Pull each card over its neighbours in other rows so edges run short and + // steep, keeping the row's order and gaps (weighted isotonic regression). + const across = new Map(nodes.map(node => [node.id, []])); + for (const edge of edges) { + const a = idOf(edge.source), b = idOf(edge.target); + if (rowOf.get(a) === undefined || rowOf.get(b) === undefined || rowOf.get(a) === rowOf.get(b)) continue; + across.get(a)!.push(b); + across.get(b)!.push(a); + } + for (let sweep = 0; sweep < 4; sweep++) { + const order = [...ordered.keys()]; + if (sweep % 2) order.reverse(); + for (const index of order) { + const row = ordered[index]; + const wanted = row.map(node => { + const others = across.get(node.id)!; + return others.length ? others.reduce((sum, id) => sum + xs.get(id)!, 0) / others.length : xs.get(node.id)!; + }); + const fitted = isotonic( + wanted.map((x, i) => x - offsets[index][i]), + row.map(node => (across.get(node.id)!.length ? 1 : 0.05)) + ); + row.forEach((node, i) => xs.set(node.id, fitted[i] + offsets[index][i])); + } + } + let minX = Infinity, maxX = -Infinity; + for (const node of nodes) { + const half = dimensions(node).width / 2; + minX = Math.min(minX, xs.get(node.id)! - half); + maxX = Math.max(maxX, xs.get(node.id)! + half); + } + const shift = nodes.length ? viewport.width / 2 - (minX + maxX) / 2 : 0; let y = (viewport.height - totalHeight) / 2; const out = new Map(); ordered.forEach((row, index) => { - const width = row.reduce((sum, n) => sum + dimensions(n).width, 0) + (row.length - 1) * 36; - let x = (viewport.width - width) / 2; - for (const node of row) { - const size = dimensions(node); - out.set(node.id, { x: x + size.width / 2, y: y + heights[index] / 2 }); - x += size.width + 36; - } - y += heights[index] + 64; + for (const node of row) out.set(node.id, { x: xs.get(node.id)! + shift, y: y + heights[index] / 2 }); + y += heights[index] + rowGaps[index]; }); return out; } From a1ed8c257e8668524c8096b1ecd6e8a46e75a8fb Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 10 Nov 2026 22:48:30 -0800 Subject: [PATCH 04/25] feat(graph): label clearance for automatic layouts labelClearance.ts places every label as the renderer does over a whole layout and makes room where one would be dropped: Organize and the hierarchical rows reserve labelGap per edge at the zoom the camera will frame them, then spread by the smallest factor that frees every label (rows apart first). clearFreeCards moves only the cards the force layout placed itself. Groups over 200 cards keep only the reserved gaps. labelClearanceShapes.ts holds the new-user graphs and hard shapes (hubs, two hubs, a long chain, a dense cluster of 30, long labels) shared by the unit and browser tests. Co-Authored-By: Claude Opus 5.5 --- packages/web/src/lib/README.md | 4 +- packages/web/src/lib/labelClearance.test.ts | 241 ++++++++ packages/web/src/lib/labelClearance.ts | 571 +++++++++++++++++++ packages/web/src/lib/labelClearanceShapes.ts | 231 ++++++++ 4 files changed, 1045 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/lib/labelClearance.test.ts create mode 100644 packages/web/src/lib/labelClearance.ts create mode 100644 packages/web/src/lib/labelClearanceShapes.ts diff --git a/packages/web/src/lib/README.md b/packages/web/src/lib/README.md index 7ecc5bf0..f1d0bad4 100644 --- a/packages/web/src/lib/README.md +++ b/packages/web/src/lib/README.md @@ -24,12 +24,12 @@ flowchart LR | Session and access | [`authStorage.ts`](authStorage.ts), [`roleAccess.ts`](roleAccess.ts), `loginCode.ts`, `loginFocus.ts`, `clipboard.ts` | Token and user storage ("keep me signed in" uses `localStorage`, otherwise `sessionStorage`); which roles may open Admin and Backend; the emailed sign-in code format; copy-to-clipboard and short ids. | | Saving layout and undo | [`positionSaveQueue.ts`](positionSaveQueue.ts), [`recoverablePositionQueue.ts`](recoverablePositionQueue.ts), [`layoutPersistence.ts`](layoutPersistence.ts), `serialQueue.ts`, [`undoStack.ts`](undoStack.ts) | Coalesced, bounded writes of node positions with an IndexedDB recovery copy; per-key ordered tasks; the undo stack behind Ctrl/Cmd+Z. Used through `hooks/usePositionSaveQueue.ts`. | | Canvas geometry | `edgeGeometry.ts`, `edgeRouteGeometry.ts`, `edgeLabelLayout.ts`, `edgeLabelPlacement.ts`, `edgeLOD.ts`, `parallelEdges.ts`, `schematicRouting.ts`, [`graphSymbolLayout.ts`](graphSymbolLayout.ts), `graphFit.ts`, `separateCards.ts`, `nodeControlIcons.ts` | Where edges meet cards, routed edge paths and their labels, how edges fade or bundle by zoom, card sizes and fonts, fitting the camera, untangling overlapping cards, header control icons. | -| Layout and simulation | `physicsConfig.ts`, `layoutAlgorithms.ts`, `topologyOrganization.ts`, `organizationCamera.ts`, `graphDataMerge.ts`, `graphAdjacency.ts` | Force-simulation settings, the optional layered (hierarchical) layout, "organize" arrangement and its camera, merging fresh data into the live simulation without losing positions, hover neighbourhoods. | +| Layout and simulation | `physicsConfig.ts`, `layoutAlgorithms.ts`, `topologyOrganization.ts`, `labelClearance.ts`, `labelClearanceShapes.ts`, `organizationCamera.ts`, `graphDataMerge.ts`, `graphAdjacency.ts` | Force-simulation settings, the optional layered (hierarchical) layout, "organize" arrangement and its camera, room for every relationship label in those layouts (and the graph shapes its unit and browser tests share), merging fresh data into the live simulation without losing positions, hover neighbourhoods. | | Level of detail | [`semanticLOD.ts`](semanticLOD.ts), `semanticRelationships.ts`, `semanticTrace.ts`, `overviewOrganization.ts`, `overviewBusHint.ts`, `overviewBusHit.ts`, `localItemLOD.ts`, `localItemLayout.ts`, `subgraphPreview.ts` | Display-only projections for large graphs: grouped overview, relationship buses and traces, the readable local neighbourhood of a dense item, sub-graph previews. They never feed back into saved positions. Background: [`docs/architecture/`](../../../../docs/architecture/README.md). | | Item data | `graphAnalytics.ts`, `recordedActivity.ts`, `statusNotes.ts`, `requirementsCoverage.ts`, [`relationshipPriority.ts`](relationshipPriority.ts), `connectionUtils.ts` | Analytics summaries, the activity list (only real timestamps and notes), status notes kept in item metadata, requirement coverage via `SATISFIES` edges, relationship priority levels, connection lookups. | | Device and feel | [`adaptiveQuality.ts`](adaptiveQuality.ts), `perfMeter.ts`, `touchInteraction.ts`, `nodeAnimations.ts`, `celebration.ts` | Quality tiers from device and network, frame-time meters for the debug console, tap and touch-target rules, status-driven node animation classes, the completion burst. | | Not used | `nodeTypeConfig.ts` | An older colour table for node types; nothing imports it (colours now come from `constants/workItemConstants.tsx`). | -| Tests here | 16 `*.test.ts(x)` files | Tests for the newer modules sit next to them (`semanticLOD.test.ts`, `localItemLOD.test.ts`, `topologyOrganization.test.ts`...). `routerNavigation.test.tsx` checks React Router navigation behaviour itself. | +| Tests here | 17 `*.test.ts(x)` files | Tests for the newer modules sit next to them (`semanticLOD.test.ts`, `localItemLOD.test.ts`, `topologyOrganization.test.ts`...). `routerNavigation.test.tsx` checks React Router navigation behaviour itself. | | Subfolders | [`offline/`](offline/README.md), [`project-transfer/`](project-transfer/README.md), [`__tests__/`](__tests__/README.md) | Browser-local workspace and sync; project file import/export; unit tests. | Up: [`src/`](../README.md) diff --git a/packages/web/src/lib/labelClearance.test.ts b/packages/web/src/lib/labelClearance.test.ts new file mode 100644 index 00000000..94fb9bb1 --- /dev/null +++ b/packages/web/src/lib/labelClearance.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import { + edgeLabelDimensions, + EDGE_LABEL_SIZE, + graphItemLayout, + type TextMeasure, +} from './graphSymbolLayout'; +import { getRelationshipConfig } from '../constants/workItemConstants'; +import type { RelationshipType } from '../types/graph'; +import { + projectRelationshipPriority, + relationshipPriorityTier, +} from './relationshipPriority'; +import { + clearFreeCards, + hierarchyWithLabelRoom, + labelGap, + labelReport, + LABEL_MIN_ZOOM, + ORGANIZED_ZOOM_FLOOR, + organizeWithLabelRoom, + placeGraphLabels, + spreadForLabels, + SPREAD_SLACK, + type LabelCard, + type LabelEdge, +} from './labelClearance'; +import { organizeTopology } from './topologyOrganization'; +import { LABEL_SHAPES, type LabelShape } from './labelClearanceShapes'; + +// measureGraphText's own estimate when there is no canvas: wider than the +// browser's text, so layouts that pass here have room to spare on screen. +const measure: TextMeasure = (text, size) => Array.from(text).length * size * 0.65; + +// Graph-area sizes of the three screens the browser journey uses. +const SCREENS = { + desktop: { width: 1376, height: 750 }, + iPad: { width: 834, height: 1000 }, + iPhone: { width: 390, height: 560 }, +} as const; + +type Item = { id: string; title: string; type: string; description?: string; x: number; y: number }; +type Edge = { id: string; source: string; target: string; type: string }; +type Points = ReadonlyMap; + +/** A shape as the renderer draws it: one relationship per pair, "+N" for the rest. */ +function graph(shape: LabelShape) { + const items: Item[] = shape.items.map(item => ({ + id: item.key, title: item.title, type: item.type, description: item.description, x: item.x ?? 0, y: item.y ?? 0, + })); + const edges: Edge[] = shape.edges.map((edge, i) => ({ id: `e${i}`, ...edge })); + const priority = projectRelationshipPriority(items, edges, 'essential'); + const labels = new Map>(); + for (const edge of edges) { + if (!priority.representedIds.has(edge.id)) continue; + const pair = priority.byEdge.get(edge.id); + const more = pair && pair.primary.id === edge.id ? pair.summarized.length : 0; + const text = `${getRelationshipConfig(edge.type as RelationshipType).label}${more ? ` +${more}` : ''}`; + labels.set(edge.id, edgeLabelDimensions(measure(text, EDGE_LABEL_SIZE))); + } + const drawn: LabelEdge[] = edges.filter(edge => labels.has(edge.id)).map(edge => ({ + id: edge.id, source: edge.source, target: edge.target, label: labels.get(edge.id)!, priority: relationshipPriorityTier(edge.type), + })); + return { + items, edges, drawn, + dimensionsAt: (item: Item, scale: number) => graphItemLayout(item, measure, scale), + labelFor: (edge: Edge) => labels.get(edge.id) ?? null, + priority: (edge: Edge) => relationshipPriorityTier(edge.type), + }; +} +type Graph = ReturnType; + +const cardsAt = (g: Graph, positions: Points, zoom: number): LabelCard[] => + g.items.map(item => ({ id: item.id, ...positions.get(item.id)!, ...g.dimensionsAt(item, zoom) })); + +/** Every way a label can be unreadable, at `zoom`; all empty when they all read. */ +function problems(g: Graph, positions: Points, zoom: number) { + const cards = cardsAt(g, positions, zoom); + return labelReport(cards, g.drawn, placeGraphLabels(cards, g.drawn, { zoom })); +} +const NONE = { hidden: [], onCards: [], onLabels: [], outOfGap: [] }; + +/** Pairs of cards that overlap at 100%. */ +function cardsTouching(g: Graph, positions: Points) { + const cards = cardsAt(g, positions, 1); + const touching: string[] = []; + for (const [i, a] of cards.entries()) + for (const b of cards.slice(i + 1)) + if (Math.abs(a.x - b.x) * 2 < a.width + b.width && Math.abs(a.y - b.y) * 2 < a.height + b.height) touching.push(`${a.id} × ${b.id}`); + return touching; +} + +/** Area of the box around every card at 100%. */ +function area(g: Graph, positions: Points) { + const cards = cardsAt(g, positions, 1); + const left = Math.min(...cards.map(card => card.x - card.width / 2)), right = Math.max(...cards.map(card => card.x + card.width / 2)); + const top = Math.min(...cards.map(card => card.y - card.height / 2)), bottom = Math.max(...cards.map(card => card.y + card.height / 2)); + return (right - left) * (bottom - top); +} + +// The organizer without label room (as it was), for how much room labels cost. +const plain = (g: Graph, screen: Screen) => organizeTopology(g.items, g.edges, item => g.dimensionsAt(item, 1), SCREENS[screen]); + +type Screen = keyof typeof SCREENS; +const cases = LABEL_SHAPES.filter(shape => shape.edges.length) + .flatMap(shape => (Object.keys(SCREENS) as Screen[]).map(screen => [shape.name, screen, shape] as const)); + +describe('labelGap', () => { + it('asks for the label, its clearance and the arrowhead, growing below 100% zoom', () => { + expect(labelGap({ width: 120, height: 30 }, 1)).toBe(120 + 32 + 16); + expect(labelGap({ width: 120, height: 30 }, 1.25)).toBe(168); + expect(labelGap({ width: 120, height: 30 }, 0.5)).toBe(240 + 48); + }); +}); + +describe('placeGraphLabels', () => { + it('hides the label of an edge too short for it, and shows it once the cards part', () => { + const g = graph({ name: 'pair', items: [ + { key: 'a', title: 'Write the plan', type: 'TASK', status: 'NOT_STARTED' }, + { key: 'b', title: 'Review the plan', type: 'TASK', status: 'NOT_STARTED' }, + ], edges: [{ source: 'a', target: 'b', type: 'DEPENDS_ON' }] }); + const [a, b] = g.items.map(item => g.dimensionsAt(item, 1).width); + const at = (gap: number) => new Map([['a', { x: 0, y: 0 }], ['b', { x: (a + b) / 2 + gap, y: 0 }]]); + expect(problems(g, at(64), 1).hidden).toEqual(['e0']); + expect(problems(g, at(labelGap(g.drawn[0].label, 1) + 1), 1)).toEqual(NONE); + // At 65% the same label is half as big again in graph units. + expect(problems(g, at(labelGap(g.drawn[0].label, 1) + 1), ORGANIZED_ZOOM_FLOOR).hidden).toEqual(['e0']); + }); +}); + +describe('organizeWithLabelRoom', () => { + it.each(cases)('%s on %s: every label reads at the zoom it is framed at and at 100%%', (_name, screen, shape) => { + const g = graph(shape); + const result = organizeWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS[screen] }); + expect(result.zoom).toBeGreaterThanOrEqual(ORGANIZED_ZOOM_FLOOR); + expect(problems(g, result.positions, result.zoom)).toEqual(NONE); + expect(problems(g, result.positions, 1)).toEqual(NONE); + expect(cardsTouching(g, result.positions)).toEqual([]); + }); + + it.each(cases)('%s on %s: takes at most 2.8 times the area of the organizer without label room', (_name, screen, shape) => { + const g = graph(shape); + const result = organizeWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS[screen] }); + expect(area(g, result.positions) / area(g, plain(g, screen).positions)).toBeLessThan(2.8); + }); + + it('changes nothing where the organizer already left room for every label', () => { + const g = graph(LABEL_SHAPES.find(shape => shape.name === 'Hub with 12 spokes')!); + const before = plain(g, 'desktop'); + expect(problems(g, before.positions, ORGANIZED_ZOOM_FLOOR)).toEqual(NONE); + const result = organizeWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS.desktop }); + expect(result.positions).toEqual(before.positions); + }); +}); + +describe('hierarchyWithLabelRoom', () => { + it.each(cases)('%s on %s: every label reads at the zoom it is framed at and at 100%%', (_name, screen, shape) => { + const g = graph(shape); + const result = hierarchyWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS[screen], floor: ORGANIZED_ZOOM_FLOOR }); + expect(problems(g, result.positions, Math.max(LABEL_MIN_ZOOM, result.zoom))).toEqual(NONE); + expect(problems(g, result.positions, 1)).toEqual(NONE); + expect(cardsTouching(g, result.positions)).toEqual([]); + }); + + it.each(cases)('%s on %s: takes at most 1.5 times the area "Organize" takes', (_name, screen, shape) => { + const g = graph(shape); + const result = hierarchyWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS[screen], floor: ORGANIZED_ZOOM_FLOOR }); + const organized = organizeWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS[screen] }); + expect(area(g, result.positions) / area(g, organized.positions)).toBeLessThan(1.5); + }); + + it('keeps prerequisites above the work that depends on them', () => { + const g = graph(LABEL_SHAPES.find(shape => shape.name === 'Plan a launch')!); + const { positions } = hierarchyWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS.desktop }); + const dependencies = g.edges.filter(edge => edge.type === 'DEPENDS_ON'); + expect(dependencies.length).toBeGreaterThan(0); + for (const edge of dependencies) expect(positions.get(edge.target)!.y).toBeLessThan(positions.get(edge.source)!.y); + }); +}); + +describe('spreadForLabels', () => { + it('returns 1 when every label already shows, and the smallest factor that frees them otherwise', () => { + const g = graph(LABEL_SHAPES.find(shape => shape.name === 'Long chain')!); + const sizes = new Map(g.items.map(item => [item.id, g.dimensionsAt(item, 1)])); + const row = (gap: number) => { + let x = 0; + return new Map(g.items.map(item => { + const at = { x: x + sizes.get(item.id)!.width / 2, y: 0 }; + x += sizes.get(item.id)!.width + gap; + return [item.id, at]; + })); + }; + const anchor = { x: 0, y: 0 }; + expect(spreadForLabels(row(400), sizes, g.drawn, { zoom: 1, anchor })).toBe(1); + const factor = spreadForLabels(row(40), sizes, g.drawn, { zoom: 1, anchor }); + expect(factor).toBeGreaterThan(1); + expect(factor).toBeLessThan(4); + const spread = new Map([...row(40)].map(([id, p]) => [id, { x: p.x * factor, y: 0 }])); + expect(problems(g, spread, 1).hidden).toEqual([]); + // Just under the smallest factor (the result has 10% to spare) is not enough. + const less = new Map([...row(40)].map(([id, p]) => [id, { x: p.x * (factor / SPREAD_SLACK - 0.05), y: 0 }])); + expect(problems(g, less, 1).hidden.length).toBeGreaterThan(0); + }); +}); + +describe('clearFreeCards', () => { + // A settled force layout that left cards too close: a tight grid. + const cramped = (g: Graph, zoom: number) => { + const columns = Math.ceil(Math.sqrt(g.items.length)); + return g.items.map((item, i) => ({ + id: item.id, + x: (i % columns) * 300, + y: Math.floor(i / columns) * 160, + ...g.dimensionsAt(item, zoom), + })); + }; + + it.each(LABEL_SHAPES.filter(shape => shape.edges.length).map(shape => [shape.name, shape] as const))( + '%s: frees every label by moving only free cards', (_name, shape) => { + const g = graph(shape); + const zoom = LABEL_MIN_ZOOM; + const cards = cramped(g, zoom); + const pinned = cards[0].id; + const { moved, hidden } = clearFreeCards(cards, g.drawn, { zoom, free: id => id !== pinned }); + expect(hidden).toEqual([]); + expect(moved.has(pinned)).toBe(false); + const positions = new Map(cards.map(card => [card.id, moved.get(card.id) ?? { x: card.x, y: card.y }])); + expect(problems(g, positions, zoom)).toEqual(NONE); + }); + + it('moves nothing when every label already shows, and never moves a pinned card', () => { + const g = graph(LABEL_SHAPES.find(shape => shape.name === 'Welcome')!); + const result = organizeWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS.desktop }); + const cards = cardsAt(g, result.positions, 1); + expect(clearFreeCards(cards, g.drawn, { zoom: 1, free: () => true }).moved.size).toBe(0); + const tight = cards.map((card, i) => ({ ...card, x: i * (card.width + 30), y: 0 })); + const stuck = clearFreeCards(tight, g.drawn, { zoom: 1, free: () => false }); + expect(stuck.moved.size).toBe(0); + expect(stuck.hidden.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/web/src/lib/labelClearance.ts b/packages/web/src/lib/labelClearance.ts new file mode 100644 index 00000000..7a96e97d --- /dev/null +++ b/packages/web/src/lib/labelClearance.ts @@ -0,0 +1,571 @@ +/** + * Room for relationship labels in the automatic layouts. + * + * The renderer draws a label beside its edge, turned to the edge's angle, only + * where the whole label clears every card, arrowhead, other edge line and label + * placed before it (edgeRouteLabel); otherwise the label is hidden. Below 100% + * zoom a label keeps its screen size, so in graph units it grows by 1/zoom. + * Layouts that only keep cards 64px apart therefore hide most labels. + * + * - `placeGraphLabels` places every label of a laid-out graph the way the + * renderer does, over the whole graph, and `labelReport` checks the result + * with exact geometry: hidden labels, labels on cards or on other labels, and + * labels outside the visible gap of their own edge. + * - `labelGap` is how long an edge's visible line must be for its label. + * - `spreadForLabels` finds the smallest spread of a group of cards that lets + * all of their labels show: the fallback when reserving gaps is not enough. + * - `organizeWithLabelRoom`, `hierarchyWithLabelRoom` and `clearFreeCards` run + * the organizer, the hierarchical rows and a settled force layout with that + * room, at the zoom the camera will show them. + */ +import type { Dims, ObstacleBox } from './edgeLabelLayout'; +import { borderReach, edgeBorderEndpoints } from './edgeGeometry'; +import { + createParallelEdgeRoute, + edgeRouteArrowBounds, + orientedBoxesOverlap, + type EdgeRouteGeometry, + type EdgeRouteLabel, +} from './edgeRouteGeometry'; +import { placeItemLabels, type ItemLabelBudget } from './edgeLabelPlacement'; +import { arrowVisibility, perpendicularOffset } from './edgeLOD'; +import { assignParallelEdgeIndices } from './parallelEdges'; +import { computeGraphFit, type FitObstacle } from './graphFit'; +import { + organizeTopology, + type OrganizationEdge, + type OrganizationNode, +} from './topologyOrganization'; +import { computeCompactLayout } from './layoutAlgorithms'; + +type Point = { x: number; y: number }; +type Size = { width: number; height: number }; + +/** Clearance the renderer keeps around a label (edgeRouteLabel's padding). */ +export const LABEL_PADDING = 8; +/** Labels are placed at this zoom and closer (below it they are hidden). */ +export const LABEL_MIN_ZOOM = 0.6; +/** "Organize around connections" never frames below this zoom. */ +export const ORGANIZED_ZOOM_FLOOR = 0.65; +const ARROW_LENGTH = 16; + +/** How much bigger than its drawn size a label is in graph units at `zoom`. */ +export const labelWorldScale = (zoom: number) => Math.max(1, 1 / zoom); +/** Distance from an edge's line to its label's centre at that label scale. */ +export const labelSideOffset = (scale: number) => 26 * scale + 3; + +/** + * Visible (border-to-border) edge length a label needs at `zoom`: the label, + * the clearance at both ends and the arrowhead it must not cover. + */ +export function labelGap(label: Dims, zoom: number): number { + return label.width * labelWorldScale(zoom) + 4 * LABEL_PADDING + ARROW_LENGTH; +} + +export interface LabelCard { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface LabelEdge { + id: string; + source: string; + target: string; + /** Drawn label size at 100% (edgeLabelDimensions of its measured text). */ + label: Dims; + /** Lower first, as relationshipPriorityTier. */ + priority?: number; +} + +export interface GraphLabels { + zoom: number; + scale: number; + routes: Map; + placements: Map; + hidden: string[]; + checks: number; +} + +const UNBOUNDED: ItemLabelBudget = { labels: Infinity, attempts: Infinity, checks: Infinity }; + +/** + * Place every label of a laid-out graph as the renderer does at `zoom` + * (InteractiveGraphVisualization.updateEdgePositions): border-to-border routes + * with parallel lanes, cards and arrowheads as obstacles, every edge line as a + * wire to avoid, labels in priority order. With no viewport the whole graph + * counts as on screen. + */ +export function placeGraphLabels( + cards: readonly LabelCard[], + edges: readonly LabelEdge[], + options: { + zoom: number; + viewport?: ObstacleBox; + obstacles?: readonly ObstacleBox[]; + budget?: ItemLabelBudget; + } +): GraphLabels { + const { zoom } = options; + const scale = labelWorldScale(zoom); + const byId = new Map(cards.map(card => [card.id, card])); + const lanes = assignParallelEdgeIndices([...edges]); + const routes = new Map(); + const obstacles: ObstacleBox[] = cards.map(({ x, y, width, height }) => ({ x, y, width, height })); + for (const edge of edges) { + const a = byId.get(edge.source), + b = byId.get(edge.target); + if (!a || !b) { + routes.set(edge.id, null); + continue; + } + const ends = edgeBorderEndpoints(a, a, b, b); + const lane = lanes.get(edge.id); + const route = createParallelEdgeRoute( + { id: a.id, x: ends.x1, y: ends.y1 }, + { id: b.id, x: ends.x2, y: ends.y2 }, + perpendicularOffset(lane?.index ?? 0, lane?.total ?? 1, zoom) + ); + routes.set(edge.id, route); + if (route) obstacles.push(edgeRouteArrowBounds(route, arrowVisibility(zoom, lane?.total ?? 1).scale)); + } + if (options.obstacles) obstacles.push(...options.obstacles); + let left = Infinity, + right = -Infinity, + top = Infinity, + bottom = -Infinity; + for (const card of cards) { + left = Math.min(left, card.x - card.width / 2); + right = Math.max(right, card.x + card.width / 2); + top = Math.min(top, card.y - card.height / 2); + bottom = Math.max(bottom, card.y + card.height / 2); + } + const viewport = options.viewport ?? { + x: (left + right) / 2, + y: (top + bottom) / 2, + width: right - left + 4000, + height: bottom - top + 4000, + }; + const segments = [...routes.values()].flatMap(route => route?.segments ?? []); + const result = placeItemLabels( + edges.map(edge => ({ + id: edge.id, + route: routes.get(edge.id) ?? null, + eligible: zoom >= LABEL_MIN_ZOOM, + priority: edge.priority ?? 0, + dims: { width: edge.label.width * scale, height: edge.label.height * scale }, + t: 0.5, + offset: labelSideOffset(scale), + })), + { viewport, obstacles, segments }, + options.budget ?? UNBOUNDED + ); + return { + zoom, + scale, + routes, + placements: result.placements, + hidden: edges.filter(edge => !result.placements.has(edge.id)).map(edge => edge.id), + checks: result.checks, + }; +} + +export interface LabelReport { + /** Edges with no label shown. */ + hidden: string[]; + /** "edge × card" for every label that touches any card. */ + onCards: string[]; + /** "edge × edge" for every pair of labels that touch. */ + onLabels: string[]; + /** Labels that reach past the visible part of their own edge. */ + outOfGap: string[]; +} + +/** Check placed labels with exact (turned) boxes and no padding. */ +export function labelReport( + cards: readonly LabelCard[], + edges: readonly LabelEdge[], + labels: GraphLabels +): LabelReport { + const boxes: [string, ObstacleBox][] = []; + for (const edge of edges) { + const placed = labels.placements.get(edge.id); + if (!placed) continue; + boxes.push([edge.id, { + x: placed.x, + y: placed.y, + width: edge.label.width * labels.scale, + height: edge.label.height * labels.scale, + rotation: placed.rotation, + }]); + } + const onCards: string[] = [], + onLabels: string[] = [], + outOfGap: string[] = []; + for (const [i, [id, box]] of boxes.entries()) { + for (const card of cards) if (orientedBoxesOverlap(box, card)) onCards.push(`${id} × ${card.id}`); + for (const [other, peer] of boxes.slice(i + 1)) if (orientedBoxesOverlap(box, peer)) onLabels.push(`${id} × ${other}`); + const route = labels.routes.get(id); + const segment = route?.segments.find(part => { + const ux = (part.target.x - part.source.x) / part.length, + uy = (part.target.y - part.source.y) / part.length; + const along = (box.x - part.source.x) * ux + (box.y - part.source.y) * uy; + return along >= 0 && along <= part.length; + }); + const r = ((box.rotation ?? 0) * Math.PI) / 180; + const corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([u, v]) => ({ + x: box.x + (u * box.width * Math.cos(r)) / 2 - (v * box.height * Math.sin(r)) / 2, + y: box.y + (u * box.width * Math.sin(r)) / 2 + (v * box.height * Math.cos(r)) / 2, + })); + const inside = + !!segment && + corners.every(corner => { + const ux = (segment.target.x - segment.source.x) / segment.length, + uy = (segment.target.y - segment.source.y) / segment.length; + const along = (corner.x - segment.source.x) * ux + (corner.y - segment.source.y) * uy; + return along >= -0.5 && along <= segment.length + 0.5; + }); + if (!inside) outOfGap.push(id); + } + return { hidden: labels.hidden, onCards, onLabels, outOfGap }; +} + +/** `point` moved `factor` times as far from `anchor` (only vertically for 'y'). */ +export function spreadPoint(point: Point, anchor: Point, factor: number, axes: 'xy' | 'y' = 'xy'): Point { + return { + x: axes === 'y' ? point.x : anchor.x + (point.x - anchor.x) * factor, + y: anchor.y + (point.y - anchor.y) * factor, + }; +} + +/** Work limit for the spread searches of one layout, in obstacle checks. */ +export const SPREAD_CHECKS = 4_000_000; +/** + * Groups of more cards than this keep only the reserved gaps, without the + * spread search: a graph that large opens as an overview, a person reads its + * labels a neighbourhood at a time, and the search would cost seconds. + */ +export const SPREAD_MAX_CARDS = 200; + +/** + * Extra spread on top of the smallest that frees every label: the renderer + * places labels one by one from where each was last, so its order can differ a + * little from this search's and needs some room to spare. + */ +export const SPREAD_SLACK = 1.1; + +/** + * The smallest factor (1 = as is) by which spreading `points` about `anchor` + * lets every label in `edges` show at `zoom`, times SPREAD_SLACK (at most + * `maxFactor`); `maxFactor` when even that does not. Card sizes are the ones + * drawn at `zoom`. `work.remaining` counts down the obstacle checks it may + * spend; with none left it stops searching and keeps the largest factor it has + * tried. + */ +export function spreadForLabels( + points: ReadonlyMap, + sizes: ReadonlyMap, + edges: readonly LabelEdge[], + options: { + zoom: number; + anchor?: Point; + maxFactor?: number; + work?: { remaining: number }; + /** 'y' spreads rows apart and keeps each card's x. */ + axes?: 'xy' | 'y'; + /** Only these cards move; the others stay where they are. */ + moves?: (id: string) => boolean; + } +): number { + const ids = new Set(points.keys()); + const local = edges.filter(edge => ids.has(edge.source) && ids.has(edge.target)); + if (!local.length || options.zoom < LABEL_MIN_ZOOM) return 1; + const anchor = options.anchor ?? points.values().next().value!; + const maxFactor = options.maxFactor ?? 4; + const work = options.work ?? { remaining: SPREAD_CHECKS }; + const fits = (factor: number) => { + if (work.remaining <= 0) return false; + const cards: LabelCard[] = [...points].map(([id, p]) => ({ + id, + ...(options.moves?.(id) === false ? p : spreadPoint(p, anchor, factor, options.axes)), + ...sizes.get(id)!, + })); + const placed = placeGraphLabels(cards, local, { + zoom: options.zoom, + budget: { labels: Infinity, attempts: Infinity, checks: work.remaining }, + }); + work.remaining -= placed.checks; + return placed.hidden.length === 0; + }; + if (fits(1)) return 1; + let low = 1, + high = 1.2; + while (high < maxFactor && !fits(high)) { + low = high; + high = Math.min(maxFactor, high * 1.25); + } + if (high >= maxFactor) return maxFactor; + for (let step = 0; step < 6 && high - low > 0.02; step++) { + const middle = (low + high) / 2; + if (fits(middle)) high = middle; + else low = middle; + } + return Math.min(maxFactor, high * SPREAD_SLACK); +} + +export interface LabelRoomOptions { + /** Card size drawn at `scale` (graphItemLayout with the real text measure). */ + dimensionsAt: (node: T, scale: number) => Size; + /** Drawn size of the edge's label at 100%, or null when it is not drawn. */ + labelFor: (edge: E) => Dims | null; + priority?: (edge: E) => number; + viewport: Size; + /** Screen rectangles the camera fit keeps cards away from. */ + obstacles?: FitObstacle[]; + /** The camera never frames below this zoom. */ + floor?: number; +} + +const endpointId = (value: OrganizationEdge['source']) => + typeof value === 'string' ? value : value.id; + +function drawnEdges( + edges: readonly E[], + options: Pick, 'labelFor' | 'priority'> +): LabelEdge[] { + return edges.flatMap(edge => { + const label = options.labelFor(edge); + return label + ? [{ id: edge.id, source: endpointId(edge.source), target: endpointId(edge.target), label, priority: options.priority?.(edge) }] + : []; + }); +} + +/** The zoom the camera frames these positions at (graphFit, then the floor). */ +export function framedZoom( + nodes: readonly T[], + positions: ReadonlyMap, + options: Pick, 'dimensionsAt' | 'viewport' | 'obstacles'>, + floor: number +): number { + const byId = new Map(nodes.map(node => [node.id, node])); + const fit = computeGraphFit({ + width: options.viewport.width, + height: options.viewport.height, + obstacles: options.obstacles ?? [], + cards: [...positions].map(([id, p]) => ({ id, ...p })), + dimensionsAt: (card, scale) => options.dimensionsAt(byId.get(card.id)!, scale), + }); + return Math.max(floor, fit?.scale ?? 1); +} + +/** + * Run `layout` with label room for the zoom it will be shown at. A layout made + * for 100% may only fit on screen below 100%, where labels are bigger in graph + * units, so lay out again for that zoom until the room matches the framing + * (the floor bounds it). + */ +function withFramedRoom }>( + nodes: readonly T[], + options: LabelRoomOptions, + layout: (zoom: number) => R +): R & { zoom: number; labelZoom: number } { + const floor = Math.max(options.floor ?? 0, 0); + let labelZoom = 1, + result = layout(labelZoom); + for (let round = 0; round < 3; round++) { + const zoom = framedZoom(nodes, result.positions, options, floor); + // Labels below LABEL_MIN_ZOOM are not drawn; leave room for the closest + // zoom that draws them. + const needed = Math.max(zoom, LABEL_MIN_ZOOM); + if (labelWorldScale(needed) <= labelWorldScale(labelZoom) + 1e-3) return { ...result, zoom, labelZoom }; + labelZoom = needed; + result = layout(labelZoom); + } + labelZoom = Math.max(floor, LABEL_MIN_ZOOM); + result = layout(labelZoom); + return { ...result, zoom: framedZoom(nodes, result.positions, options, floor), labelZoom }; +} + +/** + * "Organize around connections" with room for every label: the organizer's + * gaps between connected cards fit their labels, and a component whose labels + * still collide (crossing wires in a dense network) is spread just enough. + */ +export function organizeWithLabelRoom< + T extends OrganizationNode, + E extends OrganizationEdge & { id: string }, +>(nodes: readonly T[], edges: readonly E[], options: LabelRoomOptions) { + const labelled = drawnEdges(edges, options); + const byEdge = new Map(labelled.map(edge => [edge.id, edge])); + const byId = new Map(nodes.map(node => [node.id, node])); + const work = { remaining: SPREAD_CHECKS }; + return withFramedRoom(nodes, { ...options, floor: options.floor ?? ORGANIZED_ZOOM_FLOOR } as LabelRoomOptions, labelZoom => + organizeTopology(nodes, edges, node => options.dimensionsAt(node, 1), options.viewport, 64, { + edgeGap: edge => { + const drawn = byEdge.get(edge.id); + return drawn ? labelGap(drawn.label, labelZoom) : 0; + }, + spread: (ids, points) => { + if (ids.length > SPREAD_MAX_CARDS) return 1; + const sizes = new Map(ids.map(id => [id, options.dimensionsAt(byId.get(id)!, labelZoom)])); + return spreadForLabels(points, sizes, labelled, { zoom: labelZoom, anchor: points.get(ids[0]), work }); + }, + }) + ); +} + +/** + * The hierarchical rows with room for every label: rows sit far enough apart + * for the labels of the edges between them, cards in a row for edges inside + * it, and when wires still cross labels the rows spread further apart. + */ +export function hierarchyWithLabelRoom< + T extends OrganizationNode, + E extends OrganizationEdge & { id: string }, +>(nodes: readonly T[], edges: readonly E[], options: LabelRoomOptions) { + const labelled = drawnEdges(edges, options); + const byEdge = new Map(labelled.map(edge => [edge.id, edge])); + return withFramedRoom(nodes, options as LabelRoomOptions, labelZoom => { + const gapFor = (edge: E) => { + const drawn = byEdge.get(edge.id); + return drawn ? labelGap(drawn.label, labelZoom) : 0; + }; + let positions = computeCompactLayout( + nodes as unknown as (T & { id: string })[], + edges as never, + options.viewport, + node => options.dimensionsAt(node, 1), + { edgeGap: gapFor as never } + ); + if (nodes.length > SPREAD_MAX_CARDS || !labelled.length) return { positions }; + const sizes = new Map(nodes.map(node => [node.id, options.dimensionsAt(node, labelZoom)])); + const ys = [...positions.values()].map(p => p.y); + const anchor = { x: options.viewport.width / 2, y: (Math.min(...ys) + Math.max(...ys)) / 2 }; + // Rows apart first (keeps the columns); both ways only when that is not enough. + const work = { remaining: SPREAD_CHECKS }; + const rows = spreadForLabels(positions, sizes, labelled, { zoom: labelZoom, anchor, maxFactor: 3, axes: 'y', work }); + positions = new Map([...positions].map(([id, p]) => [id, spreadPoint(p, anchor, rows, 'y')])); + if (rows >= 3) { + const both = spreadForLabels(positions, sizes, labelled, { zoom: labelZoom, anchor, maxFactor: 2, work }); + positions = new Map([...positions].map(([id, p]) => [id, spreadPoint(p, anchor, both)])); + } + return { positions }; + }); +} + +/** Gap two cards keep when `clearFreeCards` moves one past the other. */ +const CARD_CLEARANCE = 24; + +const cardsOverlap = (a: LabelCard, b: LabelCard, clearance: number) => + Math.abs(a.x - b.x) * 2 < a.width + b.width + 2 * clearance && + Math.abs(a.y - b.y) * 2 < a.height + b.height + 2 * clearance; + +/** + * After the force layout settles: move only the cards the simulation is free + * to move (never a pinned, saved card) until every label in `edges` shows at + * `zoom`. First a hidden label's free card steps away from the card at the + * other end of its edge, enough for the label and a little more to get clear + * of crowding; when wires still cross labels, the free cards spread out about + * the pinned ones (or their own centre) by the smallest factor that frees + * them, and the steps run once more. A moved card that lands on another card + * keeps going the same way. Returns the new centres of the cards it moved and + * the labels still hidden (both ends pinned, or out of work). + */ +export function clearFreeCards( + cards: readonly LabelCard[], + edges: readonly LabelEdge[], + options: { zoom: number; free: (id: string) => boolean; rounds?: number; work?: { remaining: number } } +): { moved: Map; hidden: string[] } { + const current = new Map(cards.map(card => [card.id, { ...card }])); + const byEdge = new Map(edges.map(edge => [edge.id, edge])); + const moved = new Set(); + const work = options.work ?? { remaining: SPREAD_CHECKS }; + const rounds = options.rounds ?? 6; + const hiddenNow = () => { + if (work.remaining <= 0) return null; + const placed = placeGraphLabels([...current.values()], edges, { + zoom: options.zoom, + budget: { labels: Infinity, attempts: Infinity, checks: work.remaining }, + }); + work.remaining -= placed.checks; + return placed.hidden; + }; + // Move `card` by (dx, dy), then on along (ux, uy) while it sits on a card. + const shift = (card: LabelCard, dx: number, dy: number, ux = dx, uy = dy) => { + card.x += dx; + card.y += dy; + const length = Math.hypot(ux, uy); + for (let step = 0; length && step < 20; step++) { + const blocker = [...current.values()].find(other => other !== card && cardsOverlap(card, other, CARD_CLEARANCE)); + if (!blocker) break; + card.x += (ux / length) * (blocker.width / 2 + CARD_CLEARANCE); + card.y += (uy / length) * (blocker.height / 2 + CARD_CLEARANCE); + } + moved.add(card.id); + }; + const step = (hidden: string[]) => { + const push = new Map(); + const add = (id: string, x: number, y: number) => { + const p = push.get(id) ?? { x: 0, y: 0 }; + push.set(id, { x: p.x + x, y: p.y + y }); + }; + for (const id of hidden) { + const edge = byEdge.get(id)!; + const a = current.get(edge.source), + b = current.get(edge.target); + if (!a || !b) continue; + const freeA = options.free(a.id), + freeB = options.free(b.id); + if (!freeA && !freeB) continue; + const apart = Math.hypot(b.x - a.x, b.y - a.y); + // Cards on the same spot part sideways. + const ux = apart ? (b.x - a.x) / apart : 1, + uy = apart ? (b.y - a.y) / apart : 0; + const visible = apart - borderReach(a, ux, uy) - borderReach(b, ux, uy); + const need = labelGap(edge.label, options.zoom); + const length = Math.max(0, need - visible) + 0.2 * need; + if (freeA && freeB) { + add(a.id, (-ux * length) / 2, (-uy * length) / 2); + add(b.id, (ux * length) / 2, (uy * length) / 2); + } else if (freeB) add(b.id, ux * length, uy * length); + else add(a.id, -ux * length, -uy * length); + } + for (const [id, p] of push) shift(current.get(id)!, p.x, p.y); + return push.size > 0; + }; + const steps = () => { + let hidden = hiddenNow(); + for (let round = 0; round < rounds && hidden?.length && step(hidden); round++) hidden = hiddenNow(); + return hidden; + }; + let hidden = steps(); + if (hidden?.length) { + const free = [...current.values()].filter(card => options.free(card.id)); + const pinned = [...current.values()].filter(card => !options.free(card.id)); + const around = pinned.length ? pinned : free; + const anchor = { + x: around.reduce((sum, card) => sum + card.x, 0) / around.length, + y: around.reduce((sum, card) => sum + card.y, 0) / around.length, + }; + const points = new Map([...current].map(([id, card]) => [id, { x: card.x, y: card.y }])); + const sizes = new Map([...current].map(([id, card]) => [id, { width: card.width, height: card.height }])); + const factor = spreadForLabels(points, sizes, edges, { zoom: options.zoom, anchor, work, moves: options.free }); + if (factor > 1) { + for (const card of free) { + const to = spreadPoint(card, anchor, factor); + card.x = to.x; + card.y = to.y; + moved.add(card.id); + } + for (const card of free) shift(card, 0, 0, card.x - anchor.x, card.y - anchor.y); + hidden = steps(); + } + } + return { + moved: new Map([...moved].map(id => [id, { x: current.get(id)!.x, y: current.get(id)!.y }])), + hidden: hidden ?? edges.map(edge => edge.id), + }; +} diff --git a/packages/web/src/lib/labelClearanceShapes.ts b/packages/web/src/lib/labelClearanceShapes.ts new file mode 100644 index 00000000..1dac559f --- /dev/null +++ b/packages/web/src/lib/labelClearanceShapes.ts @@ -0,0 +1,231 @@ +/** + * Graphs whose relationship labels must all stay readable after the automatic + * layouts. The unit tests (`labelClearance.test.ts`) and the browser journey + * (`tests/e2e/journeys/organize-labels.spec.ts`) use the same shapes, so both + * measure the same thing. + * + * The example graphs copy what a new workspace gets: the Welcome tutorial and + * the example project's graphs (#277). The other shapes are the hard cases: a + * hub with many spokes, two connected hubs, a long chain, a dense cluster and + * relationship types with long labels. Items without `x`/`y` are unplaced, as + * items made by an agent or an import are. + */ + +export interface ShapeItem { + key: string; + title: string; + type: string; + status: string; + description?: string; + x?: number; + y?: number; +} + +export interface ShapeEdge { + source: string; + target: string; + type: string; +} + +export interface LabelShape { + name: string; + items: ShapeItem[]; + edges: ShapeEdge[]; +} + +const item = ( + key: string, + title: string, + type: string, + status: string, + x?: number, + y?: number, + description?: string +): ShapeItem => ({ key, title, type, status, x, y, description }); + +const dependsOn = (source: string, target: string): ShapeEdge => ({ + source, + target, + type: 'DEPENDS_ON', +}); + +export const WELCOME_SHAPE: LabelShape = { + name: 'Welcome', + items: [ + item('welcome', 'Welcome to GraphDone', 'MILESTONE', 'IN_PROGRESS', 0, -100, + 'GraphDone helps you organize work through natural dependencies instead of rigid hierarchies. Start by exploring this tutorial graph.'), + item('first-item', 'Create your first work item', 'TASK', 'COMPLETED', -150, 50, + 'Use the + button at the top to create a new work item. Try it now!'), + item('connect', 'Connect work items together', 'TASK', 'PLANNED', 150, 50, + 'Drag from one work item to another to create connections. This shows dependencies and relationships.'), + item('views', 'Explore different views', 'TASK', 'PLANNED', 0, 200, + 'Switch between Graph, Table, Kanban, Gantt, Calendar, and Card views using the buttons at the top.'), + ], + edges: [dependsOn('welcome', 'first-item'), dependsOn('first-item', 'connect'), dependsOn('connect', 'views')], +}; + +export const PLAN_A_LAUNCH_SHAPE: LabelShape = { + name: 'Plan a launch', + items: [ + item('launch-day', 'Launch day', 'MILESTONE', 'PLANNED', 0, -300, 'The day the product opens to everyone.'), + item('publish-terms', 'Publish terms of service', 'TASK', 'PLANNED', -680, -120, 'Put the reviewed terms on the website before launch.'), + item('test-phones', 'Test sign-up on phones', 'TASK', 'PLANNED', -340, -120, 'Sign up on a small phone and a tablet, and fix anything that does not fit.'), + item('pricing-page', 'Pricing page', 'FEATURE', 'PLANNED', 0, -120, 'One page that shows each plan and what it costs.'), + item('announcement', 'Write the announcement', 'TASK', 'IN_PROGRESS', 340, -120, 'A short post that says what the product does and who it is for.'), + item('analytics', 'Set up analytics', 'TASK', 'COMPLETED', 680, -120, 'Count sign-ups so you can see how the launch goes.'), + item('legal-review', 'Legal review of the terms', 'TASK', 'BLOCKED', -680, 60, 'Blocked: waiting for the lawyer to reply. The terms cannot be published until this is done.'), + item('signup-page', 'Sign-up page', 'FEATURE', 'IN_PROGRESS', -340, 60, 'A page where new people create an account.'), + item('beta-users', 'Invite beta users', 'TASK', 'PLANNED', 0, 60, 'Ask a few friendly people to try the product before launch.'), + item('launch-date', 'Pick a launch date', 'TASK', 'COMPLETED', 340, 60, 'Choose a day and tell everyone who is helping.'), + item('draft-terms', 'Draft terms of service', 'TASK', 'COMPLETED', -680, 240, 'A first version of the terms, ready for review.'), + item('welcome-email', 'Welcome email', 'FEATURE', 'PROPOSED', -340, 240, 'An email that new people get right after they sign up.'), + ], + edges: [ + dependsOn('launch-day', 'publish-terms'), + dependsOn('publish-terms', 'legal-review'), + dependsOn('legal-review', 'draft-terms'), + dependsOn('launch-day', 'test-phones'), + dependsOn('test-phones', 'signup-page'), + dependsOn('launch-day', 'pricing-page'), + dependsOn('launch-day', 'announcement'), + dependsOn('announcement', 'launch-date'), + dependsOn('launch-day', 'analytics'), + dependsOn('beta-users', 'signup-page'), + dependsOn('welcome-email', 'signup-page'), + ], +}; + +export const FIX_BUGS_SHAPE: LabelShape = { + name: 'Fix bugs', + items: [ + item('signin-link', 'Sign-in link expires too soon', 'BUG', 'IN_PROGRESS', -340, -90, 'People who open the email a few minutes late have to ask for a new link.'), + item('release-fixes', 'Release the bug fixes', 'TASK', 'PLANNED', 0, -90, 'Ship the fixes together once they are done.'), + item('safari-export', 'Export button does nothing in Safari', 'BUG', 'COMPLETED', 340, -90, 'Fixed: the download now starts in Safari too.'), + item('signin-test', 'Add a test for sign-in links', 'TASK', 'PLANNED', -340, 90, 'A test that fails if a sign-in link expires early again.'), + item('accent-search', 'Search misses accented words', 'BUG', 'PLANNED', 0, 90, 'Searching for "cafe" does not find "café".'), + item('dark-mode-text', 'Dark mode text is hard to read', 'BUG', 'PROPOSED', 340, 90, 'Grey text on the dark background has too little contrast.'), + ], + edges: [ + dependsOn('release-fixes', 'signin-link'), + dependsOn('release-fixes', 'safari-export'), + dependsOn('signin-test', 'signin-link'), + ], +}; + +export const MOVE_HOUSE_SHAPE: LabelShape = { + name: 'Move house', + items: [ + item('movers', 'Book the movers', 'TASK', 'COMPLETED', -340, -90, 'Booked for the morning of moving day.'), + item('moving-day', 'Moving day', 'MILESTONE', 'PLANNED', 0, -90, 'Everything leaves the old home and arrives at the new one.'), + item('clean-old', 'Clean the old apartment', 'TASK', 'PLANNED', 340, -90, 'Clean once it is empty, then hand back the keys.'), + item('pack-kitchen', 'Pack the kitchen', 'TASK', 'IN_PROGRESS', -340, 90, 'Keep out one pan, plates and cups for the last few days.'), + item('bank-address', 'Change address with the bank', 'TASK', 'PLANNED', 0, 90, 'Update the address so letters go to the new home.'), + item('internet', 'Set up internet at the new place', 'TASK', 'PLANNED', 340, 90, 'Book the installation for the week you move in.'), + ], + edges: [ + dependsOn('moving-day', 'movers'), + dependsOn('moving-day', 'pack-kitchen'), + dependsOn('clean-old', 'moving-day'), + ], +}; + +const SPOKES = [ + 'Sign-in screen', 'Push notifications', 'Offline mode', 'App store listing', + 'Crash reporting', 'Dark theme', 'Payments', 'Accessibility review', + 'Tablet layout', 'Onboarding tour', 'Privacy policy', 'Beta testers', +]; +const MIXED = ['DEPENDS_ON', 'BLOCKS', 'ENABLES', 'VALIDATES']; + +export const HUB_SHAPE: LabelShape = { + name: 'Hub with 12 spokes', + items: [ + item('hub', 'Ship the mobile app', 'MILESTONE', 'IN_PROGRESS'), + ...SPOKES.map((title, i) => item(`spoke-${i}`, title, i % 3 ? 'TASK' : 'FEATURE', 'PLANNED')), + ], + edges: SPOKES.map((_, i) => ({ source: 'hub', target: `spoke-${i}`, type: MIXED[i % MIXED.length] })), +}; + +export const TWO_HUBS_SHAPE: LabelShape = { + name: 'Two connected hubs', + items: [ + item('server', 'Server release', 'MILESTONE', 'PLANNED'), + item('client', 'Client release', 'MILESTONE', 'PLANNED'), + ...Array.from({ length: 7 }, (_, i) => item(`server-${i}`, `Server task ${i + 1}`, 'TASK', 'PLANNED')), + ...Array.from({ length: 7 }, (_, i) => item(`client-${i}`, `Client task ${i + 1}`, 'TASK', 'PLANNED')), + ], + edges: [ + { source: 'client', target: 'server', type: 'DEPENDS_ON' }, + ...Array.from({ length: 7 }, (_, i) => ({ source: 'server', target: `server-${i}`, type: MIXED[i % MIXED.length] })), + ...Array.from({ length: 7 }, (_, i) => ({ source: 'client', target: `client-${i}`, type: MIXED[(i + 1) % MIXED.length] })), + ], +}; + +const STEPS = [ + 'Write the brief', 'Collect quotes', 'Choose a supplier', 'Sign the contract', + 'Order materials', 'Prepare the site', 'Build the frame', 'Fit the windows', + 'Wire the electrics', 'Paint the walls', 'Final inspection', 'Hand over the keys', +]; + +export const CHAIN_SHAPE: LabelShape = { + name: 'Long chain', + items: STEPS.map((title, i) => item(`step-${i}`, title, 'TASK', i < 4 ? 'COMPLETED' : 'PLANNED')), + edges: STEPS.slice(1).map((_, i) => ({ source: `step-${i + 1}`, target: `step-${i}`, type: 'FOLLOWS' })), +}; + +const DENSE_TYPES = ['DEPENDS_ON', 'RELATES_TO', 'BLOCKS', 'ENABLES', 'REFERENCES']; +const denseEdges: ShapeEdge[] = []; +for (let i = 0; i + 1 < 30; i++) denseEdges.push({ source: `n-${i}`, target: `n-${i + 1}`, type: DENSE_TYPES[i % 5] }); +for (let i = 0; i + 3 < 30; i += 2) denseEdges.push({ source: `n-${i}`, target: `n-${i + 3}`, type: DENSE_TYPES[(i + 2) % 5] }); +for (let i = 0; i + 11 < 30; i += 5) denseEdges.push({ source: `n-${i}`, target: `n-${i + 11}`, type: DENSE_TYPES[(i + 4) % 5] }); + +export const DENSE_SHAPE: LabelShape = { + name: 'Dense cluster of 30', + items: Array.from({ length: 30 }, (_, i) => item(`n-${i}`, `Service ${String(i + 1).padStart(2, '0')}`, ['TASK', 'FEATURE', 'BUG'][i % 3], 'PLANNED')), + edges: denseEdges, +}; + +export const LONG_LABELS_SHAPE: LabelShape = { + name: 'Long relationship labels', + items: [ + item('spec', 'Payment provider requirements', 'REQUIREMENT', 'PLANNED'), + item('card', 'Card checkout', 'FEATURE', 'IN_PROGRESS'), + item('wallet', 'Wallet checkout', 'FEATURE', 'PLANNED'), + item('old', 'Old checkout page', 'FEATURE', 'COMPLETED'), + item('copy', 'Copy of the checkout page', 'FEATURE', 'PROPOSED'), + item('tests', 'Checkout test plan', 'TASK', 'PLANNED'), + item('audit', 'Security audit', 'TASK', 'PLANNED'), + item('epic', 'Payments epic', 'EPIC', 'IN_PROGRESS'), + item('docs', 'Payment API documentation', 'RESEARCH', 'PLANNED'), + ], + edges: [ + { source: 'card', target: 'wallet', type: 'PARALLEL_WITH' }, + { source: 'card', target: 'old', type: 'CONFLICTS_WITH' }, + { source: 'copy', target: 'old', type: 'DUPLICATES' }, + { source: 'card', target: 'spec', type: 'SATISFIES' }, + { source: 'tests', target: 'card', type: 'VALIDATES' }, + { source: 'audit', target: 'card', type: 'VALIDATES' }, + { source: 'card', target: 'epic', type: 'IS_PART_OF' }, + { source: 'wallet', target: 'epic', type: 'IS_PART_OF' }, + { source: 'docs', target: 'spec', type: 'REFERENCES' }, + { source: 'wallet', target: 'spec', type: 'DEPENDS_ON' }, + { source: 'wallet', target: 'spec', type: 'RELATES_TO' }, + ], +}; + +/** The graphs a new user gets, in the order the example project lists them. */ +export const EXAMPLE_SHAPES: readonly LabelShape[] = [ + WELCOME_SHAPE, + PLAN_A_LAUNCH_SHAPE, + FIX_BUGS_SHAPE, + MOVE_HOUSE_SHAPE, +]; + +export const HARD_SHAPES: readonly LabelShape[] = [ + HUB_SHAPE, + TWO_HUBS_SHAPE, + CHAIN_SHAPE, + DENSE_SHAPE, + LONG_LABELS_SHAPE, +]; + +export const LABEL_SHAPES: readonly LabelShape[] = [...EXAMPLE_SHAPES, ...HARD_SHAPES]; From 68a9ae5b6af1d1b9cac032fbca103bd1f3ba3df3 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 10 Nov 2026 22:48:30 -0800 Subject: [PATCH 05/25] fix(graph): Organize, hierarchical and force layouts leave label room Organize and the hierarchical toggle lay out through labelClearance with the measured label of every drawn edge and the camera framing; the force link distance uses the measured label, and once the simulation settles clearFreeCards frees labels by moving only unpinned cards. A new layout forgets the remembered label spots so labels are placed afresh. The "Show Mini-Map" button is now an obstacle labels and fits keep clear of. Co-Authored-By: Claude Opus 5.5 --- .../InteractiveGraphVisualization.tsx | 136 +++++++++++++----- packages/web/src/pages/Workspace.tsx | 1 + 2 files changed, 99 insertions(+), 38 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 635f4945..31fe15c5 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -59,16 +59,16 @@ import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerg import type { ObstacleBox } from '../lib/edgeLabelLayout'; import { createParallelEdgeRoute, edgeRouteLabel, edgeRouteArrowBounds, edgeSegmentHitsBox, projectEdgeRoutePointer, type EdgeRouteGeometry, type EdgeRouteLabel } from '../lib/edgeRouteGeometry'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; -import { computeGraphFit } from '../lib/graphFit'; +import { computeGraphFit, type FitObstacle } from '../lib/graphFit'; import { appendNodeControlGlyph, nodeControlGlyphTransform } from '../lib/nodeControlIcons'; import { graphItemLayout, itemTitleScale, measureGraphText, edgeLabelDimensions, GRAPH_FONT_FAMILY, ITEM_TITLE_SIZE, ITEM_TITLE_LINE, ITEM_HEADER_HEIGHT, EDGE_LABEL_SIZE, ITEM_WIRE_WIDTH } from '../lib/graphSymbolLayout'; import { placeItemLabels } from '../lib/edgeLabelPlacement'; import { separateCards } from '../lib/separateCards'; -import { organizeTopology } from '../lib/topologyOrganization'; +import { clearFreeCards, framedZoom, hierarchyWithLabelRoom, labelGap, LABEL_MIN_ZOOM, organizeWithLabelRoom, SPREAD_MAX_CARDS, type LabelEdge, type LabelRoomOptions } from '../lib/labelClearance'; import { LocalItemNeighborhood } from './LocalItemNeighborhood'; import { frameOrganizedNeighborhood } from '../lib/organizationCamera'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; -import { computeCompactLayout, isHierarchicalLayout, parseLayoutMode, LAYOUT_MODE_STORAGE_KEY, type LayoutMode } from '../lib/layoutAlgorithms'; +import { isHierarchicalLayout, parseLayoutMode, LAYOUT_MODE_STORAGE_KEY, type LayoutMode } from '../lib/layoutAlgorithms'; import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; import { directionStrategy, arrowVisibility, perpendicularOffset } from '../lib/edgeLOD'; import { assignParallelEdgeIndices } from '../lib/parallelEdges'; @@ -111,6 +111,40 @@ const DOT_SCALE = 0.2; // title (kept at 14 px on screen) and status stripe. const PHONE_READABLE_SCALE = 0.5; +// The label size the automatic layouts leave room for: a drawn relationship's +// measured label ("+N" included once the renderer has set it), else none. +const drawnLabel = (edge: any) => edge._priorityVisible === false ? null + : edge._labelDims ?? edgeLabelDimensions(measureGraphText(getRelationshipConfig(edge.type as RelationshipType)?.label || '', EDGE_LABEL_SIZE)); + +// A new arrangement places labels afresh from the middle of their edges, where +// the layout left them room; the renderer otherwise starts from each label's +// last spot. A label a person dragged keeps its place. +const forgetLabelSpots = (edges: any[]) => { + for (const edge of edges) if (!edge.labelTUser) delete edge.labelT; +}; + +// Screen rectangles of the fixed graph controls and MiniMap, inside the graph +// area, that a camera fit keeps cards out of. +function graphFitObstacles(container: HTMLElement, minimumScale: number): FitObstacle[] { + const width = container.clientWidth; + const height = container.clientHeight; + const containerRect = container.getBoundingClientRect(); + const gap = 12; + return Array.from(document.querySelectorAll('[data-graph-fit-obstacle="true"]')) + // A passive save note lets taps through and disappears on its own; it + // must not reframe the camera under a person's first touch. + .filter(element => element.dataset.layoutSavePassive !== 'true' && !(minimumScale > 0 && element.dataset.layoutSaveProgress === 'true')) + .map(element => { + const rect = element.getBoundingClientRect(); + return { + left: Math.max(0, Math.min(width, rect.left - containerRect.left - gap)), + top: Math.max(0, Math.min(height, rect.top - containerRect.top - gap)), + right: Math.max(0, Math.min(width, rect.right - containerRect.left + gap)), + bottom: Math.max(0, Math.min(height, rect.bottom - containerRect.top + gap)), + }; + }); +} + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -1781,6 +1815,16 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } return layout; }, [getNodeDimensions]); + // Room for every relationship label in "Organize", the hierarchical rows and + // a settled force layout, framed as fitViewToNodes will frame it. + const labelRoom = useCallback((width: number, height: number, floor: number): LabelRoomOptions => ({ + dimensionsAt: getNodeDimensionsAt, + labelFor: drawnLabel, + priority: (edge: any) => relationshipPriorityTier(edge.type), + viewport: { width, height }, + obstacles: containerRef.current ? graphFitObstacles(containerRef.current, floor) : [], + floor, + }), [getNodeDimensionsAt]); // Smart data update function - only reinitializes when necessary, preserves camera position const updateVisualizationData = useCallback(() => { @@ -2081,7 +2125,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // (This block used to null every node's fx/fy unconditionally, which is // why arrangements never survived a reload — the real drift bug.) const initialOrganization = nodes.length && nodes.every(node => !(node.positionX || node.positionY)) - ? organizeTopology(nodes, validatedEdges as any[], node => graphItemLayout(node, measureGraphText), {width, height}) + ? organizeWithLabelRoom(nodes, validatedEdges as any[], labelRoom(width, height, 0)) : null; const spreadCols = Math.max(1, Math.ceil(Math.sqrt(nodes.length))); const spreadSpacing = 260; // > collision diameter so the spread has no overlaps @@ -2182,17 +2226,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const maxDistance = linkMaxDistance(width, height, phys); const preferred = currentDistance > maxDistance ? maxDistance : linkDistance(width, height, phys); // Floor: never pull connected nodes closer than their edge label - // needs to display — the label width sets a minimum edge length so - // it always fits in the border-to-border gap (edgeGeometry.minEdgeLength). - const label = getRelationshipConfig(d.type as RelationshipType)?.label || ''; - // Slightly generous estimate of the rendered label box (10px/600 text - // + icon + padding) so the gap never UNDER-shoots the real label. - const labelW = label.length * 7 + 34; + // needs to display — the border-to-border gap its measured label, + // clearance and arrowhead take at 100% (labelClearance.labelGap). // Pass the edge direction so the minimum is just the per-angle border - // reach + label + a small margin (not an oversized half-diagonal buffer). + // reach + that gap (not an oversized half-diagonal buffer). + const label = drawnLabel(d); const dx = (d.target.x || 0) - (d.source.x || 0); const dy = (d.target.y || 0) - (d.source.y || 0); - const minLen = minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelW, dx, dy); + const minLen = label ? minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelGap(label, 1), dx, dy, 0) : 0; d._minLen = minLen; // cached for the hard min-edge constraint below return Math.max(preferred, minLen); }) @@ -3654,11 +3695,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const center = d._labelIconCenter; label.select('.edge-label-icon').attr('transform', placement.iconRotation ? `rotate(${placement.iconRotation}, ${center.x}, ${center.y})` : null); - const angle = placement.rotation * Math.PI / 180; + // The turned label itself (edgeRouteLabel tests turned boxes exactly). labelRects.set(d.id, { x: placement.x, y: placement.y, - width: (Math.abs(Math.cos(angle)) * d._labelDims.width + Math.abs(Math.sin(angle)) * d._labelDims.height) * (d._labelScale || 1), - height: (Math.abs(Math.sin(angle)) * d._labelDims.width + Math.abs(Math.cos(angle)) * d._labelDims.height) * (d._labelScale || 1) + width: d._labelDims.width * (d._labelScale || 1), + height: d._labelDims.height * (d._labelScale || 1), + rotation: placement.rotation, }); }; let displayedPriority: typeof priorityProjection | null = null; @@ -4074,8 +4116,38 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // When the layout settles: record settle time, persist the arrangement so // it's durable across reloads, run a final edge-label de-overlap pass, and // center the camera. After this the simulation is idle (one-shot physics). + // A settled layout can leave cards the simulation placed itself too close + // for a label at the zoom the camera will frame them. Move only those free + // cards apart, never a saved or organized one (labelClearance.clearFreeCards). + // Larger graphs open as an overview without labels (SPREAD_MAX_CARDS). + const clearLabelsOfFreeCards = () => { + const simNodes = simulation.nodes() as any[]; + if (simNodes.length > SPREAD_MAX_CARDS || !simNodes.some(node => node.fx == null)) return false; + forgetLabelSpots(validatedEdges as any[]); + const byId = new Map(simNodes.map(node => [node.id, node])); + const room = labelRoom(width, height, 0); + const labelled: LabelEdge[] = (validatedEdges as any[]).flatMap(edge => { + const label = drawnLabel(edge); + return label && byId.has(edge.source?.id) && byId.has(edge.target?.id) + ? [{ id: edge.id, source: edge.source.id, target: edge.target.id, label, priority: relationshipPriorityTier(edge.type) }] + : []; + }); + let moved = false; + for (let pass = 0; pass < 2 && labelled.length; pass++) { + const positions = new Map(simNodes.map(node => [node.id, { x: node.x, y: node.y }])); + const zoom = Math.max(LABEL_MIN_ZOOM, framedZoom(simNodes, positions, room, 0)); + const cards = simNodes.map(node => ({ id: node.id, x: node.x, y: node.y, ...getNodeDimensionsAt(node, zoom) })); + const cleared = clearFreeCards(cards, labelled, { zoom, free: id => byId.get(id)?.fx == null }); + for (const [id, point] of cleared.moved) Object.assign(byId.get(id), { x: point.x, y: point.y, vx: 0, vy: 0 }); + moved ||= cleared.moved.size > 0; + if (!cleared.moved.size || !cleared.hidden.length) break; + } + return moved; + }; + simulation.on('end.persist', () => { if (simulationRef.current !== simulation) return; + if (clearLabelsOfFreeCards()) nodeElements.attr('transform', (d: any) => `translate(${d.x || 0},${d.y || 0})`); publishPerf(performance.now(), true); if (layoutStartRef.current != null && lastSettleMsRef.current == null) { lastSettleMsRef.current = Math.round(performance.now() - layoutStartRef.current); @@ -4235,21 +4307,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // the cards inside the largest rectangle that avoids every control and the // MiniMap, measuring each card with the geometry it will have at the fitted // scale (full at 100% and above, compact below) so repeated fits settle. - const containerRect = container.getBoundingClientRect(); - const gap = 12; - const obstacles = Array.from(document.querySelectorAll('[data-graph-fit-obstacle="true"]')) - // A passive save note lets taps through and disappears on its own; it - // must not reframe the camera under a person's first touch. - .filter(element => element.dataset.layoutSavePassive !== 'true' && !(minimumScale > 0 && element.dataset.layoutSaveProgress === 'true')) - .map(element => { - const rect = element.getBoundingClientRect(); - return { - left: Math.max(0, Math.min(width, rect.left - containerRect.left - gap)), - top: Math.max(0, Math.min(height, rect.top - containerRect.top - gap)), - right: Math.max(0, Math.min(width, rect.right - containerRect.left + gap)), - bottom: Math.max(0, Math.min(height, rect.bottom - containerRect.top + gap)), - }; - }); + const obstacles = graphFitObstacles(container, minimumScale); const cards = simNodes.filter((node: any) => typeof node.x === 'number' && typeof node.y === 'number'); const fit = computeGraphFit({ width, @@ -4536,7 +4594,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i layoutModeRef.current = 'force'; try { window.localStorage.setItem(LAYOUT_MODE_STORAGE_KEY, 'force'); } catch (error) { console.warn('Unable to save layout mode', error); } window.dispatchEvent(new CustomEvent('graphdone-layout-mode-change', { detail: 'force' })); - const arranged = organizeTopology(simulation.nodes() as WorkItem[], validatedEdges as any[], node => graphItemLayout(node, measureGraphText), {width:container.clientWidth,height:container.clientHeight}); + // Room for every relationship label at the zoom the organized graph is framed at. + const arranged = organizeWithLabelRoom(simulation.nodes() as WorkItem[], validatedEdges as any[], labelRoom(container.clientWidth, container.clientHeight, 0.65)); for (const node of simulation.nodes() as any[]) { const point = arranged.positions.get(node.id); if (!point) continue; @@ -4550,6 +4609,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i node.userPreferredPosition = null; node.userPreferenceVector = null; } + forgetLabelSpots(validatedEdges as any[]); (window as any).__graphOrganization = {components:arranged.components,work:arranged.work}; organizationZoomFloorRef.current = 0.65; organizationFocusRef.current = arranged.components[0]?.focus; @@ -4563,9 +4623,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i runLabelAvoidanceRef.current?.(); fitViewToNodes(); }); - }, [validatedEdges, fitViewToNodes, persistAllPositions]); + }, [validatedEdges, fitViewToNodes, persistAllPositions, labelRoom]); - // Hierarchical mode packs actual card sizes into compact dependency rows. + // Hierarchical mode packs actual card sizes into compact dependency rows, + // with room for every relationship label at the zoom they are framed at. // Pin both axes so repulsion cannot spread a finished row across the viewport. // Switching back restores both original pins, including saved-position pins. const layoutModeRef = useRef( @@ -4580,10 +4641,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (!sim || !container) return; const simNodes = sim.nodes() as any[]; if (isHierarchicalLayout(mode)) { - const positions = computeCompactLayout( - simNodes, validatedEdges as any[], - { width: container.clientWidth, height: container.clientHeight }, getNodeDimensions, - ); + const { positions } = hierarchyWithLabelRoom(simNodes, validatedEdges as any[], + labelRoom(container.clientWidth, container.clientHeight, organizationZoomFloorRef.current)); simNodes.forEach((node: any) => { const point = positions.get(node.id); if (!point) return; @@ -4603,9 +4662,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i delete node.__preLayerFy; }); } + forgetLabelSpots(validatedEdges as any[]); sim.alpha(DEFAULT_PHYSICS.alpha.loadEnergy).restart(); if (isHierarchicalLayout(mode)) requestAnimationFrame(() => fitViewToNodes()); - }, [validatedEdges, getNodeDimensions, fitViewToNodes]); + }, [validatedEdges, labelRoom, fitViewToNodes]); // Stable handle so the window hook + load effect don't churn when the edge // poll re-creates applyLayoutMode (which would reheat the sim every poll). const applyLayoutModeRef = useRef(applyLayoutMode); diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 30b08575..98d0fa00 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -484,6 +484,7 @@ export function Workspace() { {/* Mini-Map Toggle Button - Shows when mini-map is hidden (desktop only) */} {viewMode === 'graph' && currentGraph && !showMiniMap && !isMobile && createPortal(