ixmaps-gl 0.2.14 → 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 +38 -3
  2. package/ixmaps-gl.js +418 -202
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -101,6 +101,41 @@ an id wins, a polygon's position is flat's shape center (the vertex mean in Merc
101
101
  `chartlower` (else `layerupper`/`layerlower`) hide a theme outside their scales; `.filter()` takes
102
102
  flat's grammar (`WHERE a > 5 AND b NOT x`, `LIKE`, `IN`, `BETWEEN`, `$field$`, or a plain regex).
103
103
 
104
+ ### Handle API
105
+
106
+ The main interface is flat's handle chain: `ixmaps.map()` — or `ixmaps.map("name")` for the map
107
+ with that `name` in its `Map()` options, so several maps on one page can be addressed separately
108
+ (no name: the last built map, as in flat). From the map handle, `map("name").theme("themename")`
109
+ returns the theme handle (flat's `themeApi`):
110
+
111
+ ```js
112
+ ixmaps.map("map1").theme("mytheme").changeStyle("scale:1.2", "factor");
113
+ ixmaps.map().theme("mytheme").show(); // also hide(), toggle(), remove(),
114
+ // replace(theme), markClass(n), unmarkClass(n)
115
+ ```
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
+
127
+ The historic global forms remain as compatibility wrappers and dispatch on arity: the canonical
128
+ theme-first `ixmaps.changeThemeStyle(themeId, "style:value", "set")` and `ixmaps.removeTheme(id)`
129
+ (htmlgui.js), the handle method `map().changeThemeStyle(theme, style, flag)`, and the older
130
+ map-first pages calling `ixmaps.changeThemeStyle(null|mapName, themeId, style, flag)` /
131
+ `ixmaps.removeTheme(null|mapName, id)` / `ixmaps.setBasemapOpacity(null|mapName, opacity, mode)`
132
+ — where a registered map name targets that map and a null or unknown name the last built one.
133
+ The `ixmaps.Map(div, opts, callback)` callback receives the builder for its own
134
+ `.options().view().layer()` chain, which also carries flat's mapApi handle methods
135
+ (`setView`, `replace`, `show`, `getThemes`, … — htmlgui_flat.js), each delegated to the handle
136
+ of that map's options name — so a page that keeps the callback object as its map handle,
137
+ as flat pages do, works unmodified.
138
+
104
139
  ### Zoom levels
105
140
 
106
141
  Zoom numbers in the ixmaps API are ixmaps-flat's, i.e. Leaflet's (256px tiles):
@@ -208,7 +243,7 @@ of your own project files gl can render.
208
243
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
209
244
 
210
245
  ```html
211
- <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>
212
247
  ```
213
248
 
214
249
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -216,13 +251,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
216
251
  browser script, which defines the global `ixmaps`):
217
252
 
218
253
  ```html
219
- <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>
220
255
  ```
221
256
 
222
257
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
223
258
 
224
259
  ```html
225
- <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>
226
261
  <div id="map_div" style="position:absolute;inset:0;"></div>
227
262
 
228
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.14';
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 };
@@ -1951,6 +1955,10 @@
1951
1955
  // per-map handle — most real pages only ever build one map anyway. Set
1952
1956
  // once per successful build(), just before it returns engineApi below.
1953
1957
  let _lastMapApi = null;
1958
+ // flat's map instance registry (htmlgui_flat.js 2416): the built maps by
1959
+ // their Map() options name — ixmaps.map("name") is the handle of THAT
1960
+ // map, ixmaps.map() stays the last built one
1961
+ const _mapApis = Object.create(null);
1954
1962
  // resolves with the first built map's API: ixmaps.map() calls a page
1955
1963
  // makes while the map is still building wait for it
1956
1964
  let _resolveMapReady;
@@ -2006,16 +2014,57 @@
2006
2014
  // the user chart's size unit: flat's normalX(15) = 300 is the chart's
2007
2015
  // normal radius (maxSize), shown at gl's normal bubble radius
2008
2016
  const USER_CHART_MAX_SIZE = 300;
2009
- // raster sizes (px) of a user chart's image — see _userChartIcon
2010
- 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
+ }
2011
2064
  // ?ixgl-debug in the page URL: ixmaps-gl logs what it draws (console.info)
2012
2065
  function glDebug() {
2013
2066
  try { return new URLSearchParams(global.location.search).has('ixgl-debug'); } catch (e) { return false; }
2014
2067
  }
2015
- // the pixel area all of a layer's chart images may take together: deck.gl
2016
- // packs them into one texture (1024 px wide), which must stay well below
2017
- // the GPU's texture size limit — see _userChartIcon
2018
- const USER_CHART_ATLAS_AREA = 1024 * 8192;
2019
2068
  class LayerBuilder {
2020
2069
  constructor(name) {
2021
2070
  this.name = name;
@@ -2110,11 +2159,24 @@
2110
2159
  define() { return this; }
2111
2160
  }
2112
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.
2113
2168
  function layer(name, configFn) {
2114
2169
  const builder = new LayerBuilder(name);
2115
2170
  if (configFn) configFn(builder);
2116
- 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;
2117
2178
  }
2179
+ const unwrapLayer = x => (typeof x === 'function' && x.__ixLayer) ? x.__ixLayer : x;
2118
2180
 
2119
2181
  // ---------------------------------------------------------------
2120
2182
  // Binding aliases. Real ixmaps-flat accepts ~50 .binding() keys and maps
@@ -2871,8 +2933,20 @@
2871
2933
  if (__hoverTooltipWatch || !el.parentElement) return;
2872
2934
  const host = el.parentElement;
2873
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();
2874
2947
  let lastSetTransform = null;
2875
2948
  const mo = new MutationObserver(() => {
2949
+ liftTooltip();
2876
2950
  if (tipEl.style.display === 'none' || !tipEl.textContent.trim()) return;
2877
2951
  const t = tipEl.style.transform;
2878
2952
  if (!t || t === lastSetTransform) return;
@@ -2972,7 +3046,10 @@
2972
3046
  // touches the tooltip DOM in the same tick.
2973
3047
  function hideDefaultHoverTooltip() {
2974
3048
  requestAnimationFrame(() => {
2975
- 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'));
2976
3053
  if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
2977
3054
  });
2978
3055
  }
@@ -3063,10 +3140,12 @@
3063
3140
  function mount() {
3064
3141
  map.on('move', updatePinnedTooltipPosition);
3065
3142
  watchHoverTooltip();
3066
- if (el.parentElement) {
3067
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
3068
- el.parentElement.appendChild(pinnedTooltipEl);
3069
- }
3143
+ // anchor inside the map container: map.project() gives container-
3144
+ // relative pixels, and styling the page's own body (relative) breaks
3145
+ // any page element absolutely positioned against it (AirBnB's
3146
+ // #onmap-buttons on a zero-height all-float body flew off-screen)
3147
+ el.style.position = el.style.position || 'relative';
3148
+ el.appendChild(pinnedTooltipEl);
3070
3149
  }
3071
3150
  return { getTooltip, onClick, mount };
3072
3151
  }
@@ -3079,8 +3158,35 @@
3079
3158
  // calls: addLegendPanel(rt), setLegendOption(value), updateSubTheme(rt),
3080
3159
  // layoutLegends().
3081
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
+ }
3082
3188
  function createLegend(ctx) {
3083
- const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw } = ctx;
3189
+ const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw, quietRefresh } = ctx;
3084
3190
  let addLegendPanel = () => {};
3085
3191
  let setLegendOption = () => {};
3086
3192
  let updateSubTheme = () => {};
@@ -3122,6 +3228,10 @@
3122
3228
  const legendOpt = builder.mapOptions.legend;
3123
3229
  // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
3124
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;
3125
3235
  let legendFolded = legendOpt === 'closed';
3126
3236
  {
3127
3237
  // Map-level `align` option positions the legend panel — a map-
@@ -3140,24 +3250,7 @@
3140
3250
  // shape choice earlier this session. Default "top-right" per
3141
3251
  // explicit request (real engine's own fallback is right-anchored
3142
3252
  // too, just without an opinion on vertical placement).
3143
- const ALIGN_CSS = {
3144
- 'top-left': 'left:10px;top:10px;',
3145
- 'top-right': 'right:10px;top:10px;',
3146
- 'bottom-left': 'left:10px;bottom:10px;',
3147
- 'bottom-right': 'right:10px;bottom:10px;'
3148
- };
3149
- // gl's four corners, else flat's own values (htmlgui_flat.js
3150
- // 421-470): "…left" → left (the part before "left", else 55px), 12px
3151
- // from the top; "…right" → right (else 25px); "center" / "top" → top
3152
- // center
3153
- const flatAlignCss = align => {
3154
- const a = String(align || '');
3155
- if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
3156
- if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
3157
- if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
3158
- return null;
3159
- };
3160
- const legendAlignCss = ALIGN_CSS[builder.mapOptions.align] || flatAlignCss(builder.mapOptions.align) || ALIGN_CSS['top-right'];
3253
+ const legendAlignCss = legendAlignCssFor(builder.mapOptions);
3161
3254
  // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
3162
3255
  // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
3163
3256
  // defaults to true): marking fields of a multi-field choropleth
@@ -3228,15 +3321,28 @@
3228
3321
  legendStack = document.createElement('div');
3229
3322
  legendStack.className = 'ix-legend-stack';
3230
3323
  legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3231
- + 'display:flex;flex-direction:column;max-height:calc(100% - 24px);overflow-y:auto;'
3324
+ + 'display:flex;flex-direction:column;max-height:66.6%;overflow-y:auto;'
3232
3325
  + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3233
3326
  if (!document.getElementById('ix-legend-stack-css')) {
3234
3327
  const css = document.createElement('style');
3235
3328
  css.id = 'ix-legend-stack-css';
3236
- css.textContent = '.ix-legend-stack>.ix-native-legend:not([data-ixoff])~.ix-native-legend:not([data-ixoff]){border-top:1px solid rgba(128,128,128,.35)}';
3329
+ // the slider rules are flat's own legend.css .slider block
3330
+ // (355-411) — its two duplicate blocks resolved to what the
3331
+ // cascade actually shows: a 5px rounded grey track at 0.7
3332
+ // opacity (1 on hover) and a green thumb
3333
+ css.textContent = '.ix-legend-stack>.ix-native-legend:not([data-ixoff])~.ix-native-legend:not([data-ixoff]){border-top:1px solid rgba(128,128,128,.35)}'
3334
+ + '.ix-legend-stack input[type=range]{-webkit-appearance:none;appearance:none;height:5px;border-radius:10px;background:rgba(155,155,155,0.3);border:solid #888 0px;outline:none;opacity:0.7;transition:opacity .2s;}'
3335
+ + '.ix-legend-stack input[type=range]:hover{opacity:1;}'
3336
+ + '.ix-legend-stack input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:0.5em;background:#4CAF50;cursor:pointer;}'
3337
+ + '.ix-legend-stack input[type=range]::-moz-range-thumb{width:25px;height:25px;border-radius:50%;background:#4CAF50;cursor:pointer;}';
3237
3338
  document.head.appendChild(css);
3238
3339
  }
3239
- el.parentElement.appendChild(legendStack);
3340
+ // flat anchors its legend inside the map div (#map-legend), so the
3341
+ // panel's max-height resolves against the MAP's height — the stack's
3342
+ // former parent anchor collapsed it to 0 on pages whose body has
3343
+ // only floated children (height 0)
3344
+ el.style.position = el.style.position || 'relative';
3345
+ el.appendChild(legendStack);
3240
3346
  return legendStack;
3241
3347
  };
3242
3348
  // map.setLegend(value): the `legend` map option at runtime, same values as
@@ -3246,7 +3352,7 @@
3246
3352
  setLegendOption = (opt) => {
3247
3353
  builder.mapOptions.legend = opt;
3248
3354
  legendFolded = opt === 'closed';
3249
- legendOn = legendIsOn(opt);
3355
+ legendOn = builder._legendHtml ? false : legendIsOn(opt);
3250
3356
  runtimes.forEach(rt => {
3251
3357
  if (!legendOn) {
3252
3358
  if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
@@ -3281,7 +3387,7 @@
3281
3387
  rt._triggerRedraw = () => { refresh(); };
3282
3388
  if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3283
3389
  if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3284
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
3390
+ el.style.position = el.style.position || 'relative';
3285
3391
  {
3286
3392
  // .type("...|NOLEGEND") — the fourth real legend-related type()
3287
3393
  // token: opts a theme OUT of the legend entirely (real engine's
@@ -3328,23 +3434,22 @@
3328
3434
  // would hand the dark look a light bg here: its dark branch never
3329
3435
  // ran for this map, its light bg would clash with dark rows/text)
3330
3436
  if (!rt.style.legendtheme) legendColors.bg = looksDark ? '#111' : flatLook.bg;
3331
- // max-height:66% resolves against el.parentElement's own
3332
- // height (the map container, which always has a definite
3333
- // height for the map itself to render into) since this panel
3334
- // is absolutely positioned inside it — a plain percentage on
3335
- // an absolutely-positioned element's height IS legal CSS as
3336
- // long as its containing block has a definite height, which
3337
- // this one does. display:flex column + min-height:0 on the
3338
- // ROWS wrapper below (not this outer panel) is what makes
3339
- // only the row list scroll while the header/description/
3340
- // slider stay fixed in place — a flex child won't actually
3341
- // shrink to fit and scroll internally without min-height:0,
3342
- // it just overflows its flex parent instead.
3437
+ // max-height:66.6% resolves against el's own height (the map
3438
+ // container, which always has a definite height for the map
3439
+ // itself to render into) since this panel sits in the stack,
3440
+ // absolutely positioned inside el. flex:0 1 auto + min-height:0
3441
+ // lets the panel SHRINK to the stack's 2/3 cap so the ROWS
3442
+ // wrapper (its own flex:1 1 auto + min-height:0 below) scrolls
3443
+ // internally while the header/description/slider stay fixed —
3444
+ // with flex:0 0 auto the panel grew past the cap and the whole
3445
+ // stack scrolled instead. text-align:left: the page body may
3446
+ // centre everything (the AirBnB port does), the legend is left
3447
+ // aligned chrome.
3343
3448
  // one box for every theme, as flat's #map-legend (legend.js
3344
3449
  // 2200-2460): the panels stack in the legend stack in theme
3345
3450
  // order, a line between them (see layoutLegends)
3346
- panel.style.cssText = 'position:relative;flex:0 0 auto;'
3347
- + 'display:flex;flex-direction:column;'
3451
+ panel.style.cssText = 'position:relative;flex:0 1 auto;min-height:0;overflow:hidden;'
3452
+ + 'display:flex;flex-direction:column;text-align:left;'
3348
3453
  + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3349
3454
  + 'padding:10px 12px 12px;pointer-events:auto;';
3350
3455
  const stack = legendStackEl(legendColors.shadow);
@@ -3783,12 +3888,12 @@
3783
3888
  slider.min = isOpacitySlider ? '0' : '25';
3784
3889
  slider.max = isOpacitySlider ? '100' : '200';
3785
3890
  slider.value = String(initialPct);
3786
- slider.style.cssText = 'width:100%;margin-top:2px;';
3891
+ slider.style.cssText = 'width:50%;margin-top:0.4em;'; // flat's own inline slider style (legend.js 2392)
3787
3892
  slider.addEventListener('input', () => {
3788
3893
  const pct = parseInt(slider.value, 10);
3789
3894
  sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
3790
3895
  rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
3791
- refresh();
3896
+ quietRefresh();
3792
3897
  });
3793
3898
  bodyEl.appendChild(slider);
3794
3899
  }
@@ -3855,7 +3960,7 @@
3855
3960
  this._layerBuilders.push(lb);
3856
3961
  return lb;
3857
3962
  }
3858
- this._layerBuilders.push(nameOrBuilder);
3963
+ this._layerBuilders.push(unwrapLayer(nameOrBuilder));
3859
3964
  return this;
3860
3965
  }
3861
3966
  // .Layer() — alias of .layer()
@@ -4058,6 +4163,10 @@
4058
4163
  // world once, at the cost of that zoom limit.
4059
4164
  renderWorldCopies: this._engineOptions.worldcopies !== false
4060
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);
4061
4170
  // ixmaps.Map(id, {mapProjection}): flat's orthographic map is MapLibre's
4062
4171
  // native globe here (same mapping as loadProject, applyProjectMap);
4063
4172
  // "ortographic" is the spelling flat pages commonly carry. Any other
@@ -4179,12 +4288,16 @@
4179
4288
  attributionBox.set(this._attributionText != null ? this._attributionText : this.mapOptions.attribution);
4180
4289
  this._setAttribution = text => attributionBox.set(text);
4181
4290
 
4182
- if (this._legendHtml && el.parentElement) {
4291
+ if (this._legendHtml) {
4183
4292
  const legendEl = document.createElement('div');
4184
- 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';
4185
4296
  legendEl.innerHTML = this._legendHtml;
4186
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
4187
- el.parentElement.appendChild(legendEl);
4297
+ // inside the map container, never the page's body (see the pinned
4298
+ // tooltip mount) — styling body breaks page-positioned elements
4299
+ el.style.position = el.style.position || 'relative';
4300
+ el.appendChild(legendEl);
4188
4301
  }
4189
4302
 
4190
4303
  // themes are addressed by either the layer()'s own name (e.g. "AREU")
@@ -4305,6 +4418,7 @@
4305
4418
  // the layer name): add(theme, flag) — "replace" swaps a theme of the
4306
4419
  // same id —, replace(id, theme, flag), remove(id)
4307
4420
  add: (layerBuilder, flag) => {
4421
+ layerBuilder = unwrapLayer(layerBuilder);
4308
4422
  _chainedLayers = _chainedLayers.then(() => {
4309
4423
  if (/replace/i.test(flag || '')) {
4310
4424
  const def = layerBuilder.definition();
@@ -4316,6 +4430,7 @@
4316
4430
  return engineApi;
4317
4431
  },
4318
4432
  replace: (id, layerBuilder, flag) => {
4433
+ layerBuilder = unwrapLayer(layerBuilder);
4319
4434
  _chainedLayers = _chainedLayers.then(() => { removeThemeById(id); return engineApi.defineLayer(layerBuilder); })
4320
4435
  .catch(err => console.error('[ixmaps-gl] map.replace():', err));
4321
4436
  return engineApi;
@@ -4347,6 +4462,23 @@
4347
4462
  },
4348
4463
  setMapTypeId: (id) => engineApi.setMapType(id),
4349
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
+ },
4350
4482
  // flat's getMapTypeId(): the basemap type name in use
4351
4483
  getMapTypeId: () => String(builder.mapOptions.mapType || ''),
4352
4484
  // flat's attribution (htmlgui.js htmlgui_set/getAttributionString)
@@ -4495,7 +4627,7 @@
4495
4627
  // exactly how this engine's own facet-filter/style setters below
4496
4628
  // already push responsibility for "what should happen" to the
4497
4629
  // caller rather than guessing.
4498
- defineLayer: (layerBuilder) => defineFromDefinition(layerBuilder.definition()),
4630
+ defineLayer: (layerBuilder) => defineFromDefinition(unwrapLayer(layerBuilder).definition()),
4499
4631
  // GL-PORT COMPAT: real ixmaps-flat's map handle, as a page gets it
4500
4632
  // from `myMap.then(map => map.view(...).options(...).layer(a)
4501
4633
  // .layer(b))` — the common multi-layer idiom — is chainable on the
@@ -4597,6 +4729,11 @@
4597
4729
  if (rt._unlisten) rt._unlisten();
4598
4730
  if (rt._cancelWait) rt._cancelWait();
4599
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);
4600
4737
  });
