ixmaps-gl 0.2.11 → 0.2.15

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 +28 -3
  2. package/ixmaps-gl.js +883 -181
  3. package/package.json +1 -1
package/ixmaps-gl.js CHANGED
@@ -21,8 +21,8 @@
21
21
  // alpha100}) when set (alpha100:"$density$" divides by the polygon's
22
22
  // own geodesic area — see _prepareAlphaField/_resolveDopacityAlpha).
23
23
  // AGGREGATE CATEGORICAL on one field paints each polygon in its records'
24
- // dominant category; a plain CATEGORICAL choropleth is not implemented,
25
- // see _buildChoroplethLayers), and the CHART|SYMBOL|
24
+ // dominant category; a plain CATEGORICAL choropleth matches each polygon's
25
+ // raw value against the categories, see _buildChoroplethLayers), and the CHART|SYMBOL|
26
26
  // GLOW|CATEGORICAL|AGGREGATE|COUNT|RELOCATE|VALUES pipeline (categorical
27
27
  // clustering + sizing + glow + multi-bubble grouping + on-bubble value
28
28
  // labels, generalized to however many distinct category values the DATA
@@ -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.11';
85
+ const IXMAPS_GL_VERSION = '0.2.15';
86
86
 
87
87
  const LIB_URLS = {
88
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -308,7 +308,7 @@
308
308
  // crafted link must not be able to load arbitrary code into the page.
309
309
  // ---------------------------------------------------------------
310
310
  // v0.1.6: knows this engine's aliases, lookupfield rule, meta↔style, builder methods, project loading
311
- const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.25/dist/validate.mjs';
311
+ const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.27/dist/validate.mjs';
312
312
  // a page may set ixmaps.validate BEFORE loading this script — the
313
313
  // `global.ixmaps = {...}` export below would otherwise overwrite it
314
314
  const _preloadValidate = global.ixmaps && global.ixmaps.validate;
@@ -1951,6 +1951,10 @@
1951
1951
  // per-map handle — most real pages only ever build one map anyway. Set
1952
1952
  // once per successful build(), just before it returns engineApi below.
1953
1953
  let _lastMapApi = null;
1954
+ // flat's map instance registry (htmlgui_flat.js 2416): the built maps by
1955
+ // their Map() options name — ixmaps.map("name") is the handle of THAT
1956
+ // map, ixmaps.map() stays the last built one
1957
+ const _mapApis = Object.create(null);
1954
1958
  // resolves with the first built map's API: ixmaps.map() calls a page
1955
1959
  // makes while the map is still building wait for it
1956
1960
  let _resolveMapReady;
@@ -2125,7 +2129,7 @@
2125
2129
  // edit it by hand; `npm run unit` fails when it drifts.
2126
2130
  // ---------------------------------------------------------------
2127
2131
  // <grammar:binding-aliases>
2128
- // generated from ixmaps-grammar 0.1.25 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2132
+ // generated from ixmaps-grammar 0.1.27 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2129
2133
  const FLAT_BINDING_ALIASES = {
2130
2134
  "aggregation": "style.aggregationfield",
2131
2135
  "aggregationfield": "style.aggregationfield",
@@ -2187,7 +2191,7 @@
2187
2191
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2188
2192
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2189
2193
  // <grammar:meta-keys>
2190
- // generated from ixmaps-grammar 0.1.25 — 5 meta keys
2194
+ // generated from ixmaps-grammar 0.1.27 — 5 meta keys
2191
2195
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2192
2196
  // </grammar:meta-keys>
2193
2197
 
@@ -2206,6 +2210,7 @@
2206
2210
  'style.alphafield': ['binding', 'alpha'],
2207
2211
  'style.alphafield100': ['binding', 'alpha100'],
2208
2212
  'style.valuefield': ['style', 'valuefield'],
2213
+ 'style.timefield': ['binding', 'time'], // setThemeTimeFrame, see LayerRuntime.setTimeFrame
2209
2214
  'style.titlefield': ['binding', 'title'], // {{theme.item.title}}, see _buildTooltipContext
2210
2215
  // the lookup key's normalization (flatLookupKey): flat's newTheme moves
2211
2216
  // binding.tonumber / digits / … into these style keys (htmlgui.js 1712)
@@ -2371,7 +2376,8 @@
2371
2376
  const STYLE_NUMBER_KEYS = ['linewidth', 'fillopacity', 'scale', 'classes', 'valuedecimals', 'normalsizevalue',
2372
2377
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
2373
2378
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
2374
- 'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety'];
2379
+ 'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
2380
+ 'borderradius', 'maxcharts'];
2375
2381
  function toNumberIfNumeric(v) {
2376
2382
  if (typeof v !== 'string' || !v.trim()) return v;
2377
2383
  const n = Number(v);
@@ -2635,6 +2641,69 @@
2635
2641
  return { dark: false, bg: 'rgba(255,255,255,0.9)' };
2636
2642
  }
2637
2643
 
2644
+ // ---------------------------------------------------------------
2645
+ // Effective basemap darkness — what the map canvas actually shows,
2646
+ // not what its name suggests. flatLegendLook's name-plus-opacity
2647
+ // heuristic assumes the faded (basemapopacity <= 0.5) basemap blends
2648
+ // against a WHITE page, so a dark basemap faded to half goes light;
2649
+ // on a page with a dark background it reads dark all the same, and
2650
+ // tooltips/legends styled light stick out (the exact bug fixed here).
2651
+ // Rule: an explicit color (legendBackground, a color mapType) stays
2652
+ // authoritative as before; with a real basemap style, a DARK style
2653
+ // background means dark — except when it is faded to
2654
+ // basemapopacity <= 0.5, where the mix is mostly the page background
2655
+ // behind the semi-transparent canvas and THAT decides (dark page ->
2656
+ // still dark; white page -> flat's washed-out light look). A light
2657
+ // style background reads light however it is faded (flat's own rule).
2658
+ // Falls back to the name heuristic while no style exists.
2659
+ // ---------------------------------------------------------------
2660
+ function styleBackgroundColorOf(map) {
2661
+ try {
2662
+ const style = map && map.getStyle && map.getStyle();
2663
+ if (!style || !style.layers) return null;
2664
+ for (const layer of style.layers) {
2665
+ if (layer.type !== 'background') continue;
2666
+ const paint = layer.paint || {};
2667
+ const color = paint['background-color'] != null ? paint['background-color'] : (paint.background != null ? paint.background : null);
2668
+ // the blank background substituted for a color mapType IS the page's
2669
+ // requested background and never faded (see applyBasemapOpacity)
2670
+ return { color: color, faded: layer.id !== BLANK_BACKGROUND_LAYER_ID };
2671
+ }
2672
+ } catch (e) { /* no style (yet) */ }
2673
+ return null;
2674
+ }
2675
+
2676
+ function pageBackgroundColorOf(el) {
2677
+ let node = el;
2678
+ while (node && node.nodeType === 1 && node !== document.documentElement) {
2679
+ const m = String(getComputedStyle(node).backgroundColor)
2680
+ .match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)/i);
2681
+ if (m && (m[4] === undefined || +m[4] > 0)) return [+m[1], +m[2], +m[3]];
2682
+ node = node.parentElement;
2683
+ }
2684
+ return [255, 255, 255]; // the page default
2685
+ }
2686
+
2687
+ function effectiveMapDark(map, el, basemapOpacity, mapType, legendBackground) {
2688
+ const id = legendBackground || mapType;
2689
+ if (flatIsCssColorValue(id)) return flatIsDarkColor(id);
2690
+ const base = styleBackgroundColorOf(map);
2691
+ const rgb = base && base.color != null ? parseCssColor(String(base.color)) : null;
2692
+ if (!rgb) {
2693
+ // no style (yet): the name heuristic, exactly as before
2694
+ return flatLegendLook(mapType, basemapOpacity, legendBackground).dark;
2695
+ }
2696
+ const o = isNaN(basemapOpacity) ? 1 : Math.max(0, Math.min(1, basemapOpacity));
2697
+ if (flatIsDarkColor('rgb(' + rgb.map(Math.round).join(',') + ')')) {
2698
+ // a dark basemap faded to half or less shows mostly the page background
2699
+ // behind the canvas - so at that point the PAGE decides (dark page ->
2700
+ // still dark; white page -> the washed-out flat look stays light)
2701
+ return (base.faded && o <= 0.5) ? flatIsDarkColor('rgb(' + pageBackgroundColorOf(el).map(Math.round).join(',') + ')') : true;
2702
+ }
2703
+ // a light basemap reads light however it is faded - flat's own rule
2704
+ return false;
2705
+ }
2706
+
2638
2707
  function buildBlankBackgroundStyle(color) {
2639
2708
  return {
2640
2709
  version: 8,
@@ -2677,6 +2746,40 @@
2677
2746
  }
2678
2747
 
2679
2748
  // ---------------------------------------------------------------
2749
+ // Embedded YouTube players in tooltips (data fields carrying <iframe
2750
+ // src=".../embed/ID">) are replaced by a thumbnail with a play button; the
2751
+ // real iframe is created on click. Keeps tooltips light, and a removed
2752
+ // video or a refused embed (e.g. error 153 on file:// pages) degrades to a
2753
+ // thumbnail that opens YouTube in a new tab instead of a broken player.
2754
+ const YT_IFRAME_RE = /<iframe\b[^>]*?\bsrc\s*=\s*["']?(?:https?:)?\/\/(?:www\.)?youtube(?:-nocookie)?\.com\/embed\/([\w-]{6,})[^"'\s>]*["']?[^>]*>\s*<\/iframe>/gi;
2755
+ function youtubeClickToPlay(html) {
2756
+ if (!html || html.indexOf('youtube') < 0) return html;
2757
+ return html.replace(YT_IFRAME_RE, (m, id) => {
2758
+ const w = (/\bwidth\s*=\s*["']?(\d+)/i.exec(m) || [])[1] || 240;
2759
+ const h = (/\bheight\s*=\s*["']?(\d+)/i.exec(m) || [])[1] || 180;
2760
+ return '<div class="ixgl-yt" data-yt="' + id + '" data-w="' + w + '" data-h="' + h + '" ' +
2761
+ 'style="position:relative;width:' + w + 'px;height:' + h + 'px;cursor:pointer;background:#000 url(https://i.ytimg.com/vi/' + id + '/hqdefault.jpg) center/cover" ' +
2762
+ 'title="play"><span style="position:absolute;left:50%;top:50%;width:48px;height:34px;margin:-17px 0 0 -24px;' +
2763
+ 'border-radius:8px;background:rgba(220,0,0,0.9)"></span><span style="position:absolute;left:50%;top:50%;margin:-9px 0 0 -6px;' +
2764
+ 'border-style:solid;border-width:9px 0 9px 15px;border-color:transparent transparent transparent #fff"></span></div>';
2765
+ });
2766
+ }
2767
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function' && !global.__ixglYtClick) {
2768
+ global.__ixglYtClick = true;
2769
+ document.addEventListener('click', e => {
2770
+ const el = e.target.closest && e.target.closest('.ixgl-yt');
2771
+ if (!el) return;
2772
+ e.stopPropagation();
2773
+ const f = document.createElement('iframe');
2774
+ f.width = el.dataset.w; f.height = el.dataset.h; f.frameBorder = '0';
2775
+ f.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
2776
+ f.allowFullscreen = true;
2777
+ f.referrerPolicy = 'strict-origin-when-cross-origin';
2778
+ f.src = 'https://www.youtube.com/embed/' + el.dataset.yt + '?autoplay=1&rel=0';
2779
+ el.replaceWith(f);
2780
+ }, true);
2781
+ }
2782
+
2680
2783
  // Hover tooltips and the click-to-pin tooltip of a map, built once per map
2681
2784
  // by MapBuilder.build(). ctx: the builder (its map options, for the
2682
2785
  // tooltip look), the MapLibre map, its container element and
@@ -2686,6 +2789,12 @@
2686
2789
  // ---------------------------------------------------------------
2687
2790
  function createTooltips(ctx) {
2688
2791
  const { builder, map, el, findRuntimeForLayerId } = ctx;
2792
+ // the effective (rendered) dark/light probe for tooltip and legend —
2793
+ // the builder carries it so the legend look (built later, without the
2794
+ // map element at hand) can ask the same question
2795
+ builder.__effectiveDark = function (basemapOpacity, mapType, legendBackground) {
2796
+ return effectiveMapDark(map, el, basemapOpacity, mapType, legendBackground);
2797
+ };
2689
2798
  // Click-to-pin tooltip: matches the real ixmaps engine's own
2690
2799
  // click-pins-the-tooltip convention in pan mode (a hover tooltip is
2691
2800
  // transient; a click keeps one visible until dismissed). This is a
@@ -2695,28 +2804,101 @@
2695
2804
  // at click time (not live-updating), but its screen POSITION is kept
2696
2805
  // in sync with the map on every pan/zoom via map.project().
2697
2806
  let pinned = null; // { runtime, object, lngLat }
2698
- // Tooltip look, like the legend's (flatLegendLook): flat's light
2699
- // tooltip (tooltip_mustache.js: white 0.95, #444 text, thin black
2700
- // border, 5px radius) on light basemaps, this engine's dark tooltip
2701
- // on dark ones. Read when shown, so options set later (a
2807
+ // Tooltip look, like the legend's: flat's light tooltip
2808
+ // (tooltip_mustache.js: white 0.95, #444 text, thin black border, 5px
2809
+ // radius) on light basemaps, this engine's dark tooltip on dark ones —
2810
+ // decided by the EFFECTIVE map darkness (style background vs page
2811
+ // background, see effectiveMapDark), not the basemap name alone. Read
2812
+ // when shown, so options set later (a
2702
2813
  // myMap.then(...).options({basemapopacity})) count.
2703
- const tooltipLook = () => {
2814
+ const tooltipIsDark = () => {
2704
2815
  const o = parseFloat(builder._engineOptions.basemapopacity);
2705
- const look = flatLegendLook(builder.mapOptions.mapType, isNaN(o) ? 1 : o, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
2706
- return look.dark
2707
- ? { background: 'rgb(41,50,60)', color: 'rgb(200,205,214)', border: 'none', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }
2708
- : { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
2709
- boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' };
2816
+ const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
2817
+ return typeof builder.__effectiveDark === 'function'
2818
+ ? builder.__effectiveDark(isNaN(o) ? 1 : o, builder.mapOptions.mapType, legendBackground)
2819
+ : flatLegendLook(builder.mapOptions.mapType, isNaN(o) ? 1 : o, legendBackground).dark;
2820
+ };
2821
+ // flat's tooltip look (tooltip_mustache.js 118/151-154): arial narrow,
2822
+ // the font size clamped to 11..14px; light: white 0.95, #444 text, thin
2823
+ // black border; dark: rgba(50,50,50,0.95), #555 border (text light for
2824
+ // readability — flat leaves its #444 on dark and only adapts the panel).
2825
+ // line-height is declared so hover (deck.gl's .deck-tooltip stylesheet
2826
+ // gives it 20px) and the pinned tooltip (a bare div that would inherit
2827
+ // the page's 'normal') render the same content identically
2828
+ const tooltipLook = () => {
2829
+ const fontsize = Math.min(14, Math.max(11, (22 / 1200 * (global.innerWidth || 1200))));
2830
+ const fonts = { fontFamily: 'arial narrow, system', fontSize: fontsize + 'px', lineHeight: '20px' };
2831
+ return tooltipIsDark()
2832
+ ? Object.assign(fonts, { background: 'rgba(50,50,50,0.95)', color: 'rgb(200,205,214)', border: '0.5px solid #555', borderRadius: '5px',
2833
+ boxShadow: '0 2px 8px rgba(0,0,0,0.3)' })
2834
+ : Object.assign(fonts, { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
2835
+ boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' });
2710
2836
  };
2837
+ // flat's content pane (tooltip_mustache.js 124): the whole tooltip
2838
+ // content scrolls inside its own padded pane — a close button outside
2839
+ // the pane stays visible while it scrolls, and the scrollbar clears
2840
+ // the content by a small right margin
2841
+ const TOOLTIP_PANE_STYLE = 'margin:0.5em;max-height:360px;overflow:auto';
2842
+ const tooltipPaneHtml = (html) => `<div style="${TOOLTIP_PANE_STYLE}">${html}</div>`;
2711
2843
  const pinnedTooltipEl = document.createElement('div');
2712
2844
  pinnedTooltipEl.style.cssText = 'position:absolute;top:0;left:0;z-index:6;display:none;' +
2713
- 'pointer-events:auto;max-width:440px;max-height:360px;overflow:auto;' +
2714
- 'padding:0.6em 1.6em 0.6em 0.7em;font-size:1em;';
2845
+ 'pointer-events:auto;max-width:440px;padding:0.5em 0.7em 0.5em 0.5em;';
2846
+
2847
+ // flat's own tooltip placement (tooltip_mustache.js 145-148): flip it
2848
+ // to the left/above the anchor when there is no room right/below, then
2849
+ // clamp it into the window — a tooltip must always be completely
2850
+ // visible (an element larger than the viewport ends up at its edge)
2851
+ function keepTooltipVisible(tipEl, x, y) {
2852
+ const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
2853
+ const vw = global.innerWidth || 1200, vh = global.innerHeight || 800;
2854
+ let tx = x > vw / 2 ? x - w - 30 : x + 30;
2855
+ let ty = y > vh / 2 ? y - h - 20 : y + 20;
2856
+ tx = Math.min(Math.max(8, tx), Math.max(8, vw - w - 8));
2857
+ ty = Math.min(Math.max(8, ty), Math.max(8, vh - h - 8));
2858
+ const transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
2859
+ tipEl.style.transform = transform;
2860
+ return transform;
2861
+ }
2715
2862
 
2716
2863
  function updatePinnedTooltipPosition() {
2717
2864
  if (!pinned) return;
2718
2865
  const pt = map.project(pinned.lngLat);
2719
- pinnedTooltipEl.style.transform = `translate(${pt.x + 10}px, ${pt.y - 10}px)`;
2866
+ keepTooltipVisible(pinnedTooltipEl, pt.x, pt.y);
2867
+ }
2868
+
2869
+ // hover tooltip: deck.gl pins it at the pointer without any viewport
2870
+ // logic (it overflows the right/bottom edge there) — watch the element's
2871
+ // style mutations and re-place it inside the window every time deck
2872
+ // moves it. Our own write is recognized and skipped (lastSetTransform).
2873
+ let __hoverTooltipWatch = null;
2874
+ function watchHoverTooltip() {
2875
+ if (__hoverTooltipWatch || !el.parentElement) return;
2876
+ const host = el.parentElement;
2877
+ const attach = (tipEl) => {
2878
+ let lastSetTransform = null;
2879
+ const mo = new MutationObserver(() => {
2880
+ if (tipEl.style.display === 'none' || !tipEl.textContent.trim()) return;
2881
+ const t = tipEl.style.transform;
2882
+ if (!t || t === lastSetTransform) return;
2883
+ const m = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(t);
2884
+ if (!m) return;
2885
+ lastSetTransform = keepTooltipVisible(tipEl, +m[1], +m[2]);
2886
+ });
2887
+ mo.observe(tipEl, { attributes: true, attributeFilter: ['style'] });
2888
+ __hoverTooltipWatch = mo;
2889
+ };
2890
+ const existing = host.querySelector('.deck-tooltip');
2891
+ if (existing) { attach(existing); return; }
2892
+ const creation = new MutationObserver((muts, obs) => {
2893
+ for (const mu of muts) for (const n of mu.addedNodes) {
2894
+ if (n.nodeType === 1 && n.classList && n.classList.contains('deck-tooltip')) {
2895
+ attach(n);
2896
+ obs.disconnect();
2897
+ return;
2898
+ }
2899
+ }
2900
+ });
2901
+ creation.observe(host, { childList: true, subtree: true });
2720
2902
  }
2721
2903
 
2722
2904
  // A stable per-geometry anchor coordinate for the identity check
@@ -2750,13 +2932,19 @@
2750
2932
  }
2751
2933
 
2752
2934
  // content lives in its own child so re-rendering (innerHTML) never
2753
- // wipes out the unpin button below (a sibling, not a descendant of it)
2935
+ // content lives in its own child so re-rendering (innerHTML) never
2936
+ // wipes out the unpin button below (a sibling, not a descendant of it).
2937
+ // The content child sits inside a scroll PANE (flat's own tooltip
2938
+ // structure): the pane scrolls, the close button outside it does not
2939
+ const pinnedPaneEl = document.createElement('div');
2940
+ pinnedPaneEl.style.cssText = TOOLTIP_PANE_STYLE;
2754
2941
  const pinnedContentEl = document.createElement('div');
2755
- pinnedTooltipEl.appendChild(pinnedContentEl);
2942
+ pinnedPaneEl.appendChild(pinnedContentEl);
2943
+ pinnedTooltipEl.appendChild(pinnedPaneEl);
2756
2944
 
2757
2945
  function renderPinnedTooltip() {
2758
2946
  if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
2759
- const html = pinned.runtime.buildTooltipHtml(pinned.object);
2947
+ const html = pinned.runtime.buildTooltipHtml(pinned.object, tooltipIsDark());
2760
2948
  if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
2761
2949
  pinnedContentEl.innerHTML = html;
2762
2950
  Object.assign(pinnedTooltipEl.style, tooltipLook());
@@ -2797,7 +2985,15 @@
2797
2985
  // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
2798
2986
  // the feature is its d
2799
2987
  function pickedFeature(o) { return o && o.icon && o.d ? o.d : o; }
2988
+ // flat's map tool 'pan' (Map option mode:"pan", Api.setMapTool): pure
2989
+ // navigation — no hover tooltip there (flat's own hover-tooltip paths
2990
+ // run only for the ""/info/clickinfo tool types, mapscript2.js 5232).
2991
+ // The click-to-pin tooltip stays: it is this engine's equivalent of
2992
+ // flat's info popup, so a click still shows the permanent tooltip.
2993
+ const isPanMode = () => /^\s*pan\s*$/i.test(String(builder.mapOptions.mode || ''));
2994
+
2800
2995
  function tooltipFor({ object: picked, layer }) {
2996
+ if (isPanMode()) return null;
2801
2997
  const object = pickedFeature(picked);
2802
2998
  if (!object || !layer) return null;
2803
2999
  // suppress the hover tooltip ONLY for the specific item that's
@@ -2807,8 +3003,10 @@
2807
3003
  if (pinned && isSameAsPinned(layer.id, object)) return null;
2808
3004
  const rt = findRuntimeForLayerId(layer.id);
2809
3005
  if (!rt) return null;
2810
- const html = rt.buildTooltipHtml(object);
2811
- return html ? { html, style: Object.assign({ fontSize: '1em', padding: '0.5em 0.7em', maxWidth: '440px' }, tooltipLook()) } : null;
3006
+ const html = rt.buildTooltipHtml(object, tooltipIsDark());
3007
+ // same layout as the pinned tooltip: the content in flat's scroll
3008
+ // pane, the shared look (fonts included) on the container
3009
+ return html ? { html: tooltipPaneHtml(html), style: Object.assign({ padding: '0.5em 0.7em', maxWidth: '440px' }, tooltipLook()) } : null;
2812
3010
  }
2813
3011
  function clickFor(picked) {
2814
3012
  const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
@@ -2868,10 +3066,13 @@
2868
3066
  }
2869
3067
  function mount() {
2870
3068
  map.on('move', updatePinnedTooltipPosition);
2871
- if (el.parentElement) {
2872
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
2873
- el.parentElement.appendChild(pinnedTooltipEl);
2874
- }
3069
+ watchHoverTooltip();
3070
+ // anchor inside the map container: map.project() gives container-
3071
+ // relative pixels, and styling the page's own body (relative) breaks
3072
+ // any page element absolutely positioned against it (AirBnB's
3073
+ // #onmap-buttons on a zero-height all-float body flew off-screen)
3074
+ el.style.position = el.style.position || 'relative';
3075
+ el.appendChild(pinnedTooltipEl);
2875
3076
  }
2876
3077
  return { getTooltip, onClick, mount };
2877
3078
  }
@@ -2885,7 +3086,7 @@
2885
3086
  // layoutLegends().
2886
3087
  // ---------------------------------------------------------------
2887
3088
  function createLegend(ctx) {
2888
- const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw } = ctx;
3089
+ const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw, quietRefresh } = ctx;
2889
3090
  let addLegendPanel = () => {};
2890
3091
  let setLegendOption = () => {};
2891
3092
  let updateSubTheme = () => {};
@@ -3033,15 +3234,28 @@
3033
3234
  legendStack = document.createElement('div');
3034
3235
  legendStack.className = 'ix-legend-stack';
3035
3236
  legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3036
- + 'display:flex;flex-direction:column;max-height:calc(100% - 24px);overflow-y:auto;'
3237
+ + 'display:flex;flex-direction:column;max-height:66.6%;overflow-y:auto;'
3037
3238
  + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3038
3239
  if (!document.getElementById('ix-legend-stack-css')) {
3039
3240
  const css = document.createElement('style');
3040
3241
  css.id = 'ix-legend-stack-css';
3041
- 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)}';
3242
+ // the slider rules are flat's own legend.css .slider block
3243
+ // (355-411) — its two duplicate blocks resolved to what the
3244
+ // cascade actually shows: a 5px rounded grey track at 0.7
3245
+ // opacity (1 on hover) and a green thumb
3246
+ 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)}'
3247
+ + '.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;}'
3248
+ + '.ix-legend-stack input[type=range]:hover{opacity:1;}'
3249
+ + '.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;}'
3250
+ + '.ix-legend-stack input[type=range]::-moz-range-thumb{width:25px;height:25px;border-radius:50%;background:#4CAF50;cursor:pointer;}';
3042
3251
  document.head.appendChild(css);
3043
3252
  }
3044
- el.parentElement.appendChild(legendStack);
3253
+ // flat anchors its legend inside the map div (#map-legend), so the
3254
+ // panel's max-height resolves against the MAP's height — the stack's
3255
+ // former parent anchor collapsed it to 0 on pages whose body has
3256
+ // only floated children (height 0)
3257
+ el.style.position = el.style.position || 'relative';
3258
+ el.appendChild(legendStack);
3045
3259
  return legendStack;
3046
3260
  };
3047
3261
  // map.setLegend(value): the `legend` map option at runtime, same values as
@@ -3073,7 +3287,10 @@
3073
3287
  });
3074
3288
  legendStack.style.display = shown ? 'flex' : 'none';
3075
3289
  };
