Skip to content

Spatial — depth scaffolding for layered typography

A small library for putting type into felt space — perspective grids that turn the page from a stack of layers into a single volumetric scene, plus a set of Three.js + CSS3DRenderer exemplars that consume the same coordinate system at runtime. Reach for this channel when you need to argue "these things live in a relationship," not just "these things appear in a list."

Part of the muriel skill — see the top-level index for mission and universal rules. Sister channels: svg.md for the static surface this writes onto; web.md and interactive.md for the runtime surfaces the JS exemplars target.

Why this channel exists

Floating text in 3D space without a perspective scaffold reads as stacked planes — there is no felt depth, just layers. The visual system needs three or four well-placed cues — vanishing-point convergence, a horizon anchor, foreshortened transversals — to lock a scene into one continuous space. Once it does, the same screen real estate carries far more information without overcrowding.

This is not a styling trick. Spatial-typography is an explicit lineage in design history — Alberti's De pictura (1435) and the costruzione legittima; Dürer's perspective machine (1525); architect's blueprint axonometrics; Muriel Cooper's MIT Visible Language Workshop (receding planes of type as an information environment); the Robertson / Mackinlay / Card Information Visualizer cone trees and the Dumais / Cockburn / Robertson Data Mountain at MSR (spatial memory as an indexing primitive); and the Tron / synthwave horizon-to-VP grid that re-popularised one-point perspective in the 1980s. Every one of those traditions earned its geometry — the depth carried meaning, not decoration.

Pre-flight question for any spatial composition: if I flattened the scene to a list, would the reader lose information? If no, ship the list.

What ships today

# Surface Module / file What it does
1 Static SVG perspective grids muriel.spatial grid("1pt"|"2pt"|"3pt"|"iso", BBox)PerspectiveGrid with .svg(). Tron-style cyan-on-near-black defaults; horizon anchor, fade-to-horizon depth weighting, VP annotation toggle.
2 Ridgemap (stacked 1D slices) muriel.spatial.ridgemap ridgemap(field, BBox)RidgeMap with .svg(). Joy Division Unknown Pleasures / Harold Craft 1970 pulsar plot — each row of a 2D scalar field becomes a polyline, rows stacked top-to-bottom, front ridges occlude back ridges via a baseline-closed polygon fill. Sibling primitive to grid(): where the perspective grid is a scaffold for space, the ridgemap is a scaffold for scalar fields. Zero-dep (duck-types numpy ndarray).
3 Shared 3D runtime helpers render_assets/_lib/spatial.{css,js} createScene, Mountain, addFloorGrid, addHorizon, makePlane (CSS3D), FocusController, startRenderLoop. WebGL + CSS3DRenderer stacked so DOM text gets composited in 3D without losing selectability or accessibility.
4 Layered DOM × perspective grid render_assets/spatial-typography/ Cooper VLW homage — receding planes of DOM type, navigable, on a horizon-anchored grid. Base exemplar for the lib.
5 Data Mountain (MSR) render_assets/mindbendingpixels-mountain/ + render_assets/sciprogfi-agentchan-mountain/ Dumais et al. (2001) spatial-memory index — tilted plane group with click-to-focus zones; cards arranged on the slope so spatial position carries identity. Two brand skins (psychodeli + sciprogfi/agentchan).
6 Perspective Wall render_assets/perspective-wall/ + render_assets/sciprogfi-lux-mesh-wall/ Mackinlay / Robertson / Card (1991) focus + context — central card readable, peripheral cards foreshortened against the receding wall. Two brand skins (psychodeli + sciprogfi/OHC mesh).
7 Demo gallery render_assets/index.html Single page indexing every exemplar with kicker / tag / lineage / source link.

Queued. muriel.spatial.typeset_scene() — Python emitter that consumes a PerspectiveGrid and a list of DOM blocks and writes a .html artifact ready to drop into render_assets/<name>/index.html. Closes the design.md → brand.toml → CSS-3D-typography loop so the static SVG grid and the interactive scene share their coordinate system by construction, not by hand-port.

The static path — muriel.spatial

Pure Python, zero deps, SVG-first. Match the muriel ethos: deterministic output, no runtime, exportable to print.

