Live voorbeeld
Klik en sleep om de wereldbol te draaienInteractieve 3D Aardbol
Hoogwaardige WebGL 3D-aardevisualisatie met atmosferische shaders en interactieve locatiemarkers.
Probleem en oplossing
Realistische planetaire weergave in de browser zonder framerate-verlies op standaardapparaten.
Functies
- Aangepaste WebGL-shaders voor atmosferische verstrooiing
- Dag/nacht-verlichting synchroon met de werkelijke tijd
- Interactieve locatiemarkers op basis van breedte- en lengtegraad
- Responsieve iframe-insluiting met automatische themasynchronisatie
Technologiestack
| Technologie | Versie | Doel | Waarom gekozen |
|---|---|---|---|
| React | ^19.0.1 | UI runtime for both entries | Component model; canvas work stays outside React and is driven through a ref |
| TypeScript | ^7.0.2 | Types for the whole project | Only static gate in CI (lint = tsc --noEmit) with strict mode on |
| Vite | ^8.3.0 | Build tool and dev server | Fast builds with dual entry via rollupOptions.input |
| Tailwind CSS | ^4.3.3 | Utility styling | No config file; @theme tokens in index.css |
| d3-geo | ^3.1.1 | Projection maths only | geoOrthographic/geoPath/graticule without committing to a renderer |
| topojson-client + world-atlas | ^3.1.0 / ^2.0.2 | Country polygons | Free, offline, no API key; India's entry is replaced by official vectors |
| lucide-react | ^0.546.0 | Icons | Tree-shakeable icon set |
| @vercel/analytics | ^2.0.1 | Pageview analytics | Zero-config on Vercel |
| Vitest | ^5.0.4 | Unit tests | Fast, node environment - everything under test is pure logic |
| pnpm | 10.33.2 (pinned) | Package manager | Deterministic lockfile in CI |
| GitHub Actions | ubuntu-latest, Node 22 | CI | Native to the repo host; lint -> test -> build gates |
| Vercel | — | Static hosting and CDN | Static assets + headers + rewrites with no server |
| OSM Nominatim | public API | Geocoding search | Free and keyless |
Architectuur
Two Runtime Entries, One Renderer
- vite.config.ts builds both index.html -> src/main.tsx -> App.tsx and embed.html -> src/embed.tsx
- Both entries render the same EarthGlobe component, so fixes reach both surfaces
- App.tsx (721 lines) adds customizer panels, search, persistence and the embed generator
- embed.tsx (418 lines) is deliberately inert: globe only, no chrome, no storage, command API
Pure Config Layer (the architectural keystone)
- src/config/parseGlobeConfig.ts (405) - location.search -> GlobeConfig, the only URL reader
- src/config/buildEmbedUrl.ts (188) - GlobeConfig -> embed URL / iframe snippet, the only URL writer
- src/config/embedTrust.ts (36) - postMessage trust boundary (source === window.parent)
- src/config/resolveMarker.ts (121) - URL signals -> GlobeMarker with per-surface policy
- No React and no DOM in this layer, which is what makes the round-trip test possible
Renderer & Data
- components/EarthGlobe/EarthGlobe.tsx (~1500) - requestAnimationFrame loop, projection setup, gestures
- d3-geo geoOrthographic/geoPath/geoGraticule for maths only; projection configured once at module scope
- topojson-client feature() decodes world-atlas countries-110m once at import time
- india-official.json (37 features), india-internal-borders.json and india-outer-boundary.json are vendored Survey of India vectors
- Self-contained renderer package with zero knowledge of the main app or the embed
State, Search & Delivery
- hooks/useMapSettings.ts (460) - state, localStorage persistence, storage-event reconciliation
- services/geocoding.ts (370) - Nominatim client, rate budget, offline curated table
- utils/coordinates.ts - hemisphere-correct lat/lng formatting used by all four call sites
- Vercel CDN serves dist/ with cleanUrls, a /embed rewrite and CSP frame-ancestors * on /embed(.*)
- No API gateway, queue, cache server, worker or message broker exists
Projectstructuur
API-endpoints
Bharat Atlas (static SPA on Vercel) (poort 443)
| Methode | Route | Doel |
|---|---|---|
| GET | / | Main app SPA - customizer, search, embed generator (rewritten to /index.html) |
| GET | /embed | Chrome-free iframe target; rewritten to /embed.html and served with CSP frame-ancestors * |
| GET | /embed.html | Same embed entry, served directly; configured by URL query params |
OpenStreetMap Nominatim (external) (poort 443)
| Methode | Route | Doel |
|---|---|---|
| GET | /search | Resolve a place name to lat/lng for the search pin; debounced, abortable, ~1 req/s budget with a 30 s cooldown on 429 |
Belangrijkste ontwerpbeslissingen
One Build, Two Entries, One Renderer
Beslissing: index.html and embed.html are built from one config and both render the same EarthGlobe component
Waarom: The embed must never diverge visually or behaviourally from the app; a second renderer would double the surface area of every fix
Voordelen: One renderer, every fix reaches both surfaces
Afwegingen: The embed downloads saved-settings branches it never runs (~3.5 kB, ~0.3 kB gzipped)
Canvas 2D + d3-geo Instead of WebGL
Beslissing: Hand-written Canvas 2D painter with d3-geo used only for projection maths
Waarom: Zero backend, zero keys, static-only hosting - a hand-painted orthographic globe at 110m resolution does not need a GPU stack
Voordelen: Negligible library weight, no shader or context-loss failure modes, full control of the drawing passes
Afwegingen: Per-frame CPU scales with geometry (177 country + 37 state polygons every frame); no LOD, culling or instancing
Vendored Survey of India GeoJSON Replaces the Atlas Entry
Beslissing: Filter ISO 356 out of world-atlas and draw India from india-official.json (37 features incl. J&K and Ladakh)
Waarom: Natural Earth's India does not match official boundaries, and cartographic accuracy should not require a tile server
Voordelen: Authoritative borders, fully offline, no map service at runtime
Afwegingen: ~+210 KB payload; TopoJSON quantisation was rejected because it changes cartographic fidelity
Byte-Stable Embed URL Generation
Beslissing: Fixed parameter order in buildEmbedUrl, plus a round-trip test that parses generated URLs back through parseGlobeConfig
Waarom: A pasted embed snippet is a public API surface that cannot change
Voordelen: A silent behavioural break becomes a red build; the CI workflow flags the round-trip test as load-bearing
Afwegingen: Genuine awkwardness is preserved to keep the byte format stable (e.g. DEFAULT_APP_ZOOM exists only to make a non-behaviour visible)
Deliberately Lenient postMessage Validation
Beslissing: Coerce-then-clamp instead of strict typing; id optional, numeric strings accepted, longitude wraps, zoom clamps
Waarom: Hosts send string coordinates, out-of-range zoom or a marker without id - a typeof check would reject those and silently stop the globe moving
Voordelen: Existing embeds keep working; only genuinely unusable payloads are rejected, with a commandRejected event
Afwegingen: Hand-written and only partly uniform; 3 of 20 commands still fail silently
postMessage Trust Limited to window.parent
Beslissing: Require event.source === window.parent, with EMBED_ALLOWED_PARENT_ORIGINS shipping empty and outbound events targeting the referrer origin
Waarom: Embedding is a public, unauthenticated capability - the realistic threat is a third party messaging an embed already on screen
Voordelen: Blocks pop-up/opener/sibling-frame attacks at zero cost to embedders
Afwegingen: Any site that iframes the globe has full script control; acceptable because nothing secret is transmitted
Layered localStorage Persistence
Beslissing: Diff writes field-by-field against a last-known ref, merge them over a fresh disk read, skip unchanged writes, and reconcile on the storage event
Waarom: Whole-blob last-write-wins destroyed a sibling tab's unrelated fields and fired spurious events
Voordelen: Correct multi-tab behaviour; a slider drag collapses into a single serialisation
Afwegingen: A ~45-value hook return, three parallel write effects, no schema version for forward migration
Opaque Canvas With a Neutral Backdrop
Beslissing: background=false substitutes pure white/black instead of clearing to transparent; every frame is fully painted
Waarom: Removes clearRect, avoids compositing cost and letterboxing, and makes the theme halo legible
Voordelen: Simpler and cheaper frames, predictable look across hosts
Afwegingen: The globe cannot be composited over a host page background - a real limitation for embeds
Frame-Rate-Independent Physics Constants
Beslissing: Inertia 0.93^(dt*60), auto-rotation speed*dt*10, wheel zoom 1.08 in / 1/1.08 out
Waarom: Flat velocity decay behaves differently at 60 vs 120 Hz and asymmetric zoom steps are not reversible
Voordelen: Identical feel on 60 Hz, 120 Hz and ProMotion displays
Afwegingen: Constants are scattered across three sites rather than shared
Tests
Wat wordt getest
- parseGlobeConfig (67 cases) - theme polarity rules, bound-palette chain, isOff semantics, onlyIndia presence-sensitivity, unclamped speed, zoom master switch, zoomLevel clamping, lat/lng pairing, reduced motion
- buildEmbedUrl (~45 cases) - round trip generate -> parse -> assert survival, exhaustive toggle combinations, byte-stable parameter order, iframe snippet generation
- resolveMarker (25 cases) - app lat/lng precedence, curated id/exact-name/substring matching, saved-selection fallback, embed centroid behaviour
- embedTrust (13 cases) - empty-allowlist default, source !== parent rejection, configured allowlist, referrer-derived origin
- background (40 cases) - colour normalisation with 15 rejection cases, parseShowBackground, resolveBackgroundColor, resolveFrameBackground
- themePresets (31 cases) - exactly 12 presets in a 6/6 split, hex-only colours, polarity partition
- ColorPicker (27 cases) - reference-identity with the shared validator, RGB <-> HSV round trips, clamping, draft/commit pipeline
- GlobeControls (14 cases) - parseCoordinate clamping and parseFloat prefix semantics
- geocoding (13 cases) - query shape, network-error degradation, abort, non-OK, 429 cooldown, dedupe, abort-signal passthrough
- coordinates (13 cases) - hemisphere labelling including zero-as-positive and both-negative pairs
Ontbrekende dekking
- The ~1500-line EarthGlobe render loop, hit-testing and gesture physics have zero tests (no jsdom, no canvas mock)
- No integration or E2E tests - nothing verifies that an embed URL renders or that the ready handshake fires
- No component-render tests for ErrorBoundary, Navbar, CityDetailsCard, LocationSearchBar or CodeExportModal
- No tests for useMapSettings (460 lines of persistence and cross-tab logic)
- No coverage tooling and no FPS instrumentation - the long-frame logger exists because no performance claim was ever measured