@chrtco/sdk 0.10.43 → 0.10.45

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.
@@ -279,6 +279,32 @@ declare function reloadChrtRasterSymbolImages(map: maplibregl.Map, colorTable: C
279
279
  * The rasterising itself (SVG → canvas → pixels) is main-thread work, so it
280
280
  * runs in slices with a yield between them and reports progress; the chart
281
281
  * keeps drawing while the icons come over.
282
+ *
283
+ * ===========================================================================
284
+ * ⚠ ONE SVG DOCUMENT PER SYMBOL STARVES THE MAP. A SWITCH DRAWS FROM SHEETS.
285
+ * ===========================================================================
286
+ *
287
+ * An `<img>` with an SVG data URL is a whole Blink document — its own page,
288
+ * parser, font set, idleness timers, memory registration and mojo pipes — and
289
+ * each one queues a dozen-odd small main-thread tasks, some long after
290
+ * `decode()` resolves. 569 of them queued 6,452 tasks at once (Chrome trace,
291
+ * palette harness, 2026-10-03), and MapLibre's worker messages waited behind
292
+ * them: the paint diff's tile reload, answered by the workers in ~30 ms, was
293
+ * picked up by the main thread 21 s and then 27 s later at 4x CPU throttle.
294
+ * Until it lands, data-driven paint — soundings, light circles — keeps its
295
+ * DAY colours. One decode in flight at a time did not help; fewer DOCUMENTS
296
+ * did. `rethemeSymbolImages` therefore draws up to `SHEET_MAX_SYMBOLS`
297
+ * symbols from ONE document — a column of nested `<svg>`s at whole-pixel
298
+ * rows — and rasterises each symbol by drawing that sheet shifted up by the
299
+ * symbol's row onto a canvas the symbol's size.
300
+ *
301
+ * ⚠ SHIFT THE SHEET; DO NOT CUT ROWS OUT OF ONE DRAW. Drawn once and cut,
302
+ * ~15% of symbols moved an anti-aliased edge pixel (up to 64/255): a symbol
303
+ * at row y is painted at y + s·p, which rounds differently in float from the
304
+ * s·p it gets alone. Shifted by −y, the canvas and sheet translations cancel
305
+ * in the matrix (−y + y = 0, exactly) before any point is mapped, and every
306
+ * symbol of every family and palette comes out byte-identical to its own
307
+ * document (`scripts/perf/palette-switch/verify-run.mjs`).
282
308
  */
283
309
 
284
310
  interface SymbolImageProgress {
@@ -279,6 +279,32 @@ declare function reloadChrtRasterSymbolImages(map: maplibregl.Map, colorTable: C
279
279
  * The rasterising itself (SVG → canvas → pixels) is main-thread work, so it
280
280
  * runs in slices with a yield between them and reports progress; the chart
281
281
  * keeps drawing while the icons come over.
282
+ *
283
+ * ===========================================================================
284
+ * ⚠ ONE SVG DOCUMENT PER SYMBOL STARVES THE MAP. A SWITCH DRAWS FROM SHEETS.
285
+ * ===========================================================================
286
+ *
287
+ * An `<img>` with an SVG data URL is a whole Blink document — its own page,
288
+ * parser, font set, idleness timers, memory registration and mojo pipes — and
289
+ * each one queues a dozen-odd small main-thread tasks, some long after
290
+ * `decode()` resolves. 569 of them queued 6,452 tasks at once (Chrome trace,
291
+ * palette harness, 2026-10-03), and MapLibre's worker messages waited behind
292
+ * them: the paint diff's tile reload, answered by the workers in ~30 ms, was
293
+ * picked up by the main thread 21 s and then 27 s later at 4x CPU throttle.
294
+ * Until it lands, data-driven paint — soundings, light circles — keeps its
295
+ * DAY colours. One decode in flight at a time did not help; fewer DOCUMENTS
296
+ * did. `rethemeSymbolImages` therefore draws up to `SHEET_MAX_SYMBOLS`
297
+ * symbols from ONE document — a column of nested `<svg>`s at whole-pixel
298
+ * rows — and rasterises each symbol by drawing that sheet shifted up by the
299
+ * symbol's row onto a canvas the symbol's size.
300
+ *
301
+ * ⚠ SHIFT THE SHEET; DO NOT CUT ROWS OUT OF ONE DRAW. Drawn once and cut,
302
+ * ~15% of symbols moved an anti-aliased edge pixel (up to 64/255): a symbol
303
+ * at row y is painted at y + s·p, which rounds differently in float from the
304
+ * s·p it gets alone. Shifted by −y, the canvas and sheet translations cancel
305
+ * in the matrix (−y + y = 0, exactly) before any point is mapped, and every
306
+ * symbol of every family and palette comes out byte-identical to its own
307
+ * document (`scripts/perf/palette-switch/verify-run.mjs`).
282
308
  */
283
309
 
284
310
  interface SymbolImageProgress {
@@ -1127,17 +1127,110 @@ async function decode(spec) {
1127
1127
  }
1128
1128
  }
1129
1129
  var scratch = null;
1130
- function rasterise(img, spec) {
1130
+ function scratchContext() {
1131
1131
  if (!scratch) {
1132
- const canvas2 = document.createElement("canvas");
1133
- scratch = canvas2.getContext("2d", { willReadFrequently: true });
1132
+ const canvas = document.createElement("canvas");
1133
+ scratch = canvas.getContext("2d", { willReadFrequently: true });
1134
1134
  if (!scratch) throw new Error("symbol-images: no 2d canvas context");
1135
1135
  }
1136
- const canvas = scratch.canvas;
1136
+ return scratch;
1137
+ }
1138
+ function rasterise(src, spec) {
1139
+ const ctx = scratchContext();
1140
+ const canvas = ctx.canvas;
1137
1141
  canvas.width = spec.width;
1138
1142
  canvas.height = spec.height;
1139
- scratch.drawImage(img, 0, 0, spec.width, spec.height);
1140
- return scratch.getImageData(0, 0, spec.width, spec.height);
1143
+ ctx.drawImage(src.img, 0, -src.y, src.width, src.height);
1144
+ return ctx.getImageData(0, 0, spec.width, spec.height);
1145
+ }
1146
+ var SHEET_MAX_SYMBOLS = 32;
1147
+ var SHEET_MAX_HEIGHT = 4096;
1148
+ var ROOT = /^\s*<svg\b([^>]*)>[\s\S]*<\/svg>\s*$/;
1149
+ var NOT_SHAREABLE = /<\?xml|<!DOCTYPE|<style\b|<script\b|<foreignObject\b|<svg\b[\s\S]*<svg\b/i;
1150
+ function rootSize(attrs) {
1151
+ const w = /\swidth="(\d+(?:\.\d+)?)"/.exec(attrs);
1152
+ const h = /\sheight="(\d+(?:\.\d+)?)"/.exec(attrs);
1153
+ return w && h ? { w: Number(w[1]), h: Number(h[1]) } : null;
1154
+ }
1155
+ function shareable(spec) {
1156
+ if (!(spec.width > 0 && spec.height > 0) || spec.height > SHEET_MAX_HEIGHT) return null;
1157
+ const root = ROOT.exec(spec.svg);
1158
+ const own = root ? rootSize(root[1]) : null;
1159
+ if (!own || NOT_SHAREABLE.test(spec.svg)) return null;
1160
+ const ids = /* @__PURE__ */ new Set();
1161
+ for (const m of spec.svg.matchAll(/\sid="([^"]*)"/g)) {
1162
+ if (ids.has(m[1])) return null;
1163
+ ids.add(m[1]);
1164
+ }
1165
+ for (const m of spec.svg.matchAll(/url\(\s*['"]?#([^'")\s]*)|href="#([^"]*)"/g)) {
1166
+ if (!ids.has(m[1] ?? m[2])) return null;
1167
+ }
1168
+ return { spec, own, ids };
1169
+ }
1170
+ function sheetOf(run) {
1171
+ let y = 0;
1172
+ let width = 0;
1173
+ const parts = [];
1174
+ const slots = [];
1175
+ for (const { spec, own } of run) {
1176
+ parts.push(
1177
+ `<svg x="0" y="${y}" width="${spec.width}" height="${spec.height}" viewBox="0 0 ${own.w} ${own.h}" preserveAspectRatio="none">${spec.svg}</svg>`
1178
+ );
1179
+ slots.push({ spec, y });
1180
+ y += spec.height;
1181
+ width = Math.max(width, spec.width);
1182
+ }
1183
+ return {
1184
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${y}">${parts.join("")}</svg>`,
1185
+ width,
1186
+ height: y,
1187
+ slots
1188
+ };
1189
+ }
1190
+ function planRasterUnits(specs) {
1191
+ const units = [];
1192
+ let run = [];
1193
+ let runIds = /* @__PURE__ */ new Set();
1194
+ let runHeight = 0;
1195
+ const flush = () => {
1196
+ if (run.length === 1) units.push({ one: run[0].spec });
1197
+ else if (run.length > 1) units.push({ sheet: sheetOf(run) });
1198
+ run = [];
1199
+ runIds = /* @__PURE__ */ new Set();
1200
+ runHeight = 0;
1201
+ };
1202
+ for (const spec of specs) {
1203
+ const s = shareable(spec);
1204
+ if (!s) {
1205
+ flush();
1206
+ units.push({ one: spec });
1207
+ continue;
1208
+ }
1209
+ if (run.length >= SHEET_MAX_SYMBOLS || runHeight + spec.height > SHEET_MAX_HEIGHT || [...s.ids].some((id) => runIds.has(id))) {
1210
+ flush();
1211
+ }
1212
+ run.push(s);
1213
+ runHeight += spec.height;
1214
+ for (const id of s.ids) runIds.add(id);
1215
+ }
1216
+ flush();
1217
+ return units;
1218
+ }
1219
+ async function sourcesFor(unit, decoded) {
1220
+ const img = await decoded;
1221
+ if ("one" in unit) {
1222
+ return [img ? { img, y: 0, width: unit.one.width, height: unit.one.height } : null];
1223
+ }
1224
+ const { sheet } = unit;
1225
+ if (img) return sheet.slots.map(({ y }) => ({ img, y, width: sheet.width, height: sheet.height }));
1226
+ const singles = sheet.slots.map(({ spec }) => decode(spec));
1227
+ const out = [];
1228
+ for (let i = 0; i < singles.length; i++) {
1229
+ const one = await singles[i];
1230
+ const { spec } = sheet.slots[i];
1231
+ out.push(one ? { img: one, y: 0, width: spec.width, height: spec.height } : null);
1232
+ }
1233
+ return out;
1141
1234
  }
1142
1235
  function yieldToBrowser() {
1143
1236
  const scheduler = globalThis.scheduler;
@@ -1172,45 +1265,51 @@ async function rethemeSymbolImages(map, specs, opts = {}) {
1172
1265
  const total = specs.length;
1173
1266
  const result = { updated: 0, added: 0, failed: 0, aborted: false };
1174
1267
  let done = 0;
1175
- const decoded = specs.map((spec) => decode(spec));
1268
+ const units = planRasterUnits(specs);
1269
+ const decoded = units.map((unit) => decode("one" in unit ? unit.one : unit.sheet));
1176
1270
  let sliceStart = performance.now();
1177
1271
  let lastRepaint = sliceStart;
1178
- for (let i = 0; i < total; i++) {
1272
+ outer: for (let u = 0; u < units.length; u++) {
1273
+ const unit = units[u];
1274
+ const unitSpecs = "one" in unit ? [unit.one] : unit.sheet.slots.map((slot) => slot.spec);
1179
1275
  if (opts.signal?.aborted) {
1180
1276
  result.aborted = true;
1181
1277
  break;
1182
1278
  }
1183
- const spec = specs[i];
1184
- const img = await decoded[i];
1185
- if (opts.signal?.aborted) {
1186
- result.aborted = true;
1187
- break;
1188
- }
1189
- if (!img) {
1190
- result.failed++;
1191
- } else {
1192
- const pixels = rasterise(img, spec);
1193
- const have = existingSize(map, spec.id);
1194
- if (have && have.w === spec.width && have.h === spec.height) {
1195
- map.updateImage(spec.id, pixels);
1196
- result.updated++;
1279
+ const sources = await sourcesFor(unit, decoded[u]);
1280
+ for (let i = 0; i < unitSpecs.length; i++) {
1281
+ if (opts.signal?.aborted) {
1282
+ result.aborted = true;
1283
+ break outer;
1284
+ }
1285
+ const spec = unitSpecs[i];
1286
+ const src = sources[i];
1287
+ if (!src) {
1288
+ result.failed++;
1197
1289
  } else {
1198
- if (map.hasImage(spec.id)) map.removeImage(spec.id);
1199
- map.addImage(spec.id, pixels, { sdf: spec.sdf, pixelRatio: spec.pixelRatio });
1200
- result.added++;
1290
+ const px = rasterise(src, spec);
1291
+ const have = existingSize(map, spec.id);
1292
+ if (have && have.w === spec.width && have.h === spec.height) {
1293
+ map.updateImage(spec.id, px);
1294
+ result.updated++;
1295
+ } else {
1296
+ if (map.hasImage(spec.id)) map.removeImage(spec.id);
1297
+ map.addImage(spec.id, px, { sdf: spec.sdf, pixelRatio: spec.pixelRatio });
1298
+ result.added++;
1299
+ }
1300
+ opts.onApplied?.(spec.id);
1201
1301
  }
1202
- opts.onApplied?.(spec.id);
1203
- }
1204
- done++;
1205
- const now = performance.now();
1206
- if (now - sliceStart >= sliceMs) {
1207
- if (now - lastRepaint >= repaintEveryMs) {
1208
- map.triggerRepaint();
1209
- opts.onProgress?.({ done, total });
1210
- lastRepaint = now;
1302
+ done++;
1303
+ const now = performance.now();
1304
+ if (now - sliceStart >= sliceMs) {
1305
+ if (now - lastRepaint >= repaintEveryMs) {
1306
+ map.triggerRepaint();
1307
+ opts.onProgress?.({ done, total });
1308
+ lastRepaint = now;
1309
+ }
1310
+ await yieldToBrowser();
1311
+ sliceStart = performance.now();
1211
1312
  }
1212
- await yieldToBrowser();
1213
- sliceStart = performance.now();
1214
1313
  }
1215
1314
  }
1216
1315
  map.triggerRepaint();