@react-x11/components 0.8.0 → 0.9.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 (323) hide show
  1. package/README.md +95 -34
  2. package/dist/charts/node.d.ts +1 -1
  3. package/dist/charts/node.d.ts.map +1 -1
  4. package/dist/charts/node.js +6 -2
  5. package/dist/charts/node.js.map +1 -1
  6. package/dist/embed/client.d.ts +48 -3
  7. package/dist/embed/client.d.ts.map +1 -1
  8. package/dist/embed/client.js +79 -5
  9. package/dist/embed/client.js.map +1 -1
  10. package/dist/embed/index.d.ts +1 -1
  11. package/dist/embed/index.d.ts.map +1 -1
  12. package/dist/embed/index.js +1 -1
  13. package/dist/embed/index.js.map +1 -1
  14. package/dist/html/node.d.ts +3 -1
  15. package/dist/html/node.d.ts.map +1 -1
  16. package/dist/html/node.js +7 -5
  17. package/dist/html/node.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/{markdown → internal/markdown}/ast.d.ts +12 -0
  23. package/dist/internal/markdown/ast.d.ts.map +1 -0
  24. package/dist/internal/markdown/ast.js +22 -0
  25. package/dist/internal/markdown/ast.js.map +1 -0
  26. package/dist/internal/markdown/parse.d.ts.map +1 -0
  27. package/dist/{markdown → internal/markdown}/parse.js +112 -22
  28. package/dist/internal/markdown/parse.js.map +1 -0
  29. package/dist/internal/markdown/stringify.d.ts +4 -0
  30. package/dist/internal/markdown/stringify.d.ts.map +1 -0
  31. package/dist/internal/markdown/stringify.js +684 -0
  32. package/dist/internal/markdown/stringify.js.map +1 -0
  33. package/dist/internal/markdown/tags.d.ts.map +1 -0
  34. package/dist/internal/markdown/tags.js.map +1 -0
  35. package/dist/internal/scroll.d.ts +3 -2
  36. package/dist/internal/scroll.d.ts.map +1 -1
  37. package/dist/internal/scroll.js +1 -0
  38. package/dist/internal/scroll.js.map +1 -1
  39. package/dist/maps/anchors.d.ts +98 -0
  40. package/dist/maps/anchors.d.ts.map +1 -0
  41. package/dist/maps/anchors.js +531 -0
  42. package/dist/maps/anchors.js.map +1 -0
  43. package/dist/maps/controller.d.ts +283 -0
  44. package/dist/maps/controller.d.ts.map +1 -0
  45. package/dist/maps/controller.js +743 -0
  46. package/dist/maps/controller.js.map +1 -0
  47. package/dist/maps/gl/buckets.d.ts +95 -0
  48. package/dist/maps/gl/buckets.d.ts.map +1 -0
  49. package/dist/maps/gl/buckets.js +362 -0
  50. package/dist/maps/gl/buckets.js.map +1 -0
  51. package/dist/maps/gl/build-worker.d.ts +13 -0
  52. package/dist/maps/gl/build-worker.d.ts.map +1 -0
  53. package/dist/maps/gl/build-worker.js +44 -0
  54. package/dist/maps/gl/build-worker.js.map +1 -0
  55. package/dist/maps/gl/color.d.ts +8 -0
  56. package/dist/maps/gl/color.d.ts.map +1 -0
  57. package/dist/maps/gl/color.js +57 -0
  58. package/dist/maps/gl/color.js.map +1 -0
  59. package/dist/maps/gl/cover.d.ts +51 -0
  60. package/dist/maps/gl/cover.d.ts.map +1 -0
  61. package/dist/maps/gl/cover.js +124 -0
  62. package/dist/maps/gl/cover.js.map +1 -0
  63. package/dist/maps/gl/index.d.ts +3 -0
  64. package/dist/maps/gl/index.d.ts.map +1 -0
  65. package/dist/maps/gl/index.js +20 -0
  66. package/dist/maps/gl/index.js.map +1 -0
  67. package/dist/maps/gl/markers.d.ts +28 -0
  68. package/dist/maps/gl/markers.d.ts.map +1 -0
  69. package/dist/maps/gl/markers.js +60 -0
  70. package/dist/maps/gl/markers.js.map +1 -0
  71. package/dist/maps/gl/overlays.d.ts +68 -0
  72. package/dist/maps/gl/overlays.d.ts.map +1 -0
  73. package/dist/maps/gl/overlays.js +300 -0
  74. package/dist/maps/gl/overlays.js.map +1 -0
  75. package/dist/maps/gl/pane.d.ts +12 -0
  76. package/dist/maps/gl/pane.d.ts.map +1 -0
  77. package/dist/maps/gl/pane.js +37 -0
  78. package/dist/maps/gl/pane.js.map +1 -0
  79. package/dist/maps/gl/placement.d.ts +111 -0
  80. package/dist/maps/gl/placement.d.ts.map +1 -0
  81. package/dist/maps/gl/placement.js +567 -0
  82. package/dist/maps/gl/placement.js.map +1 -0
  83. package/dist/maps/gl/renderer.d.ts +352 -0
  84. package/dist/maps/gl/renderer.d.ts.map +1 -0
  85. package/dist/maps/gl/renderer.js +1127 -0
  86. package/dist/maps/gl/renderer.js.map +1 -0
  87. package/dist/maps/gl/shaders.d.ts +120 -0
  88. package/dist/maps/gl/shaders.d.ts.map +1 -0
  89. package/dist/maps/gl/shaders.js +451 -0
  90. package/dist/maps/gl/shaders.js.map +1 -0
  91. package/dist/maps/gl/store.d.ts +115 -0
  92. package/dist/maps/gl/store.d.ts.map +1 -0
  93. package/dist/maps/gl/store.js +469 -0
  94. package/dist/maps/gl/store.js.map +1 -0
  95. package/dist/maps/gl/text.d.ts +154 -0
  96. package/dist/maps/gl/text.d.ts.map +1 -0
  97. package/dist/maps/gl/text.js +382 -0
  98. package/dist/maps/gl/text.js.map +1 -0
  99. package/dist/maps/gl/view.d.ts +54 -0
  100. package/dist/maps/gl/view.d.ts.map +1 -0
  101. package/dist/maps/gl/view.js +974 -0
  102. package/dist/maps/gl/view.js.map +1 -0
  103. package/dist/maps/index.d.ts +1 -1
  104. package/dist/maps/index.d.ts.map +1 -1
  105. package/dist/maps/index.js +216 -52
  106. package/dist/maps/index.js.map +1 -1
  107. package/dist/maps/labels.d.ts +37 -16
  108. package/dist/maps/labels.d.ts.map +1 -1
  109. package/dist/maps/labels.js +188 -119
  110. package/dist/maps/labels.js.map +1 -1
  111. package/dist/maps/node.d.ts +162 -56
  112. package/dist/maps/node.d.ts.map +1 -1
  113. package/dist/maps/node.js +607 -515
  114. package/dist/maps/node.js.map +1 -1
  115. package/dist/maps/overlay.d.ts +72 -9
  116. package/dist/maps/overlay.d.ts.map +1 -1
  117. package/dist/maps/overlay.js +108 -28
  118. package/dist/maps/overlay.js.map +1 -1
  119. package/dist/maps/paint.d.ts +3 -0
  120. package/dist/maps/paint.d.ts.map +1 -1
  121. package/dist/maps/paint.js.map +1 -1
  122. package/dist/maps/proj.d.ts +14 -0
  123. package/dist/maps/proj.d.ts.map +1 -1
  124. package/dist/maps/proj.js +28 -0
  125. package/dist/maps/proj.js.map +1 -1
  126. package/dist/maps/renderer.d.ts +69 -0
  127. package/dist/maps/renderer.d.ts.map +1 -0
  128. package/dist/maps/renderer.js +66 -0
  129. package/dist/maps/renderer.js.map +1 -0
  130. package/dist/maps/sources.d.ts +25 -3
  131. package/dist/maps/sources.d.ts.map +1 -1
  132. package/dist/maps/sources.js +17 -0
  133. package/dist/maps/sources.js.map +1 -1
  134. package/dist/maps/theme.d.ts +19 -0
  135. package/dist/maps/theme.d.ts.map +1 -0
  136. package/dist/maps/theme.js +44 -0
  137. package/dist/maps/theme.js.map +1 -0
  138. package/dist/maps/tiles.d.ts +117 -17
  139. package/dist/maps/tiles.d.ts.map +1 -1
  140. package/dist/maps/tiles.js +163 -30
  141. package/dist/maps/tiles.js.map +1 -1
  142. package/dist/maps/types.d.ts +188 -20
  143. package/dist/maps/types.d.ts.map +1 -1
  144. package/dist/markdown/expressions.d.ts +1 -1
  145. package/dist/markdown/expressions.d.ts.map +1 -1
  146. package/dist/markdown/expressions.js.map +1 -1
  147. package/dist/markdown/index.d.ts +7 -6
  148. package/dist/markdown/index.d.ts.map +1 -1
  149. package/dist/markdown/index.js +9 -7
  150. package/dist/markdown/index.js.map +1 -1
  151. package/dist/markdown/spans.d.ts +1 -1
  152. package/dist/markdown/spans.d.ts.map +1 -1
  153. package/dist/media-player/index.d.ts +11 -3
  154. package/dist/media-player/index.d.ts.map +1 -1
  155. package/dist/media-player/index.js +13 -2
  156. package/dist/media-player/index.js.map +1 -1
  157. package/dist/rich-text-editor/clipboard.d.ts +23 -0
  158. package/dist/rich-text-editor/clipboard.d.ts.map +1 -0
  159. package/dist/rich-text-editor/clipboard.js +307 -0
  160. package/dist/rich-text-editor/clipboard.js.map +1 -0
  161. package/dist/rich-text-editor/collab.d.ts +32 -0
  162. package/dist/rich-text-editor/collab.d.ts.map +1 -0
  163. package/dist/rich-text-editor/collab.js +55 -0
  164. package/dist/rich-text-editor/collab.js.map +1 -0
  165. package/dist/rich-text-editor/commands.d.ts +50 -0
  166. package/dist/rich-text-editor/commands.d.ts.map +1 -0
  167. package/dist/rich-text-editor/commands.js +376 -0
  168. package/dist/rich-text-editor/commands.js.map +1 -0
  169. package/dist/rich-text-editor/drag.d.ts +68 -0
  170. package/dist/rich-text-editor/drag.d.ts.map +1 -0
  171. package/dist/rich-text-editor/drag.js +148 -0
  172. package/dist/rich-text-editor/drag.js.map +1 -0
  173. package/dist/rich-text-editor/html.d.ts +145 -0
  174. package/dist/rich-text-editor/html.d.ts.map +1 -0
  175. package/dist/rich-text-editor/html.js +516 -0
  176. package/dist/rich-text-editor/html.js.map +1 -0
  177. package/dist/rich-text-editor/index.d.ts +263 -0
  178. package/dist/rich-text-editor/index.d.ts.map +1 -0
  179. package/dist/rich-text-editor/index.js +822 -0
  180. package/dist/rich-text-editor/index.js.map +1 -0
  181. package/dist/rich-text-editor/inline.d.ts +94 -0
  182. package/dist/rich-text-editor/inline.d.ts.map +1 -0
  183. package/dist/rich-text-editor/inline.js +252 -0
  184. package/dist/rich-text-editor/inline.js.map +1 -0
  185. package/dist/rich-text-editor/keymap.d.ts +71 -0
  186. package/dist/rich-text-editor/keymap.d.ts.map +1 -0
  187. package/dist/rich-text-editor/keymap.js +393 -0
  188. package/dist/rich-text-editor/keymap.js.map +1 -0
  189. package/dist/rich-text-editor/keys.d.ts +29 -0
  190. package/dist/rich-text-editor/keys.d.ts.map +1 -0
  191. package/dist/rich-text-editor/keys.js +93 -0
  192. package/dist/rich-text-editor/keys.js.map +1 -0
  193. package/dist/rich-text-editor/look.d.ts +71 -0
  194. package/dist/rich-text-editor/look.d.ts.map +1 -0
  195. package/dist/rich-text-editor/look.js +342 -0
  196. package/dist/rich-text-editor/look.js.map +1 -0
  197. package/dist/rich-text-editor/markdown.d.ts +18 -0
  198. package/dist/rich-text-editor/markdown.d.ts.map +1 -0
  199. package/dist/rich-text-editor/markdown.js +496 -0
  200. package/dist/rich-text-editor/markdown.js.map +1 -0
  201. package/dist/rich-text-editor/nodes.d.ts +135 -0
  202. package/dist/rich-text-editor/nodes.d.ts.map +1 -0
  203. package/dist/rich-text-editor/nodes.js +397 -0
  204. package/dist/rich-text-editor/nodes.js.map +1 -0
  205. package/dist/rich-text-editor/render.d.ts +68 -0
  206. package/dist/rich-text-editor/render.d.ts.map +1 -0
  207. package/dist/rich-text-editor/render.js +500 -0
  208. package/dist/rich-text-editor/render.js.map +1 -0
  209. package/dist/rich-text-editor/schema.d.ts +16 -0
  210. package/dist/rich-text-editor/schema.d.ts.map +1 -0
  211. package/dist/rich-text-editor/schema.js +386 -0
  212. package/dist/rich-text-editor/schema.js.map +1 -0
  213. package/dist/rich-text-editor/store.d.ts +11 -0
  214. package/dist/rich-text-editor/store.d.ts.map +1 -0
  215. package/dist/rich-text-editor/store.js +43 -0
  216. package/dist/rich-text-editor/store.js.map +1 -0
  217. package/dist/rich-text-editor/suggest.d.ts +127 -0
  218. package/dist/rich-text-editor/suggest.d.ts.map +1 -0
  219. package/dist/rich-text-editor/suggest.js +0 -0
  220. package/dist/rich-text-editor/suggest.js.map +1 -0
  221. package/dist/rich-text-editor/tables.d.ts +41 -0
  222. package/dist/rich-text-editor/tables.d.ts.map +1 -0
  223. package/dist/rich-text-editor/tables.js +204 -0
  224. package/dist/rich-text-editor/tables.js.map +1 -0
  225. package/dist/rich-text-editor/toolbar.d.ts +58 -0
  226. package/dist/rich-text-editor/toolbar.d.ts.map +1 -0
  227. package/dist/rich-text-editor/toolbar.js +548 -0
  228. package/dist/rich-text-editor/toolbar.js.map +1 -0
  229. package/dist/rich-text-editor/view.d.ts +368 -0
  230. package/dist/rich-text-editor/view.d.ts.map +1 -0
  231. package/dist/rich-text-editor/view.js +1968 -0
  232. package/dist/rich-text-editor/view.js.map +1 -0
  233. package/dist/rich-text-editor/virtual.d.ts +54 -0
  234. package/dist/rich-text-editor/virtual.d.ts.map +1 -0
  235. package/dist/rich-text-editor/virtual.js +262 -0
  236. package/dist/rich-text-editor/virtual.js.map +1 -0
  237. package/dist/richtext/node.d.ts +25 -2
  238. package/dist/richtext/node.d.ts.map +1 -1
  239. package/dist/richtext/node.js +46 -24
  240. package/dist/richtext/node.js.map +1 -1
  241. package/dist/terminal/backends.d.ts +4 -1
  242. package/dist/terminal/backends.d.ts.map +1 -1
  243. package/dist/terminal/backends.js.map +1 -1
  244. package/dist/terminal/index.d.ts +18 -7
  245. package/dist/terminal/index.d.ts.map +1 -1
  246. package/dist/terminal/index.js +28 -11
  247. package/dist/terminal/index.js.map +1 -1
  248. package/package.json +23 -4
  249. package/src/charts/node.ts +6 -2
  250. package/src/embed/client.ts +102 -8
  251. package/src/embed/index.ts +2 -0
  252. package/src/html/node.ts +7 -5
  253. package/src/index.ts +2 -0
  254. package/src/{markdown → internal/markdown}/ast.ts +24 -7
  255. package/src/{markdown → internal/markdown}/parse.ts +134 -24
  256. package/src/internal/markdown/stringify.ts +772 -0
  257. package/src/internal/scroll.ts +5 -3
  258. package/src/maps/anchors.ts +607 -0
  259. package/src/maps/controller.ts +999 -0
  260. package/src/maps/gl/buckets.ts +457 -0
  261. package/src/maps/gl/build-worker.ts +55 -0
  262. package/src/maps/gl/color.ts +62 -0
  263. package/src/maps/gl/cover.ts +170 -0
  264. package/src/maps/gl/index.ts +23 -0
  265. package/src/maps/gl/markers.ts +83 -0
  266. package/src/maps/gl/overlays.ts +379 -0
  267. package/src/maps/gl/pane.ts +42 -0
  268. package/src/maps/gl/placement.ts +816 -0
  269. package/src/maps/gl/renderer.ts +1576 -0
  270. package/src/maps/gl/shaders.ts +486 -0
  271. package/src/maps/gl/store.ts +566 -0
  272. package/src/maps/gl/text.ts +495 -0
  273. package/src/maps/gl/view.ts +1224 -0
  274. package/src/maps/index.ts +270 -81
  275. package/src/maps/labels.ts +214 -121
  276. package/src/maps/node.ts +763 -629
  277. package/src/maps/overlay.ts +148 -43
  278. package/src/maps/paint.ts +3 -0
  279. package/src/maps/proj.ts +31 -0
  280. package/src/maps/renderer.ts +155 -0
  281. package/src/maps/sources.ts +38 -3
  282. package/src/maps/theme.ts +51 -0
  283. package/src/maps/tiles.ts +191 -33
  284. package/src/maps/types.ts +188 -20
  285. package/src/markdown/expressions.ts +5 -1
  286. package/src/markdown/index.ts +14 -11
  287. package/src/markdown/spans.ts +1 -1
  288. package/src/media-player/index.ts +18 -4
  289. package/src/rich-text-editor/clipboard.ts +439 -0
  290. package/src/rich-text-editor/collab.ts +91 -0
  291. package/src/rich-text-editor/commands.ts +448 -0
  292. package/src/rich-text-editor/dom-globals.d.ts +23 -0
  293. package/src/rich-text-editor/drag.ts +211 -0
  294. package/src/rich-text-editor/html.ts +619 -0
  295. package/src/rich-text-editor/index.ts +1331 -0
  296. package/src/rich-text-editor/inline.ts +345 -0
  297. package/src/rich-text-editor/keymap.ts +510 -0
  298. package/src/rich-text-editor/keys.ts +119 -0
  299. package/src/rich-text-editor/look.ts +427 -0
  300. package/src/rich-text-editor/markdown.ts +653 -0
  301. package/src/rich-text-editor/nodes.ts +497 -0
  302. package/src/rich-text-editor/render.ts +800 -0
  303. package/src/rich-text-editor/schema.ts +431 -0
  304. package/src/rich-text-editor/store.ts +39 -0
  305. package/src/rich-text-editor/suggest.ts +597 -0
  306. package/src/rich-text-editor/tables.ts +272 -0
  307. package/src/rich-text-editor/toolbar.ts +724 -0
  308. package/src/rich-text-editor/view.ts +2287 -0
  309. package/src/rich-text-editor/virtual.ts +347 -0
  310. package/src/richtext/node.ts +59 -30
  311. package/src/terminal/backends.ts +4 -1
  312. package/src/terminal/index.ts +43 -15
  313. package/dist/markdown/ast.d.ts.map +0 -1
  314. package/dist/markdown/ast.js +0 -17
  315. package/dist/markdown/ast.js.map +0 -1
  316. package/dist/markdown/parse.d.ts.map +0 -1
  317. package/dist/markdown/parse.js.map +0 -1
  318. package/dist/markdown/tags.d.ts.map +0 -1
  319. package/dist/markdown/tags.js.map +0 -1
  320. /package/dist/{markdown → internal/markdown}/parse.d.ts +0 -0
  321. /package/dist/{markdown → internal/markdown}/tags.d.ts +0 -0
  322. /package/dist/{markdown → internal/markdown}/tags.js +0 -0
  323. /package/src/{markdown → internal/markdown}/tags.ts +0 -0
