ixmaps-gl 0.2.1 → 0.2.4

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 +8 -7
  2. package/ixmaps-gl.js +228 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -72,7 +72,7 @@ circle/square/diamond/triangle. See the top-of-file comment in
72
72
  A page's `.require(url)` scripts (page code, e.g. a user chart function) load in order before the
73
73
  layers are built. `ixmaps.map()` is the map handle as in flat — its calls wait until the map is ready —
74
74
  with `add(theme, flags)` / `replace` / `replaceTheme` / `remove`, `changeThemeStyle` for any style key,
75
- `getZoom()` (flat's zoom), `resize()` and `setBasemapOpacity`; the page's `htmlgui_onNewTheme(id)` and
75
+ `getZoom()` (flat's zoom), `resize()`, `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
76
76
  `htmlgui_onZoomAndPan()` (also once on load) are called (as in flat, a theme swapped in with `replace` comes last
77
77
  in the legend, while FEATURE and CHOROPLETH shapes always draw under the charts), and `map`, `getZoom` and flat's
78
78
  `formatValue` exist as globals. `.data({name})` without a URL waits for data of that name: from
@@ -155,8 +155,9 @@ ones, `replace` swaps themes by `style.name`/`meta.name`, `themeonly`/`maponly`/
155
155
  work as in flat. Themes in flat's older shape (data source in `style.dbtable*`, value field at
156
156
  the top level) are translated first. It resolves to `{ themes, skipped, notes }`: a theme
157
157
  that can't load is skipped with a warning, the rest still load. **Code a project names is not
158
- run by default** — `required` scripts, `ext` data scripts and a project's `process` functions are
159
- reported, not executed (only a page's own `.data({process})` runs) — and the basemap isn't switched.
158
+ run by default** — `required` scripts, `ext` data scripts and a project's `process` and `query`
159
+ functions and function-string `colorscheme`s are reported, not executed (only a page's own
160
+ `.data({process, query})` and colorscheme functions run) — and the basemap isn't switched.
160
161
 
161
162
  A page can opt in to a project's **scripts** (`data.ext`) for script URLs under prefixes it lists —
162
163
  no built-in prefixes, and a project file's own `options.trustedscripts` is ignored. Both of flat's
@@ -192,7 +193,7 @@ of your own project files gl can render.
192
193
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
193
194
 
194
195
  ```html
195
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.1/ixmaps-gl.js"></script>
196
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.4/ixmaps-gl.js"></script>
196
197
  ```
197
198
 
198
199
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -200,13 +201,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
200
201
  browser script, which defines the global `ixmaps`):
201
202
 
202
203
  ```html
203
- <script src="https://unpkg.com/ixmaps-gl@0.2.1/ixmaps-gl.js"></script>
204
+ <script src="https://unpkg.com/ixmaps-gl@0.2.4/ixmaps-gl.js"></script>
204
205
  ```
205
206
 
206
207
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
207
208
 
208
209
  ```html
209
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.1/ixmaps-gl.js"></script>
210
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.4/ixmaps-gl.js"></script>
210
211
  <div id="map_div" style="position:absolute;inset:0;"></div>
211
212
 
212
213
  <script>
@@ -272,7 +273,7 @@ For checking page files before they run, use the static checker in ixmaps-gramma
272
273
  | [`mappa_stranieri_30.html`](./stage/mappa_stranieri_30.html) | Symbol shapes, city picker, runtime `changeThemeStyle` filtering, light legend theme |
273
274
  | [`roma_incidenti_pericolosita_sidebar_gl.html`](./stage/roma_incidenti_pericolosita_sidebar_gl.html) | AGGREGATE on a gridwidth grid, facets sidebar |
274
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 |
275
- | [`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 |
276
277
  | [`examples/`](./examples) | Smaller feature-by-feature test pages (choropleth classing modes, bubble ranges, etc.) |
277
278
 
278
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.1';
83
+ const IXMAPS_GL_VERSION = '0.2.4';
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.21/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);
@@ -1720,7 +1792,7 @@
1720
1792
  // htmlgui_flat.js). Its methods act on the last built map; called while
1721
1793
  // the map is still building, they run once it is ready.
1722
1794
  const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
1723
- 'refreshTheme', 'setBasemapOpacity', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible'];
1795
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible'];
1724
1796
  const _mapHandle = {};
