Skip to content

Latest commit

 

History

History
742 lines (589 loc) · 53.6 KB

guide.md

File metadata and controls

742 lines (589 loc) · 53.6 KB

topographic guide

This guide explains how to use topographic. Each section covers one topic. To try the settings live, open the playground. To see how the parts of the code fit together, read design.md.

Install

npm install topographic            # core and the web component
npm install topographic-react      # React bindings (needs React 18 or 19)

The packages are ESM only. They have no dependencies. They are marked as side-effect free, except topographic/element. That one registers the element when you import it.

With a coding agent, npx skills add yahyabedirhan/topographic installs the topographic skill. The skill installs the packages, adds a first terrain and points the agent at this guide.

On npm: topographic and topographic-react.

Three ways to use it

Vanilla JavaScript: createTopography

import { createTopography } from 'topographic';

const topography = createTopography(element, settings?, options?);

The call adds a canvas inside element. The canvas fills the element, so give the element a size. The call returns an instance with these members:

Member What it does
update(partialSettings, { instant? }) Changes some settings and applies them right away. Values are kept inside their ranges. Only the changed values are sent to the GPU. With scenes set, a new scene and a new theme mode fade in; instant: true jumps to them at once.
setOptions({ maxPixelRatio?, maxPixels?, maxFps? }) Changes the renderer options below while the renderer runs. The values you leave out stay. The canvas is sized again at once.
blend { weights, dark }: the weight of each scene, from 0 to 1, and the dark amount drawn now, from 0 (light) to 1 (dark). See Scenes.
snapshot({ frame?, width?, height?, region?, motion? }) Draws one PNG image in the background and returns it as a Blob. See Snapshots.
record({ seconds?, fps?, width?, height?, frame?, format?, motion? }) Records a short 3D video with a moving camera. Returns it as an MP4 or WebM Blob.
setHeightfield({ samples, size, aspect } | null) Draws the lines from your own elevation data (a height map) instead of the generated shape. The data is a square grid. samples is a Float32Array of size × size values, from 0 (low) to 1 (high). Row 0 is the south edge. aspect is the place's width divided by its height. null brings back the shape's own shape. The grid stays until the location setting changes. The location setting is usually simpler.
setInsets({ top, right, bottom, left } | null) Tells the 3D view how much space your page's UI covers on each side, in CSS pixels. A toolbar or a dock are examples. The 3D picture then moves so the terrain is centred in the free space. The camera does not move, so orbiting still turns around the terrain. 2D does not change. Snapshots and videos ignore this setting (see Snapshots). null clears it. insets reads it back.
clampPan(panX, panZ) Returns the pan as [x, z], limited so the view stays over the terrain. This is the same limit the renderer uses for camera.panX and camera.panZ.
stats { fps, frameMs, mesh, quality } for the 3D view. mesh is 0 in 2D. quality is the tier actually drawing: low, medium or high. full reports high.
pose { azimuth, elevation, radius, panX, panZ }: where the 3D camera is now on its motion path. At azimuth 0, the camera looks from the south and north is at the top of the screen. The angle grows clockwise, as seen from above. So as it grows, north turns towards the right of the screen.
destroy() Stops drawing. Removes the listeners and the canvas, and frees the WebGL context.
settings The current settings, with every value filled in.
status 'webgl2', 'webgl1', 'fallback', 'lost' or 'destroyed'.
canvas The canvas element.

options is optional. You rarely need it:

Option Default What it does
maxPixelRatio 2 The highest device pixel ratio to draw at. A quality setting can cap it further.
maxPixels 4147200 (1920×1080×2) The most pixels to draw. Above this, the canvas draws at a lower resolution. A quality setting can cap this further. Infinity removes the limit.
maxFps 0 The most frames to draw each second, such as 30 on a phone. 0 draws on every display frame.
powerPreference 'low-power' on a phone, else 'default' The GPU the WebGL context asks for: 'default', 'low-power' or 'high-performance'. A background that must stay light can ask for 'low-power' everywhere.
forceWebGL1 false Skip WebGL2. Useful for testing.

setOptions changes maxPixelRatio, maxPixels and maxFps later, for example on a resize.

Web component: <topographic-view>

<script type="module">import 'topographic/element';</script>

<topographic-view shape="long-ridge" line-count="36" peaks="2" theme="dark"></topographic-view>
  • Every setting has an HTML attribute (listed in Settings). There are also look, shape, location, seed, theme, ink and paper.
  • A changed attribute applies right away. A removed attribute goes back to the shape's default.
  • element.topographic is the instance. element.snapshot(opts) is a shortcut to its snapshot.
  • The element is display: block. Size and position it with CSS, as Sizing and layering shows.

React: <Topography> and useTopography

import { Topography, useTopography } from 'topographic-react';

<Topography
  shape="dunes"
  lines={{ count: 24 }}
  theme={{ mode: 'dark' }}
  style={{ position: 'fixed', inset: 0, zIndex: -1 }}
/>
  • Props: look, shape, location, seed, lines, terrain, motion, layout, theme, mode, elevation, extent, detail, quality, camera, view2d, scenes, scene, column, options, onReady, plus any div props. Children are drawn on top.
  • style is React's normal inline style. The land shape is the shape prop.
  • TopographyInstance is the type of the instance.
  • Each group of settings updates only when its values change. A new object with the same values does nothing.
  • onReady(instance) gives you the instance after mount, for example to call snapshot. It gives null on unmount.
  • A change to options creates the renderer again. Every other change updates in place.

To use your own element, use the hook:

const ref = useTopography({ shape: 'seed-pod' });
return <section ref={ref} style={{ height: 400 }} />;
// ref.topographic is the instance while mounted

Sizing and layering

The background fills the element you give it. The element needs a size: without a height, it draws nothing.

