canvas-globe 0.1.0

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 ADDED
@@ -0,0 +1,638 @@
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.
@@ -0,0 +1,14 @@
1
+ # Third-party notices
2
+
3
+ CanvasGlobe includes geographic data derived from these sources:
4
+
5
+ | Files | Source | Terms |
6
+ | --- | --- | --- |
7
+ | `src/data/world.js` | [Natural Earth 1:110m](https://www.naturalearthdata.com/about/terms-of-use/) through [world-atlas](https://github.com/topojson/world-atlas) | Natural Earth states that its data is public domain |
8
+ | `src/data/india.js` | [Datameet india-composite](https://github.com/datameet/maps) | CC0 / public-domain dedication as stated by the source repository |
9
+
10
+ The GNU GPL in [LICENSE](LICENSE) applies to CanvasGlobe's original software
11
+ code. It does not replace the terms or public-domain status of third-party
12
+ data.
13
+
14
+ Generated distributions should retain this file.
package/codemeta.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "@context": "https://doi.org/10.5063/schema/codemeta-2.0",
3
+ "@type": "SoftwareSourceCode",
4
+ "name": "CanvasGlobe",
5
+ "identifier": "canvas-globe",
6
+ "description": "Zero-dependency JavaScript and React library for interactive 3D globes and flat world maps rendered with Canvas 2D, with no WebGL or API-key requirement.",
7
+ "url": "https://canvasglobe.swiftools.com/",
8
+ "version": "0.1.0",
9
+ "codeRepository": "https://github.com/Shree-hari/canvas-globe",
10
+ "issueTracker": "https://github.com/Shree-hari/canvas-globe/issues",
11
+ "downloadUrl": "https://www.npmjs.com/package/canvas-globe",
12
+ "applicationCategory": "DeveloperApplication",
13
+ "programmingLanguage": "JavaScript",
14
+ "runtimePlatform": "Web browser with Canvas 2D",
15
+ "license": "https://spdx.org/licenses/GPL-3.0-only.html",
16
+ "author": {
17
+ "@type": "Person",
18
+ "name": "Harsh Jhunjhunuwala"
19
+ },
20
+ "copyrightHolder": {
21
+ "@type": "Person",
22
+ "name": "Harsh Jhunjhunuwala"
23
+ },
24
+ "keywords": [
25
+ "JavaScript globe",
26
+ "interactive globe",
27
+ "Canvas globe",
28
+ "React globe",
29
+ "world map",
30
+ "Canvas 2D",
31
+ "no WebGL",
32
+ "choropleth map"
33
+ ]
34
+ }