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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ 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.
levels. Inside a file the layout is reading order (rows of modules in source
order filling the file's footprint, member stacks, code holograms on hover) —
see DESIGN.md "File interior". PR markers, arcs, labels and scaffolding are all
rebuilt against the scope — nothing may assume the real tree root.

**Strata is the shared massing, not a mode.** When a commit stream exists and
the scope root is a real folder, a file *is* its stack of per-commit slabs; modes
Expand Down
63 changes: 53 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,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, 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.
- **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 spine (modules as columns in source order, see *File interior*); module → its member stack. 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 @@ -314,7 +314,8 @@ move, a switch across that boundary can also be animated (`rise`).
All of that is one `StrataPaint` swapped through `recolor()`; the search /
tour highlight and the working-tree paint are two more paints in the same seam.
- **Module buildings appear when you isolate a file** (or a module). That is
where kind, size and nesting resolve, and it is unchanged.
where kind, size and nesting resolve — in reading order, kind-painted in every
mode (see "File interior").
- The **range handle is therefore live in every mode** — it changes the massing,
and the massing is shared. It hides only inside a file/module isolate, where
there are no stacks.
Expand Down Expand Up @@ -555,22 +556,64 @@ 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.
about its own centre to `stageFloor`: real folders to `max(48, √files·6)`, a
file scope to `max(60, √modules·11)`, a lone module or member stack to 24.
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.
- **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.
either); `revealPath` still drills to them. At folder scope a tall district's
towers can overrun the top of the framing, which fits the footprint only;
inside a file the framing adds the tallest module to the extent.
- 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.

## File interior — reading order (implemented)

Inside a file the treemap metaphor breaks: a function is not a district, and
packing modules by size threw away the one structure a file has, its order.
A file isolate fills the file's own footprint (scale-true, floored to
`max(60, √modules·11)`) with its modules in **reading order**: rows left → right,
top → bottom like text on a page (`flowLayout`), each module as wide as its
line count, every row justified to the plate and as deep as its share, so
area stays ∝ lines while source order survives. A share is floored at a
quarter of the average so a one-line `const` still gets a plot you can hover;
a light trailing row is folded into the one before it rather than becoming a
sliver. Heights are linear in lines (`lineUnit`): the file's longest module
stands about 0.4 of the plate's side (capped at 60), read off the file's scope
layout so a lone module isolated on its own keeps its height; a module-less file
normalises on its own `loc`, since its one stub module *is* the whole file. A class,
interface or enum is a **stack**: its members as slabs piled in source order,
each as tall as its lines, on a pedestal plate; members get no plate of their
own — they share the module's footprint (`Plot.y0` / `Plot.height`, stacked in
`city.ts`). Kind is the paint in here regardless of mode (every module shares
the file's churn and recency, so the overlays have nothing per-building to
say) and the legend carries the kind swatches plus `area · height = lines ·
reading order = source order`. Framing inside a file adds the tallest building
to the extent so towers do not overrun the top of the view.

**Code holograms.** Hovering a module stands its source up beside it: a
screen-aligned card (`hologram.ts`) with `kind name` and the line span in the
header, the first 14 lines with a gutter, comments dimmed, scanlines, a cyan
frame, drawn over the city (no depth test) up and to the right of the
building's top, scaled with distance so it stays readable. Selecting a module
pins its card; hover borrows the slot and hands it back. Inside a file a module
is usually hit through its synthetic leaf (its pedestal), so `moduleRecOf`
resolves the building from either kind of target, and only from the current
city's records — a target's `rec` outlives the build that made it. Source comes
through `CityHost.getSource` — just the lines the card shows, the `+N lines`
tail coming from `loc` — and is cached per span; without a host (static export)
the card is the header alone. The card replaces the name pill for modules —
the name is in its header.

The mini-treemap in `layoutModules` remains for a real file's plots at folder
scope (v1 data without commits); the reading-order layout applies to synthetic
scopes only. A module or member scope is floored at 24.

## Camera motion

