/ gint

Gint — Guide and Architecture

Geographic Interleaved Binary Format
interleave (Morton bit-interleaving) · integer (64-bit integer vertex codes) · intended (level of detail designed in)
A whole dataset on the GPU — drawn at any zoom, and able to answer questions

Part I — Use it
  1. Why Gint — Tiles Draw, Gint Knows
  2. Your First Gint Layer
  3. Choropleths — Three Examples
  4. Expressions — What Is Promised
  5. When Data Overlaps
  6. API Reference
Part II — How it works
  1. Pipeline Overview
  2. Encoding — Topology, Vertex Codes, Ranks
  3. Level of Detail in the Vertex Shader
  4. Filling Polygons Without Triangles
  5. Lines and Styles
  6. On a Sphere — Horizon and Antimeridian
  7. In 3D — On the Terrain
  8. Asking Questions — Picking and Identify
  9. Design Philosophy
Part I — Use it

1. Why Gint — Tiles Draw, Gint Knows

The ortho-earth engine has two kinds of vector layers. The basemap is drawn from vector tiles (MVT): the server cuts the world into 4z tiles per zoom level, each zoom fetches a new set, and each tile is triangulated once on the CPU. That is right for a background picture. It is wrong for data you want to ask about — every feature is chopped at tile edges, simplified differently at each zoom, and exists only as triangles.

A Gint layer keeps the whole geometry of a GeoPBF dataset on the GPU, once:

Vector tilesGint
Filesone per tile per zoomone file
Bytes when you zoomnew tiles+0 — level of detail is chosen on the GPU
Polygon fillCPU triangulation per tilewinding count — no triangulation
Featurescut at tile edgeswhole, shared borders exact
Restylere-run the style per tileone texture update — every feature can have its own colour
Questions—pick, identify, highlight, join to a table

2. Your First Gint Layer

Five steps: load, add, paint, filter, ask. The words come from MapLibre GL JS — paint properties, expressions, setPaint, setFilter — so what you already know works here. It is not a copy of MapLibre. The vocabulary is borrowed to make the first step short; the layer underneath is Gint.

import { createGlobe, geopbf } from "@ortho-earth/globe";

const map = await createGlobe({ target: "#map" });

// 1. Load — any format geopbf reads. { gint: true } also builds the GPU form (kept in IndexedDB).
const pbf = await geopbf("https://example.com/municipalities.geojson", { gint: true });

// 2. Add — a new layer on top. Nothing is replaced.
const layer = map.addGint(pbf, { tip: true });
await layer.ready;                                 // true when the layer is on the GPU

// 3. Paint — MapLibre paint properties and expressions, evaluated once per feature.
await layer.setPaint({
  "fill-color": ["interpolate", ["linear"], ["get", "pop"], 0, "#fff5eb", 1000000, "#7f2704"],
  "fill-opacity": 0.85,
  "line-color": "#ffffff",
  "line-width": 0.5,
});

// 4. Filter — hide features. Nothing is rebuilt.
await layer.setFilter([">=", ["get", "pop"], 10000]);

// 5. Ask.
layer.on("click", e => console.log(e.fid, e.properties, e.lngLat));
const hit  = layer.query([139.767, 35.681]);  // { fid, properties } or null
const hits = map.queryAll([139.767, 35.681]); // [{ layer, fid, feature }], top layer first

2.1 Load

geopbf(input, { gint: true }) takes a URL, a File, an ArrayBuffer or a GeoJSON object, in any format GeoPBF reads — GeoJSON, Shapefile, GeoPackage, FlatGeobuf, GeoParquet, KML, GPX and more. gint: true also builds the form the GPU draws: the topology encoder (WebAssembly, in a worker) turns every shared border into one shared arc. The result is kept in IndexedDB, so the next visit skips the encoder. geopbf is re-exported by @ortho-earth/globe; you do not need a second package.

Features whose properties are exactly the same become one feature — a multi-part feature with one fid. They are coloured, highlighted and answered together. If you need them apart, give each one its own property, such as an id.

2.2 Add

map.addGint(pbf, options) adds a layer and returns its handle at once. The baking — edge tables, the level-of-detail ladder, a box per feature — runs in a worker, so the map keeps drawing. await layer.ready gives true when the layer is on the GPU (false if it failed). addGint does not move the camera.