1725
1797
  for (const m of MAP_HANDLE_METHODS) {
1726
1798
  _mapHandle[m] = (...args) => {
@@ -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.21 (ixmaps-flat 1.0.41, eaaf2b7 2026-09-20) — 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.21 — 5 meta keys
2060
2132
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2061
2133
  // </grammar:meta-keys>
2062
2134
 
@@ -2148,6 +2220,36 @@
2148
2220
  dbtable: 'name', dbtableUrl: 'url', dbtableType: 'type', dbtableExt: 'ext',
2149
2221
  dbtableProcess: 'process', dbtableQuery: 'query', dbtableObj: 'obj', datacache: 'cache',
2150
2222
  };
2223
+ // Every project key the engine would turn into code (new Function)
2224
+ // is kept out, never run — a page's own .data({process, query}) and
2225
+ // .style({colorscheme: fn.toString()}) still run. Project keys of
2226
+ // that kind: the data processing function (data.process /
2227
+ // style.dbtableProcess), the broker query (data.query /
2228
+ // style.dbtableQuery — without it a data.name still reaches a
2229
+ // provider the PAGE defined as ixmaps[name]) and a colorscheme given
2230
+ // as a string, which resolveColorScheme evaluates as a function
2231
+ // (an array colorscheme is data and stays).
2232
+ const PROJECT_CODE_KEYS = [
2233
+ ['data', 'process', 'data.process function'],
2234
+ ['style', 'dbtableProcess', 'data.process function'],
2235
+ ['data', 'query', 'data.query function'],
2236
+ ['style', 'dbtableQuery', 'data.query function'],
2237
+ ['style', 'colorscheme', 'colorscheme function', v => typeof v === 'string'],
2238
+ ];
2239
+ function withoutProjectCode(t, report) {
2240
+ const found = PROJECT_CODE_KEYS.filter(([part, key, , isCode]) =>
2241
+ t[part] && t[part][key] != null && (!isCode || isCode(t[part][key])));
2242
+ if (!found.length) return t;
2243
+ const copy = Object.assign({}, t);
2244
+ found.forEach(([part, key]) => {
2245
+ if (copy[part] === t[part]) copy[part] = Object.assign({}, t[part]);
2246
+ delete copy[part][key];
2247
+ });
2248
+ const what = [...new Set(found.map(f => f[2]))].join(', ');
2249
+ report.notes.push(`theme "${t.layer}": the project's ${what} is never run by ixmaps-gl`);
2250
+ return copy;
2251
+ }
2252
+
2151
2253
  function projectThemeToDefinition(theme) {
2152
2254
  const t = theme || {};
2153
2255
  const style = Object.assign({}, t.style);
@@ -2418,6 +2520,19 @@
2418
2520
  const named = NAMED_MAPTYPE_COLORS[t.toLowerCase()];
2419
2521
  return named || null;
2420
2522
  }
2523
+ // A basemap NAME (flat's mapType: "VT_TONER_LITE", "VT_DATAVIZ_DARK",
2524
+ // "CartoDB - Dark matter", ...) picks one of two keyless CARTO vector
2525
+ // styles: dark for a name reading dark/black/night/matter, otherwise
2526
+ // light (Positron, gl's default). A color mapType (resolveMapTypeColor)
2527
+ // is a plain background instead and wins, so "dark" alone stays the
2528
+ // #1a1a1a background it always was.
2529
+ const BASEMAP_STYLE_URLS = {
2530
+ light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
2531
+ dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
2532
+ };
2533
+ function resolveBasemapStyleUrl(mapType) {
2534
+ return typeof mapType === 'string' && /dark|black|night|matter/i.test(mapType) ? BASEMAP_STYLE_URLS.dark : BASEMAP_STYLE_URLS.light;
2535
+ }
2421
2536
  // Real ixmaps-flat's legend look (tools/legend.js 3773-3845, via
2422
2537
  // tools/background_theme.js): DARK when the basemap is more than half
2423
2538
  // opaque and the map background reads as dark — a map type naming
@@ -2610,7 +2725,9 @@
2610
2725
  // ones. Confirmed live as a real, not just theoretical, problem:
2611
2726
  // without this, a 3-layer page sharing one >1.2M-row multi-file CSV
2612
2727
  // source fetched all 5 files 3 SEPARATE times.
2613
- 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();
2614
2731
  const runtimes = [];
2615
2732
  // themes whose data comes by name (see namedDataTheme) start empty and
2616
2733
  // load on their own once the map is built, as in flat, where every
@@ -2650,9 +2767,9 @@
2650
2767
  raw = await fetchLayerData(spec.data, spec.binding, this._engineOptions);
2651
2768
  cacheKey = 'obj:' + spec.name;
2652
2769
  } else {
2653
- 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 });
2654
- if (!dataCache.has(cacheKey)) dataCache.set(cacheKey, fetchLayerData(spec.data, spec.binding, this._engineOptions));
2655
- 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;
2656
2773
  }
