Hyperbolic Map Documentation

A minimal example

Two files, and nothing else. Both are complete: copy them into an empty directory, put hyperbolic-map.iife.js beside them, and serve that directory — the page fetches its data, and fetch does not work from a file:// URL.

The bundle is dist/hyperbolic-map.iife.js in the package, so after npm install hyperbolic-map it is at node_modules/hyperbolic-map/dist/hyperbolic-map.iife.js. If you would rather use the ES module, import HyperbolicViewport from "hyperbolic-map" and drop the <script src> line.

drawables.json — the map itself. Coordinates are in the local system described under the drawable format; the origin is where the viewport starts.

{
  "version": 1,
  "coordinates": "local",
  "drawables": [
    {
      "type": "path",
      "points": [[-1, -1, "L"], [1, -1, "L"], [1, 1, "L"], [-1, 1, "L"]],
      "closed": true,
      "fill": "#cde",
      "stroke": "#036",
      "lineWidth": 2
    },
    {"type": "marker", "at": [0, 0], "radius": 4, "fill": "#036"},
    {"type": "text", "text": "a square", "at": [0, 1.15], "up": [0, 1.4], "fill": "#036"}
  ]
}

index.html — the page.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A hyperbolic map</title>
</head>
<body>
<div id="map"></div>

<script src="hyperbolic-map.iife.js"></script>
<script type="module">
const doc = await (await fetch("drawables.json")).json();
new HyperbolicMap.HyperbolicViewport({
  container: "#map",
  width: 500,
  height: 500,
  data: doc,
});
</script>
</body>
</html>

You get a filled square with a dot at the origin and a label above it. Drag inside the disk to scroll, drag the outer ring to rotate. The square's sides bow inward: they are straight lines in the hyperbolic plane, and this is what a Poincaré disk does to a straight line that does not pass through the center of the view. Scroll so that one side passes through the middle and it will look straight again.

Everything below is the reference for what else those two files can say.

Options

All are optional except that either a container or a canvas must be supplied. An unrecognized option name raises an error.

Where it draws

option default meaning
container CSS selector or element; a <canvas> is created inside it
canvas use an existing canvas instead
width, height container size or 400 CSS pixels
aspectRatio null width ÷ height; derives the height from the width instead of taking height
autoResize false follow the container's size with a ResizeObserver
devicePixelRatio "auto" "auto", or a number.

Filling a fluid container

aspectRatio with autoResize is how you get a widget that fills its column and stays the shape you want, without the page computing pixel sizes:

<div id="map" style="width: 100%"></div>
new HyperbolicMap.HyperbolicViewport({
  container: "#map",
  aspectRatio: 1,     // square
  autoResize: true,   // and follow the container when the window changes
});

The height is derived from the width, so aspectRatio and height together raise an error.

What it draws

option default meaning
data null drawables, as an array or a {version, drawables} document
dataProvider null async ({center, zoom, drawRadius, visibleRadius, signal}) => data
atlas null see atlas of tiles
styles null named style classes, referenced by a drawable's class

The initial view

option default meaning
center null the data point to put in the middle
offsetX, offsetY 0 the raw view offset—the negation of center
rotation 0 radians
zoom 0.95 the disk's radius as a fraction of half the canvas
minZoom, maxZoom 0.5, null clamps; null means unbounded

Interaction

option default meaning
interactive true no interactivity if false
allowPan, allowZoom, allowRotate true allow panning/scrolling, zooming, and rotation
rimRotate true dragging the outer ring rotates
wheelZoom, wheelZoomStep true, 1.1
rotationMode "parallel-transport" or "compass" to keep one direction fixed
compassTarget [0, 1] the direction held fixed in compass mode
interactRadius 0.9 inside this, drag scrolls; outside it, drag rotates
drawRadius 1.0 content beyond this is culled

Appearance

