A WebGL2 map rendering framework specialized for orthographic (globe view) projection
Worker-based parallel pipeline · OffscreenCanvas · Gint / GeoPBF / AltPBF integration
OrthoMap keeps only projection math, event handling, and layer management on the main thread, delegating all rendering to OffscreenCanvas + Web Workers. Each layer runs on its own independent Worker, achieving zero rendering blockage on the main thread.
Initialization completes in three sequential steps.
// index.js — entry point
const map = await orthoMap({
target: d3.select("#container"), // defaults to body
baseName: "osm.street",
center: [139.7, 35.7],
zoom: 4,
accessories: { stars: { maxCount: 500 } },
gadgets: false,
});
// Internal sequence:
// 1. orthographic(map, opts) — projection, events, interaction setup
// 2. createLayers(map, opts) — spawn Workers, create 3 layers
// 3. createGadgets(map) — register UI components
D3's geoOrthographic uses a scale value representing the globe radius in screen pixels.
OrthoMap converts this to the standard web map zoom level.
Below zoom 5.5 (threshold), only the base globe image is shown; above it, slippy tiles are composited on top.
Mouse drag and pinch gestures are built on D3's zoom events,
using versors (unit quaternions) for geometrically correct sphere-surface tracking.
// Drag start: convert touch point to Cartesian 3D on the sphere
v0 = cartesian(proj.invert(pt0)); // [lng,lat] → [x,y,z]
q0 = versor(r0 = proj.rotate()); // current rotation as quaternion
// During drag: compose minimal-arc rotation as quaternion product
const v1 = cartesian(proj.rotate(r0).invert(pt));
const q1 = multiply(q0, delta(v0, v1));
proj.rotate(rotation(q1));
During a two-finger pinch, the angular difference between fingers is composed as an additional quaternion, enabling simultaneous Z-axis rotation (map tilt).
Holding Ctrl or Meta while scrolling rotates the globe around the point under the cursor.
The 3D sphere vector [x,y,z] at the cursor position is computed and used as the rotation axis,
producing a quaternion that is composed with the current rotation.
This is a physically grounded rotation about an actual point on the sphere, not a simple camera spin.
Both functions use a monotonically incrementing flyTicket counter so that any new interaction immediately cancels an in-progress animation.
| Function | Algorithm | Characteristic |
|---|---|---|
flyToFeature |
d3.interpolateZoom (zoom out → travel → zoom in) + linear easing | Draws a smooth arc through the air for long-distance navigation |
zoomToFeature |
2-phase: ① rotate to destination at threshold zoom, ② zoom in on arrival | Travels at tile-free zoom level, then zooms in — minimizes tile requests |
// zoomToFeature — 2-phase composition
// Phase 1: travel capped at zoom 5.5 (travelMs ≈ distance × 2500 ms)
const sTrav = min(s1, zval2scale(5.5));
// Phase 2: zoom in over 2000 ms after arrival (600 ms overlap)
proj.scale(s0 + (sTrav - s0) * tRotEased // travel phase
+ (s1 - sTrav) * easeCubicInOut(tZoom)); // zoom phase
Built on D3 Dispatch, the API distinguishes between user-registered (anonymous) and system-registered (named) handlers.
// User event registration — auto-numbered namespace management
const handler = map.onClick(e => console.log(e.lng, e.lat));
handler.destroy(); // unregister
// Available events:
// Enter / Move / Leave / Drop / Click / ContextMenu
// Drawing / Drawn / Resize / Change / LoadStart / LoadEnd
// Move / Click carry elevation from AltPBF integration
// e = { lng, lat, alt, x, y, shiftKey, metaKey }
Each layer transfers canvas ownership to its Worker via OffscreenCanvas.transferControlToOffscreen()
and communicates with the main thread exclusively through postMessage.
The entire render loop runs inside the Worker.
init — offscreen / dpr / workersset — data upload (per cmd)drawing — scale / rotate / panningdrawn — commit render after pan endsmove — cursor coordinatesresize — width / heightdestroy — terminate Worker{action:"done", type:"init"}{action:"done", type:"set"}{action:"done", type:"resize"}{action:"identify", featureId}{action:"click", featureId, lng, lat}{action:"redraw"} — after context restored
drawing is a per-frame "immediate render" command fired continuously during pan and zoom —
aimed at fast, lower-quality output (no new tile fetches).
drawn is a "commit render" command sent once when the pointer stops,
triggering expensive work such as high-quality FBO rendering and picking buffer updates.
When a set message contains binary render data, it is sent as a Transferable for zero-copy transfer.
// Transferable transfer only when rawBuffers is present
if (prop?.rawBuffers) {
const { rawBuffers, ...rest } = prop;
worker.postMessage({ type: "set", cmd, data, prop: rest, rawBuffers }, rawBuffers);
}
The base.js Worker maps a full-globe equirectangular image (8192×4096) onto the sphere as a texture,
then composites slippy tiles on top as the user zooms in — a two-tier design.
The fragment shader back-projects each pixel's screen coordinate to a sphere coordinate and samples the equirectangular texture directly, eliminating any CPU-side projection or vertex buffer.
// GLSL Fragment Shader (baseFs) — orthographic inverse projection
float x = (gl_FragCoord.x - translate.x) / scale;
float y = (translate.y - gl_FragCoord.y) / scale;
float rho = sqrt(x*x + y*y);
if (rho <= 1.0) { // inside globe circle only
float c = asin(rho);
float lng = atan(x * sin(c), rho * cos(c));
float lat = asin(y * sin(c) / rho);
applyRotation(rx, ry, rz, lng, lat); // apply user rotation
vec2 uv = vec2((lng + pi) / (2.0*pi), (lat + pi/2.0) / pi);
outColor = texture(u_image, uv);
} else { discard; } // discard fragments outside globe
The base texture uses REPEAT on the S axis (longitude) and CLAMP_TO_EDGE on T (latitude),
preventing seams when rendering across the ±180° antimeridian.
Tile textures use CLAMP_TO_EDGE on both axes to avoid bleeding between adjacent tiles.
A Worker pool (4 parallel by default) fetches tile images by URL, converts them to ImageBitmap,
and uploads them as GL textures via createTileTexture.
// Tile priority: load tiles closest to screen center first
ents.sort((a, b) => a[4] - b[4]) // [4] = distance from center
.forEach(([x, y, zo, pos, dist]) => {
// zo = polar LOD offset (from Y2T/Y4T tables)
// fall back to cached z-1, z-2, z-3 if preferred zoom not available
for (let dz of [0, 1, 2, 3]) {
const z = Z0 - (zo + dz); if (z < 0) continue;
const img = TileTub.get(name);
if (img) { /* remap UV to parent tile region and draw */ return; }
}
});
Y2T / Y4T tables: High-latitude tiles appear very small on screen due to Mercator distortion. These constant tables define a per-zoom LOD offset (zo) that causes polar tiles to be loaded at 1–2 zoom levels lower, avoiding unnecessary high-resolution fetches.
| Name | Base Globe Image | Tile Source | MaxZoom |
|---|---|---|---|
whiteEarth | whiteEarth.webp | none | 7 |
google.street | naturalEarth.webp | Google Maps (lyrs=m) | 22 |
google.satellite | google.satellite.webp | Google Maps (lyrs=s) | 22 |
osm.street | naturalEarth.webp | OpenStreetMap JP | 19 |
osm.satellite | osm.satellite.webp | Bing Maps (quadkey index) | 19 |
cyberjapan.std | naturalEarth.webp | GSI Japan (Japan extent) + OSM (outside) | 19 |
Firefox cannot reliably use createImageBitmap(blob) results as WebGL textures.
A UA-detected workaround renders through an intermediate OffscreenCanvas and re-converts
with transferToImageBitmap().
gintBorder.js is a lightweight variant of the Gint renderer that implements
line drawing only. It has no hover picking, polygon fill, or FBOs,
and draws multiple fixed-style datasets in a single pass.
// 4 Gint datasets (loaded via GeoPBF)
const geoNames = [
"ne_110m_graticules_10", // 10° graticule grid
"ne_50m_admin_0_boundary_lines_land", // country borders
"ne_50m_admin_0_boundary_lines_maritime_indicator", // maritime boundaries
"ne_50m_geographic_lines", // equator, tropics, etc.
];
// Per-dataset draw styles (dash values in screen pixels)
const BORDER_GL_STYLES = [
{ color: [1.0, 1.0, 1.0, 0.6], lineWidth: 1.0, dash: [0, 0] }, // graticule (solid)
{ color: [1.0, 1.0, 1.0, 1.0], lineWidth: 1.0, dash: [4, 2] }, // country borders
{ color: [0.5, 0.5, 1.0, 0.8], lineWidth: 0.8, dash: [4, 2] }, // maritime
{ color: [1.0, 1.0, 1.0, 0.6], lineWidth: 0.8, dash: [4, 2] }, // geographic lines
];
Dash lengths maintain a constant visual size regardless of zoom level.
The vertex shader computes a cumulative arc distance as
v_dist_base = vertex_index × scale × sin(1°),
giving a pixel-space distance that stays visually consistent as the zoom changes.
To prevent semi-transparent alpha from accumulating where lines overlap, the color and alpha channels are blended separately.
gl.blendFuncSeparate(
gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, // RGB: standard alpha blend
gl.ONE, gl.ONE_MINUS_SRC_ALPHA // A: preserve source alpha
);
The stencil-pass vertex shader does not collapse back-hemisphere vertices (z < 0) to NDC origin. Instead it pushes them outward to the horizon circle (radius = u_scale). This keeps fan triangles well-formed even when a border polygon straddles the horizon, preventing fill artifacts for horizon-crossing features.
// GLSL VS_STENCIL — push back-hemisphere vertices to the horizon
if (p.z < 0.0) {
vec2 v = p.xy - u_viewport * 0.5;
float d = length(v);
gl_Position = d < 1e-4
? vec4(0.0, 0.0, 0.0, 1.0) // directly behind center → origin
: toNDC(u_viewport * 0.5 + v * (u_scale / d)); // project to horizon circle
return;
}
The border.js Worker runs on a Canvas2D context and refreshes
astronomical data and map overlay information on a 1-second interval timer.
Rendering stars and constellation lines requires knowing where the sky is facing at the current time. GST is computed from the current wall-clock time.
Each star's right ascension (RA) minus GST gives the hour angle, which is then converted from equatorial to screen coordinates.
Each star from the stars.6 catalogue is projected from RA/Dec to screen coordinates.
Its color is derived from the B-V color index, and radius/opacity from apparent magnitude.
// B-V color index → star color (temperature spectrum)
const bvColor = bv =>
bv < -0.3 ? "#b2c8ff" : // O-type — blue-white
bv < 0.0 ? "#d9e2ff" : // B-type — blue-white
bv < 0.3 ? "#f8faff" : // A-type — white
bv < 0.6 ? "#fff8f0" : // F-type — yellow-white
bv < 0.8 ? "#fff2c8" : // G-type — yellow (Sun)
bv < 1.1 ? "#ffe0b5" : // K-type — orange
"#ffab91"; // M-type — red
// Apparent magnitude → radius and opacity
radius = (9 - mag) * 0.20; // brighter → larger
alpha = 1 - mag / 15; // brighter → more opaque
Constellation lines are stored as RA/Dec coordinate pairs (MultiLineString). Each point is tested for visibility by converting to Cartesian 3D before drawing.
// Equatorial (ra, dec) → screen projection (px, py)
l = ra - skyRot; // hour angle
z = sinφ*sinδ + cosφ*cosδ*cos(l); // z < 0 → below horizon
x = cosδ * (-sin(l));
y = cosφ*sinδ - sinφ*cosδ*cos(l);
px = cx + sr * (x*cosγ - y*sinγ); // sr = sky-dome radius (larger than globe)
py = cy - sr * (x*sinγ + y*cosγ);
Objects are drawn with type-specific symbols based on the type property.
| type | Object type | Symbol |
|---|---|---|
gc | Globular cluster | Circle + crosshair |
gx / gg | Galaxy | Ellipse |
oc | Open cluster | Dashed circle |
| other | Nebula, etc. | Rectangle |
Each of the four viewport edges is sampled at 30 points (120 total), inverse-projected to geographic coordinates, then re-projected onto the minimap to draw a viewport reticle polygon.
// Sample geographic coordinates along each viewport edge
const N = 30;
const edge = (x0, y0, x1, y1) =>
Array.from({ length: N }, (_, i) => {
const t = i / N;
return proj.invert([x0 + (x1-x0)*t, y0 + (y1-y0)*t]);
});
const geoPts = [...edge(0,0,w,0), ...edge(w,0,w,h), ...edge(w,h,0,h), ...edge(0,h,0,0)];
| Feature | Description |
|---|---|
| Night side (night) | Computes the terminator as a 31-point polygon from solar declination and longitude; fills with a semi-transparent dark overlay |
| Clock (clock) | Displays UTC in a large font; visible only when zoomed out (zoom < 2) |
| Coordinates (latlng) | Shows latitude, longitude, and elevation in real time as the cursor moves |
| Scale bar (scale) | log10-rounded values with automatic unit selection (m/km; metric/imperial) |
| Attribution (credit) | License text for each base map, drawn from the attribution property |
The gint.js Worker is a general-purpose layer created by the user via
createRemoteLayer({ type:"gint" }). It accepts Gint binary data from GeoPBF
and renders lines, polygons, and points with full support for
hover picking, click events, feature highlighting, and polygon fill.
| File | Role |
|---|---|
gintState.js | Shared GL state object (s) |
gintPrograms.js | GLSL shader compilation, linking, and uniform lookup |
gintTextures.js | arcTex / metaTex / ptTex texture creation and deletion |
gintFBO.js | baseFBO (clean scene) and pickFBO (picking) creation and deletion |
gintRenderPasses.js | renderCleanScene / drawOverlay / renderPickingBuffer — 3 render passes |
gintIdentify.js | GPU picking + JS polygon hit-test fallback, hover / leave handlers |
// Encode feature ID into RGB 24-bit in the picking FBO
uint fid1 = meta.a + 1u; // shift by 1 so 0 means "no hit"
v_color = vec4(
float(fid1 & 255u) / 255.0,
float((fid1 >> 8u) & 255u) / 255.0,
float((fid1 >> 16u) & 255u) / 255.0,
1.0
);
// Read 1 px at cursor position
gl.readPixels(pickX, pickY, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
const fid1 = px[0] | (px[1] << 8) | (px[2] << 16);
const featureId = fid1 === 0 ? null : fid1 - 1;
When GPU picking returns no hit (fid1 === 0) but polygon data exists,
the cursor's geographic coordinate is converted to Morton integer space and passed to
findPolygon (from geopbf). This also works when the globe is small enough that
all viewport corners lie outside the sphere and lastViewBbox = null.
// When activeId changes (hover detected):
// 1. Blit baseFBO → canvas (restore clean scene)
gl.blitFramebuffer(0, 0, w, h, 0, 0, w, h, gl.COLOR_BUFFER_BIT, gl.NEAREST);
// 2. Redraw active feature's edges wider + yellow (u_pass=1)
gl.uniform1f(uRender.u_line_width, lineWidth + 2.0);
gl.uniform1i(uRender.u_active_id, activeId);
// 3. If polygon edge range is known, stencil-dim inactive polygons
// (only when maskColor.alpha > 0 and polyEdgeByFid has a range)
The entire hover detect → drawOverlay pipeline completes inside the Worker.
There is no postMessage("redraw"); readPixels and drawOverlay
run sequentially in the same Worker, eliminating main-thread round-trip latency.
canvas.addEventListener("webglcontextlost", e => {
e.preventDefault();
// Clear all texture, FBO, and program references
s.arcTex = s.metaTex = s.ptTex = s.ptMetaTex = null;
s.baseFBO = s.pickFBO = s.programs = null;
});
canvas.addEventListener("webglcontextrestored", () => {
s.programs = createGintPrograms(s.gl);
createFBOs(); uploadGintTextures();
postMessage({ action: "redraw" }); // request redraw from main thread
});
The Cache helper from native-bucket (an IndexedDB wrapper) persists
view state and base map name across sessions.
map.stat = await Cache("GIS/stat");
map.baseName = await map.stat("base") || "osm.street";
map.view = await map.stat("view") || [[135, 35, 0], 2];
// view = [[lng, lat, rotation], zoom]
// Auto-saved after each pan/zoom commit (drawn event)
map.stat("view", getView());
The function returned by altpbf's createGetHeight is registered as map.getHeight.
This adds an alt field to Move and Click events, enabling coordinate display and elevation profiles.
UI components are registered via map.gadget(name, func) — a plugin pattern
where built-in and user-defined gadgets are treated identically.
Each gadget function is called with this = map and can freely build UI
within a DOM region reserved by map.addFrame.
// Invoking built-in gadgets
map.gadget.zoom(); // places zoom buttons in leftTop
map.gadget.layers(); // adds base map switcher button
const setTip = map.gadget.tip(); // returns the tip control function
setTip("Click for details"); // set tip text
// Registering and invoking a custom gadget
map.gadget("myTool", function () {
map.addFrame("rightTop");
map.rightTop.append("div").text("Hello");
});
map.gadget.myTool();
| Frame name | Position | Typical use |
|---|---|---|
leftTop | Top-left | Zoom, north, layer switcher, and other action buttons |
rightTop | Top-right | Auxiliary buttons and user UI |
leftBottom | Bottom-left | Legend |
rightBottom | Bottom-right | Supplementary info |
leftFrame | Fixed panel left of map | Side menu (pushes map, resize-aware) |
rightFrame | Fixed panel right of map | Attribute panel (pushes map, resize-aware) |
overlays | Full-map overlay | tip / pop / explain / loading, etc. |
| Gadget | Description |
|---|---|
zoom() |
Zoom-in (+) and zoom-out (−) buttons. Each click snaps to the nearest integer zoom level and animates via map.mag() |
north() |
Displays the current Z-axis rotation (map tilt) live inside the button's SVG icon, and calls map.north() on click to animate back to north-up |
layers() |
Base map switcher dropdown. Renders layer names in the current locale from map.Layers and calls map.setBase() on selection |
leftPanel(opts)rightPanel(opts) |
Manages side panel open/close. With opts.active = true the panel physically pushes the map area (resize mode);
otherwise it overlaps the map as a modal. Opening animation is interpolated with d3.easeCubic (configurable).
Returns a D3 Selection for the panel content area.
|
full() |
Toggles fullscreen via the Fullscreen API, automatically swapping the button icon to match state |
constellation() |
Toggles constellation lines and labels. Sends toggle-constellations to the Accessories Worker, which also toggles Messier object display in sync |
cpos() |
Obtains the current position via the Geolocation API and navigates there with flyToFeature.
The location is shown as an animated SVG marker (pulsing circle) on the overlay,
automatically shown/hidden based on map.tester() as the map moves
|
shot() |
Downloads a screenshot. All Canvas layers are composited onto an OffscreenCanvas,
then html2canvas renders the overlay (excluding buttons and noprint elements) on top.
Saved as WebP.
|
print() |
Renders at 3× resolution and opens a new window that calls window.print().
Automatically detects portrait vs. landscape and applies appropriate CSS layout.
|
measure() |
Toggle button that enters distance/area measurement mode.
When active, cancels Click events and launches the createPolygon tool,
displaying distance and area in real time as vertices are added
|
| Gadget | Description |
|---|---|
loading() |
Displays a tile loading indicator tied to LoadStart / LoadEnd events.
When multiple tiles are loading simultaneously, all names are listed;
the indicator disappears automatically once everything completes
|
explain() |
A panel in the upper-left area for explanatory text or HTML.
The returned function accepts a string or a function receiving a D3 Selection.
Pass opts.permanent = true to hide the close button.
|
legend() |
A collapsible legend panel in the lower-left. Clicking collapses it;
a re-open button restores it. Content is set the same way as explain.
|
tip() |
A tooltip that follows the cursor. Pass text to the returned function to show it;
pass null to hide. Automatically flips to the opposite side near screen edges.
Uses a wider offset on touch devices.
|
pop() |
Creates popups pinned to specific map locations.
Each call to the returned pop(content, event) spawns a new popup.
A Canvas2D leader line is drawn automatically from the popup to its coordinate.
Popups can be dragged and pin-locked. pop.clear() removes all.
|
contextmenu() |
A context menu shown on right-click or long-press.
Pass an array of [{ icon, name, func }] to the returned function to define menu items.
|
// pop example
const pop = map.gadget.pop();
map.onClick(e => {
if (!e) return;
pop(`Lat: ${e.lat.toFixed(4)}<br>Lng: ${e.lng.toFixed(4)}`, e);
});
// contextmenu example
const setMenu = map.gadget.contextmenu();
setMenu([
{ name: "Center here", func: map => map.flyToFeature({ type: "Point", coordinates: [map.center[0], map.center[1]] }) },
{ name: "Face north", func: map => map.north() },
]);
map.setProperties() writes CSS custom properties to the document root.
Key properties: spaceColor (space background), earthFilter (globe CSS filter),
borderColor (button border), buttonSize, fontSize, fontFamily, and more.
| Method | Description |
|---|---|
map.setBase(name) | Switch base map (includes tile cache reset) |
map.setView(center, zoom, angle) | Instantly change view position |
map.flyToFeature(feature, opts) | Navigate with d3.interpolateZoom animation |
map.zoomToFeature(feature, opts) | Navigate with two-phase animation |
map.bbox([x0,y0,x1,y1]) | Zoom to bounding box / get current bbox |
map.mag(n, duration) | Animate scale to n× the current value |
map.north(duration) | Animate Z-rotation (tilt) back to 0 |
map.autoRotate(true/false) | Auto-rotate at 0.01°/ms via d3.timer |
map.tester([lng,lat]) | Check whether a point is visible in the current viewport |
map.createLayer(opts) | Add a Canvas2D layer (for GeoJSON rendering) |
map.createRemoteLayer(opts) | Add a Worker + OffscreenCanvas layer |