ixmaps-gl 0.2.25 → 0.2.30

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 (3) hide show
  1. package/README.md +21 -8
  2. package/ixmaps-gl.js +253 -84
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -124,7 +124,7 @@ circle/square/diamond/triangle. See the top-of-file comment in
124
124
  A page's `.require(url)` scripts (page code, e.g. a user chart function) load in order before the
125
125
  layers are built. `ixmaps.map()` is the map handle as in flat — its calls wait until the map is ready —
126
126
  with `add(theme, flags)` / `replace` / `replaceTheme` / `remove`, `changeThemeStyle` for any style key,
127
- `getZoom()` (flat's zoom), `resize()`, `attribution(text)` (the page's attribution, bottom left on the map as in flat, in the style of the basemap credit and on its line — also
127
+ `getZoom()` (flat's zoom), `resize()`, `attribution(text)` (the page's attribution, bottom left on the map as in flat, in the style of the basemap credit and on its line, dark on a dark map like the zoom buttons — also
128
128
  `.attribution()` on the builder, the `attribution` map option and `ixmaps.setAttribution(text)`; the basemap's credit stays bottom right), `setBasemapOpacity` and `setMapType(id)` (also `setMapTypeId`, `mapType`: a basemap name reading dark/black/night/matter loads CARTO Dark Matter, any other name CARTO Positron, a color a plain background — the same rule the `mapType` option follows at start); the page's `htmlgui_onNewTheme(id)` and
129
129
  `htmlgui_onZoomAndPan()` (also once on load) are called (as in flat, a theme swapped in with `replace` comes last
130
130
  in the legend, while FEATURE and CHOROPLETH shapes always draw under the charts), and `map`, `getZoom` and flat's
@@ -224,10 +224,23 @@ but unused. It never mutates the page's own objects. Unit tests: `cd test && npm
224
224
 
225
225
  ### World copies
226
226
 
227
- At an extreme zoom-out MapLibre repeats the world side by side, and the themes are repeated on
228
- every copy (flat repeats only its basemap tiles). `.options({worldcopies: false})` draws the
229
- world once, but MapLibre then also stops the zoom-out where the world gets narrower than the
230
- window.
227
+ At an extreme zoom-out MapLibre repeats the world side by side. The themes are drawn once, as in
228
+ flat (which repeats only its basemap tiles): the map pans freely past the world's edge, with empty
229
+ space beside it. `.options({themecopies: true})` (also at runtime) repeats the themes on every world
230
+ copy. `.options({worldcopies: false})` draws the world once for the map itself, but MapLibre then
231
+ also stops the zoom-out where the world gets narrower than the window.
232
+
233
+ ### Click to action
234
+
235
+ flat's click-to-action (`ui/js/tools/tooltip_to_action.js`, e.g. a click on a country filters every
236
+ theme of the page to it) works as on flat: on a click on an item the engine calls the page's
237
+ `ixmaps.onItemAction(szId, item, themeObj)`, `item.szSelectionId` being the value of the theme's
238
+ selection field, and `ixmaps.onItemAction(null, null)` on a click on the map background. The
239
+ selection field, `getThemeObj(id).szSelectionField`, is the `id` binding, else the position (or
240
+ lookup) field; an aggregated item gives its first record's value. The flat script's own
241
+ `htmlgui_onItemClick`, which reads flat's SVG, is not called — the page only needs
242
+ `.require(".../tooltip_to_action.js")` (or its own `ixmaps.onItemAction`). The click still pins the
243
+ tooltip. See [`ports/index_embed_migration - Zeit online data - 2015-2020 - click2filter.html`](./ports/index_embed_migration%20-%20Zeit%20online%20data%20-%202015-2020%20-%20click2filter.html).
231
244
 
232
245
  ### Data formats and data.js
233
246
 
@@ -289,7 +302,7 @@ of your own project files gl can render.
289
302
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
290
303
 
291
304
  ```html
292
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.25/ixmaps-gl.js"></script>
305
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.30/ixmaps-gl.js"></script>
293
306
  ```
294
307
 
295
308
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -297,13 +310,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
297
310
  browser script, which defines the global `ixmaps`):
298
311
 
299
312
  ```html
300
- <script src="https://unpkg.com/ixmaps-gl@0.2.25/ixmaps-gl.js"></script>
313
+ <script src="https://unpkg.com/ixmaps-gl@0.2.30/ixmaps-gl.js"></script>
301
314
  ```
302
315
 
303
316
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
304
317
 