option default meaning
background "#ffffff" the disk's interior color
pageBackground null the whole canvas, behind the disk
rimFill, rimStroke, rimLineWidth "#f5d6ab", "#000000", 1.5 the rotatable annulus around the disk
sagittaTolerancePx 0.25 the largest bulge, in pixels, that may be flattened into a straight chord
decimateTolerancePx 0.25 drop a vertex projecting within this distance of the last one drawn
minFeaturePx 0 don't draw a shape whose projected size (including its stroke) is below this threshold
interactMinFeaturePx 0.5 minFeaturePx used only while a gesture is in flight
minTextPx 3 don't draw text smaller than this threshold

Hooks

onBeforeDraw and onAfterDraw receive (ctx, view); layers is an array of {z, draw(ctx, view), attach?, detach?}. Everything with z < 0 is drawn before the disk's opaque fill, so it shows only outside the disk.

Draw order:

  1. pageBackground
  2. layers with z < 0
  3. onBeforeDraw
  4. disk interior filled with background color or onDrawBackground
  5. all drawables
  6. the rim annulus or onDrawRim
  7. layers with z >= 0
  8. onAfterDraw

Steps 4 and 6 are alternatives: supplying onDrawBackground or onDrawRim suppresses the default fill rather than drawing over it.

The view object passed to a hook is read-only: {width, height, cx, cy, radius, zoom, rotation, bearing, matrix, ctxScale, drawRadius, interactRadius, effectiveRadius, interacting, toScreen, fromScreen}.

Also available: onDrawBackground, onDrawRim, onViewChange, onGestureStart, onGestureEnd, onFrame.

Methods

Reading and moving the view.

method what it does
getView() the live view as {center, zoom, rotation, bearing, interacting}; center is the local point at the middle of the disk, and round-trips with panTo
getMatrix() a copy of the live view isometry, so mutating it is safe
setMatrix(isom) replace the view isometry; it is normalized on the way in, and the jump is not animated
setZoom(z) set the zoom, clamped to minZoom/maxZoom
setRotation(θ) set the absolute screen rotation in radians, not a relative turn
panTo(x, y) put that local point at the middle of the disk

The camera (atlas mode).

method what it does
getCamera() the whole camera as {address, matrix, zoom, rotation, bearing, interacting} — the only form that stays valid at any distance; address is null without an atlas, and matrix is relative to the anchor tile
setCamera(camera) restore a camera from getCamera(), as an exact round trip
panToTile(address, local?) put that tile's local point (default [0, 0], its center) at the middle of the disk; atlas only

Data sources. The first three refuse in atlas mode, because a source's coordinates are global.

method what it does
setData(data, name?) replace one named source's drawables, defaulting to "default" — the source that data or dataProvider created
addSource(name, dataOrCallback, {transform}) add or replace a named source, either drawables or an async view => data callback; returns the source object
setSourceTransform(name, isom) give one source an extra isometry without recompiling its drawables; throws if there is no such source
removeSource(name) drop a named source and dispose it, aborting any fetch still in flight
refreshSources() make every async source re-request for the current view, bypassing its throttle and significance gate

Screen coordinates.

method what it does
toScreen(x, y) local point → [px, py] in CSS pixels
fromScreen(px, py) CSS pixels → local [x, y], or null if the pixel is outside the disk
tileAtScreen(px, py) which tile is under that pixel: {address, id, local}, or null outside the disk; atlas only

Lifecycle.

method what it does
invalidate() ask for a redraw on the next animation frame; repeated calls coalesce into one, and this is the normal way to request a frame
render() draw right now, synchronously—usually you want invalidate() instead
resize(w, h) resize the canvas, in CSS pixels
destroy() cancel any pending frame, remove event listeners, dispose sources, detach layers, and remove the canvas if the widget created it

In atlas mode (see atlas of tiles), use getCamera/setCamera/panToTile instead of getView/getMatrix/setMatrix/panTo. Those four take and return global coordinates, and far from the origin no global coordinate can be represented—that is the whole reason the atlas is anchored. Their meaning is unchanged and they remain correct in single-patch mode and while the camera is still anchored to the origin tile; past that they raise errors, naming getCamera(), rather than returning a wrong number.

(setZoom and setRotation are unaffected: zoom and screen rotation are not global-coordinate quantities, so they work the same in either mode.)