4601
4738
  const removed = removedRuntimes.length > 0;
4602
4739
  // stale-registry guard: _globalThemeRegistry is a bare name->
@@ -5228,9 +5365,18 @@
5228
5365
  // the native legend: createLegend (its four hooks are what the rest
5229
5366
  // of the map calls — panels, the legend option, sub-themes, layout)
5230
5367
  ({ addLegendPanel, setLegendOption, updateSubTheme, layoutLegends } =
5231
- createLegend({ builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw }));
5368
+ createLegend({ builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw,
5369
+ // flat's legend slider (legend.js 2839-2854) patches the style and
5370
+ // calls redrawTheme — a per-theme redraw that does NOT fire
5371
+ // htmlgui_onDrawTheme, so a page's own onDrawTheme hook (this
5372
+ // port's hookStatistics re-applying its own slider's scale)
5373
+ // only reasserts on the next full redraw (pan/zoom). refresh()
5374
+ // here would notify synchronously and clobber the new scale in
5375
+ // the same tick — this quiet variant is that flat behavior.
5376
+ quietRefresh: () => { refreshLayers(); } }));
5232
5377
 
5233
5378
  _lastMapApi = engineApi;
5379
+ if (this.mapOptions && this.mapOptions.name) _mapApis[this.mapOptions.name] = engineApi;
5234
5380
  // ?ixgl-debug: ixmaps.__glDebug.check() reports, for the moment it is