2657
2774
  const filtered = applyWhereFilter(raw, spec.filter);
2658
2775
  const fc = filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered;
@@ -2666,6 +2783,7 @@
2666
2783
  rt._definition = def; // for refreshTheme
2667
2784
  rt._deferredLoad = deferred;
2668
2785
  rt._named = named;
2786
+ rt._zoomToPending = wantsZoomToExtent(spec.flags);
2669
2787
  runtimes.push(rt);
2670
2788
  notifyNewTheme(rt);
2671
2789
  _globalThemeRegistry.set(rt.name, rt);
@@ -2688,7 +2806,7 @@
2688
2806
  const map = new maplibregl.Map({
2689
2807
  container: this.containerId,
2690
2808
  style: mapTypeColor ? buildBlankBackgroundStyle(mapTypeColor)
2691
- : 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
2809
+ : resolveBasemapStyleUrl(this.mapOptions.mapType),
2692
2810
  center: [lon, lat],
2693
2811
  zoom: this._viewZoom != null && this._viewZoom !== '' ? flatToMapLibreZoom(Number(this._viewZoom)) : 8,
2694
2812
  // .attribution(a) (MapBuilder, above) was stored but never read —
@@ -2722,6 +2840,10 @@
2722
2840
  const b = map.getBounds();
2723
2841
  return [{ lat: b.getSouth(), lng: b.getWest() }, { lat: b.getNorth(), lng: b.getEast() }];
2724
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;
2725
2847
 
2726
2848
  // flat's page hook ixmaps.htmlgui_onZoomAndPan(nZoom), called after
2727
2849
  // a zoom or a pan of more than 10 px (mapscript2.js 6445-6454) — a
@@ -3104,6 +3226,7 @@
3104
3226
  else map.once('idle', () => map.fire('move'));
3105
3227
  };
3106
3228
  let addLegendPanel = () => {}; // set up with the legend, below
3229
+ let setLegendOption = () => {}; // map.setLegend(value) — set up with the legend, below
3107
3230
  let updateSubTheme = () => {};
3108
3231
  let layoutLegends = () => {}; // set up with the legend, below
3109
3232
  let setDataLoading = () => {}; // set up with the loads, below
@@ -3140,6 +3263,31 @@
3140
3263
  },
3141
3264
  replaceTheme: (id, layerBuilder, flag) => engineApi.replace(id, layerBuilder, flag),
3142
3265
  remove: (id) => { removeThemeById(id); return engineApi; },