3076
- const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length && !rt.flags.has('FEATURE') && !rt.flags.has('FEATURES') && !rt.flags.has('NOLEGEND');
3290
+ // a plain FEATURE(S) base layer has one flat fill (no classes); FEATURE|CATEGORICAL
3291
+ // is coloured per category (see _buildFeaturesLayers), so it gets a legend
3292
+ const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length
3293
+ && (!(rt.flags.has('FEATURE') || rt.flags.has('FEATURES')) || rt.flags.has('CATEGORICAL')) && !rt.flags.has('NOLEGEND');
3077
3294
  // One theme's panel — at build time for every theme, and again for a
3078
3295
  // theme defined later (map.layer(...) in a myMap.then(...) chain,
3079
3296
  // defineLayer, loadProject); removeTheme removes it with the theme.
@@ -3083,7 +3300,7 @@
3083
3300
  rt._triggerRedraw = () => { refresh(); };
3084
3301
  if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3085
3302
  if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3086
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
3303
+ el.style.position = el.style.position || 'relative';
3087
3304
  {
3088
3305
  // .type("...|NOLEGEND") — the fourth real legend-related type()
3089
3306
  // token: opts a theme OUT of the legend entirely (real engine's
@@ -3109,8 +3326,16 @@
3109
3326
  // flat's look (flatLegendLook) unless the page picks one with
3110
3327
  // this engine's own legendtheme
3111
3328
  const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3112
- const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
3113
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !flatLook.dark);
3329
+ const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
3330
+ const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, legendBackground);
3331
+ // effective darkness (style background vs page background, see
3332
+ // effectiveMapDark): a dark basemap faded to <= 0.5 over a DARK
3333
+ // page still reads dark — flatLegendLook's name heuristic would
3334
+ // wrongly switch the panel (and the tooltip) to light there
3335
+ const looksDark = typeof builder.__effectiveDark === 'function'
3336
+ ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3337
+ : flatLook.dark;
3338
+ const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3114
3339
  const legendColors = isLightLegend
