@bitbaum/geo-kit 0.1.1 → 0.2.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 +57 -9
- package/dist/map/camera.d.ts +86 -0
- package/dist/map/camera.js +118 -0
- package/dist/map/controller.d.ts +72 -0
- package/dist/map/controller.js +344 -0
- package/dist/map/cull.d.ts +28 -0
- package/dist/map/cull.js +46 -0
- package/dist/map/index.d.ts +17 -0
- package/dist/map/index.js +17 -0
- package/dist/map/motion.d.ts +29 -0
- package/dist/map/motion.js +58 -0
- package/dist/map/paint.d.ts +66 -0
- package/dist/map/paint.js +174 -0
- package/dist/map/pick.d.ts +17 -0
- package/dist/map/pick.js +34 -0
- package/dist/map/shapes.d.ts +59 -0
- package/dist/map/shapes.js +105 -0
- package/dist/map/trace.d.ts +37 -0
- package/dist/map/trace.js +73 -0
- package/package.json +39 -6
|
@@ -0,0 +1,344 @@
|
|
|
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 ? (c, basis, p) => options.over(c, basis, p, { now, flight: flightAt, moving }) : undefined);
|
|
106
|
+
}
|
|
107
|
+
if (again)
|
|
108
|
+
raf = requestAnimationFrame(tick);
|
|
109
|
+
else
|
|
110
|
+
last = 0;
|
|
111
|
+
};
|
|
112
|
+
function invalidate() {
|
|
113
|
+
if (!raf && !destroyed)
|
|
114
|
+
raf = requestAnimationFrame(tick);
|
|
115
|
+
}
|
|
116
|
+
const flyTo = (to, duration) => {
|
|
117
|
+
const target = { center: [wrapLon(to.center[0]), clampTilt(to.center[1], limits)], k: clampZoom(to.k, limits) };
|
|
118
|
+
motion = {};
|
|
119
|
+
touched = performance.now();
|
|
120
|
+
if (reducedMotion())
|
|
121
|
+
camera = target;
|
|
122
|
+
else {
|
|
123
|
+
const f = flight(camera, target, limits);
|
|
124
|
+
motion.flight = { f: duration ? { ...f, duration } : f, start: performance.now() };
|
|
125
|
+
}
|
|
126
|
+
invalidate();
|
|
127
|
+
};
|
|
128
|
+
const grab = () => {
|
|
129
|
+
motion = {};
|
|
130
|
+
touched = performance.now();
|
|
131
|
+
};
|
|
132
|
+
const scale = (factor) => {
|
|
133
|
+
camera = { ...camera, k: clampZoom(camera.k * factor, limits) };
|
|
134
|
+
invalidate();
|
|
135
|
+
};
|
|
136
|
+
const zoomBy = (factor) => {
|
|
137
|
+
if (factor === "home")
|
|
138
|
+
flyTo(home);
|
|
139
|
+
else
|
|
140
|
+
flyTo({ ...camera, k: camera.k * factor }, 260);
|
|
141
|
+
};
|
|
142
|
+
const pick = (x, y) => {
|
|
143
|
+
const proj = projection();
|
|
144
|
+
return proj ? pickAt(index, proj, x, y) : null;
|
|
145
|
+
};
|
|
146
|
+
const choose = (id) => options.onSelect?.(id);
|
|
147
|
+
/* ------------------------------------------------------------ input */
|
|
148
|
+
const pointers = new Map();
|
|
149
|
+
let gesture = { moved: false, vx: 0, vy: 0, t: 0, startX: 0, startY: 0 };
|
|
150
|
+
const local = (e) => {
|
|
151
|
+
const box = canvas.getBoundingClientRect();
|
|
152
|
+
return { x: e.clientX - box.left, y: e.clientY - box.top };
|
|
153
|
+
};
|
|
154
|
+
const on = {
|
|
155
|
+
keydown(e) {
|
|
156
|
+
const step = e.shiftKey ? 30 : 10;
|
|
157
|
+
const perStep = step / Math.max(camera.k, 1);
|
|
158
|
+
const turn = (dLon, dLat) => flyTo({ center: [camera.center[0] + dLon * perStep, camera.center[1] + dLat * perStep], k: camera.k }, 220);
|
|
159
|
+
const keys = {
|
|
160
|
+
ArrowLeft: () => turn(-1, 0),
|
|
161
|
+
ArrowRight: () => turn(1, 0),
|
|
162
|
+
ArrowUp: () => turn(0, 1),
|
|
163
|
+
ArrowDown: () => turn(0, -1),
|
|
164
|
+
"+": () => zoomBy(1.5),
|
|
165
|
+
"=": () => zoomBy(1.5),
|
|
166
|
+
"-": () => zoomBy(1 / 1.5),
|
|
167
|
+
"0": () => zoomBy("home"),
|
|
168
|
+
Home: () => zoomBy("home"),
|
|
169
|
+
Enter: () => {
|
|
170
|
+
const view = viewAt(performance.now());
|
|
171
|
+
if (!view)
|
|
172
|
+
return;
|
|
173
|
+
const d = disc(view);
|
|
174
|
+
choose(pick(d.cx, d.cy)?.id ?? null);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const run = keys[e.key];
|
|
178
|
+
if (!run)
|
|
179
|
+
return;
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
run();
|
|
182
|
+
},
|
|
183
|
+
pointerdown(e) {
|
|
184
|
+
if (e.pointerType === "mouse" && e.button !== 0)
|
|
185
|
+
return;
|
|
186
|
+
try {
|
|
187
|
+
canvas.setPointerCapture(e.pointerId);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// An untrusted or already-lifted pointer cannot be captured; the drag works without.
|
|
191
|
+
}
|
|
192
|
+
const at = local(e);
|
|
193
|
+
pointers.set(e.pointerId, at);
|
|
194
|
+
if (pointers.size === 1)
|
|
195
|
+
gesture = { moved: false, vx: 0, vy: 0, t: e.timeStamp, startX: at.x, startY: at.y };
|
|
196
|
+
else
|
|
197
|
+
gesture.moved = true; // a second finger is never a tap
|
|
198
|
+
grab();
|
|
199
|
+
grabbed = true;
|
|
200
|
+
},
|
|
201
|
+
pointermove(e) {
|
|
202
|
+
const at = local(e);
|
|
203
|
+
const before = pointers.get(e.pointerId);
|
|
204
|
+
if (!before) {
|
|
205
|
+
if (e.pointerType === "mouse") {
|
|
206
|
+
const id = pick(at.x, at.y)?.id ?? null;
|
|
207
|
+
canvas.style.cursor = id ? "pointer" : "";
|
|
208
|
+
if (id !== hover) {
|
|
209
|
+
hover = id;
|
|
210
|
+
invalidate();
|
|
211
|
+
}
|
|
212
|
+
options.onHover?.(id, at.x, at.y);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const g = gesture;
|
|
217
|
+
if (pointers.size === 1) {
|
|
218
|
+
if (Math.hypot(at.x - g.startX, at.y - g.startY) > TAP_SLOP)
|
|
219
|
+
g.moved = true;
|
|
220
|
+
if (g.moved) {
|
|
221
|
+
const dx = at.x - before.x;
|
|
222
|
+
const dy = at.y - before.y;
|
|
223
|
+
camera = dragged(camera, dx, dy, radius(), limits);
|
|
224
|
+
invalidate();
|
|
225
|
+
const dt = Math.max(e.timeStamp - g.t, 1);
|
|
226
|
+
// Smoothed velocity in px/ms, for the coast after release.
|
|
227
|
+
g.vx = g.vx * 0.6 + (dx / dt) * 0.4;
|
|
228
|
+
g.vy = g.vy * 0.6 + (dy / dt) * 0.4;
|
|
229
|
+
g.t = e.timeStamp;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (pointers.size === 2) {
|
|
233
|
+
const other = [...pointers.entries()].find(([id]) => id !== e.pointerId)?.[1];
|
|
234
|
+
if (other) {
|
|
235
|
+
const d0 = Math.hypot(before.x - other.x, before.y - other.y);
|
|
236
|
+
const d1 = Math.hypot(at.x - other.x, at.y - other.y);
|
|
237
|
+
if (d0 > 0)
|
|
238
|
+
scale(d1 / d0);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
pointers.set(e.pointerId, at);
|
|
242
|
+
},
|
|
243
|
+
pointerup(e) {
|
|
244
|
+
if (!pointers.delete(e.pointerId) || pointers.size > 0)
|
|
245
|
+
return;
|
|
246
|
+
grabbed = false;
|
|
247
|
+
invalidate();
|
|
248
|
+
const g = gesture;
|
|
249
|
+
if (!g.moved) {
|
|
250
|
+
const at = local(e);
|
|
251
|
+
choose(pick(at.x, at.y)?.id ?? null);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const fresh = e.timeStamp - g.t < 80;
|
|
255
|
+
if (fresh && !reducedMotion() && Math.hypot(g.vx, g.vy) > 0.05) {
|
|
256
|
+
motion.spin = { vx: g.vx, vy: g.vy };
|
|
257
|
+
invalidate();
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
pointercancel(e) {
|
|
261
|
+
pointers.delete(e.pointerId);
|
|
262
|
+
if (pointers.size === 0) {
|
|
263
|
+
grabbed = false;
|
|
264
|
+
invalidate();
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
pointerleave(e) {
|
|
268
|
+
if (e.pointerType !== "mouse" || hover === null)
|
|
269
|
+
return;
|
|
270
|
+
hover = null;
|
|
271
|
+
canvas.style.cursor = "";
|
|
272
|
+
options.onHover?.(null, 0, 0);
|
|
273
|
+
invalidate();
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
const wheel = (e) => {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
const px = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
|
|
279
|
+
grab();
|
|
280
|
+
scale(Math.exp(-px * 0.0018));
|
|
281
|
+
};
|
|
282
|
+
const listeners = Object.entries(on).map(([type, fn]) => [type, fn]);
|
|
283
|
+
for (const [type, fn] of listeners)
|
|
284
|
+
canvas.addEventListener(type, fn);
|
|
285
|
+
canvas.addEventListener("wheel", wheel, { passive: false });
|
|
286
|
+
canvas.style.touchAction = "none";
|
|
287
|
+
if (!canvas.hasAttribute("tabindex"))
|
|
288
|
+
canvas.tabIndex = 0;
|
|
289
|
+
return {
|
|
290
|
+
resize(width, height, view) {
|
|
291
|
+
const dpr = pixelRatio();
|
|
292
|
+
const w = Math.round(width * dpr);
|
|
293
|
+
const h = Math.round(height * dpr);
|
|
294
|
+
if (canvas.width !== w || canvas.height !== h) {
|
|
295
|
+
canvas.width = w;
|
|
296
|
+
canvas.height = h;
|
|
297
|
+
}
|
|
298
|
+
size = { w: width, h: height };
|
|
299
|
+
const to = view ?? { x0: 0, y0: 0, x1: width, y1: height };
|
|
300
|
+
const now = performance.now();
|
|
301
|
+
const from = viewAt(now);
|
|
302
|
+
shown = from && !reducedMotion() ? { from, to, start: now } : { from: to, to, start: 0 };
|
|
303
|
+
invalidate();
|
|
304
|
+
},
|
|
305
|
+
setShapes(next) {
|
|
306
|
+
shapes = next;
|
|
307
|
+
index = pickIndex(shapes.features);
|
|
308
|
+
paint = { palette: paint.palette, groups: groupFills(shapes.features, fills, paint.palette.land) };
|
|
309
|
+
invalidate();
|
|
310
|
+
},
|
|
311
|
+
setFills(next) {
|
|
312
|
+
fills = next;
|
|
313
|
+
paint = { ...paint, groups: groupFills(shapes.features, fills, paint.palette.land) };
|
|
314
|
+
invalidate();
|
|
315
|
+
},
|
|
316
|
+
setPalette(palette) {
|
|
317
|
+
paint = { palette, groups: groupFills(shapes.features, fills, palette.land) };
|
|
318
|
+
invalidate();
|
|
319
|
+
},
|
|
320
|
+
setSelected(id, { fly = false } = {}) {
|
|
321
|
+
selected = id;
|
|
322
|
+
const f = feature(id);
|
|
323
|
+
const to = f && fly ? cameraFor(f.geometry, limits, options.focusZoom) : null;
|
|
324
|
+
if (to)
|
|
325
|
+
flyTo(to);
|
|
326
|
+
else
|
|
327
|
+
invalidate();
|
|
328
|
+
},
|
|
329
|
+
flyTo,
|
|
330
|
+
zoomBy,
|
|
331
|
+
camera: () => camera,
|
|
332
|
+
projection: () => projection(),
|
|
333
|
+
invalidate,
|
|
334
|
+
destroy() {
|
|
335
|
+
destroyed = true;
|
|
336
|
+
cancelAnimationFrame(raf);
|
|
337
|
+
raf = 0;
|
|
338
|
+
window.clearTimeout(wake);
|
|
339
|
+
for (const [type, fn] of listeners)
|
|
340
|
+
canvas.removeEventListener(type, fn);
|
|
341
|
+
canvas.removeEventListener("wheel", wheel);
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -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;
|
package/dist/map/cull.js
ADDED
|
@@ -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 {};
|