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.
All are optional except that either a container or a canvas must be supplied. An unrecognized option name raises an error.
| 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. |
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.
| 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 |
| 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 |
| 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 |
| 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 |
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:
pageBackgroundz < 0onBeforeDrawbackground color or onDrawBackgroundonDrawRimz >= 0onAfterDrawSteps 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.
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.
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:
"L" — stroke that edge;"P" — also draw a marker at this 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"}
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.
Instead of one global coordinate system, give each tile of a tiling its own. Two reasons:
1 − 3.6e-9, so only about seven significant digits remain in the quantity that matters. In an atlas, every coordinate is measured from its own tile's center.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.
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.
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.
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
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.
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.
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.