To put the background behind the whole page, fix it to the window and move it behind the content:

<topographic-view shape="long-ridge" style="position:fixed;inset:0;z-index:-1"></topographic-view>
<Topography shape="long-ridge" style={{ position: 'fixed', inset: 0, zIndex: -1 }} />

To fill one section, give the element a height. Children of <Topography> are drawn on top:

<Topography shape="granite" style={{ height: '100vh' }}>
  <h1>Hello</h1>
</Topography>

A className works the same way: size the element in your own CSS. In plain JavaScript, size the element you pass to createTopography.

Settings

The settings are in groups. Pass only the values you want to change:

{
  look: 'survey-map',
  shape: 'dunes',
  location: null,
  seed: 0,
  lines:   { count, width, opacity, indexEvery },
  terrain: { peaks, spread, roughness, sharpness, terracing, variant },
  motion:  { speed, warp, sparkle },
  layout:  { anchor, fade },
  theme:   { mode, ink, paper },
  quality: 'auto',
  column: 300,
  scenes: [],
  scene: 0,
}

column, scenes and scene are described in Layout and Scenes.

look is a named design, described in Looks. The other settings override its values.

A value outside its range is moved to the nearest limit. Whole-number settings are rounded. A value that is not a number uses the default. Some shapes have their own defaults. For example, dunes uses a lighter centre fade.

Lines

Setting Attribute Range Default What it does
lines.count line-count 6–60, whole 30 How many contour lines there are.
lines.width line-width 0–1 0.26 How thick each line is.
lines.opacity line-opacity 0–1 0.4 How strongly the lines show against the background.
lines.indexEvery index-every 0–10, whole 5 Draws every Nth line bolder, like the index lines on a map. 0 turns it off. The playground calls it Bold lines ("1 in 5").

Terrain

Setting Attribute Range Default What it does
terrain.peaks peaks 1–6, whole 1 How many summits the terrain has.
terrain.spread spread 0–1 0.5 How far the extra peaks are from the main summit.
terrain.roughness roughness 0–1 0 Adds noise to the slopes. Low is smooth hills. High is rocky ground.
terrain.sharpness sharpness 0–1 0.5 Below 0.5, summits get flatter. Above 0.5, they get sharper.
terrain.terracing terracing 0–1 0 Groups the lines into steps.
terrain.variant variant 0–1 0.5 Changes the land in a way that depends on the shape. For drifting lobes, it goes from three lobes to four.

Motion

Setting Attribute Range Default What it does
motion.speed speed 0–3 1 Animation speed. 0 stops the animation.
motion.warp warp 0–1 0.47 How much the lines wobble as they move.
motion.sparkle sparkle 0–1 0.6 Small bright dots that travel along the lines.

Layout

Setting Attribute Range Default What it does
layout.anchor anchor 0–1 0 0 keeps the summit to the side. 1 puts it in the centre.
layout.fade fade 0–1 0.7 Fades the lines behind a text column in the centre.
column column 0–4000 px, whole 300 Half the width of that faded centre column, in CSS pixels. The column is never wider than the view. It is a top-level setting, not part of layout.

Three dimensions

mode is '2d' or '3d'. 2D is the library default. The playground opens a bare URL on the built-in Istanbul map terrain in 3D, with the camera slowly pushing in and back. A URL with settings opens in 2D unless it has mode=3d. 3D shows the same contour lines as a landscape. With the same seed and settings, you get the same map in both modes. You can switch between them at any time.

Setting Attribute Range Default What it does
mode mode 2d, 3d 2d The flat map, or the same map as a landscape.
elevation elevation 0–1 0.62 How tall the 3D terrain is. 0 is flat, like the map. Higher values make it taller, with a rounded top.
extent extent endless, block endless endless is a plane that fades into the background colour. block is a solid slab. Its sides fade softly into the background colour.
detail detail 0–1 0.5 How many points the 3D surface has, from a 32² grid to a 160² grid.
quality quality auto, low, medium, high, full auto Graphics budget. auto follows the device and steps down if frames miss 60 Hz, or 120 Hz once that refresh has been seen. low, medium and high stay put. The budget caps the pixel ratio, the pixel count and the mesh size. full is high without the pixel caps, so only the renderer options limit the canvas.
camera.azimuth azimuth any finite number 349.7 The camera's angle around the terrain, in degrees.
camera.elevation camera-elevation 2–80 33.6 The camera's angle above the horizon, in degrees. The camera never goes below the terrain.
camera.radius radius 0.4–6.5 1.92 The camera's distance from the centre of the terrain.
camera.motion camera-motion still, orbit, push, flyover, drone still Makes the camera move. motion.speed still animates the terrain shape.
camera.speed camera-speed 0–3 1 How fast the camera moves along its path. 1 is the path's own pace, such as an orbit in about 26 seconds. 0 holds the camera. A change keeps the camera where it is.
camera.panX, camera.panZ pan-x, pan-z −6–6 0 The point the camera looks at, moved from the centre of the terrain. X is east and Z is south, in terrain units. The camera and every motion path turn around this point. The point stays over the terrain, so part of the terrain is always in view.
view2d.x, view2d.y, view2d.zoom view-x, view-y, view-zoom −6–6, −6–6, 1–8 0, 0, 1 The 2D map's own pan and zoom, apart from the 3D camera: the point at the centre of the view, x to the right and y up in screen heights, and how far it is zoomed in. 3D ignores it.
createTopography(el, {
  shape: 'gentle-summit',
  seed: 4,
  mode: '3d',
  elevation: 0.7,
  extent: 'block',
  camera: { azimuth: 42, elevation: 34, radius: 2.45, motion: 'orbit' },
});

The default camera looks from a little west of south. It is about a third of the way up. It is close enough that the terrain fills a wide window. record({ motion: 'flyover', format: 'mp4' }) records a video. It does not resize the live canvas. You can also use format: 'webm' if the browser can encode it.