5235
5381
  // called, every theme's geometry problems (invalid or mixed-dimension
5236
5382
  // coordinates, a shape far larger than the theme's others) and what
@@ -5296,6 +5442,27 @@
5296
5442
  }
5297
5443
  }
5298
5444
 
5445
+ // GL-PORT COMPAT: flat's ixmaps.Map(div, opts, cb) hands the callback a
5446
+ // mapApi HANDLE (htmlgui_flat.js 904-935) — a real page keeps it and
5447
+ // calls its methods on it (__map.setView(...), __map.replace(...) —
5448
+ // AirBnB's city selector). This engine hands the callback the BUILDER
5449
+ // for its own .options().view().layer() chain; the builder here also
5450
+ // carries flat's handle methods, delegated to the map handle of THIS
5451
+ // map's options name (calls before the map exists wait for it, exactly
5452
+ // as ixmaps.map() does). The builder's own methods with different
5453
+ // semantics — layer, view, options, require, attribution, on — keep
5454
+ // theirs and are not overridden.
5455
+ for (const m of ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5456
+ 'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
5457
+ 'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
5458
+ 'getThemes', 'theme', 'setView']) {
5459
+ if (!MapBuilder.prototype[m]) {
5460
+ MapBuilder.prototype[m] = function (...args) {
5461
+ return mapHandle(this.mapOptions && this.mapOptions.name)[m](...args);
5462
+ };
5463
+ }
5464
+ }
5465
+
5299
5466
  // named createMap (not Map) so it doesn't shadow the built-in ES6 Map
