ixmaps-gl 0.2.3 → 0.2.5

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 +4 -4
  2. package/ixmaps-gl.js +217 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -193,7 +193,7 @@ of your own project files gl can render.
193
193
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
194
194
 
195
195
  ```html
196
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.3/ixmaps-gl.js"></script>
196
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.5/ixmaps-gl.js"></script>
197
197
  ```
198
198
 
199
199
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -201,13 +201,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
201
201
  browser script, which defines the global `ixmaps`):
202
202
 
203
203
  ```html
204
- <script src="https://unpkg.com/ixmaps-gl@0.2.3/ixmaps-gl.js"></script>
204
+ <script src="https://unpkg.com/ixmaps-gl@0.2.5/ixmaps-gl.js"></script>
205
205
  ```
206
206
 
207
207
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
208
208
 
209
209
  ```html
210
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.3/ixmaps-gl.js"></script>
210
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.5/ixmaps-gl.js"></script>
211
211
  <div id="map_div" style="position:absolute;inset:0;"></div>
212
212
 
213
213
  <script>
@@ -273,7 +273,7 @@ For checking page files before they run, use the static checker in ixmaps-gramma
273
273
  | [`mappa_stranieri_30.html`](./stage/mappa_stranieri_30.html) | Symbol shapes, city picker, runtime `changeThemeStyle` filtering, light legend theme |
274
274
  | [`roma_incidenti_pericolosita_sidebar_gl.html`](./stage/roma_incidenti_pericolosita_sidebar_gl.html) | AGGREGATE on a gridwidth grid, facets sidebar |
275
275
  | [`roma_incidenti_pericolosita_sidebar.html`](./stage/roma_incidenti_pericolosita_sidebar.html) | The same page on the **real** ixmaps-flat engine, kept for side-by-side reference |
276
- | [`uk_collisions_2023.html`](./stage/uk_collisions_2023.html), [`ixmaps-loader_70.html`](./stage/ixmaps-loader_70.html) | Additional real-page compatibility tests |
276
+ | [`uk_collisions_2023.html`](./stage/uk_collisions_2023.html), [`app/ixmaps-loader_70.html`](./app/ixmaps-loader_70.html) | Additional real-page compatibility tests |
277
277
  | [`examples/`](./examples) | Smaller feature-by-feature test pages (choropleth classing modes, bubble ranges, etc.) |
278
278
 
279
279
  Serve any of these with a static file server (they fetch remote data over HTTPS, so
package/ixmaps-gl.js CHANGED
@@ -80,7 +80,7 @@
80
80
  // ---------------------------------------------------------------
81
81
  // this engine's release (= package.json "version", checked by
82
82
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
83
- const IXMAPS_GL_VERSION = '0.2.3';
83
+ const IXMAPS_GL_VERSION = '0.2.5';
84
84
 
85
85
  const LIB_URLS = {
86
86
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -306,7 +306,7 @@
306
306
  // crafted link must not be able to load arbitrary code into the page.
307
307
  // ---------------------------------------------------------------
308
308
  // v0.1.6: knows this engine's aliases, lookupfield rule, meta↔style, builder methods, project loading
309
- const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.20/dist/validate.mjs';
309
+ const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.22/dist/validate.mjs';
310
310
  // a page may set ixmaps.validate BEFORE loading this script — the
311
311
  // `global.ixmaps = {...}` export below would otherwise overwrite it
312
312
  const _preloadValidate = global.ixmaps && global.ixmaps.validate;
@@ -587,6 +587,78 @@
587
587
  }
588
588
  return null;
589
589
  }
590
+ // ---------------------------------------------------------------
591
+ // flat's data cache (maptheme.js 2457-2500, themeDataCacheA): data once
592
+ // loaded from a source is reused by every later theme of the map with the
593
+ // same source — data.cache (also style.datacache, see the dbtable* table)
594
+ // defaults to true (fDataCache, maptheme.js 7736); cache: false reloads.
595
+ // The key is the fetch identity PLUS the shape the binding asks for:
596
+ // rowsResult() turns the same CSV rows into a Table (lookup only) or a
597
+ // FeatureCollection (position), so two themes reading one URL differently
598
+ // must not share one result.
599
+ // ---------------------------------------------------------------
600
+ // the data source itself (no shape) — also rt._dataSourceKey, which tells the
601
+ // facet filters which themes share one dataset (setFacetFilter)
602
+ function dataSourceKey(data) {
603
+ const d = data || {};
604
+ return JSON.stringify({ url: d.url, urls: d.urls, type: d.type, query: d.query,
605
+ process: d.process && String(d.process), ext: d.ext, name: d.ext ? d.name : undefined });
606
+ }
607
+ function dataCacheKey(data, binding) {
608
+ const b = binding || {};
609
+ return dataSourceKey(data) + '|' + (b.lookup && !b.position ? 'table' : 'pos:' + (b.position || ''));
610
+ }
611
+ function dataCacheDisabled(dataConfig) {
612
+ const c = dataConfig && dataConfig.cache;
613
+ return c === false || (typeof c === 'string' && c.trim().toLowerCase() === 'false');
614
+ }
615
+ // cache: Map key → Promise; a failed load is not kept (the next theme tries again)
616
+ function cachedLayerData(cache, data, binding, engineOptions, { reload = false } = {}) {
617
+ const key = dataCacheKey(data, binding);
618
+ if (reload || dataCacheDisabled(data) || !cache.has(key)) {
619
+ const p = fetchLayerData(data, binding, engineOptions);
620
+ cache.set(key, p);
621
+ p.catch(() => { if (cache.get(key) === p) cache.delete(key); });
622
+ }
623
+ return { key, sourceKey: dataSourceKey(data), promise: cache.get(key) };
624
+ }
625
+
626
+ // ---------------------------------------------------------------
627
+ // SHOW / ZOOMTO (flat maptheme.js 8714-8740, MapTheme.zoomTo 15664): once
628
+ // a theme is drawn, the map zooms to the extent of its items, then the
629
+ // flag is dropped — once per theme definition, not on every redraw.
630
+ // featuresBounds: [[west, south], [east, north]] of every coordinate, or
631
+ // null when there is none.
632
+ // ---------------------------------------------------------------
633
+ function featuresBounds(features) {
634
+ let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
635
+ const visit = c => {
636
+ if (!Array.isArray(c)) return;
637
+ if (typeof c[0] === 'number' && typeof c[1] === 'number') {
638
+ if (!isFinite(c[0]) || !isFinite(c[1]) || Math.abs(c[1]) > 90) return;
639
+ if (c[0] < w) w = c[0]; if (c[0] > e) e = c[0];
640
+ if (c[1] < s) s = c[1]; if (c[1] > n) n = c[1];
641
+ return;
642
+ }
643
+ for (const x of c) visit(x);
644
+ };
645
+ const geom = g => {
646
+ if (!g) return;
647
+ if (g.type === 'GeometryCollection') (g.geometries || []).forEach(geom);
648
+ else visit(g.coordinates);
649
+ };
650
+ for (const f of features || []) geom(f && f.geometry);
651
+ return w === Infinity ? null : [[w, s], [e, n]];
652
+ }
653
+ // the `legend` map option as flat reads it (htmlgui_flat.js 748-794): on whenever given
654
+ // and not false/"false"/0
655
+ function legendIsOn(opt) {
656
+ return opt !== undefined && opt !== null && opt !== false && opt !== 'false' && opt !== 0 && opt !== '0';
657
+ }
658
+ function wantsZoomToExtent(flags) {
659
+ return !!flags && (flags.has('SHOW') || flags.has('ZOOMTO'));
660
+ }
661
+
590
662
  function rowsResult(rows, binding) {
591
663
  if (binding && binding.lookup && !binding.position) return { type: 'Table', rows };
592
664
  const position = (binding && binding.position) || geometryField(rows);
@@ -1994,7 +2066,7 @@
1994
2066
  // edit it by hand; `npm run unit` fails when it drifts.
1995
2067
  // ---------------------------------------------------------------
1996
2068
  // <grammar:binding-aliases>
1997
- // generated from ixmaps-grammar 0.1.20 (ixmaps-flat 1.0.41, eaaf2b7 2026-09-20) — 52 aliases
2069
+ // generated from ixmaps-grammar 0.1.22 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
1998
2070
  const FLAT_BINDING_ALIASES = {
1999
2071
  "aggregation": "style.aggregationfield",
2000
2072
  "aggregationfield": "style.aggregationfield",
@@ -2056,7 +2128,7 @@
2056
2128
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2057
2129
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2058
2130
  // <grammar:meta-keys>
2059
- // generated from ixmaps-grammar 0.1.20 — 5 meta keys
2131
+ // generated from ixmaps-grammar 0.1.22 — 5 meta keys
2060
2132
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2061
2133
  // </grammar:meta-keys>
2062
2134
 
@@ -2653,7 +2725,9 @@
2653
2725
  // ones. Confirmed live as a real, not just theoretical, problem:
2654
2726
  // without this, a 3-layer page sharing one >1.2M-row multi-file CSV
2655
2727
  // source fetched all 5 files 3 SEPARATE times.
2656
- const dataCache = new Map();
2728
+ // one data cache for the map's whole lifetime (flat's themeDataCacheA):
2729
+ // build() and every later defineLayer()/refreshTheme() share it
2730
+ const dataCache = this._dataCache = this._dataCache || new Map();
2657
2731
  const runtimes = [];
2658
2732
  // themes whose data comes by name (see namedDataTheme) start empty and
2659
2733
  // load on their own once the map is built, as in flat, where every
@@ -2693,9 +2767,9 @@
2693
2767
  raw = await fetchLayerData(spec.data, spec.binding, this._engineOptions);
2694
2768
  cacheKey = 'obj:' + spec.name;
2695
2769
  } else {
2696
- cacheKey = 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, process: spec.data && spec.data.process && String(spec.data.process), ext: spec.data && spec.data.ext, name: spec.data && spec.data.ext ? spec.data.name : undefined });
2697
- if (!dataCache.has(cacheKey)) dataCache.set(cacheKey, fetchLayerData(spec.data, spec.binding, this._engineOptions));
2698
- raw = await dataCache.get(cacheKey);
2770
+ const cached = cachedLayerData(dataCache, spec.data, spec.binding, this._engineOptions);
2771
+ cacheKey = cached.sourceKey;
2772
+ raw = await cached.promise;
2699
2773
  }
2700
2774
  const filtered = applyWhereFilter(raw, spec.filter);
2701
2775
  const fc = filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered;
@@ -2709,6 +2783,7 @@
2709
2783
  rt._definition = def; // for refreshTheme
2710
2784
  rt._deferredLoad = deferred;
2711
2785
  rt._named = named;
2786
+ rt._zoomToPending = wantsZoomToExtent(spec.flags);
2712
2787
  runtimes.push(rt);
2713
2788
  notifyNewTheme(rt);
2714
2789
  _globalThemeRegistry.set(rt.name, rt);
@@ -2765,6 +2840,10 @@
2765
2840
  const b = map.getBounds();
2766
2841
  return [{ lat: b.getSouth(), lng: b.getWest() }, { lat: b.getNorth(), lng: b.getEast() }];
2767
2842
  };
2843
+ // set once by the 'load' event — NOT map.loaded(), which MapLibre turns false again
2844
+ // whenever tiles are loading (after every pan/zoom), so a theme defined then would
2845
+ // wait for a 'load' that never fires again (SHOW / ZOOMTO, see maybeZoomToTheme)
2846
+ let mapHasLoaded = false;
2768
2847
 
2769
2848
  // flat's page hook ixmaps.htmlgui_onZoomAndPan(nZoom), called after
2770
2849
  // a zoom or a pan of more than 10 px (mapscript2.js 6445-6454) — a
@@ -3147,6 +3226,7 @@
3147
3226
  else map.once('idle', () => map.fire('move'));
3148
3227
  };
3149
3228
  let addLegendPanel = () => {}; // set up with the legend, below
3229
+ let setLegendOption = () => {}; // map.setLegend(value) — set up with the legend, below
3150
3230
  let updateSubTheme = () => {};
3151
3231
  let layoutLegends = () => {}; // set up with the legend, below
3152
3232
  let setDataLoading = () => {}; // set up with the loads, below
@@ -3358,10 +3438,14 @@
3358
3438
  options: (o) => {
3359
3439
  if (o && typeof o === 'object') {
3360
3440
  Object.assign(builder._engineOptions, o);
3441
+ // read only when the MapLibre map is created — switched here at runtime
3442
+ if ('worldcopies' in o) map.setRenderWorldCopies(o.worldcopies !== false && o.worldcopies !== 'false');
3361
3443
  refresh();
3362
3444
  }
3363
3445
  return engineApi;
3364
3446
  },
3447
+ // the `legend` map option at runtime (see setLegendOption)
3448
+ setLegend: (opt) => { setLegendOption(opt); return engineApi; },
3365
3449
  layer: (nameOrBuilder) => {
3366
3450
  if (typeof nameOrBuilder !== 'string') {
3367
3451
  const lb = nameOrBuilder;
@@ -3413,9 +3497,14 @@
3413
3497
  for (const rt of targets) {
3414
3498
  if (rt._named) { await loadNamedTheme(rt, true); continue; }
3415
3499
  const spec = normalizeTheme(rt._definition);
3416
- const raw = await fetchLayerData(spec.data, rt._specBinding, builder._engineOptions);
3500
+ // flat's refresh reloads, cache or not (maptheme.js 2366: datacache = false);
3501
+ // the fresh result also replaces the cached one for later themes
3502
+ const raw = spec.data && spec.data.obj
3503
+ ? await fetchLayerData(spec.data, rt._specBinding, builder._engineOptions)
3504
+ : await cachedLayerData(builder._dataCache, spec.data, rt._specBinding, builder._engineOptions, { reload: true }).promise;
3417
3505
  const filtered = applyWhereFilter(raw, spec.filter);
3418
3506
  rt.replaceFeatures(filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered);
3507
+ maybeZoomToTheme(rt); // a scale-deferred theme gets its first data here
3419
3508
  }
3420
3509
  if (targets.length) { refresh(); notifyRedraw(); }
3421
3510
  }
@@ -3506,6 +3595,19 @@
3506
3595
  loadingEl.style.display = 'none';
3507
3596
  }
3508
3597
  };
3598
+ // SHOW / ZOOMTO: zoom to the theme's extent the first time it has
3599
+ // features on a loaded map (see featuresBounds), then never again for
3600
+ // this definition — as flat drops the flag after its zoomTo()
3601
+ function maybeZoomToTheme(rt) {
3602
+ if (!rt || !rt._zoomToPending || !mapHasLoaded || !runtimes.includes(rt)) return;
3603
+ const bounds = featuresBounds(rt.features);
3604
+ if (!bounds) return; // no data yet (named / deferred): wait for it
3605
+ rt._zoomToPending = false;
3606
+ const [[w, s], [e, n]] = bounds;
3607
+ if (w === e && s === n) map.jumpTo({ center: [w, s], zoom: Math.max(map.getZoom(), 12) });
3608
+ else map.fitBounds(bounds, { padding: 30, animate: false, maxZoom: 16 });
3609
+ }
3610
+
3509
3611
  async function loadNamedTheme(rt, force) {
3510
3612
  const spec = normalizeTheme(rt._definition);
3511
3613
  const name = String(spec.data.name);
@@ -3523,7 +3625,7 @@
3523
3625
  table = await w.promise;
3524
3626
  }
3525
3627
  await built;
3526
- if (runtimes.includes(rt)) fillNamedTheme(rt, spec, table, patch);
3628
+ if (runtimes.includes(rt)) { fillNamedTheme(rt, spec, table, patch); maybeZoomToTheme(rt); }
3527
3629
  } catch (err) {
3528
3630
  // a broker that hands nothing over yet is normal in flat (it may
3529
3631
  // wait for other data); the theme stays empty until a refresh
@@ -3604,10 +3706,16 @@
3604
3706
  let raw;
3605
3707
  const deferred = deferredFeatureLoad(spec, map.getZoom());
3606
3708
  const named = namedDataTheme(spec);
3709
+ let sourceKey = null;
3607
3710
  if (deferred || named) {
3608
3711
  raw = { type: 'FeatureCollection', features: [] };
3609
- } else {
3712
+ } else if (spec.data && spec.data.obj) {
3713
+ // in memory already — nothing to cache (see build())
3610
3714
  raw = await fetchLayerData(spec.data, spec.binding, builder._engineOptions);
3715
+ } else {
3716
+ const cached = cachedLayerData(builder._dataCache, spec.data, spec.binding, builder._engineOptions);
3717
+ sourceKey = cached.sourceKey;
3718
+ raw = await cached.promise;
3611
3719
  }
3612
3720
  const filtered = applyWhereFilter(raw, spec.filter);
3613
3721
  const fc = filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered;
@@ -3615,8 +3723,9 @@
3615
3723
  rt._definition = pdef; // for refreshTheme
3616
3724
  rt._deferredLoad = deferred;
3617
3725
  rt._named = named;
3726
+ rt._zoomToPending = wantsZoomToExtent(spec.flags);
3618
3727
  rt._dataSourceKey = named ? 'name:' + spec.data.name
3619
- : 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) });
3728
+ : 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) });
3620
3729
  runtimes.push(rt);
3621
3730
  _globalThemeRegistry.set(rt.name, rt);
3622
3731
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
@@ -3624,6 +3733,7 @@
3624
3733
  notifyNewTheme(rt);
3625
3734
  refresh();
3626
3735
  notifyRedraw();
3736
+ maybeZoomToTheme(rt); // before the map's 'load': its handler zooms pending themes
3627
3737
  if (named) { listenNamedData(rt); if (!deferred) loadNamedTheme(rt, false); }
3628
3738
  return rt.name;
3629
3739
  }
@@ -3930,8 +4040,10 @@
3930
4040
  map.on('moveend', loadDeferredThemes);
3931
4041
 
3932
4042
  map.on('load', () => {
4043
+ mapHasLoaded = true;
3933
4044
  loadDeferredThemes();
3934
4045
  refresh();
4046
+ runtimes.forEach(maybeZoomToTheme);
3935
4047
  hideSplash(splash);
3936
4048
  const initialOpacity = parseFloat(this._engineOptions.basemapopacity);
3937
4049
  if (!isNaN(initialOpacity)) { _basemapOpacity = Math.max(0, Math.min(1, initialOpacity)); applyBasemapOpacity(); }
@@ -4041,8 +4153,9 @@
4041
4153
  // isn't false/"false" ("true", "open", "", 1, …), folded for
4042
4154
  // "closed", off when not given at all.
4043
4155
  const legendOpt = this.mapOptions.legend;
4044
- const legendOn = legendOpt !== undefined && legendOpt !== null && legendOpt !== false && legendOpt !== 'false' && legendOpt !== 0 && legendOpt !== '0';
4045
- const legendFolded = legendOpt === 'closed';
4156
+ // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
4157
+ let legendOn = legendIsOn(legendOpt);
4158
+ let legendFolded = legendOpt === 'closed';
4046
4159
  {
4047
4160
  // Map-level `align` option positions the legend panel — a map-
4048
4161
  // wide placement choice, not per-theme (unlike legendtheme/
@@ -4159,6 +4272,23 @@
4159
4272
  el.parentElement.appendChild(legendStack);
4160
4273
  return legendStack;
4161
4274
  };
4275
+ // map.setLegend(value): the `legend` map option at runtime, same values as
4276
+ // ixmaps.Map(id, {legend}) — on ("true", "open", …), folded ("closed"), off
4277
+ // (false/"false"/0/undefined). Panels are made for themes that lack one,
4278
+ // removed when off, folded/unfolded otherwise.
4279
+ setLegendOption = (opt) => {
4280
+ builder.mapOptions.legend = opt;
4281
+ legendFolded = opt === 'closed';
4282
+ legendOn = legendIsOn(opt);
4283
+ runtimes.forEach(rt => {
4284
+ if (!legendOn) {
4285
+ if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
4286
+ rt._legendPanel = null;
4287
+ } else if (!rt._legendPanel) addLegendPanel(rt);
4288
+ else if (rt._setLegendCollapsed) rt._setLegendCollapsed(legendFolded);
4289
+ });
4290
+ layoutLegends();
4291
+ };
4162
4292
  layoutLegends = () => {
4163
4293
  if (!legendStack) return;
4164
4294
  const z = map.getZoom();
@@ -4553,6 +4683,7 @@
4553
4683
  }
4554
4684
  collapseBtn.addEventListener('click', () => { collapsed = !collapsed; applyCollapsed(); });
4555
4685
  applyCollapsed();
4686
+ rt._setLegendCollapsed = c => { collapsed = !!c; applyCollapsed(); }; // setLegendOption
4556
4687
 
4557
4688
  // Selection/filter by field — opt-in via .style({legendfilter:
4558
4689
  // "<field>"}), e.g. "country_long" on the power-plants sample.
@@ -7576,6 +7707,35 @@
7576
7707
  return this._glowIconCache.get(key);
7577
7708
  }
7578
7709
 
7710
+ // SHADOW on chart symbols — flat's chart drop shadow (maptheme.js 17097-17160, 18709-18749):
7711
+ // an SVG filter that blurs the symbol by nShadowBlur (3) REAL screen pixels, offsets it by
7712
+ // nShadowDx/Dy (1.5/2.5 px) and darkens it to 0.1 × its color at the source alpha. Here: a
7713
+ // blurred disc as a MASK icon (tinted per item via getColor), one per size step, so the blur
7714
+ // stays ~blurPx on screen whatever the symbol radius (a single scaled icon would blur big
7715
+ // bubbles more). The disc spans 2/3 of the canvas: the rest is room for the blur.
7716
+ // `shape`: the symbol's own (drawSymbolPath, as _buildSingleIcon) — flat's filter shadows
7717
+ // whatever the symbol is, e.g. a RECT square
7718
+ _getShadowIcon(radiusPx, blurPx, shape) {
7719
+ shape = shape || 'circle';
7720
+ const step = Math.max(0, Math.min(7, Math.round(Math.log2(Math.max(1, radiusPx)))));
7721
+ const key = `shadow-${shape}-${step}-${blurPx}`;
7722
+ if (!this._glowIconCache.has(key)) {
7723
+ const size = 96, disc = 32, c = size / 2;
7724
+ const sigma = Math.max(0.5, Math.min(12, blurPx * disc / Math.pow(2, step)));
7725
+ const canvas = document.createElement('canvas');
7726
+ canvas.width = size; canvas.height = size;
7727
+ const ctx = canvas.getContext('2d');
7728
+ // the disc itself is drawn off-canvas; only its blurred shadow lands at the center
7729
+ ctx.shadowColor = 'rgba(255,255,255,1)';
7730
+ ctx.shadowBlur = sigma * 2;
7731
+ ctx.shadowOffsetX = size * 4;
7732
+ ctx.fillStyle = '#fff';
7733
+ ctx.beginPath(); drawSymbolPath(ctx, shape, c - size * 4, c, disc); ctx.closePath(); ctx.fill();
7734
+ this._glowIconCache.set(key, { url: canvas.toDataURL(), width: size, height: size, anchorX: c, anchorY: c, mask: true, id: key });
7735
+ }
7736
+ return this._glowIconCache.get(key);
7737
+ }
7738
+
7579
7739
  _buildBubbleIcon(counts, colors) {
7580
7740
  // The cache key quantizes each count's RATIO to the group's total,
7581
7741
  // not the raw count — computeBubblePackLayout's radii are purely
@@ -9375,6 +9535,34 @@
9375
9535
  const singleBorderColorRgb = this.style.linecolor && styleLineColor(this.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(this.style.linecolor)) : null;
9376
9536
  const singleBorderWidthPx = styleNum(this.style.linewidth) || 0;
9377
9537
 
9538
+ const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
9539
+ const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
9540
+
9541
+ // SHADOW (style.shadow, flat's gate: maxshadow, shadowupper/lower — flatShadowOn): a soft
9542
+ // drop shadow under each symbol, see _getShadowIcon
9543
+ if (flatShadowOn(this.style, combined.length, liveZoom)) {
9544
+ const blurPx = styleNum(this.style.shadowblur) || 3;
9545
+ const num = (v, d) => (isNaN(styleNum(v)) ? d : styleNum(v));
9546
+ const dx = num(this.style.shadowdx, 1.5), dy = num(this.style.shadowdy, 2.5);
9547
+ layers.push(new IconLayer({
9548
+ id: `ix-symbolshadow-${this.name}-g${this._iconGeneration}`,
9549
+ data: combined, pickable: false,
9550
+ getPosition: d => d.geometry.coordinates,
9551
+ getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts ? 'circle' : this._resolveSymbolShape(d.properties)),
9552
+ getSize: d => radiusOf(d) * 3, // disc = 2/3 of the icon → its diameter is 2r
9553
+ getPixelOffset: [dx, dy],
9554
+ getColor: d => {
9555
+ const rgb = itemRgb(d);
9556
+ const gray = Math.round(Math.min(255, 0.1 * (rgb[0] + rgb[1] + rgb[2])));
9557
+ return [gray, gray, gray, Math.round(this._iconAlpha(d) * fillOpacity)];
9558
+ },
9559
+ sizeUnits: 'pixels',
9560
+ billboard: true,
9561
+ parameters: ICON_LAYER_GLOBE_PARAMETERS,
9562
+ updateTriggers: { getIcon: [liveZoom, blurPx], getSize: liveZoom }
9563
+ }));
9564
+ }
9565
+
9378
9566
  // GLOW: gradient-texture halo (see _getGlowIcon for why this diverges
9379
9567
  // from the real engine's literal flat-circle formula). Individual
9380
9568
  // and cluster glows keep their own size multiplier (11 vs 9 — a lone
@@ -9392,6 +9580,20 @@
9392
9580
  billboard: true,
9393
9581
  parameters: ICON_LAYER_GLOBE_PARAMETERS
9394
9582
  }));
9583
+ } else if (this.flags.has('AURA') && !this.flags.has('ZOOM')) {
9584
+ // AURA (flat maptheme.js 20924-20930, the GLOW branch's `else`): a circle at 1.3 × the
9585
+ // radius behind the symbol, in the line color (else the symbol's), at 0.3 opacity
9586
+ layers.push(new IconLayer({
9587
+ id: `ix-aura-${this.name}-g${this._iconGeneration}`,
9588
+ data: combined, pickable: false,
9589
+ getPosition: d => d.geometry.coordinates,
9590
+ getIcon: d => this._buildSingleIcon(singleBorderColorRgb || itemRgb(d), 0.3, 'circle', null, 0),
9591
+ getSize: d => radiusOf(d) * 2 * 1.3,
9592
+ getColor: d => [255, 255, 255, this._iconAlpha(d)],
9593
+ sizeUnits: 'pixels',
9594
+ billboard: true,
9595
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
9596
+ }));
9395
9597
  }
9396
9598
 
9397
9599
  // BOX / CIRCULARBOX with TITLE / BOTTOMTITLE — flat's BOX block
@@ -10062,6 +10264,7 @@
10062
10264
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, formatBubbleValue, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
10063
10265
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
10064
10266
  flatFormatValue,
10267
+ dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
10065
10268
  };
10066
10269
  global.__setFilter = __setFilter;
10067
10270
  global.__removeFacets = __removeFacets;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",