Locations

A location draws a real place instead of the generated terrain. The location setting takes the id of a built-in place, or a height grid. It works in 2D and in 3D.

createTopography(el, { location: 'everest' });
createTopography(el, { location: 'fuji', mode: '3d', camera: { motion: 'orbit' } });
<topographic-view location="matterhorn" mode="3d"></topographic-view>
<Topography location="grand-canyon" mode="3d" />

The ten places

Id Place
istanbul Istanbul, the whole metro area with the Bosphorus
cyprus Cyprus, the whole island with some sea around it
matterhorn The Matterhorn and Zermatt
geirangerfjord Geirangerfjord
grand-canyon The Grand Canyon, from the South Rim to the North Rim
yosemite Yosemite Valley
torres-del-paine Torres del Paine
fuji Mount Fuji and the Fuji Five Lakes
everest Everest and the Khumbu
kilimanjaro Kilimanjaro

These are the playground's places. Each place is a 256 × 256 height grid that comes with the package, so it works offline and gives the same picture every time. A page downloads only the places it uses. A place's file is 25 to 115 KB when the server compresses it. Until the file arrives, the view shows only the paper colour. snapshot() and record() wait for it.

A built-in place also sets the look the playground gives it. That is the camera angle and distance, the 3D elevation, extent: 'block', the lines and a light centre fade:

{
  camera: { azimuth, elevation, radius: 1.32 },   // each place has its own angle
  elevation,                                      // each place has its own height
  extent: 'block',
  lines: { count: 36, width: 0, opacity: 0.9, indexEvery: 4 },
  layout: { fade: 0.15 },
}

It does not set mode, so a place is a 2D map unless you set mode: '3d'. The values you set win over the place's own. A change to another place sets that place's look again, except for the values in the same update.

LOCATIONS from topographic/locations lists each place with its id, name, bbox, aspect and defaults. loadLocation(id) returns a place's grid, with minMeters and maxMeters for its lowest and highest point. decodeGrid16(bytes) reads a grid saved in the same format, a square 16-bit greyscale PNG with north at the top, and returns { samples, size } with heights from 0 to 1. The playground serves its catalog places this way.

Any area: fetchLocation

import { fetchLocation } from 'topographic/locations';

const grid = await fetchLocation({ west: 28.45, south: 40.78, east: 29.65, north: 41.36 });
topography.update({ location: grid });

fetchLocation() downloads the elevation of any area and makes a 256 × 256 grid from it, the same way the playground does. The edges are in degrees. The result also has minMeters, maxMeters, the tile zoom and the number of tiles it downloaded.

Option Default What it does
relief 'dramatic' 'dramatic' stretches the area's own heights to fill the terrain, so a flat city still shows its hills. 'measured' makes 400 m the full height, so flat ground stays flat.
smooth 'medium' How much the heights are smoothed: 'light', 'medium' or 'strong'.
tiles AWS Open Data A URL for Terrarium elevation tiles, with {z}, {x} and {y}.
signal none An AbortSignal that stops the downloads.

areaProblem(bbox) from the same entry point tells you before the download whether an area is too small, too large or too thin.

A height grid in location is used as it is, and it brings no camera. To use your own elevation data, pass { samples, size, aspect } as described for setHeightfield() in Vanilla JavaScript. React compares a grid by identity, so keep the same object between renders.

Shapes

There are seven shapes. Each one is a different starting form for the land. The settings change each shape in many ways, so the number of possible looks is not limited to seven.

Id Name Looks like
drifting-lobes Drifting lobes Three soft lobes that drift to the side and slowly change towards four.
granite Granite Many small, lumpy lobes with a stone-like texture.
five-petals Five petals A summit with five petals that slowly grows and shrinks.
seed-pod Seed pod Two long lobes that turn slowly, like a seed pod.
gentle-summit Gentle summit One soft, slightly oval hill that leans as it moves.
long-ridge Long ridge A long, winding ridge with rounded ends.
dunes Dunes Long ripples, like sand shaped by wind. There are no summits.

An unknown shape uses drifting-lobes instead and prints one warning in the console.

terrain.variant changes the land in a way that depends on the shape. Today it has the biggest effect on drifting lobes, where it goes from three lobes to four.

The shapes image

Sixteen dark-mode tiles: the seven shapes, the three looks Survey map, Terraces and Rocky, and six seed variants

The image shows every shape and three looks in dark mode. Each tile is the frame at 4 seconds. The first seven tiles are the shapes with their own defaults. The next three are looks, by name and id, in the same tile colours as the shapes. The last six tiles each use a shape with a different seed and one or two changed settings:

Tile Settings
Twin peaks drifting-lobes, seed 7, peaks 2, spread 1
Fine lines drifting-lobes, seed 21, line count 48
Scree granite, seed 3, roughness 0.6
Bloom five-petals, seed 12, line count 42, variant 0.9
Wide dunes dunes, seed 5, line count 16, line width 0.4
Plateau seed-pod, seed 9, terracing 0.6

The tiles are small, so every tile uses a few extra settings to make the shapes easy to see. The summit is in the centre (layout.anchor 1). The centre fade is off (layout.fade 0). The lines are brighter and a little thicker: line opacity 0.85, line width 0.4, and ink #9fb0cc on paper #0b0b0d. To make the image again, run node scripts/styles-grid.mjs.

Looks

A look is a named, finished design. It sets the shape, the seed, the lines, terrain, motion and layout values, the colours and a 3D camera pose. The playground's dock shows the same looks, in the same order. Pass a look's id as the look setting:

createTopography(el, { look: 'survey-map' });
<topographic-view look="rose-pod"></topographic-view>
<Topography look="sea-dunes" />

