JK
Projects
JK
>LOADING // LOADING
INITIALIZING VIEW
Built and Designed byJK
JK
>LOADING // PROJECT DETAILS
PARSING ARCHITECTURE, DIAGRAMS & API ENDPOINTS
Back to Projects
Live DemoView on GitHub

Live Preview

Drag to rotate · scroll or use +/− to zoom
Completedreacttypescriptvitetailwind

Bharat Atlas — 3D India Globe

A 100% client-side React SPA that paints an orthographic 3D globe of India on an HTML canvas using official Survey of India GeoJSON (37 states/UTs, including J&K and Ladakh) as its basemap. One Vite build produces two surfaces — the main configurator app (index.html) and a chrome-free embed (embed.html) driven entirely by query parameters and a 20-command postMessage API. No backend, no database, no API keys: geodata is bundled at build time and hosted as static assets.

Problem & Solution

An interactive country globe normally means a paid mapping API key, a tile server and a backend. This project sidesteps all three: it vendors Survey of India cartography instead of calling a commercial basemap, uses d3-geo purely for projection maths while painting with Canvas 2D instead of WebGL, and ships a single static bundle. The result is a globe that any third-party site can drop in as an iframe and script.

Features

  • Dual-entry single build: index.html (configurator app) and embed.html (chrome-free iframe target)
  • Canvas 2D orthographic renderer with sphere shading, atmosphere halo, starfield, graticule and equator
  • Drag-to-rotate with inertial damping, multi-touch pinch zoom, and non-passive wheel zoom
  • flyTo / focusState camera animations with cubic ease-out and delta-scaled, frame-rate-independent physics
  • 12 theme presets (6 light / 6 dark) with polarity-bound palettes and per-polarity backdrop colours
  • On-demand highlighting of all 37 states/UTs via an inward-glow technique clipped to each polygon
  • Single-pin markers: 3D teardrop badge, dual radar pulse, canvas callout with a live IST clock
  • Geocoding search via OpenStreetMap Nominatim with an 18-entry offline fallback table, ~1 req/s budget and a 30 s cooldown on HTTP 429
  • URL query-parameter API (~25 params) as the primary configuration contract, no auth
  • Unauthenticated 20-command postMessage API for host pages, with ready and commandRejected outbound events
  • postMessage trust limited to window.parent, with an empty-by-default origin allowlist hook
  • Embed-code generator that emits a copy-paste iframe snippet plus a live iframe preview
  • Layered localStorage persistence (3 keys) with per-field diff, merge and cross-tab reconciliation
  • Chrome-free Pure View toggle inside the main app, plus a custom in-app SVG/hex ColorPicker
  • CSP frame-ancestors * on /embed, Vercel static hosting with cleanUrls and rewrites
  • Vitest suite: 10 files / 273 tests, including a load-bearing embed-URL round-trip test
  • GitHub Actions CI running lint -> test -> build with a 7-day dist artifact

Tech Stack

TechnologyVersionPurposeWhy Chosen
React^19.0.1UI runtime for both entriesComponent model; canvas work stays outside React and is driven through a ref
TypeScript^7.0.2Types for the whole projectOnly static gate in CI (lint = tsc --noEmit) with strict mode on
Vite^8.3.0Build tool and dev serverFast builds with dual entry via rollupOptions.input
Tailwind CSS^4.3.3Utility stylingNo config file; @theme tokens in index.css
d3-geo^3.1.1Projection maths onlygeoOrthographic/geoPath/graticule without committing to a renderer
topojson-client + world-atlas^3.1.0 / ^2.0.2Country polygonsFree, offline, no API key; India's entry is replaced by official vectors
lucide-react^0.546.0IconsTree-shakeable icon set
@vercel/analytics^2.0.1Pageview analyticsZero-config on Vercel
Vitest^5.0.4Unit testsFast, node environment - everything under test is pure logic
pnpm10.33.2 (pinned)Package managerDeterministic lockfile in CI
GitHub Actionsubuntu-latest, Node 22CINative to the repo host; lint -> test -> build gates
Vercel—Static hosting and CDNStatic assets + headers + rewrites with no server
OSM Nominatimpublic APIGeocoding searchFree and keyless

Architecture

Vercel CDN

static dist/

CodeExportModal

iframe snippet

Main App SPA

index.html

Config Layer

pure parse/emit

Embed SPA

embed.html

useMapSettings

EarthGlobe Renderer

Canvas 2D

geocoding.ts

localStorage

3 keys

d3-geo Orthographic

Bundled GeoJSON

SoI 37 + 110m

Hit Test

rAF throttled

OSM Nominatim

1 req/s

Host Page

postMessage

embedTrust

parent only
Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.

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

Project Structure

