@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.
Files changed (219) hide show
  1. package/README.md +125 -4
  2. package/dist/charts/components.d.ts +3 -1
  3. package/dist/charts/components.d.ts.map +1 -1
  4. package/dist/charts/components.js +8 -4
  5. package/dist/charts/components.js.map +1 -1
  6. package/dist/charts/node.d.ts +13 -6
  7. package/dist/charts/node.d.ts.map +1 -1
  8. package/dist/charts/node.js +75 -24
  9. package/dist/charts/node.js.map +1 -1
  10. package/dist/charts/render.d.ts +4 -0
  11. package/dist/charts/render.d.ts.map +1 -1
  12. package/dist/charts/render.js +11 -6
  13. package/dist/charts/render.js.map +1 -1
  14. package/dist/code-editor/node.d.ts +47 -16
  15. package/dist/code-editor/node.d.ts.map +1 -1
  16. package/dist/code-editor/node.js +121 -51
  17. package/dist/code-editor/node.js.map +1 -1
  18. package/dist/flow/draw.d.ts +19 -1
  19. package/dist/flow/draw.d.ts.map +1 -1
  20. package/dist/flow/draw.js +64 -35
  21. package/dist/flow/draw.js.map +1 -1
  22. package/dist/flow/index.d.ts.map +1 -1
  23. package/dist/flow/index.js +24 -8
  24. package/dist/flow/index.js.map +1 -1
  25. package/dist/flow/model.d.ts +4 -2
  26. package/dist/flow/model.d.ts.map +1 -1
  27. package/dist/flow/model.js +4 -2
  28. package/dist/flow/model.js.map +1 -1
  29. package/dist/flow/node.d.ts +40 -9
  30. package/dist/flow/node.d.ts.map +1 -1
  31. package/dist/flow/node.js +142 -53
  32. package/dist/flow/node.js.map +1 -1
  33. package/dist/flow/types.d.ts +46 -21
  34. package/dist/flow/types.d.ts.map +1 -1
  35. package/dist/formula/index.d.ts +4 -2
  36. package/dist/formula/index.d.ts.map +1 -1
  37. package/dist/formula/index.js.map +1 -1
  38. package/dist/formula/node.d.ts +5 -1
  39. package/dist/formula/node.d.ts.map +1 -1
  40. package/dist/formula/node.js +19 -3
  41. package/dist/formula/node.js.map +1 -1
  42. package/dist/html/controls.d.ts +4 -1
  43. package/dist/html/controls.d.ts.map +1 -1
  44. package/dist/html/controls.js.map +1 -1
  45. package/dist/html/css/cascade.d.ts +11 -5
  46. package/dist/html/css/cascade.d.ts.map +1 -1
  47. package/dist/html/css/cascade.js +18 -8
  48. package/dist/html/css/cascade.js.map +1 -1
  49. package/dist/html/css/parse.d.ts +15 -10
  50. package/dist/html/css/parse.d.ts.map +1 -1
  51. package/dist/html/css/parse.js +41 -16
  52. package/dist/html/css/parse.js.map +1 -1
  53. package/dist/html/css/style.d.ts +3 -0
  54. package/dist/html/css/style.d.ts.map +1 -1
  55. package/dist/html/css/style.js +3 -1
  56. package/dist/html/css/style.js.map +1 -1
  57. package/dist/html/css/values.d.ts +14 -1
  58. package/dist/html/css/values.d.ts.map +1 -1
  59. package/dist/html/css/values.js +11 -8
  60. package/dist/html/css/values.js.map +1 -1
  61. package/dist/html/index.d.ts.map +1 -1
  62. package/dist/html/index.js +1 -0
  63. package/dist/html/index.js.map +1 -1
  64. package/dist/html/layout/boxes.d.ts +12 -5
  65. package/dist/html/layout/boxes.d.ts.map +1 -1
  66. package/dist/html/layout/boxes.js +7 -4
  67. package/dist/html/layout/boxes.js.map +1 -1
  68. package/dist/html/layout/inline.d.ts.map +1 -1
  69. package/dist/html/layout/inline.js +22 -1
  70. package/dist/html/layout/inline.js.map +1 -1
  71. package/dist/html/node.d.ts +22 -8
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +97 -15
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +3 -0
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +2 -2
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/index.d.ts +7 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +21 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/maps/clip.d.ts +43 -0
  84. package/dist/maps/clip.d.ts.map +1 -0
  85. package/dist/maps/clip.js +171 -0
  86. package/dist/maps/clip.js.map +1 -0
  87. package/dist/maps/gzip.d.ts +15 -0
  88. package/dist/maps/gzip.d.ts.map +1 -0
  89. package/dist/maps/gzip.js +56 -0
  90. package/dist/maps/gzip.js.map +1 -0
  91. package/dist/maps/index.d.ts +55 -0
  92. package/dist/maps/index.d.ts.map +1 -0
  93. package/dist/maps/index.js +141 -0
  94. package/dist/maps/index.js.map +1 -0
  95. package/dist/maps/labels.d.ts +123 -0
  96. package/dist/maps/labels.d.ts.map +1 -0
  97. package/dist/maps/labels.js +323 -0
  98. package/dist/maps/labels.js.map +1 -0
  99. package/dist/maps/mvt.d.ts +244 -0
  100. package/dist/maps/mvt.d.ts.map +1 -0
  101. package/dist/maps/mvt.js +820 -0
  102. package/dist/maps/mvt.js.map +1 -0
  103. package/dist/maps/node.d.ts +278 -0
  104. package/dist/maps/node.d.ts.map +1 -0
  105. package/dist/maps/node.js +1459 -0
  106. package/dist/maps/node.js.map +1 -0
  107. package/dist/maps/overlay.d.ts +157 -0
  108. package/dist/maps/overlay.d.ts.map +1 -0
  109. package/dist/maps/overlay.js +472 -0
  110. package/dist/maps/overlay.js.map +1 -0
  111. package/dist/maps/paint.d.ts +226 -0
  112. package/dist/maps/paint.d.ts.map +1 -0
  113. package/dist/maps/paint.js +705 -0
  114. package/dist/maps/paint.js.map +1 -0
  115. package/dist/maps/proj.d.ts +281 -0
  116. package/dist/maps/proj.d.ts.map +1 -0
  117. package/dist/maps/proj.js +451 -0
  118. package/dist/maps/proj.js.map +1 -0
  119. package/dist/maps/sources.d.ts +256 -0
  120. package/dist/maps/sources.d.ts.map +1 -0
  121. package/dist/maps/sources.js +210 -0
  122. package/dist/maps/sources.js.map +1 -0
  123. package/dist/maps/style.d.ts +141 -0
  124. package/dist/maps/style.d.ts.map +1 -0
  125. package/dist/maps/style.js +147 -0
  126. package/dist/maps/style.js.map +1 -0
  127. package/dist/maps/styles.d.ts +82 -0
  128. package/dist/maps/styles.d.ts.map +1 -0
  129. package/dist/maps/styles.js +898 -0
  130. package/dist/maps/styles.js.map +1 -0
  131. package/dist/maps/tiles.d.ts +283 -0
  132. package/dist/maps/tiles.d.ts.map +1 -0
  133. package/dist/maps/tiles.js +469 -0
  134. package/dist/maps/tiles.js.map +1 -0
  135. package/dist/maps/types.d.ts +244 -0
  136. package/dist/maps/types.d.ts.map +1 -0
  137. package/dist/maps/types.js +2 -0
  138. package/dist/maps/types.js.map +1 -0
  139. package/dist/reorder/hx.d.ts +18 -0
  140. package/dist/reorder/hx.d.ts.map +1 -0
  141. package/dist/reorder/hx.js +10 -0
  142. package/dist/reorder/hx.js.map +1 -0
  143. package/dist/reorder/index.d.ts +314 -0
  144. package/dist/reorder/index.d.ts.map +1 -0
  145. package/dist/reorder/index.js +1539 -0
  146. package/dist/reorder/index.js.map +1 -0
  147. package/dist/reorder/model.d.ts +107 -0
  148. package/dist/reorder/model.d.ts.map +1 -0
  149. package/dist/reorder/model.js +162 -0
  150. package/dist/reorder/model.js.map +1 -0
  151. package/dist/richtext/node.d.ts +15 -5
  152. package/dist/richtext/node.d.ts.map +1 -1
  153. package/dist/richtext/node.js +40 -11
  154. package/dist/richtext/node.js.map +1 -1
  155. package/dist/richtext/runs.d.ts +35 -13
  156. package/dist/richtext/runs.d.ts.map +1 -1
  157. package/dist/richtext/runs.js +83 -27
  158. package/dist/richtext/runs.js.map +1 -1
  159. package/dist/tabs/index.d.ts +17 -0
  160. package/dist/tabs/index.d.ts.map +1 -1
  161. package/dist/tabs/index.js +849 -186
  162. package/dist/tabs/index.js.map +1 -1
  163. package/dist/terminal/vt/fonts.d.ts +45 -15
  164. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  165. package/dist/terminal/vt/fonts.js +89 -19
  166. package/dist/terminal/vt/fonts.js.map +1 -1
  167. package/dist/terminal/vt/node.d.ts +42 -0
  168. package/dist/terminal/vt/node.d.ts.map +1 -1
  169. package/dist/terminal/vt/node.js +95 -11
  170. package/dist/terminal/vt/node.js.map +1 -1
  171. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  172. package/dist/terminal/vt/renderer.js +13 -3
  173. package/dist/terminal/vt/renderer.js.map +1 -1
  174. package/package.json +13 -3
  175. package/src/charts/components.ts +14 -7
  176. package/src/charts/node.ts +81 -26
  177. package/src/charts/render.ts +16 -6
  178. package/src/code-editor/node.ts +147 -53
  179. package/src/flow/draw.ts +104 -35
  180. package/src/flow/index.ts +33 -13
  181. package/src/flow/model.ts +4 -2
  182. package/src/flow/node.ts +158 -59
  183. package/src/flow/types.ts +46 -21
  184. package/src/formula/index.ts +4 -2
  185. package/src/formula/node.ts +24 -5
  186. package/src/html/controls.ts +4 -1
  187. package/src/html/css/cascade.ts +18 -7
  188. package/src/html/css/parse.ts +46 -21
  189. package/src/html/css/style.ts +6 -1
  190. package/src/html/css/values.ts +25 -9
  191. package/src/html/index.ts +1 -0
  192. package/src/html/layout/boxes.ts +19 -9
  193. package/src/html/layout/inline.ts +23 -1
  194. package/src/html/node.ts +110 -17
  195. package/src/html/paint.ts +17 -2
  196. package/src/index.ts +98 -0
  197. package/src/maps/clip.ts +184 -0
  198. package/src/maps/gzip.ts +66 -0
  199. package/src/maps/index.ts +316 -0
  200. package/src/maps/labels.ts +442 -0
  201. package/src/maps/mvt.ts +857 -0
  202. package/src/maps/node.ts +1819 -0
  203. package/src/maps/overlay.ts +639 -0
  204. package/src/maps/paint.ts +978 -0
  205. package/src/maps/proj.ts +641 -0
  206. package/src/maps/sources.ts +425 -0
  207. package/src/maps/style.ts +309 -0
  208. package/src/maps/styles.ts +1040 -0
  209. package/src/maps/tiles.ts +676 -0
  210. package/src/maps/types.ts +260 -0
  211. package/src/reorder/hx.ts +31 -0
  212. package/src/reorder/index.ts +2223 -0
  213. package/src/reorder/model.ts +232 -0
  214. package/src/richtext/node.ts +49 -14
  215. package/src/richtext/runs.ts +115 -30
  216. package/src/tabs/index.ts +1040 -204
  217. package/src/terminal/vt/fonts.ts +128 -36
  218. package/src/terminal/vt/node.ts +105 -12
  219. package/src/terminal/vt/renderer.ts +13 -3