Before any paint, a layer draws in its default style: polygon outlines in orange (#FF6B35), lines in cyan (#00B4D8). When the polygons shrink below a few pixels, the layer shows them as a solid fill instead of a mesh of lines, and switches back as you zoom in. Layers stack in the order you add them; the order option (smaller is lower) fixes the order whatever the sequence of calls. Any number of layers can be on the map at once, on both backends (WebGPU and WebGL2).

2.3 Paint

setPaint(paint, filter?) takes MapLibre paint properties. Each value is a constant or an expression (§4). The expressions are evaluated once, in JavaScript, for every feature, and the results are written into a style table — one texel per feature. The GPU reads that table while it draws; no expression runs per frame. A restyle is one pass over the features plus one texture update. The geometry is never rebuilt. For the 1,919 municipalities of Japan the table is 31 KB.

PropertyApplies toValue
fill-colorpolygonscolour
fill-opacitypolygons0–1, multiplies the colour's alpha
line-colorlines and polygon outlinescolour
line-widthlines and polygon outlinesCSS pixels, in steps of 1/8, up to 31.875 · 0 = no line
circle-colorpointscolour
circle-radiuspointsCSS pixels, in steps of 1/4, up to 63.75 · 0 = no point
line-opacitylines, outlines and points0–1, multiplies the line or circle colour's alpha

Every property can be data-driven. Colours are CSS colours: #rgb, #rgba, #rrggbb, #rrggbbaa, rgb()/rgba() with commas, hsl()/hsla() and the CSS colour names. Lines and points share one colour slot per feature, so line-opacity also fades points.

Missing values

Decide what a missing value means, and say it in the expression. A feature without the property gets no fill from interpolate, but the lowest colour from step, and the fallback from match. Wrap the input in ["coalesce", ["get", "pop"], …], test it with ["has", "pop"], or filter those features out.

2.4 Filter

setFilter(expression) hides every feature for which the expression is false; setFilter(null) shows them all again. Like paint, the filter is evaluated once and written into the same table — nothing is rebuilt. A hidden feature is not drawn, and it takes no part in the fill, so it cannot spoil the fill of what lies under it (§5). It also drops out of hover and of questions: the answer is the next feature under the pointer, as if the hidden one were not there.

The filter lives in the same table as the paint. A filter given before the first setPaint is kept and applied with it; setPaint(paint, filter) sets both in one call.

2.5 Ask

One layer holds the cursor at a time: hover, the tip, the highlight and layer.on("click") belong to it. Adding an interactive layer gives it the cursor, layer.activate() moves it, and a layer added with interactive: false never takes it — the layer that held the cursor keeps it. Hover is continuous — a pick every 32 ms — so it stays on one layer and costs one pick. A click is rare, so it can afford to ask every layer. A layer of lines only (no polygons, no points) does not answer hover yet; ask it through map.on("click") or query.

layer.on("hover", f => …);        // { fid, properties } or null
layer.on("mouseenter", f => …);   // entering a feature: { fid, properties }
layer.on("mouseleave", e => …);   // leaving it: { fid }
layer.on("click", e => …);        // { fid, properties, lngLat: [lng, lat] }

map.on("click", ({ lngLat, hits }) => {   // every Gint layer under the point, top first
  for (const { layer, fid, feature } of hits) …
});

A fid is the index of a feature inside its own layer — two layers both have a feature 7 — so keep it together with its layer.

layer.query(lngLat) and map.queryAll(lngLat) answer at once, in JavaScript, with no round trip to the GPU. They look for a point within 50 m, then a line within 30 m, then the smallest polygon that contains the position — a click on a ward inside a city returns the ward. Features hidden by the filter are skipped.

2.6 More on the handle

3. Choropleths — Three Examples

3.1 Municipalities, coloured from your own table

The values often live in a table — a census CSV, a spreadsheet — and not in the boundary file. There is no need to edit the data: build a match from the table. Two thousand labels are fine, because the expression runs once, not per frame.

// Your table: municipality code → value (for example population from a census CSV)
const values = { "13101": 66680, "13102": 169179, /* … */ };
const breaks = [10000, 50000, 100000, 300000, 1000000];
const colors = ["#f7fbff", "#c6dbef", "#6baed6", "#3182bd", "#08519c", "#08306b"];
const classOf = v => breaks.filter(b => v >= b).length;

// "N03_007" = the code field of Japan's national boundary data; use your own field name
const fill = ["match", ["to-string", ["get", "N03_007"]]];
for (const [code, v] of Object.entries(values)) fill.push(code, colors[classOf(v)]);
fill.push("rgba(0,0,0,0)");                       // not in the table: no fill

await layer.setPaint({ "fill-color": fill, "fill-opacity": 0.85, "line-color": "#ffffff", "line-width": 0.3 });

match compares with ===: the text "13101" is not the number 13101, so the input is passed through to-string. The number of colours does not change the cost of drawing. Every feature may have its own colour — the fill is two passes whether there are six colours or 1,919.

3.2 Parcels, by lot number

Japan's land registry map (登記所備付地図, published by the Ministry of Justice; geopbf reads its XML directly) gives each parcel a lot number, 地番. In central Sapporo — 57,341 parcels — the same field also holds words: 道 (road), 河川 (river), 無地番 (no lot number), 筆界未定地 (boundary not settled). One expression puts public land and unsettled boundaries on the map:

await parcels.setPaint({
  "fill-color": ["match", ["get", "地番"],
    ["道", "河川", "無地番"], "#e41a1c88",  // road, river, no lot number
    "筆界未定地", "#ff7f00aa",                // boundary not settled
    "rgba(0,0,0,0)"],                           // every other parcel: no fill
  "line-color": "#ffffff66",
  "line-width": 0.5,
});

A match label can be an array: any of its values selects the output.

3.3 Two layers — a parcel inside a hazard zone

Put a hazard layer under the parcels. The parcels hold the cursor; a click reaches both.

// Landslide warning zones (Japan's National Land Numerical Information A33). A33_002 = 2: special warning zone
const zones = map.addGint(await geopbf(zonesUrl, { gint: true }), { order: -1, interactive: false });
await zones.setPaint({
  "fill-color": ["match", ["to-number", ["get", "A33_002"]], 2, "#c0392b80", "#d9a4416b"],
  "line-width": 0,                                 // fill only, no outline
});                                                   // interactive: false — the cursor stays on the parcels

map.on("click", ({ hits }) => {
  const parcel = hits.find(h => h.layer === parcels);
  const zone   = hits.find(h => h.layer === zones);
  if (parcel && zone) console.log(parcel.feature.properties["地番"], "is in a warning zone");
});

Turning a layer on and off is setVisible — nothing is baked again. The questions “which zone is this parcel in?” and “what is under this point?” are the reason Gint exists: the layers are data, not only pictures.

4. Expressions — What Is Promised

The expressions are MapLibre's. The set below is promised for Gint layers: it is the set listed in the type definitions (globe.d.ts) and it is tested. The evaluator is shared with the basemap, which reads MapLibre style files, so it accepts more operators than these; the others are not promised for Gint layers.

GroupOperators
Dataget has feature-state geometry-type zoom literal
Choicematch case step coalesce
Interpolationinterpolate — ["linear"] and ["exponential", base], numbers and colours
Comparison and logic== != > >= < <= ! all any in
Arithmetic+ - * / % ^ min max
Types and textto-number to-string concat
Variableslet var

4.1 Rules

  1. Once, not per frame. Expressions run when you call setPaint or setFilter (and when feature state changes). The GPU only reads the results.
  2. ["zoom"] is a snapshot. It is the zoom at evaluation time; the layer re-evaluates when the camera stops (§2.6). There is no interpolation between frames.
  3. No exceptions. When an expression fails or gives the wrong type for a feature, that feature keeps the default: no fill, a 1 px line, a 1.5 px point. An operator outside the evaluator gives the default too.
  4. Strict equality. ==, != and match compare with ===. Convert with to-string or to-number when the types differ.
  5. in is ["in", needle, haystack], where the haystack is a string or an array (["literal", […]]).
  6. Colours interpolate in RGB, and may be nested — an interpolate inside a match branch works.

5. When Data Overlaps

Real data overlaps: a prefecture polygon over its municipalities, a zone registered twice, parcels drawn over each other. Gint fills by the nonzero winding rule — the even-odd rule is never used — so overlaps behave in a way you can predict:

Quality becomes visible

The winding sum carries more than a colour. Read differently, it shows where two different features overlap, where one feature is registered twice, and where a ring runs the wrong way. During development the engine paints exactly those pixels: on a land registry map, a question of data quality that no one could see becomes a picture. This view is a development tool today, not part of the API.

One limit: the per-feature fill adds feature numbers in a float target. On GPUs that cannot blend 32-bit floats, the numbers are 16-bit and a layer can colour up to 2,047 features one by one; a larger layer loses the per-feature fill and is drawn in its plain style. If you meet this limit, merge the features that share a style into multi-polygons — the fill cost is the same.

6. API Reference

6.1 Adding a layer

map.addGint(pbf, options) → handle | null

Adds a Gint layer on top and returns its handle at once. Returns null when pbf has no GPU form. The layer becomes the active one unless interactive is false (§2.5).

layer.ready → Promise<boolean>

true when the layer is on the GPU, false if baking failed.

6.2 Style

await layer.setPaint(paint, filter?)

Evaluates the paint (and the filter) for every feature and sends one table to the GPU. Replaces the whole paint. null = the default style. Without filter, the current filter stays.

await layer.setFilter(filter)

Only the filter. Before the first setPaint it is kept and applied with it. null = show all.

layer.setFeatureState(fid, state) · layer.removeFeatureState(fid?)

Temporary state per feature, read by ["feature-state", key]. state is merged; null removes it. removeFeatureState() with no fid clears all.

await layer.setLabel(label | null)

Anchors: the centre of the feature's box for polygons and lines, the point for points. Hidden features have no label.

layer.style(style)

Changes the look without paint (the same object as options.style).

6.3 Data, order, visibility

await layer.setData(pbf, { minZoom?, maxZoom? }) → boolean

Swaps the data. The handle, its events, the paint and the filter stay; the paint is evaluated again on the new features. Resolves true when the new data is on the GPU.

layer.setOrder(order) · layer.setVisible(visible) · layer.remove()

Moves the layer in the stack (smaller is lower) · shows or hides it (nothing is baked again) · removes it for good. If the active layer is removed, no layer holds the cursor until you call activate() or add another interactive layer.

6.4 Events and questions

layer.on(type, callback) → layer

For the active layer only. A layer of lines only does not answer hover yet.

layer.activate()

Gives the cursor (hover, tip, highlight, layer.on("click")) to this layer.

layer.query([lng, lat]) → { fid, properties } | null

Synchronous, in JavaScript. A point within 50 m, else a line within 30 m, else the smallest polygon containing the position. Features hidden by the filter are skipped. Answers for any layer, interactive or not, even when it is switched off or outside its zoom range.

map.queryAll([lng, lat]) → [{ layer, fid, feature }]

layer.query on every Gint layer that is shown now — switched on and inside its zoom range — top layer first. feature is { fid, properties }. The globe's own layers (country outlines and the like) are not included.

map.on("click", ({ lngLat, hits }) => …)

Every click on the globe, with hits = map.queryAll(lngLat). Also map.on("move" | "settle" | "load", …) and map.off(type, callback); "load" fires at once if the map is already drawn.

6.5 The MapLibre-shaped entrance

map.addSource(id, { type: "geojson", data }) with map.addLayer({ type: "fill" | "line" | "circle", … }) builds a Gint layer underneath, so MapLibre code runs as it is: setPaintProperty, setLayoutProperty (visibility), setFilter, moveLayer, setFeatureState({ source, id }, state), queryRenderedFeatures and map.on("click", layerId, …) all work on it. The layers of one source become one Gint layer: their paints are merged, and their filters are joined with all. Zoom ranges follow MapLibre: without minzoom a layer is drawn from zoom 0.

The older single-layer methods (applyGintData, paint, paintTable, onGintClick) remain for existing apps. New code should use addGint.

Part II — How it works

7. Pipeline Overview

1
Encode — once per dataset
GeoPBF → GintBUF · WASM in a worker · cached in IndexedDB
shared-arc topology · 64-bit vertex codes · Visvalingam–Whyatt ranks · degree anchors on long arcs
↓
2
Bake — once per load
GintBUF → GPU textures · bake worker
edge meta · tier ladder · culling chunks · boundary meta · per-feature bbox and style tables
↓
3
Draw — every frame
WebGPU or WebGL2 · same passes on both
cull · level of detail in the vertex shader · winding fill · capsule lines · drape · pick

8. Encoding — Topology, Vertex Codes, Ranks

8.1 Shared arcs

The encoder (Rust compiled to WebAssembly, run in a worker) splits every ring and line at junctions into arcs, and stores each arc once. A polygon is a list of arc references — [fid][nRings][arcCount][±arcIdx…], where a reversed arc is written ~idx. Two neighbouring municipalities share their border arc, so it is simplified identically for both: no slivers, no gaps, at any zoom. Lines and a neighbour list are stored in separate streams.

8.2 The 64-bit vertex code

Longitude and latitude become integers — ix = (lon + 180) × 107, iy = (lat + 90) × 107 (about 1 cm) — and their bits are interleaved (Morton order) into one 64-bit code. The code also carries the vertex's level of detail:

L1top bit set — an arc endpoint (or a degree anchor). Always kept; reads as rank 63.
L2an interior vertex — snapped to a grid of 8 units (8×10-7°), freeing the low 6 bits for its rank (0–63).

Long arcs get an extra L1 vertex every 1° along the great circle (a degree anchor), so a long edge can never be simplified into a chord that cuts across the curve of the Earth.

8.3 Visvalingam–Whyatt rank

Each interior vertex is weighted by the Visvalingam–Whyatt effective area — the area of the triangle it forms with its neighbours, carried as a running maximum so a vertex never outranks the ones removed before it, and corrected by cos(latitude). The area becomes a 6-bit rank:

rank = clamp( floor( 1.5 · log2(area in deg²) + 61.524 ), 0, 63 )

The same formula, fed the ground area of one screen pixel, gives the rank the current zoom needs (§9). Rank and zoom speak the same unit.

8.4 GintBUF

The result is one buffer: a 64-byte header (magic "Gint", version, counts, bbox, stream lengths), then the arc vertex codes, the point codes, per-arc metadata [offset, length, weight, –, bbox], and the polygon, line and neighbour streams. It is cached in IndexedDB, so a second visit skips the encoder entirely.

9. Level of Detail in the Vertex Shader

The bake step turns arcs into edges (vertex A → vertex B) in an integer texture. Each frame:

  1. Required rank. The ground area of one pixel goes through the rank formula: rank = floor(1.5·log2(pxArea) + 61.524).
  2. Discard. The vertex shader drops every edge whose start vertex ranks below it.
  3. Forward snap. The surviving edge's end is moved forward to the next vertex that is kept. The simplified line stays connected — no gaps, no T-junctions, because the decision is made per vertex, and a shared arc makes the same decision for both polygons.

Nothing is uploaded when you zoom. What changes is one uniform.

9.1 Tiers and chunks

A national dataset can have millions of edges. Two structures keep the vertex shader from touching them all:

10. Filling Polygons Without Triangles

Polygons are never triangulated. Each polygon's edges are drawn as a fan from a pivot — the centre of that feature's bbox — into the stencil buffer: front-facing triangles increment, back-facing triangles decrement (nonzero winding). A second pass covers every pixel whose count is not zero. Concave shapes and holes come out right with no special cases; the fan's own overlaps cancel.

11. Lines and Styles

Each edge is drawn as a small quad (six vertices) with a capsule signed-distance function, so lines have round joins and ends at any width without extra geometry. Colour, width, point radius and flags come from a per-feature style table on the GPU, and a 256-slot style table and dash table serve shared styles — restyling a map is a texture update, not a rebuild.

12. On a Sphere — Horizon and Antimeridian

13. In 3D — On the Terrain

14. Asking Questions — Picking and Identify

15. Design Philosophy

How geographic data is represented is also a choice about which level of detail to treat as "reality." Just as Natural Earth's physical/cultural distinction organizes phenomena on Earth from a human perspective, Gint's level-of-detail design implements the idea that what is visible changes with scale.

The Visvalingam–Whyatt weight quantifies "how much shape would be lost by removing this vertex." It is a measure of significance — a way to separate the essential structure of a coastline or boundary from detail that only matters at finer scales. Packing it into the vertex code itself means the data carries its own answer to "what should be seen at this scale."

Two quirks belong to globes, not flat maps: polygons crossing the antimeridian must be cut, and polygons crossing the horizon must be closed along it. They are the quiet surprises that separate a flat map renderer from a globe renderer.

The API follows the same idea from the other side. The data stays as it is — the paint is a view of it, computed once and kept beside it. Borrowing MapLibre's words is not about being a copy; it is about letting what people already know carry over.

gint · @ortho-earth/core · Kenji Yoshida · 2026