Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 33 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -276,7 +275,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.
Expand Down Expand Up @@ -543,6 +542,35 @@ 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)`. 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. 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

Every zoom in the city is one gesture: one move of the camera, one unfold of the
Expand All @@ -554,7 +582,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,
Expand Down
6 changes: 3 additions & 3 deletions docs/tours.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 9 additions & 3 deletions viewer/src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
112 changes: 64 additions & 48 deletions viewer/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,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,
Expand All @@ -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;
Expand Down Expand Up @@ -118,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
Expand Down Expand Up @@ -927,11 +924,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);

Expand Down Expand Up @@ -1034,9 +1028,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;
Expand Down Expand Up @@ -1124,39 +1118,57 @@ 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 floor = stageFloor(root);
const home = base.home ?? unplacedHome(base, floor);
const r = home.rect;
// 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,
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.
* 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 minScopeSize(): number {
return Math.min(MIN_SCOPE_SIZE, CITY_SIZE * 0.33);
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
* plate is sized to the number of buildings rather than the file's tiny plot.
*/
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 {
Expand Down Expand Up @@ -3996,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];
Expand All @@ -4018,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
Expand Down
12 changes: 10 additions & 2 deletions viewer/src/terrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions viewer/src/vtree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down