package/src/maps/node.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  // Three caches, and the map's whole performance argument is the way they
12
12
  // are layered:
13
13
  //
14
- // 1. **Tile data**, keyed on `source/z/x/y` and valid forever.
14
+ // 1. **Tile data**, keyed on the source object and `z/x/y`, and valid
15
+ // forever. The object, not its `id` — see `./tiles.ts`.
15
16
  // 2. **A rendered `Surface` per tile**, valid for a zoom *level* and a
16
17
  // style — not for a camera position. So a **pan** composites the same
17
18
  // surfaces at new offsets (and blits, so most of them are not even
@@ -32,7 +33,12 @@ import type { A11ySceneItem, Context2D } from 'react-x11/node';
32
33
  import type { KeyboardEvent, MouseEvent, WheelEvent } from 'react-x11';
33
34
  import { Surface } from 'react-x11/ntk';
34
35
 
35
- import { GeometryBuffer } from './mvt.js';
36
+ import { MapController, quantizeZoom } from './controller.js';
37
+ import type {
38
+ MapControllerProps,
39
+ MapPointerInput,
40
+ MapView,
41
+ } from './controller.js';
36
42
  import {
37
43
  BATCH_VERTICES,
38
44
  DrawScratch,
@@ -44,16 +50,13 @@ import {
44
50
  import type { MapCanvas, PreparedStyle } from './paint.js';
45
51
  import {
46
52
  DEFAULT_TILE_SIZE,
47
- cameraForBounds,
48
- boundsOf,
53
+ dataSquareOf,
49
54
  dataTileFor,
50
55
  subTileOf,
51
- projectLngLat,
52
56
  rasterFor,
53
57
  tileCover,
58
+ tileKey,
54
59
  transformFor,
55
- unprojectPoint,
56
- visibleBounds,
57
60
  } from './proj.js';
58
61
  import type {
59
62
  LngLat,
@@ -64,11 +67,13 @@ import type {
64
67
  TileId,
65
68
  Transform,
66
69
  } from './proj.js';
67
- import { TileCache, pyramid } from './tiles.js';
70
+ import { TileCache, drawnFor, pyramid } from './tiles.js';
68
71
  import type { CachedTile, SurfaceLike, TileRender } from './tiles.js';
72
+ import { attributionOf } from './sources.js';
69
73
  import type { MapSource } from './sources.js';
70
74
  import { shortbreadStyle } from './styles.js';
71
75
  import type { MapStyle } from './style.js';
76
+ import { isDarkTheme, overlayPalette } from './theme.js';
72
77
  import {
73
78
  LabelShaper,
74
79
  collectLabels,
@@ -76,9 +81,16 @@ import {
76
81
  placeLabels,
77
82
  } from './labels.js';
78
83
  import type { FontsLike, LabelCandidate, PlacedLabel } from './labels.js';
79
- import { drawMarkers, drawOverlays, markerAt, markerRect } from './overlay.js';
84
+ import {
85
+ ATTRIBUTION_OPACITY,
86
+ ATTRIBUTION_SIZE,
87
+ attributionLayout,
88
+ drawMarkers,
89
+ drawOverlays,
90
+ markerRect,
91
+ } from './overlay.js';
80
92
  import type { MapMarker, MapOverlay, OverlayPalette } from './overlay.js';
81
- import type { MapFrameStats, MapPointerEvent } from './types.js';
93
+ import type { FitBoundsOptions, MapFrameStats } from './types.js';
82
94
 
83
95
  /** Registration key, `kind` and JSX tag, one string — react-x11 rejects a
84
96
  * node whose `kind` is not the name it was registered under, because
@@ -103,24 +115,6 @@ export const SELF_DAMAGED_PROPS = [
103
115
  'mapStyle',
104
116
  ] as const;
105
117
 
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
118
  /** The largest tile surface, per edge, in device pixels. An argb32 surface
125
119
  * is `4 × size²` bytes, so 2048 is 16 MB and is already more than any
126
120
  * pyramid justifies. */
@@ -154,33 +148,6 @@ function intersectRects(a: ScreenRect, b: ScreenRect): ScreenRect | null {
154
148
  return { x, y, width: right - x, height: bottom - y };
155
149
  }
156
150
 
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
151
  /** A logical value put on the device grid — the same helper `src/flow/`
185
152
  * keeps, and for the same reason: ntk's fast paths for a blit and a
186
153
  * rounded box are gated on integral geometry, and `x * 1.5` is not always
@@ -200,34 +167,106 @@ function rectsOverlap(a: ScreenRect, b: ScreenRect): boolean {
200
167
  );
201
168
  }
202
169
 
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 };
170
+ /** Whether `outer` covers all of `inner`. */
171
+ function containsRect(outer: ScreenRect, inner: ScreenRect): boolean {
172
+ return (
173
+ outer.x <= inner.x &&
174
+ outer.y <= inner.y &&
175
+ outer.x + outer.width >= inner.x + inner.width &&
176
+ outer.y + outer.height >= inner.y + inner.height
177
+ );
178
+ }
179
+
180
+ function sameSources(
181
+ a: readonly MapSource[],
182
+ b: readonly MapSource[],
183
+ ): boolean {
184
+ return a.length === b.length && a.every((source, i) => source === b[i]);
185
+ }
186
+
187
+ /** A tile of the cover that is in view, as a frame's work found it. */
188
+ interface VisibleTile {
189
+ entry: TileCoverEntry;
190
+ /** Where it lands, in pane-local logical pixels. */
191
+ box: ScreenRect;
192
+ cached: CachedTile;
193
+ }
194
+
195
+ /**
196
+ * How long a restyle may keep the previous style up once the view moves,
197
+ * in milliseconds the map was free to draw in.
198
+ *
199
+ * A still view is never cut short: its redraw is a fixed amount of work,
200
+ * and the swap waits for all of it. A moving one can bring tiles into view
201
+ * as fast as the old ones are finished — a pan with pauses in it, a camera
202
+ * an application animates — and would never swap. So once the camera has
203
+ * moved the wait is bounded, and a tile not yet redrawn when it runs out
204
+ * shows the background until it is. Frames a gesture holds rasterization
205
+ * off in do not count, since nothing is drawn in them.
206
+ */
207
+ const RESTYLE_WAIT_MS = 1500;
208
+
209
+ /**
210
+ * The previous style, held on screen while a restyle draws the new one.
211
+ *
212
+ * A `mapStyle` change or `refresh()` retires every tile surface at once,
213
+ * and the redraw is budgeted — a dense tile is 50-140 ms against 8 ms a
214
+ * frame — so it takes a second or more. A tile's own double buffer keeps
215
+ * its old picture until its new one is done, which is right for a zoom and
216
+ * wrong here: the map was a patchwork of both styles for that second, under
217
+ * a background and labels that had already switched. So the whole previous
218
+ * picture — the tiles of its generation, its background, its labels —
219
+ * stays up until the view is redrawn, and is replaced in one frame.
220
+ */
221
+ interface Outgoing {
222
+ /** The cache generation on screen. Only its pictures are composited. */
223
+ generation: number;
224
+ /** The style background the last frame before the switch painted. */
225
+ background: string | undefined;
226
+ /**
227
+ * The labels on screen, and what they were placed for and from — the
228
+ * quantized zoom, the sources, their candidates and the face — kept
229
+ * because the style that produced them is gone, or, after `refresh()`,
230
+ * was edited in place. A zoom re-places them from the candidates; a
231
+ * source taken off the map takes its names with it.
232
+ */
233
+ labels: PlacedLabel[];
234
+ labelZoom: number;
235
+ labelSources: readonly MapSource[];
236
+ candidates: Map<MapSource, LabelCandidate[]>;
237
+ family: string | undefined;
238
+ /** The camera and the pane at the switch, and whether either has moved
239
+ * since — which is what makes {@link RESTYLE_WAIT_MS} apply. */
240
+ view: string;
241
+ moved: boolean;
242
+ /** Milliseconds the map has been free to draw since the switch, and when
243
+ * the last frame that was began — `null` when the last frame was not. */
244
+ waited: number;
245
+ lastDrawn: number | null;
246
+ }
213
247
 
214
248
  export class MapViewNode extends Node {
215
249
  private readonly _cache: TileCache;
216
250
  private readonly _scratch = new DrawScratch();
217
- private readonly _geometry = new GeometryBuffer();
218
251
  private _shaper: LabelShaper | null = null;
219
252
 
220
253
  /**
221
- * The camera this element owns, used whenever `props.camera` is absent.
254
+ * The camera and the gestures: the controller `<Map>` hands down, or this
255
+ * element's own when it is used bare. Both of `<Map>`'s renderers answer
256
+ * to one (`./controller.ts`), which is how a fallback from one to the
257
+ * other keeps the camera and the handle.
222
258
  *
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".
259
+ * The camera being an object's rather than React state's is what makes a
260
+ * pan cost nothing but a blit: a drag step moves two numbers and claims a
261
+ * strip, and React is not involved at all. Routed through state instead,
262
+ * every pointer step would be a render, a commit and a full-pane claim,
263
+ * which is the shape `<Flow>` documents as "the content lags and catches
264
+ * up".
229
265
  */
230
- private _camera: MapCamera = { center: { lon: 0, lat: 20 }, zoom: 2 };
266
+ private readonly _controller: MapController;
267
+ private readonly _ownsController: boolean;
268
+ /** This element, as its controller sees it. */
269
+ private readonly _view: MapView;
231
270
 
232
271
  private _prepared: PreparedStyle | null = null;
233
272
  private _preparedFrom: MapStyle | null = null;
@@ -236,17 +275,28 @@ export class MapViewNode extends Node {
236
275
  /** The placement, and what it was computed for. */
237
276
  private _labels: PlacedLabel[] = [];
238
277
  private _labelKey = '';
278
+ /** …and what it was placed from, all of which a restyle holds on to (see
279
+ * {@link Outgoing}): the quantized zoom, the sources on the map, their
280
+ * candidates per source, and the face they were shaped in. */
281
+ private _labelZoom = 0;
282
+ private _labelSources: readonly MapSource[] = [];
283
+ private _labelCandidates = new Map<MapSource, LabelCandidate[]>();
284
+ private _shapedFamily: string | undefined;
239
285
  /** Candidates per tile, so a pan that brings a tile back does not redo
240
286
  * the walk over its symbol layers. */
241
287
  private readonly _candidates = new Map<string, LabelCandidate[]>();
242
288
 
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;
289
+ /** The previous style while a restyle draws the new one behind it, or
290
+ * null when what is on screen is the current generation. */
291
+ private _outgoing: Outgoing | null = null;
292
+ /** The style background the last frame painted — what a restyle keeps
293
+ * painting. Remembered rather than recomputed, because `refresh()`
294
+ * follows an edit made to the very style object it would be read from. */
295
+ private _paintedBackground: string | undefined;
296
+ /** Whether a frame has been painted: before one, a restyle has no
297
+ * previous picture to hold. */
298
+ private _painted = false;
299
+
250
300
  /** Whether any tile has been rasterized in the frame being painted — the
251
301
  * forward-progress guarantee below. */
252
302
  private _rastered = false;
@@ -256,10 +306,36 @@ export class MapViewNode extends Node {
256
306
 
257
307
  constructor(props: Record<string, unknown>, app: unknown) {
258
308
  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.
309
+ const given = props.mapController as MapController | undefined;
310
+ // A bare element's own controller is seeded once: `defaultCamera` is
311
+ // read here and never again, which is what makes it a *default* rather
312
+ // than a second controlled prop. `<Map>` seeds the one it hands down.
261
313
  const seed = (props.camera ?? props.defaultCamera) as MapCamera | undefined;
262
- if (seed) this._camera = { center: { ...seed.center }, zoom: seed.zoom };
314
+ this._controller = given ?? new MapController(seed);
315
+ this._ownsController = given === undefined;
316
+ if (this._ownsController) {
317
+ this._controller.setProps(props as MapControllerProps);
318
+ }
319
+ this._view = {
320
+ pane: () => this._pane(),
321
+ scale: () => this._scale,
322
+ moved: (previous, next, blit) => {
323
+ // A new pyramid level: different tiles, different labels.
324
+ if (Math.floor(next.zoom) !== Math.floor(previous.zoom)) {
325
+ this._labelKey = '';
326
+ }
327
+ if (!blit || !this._blitPan(previous, next)) this._repaint('scroll');
328
+ },
329
+ // The gesture is over: sharpen. A wake-up, not a repaint — nothing has
330
+ // moved since the last frame, so what is on screen is still right;
331
+ // what is needed is a frame to start rasterizing in, and each tile
332
+ // claims its own box as it lands.
333
+ settled: () => this._wake('content'),
334
+ refresh: () => this._restyle(),
335
+ stats: () => this._stats,
336
+ focus: () => this.focus(),
337
+ };
338
+ this._controller.attach(this._view);
263
339
  // A map is a thing you drive with the keyboard as well as the mouse:
264
340
  // arrows pan, +/- zoom. Without this it is never focused and no key
265
341
  // arrives.
@@ -272,9 +348,7 @@ export class MapViewNode extends Node {
272
348
  (error: unknown, tile: TileId & { sourceId: string }) => void
273
349
  >('onTileError')?.(entry.error, {
274
350
  ...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(':')),
351
+ sourceId: entry.sourceId,
278
352
  });
279
353
  },
280
354
  onChange: () => {
@@ -324,26 +398,11 @@ export class MapViewNode extends Node {
324
398
  }
325
399
 
326
400
  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;
401
+ return isDarkTheme(this.theme);
338
402
  }
339
403
 
340
404
  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
- };
405
+ return overlayPalette(this.theme);
347
406
  }
348
407
 
349
408
  private _sources(): MapSource[] {
@@ -351,22 +410,12 @@ export class MapViewNode extends Node {
351
410
  return given ? [...given] : [];
352
411
  }
353
412
 
413
+ /** What a source is called in its requests and its errors. Not what its
414
+ * tiles are cached under — the cache files them under the object. */
354
415
  private _sourceId(source: MapSource, index: number): string {
355
416
  return source.id ?? `source-${index}`;
356
417
  }
357
418
 
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
419
  private _markers(): readonly MapMarker[] {
371
420
  return this._prop<readonly MapMarker[]>('markers') ?? [];
372
421
  }
@@ -396,8 +445,7 @@ export class MapViewNode extends Node {
396
445
  }
397
446
 
398
447
  camera(): MapCamera {
399
- const given = this._prop<MapCamera>('camera');
400
- return given ?? this._camera;
448
+ return this._controller.camera();
401
449
  }
402
450
 
403
451
  /** The camera resolved against the pane. */
@@ -453,46 +501,6 @@ export class MapViewNode extends Node {
453
501
 
454
502
  // --- camera --------------------------------------------------------------
455
503
 
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
504
  /**
497
505
  * A pan is a scroll in every way but the bookkeeping, and react-x11#303
498
506
  * made the bookkeeping public: `scrollContents` claims the pane, arms the
@@ -548,183 +556,128 @@ export class MapViewNode extends Node {
548
556
  return true;
549
557
  }
550
558
 
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
-
559
+ /** A gesture in flight, or the settle window after a camera move: while
560
+ * it lasts nothing is rasterized, so a drag or a wheel is composites
561
+ * only. The window is the controller's, and one for both renderers. */
609
562
  private get _gesturing(): boolean {
610
- return this._gesture !== null || Date.now() < this._settleAt;
563
+ return this._controller.gesturing;
611
564
  }
612
565
 
613
566
  // --- the imperative surface ----------------------------------------------
567
+ //
568
+ // The controller's, for an application holding the element itself rather
569
+ // than `<Map>`'s handle.
614
570
 
615
571
  getCamera(): MapCamera {
616
- const camera = this.camera();
617
- return { center: { ...camera.center }, zoom: camera.zoom };
572
+ return this._controller.getCamera();
618
573
  }
619
574
 
620
575
  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
- );
576
+ this._controller.setCamera(camera);
629
577
  }
630
578
 
631
579
  /** Move by a distance in pane-local logical pixels. */
632
580
  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
- });
581
+ this._controller.panBy(dx, dy);
642
582
  }
643
583
 
644
584
  zoomIn(step = 1): void {
645
- const pane = this._pane();
646
- this._zoomAbout(step, pane.width / 2, pane.height / 2);
585
+ this._controller.zoomIn(step);
647
586
  }
648
587
 
649
588
  zoomOut(step = 1): void {
650
- this.zoomIn(-step);
589
+ this._controller.zoomOut(step);
651
590
  }
652
591
 
653
592
  zoomTo(zoom: number): void {
654
- this.setCamera({ zoom });
593
+ this._controller.zoomTo(zoom);
655
594
  }
656
595
 
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
- );
596
+ fitBounds(bounds: LngLatBounds, options?: FitBoundsOptions): void {
597
+ this._controller.fitBounds(bounds, options);
681
598
  }
682
599
 
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);
600
+ fitMarkers(ids?: readonly string[], options?: FitBoundsOptions): void {
601
+ this._controller.fitMarkers(ids, options);
695
602
  }
