From c18b00ca09cecab24a3d8e4233160597f5e14d9e Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 4 Sep 2026 15:28:39 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20scale-true=20drill-down=20=E2=80=94?= =?UTF-8?q?=20the=20section=20never=20rescales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each node keeps the rect/depth/tier it was first laid out at (VNode.home); a drilled-in scope is laid out there instead of re-mapped to the full stage, so heights, footprints and terrace altitude read the same at every level and the homing transition has nothing to scale. A legibility floor enlarges only scopes too small to place their children. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 4 ++- DESIGN.md | 31 ++++++++++++++++-- viewer/src/layout.ts | 12 +++++-- viewer/src/main.ts | 76 +++++++++++++++++++++----------------------- viewer/src/vtree.ts | 9 ++++++ 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a317200..ad24d83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,9 @@ augments onto the read-only data.json tree (parent links, rects, synthetic file/module scopes). **The focus stack.** The city renders exactly one scope. Double-click disposes -the current city and re-lays out that subtree at full extent +the current city and re-lays out that subtree at the footprint it already had +(scale-true: surroundings go away, the camera closes in; a legibility floor +only enlarges scopes too small to place their children) (folder β†’ file β†’ module β†’ member), with synthetic nodes for the file/module levels. PR markers, arcs, labels and scaffolding are all rebuilt against the scope β€” nothing may assume the real tree root. diff --git a/DESIGN.md b/DESIGN.md index c6cea7d..4360e69 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -276,7 +276,7 @@ Viewer must degrade gracefully when these 404 (e.g. static hosting): hide snippe ## Interaction v2 - **No cursor tooltip.** A persistent right sidebar shows hover info (top section, live) and pinned selection detail (on click): stats, PRs, recent commits for that path, and code β€” module source snippet + latest diff via the dev API. When the focused/selected node is file-level or deeper, the sidebar expands to a wide code pane showing the actual source (module span, or whole file capped). -- **Double-click = isolate.** Rendering the focused node's subtree ONLY, re-laid out to fill the stage: folder β†’ its city; file β†’ its modules as blocks; module β†’ its `children` (methods etc.) as buildings. Breadcrumb/Esc rebuilds the parent scene. This is the hierarchy: org β†’ repo β†’ folder β†’ file β†’ module β†’ member. +- **Double-click = isolate.** Rendering the focused node's subtree ONLY, at the footprint it already had (see *Scale-true drill-down*): folder β†’ its city; file β†’ its modules as blocks; module β†’ its `children` (methods etc.) as buildings. Breadcrumb/Esc rebuilds the parent scene. This is the hierarchy: org β†’ repo β†’ folder β†’ file β†’ module β†’ member. - **Map-style labels**: labels chosen dynamically from what's in view (projected size within a readable band, capped count, fade in/out) β€” district names give way to file names give way to building names as you zoom, like a map engine. - **PR markers** connect visibly: avatar at centroid, thin beams down to EACH affected file plate + glowing ground ring per file. PRs gain `additions`/`deletions` in the data; the central beam's radius and glow scale with log(additions+deletions) so big PRs read as big pillars of light. - **Directional arcs**: animated flow (moving dashes/pulses) from importer β†’ imported. @@ -543,6 +543,32 @@ it. The fixes, layered: (only panel titles stay uppercase). Same rule in 3D β€” folder names are uppercase city signage, file and module names are identifiers. +## Scale-true drill-down (implemented) + +The core assets never move or rescale; only what is around them goes away and +the camera closes in. Each node records the rect, depth and tier it was first +laid out at (`VNode.home`, assigned by the top-level layout), and a drilled-in +scope is laid out *at that home* (`layoutCity(root, { at })`) rather than +re-mapped to the full stage. The layout is deterministic, so every child lands +exactly where it stood in the parent city: heights, footprints, streets and +terrace altitude read the same at every level, the transition has nothing to +scale (`k = 1`), and the old city fading out over the identical new one is +invisible. + +- **Legibility floor.** A scope too small to place its children is enlarged + about its own centre to `stageFloor`: real folders to `max(48, √filesΒ·6)`, + file / module scopes (buildings up to 60 tall on a plot a few units wide) to + the previous `max(60, √buildingsΒ·55)`. Only then does the homing scale + animate. The one deliberate exception to scale-true; expected to shrink once + the file interior is redesigned. +- **What you lose.** Files too small to earn a plate at true scale stay + unplaced when their folder is isolated (they had none at the overview + either); `revealPath` still drills to them. A tall district's towers can + overrun the top of the framing, which fits the footprint only. +- Synthetic roots stand for a real node: a file scope is laid out at the file's + home, a `wrap` at its lone leaf's, so isolating a module or a file keeps the + building where it stood. + ## Camera motion Every zoom in the city is one gesture: one move of the camera, one unfold of the @@ -554,7 +580,8 @@ scene, one continuous world underneath both. The rules, in the order they matter (`startTransition`): the new layout is translated so the anchor β€” the node being drilled into, or the child being backed out of β€” lands exactly on the world position it already occupied, and only the SCALE animates from there. - The section grows (or folds) about the spot it already stood on. Everything + With scale-true drill-down that scale is 1 unless the legibility floor kicks + in, so the section normally just stands where it stood. Everything derived from a layout rect carries `stageHome`: framings, labels, callouts; the selection boxes just ride the stage. Once everything settles, `rehomeStage` shifts stage, camera and orbit target back to the origin together β€” invisible, diff --git a/viewer/src/layout.ts b/viewer/src/layout.ts index 4ac9150..04c2585 100644 --- a/viewer/src/layout.ts +++ b/viewer/src/layout.ts @@ -4,7 +4,7 @@ * World space convention: the city lies on the XZ plane, Y is up. * A rect is { x, z, w, h } with (x, z) the min corner. */ -import type { Plot, Rect, VNode, VMod } from './vtree.js'; +import type { Home, Plot, Rect, VNode, VMod } from './vtree.js'; /** Anything the treemap can place: only its relative weight matters. */ export interface Weighted { @@ -61,11 +61,14 @@ export function worldScale(): number { * * @param root tree root (folder node) * @param opts.size world extent of the root plate (square), default 900 + * @param opts.at place the root at this rect/depth/tier instead (a drilled-in + * scope keeps the footprint it had, so nothing rescales) * @returns the same root */ -export function layoutCity(root: VNode, opts: { size?: number } = {}): VNode { +export function layoutCity(root: VNode, opts: { size?: number; at?: Home } = {}): VNode { const size = opts.size ?? 900; - layoutNode(root, { x: -size / 2, z: -size / 2, w: size, h: size }, 0, 0); + const at = opts.at ?? { rect: { x: -size / 2, z: -size / 2, w: size, h: size }, depth: 0, tier: 0 }; + layoutNode(root, at.rect, at.depth, at.tier); return root; } @@ -180,6 +183,9 @@ function layoutNode(node: VNode, rect: Rect, depth: number, tier: number): void node.depth = depth; node.tier = tier; node.massed = false; + // First placement wins: the top-level layout runs first, so a real node's + // home is its spot in the whole city. + if (!node.home) node.home = { rect, depth, tier }; // Pass-through levels share a tier, so nudge each depth by a hair: without it // a repo -> packages wrapper would be exactly coplanar with its child. node.top = plateTop(tier, node.type === 'file') + depth * PLATE_EPSILON * SCALE; diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 25b6aa5..1faa128 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -3,8 +3,10 @@ * * The city is always rendered for ONE focus scope: double-clicking pushes into * a node (folder β†’ file β†’ module β†’ member), which disposes the current city and - * re-lays out that subtree at full extent. Everything else β€” PR markers, arcs, - * labels, scaffolding β€” is rebuilt against the same scope. + * re-lays out that subtree at the footprint it already had β€” the surroundings + * go away and the camera closes in; the section itself never rescales. + * Everything else β€” PR markers, arcs, labels, scaffolding β€” is rebuilt against + * the same scope. */ import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; @@ -47,7 +49,7 @@ import { type FileFilter, type LevelFilter, type StrataBand, type BandSource, type StrataUpdate, LEVEL_HEIGHT, } from './strata.js'; import { createSkyline, type Skyline, type SkyHit } from './skyline.js'; -import { asVNode, type AnyKind, type VMod, type VNode } from './vtree.js'; +import { asVNode, type AnyKind, type Home, type VMod, type VNode } from './vtree.js'; const MAX_ARCS = 150; const DAY = 86400; @@ -927,11 +929,8 @@ function rebuildScene( const root = makeScopeRoot(focus); scope.root = root; - // The scale must be in force BEFORE the layout runs: every world constant the - // layout and the geometry builders read goes through it. - const extent = scopeExtent(root); - setWorldScale(extent.scale); - layoutCity(root, { size: extent.size }); + setWorldScale(1); + layoutCity(root, root === state.root ? { size: CITY_SIZE } : { at: scopeHome(root) }); city = buildCity(root); stage.add(city.group); @@ -1034,9 +1033,9 @@ function disposeGhost(): void { } /** - * Where a node sits in the layout it belongs to. The scope root always occupies - * the whole stage, so a node that *is* the current root reports the stage rect β€” - * which is what makes the drill-down and drill-up maps symmetric. + * Where a node sits in the layout it belongs to. A node that *is* the current + * root reports the root's rect β€” which is what makes the drill-down and + * drill-up maps symmetric. */ function footprintOf(node: VNode, root: VNode | null): Footprint | null { if (!root) return null; @@ -1124,39 +1123,38 @@ function rehomeStage(): void { } /** - * Stage extent for a scope, in CITY-WIDE units. - * - * Footprint area per LOC is a property of the city, not of the level you happen - * to be looking at: a scope holding a tenth of the repo's lines gets a tenth of - * the root plate's AREA (so a linear factor of √0.1), and the camera moves in to - * fill the screen with it. Heights are absolute for the same reason, so a file's - * stack has the same silhouette isolated as it does from the org overview. - * - * A scope small enough to fall under `minScopeSize` is the one exception: it is - * scaled up UNIFORMLY, footprints and heights by the same linear factor, so it - * becomes legible without any of its proportions changing. That factor is the - * layout's world scale (`setWorldScale`), and every world constant β€” street - * widths, terrace lifts, building heights, strata slabs β€” runs through it. + * Where a drilled-in scope is laid out: the rect its root was first placed at, + * so the section keeps its footprint and heights while everything around it + * goes away. Only a scope too small to place its children is enlarged, about + * its own centre, to a legibility floor. */ -function scopeExtent(root: VNode): { size: number; scale: number } { - const total = Math.max(state.root?.loc ?? 0, 1); - const share = Math.min(Math.max(root.loc, 1) / total, 1); - const trueSize = CITY_SIZE * Math.sqrt(share); - // Clamp to the layout's scale ceiling and derive the size back from it: a - // plate laid out beyond what setWorldScale accepts would break uniformity - // (footprints inflated, heights not). - const scale = Math.min(Math.max(trueSize, minScopeSize()) / trueSize, 60); - return { size: trueSize * scale, scale }; +function scopeHome(root: VNode): Home { + // Synthetic roots stand for a real node: the file, or the lone leaf they wrap. + const base = root.synth === 'fileScope' && root.srcFile ? root.srcFile + : root.synth === 'wrap' ? root.children?.[0] ?? root + : root; + const home = base.home + ?? (base.rect ? { rect: base.rect, depth: base.depth ?? 0, tier: base.tier ?? 0 } : null) + ?? { rect: { x: -CITY_SIZE / 2, z: -CITY_SIZE / 2, w: CITY_SIZE, h: CITY_SIZE }, depth: 0, tier: 0 }; + const r = home.rect; + const s = stageFloor(root) / Math.max(r.w, r.h); + if (s <= 1) return home; + return { + ...home, + rect: { x: r.x + (r.w - r.w * s) / 2, z: r.z + (r.h - r.h * s) / 2, w: r.w * s, h: r.h * s }, + }; } /** - * The legibility floor. Absolute in world units, but never more than a third of - * the whole city: in a small repo the root plate is itself only a few hundred - * units across, and a floor at the city's own size would flatten every level - * back onto the full square β€” the exact behaviour this replaces. + * Smallest extent a scope is allowed: real folders need room to place each + * file; inside a file the module buildings are up to 60 units tall, so the + * plate is sized to the number of buildings rather than the file's tiny plot. */ -function minScopeSize(): number { - return Math.min(MIN_SCOPE_SIZE, CITY_SIZE * 0.33); +function stageFloor(root: VNode): number { + let n = 0; + walk(root, (nd) => { if (nd.type === 'file') n += root.synth ? (nd.modules || []).length : 1; }); + const floor = root.synth ? Math.sqrt(Math.max(n, 1)) * 55 : Math.sqrt(n) * 6; + return Math.min(Math.max(floor, root.synth ? 60 : 48), CITY_SIZE); } function indexScope(): void { diff --git a/viewer/src/vtree.ts b/viewer/src/vtree.ts index f9a35df..7ebbb52 100644 --- a/viewer/src/vtree.ts +++ b/viewer/src/vtree.ts @@ -36,6 +36,13 @@ export interface Plot extends Rect { mod: VMod; } +/** Where a node was first placed. A scope root is re-laid out here, so drilling in never rescales it. */ +export interface Home { + rect: Rect; + depth: number; + tier: number; +} + /** Which synthetic layer a node belongs to, absent for real tree nodes. */ export type SynthKind = 'fileScope' | 'module' | 'member' | 'leaf' | 'wrap'; @@ -72,6 +79,8 @@ export interface VNode { rect?: Rect | null; /** Placed as one aggregate massing block; the interior is not laid out. */ massed?: boolean; + /** First placement, kept across rebuilds; see layoutCity `at`. */ + home?: Home; depth?: number; /** Terrace tier β€” depth minus pass-through (single-child) levels. */ tier?: number; From 2467be5d92c0a453bc511747544afdcdaee95c02 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 4 Sep 2026 16:12:14 -0700 Subject: [PATCH 2/2] fix(scale-true): unplaced scopes, short-side floor, unsignable folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drilled scope whose root was never laid out (a module made on the spot by a double-click, a file stripped for being too small) fell back to the whole CITY_SIZE rect β€” a 900-unit pancake. It now lands on a floor-sized square at its nearest placed ancestor. The legibility floor measures the home's short side, so a skinny plot is widened too, and label culling is relative to the stage instead of a fixed 3 units. Folders too deep for a terrace sign no longer count as signed, so they keep their label pill. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 14 ++++++++------ docs/tours.md | 6 +++--- viewer/src/main.ts | 44 ++++++++++++++++++++++++++++++------------- viewer/src/terrace.ts | 12 ++++++++++-- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4360e69..93f45b0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -224,8 +224,7 @@ still exactly where it was. - **Known limit.** Seen from steeply above, the *top face* is whichever band is on top (usually `new`), so a 98%-verbatim file reads orange from directly overhead; the cyan mass is on the sides. It reads correctly at the oblique - angles the city is normally flown at, and worst inside a folder isolate, where - footprints are re-laid out to full extent while heights stay in world units. + angles the city is normally flown at. ### The timeline shows the diff @@ -558,16 +557,19 @@ invisible. - **Legibility floor.** A scope too small to place its children is enlarged about its own centre to `stageFloor`: real folders to `max(48, √filesΒ·6)`, file / module scopes (buildings up to 60 tall on a plot a few units wide) to - the previous `max(60, √buildingsΒ·55)`. Only then does the homing scale - animate. The one deliberate exception to scale-true; expected to shrink once - the file interior is redesigned. + the previous `max(60, √buildingsΒ·55)`. Measured on the home's *short* side, so + a skinny plot is widened too. Only then does the homing scale animate. The one + deliberate exception to scale-true; expected to shrink once the file interior + is redesigned. - **What you lose.** Files too small to earn a plate at true scale stay unplaced when their folder is isolated (they had none at the overview either); `revealPath` still drills to them. A tall district's towers can overrun the top of the framing, which fits the footprint only. - Synthetic roots stand for a real node: a file scope is laid out at the file's home, a `wrap` at its lone leaf's, so isolating a module or a file keeps the - building where it stood. + building where it stood. A node that never got a home β€” a module scope made on + the spot by a double-click, a file stripped for being too small β€” falls back to + a floor-sized square on its nearest placed ancestor's centre, not the city. ## Camera motion diff --git a/docs/tours.md b/docs/tours.md index 23568d3..c48328d 100644 --- a/docs/tours.md +++ b/docs/tours.md @@ -124,9 +124,9 @@ reads as a log. ## Authoring tips -**Use `isolate` for "look inside this".** It drills into the target and -re-lays out that subtree to fill the stage β€” a file becomes a district of its -functions, a class becomes a district of its methods. It is the right treatment +**Use `isolate` for "look inside this".** It drills into the target, which keeps +the footprint it had in the city β€” a file becomes a district of its functions, a +class becomes a district of its methods. It is the right treatment when the step is about the *internals* of one place, and the wrong one when the point is where that place sits. diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 1faa128..c6c4395 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -28,7 +28,7 @@ import { type Arc, type ArcFlow, type CityBuild, type ModuleRecord, type Site, } from './city.js'; import { createLabeler, type LabelCandidate, type Labeler } from './labels.js'; -import { createTerraceSigns, type TerraceSigns } from './terrace.js'; +import { canSign, createTerraceSigns, type TerraceSigns } from './terrace.js'; import { createSidebar, escapeHtml, type Descriptor, type Sidebar, type WorkChange, type WorkKind, @@ -120,11 +120,6 @@ function cityExtent(fileCount: number): number { return Math.min(Math.max(Math.sqrt(fileCount) * 15, 260), 900); } let CITY_SIZE = 900; -/** - * Smallest world extent an isolated scope may occupy before the uniform - * legibility scale kicks in (see `scopeExtent`). - */ -const MIN_SCOPE_SIZE = 240; // --------------------------------------------------------------------------- // State @@ -1133,11 +1128,12 @@ function scopeHome(root: VNode): Home { const base = root.synth === 'fileScope' && root.srcFile ? root.srcFile : root.synth === 'wrap' ? root.children?.[0] ?? root : root; - const home = base.home - ?? (base.rect ? { rect: base.rect, depth: base.depth ?? 0, tier: base.tier ?? 0 } : null) - ?? { rect: { x: -CITY_SIZE / 2, z: -CITY_SIZE / 2, w: CITY_SIZE, h: CITY_SIZE }, depth: 0, tier: 0 }; + const floor = stageFloor(root); + const home = base.home ?? unplacedHome(base, floor); const r = home.rect; - const s = stageFloor(root) / Math.max(r.w, r.h); + // Measured on the short side: a skinny home (54 x 2) clears a floor set on + // its long one and still has no room for layoutNode to place anything in. + const s = floor / Math.min(r.w, r.h); if (s <= 1) return home; return { ...home, @@ -1145,6 +1141,24 @@ function scopeHome(root: VNode): Home { }; } +/** + * Home for a node that was never laid out β€” a module scope made on the spot, or + * a file stripped for being too small: a floor-sized square on the nearest + * placed ancestor's centre, rather than the whole city. + */ +function unplacedHome(base: VNode, side: number): Home { + let anc = base.parent ?? null; + while (anc && !anc.rect) anc = anc.parent ?? null; + const r = anc?.rect; + const cx = r ? r.x + r.w / 2 : 0; + const cz = r ? r.z + r.h / 2 : 0; + return { + rect: { x: cx - side / 2, z: cz - side / 2, w: side, h: side }, + depth: (anc?.depth ?? -1) + 1, + tier: (anc?.tier ?? -1) + 1, + }; +} + /** * Smallest extent a scope is allowed: real folders need room to place each * file; inside a file the module buildings are up to 60 units tall, so the @@ -3994,10 +4008,10 @@ function terraceSignNodes(): VNode[] { if (!root || root.synth) return []; const base = groupingRoot() ?? root; for (const a of base.children || []) { - if (a.type !== 'folder' || !a.rect) continue; + if (a.type !== 'folder' || !canSign(a)) continue; signedNodes.add(a); for (const b of a.children || []) { - if (b.type === 'folder' && b.rect) signedNodes.add(b); + if (b.type === 'folder' && canSign(b)) signedNodes.add(b); } } return [...signedNodes]; @@ -4016,11 +4030,15 @@ function parentLabelKey(node: VNode): string | null { function updateLabelCandidates(): void { if (!city || !scope.root) return; const list: LabelCandidate[] = []; + // The cull is relative to the stage: at true scale a file plate inside a + // folder isolate stays as small as it was in the whole city. + const stageRect = scope.root.rect; + const minSize = stageRect ? Math.max(stageRect.w, stageRect.h) / 300 : 3; walk(scope.root, (n) => { if (!n.rect) return; const size = Math.min(n.rect.w, n.rect.h); - if (size < 3) return; + if (size < minSize) return; const isFile = n.type === 'file'; if (!isFile && n === scope.root && scope.root.depth === 0 && (n.children || []).length === 1) return; if (!isFile && signedNodes.has(n)) return; // its name is on the terrace wall diff --git a/viewer/src/terrace.ts b/viewer/src/terrace.ts index 059a44b..15c626b 100644 --- a/viewer/src/terrace.ts +++ b/viewer/src/terrace.ts @@ -40,6 +40,15 @@ const INK_RATIO = 0.5; const MAX_UPSCALE = 2.2; const SUB_MAX_UPSCALE = 1.6; +/** + * Whether a node's terrace wall is deep enough to carry a sign. Callers pick the + * signed set with this, so a folder that gets no sign still gets a label pill. + */ +export function canSign(node: VNode): boolean { + if (!node.rect) return false; + return plateThickness(node.tier ?? node.depth ?? 0, node.type === 'file') >= 1.4; +} + export interface TerraceSigns { group: THREE.Group; /** Replace the signed folder set (called on every scope rebuild). */ @@ -142,10 +151,9 @@ export function createTerraceSigns(camera: THREE.PerspectiveCamera): TerraceSign function makeSign(node: VNode): Sign | null { const r = node.rect; - if (!r) return null; + if (!r || !canSign(node)) return null; const tier = node.tier ?? node.depth ?? 0; const wall = plateThickness(tier, node.type === 'file'); - if (wall < 1.4) return null; const tex = signTexture(node.name, tier); const material = new THREE.MeshBasicMaterial({