Every zoom in the city is one gesture: one move of the camera, one unfold of the
Expand Down
22 changes: 14 additions & 8 deletions viewer/src/city.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as THREE from 'three';
import type { Pr, PrStatus } from '../../shared/types.js';
import { buildingHeight, plateTop, plateThickness, worldScale, PLATE_THICKNESS } from './layout.js';
import type { AnyKind, VMod, VNode } from './vtree.js';
import type { AnyKind, Plot, VMod, VNode } from './vtree.js';

// ---------------------------------------------------------------------------
// Palette
Expand Down Expand Up @@ -97,6 +97,8 @@ export interface ModuleRecord {
file: VNode;
mod: VMod;
baseColor: THREE.Color;
/** World Y of the building's base (a stacked member sits above its plate). */
base: number;
height: number;
center: THREE.Vector3;
}
Expand Down Expand Up @@ -133,12 +135,14 @@ export function buildCity(root: VNode): CityBuild {
if (node.type === 'file') files.push(node);
else folders.push(node);
});
// Members share their module's footprint as stacked slabs; no plate each.
const plated = files.filter((f) => f.synth !== 'member');

const folderPart = buildPlates(folders, false);
const filePart = buildPlates(files, true);
const filePart = buildPlates(plated, true);
const buildings = buildBuildings(files);
const massedMesh = buildMassedBlocks(massed);
const outlines = buildOutlines(folders, files);
const outlines = buildOutlines(folders, plated);