305
318
  ```html
306
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.25/ixmaps-gl.js"></script>
319
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.30/ixmaps-gl.js"></script>
307
320
  <div id="map_div" style="position:absolute;inset:0;"></div>
308
321
 
309
322
  <script>
package/ixmaps-gl.js CHANGED
@@ -100,7 +100,7 @@
100
100
  // ---------------------------------------------------------------
101
101
  // this engine's release (= package.json "version", checked by
102
102
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
103
- const IXMAPS_GL_VERSION = '0.2.25';
103
+ const IXMAPS_GL_VERSION = '0.2.30';
104
104
 
105
105
  const LIB_URLS = {
106
106
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -1201,7 +1201,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1201
1201
  if (!table || !Array.isArray(table.records) || !Array.isArray(table.fields)) {
1202
1202
  return table && typeof table.json === 'function' ? table.json() : [];
1203
1203
  }
1204
- const names = table.fields.map(f => f.id);
1204
+ // a header cell that spans lines ("Sezione (*)\nSektion", bilingual
1205
+ // exports) reads as one line, as in flat: "Sezione (*) Sektion"
1206
+ const names = table.fields.map(f => String(f.id).replace(/[\r\n]+/g, ' '));
1205
1207
  const rows = new Array(table.records.length);
1206
1208
  for (let i = 0; i < table.records.length; i++) {
1207
1209
  const rec = table.records[i], o = {};
@@ -1210,6 +1212,26 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1210
1212
  }
1211
1213
  return rows;
1212
1214
  }
1215
+ // .data({process}) on topojson/geojson, as flat: its data.js hands the
1216
+ // process function the features' properties as a Table (column(...).map,
1217
+ // addColumn, ...); the result goes back onto the same features, by row
1218
+ function processFeatureCollection(fc, dataConfig) {
1219
+ if (!dataConfig.process || !fc || !Array.isArray(fc.features) || !fc.features.length) return fc;
1220
+ requireDataJs('process');
1221
+ const headers = [];
1222
+ const seen = new Set();
1223
+ for (const f of fc.features) {
1224
+ for (const k of Object.keys(f.properties || {})) if (!seen.has(k)) { seen.add(k); headers.push(k); }
1225
+ }
1226
+ const table = new global.Data.Table().setArray([headers, ...fc.features.map(f => headers.map(h => (f.properties || {})[h]))]);
1227
+ const rows = dataTableRows(runDataProcess(table, dataConfig));
1228
+ if (rows.length !== fc.features.length) {
1229
+ console.warn(`[ixmaps-gl] .data({process}) changed the row count of ${dataConfig.type} features (${fc.features.length} → ${rows.length}); process result ignored`);
1230
+ return fc;
1231
+ }
1232
+ fc.features.forEach((f, i) => { f.properties = rows[i]; });
1233
+ return fc;
1234
+ }
1213
1235
  // parse CSV text with data.js (as flat) → a Table
1214
1236
  function dataJsCsvTable(text) {
1215
1237
  return new Promise((resolve, reject) => {
@@ -1531,8 +1553,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1531
1553
  // genuinely CSV-shaped .obj with a missing binding.position would
1532
1554
  // throw, even though this .obj was never row data in the first place.
1533
1555
  if (dataConfig.obj) {
1534
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(dataConfig.obj, dataConfig.object);
1535
- if (dataConfig.type === 'geojson') return sanitizeGeoJSON(dataConfig.obj);
1556
+ if (dataConfig.type === 'topojson') return processFeatureCollection(topojsonToFeatureCollection(dataConfig.obj, dataConfig.object), dataConfig);
1557
+ if (dataConfig.type === 'geojson') return processFeatureCollection(sanitizeGeoJSON(dataConfig.obj), dataConfig);
1536
1558
  let obj = dataConfig.obj;
1537
1559
  if (dataConfig.process) {
1538
1560
  requireDataJs('process');
@@ -1624,8 +1646,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1624
1646
  // them into a table with a geometry column, and back)
1625
1647
  const resp = await fetch(dataConfig.url);
1626
1648
  if (!resp.ok) throw new Error(`[ixmaps-gl] failed to fetch ${dataConfig.url}: ${resp.status}`);
1627
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(await resp.json(), dataConfig.object);
1628
- return sanitizeGeoJSON(await resp.json());
1649
+ if (dataConfig.type === 'topojson') return processFeatureCollection(topojsonToFeatureCollection(await resp.json(), dataConfig.object), dataConfig);
1650
+ return processFeatureCollection(sanitizeGeoJSON(await resp.json()), dataConfig);
1629
1651
  }
1630
1652
  // every other format (json, jsonstat, ndjson, rss, kml, gml, geobuf,
1631
1653
  // flatgeobuf, geopackage, parquet, ...) through data.js, as flat
@@ -3100,7 +3122,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3100
3122
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
3101
3123
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
3102
3124
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
3103
- 'borderradius', 'maxcharts', 'archeight'];
3125
+ 'borderradius', 'maxcharts', 'archeight', 'alignmargin'];
3104
3126
  function toNumberIfNumeric(v) {
3105
3127
  if (typeof v !== 'string' || !v.trim()) return v;
3106
3128
  const n = Number(v);
@@ -3592,6 +3614,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3592
3614
  // The ocean of flat's world / orthographic maps is a {type:"Sphere"} feature: the whole map disc,
3593
3615
  // filled with the layer's color. Here it is the surface of the map (the globe, or the flat
3594
3616
  // map): a background layer under everything, in that color and opacity.
3617
+ // the themes are drawn once, as in flat, even where the map repeats its world (the themecopies map option:
3618
+ // true repeats them on every world copy)
3619
+ function applyThemeCopies(map, value) {
3620
+ if (value !== true && value !== 'true') {
3621
+ if (!map.__ixOrigGetRenderWorldCopies) map.__ixOrigGetRenderWorldCopies = map.getRenderWorldCopies;
3622
+ map.getRenderWorldCopies = () => false;
3623
+ } else if (map.__ixOrigGetRenderWorldCopies) {
3624
+ map.getRenderWorldCopies = map.__ixOrigGetRenderWorldCopies;
3625
+ delete map.__ixOrigGetRenderWorldCopies;
3626
+ }
3627
+ }
3595
3628
  const SPHERE_BACKDROP_LAYER_ID = '__ixmaps_gl_sphere_backdrop';
3596
3629
  function applySphereBackdrop(map, style) {
3597
3630
  const cs = style && style.colorscheme;
@@ -3631,6 +3664,23 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3631
3664
  // basemap credit, bottom right — same line, same pill: white, 12px
3632
3665
  // radius, 12px/20px Helvetica Neue, links rgba(0,0,0,0.75)).
3633
3666
  // ---------------------------------------------------------------
3667
+ // On a dark map (the effective darkness, as the legend and the tooltip use it) the MapLibre control buttons
3668
+ // (zoom, compass) and the page's attribution are dark too: the class ixgl-dark on the map element switches them
3669
+ let _controlLookCss = false;
3670
+ function applyControlLook(el, dark) {
3671
+ if (!_controlLookCss && typeof document !== 'undefined') {
3672
+ _controlLookCss = true;
3673
+ const css = document.createElement('style');
3674
+ css.textContent = '.ixgl-dark .maplibregl-ctrl-group{background:#222!important;box-shadow:0 0 0 1px rgba(255,255,255,.15)!important}'
3675
+ + '.ixgl-dark .maplibregl-ctrl-group button+button{border-top:1px solid #444!important}'
3676
+ + '.ixgl-dark .maplibregl-ctrl-group button:not(:disabled):hover{background-color:#3a3a3a!important}'
3677
+ + '.ixgl-dark .maplibregl-ctrl-group .maplibregl-ctrl-icon{filter:invert(1)}'
3678
+ + '.ixgl-dark .ixmaps-gl-attribution{background:#222!important;color:#aaa!important}'
3679
+ + '.ixgl-dark .ixmaps-gl-attribution a{color:#ddd!important}';
3680
+ document.head.appendChild(css);
3681
+ }
3682
+ if (el && el.classList) el.classList.toggle('ixgl-dark', !!dark);
3683
+ }
3634
3684
  let _attributionCss = false;
3635
3685
  function createAttribution(map) {
3636
3686
  if (!_attributionCss && typeof document !== 'undefined') {
@@ -3937,11 +3987,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3937
3987
  // pane, the shared look (fonts included) on the container
3938
3988
  return html ? { html: tooltipPaneHtml(html), style: Object.assign({ padding: '0.5em 0.7em', maxWidth: '440px' }, tooltipLook()) } : null;
3939
3989
  }
3990
+ // flat's click-to-action hook (ui/js/tools/tooltip_to_action.js): ixmaps.onItemAction(szId, item, themeObj) on a
3991
+ // click on an item — item.szSelectionId is the value of the theme's selection field — and onItemAction(null, null)
3992
+ // on a click on the map background
3993
+ function callItemAction(rt, object) {
3994
+ const hook = pageIxmaps() && pageIxmaps().onItemAction;
3995
+ if (typeof hook !== 'function') return;
3996
+ try {
3997
+ if (!rt) { hook.call(global.ixmaps, null, null); return; }
3998
+ const field = selectionFieldOf(rt);
3999
+ const props = object.properties || {};
4000
+ // a single record, or an aggregated item's first one
4001
+ const raw = props.raw || props.firstRaw || props.titleRaw || (props.raws && props.raws[0]) || props;
4002
+ const value = field && raw ? raw[field] : null;
4003
+ if (value == null) return;
4004
+ hook.call(global.ixmaps, themeIdOf(rt) + '::' + value, { szSelectionId: String(value) }, getThemeObj(themeIdOf(rt)));
4005
+ } catch (err) { console.error('[ixmaps-gl] onItemAction failed:', err); }
4006
+ }
3940
4007
  function clickFor(picked) {
3941
4008
  const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
3942
4009
  if (info && info.object && info.layer) {
3943
4010
  const rt = findRuntimeForLayerId(info.layer.id);
3944
4011
  if (rt) {
4012
+ callItemAction(rt, info.object);
3945
4013
  // Only a Point geometry's own coordinates ARE an
3946
4014
  // [lng,lat] pair (every point/bubble/dot/chart-cluster
3947
4015
  // layer) — a CHOROPLETH polygon's geometry.coordinates is
@@ -3972,6 +4040,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3972
4040
  }
3973
4041
  }
3974
4042
  // clicked empty map space (or a non-tooltippable layer) -> unpin
4043
+ callItemAction(null, null);
3975
4044
  pinned = null;
3976
4045
  pinnedTooltipEl.style.display = 'none';
3977
4046
  return false;
@@ -5313,11 +5382,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5313
5382
  // world map can't open fully zoomed out. The deck.gl overlay takes
5314
5383
  // its `repeat` from map.getRenderWorldCopies(), so the themes are
5315
5384
  // drawn on every world copy too (flat draws them once; only its
5316
- // basemap tiles wrap). .options({worldcopies: false}) draws the
5385
+ // basemap tiles wrap) — unless themecopies is off, which is the default
5386
+ // (see applyThemeCopies). .options({worldcopies: false}) draws the
5317
5387
  // world once, at the cost of that zoom limit.
5318
5388
  renderWorldCopies: this._engineOptions.worldcopies !== false
5319
5389
  });
5320
5390
  engineOptionsByMap.set(map, this._engineOptions);
5391
+ // themecopies (default: off): the map keeps its world copies (free panning past the world's edge, empty
5392
+ // space beside it) but the deck.gl overlay, which asks map.getRenderWorldCopies() for its `repeat`, draws the
5393
+ // themes once, as flat does. .options({themecopies: true}) repeats them. MapLibre's own transform does not
5394
+ // use this method.
5395
+ applyThemeCopies(map, this._engineOptions.themecopies);
5321
5396
  sphereBackdrops.forEach(style => applySphereBackdrop(map, style));
5322
5397
  // the live-SVG pane USER charts render into (see chartHostFor) —
5323
5398
  // registered here, keyed by this map's options object, so every
@@ -5446,6 +5521,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5446
5521
  // the page's attribution (.attribution(), Map option attribution)
5447
5522
  const attributionBox = createAttribution(map);
5448
5523
  attributionBox.set(this._attributionText != null ? this._attributionText : this.mapOptions.attribution);
5524
+ // dark controls on a dark map; re-read when a style (basemap) loads and when basemapopacity is set
5525
+ const refreshControlLook = () => {
5526
+ const o = parseFloat(this._engineOptions.basemapopacity);
5527
+ const legendBackground = this.mapOptions.legendBackground || this.mapOptions.legendbackground;
5528
+ applyControlLook(el, typeof this.__effectiveDark === 'function'
5529
+ ? this.__effectiveDark(isNaN(o) ? 1 : o, this.mapOptions.mapType, legendBackground) : false);
5530
+ };
5531
+ this._refreshControlLook = refreshControlLook;
5532
+ refreshControlLook();
5533
+ map.on('style.load', refreshControlLook);
5449
5534
  this._setAttribution = text => attributionBox.set(text);
5450
5535
 
5451
5536
  if (this._legendHtml) {
@@ -5586,18 +5671,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5586
5671
  add: (layerBuilder, flag) => {
5587
5672
  layerBuilder = unwrapLayer(layerBuilder);
5588
5673
  _chainedLayers = _chainedLayers.then(() => {
5674
+ let slot = null;
5589
5675
  if (/replace/i.test(flag || '')) {
5590
5676
  const def = layerBuilder.definition();
5591
5677
  const id = (def.style && def.style.name) || (def.meta && def.meta.name);
5592
- if (id) removeThemeById(id);
5678
+ if (id) { slot = captureSlot(id); removeThemeById(id); }
5593
5679
  }
5594
- return engineApi.defineLayer(layerBuilder);
5680
+ return engineApi.defineLayer(layerBuilder, slot);
5595
5681
  }).catch(err => console.error('[ixmaps-gl] map.add():', err));
5596
5682
  return engineApi;
5597
5683
  },
5598
5684
  replace: (id, layerBuilder, flag) => {
5599
5685
  layerBuilder = unwrapLayer(layerBuilder);
5600
- _chainedLayers = _chainedLayers.then(() => { removeThemeById(id); return engineApi.defineLayer(layerBuilder); })
5686
+ _chainedLayers = _chainedLayers.then(() => { const slot = captureSlot(id); removeThemeById(id); return engineApi.defineLayer(layerBuilder, slot); })
5601
5687
  .catch(err => console.error('[ixmaps-gl] map.replace():', err));
5602
5688
  return engineApi;
5603
5689
  },
@@ -5612,11 +5698,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5612
5698
  layerBuilder = unwrapLayer(layerBuilder);
5613
5699
  const f = String(flag || '');
5614
5700
  _chainedLayers = _chainedLayers.then(() => {
5701
+ let slot = null;
5615
5702
  if (!/force/i.test(f)) {
5616
5703
  if (/replace/i.test(f)) {
5617
5704
  const def = layerBuilder.definition();
5618
5705
  const id = (def.style && def.style.name) || (def.meta && def.meta.name);
5619
- if (id) removeThemeById(id);
5706
+ if (id) { slot = captureSlot(id); removeThemeById(id); }
5620
5707
  } else if (/clearcharts|clear|clearall/i.test(f)) {
5621
5708
  const charts = /clearcharts/i.test(f);
5622
5709
  const doomed = runtimes.filter(rt => !flatFlag(rt.flags, 'LOCKED') && (!charts || rt.flags.has('CHART')));
@@ -5625,7 +5712,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5625
5712
  byName.forEach((list, name) => removeRuntimes(name, list));
5626
5713
  }
5627
5714
  }
5628
- return engineApi.defineLayer(layerBuilder);
5715
+ return engineApi.defineLayer(layerBuilder, slot);
5629
5716
  }).catch(err => console.error('[ixmaps-gl] map.newTheme():', err));
5630
5717
  return engineApi;
5631
5718
  },
@@ -5857,7 +5944,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5857
5944
  // exactly how this engine's own facet-filter/style setters below
5858
5945
  // already push responsibility for "what should happen" to the
5859
5946
  // caller rather than guessing.
5860
- defineLayer: (layerBuilder) => defineFromDefinition(unwrapLayer(layerBuilder).definition()),
5947
+ defineLayer: (layerBuilder, slot) => defineFromDefinition(unwrapLayer(layerBuilder).definition(), slot),
5861
5948
  // GL-PORT COMPAT: real ixmaps-flat's map handle, as a page gets it
5862
5949
  // from `myMap.then(map => map.view(...).options(...).layer(a)
5863
5950
  // .layer(b))` — the common multi-layer idiom — is chainable on the
@@ -5879,6 +5966,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5879
5966
  if (o && typeof o === 'object') {
5880
5967
  Object.assign(builder._engineOptions, o);
5881
5968
  // read only when the MapLibre map is created — switched here at runtime
5969
+ if ('basemapopacity' in o && builder._refreshControlLook) builder._refreshControlLook();
5970
+ if ('themecopies' in o) { applyThemeCopies(map, o.themecopies); map.triggerRepaint(); }
5882
5971
  if ('worldcopies' in o) map.setRenderWorldCopies(o.worldcopies !== false && o.worldcopies !== 'false');
5883
5972
  refresh();
5884
5973
  }
@@ -6017,6 +6106,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6017
6106
  if (removed) { refresh(); notifyRedraw(); }
6018
6107
  return removed;
6019
6108
  }
