diff --git a/.agents/inbox/2026-11-10-layouts-must-leave-label-room.md b/.agents/inbox/2026-11-10-layouts-must-leave-label-room.md new file mode 100644 index 00000000..26f23299 --- /dev/null +++ b/.agents/inbox/2026-11-10-layouts-must-leave-label-room.md @@ -0,0 +1,70 @@ +# Automatic layouts must leave room for every relationship label + +Observed 2026-11-10 after the owner reported that "Organize around connections" +and the force layout put cards so close that relationship labels disappear. The +renderer only draws a label where the whole label clears every card, arrowhead, +other wire and earlier label (`edgeRouteLabel`); otherwise it hides it. The +layouts kept cards 64px apart (organize, hierarchical rows) or guessed label +widths (force), so most labels had nowhere to go. Earlier lessons on clearance +(`2026-10-28-clean-layout-needs-clearance-and-topology.md`) and fitting +(`2026-11-07-fit-must-measure-presented-geometry.md`) were read first; this +concerns the room between connected cards. + +## Evidence + +- `tests/e2e/journeys/organize-labels.spec.ts` against the unchanged `dev` + source (3a629e2), desktop: Welcome organized 0 of 3 labels shown; Plan a + launch organized 7 of 8 in-view labels hidden; hierarchical hid 2-33 labels per + graph; a settled force layout from unplaced items hid 1-47 per graph (Dense + cluster of 30: all 47). iPad and iPhone failed the same way. No label was ever + drawn on a card or another label: the renderer hides instead, so the symptom + is missing labels, not overlap. +- `labelClearance.test.ts` places every label as the renderer does over whole + layouts of the same shapes on three screen sizes: the old organizer hid 2-13 + labels per shape at the zoom it frames them, the old hierarchical rows 2-47. +- Labels keep their screen size below 100% zoom, so in graph units they grow by + 1/zoom: "Organize" frames at no less than 65%, where a label needs half as + much room again as at 100%. + +## Cause and corrective rule + +- Every automatic layout reserves, for each drawn edge, a visible + (border-to-border) length of `labelGap(label, zoom)`: the measured label at the + zoom the camera will frame the result, its clearance at both ends and the + arrowhead. Lay out, compute the framing (`computeGraphFit` with the controls as + obstacles, and the floor), and lay out again when that zoom needs more room. +- Reserved gaps are not enough where wires cross labels: check the result by + placing labels exactly as the renderer does (`placeGraphLabels`) and spread + the group by the smallest factor that frees them (`spreadForLabels`). +- Check the layout the renderer will get, under any ids: real ids are random + and break ties in the organizer and the label order. Keep only spreads that + were checked, check the finished layout as a whole (`checkedWhole`), and + test several id orders (`label room whatever the item ids`). The renderer + starts each label from its spot in the previous frame; in a small graph, + when a label on screen found no spot (`labelsMissed`), it retries from fresh + spots, the placement the layouts leave room for. +- Never move a saved or organized (pinned) card to make room; after the force + layout settles, move only the cards it placed itself (`clearFreeCards`), only + for labels with such a card at one end, and keep a move only when it shows + more of them. A label between two saved cards once made a new card in a + saved graph fly 4 and then 16 times as far away; now nothing moves for it. +- Test readability with the renderer's own geometry: the browser audit reads + every label from the DOM (turned boxes, `elementFromPoint`) and an edge whose + cards are on screen and clear of the controls must show its label. + +## Limits + +The search runs in the click handler, so its work is bounded: at most +`WHOLE_FIT_MAX_CARDS` (60) cards, `LAYOUT_CHECKS` label checks and five +layouts per click (about 100 ms on a desktop; a unit test guards it). Larger +graphs keep the plain layouts and the 0.65 Organize floor (an early version +searched up to 200 cards and froze the page for seconds). A phone keeps the +0.65 Organize floor: two compact cards and a label side by side do not fit +390 px at a zoom where titles still fit whole (below about 0.45 a long word is +cut short), so a whole graph there needs compact label chips. The +hierarchical layout and the first open of a graph of unplaced items stay as +they were: label room in hierarchical rows that still wrap like dev's (a hub +with many dependents) is #283. A graph's saved +arrangement is never moved, so labels hidden there stay hidden until the person +organizes. Labels under a control or the MiniMap are a camera matter, recorded +by the audit but not counted. diff --git a/.agents/inbox/README.md b/.agents/inbox/README.md index 42048e1d..6cc03a2a 100644 --- a/.agents/inbox/README.md +++ b/.agents/inbox/README.md @@ -22,7 +22,7 @@ test name or file path. Lessons so far cover: | Topic | Example lessons | | --- | --- | -| Graph rendering, LOD and layout | `2026-10-29-high-degree-detail-needs-local-lod.md`, `2026-11-07-fit-must-measure-presented-geometry.md` | +| Graph rendering, LOD and layout | `2026-10-29-high-degree-detail-needs-local-lod.md`, `2026-11-07-fit-must-measure-presented-geometry.md`, `2026-11-10-layouts-must-leave-label-room.md` | | Interaction and focus | `2026-10-28-drag-mode-must-survive-modifier-release.md`, `2026-10-27-delayed-autofocus-must-preserve-chosen-focus.md` | | Auth and security | `2026-10-23-magic-link-consumption.md`, `2026-10-27-captcha-proof-must-be-server-issued.md` | | Releases, CI and dependencies | `2026-10-23-release-gates-must-fail-closed.md`, `2026-10-28-advisories-follow-installed-consumers.md` | diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index abab08e3..891dc2fc 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -4,6 +4,7 @@ | --- | --- | | A dense item shows a fan of essential wires while every neighbor is off screen, despite settled/no-overlap metrics | [Detailed LOD needs a readable local cut](../inbox/2026-10-29-high-degree-detail-needs-local-lod.md); EPIC2215 owned301 failure and local-neighborhood acceptance | | Overview has no intersections but wires hug cards/each other, or hubs are flattened into rows | [Measure clearance and organize by topology](../inbox/2026-10-28-clean-layout-needs-clearance-and-topology.md); topology/overview organization tests, real control/save journey and strict schematic clearance | +| Relationship labels disappear after "Organize around connections", in hierarchical mode or once the force layout settles | [Layouts must leave room for every label](../inbox/2026-11-10-layouts-must-leave-label-room.md); `labelClearance.test.ts`, `organize-labels` browser sequence | | Zoom to fit alternates compact/full cards on phones; focus returned after quick edit or expand lands on `` | [Fit the presented geometry; focus follows detail](../inbox/2026-11-07-fit-must-measure-presented-geometry.md); `graphFit.test.ts`, `fit-focus.spec.ts`, `agent-approval.spec.ts` | | Overview zoom exceeds five seconds while original item groups are hidden | [Hidden items must not delay overview](../inbox/2026-10-29-hidden-items-must-not-delay-overview.md); issue #235, retained actual-dev 2k failure | | Overview wires cannot be hovered, do not light endpoints, or lose focus when their route disappears | [Interaction geometry and original-edge inspection](../inbox/2026-10-28-overview-wires-need-interaction-geometry.md); `OverviewBusLayer`, semantic LOD and 2k bus inspection journeys | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce5e79eb..b8589ecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,6 +191,7 @@ jobs: semantic-lod ci-semantic-lod xvfb layout-recovery ci-layout-recovery xvfb example-resume ci-example-resume + organize-labels ci-organize-labels relationship-edit ci-relationship-edit services: neo4j: diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index a8d1bf53..5a9dbd63 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -60,12 +60,13 @@ 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 { placeItemLabels, labelsMissed } from '../lib/edgeLabelPlacement'; import { separateCards } from '../lib/separateCards'; import { organizeTopology } from '../lib/topologyOrganization'; +import { clearFreeCards, framedZoom, labelGap, LABEL_MIN_ZOOM, LAYOUT_CHECKS, organizeWithLabelRoom, ORGANIZED_ZOOM_FLOOR, SMALL_GRAPH_ZOOM_FLOOR, WHOLE_FIT_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'; @@ -88,7 +89,7 @@ import type { ZoomLevel } from '../lib/graphZoomContext'; const LOD_THRESHOLDS = { VERY_FAR: 0.3, // below: viewport-cull, basic shapes only FAR: 0.5, // below: hide type text + edit/grow/expand/descend icons - CLOSE: 0.6, // below: hide descriptions, edge labels, sub-graph counts + CLOSE: 0.6, // below: hide descriptions, sub-graph counts (edge labels follow card titles) }; // Above this node count a graph is "dense": the continuous living-graph effects @@ -114,6 +115,43 @@ 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. +/** Graphs with at most this many relationships retry a missing label from fresh spots. */ +const FRESH_LABEL_RETRY_MAX = 60; + +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; @@ -1799,6 +1837,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(() => { @@ -2201,17 +2249,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); }) @@ -3168,11 +3213,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Every connection label is a button: it opens the connection editor // (or, for a summarized pair, the list of its relationships). .attr('role', 'button') - .attr('tabindex', liveScale >= 0.6 ? 0 : -1) - .attr('aria-hidden', liveScale >= 0.6 ? null : 'true') + .attr('tabindex', liveScale >= LABEL_MIN_ZOOM ? 0 : -1) + .attr('aria-hidden', liveScale >= LABEL_MIN_ZOOM ? null : 'true') .attr('aria-label', (d: WorkItemEdge) => editEdgeLabel(d)) - .style('opacity', (d: any) => (liveScale >= 0.6 ? 1 : 0)) - .style('pointer-events', (d: any) => (liveScale >= 0.6 ? 1 : 0) > 0 ? 'all' : 'none') + .style('opacity', (d: any) => (liveScale >= LABEL_MIN_ZOOM ? 1 : 0)) + .style('pointer-events', (d: any) => (liveScale >= LABEL_MIN_ZOOM ? 1 : 0) > 0 ? 'all' : 'none') .style('cursor', 'pointer') .on('click', (event: MouseEvent, edge: WorkItemEdge) => { if (!openPriorityPair(event, edge)) openEdgeEditor(event, edge); @@ -3653,11 +3698,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; @@ -3738,14 +3784,25 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const drawnEdges = visibleEdges.filter((edge: any) => edgeDisplay(edge) !== 'none'); routeSegments = drawnEdges.flatMap((edge: any) => edge._route?.segments ?? []).filter(segment => edgeSegmentHitsBox(segment, labelViewport!, 8)); routeObstacles = drawnEdges.map((edge: any) => edgeRouteArrowBounds(edge._route, arrowVisibility(liveK, edge._pTotal || 1).scale)).filter(intersectsViewport); - const result = placeItemLabels(edgeLabelGroups.data().map((d: any) => { + const candidates = edgeLabelGroups.data().map((d: any) => { d._labelScale = Math.max(1, 1 / liveK); return { - id: d.id, route: d._route, eligible: liveK >= 0.6 && edgeDisplay(d) !== 'none', priority: relationshipPriorityTier(d.type), + id: d.id, route: d._route, eligible: liveK >= LABEL_MIN_ZOOM && edgeDisplay(d) !== 'none', priority: relationshipPriorityTier(d.type), dims: { width: d._labelDims.width * d._labelScale, height: d._labelDims.height * d._labelScale }, - t: d.labelT, offset: (d._routeLabel?.offset < 0 ? -1 : 1) * (26 * d._labelScale + 3), + t: d.labelT, offset: (d._routeLabel?.offset < 0 ? -1 : 1) * (26 * d._labelScale + 3), dragged: !!d.labelTUser, }; - }), { viewport: labelViewport, obstacles: nodeLabelObstacles.concat(uiLabelObstacles.filter(intersectsViewport), routeObstacles), segments: routeSegments }); + }); + const placeOptions = { viewport: labelViewport, obstacles: nodeLabelObstacles.concat(uiLabelObstacles.filter(intersectsViewport), routeObstacles), segments: routeSegments }; + let result = placeItemLabels(candidates, placeOptions); + // Spots remembered from earlier frames keep labels steady while cards + // move, but they can box out a label that a fresh placement shows (the + // layouts leave room for a fresh one, see labelClearance): in a small + // graph, when a label on screen found no spot (labelsMissed), try fresh + // spots and keep them if they show more. + if (candidates.length <= FRESH_LABEL_RETRY_MAX && labelsMissed(result)) { + const fresh = placeItemLabels(candidates.map(c => c.dragged ? c : { ...c, t: 0.5, offset: Math.abs(c.offset) }), placeOptions); + if (fresh.placements.size > result.placements.size) result = fresh; + } edgeLabelGroups.each(function(d: any) { const placement = result.placements.get(d.id) ?? null; d._labelBlocked = !placement; @@ -3954,7 +4011,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }); // Update zoom with LOD updates - let labelsShownAtScale = liveScale >= 0.6; + // Relationship labels show while card titles do (they keep their screen size). + let labelsShownAtScale = liveScale >= LABEL_MIN_ZOOM; const updateZoomPresentation = (scale: number) => { if (itemPresentation.isOverview()) return; // Smooth opacity transitions based on zoom level @@ -3976,8 +4034,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i activeNodes.selectAll('.node-description-text' as any) .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE)); // Edge labels + arrows follow the edge LOD module (zoom-aware, crowd-aware). - if (labelsShownAtScale !== (scale >= 0.6)) { - labelsShownAtScale = scale >= 0.6; + if (labelsShownAtScale !== (scale >= LABEL_MIN_ZOOM)) { + labelsShownAtScale = scale >= LABEL_MIN_ZOOM; edgeLabelGroups.style('opacity', labelsShownAtScale ? 1 : 0).style('pointer-events', labelsShownAtScale ? 'all' : 'none') .attr('tabindex', labelsShownAtScale ? 0 : -1).attr('aria-hidden', labelsShownAtScale ? null : 'true'); } @@ -4077,8 +4135,41 @@ 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 keep the settled layout (WHOLE_FIT_MAX_CARDS), and the + // work is bounded like a click's layout (LAYOUT_CHECKS): this runs on the + // main thread when the simulation ends. + const clearLabelsOfFreeCards = () => { + const simNodes = simulation.nodes() as any[]; + if (simNodes.length > WHOLE_FIT_MAX_CARDS || !simNodes.some(node => node.fx == null)) return false; + const work = { remaining: LAYOUT_CHECKS }; + 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, work }); + 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); @@ -4212,9 +4303,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const altDragSourceRef = useRef(null); // Alt-drag: item being dragged from const organizationZoomFloorRef = useRef(0); const organizationFocusRef = useRef(); + // Organize on a graph of at most WHOLE_FIT_MAX_CARDS: frame the whole graph + // down to the zoom where card titles and relationship labels still show (no + // phone floor). When even that is too small, stay centred if the graph + // nearly fits and frame the busiest card's neighbourhood otherwise. + const wholeGraphFitRef = useRef(false); useEffect(() => { organizationZoomFloorRef.current = 0; organizationFocusRef.current = undefined; + wholeGraphFitRef.current = false; }, [layoutScope]); // Fit view to show all nodes. Returns true if a fit transform was applied, @@ -4238,21 +4335,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, @@ -4268,9 +4351,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // most important item; the rest is a pan away. // (Graphs of 40+ items open as a group overview, and a focused item has // its own camera; both zoom out on purpose and are left alone.) - const phoneFloor = window.matchMedia(COMPACT_WORKSPACE_QUERY).matches && cards.length < 40 && !localItemActiveRef.current + const phoneFloor = window.matchMedia(COMPACT_WORKSPACE_QUERY).matches && cards.length < 40 && !localItemActiveRef.current && !wholeGraphFitRef.current && PHONE_READABLE_SCALE > Math.max(scale, minimumScale); if (phoneFloor) minimumScale = PHONE_READABLE_SCALE; + // A small graph that nearly fits stays centred whole rather than on its hub. + const nearlyWhole = wholeGraphFitRef.current && scale >= 0.8 * minimumScale; if (minimumScale > scale) { // A freshly organized graph keeps a readable floor instead of shrinking // every card to fit: stay centred on the same cards at that scale. @@ -4282,7 +4367,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } const focus = (organizationFocusRef.current && cards.find(node => node.id === organizationFocusRef.current)) || (phoneFloor ? [...cards].sort((a, b) => ((b as WorkItem).priority ?? 0) - ((a as WorkItem).priority ?? 0))[0] : undefined); - if (minimumScale > 0 && focus) { + if (minimumScale > 0 && focus && !nearlyWhole) { const at = frameOrganizedNeighborhood( cards.map(node => ({ id: node.id, x: node.x, y: node.y, ...getNodeDimensionsAt(node, scale) })), focus.id, @@ -4539,7 +4624,15 @@ 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. + // A small graph is framed whole while its titles and labels still read; a + // large one keeps the organized floor. + // A phone keeps the organized floor and frames the busiest part: a 390 px + // screen cannot hold two compact cards and a label side by side at a zoom + // where their titles still fit whole (compact label chips: #283). + const whole = simulation.nodes().length <= WHOLE_FIT_MAX_CARDS && !window.matchMedia(COMPACT_WORKSPACE_QUERY).matches; + const floor = whole ? SMALL_GRAPH_ZOOM_FLOOR : ORGANIZED_ZOOM_FLOOR; + const arranged = organizeWithLabelRoom(simulation.nodes() as WorkItem[], validatedEdges as any[], labelRoom(container.clientWidth, container.clientHeight, floor)); for (const node of simulation.nodes() as any[]) { const point = arranged.positions.get(node.id); if (!point) continue; @@ -4553,8 +4646,10 @@ 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; + organizationZoomFloorRef.current = floor; + wholeGraphFitRef.current = whole; organizationFocusRef.current = arranged.components[0]?.focus; cameraManuallyPositionedRef.current = true; if (currentGraphIdRef.current) fittedGraphsRef.current.add(currentGraphIdRef.current); @@ -4566,7 +4661,7 @@ 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. // Pin both axes so repulsion cannot spread a finished row across the viewport. @@ -4988,6 +5083,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i useEffect(() => { (window as any).triggerZoomToFit = () => { organizationZoomFloorRef.current = 0; + wholeGraphFitRef.current = false; return fitViewRef.current(); }; return () => { diff --git a/packages/web/src/lib/README.md b/packages/web/src/lib/README.md index 0a5affc1..33006dca 100644 --- a/packages/web/src/lib/README.md +++ b/packages/web/src/lib/README.md @@ -24,7 +24,7 @@ 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`, `cardTitleZoom.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 after "organize" and once the force layout settles (and the graph shapes its unit and browser tests share), the zoom below which card titles and labels stop showing, merging fresh data into the live simulation without losing positions, hover neighbourhoods. | | Level of detail | [`graphZoomContext.ts`](graphZoomContext.ts), [`semanticLOD.ts`](semanticLOD.ts), `semanticRelationships.ts`, `semanticTrace.ts`, `overviewOrganization.ts`, `overviewBusHint.ts`, `overviewBusHit.ts`, `localItemLOD.ts`, `localItemLayout.ts`, `subgraphPreview.ts` | Display-only projections: the zoom-out levels for every graph (its labelled frame, the graphs beside it, their projects; #277), and for large graphs the 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), [`relationshipEdit.ts`](relationshipEdit.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, the relationship editor's saved-state reading and safe flip (reversed copy first, original removed after, rolled back on failure), connection lookups. | | Starter content | [`starterWorkspace.ts`](starterWorkspace.ts), [`workspaceRules.ts`](workspaceRules.ts) | The example workspace a new user gets, and "Add example project": projects Getting started (Welcome, Plan a launch, Fix bugs, Ideas) and Home (Move house), each item at a fixed position; or the single Welcome graph when the backend cannot keep a hierarchy. `createStarterWorkspace` writes it through any backend: Welcome and its project first, opened at once, then its items and connections, then the other projects, graphs, items and connections, one small write at a time, each waiting while the user's own view is loading, so the database plans each shape once and the user's own reads never wait behind it. On the self-hosted server each unfinished part carries the `example:pending` tag until its items, connections and counts are written, so a sign-in in any browser finishes what a closed one left (`awaitExampleIdle` first waits until nobody else is writing it). There every item the example writes is tagged `example:item`. A resume reads again before each write and then removes duplicate example items (with every connection to or from them) and duplicate example connections, so two browsers finishing the same part leave each once, while the user's own items are never touched. Clearing the tag keeps the part's other tags, a pending graph the example no longer matches (renamed, or its project deleted) has its tag cleared, and `examplePendingParts` gives up on a part after 3 failed resumes. `workspaceRules.ts` holds the decisions as pure functions: who gets which starter, the 100-graph check, what is still missing, the graph list as a tree (a graph whose project is gone listed at the top), when the example may open Welcome (never over a graph the user picked), when a project cannot be deleted, and which graphs only the system may change (a Welcome graph the user made is theirs). | diff --git a/packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts b/packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts index 96854274..baac3022 100644 --- a/packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts +++ b/packages/web/src/lib/__tests__/edgeLabelPlacement.test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { createEdgeRoute } from '../edgeRouteGeometry'; import { + labelsMissed, placeItemLabels, ITEM_LABEL_BUDGET, type ItemLabelCandidate, @@ -103,3 +104,17 @@ it('places labels along the visible slice of a long route without changing its i expect(result.attempts).toBe(1); expect(result.checks).toBeLessThan(ITEM_LABEL_BUDGET.checks); }); + +// The renderer places labels again from fresh spots only when that could show +// more: not for labels off screen, not drawn or past the label cap. +it('a label is missed only when one on screen was tried and found no spot', () => { + const options = { viewport, obstacles: [], segments: route.segments }; + const offScreen = { ...candidate('b-off'), route: createEdgeRoute([{ x: 10000, y: 0 }, { x: 11000, y: 0 }])! }; + const notDrawn = { ...candidate('c-hidden'), eligible: false }; + expect(labelsMissed(placeItemLabels([candidate('a'), offScreen, notDrawn], options))).toBe(false); + const below = { ...candidate('d-below'), route: createEdgeRoute([{ x: 0, y: 200 }, { x: 300, y: 200 }])! }; + const capped = { labels: 1, attempts: 128, checks: 200000 }; + expect(labelsMissed(placeItemLabels([candidate('a'), below], options, capped), capped)).toBe(false); + const blocked = placeItemLabels([candidate('a')], { ...options, obstacles: [{ x: 150, y: 0, width: 1000, height: 1000 }] }); + expect(labelsMissed(blocked)).toBe(true); +}); 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/__tests__/graphSymbolLayout.test.ts b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts index 87806e4a..e75509bc 100644 --- a/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts +++ b/packages/web/src/lib/__tests__/graphSymbolLayout.test.ts @@ -31,6 +31,68 @@ describe('graph symbol content geometry', () => { } }); + it('breaks titles only at spaces and ends a word too wide for a line in "…"', () => { + // Every line is whole words of the title; only a line ending in "…" may + // end in part of a word (its start), and no line starts inside a word. + const broken = (title: string, lines: string[]) => { + const words = title.split(' '); + return lines.filter(line => { + const clipped = line.endsWith('…'); + const tokens = (clipped ? line.slice(0, -1) : line).split(' ').filter(Boolean); + return !tokens.every((token, index) => + clipped && index === tokens.length - 1 + ? words.some(word => word.startsWith(token)) + : words.includes(token) + ); + }); + }; + const titles = [ + 'System coordinator', + 'Design mockups', + 'Set up analytics', + 'Email campaign', + 'coordinator of the subsystems', + 'Review the gateway settlement reconciliation and retry policy', + 'Coordinator, then subsystems.', + ]; + for (const title of titles) + for (let width = 40; width <= 320; width += 10) { + const result = fitGraphText(title, width, ITEM_TITLE_SIZE, measure); + expect(broken(title, result.lines), `${title} at ${width}`).toEqual([]); + for (const line of result.lines) + if (line !== '…') expect(measure(line, ITEM_TITLE_SIZE)).toBeLessThanOrEqual(width); + if (result.truncated) expect(result.lines.some(line => line.endsWith('…'))).toBe(true); + } + // A word too wide for the line is cut where it is, never carried over. + expect(fitGraphText('System coordinator', 110, ITEM_TITLE_SIZE, measure).lines).toEqual(['System', 'coordinat…']); + }); + + it('shows a path, URL or identifier wider than the line in full while lines are left, "…" only when they run out', () => { + const fit = (title: string, width: number) => fitGraphText(title, width, ITEM_TITLE_SIZE, measure); + expect(fit('Update packages/web/src/lib/labelClearance.ts', 240)).toEqual({ + lines: ['Update', 'packages/web/src/lib/', 'labelClearance.ts'], truncated: false, + }); + expect(fit('PaymentGatewayRetryReconciliationHandler', 240)).toEqual({ + lines: ['PaymentGatewayRetry', 'ReconciliationHandler'], truncated: false, + }); + expect(fit('See https://example.com/docs/graph?view=labels', 240)).toEqual({ + lines: ['See', 'https://example.com/', 'docs/graph?view=labels'], truncated: false, + }); + // Out of lines: the last one ends in "…". + const long = fit('packages/web/src/lib/__tests__/graphSymbolLayout.test.ts', 120); + expect(long.lines).toHaveLength(3); + expect(long.truncated).toBe(true); + expect(long.lines[2]).toMatch(/…$/); + for (const line of long.lines) expect(measure(line, ITEM_TITLE_SIZE)).toBeLessThanOrEqual(120); + // Shown in full: the lines put back together are the title. + for (const title of ['src/lib/labelClearance.ts', 'PaymentGatewayRetryReconciliationHandler', 'https://example.com/a/b']) + for (let width = 60; width <= 320; width += 10) { + const result = fit(title, width); + if (!result.truncated) expect(result.lines.join(''), `${title} at ${width}`).toBe(title); + else expect(result.lines, `${title} at ${width}`).toHaveLength(3); + } + }); + it('makes short cards compact and reserves title, description and metadata rows', () => { const short = graphItemLayout({ title: 'Review', type: 'TASK' }, measure); const long = graphItemLayout( diff --git a/packages/web/src/lib/cardTitleZoom.ts b/packages/web/src/lib/cardTitleZoom.ts new file mode 100644 index 00000000..ffb49aea --- /dev/null +++ b/packages/web/src/lib/cardTitleZoom.ts @@ -0,0 +1,11 @@ +/** + * The zoom below which card titles stop being readable. Relationship labels + * follow the same cutoff: they show while card titles show, so zooming out + * never leaves labels floating over unreadable cards (#277). + * + * Shared by the zoom-out levels (graphZoomContext) and the label layout + * (labelClearance); change it in one place. + */ +export const CARD_TITLES_HIDE_BELOW = 0.34; +/** Titles come back above this zoom (a little higher, so they never flicker). */ +export const CARD_TITLES_SHOW_ABOVE = 0.38; 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..8fda51da 100644 --- a/packages/web/src/lib/edgeLabelPlacement.ts +++ b/packages/web/src/lib/edgeLabelPlacement.ts @@ -23,24 +23,27 @@ export const ITEM_LABEL_BUDGET = { 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,29 @@ 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, }; } + +/** + * Whether placing the same labels again from other spots could show more: a + * label on screen was tried and found no spot, and the label cap leaves room + * for it. Labels off screen, not drawn, or past the cap are never tried. + */ +export function labelsMissed( + result: { placements: ReadonlyMap; attempts: number }, + limits: ItemLabelBudget = ITEM_LABEL_BUDGET +) { + return result.placements.size < Math.min(result.attempts, limits.labels); +} diff --git a/packages/web/src/lib/edgeRouteGeometry.ts b/packages/web/src/lib/edgeRouteGeometry.ts index 268b73c4..b280ead9 100644 --- a/packages/web/src/lib/edgeRouteGeometry.ts +++ b/packages/web/src/lib/edgeRouteGeometry.ts @@ -236,6 +236,81 @@ 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 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; + // Far apart: the circles around the two boxes do not meet. + const reachAll = Math.hypot(aw, ah) + Math.hypot(bw, bh); + if (dx * dx + dy * dy >= reachAll * reachAll) return false; + const ra = ((a.rotation ?? 0) * Math.PI) / 180, + rb = ((b.rotation ?? 0) * Math.PI) / 180; + const aux = Math.cos(ra), + auy = Math.sin(ra), + bux = Math.cos(rb), + buy = Math.sin(rb); + // Axes: each box's own two (u, and v = u turned a quarter). + return !( + separatedAlong(aux, auy, dx, dy, aw, ah, aux, auy, bw, bh, bux, buy) || + separatedAlong(-auy, aux, dx, dy, aw, ah, aux, auy, bw, bh, bux, buy) || + separatedAlong(bux, buy, dx, dy, aw, ah, aux, auy, bw, bh, bux, buy) || + separatedAlong(-buy, bux, dx, dy, aw, ah, aux, auy, bw, bh, bux, buy) + ); +} + +/** Whether axis (nx, ny) separates two turned boxes (half sizes and u axes). */ +function separatedAlong( + nx: number, + ny: number, + dx: number, + dy: number, + aw: number, + ah: number, + aux: number, + auy: number, + bw: number, + bh: number, + bux: number, + buy: number +) { + const reach = + Math.abs(aw * (aux * nx + auy * ny)) + + Math.abs(ah * (-auy * nx + aux * ny)) + + Math.abs(bw * (bux * nx + buy * ny)) + + Math.abs(bh * (-buy * nx + bux * ny)); + return Math.abs(dx * nx + dy * ny) >= reach - 1e-9; +} + +/** Half width of the axis-aligned bounds of a box turned by `rotation` degrees, grown by `pad`. */ +function reachX(width: number, height: number, rotation: number | undefined, pad: number) { + if (!rotation) return width / 2 + pad; + const r = (rotation * Math.PI) / 180; + return Math.abs(Math.cos(r)) * (width / 2 + pad) + Math.abs(Math.sin(r)) * (height / 2 + pad); +} + +/** Half height of those bounds. */ +function reachY(width: number, height: number, rotation: number | undefined, pad: number) { + if (!rotation) return height / 2 + pad; + const r = (rotation * Math.PI) / 180; + return Math.abs(Math.sin(r)) * (width / 2 + pad) + Math.abs(Math.cos(r)) * (height / 2 + pad); +} + +/** 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 +336,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 +389,162 @@ 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); + // A turned box lies inside its axis-aligned bounds (reachX, reachY), so + // whatever is clear of the bounds is clear of the box; for two boxes square + // to the axes the bounds are exact. Only obstacles and wires that reach some + // candidate are checked for each one (padding grows the obstacle against a + // label and the label against a wire, each in its own turned frame). Each + // one looked at here counts against the budget like a check. + let left = Infinity, + right = -Infinity, + top = Infinity, + bottom = -Infinity; + for (const { x, y, rotation } of candidates) { + const rx = reachX(dims.width, dims.height, rotation, padding), + ry = reachY(dims.width, dims.height, rotation, padding); + left = Math.min(left, x - rx); + right = Math.max(right, x + rx); + top = Math.min(top, y - ry); + bottom = Math.max(bottom, y + ry); + } + const nearObstacles: ObstacleBox[] = [], + nearX: number[] = [], + nearY: number[] = []; + for (const box of candidates.length ? obstacles : []) { + if (options.budget) { + if (options.budget.remaining <= 0) return null; + options.budget.remaining--; + } + const rx = reachX(box.width, box.height, box.rotation, padding), + ry = reachY(box.width, box.height, box.rotation, padding); + if (box.x + rx > left && box.x - rx < right && box.y + ry > top && box.y - ry < bottom) { + nearObstacles.push(box); + nearX.push(rx); + nearY.push(ry); + } + } + const nearSegments: Pick[] = []; + for (const part of candidates.length ? options.segments ?? [] : []) { + if (options.budget) { + if (options.budget.remaining <= 0) return null; + options.budget.remaining--; + } + if ( + Math.max(part.source.x, part.target.x) >= left && + Math.min(part.source.x, part.target.x) <= right && + Math.max(part.source.y, part.target.y) >= top && + Math.min(part.source.y, part.target.y) <= bottom + ) + nearSegments.push(part); + } + 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 }; + const rx = reachX(dims.width, dims.height, rotation, padding), + ry = reachY(dims.width, dims.height, rotation, padding); + const square = rotation % 90 === 0; + let blocked = false; + for (let index = 0; index < nearObstacles.length; index++) { + const box = nearObstacles[index]; + if (options.budget) { + if (options.budget.remaining <= 0) return null; + options.budget.remaining--; + } + if ( + Math.abs(box.x - x) >= width / 2 + nearX[index] - 1e-9 || + Math.abs(box.y - y) >= height / 2 + nearY[index] - 1e-9 + ) + continue; + if ( + (square && (!box.rotation || box.rotation % 90 === 0)) || + orientedBoxesOverlap(label, box, padding) + ) { + blocked = true; + break; + } + } + if (blocked) continue; + for (const part of nearSegments) { + if (options.budget) { + if (options.budget.remaining <= 0) return null; + options.budget.remaining--; + } + if ( + Math.min(part.source.x, part.target.x) > x + rx + 1e-9 || + Math.max(part.source.x, part.target.x) < x - rx - 1e-9 || + Math.min(part.source.y, part.target.y) > y + ry + 1e-9 || + Math.max(part.source.y, part.target.y) < y - ry - 1e-9 + ) + continue; + 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; } diff --git a/packages/web/src/lib/graphSymbolLayout.ts b/packages/web/src/lib/graphSymbolLayout.ts index efd6e431..592ac17b 100644 --- a/packages/web/src/lib/graphSymbolLayout.ts +++ b/packages/web/src/lib/graphSymbolLayout.ts @@ -12,6 +12,26 @@ export type TextMeasure = ( weight?: number ) => number; +/** Scripts written without spaces between words: a line may break between any two characters there. */ +const UNSPACED_SCRIPT = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/; +/** + * A path, URL, identifier or number rather than a word: inside its leading and + * trailing punctuation it has something other than letters, or a capital after + * a small letter (camelCase). + */ +const isToken = (word: string) => /[^\p{L}'’]|\p{Ll}\p{Lu}/u.test(word.replace(/^\p{P}+|\p{P}+$/gu, '')); +/** Where a token breaks best: after a separator, or before a capital in camelCase. */ +const tokenBreak = (chars: string[], at: number) => + /[\/\\._:?=&#,;+~|@-]/.test(chars[at - 1]) || (/[\p{Ll}\p{N}]/u.test(chars[at - 1]) && /\p{Lu}/u.test(chars[at])); + +/** + * `text` in at most `maxLines` lines no wider than `width`, broken at spaces. + * A word wider than a line is never split across lines: it is cut and ends in + * "…". A path, URL or identifier wider than a line breaks inside instead + * (after a separator or before a capital where it can) and shows in full while + * lines are left, as do scripts written without spaces. When more text + * follows the last line, that line ends in "…". + */ export function fitGraphText( text: string, width: number, @@ -19,41 +39,72 @@ export function fitGraphText( measure: TextMeasure, maxLines = 3 ) { - let remaining = text.replace(/\s+/g, ' ').trim(); - const lines: string[] = []; - while (remaining && lines.length < maxLines) { - if (measure(remaining, size, 600) <= width) { - lines.push(remaining); - remaining = ''; - break; - } - const last = lines.length === maxLines - 1; - const chars = Array.from(remaining); + const fits = (line: string) => measure(line, size, 600) <= width; + /** How many of `chars` fit on a line followed by `suffix`. */ + const longest = (chars: string[], suffix: string) => { let low = 0, high = chars.length; while (low < high) { const middle = Math.ceil((low + high) / 2); - if ( - measure( - chars.slice(0, middle).join('') + (last ? '…' : ''), - size, - 600 - ) <= width - ) - low = middle; + if (fits(chars.slice(0, middle).join('') + suffix)) low = middle; else high = middle - 1; } - if (!low) return { lines: [...lines, '…'], truncated: true }; - let take = low; - const prefix = chars.slice(0, low).join(''); - const space = prefix.lastIndexOf(' '); - if (!last && space > 0) take = Array.from(prefix.slice(0, space)).length; - lines.push(chars.slice(0, take).join('').trimEnd() + (last ? '…' : '')); - remaining = chars.slice(take).join('').trimStart(); + return low; + }; + const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean); + const lines: string[] = []; + let next = 0, + truncated = false; + while (next < words.length && lines.length < maxLines) { + const last = lines.length === maxLines - 1; + let end = next; + while (end < words.length && fits(words.slice(next, end + 1).join(' '))) + end++; + if (end === words.length) { + lines.push(words.slice(next).join(' ')); + next = end; + break; + } + if (last && end > next) { + // More follows: as many whole words as fit with the "…". + let keep = end - next; + while (keep > 0 && !fits(`${words.slice(next, next + keep).join(' ')}…`)) + keep--; + if (keep > 0) { + lines.push(`${words.slice(next, next + keep).join(' ')}…`); + truncated = true; + break; + } + } else if (end > next) { + lines.push(words.slice(next, end).join(' ')); + next = end; + continue; + } + // The next word alone is wider than the line (or the last line has room + // for none of the words and the "…"). + const chars = Array.from(words[next]); + if (!last && (UNSPACED_SCRIPT.test(words[next]) || isToken(words[next]))) { + let take = longest(chars, ''); + if (!take) return { lines: [...lines, '…'], truncated: true }; + for (let at = take; at > 0; at--) + if (tokenBreak(chars, at)) { + take = at; + break; + } + lines.push(chars.slice(0, take).join('')); + words[next] = chars.slice(take).join(''); + continue; + } + const take = longest(chars, '…'); + if (!take) return { lines: [...lines, '…'], truncated: true }; + lines.push(chars.slice(0, take).join('') + '…'); + truncated = true; + next++; + if (last) break; } return { lines: lines.length ? lines : ['Untitled'], - truncated: remaining.length > 0, + truncated: truncated || next < words.length, }; } diff --git a/packages/web/src/lib/graphZoomContext.ts b/packages/web/src/lib/graphZoomContext.ts index 06eed0e7..9c878970 100644 --- a/packages/web/src/lib/graphZoomContext.ts +++ b/packages/web/src/lib/graphZoomContext.ts @@ -12,6 +12,8 @@ * as item positions): nothing enters the simulation or the position-save queue. */ +import { CARD_TITLES_HIDE_BELOW, CARD_TITLES_SHOW_ABOVE } from './cardTitleZoom'; + export interface ZoomGraph { id: string; name: string; @@ -221,10 +223,10 @@ export interface ZoomLevelState { export const INITIAL_ZOOM_STATE: ZoomLevelState = { cardsHidden: false, siblings: false, projects: false }; -/** Below this scale compact card titles are fragments (they hide entirely below - * 0.3), so the graph's own label takes over. */ -export const CARD_TITLES_HIDE_BELOW = 0.34; -export const CARD_TITLES_SHOW_ABOVE = 0.38; +/** Below CARD_TITLES_HIDE_BELOW compact card titles are fragments (they hide + * entirely below 0.3), so the graph's own label takes over. The relationship + * labels share the cutoff, so both live in cardTitleZoom. */ +export { CARD_TITLES_HIDE_BELOW, CARD_TITLES_SHOW_ABOVE }; export interface ZoomLevelInput { scale: number; diff --git a/packages/web/src/lib/labelClearance.test.ts b/packages/web/src/lib/labelClearance.test.ts new file mode 100644 index 00000000..f5cca19d --- /dev/null +++ b/packages/web/src/lib/labelClearance.test.ts @@ -0,0 +1,384 @@ +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, + labelGap, + labelReport, + framedZoom, + LABEL_MIN_ZOOM, + SMALL_GRAPH_ZOOM_FLOOR, + WHOLE_FIT_MAX_CARDS, + 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); + }); +}); + +// What the app does for a graph of at most WHOLE_FIT_MAX_CARDS: framed whole +// down to SMALL_GRAPH_ZOOM_FLOOR, every label drawn at the zoom it is framed at. +const SMALL = LABEL_SHAPES.filter(shape => shape.items.length <= WHOLE_FIT_MAX_CARDS && ['Welcome', 'Launch checklist'].includes(shape.name)); +const smallCases = SMALL.flatMap(shape => (Object.keys(SCREENS) as Screen[]).map(screen => [shape.name, screen, shape] as const)); + +describe('the whole small graph on screen', () => { + it.each(smallCases)('%s on %s: organized, framed at a zoom that draws every label', (_name, screen, shape) => { + const g = graph(shape); + const result = organizeWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS[screen], floor: SMALL_GRAPH_ZOOM_FLOOR }); + expect(result.zoom).toBeGreaterThanOrEqual(LABEL_MIN_ZOOM); + expect(problems(g, result.positions, result.zoom)).toEqual(NONE); + expect(cardsTouching(g, result.positions)).toEqual([]); + }); + + it('the Welcome graph is framed whole on desktop and iPad', () => { + const g = graph(LABEL_SHAPES.find(shape => shape.name === 'Welcome')!); + for (const screen of ['desktop', 'iPad'] as const) { + const options = { ...g, viewport: SCREENS[screen], floor: SMALL_GRAPH_ZOOM_FLOOR }; + const result = organizeWithLabelRoom(g.items, g.edges, options); + expect(framedZoom(g.items, result.positions, options, 0)).toBeGreaterThanOrEqual(SMALL_GRAPH_ZOOM_FLOOR); + } + }); + + it('turns a tall organized layout to a wide screen (Launch checklist on desktop)', () => { + const g = graph(LABEL_SHAPES.find(shape => shape.name === 'Launch checklist')!); + const options = { ...g, viewport: SCREENS.desktop, floor: SMALL_GRAPH_ZOOM_FLOOR }; + const xs = [...organizeWithLabelRoom(g.items, g.edges, options).positions.values()].map(p => p.x); + const ys = [...organizeWithLabelRoom(g.items, g.edges, options).positions.values()].map(p => p.y); + expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThan(Math.max(...ys) - Math.min(...ys)); + }); +}); + +/** A generated graph: `n` tasks, `m` relationships mostly between near neighbours. */ +function generated(n: number, m: number, seed = 7): LabelShape { + let state = seed; + const random = () => (state = (state * 16807) % 2147483647) / 2147483647; + const types = ['DEPENDS_ON', 'BLOCKS', 'RELATES_TO', 'ENABLES']; + const edges: LabelShape['edges'] = []; + const seen = new Set(); + while (edges.length < m) { + const a = Math.floor(random() * n), + b = Math.min(n - 1, Math.max(0, a + Math.floor((random() - 0.5) * 12))); + const key = `${Math.min(a, b)}-${Math.max(a, b)}`; + if (a === b || seen.has(key)) continue; + seen.add(key); + edges.push({ source: `t${a}`, target: `t${b}`, type: types[edges.length % types.length] }); + } + return { + name: `Generated ${n}`, + items: Array.from({ length: n }, (_, i) => ({ key: `t${i}`, title: `Task ${i + 1} of the plan`, type: 'TASK', status: 'NOT_STARTED' })), + edges, + }; +} + +describe('bounded work', () => { + it('a hub with four spokes on a phone is framed at the closest zoom that shows every label, not at the lowest', () => { + const shape: LabelShape = { + name: 'Hub', + items: ['System coordinator', 'Subsystem 1', 'Subsystem 2', 'Subsystem 3', 'Subsystem 4'].map((title, i) => ({ key: `h${i}`, title, type: 'TASK', status: 'NOT_STARTED' })), + edges: [1, 2, 3, 4].map(i => ({ source: 'h0', target: `h${i}`, type: 'BLOCKS' })), + }; + const g = graph(shape); + const result = organizeWithLabelRoom(g.items, g.edges, { ...g, viewport: SCREENS.iPhone, floor: SMALL_GRAPH_ZOOM_FLOOR }); + // The widest layout (room for labels at the lowest zoom) also fits; the + // search must go on to the closest zoom instead of settling for it. + expect(result.labelZoom).toBeGreaterThan(LABEL_MIN_ZOOM + 0.03); + expect(result.zoom).toBeGreaterThanOrEqual(result.labelZoom); + expect(problems(g, result.positions, result.zoom)).toEqual(NONE); + }); + + // Organize runs in the click handler: a whole layout of WHOLE_FIT_MAX_CARDS + // cards takes about 100 ms on a desktop. The bound has room for a slow test + // runner, not for the seconds it once took. + it('Organize of 60 cards and 90 relationships finishes within a second', () => { + const g = graph(generated(WHOLE_FIT_MAX_CARDS, 90)); + const options = { ...g, viewport: SCREENS.desktop, floor: SMALL_GRAPH_ZOOM_FLOOR }; + const run = () => organizeWithLabelRoom(g.items, g.edges, options); + run(); + const start = performance.now(); + run(); + expect(performance.now() - start).toBeLessThan(1000); + }); + + it.each([SMALL_GRAPH_ZOOM_FLOOR, ORGANIZED_ZOOM_FLOOR])('above WHOLE_FIT_MAX_CARDS (floor %s) Organize is the plain organizer', floor => { + const g = graph(generated(WHOLE_FIT_MAX_CARDS + 1, 90)); + const options = { ...g, viewport: SCREENS.desktop, floor }; + const dims = (item: (typeof g.items)[number]) => g.dimensionsAt(item, 1); + expect(organizeWithLabelRoom(g.items, g.edges, options).positions).toEqual(organizeTopology(g.items, g.edges, dims, SCREENS.desktop).positions); + }); +}); + +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); + }); + + // One card an agent or the API added to a saved graph has no saved position, + // so it is the only free card when the layout settles. Two saved cards sit + // so close that their own label is hidden: only a person can fix that, and + // it must not throw the new card away (as the settled layout does it twice). + describe('one new card in a saved graph whose saved cards hide a label', () => { + const g = graph({ + name: 'Saved graph and one new card', + items: [['a', 'Draft the plan'], ['b', 'Review the plan'], ['c', 'Ship the release'], ['n', 'Added by an agent']] + .map(([key, title]) => ({ key, title, type: 'TASK', status: 'NOT_STARTED' })), + edges: [{ source: 'a', target: 'b', type: 'BLOCKS' }, { source: 'c', target: 'n', type: 'BLOCKS' }], + }); + const zoom = 0.5; + const free = (id: string) => id === 'n'; + const settle = (start: Record) => { + const after: Points[] = []; + let positions: Points = new Map(Object.entries(start)); + for (let pass = 0; pass < 3; pass++) { + const { moved } = clearFreeCards(cardsAt(g, positions, zoom), g.drawn, { zoom, free }); + positions = new Map([...positions].map(([id, p]) => [id, moved.get(id) ?? p])); + after.push(positions); + } + return after; + }; + const ab = g.drawn.find(edge => edge.source === 'a')!.id, cn = g.drawn.find(edge => edge.source === 'c')!.id; + // The new card's visible gap to the saved card it is linked to. + const gapToC = (positions: Points) => { + const [c, n] = cardsAt(g, positions, zoom).filter(card => card.id === 'c' || card.id === 'n'); + return Math.hypot(n.x - c.x, n.y - c.y) - c.width / 2 - n.width / 2; + }; + + it('leaves the new card where it is when its own label shows', () => { + const start = { a: { x: 0, y: 0 }, b: { x: 230, y: 0 }, c: { x: 0, y: 400 }, n: { x: 600, y: 400 } }; + expect(problems(g, new Map(Object.entries(start)), zoom).hidden).toEqual([ab]); + for (const positions of settle(start)) expect(positions.get('n')).toEqual(start.n); + }); + + it('moves the new card only as far as its own label needs, and only once', () => { + const start = { a: { x: 0, y: 0 }, b: { x: 230, y: 0 }, c: { x: 0, y: 400 }, n: { x: 260, y: 400 } }; + expect(problems(g, new Map(Object.entries(start)), zoom).hidden).toEqual([ab, cn]); + const [first, ...later] = settle(start); + expect(problems(g, first, zoom).hidden).toEqual([ab]); + // Its label's gap and some room to spare, beside the card it is linked to. + expect(gapToC(first)).toBeLessThan(1.5 * labelGap(g.drawn.find(edge => edge.id === cn)!.label, zoom)); + for (const positions of later) expect(positions.get('n')).toEqual(first.get('n')); + }); + }); +}); + +// Item ids break ties in the organizer and in the label order, and real ids are +// random, so the room for labels must not depend on them. These seeds gave +// orders where a label stayed hidden: the component spread was taken at an +// unchecked 1.1 slack, and the finished layout was never checked as a whole. +describe('label room whatever the item ids', () => { + const renamed = (shape: LabelShape, seed: number): LabelShape => { + let state = seed; + const next = () => (state = (state * 1103515245 + 12345) % 2147483648); + const key = new Map(shape.items.map(item => [item.key, next().toString(16).padStart(8, '0')])); + return { + ...shape, + items: shape.items.map(item => ({ ...item, key: key.get(item.key)! })), + edges: shape.edges.map(edge => ({ ...edge, source: key.get(edge.source)!, target: key.get(edge.target)! })), + }; + }; + const orders = LABEL_SHAPES.filter(shape => ['Launch checklist', 'Two connected hubs'].includes(shape.name)) + .flatMap(shape => [3, 10, 29, 36, 78].map(seed => [shape.name, seed, shape] as const)); + it.each(orders)('%s with ids from seed %i: every label shows after Organize', (_name, seed, shape) => { + const g = graph(renamed(shape, seed)); + for (const screen of Object.keys(SCREENS) as Screen[]) { + const phone = screen === 'iPhone'; + const options = { ...g, viewport: SCREENS[screen] }; + const organized = organizeWithLabelRoom(g.items, g.edges, { ...options, floor: phone ? ORGANIZED_ZOOM_FLOOR : SMALL_GRAPH_ZOOM_FLOOR }); + expect(problems(g, organized.positions, organized.zoom), `organized on ${screen}`).toEqual(NONE); + } + }); +}); diff --git a/packages/web/src/lib/labelClearance.ts b/packages/web/src/lib/labelClearance.ts new file mode 100644 index 00000000..1b7bbcc0 --- /dev/null +++ b/packages/web/src/lib/labelClearance.ts @@ -0,0 +1,702 @@ +/** + * 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` and `clearFreeCards` run the organizer 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 { CARD_TITLES_HIDE_BELOW } from './cardTitleZoom'; + +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: while card titles show. */ +export const LABEL_MIN_ZOOM = CARD_TITLES_HIDE_BELOW; +/** "Organize around connections" never frames a large graph below this zoom. */ +export const ORGANIZED_ZOOM_FLOOR = 0.65; +/** + * Up to this many cards, Organize frames the whole graph, down to + * SMALL_GRAPH_ZOOM_FLOOR (the smallest zoom where card titles and labels still + * show), with room for each label at that zoom. Below it the camera holds that + * zoom on the organized hub's neighbourhood. Larger graphs keep + * ORGANIZED_ZOOM_FLOOR and open on a neighbourhood or the overview. + */ +export const WHOLE_FIT_MAX_CARDS = 60; +export const SMALL_GRAPH_ZOOM_FLOOR = CARD_TITLES_HIDE_BELOW; +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`. */ +export function spreadPoint(point: Point, anchor: Point, factor: number): Point { + return { 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; +/** + * Work limit for one whole Organize layout of a small graph + * (every zoom tried, every spread search and the turn check), in obstacle + * checks: about 100 ms. It runs in the click handler. + */ +export const LAYOUT_CHECKS = 300_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; when it runs out the search stops and returns the smallest factor + * found to fit so far, or 1 (no spread) when none was. + */ +export function spreadForLabels( + points: ReadonlyMap, + sizes: ReadonlyMap, + edges: readonly LabelEdge[], + options: { + zoom: number; + anchor?: Point; + maxFactor?: number; + work?: { remaining: number }; + /** 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 }; + // true / false, or null once the work is used up (no answer). + const fits = (factor: number): boolean | null => { + if (work.remaining <= 0) return null; + const cards: LabelCard[] = [...points].map(([id, p]) => ({ + id, + ...(options.moves?.(id) === false ? p : spreadPoint(p, anchor, factor)), + ...sizes.get(id)!, + })); + const before = work.remaining; + const placed = placeGraphLabels(cards, local, { + zoom: options.zoom, + budget: { labels: Infinity, attempts: Infinity, checks: before }, + }); + work.remaining -= placed.checks; + if (placed.hidden.length && placed.checks >= before) return null; + return placed.hidden.length === 0; + }; + const first = fits(1); + if (first !== false) return 1; + let low = 1, + high = 1.2; + for (;;) { + const ok = fits(high); + if (ok === null) return 1; + if (ok) break; + if (high >= maxFactor) return maxFactor; + low = high; + high = Math.min(maxFactor, high * 1.25); + } + for (let step = 0; step < 6 && high - low > 0.02; step++) { + const middle = (low + high) / 2; + const ok = fits(middle); + if (ok === null) break; + if (ok) high = middle; + else low = middle; + } + // The slack is kept only where it still frees every label: spreading is not + // always monotonic (a wire can swing onto a label further out). + const padded = Math.min(maxFactor, high * SPREAD_SLACK); + return padded > high && fits(padded) === true ? padded : high; +} + +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. Below 100% + * labels are bigger in graph units, so more room spreads the layout and the + * camera frames it further out: find the closest zoom (between the floor, or + * LABEL_MIN_ZOOM, and 100%) whose layout the camera still frames at that zoom + * or closer. A layout's size grows about as a + b / zoom, so the layouts at + * 100% and at the lowest zoom give a first guess at that zoom; up to + * FRAMED_STEPS more layouts close in on it from both sides (regula falsi), + * keeping the closest that fits. When none fits, the lowest zoom's layout is + * returned (the camera then frames part of it at the floor). + */ +export const FRAMED_STEPS = 2; + +function withFramedRoom }>( + nodes: readonly T[], + options: LabelRoomOptions, + layout: (zoom: number) => R +): R & { zoom: number; labelZoom: number } { + const floor = Math.max(options.floor ?? 0, 0); + const at = (labelZoom: number) => { + const result = layout(labelZoom); + return { ...result, zoom: framedZoom(nodes, result.positions, options, floor), labelZoom }; + }; + const closest = at(1); + if (closest.zoom >= 1) return closest; + const low = Math.min(1, Math.max(floor, LABEL_MIN_ZOOM)); + const lowest = at(low); + if (low >= 1) return lowest; + // How much more the layout fits than its labels need (>= 0 fits). + const slack = (r: { zoom: number; labelZoom: number }) => r.zoom - r.labelZoom; + let fits = slack(lowest) >= 0 ? lowest : null, + under = closest; + if (!fits) { + // 1 / zoom = a + b / labelZoom through both layouts fits up to (1 - b) / a. + const b = (1 / lowest.zoom - 1 / closest.zoom) / (1 / low - 1), + a = 1 / closest.zoom - b; + const guess = a > 0 ? (1 - b) / a : NaN; + if (!(guess > low && guess < 1)) return lowest; + const tried = at(Math.min(Math.max(guess, low + 0.15 * (1 - low)), 1 - 0.15 * (1 - low))); + if (slack(tried) < 0) return lowest; + fits = tried; + } + for (let step = 0; step < FRAMED_STEPS && under.labelZoom - fits.labelZoom > 0.04; step++) { + const lo = fits.labelZoom, + hi = under.labelZoom, + span = hi - lo; + const root = lo + (span * slack(fits)) / (slack(fits) - slack(under)); + const tried = at(Math.min(Math.max(root, lo + 0.15 * span), hi - 0.15 * span)); + if (slack(tried) >= 0) fits = tried; + else under = tried; + } + return fits; +} + +/** + * "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 floor = options.floor ?? ORGANIZED_ZOOM_FLOOR; + if (nodes.length > WHOLE_FIT_MAX_CARDS) { + // Large graphs open as an overview: the organizer as it always was. + const result = organizeTopology(nodes, edges, node => options.dimensionsAt(node, 1), options.viewport); + return { ...result, zoom: framedZoom(nodes, result.positions, options, floor), labelZoom: 1 }; + } + 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: LAYOUT_CHECKS }; + return withFramedRoom(nodes, { ...options, floor } as LabelRoomOptions, labelZoom => { + const result = 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 }); + }, + }); + const turned = turnToScreen(nodes, result.positions, labelled, options, labelZoom, work); + return { ...result, positions: checkedWhole(nodes, turned ?? result.positions, labelled, options, labelZoom, work) }; + }); +} + +/** + * The finished layout checked as a whole, as the renderer will see it (after + * the components are packed and turned): a label still hidden there, for + * instance one that only just fitted its component's spread, spreads the whole + * layout a little more. + */ +function checkedWhole( + nodes: readonly T[], + positions: Map, + labelled: readonly LabelEdge[], + options: Pick, 'dimensionsAt'>, + zoom: number, + work: { remaining: number } +): Map { + if (!nodes.length || nodes.length > SPREAD_MAX_CARDS) return positions; + const sizes = new Map(nodes.map(node => [node.id, options.dimensionsAt(node, zoom)])); + const anchor = positions.get(nodes[0].id)!; + const factor = spreadForLabels(positions, sizes, labelled, { zoom, anchor, work }); + return factor > 1 ? new Map([...positions].map(([id, p]) => [id, spreadPoint(p, anchor, factor)])) : positions; +} + +/** + * A tall layout on a wide screen (or a wide one on a tall screen) turned a + * quarter turn, so the camera can frame it closer; null when it already + * matches, is too large to check, or turning would put cards on cards or hide + * a label that shows now. + */ +function turnToScreen( + nodes: readonly T[], + positions: ReadonlyMap, + labelled: readonly LabelEdge[], + options: Pick, 'dimensionsAt' | 'viewport'>, + zoom: number, + work: { remaining: number } +): Map | null { + if (nodes.length < 2 || nodes.length > SPREAD_MAX_CARDS || work.remaining <= 0) return null; + const cardsAt = (points: ReadonlyMap, scale = zoom): LabelCard[] => + nodes.map(node => ({ id: node.id, ...points.get(node.id)!, ...options.dimensionsAt(node, scale) })); + const extent = (cards: LabelCard[]) => ({ + width: Math.max(...cards.map(card => card.x + card.width / 2)) - Math.min(...cards.map(card => card.x - card.width / 2)), + height: Math.max(...cards.map(card => card.y + card.height / 2)) - Math.min(...cards.map(card => card.y - card.height / 2)), + }); + const before = cardsAt(positions); + const size = extent(before); + const screenWide = options.viewport.width >= options.viewport.height; + if (screenWide === size.width >= size.height) return null; + const cx = before.reduce((sum, card) => sum + card.x, 0) / before.length, + cy = before.reduce((sum, card) => sum + card.y, 0) / before.length; + const turned = new Map([...positions].map(([id, p]) => [id, { x: cx + (p.y - cy), y: cy - (p.x - cx) }])); + const after = cardsAt(turned); + const fitOf = (box: Size) => Math.min(options.viewport.width / box.width, options.viewport.height / box.height); + if (fitOf(extent(after)) <= fitOf(size)) return null; + // Cards are largest at 100%: none may touch there. + const full = cardsAt(turned, 1); + for (const [i, a] of full.entries()) for (const b of full.slice(i + 1)) if (cardsOverlap(a, b, CARD_CLEARANCE)) return null; + const hidden = (cards: LabelCard[]) => { + const placed = placeGraphLabels(cards, labelled, { zoom, budget: { labels: Infinity, attempts: Infinity, checks: work.remaining } }); + const spent = placed.checks >= work.remaining; + work.remaining -= placed.checks; + return spent ? null : placed.hidden.length; + }; + const hiddenBefore = hidden(before); + const hiddenAfter = hiddenBefore === null ? null : hidden(after); + return hiddenBefore !== null && hiddenAfter !== null && hiddenAfter <= hiddenBefore ? turned : null; +} + +/** 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` with a + * free card at one end shows at `zoom`. A label between two pinned cards is + * left as it is: nothing moves for it. 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 (the steps are kept only when they + * show more of those labels). When wires still cross labels, the free cards of + * the groups those labels are in spread out about the other cards by the + * smallest factor that shows every label of the cards it moves, kept only when + * it does. A moved card that lands on another card keeps going the same way. + * So running it again on its own result moves nothing. 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; + }; + // Hidden labels a free card can do something about. + const freeable = (hidden: string[]) => + hidden.filter(id => options.free(byEdge.get(id)!.source) || options.free(byEdge.get(id)!.target)); + const saved = () => ({ at: new Map([...current].map(([id, card]) => [id, { x: card.x, y: card.y }])), moved: new Set(moved) }); + const restore = (state: ReturnType) => { + for (const [id, p] of state.at) Object.assign(current.get(id)!, p); + moved.clear(); + for (const id of state.moved) moved.add(id); + }; + // `change` kept when it shows more of the freeable labels (or `good` says + // so), else undone; the labels then hidden, or null. + const keepIfBetter = ( + hidden: string[], + change: () => boolean, + good = (after: string[]) => freeable(after).length < freeable(hidden).length + ) => { + const before = saved(); + if (!change()) return null; + const after = hiddenNow(); + if (after && good(after)) return after; + restore(before); + return null; + }; + // Up to `rounds` steps, kept together when they show more of those labels. + const steps = (hidden: string[] | null) => { + if (!hidden || !freeable(hidden).length) return hidden; + let last: string[] | null = hidden; + const kept = keepIfBetter(hidden, () => { + let any = false; + for (let round = 0; round < rounds && last && freeable(last).length && step(freeable(last)); round++) { + any = true; + last = hiddenNow(); + } + return any; + }); + return kept ?? hidden; + }; + let hidden = steps(hiddenNow()); + if (hidden && freeable(hidden).length) { + // The free cards of the groups (linked cards) the hidden labels are in. + const group = new Map([...current.keys()].map(id => [id, id])); + const root = (id: string): string => (group.get(id) === id ? id : root(group.get(id)!)); + for (const edge of edges) if (current.has(edge.source) && current.has(edge.target)) group.set(root(edge.source), root(edge.target)); + const groups = new Set(freeable(hidden).map(id => root(byEdge.get(id)!.source))); + const movers = new Set([...current.keys()].filter(id => options.free(id) && groups.has(root(id)))); + const still = [...current.values()].filter(card => !movers.has(card.id)); + const around = still.length ? still : [...current.values()]; + 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 }])); + // The smallest spread that shows every label of the cards it moves, kept + // only when it does (spreadForLabels gives its largest when none does). + const theirs = edges.filter(edge => movers.has(edge.source) || movers.has(edge.target)); + const factor = spreadForLabels(points, sizes, theirs, { zoom: options.zoom, anchor, work, moves: id => movers.has(id) }); + const spread = + factor > 1 && + keepIfBetter(hidden, () => { + for (const id of movers) { + const card = current.get(id)!; + Object.assign(card, spreadPoint(card, anchor, factor)); + moved.add(id); + } + for (const id of movers) { + const card = current.get(id)!; + shift(card, 0, 0, card.x - anchor.x, card.y - anchor.y); + } + return true; + }, after => !theirs.some(edge => after.includes(edge.id))); + if (spread) hidden = spread; + } + 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..88838c04 --- /dev/null +++ b/packages/web/src/lib/labelClearanceShapes.ts @@ -0,0 +1,268 @@ +/** + * 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' }, + ], +}; + +/** Twelve items and fifteen relationships of mixed types around one hub: the + * graph a person imported when labels went missing after Organize. */ +export const LAUNCH_CHECKLIST_SHAPE: LabelShape = { + name: 'Launch checklist', + items: [ + item('launch-plan', 'Launch plan', 'MILESTONE', 'PROPOSED', -570, -260), + item('design-mockups', 'Design mockups', 'TASK', 'PLANNED', -190, -260), + item('write-copy', 'Write copy', 'TASK', 'IN_PROGRESS', 190, -260), + item('build-landing-page', 'Build landing page', 'TASK', 'COMPLETED', 570, -260), + item('set-up-analytics', 'Set up analytics', 'TASK', 'PROPOSED', -570, 0), + item('legal-review', 'Legal review', 'TASK', 'PLANNED', -190, 0), + item('pricing-decision', 'Pricing decision', 'TASK', 'IN_PROGRESS', 190, 0), + item('email-campaign', 'Email campaign', 'TASK', 'COMPLETED', 570, 0), + item('qa-pass', 'QA pass', 'TASK', 'PROPOSED', -570, 260), + item('press-kit', 'Press kit', 'TASK', 'PLANNED', -190, 260), + item('beta-feedback', 'Beta feedback', 'TASK', 'IN_PROGRESS', 190, 260), + item('retrospective', 'Retrospective', 'TASK', 'COMPLETED', 570, 260), + ], + edges: [ + { source: 'launch-plan', target: 'design-mockups', type: 'DEPENDS_ON' }, + { source: 'launch-plan', target: 'write-copy', type: 'DEPENDS_ON' }, + { source: 'launch-plan', target: 'build-landing-page', type: 'BLOCKS' }, + { source: 'launch-plan', target: 'set-up-analytics', type: 'ENABLES' }, + { source: 'launch-plan', target: 'legal-review', type: 'RELATES_TO' }, + { source: 'launch-plan', target: 'pricing-decision', type: 'IS_PART_OF' }, + { source: 'launch-plan', target: 'email-campaign', type: 'FOLLOWS' }, + { source: 'design-mockups', target: 'build-landing-page', type: 'DEPENDS_ON' }, + { source: 'write-copy', target: 'build-landing-page', type: 'DEPENDS_ON' }, + { source: 'build-landing-page', target: 'qa-pass', type: 'BLOCKS' }, + { source: 'set-up-analytics', target: 'qa-pass', type: 'VALIDATES' }, + { source: 'pricing-decision', target: 'email-campaign', type: 'ENABLES' }, + { source: 'email-campaign', target: 'press-kit', type: 'REFERENCES' }, + { source: 'qa-pass', target: 'beta-feedback', type: 'FOLLOWS' }, + { source: 'beta-feedback', target: 'retrospective', type: 'CONTAINS' }, + ], +}; + +/** 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, LAUNCH_CHECKLIST_SHAPE, ...HARD_SHAPES]; 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) ); diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 2fac8bab..102a3c9f 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -488,6 +488,7 @@ export function Workspace() { {/* Mini-Map Toggle Button - Shows when mini-map is hidden (desktop only) */} {viewMode === 'graph' && currentGraph && !showMiniMap && !isMobile && createPortal(