5300
5467
  // class used throughout this file — a real bug caught during testing
5301
5468
  // (every `new Map()` below silently resolved to *this* function instead)
@@ -5362,6 +5529,9 @@
5362
5529
  }
5363
5530
  return promise;
5364
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);
5365
5535
  promise.options = (...args) => { builder.options(...args); return promise; };
5366
5536
  promise.attribution = (...args) => { builder.attribution(...args); return promise; };
5367
5537
  promise.legend = (...args) => { builder.legend(...args); return promise; };
@@ -9030,8 +9200,12 @@
9030
9200
  buildDeckLayers(zoom, bbox, liveZoom = zoom, globeCenter = null) {
9031
9201
  this._lastZoom = zoom;
9032
9202
  this._lastBbox = bbox;
9033
- if (this._hidden) return [];
9034
- 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 []; }
9035
9209
  // DOT is checked FIRST, matching the real engine's own precedence
9036
9210
  // (its DOT branch is a loop-level fast-path that bypasses the whole
9037
9211
  // drawChart/modifier machinery entirely — a layer flagged DOT never
@@ -9902,6 +10076,11 @@
9902
10076
  }
9903
10077
  // USER charts (see resolveUserChartFunction): one icon per chart, drawn
9904
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
+ }
9905
10084
  _buildUserChartLayers(combined, liveZoom, sizeValueOf) {
9906
10085
  const name = String(this.style.userdraw || '');
9907
10086
  const draw = resolveUserChartFunction(name);
@@ -9937,9 +10116,17 @@
9937
10116
  // radius of it (see USER_CHART_MAX_SIZE)
9938
10117
  const pxPerUnit = NORMAL_RADIUS_PX * objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1) / USER_CHART_MAX_SIZE;