6109
+ // where the theme(s) removeThemeById(id) would remove stand in the stack: the first remaining
6110
+ // theme after them (null = they were on top). Taken BEFORE the removal, handed to defineLayer.
6111
+ function captureSlot(id) {
6112
+ const named = runtimes.filter(r => (r.style && r.style.name === id) || (r.meta && r.meta.name === id));
6113
+ const doomed = named.length ? named : runtimes.filter(r => r.name === id);
6114
+ if (!doomed.length) return null;
6115
+ const last = Math.max(...doomed.map(r => runtimes.indexOf(r)));
6116
+ const anchor = runtimes.slice(last + 1).find(r => !doomed.includes(r) && !doomed.some(d => d._subTheme === r)) || null;
6117
+ return { anchor };
6118
+ }
6119
+
6020
6120
  // flat addresses a theme by its id — style.name / meta.name (several
6021
6121
  // themes may share one layer), else the layer name
6022
6122
  function removeThemeById(id) {
@@ -6187,11 +6287,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6187
6287
  // flat itself does ("defining a theme with the same name replaces it
6188
6288
  // in place"; a page's live-refresh loop relies on it, e.g. the NYC
6189
6289
  // status page re-defining its two themes every 10s).
6190
- async function defineFromDefinition(def) {
6290
+ // `slot` (from captureSlot) is where a replaced theme stood in the stack: the replace paths
6291
+ // remove the old theme first and pass it on, so the new one is not appended on top
6292
+ async function defineFromDefinition(def, slot) {
6191
6293
  if (validation) validation.definition(def);
6192
6294
  const pdef = projectThemeToDefinition(def);
6193
6295
  const replacedId = (pdef.meta && pdef.meta.name) || (pdef.style && pdef.style.name);
6194
- if (replacedId) removeThemeById(replacedId);
6296
+ if (replacedId) {
6297
+ if (!slot) slot = captureSlot(replacedId);
6298
+ removeThemeById(replacedId);
6299
+ }
6195
6300
  let spec = normalizeTheme(pdef);
6196
6301
  let raw;
6197
6302
  const deferred = deferredFeatureLoad(spec, map.getZoom());
@@ -6218,7 +6323,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6218
6323
  rt._zoomToPending = wantsZoomToExtent(spec.flags);
6219
6324
  rt._dataSourceKey = named ? 'name:' + spec.data.name
6220
6325
  : sourceKey || JSON.stringify({ url: spec.data && spec.data.url, urls: spec.data && spec.data.urls, type: spec.data && spec.data.type, query: spec.data && spec.data.query, obj: !!(spec.data && spec.data.obj) });
6221
- runtimes.push(rt);
6326
+ // a replacement keeps the stacking position (draw order, legend order) of the theme it replaces:
6327
+ // it goes in front of the theme that followed the old one, still on the map after the await above
6328
+ const slotAt = slot && slot.anchor ? runtimes.indexOf(slot.anchor) : -1;
6329
+ if (slotAt >= 0) runtimes.splice(slotAt, 0, rt); else runtimes.push(rt);
6222
6330
  if (fc._sphere) applySphereBackdrop(map, rt.style);
6223
6331
  _globalThemeRegistry.set(rt.name, rt);
6224
6332
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
@@ -6268,10 +6376,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6268
6376
  for (let i = 0; i < themes.length; i++) {
6269
6377
  const t = themes[i] || {};
6270
6378
  const name = (t.style && t.style.name) || (t.meta && t.meta.name);
6271
- if (/replace/i.test(f) && name) removeThemesNamed(name);
6379
+ let slot = null;
6380
+ if (/replace/i.test(f) && name) { slot = captureSlot(name); removeThemesNamed(name); }
6272
6381
  else if (i === 0 && !/add/i.test(f)) [...new Set(runtimes.map(r => r.name))].forEach(n => engineApi.removeTheme(n));
6273
6382
  try {
6274
- report.themes.push(await defineFromDefinition(withoutProjectCode(t, report)));
6383
+ report.themes.push(await defineFromDefinition(withoutProjectCode(t, report), slot));
6275
6384
  } catch (e) {
6276
6385
  report.skipped.push({ layer: t.layer, reason: e.message });
6277
6386
  }
@@ -7409,7 +7518,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7409
7518
  : 1;
7410
7519
  }
7411
7520
 
7412
- function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue) {
7521
+ function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue, sizeBound) {
7413
7522
  const zoomFactor = objectZoomFactor(zoom, mapOptions);
7414
7523
  // FIXSIZE (a symbol chart; PLOT / GRIDSIZE size their markers
7415
7524
  // themselves): one radius for every item, whatever its value — the
@@ -7452,7 +7561,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7452
7561
  if (flags && flags.has('SYMBOL') && !flags.has('BUBBLE') && !flags.has('USER')
7453
7562
  && !flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')
7454
7563
  && flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP')
7455
- && !style.sizefield) {
7564
+ && !style.sizefield && !sizeBound) {
7456
7565
  const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1);
7457
7566
  return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
7458
7567
  }
@@ -7825,6 +7934,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7825
7934
  return out;
7826
7935
  }
7827
7936
 
7937
+ // the outline colour of a flat bubble icon: none for NOLINES / linecolor "none", else linecolor, else the
7938
+ // class colour's darker derivate — and none when there is neither (an unfilled "none" class has no colour
7939
+ // to derive from; with a linecolor its outline must still draw)
7940
+ function bubbleStrokeRgb(look, rgb) {
7941
+ return look.noStroke ? null : (look.stroke || (rgb ? flatDerivateRgb(rgb, 0.7) : null));
7942
+ }
7943
+
7828
7944
  // ChartColors.textColor (colorscheme.js 104-116): darker on light colors
7829
7945
  function flatChartTextRgb(rgb) {
7830
7946
  return flatDerivateRgb(rgb, rgb[0] + rgb[1] + rgb[2] > 450 ? 0.6 : 3);
@@ -12103,6 +12219,91 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12103
12219
  return Array.isArray(cs) && cs.length === 1 && String(cs[0]).trim().toLowerCase() === 'none';
12104
12220
  }
12105
12221
 
12222
+ // an external symbol file (.svg/.png/.jpeg) named by `symbols` — flat draws it as an
12223
+ // <image> (maptheme.js 21743, 22168), not as the chart's own shape. null for a shape name
12224
+ _urlSymbolOf(sym) {
12225
+ if (typeof sym !== 'string') return null;
12226
+ const s = sym.trim();
12227
+ // a data: URI is a symbol file too, and the only kind fetch() can read on a file:// page
12228
+ return /\.(svg|png|jpe?g)([?#]|$)/i.test(s) || /^data:image\/(svg\+xml|png|jpe?g)[;,]/i.test(s) ? s : null;
12229
+ }
12230
+
12231
+ // the symbol file as an IconLayer icon descriptor, NOT tinted (flat doesn't recolor an
12232
+ // <image> either); fetched and rasterized once per URL, shared by SEQUENCE and plain symbols
12233
+ _urlSymbolIcon(u) {
12234
+ if (!this._urlIconCache) this._urlIconCache = new Map();
12235
+ if (!this._urlIconCache.has(u)) {
12236
+ // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
12237
+ // URL: deck.gl would immediately try to load that itself, and an
12238
+ // SVG without natural dimensions (a viewBox only, the bus
12239
+ // pictogram of the City-moving page) makes Chrome's
12240
+ // createImageBitmap throw — a console error and a part that
12241
+ // never renders. The real raster replaces it below
12242
+ const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
12243
+ this._urlIconCache.set(u, entry);
12244
+ // fetch the symbol ONCE and rasterize it into the descriptor's
12245
+ // own 256px cell here: deck then always sees exactly the declared
12246
+ // dimensions, and dimension-less SVGs work like any other. The
12247
+ // redraw lets the next getIcon see the patched descriptor
12248
+ fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
12249
+ if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
12250
+ if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
12251
+ const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
12252
+ const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
12253
+ if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
12254
+ text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
12255
+ }
12256
+ }
12257
+ const img = new Image();
12258
+ img.onload = () => resolve(img);
12259
+ img.onerror = () => reject(new Error('svg decode failed'));
12260
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
12261
+ })).then(img => {
12262
+ const size = entry.width;
12263
+ const canvas = document.createElement('canvas');
12264
+ canvas.width = size; canvas.height = size;
12265
+ const ctx = canvas.getContext('2d');
12266
+ const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
12267
+ const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
12268
+ ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
12269
+ entry.url = canvas.toDataURL('image/png');
12270
+ entry.id = 'seq-sym-raster:' + u;
12271
+ if (this._triggerRedraw) this._triggerRedraw();
12272
+ }).catch(() => {
12273
+ // a symbol FILE of a file:// page can't be fetched (nor loaded by deck): say so once
12274
+ let onDisk = false;
12275
+ try { onDisk = new URL(u, location.href).protocol === 'file:'; } catch (e) { /* not a URL */ }
12276
+ if (onDisk && !this._warnedFileSymbol) {
12277
+ this._warnedFileSymbol = true;
12278
+ console.warn('[ixmaps-gl] symbol file "' + u + '" cannot be loaded from a file:// page — serve the page over http(s) or give the symbol as a data: URI');
12279
+ }
12280
+ // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
12281
+ // it from here — and a pre-sized SVG that failed to rasterize
12282
+ // gets that same second chance
12283
+ entry.url = u;
12284
+ entry.id = 'seq-sym-natural:' + u;
12285
+ if (this._triggerRedraw) this._triggerRedraw();
12286
+ });
12287
+ }
12288
+ return this._urlIconCache.get(u);
12289
+ }
12290
+
12291
+ // a size field is bound (flat's szSizeField): .binding({size}) or style.sizefield, live —
12292
+ // setSizeField() rebinds at runtime. '$item$' is not a field
12293
+ _sizeBound() { return !!(this.binding.size && this.binding.size !== '$item$'); }
12294
+
12295
+ // the symbol file of a plain (non-SEQUENCE) item, or null: the same class→symbol /
12296
+ // symbolfield mapping as _resolveSymbolShape, before it folds unknown names to 'circle'
12297
+ _resolveSymbolUrl(props) {
12298
+ const s = this.style.symbols;
12299
+ if (!Array.isArray(s)) return null;
12300
+ if (!this.style.symbolfield) {
12301
+ const cat = props && props.cat != null ? props.cat : 0;
12302
+ return this._urlSymbolOf(s[Math.min(cat, s.length - 1)]);
12303
+ }
12304
+ return this._urlSymbolOf(this._resolveSymbolShape(props));
12305
+ }
12306
+
12106
12307
  _buildSingleIcon(colorRgb, opacity, shape, borderColorRgb, borderWidth, borderOpacity = 1) {
12107
12308
  if (!colorRgb) { colorRgb = [128, 128, 128]; opacity = 0; } // no class colour (["none"]): unfilled
12108
12309
  shape = shape || 'circle';
@@ -13681,7 +13882,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13681
13882
  // the item's class (range classes set it for groups too — a negative
13682
13883
  // group total would make dominant() pick the wrong class)
13683
13884
  const cat = d.properties.cat != null ? d.properties.cat : (d.properties.counts ? dominant(d.properties.counts) : 0);
13684
- const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
13885
+ const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound());
13685
13886
  const size = radiusPx / pxPerUnit;
