@bitbaum/geo-kit 0.1.2 → 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.
@@ -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 {};
@@ -0,0 +1,174 @@
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 { geoGraticule10, geoPath } from "d3-geo";
10
+ import { centreOf, horizonOf, visible } from "./cull.js";
11
+ import { basisOf, pack, traceLine, traceRing } from "./trace.js";
12
+ const FULL = Math.PI * 2;
13
+ /**
14
+ * Any CSS colour at an alpha, for gradients that fade without greying. The
15
+ * canvas normalises the colour first: a build may minify `#000000` to `#000`,
16
+ * and a token could as well be `rgb()`.
17
+ */
18
+ export function fade(colour, a) {
19
+ const probe = document.createElement("canvas").getContext("2d");
20
+ if (!probe)
21
+ return colour;
22
+ probe.fillStyle = "#000";
23
+ probe.fillStyle = colour;
24
+ const norm = String(probe.fillStyle);
25
+ const hex = /^#([0-9a-f]{6})$/i.exec(norm);
26
+ if (hex?.[1]) {
27
+ const n = parseInt(hex[1], 16);
28
+ return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`;
29
+ }
30
+ const rgba = /^rgba?\(([^,]+),([^,]+),([^,)]+)/.exec(norm.replace(/\s/g, ""));
31
+ return rgba ? `rgba(${rgba[1]},${rgba[2]},${rgba[3]},${a})` : colour;
32
+ }
33
+ /**
34
+ * The fills as one entry per colour, in the order the caller's map first
35
+ * names each colour, then the features with no fill: a path per colour, not
36
+ * per feature. As indexes, so the full and the coarse shapes share it.
37
+ */
38
+ export function groupFills(features, fills, land) {
39
+ const groups = new Map();
40
+ for (const fill of fills.values())
41
+ if (!groups.has(fill))
42
+ groups.set(fill, []);
43
+ groups.set(land, groups.get(land) ?? []);
44
+ features.forEach((f, i) => groups.get(fills.get(f.id) ?? land).push(i));
45
+ // The plain land first, so a coloured feature's edge is never painted over.
46
+ const plain = groups.get(land);
47
+ groups.delete(land);
48
+ return [[land, plain], ...groups.entries()].filter(([, members]) => members.length > 0);
49
+ }
50
+ const GRATICULE = geoGraticule10().coordinates.map(pack);
51
+ function blit(ctx, layer) {
52
+ ctx.save();
53
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
54
+ ctx.drawImage(layer, 0, 0);
55
+ ctx.restore();
56
+ }
57
+ /**
58
+ * Atmosphere and sphere (under the land) and the limb shade (over it) do not
59
+ * turn with the Earth: render them once per disc and blit them, instead of
60
+ * full-disc gradients every frame.
61
+ */
62
+ function backdrop(paint, target, cx, cy, r, size) {
63
+ const key = [target.width, target.height, cx, cy, r].map((n) => n.toFixed(1)).join();
64
+ if (paint.backdrop?.key === key)
65
+ return paint.backdrop;
66
+ const { palette } = paint;
67
+ const scale = target.width / size.w;
68
+ const layer = (draw) => {
69
+ const canvas = document.createElement("canvas");
70
+ canvas.width = target.width;
71
+ canvas.height = target.height;
72
+ const c = canvas.getContext("2d");
73
+ if (c) {
74
+ c.setTransform(scale, 0, 0, scale, 0, 0);
75
+ draw(c);
76
+ }
77
+ return canvas;
78
+ };
79
+ const disc = (c, radius, fill) => {
80
+ c.fillStyle = fill;
81
+ c.beginPath();
82
+ c.arc(cx, cy, radius, 0, FULL);
83
+ c.fill();
84
+ };
85
+ const under = layer((c) => {
86
+ if (palette.glow && r < Math.hypot(size.w, size.h)) {
87
+ const glow = c.createRadialGradient(cx, cy, r * 0.97, cx, cy, r * 1.12);
88
+ glow.addColorStop(0, palette.glow[0]);
89
+ glow.addColorStop(1, palette.glow[1]);
90
+ disc(c, r * 1.12, glow);
91
+ }
92
+ if (palette.sphereLight) {
93
+ // The sphere, lit from the upper left.
94
+ const sea = c.createRadialGradient(cx - r * 0.4, cy - r * 0.45, r * 0.05, cx, cy, r);
95
+ sea.addColorStop(0, palette.sphereLight);
96
+ sea.addColorStop(1, palette.sphere);
97
+ disc(c, r, sea);
98
+ }
99
+ else
100
+ disc(c, r, palette.sphere);
101
+ });
102
+ const over = layer((c) => {
103
+ if (!palette.limb)
104
+ return;
105
+ const limb = c.createRadialGradient(cx, cy, r * 0.55, cx, cy, r);
106
+ limb.addColorStop(0, palette.limb[0]);
107
+ limb.addColorStop(1, palette.limb[1]);
108
+ disc(c, r, limb);
109
+ });
110
+ paint.backdrop = { key, under, over };
111
+ return paint.backdrop;
112
+ }
113
+ /** One frame, in CSS pixels (the caller has set the device-pixel transform). */
114
+ export function paintMap(ctx, size, proj, shapes, paint, marks = {}, over) {
115
+ const { palette, groups } = paint;
116
+ const [cx, cy] = proj.translate();
117
+ const r = proj.scale();
118
+ ctx.clearRect(0, 0, size.w, size.h);
119
+ const { under, over: shade } = backdrop(paint, ctx.canvas, cx, cy, r, size);
120
+ blit(ctx, under);
121
+ const centre = centreOf(proj);
122
+ const horizon = horizonOf(proj, size);
123
+ const basis = basisOf(centre, cx, cy, r);
124
+ if (palette.graticule) {
125
+ ctx.beginPath();
126
+ for (const line of GRATICULE)
127
+ traceLine(ctx, line, basis);
128
+ ctx.strokeStyle = palette.graticule;
129
+ ctx.lineWidth = 0.5;
130
+ ctx.stroke();
131
+ }
132
+ for (const [fill, members] of groups) {
133
+ ctx.beginPath();
134
+ for (const i of members)
135
+ if (visible(shapes.caps[i], centre, horizon))
136
+ for (const ring of shapes.rings[i])
137
+ traceRing(ctx, ring, basis);
138
+ ctx.fillStyle = fill;
139
+ ctx.fill("evenodd");
140
+ }
141
+ ctx.beginPath();
142
+ shapes.lines.forEach((line, i) => {
143
+ if (visible(shapes.lineCaps[i], centre, horizon))
144
+ traceLine(ctx, line, basis);
145
+ });
146
+ ctx.strokeStyle = palette.border;
147
+ ctx.lineWidth = palette.borderWidth ?? 0.6;
148
+ ctx.lineJoin = "round";
149
+ ctx.stroke();
150
+ blit(ctx, shade);
151
+ const path = geoPath(proj, ctx);
152
+ const outline = (f, width, colour) => {
153
+ if (!f)
154
+ return;
155
+ const feature = { type: "Feature", geometry: f.geometry, properties: null };
156
+ ctx.beginPath();
157
+ path(feature);
158
+ ctx.strokeStyle = colour;
159
+ ctx.lineWidth = width;
160
+ ctx.stroke();
161
+ // Too small to see at this zoom: ring it.
162
+ const [[x0, y0], [x1, y1]] = path.bounds(feature);
163
+ if (Number.isFinite(x0) && Math.max(x1 - x0, y1 - y0) < 10) {
164
+ ctx.beginPath();
165
+ ctx.arc((x0 + x1) / 2, (y0 + y1) / 2, 11, 0, FULL);
166
+ ctx.lineWidth = 1.5;
167
+ ctx.stroke();
168
+ }
169
+ };
170
+ if (marks.hover !== marks.selected)
171
+ outline(marks.hover, 1.25, palette.hover);
172
+ outline(marks.selected, 2, palette.selected);
173
+ over?.(ctx, basis, proj);
174
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Which feature is under a pixel of the map: invert the projection, then test
3
+ * the few features whose lon/lat box holds the point.
4
+ */
5
+ import { type GeoProjection } from "d3-geo";
6
+ import type { MapFeature } from "./shapes.ts";
7
+ export interface Pickable<P = Record<string, unknown>> {
8
+ feature: MapFeature<P>;
9
+ /** lon/lat box; west > east when it crosses the antimeridian. */
10
+ box: [[number, number], [number, number]];
11
+ }
12
+ export declare function pickIndex<P>(features: readonly MapFeature<P>[]): Pickable<P>[];
13
+ /**
14
+ * The feature under a canvas pixel, or null for sea and the space around the
15
+ * globe. `invert` is the projection's; off the disc it has no answer.
16
+ */
17
+ export declare function pickAt<P>(index: readonly Pickable<P>[], projection: GeoProjection, x: number, y: number): MapFeature<P> | null;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which feature is under a pixel of the map: invert the projection, then test
3
+ * the few features whose lon/lat box holds the point.
4
+ */
5
+ import { geoBounds, geoContains } from "d3-geo";
6
+ export function pickIndex(features) {
7
+ return features.map((feature) => ({
8
+ feature,
9
+ box: geoBounds({ type: "Feature", geometry: feature.geometry, properties: null }),
10
+ }));
11
+ }
12
+ function inBox([[w, s], [e, n]], [lon, lat]) {
13
+ if (lat < s || lat > n)
14
+ return false;
15
+ return w <= e ? lon >= w && lon <= e : lon >= w || lon <= e;
16
+ }
17
+ /**
18
+ * The feature under a canvas pixel, or null for sea and the space around the
19
+ * globe. `invert` is the projection's; off the disc it has no answer.
20
+ */
21
+ export function pickAt(index, projection, x, y) {
22
+ const [cx, cy] = projection.translate();
23
+ const r = projection.scale();
24
+ if ((x - cx) ** 2 + (y - cy) ** 2 > r * r)
25
+ return null;
26
+ const at = projection.invert?.([x, y]);
27
+ if (!at || !Number.isFinite(at[0]) || !Number.isFinite(at[1]))
28
+ return null;
29
+ const point = [at[0], at[1]];
30
+ for (const p of index)
31
+ if (inBox(p.box, point) && geoContains({ type: "Feature", geometry: p.feature.geometry, properties: null }, point))
32
+ return p.feature;
33
+ return null;
34
+ }
@@ -0,0 +1,59 @@
1
+ import type { Feature, GeoJsonProperties, Geometry, MultiLineString, Position } from "geojson";
2
+ import type { Topology } from "topojson-specification";
3
+ import { type Cap } from "./cull.ts";
4
+ import { type Packed } from "./trace.ts";
5
+ /** A feature as the map holds it: its id, its geometry and the caller's properties. */
6
+ export interface MapFeature<P = Record<string, unknown>> {
7
+ id: string;
8
+ geometry: Geometry;
9
+ properties: P;
10
+ }
11
+ export interface Shapes<P = Record<string, unknown>> {
12
+ features: MapFeature<P>[];
13
+ /** Where each id sits in `features`, `caps` and `rings`. */
14
+ index: Map<string, number>;
15
+ /** Every border and coast once. */
16
+ borders: MultiLineString;
17
+ caps: Cap[];
18
+ lineCaps: Cap[];
19
+ rings: Packed[][];
20
+ lines: Packed[];
21
+ }
22
+ /** The full detail and the coarse copy painted in motion. */
23
+ export interface MapShapes<P = Record<string, unknown>> extends Shapes<P> {
24
+ coarse: Shapes<P>;
25
+ }
26
+ type Ring = Position[];
27
+ export declare function polygonsOf(g: Geometry): Ring[][];
28
+ /**
29
+ * d3-geo is spherical: a ring wound the "wrong" way encloses the rest of the
30
+ * globe, so one bad polygon floods the ocean with that feature's colour.
31
+ * Simplification leaves a few such rings, and planar data (most national
32
+ * boundary files) is wound the other way throughout; a polygon covering more
33
+ * than a hemisphere is one of them, and reversing its rings puts it right.
34
+ */
35
+ export declare function rewound(g: Geometry): Geometry;
36
+ /** Prepare features and their shared borders for painting. */
37
+ export declare function shapesOf<P>(features: readonly MapFeature<P>[], borders: MultiLineString): Shapes<P>;
38
+ /**
39
+ * Every `every`-th vertex of each arc, ends kept. Thinning the topology's arcs
40
+ * rather than each feature's rings keeps a shared border shared: neighbours
41
+ * still meet exactly, with no slivers between them.
42
+ */
43
+ export declare function coarsened<T extends Topology>(topo: T, every: number): T;
44
+ export interface TopologyOptions<P extends GeoJsonProperties> {
45
+ /** The object in `topo.objects` to draw. */
46
+ object: string;
47
+ /** The id each feature is known by; null leaves the feature out. */
48
+ idOf: (feature: Feature<Geometry, P>) => string | null;
49
+ /** Keep every n-th vertex for the copy painted in motion (default 3; 1 keeps all). */
50
+ coarseEvery?: number;
51
+ }
52
+ /** Shapes from one object of a TopoJSON topology, with its coarse copy. */
53
+ export declare function shapesFromTopology<P extends GeoJsonProperties>(topo: Topology, options: TopologyOptions<P>): MapShapes<P>;
54
+ /**
55
+ * Shapes from GeoJSON features, whose borders are their rings (a feature
56
+ * collection carries no shared arcs). Painted in motion as they are.
57
+ */
58
+ export declare function shapesFromFeatures<P>(features: readonly MapFeature<P>[]): MapShapes<P>;
59
+ export {};
@@ -0,0 +1,105 @@
1
+ /**
2
+ * What a map draws, prepared once: each feature keyed by its id, wound the
3
+ * way d3's spherical geometry needs, its far-side cap (cull.ts) and its rings
4
+ * as unit vectors (trace.ts); every shared border once, so the outline is one
5
+ * stroke, not one per feature. A coarse copy, the same features in the same
6
+ * order, is painted while the map moves so a drag stays inside a phone's
7
+ * frame budget; the frame after it stops is full detail again.
8
+ *
9
+ * Country-neutral: the features are whatever the caller loaded (countries,
10
+ * cantons, communes), keyed by whatever id the caller's data uses.
11
+ */
12
+ import { geoArea } from "d3-geo";
13
+ import { feature as topoFeature, mesh } from "topojson-client";
14
+ import { capOf } from "./cull.js";
15
+ import { pack } from "./trace.js";
16
+ export function polygonsOf(g) {
17
+ if (g.type === "Polygon")
18
+ return [g.coordinates];
19
+ if (g.type === "MultiPolygon")
20
+ return g.coordinates;
21
+ return [];
22
+ }
23
+ /**
24
+ * d3-geo is spherical: a ring wound the "wrong" way encloses the rest of the
25
+ * globe, so one bad polygon floods the ocean with that feature's colour.
26
+ * Simplification leaves a few such rings, and planar data (most national
27
+ * boundary files) is wound the other way throughout; a polygon covering more
28
+ * than a hemisphere is one of them, and reversing its rings puts it right.
29
+ */
30
+ export function rewound(g) {
31
+ const fix = (rings) => {
32
+ const area = geoArea({ type: "Polygon", coordinates: rings });
33
+ return area > 2 * Math.PI ? rings.map((ring) => [...ring].reverse()) : rings;
34
+ };
35
+ if (g.type === "Polygon")
36
+ return { ...g, coordinates: fix(g.coordinates) };
37
+ if (g.type === "MultiPolygon")
38
+ return { ...g, coordinates: g.coordinates.map(fix) };
39
+ return g;
40
+ }
41
+ /** Prepare features and their shared borders for painting. */
42
+ export function shapesOf(features, borders) {
43
+ const kept = features.map((f) => ({ ...f, geometry: rewound(f.geometry) }));
44
+ return {
45
+ features: kept,
46
+ index: new Map(kept.map((f, i) => [f.id, i])),
47
+ borders,
48
+ caps: kept.map((f) => capOf(f.geometry)),
49
+ lineCaps: borders.coordinates.map((line) => capOf({ type: "LineString", coordinates: line })),
50
+ rings: kept.map((f) => polygonsOf(f.geometry).flat().map(pack)),
51
+ lines: borders.coordinates.map(pack),
52
+ };
53
+ }
54
+ /**
55
+ * Every `every`-th vertex of each arc, ends kept. Thinning the topology's arcs
56
+ * rather than each feature's rings keeps a shared border shared: neighbours
57
+ * still meet exactly, with no slivers between them.
58
+ */
59
+ export function coarsened(topo, every) {
60
+ const t = topo.transform;
61
+ const arcs = topo.arcs.map((arc) => {
62
+ let x = 0;
63
+ let y = 0;
64
+ const absolute = t
65
+ ? arc.map(([dx = 0, dy = 0]) => {
66
+ x += dx;
67
+ y += dy;
68
+ return [x * t.scale[0] + t.translate[0], y * t.scale[1] + t.translate[1]];
69
+ })
70
+ : arc;
71
+ return absolute.filter((_, i) => i % every === 0 || i === absolute.length - 1);
72
+ });
73
+ return { ...topo, transform: undefined, arcs };
74
+ }
75
+ function fromTopology(topo, options) {
76
+ const object = topo.objects[options.object];
77
+ if (!object)
78
+ throw new Error(`topology has no object "${options.object}"`);
79
+ const collection = topoFeature(topo, object);
80
+ const features = collection.features.flatMap((f) => {
81
+ const id = options.idOf(f);
82
+ return id === null || !f.geometry ? [] : [{ id, geometry: f.geometry, properties: f.properties }];
83
+ });
84
+ return shapesOf(features, mesh(topo, object));
85
+ }
86
+ /** Shapes from one object of a TopoJSON topology, with its coarse copy. */
87
+ export function shapesFromTopology(topo, options) {
88
+ const every = options.coarseEvery ?? 3;
89
+ return {
90
+ ...fromTopology(topo, options),
91
+ coarse: fromTopology(every > 1 ? coarsened(topo, every) : topo, options),
92
+ };
93
+ }
94
+ /**
95
+ * Shapes from GeoJSON features, whose borders are their rings (a feature
96
+ * collection carries no shared arcs). Painted in motion as they are.
97
+ */
98
+ export function shapesFromFeatures(features) {
99
+ const borders = {
100
+ type: "MultiLineString",
101
+ coordinates: features.flatMap((f) => polygonsOf(f.geometry).flat()),
102
+ };
103
+ const shapes = shapesOf(features, borders);
104
+ return { ...shapes, coarse: shapes };
105
+ }