ixmaps-gl 0.2.31 → 0.2.33

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 +39 -7
  2. package/ixmaps-gl.js +348 -60
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -114,10 +114,10 @@ page. It currently implements:
114
114
  `window.__setFacetFilter`) for building filterable sidebars
115
115
  - Globe (orthographic) projection alongside flat Mercator
116
116
 
117
- Not yet implemented: plain (non-AGGREGATE) `CATEGORICAL` choropleths, and the `QUAD`/
118
- `WAFFLE` base types, and SYMBOL shape variants beyond
119
- circle/square/diamond/triangle. See the top-of-file comment in
120
- [`ixmaps-gl.js`](./ixmaps-gl.js) for the exact, currently-accurate scope note.
117
+ Not yet implemented: the `QUAD`/`WAFFLE` base types. Custom SYMBOL shapes are supported
118
+ as image-URL symbols (`symbols: [...]`), not as built-in shape variants.
119
+ The top-of-file comment in [`ixmaps-gl.js`](./ixmaps-gl.js) is the single source of truth
120
+ for scope; this list only summarises it.
121
121
 
122
122
  ### Page scripts, named data and the runtime API
123
123
 
@@ -230,6 +230,38 @@ space beside it. `.options({themecopies: true})` (also at runtime) repeats the t
230
230
  copy. `.options({worldcopies: false})` draws the world once for the map itself, but MapLibre then
231
231
  also stops the zoom-out where the world gets narrower than the window.
232
232
 
233
+ ### Map events, bounds and the MapLibre map
234
+
235
+ `map.on("zoomend moveend", fn)` (flat's `mapApi.on`; on the builder chain, the callback object or
236
+ `ixmaps.map()`) fires:
237
+
238
+ | event | when | handler receives |
239
+ |---|---|---|
240
+ | `ready` | the map has loaded (called at once if it already has) | `{type, szMap}` |
241
+ | `viewchange` (`zoompan`) | after every pan or zoom | `{type, szMap, nZoom, zoomChanged, panChanged}` |
242
+ | `zoomend` / `moveend` | the zoom changed / the centre moved without a zoom change | as `viewchange` |
243
+ | `click` (`itemclick`) | an item was clicked — every item, also of a theme bound by geometry only | `{type, szMap, szId, id, theme, record, lngLat, point}` |
244
+ | `mapclick` | any click on the map, background included (gl addition) | `{type, szMap, lngLat: {lng, lat}, point: {x, y}}` |
245
+ | `layerdraw` (`drawtheme`) | after every redraw, once per theme | `{type, szMap, szId, id}` |
246
+
247
+ `szId` is `theme::key` and `id` the key: the theme's selection field value (the `id`, position or lookup
248
+ field), else the feature's `id`, else its position `"lng,lat"`; `record` is the item's data record (gl
249
+ addition). `szMap` is the map's `name` option, or `null`. flat's `frozenDynamic` is not provided.
250
+ `map.off("click", fn)` removes a handler, as in flat; several event names may be given, separated by
251
+ spaces, and the names in brackets are flat's aliases.
252
+
253
+ Differences from flat: `layerdraw` fires for every theme on every redraw, panning included (flat: once per
254
+ theme as it is drawn); `ready` means the MapLibre map has loaded, themes may still be loading; view events
255
+ fire when a movement ends, not during it. The other flat event names (`mouseover`/`itemover`,
256
+ `mouseout`/`itemout`, `resize`, `layeradd`/`newtheme`, `layerremove`/`removetheme`) are accepted but never
257
+ fire; the console says so once. `ixmaps.onItemAction(null, null, {lngLat, point})` gets the click position on
258
+ a background click as a third argument; it is still called for items only when the theme has a selection
259
+ field.
260
+
261
+ `ixmaps.map().getBounds()` returns flat's `[swLat, swLng, neLat, neLng]` (`ixmaps.getBoundingBox()`
262
+ the same box as `[{lat, lng} SW, {lat, lng} NE]`); `ixmaps.map().getMapLibre()` returns the MapLibre
263
+ map itself, or `undefined` until it is built.
264
+
233
265
  ### Click to action
234
266
 
235
267
  flat's click-to-action (`ui/js/tools/tooltip_to_action.js`, e.g. a click on a country filters every
@@ -302,7 +334,7 @@ of your own project files gl can render.
302
334
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
303
335
 
304
336
  ```html
305
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.31/ixmaps-gl.js"></script>
337
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.33/ixmaps-gl.js"></script>
306
338
  ```
307
339
 
308
340
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -310,13 +342,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
310
342
  browser script, which defines the global `ixmaps`):
311
343
 
312
344
  ```html
313
- <script src="https://unpkg.com/ixmaps-gl@0.2.31/ixmaps-gl.js"></script>
345
+ <script src="https://unpkg.com/ixmaps-gl@0.2.33/ixmaps-gl.js"></script>
314
346
  ```
315
347
 
316
348
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
317
349
 
