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