3266
+ // flat's map.setMapType(id) / setMapTypeId / mapType (htmlgui_flat.js
3267
+ // mapApi): swap the basemap at runtime. MapLibre's setStyle replaces
3268
+ // the whole style; deck.gl's interleaved overlay re-adds its own
3269
+ // layers on 'styledata' by itself. What the new style resets is put
3270
+ // back once it has loaded: the projection (globe), the basemap
3271
+ // opacity (it is written into the style's paint properties).
3272
+ setMapType: (id) => {
3273
+ const color = resolveMapTypeColor(id);
3274
+ const style = color ? buildBlankBackgroundStyle(color) : resolveBasemapStyleUrl(id);
3275
+ const current = color ? null : resolveBasemapStyleUrl(builder.mapOptions.mapType);
3276
+ const wasColor = !!resolveMapTypeColor(builder.mapOptions.mapType);
3277
+ builder.mapOptions.mapType = id;
3278
+ if (!color && !wasColor && style === current) return engineApi; // same style: nothing to reload
3279
+ const projection = typeof map.getProjection === 'function' ? map.getProjection() : null;
3280
+ map.once('style.load', () => {
3281
+ if (projection && projection.type && typeof map.setProjection === 'function') {
3282
+ try { map.setProjection({ type: projection.type }); } catch (e) { /* projection unsupported by this build */ }
3283
+ }
3284
+ applyBasemapOpacity();
3285
+ });
3286
+ map.setStyle(style);
3287
+ return engineApi;
3288
+ },
3289
+ setMapTypeId: (id) => engineApi.setMapType(id),
3290
+ mapType: (id) => engineApi.setMapType(id),
3143
3291
  setBasemapOpacity: (delta, mode) => {
3144
3292
  _basemapOpacity = mode === 'relative'
3145
3293
  ? Math.max(0, Math.min(1, _basemapOpacity + (parseFloat(delta) || 0)))
@@ -3290,10 +3438,14 @@
3290
3438
  options: (o) => {
3291
3439
  if (o && typeof o === 'object') {
3292
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');
3293
3443
  refresh();
3294
3444
  }
3295
3445
  return engineApi;
3296
3446
  },
3447
+ // the `legend` map option at runtime (see setLegendOption)
3448
+ setLegend: (opt) => { setLegendOption(opt); return engineApi; },
3297
3449
  layer: (nameOrBuilder) => {
3298
3450
  if (typeof nameOrBuilder !== 'string') {
3299
3451
  const lb = nameOrBuilder;
@@ -3345,9 +3497,14 @@
3345
3497
  for (const rt of targets) {
3346
3498
  if (rt._named) { await loadNamedTheme(rt, true); continue; }
3347
3499
  const spec = normalizeTheme(rt._definition);
3348
- 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;
3349
3505
  const filtered = applyWhereFilter(raw, spec.filter);
3350
3506
  rt.replaceFeatures(filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered);
3507
+ maybeZoomToTheme(rt); // a scale-deferred theme gets its first data here
3351
3508
  }
3352
3509
  if (targets.length) { refresh(); notifyRedraw(); }
3353
3510
  }
@@ -3438,6 +3595,19 @@
3438
3595
  loadingEl.style.display = 'none';
3439
3596
  }
3440
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
+
3441
3611
  async function loadNamedTheme(rt, force) {
3442
3612
  const spec = normalizeTheme(rt._definition);
3443
3613
  const name = String(spec.data.name);
@@ -3455,7 +3625,7 @@
3455
3625
  table = await w.promise;
3456
3626
  }
3457
3627
  await built;
3458
- if (runtimes.includes(rt)) fillNamedTheme(rt, spec, table, patch);
3628
+ if (runtimes.includes(rt)) { fillNamedTheme(rt, spec, table, patch); maybeZoomToTheme(rt); }
3459
3629
  } catch (err) {
3460
3630
  // a broker that hands nothing over yet is normal in flat (it may
3461
3631
  // wait for other data); the theme stays empty until a refresh
@@ -3536,10 +3706,16 @@
3536
3706
  let raw;
3537
3707
  const deferred = deferredFeatureLoad(spec, map.getZoom());
3538
3708
  const named = namedDataTheme(spec);
3709
+ let sourceKey = null;
3539
3710
  if (deferred || named) {
3540
3711
  raw = { type: 'FeatureCollection', features: [] };
3541
- } else {
3712
+ } else if (spec.data && spec.data.obj) {
3713
+ // in memory already — nothing to cache (see build())
3542
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;
3543
3719
  }
3544
3720
  const filtered = applyWhereFilter(raw, spec.filter);
3545
3721
  const fc = filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered;
@@ -3547,8 +3723,9 @@
3547
3723
  rt._definition = pdef; // for refreshTheme
3548
3724
  rt._deferredLoad = deferred;
3549
3725
  rt._named = named;
3726
+ rt._zoomToPending = wantsZoomToExtent(spec.flags);
3550
3727
  rt._dataSourceKey = named ? 'name:' + spec.data.name
3551
- : 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) });
3552
3729
  runtimes.push(rt);
3553
3730
  _globalThemeRegistry.set(rt.name, rt);
3554
3731
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
@@ -3556,6 +3733,7 @@
3556
3733
  notifyNewTheme(rt);
3557
3734
  refresh();
3558
3735
  notifyRedraw();
3736
+ maybeZoomToTheme(rt); // before the map's 'load': its handler zooms pending themes
3559
3737
  if (named) { listenNamedData(rt); if (!deferred) loadNamedTheme(rt, false); }
3560
3738
  return rt.name;
3561
3739
  }
@@ -3611,19 +3789,6 @@
3611
3789
  return report;
3612
3790
  }
3613
3791
 