13686
13887
  const chart = this._userChartSvg(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d));
13687
13888
  if (!chart) continue;
@@ -14321,7 +14522,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14321
14522
  // GRIDSIZE: every chart the size of its grid cell, whatever its value
14322
14523
  // (_gridChartRadiusPx); otherwise valueRadius
14323
14524
  const gridR = this._gridChartRadiusPx(zoom, liveZoom);
14324
- const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue));
14525
+ const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound()));
14325
14526
  // AGGREGATE sums without a normalsizevalue: flat's default is the
14326
14527
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
14327
14528
  // sizes depend on the aggregation)
@@ -14641,7 +14842,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14641
14842
  if (!flatLook) return this._buildSingleIcon(rgb, fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx);
14642
14843
  const look = flatLook(d);
14643
14844
  return this._buildSingleIcon(rgb, look.fillOpacity, shapeOf(d.properties),
14644
- look.noStroke || !rgb ? null : (look.stroke || flatDerivateRgb(rgb, 0.7)), look.width, look.strokeOpacity);
14845
+ bubbleStrokeRgb(look, rgb), look.width, look.strokeOpacity);
14645
14846
  };
14646
14847
  // flat's symbol outline (maptheme.js 21595-21610 and 21950-21997,
14647
14848
  // drawn 21625-21634 / 21995-22035): every SYMBOL shape is stroked,