The other settings next to look override the look's values. A setting the look leaves out keeps its default. For example, { look: 'rose-pod', lines: { count: 40 } } draws Rose pod with 40 lines, and keeps its colours, seed and line width. A shape next to a look draws the look's values on that shape.

In update(), a look replaces the design and keeps the view. The view is mode, elevation, extent, detail, quality, view2d, and the camera motion and pan. The look also keeps column, scenes and scene. The other values in the same update apply on top of the look. In React, a new look prop applies the other props again on top of the new look.

An unknown look is ignored, and the console shows one warning.

The looks are exported as LOOKS, with the type Look. findLook(id) returns one look, or undefined. LookId is the type of the ids. In a playground link, look= takes the same ids, such as ?look=night-lobes&lc=40.

Every look in its own colours, with its name and id: twelve dark looks, twelve light looks, then Survey map and Rocky in dark mode

Each tile is the 2D frame at 4 seconds. To make the image again, run node scripts/looks-grid.mjs.

Id Name Shape Colours
survey-ridge Survey ridge long-ridge dark, ink #e8e6e1 on #141414
fine-granite Fine granite granite dark, ink #d7d2c8 on #0b0b0d
wide-lobes Wide lobes drifting-lobes dark, ink #c9c2b4 on #12141a
rose-pod Rose pod seed-pod dark, ink #f0b2a8 on #1a1214
terraces Terraces gentle-summit dark, ink #e0b089 on #16130f
high-dunes High dunes dunes dark, ink #c6d6a8 on #101614
moss-granite Moss granite granite dark, ink #9dcec0 on #101614
sea-dunes Sea dunes dunes dark, ink #8ec4d4 on #0e1a22
night-flower Night flower five-petals dark, ink #a9c7e4 on #10151c
night-lobes Night lobes drifting-lobes dark, ink #9fb0cc on #0b0b0d
violet-pod Violet pod seed-pod dark, ink #b9a4e0 on #16131a
ink-petals Ink petals five-petals dark, ink #cbb6ea on #16131a
stepped-petals Stepped petals five-petals light, ink #6b3050 on #f6eef2
day-lobes Day lobes drifting-lobes light, ink #5c3348 on #f6eef2
still-dunes Still dunes dunes light, ink #7a3e2e on #f3ece4
rocky-spine Rocky spine long-ridge light, ink #5c3a28 on #efe6d6
pale-dunes Pale dunes dunes light, ink #6b4630 on #efe6d6
stone-grain Stone grain granite light, ink #3a342c on #e7e2d8
open-pod Open pod seed-pod light, ink #2c2c2a on #f4f1ea
quiet-hill Quiet hill gentle-summit light, ink #2c2c2a on #f7f4ee
bloom Bloom five-petals light, ink #3c4a36 on #f3efe6
soft-summit Soft summit gentle-summit light, ink #1e4650 on #e5eef0
paper-ridge Paper ridge long-ridge light, ink #3d4c63 on #f7f4ee
centred-lobes Centred lobes drifting-lobes light, ink #243656 on #e7eef6
survey-map Survey map gentle-summit Follows the page theme
rocky Rocky long-ridge Follows the page theme

Survey map and Rocky set no colours, so they follow the page's light or dark theme and the CSS variables. The image shows them in dark mode.

Scenes

A background can blend between scenes. Each scene has its own shape and line colour, and shares every other setting. scenes is the list, and scene is the index of the target scene. When scene changes, the target fades in and the other scenes fade out. Each frame, every weight moves toward 1 for the target and toward 0 for the others, by 1 - exp(-dt × 4.5), where dt is the frame time in seconds, at most 0.05. The shader draws each scene with a weight above 0.002, and divides the colour sum by the weight sum.

const topography = createTopography(el, {
  scenes: [
    { shape: 'drifting-lobes' },                    // no ink: the theme's ink
    { shape: 'long-ridge', ink: 'rgb(98% 50% 13%)' }, // orange lines
    { shape: 'dunes' },
  ],
  scene: 0,
});

topography.update({ scene: 1 });                     // fades to the long ridge
topography.update({ scene: 2 }, { instant: true });  // jumps to the dunes
  • scenes holds at most 8 scenes. An unknown shape uses the default shape and logs one warning. An empty list, the default, draws shape alone.
  • ink is any CSS colour. Without it, the scene uses the theme's ink.
  • A shape's own defaults, such as the lighter fade of dunes, do not apply to a scene. Set the shared values yourself.
  • While scenes are set, a theme mode change fades too. The dark amount goes from 0 (light) to 1 (dark) and moves toward its target by 1 - exp(-dt × 6). The paper and the theme's ink are mixed between the light and dark colours by that amount. update(patch, { instant: true }) sets the scene weights and the dark amount at once. A new renderer always starts on its targets.
  • blend reads the weights and the dark amount drawn now.
  • While scenes are set, the animation time also takes at most 0.05 s a frame, so a slow frame does not jump the terrain ahead.
  • A new look keeps the scenes, the target scene and column.
  • With reduced motion, the terrain holds still, but a fade still runs to its end. Then drawing stops on a still frame.
  • Scenes apply to the 2D view. 3D draws shape, with the faded theme colours.
  • snapshot draws the scenes as they are now. With patch, it draws the patched settings at rest: the target scene alone, in the theme's own colours.
  • The web component takes scenes as JSON and scene as a number: <topographic-view scenes='[{"shape":"dunes"}]' scene="0">. Scenes set with update() stay until one of those attributes is used. The React component takes scenes, scene and column props.

A full-page background

This setup draws the background of yahyapak.dev, pixel for pixel. scripts/blend-check.mjs compares the two in a browser.