3614
- // a project file's data processing function is code the project
3615
- // names — kept out, never run (a page's own .data({process}) runs)
3616
- function withoutProjectCode(t, report) {
3617
- const hasData = t.data && t.data.process != null;
3618
- const hasStyle = t.style && t.style.dbtableProcess != null;
3619
- if (!hasData && !hasStyle) return t;
3620
- report.notes.push(`theme "${t.layer}": the project's data.process function is never run by ixmaps-gl`);
3621
- const copy = Object.assign({}, t);
3622
- if (hasData) { copy.data = Object.assign({}, t.data); delete copy.data.process; }
3623
- if (hasStyle) { copy.style = Object.assign({}, t.style); delete copy.style.dbtableProcess; }
3624
- return copy;
3625
- }
3626
-
3627
3792
  function applyProjectMap(m, f, report) {
3628
3793
  const svg = String(m.map || '');
3629
3794
  if (svg && !/generic\/(mercator|orthographic)\.svg$/i.test(svg)) report.notes.push(`flat SVG map "${svg.split('/').pop()}" is not available in ixmaps-gl — mercator used`);
@@ -3875,8 +4040,10 @@
3875
4040
  map.on('moveend', loadDeferredThemes);
3876
4041
 
3877
4042
  map.on('load', () => {
4043
+ mapHasLoaded = true;
3878
4044
  loadDeferredThemes();
3879
4045
  refresh();
4046
+ runtimes.forEach(maybeZoomToTheme);
3880
4047
  hideSplash(splash);
3881
4048
  const initialOpacity = parseFloat(this._engineOptions.basemapopacity);
3882
4049
  if (!isNaN(initialOpacity)) { _basemapOpacity = Math.max(0, Math.min(1, initialOpacity)); applyBasemapOpacity(); }
@@ -3986,8 +4153,9 @@
3986
4153
  // isn't false/"false" ("true", "open", "", 1, …), folded for
3987
4154
  // "closed", off when not given at all.
3988
4155
  const legendOpt = this.mapOptions.legend;
3989
- const legendOn = legendOpt !== undefined && legendOpt !== null && legendOpt !== false && legendOpt !== 'false' && legendOpt !== 0 && legendOpt !== '0';
3990
- 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';
3991
4159
  {
3992
4160
  // Map-level `align` option positions the legend panel — a map-
3993
4161
  // wide placement choice, not per-theme (unlike legendtheme/
@@ -4104,6 +4272,23 @@
4104
4272
  el.parentElement.appendChild(legendStack);
4105
4273
  return legendStack;
4106
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
+ };
4107
4292
  layoutLegends = () => {
4108
4293
  if (!legendStack) return;
4109
4294
  const z = map.getZoom();
@@ -4498,6 +4683,7 @@
4498
4683
  }
4499
4684
  collapseBtn.addEventListener('click', () => { collapsed = !collapsed; applyCollapsed(); });
4500
4685
  applyCollapsed();
4686
+ rt._setLegendCollapsed = c => { collapsed = !!c; applyCollapsed(); }; // setLegendOption
4501
4687
 
4502
4688
  // Selection/filter by field — opt-in via .style({legendfilter:
4503
4689
  // "<field>"}), e.g. "country_long" on the power-plants sample.
@@ -5266,6 +5452,16 @@
5266
5452
  return text;
5267
5453
  }
5268
5454
 
5455
+ // Records behind one drawn chart item: a group's per-category record
5456
+ // counts summed, else a Supercluster cluster's point_count, else 1.
5457
+ // recordCounts only exists on RELOCATE groups (groupCoLocated); a plain
5458
+ // grid group (no RELOCATE) carries counts only — read unguarded, BOX|TITLE
5459
+ // without RELOCATE threw on the first cluster and the theme stopped drawing.
5460
+ function groupRecordCount(props) {
5461
+ const perCat = props.recordCounts || props.counts;
5462
+ return perCat ? perCat.reduce((x, c) => x + c, 0) : (props.point_count || 1);
5463
+ }
5464
+
5269
5465
  function valuesFontSizePx(radiusPx, text, valueScale) {
5270
5466
  return Math.min(radiusPx * 0.8, radiusPx * (3.3 / Math.max(1, text.length))) * (valueScale || 1);
5271
5467
  }
@@ -9364,7 +9560,7 @@
9364
9560
  let title = null;
9365
9561
  if (titleShown) {
9366
9562
  const raw = d.properties.raw;
9367
- const n = d.properties.counts ? d.properties.recordCounts.reduce((x, c) => x + c, 0) : (d.properties.point_count || 1);
9563
+ const n = groupRecordCount(d.properties);
9368
9564
  const text = n > 3 ? '(' + n + ')' : (raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '');
9369
9565
  if (text) {
9370
9566
  const f = titleFontPx;
@@ -9987,7 +10183,7 @@
9987
10183
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
9988
10184
  global.__ixmapsGlInternals = {
9989
10185
  IXMAPS_GL_VERSION,
9990
- normalizeTheme, projectThemeToDefinition, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
10186
+ normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
9991
10187
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
9992
10188
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
9993
10189
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
@@ -9997,6 +10193,7 @@
9997
10193
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, formatBubbleValue, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
9998
10194
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
9999
10195
  flatFormatValue,
10196
+ dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
10000
10197
  };
10001
10198
  global.__setFilter = __setFilter;
10002
10199
  global.__removeFacets = __removeFacets;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
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",