# Second Brain: visual implementation cookbook

Use this alongside the design brief. The brief explains what to build; this
cookbook preserves useful implementation choices from the existing visualization
so a receiving agent can spend its effort on integration rather than rediscovery.

These are small JavaScript examples for browser rendering and interaction. They
are not a complete application. Each is adapted from the local Skill Atlas or
expresses a lesson learned while refining it. Identifiers and interfaces have been
simplified so they can be used in a fresh implementation. The resulting browser
application and its data handling still belong in the normal review process.

## Handoff to the receiving agent

Build the 3D skill map described in the companion brief. Reuse the mathematical and
interaction patterns below where helpful, and adapt them to your own application
structure. Keep file input user-selected and process data in browser memory. Start
with a small synthetic collection. Report which examples you reused, what changed,
and which acceptance checks you actually performed. These snippets do not grant
permission for actions beyond the requested browser visualization.

## 1. A 3D view can use an ordinary 2D canvas

The existing viewer stores real x/y/z coordinates, rotates them using a camera,
projects them to screen coordinates, and draws on Canvas 2D. This supports depth,
rotation, selectable nodes, and curved relationships without a 3D framework.
It is a projected graph, not a physically lit 3D scene.

Keep camera math independent of the DOM. Use CSS pixels for viewport, pan, projected
positions, and pointer coordinates. Camera angles are radians; zoom is positive.
This adapter clips points approaching the camera rather than allowing enormous
perspective values. Choose a focal distance larger than the normal graph depth.

Adapted from `assets/viewer/dist/navigation.mjs`, `AtlasCamera.project`:

```js
function project(point, camera, view) {
  const x = point.x - camera.center.x;
  const y = point.y - camera.center.y;
  const z = point.z - camera.center.z;
  const cy = Math.cos(camera.yaw), sy = Math.sin(camera.yaw);
  const cp = Math.cos(camera.pitch), sp = Math.sin(camera.pitch);
  const rx = x * cy + z * sy;
  const rz = -x * sy + z * cy;
  const ry = y * cp - rz * sp;
  const depth = y * sp + rz * cp;
  const focal = 1050;
  if (focal + depth <= 120) return null;
  const perspective = focal / (focal + depth);
  const scale = Math.min(view.width / 640, view.height / 610) * camera.zoom;
  return {
    x: view.x + view.width / 2 + camera.pan.x + rx * scale * perspective,
    y: view.y + view.height / 2 + camera.pan.y + ry * scale * perspective,
    depth, perspective, scale
  };
}
```

Draw visible nodes from greatest depth to least depth. With this convention,
positive depth is farther away. Exclude clipped points from drawing and selection.
Use the same projected results for edges, labels, and hit detection; separate
projection implementations tend to drift apart.

## 2. Stable layout preserves the user's mental map

Random positions on every render made navigation disorienting. Derive offsets
from stable source-qualified file IDs. Group centers can come from source labels;
skill roots get anchors, and supporting files sit around their owning skill.
Sort the complete inventory once when assigning anchors, then filter its visible
members without recomputing their positions. Cache positions by ID when importing
additional data, so adding one node need not move everything.

Adapted from `atlas-layout.mjs`; positions express layout, never semantic links:

```js
function unitHash(text) {
  let n = 2166136261;
  for (const c of text) n = Math.imul(n ^ c.charCodeAt(0), 16777619);
  return (n >>> 0) / 4294967296;
}

function supportingPosition(id, anchor) {
  const angle = unitHash(id + ':angle') * Math.PI * 2;
  const radius = 24 + unitHash(id + ':radius') * 27;
  return {
    x: anchor.x + Math.cos(angle) * radius,
    y: anchor.y + Math.sin(angle) * radius,
    z: anchor.z + (unitHash(id + ':depth') - 0.5) * 55
  };
}
```

Treat clustering as presentation. Only draw a file-reference edge when the selected
input documents contain evidence for it. Repeated filenames in different sources
need different IDs, such as a source identifier paired with a relative path.

## 3. Fit into the clear map area, not the whole canvas

