@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,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;
|
package/dist/map/pick.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The map's fast path. d3's projection stream spends several sin/cos per
|
|
3
|
+
* vertex per frame (rotate, clip, project); on a phone that is the frame
|
|
4
|
+
* budget. Here every vertex is turned into a unit vector once, at load, and a
|
|
5
|
+
* frame is three dot products per vertex against the view's basis — the same
|
|
6
|
+
* orthographic projection d3 draws (tested against it), without the trig.
|
|
7
|
+
*
|
|
8
|
+
* Behind the globe a polygon's vertices are pulled onto the limb, so a
|
|
9
|
+
* country crossing the horizon fills up to the edge; a line breaks there.
|
|
10
|
+
* Picking and one-off outlines still use d3 (pick.ts, paint.ts).
|
|
11
|
+
*/
|
|
12
|
+
import type { Position } from "geojson";
|
|
13
|
+
/** x, y, z triples of unit vectors. */
|
|
14
|
+
export type Packed = Float64Array;
|
|
15
|
+
export declare function pack(coords: Position[]): Packed;
|
|
16
|
+
/** The view's basis: east and north on screen, forward toward the reader. */
|
|
17
|
+
export interface Basis {
|
|
18
|
+
e: [number, number, number];
|
|
19
|
+
n: [number, number, number];
|
|
20
|
+
f: [number, number, number];
|
|
21
|
+
cx: number;
|
|
22
|
+
cy: number;
|
|
23
|
+
r: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function basisOf(center: [number, number], cx: number, cy: number, r: number): Basis;
|
|
26
|
+
/** [x, y, depth]: depth < 0 is behind the globe. For tests and markers. */
|
|
27
|
+
export declare function project(b: Basis, lon: number, lat: number): [number, number, number];
|
|
28
|
+
interface Pen {
|
|
29
|
+
moveTo(x: number, y: number): void;
|
|
30
|
+
lineTo(x: number, y: number): void;
|
|
31
|
+
closePath(): void;
|
|
32
|
+
}
|
|
33
|
+
/** A closed ring; vertices behind the globe are pulled onto the limb. */
|
|
34
|
+
export declare function traceRing(pen: Pen, ring: Packed, b: Basis): void;
|
|
35
|
+
/** An open line; it breaks where it passes behind the globe. */
|
|
36
|
+
export declare function traceLine(pen: Pen, line: Packed, b: Basis): void;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const RAD = Math.PI / 180;
|
|
2
|
+
export function pack(coords) {
|
|
3
|
+
const out = new Float64Array(coords.length * 3);
|
|
4
|
+
coords.forEach(([lon = 0, lat = 0], i) => {
|
|
5
|
+
const l = lon * RAD;
|
|
6
|
+
const p = lat * RAD;
|
|
7
|
+
out[i * 3] = Math.cos(p) * Math.cos(l);
|
|
8
|
+
out[i * 3 + 1] = Math.cos(p) * Math.sin(l);
|
|
9
|
+
out[i * 3 + 2] = Math.sin(p);
|
|
10
|
+
});
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
export function basisOf(center, cx, cy, r) {
|
|
14
|
+
const l = center[0] * RAD;
|
|
15
|
+
const p = center[1] * RAD;
|
|
16
|
+
return {
|
|
17
|
+
e: [-Math.sin(l), Math.cos(l), 0],
|
|
18
|
+
n: [-Math.sin(p) * Math.cos(l), -Math.sin(p) * Math.sin(l), Math.cos(p)],
|
|
19
|
+
f: [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)],
|
|
20
|
+
cx,
|
|
21
|
+
cy,
|
|
22
|
+
r,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** [x, y, depth]: depth < 0 is behind the globe. For tests and markers. */
|
|
26
|
+
export function project(b, lon, lat) {
|
|
27
|
+
const [x = 0, y = 0, z = 0] = pack([[lon, lat]]);
|
|
28
|
+
const u = b.e[0] * x + b.e[1] * y + b.e[2] * z;
|
|
29
|
+
const v = b.n[0] * x + b.n[1] * y + b.n[2] * z;
|
|
30
|
+
return [b.cx + b.r * u, b.cy - b.r * v, b.f[0] * x + b.f[1] * y + b.f[2] * z];
|
|
31
|
+
}
|
|
32
|
+
/** A closed ring; vertices behind the globe are pulled onto the limb. */
|
|
33
|
+
export function traceRing(pen, ring, b) {
|
|
34
|
+
const { e, n, f, cx, cy, r } = b;
|
|
35
|
+
for (let i = 0; i < ring.length; i += 3) {
|
|
36
|
+
const x = ring[i];
|
|
37
|
+
const y = ring[i + 1];
|
|
38
|
+
const z = ring[i + 2];
|
|
39
|
+
let u = e[0] * x + e[1] * y + e[2] * z;
|
|
40
|
+
let v = n[0] * x + n[1] * y + n[2] * z;
|
|
41
|
+
if (f[0] * x + f[1] * y + f[2] * z < 0) {
|
|
42
|
+
const m = Math.hypot(u, v) || 1;
|
|
43
|
+
u /= m;
|
|
44
|
+
v /= m;
|
|
45
|
+
}
|
|
46
|
+
if (i === 0)
|
|
47
|
+
pen.moveTo(cx + r * u, cy - r * v);
|
|
48
|
+
else
|
|
49
|
+
pen.lineTo(cx + r * u, cy - r * v);
|
|
50
|
+
}
|
|
51
|
+
pen.closePath();
|
|
52
|
+
}
|
|
53
|
+
/** An open line; it breaks where it passes behind the globe. */
|
|
54
|
+
export function traceLine(pen, line, b) {
|
|
55
|
+
const { e, n, f, cx, cy, r } = b;
|
|
56
|
+
let down = false;
|
|
57
|
+
for (let i = 0; i < line.length; i += 3) {
|
|
58
|
+
const x = line[i];
|
|
59
|
+
const y = line[i + 1];
|
|
60
|
+
const z = line[i + 2];
|
|
61
|
+
if (f[0] * x + f[1] * y + f[2] * z < 0) {
|
|
62
|
+
down = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const px = cx + r * (e[0] * x + e[1] * y + e[2] * z);
|
|
66
|
+
const py = cy - r * (n[0] * x + n[1] * y + n[2] * z);
|
|
67
|
+
if (down)
|
|
68
|
+
pen.lineTo(px, py);
|
|
69
|
+
else
|
|
70
|
+
pen.moveTo(px, py);
|
|
71
|
+
down = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitbaum/geo-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Versioned, on-demand geography resources and sourced boundary assertions for web applications.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Versioned, on-demand geography resources and sourced boundary assertions for web applications, and a Canvas 2D renderer for them (globe or map).",
|
|
5
5
|
"author": "BitBaum",
|
|
6
6
|
"homepage": "https://github.com/bitbaum/geo-kit#readme",
|
|
7
7
|
"repository": {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"type": "module",
|
|
16
16
|
"packageManager": "pnpm@11.25.0",
|
|
17
|
-
"engines": {
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
18
20
|
"main": "./dist/index.js",
|
|
19
21
|
"types": "./dist/index.d.ts",
|
|
20
22
|
"exports": {
|
|
@@ -22,10 +24,22 @@
|
|
|
22
24
|
"types": "./dist/index.d.ts",
|
|
23
25
|
"import": "./dist/index.js",
|
|
24
26
|
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./map": {
|
|
29
|
+
"types": "./dist/map/index.d.ts",
|
|
30
|
+
"import": "./dist/map/index.js",
|
|
31
|
+
"default": "./dist/map/index.js"
|
|
25
32
|
}
|
|
26
33
|
},
|
|
27
|
-
"files": [
|
|
28
|
-
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
},
|
|
29
43
|
"sideEffects": false,
|
|
30
44
|
"scripts": {
|
|
31
45
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -34,8 +48,27 @@
|
|
|
34
48
|
"pack:check": "node scripts/check-pack.mjs",
|
|
35
49
|
"verify": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run pack:check"
|
|
36
50
|
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"d3-geo": "^3.1.0",
|
|
53
|
+
"topojson-client": "^3.1.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"d3-geo": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"topojson-client": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
37
63
|
"devDependencies": {
|
|
64
|
+
"@types/d3-geo": "^3.1.1",
|
|
65
|
+
"@types/geojson": "^7946.0.16",
|
|
38
66
|
"@types/node": "^22.0.0",
|
|
39
|
-
"
|
|
67
|
+
"@types/topojson-client": "^3.1.5",
|
|
68
|
+
"@types/topojson-specification": "^1.0.5",
|
|
69
|
+
"d3-geo": "3.1.1",
|
|
70
|
+
"topojson-client": "3.1.0",
|
|
71
|
+
"typescript": "^5.8.0",
|
|
72
|
+
"world-atlas": "2.0.2"
|
|
40
73
|
}
|
|
41
74
|
}
|