const cam = viewport.getCamera();    // the whole camera; cam.matrix is anchor-relative
viewport.setCamera(cam);             // exact round trip
viewport.panToTile(address, [0, 0]); // center a tile, at any distance
viewport.tileAtScreen(px, py);       // { address, id, local }; which tile is at px, py?

toScreen and fromScreen work in whatever frame the view is expressed in: the global frame in single-patch mode, the current anchor tile's frame in atlas mode (pair them with getCamera().address). tileAtScreen is the atlas-mode picking question, and it answers with the address and the tile-local coordinates the renderer used, so a pick correlates exactly with atlas.lastTiles.

setSourceTransform applies an extra isometry to one named source without recompiling its drawables. The clock example rotates its hands with it once a second, which is an O(1) matrix change rather than rebuilding every hand.

The drawable format

A document is {"version": 1, "coordinates": "local", "drawables": [...]}, or just a bare array. Coordinates are always in the local system described above (or, inside an atlas, relative to the tile's own center).

path

{"type": "path",
 "points": [[0.1, 0.2, "L"], [0.4, 0.2, "L"], [0.3, 0.5]],
 "closed": true,
 "fill": "#cde", "stroke": "#036", "lineWidth": 2}

A point's optional third element is a flag string for the edge leaving that point:

So the fill path always closes while the stroke may be disconnected. This is deliberate: a move/line command model cannot express a closed fill with a disconnected outline without duplicating the geometry.

text

{"type": "text", "text": "48", "at": [0.0, 1.2], "up": [0.0, 1.4],
 "fill": "#000", "align": "center", "baseline": "bottom"}

at is the anchor and up is a second point giving the text's up direction. The distance between their projections sets the size, so text foreshortens with the geometry around it. Text smaller than minTextPx is skipped.

marker

{"type": "marker", "at": [0.3, 0.7], "radius": 3.5, "fill": "#000"}

Shared fields

class selects a named style from styles; fill, stroke, lineWidth, lineCap, lineJoin, miterLimit, align, baseline, font override it. Use "none" for no fill or no stroke.

Atlas of tiles

Instead of one global coordinate system, give each tile of a tiling its own. Two reasons:

To use it, pass an atlas instead of data or dataProvider:

const tiling = new RegularTiling({ p: 8, q: 3, frameSymmetry: 4 });

const viewport = new HyperbolicViewport({
  container: "#map",
  atlas: {
    tiling: tiling,
    tileData: async (tile) => {
      // tile.address identifies the tile; tile.id is its string form and
      // tile.relativeFrame is its position relative to the camera, if you want it.
      // Return DATA in TILE-LOCAL coordinates.
      const res = await fetch(`tiles/${tile.id}.json`);
      return res.json();
    },
    clip: "auto",     // "always" | "never" | "auto" (honors a tile's `withinTile: true`)
    maxTiles: 200,
    cacheSize: 512,
    lodPx: 11,        // below this on-screen tile radius, use the tile's `lod` art if any
    onTileLoad: (tile, drawables) => {},   // a tile's data arrived and compiled
    onTileError: (tile, err) => {},        // tileData threw or rejected; that tile is skipped
    tileSymmetryTolerance: 1e-6,           // what counts as symmetric for checkTileSymmetry
  },
  // Optional: open on a given tile rather than the origin, however far out it is.
  // The address must be one of THIS tiling's own—a node object for RegularTiling (usually
  // a saved `getCamera().address`), or `{lat, lon}` BigInts for BinaryTiling.
  anchor: tiling.originAddress(),
});

The tile index names the tile, not a route to it: reaching a tile from any direction gives the same tile.id, and the library draws it in the same frame every time. So tileData may return whatever it likes per tile — fully asymmetric art, art keyed on tile.id, a different picture in every tile — and it will not shift or turn as the user scrolls.

tile.classIndex is the structured alternative to tile.id: it runs over 0 .. classCount-1 and adjacent tiles never share a value, so it colors the tiling the way a map colors countries rather than at random.

Color symmetry

A tile class is one integer per tile, and it cannot describe a pattern whose colors move. In M.C. Escher's Circle Limit III every motion of the tiling permutes the four fish colors, so the color of a fish is not a property of the fish or of the tile — it is a group element applied to a base color. That needs a homomorphism into a permutation group, and for {8,3} that group is A₄: twelve elements, not abelian, and it does not kill the tile stabilizer.

Declare one and every tile is handed its element:

const tiling = new RegularTiling({
  p: 8, q: 3, frameSymmetry: 4,
  colorSymmetry: {
    colors: 4,
    generators: [[2,0,1,3], /* ...one permutation per walk generator... */],
    stabilizer: [1,0,3,2],   // the image of the 2π/m rotation about a tile center
  },
});

tileData: (tile) => {
  tile.colorPermutation;   // e.g. [2,0,1,3] — this tile's permutation of your colors
  tile.colorIndex;         // the same thing as 0 .. colorCount-1, for caching
  tile.colorCount;         // 12 here; 1 when no color symmetry was declared
}

Then a shape's fill in the file is a role, and what you draw is palette[tile.colorPermutation[role]]. Build one recolored copy of your art per colorIndex and return it by index: there are only colorCount of them for the whole infinite plane, so the compile memo keeps hitting.

To draw a repeating pattern such as M.C. Escher's Circle Limit series, the art does have to be invariant under a rotation of 2π/m about the polygon's center (m = frameSymmetry). If your art is meant to be symmetric in that way, the library can watch for it drifting:

atlas: {
  checkTileSymmetry: "off",    // "off" (default) | "warn" | "throw"
}
viewport.atlas.tileSymmetry;   // { residual, checked, m, ok }; residual = 0 means ok

The art is measured once, on the first tile that carries any.

Performance hints

Return data synchronously when you can. A callback that returns a plain object (rather than a promise) is compiled and drawn in the same frame. A tile that is not drawn for one frame visibly blinks, and tiles enter at the rim continuously while panning, so this is the difference between a clean edge and a shimmering one. Asynchronous providers work fine; they just cannot avoid that first frame.

Return the same object for tiles that look the same. Compiled art is memoized on the identity of the object you return, so a provider that hands back one of a few shared objects never pays to recompile.

Prevent very small tiles from drawing. Tiles smaller than the lodPx threshold are replaced by lod, which may be a solid color.

Regular tiling

Use the RegularTiling({p, q, frameSymmetry}) class.

The {p, q} tilings: regular p-gons, q meeting at each vertex, which exist whenever 1/p + 1/q < 1/2. An address is a canonical id: one tile, one address, whatever route the walk took to reach it. Treat it as opaque and use addressToString (or addressKey) for a printable form, which is stable and safe to use as a persistent key. Underneath it is the tile's center in the Coxeter reflection representation of [p,q], held exactly in ℤ[2cos(π/N)] with integer coefficients — identity is decided by integer equality, so it has no distance ceiling. (Those coefficients are ordinary numbers while they fit a double exactly and BigInt beyond that, which is a speed optimization and not a limit: the arithmetic is exact either way, and the id text is identical.) The string is a name, not a coordinate: there is no way back from it to an address, so keep the object (getCamera().address) if you need to return to a tile.

One consequence worth knowing: an id's length grows linearly with distance from the origin, about 12 characters per tile crossed. Naming a tile is exact integer arithmetic and happens once per tile ever, never per frame, but a walk of thousands of tiles is no longer free — see the performance notes.

frameSymmetry (m) selects the walk group, so that the tile stabilizer is exactly C_m. It decides which group the tiling is built from and hence what pattern it makes: for M.C. Escher's Circle Limit III it must be 4, not 8. It is not a constraint on your art — each tile has a canonical frame, the lexicographically least element of its coset, so your art may be fully asymmetric and may depend on the address.

Only m = p and m = p/2 are accepted, and anything else throws. m = p steps by half-turns about edge midpoints, one generator per edge; m = p/2 steps by rotations about alternate vertices, two per vertex. A smaller m would reach only 2m of the p neighbors and could not cover the plane.

Circle Limit III needs m = 4 because the pattern has 4-fold centers at the octagon centers, and the natural general-purpose generator—a half-turn about an edge midpoint—is outside that group entirely.

The tiling also exposes:

tiling.stabilizerOrder;   // m: the tile stabilizer is C_m
tiling.selfRotation;      // that rotation, as an Isom
tiling.classModulus;      // how many distinct tile classes exist (1 = every tile identical)
tiling.tileClass(addr);   // 0 .. classModulus-1, the same by every route

Binary-tree tiling

Use the BinaryTiling() class.

The binary (Böröczky) tiling, addressed by {lat, lon} as BigInt arbitrary-precision integers. Point-to-cell is two floors, which no {p,q} scheme can match, and the integer addresses make natural filenames and are canonical: one cell, one address, no ambiguity. BigInt because descending one latitude doubles the longitude, so about fifty levels down a plain number stops being exact—and addresses are identity only, never geometry, so it costs nothing per frame. Cells are congruent but not regular polygons—two sides are geodesics and two are horocycles—and the tiling is not tile-transitive, so it cannot make a seamless repeating pattern.

Custom tiling

A new tiling is any object with these members. Everything in the first group is called without a guard, so leaving one out is a crash on the first frame rather than a degraded picture.

{
  // ---- required ----
  metrics: { circumradius, centerSpacing },  // sizes the walk
  originAddress(),                           // the tile containing the origin
  addressToString(address),                  // canonical string, for filenames and display
  addressKey(address),                       // the CACHE key; may be the same string, and is
                                             //   asked for once per visible tile per frame
  addressEquals(a, b),
  neighbors(address),                        // [{ address, gen }] gen indexes the table
  neighborGens(address),                     // just the gen indices, WITHOUT building neighbors
  extendAddress(address, gen),               // the address one step along gen
  stepFrame(address, gen),                   // Isom for that step, into the neighbor's own frame
  stepToward(x, y),                          // index INTO neighbors(address) -- not a gen index --
                                             //   for the step toward this tile-local point, or -1
                                             //   when the point is already inside. Each step must
                                             //   strictly decrease the distance, or re-anchoring
                                             //   will not terminate
  generator(i),                              // Isom, CONSTANT: neighbor-local → tile local
  containsLocal(x, y, tol?),                 // is this tile-local point inside this tile?
  boundaryLocal(),                           // for clipping, in tile-local coordinates
  stabilizerOrder,                           // m: the tile stabilizer is C_m
  classModulus,                              // number of tile classes (1 = all must match)
  tileClass(address),                        // 0 .. classModulus-1, path-independent

  // ---- optional; omit them and the library uses a sensible default ----
  compareForDrawing(a, b),                   // painter's order; omitted means walk order
  colorCount,                                // 1 when omitted
  colorPermutation(address),                 // null when omitted
  colorIndex(address),                       // 0 when omitted
}

RegularTiling and BinaryTiling additionally carry selfRotation, inverseGenerator(i), reverseGenerator(address, i) and generatorCount(), which are there for callers rather than for the renderer: nothing inside the library calls them, so a tiling of your own does not have to provide them.

stepFrame and generator are not the same thing when the tile stabilizer is non-trivial. generator(i) is the bare step; stepFrame is that step composed with whatever rotation lands in the neighbor's canonical frame. Returning the bare generator from both is correct only when stabilizerOrder is 1.

The generators must be constant matrices—independent of which tile you are in. That is what makes a walk a product of small factors, and it is the whole trick. In SU(1,1) an edge half-turn squares to −I rather than +I (the spin double cover), so inverseGenerator may return the index of a matrix equal to the negation of the inverse; any comparison of frames must work up to sign.

Checking a tiling

The diagnostics page is where the claims on this page are demonstrated rather than asserted. It draws nine tilings with a choice of motif, colors each tile by a hash of its own id so that nothing can hide a tile that has been turned or renamed, and lets you jump 5,000 tiles out and compare. Its ten built-in checks measure the same properties numerically — translation invariance byte-for-byte, tile ownership pixel-by-pixel, address round-trips, boundedness of the view matrix, picking, and smoothness across a tile boundary to a hundredth of a pixel.

It is a verification page rather than a demo, and it is the fastest way to see whether a custom tiling of your own satisfies the contract above.