from muriel.spatial import grid
from muriel.layout import BBox

g = grid("1pt", canvas=BBox(0, 0, 1200, 700))
open("grid.svg", "w").write(g.svg(stroke="#7fdfff", bg="#0a0a14"))

g2 = grid("2pt", canvas=BBox(0, 0, 1200, 700), vp_offsets=(-1.5, 1.5))
for vp in g2.vanishing_points:
    print(vp.name, vp.x, vp.y)

Four modes, each carrying a different argument shape:

  • "1pt" (one-point) — single VP on the horizon. Corridor / Tron horizon / cinematic into-the-distance. Use when the argument is "this leads toward a single point."
  • "2pt" (two-point) — two VPs on the horizon, verticals stay vertical. Architectural cube corner. Use when the argument is "this object has volume; you're looking at it from a definite vantage."
  • "3pt" (three-point) — two horizon VPs + one vertical VP (high or low). Looking up at a tower / down into a pit. Use when the argument is "you are below / above the subject."
  • "iso" (isometric) — three axes at 30° / 150° / 90°, no convergence. Use when the argument is "every position is measurable; depth is not a story."
python -m muriel.spatial --demo                # 2×2 panel of all four modes
python -m muriel.spatial --demo --mode 1pt     # one mode, full canvas
python -m muriel.spatial --ridgemap            # pulsar-style ridgemap demo
python -m muriel.spatial --selftest            # assertion suite

The result objects (PerspectiveGrid, VanishingPoint, GridLine) are frozen dataclasses you can inspect before emit — the lines tuple carries every clipped segment with its role (horizon, orthogonal-floor, transversal-ceiling, from-vp-z, …) and weight (depth-driven opacity). That makes the grid composable into a larger SVG composition rather than only viewable as a standalone document.

The ridgemap path — muriel.spatial.ridgemap

