ixmaps-gl 0.2.15 → 0.2.16

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 +13 -3
  2. package/ixmaps-gl.js +237 -156
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -114,6 +114,16 @@ ixmaps.map().theme("mytheme").show(); // also hide(), toggle(), remove(),
114
114
  // replace(theme), markClass(n), unmarkClass(n)
115
115
  ```
116
116
 
117
+ Projection changes never need the project machinery: `map().setProjection("globe")` (or `"mercator"`)
118
+ swaps the MapLibre projection and rebuilds the projection-dependent layers — themes, data and the
119
+ current view are never touched (unlike `setProjectJSON`, which re-defines every theme to change
120
+ the projection and, without `"keepview"`, resets the view).
121
+
122
+ `CHART|USER` themes (a page's own `userdraw` chart function) render as live SVG in a pane over
123
+ the map, as flat renders them — vector-crisp at every zoom, labels as real text, following every
124
+ pan via transform-only updates (≈3ms for 8,000 charts). Picking/tooltips run on a transparent
125
+ deck layer at the same positions.
126
+
117
127
  The historic global forms remain as compatibility wrappers and dispatch on arity: the canonical
118
128
  theme-first `ixmaps.changeThemeStyle(themeId, "style:value", "set")` and `ixmaps.removeTheme(id)`
119
129
  (htmlgui.js), the handle method `map().changeThemeStyle(theme, style, flag)`, and the older
@@ -233,7 +243,7 @@ of your own project files gl can render.
233
243
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
234
244
 
235
245
  ```html
236
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.14/ixmaps-gl.js"></script>
246
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.16/ixmaps-gl.js"></script>
237
247
  ```
238
248
 
239
249
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -241,13 +251,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
241
251
  browser script, which defines the global `ixmaps`):
242
252
 
243
253
  ```html
244
- <script src="https://unpkg.com/ixmaps-gl@0.2.14/ixmaps-gl.js"></script>
254
+ <script src="https://unpkg.com/ixmaps-gl@0.2.16/ixmaps-gl.js"></script>
245
255
  ```
246
256
 
247
257
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
248
258
 
249
259
  ```html
250
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.14/ixmaps-gl.js"></script>
260
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.16/ixmaps-gl.js"></script>
251
261
  <div id="map_div" style="position:absolute;inset:0;"></div>
252
262
 
253
263
  <script>
package/ixmaps-gl.js CHANGED
@@ -82,7 +82,7 @@
82
82
  // ---------------------------------------------------------------
83
83
  // this engine's release (= package.json "version", checked by
84
84
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
85
- const IXMAPS_GL_VERSION = '0.2.15';
85
+ const IXMAPS_GL_VERSION = '0.2.16';
86
86
 
87
87
  const LIB_URLS = {
88
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -1411,7 +1411,7 @@
1411
1411
  const key = String(id), area = geometryArea(f.geometry);
1412
1412
  if (!flatShapeWins(best.get(key), area)) continue;
1413
1413
  const pos = flatShapeCenter(f.geometry);
1414
- if (pos) best.set(key, { area, pos });
1414
+ if (pos) best.set(key, { area, pos, areaKm2: flatShapeAreaKm2(rt, f) });
1415
1415
  }
1416
1416
  }
1417
1417
  rt._positionIndex = best;
@@ -1445,7 +1445,11 @@
1445
1445
  const v = row[lookupField];
1446
1446
  if (v == null || v === '') continue;
1447
1447
  const hit = index.get(flatLookupKey(v, spec.style));
1448
- if (hit) features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: hit.pos }, properties: row });
1448
+ // the joined feature is a POINT — DENSITY (value per km² of the
1449
+ // joined shape, as flat) needs the shape's km² area carried along
1450
+ // (featureAreaKm2 prefers this marker), exactly as the choropleth
1451
+ // join stamps it
1452
+ if (hit) features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: hit.pos }, properties: row, _flatAreaKm2: hit.areaKm2 });
1449
1453
  }
1450
1454
  }
1451
1455
  return { type: 'FeatureCollection', features };
@@ -2010,16 +2014,57 @@
2010
2014
  // the user chart's size unit: flat's normalX(15) = 300 is the chart's
2011
2015
  // normal radius (maxSize), shown at gl's normal bubble radius
2012
2016
  const USER_CHART_MAX_SIZE = 300;