One of the most consequential fixes was accounting for the inspector, headings,
and playback controls. A graph centered in the canvas can be hidden behind a panel.
Measure visible panels in canvas-local CSS coordinates and reserve their space.
On narrow screens, put panels below the map rather than reducing the map to a sliver.

The fitting example uses projected bounds, not the mean screen position. This
matters for asymmetric branches: one distant endpoint should not pull the visual
center toward a cluster of nearby files. Reserve padding for node radii and labels.

Adapted from `app.js` viewport measurement and `navigation.mjs` framing:

```js
function clearViewport(width, height, inset) {
  const v = {
    x: inset.left, y: inset.top,
    width: width - inset.left - inset.right,
    height: height - inset.top - inset.bottom
  };
  return v.width >= 80 && v.height >= 80 ? v : null;
}

function fitCamera(points, camera, view, padding = 36) {
  if (!points.length) return structuredClone(camera);
  if (view.width <= padding * 2 || view.height <= padding * 2)
    throw new Error('Give the map more room before fitting.');
  const next = structuredClone(camera);
  next.center = {x: 0, y: 0, z: 0};
  for (const p of points) for (const axis of ['x', 'y', 'z'])
    next.center[axis] += p[axis] / points.length;
  next.pan = {x: 0, y: 0};
  next.zoom = 1;
  const screen = points.map(p => project(p, next, view));
  if (screen.some(p => p === null))
    throw new Error('Graph depth exceeds the camera range.');
  const minX = Math.min(...screen.map(p => p.x));
  const maxX = Math.max(...screen.map(p => p.x));
  const minY = Math.min(...screen.map(p => p.y));
  const maxY = Math.max(...screen.map(p => p.y));
  next.zoom = Math.min(2, (view.width - 2 * padding) / Math.max(1, maxX - minX),
    (view.height - 2 * padding) / Math.max(1, maxY - minY));
  next.pan.x = (view.x + view.width / 2 - (minX + maxX) / 2) * next.zoom;
  next.pan.y = (view.y + view.height / 2 - (minY + maxY) / 2) * next.zoom;
  return next;
}
```

Here radius is treated as screen-oriented, as in the original viewer. Choose padding
large enough for the labels that must remain visible, or include measured label
extents in your bounds calculation. Clip/suppress optional labels instead of claiming
every label fits. For very large collections, calculate min/max in a loop rather
than spreading arbitrarily large arrays into Math.min/Math.max.

Compute the viewport after panel layout is settled. When resize changes it, reframe
only if the user was in a fitted view. Preserve a manually chosen camera otherwise.

## 4. Zoom toward the pointer

Zoom centered only on the canvas made users repeatedly pan back to their target.
Keep the point under the mouse in the same screen location as zoom changes.
Normalize wheel delta modes because trackpads and mouse wheels report different
units. Apply wheel prevention only while the pointer is interacting with the map.

Adapted from `navigation.mjs`, `zoomBy` and `wheelFactor`:

```js
function wheelFactor(delta, mode = 0, height = 600) {
  const pixels = delta * (mode === 1 ? 16 : mode === 2 ? height : 1);
  return Math.exp(-Math.max(-160, Math.min(160, pixels)) * 0.0014);
}

function zoomAt(camera, factor, anchor, view) {
  const next = structuredClone(camera);
  next.zoom = Math.max(0.08, Math.min(8, camera.zoom * factor));
  const ratio = next.zoom / camera.zoom;
  const cx = view.x + view.width / 2, cy = view.y + view.height / 2;
  next.pan.x = anchor.x - cx - (anchor.x - cx - camera.pan.x) * ratio;
  next.pan.y = anchor.y - cy - (anchor.y - cy - camera.pan.y) * ratio;
  return next;
}
```

On a pinch gesture, apply scale immediately, then pan by the movement of the two
fingers' midpoint. Easing each touch update makes the view lag behind the fingers.
If a camera animation is running, resolve its current state before starting a new
manual gesture. Capture one history entry per gesture, not one per pointer event.

## 5. Consistent node sizes communicate real measurements