const coarse = matchMedia('(pointer: coarse)').matches;
const ratio = () => (innerWidth < 600 || coarse ? 1 : 1.5);
const sized = () => ({
  lines: { count: innerWidth < 600 ? 36 : innerWidth < 1024 ? 30 : 24 },
  layout: { fade: 0.7 * (innerWidth < 720 ? 0.4 : 1) },
});
const topography = createTopography(document.getElementById('bg'), {
  scenes: [
    { shape: 'drifting-lobes' },
    { shape: 'long-ridge', ink: 'rgb(98% 50% 13%)' },
    { shape: 'five-petals', ink: 'rgb(16% 66% 40%)' },
    { shape: 'seed-pod', ink: 'rgb(47% 38% 98%)' },
    { shape: 'gentle-summit' },
    { shape: 'dunes' },
  ],
  column: 332, quality: 'full', theme: { mode: 'light' },
  lines: { width: 0.9 / 3.5 }, motion: { warp: 0.7 / 1.5 }, ...sized(),
}, { maxPixelRatio: ratio(), maxPixels: Infinity, maxFps: coarse ? 30 : 0, powerPreference: 'low-power' });
addEventListener('resize', () => { topography.setOptions({ maxPixelRatio: ratio() }); topography.update(sized()); });

// On a page change, and when the theme changes:
topography.update({ scene: 2 });
topography.update({ theme: { mode: 'dark' } });
  • powerPreference: 'low-power' asks for the same GPU as the old background did.
  • quality: 'full' leaves the pixel ratio and the pixel count to the options, so a large screen is not drawn smaller.
  • lines.width and motion.warp are set so the shader receives a line width of 1.4 and a warp of 0.7.
  • Leave theme.ink and --topographic-ink unset: the neutral scenes then follow the dark amount between the light and dark default inks.
  • The renderer sizes its canvas from the element. On a page with classic scrollbars, innerWidth includes the scrollbar and the element does not, so give the element the whole viewport.

Colours, CSS variables and dark mode

There are two colours. ink is the line colour. paper is the background colour. Each colour comes from the first of these that is set: the settings, then the CSS variables, then the light or dark defaults.

topographic-view, .my-hero {
  --topographic-ink: #9fb3c8;
  --topographic-paper: transparent;
}
  • theme.mode: auto is the default. It follows the operating system and changes when the system changes. light or dark always use that mode.
  • theme.ink / theme.paper: any CSS colour, including named colours and oklch().
  • Transparent paper: with paper: 'transparent', your page shows through the background.
  • Light mode draws ink lines on the paper. In dark mode, the lines glow softly on dark paper.
  • If you change a CSS variable later, call update({ theme: {} }) to read it again.

Seed

The seed is a number that picks a variation of a shape. seed is a whole number from 0 to 999999. The same seed gives the same terrain on every screen. 0 is the original look. Change the seed to get a new variation of a shape. The other settings stay the same.

Snapshots and PNG export

const blob = await topography.snapshot({ width: 1920, height: 1080, frame: 4000 });
  • It draws in the background. The live canvas keeps its size and keeps animating.
  • patch and field draw that image from temporary settings and a temporary height grid. The live view is put back before the call returns, so the page does not flash and a second WebGL context is not required.
  • To keep the on-screen shape, give only width or only height. If you give neither, you get the current drawing size.
  • frame is the animation time in milliseconds. The same settings and the same frame always give the same image.
  • region keeps only part of the view: { x, y, width, height }, as fractions measured from the top-left corner. The image is exactly that part of the screen, at width × height pixels. For example, you can take a phone-shaped strip from a wide window.
  • motion draws the 3D view at a point on a camera path ('orbit', 'push' and so on). The point is the camera's position at frame. You get the same picture as that moment of record.
  • It always centres the terrain in the image and ignores setInsets, because an image has no toolbar. A region is a part of that centred image. record works the same way.
  • The largest size is the GPU's maximum renderbuffer size, usually 8192 or 16384 pixels. With a region, the limit applies to the whole view, not only the region.
  • Without WebGL, it fails (the promise rejects).

The playground's PNG export uses this. The sizes are 2× the window, a phone size, or 1920 × 1080.

Performance and accessibility

  • Paused when hidden: drawing stops when the element is off-screen or the tab is hidden. It starts again when it is visible.
  • Still when nothing moves: when motion.speed is 0, there is no animation loop. It draws again only when something changes.
  • Reduced motion: when the system asks for reduced motion, it draws one still frame and ignores speed. A scene fade still runs to its end, on a still terrain.
  • Frame rate cap: the maxFps option draws at most that many frames each second. A skipped display frame adds its time to the next drawn frame.
  • Pixel limit: the pixel ratio is at most 2, and quality can cap it lower. Very large canvases draw at a lower resolution above maxPixels. The GPU code smooths the lines, so they stay sharp.
  • Graphics budget: quality: 'auto' starts a phone at low and a desktop at high, then steps down when frames miss the refresh period. It does not step back up. Set low, medium or high to keep one tier. full draws like high without its pixel caps: only maxPixelRatio and maxPixels limit the canvas.
  • Screen readers: the canvas is aria-hidden, because it is only decoration. Put real content on top of it.
  • Readability: layout.fade makes the lines softer behind a text column in the centre. layout.anchor moves the main summit from the side (0) to the centre (1).

Fallbacks

Situation What you see
WebGL2 available Full rendering (status: 'webgl2')
Only WebGL1 The same rendering through WebGL1 ('webgl1')
No WebGL at all Only the paper colour, and one console warning ('fallback')
The GPU context is lost Only the paper colour until the browser restores the context. If the browser never restores it, the canvas is replaced and drawing starts again ('lost' until then)

Server rendering and Next.js