696
603
 
697
604
  getBounds(): LngLatBounds {
698
- return visibleBounds(this._transform());
605
+ return this._controller.getBounds();
699
606
  }
700
607
 
701
608
  project(position: LngLat): { x: number; y: number } {
702
- return projectLngLat(this._transform(), position);
609
+ return this._controller.project(position);
703
610
  }
704
611
 
705
612
  unproject(x: number, y: number): LngLat {
706
- return unprojectPoint(this._transform(), x, y);
613
+ return this._controller.unproject(x, y);
707
614
  }
708
615
 
709
616
  markerAt(x: number, y: number): MapMarker | null {
710
- return markerAt(this._markers(), this._transform(), x, y);
617
+ return this._controller.markerAt(x, y);
711
618
  }
712
619
 
713
620
  refresh(): void {
621
+ this._restyle();
622
+ }
623
+
624
+ /**
625
+ * Retire every rendered tile — for a new `mapStyle`, or `refresh()` after
626
+ * an edit made to the style in place. One path for both, and for anything
627
+ * else that ever needs the map redrawn in a new look.
628
+ *
629
+ * The compiled style and the label candidates go with the tiles: both are
630
+ * readings of the style, and `refresh()` exists because the object was
631
+ * edited under them. What does not go is the picture on screen. Unless
632
+ * there is none yet, or `progressive` asked to watch the repaint, it is
633
+ * held as {@link Outgoing} until the new one is ready to replace it.
634
+ *
635
+ * True when it is held. Then nothing on screen changes yet, so the only
636
+ * claim is the pixel that asks for a frame to start drawing in — and
637
+ * anything else a commit changed has to claim its own damage.
638
+ */
639
+ private _restyle(): boolean {
640
+ this._prepared = null;
641
+ this._preparedFrom = null;
642
+ this._candidates.clear();
643
+ if (
644
+ this._outgoing === null &&
645
+ this._painted &&
646
+ this._prop<boolean>('progressive') !== true
647
+ ) {
648
+ this._outgoing = {
649
+ generation: this._cache.generation,
650
+ background: this._paintedBackground,
651
+ labels: this._labels,
652
+ labelZoom: this._labelZoom,
653
+ labelSources: this._labelSources,
654
+ candidates: this._labelCandidates,
655
+ family: this._shapedFamily,
656
+ view: this._viewKey(this._transform(), this._pane()),
657
+ moved: false,
658
+ waited: 0,
659
+ lastDrawn: null,
660
+ };
661
+ }
714
662
  this._cache.invalidateStyle();
715
663
  this._labelKey = '';
716
- this._repaint('content');
664
+ if (this._outgoing === null) {
665
+ this._repaint('props');
666
+ return false;
667
+ }
668
+ this._wake('props');
669
+ return true;
670
+ }
671
+
672
+ /** The camera and the pane, as a string that changes when either does. */
673
+ private _viewKey(transform: Transform, pane: ScreenRect): string {
674
+ return `${transform.centerX},${transform.centerY},${transform.zoom},${pane.width}x${pane.height}`;
717
675
  }