Calculate bytes and distinct reference neighbors over the full selected inventory.
Changing a search filter should not change a file's measured importance. A skill
node measures its own instruction file; a folder aggregate is a different measure
and should be labeled as such. Use a fixed logarithmic scale with a minimum radius
so small files remain selectable and large files do not cover their neighbors.

Adapted from `metrics.mjs`:

```js
function metricRadius(metric, mode = 'connections') {
  const value = mode === 'bytes' ? metric.bytes / 1024 : metric.connections;
  const safe = Number.isFinite(value) && value > 0 ? value : 0;
  const weight = mode === 'bytes' ? 1.45 : 2.1;
  return Math.min(13, 3 + weight * Math.log2(1 + safe));
}
```

Apply perspective to this radius if you want distant nodes to appear smaller;
retain a generous screen-space hit target. Do not call degree "usage" or "importance"
without evidence: it is simply the number of distinct referenced neighbors.

## 6. Labels should compete for space in priority order

Showing every label created unreadable overlaps. Lay out the selected and hovered
labels first, then current workflow steps and neighbors, then secondary context.
Measure text using the actual canvas font. Try below, above, right, and left.
Accept only positions inside the clear viewport that do not intersect already
accepted labels or important node circles' bounding boxes.

Adapted from label placement in `render.js`:

```js
function overlaps(a, b) {
  return a.x < b.x + b.w && a.x + a.w > b.x &&
    a.y < b.y + b.h && a.y + a.h > b.y;
}

function chooseLabel(node, textWidth, view, occupied) {
  const w = textWidth + 14, h = 22, gap = node.r + 8;
  const choices = [
    {x: node.x - w / 2, y: node.y + gap, w, h},
    {x: node.x - w / 2, y: node.y - gap - h, w, h},
    {x: node.x + gap, y: node.y - h / 2, w, h},
    {x: node.x - gap - w, y: node.y - h / 2, w, h}
  ];
  return choices.find(r => r.x >= view.x && r.y >= view.y &&
    r.x + r.w <= view.x + view.width && r.y + r.h <= view.y + view.height &&
    !occupied.some(other => overlaps(r, other))) ?? null;
}
```

Pass other visible nodes' bounding boxes into occupied, then append each accepted
label box. When no placement is available, show the full title in the inspector
and accessible list. Store accepted label boxes as click targets as well as node
circles. Use pointer distance and depth to resolve overlapping targets predictably.
Separate a drag from a click using a movement threshold, and handle pointer cancel.

## 7. Crisp canvas, restrained glow

The drawing buffer needs device-pixel scaling, but the scene math stays in CSS
pixels. Cap pixel ratio to keep memory and fill-rate costs reasonable. Give the
canvas its CSS dimensions through its containing layout; changing the buffer alone
must not resize the container recursively.

Adapted from `render.js` sizing; connect this helper to your resize observer:

```js
function sizeCanvas(canvas, width, height, pixelRatio = 1) {
  const ratio = Math.max(1, Math.min(pixelRatio, 2));
  canvas.width = Math.max(1, Math.round(width * ratio));
  canvas.height = Math.max(1, Math.round(height * ratio));
  const ctx = canvas.getContext('2d');
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
  return ctx;
}
```

Use a small bright core, a subdued halo, and a thin selection ring. Too much glow
made the view hazy. Dim unrelated nodes when selecting a file; keep selected nodes,
neighbors, and current workflow steps legible. Reset alpha/shadow settings between
passes, or use save/restore. Give panels and buttons solid readable surfaces.

Redraw when data, camera, hover, selection, viewport, or an active animation changes.
Pause optional animation when the document is hidden. Respect reduced motion for
camera transitions, pulses, particles, and orbit. The accessible list remains a
first-class route to every file even when the graph is dense.

## 8. Process steps need identities separate from file nodes

A workflow may use the same skill twice. Reusing its file ID for both steps collapses
two distinct actions into one visual node. Give every process step its own ID and
link it to supporting skill IDs. Keep gold directed sequence arrows separate from
the static file-reference graph. Human actions can appear with no linked skill.

This small route reader illustrates the separation. Validate the entire workflow
first, including unreachable branches and duplicate IDs. This function checks the
selected path but is not a complete schema validator.

