@bitbaum/geo-kit 0.1.2 → 0.3.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.
@@ -0,0 +1,375 @@
1
+ import { WORLD_LIMITS, clampTilt, clampZoom, disc, projectionFor, wrapLon, cameraFor } from "./camera.js";
2
+ import { coast, dragged, flight } from "./motion.js";
3
+ import { groupFills, paintMap } from "./paint.js";
4
+ import { pickAt, pickIndex } from "./pick.js";
5
+ const IDLE_BEFORE_TURNING = 4000;
6
+ const STOP_TURNING_AFTER = 120_000;
7
+ const TURN_PER_MS = 0.004; // degrees: a full turn in about 90 seconds
8
+ /** How long the disc takes to follow its view to a new place. */
9
+ const FOLLOW_MS = 280;
10
+ /** Past this a press is a drag, not a tap. */
11
+ const TAP_SLOP = 6;
12
+ const reducedMotion = () => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
13
+ const pixelRatio = () => Math.min(window.devicePixelRatio || 1, 2);
14
+ const lerp = (a, b, t) => a + (b - a) * t;
15
+ export function createMap(canvas, options) {
16
+ const limits = options.limits ?? WORLD_LIMITS;
17
+ const home = options.home ?? { center: [30, 18], k: 1 };
18
+ let shapes = options.shapes;
19
+ let index = pickIndex(shapes.features);
20
+ let fills = options.fills ?? new Map();
21
+ let paint = { palette: options.palette, groups: groupFills(shapes.features, fills, options.palette.land) };
22
+ let camera = home;
23
+ let size = { w: 0, h: 0 };
24
+ let shown = null;
25
+ let motion = {};
26
+ let touched = 0;
27
+ let hover = null;
28
+ let selected = null;
29
+ let grabbed = false;
30
+ let raf = 0;
31
+ let wake = 0;
32
+ let last = 0;
33
+ let destroyed = false;
34
+ const viewAt = (now) => {
35
+ if (!shown)
36
+ return null;
37
+ const t = Math.min((now - shown.start) / FOLLOW_MS, 1);
38
+ if (t >= 1)
39
+ return shown.to;
40
+ const e = 1 - (1 - t) ** 3;
41
+ const s = shown;
42
+ const at = (k) => lerp(s.from[k], s.to[k], e);
43
+ return { x0: at("x0"), y0: at("y0"), x1: at("x1"), y1: at("y1") };
44
+ };
45
+ const projection = (now = performance.now()) => {
46
+ const view = viewAt(now);
47
+ return view ? projectionFor(view, camera) : null;
48
+ };
49
+ const radius = () => {
50
+ const view = viewAt(performance.now());
51
+ return view ? disc(view).radius : 200;
52
+ };
53
+ const feature = (id) => {
54
+ const i = id === null ? undefined : shapes.index.get(id);
55
+ return i === undefined ? undefined : shapes.features[i];
56
+ };
57
+ /** Advance whatever is moving; true while another frame is needed. */
58
+ const advance = (now, dt) => {
59
+ if (motion.flight) {
60
+ const t = (now - motion.flight.start) / motion.flight.f.duration;
61
+ camera = motion.flight.f.at(t);
62
+ if (t < 1)
63
+ return true;
64
+ motion.flight = undefined;
65
+ return false;
66
+ }
67
+ if (motion.spin) {
68
+ const step = coast(camera, motion.spin, dt, radius(), limits);
69
+ camera = step.camera;
70
+ motion.spin = step.spin;
71
+ return !!step.spin;
72
+ }
73
+ if (!options.idleTurn || selected || camera.k > 1.3 || options.holdStill?.() || reducedMotion())
74
+ return false;
75
+ const idle = now - touched;
76
+ if (idle > IDLE_BEFORE_TURNING && idle < STOP_TURNING_AFTER) {
77
+ camera = { ...camera, center: [wrapLon(camera.center[0] + TURN_PER_MS * dt), camera.center[1]] };
78
+ return true;
79
+ }
80
+ if (idle <= IDLE_BEFORE_TURNING) {
81
+ // Nothing to draw until the turn may start: wake then, not every frame.
82
+ window.clearTimeout(wake);
83
+ wake = window.setTimeout(invalidate, IDLE_BEFORE_TURNING - idle + 20);
84
+ }
85
+ return false;
86
+ };
87
+ const tick = (now) => {
88
+ raf = 0;
89
+ if (destroyed)
90
+ return;
91
+ const dt = Math.min(now - (last || now), 64);
92
+ last = now;
93
+ const moving = advance(now, dt) || grabbed;
94
+ let again = moving;
95
+ if (shown && now - shown.start < FOLLOW_MS)
96
+ again = true;
97
+ if (options.animating?.(now))
98
+ again = true;
99
+ const ctx = canvas.getContext("2d");
100
+ const proj = projection(now);
101
+ if (ctx && proj && size.w > 0) {
102
+ const dpr = pixelRatio();
103
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
104
+ const flightAt = motion.flight ? (now - motion.flight.start) / motion.flight.f.duration : null;
105
+ paintMap(ctx, size, proj, moving ? shapes.coarse : shapes, paint, { hover: feature(hover), selected: feature(selected) }, options.over
106
+ ? (c, basis, p) => options.over(c, basis, p, { now, flight: flightAt, moving, sinceTouch: now - touched })
107
+ : undefined);
108
+ }
109
+ if (again)
110
+ raf = requestAnimationFrame(tick);
111
+ else
112
+ last = 0;
113
+ };
114
+ function invalidate() {
115
+ if (!raf && !destroyed)
116
+ raf = requestAnimationFrame(tick);
117
+ }
118
+ const flyTo = (to, duration) => {
119
+ const target = { center: [wrapLon(to.center[0]), clampTilt(to.center[1], limits)], k: clampZoom(to.k, limits) };
120
+ motion = {};
121
+ touched = performance.now();
122
+ if (reducedMotion())
123
+ camera = target;
124
+ else {
125
+ const f = flight(camera, target, limits);
126
+ motion.flight = { f: duration ? { ...f, duration } : f, start: performance.now() };
127
+ }
128
+ invalidate();
129
+ };
130
+ const grab = () => {
131
+ motion = {};
132
+ touched = performance.now();
133
+ };
134
+ const scale = (factor) => {
135
+ camera = { ...camera, k: clampZoom(camera.k * factor, limits) };
136
+ invalidate();
137
+ };
138
+ const zoomBy = (factor) => {
139
+ if (factor === "home")
140
+ flyTo(home);
141
+ else
142
+ flyTo({ ...camera, k: camera.k * factor }, 260);
143
+ };
144
+ const pick = (x, y) => {
145
+ const proj = projection();
146
+ return proj ? pickAt(index, proj, x, y) : null;
147
+ };
148
+ const choose = (id) => options.onSelect?.(id);
149
+ const takenFirst = (x, y, kind) => {
150
+ const proj = projection();
151
+ return !!proj && !!options.pickFirst?.(x, y, proj, kind);
152
+ };
153
+ const facing = () => {
154
+ const view = viewAt(performance.now());
155
+ if (!view)
156
+ return null;
157
+ const d = disc(view);
158
+ return pick(d.cx, d.cy)?.id ?? null;
159
+ };
160
+ /* ------------------------------------------------------------ input */
161
+ const pointers = new Map();
162
+ let gesture = { moved: false, vx: 0, vy: 0, t: 0, startX: 0, startY: 0 };
163
+ const local = (e) => {
164
+ const box = canvas.getBoundingClientRect();
165
+ return { x: e.clientX - box.left, y: e.clientY - box.top };
166
+ };
167
+ const on = {
168
+ keydown(e) {
169
+ const step = e.shiftKey ? 30 : 10;
170
+ const perStep = step / Math.max(camera.k, 1);
171
+ const turn = (dLon, dLat) => {
172
+ motion = {};
173
+ touched = performance.now();
174
+ camera = {
175
+ center: [wrapLon(camera.center[0] + dLon * perStep), clampTilt(camera.center[1] + dLat * perStep, limits)],
176
+ k: camera.k,
177
+ };
178
+ invalidate();
179
+ options.onFacing?.(facing());
180
+ };
181
+ const keys = {
182
+ ArrowLeft: () => turn(-1, 0),
183
+ ArrowRight: () => turn(1, 0),
184
+ ArrowUp: () => turn(0, 1),
185
+ ArrowDown: () => turn(0, -1),
186
+ "+": () => zoomBy(1.5),
187
+ "=": () => zoomBy(1.5),
188
+ "-": () => zoomBy(1 / 1.5),
189
+ "0": () => zoomBy("home"),
190
+ Home: () => zoomBy("home"),
191
+ Enter: () => choose(facing()),
192
+ };
193
+ const run = keys[e.key];
194
+ if (!run)
195
+ return;
196
+ e.preventDefault();
197
+ run();
198
+ },
199
+ pointerdown(e) {
200
+ if (e.pointerType === "mouse" && e.button !== 0)
201
+ return;
202
+ try {
203
+ canvas.setPointerCapture(e.pointerId);
204
+ }
205
+ catch {
206
+ // An untrusted or already-lifted pointer cannot be captured; the drag works without.
207
+ }
208
+ const at = local(e);
209
+ pointers.set(e.pointerId, at);
210
+ if (pointers.size === 1)
211
+ gesture = { moved: false, vx: 0, vy: 0, t: e.timeStamp, startX: at.x, startY: at.y };
212
+ else
213
+ gesture.moved = true; // a second finger is never a tap
214
+ grab();
215
+ grabbed = true;
216
+ if (hover !== null) {
217
+ hover = null;
218
+ options.onHover?.(null, at.x, at.y);
219
+ }
220
+ },
221
+ pointermove(e) {
222
+ const at = local(e);
223
+ const before = pointers.get(e.pointerId);
224
+ if (!before) {
225
+ if (e.pointerType === "mouse") {
226
+ touched = performance.now();
227
+ const taken = takenFirst(at.x, at.y, "hover");
228
+ const id = taken ? null : (pick(at.x, at.y)?.id ?? null);
229
+ canvas.style.cursor = taken || id ? "pointer" : "";
230
+ if (id !== hover) {
231
+ hover = id;
232
+ invalidate();
233
+ }
234
+ // The caller's own layer answered this point; its hover is its own to show.
235
+ if (!taken)
236
+ options.onHover?.(id, at.x, at.y);
237
+ }
238
+ return;
239
+ }
240
+ const g = gesture;
241
+ if (pointers.size === 1) {
242
+ if (Math.hypot(at.x - g.startX, at.y - g.startY) > TAP_SLOP)
243
+ g.moved = true;
244
+ if (g.moved) {
245
+ const dx = at.x - before.x;
246
+ const dy = at.y - before.y;
247
+ camera = dragged(camera, dx, dy, radius(), limits);
248
+ invalidate();
249
+ const dt = Math.max(e.timeStamp - g.t, 1);
250
+ // Smoothed velocity in px/ms, for the coast after release.
251
+ g.vx = g.vx * 0.6 + (dx / dt) * 0.4;
252
+ g.vy = g.vy * 0.6 + (dy / dt) * 0.4;
253
+ g.t = e.timeStamp;
254
+ }
255
+ }
256
+ else if (pointers.size === 2) {
257
+ const other = [...pointers.entries()].find(([id]) => id !== e.pointerId)?.[1];
258
+ if (other) {
259
+ const d0 = Math.hypot(before.x - other.x, before.y - other.y);
260
+ const d1 = Math.hypot(at.x - other.x, at.y - other.y);
261
+ if (d0 > 0)
262
+ scale(d1 / d0);
263
+ }
264
+ }
265
+ pointers.set(e.pointerId, at);
266
+ },
267
+ pointerup(e) {
268
+ if (!pointers.delete(e.pointerId) || pointers.size > 0)
269
+ return;
270
+ grabbed = false;
271
+ invalidate();
272
+ const g = gesture;
273
+ if (!g.moved) {
274
+ const at = local(e);
275
+ if (!takenFirst(at.x, at.y, "tap"))
276
+ choose(pick(at.x, at.y)?.id ?? null);
277
+ return;
278
+ }
279
+ const fresh = e.timeStamp - g.t < 80;
280
+ if (fresh && !reducedMotion() && Math.hypot(g.vx, g.vy) > 0.05) {
281
+ motion.spin = { vx: g.vx, vy: g.vy };
282
+ invalidate();
283
+ }
284
+ },
285
+ pointercancel(e) {
286
+ pointers.delete(e.pointerId);
287
+ if (pointers.size === 0) {
288
+ grabbed = false;
289
+ invalidate();
290
+ }
291
+ },
292
+ pointerleave(e) {
293
+ if (e.pointerType !== "mouse" || hover === null)
294
+ return;
295
+ hover = null;
296
+ canvas.style.cursor = "";
297
+ options.onHover?.(null, 0, 0);
298
+ invalidate();
299
+ },
300
+ };
301
+ const wheel = (e) => {
302
+ e.preventDefault();
303
+ const px = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
304
+ grab();
305
+ scale(Math.exp(-px * 0.0018));
306
+ };
307
+ const listeners = Object.entries(on).map(([type, fn]) => [type, fn]);
308
+ for (const [type, fn] of listeners)
309
+ canvas.addEventListener(type, fn);
310
+ canvas.addEventListener("wheel", wheel, { passive: false });
311
+ canvas.style.touchAction = "none";
312
+ if (!canvas.hasAttribute("tabindex"))
313
+ canvas.tabIndex = 0;
314
+ return {
315
+ resize(width, height, view) {
316
+ const dpr = pixelRatio();
317
+ const w = Math.round(width * dpr);
318
+ const h = Math.round(height * dpr);
319
+ if (canvas.width !== w || canvas.height !== h) {
320
+ canvas.width = w;
321
+ canvas.height = h;
322
+ }
323
+ size = { w: width, h: height };
324
+ const to = view ?? { x0: 0, y0: 0, x1: width, y1: height };
325
+ const now = performance.now();
326
+ const from = viewAt(now);
327
+ shown = from && !reducedMotion() ? { from, to, start: now } : { from: to, to, start: 0 };
328
+ invalidate();
329
+ },
330
+ setShapes(next) {
331
+ shapes = next;
332
+ index = pickIndex(shapes.features);
333
+ paint = { palette: paint.palette, groups: groupFills(shapes.features, fills, paint.palette.land) };
334
+ invalidate();
335
+ },
336
+ setFills(next) {
337
+ fills = next;
338
+ paint = { ...paint, groups: groupFills(shapes.features, fills, paint.palette.land) };
339
+ invalidate();
340
+ },
341
+ setPalette(palette) {
342
+ paint = { palette, groups: groupFills(shapes.features, fills, palette.land) };
343
+ invalidate();
344
+ },
345
+ setSelected(id, { fly = false } = {}) {
346
+ selected = id;
347
+ const f = feature(id);
348
+ const to = f && fly ? cameraFor(f.geometry, limits, options.focusZoom) : null;
349
+ if (to)
350
+ flyTo(to);
351
+ else
352
+ invalidate();
353
+ },
354
+ flyTo,
355
+ jumpTo(to) {
356
+ motion = {};
357
+ touched = performance.now();
358
+ camera = { center: [wrapLon(to.center[0]), clampTilt(to.center[1], limits)], k: clampZoom(to.k, limits) };
359
+ invalidate();
360
+ },
361
+ zoomBy,
362
+ camera: () => camera,
363
+ projection: () => projection(),
364
+ invalidate,
365
+ destroy() {
366
+ destroyed = true;
367
+ cancelAnimationFrame(raf);
368
+ raf = 0;
369
+ window.clearTimeout(wake);
370
+ for (const [type, fn] of listeners)
371
+ canvas.removeEventListener(type, fn);
372
+ canvas.removeEventListener("wheel", wheel);
373
+ },
374
+ };
375
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What is on the far side of the globe need not be drawn. Each country and
3
+ * each border line carries a cap — the smallest circle on the sphere, around
4
+ * its centre, that holds every vertex — and a frame streams only the ones
5
+ * whose cap reaches over the horizon. Half the world is skipped at rest, far
6
+ * more when zoomed in. Pure, so the horizon maths is tested.
7
+ */
8
+ import { type GeoPermissibleObjects, type GeoProjection } from "d3-geo";
9
+ export interface Cap {
10
+ center: [number, number];
11
+ /** Degrees from the centre to the farthest vertex. */
12
+ reach: number;
13
+ }
14
+ export declare function capOf(shape: GeoPermissibleObjects & {
15
+ coordinates: unknown;
16
+ }): Cap;
17
+ /**
18
+ * How far from the centre of view, in degrees, anything can still be seen on
19
+ * this canvas: the hemisphere when the whole disc fits, less when the globe
20
+ * is zoomed past the canvas edges.
21
+ */
22
+ export declare function horizonOf(proj: GeoProjection, size: {
23
+ w: number;
24
+ h: number;
25
+ }): number;
26
+ /** The point of the Earth at the centre of an orthographic projection. */
27
+ export declare function centreOf(proj: GeoProjection): [number, number];
28
+ export declare function visible(cap: Cap, centre: [number, number], horizon: number): boolean;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What is on the far side of the globe need not be drawn. Each country and
3
+ * each border line carries a cap — the smallest circle on the sphere, around
4
+ * its centre, that holds every vertex — and a frame streams only the ones
5
+ * whose cap reaches over the horizon. Half the world is skipped at rest, far
6
+ * more when zoomed in. Pure, so the horizon maths is tested.
7
+ */
8
+ import { geoCentroid, geoDistance } from "d3-geo";
9
+ const DEG = 180 / Math.PI;
10
+ function eachPosition(coords, fn) {
11
+ if (!Array.isArray(coords))
12
+ return;
13
+ if (typeof coords[0] === "number")
14
+ return fn(coords);
15
+ for (const c of coords)
16
+ eachPosition(c, fn);
17
+ }
18
+ export function capOf(shape) {
19
+ const [lon, lat] = geoCentroid(shape);
20
+ const center = Number.isFinite(lon) ? [lon, lat] : [0, 0];
21
+ let reach = 0;
22
+ eachPosition(shape.coordinates, (p) => {
23
+ reach = Math.max(reach, geoDistance(center, [p[0] ?? 0, p[1] ?? 0]));
24
+ });
25
+ // A shape whose centroid is meaningless (a ring around a pole) is never culled.
26
+ return { center, reach: Number.isFinite(lon) ? reach * DEG : 180 };
27
+ }
28
+ /**
29
+ * How far from the centre of view, in degrees, anything can still be seen on
30
+ * this canvas: the hemisphere when the whole disc fits, less when the globe
31
+ * is zoomed past the canvas edges.
32
+ */
33
+ export function horizonOf(proj, size) {
34
+ const [cx, cy] = proj.translate();
35
+ const r = proj.scale();
36
+ const far = Math.max(Math.hypot(cx, cy), Math.hypot(size.w - cx, cy), Math.hypot(cx, size.h - cy), Math.hypot(size.w - cx, size.h - cy));
37
+ return far >= r ? 90 : Math.asin(far / r) * DEG;
38
+ }
39
+ /** The point of the Earth at the centre of an orthographic projection. */
40
+ export function centreOf(proj) {
41
+ const [l, p] = proj.rotate();
42
+ return [-l, -p];
43
+ }
44
+ export function visible(cap, centre, horizon) {
45
+ return geoDistance(centre, cap.center) * DEG - cap.reach < horizon + 1;
46
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@bitbaum/geo-kit/map`: the renderer for the geography the main entry
3
+ * delivers. An orthographic globe on Canvas 2D (d3-geo), which zoomed in is a
4
+ * flat map of the place in front of the reader, so the world and a commune are
5
+ * one projection. Framework-free: `createMap` takes a canvas; the pure parts
6
+ * (camera, motion, culling, picking, the fast projection) are exported for
7
+ * tests and overlays. Needs the optional peers `d3-geo` and, for TopoJSON,
8
+ * `topojson-client`.
9
+ */
10
+ export { WORLD_LIMITS, cameraFor, cameraForBounds, clampTilt, clampZoom, disc, focusOf, mainland, projectionFor, rotationOf, wrapLon, zoomFor, type Camera, type Focus, type Limits, type View, } from "./camera.ts";
11
+ export { createMap, type FrameInfo, type MapController, type MapOptions } from "./controller.ts";
12
+ export { capOf, centreOf, horizonOf, visible, type Cap } from "./cull.ts";
13
+ export { coast, dragged, flight, type Flight, type Spin } from "./motion.ts";
14
+ export { fade, groupFills, paintMap, type DrawOver, type Fill, type Marks, type Paint, type Palette } from "./paint.ts";
15
+ export { pickAt, pickIndex, type Pickable } from "./pick.ts";
16
+ export { coarsened, polygonsOf, rewound, shapesFromFeatures, shapesFromTopology, shapesOf, type MapFeature, type MapShapes, type Shapes, type TopologyOptions, } from "./shapes.ts";
17
+ export { basisOf, pack, project, traceLine, traceRing, type Basis, type Packed } from "./trace.ts";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@bitbaum/geo-kit/map`: the renderer for the geography the main entry
3
+ * delivers. An orthographic globe on Canvas 2D (d3-geo), which zoomed in is a
4
+ * flat map of the place in front of the reader, so the world and a commune are
5
+ * one projection. Framework-free: `createMap` takes a canvas; the pure parts
6
+ * (camera, motion, culling, picking, the fast projection) are exported for
7
+ * tests and overlays. Needs the optional peers `d3-geo` and, for TopoJSON,
8
+ * `topojson-client`.
9
+ */
10
+ export { WORLD_LIMITS, cameraFor, cameraForBounds, clampTilt, clampZoom, disc, focusOf, mainland, projectionFor, rotationOf, wrapLon, zoomFor, } from "./camera.js";
11
+ export { createMap } from "./controller.js";
12
+ export { capOf, centreOf, horizonOf, visible } from "./cull.js";
13
+ export { coast, dragged, flight } from "./motion.js";
14
+ export { fade, groupFills, paintMap } from "./paint.js";
15
+ export { pickAt, pickIndex } from "./pick.js";
16
+ export { coarsened, polygonsOf, rewound, shapesFromFeatures, shapesFromTopology, shapesOf, } from "./shapes.js";
17
+ export { basisOf, pack, project, traceLine, traceRing } from "./trace.js";
@@ -0,0 +1,29 @@
1
+ import { type Camera, type Limits } from "./camera.ts";
2
+ /**
3
+ * Turning the globe by a drag of (dx, dy) pixels: the land under the finger
4
+ * follows it at the centre of the disc, whatever the zoom. North stays up.
5
+ */
6
+ export declare function dragged(camera: Camera, dx: number, dy: number, radius: number, limits?: Limits): Camera;
7
+ export interface Flight {
8
+ at: (t: number) => Camera;
9
+ /** Milliseconds: longer the farther it turns, never a slog. */
10
+ duration: number;
11
+ }
12
+ /**
13
+ * From one view to another along the great circle (the short way round, so
14
+ * Fiji to Samoa crosses the date line instead of circling the globe), pulling
15
+ * back mid-way on a long turn so the reader sees where they are going. The
16
+ * zoom moves on a log scale, so a flight from the world to a commune does not
17
+ * spend its first half barely zooming.
18
+ */
19
+ export declare function flight(from: Camera, to: Camera, limits?: Limits): Flight;
20
+ /** A flick: px/ms along each axis, carried on after the finger lifts. */
21
+ export interface Spin {
22
+ vx: number;
23
+ vy: number;
24
+ }
25
+ /** One frame of coasting: turn by the velocity, then let it decay. */
26
+ export declare function coast(camera: Camera, spin: Spin, dt: number, radius: number, limits?: Limits): {
27
+ camera: Camera;
28
+ spin?: Spin;
29
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * How the map moves, pure so it can be tested: a drag turns it, a flight
3
+ * carries it from one view to another the short way round, and a flick keeps
4
+ * it turning for a moment after release.
5
+ */
6
+ import { geoDistance, geoInterpolate } from "d3-geo";
7
+ import { WORLD_LIMITS, clampTilt, wrapLon } from "./camera.js";
8
+ /**
9
+ * Turning the globe by a drag of (dx, dy) pixels: the land under the finger
10
+ * follows it at the centre of the disc, whatever the zoom. North stays up.
11
+ */
12
+ export function dragged(camera, dx, dy, radius, limits = WORLD_LIMITS) {
13
+ const perPixel = 180 / (Math.PI * radius * camera.k);
14
+ // Near the poles a pixel is more longitude; ease it so a drag there does not whip.
15
+ const lonScale = 1 / Math.max(Math.cos((camera.center[1] * Math.PI) / 180), 0.35);
16
+ return {
17
+ center: [
18
+ wrapLon(camera.center[0] - dx * perPixel * Math.min(lonScale, 2)),
19
+ clampTilt(camera.center[1] + dy * perPixel, limits),
20
+ ],
21
+ k: camera.k,
22
+ };
23
+ }
24
+ const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
25
+ /**
26
+ * From one view to another along the great circle (the short way round, so
27
+ * Fiji to Samoa crosses the date line instead of circling the globe), pulling
28
+ * back mid-way on a long turn so the reader sees where they are going. The
29
+ * zoom moves on a log scale, so a flight from the world to a commune does not
30
+ * spend its first half barely zooming.
31
+ */
32
+ export function flight(from, to, limits = WORLD_LIMITS) {
33
+ const arc = (geoDistance(from.center, to.center) * 180) / Math.PI;
34
+ const along = geoInterpolate(from.center, to.center);
35
+ const pull = Math.min(Math.max((arc - 30) / 150, 0), 0.45);
36
+ const lk0 = Math.log(from.k);
37
+ const lk1 = Math.log(to.k);
38
+ return {
39
+ duration: Math.round(450 + Math.min(arc, 180) * 4 + Math.min(Math.abs(lk1 - lk0), 6) * 60),
40
+ at(t) {
41
+ const e = ease(Math.min(Math.max(t, 0), 1));
42
+ const [lon = 0, lat = 0] = along(e);
43
+ const k = Math.exp(lk0 + (lk1 - lk0) * e);
44
+ const dip = 1 - pull * Math.sin(Math.PI * e);
45
+ return {
46
+ center: [wrapLon(lon), clampTilt(lat, limits)],
47
+ k: Math.max(k * dip, limits.minZoom),
48
+ };
49
+ },
50
+ };
51
+ }
52
+ /** One frame of coasting: turn by the velocity, then let it decay. */
53
+ export function coast(camera, spin, dt, radius, limits = WORLD_LIMITS) {
54
+ const next = dragged(camera, spin.vx * dt, spin.vy * dt, radius, limits);
55
+ const decay = Math.exp(-dt / 325);
56
+ const left = { vx: spin.vx * decay, vy: spin.vy * decay };
57
+ return { camera: next, spin: Math.hypot(left.vx, left.vy) < 0.01 ? undefined : left };
58
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Painting the map on a canvas: the fills grouped so each colour is one path,
3
+ * and one frame drawn: atmosphere and lit sphere, graticule, land, borders,
4
+ * limb shade, the hover and selection outlines, then whatever the caller
5
+ * draws over it (sites, routes, figures). Every layer but the land and its
6
+ * borders is optional, so the same painter draws a lit globe of the world and
7
+ * a plain map of communes where the data is the picture.
8
+ */
9
+ import { type GeoProjection } from "d3-geo";
10
+ import type { MapFeature, Shapes } from "./shapes.ts";
11
+ import { type Basis } from "./trace.ts";
12
+ export type Fill = string | CanvasPattern;
13
+ export interface Palette {
14
+ /** The sea, or the page under a map with no base. */
15
+ sphere: string;
16
+ /** The sphere's lit side; absent paints it flat. */
17
+ sphereLight?: string;
18
+ /** Absent draws no graticule. */
19
+ graticule?: string;
20
+ /** A feature with no fill of its own. */
21
+ land: string;
22
+ border: string;
23
+ hover: string;
24
+ selected: string;
25
+ /** A glow past the limb, [inner, outer]; absent draws none. */
26
+ glow?: [string, string];
27
+ /** Shade toward the limb, [inner, outer]; absent draws none. */
28
+ limb?: [string, string];
29
+ /** Border width in CSS pixels (default 0.6). */
30
+ borderWidth?: number;
31
+ }
32
+ /**
33
+ * Any CSS colour at an alpha, for gradients that fade without greying. The
34
+ * canvas normalises the colour first: a build may minify `#000000` to `#000`,
35
+ * and a token could as well be `rgb()`.
36
+ */
37
+ export declare function fade(colour: string, a: number): string;
38
+ /**
39
+ * The fills as one entry per colour, in the order the caller's map first
40
+ * names each colour, then the features with no fill: a path per colour, not
41
+ * per feature. As indexes, so the full and the coarse shapes share it.
42
+ */
43
+ export declare function groupFills(features: readonly MapFeature<unknown>[], fills: ReadonlyMap<string, Fill>, land: Fill): [Fill, number[]][];
44
+ interface Backdrop {
45
+ key: string;
46
+ under: HTMLCanvasElement;
47
+ over: HTMLCanvasElement;
48
+ }
49
+ /** What a frame needs that outlives it: the palette, the fill groups, the cached backdrop. */
50
+ export interface Paint {
51
+ palette: Palette;
52
+ groups: [Fill, number[]][];
53
+ backdrop?: Backdrop;
54
+ }
55
+ export interface Marks<P = Record<string, unknown>> {
56
+ hover?: MapFeature<P>;
57
+ selected?: MapFeature<P>;
58
+ }
59
+ /** What the caller draws over the map, in CSS pixels, with the frame's basis. */
60
+ export type DrawOver = (ctx: CanvasRenderingContext2D, basis: Basis, projection: GeoProjection) => void;
61
+ /** One frame, in CSS pixels (the caller has set the device-pixel transform). */
62
+ export declare function paintMap<P>(ctx: CanvasRenderingContext2D, size: {
63
+ w: number;
64
+ h: number;
65
+ }, proj: GeoProjection, shapes: Shapes<P>, paint: Paint, marks?: Marks<P>, over?: DrawOver): void;
66
+ export {};