.github
workflows
ci.ymllint -> test -> build -> upload dist artifact
src
components
CityDetailsCard.tsx
Navbar.tsx
LocationSearchBar.tsx
EarthGlobe
CodeExportModal.tsxEmbed generator + live iframe preview
ColorPicker.tsxIn-app SV square + hue strip + hex field picker
ErrorBoundary.tsxFault isolation around the globe
GlobeControls.tsxCustomizer panel (exports parseCoordinate)
config
buildEmbedUrl.tsGlobeConfig -> embed URL / iframe snippet (188, pure)
embedTrust.tspostMessage trust boundary (36, pure)
parseGlobeConfig.tslocation.search -> GlobeConfig (405 lines, pure)
resolveMarker.tsURL signals -> GlobeMarker (121, pure)
data
defaultLocations.tsCurated markers + ALL_INDIAN_STATES (36 entries)
india-internal-borders.jsonBare MultiLineString geometry
india-official.jsonSurvey of India FeatureCollection, 37 features (st_nm only)
india-outer-boundary.jsonBare MultiLineString geometry
hooks
useKeyboardShortcuts.tsApp-level shortcut bindings
useMapSettings.tsState + localStorage persistence + multi-tab sync (460)
services
geocoding.tsNominatim client + rate budget + offline table (370)
utils
coordinates.tsHemisphere-correct lat/lng formatting
App.tsxMain app composition root and orchestration
embed.tsxEntry B: module-scope config parse + 20-command postMessage API
main.tsxEntry A: inject(), createRoot(<App/>)
embed.htmlEmbed entry; noindex,nofollow and deliberately no font links
index.htmlMain app entry (#root) plus sr-only SEO copy for crawlers
vercel.jsoncleanUrls, CSP frame-ancestors * on /embed, 2 rewrites
vite.config.tsDual rollup entry, 3 manual chunks, sourcemaps, HMR toggle
vitest.config.tsStandalone node-environment config for src/**/*.test.ts

API Endpoints

Bharat Atlas (static SPA on Vercel) (port 443)

MethodRoutePurpose
GET/Main app SPA - customizer, search, embed generator (rewritten to /index.html)
GET/embedChrome-free iframe target; rewritten to /embed.html and served with CSP frame-ancestors *
GET/embed.htmlSame embed entry, served directly; configured by URL query params

OpenStreetMap Nominatim (external) (port 443)

MethodRoutePurpose
GET/searchResolve a place name to lat/lng for the search pin; debounced, abortable, ~1 req/s budget with a 30 s cooldown on 429

Key Design Decisions

One Build, Two Entries, One Renderer

Decision: index.html and embed.html are built from one config and both render the same EarthGlobe component

Why: The embed must never diverge visually or behaviourally from the app; a second renderer would double the surface area of every fix

Benefits: One renderer, every fix reaches both surfaces

Trade-offs: The embed downloads saved-settings branches it never runs (~3.5 kB, ~0.3 kB gzipped)

Canvas 2D + d3-geo Instead of WebGL

Decision: Hand-written Canvas 2D painter with d3-geo used only for projection maths

Why: Zero backend, zero keys, static-only hosting - a hand-painted orthographic globe at 110m resolution does not need a GPU stack

Benefits: Negligible library weight, no shader or context-loss failure modes, full control of the drawing passes

Trade-offs: 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

Decision: Filter ISO 356 out of world-atlas and draw India from india-official.json (37 features incl. J&K and Ladakh)

Why: Natural Earth's India does not match official boundaries, and cartographic accuracy should not require a tile server

Benefits: Authoritative borders, fully offline, no map service at runtime

Trade-offs: ~+210 KB payload; TopoJSON quantisation was rejected because it changes cartographic fidelity

Byte-Stable Embed URL Generation

Decision: Fixed parameter order in buildEmbedUrl, plus a round-trip test that parses generated URLs back through parseGlobeConfig

Why: A pasted embed snippet is a public API surface that cannot change

Benefits: A silent behavioural break becomes a red build; the CI workflow flags the round-trip test as load-bearing

Trade-offs: 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

Decision: Coerce-then-clamp instead of strict typing; id optional, numeric strings accepted, longitude wraps, zoom clamps

Why: 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

Benefits: Existing embeds keep working; only genuinely unusable payloads are rejected, with a commandRejected event

Trade-offs: Hand-written and only partly uniform; 3 of 20 commands still fail silently

postMessage Trust Limited to window.parent

Decision: Require event.source === window.parent, with EMBED_ALLOWED_PARENT_ORIGINS shipping empty and outbound events targeting the referrer origin

Why: Embedding is a public, unauthenticated capability - the realistic threat is a third party messaging an embed already on screen

Benefits: Blocks pop-up/opener/sibling-frame attacks at zero cost to embedders

Trade-offs: Any site that iframes the globe has full script control; acceptable because nothing secret is transmitted

Layered localStorage Persistence

Decision: 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

Why: Whole-blob last-write-wins destroyed a sibling tab's unrelated fields and fired spurious events

Benefits: Correct multi-tab behaviour; a slider drag collapses into a single serialisation

Trade-offs: A ~45-value hook return, three parallel write effects, no schema version for forward migration

Opaque Canvas With a Neutral Backdrop

Decision: background=false substitutes pure white/black instead of clearing to transparent; every frame is fully painted

Why: Removes clearRect, avoids compositing cost and letterboxing, and makes the theme halo legible

Benefits: Simpler and cheaper frames, predictable look across hosts

Trade-offs: The globe cannot be composited over a host page background - a real limitation for embeds

Frame-Rate-Independent Physics Constants

Decision: Inertia 0.93^(dt*60), auto-rotation speed*dt*10, wheel zoom 1.08 in / 1/1.08 out

Why: Flat velocity decay behaves differently at 60 vs 120 Hz and asymmetric zoom steps are not reversible

Benefits: Identical feel on 60 Hz, 120 Hz and ProMotion displays

Trade-offs: Constants are scattered across three sites rather than shared

Testing

Vitest•273 unit tests

What's Tested

  • 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

Missing Coverage

  • 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