```js
function selectedRoute(workflow, choices = {}) {
  const steps = new Map(workflow.steps.map(step => [step.id, step]));
  const route = [], visited = new Set();
  let id = workflow.start;
  while (id != null) {
    if (visited.has(id)) throw new Error('Cycle in the selected route.');
    const step = steps.get(id);
    if (!step) throw new Error('Missing step: ' + id);
    visited.add(id);
    route.push(step);
    if (step.kind === 'decision') {
      const index = Object.hasOwn(choices, id) ? choices[id] : 0;
      if (!Number.isInteger(index) || !step.choices?.[index])
        throw new Error('Invalid branch choice: ' + id);
      id = step.choices[index].next;
    } else id = step.next;
  }
  return route;
}
```

Keep route selection separate from playback position. Changing a branch should
pause playback and select a valid step on the new route. Store workflow ID, choice
map, and current step ID rather than relying solely on a numeric index.

For optional timed highlighting, the existing player tracked remaining base time.
On pause: remaining = max(0, remaining - elapsed times speed). On resume: schedule
remaining divided by speed. On speed change: pause, update speed, resume. Clear the
old timer before a seek, route change, or close. Inject the clock for deterministic
checks. Playback illustrates the document and does not perform its actions.

## Fixes worth carrying forward

| Problem encountered | Pattern to preserve |
| --- | --- |
| Workflow appeared off-center or under a panel | Fit projected bounds in the measured clear viewport. |
| Fullscreen hid the steps | Keep the map and sequence inside the same fullscreen wrapper. |
| Camera kept fighting the user | Manual pan, rotation, zoom, or centering disables Follow step. |
| Back returned to the wrong view | Keep map and workflow camera history separate; restore the complete saved state. |
| Same skill appeared in multiple steps | Separate process-step IDs from linked file IDs. |
| Filter changes altered node sizes | Measure the full inventory and use fixed scales. |
| Labels covered nodes and each other | Place important labels first; suppress optional labels when crowded. |
| Touch controls became cramped | Use a stacked narrow-screen layout and roughly 44px touch controls. |
| Background playback advanced unexpectedly | Pause on visibility change and preserve the remaining step time. |
| A data refresh lost the user's place | Match stable IDs, preserve valid choices, and resume paused. |

## Efficient build order

1. Define a tiny synthetic inventory and one branching workflow.
2. Implement pure camera projection, framing, and stable IDs; check their invariants.
3. Draw the graph and connect the same screen positions to pointer selection.
4. Add the inspector, measured clear viewport, and pointer-anchored zoom.
5. Add label placement, fixed metric sizes, and accessible file navigation.
6. Add workflow branching and manual step selection, then optional playback.
7. Refine glow, depth cues, transitions, narrow screens, and keyboard interaction.
8. Add user-selected inputs and snapshot exports within the companion brief's scope.

Avoid changing the layout algorithm, camera convention, and viewport handling at
the same time: diagnose each independently. A complete framework rewrite is not
needed to fix framing or label density.

## Checks that catch the expensive mistakes

- The camera center projects to viewport center plus pan at every angle.
- A point directly under the zoom anchor stays there after zoom.
- An asymmetric set fits within padding and has centered projected bounds.
- The same file ID and anchor produce exactly the same layout position.
- Filtering preserves positions and metric radii of remaining nodes.
- Label rectangles fit in the viewport and avoid previously occupied rectangles.
- Both branches of the sample workflow reach their correct outcomes; a cycle fails.
- Repeated use of one skill produces two process steps rather than a merged step.
- Desktop, small laptop, tablet, and phone layouts leave a useful clear map area.
- Escape leaves fullscreen before closing the workflow. Returning to the map restores
  its camera, selection, and filters.

## Evidence boundary

The original viewport/navigation work included desktop and phone checks, asymmetric
route centering across multiple angles, and camera restoration checks. These
adapted snippets need their own verification: some interfaces and edge handling
differ from the original. The accompanying authoring check exercises projection,
framing, zoom, deterministic placement, radius limits, label placement, and branch
selection. It also checks canvas buffer scaling with a small stub. That is not an
end-to-end browser test of a newly built viewer.