@@ -14676,7 +14877,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14676
14877
  // routing goes by the final id (layerRuntimeById). A symbol's outline
14677
14878
  // is centered on its radius (flat); BUBBLE's border keeps the
14678
14879
  // icon's look, inside the radius
14679
- if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle')) {
14880
+ if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle' && !this._resolveSymbolUrl(d.properties))) {
14680
14881
  const radiusPx = d => vRadius(sizeValueOf(d));
14681
14882
  // a CATEGORICAL BUBBLE: flat's look (flatBubbleLook) in screen px,
14682
14883
  // its stroke centered on the radius as flat's SVG circle
@@ -14705,6 +14906,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14705
14906
  getPosition: d => d.geometry.coordinates,
14706
14907
  getIcon: d => {
14707
14908
  if (!singleSymbol(d)) return this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null, flatLook && flatLook(d));
14909
+ const symbolUrl = this._resolveSymbolUrl(d.properties);
14910
+ if (symbolUrl) return this._urlSymbolIcon(symbolUrl);
14708
14911
  if (flatLook) return singleIcon(d);
14709
14912
  const shape = shapeOf(d.properties), rgb = outlineRgb(d);
14710
14913
  if (!symbolOutline || !rgb) return this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shape, rgb, rgb ? singleBorderWidthPx : 0);