318
350
  ```html
319
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.31/ixmaps-gl.js"></script>
351
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.33/ixmaps-gl.js"></script>
320
352
  <div id="map_div" style="position:absolute;inset:0;"></div>
321
353
 
322
354
  <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.31';
103
+ const IXMAPS_GL_VERSION = '0.2.33';
104
104
 
105
105
  const LIB_URLS = {
106
106
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -614,6 +614,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
614
614
  [...document.styleSheets].some(s => s.href === LIB_URLS.maplibreCss) ? Promise.resolve() : loadStylesheet(LIB_URLS.maplibreCss)
615
615
  ]).then(() => {
616
616
  ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension, HeatmapLayer } = global.deck);
617
+ // deck.gl 9 gamma-encodes the layer-level `opacity` prop in its `layer` shader module
618
+ // (Math.pow(opacity, 1/2.2); deck 8 and flat's SVG fill-opacity are linear), so a declared
619
+ // fillopacity 0.2 would draw as 0.48. Hand deck opacity ** 2.2: it draws exactly the declared alpha.
620
+ // Alpha inside a color array (getFillColor [r,g,b,a]) is linear and stays as it is.
621
+ const linearOpacity = Cls => new Proxy(Cls, {
622
+ construct(target, args, newTarget) {
623
+ const fixed = args.map(p => (p && typeof p === 'object' && typeof p.opacity === 'number' && p.opacity >= 0 && p.opacity < 1)
624
+ ? Object.assign({}, p, { opacity: Math.pow(p.opacity, 2.2) }) : p);
625
+ return Reflect.construct(target, fixed, newTarget);
626
+ }
627
+ });
628
+ [IconLayer, ScatterplotLayer, GeoJsonLayer, TextLayer, PathLayer, HeatmapLayer] =
629
+ [IconLayer, ScatterplotLayer, GeoJsonLayer, TextLayer, PathLayer, HeatmapLayer].map(linearOpacity);
617
630
  PieSliceLayer = makePieSliceLayer(ScatterplotLayer);
618
631
  BarShapeLayer = makeBarShapeLayer(ScatterplotLayer);
619
632
  FlowPathLayer = makeFlowPathLayer(PathLayer);
@@ -1861,12 +1874,31 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1861
1874
  })(g && g.coordinates);
1862
1875
  return minX <= maxX ? [(minX + maxX) / 2, (minY + maxY) / 2] : null;
1863
1876
  }
1877
+ // flat finds a geometry id field whatever its letter case (the comuni topojson has "pro_com_t", the page
1878
+ // binds id "pro_com_T"): the property name of a FEATURE runtime's shapes that matches the binding, else
1879
+ // the binding as written. Cached per runtime and name
1880
+ function geometryIdField(rt, name) {
1881
+ if (!name || !rt) return name;
1882
+ const cache = rt._idFieldCache || (rt._idFieldCache = {});
1883
+ if (cache[name] !== undefined) return cache[name];
1884
+ const feats = rt.features || [];
1885
+ let found = name;
1886
+ const has = f => f.properties && Object.prototype.hasOwnProperty.call(f.properties, name);
1887
+ if (!feats.slice(0, 50).some(has)) {
1888
+ const low = String(name).toLowerCase();
1889
+ for (const f of feats.slice(0, 50)) {
1890
+ const k = f.properties && Object.keys(f.properties).find(key => key.toLowerCase() === low);
1891
+ if (k) { found = k; break; }
1892
+ }
1893
+ }
1894
+ return (cache[name] = found);
1895
+ }
1864
1896
  // id → position of one FEATURE runtime's shapes (flat's getNodePosition,
1865
1897
  // maptheme.js 24702-24784): its shape (flatShapeWins) at flat's shape
1866
1898
  // center (flatShapeCenter)
1867
1899
  function featurePositionIndex(rt) {
1868
1900
  if (rt._positionIndex && rt._positionIndexOf === rt.features) return rt._positionIndex;
1869
- const idField = rt.binding && rt.binding.id;
1901
+ const idField = geometryIdField(rt, rt.binding && rt.binding.id);
1870
1902
  const best = new Map();
1871
1903
  if (idField) {
1872
1904
  for (const f of rt.features) {
@@ -2450,7 +2482,7 @@ self.onmessage = e => {
2450
2482
  // the donor's own style (outline) — the layer registry is keyed by name,
2451
2483
  // so the same-named CHOROPLETH replaces the donor there and can't find it
2452
2484
  Object.defineProperty(spec, '_donorStyle', { value: geomRt.style, configurable: true });
2453
- const idField = geomRt.binding.id;
2485
+ const idField = geometryIdField(geomRt, geomRt.binding.id);
2454
2486
  const lookupField = spec.binding.lookup;
2455
2487
  if (!idField || !lookupField) {
2456
2488
  throw new Error(`[ixmaps-gl] CHOROPLETH layer "${spec.name}" needs .binding({lookup}), and its FEATURE base needs .binding({id})`);
@@ -3174,6 +3206,13 @@ self.onmessage = e => {
3174
3206
  if (this._filterExpr != null) style.filter = this._filterExpr;
3175
3207
  if (this._titleField != null) style.title = this._titleField;
3176
3208
  if (this._lookupfield != null) style.lookupfield = this._lookupfield;
3209
+ // flat's .data() (htmlgui_flat.js ~1827): a geojson/topojson layer with no type of its own is
3210
+ // a FEATURES base layer ("FEATURES|NOLEGEND", lookup on its geometry) — the base a CHOROPLETH
3211
+ // of the same name needs. Only when neither .type() nor style.type says otherwise
3212
+ if (!style.type && this._data && /geojson|topojson/i.test(String(this._data.type || ''))) {
3213
+ style.type = 'FEATURES|NOLEGEND';
3214
+ if (style.lookupfield == null) style.lookupfield = 'geometry';
3215
+ }
3177
3216
  let data = this._data;
3178
3217
  if (this._query != null || this._process != null) {
3179
3218
  data = Object.assign({}, this._data);
@@ -3237,7 +3276,7 @@ self.onmessage = e => {
3237
3276
  // edit it by hand; `npm run unit` fails when it drifts.
3238
3277
  // ---------------------------------------------------------------
3239
3278
  // <grammar:binding-aliases>
3240
- // generated from ixmaps-grammar 0.1.29 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
3279
+ // generated from ixmaps-grammar 0.1.30 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
3241
3280
  const FLAT_BINDING_ALIASES = {
3242
3281
  "aggregation": "style.aggregationfield",
3243
3282
  "aggregationfield": "style.aggregationfield",
@@ -3299,7 +3338,7 @@ self.onmessage = e => {
3299
3338
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
3300
3339
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
3301
3340
  // <grammar:meta-keys>
3302
- // generated from ixmaps-grammar 0.1.29 — 5 meta keys
3341
+ // generated from ixmaps-grammar 0.1.30 — 5 meta keys
3303
3342
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
3304
3343
  // </grammar:meta-keys>
3305
3344
 
@@ -3541,7 +3580,7 @@ self.onmessage = e => {
3541
3580
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
3542
3581
  'borderradius', 'maxcharts', 'archeight', 'alignmargin',
3543
3582
  // HEATMAP / MLHEATMAP / KDE (a project JSON writes them as strings)
3544
- 'radiuspx', 'intensity', 'threshold', 'weightmax', 'bandwidth'];
3583
+ 'radiuspx', 'intensity', 'threshold', 'weightmax', 'bandwidth', 'bandwidthscale', 'bandwidthzoompow', 'bandwidthzoomref'];
3545
3584
  function toNumberIfNumeric(v) {
3546
3585
  if (typeof v !== 'string' || !v.trim()) return v;
3547
3586
  const n = Number(v);
@@ -3925,9 +3964,23 @@ self.onmessage = e => {
3925
3964
  if (t === entry.name || (entry.aliases || []).includes(t)) return entry;
3926
3965
  }
3927
3966
  }
3967
+ // keyless vector styles by NAME (exact match, before the dark/light rule): flat's own VT_VOYAGER names
3968
+ // resolve to the CARTO Voyager style they are made from; the rest are further keyless MapLibre styles
3969
+ const KEYLESS_VECTOR_BASEMAPS = [
3970
+ { name: 'CARTO - Voyager', aliases: ['VT_VOYAGER'], url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json' },
3971
+ { name: 'CARTO - Voyager (no labels)', aliases: ['VT_VOYAGER_LIGHT'], url: 'https://basemaps.cartocdn.com/gl/voyager-nolabels-gl-style/style.json' },
3972
+ { name: 'CARTO - Positron (no labels)', url: 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json' },
3973
+ { name: 'CARTO - Dark Matter (no labels)', url: 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json' },
3974
+ { name: 'OpenFreeMap - Liberty', url: 'https://tiles.openfreemap.org/styles/liberty' },
3975
+ { name: 'OpenFreeMap - Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
3976
+ { name: 'OpenFreeMap - Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
3977
+ ];
3928
3978
  function resolveBasemapStyleUrl(mapType) {
3929
3979
  const raster = flatRasterBasemap(mapType);
3930
3980
  if (raster) return buildRasterBasemapStyle(raster);
3981
+ const typeName = typeof mapType === 'string' ? mapType.trim() : '';
3982
+ const vector = KEYLESS_VECTOR_BASEMAPS.find(e => e.name === typeName || (e.aliases || []).includes(typeName));
3983
+ if (vector) return vector.url;
3931
3984
  return typeof mapType === 'string' && /dark|black|night|matter/i.test(mapType) ? BASEMAP_STYLE_URLS.dark : BASEMAP_STYLE_URLS.light;
3932
3985
  }
3933
3986
  // Real ixmaps-flat's legend look (tools/legend.js 3773-3845, via
@@ -4414,18 +4467,34 @@ self.onmessage = e => {
4414
4467
  }
4415
4468
  // flat's click-to-action hook (ui/js/tools/tooltip_to_action.js): ixmaps.onItemAction(szId, item, themeObj) on a
4416
4469
  // click on an item — item.szSelectionId is the value of the theme's selection field — and onItemAction(null, null)
4417
- // on a click on the map background
4418
- function callItemAction(rt, object) {
4470
+ // on a click on the map background — gl adds the click position as third argument there:
4471
+ // onItemAction(null, null, { lngLat: {lng, lat}, point: {x, y} }) (flat passes none)
4472
+ function clickPosition(info) {
4473
+ const c = info && info.coordinate;
4474
+ if (!c || !isFinite(c[0]) || !isFinite(c[1])) return undefined;
4475
+ return { lngLat: { lng: c[0], lat: c[1] }, point: { x: info.x, y: info.y } };
4476
+ }
4477
+ function callItemAction(rt, object, info) {
4419
4478
  const hook = pageIxmaps() && pageIxmaps().onItemAction;
4420
- if (typeof hook !== 'function') return;
4421
4479
  try {
4422
- if (!rt) { hook.call(global.ixmaps, null, null); return; }
4480
+ const pos = clickPosition(info);
4481
+ if (!rt) { if (typeof hook === 'function') hook.call(global.ixmaps, null, null, pos); return; }
4423
4482
  const field = selectionFieldOf(rt);
4424
4483
  const props = object.properties || {};
4425
4484
  // a single record, or an aggregated item's first one
4426
4485
  const raw = props.raw || props.firstRaw || props.titleRaw || (props.raws && props.raws[0]) || props;
4427
4486
  const value = field && raw ? raw[field] : null;
4428
- if (value == null) return;
4487
+ // the map's "click" event, for EVERY item as in flat (szId = theme::itemKey): the key is the selection
4488
+ // field's value, else the feature's id, else its position "lng,lat" — a theme bound by geometry only
4489
+ // (no id/lookup field) has no selection value. gl adds the record and the click position.
4490
+ if (builder.__emitItemClick) {
4491
+ const g = object.geometry, a = anchorCoordOf(g) || (g && Array.isArray(g.coordinates) && g.coordinates[0]);
4492
+ const key = value != null ? String(value)
4493
+ : (object.id != null ? String(object.id) : (a && typeof a[0] === 'number' ? a[0] + ',' + a[1] : ''));
4494
+ builder.__emitItemClick(Object.assign({ szId: themeIdOf(rt) + '::' + key, id: key, theme: themeIdOf(rt), record: raw }, pos));
4495
+ }
4496
+ // flat's onItemAction needs the theme's selection value
4497
+ if (value == null || typeof hook !== 'function') return;
4429
4498
  hook.call(global.ixmaps, themeIdOf(rt) + '::' + value, { szSelectionId: String(value) }, getThemeObj(themeIdOf(rt)));
4430
4499
  } catch (err) { console.error('[ixmaps-gl] onItemAction failed:', err); }
4431
4500
  }
@@ -4434,7 +4503,7 @@ self.onmessage = e => {
4434
4503
  if (info && info.object && info.layer) {
4435
4504
  const rt = findRuntimeForLayerId(info.layer.id);
4436
4505
  if (rt) {
4437
- callItemAction(rt, info.object);
4506
+ callItemAction(rt, info.object, info);
4438
4507
  // Only a Point geometry's own coordinates ARE an
4439
4508
  // [lng,lat] pair (every point/bubble/dot/chart-cluster
4440
4509
  // layer) — a CHOROPLETH polygon's geometry.coordinates is
@@ -4465,7 +4534,7 @@ self.onmessage = e => {
4465
4534
  }
4466
4535
  }
4467
4536
  // clicked empty map space (or a non-tooltippable layer) -> unpin
4468
- callItemAction(null, null);
4537
+ callItemAction(null, null, picked);
4469
4538
  pinned = null;
4470
4539
  pinnedTooltipEl.style.display = 'none';
4471
4540
  return false;
@@ -5472,6 +5541,23 @@ self.onmessage = e => {
5472
5541
  return { addLegendPanel, setLegendOption, updateSubTheme, layoutLegends };
5473
5542
  }
5474
5543
 
5544
+ // flat's map event names (htmlgui_flat.js mapApi.on, with its aliases) and the ones this engine fires:
5545
+ // viewchange (every view change end), zoomend / moveend ({ nZoom, zoomChanged, panChanged }, as flat),
5546
+ // ready (the map has loaded), click (an item: { szId, id, theme } + lngLat/point), layerdraw ({ id }) and,
5547
+ // as a gl addition, mapclick (every click on the map, background included: { lngLat, point })
5548
+ const MAP_EVENT_ALIAS = { zoompan: 'viewchange', itemover: 'mouseover', itemclick: 'click', itemout: 'mouseout', mapready: 'ready',
5549
+ drawtheme: 'layerdraw', newtheme: 'layeradd', removetheme: 'layerremove' };
5550
+ const MAP_EVENTS_WIRED = new Set(['viewchange', 'zoomend', 'moveend', 'ready', 'click', 'mapclick', 'layerdraw']);
5551
+ function splitMapEvents(events) {
5552
+ return String(events == null ? '' : events).trim().toLowerCase().split(/\s+/).filter(Boolean).map(e => MAP_EVENT_ALIAS[e] || e);
5553
+ }
5554
+ const _warnedMapEvents = new Set();
5555
+ function warnUnwiredMapEvent(ev) {
5556
+ if (MAP_EVENTS_WIRED.has(ev) || _warnedMapEvents.has(ev)) return;
5557
+ _warnedMapEvents.add(ev);
5558
+ console.warn(`[ixmaps-gl] map event "${ev}" is not implemented — the handler is accepted but never fires (supported: ${[...MAP_EVENTS_WIRED].join(', ')})`);
5559
+ }
5560
+
5475
5561
  class MapBuilder {
5476
5562
  constructor(containerId, options) {
5477
5563
  this.containerId = containerId;
@@ -5536,15 +5622,21 @@ self.onmessage = e => {
5536
5622
  }
5537
5623
  // .Layer() — alias of .layer()
5538
5624
  Layer(nameOrBuilder) { return this.layer(nameOrBuilder); }
5539
- // GL-PORT COMPAT: real ixmaps-flat's map.on("layerdraw", cb) — only
5540
- // "layerdraw" is actually wired up (see build()'s own note on how
5541
- // it's translated into onRedraw + a synthetic per-runtime event); any
5542
- // OTHER event name is accepted and stored but never fired —
5543
- // recognized rather than throwing, the same "known but inert" pattern
5544
- // KNOWN_INERT_FLAGS already uses for unimplemented type flags.
5625
+ // flat's mapApi.on(events, cb) (htmlgui_flat.js 1631), "a b" for several names; see MAP_EVENTS for the
5626
+ // ones wired here. Any other name is accepted (never throws) but never fires, and says so once on the console.
5627
+ // Registered before the map is built, they are attached by build().
5545
5628
  on(event, cb) {
5546
5629
  this._pendingEvents = this._pendingEvents || [];
5547
- this._pendingEvents.push([event, cb]);
5630
+ for (const ev of splitMapEvents(event)) {
5631
+ warnUnwiredMapEvent(ev);
5632
+ this._pendingEvents.push([ev, cb]);
5633
+ }
5634
+ return this;
5635
+ }
5636
+ // flat's mapApi.off(events, cb), before the map is built (after it: the handle's off)
5637
+ off(event, cb) {
5638
+ const names = new Set(splitMapEvents(event));
5639
+ this._pendingEvents = (this._pendingEvents || []).filter(([ev, f]) => !(names.has(ev) && f === cb));
5548
5640
  return this;
5549
5641
  }
5550
5642
 
@@ -5966,20 +6058,29 @@ self.onmessage = e => {
5966
6058
  map.on('style.load', refreshControlLook);
5967
6059
  this._setAttribution = text => attributionBox.set(text);
5968
6060
 
5969
- if (this._legendHtml) {
6061
+ // the page legend (map.legend(html)), or the legend a loaded project
6062
+ // carries in its map part (applyProjectMap) — flat shows the project's
6063
+ // legend in the same pane, so the project's replaces the page's
6064
+ let pageLegendEl = null;
6065
+ const showPageLegend = html => {
6066
+ if (pageLegendEl) { pageLegendEl.remove(); pageLegendEl = null; }
6067
+ this._legendHtml = html;
6068
+ if (!html) return;
5970
6069
  const legendEl = document.createElement('div');
5971
6070
  // a page legend (map.legend(html)) is positioned by the map's
5972
6071
  // own align option — the same rule that places the native stack
5973
6072
  legendEl.style.cssText = 'position:absolute;' + legendAlignCssFor(this.mapOptions) + 'z-index:5;pointer-events:none';
5974
6073
  // flat's legend takes the pane's html OR a URL to it (htmlgui):
5975
6074
  // a value without a tag is fetched, its html shown
5976
- if (/<[a-z!]/i.test(this._legendHtml)) legendEl.innerHTML = this._legendHtml;
5977
- else fetch(this._legendHtml).then(r => r.ok ? r.text() : '').then(t => { legendEl.innerHTML = t; }).catch(() => {});
6075
+ if (/<[a-z!]/i.test(html)) legendEl.innerHTML = html;
6076
+ else fetch(html).then(r => r.ok ? r.text() : '').then(t => { legendEl.innerHTML = t; }).catch(() => {});
5978
6077
  // inside the map container, never the page's body (see the pinned
5979
6078
  // tooltip mount) — styling body breaks page-positioned elements
5980
6079
  anchorInside(el);
5981
6080
  el.appendChild(legendEl);
5982
- }
6081
+ pageLegendEl = legendEl;
6082
+ };
6083
+ if (this._legendHtml) showPageLegend(this._legendHtml);
5983
6084
 
5984
6085
  // themes are addressed by either the layer()'s own name (e.g. "AREU")
5985
6086
  // or style.name (e.g. "chart") — the real engine's changeThemeStyle
@@ -6865,6 +6966,12 @@ self.onmessage = e => {
6865
6966
  Object.assign(builder._engineOptions, o);
6866
6967
  }
6867
6968
  if (m.scaleParam && m.scaleParam.normalSizeScale != null) builder._engineOptions.normalSizeScale = m.scaleParam.normalSizeScale;
6969
+ // the project's own legend (map.legend: html or a URL to it) replaces the page's, as in flat;
6970
+ // like a page legend it takes the place of the theme-generated legend panels
6971
+ if (typeof m.legend === 'string' && m.legend.trim()) {
6972
+ showPageLegend(m.legend);
6973
+ setLegendOption(builder.mapOptions.legend);
6974
+ }
6868
6975
  if (m.basemap) report.notes.push(`basemap "${m.basemap}" not switched (ixmaps-gl keeps the map's own basemap)`);
6869
6976
  }