9939
10118
  const flag = theme.szFlag;
9940
- const maxRasterPx = Math.sqrt(USER_CHART_ATLAS_AREA / Math.max(1, combined.length));
9941
- const data = [];
9942
- 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 = '';
9943
10130
  for (const d of combined) {
9944
10131
  const value = valueOf(d);
9945
10132
  // the item's class (range classes set it for groups too — a negative
@@ -9947,133 +10134,69 @@
9947
10134
  const cat = d.properties.cat != null ? d.properties.cat : (d.properties.counts ? dominant(d.properties.counts) : 0);
9948
10135
  const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
9949
10136
  const size = radiusPx / pxPerUnit;
9950
- const icon = this._userChartIcon(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d), pxPerUnit, maxRasterPx);
9951
- if (!icon || icon.failed) continue;
9952
- if (!icon.canvas) { pending++; continue; }
9953
- data.push({ d, icon });
9954
- }
9955
- // The charts' images come asynchronously (an SVG decodes as an image):
9956
- // until every chart of this view has its image the previous complete
9957
- // drawing stays, then the new one replaces it in one piece — as flat
9958
- // shows a theme once it is drawn. (Drawing the ready part meanwhile,
9959
- // and redrawing batch by batch as images came, left the map on an
9960
- // incomplete or old state after many zooms and pans.)
9961
- 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)' : ''}`);
9962
- if (pending) {
9963
- this._userChartWaiting = true;
9964
- return this._userChartLastLayers || [];
9965
- }
9966
- this._userChartWaiting = false;
9967
- if (!data.length) return (this._userChartLastLayers = []);
9968
- // the icon texture is built here, from the chart canvases, and given
9969
- // to deck.gl whole (iconAtlas + iconMapping): deck.gl has nothing to
9970
- // load, and the texture is exactly this view's charts
9971
- const atlas = this._userChartAtlas(data.map(e => e.icon));
9972
- 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({
9973
10171
  id: `ix-bubbles-${this.name}`,
9974
- data, pickable: true,
9975
- iconAtlas: atlas.canvas, iconMapping: atlas.mapping,
10172
+ data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
10173
+ pickable: true,
9976
10174
  getPosition: e => e.d.geometry.coordinates,
9977
- getIcon: e => e.icon.id,
9978
- getSize: e => e.icon.unitHeight * pxPerUnit,
9979
- sizeUnits: 'pixels',
9980
- billboard: true,
9981
- // no mipmaps: the charts are drawn at their size on screen
9982
- 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],
9983
10178
  parameters: ICON_LAYER_GLOBE_PARAMETERS
9984
10179
  })];