2013
- // raster sizes (px) of a user chart's image — see _userChartIcon
2014
- const USER_CHART_RASTER = [16, 24, 32, 48, 64, 96, 128, 192, 256];
2017
+ // ---- the pane USER charts render into: one per map, live over the canvas ----
2018
+ // flat draws its user charts as LIVE SVG over the map — vector-crisp at
2019
+ // every zoom, labels as real text. The first port here rasterized every
2020
+ // chart into one texture atlas instead, and a page with thousands of
2021
+ // charts got ~50 px per chart (the atlas area is fixed) — icons scaled
2022
+ // up several times over: blur, mushy gradients, unreadable labels. In
2023
+ // this pane each chart is a <g> mounted once; a map 'move' only updates
2024
+ // its transform (translate + scale), which the browser composites —
2025
+ // the same thing flat's own engine does over Leaflet, at the same item
2026
+ // counts. Keyed by the map's options object, so runtimes defined later
2027
+ // (loadProject, post-build map.layer) find it too.
2028
+ const _chartHosts = new WeakMap();
2029
+ function chartHostFor(mapOptions, map, el) {
2030
+ let host = _chartHosts.get(mapOptions);
2031
+ if (host) return host;
2032
+ const pane = document.createElementNS(SVG_NS, 'svg');
2033
+ pane.setAttribute('class', 'ix-user-charts');
2034
+ pane.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;overflow:visible;z-index:3;';
2035
+ const defs = document.createElementNS(SVG_NS, 'defs');
2036
+ pane.appendChild(defs);
2037
+ el.appendChild(pane);
2038
+ host = { pane, defs, map, el, runtimes: new Set(), raf: 0 };
2039
+ host.requestUpdate = () => {
2040
+ if (host.raf) return;
2041
+ host.raf = global.requestAnimationFrame(() => { host.raf = 0; host.update(); });
2042
+ };
2043
+ // transform-only per move: positions follow the map live; the charts'
2044
+ // own markup is redrawn on the settled-refresh cadence (freezeOnPan)
2045
+ host.update = () => {
2046
+ const w = el.clientWidth, h = el.clientHeight, margin = 256;
2047
+ for (const rt of host.runtimes) {
2048
+ const items = rt._chartItems;
2049
+ if (!items) continue;
2050
+ for (const it of items) {
2051
+ const p = map.project(it.coords);
2052
+ const off = p.x < -margin || p.y < -margin || p.x > w + margin || p.y > h + margin;
2053
+ if (off) { if (it.el.style.display !== 'none') it.el.style.display = 'none'; continue; }
2054
+ if (it.el.style.display === 'none') it.el.style.display = '';
2055
+ it.el.setAttribute('transform', `translate(${p.x} ${p.y}) scale(${it.s}) translate(${-it.ox} ${-it.oy})`);
2056
+ }
2057
+ }
2058
+ };
2059
+ map.on('move', host.requestUpdate);
2060
+ map.on('resize', host.requestUpdate);
2061
+ _chartHosts.set(mapOptions, host);
2062
+ return host;
2063
+ }
2015
2064
  // ?ixgl-debug in the page URL: ixmaps-gl logs what it draws (console.info)
2016
2065
  function glDebug() {
2017
2066
  try { return new URLSearchParams(global.location.search).has('ixgl-debug'); } catch (e) { return false; }
2018
2067
  }