Nothing uses the DOM or WebGL until the code runs in the browser.

  • React: topographic-react starts with 'use client'. On the server, <Topography> renders an empty div. The canvas appears after hydration. In the Next.js App Router, you can use it directly inside a server component's tree.
  • Web component: you can safely import topographic/element on the server. It registers the element only when customElements exists.
  • Vanilla: call createTopography in the browser. For example, call it in an effect or after DOMContentLoaded.

The playground

The playground (apps/playground) is the project's live demo page.

  • The page background is the preview. The dock at the bottom holds your saved map terrains, then the built-in looks as thumbnails. On a phone the previews are a little narrower, so four of them fit. The row fades at an end that still has terrains, and a chevron scrolls to the next one. Theme, Shuffle, paper and ink sit in a 2×2 grid on the right. Each item in the dock is a complete terrain. One click changes the shape and the colours together.

  • The view follows the gestures of Google Maps and Google Earth:

    • Drag, or one finger: move the terrain. It follows the pointer and can't leave the screen.
    • Scroll, pinch, or ⌘/Ctrl + scroll: zoom. In 2D the zoom keeps the point under the pointer in place, up to eight times closer.
    • In 3D, ⌘, Ctrl or Shift + drag, or a right-drag: turn and tilt the camera.
    • On touch, in 3D: twist two fingers to turn, or move two fingers up or down together to tilt.
    • Double tap: zoom in. Two finger tap: zoom out.
    • Double-click: move the pan smoothly back to the centre.

    2D and 3D keep their own pan and zoom: the 2D view is view2d (vx, vy, vz in the URL), the 3D view is the camera. The Anchor setting still places the summit in 2D: 0 keeps it to the side, 1 puts it in the centre. The terrain is centred in the space between the top buttons and the dock, not behind the dock. The pan and the zoom are part of the camera. A saved map terrain keeps them. A click on a look, or the camera reset in Tune, moves the camera back to the centre.

  • The buttons at the top right are Map, 2D/3D, Tune and Export, and Install in its own circle in the corner. Each one opens its panel below the buttons. Only one panel is open at a time. The 2D/3D button has a small badge that shows the current mode.

  • Tune opens the settings, one tab at a time: Lines, Terrain, Motion, Layout and 3D.

    • Each tab starts with a small live picture of its settings. The pictures show the contour rings, the side view of the terrain, the movement, the page layout, or the camera from above and from the side. The camera picture follows a moving camera a few times a second.
    • Every row has a small icon. Each slider's track shows what the setting does, such as thicker lines, rougher ground or wider waves.
    • Choices are icon buttons. The selected one shows its name.
    • Line width shows in pixels (0.5 to 4 px).
    • The 3D tab holds the scene (extent and elevation), the camera (motion, azimuth, angle and distance), the detail level and the graphics budget (Auto, Low, Med or High).
    • The playground starts at High detail and High budget, unless the link or saved terrain names its own. If the frame rate stays below 30 fps, a Low performance: reduce quality button shows under the top right buttons. It sets Detail and Budget to medium. It shows once per browser session.
    • In 3D, the frame rate shows just above the dock, at its left edge, with the graphics tier that is actually drawing.
    • When a map place is applied, the Terrain sliders and Motion's Warp are dimmed, because the place sets the terrain. A note with Clear map is above them.
  • Install shows a preview of the view with its main settings, the agent skill command npx skills add yahyabedirhan/topographic, the npm install lines and this view's code as one createTopography call. Copy link copies the same link as Export's Link tab, and Export opens the Export panel.

  • Export gives you a link, a PNG, a 4 second 3D video as MP4 or WebM, and the code as React, HTML or JSON.

    • The link tab shows a labelled still of the terrain the link will open, then each of the link's parameters as a key and value.
    • The PNG tab shows the view with a frame for each size: 2× the window, a phone (1170 × 2532) or 1920 × 1080. If a size has a different shape from the window, it crops the view. Drag its frame, or use the arrow keys, to choose the part to keep. For example, choose which part of a wide view goes on a phone. The frame is centred each time Export opens. The image is exactly that part of the screen, at full detail.
    • The Video tab previews the video. Drag the timeline to see any moment of the selected camera path. The path is drawn from above.
    • The files are named after the named place on the map, such as topographic-mount-fuji.png and topographic-mount-fuji-orbit.mp4. Without a named place, a PNG is named after the shape and the seed, such as topographic-seed-pod-71.png, and a video after its camera path, such as topographic-orbit.mp4.
    • The code tabs number their lines. They colour the values that come from your settings.
  • Map opens a map with a frame. Use it to draw a real place as the terrain.

    • Pan and zoom the map, or drag the frame's corners and edges. You can also pick a place from the badge's menu. It holds 108 places, grouped by continent: the ten built-in places and 98 more, listed in Places.
    • The menu opens with a search field. Type a name, a country, a continent or a kind such as volcano; case and accents do not matter, so reunion finds Réunion. The arrow keys move through the results and Enter picks one.
    • The ten built-in places load from the library. Every other place loads its pre-rendered grid from /places/<id>.png, and falls back to the elevation tiles when that file fails.
    • Press Apply. The real elevation inside the frame becomes the terrain.
    • Apply switches to 3D and uses the map terrain view: 36 lines at 0.5 px and 0.9 opacity, every fourth line bold, a block at elevation 0.12, and a closer camera.
    • Each place keeps that view but has its own camera angle and elevation. This puts its main feature in the middle, with the skyline above it.
    • Apply also saves the terrain in the dock, after the built-in map terrains. Its name is the place's name, or Area.
    • The dock starts with eleven built-in map terrains for everyone: Istanbul, Grand Canyon, Geirangerfjord, Yosemite Valley, Mount Fuji, Matterhorn & Zermatt, Torres del Paine, Everest, Kilimanjaro, Machu Picchu, and Naples & Vesuvius. The last two are catalog places and load their grids from /places/<id>.png. Each one is like a look: it does not keep your changes, and a click on it always brings back its own settings, camera and pan.
    • When a saved item is active, your changes to it are kept, including camera moves. A click on it brings back the view you left it in.
    • A built-in look does not keep your changes. A click on it always brings back its own camera. A plain shape uses the playground camera.
    • Clear goes back to the generated terrain. It also brings back the lines, camera, extent and elevation you had before.
    • When a map terrain is active, a small map above the right end of the dock shows the area you framed. In 3D, it turns with the camera, so it always matches the terrain on screen. Its compass points north. Click the compass to turn the camera back to north at the top (azimuth 0). Click the small map to open the map. In 2D, north is already at the top, so the small map does not turn.
    • The map loads only when you open it. It needs a network connection.
    • A link that opens a place hides the canvas until the heights arrive, so the default terrain does not flash. Contour rings draw in while it waits, on a desktop and on a phone. With reduced motion, the rings stay still. The page sets data-loading to terrain, and get_region returns loading: true with the message Loading terrain.
  • The URL stores the settings as short keys, so a link shows the same background.

    • It stores only the values that are different from the shape's defaults or from a look: ?s=dunes&lc=24, ?look=night-lobes&mode=3d. look= takes the library's look ids, and links made before 0.6.0 use the same ids.
    • Colours have no # (ink=9fb0cc).
    • 3D uses the library defaults unless the URL changes them: endless, azimuth 349.7, angle 33.6 and distance 1.92.
    • A pan away from the centre adds px and pz. A 2D pan or zoom adds vx, vy and vz.
    • Quality writes ql when it is low, medium or high. auto is the default, so it is left out.
    • The address bar updates a few times a second at most, and again when a drag or pinch ends. A gesture does not write the URL on every frame.
    • ?place=istanbul or ?region=west,south,east,north opens a real place. place= takes any id in Places.
  • It serves /llms.txt, /llms-full.txt and /docs/*.md. These files are made from the settings table in the code.

To run it, use npm install && npm run dev.

A public build does not include the debug heartbeat. Set VITE_TOPOGRAPHIC_DEBUG=1 for that build to include it. The heartbeat records errors, WebGL context loss, how many contexts are alive, canvas size and memory. It shows a small on-screen log and posts batches to ./__debug. astro dev prints those batches. A static host has no such path, and the page ignores the failed post. A URL cannot turn the heartbeat on.

Analytics and error reports

The playground can send product analytics to PostHog and errors to Sentry. Both are set at build time, and both are off without their variable: the call sites do nothing, and the client is not in the build. The npm packages never include either, and scripts/check-public-bundle.mjs fails the build if they do.

Variable Effect
VITE_POSTHOG_KEY PostHog project key. Turns on analytics.
VITE_POSTHOG_HOST PostHog ingest host. Defaults to https://eu.i.posthog.com.
VITE_SENTRY_DSN Sentry DSN. Turns on error reports.

Each client loads in its own chunk when the page is idle after the first paint.

  • PostHog runs without cookies (persistence: 'memory', cookieless_mode: 'always', which needs the project's cookieless server hash setting), with no session recording and no autocapture, and it respects Do Not Track. It sends pageview and pageleave (time on page, referrer and UTM parameters) and the typed events in apps/playground/src/analytics.ts:

    • Navigation: ui_click (header links), view_mode_changed, panel_opened, panel_closed (with time open), export_tab_changed.
    • Looks and settings: dock_item_clicked, saved_terrain_deleted, place_applied, region_loaded, setting_changed, quality_changed, theme_toggled, shuffle_clicked, colour_changed.
    • Sharing and export: link_copied, code_copied, install_copied, export_image_downloaded, export_image_failed, export_video_started, export_video_completed, export_video_failed.
    • performance_sample, once about 20 seconds into a visible visit: median and 10th-percentile fps, refresh rate, quality tier, viewport, device pixel ratio, GPU renderer, device memory, browser, OS, long frames, context losses and time to first frame.
    • low_performance_prompt_shown (fps, view, detail and budget levels) and low_performance_prompt_clicked, from the low performance prompt.
    • analytics_opted_out, once, from the Opt out button.
    • agent_tool_called, after each WebMCP tool call (see below).

    Every event carries fixed super properties: site, app, app_version, viewport_class, is_mobile, platform, os and initial_view_mode, plus the actor properties from actor.ts:

    • actor: human or agent.
    • A person's events carry visitor_id: a random UUID that visitor.ts saves in local storage under topographic:visitor, so one browser's visits can be counted together across days.
    • An agent's events carry agent_id instead, a random UUID in that tab's session storage under topographic:agent, with agent_name and purpose when the agent identified itself (the identify_agent tool, or ?analytics_agent=<name>&analytics_purpose=<purpose> on the first load), and automation (headless or visible) in a WebDriver browser. Names and purposes come only from the fixed lists in actor.ts; any other value is refused.
    • agent_tool_called is sent by the page after every WebMCP tool call, with the tool name, whether it worked and a duration range. It never carries the tool's input, output or error message.

    PostHog's distinct id stays the server's daily hash. posthog-js starts with __preview_capture_bot_pageviews, so WebDriver browsers are sent instead of dropped; their pageviews arrive as $bot_pageview. Either opt out deletes the visitor id and the tab's agent id and stops sending them. Properties never hold free text, colours or the query string.

  • Sentry reports errors only, with no tracing or replay and no user data, cookies, headers or query string. It reports error-boundary crashes and lost WebGL contexts, tagged with the quality tier and GPU. A small buffer keeps errors from before it loads, and one page sends at most 10 reports.

Visitors can opt out with ?analytics_optout (pageviews only, no error reports), ?analytics_optout=all (nothing; neither client loads) or ?analytics_optin, or with the button on /about#analytics. The choice is kept in local storage under topographic:analytics, per browser. PRIVACY.md lists every event, and a test checks that it names each one. The Analytics section of /about is a short summary that links to it.

The playground is a Cloudflare Worker with static assets. With Workers Builds, add these as build variables (the Worker's Settings, then Build, then Variables and secrets). Vite reads them while building, so set them before the deploy that should use them.

Hosting

The playground site is built with Astro as static files in apps/playground/dist.

  • / is the playground. It is a React island that renders only in the browser (client:only), from index.html.
  • /about and /docs are HTML pages, about.html and docs.html, with their text in the HTML. Each page has its own title, description and share image, from apps/playground/src/meta.ts. On /about, only the opt-out row runs in the browser. /docs hydrates for its live figures and code tabs.
  • Astro's client router moves between the three pages without a full load, with an animation. A browser without the View Transition API gets Astro's own animation. Each page change sends one pageleave for the page left and one pageview for the page shown.
  • The build also writes llms.txt, llms-full.txt and docs/*.md, from apps/playground/llms.ts.

wrangler.jsonc at the repo root describes the Worker topographic. It serves apps/playground/dist as static assets, with _headers from apps/playground/public for caching. /about and /docs are served from about.html and docs.html.

  • A browser that navigates to an unknown path gets index.html, since the playground keeps its state in the query string. The page then goes home and keeps the query.

  • Any other request for an unknown path, such as curl or fetch for /.well-known/mcp.json, reaches the small script worker/index.js and gets a plain-text 404 that points to llms.txt.

  • robots.txt allows every crawler and points to llms.txt and sitemap.xml.

  • npm run deploy builds the playground and runs wrangler deploy.

  • Workers Builds, connected to this repo, uses the build command npm run build, the deploy command npx wrangler deploy and NODE_VERSION=22. Astro needs Node.js 22.23.4 or later.

WebMCP tools

WebMCP lets a web page give tools to AI agents in the browser. In browsers that support it, the playground registers these tools with document.modelContext.registerTool(). Without WebMCP, no tools are registered.

Most headless and agent browsers have no native WebMCP yet. An agent there can still drive the playground:

  • With URL parameters. llms.txt on the playground lists every key, and the page applies them on load.

  • With a stub. Before the page loads, give it a document.modelContext whose registerTool keeps each tool. Then call a tool's execute(input). In Playwright:

    await page.addInitScript(() => {
      window.__tools = {};
      document.modelContext = { registerTool: (tool) => { window.__tools[tool.name] = tool; } };
    });
    await page.goto('https://topographic.yahyapak.dev/?analytics_agent=claude&analytics_purpose=exploring');
    await page.waitForFunction(() => window.__tools?.get_settings);
    await page.evaluate(() => window.__tools.set_settings.execute({ shape: 'dunes', mode: '3d' }));

The ?analytics_agent=<name>&analytics_purpose=<purpose> keys in that first URL are a polite request, not a rule. They tell the maintainer which agent visited and why, and change nothing else. Analytics and error reports lists the names and purposes, and ?analytics_optout=all sends nothing.

Tool What it does
identify_agent Optional. The agent gives its name and a purpose, both from fixed lists, so analytics count it apart from people. Any other value is an error.
list_shapes Lists the shapes, with their ids and descriptions, and the looks, with their ids and settings.
get_settings Returns the current settings, the shortest config JSON, and the graphics tier that is drawing (quality, fps, frameMs, mesh).
set_settings Applies some settings. look applies a look by id, and the other keys in the same call override it. The land shape is shape; the old key style still works until the next release. Reports values that were moved into range and keys that were ignored. A new theme.mode clears the earlier ink and paper, as the theme button does, unless the same call gives them. The result says which colours were cleared.
randomize Picks a new random seed. With { "shape": true }, it also picks a random shape.
take_screenshot Returns a PNG of the background as a data URL, 1200 px wide by default. An optional region ({ x, y, width, height }, as fractions of the view) keeps only that part.
record_video Records a short 3D video of the current terrain with a moving camera. Returns it as an MP4 or WebM data URL. The default is a 2 second orbit at 640×360.
get_embed_code Returns code for html, react, vanilla or json.
get_install_instructions Returns setup steps for npm, web-component, react or vanilla.
set_region Draws a real place as the terrain. Use a named place, any id in Places, or a bounding box in degrees. relief and smooth are optional.
get_region Returns the current region, relief, smoothing, load status and URL query. While heights are still loading, loading is true and message is Loading terrain.
clear_region Removes the region and brings back the generated terrain.

The input schemas are in /docs/webmcp.md on the playground. WebMCP needs a secure page (HTTPS or localhost).

Troubleshooting

  • Nothing shows. The element needs a size. See Sizing and layering.
  • I only see a flat colour. WebGL is not available, or it was lost. Check status, and look for the warning in the console.
  • It doesn't move. Check motion.speed. Check whether reduced motion is on in your system. Check whether the element is on screen.
  • The lines are too faint. Increase lines.opacity or lines.width, or use a stronger ink.
  • The lines disappear behind my text. That's layout.fade. Lower it, or set it to 0.
  • My CSS variable change didn't apply. Call update({ theme: {} }) after you change it.
  • snapshot throws. The size is larger than the GPU limit, or WebGL is not available.
  • A location shows only the paper colour. The place's file did not load. Look for the warning in the console. For fetchLocation(), the page needs network access to the tile host.
  • fetchLocation() rejects the area. The shorter side must be at least 1.5 km, and the longer side at most 300 km. The longer side can be at most 3 times the shorter side. areaProblem(bbox) names the problem before the download.
  • There's a warning about an unknown location. Compare the id with the ten places.
  • There's a warning about an unknown shape. Compare the id with the Shapes list. Ids use dashes, like seed-pod.