3115
3340
  ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3116
3341
  rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
@@ -3118,24 +3343,26 @@
3118
3343
  : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3119
3344
  rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3120
3345
  selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3121
- if (!rt.style.legendtheme) legendColors.bg = flatLook.bg;
3122
- // max-height:66% resolves against el.parentElement's own
3123
- // height (the map container, which always has a definite
3124
- // height for the map itself to render into) since this panel
3125
- // is absolutely positioned inside it — a plain percentage on
3126
- // an absolutely-positioned element's height IS legal CSS as
3127
- // long as its containing block has a definite height, which
3128
- // this one does. display:flex column + min-height:0 on the
3129
- // ROWS wrapper below (not this outer panel) is what makes
3130
- // only the row list scroll while the header/description/
3131
- // slider stay fixed in place — a flex child won't actually
3132
- // shrink to fit and scroll internally without min-height:0,
3133
- // it just overflows its flex parent instead.
3346
+ // the panel bg follows the same effective decision (flatLegendLook
3347
+ // would hand the dark look a light bg here: its dark branch never
3348
+ // ran for this map, its light bg would clash with dark rows/text)
3349
+ if (!rt.style.legendtheme) legendColors.bg = looksDark ? '#111' : flatLook.bg;
3350
+ // max-height:66.6% resolves against el's own height (the map
3351
+ // container, which always has a definite height for the map
3352
+ // itself to render into) since this panel sits in the stack,
3353
+ // absolutely positioned inside el. flex:0 1 auto + min-height:0
3354
+ // lets the panel SHRINK to the stack's 2/3 cap so the ROWS
3355
+ // wrapper (its own flex:1 1 auto + min-height:0 below) scrolls
3356
+ // internally while the header/description/slider stay fixed —
3357
+ // with flex:0 0 auto the panel grew past the cap and the whole
3358
+ // stack scrolled instead. text-align:left: the page body may
3359
+ // centre everything (the AirBnB port does), the legend is left
3360
+ // aligned chrome.
3134
3361
  // one box for every theme, as flat's #map-legend (legend.js
3135
3362
  // 2200-2460): the panels stack in the legend stack in theme
3136
3363
  // order, a line between them (see layoutLegends)
3137
- panel.style.cssText = 'position:relative;flex:0 0 auto;'
3138
- + 'display:flex;flex-direction:column;'
3364
+ panel.style.cssText = 'position:relative;flex:0 1 auto;min-height:0;overflow:hidden;'
3365
+ + 'display:flex;flex-direction:column;text-align:left;'
3139
3366
  + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3140
3367
  + 'padding:10px 12px 12px;pointer-events:auto;';
3141
3368
  const stack = legendStackEl(legendColors.shadow);
@@ -3208,7 +3435,11 @@
3208
3435
  (rt._activeFeatures || rt.features).forEach(f => {
3209
3436
  const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
3210
3437
  if (idx == null) return;
3211
- const [lng, lat] = f.geometry.coordinates;
3438
+ // a polygon (FEATURE|CATEGORICAL) is counted at its first vertex
3439
+ let pt = f.geometry && f.geometry.coordinates;
3440
+ while (Array.isArray(pt) && Array.isArray(pt[0])) pt = pt[0];
3441
+ if (!Array.isArray(pt)) return;
3442
+ const [lng, lat] = pt;
3212
3443
  if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3213
3444
  if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3214
3445
  totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
@@ -3570,12 +3801,12 @@
3570
3801
  slider.min = isOpacitySlider ? '0' : '25';
3571
3802
  slider.max = isOpacitySlider ? '100' : '200';
3572
3803
  slider.value = String(initialPct);
3573
- slider.style.cssText = 'width:100%;margin-top:2px;';
3804
+ slider.style.cssText = 'width:50%;margin-top:0.4em;'; // flat's own inline slider style (legend.js 2392)
3574
3805
  slider.addEventListener('input', () => {
3575
3806
  const pct = parseInt(slider.value, 10);
3576
3807
  sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
3577
3808
  rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
3578
- refresh();
3809
+ quietRefresh();
3579
3810
  });
3580
3811
  bodyEl.appendChild(slider);
3581
3812
  }
@@ -3645,6 +3876,8 @@
3645
3876
  this._layerBuilders.push(nameOrBuilder);
3646
3877
  return this;
3647
3878
  }
3879
+ // .Layer() — alias of .layer()
3880
+ Layer(nameOrBuilder) { return this.layer(nameOrBuilder); }
3648
3881
  // GL-PORT COMPAT: real ixmaps-flat's map.on("layerdraw", cb) — only
3649
3882
  // "layerdraw" is actually wired up (see build()'s own note on how
3650
3883
  // it's translated into onRedraw + a synthetic per-runtime event); any
@@ -3843,6 +4076,16 @@
3843
4076
  // world once, at the cost of that zoom limit.
3844
4077
  renderWorldCopies: this._engineOptions.worldcopies !== false
3845
4078
  });
4079
+ // ixmaps.Map(id, {mapProjection}): flat's orthographic map is MapLibre's
4080
+ // native globe here (same mapping as loadProject, applyProjectMap);
4081
+ // "ortographic" is the spelling flat pages commonly carry. Any other
4082
+ // value stays mercator.
4083
+ if (/^(orth?ographic|globe)$/i.test(String(this.mapOptions.mapProjection || '').trim())) {
4084
+ map.once('style.load', () => {
4085
+ try { map.setProjection({ type: 'globe' }); }
4086
+ catch (e) { console.warn('[ixmaps-gl] mapProjection "globe": map.setProjection failed (needs maplibre-gl >=5.0.1)', e); }
4087
+ });
4088
+ }
3846
4089
  _boundsSource = () => {
3847
4090
  const b = map.getBounds();
3848
4091
  return [{ lat: b.getSouth(), lng: b.getWest() }, { lat: b.getNorth(), lng: b.getEast() }];
@@ -3937,9 +4180,11 @@
3937
4180
  const overlay = new MapLibreOverlay({
3938
4181
  interleaved: true,
3939
4182
  layers: [],
3940
- // pointer becomes a hand over anything pickable (bubbles/points),
3941
- // so hovering something clickable actually looks clickable
3942
- getCursor: ({ isDragging, isHovering }) => (isDragging ? 'grabbing' : (isHovering ? 'pointer' : 'grab')),
4183
+ // pointer becomes a hand over anything pickable (bubbles/points), so
4184
+ // hovering something clickable actually looks clickable — except in
4185
+ // the 'pan' input mode, where nothing responds to hovering
4186
+ getCursor: ({ isDragging, isHovering }) =>
4187
+ (isDragging ? 'grabbing' : ((isHovering && !/^\s*pan\s*$/i.test(String(this.mapOptions.mode || ''))) ? 'pointer' : 'grab')),
3943
4188
  // (guarded: see createTooltips)
3944
4189
  getTooltip: tooltips.getTooltip,
3945
4190
  onClick: tooltips.onClick
@@ -3952,12 +4197,14 @@
3952
4197
  attributionBox.set(this._attributionText != null ? this._attributionText : this.mapOptions.attribution);
3953
4198
  this._setAttribution = text => attributionBox.set(text);
3954
4199
 
3955
- if (this._legendHtml && el.parentElement) {
4200
+ if (this._legendHtml) {
3956
4201
  const legendEl = document.createElement('div');
3957
4202
  legendEl.style.cssText = 'position:absolute;top:0;left:0;z-index:5;pointer-events:none';
3958
4203
  legendEl.innerHTML = this._legendHtml;
3959
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
3960
- el.parentElement.appendChild(legendEl);
4204
+ // inside the map container, never the page's body (see the pinned
4205
+ // tooltip mount) — styling body breaks page-positioned elements
4206
+ el.style.position = el.style.position || 'relative';
4207
+ el.appendChild(legendEl);
3961
4208
  }
3962
4209
 
3963
4210
  // themes are addressed by either the layer()'s own name (e.g. "AREU")
@@ -4216,6 +4463,18 @@
4216
4463
  refresh();
4217
4464
  }
4218
4465
  },
4466
+ // flat's ixmaps.setThemeTimeFrame(szId, min, max): show only the
4467
+ // records whose timefield lies in [min, max) (ms or Date-
4468
+ // parsable). null min+max clears. szId null/undefined = every theme
4469
+ // that has a timefield (flat: one call updates all themes at once).
4470
+ setThemeTimeFrame: (themeId, min, max) => {
4471
+ const targets = themeId == null ? runtimes.filter(r => r.binding && r.binding.time) : (() => {
4472
+ const rt = findRuntime(themeId);
4473
+ return rt ? siblingRuntimes(rt).filter(r => r.binding && r.binding.time) : [];
4474
+ })();
4475
+ targets.forEach(rt => rt.setTimeFrame(min, max));
4476
+ if (targets.length) refresh();
4477
+ },
4219
4478
  // shows/hides a whole theme (all the deck.gl layers its
4220
4479
  // buildDeckLayers would otherwise produce) without touching its
4221
4480
  // data or style — for toggle controls like the real page's
@@ -4301,6 +4560,8 @@
4301
4560
  };
4302
4561
  return lb;
4303
4562
  },
4563
+ // .Layer() — alias of .layer()
4564
+ Layer: (nameOrBuilder) => engineApi.layer(nameOrBuilder),
4304
4565
  // Real ixmaps-flat's loadProject (htmlgui.js loadProject →
4305
4566
  // setProjectJSON → continueSetProjectJSON): src is a project object,
4306
4567
  // a JSON string, or a URL (fetched as DATA — nothing a project names
@@ -4633,7 +4894,15 @@
4633
4894
  const svg = String(m.map || '');
4634
4895
  if (svg && !/generic\/(mercator|orthographic)\.svg$/i.test(svg)) report.notes.push(`flat SVG map "${svg.split('/').pop()}" is not available in ixmaps-gl — mercator used`);