if (folderPart.mesh) group.add(folderPart.mesh);
if (filePart.mesh) group.add(filePart.mesh);
Expand Down Expand Up @@ -296,7 +300,7 @@ function buildingMaterial(kind: AnyKind): THREE.MeshStandardMaterial {

function buildBuildings(files: VNode[]): { meshes: THREE.InstancedMesh[]; records: ModuleRecord[] } {
// Bucket module plots by kind so each kind gets one InstancedMesh.
const buckets = new Map<AnyKind, Array<{ file: VNode; plot: { mod: VMod; x: number; z: number; w: number; h: number } }>>();
const buckets = new Map<AnyKind, Array<{ file: VNode; plot: Plot }>>();
for (const kind of ALL_KINDS) buckets.set(kind, []);

for (const file of files) {
Expand Down Expand Up @@ -334,8 +338,8 @@ function buildBuildings(files: VNode[]): { meshes: THREE.InstancedMesh[]; record
const entry = entries[i];
if (!entry) continue;
const { file, plot } = entry;
const top = file.top ?? 0;
const h = buildingHeight(plot.mod.loc) * (KIND_HEIGHT_SCALE[kind] ?? 1);
const top = (file.top ?? 0) + (plot.y0 ?? 0);
const h = plot.height ?? buildingHeight(plot.mod.loc) * (KIND_HEIGHT_SCALE[kind] ?? 1);
pos.set(plot.x + plot.w / 2, top, plot.z + plot.h / 2);
scale.set(Math.max(plot.w, 0.25 * s), h, Math.max(plot.h, 0.25 * s));
m.compose(pos, q, scale);
Expand All @@ -349,6 +353,7 @@ function buildBuildings(files: VNode[]): { meshes: THREE.InstancedMesh[]; record
file,
mod: plot.mod,
baseColor: color.clone(),
base: top,
height: h,
center: new THREE.Vector3(pos.x, top + h / 2, pos.z),
};
Expand Down Expand Up @@ -1123,10 +1128,11 @@ export function buildScaffolding(fileNodes: VNode[], color: number = PALETTE.ora
return lines;
}

function tallestBuilding(fileNode: VNode): number {
/** Top of the tallest building on a file's plate, relative to the plate. */
export function tallestBuilding(fileNode: VNode): number {
let max = 0;
if (fileNode.plots) {
for (const p of fileNode.plots) max = Math.max(max, buildingHeight(p.mod.loc));
for (const p of fileNode.plots) max = Math.max(max, (p.y0 ?? 0) + (p.height ?? buildingHeight(p.mod.loc)));
}
return max;
}
Expand Down
107 changes: 107 additions & 0 deletions viewer/src/hologram.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* hologram.ts — floating code readouts. A module's source is drawn to a canvas
* and stood up in the world above its column: dark glass, cyan monospace,
* scanlines, turned to face the camera every frame (`facePanel`).
*/
import * as THREE from 'three';

export const SNIPPET_LINES = 14;
const MAX_COLS = 88;
const PX_W = 1024;
const LINE_PX = 26;
const HEAD_PX = 54;
const PAD_PX = 14;
const GUTTER_PX = 78;
const MONO = '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace';

export interface CodePanel {
mesh: THREE.Mesh;
/** Canvas height over width: world height = world width × aspect. */
aspect: number;
}

/**
* @param header `kind name`
* @param sub right-aligned span note (`L12–48`)
* @param lines source lines, or null when the host cannot serve source
* @param first line number of `lines[0]`
* @param more lines past the snippet, for the "+N lines" tail
*/
export function makeCodePanel(header: string, sub: string, lines: string[] | null, first: number, more = 0): CodePanel {
const rows = lines ? Math.min(lines.length, SNIPPET_LINES) : 0;
const canvas = document.createElement('canvas');
canvas.width = PX_W;
const tail = rows > 0 && more > 0;
canvas.height = HEAD_PX + (rows ? PAD_PX * 2 + rows * LINE_PX + (tail ? LINE_PX : 0) : 0);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2d context unavailable');
const W = canvas.width;
const H = canvas.height;

ctx.fillStyle = 'rgba(4, 10, 24, 0.88)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(34, 211, 238, 0.10)';
ctx.fillRect(0, 0, W, HEAD_PX);
ctx.textBaseline = 'middle';
ctx.font = `bold 22px ${MONO}`;
ctx.fillStyle = '#eafcff';
ctx.fillText(clip(header, 60), 20, HEAD_PX / 2);
ctx.font = `18px ${MONO}`;
ctx.fillStyle = 'rgba(168, 244, 255, 0.75)';
ctx.textAlign = 'right';
ctx.fillText(sub, W - 20, HEAD_PX / 2);

ctx.font = `19px ${MONO}`;
for (let i = 0; i < rows; i++) {
const y = HEAD_PX + PAD_PX + i * LINE_PX + LINE_PX / 2;
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(120, 160, 190, 0.55)';
ctx.fillText(String(first + i), GUTTER_PX - 16, y);
ctx.textAlign = 'left';
const text = clip((lines?.[i] ?? '').replace(/\t/g, ' '), MAX_COLS);
ctx.fillStyle = tone(text);
ctx.fillText(text, GUTTER_PX, y);
}
if (tail) {
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(120, 160, 190, 0.6)';
ctx.fillText(`… +${more} lines`, GUTTER_PX, HEAD_PX + PAD_PX + rows * LINE_PX + LINE_PX / 2);
}
// Scanlines, then the frame on top.
ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
for (let y = 0; y < H; y += 4) ctx.fillRect(0, y, W, 1);
ctx.strokeStyle = 'rgba(34, 211, 238, 0.75)';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);

const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearFilter;
tex.generateMipmaps = false;
const mat = new THREE.MeshBasicMaterial({
map: tex,
transparent: true,
depthWrite: false,
depthTest: false, // a readout is drawn over the city, never inside it
toneMapped: false,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
mesh.renderOrder = 18;
mesh.frustumCulled = false;
return { mesh, aspect: H / W };
}

/** Screen-align the panel: its text stays upright and square to the viewer. */
export function facePanel(mesh: THREE.Object3D, camera: THREE.Camera): void {
mesh.quaternion.copy(camera.quaternion);
}

/** Comments recede; code reads in the hologram's ink. */
function tone(text: string): string {
const t = text.trimStart();
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*') ? 'rgba(140, 170, 200, 0.7)' : '#c8f1ff';
}

function clip(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
Loading