Sibling primitive to grid(). Where the perspective grid scaffolds space, the ridgemap scaffolds scalar fields: any source that yields one value per (x, y) — terrain DEM, gaze density, image luminance, attention map, audio spectrogram, weather front — feeds the same emitter, and the rendering is just which projection of the field you draw. First shipped projection is the stacked 1D-slice form (Harold Craft's 1970 PSR B1919+21 successive-period chart, popularised by Peter Saville's 1979 Joy Division Unknown Pleasures sleeve, ported to statistical density plots by Wilke's ggridges in 2016). Filled-isoline, wireframe-protrusion, and hachured-relief projections are queued under the same module.

from muriel.spatial import ridgemap
from muriel.layout import BBox

# field is any 2D iterable — list-of-lists, tuple-of-tuples,
# numpy.ndarray. Each row becomes one ridge polyline.
field = [[...], [...], ...]                        # shape (rows, cols)
rm = ridgemap(field, canvas=BBox(0, 0, 800, 600))
open("ridges.svg", "w").write(rm.svg())             # cream-on-near-black default
open("ridges-lineart.svg", "w").write(rm.svg(fill=None))  # no occlusion, every ridge visible

The default .svg() paints each ridge with a fill that matches the background colour, closed down to the row's baseline — that is the occlusion that makes the stack read as layered rather than transparent. Set fill=None to drop the occlusion and let every ridge show through, useful when the back-to-front semantics are not what you're arguing.

Key knobs on ridgemap(...):

  • amplitude — peak excursion (canvas units) for a sample at vmax. Default 1.6 × row_spacing, which makes the tallest peaks rise past the next row's baseline (the overlap that creates the iconic stacked look).
  • row_spacing — vertical distance between baselines. Default fills the canvas: (canvas.height - 2×pad) / (n_rows - 1).
  • vmin / vmax — clamp the normalisation range. Default: data min / max.
  • margin — fraction of canvas reserved as padding on every side (default 0.06).

The result object (RidgeMap, frozen, with a ridges tuple of Ridge frozen dataclasses) is composable: read each ridge's points and baseline_y to drop the geometry into a larger SVG — annotate the strongest peak, overlay a vertical reference axis, etc.

When to reach for ridgemap vs the existing channels:

  • Reach for ridgemap when the row ordering and stacking direction carry meaning (time-series of successive periods, depth profiles down a core, frequency-over-time spectrogram). The eye reads the stack as evolution — that semantics is the whole argument.
  • Reach for heatmaps.md (render_heatmap) when row order doesn't matter and you want value→colour with no occlusion. Better for quantitative comparison across the whole field.
  • Reach for science.md (matplotlib pcolormesh / contourf) when you need axis labels, value scale legend, and a peer-reviewable figure.

The interactive path — render_assets/_lib/spatial.{css,js}

Each exemplar in render_assets/<name>/index.html is self-contained: an importmap points three at a CDN, the page imports ../_lib/spatial.js, declares its scene, and starts the render loop. No build step.

<link rel="stylesheet" href="../_lib/spatial.css">

<script type="importmap">
{"imports": {
  "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
  "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}}</script>

<script type="module">
  import { createScene, Mountain, makePlane, FocusController,
           startRenderLoop } from '../_lib/spatial.js';

  const { scene, camera, webglRenderer, cssRenderer } =
    createScene({ cameraPos: [0, 170, 780], lookAt: [0, 130, -300] });

  const mountain = new Mountain(scene, { tiltDeg: 22, depth: 1400 });
  for (const card of cards) {
    const plane = makePlane(card.html, { width: 360, height: 220 });
    const { position, rotation } = mountain.planeToWorld(card.row, card.col);
    plane.position.copy(position); plane.rotation.copy(rotation);
    scene.add(plane);
  }

  startRenderLoop({ scene, camera, webglRenderer, cssRenderer });
</script>

The two-renderer stack (#webgl underneath, #css3d on top) is intentional: WebGL handles the grid / horizon / atmospheric layer at GPU speed; CSS3DRenderer carries the DOM cards so text stays selectable, copyable, and screen-reader-addressable. pointer-events is gated on the HUD so floating-corner brand chrome doesn't absorb clicks meant for cards that overlap it in screen space.

Palette tokens

_lib/spatial.css exposes the demo base palette as CSS custom properties at :root — every exemplar overrides --bg and adds its own card classes, but pulls font stacks and accent colours from this floor:

Token Default Use
--bg #07070d Page background — demos override per scene mood
--ink #e6e4d2 Primary text. 15.42:1 contrast on default --bg — passes muriel's 8:1 floor
--ink-dim rgba(230,228,210,0.62) Secondary text
--cyan #7fdfff Grid lines, hint chips, citation links
--magenta #ff5fa2 Horizon, hover states, focal accent
--gold #d2b06a Editorial highlight (italicised brand chrome)
--mono system mono stack HUD meta, citations, kbd chips
--sans system sans stack Brand chrome, headings
--serif system serif stack Editorial body within DOM cards

The defaults are deliberately Tron / Cooper-VLW palette-coded so the exemplars all feel like one family. A brand can override by reassigning the tokens at :root inside its own demo override block (see sciprogfi-agentchan-mountain/index.html for the agent-green override).

Lineage — where to read more

Each piece of geometry in this channel has prior art that earned it. Cite when you ship something built on the channel; the citation does work, not decoration.

Period Source Geometry it earned
1435 Alberti, De pictura Linear perspective; the costruzione legittima construction
1525 Dürer, Underweysung der Messung Perspective machine; mechanical projection
1980s Cooper, MIT Visible Language Workshop Receding planes of type as an information environment
1991 Mackinlay, Robertson, Card (Xerox PARC) — The Perspective Wall: Detail and Context Smoothly Integrated (CHI '91) Focus + context; central detail readable, peripheral context foreshortened
1993 Robertson, Mackinlay, Card — Information Visualizer Cone trees; perspective as navigation primitive
1970 Harold Craft, Cornell PhD — successive-period plot of pulsar PSR B1919+21 Stacked 1D slices of a 2D scalar field; row order = time, x = phase, height = amplitude
1979 Peter Saville for Joy Division, Unknown Pleasures Same plot, restyled into the most reproduced ridge-plot in graphic design; established back-to-front occlusion as the readable form
1998 Tron / synthwave / vaporwave One-point perspective as cultural shorthand for "computer space"
1998 Robertson, Czerwinski, Larson, Robbins, Thiel, van Dantzich (MSR) — Data Mountain (UIST '98) — (Dumais et al. 2001 was the spatial-memory follow-up study) Spatial memory as an indexing primitive; cards arranged by location on a tilted plane
2016 Claus O. Wilke — ggridges (R package) Ridge plot ported to statistical density visualisation; established stack-as-distribution-comparison vocabulary

Anti-prescription — when not to reach for this channel

  • Quantitative comparison. Perspective foreshortens. If the reader needs to compare values, the foreshortened series will systematically under-weight the back. Use science.md or infographics.md.
  • Forms / data entry / chrome. Tilted text impairs reading speed. Don't tilt anything the user needs to fill in.
  • Tiny screens. Under ~720px wide, perspective scenes lose their cues and read as visual noise. Fall back to a flat composition.
  • Accessibility-critical surfaces. CSS3DRenderer preserves DOM text, but screen-reader navigation through a 3D scene is non-trivial; pair every demo with a flat fallback at prefers-reduced-motion: reduce and (where the artifact is reference, not exploration) a linked flat-HTML companion.
  • Generic 'add depth' decoration. If you can't name which lineage (Cooper / Perspective Wall / Data Mountain / Tron / Unknown Pleasures) the composition belongs to, you're decorating, not arguing.
  • Ridgemap when row order is arbitrary. Stacking implies sequence (time / depth / index). If the rows could be shuffled without losing meaning, the visual is making an argument the data does not support — use a heatmap.
  • Ridgemap for precise peak comparison. Front ridges occlude back ridges by construction. If the reader needs to compare row maxima quantitatively, switch to fill=None (line-art mode), a heatmap, or small multiples.

Files this channel owns

muriel/spatial.py                                  # static SVG perspective grids + ridgemap
render_assets/_lib/spatial.css                     # shared base palette + HUD
render_assets/_lib/spatial.js                      # createScene / Mountain / FocusController / planeToWorld
render_assets/index.html                           # gallery
render_assets/spatial-typography/index.html        # Cooper VLW exemplar
render_assets/mindbendingpixels-mountain/          # Data Mountain (psychodeli skin)
render_assets/sciprogfi-agentchan-mountain/        # Data Mountain (sciprogfi/agentchan skin)
render_assets/perspective-wall/                    # Mackinlay-Robertson-Card (psychodeli skin)
render_assets/sciprogfi-lux-mesh-wall/             # Perspective Wall (sciprogfi/OHC skin)

Queued

  • muriel.spatial.typeset_scene() — Python emitter that takes a PerspectiveGrid + a list of DOM blocks (HTML strings + ("vp", "left", 3) / ("grid", row, col, depth) anchor names) and writes a runnable <scene>/index.html that wires the same coordinates into the JS lib. Closes the static-↔-interactive loop so a paper figure and a fly-through share their geometry by construction.
  • Reduced-motion fallback emitter. When a demo loads under prefers-reduced-motion: reduce, the lib should swap to a flat composition (the static SVG grid plus the cards laid out by row/col in the document order). One opt-in flag on startRenderLoop.
  • muriel.spatial.cone_tree() — Robertson-Mackinlay-Card (1993) hierarchical cone-tree primitive, both as static SVG and as a JS scene. Pairs with the diagrams.md dendrogram / pyramid entries when the hierarchy is large enough that a flat tree falls off the page.
  • Further ridgemap projections. The ridgemap MVP ships only the stacked-1D-slice form. The same (field, canvas, …) signature should grow siblings that render the same scalar field as: filled isoline bands (USGS / Imhof quadrangle), wireframe protrusion from a grid (BYTE-cover / Tinney 3D mesh), and hachured / halftone relief. One extractor stage, several emitters — the unifier is scalar field → topology. Documenting the taxonomy in a channels/topography.md companion before the next emitter lands.
  • Field ingestion from pixels. Today ridgemap takes a 2D numeric array. A small muriel.spatial.field_from_image(path, downsample=N, channel="luma") helper would let raster sources (image luminance, video frame Δ, attention/saliency maps) feed the same primitive without callers needing to roll their own resampling. Crosses the channel's first input-is-pixels line — defer until a real artifact needs it.

See TODO.md for cross-channel queue context.