@react-x11/components 0.4.0 → 0.5.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 +125 -4
- package/dist/charts/components.d.ts +3 -1
- package/dist/charts/components.d.ts.map +1 -1
- package/dist/charts/components.js +8 -4
- package/dist/charts/components.js.map +1 -1
- package/dist/charts/node.d.ts +13 -6
- package/dist/charts/node.d.ts.map +1 -1
- package/dist/charts/node.js +75 -24
- package/dist/charts/node.js.map +1 -1
- package/dist/charts/render.d.ts +4 -0
- package/dist/charts/render.d.ts.map +1 -1
- package/dist/charts/render.js +11 -6
- package/dist/charts/render.js.map +1 -1
- package/dist/code-editor/node.d.ts +47 -16
- package/dist/code-editor/node.d.ts.map +1 -1
- package/dist/code-editor/node.js +121 -51
- package/dist/code-editor/node.js.map +1 -1
- package/dist/flow/draw.d.ts +19 -1
- package/dist/flow/draw.d.ts.map +1 -1
- package/dist/flow/draw.js +64 -35
- package/dist/flow/draw.js.map +1 -1
- package/dist/flow/index.d.ts.map +1 -1
- package/dist/flow/index.js +24 -8
- package/dist/flow/index.js.map +1 -1
- package/dist/flow/model.d.ts +4 -2
- package/dist/flow/model.d.ts.map +1 -1
- package/dist/flow/model.js +4 -2
- package/dist/flow/model.js.map +1 -1
- package/dist/flow/node.d.ts +40 -9
- package/dist/flow/node.d.ts.map +1 -1
- package/dist/flow/node.js +142 -53
- package/dist/flow/node.js.map +1 -1
- package/dist/flow/types.d.ts +46 -21
- package/dist/flow/types.d.ts.map +1 -1
- package/dist/formula/index.d.ts +4 -2
- package/dist/formula/index.d.ts.map +1 -1
- package/dist/formula/index.js.map +1 -1
- package/dist/formula/node.d.ts +5 -1
- package/dist/formula/node.d.ts.map +1 -1
- package/dist/formula/node.js +19 -3
- package/dist/formula/node.js.map +1 -1
- package/dist/html/controls.d.ts +4 -1
- package/dist/html/controls.d.ts.map +1 -1
- package/dist/html/controls.js.map +1 -1
- package/dist/html/css/cascade.d.ts +11 -5
- package/dist/html/css/cascade.d.ts.map +1 -1
- package/dist/html/css/cascade.js +18 -8
- package/dist/html/css/cascade.js.map +1 -1
- package/dist/html/css/parse.d.ts +15 -10
- package/dist/html/css/parse.d.ts.map +1 -1
- package/dist/html/css/parse.js +41 -16
- package/dist/html/css/parse.js.map +1 -1
- package/dist/html/css/style.d.ts +3 -0
- package/dist/html/css/style.d.ts.map +1 -1
- package/dist/html/css/style.js +3 -1
- package/dist/html/css/style.js.map +1 -1
- package/dist/html/css/values.d.ts +14 -1
- package/dist/html/css/values.d.ts.map +1 -1
- package/dist/html/css/values.js +11 -8
- package/dist/html/css/values.js.map +1 -1
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +1 -0
- package/dist/html/index.js.map +1 -1
- package/dist/html/layout/boxes.d.ts +12 -5
- package/dist/html/layout/boxes.d.ts.map +1 -1
- package/dist/html/layout/boxes.js +7 -4
- package/dist/html/layout/boxes.js.map +1 -1
- package/dist/html/layout/inline.d.ts.map +1 -1
- package/dist/html/layout/inline.js +22 -1
- package/dist/html/layout/inline.js.map +1 -1
- package/dist/html/node.d.ts +22 -8
- package/dist/html/node.d.ts.map +1 -1
- package/dist/html/node.js +97 -15
- package/dist/html/node.js.map +1 -1
- package/dist/html/paint.d.ts +3 -0
- package/dist/html/paint.d.ts.map +1 -1
- package/dist/html/paint.js +2 -2
- package/dist/html/paint.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/maps/clip.d.ts +43 -0
- package/dist/maps/clip.d.ts.map +1 -0
- package/dist/maps/clip.js +171 -0
- package/dist/maps/clip.js.map +1 -0
- package/dist/maps/gzip.d.ts +15 -0
- package/dist/maps/gzip.d.ts.map +1 -0
- package/dist/maps/gzip.js +56 -0
- package/dist/maps/gzip.js.map +1 -0
- package/dist/maps/index.d.ts +55 -0
- package/dist/maps/index.d.ts.map +1 -0
- package/dist/maps/index.js +141 -0
- package/dist/maps/index.js.map +1 -0
- package/dist/maps/labels.d.ts +123 -0
- package/dist/maps/labels.d.ts.map +1 -0
- package/dist/maps/labels.js +323 -0
- package/dist/maps/labels.js.map +1 -0
- package/dist/maps/mvt.d.ts +244 -0
- package/dist/maps/mvt.d.ts.map +1 -0
- package/dist/maps/mvt.js +820 -0
- package/dist/maps/mvt.js.map +1 -0
- package/dist/maps/node.d.ts +278 -0
- package/dist/maps/node.d.ts.map +1 -0
- package/dist/maps/node.js +1459 -0
- package/dist/maps/node.js.map +1 -0
- package/dist/maps/overlay.d.ts +157 -0
- package/dist/maps/overlay.d.ts.map +1 -0
- package/dist/maps/overlay.js +472 -0
- package/dist/maps/overlay.js.map +1 -0
- package/dist/maps/paint.d.ts +226 -0
- package/dist/maps/paint.d.ts.map +1 -0
- package/dist/maps/paint.js +705 -0
- package/dist/maps/paint.js.map +1 -0
- package/dist/maps/proj.d.ts +281 -0
- package/dist/maps/proj.d.ts.map +1 -0
- package/dist/maps/proj.js +451 -0
- package/dist/maps/proj.js.map +1 -0
- package/dist/maps/sources.d.ts +256 -0
- package/dist/maps/sources.d.ts.map +1 -0
- package/dist/maps/sources.js +210 -0
- package/dist/maps/sources.js.map +1 -0
- package/dist/maps/style.d.ts +141 -0
- package/dist/maps/style.d.ts.map +1 -0
- package/dist/maps/style.js +147 -0
- package/dist/maps/style.js.map +1 -0
- package/dist/maps/styles.d.ts +82 -0
- package/dist/maps/styles.d.ts.map +1 -0
- package/dist/maps/styles.js +898 -0
- package/dist/maps/styles.js.map +1 -0
- package/dist/maps/tiles.d.ts +283 -0
- package/dist/maps/tiles.d.ts.map +1 -0
- package/dist/maps/tiles.js +469 -0
- package/dist/maps/tiles.js.map +1 -0
- package/dist/maps/types.d.ts +244 -0
- package/dist/maps/types.d.ts.map +1 -0
- package/dist/maps/types.js +2 -0
- package/dist/maps/types.js.map +1 -0
- package/dist/reorder/hx.d.ts +18 -0
- package/dist/reorder/hx.d.ts.map +1 -0
- package/dist/reorder/hx.js +10 -0
- package/dist/reorder/hx.js.map +1 -0
- package/dist/reorder/index.d.ts +314 -0
- package/dist/reorder/index.d.ts.map +1 -0
- package/dist/reorder/index.js +1539 -0
- package/dist/reorder/index.js.map +1 -0
- package/dist/reorder/model.d.ts +107 -0
- package/dist/reorder/model.d.ts.map +1 -0
- package/dist/reorder/model.js +162 -0
- package/dist/reorder/model.js.map +1 -0
- package/dist/richtext/node.d.ts +15 -5
- package/dist/richtext/node.d.ts.map +1 -1
- package/dist/richtext/node.js +40 -11
- package/dist/richtext/node.js.map +1 -1
- package/dist/richtext/runs.d.ts +35 -13
- package/dist/richtext/runs.d.ts.map +1 -1
- package/dist/richtext/runs.js +83 -27
- package/dist/richtext/runs.js.map +1 -1
- package/dist/tabs/index.d.ts +17 -0
- package/dist/tabs/index.d.ts.map +1 -1
- package/dist/tabs/index.js +849 -186
- package/dist/tabs/index.js.map +1 -1
- package/dist/terminal/vt/fonts.d.ts +45 -15
- package/dist/terminal/vt/fonts.d.ts.map +1 -1
- package/dist/terminal/vt/fonts.js +89 -19
- package/dist/terminal/vt/fonts.js.map +1 -1
- package/dist/terminal/vt/node.d.ts +42 -0
- package/dist/terminal/vt/node.d.ts.map +1 -1
- package/dist/terminal/vt/node.js +95 -11
- package/dist/terminal/vt/node.js.map +1 -1
- package/dist/terminal/vt/renderer.d.ts.map +1 -1
- package/dist/terminal/vt/renderer.js +13 -3
- package/dist/terminal/vt/renderer.js.map +1 -1
- package/package.json +13 -3
- package/src/charts/components.ts +14 -7
- package/src/charts/node.ts +81 -26
- package/src/charts/render.ts +16 -6
- package/src/code-editor/node.ts +147 -53
- package/src/flow/draw.ts +104 -35
- package/src/flow/index.ts +33 -13
- package/src/flow/model.ts +4 -2
- package/src/flow/node.ts +158 -59
- package/src/flow/types.ts +46 -21
- package/src/formula/index.ts +4 -2
- package/src/formula/node.ts +24 -5
- package/src/html/controls.ts +4 -1
- package/src/html/css/cascade.ts +18 -7
- package/src/html/css/parse.ts +46 -21
- package/src/html/css/style.ts +6 -1
- package/src/html/css/values.ts +25 -9
- package/src/html/index.ts +1 -0
- package/src/html/layout/boxes.ts +19 -9
- package/src/html/layout/inline.ts +23 -1
- package/src/html/node.ts +110 -17
- package/src/html/paint.ts +17 -2
- package/src/index.ts +98 -0
- package/src/maps/clip.ts +184 -0
- package/src/maps/gzip.ts +66 -0
- package/src/maps/index.ts +316 -0
- package/src/maps/labels.ts +442 -0
- package/src/maps/mvt.ts +857 -0
- package/src/maps/node.ts +1819 -0
- package/src/maps/overlay.ts +639 -0
- package/src/maps/paint.ts +978 -0
- package/src/maps/proj.ts +641 -0
- package/src/maps/sources.ts +425 -0
- package/src/maps/style.ts +309 -0
- package/src/maps/styles.ts +1040 -0
- package/src/maps/tiles.ts +676 -0
- package/src/maps/types.ts +260 -0
- package/src/reorder/hx.ts +31 -0
- package/src/reorder/index.ts +2223 -0
- package/src/reorder/model.ts +232 -0
- package/src/richtext/node.ts +49 -14
- package/src/richtext/runs.ts +115 -30
- package/src/tabs/index.ts +1040 -204
- package/src/terminal/vt/fonts.ts +128 -36
- package/src/terminal/vt/node.ts +105 -12
- package/src/terminal/vt/renderer.ts +13 -3
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// What the application puts on the map: markers, and the lines, areas and
|
|
2
|
+
// circles that carry a route, a traffic segment, a transit shape or a
|
|
3
|
+
// GeoJSON layer.
|
|
4
|
+
//
|
|
5
|
+
// All of it is drawn **into the frame**, not into a tile surface, for the
|
|
6
|
+
// reason the labels are: it is application state that changes on its own
|
|
7
|
+
// clock, and baking it into a tile would mean re-rasterizing a tile every
|
|
8
|
+
// time a vehicle moved. It rides the pan blit like everything else on the
|
|
9
|
+
// map, because it is anchored to geography.
|
|
10
|
+
//
|
|
11
|
+
// The vocabulary is deliberately small and deliberately geometric — a line
|
|
12
|
+
// is a line whether it came from a routing engine, a GTFS shape or a
|
|
13
|
+
// traffic feed — with the format adapters ({@link decodePolyline},
|
|
14
|
+
// {@link geoJsonOverlays}) kept as pure functions beside it rather than as
|
|
15
|
+
// props of the map. `docs/prd-maps.md` surveys what the real feeds look
|
|
16
|
+
// like and why this is the shape they all reduce to.
|
|
17
|
+
import { clipOf, clipRing, clipSegment } from './clip.js';
|
|
18
|
+
import type { ClipRect } from './clip.js';
|
|
19
|
+
import type { MapCanvas } from './paint.js';
|
|
20
|
+
import { projectLngLat } from './proj.js';
|
|
21
|
+
import type { LngLat, Transform } from './proj.js';
|
|
22
|
+
|
|
23
|
+
/** A point on the map the user can click. */
|
|
24
|
+
export interface MapMarker {
|
|
25
|
+
/** Stable across renders — what an event names and what a hit test
|
|
26
|
+
* returns. */
|
|
27
|
+
id: string;
|
|
28
|
+
position: LngLat;
|
|
29
|
+
/** `'pin'` (the default) stands on its position; `'circle'` is centred on
|
|
30
|
+
* it. A pin is what a place wants and a circle is what a vehicle or a
|
|
31
|
+
* measurement wants. */
|
|
32
|
+
shape?: 'pin' | 'circle';
|
|
33
|
+
/** Logical pixels: a pin's width, a circle's diameter. 14 by default. */
|
|
34
|
+
size?: number;
|
|
35
|
+
/** Fill. The theme's accent by default. */
|
|
36
|
+
color?: string;
|
|
37
|
+
/** The ring around it, so a marker stays visible on any basemap. The
|
|
38
|
+
* theme's background by default. */
|
|
39
|
+
outline?: string;
|
|
40
|
+
/** Drawn above unselected markers, and given the selected ring. */
|
|
41
|
+
selected?: boolean;
|
|
42
|
+
/** Higher draws later. Ties break on array order. */
|
|
43
|
+
zIndex?: number;
|
|
44
|
+
/** Skipped by hit testing — for a marker that is decoration. */
|
|
45
|
+
interactive?: boolean;
|
|
46
|
+
/** Announced by a screen reader, and shown by an application's own
|
|
47
|
+
* tooltip. */
|
|
48
|
+
title?: string;
|
|
49
|
+
/** Handed back on an event. Never read here. */
|
|
50
|
+
data?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Anything else drawn over the basemap. */
|
|
54
|
+
export type MapOverlay =
|
|
55
|
+
| {
|
|
56
|
+
kind: 'line';
|
|
57
|
+
id: string;
|
|
58
|
+
path: readonly LngLat[];
|
|
59
|
+
color?: string;
|
|
60
|
+
/** Logical pixels. 3 by default. */
|
|
61
|
+
width?: number;
|
|
62
|
+
opacity?: number;
|
|
63
|
+
dash?: readonly number[];
|
|
64
|
+
cap?: 'butt' | 'round' | 'square';
|
|
65
|
+
join?: 'miter' | 'round' | 'bevel';
|
|
66
|
+
/** A second, wider stroke under the first — what makes a route
|
|
67
|
+
* readable over a road of the same colour. */
|
|
68
|
+
casing?: string;
|
|
69
|
+
casingWidth?: number;
|
|
70
|
+
zIndex?: number;
|
|
71
|
+
data?: unknown;
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
kind: 'polygon';
|
|
75
|
+
id: string;
|
|
76
|
+
/** Exterior ring first; every ring after it is a hole. */
|
|
77
|
+
rings: readonly (readonly LngLat[])[];
|
|
78
|
+
fill?: string;
|
|
79
|
+
opacity?: number;
|
|
80
|
+
outline?: string;
|
|
81
|
+
outlineWidth?: number;
|
|
82
|
+
zIndex?: number;
|
|
83
|
+
data?: unknown;
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
kind: 'circle';
|
|
87
|
+
id: string;
|
|
88
|
+
center: LngLat;
|
|
89
|
+
/** Ground metres, so the circle grows with the zoom the way a real
|
|
90
|
+
* radius does — an accuracy ring, a catchment, a geofence. */
|
|
91
|
+
radiusMetres: number;
|
|
92
|
+
fill?: string;
|
|
93
|
+
opacity?: number;
|
|
94
|
+
outline?: string;
|
|
95
|
+
outlineWidth?: number;
|
|
96
|
+
zIndex?: number;
|
|
97
|
+
data?: unknown;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Colours an overlay falls back to, from the theme. */
|
|
101
|
+
export interface OverlayPalette {
|
|
102
|
+
accent: string;
|
|
103
|
+
background: string;
|
|
104
|
+
text: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function byZ<T extends { zIndex?: number }>(items: readonly T[]): T[] {
|
|
108
|
+
// A stable sort — the language guarantees it — so items with no `zIndex`
|
|
109
|
+
// keep the order the application listed them in.
|
|
110
|
+
return [...items].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Draw the overlays.
|
|
115
|
+
*
|
|
116
|
+
* Under the markers, because a marker is a thing the user aims at and an
|
|
117
|
+
* overlay is context for it.
|
|
118
|
+
*/
|
|
119
|
+
export function drawOverlays(
|
|
120
|
+
ctx: MapCanvas,
|
|
121
|
+
overlays: readonly MapOverlay[],
|
|
122
|
+
transform: Transform,
|
|
123
|
+
pane: { x: number; y: number; width: number; height: number },
|
|
124
|
+
scale: number,
|
|
125
|
+
palette: OverlayPalette,
|
|
126
|
+
): void {
|
|
127
|
+
const clip = clipOf(pane, scale);
|
|
128
|
+
for (const overlay of byZ(overlays)) {
|
|
129
|
+
const opacity = overlay.opacity ?? 1;
|
|
130
|
+
if (opacity <= 0) continue;
|
|
131
|
+
ctx.save();
|
|
132
|
+
if (ctx.globalAlpha !== undefined && opacity < 1) ctx.globalAlpha = opacity;
|
|
133
|
+
if (overlay.kind === 'line') {
|
|
134
|
+
const points = projectPath(overlay.path, transform, pane, scale);
|
|
135
|
+
const width = (overlay.width ?? 3) * scale;
|
|
136
|
+
if (overlay.casing !== undefined) {
|
|
137
|
+
ctx.strokeStyle = overlay.casing;
|
|
138
|
+
ctx.lineWidth = Math.max(
|
|
139
|
+
width + 2 * scale,
|
|
140
|
+
(overlay.casingWidth ?? 0) * scale,
|
|
141
|
+
);
|
|
142
|
+
ctx.lineCap = overlay.cap ?? 'round';
|
|
143
|
+
ctx.lineJoin = overlay.join ?? 'round';
|
|
144
|
+
strokeClipped(ctx, points, clip);
|
|
145
|
+
}
|
|
146
|
+
ctx.strokeStyle = overlay.color ?? palette.accent;
|
|
147
|
+
ctx.lineWidth = width;
|
|
148
|
+
ctx.lineCap = overlay.cap ?? 'round';
|
|
149
|
+
ctx.lineJoin = overlay.join ?? 'round';
|
|
150
|
+
if (overlay.dash && ctx.setLineDash) {
|
|
151
|
+
ctx.setLineDash(overlay.dash.map((d) => d * scale));
|
|
152
|
+
}
|
|
153
|
+
strokeClipped(ctx, points, clip);
|
|
154
|
+
if (overlay.dash && ctx.setLineDash) ctx.setLineDash([]);
|
|
155
|
+
} else if (overlay.kind === 'polygon') {
|
|
156
|
+
ctx.beginPath();
|
|
157
|
+
let any = false;
|
|
158
|
+
for (const ring of overlay.rings) {
|
|
159
|
+
const clipped = clipRing(
|
|
160
|
+
projectPath(ring, transform, pane, scale),
|
|
161
|
+
clip,
|
|
162
|
+
);
|
|
163
|
+
if (appendRing(ctx, clipped)) any = true;
|
|
164
|
+
}
|
|
165
|
+
if (any) {
|
|
166
|
+
ctx.fillStyle = overlay.fill ?? palette.accent;
|
|
167
|
+
ctx.fill();
|
|
168
|
+
if (overlay.outline !== undefined) {
|
|
169
|
+
ctx.strokeStyle = overlay.outline;
|
|
170
|
+
ctx.lineWidth = (overlay.outlineWidth ?? 1) * scale;
|
|
171
|
+
ctx.stroke();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
// A circle in ground metres is an ellipse on a Mercator map, and at
|
|
176
|
+
// the sizes an application draws one it is close enough to a circle
|
|
177
|
+
// that the difference is under a pixel — except near the poles, where
|
|
178
|
+
// it is not. So the radius is computed from the *projection* rather
|
|
179
|
+
// than from a metres-per-pixel constant: one degree of latitude
|
|
180
|
+
// either side of the centre, projected, is exactly the scale factor
|
|
181
|
+
// this circle should be drawn at.
|
|
182
|
+
const centre = projectLngLat(transform, overlay.center);
|
|
183
|
+
const north = projectLngLat(transform, {
|
|
184
|
+
lon: overlay.center.lon,
|
|
185
|
+
lat: Math.min(85, overlay.center.lat + 0.01),
|
|
186
|
+
});
|
|
187
|
+
const metresPerDegree = 111_319.9;
|
|
188
|
+
const pixelsPerMetre =
|
|
189
|
+
Math.abs(centre.y - north.y) / (0.01 * metresPerDegree);
|
|
190
|
+
const radius = overlay.radiusMetres * pixelsPerMetre * scale;
|
|
191
|
+
const cx = (pane.x + centre.x) * scale;
|
|
192
|
+
const cy = (pane.y + centre.y) * scale;
|
|
193
|
+
const outside =
|
|
194
|
+
cx + radius < clip.minX ||
|
|
195
|
+
cx - radius > clip.maxX ||
|
|
196
|
+
cy + radius < clip.minY ||
|
|
197
|
+
cy - radius > clip.maxY;
|
|
198
|
+
if (radius > 0.5 && !outside) {
|
|
199
|
+
ctx.fillStyle = overlay.fill ?? palette.accent;
|
|
200
|
+
if (radius <= MAX_ARC_RADIUS) {
|
|
201
|
+
ctx.beginPath();
|
|
202
|
+
ctx.moveTo(cx + radius, cy);
|
|
203
|
+
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
204
|
+
ctx.fill();
|
|
205
|
+
if (overlay.outline !== undefined) {
|
|
206
|
+
ctx.strokeStyle = overlay.outline;
|
|
207
|
+
ctx.lineWidth = (overlay.outlineWidth ?? 1) * scale;
|
|
208
|
+
ctx.stroke();
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
// Past the arc's safe range the circle becomes a ring and is
|
|
212
|
+
// clipped like any other polygon. The segment count keeps the
|
|
213
|
+
// sagitta under half a pixel, and for a circle this large the
|
|
214
|
+
// visible arc is very nearly straight anyway.
|
|
215
|
+
ctx.beginPath();
|
|
216
|
+
if (appendRing(ctx, clipRing(circleRing(cx, cy, radius), clip))) {
|
|
217
|
+
ctx.fill();
|
|
218
|
+
if (overlay.outline !== undefined) {
|
|
219
|
+
ctx.strokeStyle = overlay.outline;
|
|
220
|
+
ctx.lineWidth = (overlay.outlineWidth ?? 1) * scale;
|
|
221
|
+
ctx.stroke();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
ctx.restore();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Everything below this line exists because **an overlay's coordinates are
|
|
233
|
+
* unbounded and the renderer's are not.**
|
|
234
|
+
*
|
|
235
|
+
* A route is geography, so its far end stays where it is when the camera
|
|
236
|
+
* zooms in on one corner of it. World pixels are `512 · 2^zoom`, which at
|
|
237
|
+
* zoom 20 is 134 million, so a vertex a fraction of a degree outside the
|
|
238
|
+
* pane is already tens of thousands of pixels away — and ntk hands a
|
|
239
|
+
* stroke's geometry to XRender as 16.16 fixed point, which overflows a
|
|
240
|
+
* signed 32-bit word at 32,768. The symptom is a `RangeError` out of
|
|
241
|
+
* `x11/lib/ext/render.js` a few zoom steps in, from inside `paint`, which
|
|
242
|
+
* is not a place an application can catch it.
|
|
243
|
+
*
|
|
244
|
+
* So geometry is clipped to the viewport before it reaches the context.
|
|
245
|
+
* That is the fix; the fact that it also stops the renderer rasterizing
|
|
246
|
+
* megametres of off-screen line is a bonus rather than the reason.
|
|
247
|
+
*/
|
|
248
|
+
|
|
249
|
+
/** A path projected into target pixels, as a flat `[x0, y0, x1, y1, …]`. */
|
|
250
|
+
function projectPath(
|
|
251
|
+
path: readonly LngLat[],
|
|
252
|
+
transform: Transform,
|
|
253
|
+
pane: { x: number; y: number },
|
|
254
|
+
scale: number,
|
|
255
|
+
): number[] {
|
|
256
|
+
const out: number[] = [];
|
|
257
|
+
for (const position of path) {
|
|
258
|
+
const point = projectLngLat(transform, position);
|
|
259
|
+
out.push((pane.x + point.x) * scale, (pane.y + point.y) * scale);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Stroke a projected path, clipped.
|
|
266
|
+
*
|
|
267
|
+
* A polyline that leaves and re-enters the window becomes several subpaths,
|
|
268
|
+
* which is why this cannot be a `ctx.clip()` and a single path: the clip
|
|
269
|
+
* would keep the coordinates, and the coordinates are the problem.
|
|
270
|
+
*/
|
|
271
|
+
function strokeClipped(
|
|
272
|
+
ctx: MapCanvas,
|
|
273
|
+
points: readonly number[],
|
|
274
|
+
clip: ClipRect,
|
|
275
|
+
): void {
|
|
276
|
+
if (points.length < 4) return;
|
|
277
|
+
ctx.beginPath();
|
|
278
|
+
let open = false;
|
|
279
|
+
let drew = false;
|
|
280
|
+
for (let i = 0; i + 3 < points.length; i += 2) {
|
|
281
|
+
const piece = clipSegment(
|
|
282
|
+
points[i],
|
|
283
|
+
points[i + 1],
|
|
284
|
+
points[i + 2],
|
|
285
|
+
points[i + 3],
|
|
286
|
+
clip,
|
|
287
|
+
);
|
|
288
|
+
if (!piece) {
|
|
289
|
+
open = false;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const [x0, y0, x1, y1] = piece;
|
|
293
|
+
// A new subpath unless this segment continues exactly where the last
|
|
294
|
+
// one ended — which is the whole-segment-visible case, and the one that
|
|
295
|
+
// has to keep its joins.
|
|
296
|
+
if (!open) {
|
|
297
|
+
ctx.moveTo(x0, y0);
|
|
298
|
+
open = true;
|
|
299
|
+
}
|
|
300
|
+
ctx.lineTo(x1, y1);
|
|
301
|
+
drew = true;
|
|
302
|
+
// The segment was cut short at the far end, so the next one does not
|
|
303
|
+
// continue from here.
|
|
304
|
+
if (x1 !== points[i + 2] || y1 !== points[i + 3]) open = false;
|
|
305
|
+
}
|
|
306
|
+
if (drew) ctx.stroke();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function appendRing(ctx: MapCanvas, ring: readonly number[]): boolean {
|
|
310
|
+
if (ring.length < 6) return false;
|
|
311
|
+
ctx.moveTo(ring[0], ring[1]);
|
|
312
|
+
for (let i = 2; i < ring.length; i += 2) ctx.lineTo(ring[i], ring[i + 1]);
|
|
313
|
+
ctx.closePath();
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Where a marker's ink lands, in pane-local logical pixels. Shared by the
|
|
318
|
+
* drawing and the hit test, so the two cannot disagree — the bug that
|
|
319
|
+
* makes a marker unclickable a few pixels from where it looks. */
|
|
320
|
+
export function markerRect(
|
|
321
|
+
marker: MapMarker,
|
|
322
|
+
transform: Transform,
|
|
323
|
+
): {
|
|
324
|
+
x: number;
|
|
325
|
+
y: number;
|
|
326
|
+
width: number;
|
|
327
|
+
height: number;
|
|
328
|
+
tipX: number;
|
|
329
|
+
tipY: number;
|
|
330
|
+
} {
|
|
331
|
+
const point = projectLngLat(transform, marker.position);
|
|
332
|
+
const size = marker.size ?? 14;
|
|
333
|
+
if ((marker.shape ?? 'pin') === 'circle') {
|
|
334
|
+
return {
|
|
335
|
+
x: point.x - size / 2,
|
|
336
|
+
y: point.y - size / 2,
|
|
337
|
+
width: size,
|
|
338
|
+
height: size,
|
|
339
|
+
tipX: point.x,
|
|
340
|
+
tipY: point.y,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
// A pin *stands on* its position: the point is the tip, and the head is
|
|
344
|
+
// above it. Getting this the other way round puts every marker half its
|
|
345
|
+
// own height north of where it belongs, which on a city map is a street.
|
|
346
|
+
const height = size * 1.4;
|
|
347
|
+
return {
|
|
348
|
+
x: point.x - size / 2,
|
|
349
|
+
y: point.y - height,
|
|
350
|
+
width: size,
|
|
351
|
+
height,
|
|
352
|
+
tipX: point.x,
|
|
353
|
+
tipY: point.y,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Draw the markers, lowest `zIndex` first. */
|
|
358
|
+
export function drawMarkers(
|
|
359
|
+
ctx: MapCanvas,
|
|
360
|
+
markers: readonly MapMarker[],
|
|
361
|
+
transform: Transform,
|
|
362
|
+
pane: { x: number; y: number; width: number; height: number },
|
|
363
|
+
scale: number,
|
|
364
|
+
palette: OverlayPalette,
|
|
365
|
+
): number {
|
|
366
|
+
let drawn = 0;
|
|
367
|
+
for (const marker of byZ(markers)) {
|
|
368
|
+
const rect = markerRect(marker, transform);
|
|
369
|
+
if (
|
|
370
|
+
rect.x + rect.width < 0 ||
|
|
371
|
+
rect.y + rect.height < 0 ||
|
|
372
|
+
rect.x > pane.width ||
|
|
373
|
+
rect.y > pane.height
|
|
374
|
+
) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const size = marker.size ?? 14;
|
|
378
|
+
const fill = marker.color ?? palette.accent;
|
|
379
|
+
const ring = marker.outline ?? palette.background;
|
|
380
|
+
const x = (pane.x + rect.tipX) * scale;
|
|
381
|
+
const y = (pane.y + rect.tipY) * scale;
|
|
382
|
+
const r = (size / 2) * scale;
|
|
383
|
+
ctx.save();
|
|
384
|
+
ctx.lineWidth = Math.max(1, (marker.selected ? 2.5 : 1.5) * scale);
|
|
385
|
+
ctx.strokeStyle = marker.selected ? palette.text : ring;
|
|
386
|
+
ctx.fillStyle = fill;
|
|
387
|
+
if ((marker.shape ?? 'pin') === 'circle') {
|
|
388
|
+
ctx.beginPath();
|
|
389
|
+
ctx.moveTo(x + r, y);
|
|
390
|
+
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
391
|
+
ctx.fill();
|
|
392
|
+
ctx.stroke();
|
|
393
|
+
} else {
|
|
394
|
+
// A teardrop: a circle for the head and two lines down to the tip.
|
|
395
|
+
// The tangent angle is where the straight sides meet the circle, so
|
|
396
|
+
// the outline is smooth rather than two lines crossing an arc.
|
|
397
|
+
const cy = y - size * 1.4 * scale + r;
|
|
398
|
+
const d = y - cy;
|
|
399
|
+
const angle = Math.asin(Math.min(1, r / d));
|
|
400
|
+
ctx.beginPath();
|
|
401
|
+
ctx.arc(x, cy, r, Math.PI / 2 + angle, Math.PI / 2 - angle);
|
|
402
|
+
ctx.lineTo(x, y);
|
|
403
|
+
ctx.closePath();
|
|
404
|
+
ctx.fill();
|
|
405
|
+
ctx.stroke();
|
|
406
|
+
}
|
|
407
|
+
ctx.restore();
|
|
408
|
+
drawn++;
|
|
409
|
+
}
|
|
410
|
+
return drawn;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The marker under a pane-local logical point, topmost first, or null. */
|
|
414
|
+
export function markerAt(
|
|
415
|
+
markers: readonly MapMarker[],
|
|
416
|
+
transform: Transform,
|
|
417
|
+
x: number,
|
|
418
|
+
y: number,
|
|
419
|
+
slop = 2,
|
|
420
|
+
): MapMarker | null {
|
|
421
|
+
const ordered = byZ(markers);
|
|
422
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
423
|
+
const marker = ordered[i];
|
|
424
|
+
if (marker.interactive === false) continue;
|
|
425
|
+
const rect = markerRect(marker, transform);
|
|
426
|
+
if (
|
|
427
|
+
x >= rect.x - slop &&
|
|
428
|
+
x <= rect.x + rect.width + slop &&
|
|
429
|
+
y >= rect.y - slop &&
|
|
430
|
+
y <= rect.y + rect.height + slop
|
|
431
|
+
) {
|
|
432
|
+
return marker;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Decode Google's Encoded Polyline Algorithm Format.
|
|
440
|
+
*
|
|
441
|
+
* Here because it is what every routing engine on the open web answers
|
|
442
|
+
* with — Google Directions, OSRM, Valhalla, GraphHopper, Mapbox Directions
|
|
443
|
+
* — so a route arrives as one of these strings far more often than as
|
|
444
|
+
* GeoJSON. `precision` is 5 for Google and OSRM's default, 6 for Valhalla
|
|
445
|
+
* and OSRM's `polyline6`; passing the wrong one puts the route in the
|
|
446
|
+
* Atlantic, which is the standard way to discover this.
|
|
447
|
+
*/
|
|
448
|
+
export function decodePolyline(encoded: string, precision = 5): LngLat[] {
|
|
449
|
+
const factor = Math.pow(10, precision);
|
|
450
|
+
const out: LngLat[] = [];
|
|
451
|
+
let index = 0;
|
|
452
|
+
let lat = 0;
|
|
453
|
+
let lon = 0;
|
|
454
|
+
while (index < encoded.length) {
|
|
455
|
+
let result = 0;
|
|
456
|
+
let shift = 0;
|
|
457
|
+
let byte: number;
|
|
458
|
+
do {
|
|
459
|
+
byte = encoded.charCodeAt(index++) - 63;
|
|
460
|
+
result |= (byte & 0x1f) << shift;
|
|
461
|
+
shift += 5;
|
|
462
|
+
} while (byte >= 0x20 && index < encoded.length);
|
|
463
|
+
lat += result & 1 ? ~(result >> 1) : result >> 1;
|
|
464
|
+
result = 0;
|
|
465
|
+
shift = 0;
|
|
466
|
+
do {
|
|
467
|
+
byte = encoded.charCodeAt(index++) - 63;
|
|
468
|
+
result |= (byte & 0x1f) << shift;
|
|
469
|
+
shift += 5;
|
|
470
|
+
} while (byte >= 0x20 && index < encoded.length);
|
|
471
|
+
lon += result & 1 ? ~(result >> 1) : result >> 1;
|
|
472
|
+
out.push({ lat: lat / factor, lon: lon / factor });
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The slice of GeoJSON {@link geoJsonOverlays} reads. Written out
|
|
478
|
+
* structurally rather than taken from `@types/geojson`, so this package
|
|
479
|
+
* does not put a type dependency in an application's graph for a shape
|
|
480
|
+
* that is four lines. */
|
|
481
|
+
export interface GeoJsonLike {
|
|
482
|
+
type: string;
|
|
483
|
+
features?: readonly GeoJsonLike[];
|
|
484
|
+
geometry?: GeoJsonLike | null;
|
|
485
|
+
geometries?: readonly GeoJsonLike[];
|
|
486
|
+
coordinates?: unknown;
|
|
487
|
+
properties?: Record<string, unknown> | null;
|
|
488
|
+
id?: string | number;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* GeoJSON to overlays.
|
|
493
|
+
*
|
|
494
|
+
* Points become markers and everything else becomes an overlay, which is
|
|
495
|
+
* the split the drawing makes. `style` is asked once per feature so an
|
|
496
|
+
* application can colour by a property — a traffic feed's congestion, a
|
|
497
|
+
* transit feed's route colour — without this function growing an
|
|
498
|
+
* expression language.
|
|
499
|
+
*
|
|
500
|
+
* Coordinates are `[lon, lat]`, which is GeoJSON's order and the opposite
|
|
501
|
+
* of how most people say it.
|
|
502
|
+
*/
|
|
503
|
+
export function geoJsonOverlays(
|
|
504
|
+
geojson: GeoJsonLike,
|
|
505
|
+
style?: (
|
|
506
|
+
feature: GeoJsonLike,
|
|
507
|
+
) => Partial<Extract<MapOverlay, { kind: 'line' }>> &
|
|
508
|
+
Partial<Extract<MapOverlay, { kind: 'polygon' }>>,
|
|
509
|
+
): { overlays: MapOverlay[]; markers: MapMarker[] } {
|
|
510
|
+
const overlays: MapOverlay[] = [];
|
|
511
|
+
const markers: MapMarker[] = [];
|
|
512
|
+
let counter = 0;
|
|
513
|
+
const positions = (value: unknown): LngLat[] =>
|
|
514
|
+
Array.isArray(value)
|
|
515
|
+
? value
|
|
516
|
+
.filter(
|
|
517
|
+
(pair): pair is [number, number] =>
|
|
518
|
+
Array.isArray(pair) &&
|
|
519
|
+
typeof pair[0] === 'number' &&
|
|
520
|
+
typeof pair[1] === 'number',
|
|
521
|
+
)
|
|
522
|
+
.map(([lon, lat]) => ({ lon, lat }))
|
|
523
|
+
: [];
|
|
524
|
+
|
|
525
|
+
const walk = (node: GeoJsonLike, owner: GeoJsonLike): void => {
|
|
526
|
+
const id = String(owner.id ?? `geojson-${counter++}`);
|
|
527
|
+
const extra = style?.(owner) ?? {};
|
|
528
|
+
switch (node.type) {
|
|
529
|
+
case 'FeatureCollection':
|
|
530
|
+
for (const feature of node.features ?? []) walk(feature, feature);
|
|
531
|
+
return;
|
|
532
|
+
case 'Feature':
|
|
533
|
+
if (node.geometry) walk(node.geometry, node);
|
|
534
|
+
return;
|
|
535
|
+
case 'GeometryCollection':
|
|
536
|
+
for (const geometry of node.geometries ?? []) walk(geometry, owner);
|
|
537
|
+
return;
|
|
538
|
+
case 'Point': {
|
|
539
|
+
const [point] = positions([node.coordinates]);
|
|
540
|
+
if (point)
|
|
541
|
+
markers.push({ id, position: point, data: owner.properties });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
case 'MultiPoint':
|
|
545
|
+
for (const point of positions(node.coordinates)) {
|
|
546
|
+
markers.push({
|
|
547
|
+
id: `${id}-${counter++}`,
|
|
548
|
+
position: point,
|
|
549
|
+
data: owner.properties,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
case 'LineString': {
|
|
554
|
+
const path = positions(node.coordinates);
|
|
555
|
+
if (path.length >= 2) {
|
|
556
|
+
overlays.push({
|
|
557
|
+
kind: 'line',
|
|
558
|
+
id,
|
|
559
|
+
path,
|
|
560
|
+
data: owner.properties,
|
|
561
|
+
...extra,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
case 'MultiLineString':
|
|
567
|
+
for (const part of (node.coordinates as unknown[]) ?? []) {
|
|
568
|
+
const path = positions(part);
|
|
569
|
+
if (path.length >= 2) {
|
|
570
|
+
overlays.push({
|
|
571
|
+
kind: 'line',
|
|
572
|
+
id: `${id}-${counter++}`,
|
|
573
|
+
path,
|
|
574
|
+
data: owner.properties,
|
|
575
|
+
...extra,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
case 'Polygon': {
|
|
581
|
+
const rings = ((node.coordinates as unknown[]) ?? []).map(positions);
|
|
582
|
+
if (rings.length > 0) {
|
|
583
|
+
overlays.push({
|
|
584
|
+
kind: 'polygon',
|
|
585
|
+
id,
|
|
586
|
+
rings,
|
|
587
|
+
data: owner.properties,
|
|
588
|
+
...extra,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
case 'MultiPolygon':
|
|
594
|
+
for (const polygon of (node.coordinates as unknown[]) ?? []) {
|
|
595
|
+
const rings = ((polygon as unknown[]) ?? []).map(positions);
|
|
596
|
+
if (rings.length > 0) {
|
|
597
|
+
overlays.push({
|
|
598
|
+
kind: 'polygon',
|
|
599
|
+
id: `${id}-${counter++}`,
|
|
600
|
+
rings,
|
|
601
|
+
data: owner.properties,
|
|
602
|
+
...extra,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
default:
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
walk(geojson, geojson);
|
|
612
|
+
return { overlays, markers };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Beyond this radius in target pixels a circle is drawn as a clipped ring
|
|
617
|
+
* rather than an arc.
|
|
618
|
+
*
|
|
619
|
+
* The bound is the renderer's rather than the geometry's: ntk hands a
|
|
620
|
+
* stroke's geometry to XRender in 16.16 fixed point, which overflows a
|
|
621
|
+
* signed 32-bit word at 32,768, so an arc whose control geometry reaches
|
|
622
|
+
* that far is a `RangeError` from inside `paint`. Comfortably under it.
|
|
623
|
+
*/
|
|
624
|
+
const MAX_ARC_RADIUS = 8192;
|
|
625
|
+
|
|
626
|
+
/** A circle as a ring, with the sagitta held under half a pixel. */
|
|
627
|
+
function circleRing(cx: number, cy: number, radius: number): number[] {
|
|
628
|
+
const step = 2 * Math.acos(Math.max(-1, 1 - 0.5 / radius));
|
|
629
|
+
const segments = Math.min(
|
|
630
|
+
4096,
|
|
631
|
+
Math.max(24, Math.ceil((Math.PI * 2) / step)),
|
|
632
|
+
);
|
|
633
|
+
const out: number[] = [];
|
|
634
|
+
for (let i = 0; i < segments; i++) {
|
|
635
|
+
const angle = (i / segments) * Math.PI * 2;
|
|
636
|
+
out.push(cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius);
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|