9985
10180
  return this._userChartLastLayers;
9986
10181
  }
9987
- // one texture for a view's charts: rows of images, 2048 px wide; the
9988
- // same set of charts keeps its texture
9989
- _userChartAtlas(icons) {
9990
- const ids = icons.map(i => i.id);
9991
- const sig = [...new Set(ids)].sort().join('\n');
9992
- if (this._userChartAtlasCache && this._userChartAtlasCache.sig === sig) return this._userChartAtlasCache;
9993
- const W = 2048, PAD = 2, mapping = {}, seen = new Set(), place = [];
9994
- let x = 0, y = 0, rowH = 0;
9995
- for (const icon of icons) {
9996
- if (seen.has(icon.id)) continue;
9997
- seen.add(icon.id);
9998
- if (x + icon.width + PAD > W) { x = 0; y += rowH + PAD; rowH = 0; }
9999
- place.push([icon, x, y]);
10000
- mapping[icon.id] = { x, y, width: icon.width, height: icon.height, anchorX: icon.anchorX, anchorY: icon.anchorY, mask: false };
10001
- x += icon.width + PAD; rowH = Math.max(rowH, icon.height);
10002
- }
10003
- const canvas = document.createElement('canvas');
10004
- canvas.width = W; canvas.height = Math.max(1, y + rowH);
10005
- const ctx = canvas.getContext('2d');
10006
- for (const [icon, px, py] of place) ctx.drawImage(icon.canvas, px, py);
10007
- this._userChartAtlasCache = { sig, canvas, mapping };
10008
- return this._userChartAtlasCache;
10009
- }
10010
- // user chart images become ready one by one: one redraw once they are
10011
- _scheduleUserChartRedraw() {
10012
- if (this._userChartRedrawTimer) return;
10013
- this._userChartRedrawTimer = setTimeout(() => {
10014
- this._userChartRedrawTimer = null;
10015
- if (this._userChartWaiting && this._triggerRedraw) this._triggerRedraw();
10016
- }, 30);
10017
- }
10018
- // The chart is drawn once (its SVG kept in _userChartSvgs) and rasterised
10019
- // at its size on screen (× devicePixelRatio, in steps of USER_CHART_RASTER
10020
- // px) onto a canvas (icon.canvas, once the SVG image has decoded): one
10021
- // image per chart at a fixed 256 px made the texture of a few hundred
10022
- // charts outgrow the GPU's texture size — WebGL errors, and deck.gl
10023
- // frames that failed, leaving the map un-updated after a zoom.
10024
- _userChartIcon(draw, name, theme, flag, d, value, cat, size, nSize, pxPerUnit, maxRasterPx = Infinity) {
10025
- 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 || ''}`;
10026
10191
  if (!this._userChartSvgs) this._userChartSvgs = new Map();
10027
- if (!this._userChartRasters) this._userChartRasters = new Map();
10028
10192
  let chart = this._userChartSvgs.get(base);
10029
10193
  if (chart === undefined) {
10030
10194
  chart = this._drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize);
10031
- if (chart === undefined) return null; // a failing draw is tried again
10195
+ if (chart === undefined) return null;
10032
10196
  if (this._userChartSvgs.size >= ICON_CACHE_MAX) this._userChartSvgs.delete(this._userChartSvgs.keys().next().value);
10033
10197
  this._userChartSvgs.set(base, chart);
10034
10198
  }
10035
- if (!chart) return null;
10036
- const dpr = (global.devicePixelRatio || 1);
10037
- const want = Math.max(chart.vw, chart.vh) * pxPerUnit * dpr;
10038
- const fits = USER_CHART_RASTER.filter(r => r <= maxRasterPx);
10039
- const steps = fits.length ? fits : USER_CHART_RASTER.slice(0, 1);
10040
- const px = steps.find(r => r >= want) || steps[steps.length - 1];
10041
- const key = base + '|' + px;
10042
- const rasters = this._userChartRasters;
10043
- if (rasters.has(key)) {
10044
- // least recently used goes first
10045
- const hit = rasters.get(key);
10046
- rasters.delete(key); rasters.set(key, hit);
10047
- return hit;
10048
- }
10049
- const s = px / Math.max(chart.vw, chart.vh);
10050
- const w = Math.max(1, Math.round(chart.vw * s)), h = Math.max(1, Math.round(chart.vh * s));
10051
- 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 };
10052
- // a chart whose image fails is left out (not waited for)
10053
- const failed = e => {
10054
- icon.failed = true;
10055
- if (!this._userChartImageError) console.warn(`[ixmaps-gl] USER chart ${name}: chart image failed`, e);
10056
- this._userChartImageError = e || true;
10057
- this._scheduleUserChartRedraw();
10058
- };
10059
- // the SVG goes through a canvas: deck.gl blends an SVG image as decoded
10060
- // (premultiplied), which darkened half-transparent fills (a
10061
- // fadenegative arrow turned grey)
10062
- const img = new Image();
10063
- img.onerror = failed;
10064
- img.onload = () => {
10065
- try {
10066
- const canvas = document.createElement('canvas');
10067
- canvas.width = w; canvas.height = h;
10068
- canvas.getContext('2d').drawImage(img, 0, 0, w, h);
10069
- icon.canvas = canvas;
10070
- } catch (e) { failed(e); return; }
10071
- this._scheduleUserChartRedraw();
10072
- };
10073
- img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(chart.svg.replace('__W__', w).replace('__H__', h));
10074
- if (rasters.size >= ICON_CACHE_MAX) rasters.delete(rasters.keys().next().value);
10075
- rasters.set(key, icon);
10076
- return icon;
10199
+ return chart || null;
10077
10200
  }
10078
10201
  // the page's chart function draws one chart into the hidden SVG: its
10079
10202
  // markup and box (chart units), null when it draws nothing, undefined
@@ -10084,11 +10207,19 @@
10084
10207
  const color = this._userChartColor(cat);
10085
10208
  const raw = d.properties.raw || d.properties;
10086
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;
10087
10214
  let chart = null;
10088
10215
  try {
10089
10216
  const ret = draw.call(global.ixmaps, document, {
10090
10217
  target: g, theme, value, values: [value], size, maxSize: USER_CHART_MAX_SIZE, color, class: cat, flag,
10091
- 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
10092
10223
  });
10093
10224
  const bb = ret ? g.getBBox() : null;
10094
10225
  if (bb && bb.width > 0 && bb.height > 0) {
@@ -10100,7 +10231,7 @@
10100
10231
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
10101
10232
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
10102
10233
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
10103
- chart = { vx, vy, vw, vh, ox, oy,
10234
+ chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids],
10104
10235
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
10105
10236
  }
10106
10237
  } catch (e) {
@@ -11423,22 +11554,73 @@
11423
11554
  try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onNewTheme:', e); }
11424
11555
  }
11425
11556
 
11426
- // flat's ixmaps.map() — the running map's handle (htmlgui.js 173ff,
11427
- // htmlgui_flat.js). Its methods act on the last built map; called while
11428
- // the map is still building, they run once it is ready.
11557
+ // flat's ixmaps.themeApi (htmlgui_flat.js 1112-1196): the theme handle a
11558
+ // page gets from map().theme(name) — changeStyle is flat's canonical
11559
+ // global changeThemeStyle(szThemeName, szStyle, szFlag) (htmlgui.js 2050)
11560
+ // in handle form; show / hide / toggle are setThemeVisible, remove the map
11561
+ // handle's. flat's own markClass delegates 3-arg — a stale call against
11562
+ // htmlgui.js's 2-arg markThemeClass (2196) — the canonical form is used
11563
+ // here. Theme ids are global (the registry), so the map binding only
11564
+ // matters for replace.
11565
+ function themeApi(szMap, szTheme) {
11566
+ this.szMap = szMap || null;
11567
+ this.szTheme = szTheme || null;
11568
+ }
11569
+ themeApi.prototype = {
11570
+ changeStyle(szStyle, szFlag) { compatChangeThemeStyle(this.szTheme, szStyle, szFlag); return this; },
11571
+ markClass(nClass) { markThemeClass(this.szTheme, nClass); return this; },
11572
+ unmarkClass(nClass) { unmarkThemeClass(this.szTheme, nClass); return this; },
11573
+ show() { mapHandle(this.szMap).setThemeVisible(this.szTheme, true); return this; },
11574
+ hide() { mapHandle(this.szMap).setThemeVisible(this.szTheme, false); return this; },
11575
+ toggle() {
11576
+ const rt = _globalThemeRegistry.get(this.szTheme);
11577
+ mapHandle(this.szMap).setThemeVisible(this.szTheme, rt ? !!rt._hidden : false);
11578
+ return this;
11579
+ },
11580
+ remove() { mapHandle(this.szMap).remove(this.szTheme); return this; },
11581
+ replace(theme, flag) { mapHandle(this.szMap).replaceTheme(this.szTheme, theme, flag); return this; }
11582
+ };
11583
+
11584
+ // flat's ixmaps.map() / ixmaps.api(szMap) — the running map's handle
11585
+ // (htmlgui.js 173ff, htmlgui_flat.js mapApi 1283). Its methods act on the
11586
+ // map named by szMap (its Map() options name) or, without a name, the
11587
+ // last built map; called while the map is still building, they run once
11588
+ // it is ready.
11429
11589
  const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
11430
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
11431
- const _mapHandle = {};
11432
- for (const m of MAP_HANDLE_METHODS) {
11433
- _mapHandle[m] = (...args) => {
11434
- if (_lastMapApi) { const r = _lastMapApi[m](...args); return r === _lastMapApi ? _mapHandle : r; }
11435
- _mapReady.then(api => api[m](...args));
11436
- return _mapHandle;
11437
- };
11590
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
11591
+ const _mapHandles = Object.create(null);
11592
+ function makeMapHandle(szMap) {
11593
+ const h = {};
11594
+ const api = () => (szMap && _mapApis[szMap]) || _lastMapApi;
11595
+ for (const m of MAP_HANDLE_METHODS) {
11596
+ h[m] = (...args) => {
11597
+ const a = api();
11598
+ if (a) { const r = a[m](...args); return r === a ? h : r; }
11599
+ _mapReady.then(ready => ready[m](...args));
11600
+ return h;
11601
+ };
11602
+ }
11603
+ h.getZoom = () => { const a = api(); return a ? a.getZoom() : undefined; };
11604
+ h.getThemeObj = id => getThemeObj(id);
11605
+ // flat's mapApi.theme(szTheme) (htmlgui_flat.js 1571): the theme handle
11606
+ h.theme = szTheme => new themeApi(szMap, szTheme);
11607
+ // flat's mapApi aliases (htmlgui_flat.js 1286+): show/hide a theme,
11608
+ // redrawTheme (refreshTheme here), project (loadProject), getThemes(),
11609
+ // setView (view, both flat's object form and (array, zoom))
11610
+ h.show = id => h.setThemeVisible(id, true);
11611
+ h.hide = id => h.setThemeVisible(id, false);
11612
+ h.redrawTheme = id => h.refreshTheme(id);
11613
+ h.project = (...a) => h.loadProject(...a);
11614
+ h.setView = (...a) => h.view(...a);
11615
+ h.getThemes = () => [...new Set(_globalThemeRegistry.values())].map(rt => getThemeObj(themeIdOf(rt))).filter(Boolean);
11616
+ return h;
11617
+ }
11618
+ const _mapHandle = makeMapHandle(null);
11619
+ function mapHandle(szMap) {
11620
+ if (!szMap) return _mapHandle;
11621
+ if (!_mapHandles[szMap]) _mapHandles[szMap] = makeMapHandle(szMap);
11622
+ return _mapHandles[szMap];
11438
11623
  }
11439
- _mapHandle.getZoom = () => (_lastMapApi ? _lastMapApi.getZoom() : undefined);
11440
- _mapHandle.getThemeObj = id => getThemeObj(id);
11441
- function mapHandle() { return _mapHandle; }
11442
11624
 
11443
11625
  // flat's ixmaps.formatValue(value, precision, flag) (ui/js/tools/format.js),
11444
11626
  // which user chart scripts call: thousands separated by "." (BLANK: a
@@ -12013,10 +12195,44 @@
12013
12195
  return _lastMapApi.loadProject(src, flags);
12014
12196
  }
12015
12197
 
12198
+ // flat's global compat forms come in two arities: the canonical
12199
+ // theme-first signatures — changeThemeStyle(szThemeName, szStyle, szFlag)
12200
+ // (htmlgui.js 2050, with its duplicate-leading shift, 2052),
12201
+ // removeTheme(szThemeId) (1884), setBasemapOpacity(nOpacity, szMode)
12202
+ // (1409) — and the older map-first pages calling
12203
+ // (null|szMap, szId, ...) — PNRR passes an explicit null. Arity tells
12204
+ // them apart; theme ids are global (the registry), so the leading map
12205
+ // argument only matters for setBasemapOpacity, where a registered map
12206
+ // name targets that map and null or an unknown name the last built one.
12207
+ function themeStyleArgs(a) {
12208
+ if (a.length >= 4) a = a.slice(1);
12209
+ else if (a[0] === a[1]) a = a.slice(1);
12210
+ return a;
12211
+ }
12212
+ function compatChangeThemeStyle(...a) {
12213
+ const [szId, szStyle, szFlag] = themeStyleArgs(a);
12214
+ return _mapHandle.changeThemeStyle(szId, szStyle, szFlag);
12215
+ }
12216
+ function compatRemoveTheme(...a) {
12217
+ return _mapHandle.remove(a.length >= 2 ? a[1] : a[0]);
12218
+ }
12219
+ function compatSetBasemapOpacity(...a) {
12220
+ if (a.length >= 3) {
12221
+ const api = (a[0] && _mapApis[a[0]]) || _lastMapApi;
12222
+ if (api) api.setBasemapOpacity(a[1], a[2]);
12223
+ return;
12224
+ }
12225
+ return _mapHandle.setBasemapOpacity(a[0], a[1]);
12226
+ }
12227
+
12016
12228
  global.ixmaps = {
12017
12229
  layer, Layer: layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
12018
12230
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
12019
12231
  markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
12232
+ // flat's ixmaps.__formatValue (ui/js/tools/legend.js 451-553) — pages call
12233
+ // it for statistics/legend values; legend's own variant differs from
12234
+ // format.js's only in the SPACE flag, which flatFormatValue covers too
12235
+ __formatValue: flatFormatValue,
12020
12236
  map: mapHandle, getZoom, getThemeDefinitionObj, embeddedSVG,
12021
12237
  // flat's theme list: the theme objects of every theme
12022
12238
  getThemes: () => [...new Set(_globalThemeRegistry.values())].map(rt => getThemeObj(themeIdOf(rt))).filter(Boolean),
@@ -12033,11 +12249,11 @@
12033
12249
  setAttribution: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
12034
12250
  htmlgui_setAttributionString: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
12035
12251
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
12036
- changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
12037
- removeTheme: (szMap, szId) => _mapHandle.remove(szId),
12252
+ changeThemeStyle: compatChangeThemeStyle,
12253
+ removeTheme: compatRemoveTheme,
12038
12254
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
12039
12255
  setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
12040
- setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
12256
+ setBasemapOpacity: compatSetBasemapOpacity,
12041
12257
  // this engine's version (flat's own ixmaps.version numbers flat)
12042
12258
  glVersion: IXMAPS_GL_VERSION,
12043
12259
  // flat always has this page hook (its layer legend tool defines it,
@@ -12062,7 +12278,7 @@
12062
12278
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
12063
12279
  global.__ixmapsGlInternals = {
12064
12280
  IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, multiQuadOffsets, pixelOffsetLngLat,
12065
- normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
12281
+ normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
12066
12282
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
12067
12283
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
12068
12284
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
@@ -12071,7 +12287,7 @@
12071
12287
  flatToMapLibreZoom, mapLibreToFlatZoom, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
12072
12288
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
12073
12289
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
12074
- flatFormatValue,
12290
+ flatFormatValue, themeStyleArgs,
12075
12291
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
12076
12292
  };
12077
12293
  global.__setFilter = __setFilter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.14",
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",