diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4a5d1..b43e127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## Unreleased +- Made newly added repositories appear in the sidebar without restarting + Stellr or performing another space action. - Emphasized the incoming and outgoing edges directly connected to a selected node while preserving dependency direction, state styling, and motion. +- Replaced compact subissue arcs with adaptive, label-aware concentric orbits, + outward titles, and larger nearest-node pointer targets for dense workflows. - Restored dependency and parent relationship lines from cached Markdown issue bodies without making additional GitHub requests. - Upgraded all bundle artifact uploads to `actions/upload-artifact@v7` and diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs index b7d92ef..08459b1 100644 --- a/crates/server/src/routes.rs +++ b/crates/server/src/routes.rs @@ -111,6 +111,7 @@ async fn add_space( .into_response(); } drop(spaces); + state.refresh.notify_one(); Json(AddSpaceResponse { id }).into_response() } diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index 13922d9..070a993 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -181,7 +181,7 @@ impl Provider for SequenceProvider { } #[tokio::test] -async fn add_repo_space_then_refresh_populates_the_model() { +async fn add_repo_space_immediately_populates_the_model() { let directory = tempfile::tempdir().unwrap(); let (hub, mut receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); let state = Arc::new(AppState { @@ -226,13 +226,6 @@ async fn add_repo_space_then_refresh_populates_the_model() { json!({ "id": "o-r" }) ); - let refreshed = client - .post(format!("{base}/api/spaces/o-r/refresh")) - .send() - .await - .unwrap(); - assert_eq!(refreshed.status(), reqwest::StatusCode::OK); - let model = tokio::time::timeout(Duration::from_secs(2), async { loop { let model = client @@ -253,7 +246,7 @@ async fn add_repo_space_then_refresh_populates_the_model() { } }) .await - .expect("refresh should publish the derived model"); + .expect("adding a space should publish the derived model"); assert_eq!(model.spaces[0].id, "o-r"); assert_eq!(model.spaces[0].repo, "o/r"); diff --git a/docs/superpowers/plans/2026-08-05-subissue-orbit-layout.md b/docs/superpowers/plans/2026-08-05-subissue-orbit-layout.md new file mode 100644 index 0000000..a7c8d8a --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-subissue-orbit-layout.md @@ -0,0 +1,213 @@ +# Adaptive Subissue Orbit Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace compact subissue arcs with deterministic adaptive concentric orbits, outward labels, and easier nearest-node selection. + +**Architecture:** Keep `computeLayout` as the broad deterministic layout seam, then place valid parent groups parent-first through `placeDirectChildClusters`. Introduce one pure label-geometry module shared by layout scoring and canvas rendering, and keep pointer behavior observable through `StarMap.selectAtScreen`. + +**Tech Stack:** TypeScript 6, Vitest 4, Canvas 2D, Svelte 5, native Windows PowerShell/npm workflow. + +## Global Constraints + +- Preserve broad parent anchors and status-only/temporal coordinate stability. +- Use full parent-centered circular orbits and add concentric rings only as density requires. +- Keep visible star sizes, relationship semantics, focus behavior, and camera behavior unchanged. +- Use deterministic finite fallbacks for invalid or fully obstructed inputs. +- Keep top-level label and hit-target behavior unchanged. + +--- + +### Task 1: Shared outward label geometry + +**Files:** +- Create: `web/src/lib/starmap/label-geometry.ts` +- Create: `web/src/lib/starmap/label-geometry.test.ts` +- Modify: `web/src/lib/starmap/starmap.ts` + +**Interfaces:** +- Produces: `outwardLabelGeometry(input: OutwardLabelInput): LabelGeometry` +- Produces: `estimateLabelWidth(number: number, title: string, fontSize?: number): number` +- Produces: `clipTitle(title: string, budget: number): string` +- `LabelGeometry` contains `x`, `y`, `align`, and a `box` with `x0`, `y0`, `x1`, `y1`. + +- [x] **Step 1: Write failing geometry tests** + +Add literal worked examples proving right children produce left-aligned labels, +left children produce right-aligned labels, vertical children stay centered, +and every returned box lies farther from the parent than the child center. + +- [x] **Step 2: Run the focused test and verify red** + +Run: `npm.exe --prefix web test -- src/lib/starmap/label-geometry.test.ts` + +Expected: FAIL because `label-geometry.ts` does not exist. + +- [x] **Step 3: Implement the pure geometry seam** + +Use a normalized parent-to-child vector, a fixed radial gap, alignment selected +from the vector, and one shared box calculation: + +```ts +export function outwardLabelGeometry(input: OutwardLabelInput): LabelGeometry +export function estimateLabelWidth(number: number, title: string, fontSize = 14): number +export function clipTitle(title: string, budget: number): string +``` + +- [x] **Step 4: Run the focused test and typecheck** + +Run: `npm.exe --prefix web test -- src/lib/starmap/label-geometry.test.ts` + +Run: `npm.exe --prefix web run check` + +Expected: PASS. + +--- + +### Task 2: Adaptive concentric orbit placement + +**Files:** +- Modify: `web/src/lib/starmap/layout.ts` +- Modify: `web/src/lib/starmap/cluster-layout.ts` +- Modify: `web/src/lib/starmap/layout.test.ts` +- Modify: `web/src/lib/starmap/cluster-layout.test.ts` + +**Interfaces:** +- Extends: `LayoutNode` with optional `title?: string` for deterministic label footprint scoring. +- Preserves: `placeDirectChildClusters(nodes, broadPoints, dependencyEdges): Record`. +- Consumes: shared label geometry and width estimation from Task 1. + +- [x] **Step 1: Replace compact-arc expectations with a failing full-orbit test** + +Pin four direct children at equal angular intervals on one ring, unchanged broad +parent/unrelated anchors, and snapshot-order independence. + +- [x] **Step 2: Run the focused layout tests and verify red** + +Run: `npm.exe --prefix web test -- src/lib/starmap/layout.test.ts src/lib/starmap/cluster-layout.test.ts` + +Expected: FAIL because current points occupy only compact arcs. + +- [x] **Step 3: Implement one-ring deterministic placement** + +Replace compact arc slots with complete-ring slots and bounded deterministic +rotation candidates. Keep sibling dependency order and hierarchy-depth order. + +- [x] **Step 4: Run focused tests and verify green** + +Run the Task 2 focused command. Expected: PASS for one-ring behavior. + +- [x] **Step 5: Add a failing dense-group multi-ring test** + +Use fourteen children with long titles. Assert at least two distinct radii, +minimum star clearance, non-overlapping shared label boxes, finite points, and +stable output when input order reverses. + +- [x] **Step 6: Implement adaptive ring allocation and scoring** + +Allocate ring capacity from circumference and occupied footprint, evaluate +bounded expansion levels and rotations, and score star/label/node/line +clearance with line crossings carrying the strongest penalty. Keep invalid +hierarchies at broad coordinates. + +- [x] **Step 7: Run focused tests and typecheck** + +Run the Task 2 focused command and `npm.exe --prefix web run check`. + +Expected: PASS. + +--- + +### Task 3: Outward rendering and nearest subissue hit targets + +**Files:** +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/starmap.test.ts` + +**Interfaces:** +- Preserves: `StarMap.selectAtScreen(sx, sy): number | null`. +- Consumes: `outwardLabelGeometry` for valid subissues. +- Top-level nodes continue through the existing above/below label solver and hit radius. + +- [x] **Step 1: Add failing renderer tests** + +Through the mounted `StarMap` seam, assert labels for right/left/vertical +subissues use outward alignment, while top-level labels remain centered. + +- [x] **Step 2: Run the focused renderer test and verify red** + +Run: `npm.exe --prefix web test -- src/lib/starmap/starmap.test.ts` + +Expected: FAIL because every label is currently centered above or below. + +- [x] **Step 3: Render subissue labels from shared geometry** + +Try bounded radial distances for subissues, collision-check the shared boxes, +store per-label text alignment, and leave the top-level solver unchanged. + +- [x] **Step 4: Add failing interaction tests** + +Assert a click outside a subissue's visible/top-level target still selects the +subissue, the same offset misses a top-level issue, and overlapping targets +select the nearest center with issue number as the exact-distance tie-breaker. + +- [x] **Step 5: Implement nearest-node hit testing** + +Use a larger minimum screen-space radius only for valid subissues, gather all +eligible nodes, and select by distance then issue number instead of array order. + +- [x] **Step 6: Run renderer tests and typecheck** + +Run: `npm.exe --prefix web test -- src/lib/starmap/starmap.test.ts` + +Run: `npm.exe --prefix web run check` + +Expected: PASS. + +--- + +### Task 4: Full validation and review + +**Files:** +- Modify only if a validation or review finding requires a scoped correction. + +**Interfaces:** +- Verifies the complete frontend and repository behavior; produces no new API. + +- [x] **Step 1: Run frontend validation** + +Run: `npm.exe --prefix web test` + +Run: `npm.exe --prefix web run check` + +Run: `npm.exe --prefix web run build` + +- [x] **Step 2: Run repository validation** + +Run: `cargo.exe test --workspace --locked` + +Run: `cargo.exe fmt --all -- --check` + +Run: `cargo.exe clippy --workspace --all-targets --locked -- -D warnings` + +Run: `git diff --check` + +- [ ] **Step 3: Validate the dense graph visually on native Windows** + +Launch the local app/preview, inspect the Encrydle-shaped graph at normal and +reduced viewport sizes, and exercise selection of neighboring subissues. + +Blocked evidence: the native server loaded the Encrydle route in the shared +preview, but repeated preview snapshot and evaluation calls failed, so no +screenshot-based visual claim is recorded. + +- [x] **Step 4: Run two-axis code review from design commit `7204c6d`** + +Review `git diff 7204c6d...HEAD` against repository standards and +`docs/superpowers/specs/2026-08-05-subissue-orbit-layout-design.md`; correct +confirmed findings and rerun affected gates. + +- [x] **Step 5: Commit the completed implementation** + +Stage only the plan, implementation, and tests, then commit on the current +branch with a focused message. diff --git a/docs/superpowers/plans/2026-08-08-immediate-space-publication.md b/docs/superpowers/plans/2026-08-08-immediate-space-publication.md new file mode 100644 index 0000000..f602066 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-immediate-space-publication.md @@ -0,0 +1,110 @@ +# Immediate Space Publication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a successfully added GitHub repository appear in Stellr's sidebar through the next authoritative model publication without requiring another space action or restart. + +**Architecture:** Keep the persisted `SpaceStore` and polled `Model` as the only sources of truth. After a successful add has been saved and the store lock released, notify the existing poller just as remove and manual refresh already do; the poller derives and broadcasts the new model through the watch hub. + +**Tech Stack:** Rust 2024, Tokio `Notify` and `watch`, Axum, native Windows PowerShell, Cargo. + +## Global Constraints + +- Run development and validation with native Windows executables; do not use WSL or Linux tooling. +- Keep the frontend authoritative-model architecture and the add response `{ "id": "..." }` unchanged. +- Do not notify after validation, duplicate, or persistence failures. +- Add no dependencies and make no unrelated refactors. +- Keep `CHANGELOG.md` append-only, newest-first, and add pending work only under `Unreleased`. + +## File Structure + +- `crates/server/src/routes.rs` owns the add endpoint and will wake the existing poller after persistence. +- `crates/server/tests/api_test.rs` proves that add alone publishes a derived model. +- `CHANGELOG.md` records the user-visible correction under `Unreleased`. + +--- + +### Task 1: Publish a model after adding a space + +**Files:** +- Modify: `crates/server/src/routes.rs:101-115` +- Test: `crates/server/tests/api_test.rs:183-255` +- Modify: `CHANGELOG.md:3` + +**Interfaces:** +- Consumes: `AppState.refresh: Arc` and the existing `run_poller` notification branch. +- Produces: a refresh notification after a successful `SpaceStore::save`, causing `AppState.hub` to publish a `Model` containing the added space. + +- [x] **Step 1: Write the failing integration test** + +Rename the existing add/refresh test to `add_repo_space_immediately_populates_the_model`, remove its explicit `POST /api/spaces/o-r/refresh`, and retain these independently derived assertions: + +```rust +assert_eq!(model.spaces[0].id, "o-r"); +assert_eq!(model.spaces[0].repo, "o/r"); +assert_eq!(model.spaces[0].stars[0].number, 1); +``` + +- [x] **Step 2: Run the test and verify the exact failure** + +Run: + +```powershell +cargo.exe test -p stellr-server --test api_test add_repo_space_immediately_populates_the_model -- --exact --nocapture +``` + +Expected before implementation: FAIL after the two-second model wait with `adding a space should publish the derived model: Elapsed(())`. + +- [x] **Step 3: Notify the poller after successful persistence** + +In `add_space`, place the existing notification after `drop(spaces)` and before returning the response: + +```rust +drop(spaces); +state.refresh.notify_one(); + +Json(AddSpaceResponse { id }).into_response() +``` + +All validation and save error returns remain before the notification. + +- [x] **Step 4: Verify the focused red-green cycle** + +Run: + +```powershell +cargo.exe test -p stellr-server --test api_test add_repo_space_immediately_populates_the_model -- --exact --nocapture +``` + +Expected: PASS with one test run and no failure output. + +- [x] **Step 5: Record the user-visible fix** + +Add this as the first bullet under `## Unreleased` in `CHANGELOG.md`: + +```markdown +- Made newly added repositories appear in the sidebar without restarting + Stellr or performing another space action. +``` + +- [x] **Step 6: Run affected and workspace validation** + +Run, in order: + +```powershell +cargo.exe fmt --all -- --check +cargo.exe test -p stellr-server --test api_test --locked -- --test-threads=1 +cargo.exe clippy --workspace --all-targets --locked -- -D warnings +cargo.exe test --workspace --locked -- --test-threads=1 +``` + +Expected: every command exits zero with no formatting differences, lint warnings, or failed tests. + +- [x] **Step 7: Review and commit the implementation** + +Review `git diff --check`, confirm only the regression test, route notification, changelog, and this plan are in scope, then commit: + +```powershell +git add -- crates/server/src/routes.rs crates/server/tests/api_test.rs CHANGELOG.md docs/superpowers/plans/2026-08-08-immediate-space-publication.md +git commit -m "fix(server): publish newly added spaces" +``` diff --git a/docs/superpowers/specs/2026-08-05-subissue-orbit-layout-design.md b/docs/superpowers/specs/2026-08-05-subissue-orbit-layout-design.md new file mode 100644 index 0000000..71f32e2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-subissue-orbit-layout-design.md @@ -0,0 +1,151 @@ +# Adaptive Subissue Orbit Layout Design + +## Problem + +Stellr currently places direct subissues on compact parent-local arcs with +fixed radii. Dense issue families therefore put stars, relationship curves, +and titles into the same small area. Labels overlap, relationship direction is +hard to follow, and neighboring stars are difficult to select reliably. + +The layout must improve subissue readability and click precision without +discarding Stellr's circular constellation language or its deterministic, +spatially stable map. + +## Goals + +- Arrange each valid subissue family on adaptive concentric circular orbits + around its immediate parent. +- Reserve enough space for visible labels as well as star centers. +- Place subissue labels outward from their parent-centered orbit. +- Make individual subissues easier to select without enlarging their visible + stars. +- Preserve deterministic coordinates across reloads, snapshot input order, + and status-only or temporal updates. +- Keep broad-layout parent anchors unchanged. + +## Non-goals + +- Replacing the broad constellation layout. +- Changing relationship semantics, focus traversal, status styling, or edge + animation. +- Moving nodes in response to status, selection, hover, or replay time. +- Guaranteeing full untruncated titles at arbitrarily high graph density. +- Adding user-configurable layout controls in this slice. + +## Layout Geometry + +The broad deterministic layout remains responsible for the initial position of +every issue. Subissue placement then operates parent-first, using each immediate +parent's final point as the fixed center of a local orbit. + +For each valid parent group: + +1. Order siblings deterministically by dependency flow, with issue number as + the stable fallback and cycle tie-breaker. +2. Estimate the occupied footprint of each child from the visible star and its + truncated label bounds. +3. Allocate children to one or more concentric rings. Ring radii and capacities + derive from the minimum occupied footprint rather than fixed child counts. +4. Distribute each ring's children around the full circle with deterministic + angular spacing. Additional rings are introduced only when one ring cannot + meet the required clearance. +5. Evaluate a bounded set of deterministic rotations for the complete cluster. + Select a collision-free candidate when available; otherwise select the + candidate with the lowest stable collision score. + +Candidate scoring considers: + +- child-to-child star and label clearance; +- clearance from unrelated nodes and labels; +- clearance from existing dependency lines and previously placed nested + cluster curves; +- relationship-line crossings, which retain a stronger penalty than ordinary + clearance differences. + +Nested groups are placed in hierarchy-depth order. A child that is also a +parent receives its own orbit only after its position in the ancestor orbit is +final. + +## Labels + +Subissue labels extend away from the immediate parent: + +- nodes on the right side use left-aligned labels; +- nodes on the left side use right-aligned labels; +- nodes near the top or bottom use centered labels; +- every label receives a consistent radial gap from the visible star. + +The renderer exposes the same deterministic label bounds to the layout scorer +and drawing path so collision decisions match what appears on screen. Full +titles remain available in the detail pane. When a graph is too dense for all +labels, the fallback preserves star separation and pointer access first, then +uses the existing consistent title truncation. + +Top-level issue labels retain their current behavior. This change is scoped to +nodes with a valid in-snapshot parent relationship. + +## Pointer Interaction + +Visible star sizes remain unchanged. Hit testing uses a larger invisible +screen-space target for subissues so a user can select the intended child even +at ordinary zoom levels. The target must not allow a more distant subissue to +win over a nearer one; when targets overlap, hit testing resolves to the closest +star center with issue number as the deterministic final tie-breaker. + +Pan, zoom, deep-link selection, focus highlighting, and detail-pane behavior +remain unchanged. + +## Invalid and Constrained Inputs + +Missing parents, self-parent relationships, parent cycles, and non-finite broad +coordinates do not participate in orbit placement. Their nodes retain their +broad-layout positions. + +If all candidate rotations are obstructed, placement uses the finite candidate +with the lowest deterministic collision score. It never introduces random +coordinates or movement tied to transient UI state. + +## Implementation Boundaries + +- `cluster-layout.ts` owns deterministic ring allocation, candidate generation, + and collision scoring. +- A small shared label-geometry seam owns subissue label alignment and bounds so + layout and canvas rendering use the same rules. +- `starmap.ts` consumes the selected coordinates and shared label geometry, and + owns screen-space pointer hit testing. +- The broad `computeLayout` seam remains status-independent and continues to + invoke subissue placement after broad relaxation. + +## Validation + +Unit tests will cover: + +- small sibling groups using one complete circular orbit; +- dense sibling groups expanding to multiple concentric rings; +- minimum star and label clearance; +- outward label alignment on the left, right, top, and bottom of a parent; +- dependency-ordered siblings and deterministic cycle fallback; +- snapshot-order independence and status-only coordinate stability; +- nested parent-first placement; +- invalid hierarchy and fully obstructed deterministic fallbacks; +- enlarged subissue hit targets, nearest-node resolution, and unchanged + top-level hit behavior. + +Renderer tests will verify that drawing and collision scoring share the same +label geometry. A native Windows browser/app validation will exercise an +Encrydle-shaped dense graph at normal and reduced viewport sizes, checking that +titles are materially more readable and intended subissues can be selected +without neighboring stars intercepting the click. + +## Acceptance Criteria + +1. Valid direct subissues appear on deterministic full circular orbits around + their immediate parent, adding concentric rings as density requires. +2. Subissue labels extend outward and participate in layout collision scoring. +3. Broad parent anchors and status-only/temporal spatial stability are + preserved. +4. Visible stars remain unchanged while subissue pointer targets become easier + to acquire and resolve to the nearest center. +5. Dense and nested groups have deterministic, finite fallback geometry. +6. Automated layout, rendering, and interaction tests pass, and the dense + Windows validation demonstrates readable labels and reliable selection. diff --git a/docs/superpowers/specs/2026-08-08-immediate-space-publication-design.md b/docs/superpowers/specs/2026-08-08-immediate-space-publication-design.md new file mode 100644 index 0000000..a29b330 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-immediate-space-publication-design.md @@ -0,0 +1,76 @@ +# Immediate Space Publication Design + +**Date:** 2026-08-08 +**Status:** Approved in conversation; pending written-spec review + +## Problem + +Adding a GitHub repository persists its space, but the new project does not +appear in the sidebar until another action wakes synchronization. Removing a +space, restarting Stellr, manually refreshing, or waiting for the scheduled +poll makes the previously added project visible. + +The frontend intentionally renders authoritative models received through the +control WebSocket. The add endpoint currently saves the new `SpaceEntry` but, +unlike remove and refresh, does not notify the poller to derive and publish a +new model. + +## Product Decision + +A successful repository add immediately requests synchronization through the +existing poller notification seam. Stellr continues to render only +authoritative server models; the frontend does not invent an optimistic space +or duplicate synchronization state. + +## Design + +After the add endpoint has validated the request, updated the `SpaceStore`, +successfully persisted it, and released the store lock, it calls the existing +refresh notification. The poller reads the updated store, synchronizes every +space, and replaces the model in the watch hub. Connected control WebSockets +then deliver that model, causing the keyed sidebar list and selected route to +update through their existing behavior. + +The HTTP response remains the existing `{ "id": "..." }` payload and need not +wait for GitHub synchronization to finish. Duplicate and persistence failures +return before notification, so failed additions cannot trigger a misleading +model publication. + +Provider failures retain the current behavior: the poller publishes the added +space with cached data when available and marks it stale with the provider +error. This keeps the new project visible even when GitHub cannot be reached. + +## Alternatives Rejected + +- An optimistic frontend placeholder would create a second source of truth and + require reconciliation for provider, persistence, duplicate, and routing + failures. +- Synchronizing inside the add request would make POST latency depend on GitHub + and duplicate the poller's existing cache and publication path. + +## Testing + +The server integration test adds a repository and waits for the derived model +without issuing a separate refresh. It must fail before the fix because no +model is published, then pass after the add endpoint notifies the poller. + +Existing add validation, persistence, remove, manual refresh, polling, control +WebSocket, and frontend authoritative-snapshot tests remain green. Native Rust +formatting, linting, and affected workspace tests provide broader validation. + +## Release Notes + +The `Unreleased` changelog records that newly added repositories now appear in +the sidebar without restarting Stellr or performing another space action. + +## Acceptance Criteria + +- A successfully added GitHub repository appears in the sidebar after the + resulting authoritative model is published, without a restart, removal, or + manual refresh. +- Failed validation or persistence does not trigger synchronization. +- Provider failure still publishes the added space with existing stale/error + semantics. +- The add response shape and frontend authoritative-model architecture remain + unchanged. +- Focused and broader native-Windows validation passes. diff --git a/web/src/lib/starmap/cluster-layout.test.ts b/web/src/lib/starmap/cluster-layout.test.ts index 491de91..ef0a50f 100644 --- a/web/src/lib/starmap/cluster-layout.test.ts +++ b/web/src/lib/starmap/cluster-layout.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import { edgesOf, type LayoutNode, type Point } from './layout' -import { compareCandidateScores, placeDirectChildClusters } from './cluster-layout' +import { + INNER_RING_RADIUS, + MIN_CHILD_CENTER_CLEARANCE, + compareCandidateScores, + orbitRingCounts, + placeDirectChildClusters, +} from './cluster-layout' import { workflowEdges } from './workflow' import { reverseEdgeKeys } from './workflow-visual' import { @@ -8,8 +14,151 @@ import { miniEdgeCurve, sampleQuadratic, } from './workflow-geometry' +import { boxesOverlap, estimateLabelWidth, outwardLabelGeometry } from './label-geometry' + +describe('adaptive orbit cluster selection', () => { + it('distributes a small sibling group around a complete parent-centred orbit', () => { + const children = [31, 32, 33, 34] + const nodes: LayoutNode[] = [ + { num: 16, blockedBy: [], parentIssue: null }, + ...children.map((num) => ({ num, blockedBy: [], parentIssue: 16 })), + ] + const broadPoints: Record = { + 16: { x: 25, y: -40 }, + 31: { x: -400, y: -400 }, + 32: { x: 400, y: -400 }, + 33: { x: 400, y: 400 }, + 34: { x: -400, y: 400 }, + } + + const points = placeDirectChildClusters(nodes, broadPoints, []) + const vectors = children.map((number) => ({ + x: points[number].x - points[16].x, + y: points[number].y - points[16].y, + })) + const centroid = vectors.reduce( + (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }), + { x: 0, y: 0 }, + ) + + expect(centroid.x / children.length).toBeCloseTo(0, 6) + expect(centroid.y / children.length).toBeCloseTo(0, 6) + expect(new Set(vectors.map((point) => Math.hypot(point.x, point.y).toFixed(6))).size).toBe(1) + }) + + it('expands a dense labelled sibling group across clear concentric rings', () => { + const children = Array.from({ length: 14 }, (_, index) => 31 + index) + const title = (number: number) => `Subissue ${number} with a descriptive operator-facing title` + const nodes = [ + { num: 16, title: 'Parent', blockedBy: [], parentIssue: null }, + ...children.map((num) => ({ num, title: title(num), blockedBy: [], parentIssue: 16 })), + ] + const broadPoints: Record = { 16: { x: 0, y: 0 } } + for (const [index, number] of children.entries()) { + broadPoints[number] = { x: 1_000 + index * 100, y: 1_000 } + } + + const points = placeDirectChildClusters(nodes, broadPoints, []) + const reversed = placeDirectChildClusters([...nodes].reverse(), broadPoints, []) + const radii = children.map((number) => + Math.hypot(points[number].x - points[16].x, points[number].y - points[16].y), + ) + const boxes = children.map((number) => + outwardLabelGeometry({ + parent: points[16], + child: points[number], + textWidth: estimateLabelWidth(number, title(number)), + fontSize: 14, + starRadius: 14, + }).box, + ) + + expect(reversed).toEqual(points) + expect(new Set(radii.map((radius) => radius.toFixed(3))).size).toBeGreaterThan(1) + for (let left = 0; left < boxes.length; left++) { + for (let right = left + 1; right < boxes.length; right++) { + const a = boxes[left] + const b = boxes[right] + expect( + boxesOverlap(a, b), + `labels ${children[left]} and ${children[right]} overlap`, + ).toBe(false) + } + } + }) + + it('derives the inner orbit radius from the occupied label footprint', () => { + const children = Array.from({ length: 7 }, (_, index) => 31 + index) + const nodes = (title: string): LayoutNode[] => [ + { num: 16, title: 'Parent', blockedBy: [], parentIssue: null }, + ...children.map((num) => ({ num, title, blockedBy: [], parentIssue: 16 })), + ] + const broadPoints: Record = { 16: { x: 0, y: 0 } } + for (const [index, number] of children.entries()) { + broadPoints[number] = { x: 1_000 + index * 100, y: 1_000 } + } + + const short = placeDirectChildClusters(nodes(''), broadPoints, []) + const long = placeDirectChildClusters(nodes('A descriptive title that consumes the full label budget'), broadPoints, []) + const radius = (points: Record) => + Math.hypot(points[31].x - points[16].x, points[31].y - points[16].y) + + expect(radius(short)).toBeCloseTo(INNER_RING_RADIUS, 6) + expect(radius(long)).toBeGreaterThan(radius(short)) + }) + + it('allocates rings from each child label footprint', () => { + const children = Array.from({ length: 8 }, (_, index) => index + 31) + const nodes: LayoutNode[] = children.map((num, index) => ({ + num, + title: index === children.length - 1 + ? 'A descriptive title that consumes the full label budget' + : '', + blockedBy: [], + parentIssue: 16, + })) + + expect(orbitRingCounts(nodes)).toEqual([7, 1]) + }) + + it('keeps a later orbit clear of labels already placed by an unrelated family', () => { + const leftChildren = [31, 32, 33, 34] + const rightChildren = [41, 42, 43, 44] + const title = (number: number) => `Subissue ${number} with a descriptive operator-facing title` + const nodes: LayoutNode[] = [ + { num: 10, title: 'Left parent', blockedBy: [], parentIssue: null }, + ...leftChildren.map((num) => ({ num, title: title(num), blockedBy: [], parentIssue: 10 })), + { num: 20, title: 'Right parent', blockedBy: [], parentIssue: null }, + ...rightChildren.map((num) => ({ num, title: title(num), blockedBy: [], parentIssue: 20 })), + ] + const broadPoints: Record = { + 10: { x: -240, y: 0 }, + 20: { x: 240, y: 0 }, + } + for (const [index, number] of [...leftChildren, ...rightChildren].entries()) { + broadPoints[number] = { x: 1_000 + index * 100, y: 1_000 } + } + + const points = placeDirectChildClusters(nodes, broadPoints, []) + const labelBox = (number: number, parent: number) => + outwardLabelGeometry({ + parent: points[parent], + child: points[number], + textWidth: estimateLabelWidth(number, title(number)), + fontSize: 14, + starRadius: 14, + }).box + + for (const left of leftChildren) { + for (const right of rightChildren) { + expect( + boxesOverlap(labelBox(left, 10), labelBox(right, 20)), + `labels ${left} and ${right} overlap`, + ).toBe(false) + } + } + }) -describe('compact cluster sector selection', () => { it('moves a cluster away from an unrelated dependency through its default sector', () => { const nodes: LayoutNode[] = [ { num: 16, blockedBy: [], parentIssue: null }, @@ -120,7 +269,7 @@ describe('compact cluster sector selection', () => { ).toBe(0) }) - it('places six children on bounded first and second arcs with minimum clearance', () => { + it('places six children on one complete orbit with minimum clearance', () => { const children = [31, 32, 33, 34, 35, 36] const nodes: LayoutNode[] = [ { num: 16, blockedBy: [], parentIssue: null }, @@ -134,8 +283,7 @@ describe('compact cluster sector selection', () => { const points = placeDirectChildClusters(nodes, broadPoints, []) const radii = children.map((number) => Math.hypot(points[number].x, points[number].y)) - expect(radii.filter((radius) => Math.abs(radius - 92) < 0.001)).toHaveLength(4) - expect(radii.filter((radius) => Math.abs(radius - 126) < 0.001)).toHaveLength(2) + expect(radii.filter((radius) => Math.abs(radius - INNER_RING_RADIUS) < 0.001)).toHaveLength(6) for (let left = 0; left < children.length; left++) { for (let right = left + 1; right < children.length; right++) { expect( @@ -143,7 +291,7 @@ describe('compact cluster sector selection', () => { points[children[left]].x - points[children[right]].x, points[children[left]].y - points[children[right]].y, ), - ).toBeGreaterThanOrEqual(44) + ).toBeGreaterThanOrEqual(MIN_CHILD_CENTER_CLEARANCE) } } }) @@ -164,10 +312,10 @@ describe('compact cluster sector selection', () => { expect( Math.hypot(points[10].x - points[50].x, points[10].y - points[50].y), - ).toBeCloseTo(92, 6) + ).toBeCloseTo(INNER_RING_RADIUS, 6) expect( Math.hypot(points[5].x - points[10].x, points[5].y - points[10].y), - ).toBeCloseTo(92, 6) + ).toBeCloseTo(INNER_RING_RADIUS, 6) }) it('retains broad coordinates for invalid hierarchy and numeric geometry without blocking valid groups', () => { @@ -195,7 +343,7 @@ describe('compact cluster sector selection', () => { expect(Number.isNaN(points[2].x)).toBe(true) expect( Math.hypot(points[3].x - points[1].x, points[3].y - points[1].y), - ).toBeCloseTo(92, 6) + ).toBeCloseTo(INNER_RING_RADIUS, 6) expect(points[4]).toEqual(broadPoints[4]) expect(points[5]).toEqual(broadPoints[5]) expect(points[6]).toEqual(broadPoints[6]) @@ -283,7 +431,7 @@ describe('compact cluster sector selection', () => { expect(cross(7, 8)).toBeGreaterThan(0) }) - it('keeps every child in a large group on bounded, clear parent-centered arcs', () => { + it('keeps every child in a large group on bounded, clear concentric orbits', () => { const children = Array.from({ length: 14 }, (_, index) => 31 + index) const nodes: LayoutNode[] = [ { num: 16, blockedBy: [], parentIssue: null }, @@ -297,11 +445,15 @@ describe('compact cluster sector selection', () => { const points = placeDirectChildClusters(nodes, broadPoints, []) const radii = children.map((number) => Math.hypot(points[number].x, points[number].y)) - expect(radii.filter((radius) => Math.abs(radius - 92) < 0.001)).toHaveLength(4) - expect(radii.filter((radius) => Math.abs(radius - 126) < 0.001)).toHaveLength(10) + const distinctRadii = [...new Set(radii.map((radius) => radius.toFixed(3)))].map(Number) + expect(distinctRadii).toHaveLength(2) + expect(Math.min(...distinctRadii)).toBeGreaterThanOrEqual(INNER_RING_RADIUS) + expect(Math.min(...distinctRadii)).toBeLessThanOrEqual(INNER_RING_RADIUS + 210) + expect(Math.max(...distinctRadii) - Math.min(...distinctRadii)).toBeGreaterThanOrEqual(220) for (const number of children) { - expect(Math.hypot(points[number].x, points[number].y)).toBeLessThanOrEqual(126.001) + expect(Number.isFinite(points[number].x)).toBe(true) + expect(Number.isFinite(points[number].y)).toBe(true) } for (let left = 0; left < children.length; left++) { for (let right = left + 1; right < children.length; right++) { @@ -310,7 +462,7 @@ describe('compact cluster sector selection', () => { points[children[left]].x - points[children[right]].x, points[children[left]].y - points[children[right]].y, ), - ).toBeGreaterThanOrEqual(44) + ).toBeGreaterThanOrEqual(MIN_CHILD_CENTER_CLEARANCE) } } }) diff --git a/web/src/lib/starmap/cluster-layout.ts b/web/src/lib/starmap/cluster-layout.ts index fc0f61a..45f4bdc 100644 --- a/web/src/lib/starmap/cluster-layout.ts +++ b/web/src/lib/starmap/cluster-layout.ts @@ -1,4 +1,15 @@ import type { Edge, LayoutNode, Point } from './layout' +import { + boxesOverlap, + estimateLabelWidth, + labelBox, + orbitLabelFontSize, + ORBIT_LABEL_REFERENCE_SCALE, + outwardLabelGeometryAtScale, + segmentIntersectsBox, + type LabelBox, +} from './label-geometry' +import { validOrbitNodeNumbers } from './parent-topology' import { isMiniWorkflowEdge, workflowEdges, type WorkflowEdge } from './workflow' import { reverseEdgeKeys } from './workflow-visual' import { @@ -10,15 +21,12 @@ import { type Segment, } from './workflow-geometry' -export const FIRST_ARC_RADIUS = 92 -export const FIRST_ARC_CAPACITY = 5 -export const SECOND_ARC_RADIUS = 126 -export const ARC_STEP = Math.PI / 6 -export const MIN_CHILD_CENTER_CLEARANCE = 44 -export const UNRELATED_NODE_CLEARANCE = 42 +export const INNER_RING_RADIUS = 180 +export const MIN_CHILD_CENTER_CLEARANCE = 72 +export const UNRELATED_NODE_CLEARANCE = 64 export const DEPENDENCY_LINE_CLEARANCE = 18 export const CANDIDATE_SECTORS = 16 -export const CLEARANCE_SCORE_CAP = SECOND_ARC_RADIUS * 2 +export const CLEARANCE_SCORE_CAP = 800 export const NODE_CLEARANCE_SCORE_WEIGHT = 1 export const DEPENDENCY_CLEARANCE_SCORE_WEIGHT = 1 export const CROSSING_SCORE_PENALTY = @@ -30,6 +38,7 @@ export interface CandidateScoreInput { crossings: number nodeClearance: number dependencyClearance: number + labelCollisions?: number } interface Candidate extends CandidateScoreInput { @@ -55,6 +64,8 @@ interface EvaluationContext { parentPoint: Point currentPoints: Record placedCurveObstacles: SegmentObstacle[] + placedLabelObstacles: LabelBox[] + topLevelLabelObstacles: LabelBox[] } function siblingOrder(children: LayoutNode[]): LayoutNode[] { @@ -128,61 +139,100 @@ function hierarchyDepth( return depth } -function centeredOffsets(count: number): number[] { - return Array.from({ length: count }, (_, index) => (index - (count - 1) / 2) * ARC_STEP) +const MIN_RING_GAP = 220 +const MIN_RING_SLOT_ARC = 112 +const MAX_RING_SLOT_ARC = 170 +const TARGET_INNER_RING_CAPACITY = 7 +const LAYOUT_FONT_SIZE = 14 +const LAYOUT_STAR_RADIUS = 14 +const EXPANSION_STEPS = [0, 70, 140, 210] as const + +interface RingMetrics { + innerRadius: number + ringGap: number } -function staggeredSecondArcOffsets(count: number): number[] { - const sideCount = count / 2 - return [ - ...Array.from({ length: sideCount }, (_, index) => -(sideCount - index) * ARC_STEP), - ...Array.from({ length: sideCount }, (_, index) => (index + 1) * ARC_STEP), - ] +function childSlotArc(child: LayoutNode): number { + const fontSize = orbitLabelFontSize(ORBIT_LABEL_REFERENCE_SCALE) / ORBIT_LABEL_REFERENCE_SCALE + const width = estimateLabelWidth(child.num, child.title ?? '', fontSize) + return Math.min(MAX_RING_SLOT_ARC, Math.max(MIN_RING_SLOT_ARC, 70 + width * 0.25)) +} + +function ringMetrics(children: LayoutNode[]): RingMetrics { + const fontSize = orbitLabelFontSize(ORBIT_LABEL_REFERENCE_SCALE) / ORBIT_LABEL_REFERENCE_SCALE + const widths = children.map((child) => + estimateLabelWidth(child.num, child.title ?? '', fontSize), + ) + const maximumWidth = widths.length === 0 ? 0 : Math.max(...widths) + const innerFootprint = children + .slice(0, TARGET_INNER_RING_CAPACITY) + .reduce((sum, child) => sum + childSlotArc(child), 0) + return { + innerRadius: Math.max(INNER_RING_RADIUS, innerFootprint / (Math.PI * 2)), + ringGap: Math.max(MIN_RING_GAP, Math.min(340, 80 + maximumWidth * 0.55)), + } } -function firstArcCount(childCount: number): number { - if (childCount <= FIRST_ARC_CAPACITY) return childCount - return childCount % 2 === 0 ? FIRST_ARC_CAPACITY - 1 : FIRST_ARC_CAPACITY +function orbitRings(children: LayoutNode[], minimumRingCount = 1): LayoutNode[][] { + const metrics = ringMetrics(children) + const rings: LayoutNode[][] = [] + let occupiedArc = 0 + for (const child of children) { + let ring = rings.at(-1) + const radius = metrics.innerRadius + Math.max(0, rings.length - 1) * metrics.ringGap + const footprint = childSlotArc(child) + if (!ring || (ring.length > 0 && occupiedArc + footprint > Math.PI * 2 * radius)) { + ring = [] + rings.push(ring) + occupiedArc = 0 + } + ring.push(child) + occupiedArc += footprint + } + + while (rings.length < minimumRingCount) { + let splitIndex = -1 + for (let index = 0; index < rings.length; index++) { + if (rings[index].length > 1 && (splitIndex < 0 || rings[index].length > rings[splitIndex].length)) { + splitIndex = index + } + } + if (splitIndex < 0) break + const ring = rings[splitIndex] + const splitAt = Math.ceil(ring.length / 2) + rings.splice(splitIndex, 1, ring.slice(0, splitAt), ring.slice(splitAt)) + } + return rings } -function arcPoints( +export function orbitRingCounts(children: LayoutNode[]): number[] { + return orbitRings(children).map((ring) => ring.length) +} + +function orbitPoints( parent: Point, children: LayoutNode[], sector: number, - expanded = false, + radialExpansion = 0, + minimumRingCount = 1, ): Record { const childPoints: Record = {} const centerAngle = (sector / CANDIDATE_SECTORS) * Math.PI * 2 - if (expanded && children.length <= FIRST_ARC_CAPACITY) { - const offsets = centeredOffsets(children.length) - for (let index = 0; index < offsets.length; index++) { - const offset = offsets[index] - const angle = centerAngle + offset - childPoints[children[index].num] = { - x: parent.x + Math.cos(angle) * SECOND_ARC_RADIUS, - y: parent.y + Math.sin(angle) * SECOND_ARC_RADIUS, + const metrics = ringMetrics(children) + const rings = orbitRings(children, minimumRingCount) + for (const [ringIndex, ring] of rings.entries()) { + const radius = metrics.innerRadius + radialExpansion + ringIndex * metrics.ringGap + const count = ring.length + const step = (Math.PI * 2) / count + const stagger = ringIndex % 2 === 0 ? 0 : step / 2 + for (let slot = 0; slot < count; slot++) { + const child = ring[slot] + const angle = centerAngle + stagger + slot * step + childPoints[child.num] = { + x: parent.x + Math.cos(angle) * radius, + y: parent.y + Math.sin(angle) * radius, } } - return childPoints - } - - const firstCount = firstArcCount(children.length) - const secondCount = children.length - firstCount - const firstOffsets = centeredOffsets(firstCount) - const secondOffsets = firstCount % 2 === 0 - ? staggeredSecondArcOffsets(secondCount) - : centeredOffsets(secondCount) - const slots = [ - ...firstOffsets.map((offset) => ({ offset, radius: FIRST_ARC_RADIUS })), - ...secondOffsets.map((offset) => ({ offset, radius: SECOND_ARC_RADIUS })), - ] - - for (let index = 0; index < children.length; index++) { - const angle = centerAngle + slots[index].offset - childPoints[children[index].num] = { - x: parent.x + Math.cos(angle) * slots[index].radius, - y: parent.y + Math.sin(angle) * slots[index].radius, - } } return childPoints } @@ -195,6 +245,53 @@ function isFinitePoint(point: Point | undefined): point is Point { return point !== undefined && Number.isFinite(point.x) && Number.isFinite(point.y) } +function pointBox(point: Point, radius: number): LabelBox { + return { + x0: point.x - radius, + y0: point.y - radius, + x1: point.x + radius, + y1: point.y + radius, + } +} + +function topLevelLabelBoxes( + nodes: LayoutNode[], + points: Record, + validOrbitNodes: Set, +): LabelBox[] { + const gap = 4 + return nodes.flatMap((node) => { + if (validOrbitNodes.has(node.num)) return [] + const point = points[node.num] + if (!isFinitePoint(point)) return [] + const width = estimateLabelWidth(node.num, node.title ?? '', LAYOUT_FONT_SIZE) + const below = point.y + LAYOUT_STAR_RADIUS + gap + LAYOUT_FONT_SIZE * 0.82 + const above = point.y - LAYOUT_STAR_RADIUS - gap - LAYOUT_FONT_SIZE * 0.22 + return [ + labelBox(point.x, below, 'center', width, LAYOUT_FONT_SIZE), + labelBox(point.x, above, 'center', width, LAYOUT_FONT_SIZE), + ] + }) +} + +function clusterLabelBoxes( + children: LayoutNode[], + parent: Point, + childPoints: Record, +): Array<{ number: number; box: LabelBox }> { + return children.map((child) => ({ + number: child.num, + box: outwardLabelGeometryAtScale({ + parent, + child: childPoints[child.num], + number: child.num, + title: child.title ?? '', + scale: ORBIT_LABEL_REFERENCE_SCALE, + starRadius: LAYOUT_STAR_RADIUS, + }).box, + })) +} + function clusterCurves( parentNode: LayoutNode, children: LayoutNode[], @@ -253,6 +350,8 @@ function evaluateCandidate( parentPoint, currentPoints, placedCurveObstacles, + placedLabelObstacles, + topLevelLabelObstacles, } = context const clusterNumbers = new Set([parentNode.num, ...children.map((child) => child.num)]) const proposedPoints = { ...currentPoints, ...childPoints } @@ -261,6 +360,7 @@ function evaluateCandidate( .map((node) => proposedPoints[node.num]) .filter(isFinitePoint) const childPointList = children.map((child) => childPoints[child.num]) + const childLabels = clusterLabelBoxes(children, parentPoint, childPoints) const childClearances: number[] = [] for (let left = 0; left < childPointList.length; left++) { for (let right = left + 1; right < childPointList.length; right++) { @@ -279,6 +379,16 @@ function evaluateCandidate( ...dependencyObstacles(edges, clusterNumbers, proposedPoints), ...placedCurveObstacles, ] + const relationshipSegments: SegmentObstacle[] = [ + ...obstacles, + ...curves.flatMap(({ edge, curve }) => { + const samples = sampleQuadratic(curve) + return samples.slice(1).map((end, index) => ({ + edge, + segment: { start: samples[index], end }, + })) + }), + ] const dependencyClearances: number[] = [] let crossings = 0 for (const { edge: miniEdge, curve } of curves) { @@ -305,16 +415,56 @@ function evaluateCandidate( const childClearance = minimum(childClearances) const nodeClearance = Math.min(minimum(nodeClearances), minimum(curveNodeClearances)) const dependencyClearance = minimum(dependencyClearances) + let labelCollisions = 0 + const unrelatedLabels = [...placedLabelObstacles, ...topLevelLabelObstacles] + for (const childPoint of childPointList) { + const childBox = pointBox(childPoint, LAYOUT_STAR_RADIUS) + for (const unrelatedLabel of unrelatedLabels) { + if (boxesOverlap(childBox, unrelatedLabel)) labelCollisions++ + } + } + for (let left = 0; left < childLabels.length; left++) { + for (let right = left + 1; right < childLabels.length; right++) { + if (boxesOverlap(childLabels[left].box, childLabels[right].box)) labelCollisions++ + } + for (const child of children) { + if (child.num === childLabels[left].number) continue + if (boxesOverlap(childLabels[left].box, pointBox(childPoints[child.num], LAYOUT_STAR_RADIUS))) { + labelCollisions++ + } + } + for (const point of unrelatedPoints) { + if (boxesOverlap(childLabels[left].box, pointBox(point, LAYOUT_STAR_RADIUS))) { + labelCollisions++ + } + } + for (const unrelatedLabel of placedLabelObstacles) { + if (boxesOverlap(childLabels[left].box, unrelatedLabel)) labelCollisions++ + } + for (const topLevelLabel of topLevelLabelObstacles) { + if (boxesOverlap(childLabels[left].box, topLevelLabel)) labelCollisions++ + } + for (const { edge, segment } of relationshipSegments) { + if (edge.from === childLabels[left].number || edge.to === childLabels[left].number) { + continue + } + if (segmentIntersectsBox(segment.start, segment.end, childLabels[left].box)) { + labelCollisions++ + } + } + } return { childPoints, collisionFree: crossings === 0 && + labelCollisions === 0 && childClearance >= MIN_CHILD_CENTER_CLEARANCE && nodeClearance >= UNRELATED_NODE_CLEARANCE && dependencyClearance >= DEPENDENCY_LINE_CLEARANCE, crossings, nodeClearance, dependencyClearance, + labelCollisions, } } @@ -328,7 +478,8 @@ function candidateScore(candidate: CandidateScoreInput): number { boundedClearanceScore(candidate.nodeClearance) * NODE_CLEARANCE_SCORE_WEIGHT + boundedClearanceScore(candidate.dependencyClearance) * DEPENDENCY_CLEARANCE_SCORE_WEIGHT - - candidate.crossings * CROSSING_SCORE_PENALTY + candidate.crossings * CROSSING_SCORE_PENALTY - + (candidate.labelCollisions ?? 0) * CROSSING_SCORE_PENALTY ) } @@ -353,15 +504,17 @@ export function placeDirectChildClusters( const points = Object.fromEntries( Object.entries(broadPoints).map(([number, point]) => [number, { ...point }]), ) as Record - const present = new Set(nodes.map((node) => node.num)) + const validOrbitNodes = validOrbitNodeNumbers(nodes) const byNumber = new Map(nodes.map((node) => [node.num, node])) const depthCache = new Map() const childrenByParent = new Map() const placedCurveObstacles: SegmentObstacle[] = [] + const placedLabelObstacles: LabelBox[] = [] + const topLevelLabelObstacles = topLevelLabelBoxes(nodes, points, validOrbitNodes) for (const node of nodes) { const parent = node.parentIssue - if (parent === null || parent === node.num || !present.has(parent)) continue + if (parent === null || !validOrbitNodes.has(node.num)) continue const children = childrenByParent.get(parent) ?? [] children.push(node) childrenByParent.set(parent, children) @@ -390,22 +543,28 @@ export function placeDirectChildClusters( parentPoint: parent, currentPoints: points, placedCurveObstacles, + placedLabelObstacles, + topLevelLabelObstacles, } - const candidates = (expanded: boolean) => + const candidates = (radialExpansion: number, minimumRingCount: number) => Array.from({ length: CANDIDATE_SECTORS }, (_, offset) => { const sector = (start + offset) % CANDIDATE_SECTORS return evaluateCandidate( evaluationContext, - arcPoints(parent, ordered, sector, expanded), + orbitPoints(parent, ordered, sector, radialExpansion, minimumRingCount), ) }) - const compactCandidates = candidates(false) - let pool = compactCandidates - if ( - ordered.length <= FIRST_ARC_CAPACITY && - !compactCandidates.some((candidate) => candidate.collisionFree) - ) { - pool = [...compactCandidates, ...candidates(true)] + const naturalRingCount = orbitRingCounts(ordered).length + const baseCandidates = candidates(EXPANSION_STEPS[0], naturalRingCount) + let pool = baseCandidates + if (!baseCandidates.some((candidate) => candidate.collisionFree)) { + const maximumRingCount = Math.min(ordered.length, naturalRingCount + 3) + pool = EXPANSION_STEPS.flatMap((radialExpansion) => + Array.from( + { length: maximumRingCount - naturalRingCount + 1 }, + (_, offset) => candidates(radialExpansion, naturalRingCount + offset), + ).flat(), + ) } let selected: Candidate | null = null for (const candidate of pool) { @@ -413,6 +572,9 @@ export function placeDirectChildClusters( } if (selected) { Object.assign(points, selected.childPoints) + placedLabelObstacles.push( + ...clusterLabelBoxes(ordered, parent, selected.childPoints).map(({ box }) => box), + ) for (const { edge, curve } of clusterCurves( parentNode, ordered, diff --git a/web/src/lib/starmap/label-geometry.test.ts b/web/src/lib/starmap/label-geometry.test.ts new file mode 100644 index 0000000..fbf6382 --- /dev/null +++ b/web/src/lib/starmap/label-geometry.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + estimateLabelWidth, + orbitLabelFontSize, + ORBIT_LABEL_REFERENCE_SCALE, + outwardLabelGeometry, + outwardLabelGeometryAtScale, + segmentIntersectsBox, +} from './label-geometry' + +const parent = { x: 0, y: 0 } +const base = { + parent, + textWidth: 84, + fontSize: 14, + starRadius: 10, +} + +describe('outward subissue label geometry', () => { + it('extends horizontal labels away from the parent', () => { + const right = outwardLabelGeometry({ ...base, child: { x: 100, y: 0 } }) + const left = outwardLabelGeometry({ ...base, child: { x: -100, y: 0 } }) + + expect(right.align).toBe('left') + expect(right.box.x0).toBeGreaterThan(100) + expect(left.align).toBe('right') + expect(left.box.x1).toBeLessThan(-100) + }) + + it('centres vertical labels beyond the child orbit', () => { + const above = outwardLabelGeometry({ ...base, child: { x: 0, y: -100 } }) + const below = outwardLabelGeometry({ ...base, child: { x: 0, y: 100 } }) + + expect(above.align).toBe('center') + expect(above.box.y1).toBeLessThan(-100) + expect(below.align).toBe('center') + expect(below.box.y0).toBeGreaterThan(100) + }) + + it('uses a deterministic truncated-title footprint', () => { + expect(estimateLabelWidth(7, 'Short')).toBe(estimateLabelWidth(7, 'Short')) + expect(estimateLabelWidth(7, 'A considerably longer subissue title')).toBeGreaterThan( + estimateLabelWidth(7, 'Short'), + ) + expect( + estimateLabelWidth(7, 'A'.repeat(200)), + ).toBeLessThan(estimateLabelWidth(7, 'A'.repeat(200), 28)) + }) + + it('detects relationship segments that cross a reserved label box', () => { + const box = { x0: 10, y0: 10, x1: 30, y1: 20 } + + expect(segmentIntersectsBox({ x: 0, y: 15 }, { x: 40, y: 15 }, box)).toBe(true) + expect(segmentIntersectsBox({ x: 15, y: 12 }, { x: 25, y: 18 }, box)).toBe(true) + expect(segmentIntersectsBox({ x: 0, y: 0 }, { x: 40, y: 5 }, box)).toBe(false) + }) + + it('projects the layout reference bounds to the renderer bounds exactly', () => { + const number = 31 + const title = 'A descriptive subissue title' + const world = outwardLabelGeometryAtScale({ + parent, + child: { x: 180, y: 0 }, + number, + title, + scale: ORBIT_LABEL_REFERENCE_SCALE, + starRadius: 14, + }) + const scale = ORBIT_LABEL_REFERENCE_SCALE + const screen = outwardLabelGeometry({ + parent, + child: { x: 180 * scale, y: 0 }, + textWidth: estimateLabelWidth(number, title, orbitLabelFontSize(scale)), + fontSize: orbitLabelFontSize(scale), + starRadius: 14 * scale, + }) + + expect(world.box.x0 * scale).toBeCloseTo(screen.box.x0, 6) + expect(world.box.y0 * scale).toBeCloseTo(screen.box.y0, 6) + expect(world.box.x1 * scale).toBeCloseTo(screen.box.x1, 6) + expect(world.box.y1 * scale).toBeCloseTo(screen.box.y1, 6) + + for (const reducedScale of [0.3, 0.12]) { + const reduced = outwardLabelGeometryAtScale({ + parent, + child: { x: 180, y: 0 }, + number, + title, + scale: reducedScale, + starRadius: 14, + }) + expect(reduced.box.x0).toBeCloseTo(world.box.x0, 6) + expect(reduced.box.y0).toBeCloseTo(world.box.y0, 6) + expect(reduced.box.x1).toBeCloseTo(world.box.x1, 6) + expect(reduced.box.y1).toBeCloseTo(world.box.y1, 6) + } + }) +}) diff --git a/web/src/lib/starmap/label-geometry.ts b/web/src/lib/starmap/label-geometry.ts new file mode 100644 index 0000000..a63ad0a --- /dev/null +++ b/web/src/lib/starmap/label-geometry.ts @@ -0,0 +1,181 @@ +import type { Point } from './layout' + +export interface LabelBox { + x0: number + y0: number + x1: number + y1: number +} + +export type LabelAlign = 'left' | 'right' | 'center' + +export interface OutwardLabelInput { + parent: Point + child: Point + textWidth: number + fontSize: number + starRadius: number + unitScale?: number +} + +export interface LabelGeometry { + x: number + y: number + align: LabelAlign + box: LabelBox +} + +export interface ScaledOutwardLabelInput { + parent: { x: number; y: number } + child: { x: number; y: number } + number: number + title: string + scale: number + starRadius: number +} + +const DEFAULT_GAP = 8 +const VERTICAL_ALIGNMENT_BAND = 0.5 +export const SUBISSUE_TITLE_BUDGET = 40 +export const ORBIT_LABEL_REFERENCE_SCALE = 0.42 +const MAX_SUBISSUE_MARKER = subissueMarker(true, true) +const AVERAGE_GLYPH_EM = 0.56 +const HORIZONTAL_PADDING = 3 +const VERTICAL_PADDING = 2 + +export function clipTitle(title: string, budget: number): string { + if (title.length <= budget) return title + const cut = title.slice(0, budget) + const space = cut.lastIndexOf(' ') + return (space >= budget - 6 && space > 0 ? cut.slice(0, space) : cut.trimEnd()) + '…' +} + +export function issueNumberText(number: number): string { + return `${number < 10 ? '0' : ''}${number}` +} + +export function subissueMarker(current: boolean, ready: boolean): string { + if (current && ready) return 'CURRENT / READY · ' + if (current) return 'CURRENT · ' + return ready ? 'READY · ' : '' +} + +export function subissueLabelText(number: number, title: string): string { + return `${issueNumberText(number)} ${clipTitle(title, SUBISSUE_TITLE_BUDGET)}` +} + +export function estimateLabelWidth(number: number, title: string, fontSize = 14): number { + const text = `${MAX_SUBISSUE_MARKER}${subissueLabelText(number, title)}` + return text.length * fontSize * AVERAGE_GLYPH_EM +} + +export function orbitLabelFontSize(scale: number): number { + if (scale < ORBIT_LABEL_REFERENCE_SCALE) { + return orbitLabelFontSize(ORBIT_LABEL_REFERENCE_SCALE) * + (scale / ORBIT_LABEL_REFERENCE_SCALE) + } + return Math.min(16, Math.max(10, 13 * Math.pow(scale, 0.3))) +} + +export function boxesOverlap(left: LabelBox, right: LabelBox): boolean { + return left.x0 < right.x1 && right.x0 < left.x1 && left.y0 < right.y1 && right.y0 < left.y1 +} + +export function segmentIntersectsBox( + start: { x: number; y: number }, + end: { x: number; y: number }, + box: LabelBox, +): boolean { + const dx = end.x - start.x + const dy = end.y - start.y + let entry = 0 + let exit = 1 + const boundaries = [ + { direction: -dx, distance: start.x - box.x0 }, + { direction: dx, distance: box.x1 - start.x }, + { direction: -dy, distance: start.y - box.y0 }, + { direction: dy, distance: box.y1 - start.y }, + ] + for (const { direction, distance } of boundaries) { + if (direction === 0) { + if (distance < 0) return false + continue + } + const ratio = distance / direction + if (direction < 0) entry = Math.max(entry, ratio) + else exit = Math.min(exit, ratio) + if (entry > exit) return false + } + return true +} + +export function labelBox( + x: number, + y: number, + align: LabelAlign, + textWidth: number, + fontSize: number, + paddingScale = 1, +): LabelBox { + const textLeft = align === 'left' ? x : align === 'right' ? x - textWidth : x - textWidth / 2 + return { + x0: textLeft - HORIZONTAL_PADDING * paddingScale, + y0: y - fontSize * 0.82 - VERTICAL_PADDING * paddingScale, + x1: textLeft + textWidth + HORIZONTAL_PADDING * paddingScale, + y1: y + fontSize * 0.22 + VERTICAL_PADDING * paddingScale, + } +} + +export function outwardLabelGeometry(input: OutwardLabelInput): LabelGeometry { + const dx = input.child.x - input.parent.x + const dy = input.child.y - input.parent.y + const length = Math.hypot(dx, dy) || 1 + const ux = dx / length + const uy = dy / length + const unitScale = input.unitScale ?? 1 + const distance = input.starRadius + DEFAULT_GAP * unitScale + const anchorX = input.child.x + ux * distance + const anchorY = input.child.y + uy * distance + const vertical = Math.abs(ux) <= Math.abs(uy) * VERTICAL_ALIGNMENT_BAND + + let align: LabelAlign + let x = anchorX + let y: number + if (vertical) { + align = 'center' + y = uy < 0 + ? anchorY - input.fontSize * 0.22 - VERTICAL_PADDING * unitScale + : anchorY + input.fontSize * 0.82 + VERTICAL_PADDING * unitScale + } else { + align = ux < 0 ? 'right' : 'left' + y = anchorY + input.fontSize * 0.3 + } + + const box = labelBox(x, y, align, input.textWidth, input.fontSize, unitScale) + return { x, y, align, box } +} + +export function outwardLabelGeometryAtScale(input: ScaledOutwardLabelInput): LabelGeometry { + const scale = Math.max(input.scale, Number.EPSILON) + const fontSize = orbitLabelFontSize(scale) + const unitScale = Math.min(1, scale / ORBIT_LABEL_REFERENCE_SCALE) + const screen = outwardLabelGeometry({ + parent: { x: input.parent.x * scale, y: input.parent.y * scale }, + child: { x: input.child.x * scale, y: input.child.y * scale }, + textWidth: estimateLabelWidth(input.number, input.title, fontSize), + fontSize, + starRadius: input.starRadius * scale, + unitScale, + }) + return { + x: screen.x / scale, + y: screen.y / scale, + align: screen.align, + box: { + x0: screen.box.x0 / scale, + y0: screen.box.y0 / scale, + x1: screen.box.x1 / scale, + y1: screen.box.y1 / scale, + }, + } +} diff --git a/web/src/lib/starmap/layout.test.ts b/web/src/lib/starmap/layout.test.ts index 14ebfc0..73ff0c0 100644 --- a/web/src/lib/starmap/layout.test.ts +++ b/web/src/lib/starmap/layout.test.ts @@ -28,7 +28,19 @@ describe('workflow-aware layout', () => { expect(structureSignature(statuses)).toBe(structureSignature(withParent)) }) - it('places direct children on a compact parent-local arc without moving broad anchors', () => { + it('treats a title footprint change as structural only when labels affect an orbit', () => { + const short: LayoutNode[] = [ + { num: 16, title: 'Parent', blockedBy: [], parentIssue: null }, + { num: 37, title: 'Short', blockedBy: [], parentIssue: 16 }, + ] + const long = short.map((node) => + node.num === 37 ? { ...node, title: 'A much longer operator-facing subissue title' } : node, + ) + + expect(structureSignature(long)).not.toBe(structureSignature(short)) + }) + + it('places direct children on a complete parent-local orbit without moving broad anchors', () => { const broad: LayoutNode[] = [ { num: 16, blockedBy: [], parentIssue: null }, { num: 34, blockedBy: [], parentIssue: null }, @@ -49,22 +61,22 @@ describe('workflow-aware layout', () => { clusterPoints[34].x - clusterPoints[16].x, clusterPoints[34].y - clusterPoints[16].y, ), - ).toBeCloseTo(92, 6) + ).toBeCloseTo(180, 6) expect( Math.hypot( clusterPoints[35].x - clusterPoints[16].x, clusterPoints[35].y - clusterPoints[16].y, ), - ).toBeCloseTo(92, 6) + ).toBeCloseTo(180, 6) expect( Math.hypot( clusterPoints[35].x - clusterPoints[34].x, clusterPoints[35].y - clusterPoints[34].y, ), - ).toBeCloseTo(47.62, 2) + ).toBeCloseTo(360, 6) }) - it('orders a sibling blocker sequence around the arc independently of snapshot order', () => { + it('orders a sibling blocker sequence around the orbit independently of snapshot order', () => { const sequence: LayoutNode[] = [ { num: 16, blockedBy: [], parentIssue: null }, { num: 50, blockedBy: [], parentIssue: 16 }, @@ -89,7 +101,7 @@ describe('workflow-aware layout', () => { expect(cross(10, 40)).toBeGreaterThan(0) }) - it('fits five direct children on the compact first arc with the approved clearance', () => { + it('fits five direct children around one complete orbit with the approved clearance', () => { const nodes: LayoutNode[] = [ { num: 16, blockedBy: [], parentIssue: null }, ...[31, 32, 33, 34, 35].map((num) => ({ num, blockedBy: [], parentIssue: 16 })), @@ -99,12 +111,12 @@ describe('workflow-aware layout', () => { for (const number of [31, 32, 33, 34, 35]) { expect( Math.hypot(points[number].x - points[16].x, points[number].y - points[16].y), - ).toBeCloseTo(92, 6) + ).toBeCloseTo(180, 6) } for (const [left, right] of [[31, 32], [32, 33], [33, 34], [34, 35]]) { expect( Math.hypot(points[left].x - points[right].x, points[left].y - points[right].y), - ).toBeGreaterThanOrEqual(44) + ).toBeGreaterThanOrEqual(72) } }) }) diff --git a/web/src/lib/starmap/layout.ts b/web/src/lib/starmap/layout.ts index d7deb27..2ec3dbc 100644 --- a/web/src/lib/starmap/layout.ts +++ b/web/src/lib/starmap/layout.ts @@ -13,11 +13,14 @@ // Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. import { placeDirectChildClusters } from './cluster-layout' +import { validOrbitNodeNumbers } from './parent-topology' import { workflowEdges, type WorkflowNode } from './workflow' export const TAU = 6.2831853 -export interface LayoutNode extends WorkflowNode {} +export interface LayoutNode extends WorkflowNode { + title?: string +} export interface Point { x: number @@ -150,6 +153,7 @@ export function computeLayout(nodes: LayoutNode[]): Record { // their statuses. The renderer recomputes layout only when this changes, so a // pure status push keeps every star exactly where it was. export function structureSignature(nodes: LayoutNode[]): string { + const validOrbitNodes = validOrbitNodeNumbers(nodes) const nums = nodes .map((n) => n.num) .sort((a, b) => a - b) @@ -158,5 +162,10 @@ export function structureSignature(nodes: LayoutNode[]): string { .map((edge) => `${edge.from}>${edge.to}:${edge.roles.join('+')}`) .sort() .join(',') - return `${nums}|${edges}` + const orbitLabels = nodes + .filter((node) => validOrbitNodes.has(node.num)) + .map((node) => `${node.num}:${JSON.stringify(node.title ?? '')}`) + .sort() + .join(',') + return `${nums}|${edges}|${orbitLabels}` } diff --git a/web/src/lib/starmap/parent-topology.ts b/web/src/lib/starmap/parent-topology.ts new file mode 100644 index 0000000..b6a0605 --- /dev/null +++ b/web/src/lib/starmap/parent-topology.ts @@ -0,0 +1,36 @@ +export interface ParentTopologyNode { + num: number + parentIssue: number | null +} + +export function validOrbitNodeNumbers(nodes: ParentTopologyNode[]): Set { + const byNumber = new Map(nodes.map((node) => [node.num, node])) + const state = new Map() + + const terminatesAtRoot = (number: number): boolean => { + const prior = state.get(number) + if (prior === 'valid') return true + if (prior === 'invalid' || prior === 'visiting') return false + const node = byNumber.get(number) + if (!node) return false + if (node.parentIssue === null) { + state.set(number, 'valid') + return true + } + if (node.parentIssue === number || !byNumber.has(node.parentIssue)) { + state.set(number, 'invalid') + return false + } + + state.set(number, 'visiting') + const valid = terminatesAtRoot(node.parentIssue) + state.set(number, valid ? 'valid' : 'invalid') + return valid + } + + const valid = new Set() + for (const node of nodes) { + if (node.parentIssue !== null && terminatesAtRoot(node.num)) valid.add(node.num) + } + return valid +} diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 06ba59d..81b42ae 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -69,6 +69,15 @@ function pan(host: HTMLElement, dx: number, dy: number): void { window.dispatchEvent(new MouseEvent('mouseup', { clientX: dx, clientY: dy, bubbles: true })) } +function zoomOut(host: HTMLElement): void { + const canvas = host.querySelector('canvas')! + for (let index = 0; index < 20; index++) { + canvas.dispatchEvent( + new WheelEvent('wheel', { cancelable: true, deltaY: 500, clientX: 500, clientY: 350 }), + ) + } +} + describe('deterministic layout', () => { it('lays the same data out to the same positions every time', () => { const a = computeLayout(fixture()) @@ -91,8 +100,11 @@ describe('deterministic layout', () => { describe('the island seam', () => { let sm: StarMap + let host: HTMLDivElement beforeEach(() => { - sm = mounted().sm + const instance = mounted() + sm = instance.sm + host = instance.host }) it('renders all five base states without a 2D context', () => { @@ -171,6 +183,148 @@ describe('the island seam', () => { expect(emitted.at(-1)).toBe(null) }) + it('gives subissues a larger invisible target without changing top-level targets', () => { + sm.setModel([ + { num: 16, slug: '16', title: 'Parent', type: 'issue', status: 'open', frontier: true, blockedBy: [], parentIssue: null }, + { num: 31, slug: '31', title: 'Child', type: 'task', status: 'open', frontier: false, blockedBy: [], parentIssue: 16 }, + { num: 99, slug: '99', title: 'Top level', type: 'issue', status: 'open', frontier: true, blockedBy: [], parentIssue: null }, + ]) + const child = sm.screenOf(31)! + const topLevel = sm.screenOf(99)! + + expect(sm.selectAtScreen(child.x + 24, child.y)).toBe(31) + expect(sm.selectAtScreen(topLevel.x + 24, topLevel.y)).toBe(null) + }) + + it('does not enlarge targets for nodes in a parent cycle', () => { + sm.setModel([ + { num: 6, slug: '6', title: 'Cycle A', type: 'task', status: 'open', frontier: false, blockedBy: [], parentIssue: 7 }, + { num: 7, slug: '7', title: 'Cycle B', type: 'task', status: 'open', frontier: false, blockedBy: [], parentIssue: 6 }, + { num: 99, slug: '99', title: 'Anchor', type: 'issue', status: 'open', frontier: true, blockedBy: [], parentIssue: null }, + ]) + const cycle = sm.screenOf(6)! + + expect(sm.selectAtScreen(cycle.x + 24, cycle.y)).toBe(null) + }) + + it('chooses the nearest subissue in overlapping targets with a numeric tie-breaker', () => { + const children = [31, 32, 33, 34] + sm.setModel([ + { num: 16, slug: '16', title: 'Parent', type: 'issue', status: 'open', frontier: true, blockedBy: [], parentIssue: null }, + ...children.map((num) => ({ + num, + slug: `${num}`, + title: `Child ${num}`, + type: 'task', + status: 'open' as const, + frontier: false, + blockedBy: [], + parentIssue: 16, + })), + ]) + zoomOut(host) + const pairs = children.flatMap((left, index) => + children.slice(index + 1).map((right) => { + const a = sm.screenOf(left)! + const b = sm.screenOf(right)! + return { left, right, a, b, distance: Math.hypot(a.x - b.x, a.y - b.y) } + }), + ) + const nearest = pairs.sort((a, b) => a.distance - b.distance)[0] + expect(nearest.distance).toBeLessThan(46) + + const towardRight = { + x: nearest.a.x * 0.6 + nearest.b.x * 0.4, + y: nearest.a.y * 0.6 + nearest.b.y * 0.4, + } + expect(sm.selectAtScreen(towardRight.x, towardRight.y)).toBe(nearest.left) + expect( + sm.selectAtScreen( + (nearest.a.x + nearest.b.x) / 2, + (nearest.a.y + nearest.b.y) / 2, + ), + ).toBe(Math.min(nearest.left, nearest.right)) + }) + + it('preserves last-drawn arbitration for overlapping top-level targets', () => { + const tickets: Ticket[] = Array.from({ length: 14 }, (_, index) => ({ + num: index + 1, + slug: `${index + 1}`, + title: `Top-level issue ${index + 1}`, + type: 'issue', + status: 'open', + frontier: true, + blockedBy: [], + parentIssue: null, + })) + sm.setModel(tickets) + zoomOut(host) + const points = tickets.map((ticket) => ({ number: ticket.num, point: sm.screenOf(ticket.num)! })) + const nearest = points.flatMap((left, index) => + points.slice(index + 1).map((right) => ({ + left, + right, + distance: Math.hypot(left.point.x - right.point.x, left.point.y - right.point.y), + })), + ).sort((a, b) => a.distance - b.distance)[0] + const click = { + x: (nearest.left.point.x + nearest.right.point.x) / 2, + y: (nearest.left.point.y + nearest.right.point.y) / 2, + } + const eligible = points + .filter(({ point }) => Math.hypot(point.x - click.x, point.y - click.y) < 14) + .map(({ number }) => number) + + expect(eligible.length).toBeGreaterThan(1) + expect(sm.selectAtScreen(click.x, click.y)).toBe(eligible.at(-1)) + }) + + it('lets a nearer top-level star win a cross-type target overlap', () => { + const children = [31, 32, 33, 34] + const topLevel = Array.from({ length: 14 }, (_, index) => index + 1) + sm.setModel([ + ...topLevel.map((num) => ({ + num, + slug: `${num}`, + title: `Top-level ${num}`, + type: 'issue', + status: 'open' as const, + frontier: true, + blockedBy: [], + parentIssue: null, + })), + { num: 16, slug: '16', title: 'Parent', type: 'issue', status: 'open', frontier: true, blockedBy: [], parentIssue: null }, + ...children.map((num) => ({ + num, + slug: `${num}`, + title: `Child ${num}`, + type: 'task', + status: 'open' as const, + frontier: false, + blockedBy: [], + parentIssue: 16, + })), + ]) + zoomOut(host) + const pairs = topLevel.flatMap((top) => + children.map((child) => { + const topPoint = sm.screenOf(top)! + const childPoint = sm.screenOf(child)! + return { + top, + child, + topPoint, + childPoint, + distance: Math.hypot(topPoint.x - childPoint.x, topPoint.y - childPoint.y), + } + }), + ).sort((left, right) => left.distance - right.distance) + const overlap = pairs[0] + expect(overlap.distance).toBeLessThan(29) + + expect(sm.selectAtScreen(overlap.topPoint.x, overlap.topPoint.y)).toBe(overlap.top) + }) + it('seats a selected star in the free rect the pane leaves, in either docking', () => { // Viewport is 1000×700 (see mounted()). A star must never sit under the pane. sm.setModel(fixture()) @@ -660,6 +814,48 @@ describe('label placement', () => { b: { x0: number; y0: number; x1: number; y1: number }) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 + it('aligns subissue labels outward while keeping the parent label centred', () => { + const tickets: Ticket[] = [ + { num: 16, slug: '16', title: 'Parent issue', type: 'issue', status: 'open', blockedBy: [], parentIssue: null, frontier: true }, + ...[31, 32, 33, 34].map((num) => ({ + num, + slug: `${num}`, + title: `Subissue ${num}`, + type: 'task', + status: 'open' as const, + blockedBy: [], + parentIssue: 16, + frontier: false, + })), + ] + const { sm, labels } = place(tickets) + const points = sm.positions() + const parentLabel = labels.find((label) => label.text.startsWith('16')) + + expect(parentLabel?.align).toBe('center') + for (const number of [31, 32, 33, 34]) { + const label = labels.find((candidate) => candidate.text.startsWith(`${number}`)) + const dx = points[number].x - points[16].x + const dy = points[number].y - points[16].y + const expected = Math.abs(dx) <= Math.abs(dy) * 0.5 + ? 'center' + : dx < 0 + ? 'right' + : 'left' + expect(label?.align, `subissue #${number} label`).toBe(expected) + } + }) + + it('keeps labels centred for nodes in a parent cycle', () => { + const { labels } = place([ + { num: 6, slug: '6', title: 'Cycle A', type: 'task', status: 'open', frontier: false, blockedBy: [], parentIssue: 7 }, + { num: 7, slug: '7', title: 'Cycle B', type: 'task', status: 'open', frontier: false, blockedBy: [], parentIssue: 6 }, + ]) + + expect(labels.find((label) => label.text.startsWith('06'))?.align).toBe('center') + expect(labels.find((label) => label.text.startsWith('07'))?.align).toBe('center') + }) + it('never draws a label across a star', () => { const tickets = CROWDED const { sm, boxes } = place(tickets) diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 3874247..01585ce 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -28,8 +28,24 @@ import { workflowVisualState, type WorkflowVisualState, } from './workflow-visual' +import { + boxesOverlap, + clipTitle, + issueNumberText, + labelBox, + orbitLabelFontSize, + ORBIT_LABEL_REFERENCE_SCALE, + outwardLabelGeometryAtScale, + subissueLabelText, + subissueMarker, + type LabelAlign, + type LabelBox, +} from './label-geometry' +import { validOrbitNodeNumbers } from './parent-topology' import type { Ticket } from './model' +export { clipTitle } from './label-geometry' + export type SelectHandler = (num: number | null) => void // Fallback only — used until the wrapper's first setBackground() call. Ticket @@ -43,6 +59,7 @@ const CONTEXT_EDGE_ALPHA = 0.45 const SELECTED_EDGE_WIDTH_SCALE = 1.7 const SELECTED_EDGE_ARROW_SCALE = 1.25 const SUBISSUE_RIM = 'rgba(170,145,255,0.82)' +const HIT_DISTANCE_EPSILON = 1e-9 interface RenderEdge extends WorkflowEdge { state: WorkflowVisualState @@ -72,21 +89,15 @@ interface LabelDraw { text: string x: number y: number + align: LabelAlign + fontSize: number fill: string alpha: number } // A screen-space rectangle. Both stars and already-placed labels become one of // these, so the solver has a single kind of thing to test against. -interface Box { - x0: number - y0: number - x1: number - y1: number -} -function hits(a: Box, b: Box): boolean { - return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 -} +type Box = LabelBox // Who gets first pick of the good slots. A contested slot should go to the star // the operator is most likely to be reading — the selected one, then the bright @@ -140,7 +151,7 @@ type Side = typeof BELOW | typeof ABOVE // anchors (a bare number at the bottom, the full title at the top) and lifts // everything between them, so the title only shortens sharply once the stars // have genuinely closed up. -const TITLE_MIN_SCALE = 0.42 +const TITLE_MIN_SCALE = ORBIT_LABEL_REFERENCE_SCALE const TITLE_FULL_SCALE = 1.6 const TITLE_MIN_CHARS = 12 const TITLE_MAX_CHARS = 60 @@ -156,16 +167,6 @@ export function titleBudget(scale: number): number { ) } -// Clip a title to `budget`, preferring a word boundary near the cut. At the -// short end of the ramp "The agent a…" is a worse label than "The agent…", and -// the difference costs one lastIndexOf. -export function clipTitle(title: string, budget: number): string { - if (title.length <= budget) return title - const cut = title.slice(0, budget) - const sp = cut.lastIndexOf(' ') - return (sp >= budget - 6 && sp > 0 ? cut.slice(0, sp) : cut.trimEnd()) + '…' -} - // How long one ticker line lingers before it fades out, and how long the fade // takes — the live map's answer to "what just changed under me?" is one glance, // then calm again. @@ -251,6 +252,7 @@ export class StarMap { #nodes: Node[] = [] #byNum = new Map() + #validOrbitNodes = new Set() #edges: RenderEdge[] = [] #miniCurve: MutableMiniCurve = { control: { x: 0, y: 0 }, bow: 0 } #miniCurveStart = { x: 0, y: 0 } @@ -345,6 +347,7 @@ export class StarMap { this.#focus = analyzeFocus(tickets, currentIssue) const focusChanged = priorFocus !== focusSignature(this.#focus) const sig = structureSignature(tickets) + this.#validOrbitNodes = validOrbitNodeNumbers(tickets) this.#resolved = new Set(tickets.filter((t) => t.status === 'resolved').map((t) => t.num)) if (sig === this.#sig && this.#nodes.length) { @@ -379,7 +382,14 @@ export class StarMap { // memory is exactly what we want kept. this.#labelSide.clear() this.#sig = sig - const pts = computeLayout(tickets.map(({ num, blockedBy, parentIssue }) => ({ num, blockedBy, parentIssue }))) + const pts = computeLayout( + tickets.map(({ num, title, blockedBy, parentIssue }) => ({ + num, + title, + blockedBy, + parentIssue, + })), + ) this.#nodes = tickets.map((t) => { const p = pts[t.num] return { @@ -555,12 +565,50 @@ export class StarMap { // exact path a click drives. Returns the selected ticket number, or null for a // click on empty space (which deselects). selectAtScreen(sx: number, sy: number): number | null { - let hit: Node | null = null + let topLevelHit: Node | null = null + let nearestTopLevelHit: Node | null = null + let nearestTopLevelDistance = Number.POSITIVE_INFINITY + let subissueHit: Node | null = null + let subissueDistance = Number.POSITIVE_INFINITY for (const n of this.#nodes) { const px = n._x * this.#cam.s + this.#cam.x const py = n._y * this.#cam.s + this.#cam.y - const r = Math.max(14, this.#radius(n) * this.#cam.s + 10) - if (Math.hypot(sx - px, sy - py) < r) hit = n + const baseRadius = Math.max(14, this.#radius(n) * this.#cam.s + 10) + const hasValidParent = this.#validOrbitNodes.has(n.num) + const radius = hasValidParent ? Math.max(28, baseRadius + 8) : baseRadius + const distance = Math.hypot(sx - px, sy - py) + if (distance >= radius) continue + if (!hasValidParent) { + topLevelHit = n + if ( + distance < nearestTopLevelDistance - HIT_DISTANCE_EPSILON || + (Math.abs(distance - nearestTopLevelDistance) <= HIT_DISTANCE_EPSILON && + (nearestTopLevelHit === null || n.num < nearestTopLevelHit.num)) + ) { + nearestTopLevelHit = n + nearestTopLevelDistance = distance + } + continue + } + if ( + distance < subissueDistance - HIT_DISTANCE_EPSILON || + (Math.abs(distance - subissueDistance) <= HIT_DISTANCE_EPSILON && + (subissueHit === null || n.num < subissueHit.num)) + ) { + subissueHit = n + subissueDistance = distance + } + } + let hit = topLevelHit + if (subissueHit && nearestTopLevelHit) { + hit = + subissueDistance < nearestTopLevelDistance - HIT_DISTANCE_EPSILON || + (Math.abs(subissueDistance - nearestTopLevelDistance) <= HIT_DISTANCE_EPSILON && + subissueHit.num < nearestTopLevelHit.num) + ? subissueHit + : nearestTopLevelHit + } else if (subissueHit) { + hit = subissueHit } const num = hit ? hit.num : null if (hit) hit.flare = Math.max(hit.flare, 0.6) @@ -1262,13 +1310,13 @@ export class StarMap { cache = this.#solveLabels(g, key) this.#labelCache = cache } - g.textAlign = 'center' - g.font = cache.fs.toFixed(1) + 'px ui-sans-serif,system-ui,sans-serif' g.shadowColor = 'rgba(0,0,0,0.85)' g.shadowBlur = 4 for (const it of cache.items) { + g.font = it.fontSize.toFixed(1) + 'px ui-sans-serif,system-ui,sans-serif' g.globalAlpha = it.alpha g.fillStyle = it.fill + g.textAlign = it.align g.fillText(it.text, it.x, it.y) } g.globalAlpha = 1 @@ -1349,20 +1397,57 @@ export class StarMap { const items: LabelDraw[] = [] for (const v of order) { + const parent = this.#validOrbitNodes.has(v.n.num) && v.n.parentIssue !== null + ? this.#byNum.get(v.n.parentIssue) + : undefined + const labelFontSize = parent ? orbitLabelFontSize(s) : fs + g.font = labelFontSize.toFixed(1) + 'px ui-sans-serif,system-ui,sans-serif' const isCurrent = this.#focus.current === v.n.num const isReady = this.#focus.readySet.has(v.n.num) - const marker = - isCurrent && isReady - ? 'CURRENT / READY \u00b7 ' - : isCurrent - ? 'CURRENT \u00b7 ' - : isReady - ? 'READY \u00b7 ' - : '' - let text = marker + (v.n.num < 10 ? '0' : '') + v.n.num + const marker = subissueMarker(isCurrent, isReady) + let text = marker + issueNumberText(v.n.num) if (!numOnly || marker) text += ' ' + clipTitle(v.n.title, budget) const w = g.measureText(text).width + if (parent) { + text = marker + subissueLabelText(v.n.num, v.n.title) + const worldGeometry = outwardLabelGeometryAtScale({ + parent: { x: parent.x, y: parent.y }, + child: { x: v.n.x, y: v.n.y }, + number: v.n.num, + title: v.n.title, + scale: s, + starRadius: v.rad / s, + }) + const geometry = { + x: worldGeometry.x * s + this.#cam.x, + y: worldGeometry.y * s + this.#cam.y, + align: worldGeometry.align, + box: { + x0: worldGeometry.box.x0 * s + this.#cam.x, + y0: worldGeometry.box.y0 * s + this.#cam.y, + x1: worldGeometry.box.x1 * s + this.#cam.x, + y1: worldGeometry.box.y1 * s + this.#cam.y, + }, + } + if (!obstacles.some((obstacle) => boxesOverlap(geometry.box, obstacle))) { + obstacles.push(geometry.box) + items.push({ + text, + x: geometry.x, + y: geometry.y, + align: geometry.align, + fontSize: labelFontSize, + fill: LABEL[v.n.vstate], + alpha: + this.#focus.emphasized.size === 0 || this.#focus.emphasized.has(v.n.num) + ? 1 + : CONTEXT_ALPHA, + }) + } + continue + } + // Every candidate is centred on the star and differs only in how far above // or below it sits. A label always reads as hanging off its own star, and // never drifts sideways toward a neighbour's. @@ -1385,17 +1470,11 @@ export class StarMap { if (j >= 0 && j <= 3) cands.push({ y: slot(other, j), side: other }) } - const left = v.sx - w / 2 for (const c of cands) { - const box: Box = { - x0: left - 3, - y0: c.y - fs * 0.82 - 2, - x1: left + w + 3, - y1: c.y + fs * 0.22 + 2, - } + const box = labelBox(v.sx, c.y, 'center', w, fs) let ok = true for (const o of obstacles) { - if (hits(box, o)) { + if (boxesOverlap(box, o)) { ok = false break } @@ -1406,6 +1485,8 @@ export class StarMap { text, x: v.sx, y: c.y, + align: 'center', + fontSize: labelFontSize, fill: LABEL[v.n.vstate], alpha: this.#focus.emphasized.size === 0 || this.#focus.emphasized.has(v.n.num)