@@ -14994,60 +15197,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14994
15197
  // it itself (its ImageBitmap loader falls back to <img>, which
14995
15198
  // rasterizes SVG) — NOT tinted: these files carry their own colors,
14996
15199
  // and flat doesn't recolor an <image> either
14997
- const urlSymbolOf = i => {
14998
- const s = symbols && (symbols[i] || symbols[0]);
14999
- return typeof s === 'string' && /\.(svg|png|jpe?g)([?#]|$)/i.test(s.trim()) ? s.trim() : null;
15000
- };
15001
- if (!this._urlIconCache) this._urlIconCache = new Map();
15002
- const urlIcon = u => {
15003
- if (!this._urlIconCache.has(u)) {
15004
- // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
15005
- // URL: deck.gl would immediately try to load that itself, and an
15006
- // SVG without natural dimensions (a viewBox only, the bus
15007
- // pictogram of the City-moving page) makes Chrome's
15008
- // createImageBitmap throw — a console error and a part that
15009
- // never renders. The real raster replaces it below
15010
- const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
15011
- this._urlIconCache.set(u, entry);
15012
- // fetch the symbol ONCE and rasterize it into the descriptor's
15013
- // own 256px cell here: deck then always sees exactly the declared
15014
- // dimensions, and dimension-less SVGs work like any other. The
15015
- // redraw lets the next getIcon see the patched descriptor
15016
- fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
15017
- if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
15018
- if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
15019
- const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
15020
- const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
15021
- if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
15022
- text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
15023
- }
15024
- }
15025
- const img = new Image();
15026
- img.onload = () => resolve(img);
15027
- img.onerror = () => reject(new Error('svg decode failed'));
15028
- img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
15029
- })).then(img => {
15030
- const size = entry.width;
15031
- const canvas = document.createElement('canvas');
15032
- canvas.width = size; canvas.height = size;
15033
- const ctx = canvas.getContext('2d');
15034
- const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
15035
- const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
15036
- ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
15037
- entry.url = canvas.toDataURL('image/png');
15038
- entry.id = 'seq-sym-raster:' + u;
15039
- if (this._triggerRedraw) this._triggerRedraw();
15040
- }).catch(() => {
15041
- // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
15042
- // it from here — and a pre-sized SVG that failed to rasterize
15043
- // gets that same second chance
15044
- entry.url = u;
15045
- entry.id = 'seq-sym-natural:' + u;
15046
- if (this._triggerRedraw) this._triggerRedraw();
15047
- });
15048
- }
15049
- return this._urlIconCache.get(u);
15050
- };
15200
+ const urlSymbolOf = i => this._urlSymbolOf(symbols && (symbols[i] || symbols[0]));
15201
+ const urlIcon = u => this._urlSymbolIcon(u);
15051
15202
  const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
