@react-x11/components 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +143 -8
  2. package/dist/charts/components.d.ts +3 -1
  3. package/dist/charts/components.d.ts.map +1 -1
  4. package/dist/charts/components.js +8 -4
  5. package/dist/charts/components.js.map +1 -1
  6. package/dist/charts/node.d.ts +13 -6
  7. package/dist/charts/node.d.ts.map +1 -1
  8. package/dist/charts/node.js +75 -24
  9. package/dist/charts/node.js.map +1 -1
  10. package/dist/charts/render.d.ts +4 -0
  11. package/dist/charts/render.d.ts.map +1 -1
  12. package/dist/charts/render.js +11 -6
  13. package/dist/charts/render.js.map +1 -1
  14. package/dist/code-editor/node.d.ts +47 -16
  15. package/dist/code-editor/node.d.ts.map +1 -1
  16. package/dist/code-editor/node.js +121 -51
  17. package/dist/code-editor/node.js.map +1 -1
  18. package/dist/flow/draw.d.ts +19 -1
  19. package/dist/flow/draw.d.ts.map +1 -1
  20. package/dist/flow/draw.js +64 -35
  21. package/dist/flow/draw.js.map +1 -1
  22. package/dist/flow/index.d.ts.map +1 -1
  23. package/dist/flow/index.js +24 -8
  24. package/dist/flow/index.js.map +1 -1
  25. package/dist/flow/model.d.ts +4 -2
  26. package/dist/flow/model.d.ts.map +1 -1
  27. package/dist/flow/model.js +4 -2
  28. package/dist/flow/model.js.map +1 -1
  29. package/dist/flow/node.d.ts +40 -9
  30. package/dist/flow/node.d.ts.map +1 -1
  31. package/dist/flow/node.js +142 -53
  32. package/dist/flow/node.js.map +1 -1
  33. package/dist/flow/types.d.ts +46 -21
  34. package/dist/flow/types.d.ts.map +1 -1
  35. package/dist/formula/index.d.ts +4 -2
  36. package/dist/formula/index.d.ts.map +1 -1
  37. package/dist/formula/index.js.map +1 -1
  38. package/dist/formula/node.d.ts +5 -1
  39. package/dist/formula/node.d.ts.map +1 -1
  40. package/dist/formula/node.js +19 -3
  41. package/dist/formula/node.js.map +1 -1
  42. package/dist/html/controls.d.ts +4 -1
  43. package/dist/html/controls.d.ts.map +1 -1
  44. package/dist/html/controls.js.map +1 -1
  45. package/dist/html/css/cascade.d.ts +11 -5
  46. package/dist/html/css/cascade.d.ts.map +1 -1
  47. package/dist/html/css/cascade.js +18 -8
  48. package/dist/html/css/cascade.js.map +1 -1
  49. package/dist/html/css/parse.d.ts +15 -10
  50. package/dist/html/css/parse.d.ts.map +1 -1
  51. package/dist/html/css/parse.js +41 -16
  52. package/dist/html/css/parse.js.map +1 -1
  53. package/dist/html/css/style.d.ts +3 -0
  54. package/dist/html/css/style.d.ts.map +1 -1
  55. package/dist/html/css/style.js +3 -1
  56. package/dist/html/css/style.js.map +1 -1
  57. package/dist/html/css/values.d.ts +14 -1
  58. package/dist/html/css/values.d.ts.map +1 -1
  59. package/dist/html/css/values.js +11 -8
  60. package/dist/html/css/values.js.map +1 -1
  61. package/dist/html/index.d.ts.map +1 -1
  62. package/dist/html/index.js +1 -0
  63. package/dist/html/index.js.map +1 -1
  64. package/dist/html/layout/boxes.d.ts +12 -5
  65. package/dist/html/layout/boxes.d.ts.map +1 -1
  66. package/dist/html/layout/boxes.js +7 -4
  67. package/dist/html/layout/boxes.js.map +1 -1
  68. package/dist/html/layout/inline.d.ts.map +1 -1
  69. package/dist/html/layout/inline.js +22 -1
  70. package/dist/html/layout/inline.js.map +1 -1
  71. package/dist/html/node.d.ts +22 -8
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +97 -15
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +3 -0
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +2 -2
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/index.d.ts +8 -2
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +21 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/maps/clip.d.ts +43 -0
  84. package/dist/maps/clip.d.ts.map +1 -0
  85. package/dist/maps/clip.js +171 -0
  86. package/dist/maps/clip.js.map +1 -0
  87. package/dist/maps/gzip.d.ts +15 -0
  88. package/dist/maps/gzip.d.ts.map +1 -0
  89. package/dist/maps/gzip.js +56 -0
  90. package/dist/maps/gzip.js.map +1 -0
  91. package/dist/maps/index.d.ts +55 -0
  92. package/dist/maps/index.d.ts.map +1 -0
  93. package/dist/maps/index.js +141 -0
  94. package/dist/maps/index.js.map +1 -0
  95. package/dist/maps/labels.d.ts +123 -0
  96. package/dist/maps/labels.d.ts.map +1 -0
  97. package/dist/maps/labels.js +323 -0
  98. package/dist/maps/labels.js.map +1 -0
  99. package/dist/maps/mvt.d.ts +244 -0
  100. package/dist/maps/mvt.d.ts.map +1 -0
  101. package/dist/maps/mvt.js +820 -0
  102. package/dist/maps/mvt.js.map +1 -0
  103. package/dist/maps/node.d.ts +278 -0
  104. package/dist/maps/node.d.ts.map +1 -0
  105. package/dist/maps/node.js +1459 -0
  106. package/dist/maps/node.js.map +1 -0
  107. package/dist/maps/overlay.d.ts +157 -0
  108. package/dist/maps/overlay.d.ts.map +1 -0
  109. package/dist/maps/overlay.js +472 -0
  110. package/dist/maps/overlay.js.map +1 -0
  111. package/dist/maps/paint.d.ts +226 -0
  112. package/dist/maps/paint.d.ts.map +1 -0
  113. package/dist/maps/paint.js +705 -0
  114. package/dist/maps/paint.js.map +1 -0
  115. package/dist/maps/proj.d.ts +281 -0
  116. package/dist/maps/proj.d.ts.map +1 -0
  117. package/dist/maps/proj.js +451 -0
  118. package/dist/maps/proj.js.map +1 -0
  119. package/dist/maps/sources.d.ts +256 -0
  120. package/dist/maps/sources.d.ts.map +1 -0
  121. package/dist/maps/sources.js +210 -0
  122. package/dist/maps/sources.js.map +1 -0
  123. package/dist/maps/style.d.ts +141 -0
  124. package/dist/maps/style.d.ts.map +1 -0
  125. package/dist/maps/style.js +147 -0
  126. package/dist/maps/style.js.map +1 -0
  127. package/dist/maps/styles.d.ts +82 -0
  128. package/dist/maps/styles.d.ts.map +1 -0
  129. package/dist/maps/styles.js +898 -0
  130. package/dist/maps/styles.js.map +1 -0
  131. package/dist/maps/tiles.d.ts +283 -0
  132. package/dist/maps/tiles.d.ts.map +1 -0
  133. package/dist/maps/tiles.js +469 -0
  134. package/dist/maps/tiles.js.map +1 -0
  135. package/dist/maps/types.d.ts +244 -0
  136. package/dist/maps/types.d.ts.map +1 -0
  137. package/dist/maps/types.js +2 -0
  138. package/dist/maps/types.js.map +1 -0
  139. package/dist/markdown/ast.d.ts +80 -6
  140. package/dist/markdown/ast.d.ts.map +1 -1
  141. package/dist/markdown/ast.js +13 -4
  142. package/dist/markdown/ast.js.map +1 -1
  143. package/dist/markdown/expressions.d.ts +27 -0
  144. package/dist/markdown/expressions.d.ts.map +1 -0
  145. package/dist/markdown/expressions.js +203 -0
  146. package/dist/markdown/expressions.js.map +1 -0
  147. package/dist/markdown/index.d.ts +61 -2
  148. package/dist/markdown/index.d.ts.map +1 -1
  149. package/dist/markdown/index.js +101 -2
  150. package/dist/markdown/index.js.map +1 -1
  151. package/dist/markdown/parse.d.ts +1 -1
  152. package/dist/markdown/parse.d.ts.map +1 -1
  153. package/dist/markdown/parse.js +163 -15
  154. package/dist/markdown/parse.js.map +1 -1
  155. package/dist/markdown/spans.d.ts.map +1 -1
  156. package/dist/markdown/spans.js +4 -0
  157. package/dist/markdown/spans.js.map +1 -1
  158. package/dist/markdown/tags.d.ts +35 -0
  159. package/dist/markdown/tags.d.ts.map +1 -0
  160. package/dist/markdown/tags.js +213 -0
  161. package/dist/markdown/tags.js.map +1 -0
  162. package/dist/reorder/hx.d.ts +18 -0
  163. package/dist/reorder/hx.d.ts.map +1 -0
  164. package/dist/reorder/hx.js +10 -0
  165. package/dist/reorder/hx.js.map +1 -0
  166. package/dist/reorder/index.d.ts +314 -0
  167. package/dist/reorder/index.d.ts.map +1 -0
  168. package/dist/reorder/index.js +1539 -0
  169. package/dist/reorder/index.js.map +1 -0
  170. package/dist/reorder/model.d.ts +107 -0
  171. package/dist/reorder/model.d.ts.map +1 -0
  172. package/dist/reorder/model.js +162 -0
  173. package/dist/reorder/model.js.map +1 -0
  174. package/dist/richtext/node.d.ts +15 -5
  175. package/dist/richtext/node.d.ts.map +1 -1
  176. package/dist/richtext/node.js +40 -11
  177. package/dist/richtext/node.js.map +1 -1
  178. package/dist/richtext/runs.d.ts +35 -13
  179. package/dist/richtext/runs.d.ts.map +1 -1
  180. package/dist/richtext/runs.js +83 -27
  181. package/dist/richtext/runs.js.map +1 -1
  182. package/dist/tabs/index.d.ts +17 -0
  183. package/dist/tabs/index.d.ts.map +1 -1
  184. package/dist/tabs/index.js +849 -186
  185. package/dist/tabs/index.js.map +1 -1
  186. package/dist/terminal/vt/fonts.d.ts +45 -15
  187. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  188. package/dist/terminal/vt/fonts.js +89 -19
  189. package/dist/terminal/vt/fonts.js.map +1 -1
  190. package/dist/terminal/vt/node.d.ts +42 -0
  191. package/dist/terminal/vt/node.d.ts.map +1 -1
  192. package/dist/terminal/vt/node.js +95 -11
  193. package/dist/terminal/vt/node.js.map +1 -1
  194. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  195. package/dist/terminal/vt/renderer.js +13 -3
  196. package/dist/terminal/vt/renderer.js.map +1 -1
  197. package/package.json +14 -3
  198. package/src/charts/components.ts +14 -7
  199. package/src/charts/node.ts +81 -26
  200. package/src/charts/render.ts +16 -6
  201. package/src/code-editor/node.ts +147 -53
  202. package/src/flow/draw.ts +104 -35
  203. package/src/flow/index.ts +33 -13
  204. package/src/flow/model.ts +4 -2
  205. package/src/flow/node.ts +158 -59
  206. package/src/flow/types.ts +46 -21
  207. package/src/formula/index.ts +4 -2
  208. package/src/formula/node.ts +24 -5
  209. package/src/html/controls.ts +4 -1
  210. package/src/html/css/cascade.ts +18 -7
  211. package/src/html/css/parse.ts +46 -21
  212. package/src/html/css/style.ts +6 -1
  213. package/src/html/css/values.ts +25 -9
  214. package/src/html/index.ts +1 -0
  215. package/src/html/layout/boxes.ts +19 -9
  216. package/src/html/layout/inline.ts +23 -1
  217. package/src/html/node.ts +110 -17
  218. package/src/html/paint.ts +17 -2
  219. package/src/index.ts +102 -0
  220. package/src/maps/clip.ts +184 -0
  221. package/src/maps/gzip.ts +66 -0
  222. package/src/maps/index.ts +316 -0
  223. package/src/maps/labels.ts +442 -0
  224. package/src/maps/mvt.ts +857 -0
  225. package/src/maps/node.ts +1819 -0
  226. package/src/maps/overlay.ts +639 -0
  227. package/src/maps/paint.ts +978 -0
  228. package/src/maps/proj.ts +641 -0
  229. package/src/maps/sources.ts +425 -0
  230. package/src/maps/style.ts +309 -0
  231. package/src/maps/styles.ts +1040 -0
  232. package/src/maps/tiles.ts +676 -0
  233. package/src/maps/types.ts +260 -0
  234. package/src/markdown/ast.ts +85 -9
  235. package/src/markdown/expressions.ts +223 -0
  236. package/src/markdown/index.ts +190 -5
  237. package/src/markdown/parse.ts +230 -15
  238. package/src/markdown/spans.ts +4 -0
  239. package/src/markdown/tags.ts +240 -0
  240. package/src/reorder/hx.ts +31 -0
  241. package/src/reorder/index.ts +2223 -0
  242. package/src/reorder/model.ts +232 -0
  243. package/src/richtext/node.ts +49 -14
  244. package/src/richtext/runs.ts +115 -30
  245. package/src/tabs/index.ts +1040 -204
  246. package/src/terminal/vt/fonts.ts +128 -36
  247. package/src/terminal/vt/node.ts +105 -12
  248. package/src/terminal/vt/renderer.ts +13 -3