4635
4896
  if (typeof map.setProjection === 'function') {
4636
- try { map.setProjection({ type: /orthographic\.svg$/i.test(svg) ? 'globe' : 'mercator' }); }
4897
+ try {
4898
+ map.setProjection({ type: /orthographic\.svg$/i.test(svg) ? 'globe' : 'mercator' });
4899
+ // the layers are projection-dependent (globe: far-hemisphere cull,
4900
+ // chart placement) and are otherwise rebuilt only by a pan: rebuild
4901
+ // now, and again once the new projection has rendered
4902
+ refreshLayers();
4903
+ map.triggerRepaint();
4904
+ map.once('idle', refreshLayers);
4905
+ }
4637
4906
  catch (e) { console.warn('[ixmaps-gl] loadProject: map.setProjection failed (needs maplibre-gl >=5.0.1)', e); }
4638
4907
  } else {
4639
4908
  console.warn('[ixmaps-gl] loadProject: map.setProjection not available on the loaded maplibre-gl build (needs >=5.0.1) — projection unchanged');
@@ -4979,9 +5248,18 @@
4979
5248
  // the native legend: createLegend (its four hooks are what the rest
4980
5249
  // of the map calls — panels, the legend option, sub-themes, layout)
4981
5250
  ({ addLegendPanel, setLegendOption, updateSubTheme, layoutLegends } =
4982
- createLegend({ builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw }));
5251
+ createLegend({ builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw,
5252
+ // flat's legend slider (legend.js 2839-2854) patches the style and
5253
+ // calls redrawTheme — a per-theme redraw that does NOT fire
5254
+ // htmlgui_onDrawTheme, so a page's own onDrawTheme hook (this
5255
+ // port's hookStatistics re-applying its own slider's scale)
5256
+ // only reasserts on the next full redraw (pan/zoom). refresh()
5257
+ // here would notify synchronously and clobber the new scale in
5258
+ // the same tick — this quiet variant is that flat behavior.
5259
+ quietRefresh: () => { refreshLayers(); } }));
4983
5260
 
4984
5261
  _lastMapApi = engineApi;
5262
+ if (this.mapOptions && this.mapOptions.name) _mapApis[this.mapOptions.name] = engineApi;
4985
5263
  // ?ixgl-debug: ixmaps.__glDebug.check() reports, for the moment it is
4986
5264
  // called, every theme's geometry problems (invalid or mixed-dimension
4987
5265
  // coordinates, a shape far larger than the theme's others) and what
@@ -5047,6 +5325,27 @@
5047
5325
  }
5048
5326
  }
5049
5327
 
5328
+ // GL-PORT COMPAT: flat's ixmaps.Map(div, opts, cb) hands the callback a
5329
+ // mapApi HANDLE (htmlgui_flat.js 904-935) — a real page keeps it and
5330
+ // calls its methods on it (__map.setView(...), __map.replace(...) —
5331
+ // AirBnB's city selector). This engine hands the callback the BUILDER
5332
+ // for its own .options().view().layer() chain; the builder here also
5333
+ // carries flat's handle methods, delegated to the map handle of THIS
5334
+ // map's options name (calls before the map exists wait for it, exactly
5335
+ // as ixmaps.map() does). The builder's own methods with different
5336
+ // semantics — layer, view, options, require, attribution, on — keep
5337
+ // theirs and are not overridden.
5338
+ for (const m of ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5339
+ 'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize',
5340
+ 'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
5341
+ 'getThemes', 'theme', 'setView']) {
5342
+ if (!MapBuilder.prototype[m]) {
5343
+ MapBuilder.prototype[m] = function (...args) {
5344
+ return mapHandle(this.mapOptions && this.mapOptions.name)[m](...args);
5345
+ };
5346
+ }
5347
+ }
5348
+
5050
5349
  // named createMap (not Map) so it doesn't shadow the built-in ES6 Map
5051
5350
  // class used throughout this file — a real bug caught during testing
5052
5351
  // (every `new Map()` below silently resolved to *this* function instead)
@@ -5179,6 +5478,8 @@
5179
5478
  // earlier hand-tuned 12.5 drew every symbol 1.2× (with the reference
5180
5479
  // below, 1.3×) smaller than flat.
5181
5480
  const NORMAL_RADIUS_PX = 15;
5481
+ // largest BOX side (px) still drawn as a canvas icon (at 2×), see _buildBoxIcon
5482
+ const BOX_ICON_MAX_PX = 2048;
5182
5483
  // Flat's own map scale, which dynamic object scaling compares with
5183
5484
  // normalSizeScale (mapscript2.js 2849: dx = nTrueMapScale · nZoomScale /
5184
5485
  // nNormalSizeScale), read from a real flat page: the generic Mercator
@@ -5425,6 +5726,16 @@
5425
5726
 
5426
5727
  function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue) {
5427
5728
  const zoomFactor = objectZoomFactor(zoom, mapOptions);
5729
+ // FIXSIZE (a symbol chart; PLOT / GRIDSIZE size their markers
5730
+ // themselves): one radius for every item, whatever its value — the
5731
+ // normal radius ÷ normalsizevalue, half of it for BUBBLE (maptheme.js
5732
+ // 20711, 21847; measured on flat: PNRR "details", r = 1.5 units at
5733
+ // scale 0.1)
5734
+ if (flags && flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')) {
5735
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1) / (styleNum(style.normalsizevalue) || 1)
5736
+ * (flatChartBranch(flags) === 'bubble' ? 0.5 : 1);
5737
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5738
+ }
5428
5739
  // Real default (maptheme.js ~line 5059) when the theme sets no
5429
5740
  // normalsizevalue: the dataset's own max value on the bound size
5430
5741
  // field (maxSizeValue, from LayerRuntime._prepare), not a fixed
@@ -5629,6 +5940,8 @@
5629
5940
  const field = binding.size ? binding.size
5630
5941
  : (symbols && !flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && binding.value && !String(binding.value).includes('|') ? binding.value : null);
5631
5942
  if (!field) return undefined;
5943
+ // "$item$": every record counts 1
5944
+ if (field === '$item$') return features.length ? 1 : 0;
5632
5945
  return features.reduce((max, f) => {
5633
5946
  const v = parseFloat(f.properties[field]);
5634
5947
  return isNaN(v) ? max : Math.max(max, v);
@@ -6228,6 +6541,13 @@
6228
6541
  // researched safe range (~1480-2980 distinct 48px icons before a real
6229
6542
  // device's WebGL MAX_TEXTURE_SIZE is at risk).
6230
6543
  const ICON_ATLAS_RESET_AFTER = 3000;
6544
+ // The same reset by icon AREA: a few large icons (BOX rectangles around
6545
+ // MULTIQUAD grids, hundreds of px each, new sizes at every zoom) fill
6546
+ // deck.gl's 1024 px wide atlas long before 3000 icons — on the PNRR
6547
+ // page a dozen boxes doubled it per zoom step, until it passed WebGL's
6548
+ // MAX_TEXTURE_SIZE (16384) and the boxes drew black. 2 Mpx leaves room
6549
+ // for the atlas's row-packing waste (measured ~2×) below that limit.
6550
+ const ICON_ATLAS_AREA_RESET_AFTER = 2 * 1024 * 1024;
6231
6551
 
6232
6552
  // Shared packing layout for a multi-category group's sub-bubbles — used
6233
6553
  // both to draw the packed icon raster (_buildBubbleIcon) and to position
@@ -6272,6 +6592,56 @@
6272
6592
  };
6273
6593
  }
6274
6594
 
6595
+ // MULTIQUAD / MULTISQUARE (flat maptheme.js 18648-18690): the items at
6596
+ // one position, in drawing order, fill quads around it — the k-th at
6597
+ // square = ⌊√k⌋, (k − square², square) while that is within the square,
6598
+ // else (square, square − (k − square² − square)); once square reaches
6599
+ // gridx, rows of gridx go on upward (k % gridx, ⌊k / gridx⌋). Steps of
6600
+ // 2 · normal radius · rangescale (flat's chart size, measured on the PNRR
6601
+ // page); y up. Returns per item its pixel offset and index, and per
6602
+ // position's first item the extent [minX, minY, maxX, maxY] of the
6603
+ // offsets there (flat sizes the BOX around all of them).
6604
+ function multiQuadOffsets(items, normalRadiusPx, rangeScale, gridX) {
6605
+ const step = 2 * normalRadiusPx * (rangeScale || 1);
6606
+ const count = new Map(), offset = new Map(), index = new Map(), first = new Map(), extent = new Map();
6607
+ for (const d of items) {
6608
+ const c = d.geometry && d.geometry.coordinates;
6609
+ const key = c ? c[0] + ',' + c[1] : '';
6610
+ const k = count.get(key) || 0;
6611
+ count.set(key, k + 1);
6612
+ index.set(d, k);
6613
+ if (k === 0) { first.set(key, d); extent.set(d, [0, 0, 0, 0]); }
6614
+ let x = 0, y = 0;
6615
+ if (k > 0) {
6616
+ const square = Math.floor(Math.sqrt(k));
6617
+ if (square < (gridX || Infinity)) {
6618
+ const line = k - square * square;
6619
+ x = line <= square ? line : square;
6620
+ y = line <= square ? square : square - (line - square);
6621
+ } else {
6622
+ x = k % gridX;
6623
+ y = Math.floor(k / gridX);
6624
+ }
6625
+ }
6626
+ offset.set(d, [x * step, -y * step]);
6627
+ const e = extent.get(first.get(key));
6628
+ e[0] = Math.min(e[0], x * step); e[1] = Math.min(e[1], -y * step);
6629
+ e[2] = Math.max(e[2], x * step); e[3] = Math.max(e[3], -y * step);
6630
+ }
6631
+ return { offset, index, extent };
6632
+ }
6633
+
6634
+ // A pixel offset [dx, dy] (y down) from lngLat at MapLibre zoom `zoom`,
6635
+ // as lng/lat (Web Mercator, 512 px world tiles) — for shapes too large
6636
+ // for an icon, drawn in map coordinates (rebuilt with every refresh).
6637
+ function pixelOffsetLngLat(lngLat, offset, zoom) {
6638
+ const world = 512 * Math.pow(2, zoom);
6639
+ const lat0 = lngLat[1] * Math.PI / 180;
6640
+ const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + lat0 / 2)) / (2 * Math.PI)) * world + offset[1];
6641
+ const lat = (2 * Math.atan(Math.exp((0.5 - y / world) * 2 * Math.PI)) - Math.PI / 2) * 180 / Math.PI;
6642
+ return [lngLat[0] + offset[0] * 360 / world, lat];
6643
+ }
6644
+
6275
6645
  // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
6276
6646
  // 22296-22395): one symbol per category part of a chart, in category
6277
6647
  // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
@@ -7194,9 +7564,13 @@
7194
7564
  // set (e.g. roma: summed Pericolosita, normalized to 0..1).
7195
7565
  // (flat's per-item nSize holds a record count in that last case, but it
7196
7566
  // is not what draws a value-based symbol.)
7567
+ // "$item$" (as size or value) is flat's record count: 1 per record — it
7568
+ // is no column (read as one, every record was 0 and a LABEL|TEXTONLY
7569
+ // theme's text 0.08 px high).
7197
7570
  function resolveAggregateValue(binding, flags, props) {
7571
+ if (binding.size === '$item$') return 1;
7198
7572
  if (binding.size) return parseFloat(props[binding.size]) || 0;
7199
- if (flags.has('CATEGORICAL') || binding.value == null) return 1;
7573
+ if (flags.has('CATEGORICAL') || binding.value == null || binding.value === '$item$') return 1;
7200
7574
  return parseFloat(props[binding.value]) || 0;
7201
7575
  }
7202
7576
 
@@ -7294,7 +7668,12 @@
7294
7668
  key = `${snapped.x}:${snapped.y}`;
7295
7669
  }
7296
7670
  let cell = cells.get(key);
7297
- if (!cell) { cell = { key, snapped, sumX: 0, sumY: 0, n: 0, value: 0, classValue: 0, value100: 0, series: null, first: f }; cells.set(key, cell); }
7671
+ if (!cell) { cell = { key, snapped, sumX: 0, sumY: 0, n: 0, value: 0, classValue: 0, value100: 0, series: null, first: f, raws: null }; cells.set(key, cell); }
7672
+ // the members' own records, for the tooltip's {{theme.item.data}}
7673
+ // (flat's __add_data tables them, numbered, up to 50)
7674
+ if (f.properties.raw && (!cell.raws || cell.raws.length < 50)) {
7675
+ (cell.raws = cell.raws || []).push(f.properties.raw);
7676
+ }
7298
7677
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7299
7678
  cell.value += f.properties.value;
7300
7679
  if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
@@ -7316,7 +7695,8 @@
7316
7695
  // titleRaw: the cell's first record — flat titles an aggregated
7317
7696
  // item by it (itemTitle)
7318
7697
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}),
7319
- titleRaw: cell.first.properties.raw || cell.first.properties }
7698
+ titleRaw: cell.first.properties.raw || cell.first.properties,
7699
+ ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}) }
7320
7700
  : cell.first.properties;
7321
7701
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
7322
7702
  // a field aggregation's cell key (the field value): the categories of
@@ -7383,6 +7763,18 @@
7383
7763
  if (!cell) { cell = { sumX: 0, sumY: 0, n: 0, cx: snapped.x, cy: snapped.y, counts: new Array(n).fill(0), recordCounts: new Array(n).fill(0), classTotal: undefined }; cells.set(key, cell); }
7384
7764
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7385
7765
  if (!cell.titleRaw) cell.titleRaw = f.properties.titleRaw || f.properties.raw || null;
7766
+ // the members' own records, for the tooltip's {{theme.item.data}} —
7767
+ // flat's __add_data tables them (numbered, up to 50) for aggregated
7768
+ // items too; references only, so no copy is made. A member can itself
7769
+ // be a pre-aggregated cluster node, whose records it carries in raws
7770
+ if (!cell.raws) cell.raws = [];
7771
+ if (cell.raws.length < 50) {
7772
+ if (f.properties.raw) cell.raws.push(f.properties.raw);
7773
+ else if (f.properties.raws) for (const r of f.properties.raws) {
7774
+ if (cell.raws.length >= 50) break;
7775
+ cell.raws.push(r);
7776
+ }
7777
+ }
7386
7778
  cell.counts[f.properties.cat] += f.properties.value;