15052
15203
  const colorOf = i => this.classRgb(i);
15053
15204
  const allParts = [], allGlows = [];
@@ -15077,7 +15228,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15077
15228
  const sizeFactor = itemSizeFactor(d);
15078
15229
  const partRadius = multi
15079
15230
  ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow) * sizeFactor)
15080
- : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
15231
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax, this._sizeBound());
15081
15232
  let layout = rings ? ringsLayout(counts, partRadius)
15082
15233
  : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
15083
15234
  if (!layout.length) continue;
@@ -16631,7 +16782,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16631
16782
  // flat's chart position for this build (flatChartAlignOffset): a
16632
16783
  // function item → pixel offset, the same for every part of one chart
16633
16784
  // (symbol, glow, shadow, box, title, value text). unit is flat's
16634
- // normalX(1) on screen, the object zoom × style.scale.
16785
+ // normalX(1) on screen, the object zoom × style.scale. flat keeps a margin of 5 units between the
16786
+ // position and the chart's facing edge for left/right/top/bottom; the gl-only style.alignmargin
16787
+ // changes it (0: the edge touches the position, so a left and a right chart touch each other)
16635
16788
  _chartAlignFn(liveZoom, radiusOf, textOnly = false) {
16636
16789
  const st = this.style;
16637
16790
  const none = () => [0, 0];
@@ -16640,12 +16793,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16640
16793
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
16641
16794
  const symbol = flatChartBranch(this.flags) === 'symbol';
16642
16795
  const normalR = NORMAL_RADIUS_PX * unit; // flat's normalX(chart size / 2)
16796
+ const margin = Number.isFinite(styleNum(st.alignmargin)) ? styleNum(st.alignmargin) : 5;
16643
16797
  const cache = new Map();
16644
16798
  const fn = d => {
16645
16799
  let v = cache.get(d);
16646
16800
  if (v) return v;
16647
16801
  const r = radiusOf(d);
16648
- v = flatChartAlignOffset(st, { unit, symbolScale: symbol ? r / (2 * normalR) : 1, half: symbol ? normalR : r, orig: r + (textOnly ? 0 : 5 * unit) });
16802
+ v = flatChartAlignOffset(st, { unit, symbolScale: symbol ? r / (2 * normalR) : 1, half: symbol ? normalR : r, orig: r + (textOnly ? 0 : margin * unit) });
16649
16803
  cache.set(d, v);
16650
16804
  return v;
16651
16805
  };
@@ -16824,7 +16978,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16824
16978
  const textOnly = this.flags.has('TEXTONLY');
16825
16979
  const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
16826
16980
  if (textOnly && !showValues) return [];
16827
- const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
16981
+ const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound());
16828
16982
  const alignOf = this._chartAlignFn(liveZoom, radiusOf, textOnly);
