canvas-globe 0.1.0 โ†’ 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,638 +1,665 @@
1
- # CanvasGlobe
2
-
3
- `canvas-globe` is a zero-dependency JavaScript library for an interactive
4
- **3D globe** and **flat world map** on Canvas 2D. It works with
5
- vanilla JavaScript, React, or a Web Component and requires no WebGL, map API
6
- key, tile service, or runtime network request.
7
-
8
- - **Zero dependencies:** no WebGL, D3, map tiles, or API keys
9
- - **Zero network calls:** country geometry ships inside the package
10
- - **Interactive:** drag, zoom, pinch, hover, and click
11
- - **Marker support:** weighted markers, avatars, pulse rings, and clustering
12
- - **Great-circle arcs:** animated routes clipped at the horizon
13
- - **Choropleths:** colour countries by ISO code, numeric ID, or name
14
- - **CSV input:** resolve cities and countries without a geocoding API
15
- - **Image export:** square, story, LinkedIn, Open Graph, and transparent PNG presets
16
- - **Country media:** clip images, GIFs, or video to a country's outline
17
- - **Viewer location:** estimate a region from the browser time zone without a permission prompt
18
- - **Live pings:** display recent activity without requiring a CanvasGlobe backend
19
- - **Recording:** export a WebM clip in the browser
20
- - **Presets:** ten included visual styles
21
- - **Day and night:** calculate the solar terminator for a given time
22
- - **Four projections:** orthographic, equirectangular, Mercator, and Natural Earth
23
- - **Accessibility:** keyboard controls, a live region, and reduced-motion support
24
- - **Bindings:** vanilla JavaScript, a custom element, and React
25
-
26
- Common uses include audience dashboards, launch pages, status boards, and share graphics.
27
-
28
- ## When to choose CanvasGlobe
29
-
30
- Choose CanvasGlobe when you need a JavaScript or React globe with markers,
31
- great-circle arcs, choropleths, keyboard interaction, and image/video export,
32
- especially when WebGL or external map services are not acceptable. Use a 3D
33
- engine such as globe.gl or Cesium instead when you need terrain, perspective
34
- cameras, custom shaders, or thousands of independent 3D objects. See the
35
- [globe-library comparison](https://canvasglobe.swiftools.com/compare/javascript-globe-libraries).
36
-
37
- ## Licensing
38
-
39
- CanvasGlobe is dual-licensed:
40
-
41
- - **GPL-3.0-only** for projects that can comply with GNU GPLv3; or
42
- - a **paid commercial license** for proprietary products.
43
-
44
- The full package and feature set are the same on both paths. GPL permits
45
- commercial activity; whether a particular distribution can comply is
46
- fact-specific. See [LICENSING.md](LICENSING.md) and the
47
- [commercial plans](https://canvasglobe.swiftools.com/pricing).
48
-
49
- Pass the license key supplied with a commercial order. GPLv3-compatible
50
- projects can request a complimentary key through the licensing page:
51
-
52
- ```js
53
- createGlobe(canvas, { licenseKey: "your_license_key" });
54
- ```
55
-
56
- The default `0000-0000-000-0000` value is for evaluation only and produces a
57
- console warning in browser builds.
58
-
59
- ## Install
60
-
61
- ```bash
62
- npm install canvas-globe
63
- ```
64
-
65
- Or drop it on a page with no build step at all:
66
-
67
- ```html
68
- <script src="https://cdn.jsdelivr.net/npm/canvas-globe/dist/canvas-globe.umd.js"></script>
69
- <canvas id="globe" style="width:520px;aspect-ratio:1"></canvas>
70
- <script>
71
- CanvasGlobe.createGlobe(document.getElementById("globe"), {
72
- markers: [{ lat: 23.03, lon: 72.58, count: 12, emoji: "๐Ÿง‘โ€๐ŸŽจ", live: true }],
73
- });
74
- </script>
75
- ```
76
-
77
- ## Usage
78
-
79
- ```js
80
- import { createGlobe } from "canvas-globe";
81
-
82
- const globe = createGlobe(document.querySelector("#globe"), {
83
- markers: [
84
- { lat: 23.03, lon: 72.58, count: 12, emoji: "๐Ÿง‘โ€๐ŸŽจ", live: true, city: "Ahmedabad" },
85
- { lat: 51.5, lon: -0.12, count: 8, emoji: "๐Ÿ‘ฉโ€๐Ÿ’ป", city: "London" },
86
- ],
87
- theme: "atlas",
88
- tooltip: (m) => `${m.city}: ${m.count} visitors`,
89
- onClick: (marker) => globe.flyTo(marker.lon, marker.lat, { zoom: 2.5 }),
90
- });
91
- ```
92
-
93
- The canvas is sized from CSS: give it a width and an aspect ratio:
94
-
95
- ```css
96
- #globe { width: 100%; aspect-ratio: 1; } /* globe mode */
97
- #map { width: 100%; aspect-ratio: 360 / 139; } /* map mode */
98
- ```
99
-
100
- `mapAspect(latRange, projection)` returns the height/width ratio for any map setup.
101
-
102
- ### Custom element
103
-
104
- ```html
105
- <script type="module">
106
- import "canvas-globe/element";
107
- </script>
108
-
109
- <geo-globe mode="map" theme="midnight" tooltip cluster style="display:block;width:100%"></geo-globe>
110
-
111
- <script>
112
- const el = document.querySelector("geo-globe");
113
- el.markers = [{ lat: 23.03, lon: 72.58, count: 12 }];
114
- el.addEventListener("geo-click", (e) => console.log(e.detail.marker));
115
- </script>
116
- ```
117
-
118
- Every scalar option is available as a dash-cased attribute (`auto-rotate`, `marker-scale`,
119
- `radius-ratio`โ€ฆ). `markers`, `arcs`, `country-colors` and `lat-range` accept JSON. Objects and
120
- callbacks go through the `markers`, `arcs` and `options` properties. Events: `geo-hover`,
121
- `geo-click`, `geo-country-hover`, `geo-country-click`, `geo-render`.
122
-
123
- ### React
124
-
125
- ```jsx
126
- import { useRef } from "react";
127
- import { Globe } from "canvas-globe/react";
128
-
129
- export function Visitors({ markers }) {
130
- const globe = useRef(null);
131
- return (
132
- <Globe
133
- ref={globe}
134
- markers={markers}
135
- theme="midnight"
136
- tooltip
137
- onClick={(m) => globe.current.flyTo(m.lon, m.lat, { zoom: 3 })}
138
- />
139
- );
140
- }
141
- ```
142
-
143
- React is an optional peer dependency: only the `/react` entry point needs it.
144
-
145
- ## Options
146
-
147
- | Option | Default | Description |
148
- | --- | --- | --- |
149
- | `licenseKey` | `"0000-0000-000-0000"` | The key supplied for a GPLv3-compatible project or with a commercial order |
150
- | `mode` | `"globe"` | `"globe"` (orthographic, spinnable) or `"map"` (flat) |
151
- | `projection` | `"equirectangular"` | Flat-map projection: also `"mercator"`, `"naturalEarth"` |
152
- | `preset` | Not set | Named bundle of theme + render style, applied under your options |
153
- | `theme` | `"atlas"` | Theme name or a partial theme object |
154
- | `landStyle` | `"fill"` | `"fill"` \| `"dots"` \| `"outline"` \| `"glow"` |
155
- | `dotSpacing` | `2` | Dot grid spacing in degrees |
156
- | `dotSize` | `1.15` | Dot radius in px |
157
- | `orbits` | `0` | Decorative rings: a count (0-6) or explicit specs |
158
- | `texture` | Not set | Equirectangular image painted onto the sphere |
159
- | `textureQuality` | `"auto"` | Pixel step for the texture pass; higher is faster |
160
- | `focus` | Not set | Frame one country: `"IN"` or `{ country, isolate, dim, outlineWidth }` |
161
- | `countryMedia` | Not set | Media clipped to each country, keyed by ISO, id or name |
162
- | `scene` | Not set | Whole composition: preset plus the layers a job needs |
163
- | `counter` | Not set | `{ value, label, format, position }` rolling headline number |
164
- | `title` | Not set | `{ text, subtitle, position }` headline painted onto the canvas |
165
- | `watermark` | Not set | `{ image, text, position, opacity }` logo baked into every export |
166
- | `annotations` | Not set | `[{ lat, lon, text, dx, dy }]` leader-line callouts |
167
- | `timeline` | Not set | `{ at }`: hides markers whose `date` has not arrived |
168
- | `transparentBackground` | `false` | Skip the ocean fill so exports keep an alpha channel |
169
- | `heatmap` | `false` | Additive density blobs: `{ radius, intensity, color }` |
170
- | `spikes` | `false` | Bars off the surface, sized by `count`: `{ height, width }` |
171
- | `labels` | `false` | `"markers"` \| `"countries"` \| `"both"`, with collision avoidance |
172
- | `legend` | Not set | `{ title, items }` or `{ title, scale, position }` |
173
- | `showViewer` | `false` | Pin the current viewer from their time zone |
174
- | `momentum` | `true` | Coast after a drag instead of stopping dead |
175
- | `countryPalette` | Not set | Fills used by `countryColors: "auto"` |
176
- | `markers` | `[]` | See [Markers](#markers) |
177
- | `arcs` | `[]` | See [Arcs](#arcs) |
178
- | `center` | `{ lon: 10, lat: 20 }` | Initial view centre |
179
- | `zoom` / `minZoom` / `maxZoom` | `1` / `1` / `8` | Zoom level and bounds |
180
- | `zoomable` | `true` | Wheel and pinch zoom |
181
- | `autoRotate` | `true` | Spin when idle |
182
- | `rotateSpeed` | `0.09` | Degrees per frame |
183
- | `interactive` | `true` | Drag, zoom, hover and click |
184
- | `keyboard` | `true` | Arrow keys, `+`/`-`, `0`, `PageUp`/`PageDown` |
185
- | `graticule` | `true` | Latitude/longitude grid |
186
- | `stars` | `true` | Starfield around the globe |
187
- | `shade` | `true` | Lit-from-upper-left shading |
188
- | `terminator` | `false` | Shade the night side using the real solar position |
189
- | `time` | `null` | Clock for the terminator; `null` tracks now |
190
- | `markerStyle` | `"auto"` | `"auto"` \| `"bubble"` \| `"dot"` |
191
- | `markerScale` | `1` | Scales every marker |
192
- | `renderMarker` | Not set | `(ctx, marker, info) => radius`: draw markers yourself |
193
- | `cluster` | `false` | Merge nearby markers into count bubbles |
194
- | `clusterRadius` | `42` | Cluster grid size in px |
195
- | `countryColors` | Not set | `{ IN: "#f00" }` keyed by ISO code, id or name, or `"auto"` |
196
- | `countryColor` | Not set | `(shape) => color`: wins over `countryColors` |
197
- | `countryKey` | Not set | `(shape) => key` used against `countryColors` |
198
- | `arcLift` | `0.28` | Default arc height, as a fraction of the radius |
199
- | `arcSpeed` | `1` | Multiplies every arc's travel speed |
200
- | `radiusRatio` | `0.4` | Globe radius vs the smaller canvas side |
201
- | `latRange` | `[83, -56]` | Map mode north/south bounds |
202
- | `world` | bundled | Your own GeoJSON |
203
- | `fps` | `30` | Frame cap |
204
- | `tooltip` | `false` | `true`, or `(target, kind) => string` |
205
- | `respectReducedMotion` | `true` | Honour `prefers-reduced-motion` |
206
- | `ariaLabel` | `"Interactive world map"` | Accessible name for the canvas |
207
- | `onHover` | Not set | `(marker \| null, { x, y } \| null) => void` |
208
- | `onClick` | Not set | `(marker, { x, y }) => void` |
209
- | `onCountryHover` | Not set | `(country \| null, { x, y } \| null) => void` |
210
- | `onCountryClick` | Not set | `(country, { x, y }) => void` |
211
- | `onRender` | Not set | Called after every frame |
212
-
213
- ## Markers
214
-
215
- ```ts
216
- {
217
- lat: number; // required
218
- lon: number; // required
219
- count?: number; // relative weight: bigger count, bigger marker
220
- emoji?: string; // drawn inside a bubble marker
221
- live?: boolean; // pulsing ring
222
- color?: string; // overrides the theme colour
223
- size?: number; // base radius, default 3.4
224
- ...anything // passed straight back to onHover / onClick
225
- }
226
- ```
227
-
228
- With `cluster: true`, dense areas collapse into a single bubble and your callbacks receive
229
- `{ cluster: true, count, markers, lat, lon }` instead. Clustering happens in screen space, so it
230
- re-balances automatically as you zoom.
231
-
232
- ### How accurate is marker placement?
233
-
234
- The projection maths is exact: a marker's pixel position matches the closed-form projection to
235
- floating-point precision, and the bubble is centred on the coordinate. Two things are worth knowing:
236
-
237
- - **The coastlines are approximate, not the markers.** The bundled geometry is Natural Earth 1:110m,
238
- decimated to a ~0.14ยฐ tolerance and rounded to two decimals, so the drawn shoreline can sit a few
239
- kilometres from the real one. A coastal marker may look slightly offshore even though it is exactly
240
- where you put it. Natural Earth 1:110m also omits microstates such as Singapore, Malta and Monaco: a marker there lands on open water or a neighbour. Pass higher-detail GeoJSON via `world` if that
241
- matters.
242
- - **`latRange` defaults to `[83, -56]`,** which trims the polar caps. Markers south of โˆ’56ยฐ or north
243
- of 83ยฐ project outside the drawn map. Use `latRange: [90, -90]` for a full-height map.
244
-
245
- The antimeridian is handled: the map pans freely across ยฑ180ยฐ once zoomed, and coordinates are
246
- wrapped around the view centre, so a marker at 179ยฐE and one at 179ยฐW render side by side.
247
-
248
- ## Arcs
249
-
250
- ```js
251
- globe.setArcs([
252
- { from: { lat: 23.03, lon: 72.58 }, to: [-0.12, 51.5] },
253
- { from: [72.58, 23.03], to: [139.69, 35.68], color: "#f97316", duration: 3200 },
254
- ]);
255
- ```
256
-
257
- Arcs follow the great circle, bow above the surface by `lift`, animate a travelling head, and are
258
- clipped at the horizon as the globe turns. Set `animate: false` for a static line.
259
-
260
- ## Choropleth
261
-
262
- ```js
263
- import { createGlobe, colorScale } from "canvas-globe";
264
-
265
- const visits = { IN: 940, US: 720, GB: 480, JP: 300 };
266
- const scale = colorScale([0, 1000], ["#e0f2fe", "#0369a1"]);
267
-
268
- createGlobe(canvas, {
269
- mode: "map",
270
- countryColors: Object.fromEntries(Object.entries(visits).map(([k, v]) => [k, scale(v)])),
271
- onCountryClick: (shape) => console.log(shape.iso, shape.name),
272
- });
273
- ```
274
-
275
- Keys are matched case-insensitively against the ISO alpha-2 code, then the numeric id, then the
276
- country name. Pass `countryKey` if your data uses something else.
277
-
278
- ## Methods
279
-
280
- ```js
281
- globe.setMarkers([...]); // swap the marker set
282
- globe.setArcs([...]); // swap the arcs
283
- globe.setMode("map"); // switch projection family
284
- globe.setProjection("naturalEarth"); // switch flat projection
285
- globe.setPreset("hologram"); // swap the whole look
286
- globe.setLandStyle("dots"); // fill | dots | outline | glow
287
- globe.setTheme("midnight"); // switch palette
288
- globe.setTime(Date.UTC(2024, 5, 21)); // terminator clock; null tracks now
289
- globe.setOptions({ autoRotate: false });
290
- globe.setZoom(3);
291
- globe.zoomBy(1.4);
292
- globe.flyTo(139.69, 35.68); // ease to Tokyo
293
- globe.flyTo(139.69, 35.68, { instant: true, zoom: 4 });
294
- globe.fitTo([68, 6, 98, 36]); // frame [west, south, east, north]
295
- globe.fitToMarkers(); // frame every marker
296
- globe.focusOn("India", { isolate: true });
297
- globe.setCountryMedia("India", "/reel.mp4");
298
- globe.countryAspect("India"); // โ†’ height / width ratio for the canvas
299
- globe.clearFocus();
300
- globe.setScene("logos"); // whole composition
301
- globe.exportImage({ preset: "story", transparent: true });
302
- await globe.exportBlob({ preset: "og" });
303
- globe.setTimelineAt("2024-06-01");
304
- globe.playTimeline({ duration: 6000 }); // โ†’ { stop() }
305
- globe.ping({ lat, lon, label }); // one-shot expanding ring
306
- globe.pingFeed(events, { interval }); // โ†’ { stop() }
307
- globe.tour(points, { dwell }); // โ†’ { stop() }
308
- globe.story(el, steps); // scroll-linked view
309
- globe.record({ duration, filename }); // โ†’ { promise, stop() }
310
- globe.locateViewer(); // โ†’ { lat, lon, timeZone, country, source, accuracy }
311
- globe.setTexture(imageOrUrl);
312
- globe.getCenter(); // โ†’ { lon, lat }
313
- globe.project(lon, lat); // โ†’ { x, y } or null if behind the globe
314
- globe.unproject(x, y); // โ†’ [lon, lat] or null
315
- globe.countryAt(x, y); // โ†’ country shape or null
316
- globe.snapshot(); // โ†’ PNG data URL, great for share images
317
- await globe.toBlob(); // โ†’ Blob
318
- globe.invalidate(); // request one more frame
319
- globe.resize(); // usually automatic via ResizeObserver
320
- globe.destroy(); // stop the loop and remove listeners
321
- ```
322
-
323
- Helpers are exported too: `mapAspect`, `colorScale`, `greatCircle`, `angularDistance`,
324
- `subsolarPoint`, `pointInGeometry`, `geometryBounds`, `projections`, `themes`, `presets`, `scenes`,
325
- `exportPresets`, `fromCSV`, `parseCSV`, `geocode`, `countryPoint`, `placeLocation`.
326
-
327
- ## Accessibility
328
-
329
- The canvas gets `role="img"`, an `aria-label`, and, when `keyboard` is on, a tab stop plus a
330
- polite live region that announces the view as it changes.
331
-
332
- | Key | Action |
333
- | --- | --- |
334
- | `โ†` `โ†’` `โ†‘` `โ†“` | Rotate or pan (hold <kbd>Shift</kbd> for bigger steps) |
335
- | `+` / `-` | Zoom in / out |
336
- | `0` | Reset to the initial view |
337
- | `PageDown` / `PageUp` | Cycle through markers, flying to each |
338
- | `Enter` / `Space` | Activate the focused marker |
339
-
340
- When the user prefers reduced motion, auto-rotation stops, `flyTo` jumps instead of easing, and
341
- pulse rings and arc animations hold still. Set `respectReducedMotion: false` to opt out.
342
-
343
- ## Data in, assets out
344
-
345
- Marketing data arrives as a spreadsheet, so `fromCSV` resolves rows itself: explicit `lat`/`lon`
346
- columns first, then a city name, then a country code or name:
347
-
348
- ```js
349
- import { fromCSV } from "canvas-globe";
350
-
351
- const markers = fromCSV(`city,count,image
352
- London,8,/logos/acme.png
353
- Tokyo,4,/logos/globex.png`);
354
-
355
- markers.skipped; // rows that could not be placed, so you can report them
356
- globe.setMarkers(markers).fitToMarkers();
357
- ```
358
-
359
- City lookup covers roughly 300 major cities that already ship with the package. Pass
360
- `{ gazetteer: { Ahmedabad: [72.58, 23.03] } }` for anything else: no geocoding service, no key.
361
-
362
- Render at whatever size the destination wants, without touching the live canvas:
363
-
364
- ```js
365
- globe.exportImage({ preset: "story" }); // 1080ร—1920 data URL
366
- globe.exportImage({ preset: "linkedin", transparent: true });
367
- await globe.exportBlob({ width: 2400, height: 1260 });
368
- ```
369
-
370
- Presets: `square`, `story`, `portrait`, `wide`, `linkedin`, `og`, `twitter`, `thumbnail`.
371
-
372
- ## Scenes
373
-
374
- Presets decide how it looks; **scenes** decide what you are making. Each one bundles a preset with
375
- the layers and overlays that job needs.
376
-
377
- ```js
378
- createGlobe(canvas, { scene: "signups" });
379
- globe.setScene("coverage");
380
- ```
381
-
382
- | Scene | For |
383
- | --- | --- |
384
- | `signups` | Live activity on a pricing or landing page |
385
- | `launch` | A regional announcement, ready for country media |
386
- | `logos` | "Trusted in N countries" with customer logos |
387
- | `team` | Where the team is, on a careers page |
388
- | `coverage` | Campaign or revenue by country, with a legend |
389
- | `review` | Scroll-linked year in review |
390
- | `routes` | Traffic between regions |
391
-
392
- Switching scenes resets every key the new scene does not set, so nothing leaks between them.
393
-
394
- ## Overlays
395
-
396
- ```js
397
- createGlobe(canvas, {
398
- counter: { value: 21947, label: "customers worldwide" }, // rolls when it changes
399
- title: { text: "Trusted in 68 countries", subtitle: "Join 21,947 teams" },
400
- watermark: { image: "/logo.svg", text: "acme.com" }, // baked into every export
401
- annotations: [{ lat: 23.03, lon: 72.58, text: "HQ: Ahmedabad" }],
402
- timeline: { at: "2024-06-01" }, // hides later markers
403
- });
404
-
405
- globe.playTimeline({ duration: 6000, loop: true }); // "our growth, animated"
406
- globe.ping({ lat, lon, label: "10,000 users ๐ŸŽ‰", burst: 18 });
407
- ```
408
-
409
- Markers take `image` for a circular logo or avatar crop, and arcs take `icon` for a travelling
410
- glyph. Country media accepts `{ text }` to cut type out of a country's outline.
411
-
412
- ## Country canvas
413
-
414
- Frame one country and paint media inside its outline: a still, an animated GIF, a video, another
415
- canvas, or a live `MediaStream`:
416
-
417
- ```js
418
- globe.focusOn("India", { isolate: true });
419
- globe.setCountryMedia("India", "/launch-reel.mp4");
420
-
421
- // or declaratively
422
- createGlobe(canvas, {
423
- mode: "map",
424
- focus: { country: "IN", isolate: true, outlineWidth: 2 },
425
- countryMedia: {
426
- IN: { src: "/reel.mp4", fit: "cover" },
427
- BR: "/photo.jpg",
428
- },
429
- });
430
- ```
431
-
432
- The media is clipped to the bundled country outline, including islands. Sources resolve
433
- automatically: `.mp4`/`.webm` become looping muted video, `.gif` keeps animating, and anything
434
- `drawImage` accepts can be passed directly. `fit` mirrors CSS `object-fit`, and `opacity`, `blend`,
435
- `scale` and `offset` are available per country.
436
-
437
- `focusOn` zooms past `maxZoom` when it has to, since framing a country is an explicit request.
438
- `countryAspect("India")` returns the height/width ratio to size the canvas with, so the shape is not
439
- letterboxed:
440
-
441
- ```css
442
- #map { width: 100%; aspect-ratio: var(--country-aspect, 1); }
443
- ```
444
-
445
- Without `isolate`, neighbours stay visible at `dim` opacity, which reads well for "our market" maps.
446
-
447
- ## Where is the viewer?
448
-
449
- ```js
450
- createGlobe(canvas, { showViewer: true });
451
- ```
452
-
453
- That pins the person looking at the page: **no permission prompt, no network call, no API key,
454
- instantly**. It reads `Intl.DateTimeFormat().resolvedOptions().timeZone`, which every browser
455
- exposes, and maps it to the coordinate the IANA database publishes for that zone. Legacy aliases
456
- resolve too (Chrome often reports `Asia/Calcutta`, not `Asia/Kolkata`).
457
-
458
- ```js
459
- const found = globe.locateViewer();
460
- // { lat: 23.29, lon: 82.52, timeZone: "Asia/Kolkata", country: "IN",
461
- // source: "timezone", accuracy: "region", accuracyMeters: 2242000 }
462
- ```
463
-
464
- **It shows a region, not a pinpoint, and represents that uncertainty.** A time zone only narrows you to
465
- its area, and the tz database publishes one representative city per zone. `Asia/Kolkata` covers all
466
- of India, so a naive pin would sit confidently on Kolkata even for someone in Ahmedabad, 1,600 km
467
- away. Two things prevent that:
468
-
469
- - **Anchoring.** For countries wider than 8ยฐ the pin goes on the country centroid instead of the
470
- zone's city, which roughly halves the average error. Smaller countries keep their real city.
471
- Override with `anchor: "country" | "timezone"`.
472
- - **An uncertainty circle.** The pin is surrounded by a dashed circle sized to the actual radius, so
473
- the graphic says "somewhere in here" rather than "exactly here". Turn it off with
474
- `accuracyCircle: false`.
475
-
476
- When you need a real position, ask for one:
477
-
478
- ```js
479
- const found = await globe.locateViewer({ precise: true });
480
- globe.setViewerLocation(found);
481
- // source: "geolocation", accuracyMeters: 24; the circle shrinks to match
482
- ```
483
-
484
- That requests the high-accuracy provider and reports the device's own `accuracyMeters`, so you can
485
- tell a 20 m GPS fix from a 40 km Wi-Fi one. On a desktop with no GPS the browser falls back to
486
- network positioning, which often lands on your ISP's city: the radius will say so. If the viewer
487
- declines, it resolves to the time-zone estimate and never rejects.
488
-
489
- | | Prompt | Network | Always works | Typical radius |
490
- | --- | --- | --- | --- | --- |
491
- | Time zone (default) | No | No | Yes | Country-sized |
492
- | Locale fallback | No | No | Yes | Country-sized |
493
- | `precise: true`, GPS | Yes | No | Only if allowed | 5-50 m |
494
- | `precise: true`, Wi-Fi/IP | Yes | Yes (by the browser) | Only if allowed | 1-50 km |
495
-
496
- Options: `showViewer: { emoji, label, color, live, anchor, accuracyCircle, accuracyColor, flyTo,
497
- ping, precise, onLocate }`. The pin lives outside `markers`, so `setMarkers()` never wipes it.
498
-
499
- ## Live pings
500
-
501
- A one-shot expanding ring: the social-proof moment, with no backend:
502
-
503
- ```js
504
- globe.ping({ lat: 52.52, lon: 13.4, emoji: "โœจ", label: "Someone in Berlin just signed up" });
505
-
506
- const feed = globe.pingFeed(events, { interval: 1800 });
507
- feed.stop();
508
- ```
509
-
510
- ## Tour, story and recording
511
-
512
- ```js
513
- globe.tour(cities, { dwell: 2600, zoom: 2.2 }); // cinematic auto-fly, returns { stop() }
514
-
515
- globe.story(section, [ // scroll-linked rotation
516
- { at: 0, center: [0, 20], zoom: 1 },
517
- { at: 0.5, center: [72, 23], zoom: 3, markers: indiaMarkers, preset: "hologram" },
518
- ]);
519
-
520
- await globe.record({ duration: 6000, filename: "globe.webm" }).promise;
521
- ```
522
-
523
- `record()` uses `MediaRecorder` on the canvas stream: the clip is encoded in the tab and never
524
- leaves the device. Check the exported `canRecord()` helper first.
525
-
526
- ## Looks
527
- A **preset** bundles a theme with a render style. Your own options always win over it.
528
-
529
- ```js
530
- createGlobe(canvas, { preset: "hologram" });
531
- globe.setPreset("neon");
532
- ```
533
-
534
- | Preset | Look |
535
- | --- | --- |
536
- | `atlas` | Bright cartographic globe (the default) |
537
- | `midnight` | Dark space theme |
538
- | `mono` | Neutral greyscale |
539
- | `political` | Printed atlas: a distinct colour per country |
540
- | `hologram` | Cyan dot-matrix earth on deep navy |
541
- | `neon` | Glowing magenta continents with a cyan rim |
542
- | `blueprint` | Technical line art with orbit rings |
543
- | `aurora` | Green dot matrix with violet rings |
544
- | `noir` | High-contrast black and white |
545
- | `constellation` | Sparse dots, stars and three orbits |
546
-
547
- The pieces compose independently, so any theme mixes with any style:
548
-
549
- | Option | Values |
550
- | --- | --- |
551
- | `landStyle` | `"fill"` ยท `"dots"` (halftone) ยท `"outline"` (line art) ยท `"glow"` (neon) ยท `"none"` |
552
- | `dotSpacing` / `dotSize` | Grid spacing in degrees and dot radius in px |
553
- | `orbits` | A count (0-6), or `{ inclination, phase, radius, speed, color, width }` rings |
554
- | `countryColors: "auto"` | A distinct fill per country; `countryPalette` supplies your own |
555
-
556
- ```js
557
- createGlobe(canvas, { theme: "midnight", landStyle: "dots", dotSpacing: 2.4, orbits: 3 });
558
- ```
559
-
560
- Dots come from rasterising the land once into an off-screen bitmap and sampling a grid, so the whole
561
- matrix draws in a single fill and re-spacing is cheap. Orbit rings are real great circles, so they
562
- pass behind the globe as it turns. `countryColors: "auto"` runs a greedy graph colouring over country
563
- adjacency, so neighbours never share a fill.
564
-
565
- ## Themes
566
-
567
- `atlas`, `midnight`, `mono`, `hologram`, `neon`, `blueprint`, `aurora`, `noir` and `political`.
568
- Use `theme: "auto"` to follow the OS colour scheme, or `theme: "css"` to read `--geo-*` custom
569
- properties off the canvas so the globe inherits your design tokens:
570
-
571
- ```css
572
- #globe { --geo-land: #334155; --geo-ocean-from: #0f172a; --geo-ocean-to: #020617; }
573
- ```
574
-
575
- Override any subset directly too:
576
-
577
- ```js
578
- createGlobe(canvas, {
579
- theme: { ocean: ["#0f172a", "#020617"], land: "#334155", marker: "#f97316", arc: "#22d3ee" },
580
- });
581
- ```
582
-
583
- ## Custom geometry
584
-
585
- The bundled data is Natural Earth 1:110m, simplified for smooth animation. Swap in anything GeoJSON:
586
-
587
- ```js
588
- const world = await fetch("/my-countries.geojson").then((r) => r.json());
589
- createGlobe(canvas, { world });
590
- ```
591
-
592
- ## Performance
593
-
594
- Rendering is capped at 30 fps, and frames are skipped entirely when nothing is moving: a static
595
- chart costs nothing after the first paint. Geometry behind the horizon is clipped away rather than
596
- drawn, so a typical globe frame skips 20-60% of the world. On a laptop a 560 px canvas costs roughly
597
- 4 ms per frame, or 25 ms with 5,000 clustered markers.
598
-
599
- ## Data & licences
600
-
601
- - Country geometry: [Natural Earth](https://www.naturalearthdata.com/) 1:110m via `world-atlas`, **public domain**
602
- - ISO codes: [natural-earth-vector](https://github.com/nvkelso/natural-earth-vector), **public domain**
603
- - Supplemental geometry: [Datameet maps](https://github.com/datameet/maps), **CC-0**
604
- - This package: **GPL-3.0-only or a commercial license**
605
-
606
- See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for source links and
607
- provenance.
608
-
609
- Regenerate the bundled data any time with `npm run data`.
610
-
611
- ## Development
612
-
613
- ```bash
614
- npm test # node --test, no test framework to install
615
- npm run build # dist/canvas-globe.umd.js, with a gzipped size budget
616
- npm run example # demo at http://localhost:8099
617
- npm run release:check # tests, types, build and packed-artifact validation
618
- ```
619
-
620
- The product website and documentation are maintained separately at
621
- [canvasglobe.swiftools.com](https://canvasglobe.swiftools.com/).
622
-
623
- ## Support
624
-
625
- For installation help, licensing questions, commercial inquiries, or general
626
- support, email [globe@swiftools.com](mailto:globe@swiftools.com).
627
-
628
- ## Author
629
-
630
- Harsh Jhunjhunuwala
631
-
632
- Copyright (C) 2026 Harsh Jhunjhunuwala. CanvasGlobe is published under the
633
- Swiftools brand.
634
-
635
- ## Browser support
636
-
637
- Any browser with `<canvas>` and `ResizeObserver`: Chrome, Edge, Firefox, Safari 13.1+. No polyfills
638
- required. The modules load safely during SSR; nothing touches the DOM until you construct an instance.
1
+ # CanvasGlobe
2
+
3
+ [![npm version](https://img.shields.io/npm/v/canvas-globe.svg)](https://www.npmjs.com/package/canvas-globe)
4
+ [![npm downloads](https://img.shields.io/npm/dm/canvas-globe.svg)](https://www.npmjs.com/package/canvas-globe)
5
+ [![CI](https://github.com/Shree-hari/canvas-globe/actions/workflows/ci.yml/badge.svg)](https://github.com/Shree-hari/canvas-globe/actions/workflows/ci.yml)
6
+ [![GPL-3.0-only](https://img.shields.io/npm/l/canvas-globe.svg)](LICENSE)
7
+ [![TypeScript declarations](https://img.shields.io/badge/types-included-3178c6.svg)](types/index.d.ts)
8
+ [![zero runtime dependencies](https://img.shields.io/badge/runtime_dependencies-0-2ea44f.svg)](package.json)
9
+
10
+ [![CanvasGlobe interactive JavaScript globe with markers and animated routes](https://raw.githubusercontent.com/Shree-hari/canvas-globe/main/assets/readme/canvas-globe-hero.webp)](https://canvasglobe.swiftools.com/playground)
11
+
12
+ [**Open the live playground**](https://canvasglobe.swiftools.com/playground) ยท
13
+ [Examples](https://canvasglobe.swiftools.com/examples) ยท
14
+ [Documentation](https://canvasglobe.swiftools.com/intro) ยท
15
+ [React guide](https://canvasglobe.swiftools.com/react-globe) ยท
16
+ [Showcase](https://canvasglobe.swiftools.com/showcase) ยท
17
+ [Commercial pricing](https://canvasglobe.swiftools.com/pricing)
18
+
19
+ `canvas-globe` is a zero-dependency JavaScript library for an interactive
20
+ **3D globe** and **flat world map** on Canvas 2D. It works with
21
+ vanilla JavaScript, React, or a Web Component and requires no WebGL, map API
22
+ key, tile service, or runtime network request.
23
+
24
+ - **Zero dependencies:** no WebGL, D3, map tiles, or API keys
25
+ - **Zero network calls:** country geometry ships inside the package
26
+ - **Interactive:** drag, zoom, pinch, hover, and click
27
+ - **Marker support:** weighted markers, avatars, pulse rings, and clustering
28
+ - **Great-circle arcs:** animated routes clipped at the horizon
29
+ - **Choropleths:** colour countries by ISO code, numeric ID, or name
30
+ - **CSV input:** resolve cities and countries without a geocoding API
31
+ - **Image export:** square, story, LinkedIn, Open Graph, and transparent PNG presets
32
+ - **Country media:** clip images, GIFs, or video to a country's outline
33
+ - **Viewer location:** estimate a region from the browser time zone without a permission prompt
34
+ - **Live pings:** display recent activity without requiring a CanvasGlobe backend
35
+ - **Recording:** export a WebM clip in the browser
36
+ - **Presets:** ten included visual styles
37
+ - **Day and night:** calculate the solar terminator for a given time
38
+ - **Four projections:** orthographic, equirectangular, Mercator, and Natural Earth
39
+ - **Accessibility:** keyboard controls, a live region, and reduced-motion support
40
+ - **Bindings:** vanilla JavaScript, a custom element, and React
41
+
42
+ Common uses include audience dashboards, launch pages, status boards, and share graphics.
43
+
44
+ [![CanvasGlobe examples showing route maps, choropleths, visual presets, and flat map projections](https://raw.githubusercontent.com/Shree-hari/canvas-globe/main/assets/readme/canvas-globe-showcase.webp)](https://canvasglobe.swiftools.com/examples)
45
+
46
+ The images above are generated from real CanvasGlobe renders. Open the
47
+ [interactive playground](https://canvasglobe.swiftools.com/playground) to drag,
48
+ zoom, change projections, switch presets, toggle data layers, and export the result.
49
+
50
+ ## When to choose CanvasGlobe
51
+
52
+ Choose CanvasGlobe when you need a JavaScript or React globe with markers,
53
+ great-circle arcs, choropleths, keyboard interaction, and image/video export,
54
+ especially when WebGL or external map services are not acceptable. Use a 3D
55
+ engine such as globe.gl or Cesium instead when you need terrain, perspective
56
+ cameras, custom shaders, or thousands of independent 3D objects. See the
57
+ [globe-library comparison](https://canvasglobe.swiftools.com/compare/javascript-globe-libraries).
58
+
59
+ ## Licensing
60
+
61
+ CanvasGlobe is dual-licensed:
62
+
63
+ - **GPL-3.0-only** for projects that can comply with GNU GPLv3; or
64
+ - a **paid commercial license** for proprietary products.
65
+
66
+ The full package and feature set are the same on both paths. GPL permits
67
+ commercial activity; whether a particular distribution can comply is
68
+ fact-specific. See [LICENSING.md](LICENSING.md) and the
69
+ [commercial plans](https://canvasglobe.swiftools.com/pricing).
70
+
71
+ Pass the license key supplied with a commercial order. GPLv3-compatible
72
+ projects can request a complimentary key through the licensing page:
73
+
74
+ ```js
75
+ createGlobe(canvas, { licenseKey: "your_license_key" });
76
+ ```
77
+
78
+ The default `0000-0000-000-0000` value is for evaluation only and produces a
79
+ console warning in browser builds.
80
+
81
+ ## Install
82
+
83
+ ```bash
84
+ npm install canvas-globe
85
+ ```
86
+
87
+ Or drop it on a page with no build step at all:
88
+
89
+ ```html
90
+ <script src="https://cdn.jsdelivr.net/npm/canvas-globe/dist/canvas-globe.umd.js"></script>
91
+ <canvas id="globe" style="width:520px;aspect-ratio:1"></canvas>
92
+ <script>
93
+ CanvasGlobe.createGlobe(document.getElementById("globe"), {
94
+ markers: [{ lat: 23.03, lon: 72.58, count: 12, emoji: "๐Ÿง‘โ€๐ŸŽจ", live: true }],
95
+ });
96
+ </script>
97
+ ```
98
+
99
+ ## Usage
100
+
101
+ ```js
102
+ import { createGlobe } from "canvas-globe";
103
+
104
+ const globe = createGlobe(document.querySelector("#globe"), {
105
+ markers: [
106
+ { lat: 23.03, lon: 72.58, count: 12, emoji: "๐Ÿง‘โ€๐ŸŽจ", live: true, city: "Ahmedabad" },
107
+ { lat: 51.5, lon: -0.12, count: 8, emoji: "๐Ÿ‘ฉโ€๐Ÿ’ป", city: "London" },
108
+ ],
109
+ theme: "atlas",
110
+ tooltip: (m) => `${m.city}: ${m.count} visitors`,
111
+ onClick: (marker) => globe.flyTo(marker.lon, marker.lat, { zoom: 2.5 }),
112
+ });
113
+ ```
114
+
115
+ The canvas is sized from CSS: give it a width and an aspect ratio:
116
+
117
+ ```css
118
+ #globe { width: 100%; aspect-ratio: 1; } /* globe mode */
119
+ #map { width: 100%; aspect-ratio: 360 / 139; } /* map mode */
120
+ ```
121
+
122
+ `mapAspect(latRange, projection)` returns the height/width ratio for any map setup.
123
+
124
+ ### Custom element
125
+
126
+ ```html
127
+ <script type="module">
128
+ import "canvas-globe/element";
129
+ </script>
130
+
131
+ <geo-globe mode="map" theme="midnight" tooltip cluster style="display:block;width:100%"></geo-globe>
132
+
133
+ <script>
134
+ const el = document.querySelector("geo-globe");
135
+ el.markers = [{ lat: 23.03, lon: 72.58, count: 12 }];
136
+ el.addEventListener("geo-click", (e) => console.log(e.detail.marker));
137
+ </script>
138
+ ```
139
+
140
+ Every scalar option is available as a dash-cased attribute (`auto-rotate`, `marker-scale`,
141
+ `radius-ratio`โ€ฆ). `markers`, `arcs`, `country-colors` and `lat-range` accept JSON. Objects and
142
+ callbacks go through the `markers`, `arcs` and `options` properties. Events: `geo-hover`,
143
+ `geo-click`, `geo-country-hover`, `geo-country-click`, `geo-render`.
144
+
145
+ ### React
146
+
147
+ ```jsx
148
+ import { useRef } from "react";
149
+ import { Globe } from "canvas-globe/react";
150
+
151
+ export function Visitors({ markers }) {
152
+ const globe = useRef(null);
153
+ return (
154
+ <Globe
155
+ ref={globe}
156
+ markers={markers}
157
+ theme="midnight"
158
+ tooltip
159
+ onClick={(m) => globe.current.flyTo(m.lon, m.lat, { zoom: 3 })}
160
+ />
161
+ );
162
+ }
163
+ ```
164
+
165
+ React is an optional peer dependency: only the `/react` entry point needs it.
166
+
167
+ ## Options
168
+
169
+ | Option | Default | Description |
170
+ | --- | --- | --- |
171
+ | `licenseKey` | `"0000-0000-000-0000"` | The key supplied for a GPLv3-compatible project or with a commercial order |
172
+ | `mode` | `"globe"` | `"globe"` (orthographic, spinnable) or `"map"` (flat) |
173
+ | `projection` | `"equirectangular"` | Flat-map projection: also `"mercator"`, `"naturalEarth"` |
174
+ | `preset` | Not set | Named bundle of theme + render style, applied under your options |
175
+ | `theme` | `"atlas"` | Theme name or a partial theme object |
176
+ | `landStyle` | `"fill"` | `"fill"` \| `"dots"` \| `"outline"` \| `"glow"` |
177
+ | `dotSpacing` | `2` | Dot grid spacing in degrees |
178
+ | `dotSize` | `1.15` | Dot radius in px |
179
+ | `orbits` | `0` | Decorative rings: a count (0-6) or explicit specs |
180
+ | `texture` | Not set | Equirectangular image painted onto the sphere |
181
+ | `textureQuality` | `"auto"` | Pixel step for the texture pass; higher is faster |
182
+ | `focus` | Not set | Frame one country: `"IN"` or `{ country, isolate, dim, outlineWidth }` |
183
+ | `countryMedia` | Not set | Media clipped to each country, keyed by ISO, id or name |
184
+ | `scene` | Not set | Whole composition: preset plus the layers a job needs |
185
+ | `counter` | Not set | `{ value, label, format, position }` rolling headline number |
186
+ | `title` | Not set | `{ text, subtitle, position }` headline painted onto the canvas |
187
+ | `watermark` | Not set | `{ image, text, position, opacity }` logo baked into every export |
188
+ | `annotations` | Not set | `[{ lat, lon, text, dx, dy }]` leader-line callouts |
189
+ | `timeline` | Not set | `{ at }`: hides markers whose `date` has not arrived |
190
+ | `transparentBackground` | `false` | Skip the ocean fill so exports keep an alpha channel |
191
+ | `heatmap` | `false` | Additive density blobs: `{ radius, intensity, color }` |
192
+ | `spikes` | `false` | Bars off the surface, sized by `count`: `{ height, width }` |
193
+ | `labels` | `false` | `"markers"` \| `"countries"` \| `"both"`, with collision avoidance |
194
+ | `legend` | Not set | `{ title, items }` or `{ title, scale, position }` |
195
+ | `showViewer` | `false` | Pin the current viewer from their time zone |
196
+ | `momentum` | `true` | Coast after a drag instead of stopping dead |
197
+ | `countryPalette` | Not set | Fills used by `countryColors: "auto"` |
198
+ | `markers` | `[]` | See [Markers](#markers) |
199
+ | `arcs` | `[]` | See [Arcs](#arcs) |
200
+ | `center` | `{ lon: 10, lat: 20 }` | Initial view centre |
201
+ | `zoom` / `minZoom` / `maxZoom` | `1` / `1` / `8` | Zoom level and bounds |
202
+ | `zoomable` | `true` | Wheel and pinch zoom |
203
+ | `autoRotate` | `true` | Spin when idle |
204
+ | `rotateSpeed` | `0.09` | Degrees per frame |
205
+ | `interactive` | `true` | Drag, zoom, hover and click |
206
+ | `keyboard` | `true` | Arrow keys, `+`/`-`, `0`, `PageUp`/`PageDown` |
207
+ | `graticule` | `true` | Latitude/longitude grid |
208
+ | `stars` | `true` | Starfield around the globe |
209
+ | `shade` | `true` | Lit-from-upper-left shading |
210
+ | `terminator` | `false` | Shade the night side using the real solar position |
211
+ | `time` | `null` | Clock for the terminator; `null` tracks now |
212
+ | `markerStyle` | `"auto"` | `"auto"` \| `"bubble"` \| `"dot"` |
213
+ | `markerScale` | `1` | Scales every marker |
214
+ | `renderMarker` | Not set | `(ctx, marker, info) => radius`: draw markers yourself |
215
+ | `cluster` | `false` | Merge nearby markers into count bubbles |
216
+ | `clusterRadius` | `42` | Cluster grid size in px |
217
+ | `countryColors` | Not set | `{ IN: "#f00" }` keyed by ISO code, id or name, or `"auto"` |
218
+ | `countryColor` | Not set | `(shape) => color`: wins over `countryColors` |
219
+ | `countryKey` | Not set | `(shape) => key` used against `countryColors` |
220
+ | `arcLift` | `0.28` | Default arc height, as a fraction of the radius |
221
+ | `arcSpeed` | `1` | Multiplies every arc's travel speed |
222
+ | `radiusRatio` | `0.4` | Globe radius vs the smaller canvas side |
223
+ | `latRange` | `[83, -56]` | Map mode north/south bounds |
224
+ | `world` | bundled | Your own GeoJSON |
225
+ | `fps` | `30` | Frame cap |
226
+ | `tooltip` | `false` | `true`, or `(target, kind) => string` |
227
+ | `respectReducedMotion` | `true` | Honour `prefers-reduced-motion` |
228
+ | `ariaLabel` | `"Interactive world map"` | Accessible name for the canvas |
229
+ | `onHover` | Not set | `(marker \| null, { x, y } \| null) => void` |
230
+ | `onClick` | Not set | `(marker, { x, y }) => void` |
231
+ | `onCountryHover` | Not set | `(country \| null, { x, y } \| null) => void` |
232
+ | `onCountryClick` | Not set | `(country, { x, y }) => void` |
233
+ | `onRender` | Not set | Called after every frame |
234
+
235
+ ## Markers
236
+
237
+ ```ts
238
+ {
239
+ lat: number; // required
240
+ lon: number; // required
241
+ count?: number; // relative weight: bigger count, bigger marker
242
+ emoji?: string; // drawn inside a bubble marker
243
+ live?: boolean; // pulsing ring
244
+ color?: string; // overrides the theme colour
245
+ size?: number; // base radius, default 3.4
246
+ ...anything // passed straight back to onHover / onClick
247
+ }
248
+ ```
249
+
250
+ With `cluster: true`, dense areas collapse into a single bubble and your callbacks receive
251
+ `{ cluster: true, count, markers, lat, lon }` instead. Clustering happens in screen space, so it
252
+ re-balances automatically as you zoom.
253
+
254
+ ### How accurate is marker placement?
255
+
256
+ The projection maths is exact: a marker's pixel position matches the closed-form projection to
257
+ floating-point precision, and the bubble is centred on the coordinate. Two things are worth knowing:
258
+
259
+ - **The coastlines are approximate, not the markers.** The bundled geometry is Natural Earth 1:110m,
260
+ decimated to a ~0.14ยฐ tolerance and rounded to two decimals, so the drawn shoreline can sit a few
261
+ kilometres from the real one. A coastal marker may look slightly offshore even though it is exactly
262
+ where you put it. Natural Earth 1:110m also omits microstates such as Singapore, Malta and Monaco: a marker there lands on open water or a neighbour. Pass higher-detail GeoJSON via `world` if that
263
+ matters.
264
+ - **`latRange` defaults to `[83, -56]`,** which trims the polar caps. Markers south of โˆ’56ยฐ or north
265
+ of 83ยฐ project outside the drawn map. Use `latRange: [90, -90]` for a full-height map.
266
+
267
+ The antimeridian is handled: the map pans freely across ยฑ180ยฐ once zoomed, and coordinates are
268
+ wrapped around the view centre, so a marker at 179ยฐE and one at 179ยฐW render side by side.
269
+
270
+ ## Arcs
271
+
272
+ ```js
273
+ globe.setArcs([
274
+ { from: { lat: 23.03, lon: 72.58 }, to: [-0.12, 51.5] },
275
+ { from: [72.58, 23.03], to: [139.69, 35.68], color: "#f97316", duration: 3200 },
276
+ ]);
277
+ ```
278
+
279
+ Arcs follow the great circle, bow above the surface by `lift`, animate a travelling head, and are
280
+ clipped at the horizon as the globe turns. Set `animate: false` for a static line.
281
+
282
+ ## Choropleth
283
+
284
+ ```js
285
+ import { createGlobe, colorScale } from "canvas-globe";
286
+
287
+ const visits = { IN: 940, US: 720, GB: 480, JP: 300 };
288
+ const scale = colorScale([0, 1000], ["#e0f2fe", "#0369a1"]);
289
+
290
+ createGlobe(canvas, {
291
+ mode: "map",
292
+ countryColors: Object.fromEntries(Object.entries(visits).map(([k, v]) => [k, scale(v)])),
293
+ onCountryClick: (shape) => console.log(shape.iso, shape.name),
294
+ });
295
+ ```
296
+
297
+ Keys are matched case-insensitively against the ISO alpha-2 code, then the numeric id, then the
298
+ country name. Pass `countryKey` if your data uses something else.
299
+
300
+ ## Methods
301
+
302
+ ```js
303
+ globe.setMarkers([...]); // swap the marker set
304
+ globe.setArcs([...]); // swap the arcs
305
+ globe.setMode("map"); // switch projection family
306
+ globe.setProjection("naturalEarth"); // switch flat projection
307
+ globe.setPreset("hologram"); // swap the whole look
308
+ globe.setLandStyle("dots"); // fill | dots | outline | glow
309
+ globe.setTheme("midnight"); // switch palette
310
+ globe.setTime(Date.UTC(2024, 5, 21)); // terminator clock; null tracks now
311
+ globe.setOptions({ autoRotate: false });
312
+ globe.setZoom(3);
313
+ globe.zoomBy(1.4);
314
+ globe.flyTo(139.69, 35.68); // ease to Tokyo
315
+ globe.flyTo(139.69, 35.68, { instant: true, zoom: 4 });
316
+ globe.fitTo([68, 6, 98, 36]); // frame [west, south, east, north]
317
+ globe.fitToMarkers(); // frame every marker
318
+ globe.focusOn("India", { isolate: true });
319
+ globe.setCountryMedia("India", "/reel.mp4");
320
+ globe.countryAspect("India"); // โ†’ height / width ratio for the canvas
321
+ globe.clearFocus();
322
+ globe.setScene("logos"); // whole composition
323
+ globe.exportImage({ preset: "story", transparent: true });
324
+ await globe.exportBlob({ preset: "og" });
325
+ globe.setTimelineAt("2024-06-01");
326
+ globe.playTimeline({ duration: 6000 }); // โ†’ { stop() }
327
+ globe.ping({ lat, lon, label }); // one-shot expanding ring
328
+ globe.pingFeed(events, { interval }); // โ†’ { stop() }
329
+ globe.tour(points, { dwell }); // โ†’ { stop() }
330
+ globe.story(el, steps); // scroll-linked view
331
+ globe.record({ duration, filename }); // โ†’ { promise, stop() }
332
+ globe.locateViewer(); // โ†’ { lat, lon, timeZone, country, source, accuracy }
333
+ globe.setTexture(imageOrUrl);
334
+ globe.getCenter(); // โ†’ { lon, lat }
335
+ globe.project(lon, lat); // โ†’ { x, y } or null if behind the globe
336
+ globe.unproject(x, y); // โ†’ [lon, lat] or null
337
+ globe.countryAt(x, y); // โ†’ country shape or null
338
+ globe.snapshot(); // โ†’ PNG data URL, great for share images
339
+ await globe.toBlob(); // โ†’ Blob
340
+ globe.invalidate(); // request one more frame
341
+ globe.resize(); // usually automatic via ResizeObserver
342
+ globe.destroy(); // stop the loop and remove listeners
343
+ ```
344
+
345
+ Helpers are exported too: `mapAspect`, `colorScale`, `greatCircle`, `angularDistance`,
346
+ `subsolarPoint`, `pointInGeometry`, `geometryBounds`, `projections`, `themes`, `presets`, `scenes`,
347
+ `exportPresets`, `fromCSV`, `parseCSV`, `geocode`, `countryPoint`, `placeLocation`.
348
+
349
+ ## Accessibility
350
+
351
+ The canvas gets `role="img"`, an `aria-label`, and, when `keyboard` is on, a tab stop plus a
352
+ polite live region that announces the view as it changes.
353
+
354
+ | Key | Action |
355
+ | --- | --- |
356
+ | `โ†` `โ†’` `โ†‘` `โ†“` | Rotate or pan (hold <kbd>Shift</kbd> for bigger steps) |
357
+ | `+` / `-` | Zoom in / out |
358
+ | `0` | Reset to the initial view |
359
+ | `PageDown` / `PageUp` | Cycle through markers, flying to each |
360
+ | `Enter` / `Space` | Activate the focused marker |
361
+
362
+ When the user prefers reduced motion, auto-rotation stops, `flyTo` jumps instead of easing, and
363
+ pulse rings and arc animations hold still. Set `respectReducedMotion: false` to opt out.
364
+
365
+ ## Data in, assets out
366
+
367
+ Marketing data arrives as a spreadsheet, so `fromCSV` resolves rows itself: explicit `lat`/`lon`
368
+ columns first, then a city name, then a country code or name:
369
+
370
+ ```js
371
+ import { fromCSV } from "canvas-globe";
372
+
373
+ const markers = fromCSV(`city,count,image
374
+ London,8,/logos/acme.png
375
+ Tokyo,4,/logos/globex.png`);
376
+
377
+ markers.skipped; // rows that could not be placed, so you can report them
378
+ globe.setMarkers(markers).fitToMarkers();
379
+ ```
380
+
381
+ City lookup covers roughly 300 major cities that already ship with the package. Pass
382
+ `{ gazetteer: { Ahmedabad: [72.58, 23.03] } }` for anything else: no geocoding service, no key.
383
+
384
+ Render at whatever size the destination wants, without touching the live canvas:
385
+
386
+ ```js
387
+ globe.exportImage({ preset: "story" }); // 1080ร—1920 data URL
388
+ globe.exportImage({ preset: "linkedin", transparent: true });
389
+ await globe.exportBlob({ width: 2400, height: 1260 });
390
+ ```
391
+
392
+ Presets: `square`, `story`, `portrait`, `wide`, `linkedin`, `og`, `twitter`, `thumbnail`.
393
+
394
+ ## Scenes
395
+
396
+ Presets decide how it looks; **scenes** decide what you are making. Each one bundles a preset with
397
+ the layers and overlays that job needs.
398
+
399
+ ```js
400
+ createGlobe(canvas, { scene: "signups" });
401
+ globe.setScene("coverage");
402
+ ```
403
+
404
+ | Scene | For |
405
+ | --- | --- |
406
+ | `signups` | Live activity on a pricing or landing page |
407
+ | `launch` | A regional announcement, ready for country media |
408
+ | `logos` | "Trusted in N countries" with customer logos |
409
+ | `team` | Where the team is, on a careers page |
410
+ | `coverage` | Campaign or revenue by country, with a legend |
411
+ | `review` | Scroll-linked year in review |
412
+ | `routes` | Traffic between regions |
413
+
414
+ Switching scenes resets every key the new scene does not set, so nothing leaks between them.
415
+
416
+ ## Overlays
417
+
418
+ ```js
419
+ createGlobe(canvas, {
420
+ counter: { value: 21947, label: "customers worldwide" }, // rolls when it changes
421
+ title: { text: "Trusted in 68 countries", subtitle: "Join 21,947 teams" },
422
+ watermark: { image: "/logo.svg", text: "acme.com" }, // baked into every export
423
+ annotations: [{ lat: 23.03, lon: 72.58, text: "HQ: Ahmedabad" }],
424
+ timeline: { at: "2024-06-01" }, // hides later markers
425
+ });
426
+
427
+ globe.playTimeline({ duration: 6000, loop: true }); // "our growth, animated"
428
+ globe.ping({ lat, lon, label: "10,000 users ๐ŸŽ‰", burst: 18 });
429
+ ```
430
+
431
+ Markers take `image` for a circular logo or avatar crop, and arcs take `icon` for a travelling
432
+ glyph. Country media accepts `{ text }` to cut type out of a country's outline.
433
+
434
+ ## Country canvas
435
+
436
+ Frame one country and paint media inside its outline: a still, an animated GIF, a video, another
437
+ canvas, or a live `MediaStream`:
438
+
439
+ ```js
440
+ globe.focusOn("India", { isolate: true });
441
+ globe.setCountryMedia("India", "/launch-reel.mp4");
442
+
443
+ // or declaratively
444
+ createGlobe(canvas, {
445
+ mode: "map",
446
+ focus: { country: "IN", isolate: true, outlineWidth: 2 },
447
+ countryMedia: {
448
+ IN: { src: "/reel.mp4", fit: "cover" },
449
+ BR: "/photo.jpg",
450
+ },
451
+ });
452
+ ```
453
+
454
+ The media is clipped to the bundled country outline, including islands. Sources resolve
455
+ automatically: `.mp4`/`.webm` become looping muted video, `.gif` keeps animating, and anything
456
+ `drawImage` accepts can be passed directly. `fit` mirrors CSS `object-fit`, and `opacity`, `blend`,
457
+ `scale` and `offset` are available per country.
458
+
459
+ `focusOn` zooms past `maxZoom` when it has to, since framing a country is an explicit request.
460
+ `countryAspect("India")` returns the height/width ratio to size the canvas with, so the shape is not
461
+ letterboxed:
462
+
463
+ ```css
464
+ #map { width: 100%; aspect-ratio: var(--country-aspect, 1); }
465
+ ```
466
+
467
+ Without `isolate`, neighbours stay visible at `dim` opacity, which reads well for "our market" maps.
468
+
469
+ ## Where is the viewer?
470
+
471
+ ```js
472
+ createGlobe(canvas, { showViewer: true });
473
+ ```
474
+
475
+ That pins the person looking at the page: **no permission prompt, no network call, no API key,
476
+ instantly**. It reads `Intl.DateTimeFormat().resolvedOptions().timeZone`, which every browser
477
+ exposes, and maps it to the coordinate the IANA database publishes for that zone. Legacy aliases
478
+ resolve too (Chrome often reports `Asia/Calcutta`, not `Asia/Kolkata`).
479
+
480
+ ```js
481
+ const found = globe.locateViewer();
482
+ // { lat: 23.29, lon: 82.52, timeZone: "Asia/Kolkata", country: "IN",
483
+ // source: "timezone", accuracy: "region", accuracyMeters: 2242000 }
484
+ ```
485
+
486
+ **It shows a region, not a pinpoint, and represents that uncertainty.** A time zone only narrows you to
487
+ its area, and the tz database publishes one representative city per zone. `Asia/Kolkata` covers all
488
+ of India, so a naive pin would sit confidently on Kolkata even for someone in Ahmedabad, 1,600 km
489
+ away. Two things prevent that:
490
+
491
+ - **Anchoring.** For countries wider than 8ยฐ the pin goes on the country centroid instead of the
492
+ zone's city, which roughly halves the average error. Smaller countries keep their real city.
493
+ Override with `anchor: "country" | "timezone"`.
494
+ - **An uncertainty circle.** The pin is surrounded by a dashed circle sized to the actual radius, so
495
+ the graphic says "somewhere in here" rather than "exactly here". Turn it off with
496
+ `accuracyCircle: false`.
497
+
498
+ When you need a real position, ask for one:
499
+
500
+ ```js
501
+ const found = await globe.locateViewer({ precise: true });
502
+ globe.setViewerLocation(found);
503
+ // source: "geolocation", accuracyMeters: 24; the circle shrinks to match
504
+ ```
505
+
506
+ That requests the high-accuracy provider and reports the device's own `accuracyMeters`, so you can
507
+ tell a 20 m GPS fix from a 40 km Wi-Fi one. On a desktop with no GPS the browser falls back to
508
+ network positioning, which often lands on your ISP's city: the radius will say so. If the viewer
509
+ declines, it resolves to the time-zone estimate and never rejects.
510
+
511
+ | | Prompt | Network | Always works | Typical radius |
512
+ | --- | --- | --- | --- | --- |
513
+ | Time zone (default) | No | No | Yes | Country-sized |
514
+ | Locale fallback | No | No | Yes | Country-sized |
515
+ | `precise: true`, GPS | Yes | No | Only if allowed | 5-50 m |
516
+ | `precise: true`, Wi-Fi/IP | Yes | Yes (by the browser) | Only if allowed | 1-50 km |
517
+
518
+ Options: `showViewer: { emoji, label, color, live, anchor, accuracyCircle, accuracyColor, flyTo,
519
+ ping, precise, onLocate }`. The pin lives outside `markers`, so `setMarkers()` never wipes it.
520
+
521
+ ## Live pings
522
+
523
+ A one-shot expanding ring: the social-proof moment, with no backend:
524
+
525
+ ```js
526
+ globe.ping({ lat: 52.52, lon: 13.4, emoji: "โœจ", label: "Someone in Berlin just signed up" });
527
+
528
+ const feed = globe.pingFeed(events, { interval: 1800 });
529
+ feed.stop();
530
+ ```
531
+
532
+ ## Tour, story and recording
533
+
534
+ ```js
535
+ globe.tour(cities, { dwell: 2600, zoom: 2.2 }); // cinematic auto-fly, returns { stop() }
536
+
537
+ globe.story(section, [ // scroll-linked rotation
538
+ { at: 0, center: [0, 20], zoom: 1 },
539
+ { at: 0.5, center: [72, 23], zoom: 3, markers: indiaMarkers, preset: "hologram" },
540
+ ]);
541
+
542
+ await globe.record({ duration: 6000, filename: "globe.webm" }).promise;
543
+ ```
544
+
545
+ `record()` uses `MediaRecorder` on the canvas stream: the clip is encoded in the tab and never
546
+ leaves the device. Check the exported `canRecord()` helper first.
547
+
548
+ ## Looks
549
+ A **preset** bundles a theme with a render style. Your own options always win over it.
550
+
551
+ ```js
552
+ createGlobe(canvas, { preset: "hologram" });
553
+ globe.setPreset("neon");
554
+ ```
555
+
556
+ | Preset | Look |
557
+ | --- | --- |
558
+ | `atlas` | Bright cartographic globe (the default) |
559
+ | `midnight` | Dark space theme |
560
+ | `mono` | Neutral greyscale |
561
+ | `political` | Printed atlas: a distinct colour per country |
562
+ | `hologram` | Cyan dot-matrix earth on deep navy |
563
+ | `neon` | Glowing magenta continents with a cyan rim |
564
+ | `blueprint` | Technical line art with orbit rings |
565
+ | `aurora` | Green dot matrix with violet rings |
566
+ | `noir` | High-contrast black and white |
567
+ | `constellation` | Sparse dots, stars and three orbits |
568
+
569
+ The pieces compose independently, so any theme mixes with any style:
570
+
571
+ | Option | Values |
572
+ | --- | --- |
573
+ | `landStyle` | `"fill"` ยท `"dots"` (halftone) ยท `"outline"` (line art) ยท `"glow"` (neon) ยท `"none"` |
574
+ | `dotSpacing` / `dotSize` | Grid spacing in degrees and dot radius in px |
575
+ | `orbits` | A count (0-6), or `{ inclination, phase, radius, speed, color, width }` rings |
576
+ | `countryColors: "auto"` | A distinct fill per country; `countryPalette` supplies your own |
577
+
578
+ ```js
579
+ createGlobe(canvas, { theme: "midnight", landStyle: "dots", dotSpacing: 2.4, orbits: 3 });
580
+ ```
581
+
582
+ Dots come from rasterising the land once into an off-screen bitmap and sampling a grid, so the whole
583
+ matrix draws in a single fill and re-spacing is cheap. Orbit rings are real great circles, so they
584
+ pass behind the globe as it turns. `countryColors: "auto"` runs a greedy graph colouring over country
585
+ adjacency, so neighbours never share a fill.
586
+
587
+ ## Themes
588
+
589
+ `atlas`, `midnight`, `mono`, `hologram`, `neon`, `blueprint`, `aurora`, `noir` and `political`.
590
+ Use `theme: "auto"` to follow the OS colour scheme, or `theme: "css"` to read `--geo-*` custom
591
+ properties off the canvas so the globe inherits your design tokens:
592
+
593
+ ```css
594
+ #globe { --geo-land: #334155; --geo-ocean-from: #0f172a; --geo-ocean-to: #020617; }
595
+ ```
596
+
597
+ Override any subset directly too:
598
+
599
+ ```js
600
+ createGlobe(canvas, {
601
+ theme: { ocean: ["#0f172a", "#020617"], land: "#334155", marker: "#f97316", arc: "#22d3ee" },
602
+ });
603
+ ```
604
+
605
+ ## Custom geometry
606
+
607
+ The bundled data is Natural Earth 1:110m, simplified for smooth animation. Swap in anything GeoJSON:
608
+
609
+ ```js
610
+ const world = await fetch("/my-countries.geojson").then((r) => r.json());
611
+ createGlobe(canvas, { world });
612
+ ```
613
+
614
+ ## Performance
615
+
616
+ Rendering is capped at 30 fps, and frames are skipped entirely when nothing is moving: a static
617
+ chart costs nothing after the first paint. Geometry behind the horizon is clipped away rather than
618
+ drawn, so a typical globe frame skips 20-60% of the world. On a laptop a 560 px canvas costs roughly
619
+ 4 ms per frame, or 25 ms with 5,000 clustered markers.
620
+
621
+ ## Data & licences
622
+
623
+ - Country geometry: [Natural Earth](https://www.naturalearthdata.com/) 1:110m via `world-atlas`, **public domain**
624
+ - ISO codes: [natural-earth-vector](https://github.com/nvkelso/natural-earth-vector), **public domain**
625
+ - Supplemental geometry: [Datameet maps](https://github.com/datameet/maps), **CC-0**
626
+ - This package: **GPL-3.0-only or a commercial license**
627
+
628
+ See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for source links and
629
+ provenance.
630
+
631
+ Regenerate the bundled data any time with `npm run data`.
632
+
633
+ ## Development
634
+
635
+ ```bash
636
+ npm test # node --test, no test framework to install
637
+ npm run build # dist/canvas-globe.umd.js, with a gzipped size budget
638
+ npm run example # demo at http://localhost:8099
639
+ npm run release:check # tests, types, build and packed-artifact validation
640
+ ```
641
+
642
+ The product website and documentation are maintained separately at
643
+ [canvasglobe.swiftools.com](https://canvasglobe.swiftools.com/).
644
+
645
+ ## Support
646
+
647
+ For installation help, licensing questions, commercial inquiries, or general
648
+ support, email [globe@swiftools.com](mailto:globe@swiftools.com).
649
+
650
+ Found a bug or have an idea? Use the repository's guided issue forms. Built
651
+ something with CanvasGlobe? Submit it to the
652
+ [community showcase](https://canvasglobe.swiftools.com/showcase); projects are
653
+ only displayed after the owner grants permission.
654
+
655
+ ## Author
656
+
657
+ Harsh Jhunjhunuwala
658
+
659
+ Copyright (C) 2026 Harsh Jhunjhunuwala. CanvasGlobe is published under the
660
+ Swiftools brand.
661
+
662
+ ## Browser support
663
+
664
+ Any browser with `<canvas>` and `ResizeObserver`: Chrome, Edge, Firefox, Safari 13.1+. No polyfills
665
+ required. The modules load safely during SSR; nothing touches the DOM until you construct an instance.