@@ -0,0 +1,978 @@
1
+ // The rasterizer: a tile's features, drawn.
2
+ //
3
+ // One function does the whole of it ({@link drawTileLayers}), and it is
4
+ // written for one situation — twelve thousand features and a hundred and
5
+ // eighty thousand vertices, sixty times a second — so every decision in it
6
+ // is about the per-vertex and per-feature paths. In rough order of how much
7
+ // each is worth on the corpus in `scripts/bench/`:
8
+ //
9
+ // 1. **One path per style layer, not per feature.** ntk batches a fill
10
+ // into server-side trapezoids and a stroke into server-side spans, both
11
+ // gated on the whole path arriving before `fill()`. Drawing 2,700
12
+ // streets as 2,700 `beginPath`/`stroke` pairs is 2,700 round-trippable
13
+ // request groups; drawing them as one path is a handful. The path is
14
+ // flushed every {@link BATCH_VERTICES} vertices so a pathological layer
15
+ // cannot make one unbounded request.
16
+ // 2. **Vertex decimation against the target grid.** A vertex less than
17
+ // `tolerance` from the one before it lands on the same pixel, so it is
18
+ // dropped. Sounds marginal; is not — Shortbread's low-zoom `land`
19
+ // layer is a single 130,000-vertex forest multipolygon whose vertices
20
+ // are, at zoom 8, roughly a fifth of a pixel apart.
21
+ // 3. **A sub-pixel feature is skipped whole.** The bounding box came free
22
+ // from the geometry read, so a building a third of a pixel across is
23
+ // one comparison rather than a path.
24
+ // 4. **Paint resolved per layer.** A zoom ramp, a filter compile and a
25
+ // colour string are per layer, never per feature. The filter arrives
26
+ // already compiled ({@link compileFilter}).
27
+ // 5. **Nothing allocates per feature.** One {@link GeometryBuffer} and one
28
+ // {@link FeatureCursor} for the whole call.
29
+ //
30
+ // The unit convention is the other half of the file, and it is the thing to
31
+ // get right before changing anything: **everything here is in *target*
32
+ // pixels**, where the target is either a tile's own surface (the retained
33
+ // path, where the tile occupies the whole surface) or the window's paint
34
+ // context (the direct path). {@link TileDraw} carries the two numbers that
35
+ // make one out of the other, so the same code serves both and neither has a
36
+ // branch in the vertex loop.
37
+ import { clipRing, clipSegment } from './clip.js';
38
+ import type { ClipRect } from './clip.js';
39
+ import type { CompiledFilter, MapStyleLayer, Zoomed } from './style.js';
40
+ import { compileFilter, resolveZoomed } from './style.js';
41
+ import { GeomType, GeometryBuffer } from './mvt.js';
42
+ import type { VectorTile, VectorTileLayer } from './mvt.js';
43
+
44
+ /**
45
+ * The slice of ntk's 2d context this draws through.
46
+ *
47
+ * Structural because react-x11 types `Context2D` as `unknown` on purpose —
48
+ * it is ntk's API rather than react-x11's — so something has to name the
49
+ * operations, and naming them once beats naming them at sixty call sites
50
+ * (the same argument `src/flow/draw.ts` makes).
51
+ */
52
+ export interface MapCanvas {
53
+ fillStyle: unknown;
54
+ strokeStyle: unknown;
55
+ lineWidth: number;
56
+ lineJoin?: unknown;
57
+ lineCap?: unknown;
58
+ globalAlpha?: number;
59
+ save(): void;
60
+ restore(): void;
61
+ beginPath(): void;
62
+ closePath(): void;
63
+ moveTo(x: number, y: number): void;
64
+ lineTo(x: number, y: number): void;
65
+ rect(x: number, y: number, w: number, h: number): void;
66
+ arc(x: number, y: number, r: number, from: number, to: number): void;
67
+ fill(): void;
68
+ stroke(): void;
69
+ clip(): void;
70
+ fillRect(x: number, y: number, w: number, h: number): void;
71
+ /** One request for many rectangles — what a marker cluster and a
72
+ * scale bar are drawn with. */
73
+ fillRects?(rects: readonly number[]): void;
74
+ setLineDash?(segments: readonly number[]): void;
75
+ roundRect?(x: number, y: number, w: number, h: number, radii: number): void;
76
+ drawImage?(image: unknown, ...args: number[]): void;
77
+ /** Straight RGBA in, for a raster tile the application decoded. */
78
+ putImageData?(
79
+ data: { width: number; height: number; data: Uint8Array },
80
+ x: number,
81
+ y: number,
82
+ ): void;
83
+ }
84
+
85
+ /**
86
+ * Whether a context can draw at all.
87
+ *
88
+ * The mock backend the headless suite runs on has no path API, which is the
89
+ * repo convention for keeping a drawn element testable: the element's paint
90
+ * becomes a no-op there rather than a throw, and everything worth asserting
91
+ * about it — the cover, the projection, the cache, the hit test — is
92
+ * asserted without pixels.
93
+ */
94
+ export function isMapCanvas(ctx: unknown): ctx is MapCanvas {
95
+ const c = ctx as Partial<MapCanvas> | null | undefined;
96
+ return typeof c?.beginPath === 'function' && typeof c?.fill === 'function';
97
+ }
98
+
99
+ const globals = globalThis as { performance?: { now(): number } };
100
+
101
+ /**
102
+ * The clock a rasterization budget is measured on.
103
+ *
104
+ * `performance.now()` rather than `Date.now()`, because a budget is a
105
+ * sub-frame quantity and `Date.now()` counts whole milliseconds: an 8 ms
106
+ * budget measured on it carries 12% of error, and anything under 1 ms
107
+ * rounds to a deadline that has already passed — so `rasterBudgetMs: 0.5`
108
+ * would silently mean "never rasterize", which is not what it says.
109
+ * Reached through `globalThis` because `src/` compiles with `types: []`.
110
+ */
111
+ export function now(): number {
112
+ return globals.performance?.now() ?? Date.now();
113
+ }
114
+
115
+ /** Vertices after which the accumulated path is flushed. Large enough that
116
+ * a whole ordinary layer is one path, small enough that the 130,000-vertex
117
+ * outlier is a few rather than one enormous request. */
118
+ export const BATCH_VERTICES = 12_000;
119
+
120
+ /**
121
+ * How a tile's own integer coordinates reach the target.
122
+ *
123
+ * `ox`/`oy`/`span` place the tile: its local `0` lands at `ox`, and its
124
+ * whole square is `span` target pixels across — so a layer's scale is
125
+ * `span / layer.extent`, computed **per layer**, because extent is a
126
+ * per-layer field and real tiles use that (OSM's Shortbread cuts `streets`,
127
+ * `land`, `ocean` and `water_polygons` at 2048 and its other twenty layers
128
+ * at 4096; a renderer that reads it once per tile draws half of them at
129
+ * twice their size).
130
+ *
131
+ * `pixelsPerLogical` is what a style's lengths are multiplied by. On the
132
+ * direct path it is simply the display scale. On the retained path it is
133
+ * `surfaceSize / tileScreenSize`, which is the display scale divided by
134
+ * whatever the composite is about to multiply by — so a two-logical-pixel
135
+ * road is two logical pixels on screen at every fractional zoom, without
136
+ * the raster being redrawn for any of them.
137
+ */
138
+ export interface TileDraw {
139
+ ox: number;
140
+ oy: number;
141
+ span: number;
142
+ pixelsPerLogical: number;
143
+ /** The zoom the style is resolved at. */
144
+ zoom: number;
145
+ /** Target pixels below which a vertex is dropped as a duplicate of the
146
+ * one before it. `0` disables decimation. */
147
+ tolerance?: number;
148
+ /** Target pixels below which a whole feature is skipped. */
149
+ minFeature?: number;
150
+ /** Clip rectangle in target coordinates — the direct path's viewport.
151
+ * Null on the retained path, where the surface is the clip. */
152
+ clip?: { x: number; y: number; width: number; height: number } | null;
153
+ /**
154
+ * Vertices after which the accumulated path is flushed, if not
155
+ * {@link BATCH_VERTICES}.
156
+ *
157
+ * One number for both backends since react-x11 2.6.1, and it was not
158
+ * before that. On **X11** a fill or stroke becomes an a8 coverage mask
159
+ * over the path's bounding box, uploaded with one `PutImage`, so a bigger
160
+ * path is fewer uploads over the same pixels — the number wants to be
161
+ * large. On the **Cocoa** backend `CGContextStrokePath` used to be
162
+ * quadratic in the number of subpaths, so it wanted the opposite, and
163
+ * `<Map>` probed the backend to pick. Core chunks that stroke itself now
164
+ * (react-x11#457), at a size it can choose and a caller cannot, so
165
+ * batching small there only defeats it.
166
+ */
167
+ batchVertices?: number;
168
+ }
169
+
170
+ /** What a rasterization cost, for the bench and for `onFrame`. */
171
+ export interface DrawStats {
172
+ layers: number;
173
+ features: number;
174
+ /** Vertices that survived decimation and were sent. */
175
+ vertices: number;
176
+ /** Vertices dropped as sub-pixel duplicates. */
177
+ decimated: number;
178
+ /** Features skipped because their whole box was sub-pixel. */
179
+ culled: number;
180
+ /** `fill()` and `stroke()` calls — the request count, near enough. */
181
+ batches: number;
182
+ }
183
+
184
+ function emptyStats(): DrawStats {
185
+ return {
186
+ layers: 0,
187
+ features: 0,
188
+ vertices: 0,
189
+ decimated: 0,
190
+ culled: 0,
191
+ batches: 0,
192
+ };
193
+ }
194
+
195
+ /** A style layer with its filter already compiled — what a cache holds so
196
+ * the compile happens once per style rather than once per tile. */
197
+ export interface PreparedLayer {
198
+ layer: MapStyleLayer;
199
+ filter: CompiledFilter;
200
+ }
201
+
202
+ /** A maximal run of consecutive style layers over one source layer. */
203
+ export interface StyleRun {
204
+ sourceLayer: string;
205
+ /** `[from, to)` into {@link PreparedStyle.layers}. */
206
+ from: number;
207
+ to: number;
208
+ }
209
+
210
+ /**
211
+ * A style with its filters compiled and its runs found — computed once per
212
+ * style, not once per tile.
213
+ *
214
+ * The runs are also the unit of **resumable** rasterization: a tile's own
215
+ * layers are drawn run by run, with a time budget between them, so a
216
+ * hundred-millisecond tile becomes a dozen frames that each stay inside
217
+ * one. See {@link drawTileRun}.
218
+ */
219
+ export interface PreparedStyle {
220
+ layers: PreparedLayer[];
221
+ runs: StyleRun[];
222
+ }
223
+
224
+ /**
225
+ * Compile a style once.
226
+ *
227
+ * Runs rather than a global grouping by source layer, because the order a
228
+ * style lists its layers in *is* the cartography — water over landuse,
229
+ * buildings over both — and a grouping would reorder a style that
230
+ * interleaves two sources. A run preserves order exactly and captures the
231
+ * win whenever a style is written the way the casing-then-fill split forces
232
+ * anyway: a road network is fourteen consecutive layers over `streets`.
233
+ */
234
+ export function prepareStyle(style: {
235
+ layers: readonly MapStyleLayer[];
236
+ }): PreparedStyle {
237
+ const layers = style.layers.map((layer) => ({
238
+ layer,
239
+ filter: compileFilter(layer.filter),
240
+ }));
241
+ const runs: StyleRun[] = [];
242
+ let i = 0;
243
+ while (i < layers.length) {
244
+ const sourceLayer = layers[i].layer.sourceLayer;
245
+ let end = i + 1;
246
+ while (
247
+ end < layers.length &&
248
+ layers[end].layer.sourceLayer === sourceLayer
249
+ ) {
250
+ end++;
251
+ }
252
+ runs.push({ sourceLayer, from: i, to: end });
253
+ i = end;
254
+ }
255
+ return { layers, runs };
256
+ }
257
+
258
+ function num(
259
+ value: Zoomed<number> | undefined,
260
+ zoom: number,
261
+ fallback: number,
262
+ ): number {
263
+ return value === undefined ? fallback : resolveZoomed(value, zoom);
264
+ }
265
+
266
+ function str(
267
+ value: Zoomed<string> | undefined,
268
+ zoom: number,
269
+ ): string | undefined {
270
+ return value === undefined ? undefined : resolveZoomed(value, zoom);
271
+ }
272
+
273
+ /**
274
+ * Scratch a caller keeps between tiles, so nothing here allocates per
275
+ * feature or per layer.
276
+ */
277
+ export class DrawScratch {
278
+ buffer = new GeometryBuffer();
279
+ /** One clipped ring at a time, reused, so the overzoom path allocates
280
+ * nothing per part either. */
281
+ ring: number[] = [];
282
+ stats: DrawStats = emptyStats();
283
+ private _buckets: number[][] = [];
284
+
285
+ /** `n` index lists, emptied. Grown to the widest run ever seen and then
286
+ * reused for the life of the element. */
287
+ buckets(n: number): number[][] {
288
+ while (this._buckets.length < n) this._buckets.push([]);
289
+ for (let i = 0; i < n; i++) this._buckets[i].length = 0;
290
+ return this._buckets;
291
+ }
292
+
293
+ resetStats(): DrawStats {
294
+ this.stats = emptyStats();
295
+ return this.stats;
296
+ }
297
+ }
298
+
299
+ /** Whether a style layer draws at this zoom at all. */
300
+ function activeAt(layer: MapStyleLayer, zoom: number): boolean {
301
+ if (layer.visible === false) return false;
302
+ if (layer.type === 'symbol') return false; // placed and drawn per frame
303
+ if (layer.minZoom !== undefined && zoom < layer.minZoom) return false;
304
+ if (layer.maxZoom !== undefined && zoom >= layer.maxZoom) return false;
305
+ return true;
306
+ }
307
+
308
+ /**
309
+ * Which geometry types a style layer draws.
310
+ *
311
+ * A `line` layer takes polygons too — stroking a ring is what a style
312
+ * asking for a pier's outline means — and a `fill` layer takes only
313
+ * polygons, since there is no area to fill in a line.
314
+ */
315
+ function typeAllowed(type: MapStyleLayer['type'], geometry: GeomType): boolean {
316
+ if (type === 'fill') return geometry === GeomType.Polygon;
317
+ if (type === 'circle') return geometry === GeomType.Point;
318
+ if (type === 'line') return geometry !== GeomType.Point;
319
+ return false;
320
+ }
321
+
322
+ /**
323
+ * Draw every layer of the style this tile has data for.
324
+ *
325
+ * The style is walked in **runs of consecutive layers over one source
326
+ * layer**, and each run's features are seeked once for the whole run. That
327
+ * is worth stating plainly because it is the difference between a fast
328
+ * zoom-12 tile and a slow one: a road network is fourteen style layers over
329
+ * `streets` (seven classes, and a casing pass before the fill pass so that
330
+ * junctions knit), so a layer-at-a-time walk parses each of eight thousand
331
+ * features' headers and tags fourteen times over. A run parses them once
332
+ * and asks fourteen already-compiled filters about them.
333
+ *
334
+ * Runs rather than a global grouping by source layer, because the order a
335
+ * style lists its layers in *is* the cartography — water over landuse,
336
+ * buildings over both — and a grouping would reorder a style that
337
+ * interleaves. A run preserves order exactly and captures the win whenever
338
+ * a style is written the way the casing/fill split forces anyway.
339
+ */
340
+ export function drawTileLayers(
341
+ ctx: MapCanvas,
342
+ tile: VectorTile,
343
+ prepared: PreparedStyle,
344
+ draw: TileDraw,
345
+ scratch: DrawScratch,
346
+ ): DrawStats {
347
+ for (let run = 0; run < prepared.runs.length; run++) {
348
+ drawTileRun(ctx, tile, prepared, run, draw, scratch);
349
+ }
350
+ return scratch.stats;
351
+ }
352
+
353
+ /**
354
+ * One run of the style, drawn.
355
+ *
356
+ * The unit a caller budgets in: the node draws runs until its per-frame
357
+ * budget is spent, remembers where it stopped, and comes back next frame.
358
+ * Layers are painted bottom-up, so a tile stopped part-way looks like a map
359
+ * whose upper layers have not arrived — which is what a partly-drawn map
360
+ * should look like — rather than like a hole.
361
+ */
362
+ export function drawTileRun(
363
+ ctx: MapCanvas,
364
+ tile: VectorTile,
365
+ prepared: PreparedStyle,
366
+ runIndex: number,
367
+ draw: TileDraw,
368
+ scratch: DrawScratch,
369
+ resume?: {
370
+ /** Skip this many of the run's active layers — where a previous frame
371
+ * stopped. */
372
+ fromLayer?: number;
373
+ /** Stop after the layer that crosses this, on {@link now}'s clock. */
374
+ deadline?: number;
375
+ },
376
+ ): number {
377
+ const stats = scratch.stats;
378
+ const run = prepared.runs[runIndex];
379
+ if (!run) return -1;
380
+ const source = tile.layers.get(run.sourceLayer);
381
+ if (!source || source.length === 0) return -1;
382
+
383
+ // The zoom gate before anything is touched. A style with forty layers
384
+ // draws eight at a given zoom, and the other thirty-two must not cost a
385
+ // feature walk.
386
+ const active: PreparedLayer[] = [];
387
+ for (let j = run.from; j < run.to; j++) {
388
+ if (activeAt(prepared.layers[j].layer, draw.zoom)) {
389
+ active.push(prepared.layers[j]);
390
+ }
391
+ }
392
+ if (active.length === 0) return -1;
393
+ const from = resume?.fromLayer ?? 0;
394
+ if (from >= active.length) return -1;
395
+ stats.layers += active.length - from;
396
+ const deadline = resume?.deadline;
397
+
398
+ if (active.length === 1) {
399
+ // Everything but roads: one layer over the source, so there is nothing
400
+ // to share and no index list to build.
401
+ drawLayer(ctx, source, active[0], draw, scratch, null);
402
+ return -1;
403
+ }
404
+ // Several layers over one source: seek each feature once and ask every
405
+ // filter about it, rather than walking the layer once per style layer.
406
+ // On a zoom-12 tile that is one pass over 8,488 streets instead of
407
+ // fourteen.
408
+ const buckets = scratch.buckets(active.length);
409
+ const cursor = source.feature(0);
410
+ for (let f = 0; f < source.length; f++) {
411
+ source.seek(f, cursor);
412
+ for (let j = from; j < active.length; j++) {
413
+ const { layer, filter } = active[j];
414
+ if (!typeAllowed(layer.type, cursor.type)) continue;
415
+ if (!filter(cursor)) continue;
416
+ buckets[j].push(f);
417
+ }
418
+ }
419
+ // Stopping between layers rather than only between runs, because a run is
420
+ // not a small unit: a road network is one run and fourteen layers, and
421
+ // one of those layers alone measured 90 ms on the profiling corpus. The
422
+ // budget has to be able to interrupt *inside* it or it is not a budget.
423
+ // Resuming re-does the bucketing pass above — one seek over the source
424
+ // layer, a few milliseconds — which is the price of not holding a frame's
425
+ // scratch across frames.
426
+ for (let j = from; j < active.length; j++) {
427
+ drawLayer(ctx, source, active[j], draw, scratch, buckets[j]);
428
+ if (deadline !== undefined && j + 1 < active.length && now() >= deadline) {
429
+ return j + 1;
430
+ }
431
+ }
432
+ return -1;
433
+ }
434
+
435
+ function drawLayer(
436
+ ctx: MapCanvas,
437
+ source: VectorTileLayer,
438
+ prepared: PreparedLayer,
439
+ draw: TileDraw,
440
+ scratch: DrawScratch,
441
+ indices: readonly number[] | null,
442
+ ): void {
443
+ const { layer, filter } = prepared;
444
+ switch (layer.type) {
445
+ case 'fill':
446
+ drawFill(ctx, source, layer, filter, draw, scratch, indices);
447
+ break;
448
+ case 'line':
449
+ drawLine(ctx, source, layer, filter, draw, scratch, indices);
450
+ break;
451
+ case 'circle':
452
+ drawCircle(ctx, source, layer, filter, draw, scratch, indices);
453
+ break;
454
+ case 'symbol':
455
+ break;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Append one part of a feature to the path, simplified against the target
461
+ * grid, and answer how many vertices were emitted.
462
+ *
463
+ * Two tests, in the order that makes the cheap one do most of the work:
464
+ *
465
+ * - **radial** — a vertex within `tolerance` of the one last kept lands on
466
+ * the same pixel, so it says nothing;
467
+ * - **perpendicular** — a vertex within `tolerance` of the straight line
468
+ * from the one last kept to the one after it is on that line, so it says
469
+ * nothing either. This is the one that matters on real data: a
470
+ * generalized coastline's vertices are about a pixel apart, which the
471
+ * radial test keeps, and nearly collinear, which this drops.
472
+ *
473
+ * The first and last vertices are always kept: a ring simplified down to
474
+ * its first point is not a ring, and a road whose end moved is a road that
475
+ * no longer meets the next one at the tile seam.
476
+ */
477
+ function emitPart(
478
+ ctx: MapCanvas,
479
+ coords: Int32Array,
480
+ from: number,
481
+ to: number,
482
+ ox: number,
483
+ oy: number,
484
+ k: number,
485
+ /** Squared, so the vertex loop needs no square root. */
486
+ toleranceSq: number,
487
+ closed: boolean,
488
+ stats: DrawStats,
489
+ /**
490
+ * Clip the geometry to this box, or null to let the target clip it.
491
+ *
492
+ * Null on the ordinary path, where a tile is drawn at its own size and a
493
+ * vertex is a few thousand pixels at worst. Set when a tile is drawn past
494
+ * its own depth — one source tile rasterized into each cell of a grid
495
+ * over it — where a tile-wide polygon is `span` tiles wide in the
496
+ * surface's pixels: at 64 cells that is a hundred thousand, and ntk hands
497
+ * a stroke to XRender as 16.16 fixed point, which overflows a signed
498
+ * 32-bit word at 32,768. The surface would clip the *drawing* correctly;
499
+ * it is the numbers on the way there that do not fit.
500
+ */
501
+ clip: ClipRect | null,
502
+ /** Scratch for the clipped ring, so a fill allocates nothing per part. */
503
+ scratch: number[],
504
+ ): number {
505
+ if (clip) {
506
+ return emitClipped(
507
+ ctx,
508
+ coords,
509
+ from,
510
+ to,
511
+ ox,
512
+ oy,
513
+ k,
514
+ closed,
515
+ clip,
516
+ scratch,
517
+ stats,
518
+ );
519
+ }
520
+ const count = to - from;
521
+ if (count < 2) return 0;
522
+ let x = ox + coords[from * 2] * k;
523
+ let y = oy + coords[from * 2 + 1] * k;
524
+ ctx.moveTo(x, y);
525
+ let emitted = 1;
526
+ let keptX = x;
527
+ let keptY = y;
528
+ const last = to - 1;
529
+ for (let i = from + 1; i <= last; i++) {
530
+ x = ox + coords[i * 2] * k;
531
+ y = oy + coords[i * 2 + 1] * k;
532
+ if (toleranceSq > 0 && i !== last) {
533
+ const dx = x - keptX;
534
+ const dy = y - keptY;
535
+ if (dx * dx + dy * dy < toleranceSq) {
536
+ stats.decimated++;
537
+ continue;
538
+ }
539
+ const nx = ox + coords[(i + 1) * 2] * k;
540
+ const ny = oy + coords[(i + 1) * 2 + 1] * k;
541
+ const ax = nx - keptX;
542
+ const ay = ny - keptY;
543
+ const lenSq = ax * ax + ay * ay;
544
+ if (lenSq > 0) {
545
+ // The cross product is the parallelogram's area, so its square over
546
+ // `lenSq` is the squared perpendicular distance — compared as a
547
+ // product to keep the division out of the loop.
548
+ const cross = ax * dy - ay * dx;
549
+ if (cross * cross < toleranceSq * lenSq) {
550
+ stats.decimated++;
551
+ continue;
552
+ }
553
+ }
554
+ }
555
+ ctx.lineTo(x, y);
556
+ emitted++;
557
+ keptX = x;
558
+ keptY = y;
559
+ }
560
+ if (closed) ctx.closePath();
561
+ return emitted;
562
+ }
563
+
564
+ /**
565
+ * One part, clipped to the target before it is emitted.
566
+ *
567
+ * A ring goes through Sutherland-Hodgman, because a fill needs a closed
568
+ * boundary — the part that crosses the window has to come back along the
569
+ * window's edge. A line goes through Cohen-Sutherland segment by segment,
570
+ * which turns one polyline into as many subpaths as it has visible
571
+ * stretches; joins at the boundary are lost, and they are off-screen.
572
+ *
573
+ * Simplification is deliberately skipped here: this path only runs for the
574
+ * handful of features that survive the box cull in an overzoomed cell, and
575
+ * at that magnification there is nothing to simplify away.
576
+ */
577
+ function emitClipped(
578
+ ctx: MapCanvas,
579
+ coords: Int32Array,
580
+ from: number,
581
+ to: number,
582
+ ox: number,
583
+ oy: number,
584
+ k: number,
585
+ closed: boolean,
586
+ clip: ClipRect,
587
+ scratch: number[],
588
+ stats: DrawStats,
589
+ ): number {
590
+ const count = to - from;
591
+ if (count < 2) return 0;
592
+ scratch.length = 0;
593
+ for (let i = from; i < to; i++) {
594
+ scratch.push(ox + coords[i * 2] * k, oy + coords[i * 2 + 1] * k);
595
+ }
596
+ if (closed) {
597
+ const ring = clipRing(scratch, clip);
598
+ if (ring.length < 6) return 0;
599
+ ctx.moveTo(ring[0], ring[1]);
600
+ for (let i = 2; i < ring.length; i += 2) ctx.lineTo(ring[i], ring[i + 1]);
601
+ ctx.closePath();
602
+ return ring.length / 2;
603
+ }
604
+ let emitted = 0;
605
+ let open = false;
606
+ for (let i = 0; i + 3 < scratch.length; i += 2) {
607
+ const piece = clipSegment(
608
+ scratch[i],
609
+ scratch[i + 1],
610
+ scratch[i + 2],
611
+ scratch[i + 3],
612
+ clip,
613
+ );
614
+ if (!piece) {
615
+ open = false;
616
+ continue;
617
+ }
618
+ const [x0, y0, x1, y1] = piece;
619
+ if (!open) {
620
+ ctx.moveTo(x0, y0);
621
+ open = true;
622
+ emitted++;
623
+ }
624
+ ctx.lineTo(x1, y1);
625
+ emitted++;
626
+ if (x1 !== scratch[i + 2] || y1 !== scratch[i + 3]) open = false;
627
+ }
628
+ void stats;
629
+ return emitted;
630
+ }
631
+
632
+ /** A draw's clip rectangle in the form the clippers take, inflated so a
633
+ * stroke's own width and its joins land outside the surface rather than
634
+ * against its edge. */
635
+ function clipRectOf(
636
+ clip: { x: number; y: number; width: number; height: number } | null,
637
+ ): ClipRect | null {
638
+ if (!clip) return null;
639
+ const margin = 64;
640
+ return {
641
+ minX: clip.x - margin,
642
+ minY: clip.y - margin,
643
+ maxX: clip.x + clip.width + margin,
644
+ maxY: clip.y + clip.height + margin,
645
+ };
646
+ }
647
+
648
+ /** Whether a part's box is too small to be worth a path. */
649
+ function partTooSmall(
650
+ bounds: Float64Array,
651
+ part: number,
652
+ k: number,
653
+ minFeature: number,
654
+ ): boolean {
655
+ if (minFeature <= 0) return false;
656
+ const at = part * 4;
657
+ return (
658
+ (bounds[at + 2] - bounds[at]) * k < minFeature &&
659
+ (bounds[at + 3] - bounds[at + 1]) * k < minFeature
660
+ );
661
+ }
662
+
663
+ /**
664
+ * Whether a whole feature lands outside the target at all.
665
+ *
666
+ * Only ever true when the target is a *cell* of a larger tile — the
667
+ * overzoom case, where one tile's data is drawn into each of `span²`
668
+ * surfaces. Without this each cell would build a path for every feature in
669
+ * the tile and let the surface clip it, so an overzoomed frame would cost
670
+ * `span²` times what it should; the box came free from the geometry read,
671
+ * so the test is four comparisons.
672
+ */
673
+ function outsideClip(
674
+ buffer: GeometryBuffer,
675
+ ox: number,
676
+ oy: number,
677
+ k: number,
678
+ clip: { x: number; y: number; width: number; height: number },
679
+ /** Slack for a stroke's own width, in target pixels. */
680
+ slack: number,
681
+ ): boolean {
682
+ return (
683
+ ox + buffer.maxX * k < clip.x - slack ||
684
+ ox + buffer.minX * k > clip.x + clip.width + slack ||
685
+ oy + buffer.maxY * k < clip.y - slack ||
686
+ oy + buffer.minY * k > clip.y + clip.height + slack
687
+ );
688
+ }
689
+
690
+ /** Iterate a bucket, or the whole layer applying the filter. Written as a
691
+ * pair of loops in each drawer rather than a shared generator: a generator
692
+ * per feature is exactly the allocation this file exists to avoid. */
693
+ function featureCount(
694
+ source: VectorTileLayer,
695
+ indices: readonly number[] | null,
696
+ ): number {
697
+ return indices ? indices.length : source.length;
698
+ }
699
+
700
+ function drawFill(
701
+ ctx: MapCanvas,
702
+ source: VectorTileLayer,
703
+ layer: Extract<MapStyleLayer, { type: 'fill' }>,
704
+ filter: CompiledFilter,
705
+ draw: TileDraw,
706
+ scratch: DrawScratch,
707
+ indices: readonly number[] | null,
708
+ ): void {
709
+ const stats = scratch.stats;
710
+ const buffer = scratch.buffer;
711
+ const k = draw.span / source.extent;
712
+ const opacity = num(layer.opacity, draw.zoom, 1);
713
+ if (opacity <= 0) return;
714
+ const outline = str(layer.outlineColor, draw.zoom);
715
+ const tol = (draw.tolerance ?? 0) * (draw.tolerance ?? 0);
716
+ const minFeature = draw.minFeature ?? 0;
717
+ const clip = draw.clip ?? null;
718
+ const clipBox = clipRectOf(clip);
719
+ const cursor = source.feature(0);
720
+ const total = featureCount(source, indices);
721
+ const batch = draw.batchVertices ?? BATCH_VERTICES;
722
+
723
+ ctx.save();
724
+ if (ctx.globalAlpha !== undefined && opacity < 1) ctx.globalAlpha = opacity;
725
+ ctx.fillStyle = resolveZoomed(layer.color, draw.zoom);
726
+ if (outline !== undefined) {
727
+ ctx.strokeStyle = outline;
728
+ ctx.lineWidth = Math.max(1, draw.pixelsPerLogical);
729
+ }
730
+ let pending = 0;
731
+ ctx.beginPath();
732
+ for (let n = 0; n < total; n++) {
733
+ source.seek(indices ? indices[n] : n, cursor);
734
+ if (!indices) {
735
+ if (cursor.type !== GeomType.Polygon) continue;
736
+ if (!filter(cursor)) continue;
737
+ }
738
+ cursor.readGeometry(buffer);
739
+ if (buffer.parts === 0) continue;
740
+ if (clip && outsideClip(buffer, draw.ox, draw.oy, k, clip, 2)) {
741
+ stats.culled++;
742
+ continue;
743
+ }
744
+ stats.features++;
745
+ for (let part = 0; part < buffer.parts; part++) {
746
+ // Rings go into one path and are filled with the non-zero rule, which
747
+ // is what makes a hole a hole: MVT winds an exterior ring one way and
748
+ // an interior ring the other, so the rule does the work and nothing
749
+ // here pairs them up.
750
+ //
751
+ // Which is exactly why the batch is flushed **only before an exterior
752
+ // ring**. A positive area is a new polygon (the specification's own
753
+ // definition), so that is the one point at which the path so far is a
754
+ // whole number of polygons; flushing between an exterior ring and its
755
+ // interior ones would fill the hole in. This is what `areas` is for.
756
+ const exterior = buffer.areas[part] > 0;
757
+ if (exterior && pending >= batch) {
758
+ ctx.fill();
759
+ if (outline !== undefined) ctx.stroke();
760
+ stats.batches += outline === undefined ? 1 : 2;
761
+ stats.vertices += pending;
762
+ pending = 0;
763
+ ctx.beginPath();
764
+ }
765
+ if (partTooSmall(buffer.partBounds, part, k, minFeature)) {
766
+ stats.culled++;
767
+ continue;
768
+ }
769
+ pending += emitPart(
770
+ ctx,
771
+ buffer.coords,
772
+ buffer.starts[part],
773
+ buffer.starts[part + 1],
774
+ draw.ox,
775
+ draw.oy,
776
+ k,
777
+ tol,
778
+ true,
779
+ stats,
780
+ clipBox,
781
+ scratch.ring,
782
+ );
783
+ }
784
+ }
785
+ if (pending > 0) {
786
+ ctx.fill();
787
+ if (outline !== undefined) ctx.stroke();
788
+ stats.batches += outline === undefined ? 1 : 2;
789
+ stats.vertices += pending;
790
+ }
791
+ ctx.restore();
792
+ }
793
+
794
+ function drawLine(
795
+ ctx: MapCanvas,
796
+ source: VectorTileLayer,
797
+ layer: Extract<MapStyleLayer, { type: 'line' }>,
798
+ filter: CompiledFilter,
799
+ draw: TileDraw,
800
+ scratch: DrawScratch,
801
+ indices: readonly number[] | null,
802
+ ): void {
803
+ const stats = scratch.stats;
804
+ const buffer = scratch.buffer;
805
+ const k = draw.span / source.extent;
806
+ const logical = num(layer.width, draw.zoom, 1);
807
+ if (logical <= 0) return;
808
+ const opacity = num(layer.opacity, draw.zoom, 1);
809
+ if (opacity <= 0) return;
810
+ // A road narrower than a pixel is drawn *at* a pixel rather than dropped:
811
+ // a motorway network that vanishes below zoom 6 is worse than one drawn a
812
+ // little heavy, and every map makes this same choice.
813
+ const width = Math.max(1, logical * draw.pixelsPerLogical);
814
+ const tol = (draw.tolerance ?? 0) * (draw.tolerance ?? 0);
815
+ const minFeature = draw.minFeature ?? 0;
816
+ const clip = draw.clip ?? null;
817
+ const clipBox = clipRectOf(clip);
818
+ const cursor = source.feature(0);
819
+ const total = featureCount(source, indices);
820
+ const batch = draw.batchVertices ?? BATCH_VERTICES;
821
+
822
+ ctx.save();
823
+ if (ctx.globalAlpha !== undefined && opacity < 1) ctx.globalAlpha = opacity;
824
+ ctx.strokeStyle = resolveZoomed(layer.color, draw.zoom);
825
+ ctx.lineWidth = width;
826
+ if (layer.cap !== undefined) ctx.lineCap = layer.cap;
827
+ if (layer.join !== undefined) ctx.lineJoin = layer.join;
828
+ if (layer.dash && ctx.setLineDash) {
829
+ ctx.setLineDash(layer.dash.map((d) => d * draw.pixelsPerLogical));
830
+ }
831
+ let pending = 0;
832
+ ctx.beginPath();
833
+ for (let n = 0; n < total; n++) {
834
+ source.seek(indices ? indices[n] : n, cursor);
835
+ if (!indices) {
836
+ if (cursor.type === GeomType.Point) continue;
837
+ if (!filter(cursor)) continue;
838
+ }
839
+ cursor.readGeometry(buffer);
840
+ if (buffer.parts === 0) continue;
841
+ if (clip && outsideClip(buffer, draw.ox, draw.oy, k, clip, width)) {
842
+ stats.culled++;
843
+ continue;
844
+ }
845
+ stats.features++;
846
+ const closed = cursor.type === GeomType.Polygon;
847
+ for (let part = 0; part < buffer.parts; part++) {
848
+ // Any part boundary is a safe flush point for a stroke: a subpath is
849
+ // stroked independently of every other, so where the path is cut
850
+ // changes nothing about the result. Inside the loop rather than after
851
+ // the feature because the feature that needs it most is a single one
852
+ // — a `land` multipolygon at zoom 8, a `buildings` multipolygon at
853
+ // zoom 14 — with tens of thousands of vertices in it.
854
+ if (pending >= batch) {
855
+ ctx.stroke();
856
+ stats.batches++;
857
+ stats.vertices += pending;
858
+ pending = 0;
859
+ ctx.beginPath();
860
+ }
861
+ if (partTooSmall(buffer.partBounds, part, k, minFeature)) {
862
+ stats.culled++;
863
+ continue;
864
+ }
865
+ pending += emitPart(
866
+ ctx,
867
+ buffer.coords,
868
+ buffer.starts[part],
869
+ buffer.starts[part + 1],
870
+ draw.ox,
871
+ draw.oy,
872
+ k,
873
+ tol,
874
+ closed,
875
+ stats,
876
+ clipBox,
877
+ scratch.ring,
878
+ );
879
+ }
880
+ }
881
+ if (pending > 0) {
882
+ ctx.stroke();
883
+ stats.batches++;
884
+ stats.vertices += pending;
885
+ }
886
+ if (layer.dash && ctx.setLineDash) ctx.setLineDash([]);
887
+ ctx.restore();
888
+ }
889
+
890
+ function drawCircle(
891
+ ctx: MapCanvas,
892
+ source: VectorTileLayer,
893
+ layer: Extract<MapStyleLayer, { type: 'circle' }>,
894
+ filter: CompiledFilter,
895
+ draw: TileDraw,
896
+ scratch: DrawScratch,
897
+ indices: readonly number[] | null,
898
+ ): void {
899
+ const stats = scratch.stats;
900
+ const buffer = scratch.buffer;
901
+ const k = draw.span / source.extent;
902
+ const radius = num(layer.radius, draw.zoom, 3) * draw.pixelsPerLogical;
903
+ if (radius <= 0) return;
904
+ const opacity = num(layer.opacity, draw.zoom, 1);
905
+ if (opacity <= 0) return;
906
+ const stroke = str(layer.strokeColor, draw.zoom);
907
+ const strokeWidth =
908
+ num(layer.strokeWidth, draw.zoom, 1) * draw.pixelsPerLogical;
909
+ const clip = draw.clip ?? null;
910
+ const cursor = source.feature(0);
911
+ const total = featureCount(source, indices);
912
+ const discBatch = Math.min(1024, draw.batchVertices ?? BATCH_VERTICES);
913
+
914
+ ctx.save();
915
+ if (ctx.globalAlpha !== undefined && opacity < 1) ctx.globalAlpha = opacity;
916
+ ctx.fillStyle = resolveZoomed(layer.color, draw.zoom);
917
+ if (stroke !== undefined) {
918
+ ctx.strokeStyle = stroke;
919
+ ctx.lineWidth = strokeWidth;
920
+ }
921
+ let pending = 0;
922
+ ctx.beginPath();
923
+ for (let n = 0; n < total; n++) {
924
+ source.seek(indices ? indices[n] : n, cursor);
925
+ if (!indices) {
926
+ if (cursor.type !== GeomType.Point) continue;
927
+ if (!filter(cursor)) continue;
928
+ }
929
+ cursor.readGeometry(buffer);
930
+ if (
931
+ buffer.points > 0 &&
932
+ clip &&
933
+ outsideClip(buffer, draw.ox, draw.oy, k, clip, radius)
934
+ ) {
935
+ stats.culled++;
936
+ continue;
937
+ }
938
+ stats.features++;
939
+ for (let p = 0; p < buffer.points; p++) {
940
+ const x = draw.ox + buffer.coords[p * 2] * k;
941
+ const y = draw.oy + buffer.coords[p * 2 + 1] * k;
942
+ // A disc is a point, so there is nothing to clip — but a multipoint
943
+ // feature can straddle the target, and only the whole feature was
944
+ // culled above. Skip the ones that land outside it.
945
+ if (
946
+ clip &&
947
+ (x + radius < clip.x ||
948
+ x - radius > clip.x + clip.width ||
949
+ y + radius < clip.y ||
950
+ y - radius > clip.y + clip.height)
951
+ ) {
952
+ continue;
953
+ }
954
+ // A fresh subpath per disc: an `arc` continuing from the previous
955
+ // point would join the two with a line.
956
+ ctx.moveTo(x + radius, y);
957
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
958
+ pending++;
959
+ }
960
+ // Discs, not vertices — each is `moveTo` plus an `arc` — so the cap is
961
+ // its own, bounded by the backend's batch preference.
962
+ if (pending >= discBatch) {
963
+ ctx.fill();
964
+ if (stroke !== undefined) ctx.stroke();
965
+ stats.batches += stroke === undefined ? 1 : 2;
966
+ stats.vertices += pending;
967
+ pending = 0;
968
+ ctx.beginPath();
969
+ }
970
+ }
971
+ if (pending > 0) {
972
+ ctx.fill();
973
+ if (stroke !== undefined) ctx.stroke();
974
+ stats.batches += stroke === undefined ? 1 : 2;
975
+ stats.vertices += pending;
976
+ }
977
+ ctx.restore();
978
+ }