@@ -0,0 +1,1819 @@
1
+ // `<mapview>` — the element that draws the map.
2
+ //
3
+ // One element, drawing a whole scene, for the reason `AGENTS.md` gives
4
+ // under "Drawing beats composing when the viewport is a transform": pan and
5
+ // zoom are a transform, this renderer's style vocabulary has no transform,
6
+ // and a composed map would re-render every road through React and re-lay it
7
+ // out through yoga on every pointer step. `<Flow>` reached the same place
8
+ // first; this adds the part a graph does not have, which is that the scene
9
+ // arrives a tile at a time and costs tens of milliseconds a tile to draw.
10
+ //
11
+ // Three caches, and the map's whole performance argument is the way they
12
+ // are layered:
13
+ //
14
+ // 1. **Tile data**, keyed on `source/z/x/y` and valid forever.
15
+ // 2. **A rendered `Surface` per tile**, valid for a zoom *level* and a
16
+ // style — not for a camera position. So a **pan** composites the same
17
+ // surfaces at new offsets (and blits, so most of them are not even
18
+ // composited), and a **fractional zoom** composites them scaled. Neither
19
+ // rasterizes anything. Only crossing an integer zoom does.
20
+ // 3. **A label placement in world pixels**, valid for a zoom and a set of
21
+ // loaded tiles — so a pan translates it rather than recomputing it, and
22
+ // the blit stays correct.
23
+ //
24
+ // And one budget: rasterization is resumable by style run, and a frame
25
+ // spends at most `rasterBudgetMs` on it. A dense city tile is 50-140 ms to
26
+ // draw (see `docs/prd-maps.md` for the measurements), so without this a
27
+ // tile arriving would drop eight frames; with it, the tile fills in over a
28
+ // dozen frames and no frame is late. Gestures set the budget to zero, so
29
+ // nothing is ever rasterized during a drag.
30
+ import { Node } from 'react-x11/node';
31
+ import type { A11ySceneItem, Context2D } from 'react-x11/node';
32
+ import type { KeyboardEvent, MouseEvent, WheelEvent } from 'react-x11';
33
+ import { Surface } from 'react-x11/ntk';
34
+
35
+ import { GeometryBuffer } from './mvt.js';
36
+ import {
37
+ BATCH_VERTICES,
38
+ DrawScratch,
39
+ drawTileRun,
40
+ isMapCanvas,
41
+ now,
42
+ prepareStyle,
43
+ } from './paint.js';
44
+ import type { MapCanvas, PreparedStyle } from './paint.js';
45
+ import {
46
+ DEFAULT_TILE_SIZE,
47
+ cameraForBounds,
48
+ boundsOf,
49
+ dataTileFor,
50
+ subTileOf,
51
+ projectLngLat,
52
+ rasterFor,
53
+ tileCover,
54
+ transformFor,
55
+ unprojectPoint,
56
+ visibleBounds,
57
+ } from './proj.js';
58
+ import type {
59
+ LngLat,
60
+ LngLatBounds,
61
+ MapCamera,
62
+ ScreenRect,
63
+ TileCoverEntry,
64
+ TileId,
65
+ Transform,
66
+ } from './proj.js';
67
+ import { TileCache, pyramid } from './tiles.js';
68
+ import type { CachedTile, SurfaceLike, TileRender } from './tiles.js';
69
+ import type { MapSource } from './sources.js';
70
+ import { shortbreadStyle } from './styles.js';
71
+ import type { MapStyle } from './style.js';
72
+ import {
73
+ LabelShaper,
74
+ collectLabels,
75
+ drawLabels,
76
+ placeLabels,
77
+ } from './labels.js';
78
+ import type { FontsLike, LabelCandidate, PlacedLabel } from './labels.js';
79
+ import { drawMarkers, drawOverlays, markerAt, markerRect } from './overlay.js';
80
+ import type { MapMarker, MapOverlay, OverlayPalette } from './overlay.js';
81
+ import type { MapFrameStats, MapPointerEvent } from './types.js';
82
+
83
+ /** Registration key, `kind` and JSX tag, one string — react-x11 rejects a
84
+ * node whose `kind` is not the name it was registered under, because
85
+ * `kind` is what paint order, the test queries and the DEV style assertion
86
+ * all match on. */
87
+ export const ELEMENT = 'mapview';
88
+
89
+ /**
90
+ * The props whose change means different pixels but whose damage this
91
+ * element claims for itself.
92
+ *
93
+ * Without this, a controlled map committing a new `markers` array per
94
+ * pointer step would claim the whole pane on every one of them, and the
95
+ * scoped claim the gesture made would be swallowed by it — the same trap
96
+ * `<Flow>` documents (react-x11#301).
97
+ */
98
+ export const SELF_DAMAGED_PROPS = [
99
+ 'camera',
100
+ 'markers',
101
+ 'overlays',
102
+ 'sources',
103
+ 'mapStyle',
104
+ ] as const;
105
+
106
+ /** Screen pixels the pointer may travel before a press becomes a drag. */
107
+ const DRAG_THRESHOLD = 3;
108
+
109
+ /** How long after the last gesture step the map goes back to rasterizing.
110
+ * Long enough that a wheel-zoom's many steps count as one gesture, short
111
+ * enough that the map sharpens before the user has finished looking. */
112
+ const SETTLE_MS = 140;
113
+
114
+ /** Zoom is quantized to this, for the reason react-x11's docs/scale.md
115
+ * gives about a gesture-driven `scale`: every distinct value is a distinct
116
+ * set of font sizes to shape, and a wheel feeding a raw accumulator makes
117
+ * a new set per frame. A sixteenth of a level is finer than the eye reads
118
+ * as stepping. */
119
+ const ZOOM_STEP = 1 / 16;
120
+
121
+ /** A wheel notch is this much zoom. */
122
+ const WHEEL_ZOOM = 1 / 2.5;
123
+
124
+ /** The largest tile surface, per edge, in device pixels. An argb32 surface
125
+ * is `4 × size²` bytes, so 2048 is 16 MB and is already more than any
126
+ * pyramid justifies. */
127
+ const MAX_RASTER = 2048;
128
+
129
+ /**
130
+ * How many levels past a source's own depth the cover may go.
131
+ *
132
+ * Each level is a factor of two in linear detail and four in the number of
133
+ * tiles sharing one fetch, so six is 64× sharper than the stretched bitmap
134
+ * it replaces and 4,096 renderings per source tile at the very bottom —
135
+ * which is fine, because only the handful on screen are ever built. Beyond
136
+ * this the data itself is the limit: at zoom 20 one unit of a zoom-14
137
+ * tile's 4,096-unit grid is already 16 device pixels across, so there is no
138
+ * more shape to draw.
139
+ */
140
+ const MAX_OVERZOOM = 6;
141
+
142
+ /** How far outside the pane tiles are kept warm, in logical pixels. Half a
143
+ * tile: enough that an ordinary flick has its tiles, not so much that a
144
+ * window covers four times the tiles it shows. */
145
+ const COVER_PADDING = 256;
146
+
147
+ /** The overlap of two rects, or null when they do not meet. */
148
+ function intersectRects(a: ScreenRect, b: ScreenRect): ScreenRect | null {
149
+ const x = Math.max(a.x, b.x);
150
+ const y = Math.max(a.y, b.y);
151
+ const right = Math.min(a.x + a.width, b.x + b.width);
152
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
153
+ if (right <= x || bottom <= y) return null;
154
+ return { x, y, width: right - x, height: bottom - y };
155
+ }
156
+
157
+ const timers = globalThis as {
158
+ setTimeout?(fn: () => void, ms: number): unknown;
159
+ clearTimeout?(id: unknown): void;
160
+ };
161
+
162
+ /**
163
+ * The settle timer, unref'd where the runtime allows it.
164
+ *
165
+ * A map that has just been panned holds a 140 ms timer, and an unref'd one
166
+ * does not keep a process alive on its own — which matters for a script or
167
+ * a test that renders a map and expects to exit, and is the call core's
168
+ * caret blink makes for the same reason.
169
+ */
170
+ function arm(tick: () => void): unknown {
171
+ const handle = timers.setTimeout?.(tick, SETTLE_MS) ?? null;
172
+ (handle as { unref?(): void } | null)?.unref?.();
173
+ return handle;
174
+ }
175
+
176
+ function clamp(value: number, low: number, high: number): number {
177
+ return value < low ? low : value > high ? high : value;
178
+ }
179
+
180
+ function quantize(zoom: number): number {
181
+ return Math.round(zoom / ZOOM_STEP) * ZOOM_STEP;
182
+ }
183
+
184
+ /** A logical value put on the device grid — the same helper `src/flow/`
185
+ * keeps, and for the same reason: ntk's fast paths for a blit and a
186
+ * rounded box are gated on integral geometry, and `x * 1.5` is not always
187
+ * the integer it should be in floating point. */
188
+ function toDevice(value: number, scale: number): number {
189
+ const out = value * scale;
190
+ const whole = Math.round(out);
191
+ return Math.abs(out - whole) < 1e-6 ? whole : out;
192
+ }
193
+
194
+ function rectsOverlap(a: ScreenRect, b: ScreenRect): boolean {
195
+ return (
196
+ a.x < b.x + b.width &&
197
+ a.x + a.width > b.x &&
198
+ a.y < b.y + b.height &&
199
+ a.y + a.height > b.y
200
+ );
201
+ }
202
+
203
+ type Gesture =
204
+ | {
205
+ kind: 'pan';
206
+ startX: number;
207
+ startY: number;
208
+ lastX: number;
209
+ lastY: number;
210
+ moved: boolean;
211
+ }
212
+ | { kind: 'marker'; id: string; startX: number; startY: number };
213
+
214
+ export class MapViewNode extends Node {
215
+ private readonly _cache: TileCache;
216
+ private readonly _scratch = new DrawScratch();
217
+ private readonly _geometry = new GeometryBuffer();
218
+ private _shaper: LabelShaper | null = null;
219
+
220
+ /**
221
+ * The camera this element owns, used whenever `props.camera` is absent.
222
+ *
223
+ * The element keeping it — rather than the component above holding it in
224
+ * `useState` — is what makes a pan cost nothing but a blit: a drag step
225
+ * moves this number and claims a strip, and React is not involved at all.
226
+ * Routed through state instead, every pointer step would be a render, a
227
+ * commit and a full-pane claim, which is the shape `<Flow>` documents as
228
+ * "the content lags and catches up".
229
+ */
230
+ private _camera: MapCamera = { center: { lon: 0, lat: 20 }, zoom: 2 };
231
+
232
+ private _prepared: PreparedStyle | null = null;
233
+ private _preparedFrom: MapStyle | null = null;
234
+ private _defaultStyle: MapStyle | null = null;
235
+
236
+ /** The placement, and what it was computed for. */
237
+ private _labels: PlacedLabel[] = [];
238
+ private _labelKey = '';
239
+ /** Candidates per tile, so a pan that brings a tile back does not redo
240
+ * the walk over its symbol layers. */
241
+ private readonly _candidates = new Map<string, LabelCandidate[]>();
242
+
243
+ private _gesture: Gesture | null = null;
244
+ private _hover: string | null = null;
245
+ /** Set while a gesture is in flight and for `SETTLE_MS` after it, which
246
+ * is when rasterization is suspended. */
247
+ private _settleAt = 0;
248
+ private _settleTimer: unknown = null;
249
+ private _painting = false;
250
+ /** Whether any tile has been rasterized in the frame being painted — the
251
+ * forward-progress guarantee below. */
252
+ private _rastered = false;
253
+ private _frameClip: ScreenRect | null = null;
254
+ private _stats: MapFrameStats | null = null;
255
+ private _sceneAnnounced = false;
256
+
257
+ constructor(props: Record<string, unknown>, app: unknown) {
258
+ super(ELEMENT, props, app as ConstructorParameters<typeof Node>[2]);
259
+ // Seeded once. `defaultCamera` is read here and never again, which is
260
+ // what makes it a *default* rather than a second controlled prop.
261
+ const seed = (props.camera ?? props.defaultCamera) as MapCamera | undefined;
262
+ if (seed) this._camera = { center: { ...seed.center }, zoom: seed.zoom };
263
+ // A map is a thing you drive with the keyboard as well as the mouse:
264
+ // arrows pan, +/- zoom. Without this it is never focused and no key
265
+ // arrives.
266
+ this.focusableByDefault = true;
267
+ this.defaultCursor = 'grab';
268
+ this._cache = new TileCache({
269
+ surfaceBudget: this._prop<number>('surfaceBudget'),
270
+ onError: (entry) => {
271
+ this._prop<
272
+ (error: unknown, tile: TileId & { sourceId: string }) => void
273
+ >('onTileError')?.(entry.error, {
274
+ ...entry.tile,
275
+ // The data entry is keyed per source, so the id is recoverable
276
+ // from the key it was built with.
277
+ sourceId: entry.key.slice(0, entry.key.lastIndexOf(':')),
278
+ });
279
+ },
280
+ onChange: () => {
281
+ // A tile landed. Its own box is the honest claim, but the tile is
282
+ // not yet rasterized and the label placement may change, so the
283
+ // frame is a full one — which is what a tile arriving looks like
284
+ // anyway, and it happens once per tile rather than per frame.
285
+ this._labelKey = '';
286
+ this._repaint('content');
287
+ },
288
+ });
289
+ }
290
+
291
+ // --- props ---------------------------------------------------------------
292
+
293
+ private _prop<T>(name: string): T | undefined {
294
+ return this.props[name] as T | undefined;
295
+ }
296
+
297
+ private get _scale(): number {
298
+ return this.scale > 0 ? this.scale : 1;
299
+ }
300
+
301
+ /** The style, compiled. Recompiled only when the style object changes
302
+ * identity, so an application holding one in a module constant pays
303
+ * once for the life of the process. */
304
+ private _style(): PreparedStyle {
305
+ const given = this._prop<MapStyle>('mapStyle');
306
+ if (given) {
307
+ if (this._prepared && this._preparedFrom === given) return this._prepared;
308
+ this._preparedFrom = given;
309
+ this._prepared = prepareStyle(given);
310
+ return this._prepared;
311
+ }
312
+ if (!this._defaultStyle) {
313
+ // Built once, and from the theme's own light/dark decision rather
314
+ // than from a prop: a map inside a dark application that stays light
315
+ // is the thing everyone notices first.
316
+ this._defaultStyle = shortbreadStyle({ dark: this._isDark() });
317
+ }
318
+ if (this._prepared && this._preparedFrom === this._defaultStyle) {
319
+ return this._prepared;
320
+ }
321
+ this._preparedFrom = this._defaultStyle;
322
+ this._prepared = prepareStyle(this._defaultStyle);
323
+ return this._prepared;
324
+ }
325
+
326
+ private _isDark(): boolean {
327
+ const theme = this.theme as Record<string, unknown> | undefined;
328
+ const background = theme?.background;
329
+ if (typeof background !== 'string') return false;
330
+ // The same reading `src/code-editor/`'s token themes make: luminance of
331
+ // the surface the widget sits on, not a flag nobody sets.
332
+ const hex = background.trim();
333
+ if (!hex.startsWith('#') || hex.length < 7) return false;
334
+ const r = parseInt(hex.slice(1, 3), 16);
335
+ const g = parseInt(hex.slice(3, 5), 16);
336
+ const b = parseInt(hex.slice(5, 7), 16);
337
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
338
+ }
339
+
340
+ private _palette(): OverlayPalette {
341
+ const theme = this.theme as Record<string, unknown> | undefined;
342
+ return {
343
+ accent: (theme?.accent as string) ?? '#2d6cdf',
344
+ background: (theme?.background as string) ?? '#ffffff',
345
+ text: (theme?.text as string) ?? '#111111',
346
+ };
347
+ }
348
+
349
+ private _sources(): MapSource[] {
350
+ const given = this._prop<readonly MapSource[]>('sources');
351
+ return given ? [...given] : [];
352
+ }
353
+
354
+ private _sourceId(source: MapSource, index: number): string {
355
+ return source.id ?? `source-${index}`;
356
+ }
357
+
358
+ private _minZoom(): number {
359
+ return this._prop<number>('minZoom') ?? 0;
360
+ }
361
+
362
+ private _maxZoom(): number {
363
+ return this._prop<number>('maxZoom') ?? 22;
364
+ }
365
+
366
+ private _interactive(): boolean {
367
+ return this._prop<boolean>('interactive') !== false;
368
+ }
369
+
370
+ private _markers(): readonly MapMarker[] {
371
+ return this._prop<readonly MapMarker[]>('markers') ?? [];
372
+ }
373
+
374
+ private _overlays(): readonly MapOverlay[] {
375
+ return this._prop<readonly MapOverlay[]>('overlays') ?? [];
376
+ }
377
+
378
+ // --- geometry ------------------------------------------------------------
379
+
380
+ /** The pane in logical pixels — the unit everything public here speaks.
381
+ * `contentBox()` is device, like everything core hands an element. */
382
+ private _pane(): ScreenRect {
383
+ const box = this.contentBox();
384
+ const s = this._scale;
385
+ return {
386
+ x: box.x / s,
387
+ y: box.y / s,
388
+ width: box.width / s,
389
+ height: box.height / s,
390
+ };
391
+ }
392
+
393
+ private _visible(): boolean {
394
+ const pane = this._pane();
395
+ return pane.width > 0 && pane.height > 0;
396
+ }
397
+
398
+ camera(): MapCamera {
399
+ const given = this._prop<MapCamera>('camera');
400
+ return given ?? this._camera;
401
+ }
402
+
403
+ /** The camera resolved against the pane. */
404
+ private _transform(camera = this.camera()): Transform {
405
+ const pane = this._pane();
406
+ return transformFor(
407
+ camera,
408
+ { width: pane.width, height: pane.height },
409
+ DEFAULT_TILE_SIZE,
410
+ );
411
+ }
412
+
413
+ /** Claim a logical rect as damage. */
414
+ private _claim(rect: ScreenRect, reason: string): void {
415
+ const s = this._scale;
416
+ const x = Math.floor(toDevice(rect.x, s));
417
+ const y = Math.floor(toDevice(rect.y, s));
418
+ this.invalidate(
419
+ false,
420
+ {
421
+ x,
422
+ y,
423
+ width: Math.ceil(toDevice(rect.x + rect.width, s)) - x,
424
+ height: Math.ceil(toDevice(rect.y + rect.height, s)) - y,
425
+ },
426
+ reason,
427
+ );
428
+ }
429
+
430
+ private _repaint(reason = 'content'): void {
431
+ this.invalidate(false, this.abs, reason);
432
+ }
433
+
434
+ /**
435
+ * Ask for another frame without asking for a repaint.
436
+ *
437
+ * There is no "call me next frame" on the element seam — damage is what
438
+ * schedules a paint — so this claims a single pixel. That is the honest
439
+ * claim for a frame whose only job is to continue a rasterization: the
440
+ * tile being drawn is a second surface nobody is looking at, so *nothing
441
+ * on screen changes* until it lands, and the one thing that does change
442
+ * pixels claims its own box when it does.
443
+ *
444
+ * Claiming the pane instead repaints the whole map at the refresh rate
445
+ * for the several frames a redraw takes. On X11 that is wasted work; on
446
+ * the Cocoa backend, which paints many more frames a second, it is a
447
+ * visible burst of repaints at the end of every zoom.
448
+ */
449
+ private _wake(reason = 'content'): void {
450
+ const box = this.contentBox();
451
+ this.invalidate(false, { x: box.x, y: box.y, width: 1, height: 1 }, reason);
452
+ }
453
+
454
+ // --- camera --------------------------------------------------------------
455
+
456
+ /**
457
+ * Move the camera.
458
+ *
459
+ * The one place the camera changes, so the controlled/uncontrolled fork,
460
+ * the clamping, the notification and — the interesting part — the
461
+ * decision between a blit and a repaint all live together.
462
+ */
463
+ private _applyCamera(next: MapCamera, blit = true): void {
464
+ const previous = this.camera();
465
+ const zoom = clamp(next.zoom, this._minZoom(), this._maxZoom());
466
+ // Latitude is clamped to what Web Mercator can represent; longitude is
467
+ // not, because the map wraps and a camera just past the antimeridian is
468
+ // a camera in the next copy of the world.
469
+ const camera: MapCamera = {
470
+ center: { lon: next.center.lon, lat: clamp(next.center.lat, -85, 85) },
471
+ zoom,
472
+ };
473
+ if (
474
+ camera.zoom === previous.zoom &&
475
+ camera.center.lon === previous.center.lon &&
476
+ camera.center.lat === previous.center.lat
477
+ ) {
478
+ return;
479
+ }
480
+ if (this.props.camera === undefined) this._camera = camera;
481
+ this._prop<(camera: MapCamera) => void>('onCameraChange')?.(camera);
482
+ if (this._painting) return;
483
+ // Every camera move defers rasterization, not just a pointer gesture.
484
+ // An application animating a camera with `panBy` in a loop wants
485
+ // exactly what a drag wants — composites while it moves, a sharpen when
486
+ // it stops — and a single programmatic move only pays the settle delay,
487
+ // which is a seventh of a second.
488
+ this._touchGesture();
489
+ if (Math.floor(camera.zoom) !== Math.floor(previous.zoom)) {
490
+ // A new pyramid level: different tiles, different labels.
491
+ this._labelKey = '';
492
+ }
493
+ if (!blit || !this._blitPan(previous, camera)) this._repaint('scroll');
494
+ }
495
+
496
+ /**
497
+ * A pan is a scroll in every way but the bookkeeping, and react-x11#303
498
+ * made the bookkeeping public: `scrollContents` claims the pane, arms the
499
+ * frame to blit the band that survives, and narrows the claim to the
500
+ * strip the shift exposed — which `paintDamage()` then hands to `paint`,
501
+ * so the existing culling draws the sliver and nothing else.
502
+ *
503
+ * The attribution strip is pinned to the pane, so its pixels must not
504
+ * ride the blit: its band is carved out of the region that shifts and
505
+ * claimed the ordinary way. The blit gate tests foreign claims against
506
+ * the *rect* (react-x11#309/#310), so a claim sitting edge to edge with
507
+ * it leaves the frame a blit.
508
+ *
509
+ * Still a full repaint when the zoom moved (scaling is not a blit) or the
510
+ * shift is fractional on the device grid — every real pan gesture is
511
+ * whole device pixels, because that is how it came off the wire.
512
+ */
513
+ private _blitPan(previous: MapCamera, next: MapCamera): boolean {
514
+ if (next.zoom !== previous.zoom) return false;
515
+ const pane = this._pane();
516
+ if (pane.width <= 0 || pane.height <= 0) return false;
517
+ const before = this._transform(previous);
518
+ const after = this._transform(next);
519
+ if (before.world !== after.world) return false;
520
+ const s = this._scale;
521
+ // How far the *pixels* moved — the sense `Surface.copyWithin` uses,
522
+ // which is the opposite of the camera's motion.
523
+ const shiftX = toDevice((before.centerX - after.centerX) * before.world, s);
524
+ const shiftY = toDevice((before.centerY - after.centerY) * before.world, s);
525
+ const dx = Math.round(shiftX);
526
+ const dy = Math.round(shiftY);
527
+ if (dx === 0 && dy === 0) return true; // sub-pixel: nothing to show yet
528
+ if (shiftX !== dx || shiftY !== dy) return false;
529
+
530
+ const box = this.contentBox();
531
+ const strip = Math.ceil(toDevice(this._attributionHeight(), s));
532
+ const blit = {
533
+ x: box.x,
534
+ y: box.y,
535
+ width: box.width,
536
+ height: box.height - strip,
537
+ };
538
+ if (blit.width < 64 * s || blit.height < 64 * s) return false;
539
+ if (Math.abs(dx) >= blit.width || Math.abs(dy) >= blit.height) return false;
540
+ this.scrollContents(blit, dx, dy);
541
+ if (strip > 0) {
542
+ this.invalidate(
543
+ false,
544
+ { x: box.x, y: blit.y + blit.height, width: box.width, height: strip },
545
+ 'scroll',
546
+ );
547
+ }
548
+ return true;
549
+ }
550
+
551
+ /** Zoom about a point that must not move — the pointer under a wheel,
552
+ * the pane's centre for a key. */
553
+ private _zoomAbout(delta: number, screenX: number, screenY: number): void {
554
+ const camera = this.camera();
555
+ const zoom = clamp(
556
+ quantize(camera.zoom + delta),
557
+ this._minZoom(),
558
+ this._maxZoom(),
559
+ );
560
+ if (zoom === camera.zoom) return;
561
+ const before = this._transform(camera);
562
+ const anchor = unprojectPoint(before, screenX, screenY);
563
+ const after = this._transform({ center: camera.center, zoom });
564
+ // Where the anchor would land at the new zoom, and how far the centre
565
+ // has to move so it lands where it already is.
566
+ const moved = projectLngLat(after, anchor);
567
+ const dx = (moved.x - screenX) / after.world;
568
+ const dy = (moved.y - screenY) / after.world;
569
+ this._applyCamera(
570
+ {
571
+ zoom,
572
+ center: unprojectPoint(
573
+ {
574
+ ...after,
575
+ centerX: after.centerX + dx,
576
+ centerY: after.centerY + dy,
577
+ },
578
+ after.paneX,
579
+ after.paneY,
580
+ ),
581
+ },
582
+ false,
583
+ );
584
+ }
585
+
586
+ /** Suspend rasterization for the length of a gesture, and arrange for it
587
+ * to resume. */
588
+ private _touchGesture(): void {
589
+ // Wall-clock here, not the budget clock: this is a 140 ms window, and
590
+ // it is compared inside a timer callback.
591
+ this._settleAt = Date.now() + SETTLE_MS;
592
+ if (this._settleTimer !== null) return;
593
+ const tick = (): void => {
594
+ this._settleTimer = null;
595
+ if (Date.now() < this._settleAt) {
596
+ this._settleTimer = arm(tick);
597
+ return;
598
+ }
599
+ // The gesture is over: sharpen. A wake-up, not a repaint — nothing
600
+ // has moved since the last frame, so what is on screen is still
601
+ // right; what is needed is a frame to start rasterizing in, and each
602
+ // tile claims its own box as it lands.
603
+ this._prop<(camera: MapCamera) => void>('onMoveEnd')?.(this.camera());
604
+ this._wake('content');
605
+ };
606
+ this._settleTimer = arm(tick);
607
+ }
608
+
609
+ private get _gesturing(): boolean {
610
+ return this._gesture !== null || Date.now() < this._settleAt;
611
+ }
612
+
613
+ // --- the imperative surface ----------------------------------------------
614
+
615
+ getCamera(): MapCamera {
616
+ const camera = this.camera();
617
+ return { center: { ...camera.center }, zoom: camera.zoom };
618
+ }
619
+
620
+ setCamera(camera: Partial<MapCamera>): void {
621
+ const current = this.camera();
622
+ this._applyCamera(
623
+ {
624
+ center: camera.center ?? current.center,
625
+ zoom: camera.zoom ?? current.zoom,
626
+ },
627
+ false,
628
+ );
629
+ }
630
+
631
+ /** Move by a distance in pane-local logical pixels. */
632
+ panBy(dx: number, dy: number): void {
633
+ const transform = this._transform();
634
+ this._applyCamera({
635
+ zoom: transform.zoom,
636
+ center: unprojectPoint(
637
+ transform,
638
+ transform.paneX + dx,
639
+ transform.paneY + dy,
640
+ ),
641
+ });
642
+ }
643
+
644
+ zoomIn(step = 1): void {
645
+ const pane = this._pane();
646
+ this._zoomAbout(step, pane.width / 2, pane.height / 2);
647
+ }
648
+
649
+ zoomOut(step = 1): void {
650
+ this.zoomIn(-step);
651
+ }
652
+
653
+ zoomTo(zoom: number): void {
654
+ this.setCamera({ zoom });
655
+ }
656
+
657
+ fitBounds(
658
+ bounds: LngLatBounds,
659
+ options?: { padding?: number; maxZoom?: number },
660
+ ): void {
661
+ const pane = this._pane();
662
+ if (pane.width <= 0 || pane.height <= 0) {
663
+ // Asked before layout has run — which `fitBounds` in an effect always
664
+ // is. Remembered and applied at the first paint that has a size.
665
+ this._pendingFit = { bounds, options };
666
+ return;
667
+ }
668
+ this._applyCamera(
669
+ cameraForBounds(
670
+ bounds,
671
+ { width: pane.width, height: pane.height },
672
+ {
673
+ padding: options?.padding ?? 24,
674
+ tileSize: DEFAULT_TILE_SIZE,
675
+ minZoom: this._minZoom(),
676
+ maxZoom: options?.maxZoom ?? this._maxZoom(),
677
+ },
678
+ ),
679
+ false,
680
+ );
681
+ }
682
+
683
+ fitMarkers(
684
+ ids?: readonly string[],
685
+ options?: { padding?: number; maxZoom?: number },
686
+ ): void {
687
+ const wanted = ids ? new Set(ids) : null;
688
+ const positions: LngLat[] = [];
689
+ for (const marker of this._markers()) {
690
+ if (wanted && !wanted.has(marker.id)) continue;
691
+ positions.push(marker.position);
692
+ }
693
+ const bounds = boundsOf(positions);
694
+ if (bounds) this.fitBounds(bounds, options);
695
+ }
696
+
697
+ getBounds(): LngLatBounds {
698
+ return visibleBounds(this._transform());
699
+ }
700
+
701
+ project(position: LngLat): { x: number; y: number } {
702
+ return projectLngLat(this._transform(), position);
703
+ }
704
+
705
+ unproject(x: number, y: number): LngLat {
706
+ return unprojectPoint(this._transform(), x, y);
707
+ }
708
+
709
+ markerAt(x: number, y: number): MapMarker | null {
710
+ return markerAt(this._markers(), this._transform(), x, y);
711
+ }
712
+
713
+ refresh(): void {
714
+ this._cache.invalidateStyle();
715
+ this._labelKey = '';
716
+ this._repaint('content');
717
+ }
718
+
719
+ stats(): MapFrameStats | null {
720
+ return this._stats;
721
+ }
722
+
723
+ private _pendingFit: {
724
+ bounds: LngLatBounds;
725
+ options?: { padding?: number; maxZoom?: number };
726
+ } | null = null;
727
+
728
+ // --- painting ------------------------------------------------------------
729
+
730
+ /** How tall the attribution strip is, in logical pixels — 0 when there is
731
+ * nothing to say. Read by the blit as well as the paint, so the band it
732
+ * carves out and the band that is drawn are one number. */
733
+ private _attributionHeight(): number {
734
+ return this._attributionText() ? 16 : 0;
735
+ }
736
+
737
+ private _attributionText(): string {
738
+ const given = this._prop<string>('attribution');
739
+ if (given !== undefined) return given;
740
+ const parts: string[] = [];
741
+ for (const source of this._sources()) {
742
+ if (source.attribution && !parts.includes(source.attribution)) {
743
+ parts.push(source.attribution);
744
+ }
745
+ }
746
+ return parts.join(' · ');
747
+ }
748
+
749
+ /** How large a tile is rasterized, and how many surface pixels one
750
+ * logical pixel is — the pair the rasterizer needs, and the pair that
751
+ * keeps a road two logical pixels wide at every fractional zoom. */
752
+ private _rasterPlan(
753
+ entry: TileCoverEntry,
754
+ pyramid: { minZoom: number; maxZoom: number; tileSize: number },
755
+ zoom: number,
756
+ ): { size: number; pixelsPerLogical: number } {
757
+ const scale = this._prop<number>('rasterScale') ?? this._scale;
758
+ // The cover level, not the source's: past the source's own depth the
759
+ // cover synthesizes tiles, and each is rasterized at its own natural
760
+ // size rather than as a slice of a stretched one.
761
+ const raster = rasterFor(
762
+ zoom,
763
+ entry.tile.z,
764
+ { ...pyramid, maxZoom: pyramid.maxZoom + MAX_OVERZOOM },
765
+ scale,
766
+ MAX_RASTER,
767
+ );
768
+ return { size: raster.size, pixelsPerLogical: raster.size / entry.size };
769
+ }
770
+
771
+ private _makeSurface(size: number): SurfaceLike | null {
772
+ try {
773
+ return new Surface(this.app, {
774
+ width: size,
775
+ height: size,
776
+ }) as SurfaceLike;
777
+ } catch {
778
+ // A backend with no offscreen surface — the headless mock. The map
779
+ // then draws its background, its overlays and its markers and no
780
+ // basemap, which is the same posture `src/terminal/vt/` takes when
781
+ // there is no pixel API: degrade, never throw.
782
+ return null;
783
+ }
784
+ }
785
+
786
+ override paint(ctx: Context2D): void {
787
+ const damage = this.paintDamage();
788
+ super.paint(ctx);
789
+ if (!this._visible() || !isMapCanvas(ctx)) return;
790
+
791
+ const started = now();
792
+ this._painting = true;
793
+ const scale = this._scale;
794
+ this._frameClip = damage
795
+ ? {
796
+ x: damage.x / scale,
797
+ y: damage.y / scale,
798
+ width: damage.width / scale,
799
+ height: damage.height / scale,
800
+ }
801
+ : null;
802
+
803
+ if (this._pendingFit) {
804
+ const { bounds, options } = this._pendingFit;
805
+ this._pendingFit = null;
806
+ this._painting = false;
807
+ this.fitBounds(bounds, options);
808
+ this._painting = true;
809
+ }
810
+
811
+ const pane = this._pane();
812
+ const camera = this.camera();
813
+ const transform = this._transform(camera);
814
+ const style = this._style();
815
+ const frame = this._cache.beginFrame();
816
+ const stats: MapFrameStats = {
817
+ rasterMs: 0,
818
+ drawMs: 0,
819
+ tiles: 0,
820
+ ready: 0,
821
+ fromAncestor: 0,
822
+ fromDescendant: 0,
823
+ pending: 0,
824
+ labels: 0,
825
+ errors: 0,
826
+ surfaceBytes: 0,
827
+ damage: damage ? { ...damage } : null,
828
+ draw: { features: 0, vertices: 0, decimated: 0, culled: 0, batches: 0 },
829
+ };
830
+
831
+ ctx.save();
832
+ ctx.beginPath();
833
+ const box = this.contentBox();
834
+ // **The damage rect, not just the pane.**
835
+ //
836
+ // Everything below draws in pane coordinates — a tile at its own box,
837
+ // the whole label layer, every overlay and marker, the attribution —
838
+ // and a partial frame must not put any of it outside the rect it
839
+ // claimed. Core presents the claimed region; pixels drawn beyond it
840
+ // reach the backing store without reaching the screen, and the two
841
+ // then disagree until something repaints the lot. That is what a
842
+ // stale strip of the *previous style* surviving a theme switch is,
843
+ // and why an app switch or a window drag clears it: those force a
844
+ // full expose, which presents everything.
845
+ //
846
+ // It is also most of the cost of a wake frame. A frame that only
847
+ // continues a rasterization claims one pixel and used to redraw every
848
+ // label and marker on the map into it.
849
+ const clip = this._frameClip
850
+ ? intersectRects(box, this._deviceRect(this._frameClip))
851
+ : box;
852
+ if (!clip) {
853
+ this._painting = false;
854
+ this._frameClip = null;
855
+ return;
856
+ }
857
+ ctx.rect(clip.x, clip.y, clip.width, clip.height);
858
+ ctx.clip();
859
+
860
+ // The style's background under everything: it is what the parts of the
861
+ // world with no tile yet look like, so it is most of what a map looks
862
+ // like while it loads.
863
+ const background =
864
+ (this.style.backgroundColor as string | undefined) ??
865
+ this._preparedBackground();
866
+ if (background) {
867
+ ctx.fillStyle = background;
868
+ const region = this._frameClip
869
+ ? this._deviceRect(this._frameClip)
870
+ : { x: box.x, y: box.y, width: box.width, height: box.height };
871
+ ctx.fillRect(region.x, region.y, region.width, region.height);
872
+ }
873
+
874
+ // Rasterization is suspended for the length of a gesture, so a drag or
875
+ // a wheel is composites only. `rasterBudgetMs` bounds the rest.
876
+ const budget = this._gesturing
877
+ ? 0
878
+ : (this._prop<number>('rasterBudgetMs') ?? 8);
879
+ const deadline = started + budget;
880
+ this._rastered = false;
881
+
882
+ const sources = this._sources();
883
+ for (let i = 0; i < sources.length; i++) {
884
+ this._paintSource(
885
+ ctx,
886
+ sources[i],
887
+ this._sourceId(sources[i], i),
888
+ transform,
889
+ pane,
890
+ style,
891
+ stats,
892
+ budget > 0,
893
+ deadline,
894
+ );
895
+ }
896
+
897
+ this._paintLabels(ctx, transform, pane, style, stats);
898
+
899
+ const palette = this._palette();
900
+ drawOverlays(ctx, this._overlays(), transform, pane, scale, palette);
901
+ drawMarkers(ctx, this._markers(), transform, pane, scale, palette);
902
+ this._paintAttribution(ctx, pane, palette);
903
+
904
+ ctx.restore();
905
+ this._cache.sweep();
906
+ stats.surfaceBytes = this._cache.surfaceBytes;
907
+ stats.drawMs = now() - started - stats.rasterMs;
908
+ this._stats = stats;
909
+ this._painting = false;
910
+ this._frameClip = null;
911
+ void frame;
912
+ this._prop<(stats: MapFrameStats) => void>('onFrame')?.(stats);
913
+
914
+ // Tiles left to rasterize: come back next frame and spend another
915
+ // budget on them. This is the whole of the progressive fill-in.
916
+ //
917
+ // Only when a next frame could make progress. With no budget — during a
918
+ // gesture, or because an application pinned `rasterBudgetMs` to 0 — the
919
+ // next frame would draw exactly this one again, and asking for it is a
920
+ // spin. The gesture's own settle timer is what brings the map back.
921
+ //
922
+ // And the claim is **one pixel**, not the pane. A frame that only
923
+ // continues a rasterization changes nothing on screen — the tile being
924
+ // drawn is a second surface nobody is looking at — so claiming the pane
925
+ // asks the renderer to repaint the whole map, at the refresh rate, for
926
+ // the several frames a redraw takes. On X11 that is wasted work; on the
927
+ // Cocoa backend, which paints many more frames a second, it is a
928
+ // visible burst of repaints at the end of every zoom. The one thing
929
+ // that *does* change pixels is a tile finishing, and that claims its
930
+ // own box above.
931
+ if (stats.pending > 0 && budget > 0) this._wake('content');
932
+
933
+ if (!this._sceneAnnounced) {
934
+ this._sceneAnnounced = true;
935
+ this.notifyA11ySceneChanged();
936
+ }
937
+ }
938
+
939
+ private _preparedBackground(): string | undefined {
940
+ const given = this._prop<MapStyle>('mapStyle');
941
+ if (given) return given.background;
942
+ this._style(); // builds `_defaultStyle` on the first paint
943
+ return this._defaultStyle?.background;
944
+ }
945
+
946
+ private _deviceRect(rect: ScreenRect): ScreenRect {
947
+ const s = this._scale;
948
+ const x = Math.floor(rect.x * s);
949
+ const y = Math.floor(rect.y * s);
950
+ return {
951
+ x,
952
+ y,
953
+ width: Math.ceil((rect.x + rect.width) * s) - x,
954
+ height: Math.ceil((rect.y + rect.height) * s) - y,
955
+ };
956
+ }
957
+
958
+ private _paintSource(
959
+ ctx: MapCanvas,
960
+ source: MapSource,
961
+ sourceId: string,
962
+ transform: Transform,
963
+ pane: ScreenRect,
964
+ style: PreparedStyle,
965
+ stats: MapFrameStats,
966
+ /** False for the length of a gesture, when nothing is rasterized. */
967
+ mayRaster: boolean,
968
+ deadline: number,
969
+ ): void {
970
+ const scale = this._scale;
971
+ const p = pyramid(source);
972
+ // The cover goes **deeper than the source cuts**, up to
973
+ // `MAX_OVERZOOM` levels past it, and the data for those tiles comes
974
+ // from their ancestor at the deepest cut level. That is what makes an
975
+ // overzoomed map sharp: instead of one tile rasterized onto a surface
976
+ // and stretched sixty-four times, there are two hundred and fifty-six
977
+ // tiles sharing one fetch, each drawn at its own natural size, with
978
+ // detail limited by the data rather than by a bitmap.
979
+ const cover = tileCover(
980
+ { ...transform, zoom: transform.zoom },
981
+ { ...p, maxZoom: p.maxZoom + MAX_OVERZOOM },
982
+ COVER_PADDING,
983
+ );
984
+ const zoom = transform.zoom;
985
+ const styleZoom = Math.floor(zoom);
986
+ const progressive = this._prop<boolean>('progressive') === true;
987
+ for (const entry of cover) {
988
+ const box = {
989
+ x: pane.x + entry.x,
990
+ y: pane.y + entry.y,
991
+ width: entry.size,
992
+ height: entry.size,
993
+ };
994
+ // Two different questions, and conflating them was a bug worth
995
+ // spelling out. **Whether to work on a tile** is about the pane: the
996
+ // cover is padded, so some of it is off screen and those tiles are
997
+ // wanted (so they load) but never drawn. **Whether to composite it**
998
+ // is about this pass's damage rect, which may be far smaller —
999
+ // including the deliberately tiny claim a rasterization continuation
1000
+ // makes, which must still let the rasterizer run.
1001
+ const onScreen = rectsOverlap(box, pane);
1002
+ const inPass =
1003
+ this._frameClip === null || rectsOverlap(box, this._frameClip);
1004
+ const cached = this._cache.want(
1005
+ source,
1006
+ sourceId,
1007
+ entry.tile,
1008
+ dataTileFor(entry.tile, p.maxZoom),
1009
+ subTileOf(entry.tile, p.maxZoom),
1010
+ );
1011
+ if (!onScreen) continue;
1012
+ stats.tiles++;
1013
+ if (cached.status === 'error') stats.errors++;
1014
+
1015
+ if (cached.status === 'ready') {
1016
+ const plan = this._rasterPlan(entry, p, zoom);
1017
+ const size = cached.raster ? cached.raster.width : plan.size;
1018
+ const drawing = this._cache.beginRender(
1019
+ cached,
1020
+ size,
1021
+ cached.raster ? 0 : styleZoom,
1022
+ (edge: number) => this._makeSurface(edge),
1023
+ );
1024
+ if (drawing && drawing.progress !== -1) {
1025
+ if (cached.raster) {
1026
+ this._uploadRaster(cached, drawing);
1027
+ } else if (mayRaster && (!this._rastered || now() < deadline)) {
1028
+ // **At least one tile per frame, whatever the budget.** A
1029
+ // budget smaller than one unit of work is not "do less", it is
1030
+ // "do nothing" — and since the frame then still has tiles
1031
+ // pending it asks for another one, forever, at the refresh
1032
+ // rate. So the first tile of a frame ignores the deadline and
1033
+ // every tile after it respects it, which bounds a frame at one
1034
+ // tile's overrun and guarantees the map finishes.
1035
+ this._rasterize(
1036
+ cached,
1037
+ drawing,
1038
+ style,
1039
+ entry,
1040
+ plan,
1041
+ styleZoom,
1042
+ stats,
1043
+ deadline,
1044
+ );
1045
+ }
1046
+ // Finished this frame: the new picture replaces the old one, and
1047
+ // the swap is what the whole pair exists for — the tile never
1048
+ // goes blank between them. Claim the box it occupies, because
1049
+ // *that* is the pixel change this whole sequence of frames was
1050
+ // for; the frames before it claimed almost nothing.
1051
+ if (this._cache.promote(cached)) this._claim(box, 'content');
1052
+ }
1053
+ // "Pending" means *there is work left that this map could still
1054
+ // do*, and nothing weaker — because `paint` asks for another frame
1055
+ // while it is non-zero. A tile whose surface could not be made (a
1056
+ // backend that has none) never becomes drawable, and counting it
1057
+ // would spin the frame clock at the refresh rate forever,
1058
+ // repainting a map that cannot change.
1059
+ if (cached.drawing) stats.pending++;
1060
+ }
1061
+
1062
+ // What is composited is `shown`, which is **finished by
1063
+ // construction** — a rendering only becomes `shown` when its last
1064
+ // style run is done. So a tile appears whole rather than as water,
1065
+ // then landuse, then road casings, then roads over a dozen frames,
1066
+ // and a *re*-rasterization does not blank it either: the previous
1067
+ // picture stays up until the new one is ready to replace it.
1068
+ //
1069
+ // `progressive` composites the draft instead, which is the old
1070
+ // behaviour and is honest about what the renderer is doing.
1071
+ const showing =
1072
+ progressive && cached.drawing ? cached.drawing : cached.shown;
1073
+ if (showing) {
1074
+ if (!inPass) continue;
1075
+ this._composite(
1076
+ ctx,
1077
+ showing.surface,
1078
+ showing.size,
1079
+ box,
1080
+ pane,
1081
+ scale,
1082
+ 0,
1083
+ 0,
1084
+ 1,
1085
+ );
1086
+ stats.ready++;
1087
+ continue;
1088
+ }
1089
+ // Nothing of this tile yet — a first load, which no buffering can
1090
+ // help. Borrow the ancestor that is already drawn, scaled up: that is
1091
+ // the difference between a map that fills in and one that flashes
1092
+ // empty on every zoom.
1093
+ if (!inPass) continue;
1094
+
1095
+ // Nothing of this tile yet — a first load, which no buffering can
1096
+ // help. Two ways to cover it, and which is available says which way
1097
+ // the camera moved.
1098
+ //
1099
+ // **Zooming in**, the tile already in hand is this one's *ancestor*:
1100
+ // one composite, scaled up, blurry but complete. **Zooming out**, the
1101
+ // tiles in hand are its *descendants*: several composites, scaled
1102
+ // down, sharp but only as complete as the pieces that are cached.
1103
+ // Only the first of those existed at first, so a zoom out showed the
1104
+ // background — with the labels and the markers still drawn over it —
1105
+ // until the coarser tile had been fetched, rasterized and composited.
1106
+ //
1107
+ // Descendants win when they cover the whole square, because they are
1108
+ // sharper and they are the level the user is coming *from*; the
1109
+ // ancestor wins when they do not, because a complete blurry picture
1110
+ // beats a sharp one with holes in it.
1111
+ const kids = this._cache.descendantsWithSurface(sourceId, entry.tile);
1112
+ const covered =
1113
+ kids.length > 0 && kids.length === kids[0].span * kids[0].span;
1114
+ const ancestor = covered
1115
+ ? null
1116
+ : this._cache.ancestorWithSurface(sourceId, entry.tile);
1117
+ if (ancestor?.shown) {
1118
+ const up = entry.tile.z - ancestor.tile.z;
1119
+ const span = 1 << up;
1120
+ const fx = entry.tile.x - (ancestor.tile.x << up);
1121
+ const fy = entry.tile.y - (ancestor.tile.y << up);
1122
+ this._composite(
1123
+ ctx,
1124
+ ancestor.shown.surface,
1125
+ ancestor.shown.size,
1126
+ box,
1127
+ pane,
1128
+ scale,
1129
+ fx,
1130
+ fy,
1131
+ span,
1132
+ );
1133
+ stats.fromAncestor++;
1134
+ } else if (kids.length > 0) {
1135
+ for (const kid of kids) {
1136
+ const piece = entry.size / kid.span;
1137
+ this._composite(
1138
+ ctx,
1139
+ kid.entry.shown!.surface,
1140
+ kid.entry.shown!.size,
1141
+ {
1142
+ x: box.x + kid.x * piece,
1143
+ y: box.y + kid.y * piece,
1144
+ width: piece,
1145
+ height: piece,
1146
+ },
1147
+ pane,
1148
+ scale,
1149
+ 0,
1150
+ 0,
1151
+ 1,
1152
+ );
1153
+ }
1154
+ stats.fromDescendant++;
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Rasterize as much of a tile as the budget allows, run by run.
1161
+ *
1162
+ * `progress` is where it stopped, so the next frame carries on. Layers
1163
+ * are painted bottom-up, so a tile stopped part-way looks like a map
1164
+ * whose upper layers have not arrived rather than like a hole.
1165
+ */
1166
+ private _rasterize(
1167
+ cached: CachedTile,
1168
+ render: TileRender,
1169
+ style: PreparedStyle,
1170
+ entry: TileCoverEntry,
1171
+ plan: { size: number; pixelsPerLogical: number },
1172
+ styleZoom: number,
1173
+ stats: MapFrameStats,
1174
+ deadline: number,
1175
+ ): void {
1176
+ const vector = cached.vector;
1177
+ if (!vector) return;
1178
+ const context = render.context;
1179
+ if (!isMapCanvas(context)) {
1180
+ // No path API on this surface: call it finished and empty rather than
1181
+ // asking again every frame.
1182
+ render.progress = -1;
1183
+ return;
1184
+ }
1185
+ const started = now();
1186
+ this._rastered = true;
1187
+ const pixels = plan.pixelsPerLogical;
1188
+ this._scratch.resetStats();
1189
+ // Where the **data** tile's square lands on this surface. When the
1190
+ // cover has gone deeper than the source cuts, this tile is one cell of
1191
+ // a `span × span` grid over that square, so the square is `span` times
1192
+ // the surface and starts `sub.x` surfaces to the left of it. Everything
1193
+ // outside the surface is clipped by the surface itself, and the cull
1194
+ // below stops it being drawn at all.
1195
+ const sub = cached.sub;
1196
+ const span = render.size * sub.span;
1197
+ const draw = {
1198
+ ox: -sub.x * render.size,
1199
+ oy: -sub.y * render.size,
1200
+ span,
1201
+ pixelsPerLogical: pixels,
1202
+ zoom: styleZoom,
1203
+ // A vertex closer than two-thirds of a pixel to the last one kept
1204
+ // says nothing; a feature under a pixel and a half is not worth a
1205
+ // path. Both are in surface pixels, which is why they are scaled.
1206
+ tolerance: 0.65 * pixels,
1207
+ minFeature: 1.5 * pixels,
1208
+ batchVertices: this._batchVertices(),
1209
+ // The surface, in its own coordinates. Only meaningful when this is
1210
+ // one cell of a larger square — and then it is what stops each of the
1211
+ // cells re-drawing the whole tile's features, which would make an
1212
+ // overzoomed frame cost `span²` times what it should.
1213
+ clip:
1214
+ sub.span > 1
1215
+ ? { x: 0, y: 0, width: render.size, height: render.size }
1216
+ : null,
1217
+ };
1218
+ let run = render.progress;
1219
+ let layer = render.progressLayer;
1220
+ while (run < style.runs.length) {
1221
+ const stoppedAt = drawTileRun(
1222
+ context,
1223
+ vector,
1224
+ style,
1225
+ run,
1226
+ draw,
1227
+ this._scratch,
1228
+ {
1229
+ fromLayer: layer,
1230
+ deadline,
1231
+ },
1232
+ );
1233
+ if (stoppedAt >= 0) {
1234
+ // The budget ran out inside the run; come back to the same run at
1235
+ // the layer after the one that crossed it.
1236
+ layer = stoppedAt;
1237
+ break;
1238
+ }
1239
+ run++;
1240
+ layer = 0;
1241
+ if (now() >= deadline) break;
1242
+ }
1243
+ this._cache.advance(render, run, layer, style);
1244
+ const drawn = this._scratch.stats;
1245
+ stats.draw.features += drawn.features;
1246
+ stats.draw.vertices += drawn.vertices;
1247
+ stats.draw.decimated += drawn.decimated;
1248
+ stats.draw.culled += drawn.culled;
1249
+ stats.draw.batches += drawn.batches;
1250
+ stats.rasterMs += now() - started;
1251
+ }
1252
+
1253
+ /**
1254
+ * How large a path to accumulate before flushing it.
1255
+ *
1256
+ * One number for both backends, which it was not until react-x11 2.6.1.
1257
+ * Before it, the two rasterizers wanted opposite things — X11 turns a
1258
+ * fill into one a8 coverage mask upload, so a bigger path is fewer
1259
+ * uploads over the same pixels, while `CGContextStrokePath` was quadratic
1260
+ * in the number of subpaths — and this element probed the backend and
1261
+ * picked 512 or 12,000. Core chunks a Cocoa stroke itself now
1262
+ * (react-x11#457), at a size it can choose and a caller cannot, so
1263
+ * batching small on that backend only defeats it: on the profiling corpus
1264
+ * 12,000 measures 114 ms against 512's 142 ms at zoom 8, and 96 against
1265
+ * 101 at zoom 12.
1266
+ *
1267
+ * The prop stays, because the number is still a real X11 trade.
1268
+ */
1269
+ private _batchVertices(): number {
1270
+ return this._prop<number>('batchVertices') ?? BATCH_VERTICES;
1271
+ }
1272
+
1273
+ private _uploadRaster(cached: CachedTile, render: TileRender): void {
1274
+ const raster = cached.raster;
1275
+ if (!raster) return;
1276
+ const context = render.context;
1277
+ if (isMapCanvas(context) && context.putImageData) {
1278
+ context.putImageData(raster, 0, 0);
1279
+ }
1280
+ render.progress = -1;
1281
+ }
1282
+
1283
+ /**
1284
+ * Composite one tile's surface, clipped to the pane.
1285
+ *
1286
+ * Two things this has to get right, and they pull in different
1287
+ * directions.
1288
+ *
1289
+ * **The destination edges are rounded independently**, so two tiles that
1290
+ * share an edge round it to the same device pixel and abut exactly. Round
1291
+ * the origin and the size instead and adjacent tiles differ by a pixel
1292
+ * wherever the fractional zoom lands, which draws a grid of hairlines
1293
+ * across the map — the classic tiled-renderer seam.
1294
+ *
1295
+ * **And the destination is clipped before it is handed over**, because
1296
+ * XRender takes composite coordinates as **int16** and an overzoomed tile
1297
+ * is far larger than the pane: at zoom 22 against a pyramid that stops at
1298
+ * 14, one tile is `512 · 2^8` = 131,072 logical pixels across, so a tile
1299
+ * that overlaps the pane can start 73,000 pixels outside it. Unclipped
1300
+ * that is a `RangeError` from `x11/lib/ext/render.js` thrown inside
1301
+ * `paint`, which is the same shape of bug as the unclipped overlay and a
1302
+ * different limit — 32,767 rather than the stroke path's 16.16 fixed
1303
+ * point. Clipping the destination and moving the source rectangle to
1304
+ * match keeps the scale factor `sw/dw` exactly what it was, so nothing
1305
+ * about the picture changes.
1306
+ */
1307
+ private _composite(
1308
+ ctx: MapCanvas,
1309
+ surface: SurfaceLike,
1310
+ size: number,
1311
+ /** Where it lands, in pane-local logical pixels. */
1312
+ dest: ScreenRect,
1313
+ pane: ScreenRect,
1314
+ scale: number,
1315
+ /** Which sub-square of the surface to take, in `subSpan`ths. */
1316
+ subX: number,
1317
+ subY: number,
1318
+ subSpan: number,
1319
+ ): void {
1320
+ if (!ctx.drawImage || size <= 0) return;
1321
+ const x0 = Math.round((pane.x + dest.x) * scale);
1322
+ const y0 = Math.round((pane.y + dest.y) * scale);
1323
+ const x1 = Math.round((pane.x + dest.x + dest.width) * scale);
1324
+ const y1 = Math.round((pane.y + dest.y + dest.height) * scale);
1325
+ if (x1 <= x0 || y1 <= y0) return;
1326
+
1327
+ // The clip is the content box — device pixels, like everything core
1328
+ // hands an element — so what survives is bounded by the window.
1329
+ const box = this.contentBox();
1330
+ const cx0 = Math.max(x0, Math.floor(box.x));
1331
+ const cy0 = Math.max(y0, Math.floor(box.y));
1332
+ const cx1 = Math.min(x1, Math.ceil(box.x + box.width));
1333
+ const cy1 = Math.min(y1, Math.ceil(box.y + box.height));
1334
+ if (cx1 <= cx0 || cy1 <= cy0) return;
1335
+
1336
+ const span = size / subSpan;
1337
+ // Source pixels per destination pixel. Preserved exactly by the
1338
+ // clipping below, which is what keeps the composite's scale right.
1339
+ const kx = span / (x1 - x0);
1340
+ const ky = span / (y1 - y0);
1341
+ const sx = subX * span + (cx0 - x0) * kx;
1342
+ const sy = subY * span + (cy0 - y0) * ky;
1343
+ // Clamped to the surface: a rounding of the destination edges must not
1344
+ // sample a pixel that is not there.
1345
+ const sw = Math.min((cx1 - cx0) * kx, size - sx);
1346
+ const sh = Math.min((cy1 - cy0) * ky, size - sy);
1347
+ if (!(sw > 0) || !(sh > 0)) return;
1348
+ ctx.drawImage(surface, sx, sy, sw, sh, cx0, cy0, cx1 - cx0, cy1 - cy0);
1349
+ }
1350
+
1351
+ /**
1352
+ * Place the labels if the placement is stale, then draw the ones on
1353
+ * screen.
1354
+ *
1355
+ * The placement is keyed on the zoom and the cache generation, and *not*
1356
+ * on the camera position — which is the whole reason it is computed in
1357
+ * world pixels. A pan reuses it and blits; a zoom recomputes it, and a
1358
+ * zoom is already a full repaint. A tile arriving clears the key from
1359
+ * `onChange`, which is the only other thing that can change who wins.
1360
+ */
1361
+ private _paintLabels(
1362
+ ctx: MapCanvas,
1363
+ transform: Transform,
1364
+ pane: ScreenRect,
1365
+ style: PreparedStyle,
1366
+ stats: MapFrameStats,
1367
+ ): void {
1368
+ const fonts = (this.app as { fonts?: FontsLike } | undefined)?.fonts;
1369
+ if (!fonts) return; // headless: nothing to shape with
1370
+ const text = this.resolvedTextStyle();
1371
+ const family =
1372
+ this._prop<MapStyle>('mapStyle')?.fontFamily ??
1373
+ this._defaultStyle?.fontFamily ??
1374
+ text.family;
1375
+ if (!this._shaper) {
1376
+ this._shaper = new LabelShaper(fonts, family, this._scale);
1377
+ } else {
1378
+ this._shaper.reconfigure(fonts, family, this._scale);
1379
+ }
1380
+ const key = `${quantize(transform.zoom)}|${this._cache.generation}`;
1381
+ if (key !== this._labelKey) {
1382
+ this._labelKey = key;
1383
+ const styleZoom = Math.floor(transform.zoom);
1384
+ // Labels come from the tile the **data** came from, at the depth that
1385
+ // source actually cuts — past which many renderings share one tile
1386
+ // and collecting per rendering would place every label `span²` times.
1387
+ const wanted = new Set(
1388
+ this._sources().map((source) =>
1389
+ Math.min(styleZoom, pyramid(source).maxZoom),
1390
+ ),
1391
+ );
1392
+ const candidates: LabelCandidate[] = [];
1393
+ for (const cached of this._cache.dataEntries()) {
1394
+ if (cached.status !== 'ready' || !cached.vector) continue;
1395
+ if (!wanted.has(cached.tile.z)) continue;
1396
+ const at = `${cached.key}|${styleZoom}`;
1397
+ let found = this._candidates.get(at);
1398
+ if (!found) {
1399
+ found = collectLabels(
1400
+ cached.vector,
1401
+ cached.tile,
1402
+ style,
1403
+ styleZoom,
1404
+ this._geometry,
1405
+ );
1406
+ // Bounded the way the shaper's cache is: a map panned across a
1407
+ // continent must not turn this into a leak, and rebuilding a
1408
+ // tile's candidates is one walk over its symbol layers.
1409
+ if (this._candidates.size > 512) this._candidates.clear();
1410
+ this._candidates.set(at, found);
1411
+ }
1412
+ for (const candidate of found) candidates.push(candidate);
1413
+ }
1414
+ this._labels = placeLabels(candidates, transform.world, this._shaper);
1415
+ }
1416
+ stats.labels = drawLabels(
1417
+ ctx,
1418
+ this._labels,
1419
+ transform,
1420
+ pane,
1421
+ this._scale,
1422
+ this._frameClip ? { ...this._frameClip } : null,
1423
+ this._shaper,
1424
+ );
1425
+ }
1426
+
1427
+ /**
1428
+ * The attribution.
1429
+ *
1430
+ * Drawn by the map rather than left to the application because for open
1431
+ * data it is a licence condition rather than a nicety, and a component
1432
+ * whose default quietly omitted it would put every application that used
1433
+ * it in breach. `attribution=""` is the way to say it has been put
1434
+ * somewhere else.
1435
+ */
1436
+ private _paintAttribution(
1437
+ ctx: MapCanvas,
1438
+ pane: ScreenRect,
1439
+ palette: OverlayPalette,
1440
+ ): void {
1441
+ const text = this._attributionText();
1442
+ if (!text || !this._shaper) return;
1443
+ const shaped = this._shaper.shape(text, 9, palette.text);
1444
+ if (!shaped) return;
1445
+ const scale = this._scale;
1446
+ const padding = 4;
1447
+ const width = shaped.width + padding * 2;
1448
+ const height = shaped.height + padding;
1449
+ const x = pane.x + pane.width - width;
1450
+ const y = pane.y + pane.height - height;
1451
+ ctx.save();
1452
+ if (ctx.globalAlpha !== undefined) ctx.globalAlpha = 0.72;
1453
+ ctx.fillStyle = palette.background;
1454
+ ctx.fillRect(
1455
+ Math.round(x * scale),
1456
+ Math.round(y * scale),
1457
+ Math.ceil(width * scale),
1458
+ Math.ceil(height * scale),
1459
+ );
1460
+ if (ctx.globalAlpha !== undefined) ctx.globalAlpha = 1;
1461
+ shaped.layout.draw(
1462
+ ctx,
1463
+ Math.round((x + padding) * scale),
1464
+ Math.round((y + padding / 2) * scale),
1465
+ );
1466
+ ctx.restore();
1467
+ }
1468
+
1469
+ // --- behaviour -----------------------------------------------------------
1470
+
1471
+ /** An event's position in the pane's own logical pixels. A synthetic
1472
+ * event's `x`/`y` are logical and relative to the window, so only the
1473
+ * pane's own origin has to come off. */
1474
+ private _point(ev: MouseEvent): { x: number; y: number } {
1475
+ const pane = this._pane();
1476
+ return { x: ev.x - pane.x, y: ev.y - pane.y };
1477
+ }
1478
+
1479
+ private _pointerEvent(
1480
+ ev: MouseEvent,
1481
+ marker: MapMarker | null,
1482
+ ): MapPointerEvent {
1483
+ const point = this._point(ev);
1484
+ return {
1485
+ lngLat: unprojectPoint(this._transform(), point.x, point.y),
1486
+ x: point.x,
1487
+ y: point.y,
1488
+ marker,
1489
+ shiftKey: ev.shiftKey ?? false,
1490
+ ctrlKey: ev.ctrlKey ?? false,
1491
+ altKey: ev.altKey ?? false,
1492
+ metaKey: ev.metaKey ?? false,
1493
+ button: ev.button ?? 1,
1494
+ };
1495
+ }
1496
+
1497
+ override defaultMouseDown(ev: MouseEvent): void {
1498
+ const point = this._point(ev);
1499
+ const marker = markerAt(
1500
+ this._markers(),
1501
+ this._transform(),
1502
+ point.x,
1503
+ point.y,
1504
+ );
1505
+ if (marker) {
1506
+ this._gesture = {
1507
+ kind: 'marker',
1508
+ id: marker.id,
1509
+ startX: point.x,
1510
+ startY: point.y,
1511
+ };
1512
+ ev.capturePointer?.();
1513
+ return;
1514
+ }
1515
+ if (!this._interactive()) {
1516
+ // Not a pan, but still a press: the release is what makes a click,
1517
+ // and an application listening for one on a frozen map should get it.
1518
+ this._gesture = {
1519
+ kind: 'pan',
1520
+ startX: point.x,
1521
+ startY: point.y,
1522
+ lastX: point.x,
1523
+ lastY: point.y,
1524
+ moved: false,
1525
+ };
1526
+ ev.capturePointer?.();
1527
+ return;
1528
+ }
1529
+ this._gesture = {
1530
+ kind: 'pan',
1531
+ startX: point.x,
1532
+ startY: point.y,
1533
+ lastX: point.x,
1534
+ lastY: point.y,
1535
+ moved: false,
1536
+ };
1537
+ this.focus();
1538
+ ev.capturePointer?.();
1539
+ }
1540
+
1541
+ override defaultMouseDrag(ev: MouseEvent): void {
1542
+ const gesture = this._gesture;
1543
+ if (!gesture || gesture.kind !== 'pan') return;
1544
+ if (!this._interactive()) return;
1545
+ const point = this._point(ev);
1546
+ if (
1547
+ !gesture.moved &&
1548
+ Math.abs(point.x - gesture.startX) < DRAG_THRESHOLD &&
1549
+ Math.abs(point.y - gesture.startY) < DRAG_THRESHOLD
1550
+ ) {
1551
+ return;
1552
+ }
1553
+ gesture.moved = true;
1554
+ // Whole device pixels, because that is what the blit can shift — a
1555
+ // fractional pan would decline it every frame and repaint the pane.
1556
+ const scale = this._scale;
1557
+ const dx = Math.round((point.x - gesture.lastX) * scale) / scale;
1558
+ const dy = Math.round((point.y - gesture.lastY) * scale) / scale;
1559
+ if (dx === 0 && dy === 0) return;
1560
+ gesture.lastX += dx;
1561
+ gesture.lastY += dy;
1562
+ this._touchGesture();
1563
+ this.panBy(-dx, -dy);
1564
+ }
1565
+
1566
+ override defaultMouseUp(ev: MouseEvent): void {
1567
+ const gesture = this._gesture;
1568
+ this._gesture = null;
1569
+ if (!gesture) return;
1570
+ const point = this._point(ev);
1571
+ if (gesture.kind === 'marker') {
1572
+ const marker = this._markers().find((m) => m.id === gesture.id);
1573
+ if (
1574
+ marker &&
1575
+ Math.abs(point.x - gesture.startX) < DRAG_THRESHOLD &&
1576
+ Math.abs(point.y - gesture.startY) < DRAG_THRESHOLD
1577
+ ) {
1578
+ const event = this._pointerEvent(ev, marker);
1579
+ this._prop<(m: MapMarker, e: MapPointerEvent) => void>(
1580
+ 'onMarkerClick',
1581
+ )?.(marker, event);
1582
+ this._prop<(e: MapPointerEvent) => void>('onMapClick')?.(event);
1583
+ }
1584
+ return;
1585
+ }
1586
+ if (gesture.moved) {
1587
+ this._touchGesture();
1588
+ return;
1589
+ }
1590
+ this._prop<(e: MapPointerEvent) => void>('onMapClick')?.(
1591
+ this._pointerEvent(ev, null),
1592
+ );
1593
+ }
1594
+
1595
+ override defaultMouseMove(ev: MouseEvent): void {
1596
+ const notify =
1597
+ this._prop<(m: MapMarker | null, e: MapPointerEvent | null) => void>(
1598
+ 'onMarkerHover',
1599
+ );
1600
+ if (!notify) return;
1601
+ const point = this._point(ev);
1602
+ const marker = markerAt(
1603
+ this._markers(),
1604
+ this._transform(),
1605
+ point.x,
1606
+ point.y,
1607
+ );
1608
+ const id = marker?.id ?? null;
1609
+ if (id === this._hover) return;
1610
+ this._hover = id;
1611
+ notify(marker, this._pointerEvent(ev, marker));
1612
+ }
1613
+
1614
+ override defaultMouseLeave(): void {
1615
+ if (this._hover === null) return;
1616
+ this._hover = null;
1617
+ // No event: the pointer has left the map, so there is no position on it
1618
+ // to report and inventing one would be worse than saying so.
1619
+ this._prop<(m: MapMarker | null, e: MapPointerEvent | null) => void>(
1620
+ 'onMarkerHover',
1621
+ )?.(null, null);
1622
+ }
1623
+
1624
+ /**
1625
+ * The wheel is a zoom, not a scroll, so it is answered here rather than
1626
+ * through `canScroll`/`scrollBy` — the case react-x11's docs/extending.md
1627
+ * carves out. A zoom needs the point that must *not* move, which the
1628
+ * scroll chain never hands over.
1629
+ */
1630
+ override defaultWheel(ev: WheelEvent): void {
1631
+ if (!this._interactive()) return;
1632
+ const pane = this._pane();
1633
+ this._touchGesture();
1634
+ this._zoomAbout(
1635
+ -(ev.deltaY ?? 0) * WHEEL_ZOOM * 0.02,
1636
+ ev.x - pane.x,
1637
+ ev.y - pane.y,
1638
+ );
1639
+ // Consumed whether or not the zoom moved: a wheel over a map is never
1640
+ // meant for whatever is behind it.
1641
+ ev.preventDefault();
1642
+ }
1643
+
1644
+ override defaultKeyDown(ev: KeyboardEvent): void {
1645
+ // `Node` declares the default actions optional — an element that has
1646
+ // no behaviour of its own simply has none — so calling up is an
1647
+ // optional call rather than a plain one.
1648
+ if (!this._interactive()) {
1649
+ super.defaultKeyDown?.(ev);
1650
+ return;
1651
+ }
1652
+ const pane = this._pane();
1653
+ const step = ev.shiftKey ? 200 : 60;
1654
+ switch (ev.keysym) {
1655
+ case 0xff51: // XK_Left
1656
+ this.panBy(-step, 0);
1657
+ break;
1658
+ case 0xff53: // XK_Right
1659
+ this.panBy(step, 0);
1660
+ break;
1661
+ case 0xff52: // XK_Up
1662
+ this.panBy(0, -step);
1663
+ break;
1664
+ case 0xff54: // XK_Down
1665
+ this.panBy(0, step);
1666
+ break;
1667
+ case 0x002b: // XK_plus
1668
+ case 0x003d: // XK_equal
1669
+ case 0xffab: // XK_KP_Add
1670
+ this._zoomAbout(1, pane.width / 2, pane.height / 2);
1671
+ break;
1672
+ case 0x002d: // XK_minus
1673
+ case 0xffad: // XK_KP_Subtract
1674
+ this._zoomAbout(-1, pane.width / 2, pane.height / 2);
1675
+ break;
1676
+ default:
1677
+ // Everything else goes to the base class, which is what keeps the
1678
+ // selection keys and Space/Enter-as-a-click working.
1679
+ super.defaultKeyDown?.(ev);
1680
+ return;
1681
+ }
1682
+ ev.preventDefault();
1683
+ }
1684
+
1685
+ /**
1686
+ * What a screen reader meets.
1687
+ *
1688
+ * A map is one painted rectangle to an assistive technology, and its
1689
+ * markers are the only things in it that are *objects* rather than
1690
+ * cartography — so those are the scene, and the map itself carries the
1691
+ * camera in its description. Announcing every road would be worse than
1692
+ * announcing none.
1693
+ */
1694
+ override a11yScene(): A11ySceneItem[] {
1695
+ if (!this._visible()) return [];
1696
+ const transform = this._transform();
1697
+ const pane = this._pane();
1698
+ const scale = this._scale;
1699
+ const items: A11ySceneItem[] = [];
1700
+ for (const marker of this._markers()) {
1701
+ const rect = markerRect(marker, transform);
1702
+ if (
1703
+ rect.x + rect.width < 0 ||
1704
+ rect.y + rect.height < 0 ||
1705
+ rect.x > pane.width ||
1706
+ rect.y > pane.height
1707
+ ) {
1708
+ continue;
1709
+ }
1710
+ items.push({
1711
+ id: `marker:${marker.id}`,
1712
+ // Device pixels in the owning window's coordinates, which is what
1713
+ // an a11y scene rect is — the same space as `abs`.
1714
+ rect: {
1715
+ x: (pane.x + rect.x) * scale,
1716
+ y: (pane.y + rect.y) * scale,
1717
+ width: rect.width * scale,
1718
+ height: rect.height * scale,
1719
+ },
1720
+ role: 'button',
1721
+ name:
1722
+ marker.title ??
1723
+ `${marker.position.lat.toFixed(4)}, ${marker.position.lon.toFixed(4)}`,
1724
+ states: { selected: marker.selected ?? false },
1725
+ });
1726
+ }
1727
+ return items;
1728
+ }
1729
+
1730
+ override applyProps(
1731
+ next: Record<string, unknown>,
1732
+ prev: Record<string, unknown>,
1733
+ ): void {
1734
+ const before = prev ?? this.props;
1735
+ super.applyProps(next, prev);
1736
+ // Every one of these is in `selfDamagedProps`, so the commit claimed
1737
+ // nothing for them and this is the only claim there will be.
1738
+ if (next.mapStyle !== before.mapStyle) {
1739
+ this._prepared = null;
1740
+ this._preparedFrom = null;
1741
+ this._candidates.clear();
1742
+ this._cache.invalidateStyle();
1743
+ this._labelKey = '';
1744
+ this._repaint('props');
1745
+ return;
1746
+ }
1747
+ if (next.sources !== before.sources) {
1748
+ this._labelKey = '';
1749
+ this._repaint('props');
1750
+ return;
1751
+ }
1752
+ if (next.camera !== before.camera && next.camera !== undefined) {
1753
+ const camera = next.camera as MapCamera;
1754
+ const previous = (before.camera as MapCamera | undefined) ?? this._camera;
1755
+ if (
1756
+ camera.zoom !== previous.zoom ||
1757
+ camera.center.lon !== previous.center.lon ||
1758
+ camera.center.lat !== previous.center.lat
1759
+ ) {
1760
+ if (camera.zoom !== previous.zoom) this._labelKey = '';
1761
+ if (!this._blitPan(previous, camera)) this._repaint('props');
1762
+ }
1763
+ return;
1764
+ }
1765
+ if (next.markers !== before.markers || next.overlays !== before.overlays) {
1766
+ // The union of where they were and where they are. A vehicle moving
1767
+ // across a city claims two marker-sized boxes rather than the pane.
1768
+ this._claimOverlayDamage(
1769
+ (before.markers as readonly MapMarker[] | undefined) ?? [],
1770
+ (next.markers as readonly MapMarker[] | undefined) ?? [],
1771
+ next.overlays !== before.overlays,
1772
+ );
1773
+ this.notifyA11ySceneChanged();
1774
+ }
1775
+ }
1776
+
1777
+ private _claimOverlayDamage(
1778
+ before: readonly MapMarker[],
1779
+ after: readonly MapMarker[],
1780
+ overlaysChanged: boolean,
1781
+ ): void {
1782
+ if (overlaysChanged || !this._visible()) {
1783
+ // An overlay is an arbitrary polyline; its damage is not worth
1784
+ // deriving, and a route changing is not a per-frame event.
1785
+ this._repaint('props');
1786
+ return;
1787
+ }
1788
+ const transform = this._transform();
1789
+ const pane = this._pane();
1790
+ const claim = (markers: readonly MapMarker[]): void => {
1791
+ for (const marker of markers) {
1792
+ const rect = markerRect(marker, transform);
1793
+ this._claim(
1794
+ {
1795
+ x: pane.x + rect.x - 3,
1796
+ y: pane.y + rect.y - 3,
1797
+ width: rect.width + 6,
1798
+ height: rect.height + 6,
1799
+ },
1800
+ 'props',
1801
+ );
1802
+ }
1803
+ };
1804
+ // Both sets, because a marker that was removed has to be painted over.
1805
+ claim(before);
1806
+ claim(after);
1807
+ }
1808
+
1809
+ override destroySubtree(): void {
1810
+ if (this._settleTimer !== null) {
1811
+ timers.clearTimeout?.(this._settleTimer);
1812
+ this._settleTimer = null;
1813
+ }
1814
+ this._cache.destroy();
1815
+ this._candidates.clear();
1816
+ this._labels = [];
1817
+ super.destroySubtree();
1818
+ }
1819
+ }