6870
6977
 
@@ -7208,8 +7315,61 @@ self.onmessage = e => {
7208
7315
  }
7209
7316
  map.on('moveend', loadDeferredThemes);
7210
7317
 
7318
+ // map events (flat's mapApi.on): see MAP_EVENTS_WIRED. "layerdraw" is approximated below.
7319
+ const mapListeners = Object.create(null);
7320
+ const drawHooks = new Map(); // layerdraw handler -> its onRedraw unsubscribe functions
7321
+ const mapName = (this.mapOptions && this.mapOptions.name) || null; // flat's szMap
7322
+ let mapReady = false;
7323
+ const emitMapEvent = (type, detail) => (mapListeners[type] || []).slice().forEach(cb => {
7324
+ try { cb(Object.assign({ type, szMap: mapName }, detail)); } catch (err) { console.error(`[ixmaps-gl] "${type}" callback failed:`, err); }
7325
+ });
7326
+ engineApi.on = (events, cb) => {
7327
+ if (typeof cb !== 'function') return engineApi;
7328
+ for (const ev of splitMapEvents(events)) {
7329
+ warnUnwiredMapEvent(ev);
7330
+ if (ev === 'layerdraw') {
7331
+ // flat fires "layerdraw" once per theme as it finishes drawing; here onRedraw fires once per refresh of
7332
+ // the whole map, so the callback is called once per current theme every time — a page that re-reads
7333
+ // that theme's state (getThemeObj/getFacets) gets fresh numbers after every redraw
7334
+ const off = engineApi.onRedraw(() => runtimes.forEach(rt => {
7335
+ const id = themeIdOf(rt);
7336
+ try { cb({ type: 'layerdraw', szId: id, id, szMap: mapName }); } catch (err) { console.error('[ixmaps-gl] "layerdraw" callback failed:', err); }
7337
+ }));
7338
+ drawHooks.set(cb, (drawHooks.get(cb) || []).concat(off));
7339
+ } else {
7340
+ (mapListeners[ev] = mapListeners[ev] || []).push(cb);
7341
+ if (ev === 'ready' && mapReady) setTimeout(() => { try { cb({ type: 'ready', szMap: mapName }); } catch (err) { console.error('[ixmaps-gl] "ready" callback failed:', err); } }, 0);
7342
+ }
7343
+ }
7344
+ return engineApi;
7345
+ };
7346
+ // flat's mapApi.off(events, cb): removes the handler(s) given to on()
7347
+ engineApi.off = (events, cb) => {
7348
+ if (typeof cb !== 'function') return engineApi;
7349
+ for (const ev of splitMapEvents(events)) {
7350
+ if (ev === 'layerdraw') { (drawHooks.get(cb) || []).forEach(off => off()); drawHooks.delete(cb); continue; }
7351
+ const list = mapListeners[ev];
7352
+ if (list) mapListeners[ev] = list.filter(f => f !== cb);
7353
+ }
7354
+ return engineApi;
7355
+ };
7356
+ let lastViewZoom = map.getZoom(), lastViewCenter = map.getCenter();
7357
+ map.on('moveend', () => {
7358
+ const z = map.getZoom(), c = map.getCenter();
7359
+ const zoomChanged = z !== lastViewZoom, panChanged = c.lng !== lastViewCenter.lng || c.lat !== lastViewCenter.lat;
7360
+ lastViewZoom = z; lastViewCenter = c;
7361
+ const detail = { nZoom: flatViewZoomOf(map), zoomChanged, panChanged };
7362
+ emitMapEvent('viewchange', detail);
7363
+ if (zoomChanged) emitMapEvent('zoomend', detail);
7364
+ if (panChanged && !zoomChanged) emitMapEvent('moveend', detail);
7365
+ });
7366
+ map.on('click', e => emitMapEvent('mapclick', { lngLat: { lng: e.lngLat.lng, lat: e.lngLat.lat }, point: { x: e.point.x, y: e.point.y } }));
7367
+ this.__emitItemClick = detail => emitMapEvent('click', detail);
7368
+
7211
7369
  map.on('load', () => {
7212
7370
  mapHasLoaded = true;
7371
+ mapReady = true;
7372
+ emitMapEvent('ready');
7213
7373
  loadDeferredThemes();
7214
7374
  refresh();
7215
7375
  runtimes.forEach(maybeZoomToTheme);
@@ -7275,18 +7435,7 @@ self.onmessage = e => {
7275
7435
  // gets the same practical effect: fresh numbers after every redraw,
7276
7436
  // for every theme, without needing the page rewritten around a
7277
7437
  // finer-grained event this engine doesn't produce.
7278
- const layerdrawCbs = (this._pendingEvents || [])
7279
- .filter(([event]) => event === 'layerdraw')
7280
- .map(([, cb]) => cb);
7281
- if (layerdrawCbs.length) {
7282
- engineApi.onRedraw(() => {
7283
- runtimes.forEach(rt => {
7284
- layerdrawCbs.forEach(cb => {
7285
- try { cb({ id: rt.name }); } catch (err) { console.error('[ixmaps-gl] "layerdraw" callback failed:', err); }
7286
- });
7287
- });
7288
- });
7289
- }
7438
+ (this._pendingEvents || []).forEach(([event, cb]) => engineApi.on(event, cb));
7290
7439
 
7291
7440
  // the native legend: createLegend (its four hooks are what the rest
7292
7441
  // of the map calls — panels, the legend option, sub-themes, layout)
@@ -7470,7 +7619,10 @@ self.onmessage = e => {
7470
7619
  promise.legend = (...args) => { builder.legend(...args); return promise; };
7471
7620
  promise.local = (...args) => { builder.local(...args); return promise; };
7472
7621
  promise.require = (url) => { if (resolvedApi) resolvedApi.require(url); else builder.require(url); return promise; };
7473
- promise.on = (...args) => { builder.on(...args); return promise; };
7622
+ promise.on = (...args) => { if (resolvedApi) resolvedApi.on(...args); else builder.on(...args); return promise; };
7623
+ promise.off = (...args) => { if (resolvedApi) resolvedApi.off(...args); else builder.off(...args); return promise; };
7624
+ promise.getBounds = () => getBounds();
7625
+ promise.getMapLibre = () => (resolvedApi ? resolvedApi.map : undefined);
7474
7626
  promise.layer = (nameOrBuilder) => {
7475
7627
  if (typeof nameOrBuilder !== 'string') {
7476
7628
  // object form (a pre-built LayerBuilder, e.g. from the global
@@ -11913,7 +12065,19 @@ self.onmessage = e => {
11913
12065
  // flat's nValueSum is the RAW weighted sum, whatever the flags.
11914
12066
  // (RELATIVE/INVERT subtract after the multiply — not reconstructable
11915
12067
  // from the parts; flat's own composed branch never pairs them.)
11916
- _composedDopacityAlpha(props) {
12068
+ // With binding.alpha the opacity follows the alpha field instead of the value sum — flat's
12069
+ // `if (this.szAlphaField)` branch of the same DOPACITY block (maptheme.js 13476-13482):
12070
+ // dopacityscale / nAlphaMax^(1/pow) · nAlpha^(1/pow), nAlpha as _prepareAlphaField made it
12071
+ // (alpha100 "$density$": alpha / the shape's area, the FEATURE base's size field when it binds one);
12072
+ // a shape with no alpha is 0. Not capped at 0.9 and not times fillopacity, as flat
12073
+ _composedDopacityAlpha(props, feature) {
12074
+ if (this.binding && this.binding.alpha) {
12075
+ const nAlpha = this._alphaByFeature && feature ? this._alphaByFeature.get(feature) : undefined;
12076
+ if (nAlpha == null) return 0;
12077
+ const pow = 1 / (styleNum(this.style.dopacitypow) || 1);
12078
+ const o = (styleNum(this.style.dopacityscale) || 1) / Math.pow(this._alphaMax || 1, pow) * Math.pow(nAlpha, pow);
12079
+ return isFinite(o) ? Math.max(0, Math.min(1, o)) : 0;
12080
+ }
11917
12081
  const fields = this._multiFields || [];
11918
12082
  const f100A = this.binding && this.binding.field100 ? String(this.binding.field100).split('|') : null;
11919
12083
  const multiplier = this.flags.has('FRACTION') ? (styleNum(this.style.fractionscale) || 1)
@@ -12932,7 +13096,10 @@ self.onmessage = e => {
12932
13096
  const labels = this.categoryDisplayLabels || this.categoryLabels || [];
12933
13097
  return tooltipTable(props.parts.map((v, i) => (v > 0 ? { rgb: this.categoryColorsRgb[i], label: labels[i] || '(n/d)', value: this._formatTooltipValue(v) + unitOf } : null)).filter(Boolean));
12934
13098
  }
12935
- if (this.flags.has('CHOROPLETH') && (this.flags.has('DOMINANT') || this.flags.has('COMPOSECOLOR')) && props.raw) {
13099
+ // (a USER chart over several value fields — the pinnacle peaks of a COMPOSECOLOR theme — lists its fields
13100
+ // the same way: its single item row would show the first class with the size total)
13101
+ const multiFieldUser = this.flags.has('USER') && String(this.binding.value || '').includes('|');
13102
+ if ((this.flags.has('CHOROPLETH') || multiFieldUser) && (this.flags.has('DOMINANT') || this.flags.has('COMPOSECOLOR')) && props.raw) {
12936
13103
  return this._renderMultiFieldBarsHtml(props.raw);
12937
13104
  }
12938
13105
  const unit = unitOf;
@@ -13148,7 +13315,7 @@ self.onmessage = e => {
13148
13315
  // early return here must also unmount its groups, or the pane keeps
13149
13316
  // showing the last drawing after the theme went out of scale (or was
13150
13317
  // hidden via setThemeVisible)
13151
- if (this._hidden) { this._clearUserChartPane(); return []; }
13318
+ if (this._hidden) { this._kdeShowGate = true; this._clearUserChartPane(); return []; }
13152
13319
  if (!this.flags.has('FEATURE') && !this.flags.has('FEATURES') && chartHiddenByScale(this.style, liveZoom)) { this._clearUserChartPane(); return []; }
13153
13320
  // DOT is checked FIRST, matching the real engine's own precedence
13154
13321
  // (its DOT branch is a loop-level fast-path that bypasses the whole
@@ -13451,7 +13618,7 @@ self.onmessage = e => {
13451
13618
  // sum over (field100/100), against the same nMax the color
13452
13619
  // blend normalizes by. The raw weighted sum is reconstructed
13453
13620
  // from the fractioned part values (raw = part × its field100)
13454
- dopacityAlpha: dopacityActive ? this._composedDopacityAlpha(f.properties) : null
13621
+ dopacityAlpha: dopacityActive ? this._composedDopacityAlpha(f.properties, f) : null
13455
13622
  }
13456
13623
  };
13457
13624
  }
@@ -14468,8 +14635,9 @@ self.onmessage = e => {
14468
14635
  // drawn now (fresh), else a cached chart's: the previous drawing's copy
14469
14636
  const def = this._userChartGroup.querySelector('#' + CSS.escape(id)) || this._userChartGroup.ownerDocument.getElementById(id);
14470
14637
  if (def) paneDefs.appendChild(def.cloneNode(true));
14638
+ else if (chart.defsById && chart.defsById[id]) paneDefs.insertAdjacentHTML('beforeend', chart.defsById[id]);
14471
14639
  }
14472
- items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit,
14640
+ items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit, pick: chart.pick,
14473
14641
  r: Math.max(chart.vw, chart.vh) * pxPerUnit / 2,
14474
14642
  // the chart's measured box (flat's getBox of the chart group) in px
14475
14643
  // around the map position: the pane draws a chart point at
@@ -14512,14 +14680,31 @@ self.onmessage = e => {
14512
14680
  positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
14513
14681
  ids: { circle: `ix-user-box-${this.name}`, icon: `ix-user-box-${this.name}`, polygons: `ix-user-box-polygons-${this.name}`, titles: `ix-user-titles-${this.name}` }
14514
14682
  }) : { box: [], titles: [] };
14515
- this._userChartLastLayers = [...boxLayers.box, new ScatterplotLayer({
14683
+ // each chart answers to its own outline: the polygons sampled from its drawing (else its box), in px
14684
+ // around the map position, here as lng/lat at this zoom (a px is 360/(512·2^z) degrees across,
14685
+ // times cos(lat) up); a few px of transparent stroke keep a thin peak easy to hit
14686
+ const degPerPx = 360 / (512 * Math.pow(2, liveZoom));
14687
+ const pickFeatures = items.map(it => {
14688
+ const [lng, lat] = it.coords;
14689
+ const kx = it.s * degPerPx, ky = it.s * degPerPx * Math.cos(lat * Math.PI / 180);
14690
+ const rings = it.pick && it.pick.length ? it.pick
14691
+ : [[[it.ext[0] / it.s, it.ext[1] / it.s], [it.ext[2] / it.s, it.ext[1] / it.s], [it.ext[2] / it.s, it.ext[3] / it.s], [it.ext[0] / it.s, it.ext[3] / it.s]]];
14692
+ const polys = rings.map(r => {
14693
+ const ring = r.map(([x, y]) => [lng + x * kx, lat - y * ky]);
14694
+ ring.push(ring[0]);
14695
+ return [ring];
14696
+ });
14697
+ // icon + d: what pickedFeature unwraps to the item
14698
+ return { type: 'Feature', geometry: { type: 'MultiPolygon', coordinates: polys }, properties: {}, icon: true, d: it.d };
14699
+ });
14700
+ this._userChartLastLayers = [...boxLayers.box, new GeoJsonLayer({
14516
14701
  id: `ix-bubbles-${this.name}`,
14517
- data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
14702
+ data: { type: 'FeatureCollection', features: pickFeatures },
14518
14703
  pickable: true,
14519
- getPosition: e => e.d.geometry.coordinates,
14520
- getRadius: e => Math.max(2, e.r),
14521
- radiusUnits: 'pixels', radiusMinPixels: 1,
14522
- stroked: false, filled: true, getFillColor: [0, 0, 0, 0],
14704
+ filled: true, stroked: true,
14705
+ // alpha 1/255: invisible, but never a fully transparent fragment a renderer might skip
14706
+ getFillColor: [0, 0, 0, 1], getLineColor: [0, 0, 0, 1],
14707
+ lineWidthUnits: 'pixels', getLineWidth: 4,
14523
14708
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14524
14709
  }), ...boxLayers.titles];
14525
14710
  return this._userChartLastLayers;
@@ -14532,11 +14717,40 @@ self.onmessage = e => {
14532
14717
  const raw = d.properties.raw || d.properties;
14533
14718
  const titleField = this.style.titlefield || this.binding.title;
14534
14719
  const title = titleField && raw && raw[titleField] != null ? String(raw[titleField]) : undefined;
14535
- const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${Math.round(size * 2) / 2}|${flag}|${title || ''}`;
14720
+ // flat hands the chart the item's value fields: values = every field of binding.value (after the
14721
+ // field100 arithmetic — the record already carries it) and value = the first of them
14722
+ // (maptheme.js 19667, nValuesA[0]); a theme with one value field keeps the item's value
14723
+ let values = [value];
14724
+ const fieldNames = this.binding.value && !d.properties.counts ? String(this.binding.value).split('|') : [];
14725
+ if (fieldNames.length > 1) {
14726
+ const parts = fieldNames.map(f => scanValue(raw && raw[f[0] === '!' ? f.slice(1) : f]));
14727
+ if (parts.some(v => !isNaN(v))) {
14728
+ values = parts.map(v => (isNaN(v) ? 0 : v));
14729
+ // COMPOSECOLOR blends all the fields into one colour, so the chart's value is the one that wins it —
14730
+ // the highest field (flat itself would hand over the first field here: a deliberate departure,
14731
+ // so a "party with the highest result" map prints that party's share)
14732
+ value = this.flags.has('COMPOSECOLOR') ? Math.max(...values) : values[0];
14733
+ }
14734
+ }
14735
+ // DOMINANT: the chart is the dominant field's — its class colour and its value (flat's USER branch,
14736
+ // maptheme.js ~19700-19770: nClass = the dominant index, nValue = nValuesA[nIndex]); the relevance
14737
+ // (plain value, PERCENTOFMEAN, DEVIATION) is the one the DOMINANT choropleth uses
14738
+ if (this.flags.has('DOMINANT') && !this.flags.has('COMPOSECOLOR') && this._multiFields && this._dominantMins && raw && fieldNames.length > 1) {
14739
+ const dominant = this._resolveDominantClass(raw);
14740
+ if (dominant) { value = dominant.value; cat = dominant.index; }
14741
+ }
14742
+ // COMPOSECOLOR: the chart takes the item's blended colour (flat's itemA[a].szColor, the same
14743
+ // blend the choropleth uses) instead of one class colour
14744
+ let composed;
14745
+ if (this.flags.has('COMPOSECOLOR') && this._multiFields && raw) {
14746
+ const c = this._resolveComposedColor(raw);
14747
+ composed = `rgb(${c[0]},${c[1]},${c[2]})`;
14748
+ }
14749
+ const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${values.length > 1 ? values.join(',') : ''}|${composed || ''}|${Math.round(size * 2) / 2}|${flag}|${title || ''}`;
14536
14750
  if (!this._userChartSvgs) this._userChartSvgs = new Map();
14537
14751
  let chart = this._userChartSvgs.get(base);
14538
14752
  if (chart === undefined) {
14539
- chart = this._drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize);
14753
+ chart = this._drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize, values, composed);
14540
14754
  if (chart === undefined) return null;
14541
14755
  if (this._userChartSvgs.size >= ICON_CACHE_MAX) this._userChartSvgs.delete(this._userChartSvgs.keys().next().value);
14542
14756
  this._userChartSvgs.set(base, chart);
@@ -14546,10 +14760,10 @@ self.onmessage = e => {
14546
14760
  // the page's chart function draws one chart into the hidden SVG: its
14547
14761
  // markup and box (chart units), null when it draws nothing, undefined
14548
14762
  // when it fails
14549
- _drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize) {
14763
+ _drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize, values, composedColor) {
14550
14764
  const g = document.createElementNS(SVG_NS, 'g');
14551
14765
  this._userChartGroup.appendChild(g);
14552
- const color = this._userChartColor(cat);
14766
+ const color = composedColor || this._userChartColor(cat);
14553
14767
  const raw = d.properties.raw || d.properties;
14554
14768
  const label = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : undefined;
14555
14769
  // flat's chart title (style.titlefield, maptheme's item.szTitle) —
@@ -14559,7 +14773,7 @@ self.onmessage = e => {
14559
14773
  let chart = null;
14560
14774
  try {
14561
14775
  const ret = draw.call(global.ixmaps, document, {
14562
- target: g, theme, value, values: [value], size, maxSize: USER_CHART_MAX_SIZE, color, class: cat, flag,
14776
+ target: g, theme, value, values: values || [value], size, maxSize: USER_CHART_MAX_SIZE, color, class: cat, flag,
14563
14777
  // flat hands the chart the item's color CLASS object (maptheme's
14564
14778
  // ColorScheme entry) — pinnacleChart reads ccolor.lowColor for its
14565
14779
  // VALUES label fill (usercharts/d3/chart.js 199)
@@ -14570,13 +14784,51 @@ self.onmessage = e => {
14570
14784
  if (bb && bb.width > 0 && bb.height > 0) {
14571
14785
  const markup = new XMLSerializer().serializeToString(g);
14572
14786
  const ids = new Set([...markup.matchAll(/url\(#([^)"']+)\)/g)].map(m => m[1]));
14573
- const defs = [...ids].map(id => { const el = this._userChartGroup.ownerDocument.getElementById(id); return el ? new XMLSerializer().serializeToString(el) : ''; }).join('');
14787
+ // the gradients the chart uses, kept WITH the chart: a cached chart that was not on the screen in the
14788
+ // last drawing finds no copy of them in the document any more (they are one per colour, and only the
14789
+ // theme's own colours are defined again by _init), and would lose its fill
14790
+ const defsById = {};
14791
+ for (const id of ids) {
14792
+ const el = this._userChartGroup.ownerDocument.getElementById(id);
14793
+ if (el) defsById[id] = new XMLSerializer().serializeToString(el);
14794
+ }
14795
+ const defs = Object.values(defsById).join('');
14574
14796
  // strokes and text halos reach past the geometric box
14575
14797
  const pad = Math.max(bb.width, bb.height) * 0.06 + 10;
14576
14798
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
14577
14799
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
14578
14800
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
14579
- chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids], bb: { x: bb.x, y: bb.y, width: bb.width, height: bb.height },
14801
+ // the chart's own outline for hover and click: its shapes (the peak's triangle, a pie's circle ...)
14802
+ // sampled into polygons, in chart units around the origin — not the box around them (a tall peak
14803
+ // would otherwise answer to a rectangle, or a circle, far wider than the peak itself)
14804
+ const pick = [];
14805
+ try {
14806
+ const inv = g.getCTM().inverse();
14807
+ const at = (el, x, y) => { const p = new DOMPoint(x, y).matrixTransform(inv.multiply(el.getCTM())); return [p.x - ox, p.y - oy]; };
14808
+ g.querySelectorAll('path,rect,circle,ellipse,polygon').forEach(el => {
14809
+ if (el.closest('defs,clipPath,mask')) return;
14810
+ let ring = null;
14811
+ if (el.tagName === 'path') {
14812
+ const len = el.getTotalLength();
14813
+ if (!(len > 0)) return;
14814
+ const n = Math.max(8, Math.min(40, Math.ceil(len / 50)));
14815
+ ring = [];
14816
+ for (let i = 0; i <= n; i++) { const q = el.getPointAtLength(len * i / n); ring.push(at(el, q.x, q.y)); }
14817
+ } else if (el.tagName === 'rect') {
14818
+ const x = el.x.baseVal.value, y = el.y.baseVal.value, w = el.width.baseVal.value, h = el.height.baseVal.value;
14819
+ ring = [at(el, x, y), at(el, x + w, y), at(el, x + w, y + h), at(el, x, y + h)];
14820
+ } else if (el.tagName === 'polygon') {
14821
+ ring = [...el.points].map(q => at(el, q.x, q.y));
14822
+ } else {
14823
+ const cx = el.cx.baseVal.value, cy = el.cy.baseVal.value;
14824
+ const rx = el.tagName === 'circle' ? el.r.baseVal.value : el.rx.baseVal.value, ry = el.tagName === 'circle' ? rx : el.ry.baseVal.value;
14825
+ ring = [];
14826
+ for (let i = 0; i < 12; i++) ring.push(at(el, cx + rx * Math.cos(i * Math.PI / 6), cy + ry * Math.sin(i * Math.PI / 6)));
14827
+ }
14828
+ if (ring && ring.length >= 3 && ring.every(q => isFinite(q[0]) && isFinite(q[1]))) pick.push(ring);
14829
+ });
14830
+ } catch (e) { /* the box below stands in */ }
14831
+ chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids], defsById, pick, bb: { x: bb.x, y: bb.y, width: bb.width, height: bb.height },
14580
14832
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
14581
14833
  }
14582
14834
  } catch (e) {
@@ -14852,7 +15104,8 @@ self.onmessage = e => {
14852
15104
  // to kdeGrid in a worker, d3-contour cuts the bands at fractions of the maximum (style.kdelevels,
14853
15105
  // default 0.15 / 0.30 / 0.50 / 0.70), each band the area at or above its threshold, so they
14854
15106
  // stack, colored from style.colorscheme, drawn with style.fillopacity. The bandwidth is
14855
- // style.bandwidth (km) or, without it, follows the zoom (kdeBandwidthKm).
15107
+ // style.bandwidth (km) or, without it, follows the zoom (kdeBandwidthKm); style.bandwidthscale
15108
+ // (default 1) multiplies either, style.bandwidthzoompow (default 1) flattens the zoom dependence, so a radius control can stay zoom-aware without lagging a redraw.
14856
15109
  // The result belongs to a view: while the map moves the last bands stay, and once it stands still
14857
15110
  // (and the view, the records, the binding or the bandwidth changed) the new ones are computed
14858
15111
  // after a short pause and drawn by a redraw — never more than one computation wanted at a time.
@@ -14869,7 +15122,18 @@ self.onmessage = e => {
14869
15122
  const source = this._activeFeatures || this.features;
14870
15123
  if (this._kdeSource !== source) { this._kdeSource = source; this._kdeSourceId = (this._kdeSourceId || 0) + 1; }
14871
15124
  const flatZoom = mapLibreToFlatZoom(zoom);
14872
- const bwKm = styleNum(this.style.bandwidth) > 0 ? styleNum(this.style.bandwidth) : kdeBandwidthKm(flatZoom);
15125
+ const bwScale = styleNum(this.style.bandwidthscale) > 0 ? styleNum(this.style.bandwidthscale) : 1;
15126
+ // style.bandwidthzoompow (0..1) flattens the zoom dependence: the radius is the table's ratio to its
15127
+ // value at the reference flat zoom (style.bandwidthzoomref, default 12), raised to this power
15128
+ // (0 = constant). Without style.bandwidth the radius is the table's, default power 1 (as the fire
15129
+ // pages); WITH style.bandwidth (km) that is the radius AT the reference zoom, default power 0 (fixed).
15130
+ const explicitKm = styleNum(this.style.bandwidth) > 0 ? styleNum(this.style.bandwidth) : 0;
15131
+ const powStyle = styleNum(this.style.bandwidthzoompow);
15132
+ const zoomPow = powStyle >= 0 ? Math.min(1, powStyle) : (explicitKm ? 0 : 1);
15133
+ const refZoom = styleNum(this.style.bandwidthzoomref) > 0 ? styleNum(this.style.bandwidthzoomref) : 12;
15134
+ const tableKm = kdeBandwidthKm(flatZoom), refKm = kdeBandwidthKm(refZoom);
15135
+ const zoomRatio = zoomPow === 1 ? tableKm / refKm : Math.pow(tableKm / refKm, zoomPow);
15136
+ const bwKm = (explicitKm ? explicitKm * zoomRatio : (zoomPow === 1 ? tableKm : refKm * zoomRatio)) * bwScale;
14873
15137
  const maxCells = kdeMaxCells(flatZoom);
14874
15138
  const levels = kdeLevels(this.style);
14875
15139
  const key = JSON.stringify([this._dataGen, this._kdeSourceId, this.binding, bwKm, maxCells, levels, bbox.map(v => Math.round(v * 1e4) / 1e4)]);
@@ -14886,6 +15150,7 @@ self.onmessage = e => {
14886
15150
  this._kdeBands = kdeBands(grid, levels, () => contours());
14887
15151
  this._kdeBandsGen = (this._kdeBandsGen || 0) + 1; // every set of bands is a new layer for deck.gl
14888
15152
  this._kdeLevelCount = levels.length;
15153
+ this._kdeBandsBw = bwKm; // the radius these bands were computed for
14889
15154
  this._kdeKeyDone = key;
14890
15155
  if (this._triggerRedraw) this._triggerRedraw();
14891
15156
  }).catch(err => {
@@ -14896,6 +15161,14 @@ self.onmessage = e => {
14896
15161
  }
14897
15162
  const bands = this._kdeBands;
14898
15163
  if (!bands || !bands.features.length) return [];
15164
+ // bands kept from before the layer was switched off belong to another view and radius: they are not
15165
+ // shown on switching on until the right ones are computed (a wrong-radius density flashing first
15166
+ // reads as flicker). Once the right ones are there the gate opens, and while the map zooms the
15167
+ // last bands stay as before — a gap on every zoom step would be worse.
15168
+ if (this._kdeShowGate) {
15169
+ if (this._kdeBandsBw !== bwKm) return [];
15170
+ this._kdeShowGate = false;
15171
+ }
14899
15172
  const colors = kdeBandColors(this.style.colorscheme, this._kdeLevelCount || levels.length);
14900
15173
  return [new GeoJsonLayer({
14901
15174
  // a new id for each set of bands: deck.gl keeps a layer's buffers by id, and bands whose polygon
@@ -17708,6 +17981,12 @@ self.onmessage = e => {
17708
17981
  if (!(fontSize > 0)) continue;
17709
17982
  labels.push({ ...d, text: textOf(d.properties), fontSize, halo: itemRgb(d.properties), pixelOffset: alignOf(d) });
17710
17983
  }
17984
+ // a text field read as the value/size scans to 0 (flat's __scanValue), so every label has size 0 and nothing is drawn
17985
+ if (!labels.length && combined.length && !this._warnedNoLabels) {
17986
+ this._warnedNoLabels = true;
17987
+ console.warn(`[ixmaps-gl] theme "${this.name}": LABEL|TEXTONLY drew no labels — the size of all ${combined.length} items is 0. ` +
17988
+ 'A non-numeric (text) field bound as value/size reads as 0; bind a numeric size field.');
17989
+ }
17711
17990
  // TextLayer's outlineColor is one per layer: a layer per halo color
17712
17991
  const byHalo = new Map();
17713
17992
  for (const l of labels) {
@@ -18069,7 +18348,7 @@ self.onmessage = e => {
18069
18348
  // last built map; called while the map is still building, they run once
18070
18349
  // it is ready.
18071
18350
  const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
18072
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'attribution'];
18351
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'attribution', 'on', 'off'];
18073
18352
  const _mapHandles = Object.create(null);
18074
18353
  function makeMapHandle(szMap) {
18075
18354
  const h = {};
@@ -18087,6 +18366,9 @@ self.onmessage = e => {
18087
18366
  };
18088
18367
  }
18089
18368
  h.getZoom = () => { const a = api(); return a ? a.getZoom() : undefined; };
18369
+ h.getBounds = () => getBounds();
18370
+ // the MapLibre map itself (undefined until the map is built)
18371
+ h.getMapLibre = () => { const a = api(); return a ? a.map : undefined; };
18090
18372
  h.getThemeObj = id => getThemeObj(id);
18091
18373
  // flat's mapApi.message(szText) (htmlgui_flat.js 1381): a short status
18092
18374
  // line over the map — the same accepted no-op as the global
@@ -18198,6 +18480,12 @@ self.onmessage = e => {
18198
18480
  return [{ lat: Math.max(b[0].lat, -85.05), lng: b[0].lng }, { lat: Math.min(b[1].lat, 85.05), lng: b[1].lng }];
18199
18481
  }
18200
18482
 
18483
+ // flat's mapApi.getBounds() (htmlgui_flat.js): [swLat, swLng, neLat, neLng] — the same box as getBoundingBox
18484
+ function getBounds() {
18485
+ const b = getBoundingBox();
18486
+ return b ? [b[0].lat, b[0].lng, b[1].lat, b[1].lng] : null;
18487
+ }
18488
+
18201
18489
  // flat's ixmaps.setTitle(html) / setTitleBox(text, color) (htmlgui.js
18202
18490
  // 1311-1331): a message line over the last built map, same markup as
18203
18491
  // flat's default (legend not aligned left); '' clears it.
@@ -18743,7 +19031,7 @@ self.onmessage = e => {
18743
19031
  global.ixmaps = {
18744
19032
  layer, Layer: layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
18745
19033
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
18746
- markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
19034
+ markThemeClass, unmarkThemeClass, getBoundingBox, getBounds, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
18747
19035
  // flat's ixmaps.__formatValue (ui/js/tools/legend.js 451-553) — pages call
18748
19036
  // it for statistics/legend values; legend's own variant differs from
18749
19037
  // format.js's only in the SPACE flag, which flatFormatValue covers too
@@ -18806,7 +19094,7 @@ self.onmessage = e => {
18806
19094
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
18807
19095
  global.__ixmapsGlInternals = {
18808
19096
  IXMAPS_GL_VERSION, applyFlatMaxCharts, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, pieSliceLayout, pieValueLabelLayout, isBarChart, flatBarLayout, flatValueRules, flatPieRecord, flatPieAccumulate, flatPieItemValues, flatVectorItems, orthographicPlane, vectorArcHeight, pathBackIndex, greatCircleMeters, cameraOptions, bezierVectorLayout, cubicBezierPoints, arrowMarkerTriangle, fadeGradientStops, flatToArray, hashUnit, flowDashPhase, multiQuadOffsets, pixelOffsetLngLat, chartTextTransmittance, insideSymbolShape,
18809
- chartBoxLayout, choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
19097
+ splitMapEvents, getBounds, chartBoxLayout, choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
18810
19098
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
18811
19099
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
18812
19100
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
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",