7387
7779
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
7388
7780
  // point_count is the aggregated-record count of a multi-record
@@ -7397,6 +7789,7 @@
7397
7789
  return {
7398
7790
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
7399
7791
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}),
7792
+ ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}),
7400
7793
  ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}) }
7401
7794
  };
7402
7795
  });
@@ -7441,13 +7834,25 @@
7441
7834
  return count;
7442
7835
  }
7443
7836
 
7837
+ // flat matches type flags as substrings (szFlag.match(/SYMBOL/)), so a
7838
+ // "SYMBOLS" flag (PNRR pages) is a SYMBOL chart there. With only the symbol
7839
+ // "label" (maptheme.js 21833: the value as text, TEXTONLY: text alone) it is
7840
+ // the LABEL chart this engine draws as _buildLabelChartLayers.
7841
+ function symbolsFlagCompat(flags, style) {
7842
+ if (!flags.has('SYMBOLS') || flags.has('SYMBOL') || flags.has('LABEL')) return flags;
7843
+ const out = new Set(flags);
7844
+ const symbols = [].concat((style && style.symbols) || []);
7845
+ out.add(symbols.length && symbols.every(v => String(v) === 'label') ? 'LABEL' : 'SYMBOL');
7846
+ return out;
7847
+ }
7848
+
7444
7849
  class LayerRuntime {
7445
7850
  // spec: a normalizeTheme() result — never a raw LayerBuilder
7446
7851
  constructor(spec, fc, mapOptions) {
7447
7852
  this.name = spec.name;
7448
7853
  this.binding = spec.binding;
7449
- this.flags = spec.flags;
7450
7854
  this.style = spec.style;
7855
+ this.flags = symbolsFlagCompat(spec.flags, spec.style);
7451
7856
  this.meta = spec.meta;
7452
7857
  // GL-PORT COMPAT: retained only for ixmaps.getThemeObj()'s szFilter
7453
7858
  // (real ixmaps-flat global compat shim, see _globalThemeRegistry) —
@@ -7481,6 +7886,7 @@
7481
7886
  // ICON_ATLAS_RESET_AFTER for why this is necessary at all).
7482
7887
  this._iconGeneration = 0;
7483
7888
  this._iconsSinceAtlasReset = 0;
7889
+ this._iconAreaSinceAtlasReset = 0;
7484
7890
  this._prepare();
7485
7891
  }
7486
7892
 
@@ -7503,9 +7909,11 @@
7503
7909
  _cacheIcon(key, icon) {
7504
7910
  if (!this._iconCache.has(key)) {
7505
7911
  this._iconsSinceAtlasReset++;
7506
- if (this._iconsSinceAtlasReset > ICON_ATLAS_RESET_AFTER) {
7912
+ this._iconAreaSinceAtlasReset += (icon.width || 0) * (icon.height || 0);
7913
+ if (this._iconsSinceAtlasReset > ICON_ATLAS_RESET_AFTER || this._iconAreaSinceAtlasReset > ICON_ATLAS_AREA_RESET_AFTER) {
7507
7914
  this._iconGeneration++;
7508
7915
  this._iconsSinceAtlasReset = 0;
7916
+ this._iconAreaSinceAtlasReset = 0;
7509
7917
  }
7510
7918
  }
7511
7919
  if (this._iconCache.size >= ICON_CACHE_MAX) {
@@ -7646,6 +8054,10 @@
7646
8054
  // a page could in principle use both at once, though no current
7647
8055
  // ported page does.
7648
8056
  this._runtimeFilterExpr = '';
8057
+ // flat's setThemeTimeFrame(id, min, max): records whose style.timefield
8058
+ // value lies in [min, max) — null = no time filter. AND'd with the
8059
+ // filters above in _rebuildActiveFeatures.
8060
+ this._timeFrame = null;
7649
8061
 
7650
8062
  // GRIDSIZE layers (PLOT curves-chart, or its grid-mesh companion)
7651
8063
  // also carry AGGREGATE in their type string, but they bin into a
@@ -7842,6 +8254,44 @@
7842
8254
  this._rebuildActiveFeatures();
7843
8255
  }
7844
8256
 
8257
+ // setThemeTimeFrame: min/max in ms or any Date-parsable value; a null /
8258
+ // NaN bound is open-ended, both null clears the frame.
8259
+ setTimeFrame(min, max) {
8260
+ const toMs = v => (v == null || v === '' ? NaN : (typeof v === 'number' ? v : new Date(v).getTime()));
8261
+ const lo = toMs(min), hi = toMs(max);
8262
+ // flat: with the slider at its start position (frame starts at or
8263
+ // before the earliest record) every item is shown, not just the first window
8264
+ const field = this.binding && this.binding.time;
8265
+ if (field && !isNaN(lo)) {
8266
+ if (this._timeMin === undefined) {
8267
+ let m = Infinity;
8268
+ for (const f of this.features) {
8269
+ const v = f.properties && f.properties[field];
8270
+ const t = typeof v === 'number' ? v : new Date(v).getTime();
8271
+ if (!isNaN(t) && t < m) m = t;
8272
+ }
8273
+ this._timeMin = m;
8274
+ }
8275
+ if (lo <= this._timeMin) { this._timeFrame = null; this._rebuildActiveFeatures(); return; }
8276
+ }
8277
+ this._timeFrame = isNaN(lo) && isNaN(hi) ? null : { min: lo, max: hi };
8278
+ this._rebuildActiveFeatures();
8279
+ }
8280
+
8281
+ // predicate over a record's properties for the active time frame
8282
+ _timePredicate() {
8283
+ const tf = this._timeFrame;
8284
+ if (!tf) return null;
8285
+ const field = this.binding && this.binding.time;
8286
+ if (!field) return null;
8287
+ return props => {
8288
+ const v = props && props[field];
8289
+ const t = typeof v === 'number' ? v : new Date(v).getTime();
8290
+ if (isNaN(t)) return false;
8291
+ return (isNaN(tf.min) || t >= tf.min) && (isNaN(tf.max) || t < tf.max);
8292
+ };
8293
+ }
8294
+
7845
8295
  // `_featuresByCategory` (plain per-category bucketing — NOT yet
7846
8296
  // grid-aggregated; that's a separate, later step gated by AGGREGATE
7847
8297
  // alone, see _computeAggregatedItems) is needed whenever this runtime
@@ -7872,7 +8322,8 @@
7872
8322
  replaceFeatures(fc) {
7873
8323
  // new data → new deck.gl layers (see _dataLayerId)
7874
8324
  this._dataGen = (this._dataGen || 0) + 1;
7875
- const facetFilters = this.facetFilters, runtimeFilterExpr = this._runtimeFilterExpr;
8325
+ this._timeMin = undefined;
8326
+ const facetFilters = this.facetFilters, runtimeFilterExpr = this._runtimeFilterExpr, timeFrame = this._timeFrame;
7876
8327
  this.binding = this._specBinding;
7877
8328
  this.features = applyField100(filterFlatValues(this._withDensity(fc.features), this.binding, this.flags), this.binding, this.flags, this.style);
7878
8329
  this.binding = field100Binding(this.binding, this.flags);
@@ -7882,6 +8333,7 @@
7882
8333
  this._prepare();
7883
8334
  this.facetFilters = facetFilters;
7884
8335
  this._runtimeFilterExpr = runtimeFilterExpr;
8336
+ this._timeFrame = timeFrame;
7885
8337
  this._rebuildActiveFeatures();
7886
8338
  }
7887
8339
 
@@ -7894,6 +8346,27 @@
7894
8346
  // the next redraw.
7895
8347
  _dataLayerId(base) { return this._dataGen ? `${base}-d${this._dataGen}` : base; }
7896
8348
 
8349
+ // deck.gl compares a layer's data by reference: a new object makes it
8350
+ // re-process every polygon (on the PNRR page ~300 ms per refresh for
8351
+ // 8 000 comuni, at every pan tick). Polygon data that doesn't depend
8352
+ // on the view is therefore handed over as the same object until its
8353
+ // inputs change.
8354
+ _featureCollection() {
8355
+ if (!this._featuresFC || this._featuresFC.features !== this.features) {
8356
+ this._featuresFC = { type: 'FeatureCollection', features: this.features };
8357
+ }
8358
+ return this._featuresFC;
8359
+ }
8360
+ // the same for derived data: build() runs only when an entry of key
8361
+ // (compared by identity) differs from the last call's
8362
+ _cachedPolygonData(slot, key, build) {
8363
+ const last = this[slot];
8364
+ if (last && last.key.length === key.length && last.key.every((v, i) => v === key[i])) return last.data;
8365
+ const data = build();
8366
+ this[slot] = { key, data };
8367
+ return data;
8368
+ }
8369
+
7897
8370
  _usesAggregationIndex() {
7898
8371
  return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !this.flags.has('GRIDSIZE');
7899
8372
  }
@@ -7905,8 +8378,10 @@
7905
8378
  if (isAggregatedCategoricalChoropleth(this)) {
7906
8379
  const expr = this._runtimeFilterExpr;
7907
8380
  const clauses = Array.from(this.facetFilters.entries());
7908
- if (!expr && !clauses.length) { this._activeFeatures = null; return; }
7909
- const pred = expr ? flatFilterPredicate(String(expr)) : null;
8381
+ const timePred = this._timePredicate();
8382
+ if (!expr && !clauses.length && !timePred) { this._activeFeatures = null; return; }
8383
+ const wherePred = expr ? flatFilterPredicate(String(expr)) : null;
8384
+ const pred = wherePred && timePred ? (r => wherePred(r) && timePred(r)) : (wherePred || timePred);
7910
8385
  this._activeFeatures = this.features.map(f => {
7911
8386
  const rows = f.properties && f.properties[AGGREGATED_ROWS];
7912
8387
  if (!rows) return f;
@@ -7924,11 +8399,13 @@
7924
8399
  // comment) if one is active — reusing applyWhereFilter, the SAME
7925
8400
  // parser the layer's load-time .filter(expr) already uses, just
7926
8401
  // invoked again here instead of once at build time.
7927
- const base = this._runtimeFilterExpr
8402
+ let base = this._runtimeFilterExpr
7928
8403
  ? applyWhereFilter({ type: 'FeatureCollection', features: this.features }, this._runtimeFilterExpr).features
7929
8404
  : this.features;
8405
+ const timePred = this._timePredicate();
8406
+ if (timePred) base = base.filter(f => timePred(f.properties));
7930
8407
  if (this.facetFilters.size === 0) {
7931
- this._activeFeatures = this._runtimeFilterExpr ? base : null;
8408
+ this._activeFeatures = (this._runtimeFilterExpr || timePred) ? base : null;
7932
8409
  } else {
7933
8410
  const clauses = Array.from(this.facetFilters.entries());
7934
8411
  this._activeFeatures = base.filter(f =>
@@ -8298,10 +8775,11 @@
8298
8775
  // formatting) instead of an inline SVG chart — one line per present
8299
8776
  // category, each showing ITS OWN value, never summed across
8300
8777
  // categories (same rule as the VALUES bubble labels).
8301
- // - theme.item.data (a table of the hovered record's raw fields) is
8302
- // only available for an individual, unaggregated point — an
8303
- // aggregated/grouped bubble has no single underlying record to
8304
- // tabulate, so it resolves to an empty string there.
8778
+ // - theme.item.data (a table of the hovered record's raw fields)
8779
+ // tables an individual point's record, and an aggregated item's
8780
+ // member records (numbered, up to 50 — flat's __add_data does the
8781
+ // same); only a group without retained records (cluster-level
8782
+ // groups) resolves to an empty string there.
8305
8783
  // an item's title as flat gives it (maptheme.js 11838-11886): the title
8306
8784
  // field of its record — of an aggregated item its first record; an
8307
8785
  // aggregated grid cell (no aggregation field) of more than 3 records,
@@ -8317,7 +8795,7 @@
8317
8795
  return title;
8318
8796
  }
8319
8797
 
8320
- buildTooltipHtml(object) {
8798
+ buildTooltipHtml(object, fDark) {
8321
8799
  // no .meta({tooltip}): flat's default template (tooltip_mustache.js 394)
8322
8800
  // — the theme title, the item's title and its chart
8323
8801
  const template = this.meta.tooltip || FLAT_DEFAULT_TOOLTIP;
@@ -8326,11 +8804,16 @@
8326
8804
  'include https://unpkg.com/mustache@4.2.0/mustache.min.js before ixmaps-gl.js.');
8327
8805
  return null;
8328
8806
  }
8329
- return global.Mustache.render(template, this._buildTooltipContext(object));
8807
+ return youtubeClickToPlay(global.Mustache.render(template, this._buildTooltipContext(object, fDark)));
8330
8808
  }
8331
8809
 
8332
- _buildTooltipContext(object) {
8333
- const props = object.properties;
8810
+ _buildTooltipContext(object, fDark) {
8811
+ let props = object.properties;
8812
+ // a FEATURE|CATEGORICAL polygon is the raw data record itself, not the
8813
+ // {raw, cat, value} wrapper the other layer kinds build
8814
+ if (props && !props.raw && (this.flags.has('FEATURE') || this.flags.has('FEATURES')) && this.flags.has('CATEGORICAL')) {
8815
+ props = { raw: props, cat: this._resolveClassIndex(props[this.binding.value]) };
8816
+ }
8334
8817
  const isGroup = !!props.counts;
8335
8818
  const chartHtml = this._renderItemChartHtml(props);
8336
8819
 
@@ -8342,7 +8825,7 @@
8342
8825
  chart: chartHtml,
8343
8826
  item: {
8344
8827
  chart: chartHtml,
8345
- data: isGroup ? '' : this._renderItemDataTableHtml(props.raw),
8828
+ data: isGroup ? this._renderGroupDataTableHtml(props.raws, fDark) : this._renderItemDataTableHtml(props.raw, fDark),
8346
8829
  value: this._hasValue(isGroup ? props.total : props.value) ? this._formatTooltipValue(isGroup ? props.total : props.value) : '',
8347
8830
  label: isGroup ? '' : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : ''),
8348
8831
  // flat: the item's title is its titlefield value (binding title/
@@ -8485,14 +8968,49 @@
8485
8968
  }
8486
8969
 
8487
8970
  // theme.item.data approximation — a plain field:value table of the
8488
- // hovered record's own raw properties (individual points only).
8489
- _renderItemDataTableHtml(raw) {
8971
+ // hovered record's own raw properties (individual points only) — flat's
8972
+ // own row markup (tooltip_mustache.js 568-590, main.css .tooltip): the
8973
+ // field name right-aligned, the value left, a dotted rule above the
8974
+ // value, the table at 0.85em with flat's min-width. Light is flat
8975
+ // exactly (#cccccc labels, #000000 values, dotted black); dark keeps
8976
+ // the same visibility hierarchy inverted - DIM labels, BRIGHT values -
8977
+ // with its own colors, a dotted #555 rule and #888 record pills
8978
+ _renderItemDataTableHtml(raw, fDark) {
8490
8979
  if (!raw) return '';
8980
+ const value = fDark ? '#e0e4ea' : '#000000';
8981
+ const label = fDark ? '#787d85' : '#cccccc';
8982
+ const rule = fDark ? '#555' : 'black';
8491
8983
  const rows = Object.keys(raw)
8492
8984
  .filter(k => k !== 'geometry')
8493
- .map(k => `<tr><td style="padding:0 0.6em 0 0;color:#888">${k}</td><td>${this._isNumericValue(raw[k]) ? this._formatTooltipValue(raw[k]) : raw[k]}</td></tr>`)
8985
+ .map(k => `<tr><td></td><td style="text-align:right;vertical-align:top;color:${label}">${k}</td>` +
8986
+ `<td style="text-align:left;vertical-align:top;color:${value};padding:0 0.2em 0 0.8em;border-top:dotted ${rule} 0.1px">` +
8987
+ `${this._isNumericValue(raw[k]) ? this._formatTooltipValue(raw[k]) : raw[k]}</td></tr>`)
8494
8988
  .join('');
8495
- return `<table style="font-size:0.95em;border-collapse:collapse">${rows}</table>`;
8989
+ return `<table style="font-size:0.85em;min-width:300px;border-collapse:collapse">${rows}</table>`;
8990
+ }
8991
+
8992
+ // an aggregated item's own records, numbered, up to 50 — like real
8993
+ // flat's __add_data (tooltip_mustache.js 490-570: one numbered block
8994
+ // per record when the item holds more than one). A single-record
8995
+ // group renders like an individual point.
8996
+ _renderGroupDataTableHtml(raws, fDark) {
8997
+ if (!raws || !raws.length) return '';
8998
+ if (raws.length === 1) return this._renderItemDataTableHtml(raws[0], fDark);
8999
+ const value = fDark ? '#e0e4ea' : '#000000';
9000
+ const label = fDark ? '#787d85' : '#cccccc';
9001
+ const rule = fDark ? '#555' : 'black';
9002
+ const pill = fDark ? '#888' : 'black';
9003
+ const rows = raws.slice(0, 50).map((raw, d) =>
9004
+ '<tr><td style="font-size:1em;">&nbsp;</td></tr>' +
9005
+ `<tr><td style="font-size:1.5em;border:solid ${pill} 1px;border-radius:1em;padding:0.1em 0.4em 0em 0.3em">${d + 1}</td></tr>` +
9006
+ Object.keys(raw)
9007
+ .filter(k => k !== 'geometry')
9008
+ .map(k => `<tr><td></td><td style="text-align:right;vertical-align:top;color:${label}">${k}</td>` +
9009
+ `<td style="text-align:left;vertical-align:top;color:${value};padding:0 0.2em 0 0.8em;border-top:dotted ${rule} 0.1px">` +
9010
+ `${this._isNumericValue(raw[k]) ? this._formatTooltipValue(raw[k]) : raw[k]}</td></tr>`)
9011
+ .join('')
9012
+ ).join('');
9013
+ return `<table style="font-size:0.85em;min-width:300px;border-collapse:collapse">${rows}</table>`;
8496
9014
  }
8497
9015
 
8498
9016
  _isNumericValue(v) {
@@ -8619,11 +9137,19 @@
8619
9137
  const cs = this.style.colorscheme;
8620
9138
  const raw = Array.isArray(cs) ? cs[0] : cs;
8621
9139
  const filled = raw !== 'none';
9140
+ // FEATURE|CATEGORICAL: polygons coloured by their category (flat colours
9141
+ // each shape by its class), not one flat fill
9142
+ const byCategory = filled && this.flags.has('CATEGORICAL') && this.categoryIndexByLabel && this.binding.value
9143
+ && this.categoryColorsRgb && this.categoryColorsRgb.length;
9144
+ const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
9145
+ const nodataRgb = parseCssColor(this.style.nodatacolor) || [255, 255, 255];
8622
9146
  const shadow = filled && flatShadowOn(this.style, this.features.length, zoom)
8623
9147
  ? this._buildShadowLayers(hexOrNamedToRgb(raw), zoom, bbox) : [];
8624
9148
  return shadow.concat([new GeoJsonLayer({
8625
9149
  id: this._dataLayerId(`ix-features-${this.name}`),
8626
- data: { type: 'FeatureCollection', features: this.features },
9150
+ data: this._featureCollection(),
9151
+ // FEATURE|CATEGORICAL polygons carry the data record: hover / click tooltips
9152
+ pickable: !!byCategory,
8627
9153
  // linecolor "none": no outline (a FEATURES theme of points with
8628
9154
  // colorscheme and linecolor "none" draws nothing, as flat)
8629
9155
  stroked: styleLineColor(this.style.linecolor) !== 'none',
@@ -8634,7 +9160,13 @@
8634
9160
  // flat puts fillopacity into the shape's fill-opacity only
8635
9161
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
8636
9162
  // (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
8637
- getFillColor: filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
9163
+ ...(byCategory ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
9164
+ getFillColor: byCategory
9165
+ ? f => {
9166
+ const cat = this._resolveClassIndex(f.properties[this.binding.value]);
9167
+ return [...(cat != null ? this.categoryColorsRgb[cat] : nodataRgb).slice(0, 3), catAlpha];
9168
+ }
9169
+ : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
8638
9170
  getLineColor: this.style.linecolor
8639
9171
  ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
8640
9172
  : [130, 130, 130],
@@ -8708,8 +9240,8 @@
8708
9240
  // single winning class, so no `cat`/tooltip chart line, just the
8709
9241
  // blended color directly); AGGREGATE CATEGORICAL on one field — the
8710
9242
  // dominant category of each polygon's records (aggregatedCategoricalClass).
8711
- // A plain CATEGORICAL choropleth (exact match of one row's value) is not
8712
- // implemented. Geometry + properties are already the joined
9243
+ // A plain CATEGORICAL choropleth matches the raw value of the polygon's
9244
+ // row against the category labels. Geometry + properties are already the joined
8713
9245
  // FeatureCollection from joinChoroplethFeatures. `zoom` is only used
8714
9246
  // for style.fillopacity:"auto" (see resolveAutoFillOpacity) — every
8715
9247
  // other branch here is zoom-independent.
@@ -8735,65 +9267,76 @@
8735
9267
  // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
8736
9268
  // (aggregatedCategoricalClass), the biggest part per class over all
8737
9269
  // polygons for the opacity (flat's nMaxA)
8738
- const aggCat = isAggregatedCategoricalChoropleth(this) && this.categoryIndexByLabel;
8739
- let aggOf = null, aggMaxA = null;
8740
- if (aggCat) {
8741
- const nCats = this.categoryLabels.length;
8742
- aggOf = new Map();
8743
- aggMaxA = new Array(nCats).fill(0);
8744
- source.forEach(f => {
8745
- const rows = f.properties && f.properties[AGGREGATED_ROWS];
8746
- if (!rows) return;
8747
- const agg = aggregatedCategoricalClass(rows, this.binding, this.flags, this.categoryIndexByLabel, nCats);
8748
- if (!agg) return;
8749
- aggOf.set(f, agg);
8750
- agg.parts.forEach((v, i) => { if (v > aggMaxA[i]) aggMaxA[i] = v; });
8751
- });
8752
- }
8753
- const data = source.map(f => {
9270
+ // the polygons' classes don't depend on the view or on marked
9271
+ // classes (those only change the colors, see updateTriggers below):
9272
+ // the data is rebuilt only when its inputs change
9273
+ const dataKey = [source, this._dataGen, this.categoryIndexByLabel, this.partsA, this._classSeparate,
9274
+ dopacityActive ? baseOpacity : null, JSON.stringify(this.style), JSON.stringify(this.binding)];
9275
+ const collection = this._cachedPolygonData('_choroplethData', dataKey, () => {
9276
+ const aggCat = isAggregatedCategoricalChoropleth(this) && this.categoryIndexByLabel;
9277
+ let aggOf = null, aggMaxA = null;
8754
9278
  if (aggCat) {
8755
- const agg = aggOf.get(f);
8756
- return {
8757
- type: 'Feature',
8758
- geometry: f.geometry,
8759
- properties: {
8760
- // no part above 0: no item in flat, not painted
8761
- value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null, parts: agg ? agg.parts : undefined,
8762
- dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
8763
- }
8764
- };
8765
- }
8766
- if (isComposeColor) {
8767
- return {
8768
- type: 'Feature',
8769
- geometry: f.geometry,
8770
- properties: { raw: f.properties, composedColor: this._resolveComposedColor(f.properties) }
8771
- };
9279
+ const nCats = this.categoryLabels.length;
9280
+ aggOf = new Map();
9281
+ aggMaxA = new Array(nCats).fill(0);
9282
+ source.forEach(f => {
9283
+ const rows = f.properties && f.properties[AGGREGATED_ROWS];
9284
+ if (!rows) return;
9285
+ const agg = aggregatedCategoricalClass(rows, this.binding, this.flags, this.categoryIndexByLabel, nCats);
9286
+ if (!agg) return;
9287
+ aggOf.set(f, agg);
9288
+ agg.parts.forEach((v, i) => { if (v > aggMaxA[i]) aggMaxA[i] = v; });
9289
+ });
8772
9290
  }
8773
- if (isDominant) {
8774
- const dom = this._resolveDominantClass(f.properties);
8775
- const value = dom ? dom.value : undefined;
9291
+ const data = source.map(f => {
9292
+ if (aggCat) {
9293
+ const agg = aggOf.get(f);
9294
+ return {
9295
+ type: 'Feature',
9296
+ geometry: f.geometry,
9297
+ properties: {
9298
+ // no part above 0: no item in flat, not painted
9299
+ value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null, parts: agg ? agg.parts : undefined,
9300
+ dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
9301
+ }
9302
+ };
9303
+ }
9304
+ if (isComposeColor) {
9305
+ return {
9306
+ type: 'Feature',
9307
+ geometry: f.geometry,
9308
+ properties: { raw: f.properties, composedColor: this._resolveComposedColor(f.properties) }
9309
+ };
9310
+ }
9311
+ if (isDominant) {
9312
+ const dom = this._resolveDominantClass(f.properties);
9313
+ const value = dom ? dom.value : undefined;
9314
+ return {
9315
+ type: 'Feature',
9316
+ geometry: f.geometry,
9317
+ properties: {
9318
+ value, raw: f.properties, cat: dom ? dom.index : null,
9319
+ dopacityAlpha: dopacityActive ? this._resolveDopacityAlpha(f, value, baseOpacity) : null
9320
+ }
9321
+ };
9322
+ }
9323
+ // a polygon without a data row is no item in flat: no value
9324
+ const hasRow = f.properties && Object.keys(f.properties).length > 0;
9325
+ // CATEGORICAL matches the raw value against the category labels
9326
+ // (a text value has no numeric form: flatNumber would give NaN)
9327
+ const rawVal = hasRow ? f.properties[this.binding.value] : undefined;
9328
+ const value = this.categoryIndexByLabel ? rawVal : (hasRow ? flatNumber(rawVal, this.flags) : NaN);
9329
+ const cat = this._resolveClassIndex(value);
8776
9330
  return {
8777
9331
  type: 'Feature',
8778
9332
  geometry: f.geometry,
8779
9333
  properties: {
8780
- value, raw: f.properties, cat: dom ? dom.index : null,
9334
+ value, raw: f.properties, cat,
8781
9335
  dopacityAlpha: dopacityActive ? this._resolveDopacityAlpha(f, value, baseOpacity) : null
8782
9336
  }
8783
9337
  };
8784
- }
8785
- // a polygon without a data row is no item in flat: no value
8786
- const hasRow = f.properties && Object.keys(f.properties).length > 0;
8787
- const value = hasRow ? flatNumber(f.properties[this.binding.value], this.flags) : NaN;
8788
- const cat = this._resolveClassIndex(value);
8789
- return {
8790
- type: 'Feature',
8791
- geometry: f.geometry,
8792
- properties: {
8793
- value, raw: f.properties, cat,
8794
- dopacityAlpha: dopacityActive ? this._resolveDopacityAlpha(f, value, baseOpacity) : null
8795
- }
8796
- };
9338
+ });
9339
+ return { type: 'FeatureCollection', features: data };
8797
9340
  });
8798
9341
  // A marked class (legend row click / ixmaps.markThemeClass) as real
8799
9342
  // ixmaps-flat shows it on a choropleth (MapTheme.markClass,
@@ -8815,9 +9358,13 @@
8815
9358
  return evidenceMode === 'isolate' ? 'hide' : 'gray';
8816
9359
  };
8817
9360
  const grayOf = ([r, g, b]) => { const y = Math.round(0.299 * r + 0.587 * g + 0.114 * b); return [y, y, y]; };
9361
+ // deck.gl re-runs the color accessors only for new data or a changed trigger
9362
+ const colorKey = JSON.stringify([[...this._markedClasses], !!this._onMarksChanged, evidenceMode,
9363
+ this.categoryColorsRgb, fallbackRgb, classAlpha, lineRgb]);
8818
9364
  return [new GeoJsonLayer({
8819
9365
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
8820
- data: { type: 'FeatureCollection', features: data },
9366
+ data: collection,
9367
+ updateTriggers: { getFillColor: colorKey, getLineColor: colorKey },
8821
9368
  pickable: true,
8822
9369
  stroked: true,
8823
9370
  filled: true,
@@ -9890,7 +10437,11 @@
9890
10437
  this._valueMedian = sorted[Math.floor((sorted.length - 1) / 2)];
9891
10438
  }
9892
10439
 
9893
- this._aggregateStatsCache = { zoom, outlier, normalize, breaks };
10440
+ // flat's default normalsizevalue is the largest aggregated size (maptheme.js 5021-5022)
10441
+ let sizeMax = 0;
10442
+ for (const v of rawSizeTotals) if (Math.abs(v) > sizeMax) sizeMax = Math.abs(v);
10443
+
10444
+ this._aggregateStatsCache = { zoom, outlier, normalize, breaks, sizeMax };
9894
10445
  return this._aggregateStatsCache;
9895
10446
  }
9896
10447
 
@@ -9914,6 +10465,12 @@
9914
10465
  // _computeAggregatedItems below — a plain (non-AGGREGATE)
9915
10466
  // BUBBLE/CHART theme never reads them.
9916
10467
  if (this.flags.has('AGGREGATE')) this._ensureClusterIndices(zoom);
10468
+ // AGGREGATE sums without a normalsizevalue: flat's default is the
10469
+ // largest aggregated value (defaultNormalSizeValue leaves it unset, the
10470
+ // sizes depend on the aggregation)
10471
+ if (this.flags.has('AGGREGATE') && !this.flags.has('CATEGORICAL') && !styleNum(this.style.normalsizevalue) && !this.binding.size) {
10472
+ this._maxSizeValue = this._ensureAggregateStats(zoom).sizeMax || undefined;
10473
+ }
9917
10474
  let { individual, groups } = this._computeAggregatedItems(bbox, zoom);
9918
10475
  // Far-hemisphere cull under globe projection — see
9919
10476
  // isOnVisibleHemisphere's own comment for why this is needed at
@@ -9930,7 +10487,7 @@
9930
10487
  }
9931
10488
  const layers = [];
9932
10489
 
9933
- if (this.flags.has('NOOUTLIER') || this.flags.has('NORMALIZE') || this._rangeClassed) {
10490
+ if (this.flags.has('NOOUTLIER')|| this.flags.has('NORMALIZE') || this._rangeClassed) {
9934
10491
  const stats = this._ensureAggregateStats(zoom);
9935
10492
 
9936
10493
  // the quantity NOOUTLIER/NORMALIZE/classes act on: the separate
@@ -10041,6 +10598,10 @@
10041
10598
  // _buildTooltipContext's own `isGroup` check already uses).
10042
10599
  const sizeValueOf = d => d.properties.counts ? d.properties.total : d.properties.value;
10043
10600
  const combined = individual.concat(groups).sort((a, b) => sizeValueOf(a) - sizeValueOf(b));
10601
+ // maxcharts: like the flat engine (maptheme.js nMaxCharts), draw only the
10602
+ // biggest N items — combined is ascending, so drop from the front
10603
+ const maxCharts = Math.round(styleNum(this.style.maxcharts)) || 0;
10604
+ if (maxCharts > 0 && combined.length > maxCharts) combined.splice(0, combined.length - maxCharts);
10044
10605
  if (this.flags.has('USER')) {
10045
10606
  const userLayers = this._buildUserChartLayers(combined, liveZoom, sizeValueOf);
10046
10607
  if (userLayers) return layers.concat(userLayers);
@@ -10063,7 +10624,16 @@
10063
10624
  const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
10064
10625
  const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10065
10626
  // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
10066
- const alignOf = this._chartAlignFn(liveZoom, radiusOf);
10627
+ // MULTIQUAD / MULTISQUARE: the items at one position side by side
10628
+ // (multiQuadOffsets), added to the chart position
10629
+ const baseAlign = this._chartAlignFn(liveZoom, radiusOf);
10630
+ const multi = flatFlag(this.flags, 'MULTIQUAD') || flatFlag(this.flags, 'MULTISQUARE')
10631
+ ? multiQuadOffsets(combined, NORMAL_RADIUS_PX * objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1),
10632
+ styleNum(this.style.rangescale) || 1, styleNum(this.style.gridx) || 0)
10633
+ : null;
10634
+ const alignOf = multi
10635
+ ? Object.assign(d => { const a = baseAlign(d), m = multi.offset.get(d) || [0, 0]; return [a[0] + m[0], a[1] + m[1]]; }, { active: true })
10636
+ : baseAlign;
10067
10637
 
10068
10638
  // SHADOW (style.shadow, flat's gate: maxshadow, shadowupper/lower — flatShadowOn): a soft
10069
10639
  // drop shadow under each symbol, see _getShadowIcon
@@ -10145,28 +10715,42 @@
10145
10715
  // (#888888) over a half-opaque white halo (stroke font/7); its first
10146
10716
  // baseline one font below the box (BOTTOMTITLE) or 0.7 font above it.
10147
10717
  // Box and title are removed outside boxupper/boxlower.
10718
+ // Flat draws box and title in the chart group, which is scaled by
10719
+ // style.scale: the units below are u · scale. Its size measure W is
10720
+ // the chart's width (2r; with MULTIQUAD the width of the whole grid
10721
+ // at the position, the box enclosing all of it); flat's chart size
10722
+ // is 30 units.
10148
10723
  const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
10149
10724
  const circular = flatFlag(this.flags, 'CIRCULAR');
10150
- const unitPx = objectZoomFactor(liveZoom, this.mapOptions);
10725
+ const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
10151
10726
  const boxMarginU = styleNum(this.style.boxmargin) || 2;
10152
10727
  const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
10153
10728
  const titleFontPx = 5 * unitPx * (styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1);
10729
+ // flat: fill-opacity (boxopacity || 1) on the raw style value — the
10730
+ // usual string "0" is transparent; typed here, a given 0 is too
10731
+ const boxOpacity = isNaN(styleNum(this.style.boxopacity)) ? 1 : styleNum(this.style.boxopacity);
10154
10732
  const bottomTitle = flatFlag(this.flags, 'BOTTOMTITLE');
10155
10733
  const alignRight = /right/.test(String(this.style.align || ''));
10156
- const boxLayout = boxShown ? combined.map(d => {
10734
+ // with MULTIQUAD only the first item of a position gets its box/title
10735
+ const boxLayout = boxShown ? combined.filter(d => !multi || multi.index.get(d) === 0).map(d => {
10157
10736
  const r = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10158
- const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * r / 15);
10737
+ const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
10738
+ const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
10739
+ const w = chart[2] - chart[0];
10740
+ const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * w / 30);
10159
10741
  const boxR = circular ? r * 1.1 + margin : r;
10160
10742
  let title = null;
10161
10743
  if (titleShown) {
10162
10744
  const text = this._itemTitle(d.properties);
10163
10745
  if (text) {
10164
10746
  const f = titleFontPx;
10165
- const lines = wrapTitleLines(text, f, boxR * 2).split('\n');
10747
+ const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
10166
10748
  const width = Math.max(...lines.map(l => titleTextWidth(l, f)));
10167
10749
  // first baseline; lines go down one font each
10168
- const base = bottomTitle ? boxR + f : -boxR - 0.7 * f - (lines.length - 1) * f;
10169
- const x = circular ? unitPx / 2 : (alignRight ? r : -r);
10750
+ const base = circular
10751
+ ? (bottomTitle ? boxR + f : -boxR - 0.7 * f - (lines.length - 1) * f)
10752
+ : (bottomTitle ? chart[3] + f : chart[1] - 0.7 * f - (lines.length - 1) * f);
10753
+ const x = circular ? unitPx / 2 : (alignRight ? chart[2] : chart[0]);
10170
10754
  const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
10171
10755
  const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
10172
10756
  // arial: ascent 0.905, descent 0.212 of the font size
@@ -10176,16 +10760,16 @@
10176
10760
  }
10177
10761
  let rect = null;
10178
10762
  if (!circular) {
10179
- const e = [-r, -r, r, r];
10763
+ const e = chart.slice();
10180
10764
  // with a title flat trims 0.1 font off the top of chart + title
10181
10765
  if (title) { e[0] = Math.min(e[0], title.extent[0]); e[1] = Math.min(e[1], title.extent[1]) + 0.1 * titleFontPx; e[2] = Math.max(e[2], title.extent[2]); e[3] = Math.max(e[3], title.extent[3]); }
10182
10766
  rect = [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin];
10183
10767
  }
10184
- return { d, r, boxR, rect, title };
10768
+ return { d, r, w, boxR, rect, title };
10185
10769
  }) : null;
10186
10770
  if (boxShown && circular) {
10187
10771
  const boxRgb = hexOrNamedToRgb(this.style.boxcolor || '#eeeeee');
10188
- const boxAlpha = Math.round(255 * (styleNum(this.style.boxopacity) || 1));
10772
+ const boxAlpha = Math.round(255 * boxOpacity);
10189
10773
  const borderRgb = hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
10190
10774
  // ScatterplotLayer has no pixel offset: a moved chart (align,
10191
10775
  // offsetx/offsety) gets its circle as an icon
@@ -10218,20 +10802,47 @@
10218
10802
  const borderStyle = String(this.style.borderstyle || '');
10219
10803
  const boxLook = {
10220
10804
  fill: this.style.boxcolor || 'white',
10221
- fillOpacity: styleNum(this.style.boxopacity) || 1,
10805
+ fillOpacity: boxOpacity,
10222
10806
  stroke: borderStyle === 'none' ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
10223
10807
  strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
10224
10808
  };
10225
10809
  const borderWidth = styleNum(this.style.borderwidth) || 1;
10226
10810
  const borderRadius = styleNum(this.style.borderradius);
10227
- boxLayout.forEach(b => {
10811
+ const strokeOf = b => borderWidth * 0.2 * b.w / 30;
10812
+ // a box larger than BOX_ICON_MAX_PX (a MULTIQUAD grid of thousands
10813
+ // of items) would need a huge canvas: it is drawn as a polygon in
10814
+ // map coordinates instead (square corners)
10815
+ const bigBox = b => Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) > BOX_ICON_MAX_PX;
10816
+ const iconBoxes = boxLayout.filter(b => !bigBox(b)), polygonBoxes = boxLayout.filter(bigBox);
10817
+ iconBoxes.forEach(b => {
10228
10818
  b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
10229
- isNaN(borderRadius) ? 0 : borderRadius * Math.min(unitPx, b.r * 2 / 30),
10230
- borderWidth * 0.2 * b.r * 2 / 30, boxLook);
10819
+ isNaN(borderRadius) ? 0 : borderRadius * Math.min(unitPx, b.w / 30),
10820
+ strokeOf(b), boxLook);
10231
10821
  });
10232
- layers.push(new IconLayer({
10822
+ if (polygonBoxes.length) {
10823
+ const fillRgb = hexOrNamedToRgb(boxLook.fill);
10824
+ const strokeRgb = boxLook.stroke ? hexOrNamedToRgb(boxLook.stroke) : null;
10825
+ layers.push(new GeoJsonLayer({
10826
+ id: `ix-box-polygons-${this.name}`,
10827
+ data: polygonBoxes.map(b => {
10828
+ const o = alignOf(b.d), c = b.d.geometry.coordinates, r = b.rect;
10829
+ const at = (x, y) => pixelOffsetLngLat(c, [x + o[0], y + o[1]], liveZoom);
10830
+ return { type: 'Feature', properties: { stroke: strokeOf(b) },
10831
+ geometry: { type: 'Polygon', coordinates: [[at(r[0], r[1]), at(r[2], r[1]), at(r[2], r[3]), at(r[0], r[3]), at(r[0], r[1])]] } };
10832
+ }),
10833
+ pickable: false,
10834
+ filled: boxLook.fillOpacity > 0,
10835
+ stroked: !!strokeRgb,
10836
+ getFillColor: [...fillRgb.slice(0, 3), Math.round(255 * boxLook.fillOpacity)],
10837
+ getLineColor: strokeRgb ? [...strokeRgb.slice(0, 3), Math.round(255 * boxLook.strokeOpacity)] : [0, 0, 0, 0],
10838
+ getLineWidth: f => f.properties.stroke,
10839
+ lineWidthUnits: 'pixels',
10840
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10841
+ }));
10842
+ }
10843
+ if (iconBoxes.length) layers.push(new IconLayer({
10233
10844
  id: `ix-box-${this.name}-g${this._iconGeneration}`,
10234
- data: boxLayout, pickable: false,
10845
+ data: iconBoxes, pickable: false,
10235
10846
  getPosition: b => b.d.geometry.coordinates,
10236
10847
  getIcon: b => b.icon,
10237
10848
  // the icon is drawn at 2× with a stroke padding around the box
@@ -10251,7 +10862,9 @@
10251
10862
  ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d))
10252
10863
  : this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
10253
10864
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
10254
- getColor: d => [255, 255, 255, this._iconAlpha(d)],
10865
+ // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
10866
+ // so a near-transparent theme doesn't show opaque 1-px-cell clusters
10867
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (d.properties.counts && this.style.fillopacity != null ? fillOpacity : 1))],
10255
10868
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
10256
10869
  sizeUnits: 'pixels',
10257
10870
  billboard: true,
@@ -10816,6 +11429,7 @@
10816
11429
  // - themes: getThemeObj (flat's theme object, with its data table and
10817
11430
  // the items on the map), getThemeDefinitionObj, getThemes,
10818
11431
  // changeThemeStyle / removeTheme / setBasemapOpacity (map name first),
11432
+ // setThemeTimeFrame (themeId, min, max),
10819
11433
  // refreshTheme, markThemeClass / unmarkThemeClass
10820
11434
  // - data: ixmaps.data (facets: getFacets, showFacets, …), the
10821
11435
  // embeddedSVG.window tables (setExternalData is with the data loading)
@@ -10859,22 +11473,73 @@
10859
11473
  try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onNewTheme:', e); }
10860
11474
  }
10861
11475
 
10862
- // flat's ixmaps.map() — the running map's handle (htmlgui.js 173ff,
10863
- // htmlgui_flat.js). Its methods act on the last built map; called while
10864
- // the map is still building, they run once it is ready.
11476
+ // flat's ixmaps.themeApi (htmlgui_flat.js 1112-1196): the theme handle a
11477
+ // page gets from map().theme(name) — changeStyle is flat's canonical
11478
+ // global changeThemeStyle(szThemeName, szStyle, szFlag) (htmlgui.js 2050)
11479
+ // in handle form; show / hide / toggle are setThemeVisible, remove the map
11480
+ // handle's. flat's own markClass delegates 3-arg — a stale call against
11481
+ // htmlgui.js's 2-arg markThemeClass (2196) — the canonical form is used
11482
+ // here. Theme ids are global (the registry), so the map binding only
11483
+ // matters for replace.
11484
+ function themeApi(szMap, szTheme) {
11485
+ this.szMap = szMap || null;
11486
+ this.szTheme = szTheme || null;
11487
+ }
11488
+ themeApi.prototype = {
11489
+ changeStyle(szStyle, szFlag) { compatChangeThemeStyle(this.szTheme, szStyle, szFlag); return this; },
11490
+ markClass(nClass) { markThemeClass(this.szTheme, nClass); return this; },
11491
+ unmarkClass(nClass) { unmarkThemeClass(this.szTheme, nClass); return this; },
11492
+ show() { mapHandle(this.szMap).setThemeVisible(this.szTheme, true); return this; },
11493
+ hide() { mapHandle(this.szMap).setThemeVisible(this.szTheme, false); return this; },
11494
+ toggle() {
11495
+ const rt = _globalThemeRegistry.get(this.szTheme);
11496
+ mapHandle(this.szMap).setThemeVisible(this.szTheme, rt ? !!rt._hidden : false);
11497
+ return this;
11498
+ },
11499
+ remove() { mapHandle(this.szMap).remove(this.szTheme); return this; },
11500
+ replace(theme, flag) { mapHandle(this.szMap).replaceTheme(this.szTheme, theme, flag); return this; }
11501
+ };
11502
+
11503
+ // flat's ixmaps.map() / ixmaps.api(szMap) — the running map's handle
11504
+ // (htmlgui.js 173ff, htmlgui_flat.js mapApi 1283). Its methods act on the
11505
+ // map named by szMap (its Map() options name) or, without a name, the
11506
+ // last built map; called while the map is still building, they run once
11507
+ // it is ready.
10865
11508
  const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
10866
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'attribution'];
10867
- const _mapHandle = {};
10868
- for (const m of MAP_HANDLE_METHODS) {
10869
- _mapHandle[m] = (...args) => {
10870
- if (_lastMapApi) { const r = _lastMapApi[m](...args); return r === _lastMapApi ? _mapHandle : r; }
10871
- _mapReady.then(api => api[m](...args));
10872
- return _mapHandle;
10873
- };
11509
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
11510
+ const _mapHandles = Object.create(null);
11511
+ function makeMapHandle(szMap) {
11512
+ const h = {};
11513
+ const api = () => (szMap && _mapApis[szMap]) || _lastMapApi;
11514
+ for (const m of MAP_HANDLE_METHODS) {
11515
+ h[m] = (...args) => {
11516
+ const a = api();
11517
+ if (a) { const r = a[m](...args); return r === a ? h : r; }
11518
+ _mapReady.then(ready => ready[m](...args));
11519
+ return h;
11520
+ };
11521
+ }
11522
+ h.getZoom = () => { const a = api(); return a ? a.getZoom() : undefined; };
11523
+ h.getThemeObj = id => getThemeObj(id);
11524
+ // flat's mapApi.theme(szTheme) (htmlgui_flat.js 1571): the theme handle
11525
+ h.theme = szTheme => new themeApi(szMap, szTheme);
11526
+ // flat's mapApi aliases (htmlgui_flat.js 1286+): show/hide a theme,
11527
+ // redrawTheme (refreshTheme here), project (loadProject), getThemes(),
11528
+ // setView (view, both flat's object form and (array, zoom))
11529
+ h.show = id => h.setThemeVisible(id, true);
11530
+ h.hide = id => h.setThemeVisible(id, false);
11531
+ h.redrawTheme = id => h.refreshTheme(id);
11532
+ h.project = (...a) => h.loadProject(...a);
11533
+ h.setView = (...a) => h.view(...a);
11534
+ h.getThemes = () => [...new Set(_globalThemeRegistry.values())].map(rt => getThemeObj(themeIdOf(rt))).filter(Boolean);
11535
+ return h;
11536
+ }
11537
+ const _mapHandle = makeMapHandle(null);
11538
+ function mapHandle(szMap) {
11539
+ if (!szMap) return _mapHandle;
11540
+ if (!_mapHandles[szMap]) _mapHandles[szMap] = makeMapHandle(szMap);
11541
+ return _mapHandles[szMap];
10874
11542
  }
10875
- _mapHandle.getZoom = () => (_lastMapApi ? _lastMapApi.getZoom() : undefined);
10876
- _mapHandle.getThemeObj = id => getThemeObj(id);
10877
- function mapHandle() { return _mapHandle; }
10878
11543
 
10879
11544
  // flat's ixmaps.formatValue(value, precision, flag) (ui/js/tools/format.js),
10880
11545
  // which user chart scripts call: thousands separated by "." (BLANK: a
@@ -11090,6 +11755,7 @@
11090
11755
  const style = Object.assign({}, rt.style);
11091
11756
  if (rt.binding.size && !style.sizefield) style.sizefield = rt.binding.size;
11092
11757
  if (rt.binding.title && !style.titlefield) style.titlefield = rt.binding.title;
11758
+ if (rt.binding.time && !style.timefield) style.timefield = rt.binding.time;
11093
11759
  style.type = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
11094
11760
  const data = Object.assign({}, rt._definition && rt._definition.data);
11095
11761
  if (!data.name) data.name = themeDataName(rt);
@@ -11448,10 +12114,44 @@
11448
12114
  return _lastMapApi.loadProject(src, flags);
11449
12115
  }
11450
12116
 
12117
+ // flat's global compat forms come in two arities: the canonical
12118
+ // theme-first signatures — changeThemeStyle(szThemeName, szStyle, szFlag)
12119
+ // (htmlgui.js 2050, with its duplicate-leading shift, 2052),
12120
+ // removeTheme(szThemeId) (1884), setBasemapOpacity(nOpacity, szMode)
12121
+ // (1409) — and the older map-first pages calling
12122
+ // (null|szMap, szId, ...) — PNRR passes an explicit null. Arity tells
12123
+ // them apart; theme ids are global (the registry), so the leading map
12124
+ // argument only matters for setBasemapOpacity, where a registered map
12125
+ // name targets that map and null or an unknown name the last built one.
12126
+ function themeStyleArgs(a) {
12127
+ if (a.length >= 4) a = a.slice(1);
12128
+ else if (a[0] === a[1]) a = a.slice(1);
12129
+ return a;
12130
+ }
12131
+ function compatChangeThemeStyle(...a) {
12132
+ const [szId, szStyle, szFlag] = themeStyleArgs(a);
12133
+ return _mapHandle.changeThemeStyle(szId, szStyle, szFlag);
12134
+ }
12135
+ function compatRemoveTheme(...a) {
12136
+ return _mapHandle.remove(a.length >= 2 ? a[1] : a[0]);
12137
+ }
12138
+ function compatSetBasemapOpacity(...a) {
12139
+ if (a.length >= 3) {
12140
+ const api = (a[0] && _mapApis[a[0]]) || _lastMapApi;
12141
+ if (api) api.setBasemapOpacity(a[1], a[2]);
12142
+ return;
12143
+ }
12144
+ return _mapHandle.setBasemapOpacity(a[0], a[1]);
12145
+ }
12146
+
11451
12147
  global.ixmaps = {
11452
- layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
12148
+ layer, Layer: layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
11453
12149
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
11454
12150
  markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
12151
+ // flat's ixmaps.__formatValue (ui/js/tools/legend.js 451-553) — pages call
12152
+ // it for statistics/legend values; legend's own variant differs from
12153
+ // format.js's only in the SPACE flag, which flatFormatValue covers too
12154
+ __formatValue: flatFormatValue,
11455
12155
  map: mapHandle, getZoom, getThemeDefinitionObj, embeddedSVG,
11456
12156
  // flat's theme list: the theme objects of every theme
11457
12157
  getThemes: () => [...new Set(_globalThemeRegistry.values())].map(rt => getThemeObj(themeIdOf(rt))).filter(Boolean),
@@ -11468,9 +12168,11 @@
11468
12168
  setAttribution: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
11469
12169
  htmlgui_setAttributionString: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
11470
12170
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
11471
- changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
11472
- removeTheme: (szMap, szId) => _mapHandle.remove(szId),
11473
- setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
12171
+ changeThemeStyle: compatChangeThemeStyle,
12172
+ removeTheme: compatRemoveTheme,
12173
+ // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
12174
+ setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
12175
+ setBasemapOpacity: compatSetBasemapOpacity,
11474
12176
  // this engine's version (flat's own ixmaps.version numbers flat)
11475
12177
  glVersion: IXMAPS_GL_VERSION,
11476
12178
  // flat always has this page hook (its layer legend tool defines it,
@@ -11494,8 +12196,8 @@
11494
12196
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
11495
12197
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
11496
12198
  global.__ixmapsGlInternals = {
11497
- IXMAPS_GL_VERSION, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
11498
- normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
12199
+ IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, multiQuadOffsets, pixelOffsetLngLat,
12200
+ normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
11499
12201
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
11500
12202
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
11501
12203
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
@@ -11504,7 +12206,7 @@
11504
12206
  flatToMapLibreZoom, mapLibreToFlatZoom, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
11505
12207
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
11506
12208
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
11507
- flatFormatValue,
12209
+ flatFormatValue, themeStyleArgs,
11508
12210
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
11509
12211
  };
11510
12212
  global.__setFilter = __setFilter;