2019
- // the pixel area all of a layer's chart images may take together: deck.gl
2020
- // packs them into one texture (1024 px wide), which must stay well below
2021
- // the GPU's texture size limit — see _userChartIcon
2022
- const USER_CHART_ATLAS_AREA = 1024 * 8192;
2023
2068
  class LayerBuilder {
2024
2069
  constructor(name) {
2025
2070
  this.name = name;
@@ -2114,11 +2159,24 @@
2114
2159
  define() { return this; }
2115
2160
  }
2116
2161
 
2162
+ // flat-era pages keep the layer construct and CALL it like a function
2163
+ // when handing it back to the map later (censimenti's sidebar:
2164
+ // map().add(__theme(),'clear')) — the call returns the builder; pages also
2165
+ // chain builder methods right on the factory result, so the construct
2166
+ // delegates every one of them (chaining stays on the construct). Every
2167
+ // consumption point unwraps __ixLayer, so the object form keeps working.
2117
2168
  function layer(name, configFn) {
2118
2169
  const builder = new LayerBuilder(name);
2119
2170
  if (configFn) configFn(builder);
2120
- return builder;
2171
+ const construct = function () { return builder; };
2172
+ construct.__ixLayer = builder;
2173
+ for (const k of Object.getOwnPropertyNames(LayerBuilder.prototype)) {
2174
+ if (k === 'constructor') continue;
2175
+ construct[k] = function (...args) { const r = builder[k](...args); return r === builder ? construct : r; };
2176
+ }
2177
+ return construct;
2121
2178
  }
2179
+ const unwrapLayer = x => (typeof x === 'function' && x.__ixLayer) ? x.__ixLayer : x;
2122
2180
 
2123
2181
  // ---------------------------------------------------------------
2124
2182
  // Binding aliases. Real ixmaps-flat accepts ~50 .binding() keys and maps
@@ -2875,8 +2933,20 @@
2875
2933
  if (__hoverTooltipWatch || !el.parentElement) return;
2876
2934
  const host = el.parentElement;
2877
2935
  const attach = (tipEl) => {
2936
+ // deck nests its tooltip inside its widget root (.fill, z-index 2),
2937
+ // a stacking context under the USER-chart pane (z 3) — the tooltip's
2938
+ // own z-index can never cross that. Moved out to the map container,
2939
+ // a sibling of the pane, where z-index 7 puts it above everything;
2940
+ // same coordinate origin, deck keeps updating the same node.
2941
+ // Re-asserted on every style write (deck sets z-index 1 itself).
2942
+ const liftTooltip = () => {
2943
+ if (tipEl.parentNode !== el) el.appendChild(tipEl);
2944
+ if (tipEl.style.zIndex !== '7') tipEl.style.zIndex = 7;
2945
+ };
2946
+ liftTooltip();
2878
2947
  let lastSetTransform = null;
2879
2948
  const mo = new MutationObserver(() => {
2949
+ liftTooltip();
2880
2950
  if (tipEl.style.display === 'none' || !tipEl.textContent.trim()) return;
2881
2951
  const t = tipEl.style.transform;
2882
2952
  if (!t || t === lastSetTransform) return;
@@ -2976,7 +3046,10 @@
2976
3046
  // touches the tooltip DOM in the same tick.
2977
3047
  function hideDefaultHoverTooltip() {
2978
3048
  requestAnimationFrame(() => {
2979
- const defaultTooltipEl = el.parentElement && el.parentElement.querySelector('.deck-tooltip');
3049
+ // the hover tooltip is reparented into el (see watchHoverTooltip) —
3050
+ // look there first, then where deck originally nested it
3051
+ const defaultTooltipEl = (el && el.querySelector('.deck-tooltip')) ||
3052
+ (el.parentElement && el.parentElement.querySelector('.deck-tooltip'));
2980
3053
  if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
2981
3054
  });
2982
3055
  }
@@ -3085,6 +3158,33 @@
3085
3158
  // calls: addLegendPanel(rt), setLegendOption(value), updateSubTheme(rt),
3086
3159
  // layoutLegends().
3087
3160
  // ---------------------------------------------------------------
3161
+ // The map-level `align` option positions the legend — BOTH the native
3162
+ // panel stack (createLegend) and a page legend (map.legend(html), the
3163
+ // legendEl built in build()): one helper, so they can never disagree.
3164
+ // gl's four corners, else flat's own values (htmlgui_flat.js 421-470):
3165
+ // "…left" → left (the part before "left", else 55px), 12px from the top;
3166
+ // "…right" → right (else 25px); "center" / "top" → top center.
3167
+ // Default "top-right" per explicit request (real engine's own fallback is
3168
+ // right-anchored too, just without an opinion on vertical placement).
3169
+ function legendAlignCssFor(mapOptions) {
3170
+ const ALIGN_CSS = {
3171
+ 'top-left': 'left:10px;top:10px;',
3172
+ 'top-right': 'right:10px;top:10px;',
3173
+ 'bottom-left': 'left:10px;bottom:10px;',
3174
+ 'bottom-right': 'right:10px;bottom:10px;'
3175
+ };
3176
+ const flatAlignCss = align => {
3177
+ const a = String(align || '');
3178
+ // flat hands these to jQuery .css(), which adds px to a unitless
3179
+ // number — a raw cssText needs it explicit
3180
+ const px = v => /^\d+(\.\d+)?$/.test(v) ? v + 'px' : v;
3181
+ if (/left/.test(a)) return 'left:' + px(a.split('left')[0] || '55px') + ';top:12px;';
3182
+ if (/right/.test(a)) return 'right:' + px(a.split('right')[0] || '25px') + ';top:10px;';
3183
+ if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
3184
+ return null;
3185
+ };
3186
+ return ALIGN_CSS[mapOptions.align] || flatAlignCss(mapOptions.align) || ALIGN_CSS['top-right'];
3187
+ }
3088
3188
  function createLegend(ctx) {
3089
3189
  const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw, quietRefresh } = ctx;
3090
3190
  let addLegendPanel = () => {};
@@ -3128,6 +3228,10 @@
3128
3228
  const legendOpt = builder.mapOptions.legend;
3129
3229
  // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
3130
3230
  let legendOn = legendIsOn(legendOpt);
3231
+ // flat: a page legend (map.legend(html) → ixmaps.legend.url) REPLACES
3232
+ // the theme-generated legend — legend.js 2076 renders the layerlist
3233
+ // legend instead (useLayerListLegend) — so the native panels stay off
3234
+ if (builder._legendHtml) legendOn = false;
3131
3235
  let legendFolded = legendOpt === 'closed';
3132
3236
  {
3133
3237
  // Map-level `align` option positions the legend panel — a map-
@@ -3146,24 +3250,7 @@
3146
3250
  // shape choice earlier this session. Default "top-right" per
3147
3251
  // explicit request (real engine's own fallback is right-anchored
3148
3252
  // too, just without an opinion on vertical placement).
3149
- const ALIGN_CSS = {
3150
- 'top-left': 'left:10px;top:10px;',
3151
- 'top-right': 'right:10px;top:10px;',
3152
- 'bottom-left': 'left:10px;bottom:10px;',
3153
- 'bottom-right': 'right:10px;bottom:10px;'
3154
- };
3155
- // gl's four corners, else flat's own values (htmlgui_flat.js
3156
- // 421-470): "…left" → left (the part before "left", else 55px), 12px
3157
- // from the top; "…right" → right (else 25px); "center" / "top" → top
3158
- // center
3159
- const flatAlignCss = align => {
3160
- const a = String(align || '');
3161
- if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
3162
- if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
3163
- if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
3164
- return null;
3165
- };
3166
- const legendAlignCss = ALIGN_CSS[builder.mapOptions.align] || flatAlignCss(builder.mapOptions.align) || ALIGN_CSS['top-right'];
3253
+ const legendAlignCss = legendAlignCssFor(builder.mapOptions);
3167
3254
  // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
3168
3255
  // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
3169
3256
  // defaults to true): marking fields of a multi-field choropleth
@@ -3265,7 +3352,7 @@
3265
3352
  setLegendOption = (opt) => {
3266
3353
  builder.mapOptions.legend = opt;
3267
3354
  legendFolded = opt === 'closed';
3268
- legendOn = legendIsOn(opt);
3355
+ legendOn = builder._legendHtml ? false : legendIsOn(opt);
3269
3356
  runtimes.forEach(rt => {
3270
3357
  if (!legendOn) {
3271
3358
  if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
@@ -3873,7 +3960,7 @@
3873
3960
  this._layerBuilders.push(lb);
3874
3961
  return lb;
3875
3962
  }
3876
- this._layerBuilders.push(nameOrBuilder);
3963
+ this._layerBuilders.push(unwrapLayer(nameOrBuilder));
3877
3964
  return this;
3878
3965
  }
3879
3966
  // .Layer() — alias of .layer()
@@ -4076,6 +4163,10 @@
4076
4163
  // world once, at the cost of that zoom limit.
4077
4164
  renderWorldCopies: this._engineOptions.worldcopies !== false
4078
4165
  });
4166
+ // the live-SVG pane USER charts render into (see chartHostFor) —
4167
+ // registered here, keyed by this map's options object, so every
4168
+ // runtime (including later loadProject/map.layer ones) finds it
4169
+ chartHostFor(this._engineOptions, map, el);
4079
4170
  // ixmaps.Map(id, {mapProjection}): flat's orthographic map is MapLibre's
4080
4171
  // native globe here (same mapping as loadProject, applyProjectMap);
4081
4172
  // "ortographic" is the spelling flat pages commonly carry. Any other
@@ -4199,7 +4290,9 @@
4199
4290
 
4200
4291
  if (this._legendHtml) {
4201
4292
  const legendEl = document.createElement('div');
4202
- legendEl.style.cssText = 'position:absolute;top:0;left:0;z-index:5;pointer-events:none';
4293
+ // a page legend (map.legend(html)) is positioned by the map's
4294
+ // own align option — the same rule that places the native stack
4295
+ legendEl.style.cssText = 'position:absolute;' + legendAlignCssFor(this.mapOptions) + 'z-index:5;pointer-events:none';
4203
4296
  legendEl.innerHTML = this._legendHtml;
4204
4297
  // inside the map container, never the page's body (see the pinned
4205
4298
  // tooltip mount) — styling body breaks page-positioned elements
@@ -4325,6 +4418,7 @@
4325
4418
  // the layer name): add(theme, flag) — "replace" swaps a theme of the
4326
4419
  // same id —, replace(id, theme, flag), remove(id)
4327
4420
  add: (layerBuilder, flag) => {
4421
+ layerBuilder = unwrapLayer(layerBuilder);
4328
4422
  _chainedLayers = _chainedLayers.then(() => {
4329
4423
  if (/replace/i.test(flag || '')) {
4330
4424
  const def = layerBuilder.definition();
@@ -4336,6 +4430,7 @@
4336
4430
  return engineApi;
4337
4431
  },
4338
4432
  replace: (id, layerBuilder, flag) => {
4433
+ layerBuilder = unwrapLayer(layerBuilder);
4339
4434
  _chainedLayers = _chainedLayers.then(() => { removeThemeById(id); return engineApi.defineLayer(layerBuilder); })
4340
4435
  .catch(err => console.error('[ixmaps-gl] map.replace():', err));
4341
4436
  return engineApi;
@@ -4367,6 +4462,23 @@
4367
4462
  },
4368
4463
  setMapTypeId: (id) => engineApi.setMapType(id),
4369
4464
  mapType: (id) => engineApi.setMapType(id),
4465
+ // projection swap WITHOUT the project machinery: setProjectJSON
4466
+ // tears down and re-defines every theme to change the projection
4467
+ // (a projection toggle on a theme-heavy page paid a full data
4468
+ // re-aggregation — the power plants stage page). The projection
4469
+ // itself is one MapLibre call; the layers are projection-
4470
+ // dependent (globe far-hemisphere cull, chart placement) so they
4471
+ // are rebuilt — refreshLayers, once now and again when the new
4472
+ // projection has rendered — but THEMES and VIEW are never touched
4473
+ setProjection: (type) => {
4474
+ if (typeof map.setProjection !== 'function') { console.warn('[ixmaps-gl] map.setProjection not available on the loaded maplibre-gl build (needs >=5.0.1) — projection unchanged'); return engineApi; }
4475
+ const t = /globe/i.test(String(type || '')) ? 'globe' : 'mercator';
4476
+ try { map.setProjection({ type: t }); } catch (e) { console.warn('[ixmaps-gl] map.setProjection failed:', e); return engineApi; }
4477
+ refreshLayers();
4478
+ map.triggerRepaint();
4479
+ map.once('idle', refreshLayers);
4480
+ return engineApi;
4481
+ },
4370
4482
  // flat's getMapTypeId(): the basemap type name in use
4371
4483
  getMapTypeId: () => String(builder.mapOptions.mapType || ''),
4372
4484
  // flat's attribution (htmlgui.js htmlgui_set/getAttributionString)
@@ -4515,7 +4627,7 @@
4515
4627
  // exactly how this engine's own facet-filter/style setters below
4516
4628
  // already push responsibility for "what should happen" to the
4517
4629
  // caller rather than guessing.
4518
- defineLayer: (layerBuilder) => defineFromDefinition(layerBuilder.definition()),
4630
+ defineLayer: (layerBuilder) => defineFromDefinition(unwrapLayer(layerBuilder).definition()),
4519
4631
  // GL-PORT COMPAT: real ixmaps-flat's map handle, as a page gets it
4520
4632
  // from `myMap.then(map => map.view(...).options(...).layer(a)
4521
4633
  // .layer(b))` — the common multi-layer idiom — is chainable on the
@@ -4617,6 +4729,11 @@
4617
4729
  if (rt._unlisten) rt._unlisten();
4618
4730
  if (rt._cancelWait) rt._cancelWait();
4619
4731
  clearTimeout(rt._dependentTimer);
4732
+ // a removed theme's live USER-chart SVG is not part of the deck
4733
+ // layers — leaving its groups mounted keeps the charts on the
4734
+ // map after map.remove()/replace() (censimenti's sidebar)
4735
+ if (rt._clearUserChartPane) rt._clearUserChartPane();
4736
+ if (rt._chartHost && rt._chartHost.runtimes) rt._chartHost.runtimes.delete(rt);
4620
4737
  });
4621
4738
  const removed = removedRuntimes.length > 0;
4622
4739
  // stale-registry guard: _globalThemeRegistry is a bare name->
@@ -5336,7 +5453,7 @@
5336
5453
  // semantics — layer, view, options, require, attribution, on — keep
5337
5454
  // theirs and are not overridden.
5338
5455
  for (const m of ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5339
- 'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize',
5456
+ 'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
5340
5457
  'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
5341
5458
  'getThemes', 'theme', 'setView']) {
5342
5459
  if (!MapBuilder.prototype[m]) {
@@ -5412,6 +5529,9 @@
5412
5529
  }
5413
5530
  return promise;
5414
5531
  };
5532
+ // flat's mapApi.setView(center, zoom) (htmlgui_flat.js 1327) — the
5533
+ // same live-or-build-time split as .view() above
5534
+ promise.setView = (...args) => promise.view(...args);
5415
5535
  promise.options = (...args) => { builder.options(...args); return promise; };
5416
5536
  promise.attribution = (...args) => { builder.attribution(...args); return promise; };
5417
5537
  promise.legend = (...args) => { builder.legend(...args); return promise; };
@@ -9080,8 +9200,12 @@
9080
9200
  buildDeckLayers(zoom, bbox, liveZoom = zoom, globeCenter = null) {
9081
9201
  this._lastZoom = zoom;
9082
9202
  this._lastBbox = bbox;
9083
- if (this._hidden) return [];
9084
- if (!this.flags.has('FEATURE') && !this.flags.has('FEATURES') && chartHiddenByScale(this.style, liveZoom)) return [];
9203
+ // the live USER-chart SVG pane is not part of the deck layers — an
9204
+ // early return here must also unmount its groups, or the pane keeps
9205
+ // showing the last drawing after the theme went out of scale (or was
9206
+ // hidden via setThemeVisible)
9207
+ if (this._hidden) { this._clearUserChartPane(); return []; }
9208
+ if (!this.flags.has('FEATURE') && !this.flags.has('FEATURES') && chartHiddenByScale(this.style, liveZoom)) { this._clearUserChartPane(); return []; }
9085
9209
  // DOT is checked FIRST, matching the real engine's own precedence
9086
9210
  // (its DOT branch is a loop-level fast-path that bypasses the whole
9087
9211
  // drawChart/modifier machinery entirely — a layer flagged DOT never
@@ -9952,6 +10076,11 @@
9952
10076
  }
9953
10077
  // USER charts (see resolveUserChartFunction): one icon per chart, drawn
9954
10078
  // by the page's function; null → no such function (drawn as bubbles)
10079
+ _clearUserChartPane() {
10080
+ if (this._chartPaneGroup) this._chartPaneGroup.remove();
10081
+ this._chartPaneGroup = null;
10082
+ this._chartItems = null;
10083
+ }
9955
10084
  _buildUserChartLayers(combined, liveZoom, sizeValueOf) {
9956
10085
  const name = String(this.style.userdraw || '');
9957
10086
  const draw = resolveUserChartFunction(name);
@@ -9987,9 +10116,17 @@
9987
10116
  // radius of it (see USER_CHART_MAX_SIZE)
9988
10117
  const pxPerUnit = NORMAL_RADIUS_PX * objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1) / USER_CHART_MAX_SIZE;
9989
10118
  const flag = theme.szFlag;
9990
- const maxRasterPx = Math.sqrt(USER_CHART_ATLAS_AREA / Math.max(1, combined.length));
9991
- const data = [];
9992
- let pending = 0;
10119
+ const chartHost = _chartHosts.get(this.mapOptions);
10120
+ if (!chartHost) {
10121
+ if (!this._userChartWarned) { this._userChartWarned = true; console.warn(`[ixmaps-gl] USER chart "${name}": no chart pane — drawn as bubbles`); }
10122
+ return null;
10123
+ }
10124
+ // this view's charts, mounted as live <g> elements in the pane (see
10125
+ // chartHostFor) — flat's own SVG rendering: no atlas, no raster, no
10126
+ // async image decode, the whole drawing is synchronous
10127
+ const items = [];
10128
+ const defsSeen = new Set();
10129
+ let html = '';
9993
10130
  for (const d of combined) {
9994
10131
  const value = valueOf(d);
9995
10132
  // the item's class (range classes set it for groups too — a negative
@@ -9997,133 +10134,69 @@
9997
10134
  const cat = d.properties.cat != null ? d.properties.cat : (d.properties.counts ? dominant(d.properties.counts) : 0);
9998
10135
  const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
9999
10136
  const size = radiusPx / pxPerUnit;
10000
- const icon = this._userChartIcon(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d), pxPerUnit, maxRasterPx);
10001
- if (!icon || icon.failed) continue;
10002
- if (!icon.canvas) { pending++; continue; }
10003
- data.push({ d, icon });
10004
- }
10005
- // The charts' images come asynchronously (an SVG decodes as an image):
10006
- // until every chart of this view has its image the previous complete
10007
- // drawing stays, then the new one replaces it in one piece — as flat
10008
- // shows a theme once it is drawn. (Drawing the ready part meanwhile,
10009
- // and redrawing batch by batch as images came, left the map on an
10010
- // incomplete or old state after many zooms and pans.)
10011
- if (glDebug()) console.info(`[ixmaps-gl debug] ${themeIdOf(this)}: zoom ${mapLibreToFlatZoom(liveZoom).toFixed(2)}, ${combined.length} charts, ${data.length} ready, ${pending} pending${this._viewMoving ? ' (moving)' : ''}`);
10012
- if (pending) {
10013
- this._userChartWaiting = true;
10014
- return this._userChartLastLayers || [];
10015
- }
10016
- this._userChartWaiting = false;
10017
- if (!data.length) return (this._userChartLastLayers = []);
10018
- // the icon texture is built here, from the chart canvases, and given
10019
- // to deck.gl whole (iconAtlas + iconMapping): deck.gl has nothing to
10020
- // load, and the texture is exactly this view's charts
10021
- const atlas = this._userChartAtlas(data.map(e => e.icon));
10022
- this._userChartLastLayers = [new IconLayer({
10137
+ const chart = this._userChartSvg(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d));
10138
+ if (!chart) continue;
10139
+ html += chart.markup;
10140
+ // the chart's own defs (gradients) go to the pane once per id —
10141
+ // several charts share one (pinnacle's per-color gradient)
10142
+ for (const id of chart.ids) {
10143
+ if (defsSeen.has(id)) continue;
10144
+ defsSeen.add(id);
10145
+ const def = this._userChartGroup.ownerDocument.getElementById(id);
10146
+ if (def) chartHost.defs.appendChild(def.cloneNode(true));
10147
+ }
10148
+ items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit,
10149
+ r: Math.max(chart.vw, chart.vh) * pxPerUnit / 2 });
10150
+ }
10151
+ if (glDebug()) console.info(`[ixmaps-gl debug] ${themeIdOf(this)}: zoom ${mapLibreToFlatZoom(liveZoom).toFixed(2)}, ${combined.length} charts, ${items.length} drawn${this._viewMoving ? ' (moving)' : ''}`);
10152
+ if (!items.length) {
10153
+ if (this._chartPaneGroup) this._chartPaneGroup.remove();
10154
+ this._chartPaneGroup = null;
10155
+ this._chartItems = null;
10156
+ return (this._userChartLastLayers = []);
10157
+ }
10158
+ if (this._chartPaneGroup) this._chartPaneGroup.remove();
10159
+ this._chartPaneGroup = document.createElementNS(SVG_NS, 'g');
10160
+ chartHost.pane.appendChild(this._chartPaneGroup);
10161
+ this._chartPaneGroup.innerHTML = html;
10162
+ const els = this._chartPaneGroup.children;
10163
+ for (let i = 0; i < items.length; i++) items[i].el = els[i];
10164
+ this._chartItems = items;
10165
+ chartHost.runtimes.add(this);
10166
+ chartHost.update();
10167
+ // picking: a transparent circle per chart — the VISIBLE chart is the
10168
+ // live SVG in the pane; this deck layer only serves hover, click and
10169
+ // the tooltips (pickedFeature unwraps the {d, icon} entry shape)
10170
+ this._userChartLastLayers = [new ScatterplotLayer({
10023
10171
  id: `ix-bubbles-${this.name}`,
10024
- data, pickable: true,
10025
- iconAtlas: atlas.canvas, iconMapping: atlas.mapping,
10172
+ data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
10173
+ pickable: true,
10026
10174
  getPosition: e => e.d.geometry.coordinates,
10027
- getIcon: e => e.icon.id,
10028
- getSize: e => e.icon.unitHeight * pxPerUnit,
10029
- sizeUnits: 'pixels',
10030
- billboard: true,
10031
- // no mipmaps: the charts are drawn at their size on screen
10032
- textureParameters: { minFilter: 'linear', magFilter: 'linear' },
10175
+ getRadius: e => Math.max(2, e.r),
10176
+ radiusUnits: 'pixels', radiusMinPixels: 1,
10177
+ stroked: false, filled: true, getFillColor: [0, 0, 0, 0],
10033
10178
  parameters: ICON_LAYER_GLOBE_PARAMETERS
10034
10179
  })];
10035
10180
  return this._userChartLastLayers;
10036
10181
  }
10037
- // one texture for a view's charts: rows of images, 2048 px wide; the
10038
- // same set of charts keeps its texture
10039
- _userChartAtlas(icons) {
10040
- const ids = icons.map(i => i.id);
10041
- const sig = [...new Set(ids)].sort().join('\n');
10042
- if (this._userChartAtlasCache && this._userChartAtlasCache.sig === sig) return this._userChartAtlasCache;
10043
- const W = 2048, PAD = 2, mapping = {}, seen = new Set(), place = [];
10044
- let x = 0, y = 0, rowH = 0;
10045
- for (const icon of icons) {
10046
- if (seen.has(icon.id)) continue;
10047
- seen.add(icon.id);
10048
- if (x + icon.width + PAD > W) { x = 0; y += rowH + PAD; rowH = 0; }
10049
- place.push([icon, x, y]);
10050
- mapping[icon.id] = { x, y, width: icon.width, height: icon.height, anchorX: icon.anchorX, anchorY: icon.anchorY, mask: false };
10051
- x += icon.width + PAD; rowH = Math.max(rowH, icon.height);
10052
- }
10053
- const canvas = document.createElement('canvas');
10054
- canvas.width = W; canvas.height = Math.max(1, y + rowH);
10055
- const ctx = canvas.getContext('2d');
10056
- for (const [icon, px, py] of place) ctx.drawImage(icon.canvas, px, py);
10057
- this._userChartAtlasCache = { sig, canvas, mapping };
10058
- return this._userChartAtlasCache;
10059
- }
10060
- // user chart images become ready one by one: one redraw once they are
10061
- _scheduleUserChartRedraw() {
10062
- if (this._userChartRedrawTimer) return;
10063
- this._userChartRedrawTimer = setTimeout(() => {
10064
- this._userChartRedrawTimer = null;
10065
- if (this._userChartWaiting && this._triggerRedraw) this._triggerRedraw();
10066
- }, 30);
10067
- }
10068
- // The chart is drawn once (its SVG kept in _userChartSvgs) and rasterised
10069
- // at its size on screen (× devicePixelRatio, in steps of USER_CHART_RASTER
10070
- // px) onto a canvas (icon.canvas, once the SVG image has decoded): one
10071
- // image per chart at a fixed 256 px made the texture of a few hundred
10072
- // charts outgrow the GPU's texture size — WebGL errors, and deck.gl
10073
- // frames that failed, leaving the map un-updated after a zoom.
10074
- _userChartIcon(draw, name, theme, flag, d, value, cat, size, nSize, pxPerUnit, maxRasterPx = Infinity) {
10075
- const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${Math.round(size * 2) / 2}|${flag}`;
10182
+ // the chart's SVG, cached by its draw parameters — a failing draw
10183
+ // (undefined) is tried again on the next redraw (null: draws nothing)
10184
+ _userChartSvg(draw, name, theme, flag, d, value, cat, size, nSize) {
10185
+ // the title is part of the markup now (item.szTitle) — items sharing
10186
+ // value/size/class but not a title must not share a cache entry
10187
+ const raw = d.properties.raw || d.properties;
10188
+ const titleField = this.style.titlefield || this.binding.title;
10189
+ const title = titleField && raw && raw[titleField] != null ? String(raw[titleField]) : undefined;
10190
+ const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${Math.round(size * 2) / 2}|${flag}|${title || ''}`;
10076
10191
  if (!this._userChartSvgs) this._userChartSvgs = new Map();
10077
- if (!this._userChartRasters) this._userChartRasters = new Map();
10078
10192
  let chart = this._userChartSvgs.get(base);
10079
10193
  if (chart === undefined) {
10080
10194
  chart = this._drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize);
10081
- if (chart === undefined) return null; // a failing draw is tried again
10195
+ if (chart === undefined) return null;
10082
10196
  if (this._userChartSvgs.size >= ICON_CACHE_MAX) this._userChartSvgs.delete(this._userChartSvgs.keys().next().value);
10083
10197
  this._userChartSvgs.set(base, chart);
10084
10198
  }
10085
- if (!chart) return null;
10086
- const dpr = (global.devicePixelRatio || 1);
10087
- const want = Math.max(chart.vw, chart.vh) * pxPerUnit * dpr;
10088
- const fits = USER_CHART_RASTER.filter(r => r <= maxRasterPx);
10089
- const steps = fits.length ? fits : USER_CHART_RASTER.slice(0, 1);
10090
- const px = steps.find(r => r >= want) || steps[steps.length - 1];
10091
- const key = base + '|' + px;
10092
- const rasters = this._userChartRasters;
10093
- if (rasters.has(key)) {
10094
- // least recently used goes first
10095
- const hit = rasters.get(key);
10096
- rasters.delete(key); rasters.set(key, hit);
10097
- return hit;
10098
- }
10099
- const s = px / Math.max(chart.vw, chart.vh);
10100
- const w = Math.max(1, Math.round(chart.vw * s)), h = Math.max(1, Math.round(chart.vh * s));
10101
- const icon = { canvas: null, width: w, height: h, anchorX: (chart.ox - chart.vx) * s, anchorY: (chart.oy - chart.vy) * s, id: key, unitHeight: chart.vh };
10102
- // a chart whose image fails is left out (not waited for)
10103
- const failed = e => {
10104
- icon.failed = true;
10105
- if (!this._userChartImageError) console.warn(`[ixmaps-gl] USER chart ${name}: chart image failed`, e);
10106
- this._userChartImageError = e || true;
10107
- this._scheduleUserChartRedraw();
10108
- };
10109
- // the SVG goes through a canvas: deck.gl blends an SVG image as decoded
10110
- // (premultiplied), which darkened half-transparent fills (a
10111
- // fadenegative arrow turned grey)
10112
- const img = new Image();
10113
- img.onerror = failed;
10114
- img.onload = () => {
10115
- try {
10116
- const canvas = document.createElement('canvas');
10117
- canvas.width = w; canvas.height = h;
10118
- canvas.getContext('2d').drawImage(img, 0, 0, w, h);
10119
- icon.canvas = canvas;
10120
- } catch (e) { failed(e); return; }
10121
- this._scheduleUserChartRedraw();
10122
- };
10123
- img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(chart.svg.replace('__W__', w).replace('__H__', h));
10124
- if (rasters.size >= ICON_CACHE_MAX) rasters.delete(rasters.keys().next().value);
10125
- rasters.set(key, icon);
10126
- return icon;
10199
+ return chart || null;
10127
10200
  }
10128
10201
  // the page's chart function draws one chart into the hidden SVG: its
10129
10202
  // markup and box (chart units), null when it draws nothing, undefined
@@ -10134,11 +10207,19 @@
10134
10207
  const color = this._userChartColor(cat);
10135
10208
  const raw = d.properties.raw || d.properties;
10136
10209
  const label = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : undefined;
10210
+ // flat's chart title (style.titlefield, maptheme's item.szTitle) —
10211
+ // pinnacleChart draws it below the value text (chart.js 204-216)
10212
+ const titleField = this.style.titlefield || this.binding.title;
10213
+ const title = titleField && raw && raw[titleField] != null ? String(raw[titleField]) : undefined;
10137
10214
  let chart = null;
10138
10215
  try {
10139
10216
  const ret = draw.call(global.ixmaps, document, {
10140
10217
  target: g, theme, value, values: [value], size, maxSize: USER_CHART_MAX_SIZE, color, class: cat, flag,
10141
- item: { szColor: color, szLabel: label, nCount: d.properties.point_count || 1, nSize }, dbRecord: raw
10218
+ // flat hands the chart the item's color CLASS object (maptheme's
10219
+ // ColorScheme entry) — pinnacleChart reads ccolor.lowColor for its
10220
+ // VALUES label fill (usercharts/d3/chart.js 199)
10221
+ ccolor: { szColor: color, lowColor: color, highColor: color },
10222
+ item: { szColor: color, szLabel: label, szTitle: title, nCount: d.properties.point_count || 1, nSize }, dbRecord: raw
10142
10223
  });
10143
10224
  const bb = ret ? g.getBBox() : null;
10144
10225
  if (bb && bb.width > 0 && bb.height > 0) {
@@ -10150,7 +10231,7 @@
10150
10231
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
10151
10232
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
10152
10233
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
10153
- chart = { vx, vy, vw, vh, ox, oy,
10234
+ chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids],
10154
10235
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
10155
10236
  }
10156
10237
  } catch (e) {
@@ -11506,7 +11587,7 @@
11506
11587
  // last built map; called while the map is still building, they run once
11507
11588
  // it is ready.
11508
11589
  const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
11509
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
11590
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
11510
11591
  const _mapHandles = Object.create(null);
11511
11592
  function makeMapHandle(szMap) {
11512
11593
  const h = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
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",