718
676
 
719
677
  stats(): MapFrameStats | null {
720
678
  return this._stats;
721
679
  }
722
680
 
723
- private _pendingFit: {
724
- bounds: LngLatBounds;
725
- options?: { padding?: number; maxZoom?: number };
726
- } | null = null;
727
-
728
681
  // --- painting ------------------------------------------------------------
729
682
 
730
683
  /** How tall the attribution strip is, in logical pixels — 0 when there is
@@ -735,15 +688,7 @@ export class MapViewNode extends Node {
735
688
  }
736
689
 
737
690
  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(' · ');
691
+ return attributionOf(this._prop<string>('attribution'), this._sources());
747
692
  }
748
693
 
749
694
  /** How large a tile is rasterized, and how many surface pixels one
@@ -789,7 +734,6 @@ export class MapViewNode extends Node {
789
734
  if (!this._visible() || !isMapCanvas(ctx)) return;
790
735
 
791
736
  const started = now();
792
- this._painting = true;
793
737
  const scale = this._scale;
794
738
  this._frameClip = damage
795
739
  ? {
@@ -800,13 +744,10 @@ export class MapViewNode extends Node {
800
744
  }
801
745
  : null;
802
746
 
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
- }
747
+ // A fit asked for before layout had a size — `fitBounds` in an effect —
748
+ // lands in the first frame that has one, and this frame draws it.
749
+ this._controller.beginFrame();
750
+ this._controller.painting = true;
810
751
 
811
752
  const pane = this._pane();
812
753
  const camera = this.camera();
@@ -814,6 +755,7 @@ export class MapViewNode extends Node {
814
755
  const style = this._style();
815
756
  const frame = this._cache.beginFrame();
816
757
  const stats: MapFrameStats = {
758
+ renderer: 'retained',
817
759
  rasterMs: 0,
818
760
  drawMs: 0,
819
761
  tiles: 0,
@@ -821,6 +763,7 @@ export class MapViewNode extends Node {
821
763
  fromAncestor: 0,
822
764
  fromDescendant: 0,
823
765
  pending: 0,
766
+ restyling: false,
824
767
  labels: 0,
825
768
  errors: 0,
826
769
  surfaceBytes: 0,
@@ -850,19 +793,67 @@ export class MapViewNode extends Node {
850
793
  ? intersectRects(box, this._deviceRect(this._frameClip))
851
794
  : box;
852
795
  if (!clip) {
853
- this._painting = false;
796
+ this._controller.painting = false;
854
797
  this._frameClip = null;
855
798
  return;
856
799
  }
857
800
  ctx.rect(clip.x, clip.y, clip.width, clip.height);
858
801
  ctx.clip();
859
802
 
803
+ // Rasterization is suspended for the length of a gesture, so a drag or
804
+ // a wheel is composites only. `rasterBudgetMs` bounds the rest.
805
+ const budget = this._gesturing
806
+ ? 0
807
+ : (this._prop<number>('rasterBudgetMs') ?? 8);
808
+ const deadline = started + budget;
809
+ this._rastered = false;
810
+ const progressive = this._prop<boolean>('progressive') === true;
811
+
812
+ // **The work first, then the picture.** Every tile the cover wants is
813
+ // asked for, and the ones in view are drawn as far as the budget goes —
814
+ // into their own surfaces, so none of it is on screen yet. Which style
815
+ // this frame shows is decided only after that, because it depends on
816
+ // what the work just finished: a restyle swaps in the frame that finds
817
+ // the view redrawn, and has to know before the first pixel goes down.
818
+ const sources = this._sources();
819
+ const holding = this._outgoing !== null && !progressive;
820
+ const views: VisibleTile[][] = [];
821
+ for (let i = 0; i < sources.length; i++) {
822
+ views.push(
823
+ this._workSource(
824
+ sources[i],
825
+ this._sourceId(sources[i], i),
826
+ transform,
827
+ pane,
828
+ style,
829
+ stats,
830
+ budget > 0,
831
+ deadline,
832
+ holding,
833
+ ),
834
+ );
835
+ }
836
+ this._settleRestyle(
837
+ transform,
838
+ pane,
839
+ stats,
840
+ budget,
841
+ started,
842
+ !damage || containsRect(damage, box),
843
+ progressive,
844
+ );
845
+ const outgoing = this._outgoing;
846
+ stats.restyling = outgoing !== null;
847
+
860
848
  // The style's background under everything: it is what the parts of the
861
849
  // world with no tile yet look like, so it is most of what a map looks
862
- // like while it loads.
850
+ // like while it loads. The previous style's while a restyle holds it.
851
+ const styleBackground = outgoing
852
+ ? outgoing.background
853
+ : this._preparedBackground();
854
+ if (!outgoing) this._paintedBackground = styleBackground;
863
855
  const background =
864
- (this.style.backgroundColor as string | undefined) ??
865
- this._preparedBackground();
856
+ (this.style.backgroundColor as string | undefined) ?? styleBackground;
866
857
  if (background) {
867
858
  ctx.fillStyle = background;
868
859
  const region = this._frameClip
@@ -871,26 +862,22 @@ export class MapViewNode extends Node {
871
862
  ctx.fillRect(region.x, region.y, region.width, region.height);
872
863
  }
873
864
 
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();
865
+ // Only pictures of the generation on screen are composited: the
866
+ // previous style's while a restyle holds it up, the current one's
867
+ // otherwise — so a tile last drawn before a switch is never shown in
868
+ // the style the map was switched away from. `progressive` shows
869
+ // whatever there is, which is its point.
870
+ const generation = progressive
871
+ ? undefined
872
+ : (outgoing?.generation ?? this._cache.generation);
883
873
  for (let i = 0; i < sources.length; i++) {
884
- this._paintSource(
874
+ this._drawSource(
885
875
  ctx,
886
876
  sources[i],
887
- this._sourceId(sources[i], i),
888
- transform,
889
- pane,
890
- style,
877
+ views[i],
891
878
  stats,
892
- budget > 0,
893
- deadline,
879
+ progressive,
880
+ generation,
894
881
  );
895
882
  }
896
883
 
@@ -906,7 +893,8 @@ export class MapViewNode extends Node {
906
893
  stats.surfaceBytes = this._cache.surfaceBytes;
907
894
  stats.drawMs = now() - started - stats.rasterMs;
908
895
  this._stats = stats;
909
- this._painting = false;
896
+ this._painted = true;
897
+ this._controller.painting = false;
910
898
  this._frameClip = null;
911
899
  void frame;
912
900
  this._prop<(stats: MapFrameStats) => void>('onFrame')?.(stats);
@@ -955,8 +943,81 @@ export class MapViewNode extends Node {
955
943
  };
956
944
  }
957
945
 
958
- private _paintSource(
959
- ctx: MapCanvas,
946
+ /**
947
+ * Whether this frame keeps showing the previous style or swaps in the new
948
+ * one — see {@link Outgoing}.
949
+ *
950
+ * The swap waits for every tile in view that has data to have a finished
951
+ * rendering in the new style, which is `pending` reaching zero: a tile
952
+ * still loading has nothing to draw and does not hold it, and a tile that
953
+ * is done waits for the rest rather than going up alone. It is made only
954
+ * in a frame that repaints the whole pane, because a swap inside a partial
955
+ * frame would leave the rest of the pane in the old style — so a frame
956
+ * that finds the view redrawn but was clipped to its one pixel claims the
957
+ * pane, and the next frame swaps. That is the one full-pane claim a
958
+ * restyle makes.
959
+ *
960
+ * Or, once the view has moved, when {@link RESTYLE_WAIT_MS} of drawing
961
+ * time has gone by, with whatever is left undrawn.
962
+ */
963
+ private _settleRestyle(
964
+ transform: Transform,
965
+ pane: ScreenRect,
966
+ stats: MapFrameStats,
967
+ budget: number,
968
+ started: number,
969
+ whole: boolean,
970
+ progressive: boolean,
971
+ ): void {
972
+ const outgoing = this._outgoing;
973
+ if (!outgoing) return;
974
+ if (progressive) {
975
+ // Turned on while a restyle was held: the opt-in holds nothing.
976
+ this._swapStyle();
977
+ if (!whole) this._repaint('content');
978
+ return;
979
+ }
980
+ if (!outgoing.moved && this._viewKey(transform, pane) !== outgoing.view) {
981
+ outgoing.moved = true;
982
+ }
983
+ // The clock runs across frames that could draw, two in a row, so the
984
+ // gap a gesture made — which drew nothing — is not counted when the
985
+ // next frame after it begins.
986
+ if (budget > 0) {
987
+ if (outgoing.lastDrawn !== null) {
988
+ outgoing.waited += started - outgoing.lastDrawn;
989
+ }
990
+ outgoing.lastDrawn = started;
991
+ } else {
992
+ outgoing.lastDrawn = null;
993
+ }
994
+ const redrawn = stats.pending === 0;
995
+ // Never by the clock in the middle of a gesture: nothing is drawn
996
+ // during one, so the view would swap to holes it cannot fill until the
997
+ // gesture ends.
998
+ const overdue =
999
+ budget > 0 && outgoing.moved && outgoing.waited >= RESTYLE_WAIT_MS;
1000
+ if (!redrawn && !overdue) return;
1001
+ if (whole) this._swapStyle();
1002
+ else this._repaint('content');
1003
+ }
1004
+
1005
+ /** The new style goes on screen: every finished tile, its background and
1006
+ * a label placement of its own, all in the frame being painted. */
1007
+ private _swapStyle(): void {
1008
+ this._outgoing = null;
1009
+ this._cache.swap();
1010
+ this._labelKey = '';
1011
+ }
1012
+
1013
+ /**
1014
+ * A frame's work, for one source: want every tile of its cover, so that
1015
+ * each loads and stays cached, and draw the ones in view into their own
1016
+ * surfaces as far as the budget goes. Returns the ones in view, for
1017
+ * {@link _drawSource} to composite once the frame knows which style it is
1018
+ * showing.
1019
+ */
1020
+ private _workSource(
960
1021
  source: MapSource,
961
1022
  sourceId: string,
962
1023
  transform: Transform,
@@ -966,8 +1027,10 @@ export class MapViewNode extends Node {
966
1027
  /** False for the length of a gesture, when nothing is rasterized. */
967
1028
  mayRaster: boolean,
968
1029
  deadline: number,
969
- ): void {
970
- const scale = this._scale;
1030
+ /** A restyle is holding the previous style up, so a tile that finishes
1031
+ * waits for the swap rather than going on screen alone. */
1032
+ holding: boolean,
1033
+ ): VisibleTile[] {
971
1034
  const p = pyramid(source);
972
1035
  // The cover goes **deeper than the source cuts**, up to
973
1036
  // `MAX_OVERZOOM` levels past it, and the data for those tiles comes
@@ -975,16 +1038,27 @@ export class MapViewNode extends Node {
975
1038
  // overzoomed map sharp: instead of one tile rasterized onto a surface
976
1039
  // and stretched sixty-four times, there are two hundred and fifty-six
977
1040
  // 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,
1041
+ // detail limited by the data rather than by a bitmap. That is vector
1042
+ // data. An image has no more detail than its pixels, so a raster tile
1043
+ // past the cut is drawn as the whole of its data tile instead — see
1044
+ // `_rasterSquares`.
1045
+ const cover = this._rasterSquares(
1046
+ source,
1047
+ sourceId,
1048
+ tileCover(
1049
+ { ...transform, zoom: transform.zoom },
1050
+ { ...p, maxZoom: p.maxZoom + MAX_OVERZOOM },
1051
+ COVER_PADDING,
1052
+ ),
1053
+ p.maxZoom,
983
1054
  );
984
1055
  const zoom = transform.zoom;
985
1056
  const styleZoom = Math.floor(zoom);
986
- const progressive = this._prop<boolean>('progressive') === true;
1057
+ const visible: VisibleTile[] = [];
987
1058
  for (const entry of cover) {
1059
+ // In the window's logical pixels, the space `pane` is in: what the
1060
+ // overlap tests below, the claim when the tile lands and `_composite`
1061
+ // all take. The pane's origin goes in here and nowhere after.
988
1062
  const box = {
989
1063
  x: pane.x + entry.x,
990
1064
  y: pane.y + entry.y,
@@ -994,13 +1068,14 @@ export class MapViewNode extends Node {
994
1068
  // Two different questions, and conflating them was a bug worth
995
1069
  // spelling out. **Whether to work on a tile** is about the pane: the
996
1070
  // 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**
1071
+ // wanted (so they load) but never drawn. They are wanted on *every*
1072
+ // frame, however small its damage rect, because the cache's `sweep`
1073
+ // cancels any load a frame did not want. **Whether to composite it**
998
1074
  // is about this pass's damage rect, which may be far smaller —
999
1075
  // including the deliberately tiny claim a rasterization continuation
1000
- // makes, which must still let the rasterizer run.
1076
+ // makes, which must still let the rasterizer run. The first is asked
1077
+ // here, and the second in `_drawSource`.
1001
1078
  const onScreen = rectsOverlap(box, pane);
1002
- const inPass =
1003
- this._frameClip === null || rectsOverlap(box, this._frameClip);
1004
1079
  const cached = this._cache.want(
1005
1080
  source,
1006
1081
  sourceId,
@@ -1009,6 +1084,7 @@ export class MapViewNode extends Node {
1009
1084
  subTileOf(entry.tile, p.maxZoom),
1010
1085
  );
1011
1086
  if (!onScreen) continue;
1087
+ visible.push({ entry, box, cached });
1012
1088
  stats.tiles++;
1013
1089
  if (cached.status === 'error') stats.errors++;
1014
1090
 
@@ -1020,6 +1096,8 @@ export class MapViewNode extends Node {
1020
1096
  size,
1021
1097
  cached.raster ? 0 : styleZoom,
1022
1098
  (edge: number) => this._makeSurface(edge),
1099
+ // A raster tile is the provider's image, the same in every style.
1100
+ !cached.raster,
1023
1101
  );
1024
1102
  if (drawing && drawing.progress !== -1) {
1025
1103
  if (cached.raster) {
@@ -1048,17 +1126,105 @@ export class MapViewNode extends Node {
1048
1126
  // goes blank between them. Claim the box it occupies, because
1049
1127
  // *that* is the pixel change this whole sequence of frames was
1050
1128
  // for; the frames before it claimed almost nothing.
1051
- if (this._cache.promote(cached)) this._claim(box, 'content');
1129
+ //
1130
+ // Not while a restyle holds the previous style up, though: one
1131
+ // tile in the new style among the rest in the old is the
1132
+ // patchwork the hold is there to prevent. It waits, finished, for
1133
+ // `_swapStyle` to put the whole view up at once. A raster tile
1134
+ // does not wait — it is in no style, so it cannot make one.
1135
+ if ((!holding || !drawing.styled) && this._cache.promote(cached)) {
1136
+ this._claim(box, 'content');
1137
+ }
1052
1138
  }
1053
1139
  // "Pending" means *there is work left that this map could still
1054
1140
  // 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++;
1141
+ // while it is non-zero, and a restyle swaps when it reaches zero. A
1142
+ // tile whose surface could not be made (a backend that has none)
1143
+ // never becomes drawable, and counting it would spin the frame
1144
+ // clock at the refresh rate forever, repainting a map that cannot
1145
+ // change. A finished tile waiting for a restyle's swap has nothing
1146
+ // left to do either.
1147
+ if (cached.drawing && cached.drawing.progress !== -1) stats.pending++;
1060
1148
  }
1149
+ }
1150
+ return visible;
1151
+ }
1061
1152
 
1153
+ /**
1154
+ * The cover, with every **raster** tile past the source's depth drawn as
1155
+ * the whole of its data tile — once, however many of its cells there are.
1156
+ *
1157
+ * The cover goes deeper than the source cuts because that is what makes
1158
+ * vector data sharp there: each cell is rasterized from its data tile's
1159
+ * features, through the cell (`sub`, in `_rasterize`). An image has no
1160
+ * detail finer than its pixels and no drawing it through a cell: a raster
1161
+ * cell was drawn by uploading the image, all of it, so each cell showed
1162
+ * its data tile whole, shrunk into its own square. Past a raster source's
1163
+ * depth the map was a grid of miniatures of each tile — 2×2 one level
1164
+ * past, 4×4 two — and a 256px source is read a level deeper than the
1165
+ * view, so for `osmRasterSource`, which cuts at 19, that was every zoom
1166
+ * from 19 up.
1167
+ *
1168
+ * A raster map past its provider's depth draws the deepest images larger,
1169
+ * and so does this. The square is where the cover at the source's own
1170
+ * depth puts the data tile, and it is drawn from that tile's own surface —
1171
+ * the one the view at that depth draws, so crossing into overzoom uploads
1172
+ * nothing.
1173
+ *
1174
+ * Which kind a tile is, is known once its data is in. Until then a cell
1175
+ * stays a cell, and is covered from its neighbours in the pyramid like
1176
+ * any other hole. Asking is a `want`, which the frame's own pass repeats
1177
+ * for a cell kept here, and which is idempotent.
1178
+ */
1179
+ private _rasterSquares(
1180
+ source: MapSource,
1181
+ sourceId: string,
1182
+ cover: TileCoverEntry[],
1183
+ maxZoom: number,
1184
+ ): TileCoverEntry[] {
1185
+ // A cover is one level, so every entry in it is past the cut or none is.
1186
+ if (cover.length === 0 || cover[0].tile.z <= maxZoom) return cover;
1187
+ const out: TileCoverEntry[] = [];
1188
+ const squares = new Set<string>();
1189
+ for (const cell of cover) {
1190
+ const cached = this._cache.want(
1191
+ source,
1192
+ sourceId,
1193
+ cell.tile,
1194
+ dataTileFor(cell.tile, maxZoom),
1195
+ subTileOf(cell.tile, maxZoom),
1196
+ );
1197
+ if (!cached.raster) {
1198
+ out.push(cell);
1199
+ continue;
1200
+ }
1201
+ const square = dataSquareOf(cell, maxZoom);
1202
+ const key = `${square.worldCopy}:${tileKey(square.tile)}`;
1203
+ if (squares.has(key)) continue;
1204
+ squares.add(key);
1205
+ out.push(square);
1206
+ }
1207
+ return out;
1208
+ }
1209
+
1210
+ /**
1211
+ * A frame's picture, for one source: composite each tile in view from its
1212
+ * own finished rendering or, for a tile with none, from a finished
1213
+ * ancestor or descendants. Only renderings of `generation` count, and
1214
+ * `undefined` counts any — which is `progressive`.
1215
+ */
1216
+ private _drawSource(
1217
+ ctx: MapCanvas,
1218
+ source: MapSource,
1219
+ visible: readonly VisibleTile[],
1220
+ stats: MapFrameStats,
1221
+ progressive: boolean,
1222
+ generation: number | undefined,
1223
+ ): void {
1224
+ const scale = this._scale;
1225
+ for (const { entry, box, cached } of visible) {
1226
+ const inPass =
1227
+ this._frameClip === null || rectsOverlap(box, this._frameClip);
1062
1228
  // What is composited is `shown`, which is **finished by
1063
1229
  // construction** — a rendering only becomes `shown` when its last
1064
1230
  // style run is done. So a tile appears whole rather than as water,
@@ -1070,14 +1236,13 @@ export class MapViewNode extends Node {
1070
1236
  // behaviour and is honest about what the renderer is doing.
1071
1237
  const showing =
1072
1238
  progressive && cached.drawing ? cached.drawing : cached.shown;
1073
- if (showing) {
1239
+ if (showing && drawnFor(showing, generation)) {
1074
1240
  if (!inPass) continue;
1075
1241
  this._composite(
1076
1242
  ctx,
1077
1243
  showing.surface,
1078
1244
  showing.size,
1079
1245
  box,
1080
- pane,
1081
1246
  scale,
1082
1247
  0,
1083
1248
  0,
@@ -1086,15 +1251,10 @@ export class MapViewNode extends Node {
1086
1251
  stats.ready++;
1087
1252
  continue;
1088
1253
  }
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
1254
 
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.
1255
+ // Nothing of this tile to show — a first load, which no buffering can
1256
+ // help, or a picture in a style the map is not showing. Two ways to
1257
+ // cover it, and which is available says which way the camera moved.
1098
1258
  //
1099
1259
  // **Zooming in**, the tile already in hand is this one's *ancestor*:
1100
1260
  // one composite, scaled up, blurry but complete. **Zooming out**, the
@@ -1108,12 +1268,30 @@ export class MapViewNode extends Node {
1108
1268
  // sharper and they are the level the user is coming *from*; the
1109
1269
  // ancestor wins when they do not, because a complete blurry picture
1110
1270
  // beats a sharp one with holes in it.
1111
- const kids = this._cache.descendantsWithSurface(sourceId, entry.tile);
1271
+ //
1272
+ // Looked up whether or not this pass draws the tile, because the
1273
+ // lookup is what stamps a piece as in use: a piece covering a hole is
1274
+ // on screen, and an unstamped entry is the first thing eviction takes
1275
+ // — which, in the one-pixel frames a redraw runs in, was every piece
1276
+ // but the one under that pixel. A restyle keeps such pieces on screen
1277
+ // for as long as its redraw takes.
1278
+ const kids = this._cache.descendantsWithSurface(
1279
+ source,
1280
+ entry.tile,
1281
+ undefined,
1282
+ generation,
1283
+ );
1112
1284
  const covered =
1113
1285
  kids.length > 0 && kids.length === kids[0].span * kids[0].span;
1114
1286
  const ancestor = covered
1115
1287
  ? null
1116
- : this._cache.ancestorWithSurface(sourceId, entry.tile);
1288
+ : this._cache.ancestorWithSurface(
1289
+ source,
1290
+ entry.tile,
1291
+ undefined,
1292
+ generation,
1293
+ );
1294
+ if (!inPass) continue;
1117
1295
  if (ancestor?.shown) {
1118
1296
  const up = entry.tile.z - ancestor.tile.z;
1119
1297
  const span = 1 << up;
@@ -1124,7 +1302,6 @@ export class MapViewNode extends Node {
1124
1302
  ancestor.shown.surface,
1125
1303
  ancestor.shown.size,
1126
1304
  box,
1127
- pane,
1128
1305
  scale,
1129
1306
  fx,
1130
1307
  fy,
@@ -1144,7 +1321,6 @@ export class MapViewNode extends Node {
1144
1321
  width: piece,
1145
1322
  height: piece,
1146
1323
  },
1147
- pane,
1148
1324
  scale,
1149
1325
  0,
1150
1326
  0,
@@ -1303,14 +1479,23 @@ export class MapViewNode extends Node {
1303
1479
  * point. Clipping the destination and moving the source rectangle to
1304
1480
  * match keeps the scale factor `sw/dw` exactly what it was, so nothing
1305
1481
  * about the picture changes.
1482
+ *
1483
+ * **`dest` is already in the window's space**, the one `pane` is in — a
1484
+ * tile's `box` is `pane.x + entry.x` — so nothing is added to it here.
1485
+ * The pane's origin used to be added a second time, which drew every
1486
+ * tile that far right of and below where the markers, labels and
1487
+ * overlays put the same place. A constant offset on screen is a
1488
+ * different distance on the ground at every zoom, so it looked like a
1489
+ * marker sliding across the map as it zoomed, and like nothing at all as
1490
+ * it panned, where the blit moves both together. Every test mounted the
1491
+ * map at the window's origin, where the offset is zero.
1306
1492
  */
1307
1493
  private _composite(
1308
1494
  ctx: MapCanvas,
1309
1495
  surface: SurfaceLike,
1310
1496
  size: number,
1311
- /** Where it lands, in pane-local logical pixels. */
1497
+ /** Where it lands, in the window's logical pixels. */
1312
1498
  dest: ScreenRect,
1313
- pane: ScreenRect,
1314
1499
  scale: number,
1315
1500
  /** Which sub-square of the surface to take, in `subSpan`ths. */
1316
1501
  subX: number,
@@ -1318,10 +1503,10 @@ export class MapViewNode extends Node {
1318
1503
  subSpan: number,
1319
1504
  ): void {
1320
1505
  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);
1506
+ const x0 = Math.round(dest.x * scale);
1507
+ const y0 = Math.round(dest.y * scale);
1508
+ const x1 = Math.round((dest.x + dest.width) * scale);
1509
+ const y1 = Math.round((dest.y + dest.height) * scale);
1325
1510
  if (x1 <= x0 || y1 <= y0) return;
1326
1511
 
1327
1512
  // The clip is the content box — device pixels, like everything core
@@ -1368,7 +1553,11 @@ export class MapViewNode extends Node {
1368
1553
  const fonts = (this.app as { fonts?: FontsLike } | undefined)?.fonts;
1369
1554
  if (!fonts) return; // headless: nothing to shape with
1370
1555
  const text = this.resolvedTextStyle();
1556
+ const outgoing = this._outgoing;
1557
+ // While a restyle holds the previous style up, its labels are set in
1558
+ // its face.
1371
1559
  const family =
1560
+ outgoing?.family ??
1372
1561
  this._prop<MapStyle>('mapStyle')?.fontFamily ??
1373
1562
  this._defaultStyle?.fontFamily ??
1374
1563
  text.family;
@@ -1377,41 +1566,92 @@ export class MapViewNode extends Node {
1377
1566
  } else {
1378
1567
  this._shaper.reconfigure(fonts, family, this._scale);
1379
1568
  }
1380
- const key = `${quantize(transform.zoom)}|${this._cache.generation}`;
1569
+ const clip = this._frameClip ? { ...this._frameClip } : null;
1570
+ if (outgoing) {
1571
+ // The labels that were on screen when the restyle began, and not the
1572
+ // new style's, which would go up over a map still drawn in the old
1573
+ // one. Re-placed only when they have to be — a placement is for one
1574
+ // zoom, and a source taken off the map takes its names with it — and
1575
+ // from what they were placed from, since the style that produced
1576
+ // that is being replaced, or was edited in place.
1577
+ const zoom = quantizeZoom(transform.zoom);
1578
+ const sources = this._sources();
1579
+ if (
1580
+ zoom !== outgoing.labelZoom ||
1581
+ !sameSources(sources, outgoing.labelSources)
1582
+ ) {
1583
+ const candidates: LabelCandidate[] = [];
1584
+ for (const source of sources) {
1585
+ for (const candidate of outgoing.candidates.get(source) ?? []) {
1586
+ candidates.push(candidate);
1587
+ }
1588
+ }
1589
+ outgoing.labels = placeLabels(
1590
+ candidates,
1591
+ transform.world,
1592
+ this._shaper,
1593
+ );
1594
+ outgoing.labelZoom = zoom;
1595
+ outgoing.labelSources = sources;
1596
+ }
1597
+ stats.labels = drawLabels(
1598
+ ctx,
1599
+ outgoing.labels,
1600
+ transform,
1601
+ pane,
1602
+ this._scale,
1603
+ clip,
1604
+ this._shaper,
1605
+ );
1606
+ return;
1607
+ }
1608
+ this._shapedFamily = family;
1609
+ const key = `${quantizeZoom(transform.zoom)}|${this._cache.generation}`;
1381
1610
  if (key !== this._labelKey) {
1382
1611
  this._labelKey = key;
1383
1612
  const styleZoom = Math.floor(transform.zoom);
1384
1613
  // Labels come from the tile the **data** came from, at the depth that
1385
1614
  // source actually cuts — past which many renderings share one tile
1386
1615
  // 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
- );
1616
+ //
1617
+ // And only from the sources on the map now. The cache keeps a
1618
+ // provider's tiles after it is switched away, so that switching back
1619
+ // is free; collecting from all of them drew the old provider's place
1620
+ // names over the new one's map until eviction reached them.
1621
+ const sources = this._sources();
1622
+ const wanted = new Map<MapSource, number>();
1623
+ for (const source of sources) {
1624
+ wanted.set(source, Math.min(styleZoom, pyramid(source).maxZoom));
1625
+ }
1392
1626
  const candidates: LabelCandidate[] = [];
1627
+ const bySource = new Map<MapSource, LabelCandidate[]>();
1393
1628
  for (const cached of this._cache.dataEntries()) {
1394
1629
  if (cached.status !== 'ready' || !cached.vector) continue;
1395
- if (!wanted.has(cached.tile.z)) continue;
1630
+ if (wanted.get(cached.source) !== cached.tile.z) continue;
1396
1631
  const at = `${cached.key}|${styleZoom}`;
1397
1632
  let found = this._candidates.get(at);
1398
1633
  if (!found) {
1399
- found = collectLabels(
1400
- cached.vector,
1401
- cached.tile,
1402
- style,
1403
- styleZoom,
1404
- this._geometry,
1405
- );
1634
+ found = collectLabels(cached.vector, cached.tile, style, styleZoom);
1406
1635
  // Bounded the way the shaper's cache is: a map panned across a
1407
1636
  // continent must not turn this into a leak, and rebuilding a
1408
1637
  // tile's candidates is one walk over its symbol layers.
1409
1638
  if (this._candidates.size > 512) this._candidates.clear();
1410
1639
  this._candidates.set(at, found);
1411
1640
  }
1412
- for (const candidate of found) candidates.push(candidate);
1641
+ let own = bySource.get(cached.source);
1642
+ if (!own) {
1643
+ own = [];
1644
+ bySource.set(cached.source, own);
1645
+ }
1646
+ for (const candidate of found) {
1647
+ candidates.push(candidate);
1648
+ own.push(candidate);
1649
+ }
1413
1650
  }
1414
1651
  this._labels = placeLabels(candidates, transform.world, this._shaper);
1652
+ this._labelZoom = quantizeZoom(transform.zoom);
1653
+ this._labelSources = sources;
1654
+ this._labelCandidates = bySource;
1415
1655
  }
1416
1656
  stats.labels = drawLabels(
1417
1657
  ctx,
@@ -1419,7 +1659,7 @@ export class MapViewNode extends Node {
1419
1659
  transform,
1420
1660
  pane,
1421
1661
  this._scale,
1422
- this._frameClip ? { ...this._frameClip } : null,
1662
+ clip,
1423
1663
  this._shaper,
1424
1664
  );
1425
1665
  }
@@ -1440,185 +1680,63 @@ export class MapViewNode extends Node {
1440
1680
  ): void {
1441
1681
  const text = this._attributionText();
1442
1682
  if (!text || !this._shaper) return;
1443
- const shaped = this._shaper.shape(text, 9, palette.text);
1683
+ const shaped = this._shaper.shape(text, ATTRIBUTION_SIZE, palette.text);
1444
1684
  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;
1685
+ const at = attributionLayout(shaped, pane, this._scale);
1451
1686
  ctx.save();
1452
- if (ctx.globalAlpha !== undefined) ctx.globalAlpha = 0.72;
1687
+ if (ctx.globalAlpha !== undefined) ctx.globalAlpha = ATTRIBUTION_OPACITY;
1453
1688
  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
- );
1689
+ ctx.fillRect(at.box.x, at.box.y, at.box.width, at.box.height);
1460
1690
  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
- );
1691
+ shaped.layout.draw(ctx, at.x, at.y);
1466
1692
  ctx.restore();
1467
1693
  }
1468
1694
 
1469
1695
  // --- behaviour -----------------------------------------------------------
1470
1696
 
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 } {
1697
+ /**
1698
+ * An event as the controller reads it. A synthetic event's `x`/`y` are
1699
+ * logical and relative to the window, so only the pane's own origin has
1700
+ * to come off.
1701
+ */
1702
+ private _input(ev: MouseEvent): MapPointerInput {
1475
1703
  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
1704
  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,
1705
+ x: ev.x - pane.x,
1706
+ y: ev.y - pane.y,
1707
+ button: ev.button,
1708
+ // The click count core puts on a press and on its release, which the
1709
+ // declarations do not name.
1710
+ detail: (ev as { detail?: number }).detail,
1711
+ shiftKey: ev.shiftKey,
1712
+ ctrlKey: ev.ctrlKey,
1713
+ altKey: ev.altKey,
1714
+ metaKey: ev.metaKey,
1494
1715
  };
1495
1716
  }
1496
1717
 
1497
1718
  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();
1719
+ // A pan takes the focus, so the arrows work next. A press on a marker,
1720
+ // or on a map that does not move, is captured all the same: its release
1721
+ // is a click, and an application listening for one should get it.
1722
+ if (this._controller.pointerDown(this._input(ev)) === 'pan') this.focus();
1538
1723
  ev.capturePointer?.();
1539
1724
  }
1540
1725
 
1541
1726
  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);
1727
+ this._controller.pointerDrag(this._input(ev));
1564
1728
  }
1565
1729
 
1566
1730
  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
- );
1731
+ this._controller.pointerUp(this._input(ev));
1593
1732
  }
1594
1733
 
1595
1734
  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));
1735
+ this._controller.pointerMove(this._input(ev));
1612
1736
  }
1613
1737
 
1614
1738
  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);
1739
+ this._controller.pointerLeave();
1622
1740
  }
1623
1741
 
1624
1742
  /**
@@ -1628,103 +1746,106 @@ export class MapViewNode extends Node {
1628
1746
  * scroll chain never hands over.
1629
1747
  */
1630
1748
  override defaultWheel(ev: WheelEvent): void {
1631
- if (!this._interactive()) return;
1632
1749
  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();
1750
+ const point = { x: ev.x - pane.x, y: ev.y - pane.y };
1751
+ // Consumed whether or not the zoom moved: a wheel over a map that moves
1752
+ // is never meant for whatever is behind it.
1753
+ // `ev.smooth` is the device: a wheel that clicks whole notches, or a
1754
+ // touchpad whose valuators measured the scroll. The controller eases
1755
+ // the one and applies the other (`MapController.wheel`).
1756
+ if (this._controller.wheel(point, ev.deltaY ?? 0, ev.smooth === true)) {
1757
+ ev.preventDefault();
1758
+ }
1642
1759
  }
1643
1760
 
1644
1761
  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);
1762
+ if (this._controller.keyDown(ev.keysym ?? 0, ev.shiftKey ?? false)) {
1763
+ ev.preventDefault();
1650
1764
  return;
1651
1765
  }
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();
1766
+ // Everything else goes to the base class, which is what keeps the
1767
+ // selection keys and Space/Enter-as-a-click working. `Node` declares the
1768
+ // default actions optional — an element that has no behaviour of its
1769
+ // own simply has none — so calling up is an optional call.
1770
+ super.defaultKeyDown?.(ev);
1683
1771
  }
1684
1772
 
1773
+ /** What a screen reader meets: the markers in view, as buttons — the
1774
+ * controller's answer, which both renderers give. */
1775
+ override a11yScene(): A11ySceneItem[] {
1776
+ if (!this._visible()) return [];
1777
+ return this._controller.markerScene();
1778
+ }
1779
+
1780
+ /** Every source this element has been handed, and how many `sources`
1781
+ * changes in a row have made a slot anew — see `_noticeRemade`. */
1782
+ private readonly _seenSources = new WeakSet<MapSource>();
1783
+ private _remade = 0;
1784
+ private _warnedRemade = false;
1785
+
1685
1786
  /**
1686
- * What a screen reader meets.
1787
+ * Say so, once, when the application makes a source anew on every
1788
+ * render.
1789
+ *
1790
+ * Tiles are cached per source object (see `./tiles.ts`), so a source
1791
+ * rebuilt on each render starts from an empty cache on each render: every
1792
+ * tile in view is fetched again, and the map is its background until they
1793
+ * land. Under a controlled camera that is a render per pan step — a drag
1794
+ * across a blank map that fetches the whole view on every step.
1795
+ *
1796
+ * What gives it away is a slot handed a source this element has never
1797
+ * had, looking like the one it replaces — the same id, pyramid and
1798
+ * attribution — on three `sources` changes in a row. A provider switch
1799
+ * does that once, and a switch back to a source already shown does not
1800
+ * count at all. Looking alike is only ever the basis of a warning, never
1801
+ * of a cache key: two providers can look exactly alike.
1687
1802
  *
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.
1803
+ * `process` and `console` come off `globalThis` because `src/` compiles
1804
+ * with `types: []`, as for the terminal's warning.
1693
1805
  */
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;
1806
+ private _noticeRemade(
1807
+ before: readonly MapSource[] | undefined,
1808
+ next: readonly MapSource[] | undefined,
1809
+ ): void {
1810
+ if (this._warnedRemade) return;
1811
+ let slot = -1;
1812
+ if (before && next) {
1813
+ const length = Math.min(before.length, next.length);
1814
+ for (let i = 0; i < length && slot < 0; i++) {
1815
+ const was = before[i];
1816
+ const now = next[i];
1817
+ if (
1818
+ was &&
1819
+ now &&
1820
+ now !== was &&
1821
+ !this._seenSources.has(now) &&
1822
+ now.id === was.id &&
1823
+ now.tileSize === was.tileSize &&
1824
+ now.minZoom === was.minZoom &&
1825
+ now.maxZoom === was.maxZoom &&
1826
+ now.attribution === was.attribution
1827
+ ) {
1828
+ slot = i;
1829
+ }
1709
1830
  }
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
1831
  }
1727
- return items;
1832
+ for (const source of before ?? []) this._seenSources.add(source);
1833
+ for (const source of next ?? []) this._seenSources.add(source);
1834
+ this._remade = slot < 0 ? 0 : this._remade + 1;
1835
+ if (this._remade < 3) return;
1836
+ this._warnedRemade = true;
1837
+ const g = globalThis as {
1838
+ process?: { env?: Record<string, string | undefined> };
1839
+ console?: { warn(message: string): void };
1840
+ };
1841
+ if (g.process?.env?.NODE_ENV === 'production') return;
1842
+ g.console?.warn(
1843
+ `@react-x11/components: <Map> was handed a new sources[${slot}] on ` +
1844
+ 'three renders in a row. Tiles are cached per source object, so ' +
1845
+ 'each one started from an empty cache and fetched every tile in ' +
1846
+ 'view again. If it is the same provider each time, make it once — ' +
1847
+ 'at module scope, or with useMemo.',
1848
+ );
1728
1849
  }
1729
1850
 
1730
1851
  override applyProps(
@@ -1733,25 +1854,38 @@ export class MapViewNode extends Node {
1733
1854
  ): void {
1734
1855
  const before = prev ?? this.props;
1735
1856
  super.applyProps(next, prev);
1857
+ if (this._ownsController) {
1858
+ this._controller.setProps(this.props as MapControllerProps);
1859
+ }
1860
+ if (next.sources !== before.sources) {
1861
+ this._noticeRemade(
1862
+ before.sources as readonly MapSource[] | undefined,
1863
+ next.sources as readonly MapSource[] | undefined,
1864
+ );
1865
+ }
1736
1866
  // Every one of these is in `selfDamagedProps`, so the commit claimed
1737
1867
  // 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
- }
1868
+ //
1869
+ // A restyle that repainted the pane covers everything else the commit
1870
+ // changed. One that is held claims a single pixel — nothing on screen
1871
+ // changes until the swap — so a camera or the markers moved in the same
1872
+ // commit still have to claim their own damage below.
1873
+ if (next.mapStyle !== before.mapStyle && !this._restyle()) return;
1747
1874
  if (next.sources !== before.sources) {
1875
+ // Nothing to throw away here: tiles are filed under the source
1876
+ // object, so a new provider starts empty, and one switched away from
1877
+ // keeps the tiles it has for when it comes back — its loads still in
1878
+ // flight are cancelled by the next frame's `sweep`, since nothing asks
1879
+ // for them. Only the label placement is stale.
1748
1880
  this._labelKey = '';
1749
1881
  this._repaint('props');
1750
1882
  return;
1751
1883
  }
1752
1884
  if (next.camera !== before.camera && next.camera !== undefined) {
1753
1885
  const camera = next.camera as MapCamera;
1754
- const previous = (before.camera as MapCamera | undefined) ?? this._camera;
1886
+ const previous =
1887
+ (before.camera as MapCamera | undefined) ??
1888
+ this._controller.ownCamera();
1755
1889
  if (
1756
1890
  camera.zoom !== previous.zoom ||
1757
1891
  camera.center.lon !== previous.center.lon ||
@@ -1807,13 +1941,13 @@ export class MapViewNode extends Node {
1807
1941
  }
1808
1942
 
1809
1943
  override destroySubtree(): void {
1810
- if (this._settleTimer !== null) {
1811
- timers.clearTimeout?.(this._settleTimer);
1812
- this._settleTimer = null;
1813
- }
1944
+ this._controller.detach(this._view);
1945
+ if (this._ownsController) this._controller.dispose();
1814
1946
  this._cache.destroy();
1815
1947
  this._candidates.clear();
1816
1948
  this._labels = [];
1949
+ this._labelCandidates = new Map();
1950
+ this._outgoing = null;
1817
1951
  super.destroySubtree();
1818
1952
  }
1819
1953
  }