16829
16983
  const valueScale = styleNum(this.style.valuescale) || 1;
16830
16984
  const textOverride = this.style.valuecolor || this.style.textcolor;
@@ -17385,6 +17539,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17385
17539
  // several themes of a page
17386
17540
  szId: themeIdOf(rt),
17387
17541
  szName: rt.name,
17542
+ // flat's szSelectionField: the field a click on an item selects by (the id binding, else the field the
17543
+ // item is positioned by)
17544
+ szSelectionField: selectionFieldOf(rt),
17388
17545
  // the filter in force: a runtime one (changeThemeStyle "filter")
17389
17546
  // replaces the definition's
17390
17547
  szFilter: rt._runtimeFilterExpr || rt._filterExpr || '',
@@ -17421,6 +17578,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17421
17578
  get itemA() { return onMap().itemA; }
17422
17579
  };
17423
17580
  }
17581
+ // the field whose value identifies an item on a click (flat's szSelectionField): binding.id, else the position field
17582
+ // (which a join to a geometry layer turns into binding.lookup)
17583
+ function selectionFieldOf(rt) {
17584
+ const b = rt.binding || {};
17585
+ const f = b.id || b.position || b.lookup;
17586
+ return typeof f === 'string' && f && f !== 'geometry' && !/^geometry$/i.test(f) ? f : null;
17587
+ }
17424
17588
  const _warnedOnce = new Set();
17425
17589
  function warnOnce(key, msg) { if (!_warnedOnce.has(key)) { _warnedOnce.add(key); console.info(msg); } }
17426
17590
  // the theme's records as flat's dbFields/dbRecords (cached per data)
@@ -17852,6 +18016,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17852
18016
  function compatRemoveTheme(...a) {
17853
18017
  return _mapHandle.remove(a.length >= 2 ? a[1] : a[0]);
17854
18018
  }
18019
+ // flat's ixmaps.toggleTheme(szMap, szTheme) (htmlgui.js): show / hide a theme by its id, e.g. a page's on / off buttons
18020
+ function compatToggleTheme(...a) {
18021
+ new themeApi(null, a.length >= 2 ? a[1] : a[0]).toggle();
18022
+ }
17855
18023
  function compatSetBasemapOpacity(...a) {
17856
18024
  if (a.length >= 3) {
17857
18025
  const api = (a[0] && _mapApis[a[0]]) || _lastMapApi;
@@ -17887,6 +18055,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17887
18055
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
17888
18056
  changeThemeStyle: compatChangeThemeStyle,
17889
18057
  removeTheme: compatRemoveTheme,
18058
+ toggleTheme: compatToggleTheme,
17890
18059
  // flat's ixmaps.setLocalString(orig, local) (htmlgui.js 3814 →
17891
18060
  // Api.setLocalString → Dictionary.replace): a page-wide translation,
17892
18061
  // e.g. the migration page's ISO code → country name; usable before
@@ -17935,7 +18104,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17935
18104
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
17936
18105
  fetchLayerData, QUERY_HANDOFF, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, explicitRangeBreaks, explicitRangeParts, resolvePartsClass, isNoneColor, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
17937
18106
  applyWhereFilter, joinChartPositions, joinChoroplethFeatures, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
17938
- drawSymbolPath, normalizeSymbolShape,
18107
+ drawSymbolPath, normalizeSymbolShape, bubbleStrokeRgb,
17939
18108
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
17940
18109
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn, applySphereBackdrop,
17941
18110
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.25",
3
+ "version": "0.2.30",
4
4
  "description": "MapLibre GL + deck.gl reimplementation of the ixmaps-flat declarative mapping engine",
5
5
  "main": "ixmaps-gl.js",
6
6
  "browser": "ixmaps-gl.js",