ixmaps-gl 0.2.11 → 0.2.14

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 +3 -3
  2. package/ixmaps-gl.js +701 -134
  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.14';
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;
@@ -2125,7 +2125,7 @@
2125
2125
  // edit it by hand; `npm run unit` fails when it drifts.
2126
2126
  // ---------------------------------------------------------------
2127
2127
  // <grammar:binding-aliases>
2128
- // generated from ixmaps-grammar 0.1.25 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2128
+ // generated from ixmaps-grammar 0.1.27 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2129
2129
  const FLAT_BINDING_ALIASES = {
2130
2130
  "aggregation": "style.aggregationfield",
2131
2131
  "aggregationfield": "style.aggregationfield",
@@ -2187,7 +2187,7 @@
2187
2187
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2188
2188
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2189
2189
  // <grammar:meta-keys>
2190
- // generated from ixmaps-grammar 0.1.25 — 5 meta keys
2190
+ // generated from ixmaps-grammar 0.1.27 — 5 meta keys
2191
2191
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2192
2192
  // </grammar:meta-keys>
2193
2193
 
@@ -2206,6 +2206,7 @@
2206
2206
  'style.alphafield': ['binding', 'alpha'],
2207
2207
  'style.alphafield100': ['binding', 'alpha100'],
2208
2208
  'style.valuefield': ['style', 'valuefield'],
2209
+ 'style.timefield': ['binding', 'time'], // setThemeTimeFrame, see LayerRuntime.setTimeFrame
2209
2210
  'style.titlefield': ['binding', 'title'], // {{theme.item.title}}, see _buildTooltipContext
2210
2211
  // the lookup key's normalization (flatLookupKey): flat's newTheme moves
2211
2212
  // binding.tonumber / digits / … into these style keys (htmlgui.js 1712)
@@ -2371,7 +2372,8 @@
2371
2372
  const STYLE_NUMBER_KEYS = ['linewidth', 'fillopacity', 'scale', 'classes', 'valuedecimals', 'normalsizevalue',
2372
2373
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
2373
2374
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
2374
- 'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety'];
2375
+ 'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
2376
+ 'borderradius', 'maxcharts'];
2375
2377
  function toNumberIfNumeric(v) {
2376
2378
  if (typeof v !== 'string' || !v.trim()) return v;
2377
2379
  const n = Number(v);
@@ -2635,6 +2637,69 @@
2635
2637
  return { dark: false, bg: 'rgba(255,255,255,0.9)' };
2636
2638
  }
2637
2639
 
2640
+ // ---------------------------------------------------------------
2641
+ // Effective basemap darkness — what the map canvas actually shows,
2642
+ // not what its name suggests. flatLegendLook's name-plus-opacity
2643
+ // heuristic assumes the faded (basemapopacity <= 0.5) basemap blends
2644
+ // against a WHITE page, so a dark basemap faded to half goes light;
2645
+ // on a page with a dark background it reads dark all the same, and
2646
+ // tooltips/legends styled light stick out (the exact bug fixed here).
2647
+ // Rule: an explicit color (legendBackground, a color mapType) stays
2648
+ // authoritative as before; with a real basemap style, a DARK style
2649
+ // background means dark — except when it is faded to
2650
+ // basemapopacity <= 0.5, where the mix is mostly the page background
2651
+ // behind the semi-transparent canvas and THAT decides (dark page ->
2652
+ // still dark; white page -> flat's washed-out light look). A light
2653
+ // style background reads light however it is faded (flat's own rule).
2654
+ // Falls back to the name heuristic while no style exists.
2655
+ // ---------------------------------------------------------------
2656
+ function styleBackgroundColorOf(map) {
2657
+ try {
2658
+ const style = map && map.getStyle && map.getStyle();
2659
+ if (!style || !style.layers) return null;
2660
+ for (const layer of style.layers) {
2661
+ if (layer.type !== 'background') continue;
2662
+ const paint = layer.paint || {};
2663
+ const color = paint['background-color'] != null ? paint['background-color'] : (paint.background != null ? paint.background : null);
2664
+ // the blank background substituted for a color mapType IS the page's
2665
+ // requested background and never faded (see applyBasemapOpacity)
2666
+ return { color: color, faded: layer.id !== BLANK_BACKGROUND_LAYER_ID };
2667
+ }
2668
+ } catch (e) { /* no style (yet) */ }
2669
+ return null;
2670
+ }
2671
+
2672
+ function pageBackgroundColorOf(el) {
2673
+ let node = el;
2674
+ while (node && node.nodeType === 1 && node !== document.documentElement) {
2675
+ const m = String(getComputedStyle(node).backgroundColor)
2676
+ .match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)/i);
2677
+ if (m && (m[4] === undefined || +m[4] > 0)) return [+m[1], +m[2], +m[3]];
2678
+ node = node.parentElement;
2679
+ }
2680
+ return [255, 255, 255]; // the page default
2681
+ }
2682
+
2683
+ function effectiveMapDark(map, el, basemapOpacity, mapType, legendBackground) {
2684
+ const id = legendBackground || mapType;
2685
+ if (flatIsCssColorValue(id)) return flatIsDarkColor(id);
2686
+ const base = styleBackgroundColorOf(map);
2687
+ const rgb = base && base.color != null ? parseCssColor(String(base.color)) : null;
2688
+ if (!rgb) {
2689
+ // no style (yet): the name heuristic, exactly as before
2690
+ return flatLegendLook(mapType, basemapOpacity, legendBackground).dark;
2691
+ }
2692
+ const o = isNaN(basemapOpacity) ? 1 : Math.max(0, Math.min(1, basemapOpacity));
2693
+ if (flatIsDarkColor('rgb(' + rgb.map(Math.round).join(',') + ')')) {
2694
+ // a dark basemap faded to half or less shows mostly the page background
2695
+ // behind the canvas - so at that point the PAGE decides (dark page ->
2696
+ // still dark; white page -> the washed-out flat look stays light)
2697
+ return (base.faded && o <= 0.5) ? flatIsDarkColor('rgb(' + pageBackgroundColorOf(el).map(Math.round).join(',') + ')') : true;
2698
+ }
2699
+ // a light basemap reads light however it is faded - flat's own rule
2700
+ return false;
2701
+ }
2702
+
2638
2703
  function buildBlankBackgroundStyle(color) {
2639
2704
  return {
2640
2705
  version: 8,
@@ -2677,6 +2742,40 @@
2677
2742
  }
2678
2743
 
2679
2744
  // ---------------------------------------------------------------
2745
+ // Embedded YouTube players in tooltips (data fields carrying <iframe
2746
+ // src=".../embed/ID">) are replaced by a thumbnail with a play button; the
2747
+ // real iframe is created on click. Keeps tooltips light, and a removed
2748
+ // video or a refused embed (e.g. error 153 on file:// pages) degrades to a
2749
+ // thumbnail that opens YouTube in a new tab instead of a broken player.
2750
+ const YT_IFRAME_RE = /<iframe\b[^>]*?\bsrc\s*=\s*["']?(?:https?:)?\/\/(?:www\.)?youtube(?:-nocookie)?\.com\/embed\/([\w-]{6,})[^"'\s>]*["']?[^>]*>\s*<\/iframe>/gi;
2751
+ function youtubeClickToPlay(html) {
2752
+ if (!html || html.indexOf('youtube') < 0) return html;
2753
+ return html.replace(YT_IFRAME_RE, (m, id) => {
2754
+ const w = (/\bwidth\s*=\s*["']?(\d+)/i.exec(m) || [])[1] || 240;
2755
+ const h = (/\bheight\s*=\s*["']?(\d+)/i.exec(m) || [])[1] || 180;
2756
+ return '<div class="ixgl-yt" data-yt="' + id + '" data-w="' + w + '" data-h="' + h + '" ' +
2757
+ 'style="position:relative;width:' + w + 'px;height:' + h + 'px;cursor:pointer;background:#000 url(https://i.ytimg.com/vi/' + id + '/hqdefault.jpg) center/cover" ' +
2758
+ 'title="play"><span style="position:absolute;left:50%;top:50%;width:48px;height:34px;margin:-17px 0 0 -24px;' +
2759
+ 'border-radius:8px;background:rgba(220,0,0,0.9)"></span><span style="position:absolute;left:50%;top:50%;margin:-9px 0 0 -6px;' +
2760
+ 'border-style:solid;border-width:9px 0 9px 15px;border-color:transparent transparent transparent #fff"></span></div>';
2761
+ });
2762
+ }
2763
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function' && !global.__ixglYtClick) {
2764
+ global.__ixglYtClick = true;
2765
+ document.addEventListener('click', e => {
2766
+ const el = e.target.closest && e.target.closest('.ixgl-yt');
2767
+ if (!el) return;
2768
+ e.stopPropagation();
2769
+ const f = document.createElement('iframe');
2770
+ f.width = el.dataset.w; f.height = el.dataset.h; f.frameBorder = '0';
2771
+ f.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
2772
+ f.allowFullscreen = true;
2773
+ f.referrerPolicy = 'strict-origin-when-cross-origin';
2774
+ f.src = 'https://www.youtube.com/embed/' + el.dataset.yt + '?autoplay=1&rel=0';
2775
+ el.replaceWith(f);
2776
+ }, true);
2777
+ }
2778
+
2680
2779
  // Hover tooltips and the click-to-pin tooltip of a map, built once per map
2681
2780
  // by MapBuilder.build(). ctx: the builder (its map options, for the
2682
2781
  // tooltip look), the MapLibre map, its container element and
@@ -2686,6 +2785,12 @@
2686
2785
  // ---------------------------------------------------------------
2687
2786
  function createTooltips(ctx) {
2688
2787
  const { builder, map, el, findRuntimeForLayerId } = ctx;
2788
+ // the effective (rendered) dark/light probe for tooltip and legend —
2789
+ // the builder carries it so the legend look (built later, without the
2790
+ // map element at hand) can ask the same question
2791
+ builder.__effectiveDark = function (basemapOpacity, mapType, legendBackground) {
2792
+ return effectiveMapDark(map, el, basemapOpacity, mapType, legendBackground);
2793
+ };
2689
2794
  // Click-to-pin tooltip: matches the real ixmaps engine's own
2690
2795
  // click-pins-the-tooltip convention in pan mode (a hover tooltip is
2691
2796
  // transient; a click keeps one visible until dismissed). This is a
@@ -2695,28 +2800,101 @@
2695
2800
  // at click time (not live-updating), but its screen POSITION is kept
2696
2801
  // in sync with the map on every pan/zoom via map.project().
2697
2802
  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
2803
+ // Tooltip look, like the legend's: flat's light tooltip
2804
+ // (tooltip_mustache.js: white 0.95, #444 text, thin black border, 5px
2805
+ // radius) on light basemaps, this engine's dark tooltip on dark ones —
2806
+ // decided by the EFFECTIVE map darkness (style background vs page
2807
+ // background, see effectiveMapDark), not the basemap name alone. Read
2808
+ // when shown, so options set later (a
2702
2809
  // myMap.then(...).options({basemapopacity})) count.
2703
- const tooltipLook = () => {
2810
+ const tooltipIsDark = () => {
2704
2811
  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' };
2812
+ const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
2813
+ return typeof builder.__effectiveDark === 'function'
2814
+ ? builder.__effectiveDark(isNaN(o) ? 1 : o, builder.mapOptions.mapType, legendBackground)
2815
+ : flatLegendLook(builder.mapOptions.mapType, isNaN(o) ? 1 : o, legendBackground).dark;
2710
2816
  };
2817
+ // flat's tooltip look (tooltip_mustache.js 118/151-154): arial narrow,
2818
+ // the font size clamped to 11..14px; light: white 0.95, #444 text, thin
2819
+ // black border; dark: rgba(50,50,50,0.95), #555 border (text light for
2820
+ // readability — flat leaves its #444 on dark and only adapts the panel).
2821
+ // line-height is declared so hover (deck.gl's .deck-tooltip stylesheet
2822
+ // gives it 20px) and the pinned tooltip (a bare div that would inherit
2823
+ // the page's 'normal') render the same content identically
2824
+ const tooltipLook = () => {
2825
+ const fontsize = Math.min(14, Math.max(11, (22 / 1200 * (global.innerWidth || 1200))));
2826
+ const fonts = { fontFamily: 'arial narrow, system', fontSize: fontsize + 'px', lineHeight: '20px' };
2827
+ return tooltipIsDark()
2828
+ ? Object.assign(fonts, { background: 'rgba(50,50,50,0.95)', color: 'rgb(200,205,214)', border: '0.5px solid #555', borderRadius: '5px',
2829
+ boxShadow: '0 2px 8px rgba(0,0,0,0.3)' })
2830
+ : Object.assign(fonts, { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
2831
+ boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' });
2832
+ };
2833
+ // flat's content pane (tooltip_mustache.js 124): the whole tooltip
2834
+ // content scrolls inside its own padded pane — a close button outside
2835
+ // the pane stays visible while it scrolls, and the scrollbar clears
2836
+ // the content by a small right margin
2837
+ const TOOLTIP_PANE_STYLE = 'margin:0.5em;max-height:360px;overflow:auto';
2838
+ const tooltipPaneHtml = (html) => `<div style="${TOOLTIP_PANE_STYLE}">${html}</div>`;
2711
2839
  const pinnedTooltipEl = document.createElement('div');
2712
2840
  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;';
2841
+ 'pointer-events:auto;max-width:440px;padding:0.5em 0.7em 0.5em 0.5em;';
2842
+
2843
+ // flat's own tooltip placement (tooltip_mustache.js 145-148): flip it
2844
+ // to the left/above the anchor when there is no room right/below, then
2845
+ // clamp it into the window — a tooltip must always be completely
2846
+ // visible (an element larger than the viewport ends up at its edge)
2847
+ function keepTooltipVisible(tipEl, x, y) {
2848
+ const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
2849
+ const vw = global.innerWidth || 1200, vh = global.innerHeight || 800;
2850
+ let tx = x > vw / 2 ? x - w - 30 : x + 30;
2851
+ let ty = y > vh / 2 ? y - h - 20 : y + 20;
2852
+ tx = Math.min(Math.max(8, tx), Math.max(8, vw - w - 8));
2853
+ ty = Math.min(Math.max(8, ty), Math.max(8, vh - h - 8));
2854
+ const transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
2855
+ tipEl.style.transform = transform;
2856
+ return transform;
2857
+ }
2715
2858
 
2716
2859
  function updatePinnedTooltipPosition() {
2717
2860
  if (!pinned) return;
2718
2861
  const pt = map.project(pinned.lngLat);
2719
- pinnedTooltipEl.style.transform = `translate(${pt.x + 10}px, ${pt.y - 10}px)`;
2862
+ keepTooltipVisible(pinnedTooltipEl, pt.x, pt.y);
2863
+ }
2864
+
2865
+ // hover tooltip: deck.gl pins it at the pointer without any viewport
2866
+ // logic (it overflows the right/bottom edge there) — watch the element's
2867
+ // style mutations and re-place it inside the window every time deck
2868
+ // moves it. Our own write is recognized and skipped (lastSetTransform).
2869
+ let __hoverTooltipWatch = null;
2870
+ function watchHoverTooltip() {
2871
+ if (__hoverTooltipWatch || !el.parentElement) return;
2872
+ const host = el.parentElement;
2873
+ const attach = (tipEl) => {
2874
+ let lastSetTransform = null;
2875
+ const mo = new MutationObserver(() => {
2876
+ if (tipEl.style.display === 'none' || !tipEl.textContent.trim()) return;
2877
+ const t = tipEl.style.transform;
2878
+ if (!t || t === lastSetTransform) return;
2879
+ const m = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(t);
2880
+ if (!m) return;
2881
+ lastSetTransform = keepTooltipVisible(tipEl, +m[1], +m[2]);
2882
+ });
2883
+ mo.observe(tipEl, { attributes: true, attributeFilter: ['style'] });
2884
+ __hoverTooltipWatch = mo;
2885
+ };
2886
+ const existing = host.querySelector('.deck-tooltip');
2887
+ if (existing) { attach(existing); return; }
2888
+ const creation = new MutationObserver((muts, obs) => {
2889
+ for (const mu of muts) for (const n of mu.addedNodes) {
2890
+ if (n.nodeType === 1 && n.classList && n.classList.contains('deck-tooltip')) {
2891
+ attach(n);
2892
+ obs.disconnect();
2893
+ return;
2894
+ }
2895
+ }
2896
+ });
2897
+ creation.observe(host, { childList: true, subtree: true });
2720
2898
  }
2721
2899
 
2722
2900
  // A stable per-geometry anchor coordinate for the identity check
@@ -2750,13 +2928,19 @@
2750
2928
  }
2751
2929
 
2752
2930
  // 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)
2931
+ // content lives in its own child so re-rendering (innerHTML) never
2932
+ // wipes out the unpin button below (a sibling, not a descendant of it).
2933
+ // The content child sits inside a scroll PANE (flat's own tooltip
2934
+ // structure): the pane scrolls, the close button outside it does not
2935
+ const pinnedPaneEl = document.createElement('div');
2936
+ pinnedPaneEl.style.cssText = TOOLTIP_PANE_STYLE;
2754
2937
  const pinnedContentEl = document.createElement('div');
2755
- pinnedTooltipEl.appendChild(pinnedContentEl);
2938
+ pinnedPaneEl.appendChild(pinnedContentEl);
2939
+ pinnedTooltipEl.appendChild(pinnedPaneEl);
2756
2940
 
2757
2941
  function renderPinnedTooltip() {
2758
2942
  if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
2759
- const html = pinned.runtime.buildTooltipHtml(pinned.object);
2943
+ const html = pinned.runtime.buildTooltipHtml(pinned.object, tooltipIsDark());
2760
2944
  if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
2761
2945
  pinnedContentEl.innerHTML = html;
2762
2946
  Object.assign(pinnedTooltipEl.style, tooltipLook());
@@ -2797,7 +2981,15 @@
2797
2981
  // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
2798
2982
  // the feature is its d
2799
2983
  function pickedFeature(o) { return o && o.icon && o.d ? o.d : o; }
2984
+ // flat's map tool 'pan' (Map option mode:"pan", Api.setMapTool): pure
2985
+ // navigation — no hover tooltip there (flat's own hover-tooltip paths
2986
+ // run only for the ""/info/clickinfo tool types, mapscript2.js 5232).
2987
+ // The click-to-pin tooltip stays: it is this engine's equivalent of
2988
+ // flat's info popup, so a click still shows the permanent tooltip.
2989
+ const isPanMode = () => /^\s*pan\s*$/i.test(String(builder.mapOptions.mode || ''));
2990
+
2800
2991
  function tooltipFor({ object: picked, layer }) {
2992
+ if (isPanMode()) return null;
2801
2993
  const object = pickedFeature(picked);
2802
2994
  if (!object || !layer) return null;
2803
2995
  // suppress the hover tooltip ONLY for the specific item that's
@@ -2807,8 +2999,10 @@
2807
2999
  if (pinned && isSameAsPinned(layer.id, object)) return null;
2808
3000
  const rt = findRuntimeForLayerId(layer.id);
2809
3001
  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;
3002
+ const html = rt.buildTooltipHtml(object, tooltipIsDark());
3003
+ // same layout as the pinned tooltip: the content in flat's scroll
3004
+ // pane, the shared look (fonts included) on the container
3005
+ return html ? { html: tooltipPaneHtml(html), style: Object.assign({ padding: '0.5em 0.7em', maxWidth: '440px' }, tooltipLook()) } : null;
2812
3006
  }
2813
3007
  function clickFor(picked) {
2814
3008
  const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
@@ -2868,6 +3062,7 @@
2868
3062
  }
2869
3063
  function mount() {
2870
3064
  map.on('move', updatePinnedTooltipPosition);
3065
+ watchHoverTooltip();
2871
3066
  if (el.parentElement) {
2872
3067
  el.parentElement.style.position = el.parentElement.style.position || 'relative';
2873
3068
  el.parentElement.appendChild(pinnedTooltipEl);
@@ -3073,7 +3268,10 @@
3073
3268
  });
3074
3269
  legendStack.style.display = shown ? 'flex' : 'none';
3075
3270
  };
3076
- const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length && !rt.flags.has('FEATURE') && !rt.flags.has('FEATURES') && !rt.flags.has('NOLEGEND');
3271
+ // a plain FEATURE(S) base layer has one flat fill (no classes); FEATURE|CATEGORICAL
3272
+ // is coloured per category (see _buildFeaturesLayers), so it gets a legend
3273
+ const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length
3274
+ && (!(rt.flags.has('FEATURE') || rt.flags.has('FEATURES')) || rt.flags.has('CATEGORICAL')) && !rt.flags.has('NOLEGEND');
3077
3275
  // One theme's panel — at build time for every theme, and again for a
3078
3276
  // theme defined later (map.layer(...) in a myMap.then(...) chain,
3079
3277
  // defineLayer, loadProject); removeTheme removes it with the theme.
@@ -3109,8 +3307,16 @@
3109
3307
  // flat's look (flatLegendLook) unless the page picks one with
3110
3308
  // this engine's own legendtheme
3111
3309
  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);
3310
+ const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
3311
+ const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, legendBackground);
3312
+ // effective darkness (style background vs page background, see
3313
+ // effectiveMapDark): a dark basemap faded to <= 0.5 over a DARK
3314
+ // page still reads dark — flatLegendLook's name heuristic would
3315
+ // wrongly switch the panel (and the tooltip) to light there
3316
+ const looksDark = typeof builder.__effectiveDark === 'function'
3317
+ ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3318
+ : flatLook.dark;
3319
+ const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3114
3320
  const legendColors = isLightLegend
3115
3321
  ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3116
3322
  rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
@@ -3118,7 +3324,10 @@
3118
3324
  : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3119
3325
  rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3120
3326
  selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3121
- if (!rt.style.legendtheme) legendColors.bg = flatLook.bg;
3327
+ // the panel bg follows the same effective decision (flatLegendLook
3328
+ // would hand the dark look a light bg here: its dark branch never
3329
+ // ran for this map, its light bg would clash with dark rows/text)
3330
+ if (!rt.style.legendtheme) legendColors.bg = looksDark ? '#111' : flatLook.bg;
3122
3331
  // max-height:66% resolves against el.parentElement's own
3123
3332
  // height (the map container, which always has a definite
3124
3333
  // height for the map itself to render into) since this panel
@@ -3208,7 +3417,11 @@
3208
3417
  (rt._activeFeatures || rt.features).forEach(f => {
3209
3418
  const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
3210
3419
  if (idx == null) return;
3211
- const [lng, lat] = f.geometry.coordinates;
3420
+ // a polygon (FEATURE|CATEGORICAL) is counted at its first vertex
3421
+ let pt = f.geometry && f.geometry.coordinates;
3422
+ while (Array.isArray(pt) && Array.isArray(pt[0])) pt = pt[0];
3423
+ if (!Array.isArray(pt)) return;
3424
+ const [lng, lat] = pt;
3212
3425
  if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3213
3426
  if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3214
3427
  totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
@@ -3645,6 +3858,8 @@
3645
3858
  this._layerBuilders.push(nameOrBuilder);
3646
3859
  return this;
3647
3860
  }
3861
+ // .Layer() — alias of .layer()
3862
+ Layer(nameOrBuilder) { return this.layer(nameOrBuilder); }
3648
3863
  // GL-PORT COMPAT: real ixmaps-flat's map.on("layerdraw", cb) — only
3649
3864
  // "layerdraw" is actually wired up (see build()'s own note on how
3650
3865
  // it's translated into onRedraw + a synthetic per-runtime event); any
@@ -3843,6 +4058,16 @@
3843
4058
  // world once, at the cost of that zoom limit.
3844
4059
  renderWorldCopies: this._engineOptions.worldcopies !== false
3845
4060
  });
4061
+ // ixmaps.Map(id, {mapProjection}): flat's orthographic map is MapLibre's
4062
+ // native globe here (same mapping as loadProject, applyProjectMap);
4063
+ // "ortographic" is the spelling flat pages commonly carry. Any other
4064
+ // value stays mercator.
4065
+ if (/^(orth?ographic|globe)$/i.test(String(this.mapOptions.mapProjection || '').trim())) {
4066
+ map.once('style.load', () => {
4067
+ try { map.setProjection({ type: 'globe' }); }
4068
+ catch (e) { console.warn('[ixmaps-gl] mapProjection "globe": map.setProjection failed (needs maplibre-gl >=5.0.1)', e); }
4069
+ });
4070
+ }
3846
4071
  _boundsSource = () => {
3847
4072
  const b = map.getBounds();
3848
4073
  return [{ lat: b.getSouth(), lng: b.getWest() }, { lat: b.getNorth(), lng: b.getEast() }];
@@ -3937,9 +4162,11 @@
3937
4162
  const overlay = new MapLibreOverlay({
3938
4163
  interleaved: true,
3939
4164
  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')),
4165
+ // pointer becomes a hand over anything pickable (bubbles/points), so
4166
+ // hovering something clickable actually looks clickable — except in
4167
+ // the 'pan' input mode, where nothing responds to hovering
4168
+ getCursor: ({ isDragging, isHovering }) =>
4169
+ (isDragging ? 'grabbing' : ((isHovering && !/^\s*pan\s*$/i.test(String(this.mapOptions.mode || ''))) ? 'pointer' : 'grab')),
3943
4170
  // (guarded: see createTooltips)
3944
4171
  getTooltip: tooltips.getTooltip,
3945
4172
  onClick: tooltips.onClick
@@ -4216,6 +4443,18 @@
4216
4443
  refresh();
4217
4444
  }
4218
4445
  },
4446
+ // flat's ixmaps.setThemeTimeFrame(szId, min, max): show only the
4447
+ // records whose timefield lies in [min, max) (ms or Date-
4448
+ // parsable). null min+max clears. szId null/undefined = every theme
4449
+ // that has a timefield (flat: one call updates all themes at once).
4450
+ setThemeTimeFrame: (themeId, min, max) => {
4451
+ const targets = themeId == null ? runtimes.filter(r => r.binding && r.binding.time) : (() => {
4452
+ const rt = findRuntime(themeId);
4453
+ return rt ? siblingRuntimes(rt).filter(r => r.binding && r.binding.time) : [];
4454
+ })();
4455
+ targets.forEach(rt => rt.setTimeFrame(min, max));
4456
+ if (targets.length) refresh();
4457
+ },
4219
4458
  // shows/hides a whole theme (all the deck.gl layers its
4220
4459
  // buildDeckLayers would otherwise produce) without touching its
4221
4460
  // data or style — for toggle controls like the real page's
@@ -4301,6 +4540,8 @@
4301
4540
  };
4302
4541
  return lb;
4303
4542
  },
4543
+ // .Layer() — alias of .layer()
4544
+ Layer: (nameOrBuilder) => engineApi.layer(nameOrBuilder),
4304
4545
  // Real ixmaps-flat's loadProject (htmlgui.js loadProject →
4305
4546
  // setProjectJSON → continueSetProjectJSON): src is a project object,
4306
4547
  // a JSON string, or a URL (fetched as DATA — nothing a project names
@@ -4633,7 +4874,15 @@
4633
4874
  const svg = String(m.map || '');
4634
4875
  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
4876
  if (typeof map.setProjection === 'function') {
4636
- try { map.setProjection({ type: /orthographic\.svg$/i.test(svg) ? 'globe' : 'mercator' }); }
4877
+ try {
4878
+ map.setProjection({ type: /orthographic\.svg$/i.test(svg) ? 'globe' : 'mercator' });
4879
+ // the layers are projection-dependent (globe: far-hemisphere cull,
4880
+ // chart placement) and are otherwise rebuilt only by a pan: rebuild
4881
+ // now, and again once the new projection has rendered
4882
+ refreshLayers();
4883
+ map.triggerRepaint();
4884
+ map.once('idle', refreshLayers);
4885
+ }
4637
4886
  catch (e) { console.warn('[ixmaps-gl] loadProject: map.setProjection failed (needs maplibre-gl >=5.0.1)', e); }
4638
4887
  } else {
4639
4888
  console.warn('[ixmaps-gl] loadProject: map.setProjection not available on the loaded maplibre-gl build (needs >=5.0.1) — projection unchanged');
@@ -5179,6 +5428,8 @@
5179
5428
  // earlier hand-tuned 12.5 drew every symbol 1.2× (with the reference
5180
5429
  // below, 1.3×) smaller than flat.
5181
5430
  const NORMAL_RADIUS_PX = 15;
5431
+ // largest BOX side (px) still drawn as a canvas icon (at 2×), see _buildBoxIcon
5432
+ const BOX_ICON_MAX_PX = 2048;
5182
5433
  // Flat's own map scale, which dynamic object scaling compares with
5183
5434
  // normalSizeScale (mapscript2.js 2849: dx = nTrueMapScale · nZoomScale /
5184
5435
  // nNormalSizeScale), read from a real flat page: the generic Mercator
@@ -5425,6 +5676,16 @@
5425
5676
 
5426
5677
  function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue) {
5427
5678
  const zoomFactor = objectZoomFactor(zoom, mapOptions);
5679
+ // FIXSIZE (a symbol chart; PLOT / GRIDSIZE size their markers
5680
+ // themselves): one radius for every item, whatever its value — the
5681
+ // normal radius ÷ normalsizevalue, half of it for BUBBLE (maptheme.js
5682
+ // 20711, 21847; measured on flat: PNRR "details", r = 1.5 units at
5683
+ // scale 0.1)
5684
+ if (flags && flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')) {
5685
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1) / (styleNum(style.normalsizevalue) || 1)
5686
+ * (flatChartBranch(flags) === 'bubble' ? 0.5 : 1);
5687
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5688
+ }
5428
5689
  // Real default (maptheme.js ~line 5059) when the theme sets no
5429
5690
  // normalsizevalue: the dataset's own max value on the bound size
5430
5691
  // field (maxSizeValue, from LayerRuntime._prepare), not a fixed
@@ -5629,6 +5890,8 @@
5629
5890
  const field = binding.size ? binding.size
5630
5891
  : (symbols && !flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && binding.value && !String(binding.value).includes('|') ? binding.value : null);
5631
5892
  if (!field) return undefined;
5893
+ // "$item$": every record counts 1
5894
+ if (field === '$item$') return features.length ? 1 : 0;
5632
5895
  return features.reduce((max, f) => {
5633
5896
  const v = parseFloat(f.properties[field]);
5634
5897
  return isNaN(v) ? max : Math.max(max, v);
@@ -6228,6 +6491,13 @@
6228
6491
  // researched safe range (~1480-2980 distinct 48px icons before a real
6229
6492
  // device's WebGL MAX_TEXTURE_SIZE is at risk).
6230
6493
  const ICON_ATLAS_RESET_AFTER = 3000;
6494
+ // The same reset by icon AREA: a few large icons (BOX rectangles around
6495
+ // MULTIQUAD grids, hundreds of px each, new sizes at every zoom) fill
6496
+ // deck.gl's 1024 px wide atlas long before 3000 icons — on the PNRR
6497
+ // page a dozen boxes doubled it per zoom step, until it passed WebGL's
6498
+ // MAX_TEXTURE_SIZE (16384) and the boxes drew black. 2 Mpx leaves room
6499
+ // for the atlas's row-packing waste (measured ~2×) below that limit.
6500
+ const ICON_ATLAS_AREA_RESET_AFTER = 2 * 1024 * 1024;
6231
6501
 
6232
6502
  // Shared packing layout for a multi-category group's sub-bubbles — used
6233
6503
  // both to draw the packed icon raster (_buildBubbleIcon) and to position
@@ -6272,6 +6542,56 @@
6272
6542
  };
6273
6543
  }
6274
6544
 
6545
+ // MULTIQUAD / MULTISQUARE (flat maptheme.js 18648-18690): the items at
6546
+ // one position, in drawing order, fill quads around it — the k-th at
6547
+ // square = ⌊√k⌋, (k − square², square) while that is within the square,
6548
+ // else (square, square − (k − square² − square)); once square reaches
6549
+ // gridx, rows of gridx go on upward (k % gridx, ⌊k / gridx⌋). Steps of
6550
+ // 2 · normal radius · rangescale (flat's chart size, measured on the PNRR
6551
+ // page); y up. Returns per item its pixel offset and index, and per
6552
+ // position's first item the extent [minX, minY, maxX, maxY] of the
6553
+ // offsets there (flat sizes the BOX around all of them).
6554
+ function multiQuadOffsets(items, normalRadiusPx, rangeScale, gridX) {
6555
+ const step = 2 * normalRadiusPx * (rangeScale || 1);
6556
+ const count = new Map(), offset = new Map(), index = new Map(), first = new Map(), extent = new Map();
6557
+ for (const d of items) {
6558
+ const c = d.geometry && d.geometry.coordinates;
6559
+ const key = c ? c[0] + ',' + c[1] : '';
6560
+ const k = count.get(key) || 0;
6561
+ count.set(key, k + 1);
6562
+ index.set(d, k);
6563
+ if (k === 0) { first.set(key, d); extent.set(d, [0, 0, 0, 0]); }
6564
+ let x = 0, y = 0;
6565
+ if (k > 0) {
6566
+ const square = Math.floor(Math.sqrt(k));
6567
+ if (square < (gridX || Infinity)) {
6568
+ const line = k - square * square;
6569
+ x = line <= square ? line : square;
6570
+ y = line <= square ? square : square - (line - square);
6571
+ } else {
6572
+ x = k % gridX;
6573
+ y = Math.floor(k / gridX);
6574
+ }
6575
+ }
6576
+ offset.set(d, [x * step, -y * step]);
6577
+ const e = extent.get(first.get(key));
6578
+ e[0] = Math.min(e[0], x * step); e[1] = Math.min(e[1], -y * step);
6579
+ e[2] = Math.max(e[2], x * step); e[3] = Math.max(e[3], -y * step);
6580
+ }
6581
+ return { offset, index, extent };
6582
+ }
6583
+
6584
+ // A pixel offset [dx, dy] (y down) from lngLat at MapLibre zoom `zoom`,
6585
+ // as lng/lat (Web Mercator, 512 px world tiles) — for shapes too large
6586
+ // for an icon, drawn in map coordinates (rebuilt with every refresh).
6587
+ function pixelOffsetLngLat(lngLat, offset, zoom) {
6588
+ const world = 512 * Math.pow(2, zoom);
6589
+ const lat0 = lngLat[1] * Math.PI / 180;
6590
+ const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + lat0 / 2)) / (2 * Math.PI)) * world + offset[1];
6591
+ const lat = (2 * Math.atan(Math.exp((0.5 - y / world) * 2 * Math.PI)) - Math.PI / 2) * 180 / Math.PI;
6592
+ return [lngLat[0] + offset[0] * 360 / world, lat];
6593
+ }
6594
+
6275
6595
  // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
6276
6596
  // 22296-22395): one symbol per category part of a chart, in category
6277
6597
  // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
@@ -7194,9 +7514,13 @@
7194
7514
  // set (e.g. roma: summed Pericolosita, normalized to 0..1).
7195
7515
  // (flat's per-item nSize holds a record count in that last case, but it
7196
7516
  // is not what draws a value-based symbol.)
7517
+ // "$item$" (as size or value) is flat's record count: 1 per record — it
7518
+ // is no column (read as one, every record was 0 and a LABEL|TEXTONLY
7519
+ // theme's text 0.08 px high).
7197
7520
  function resolveAggregateValue(binding, flags, props) {
7521
+ if (binding.size === '$item$') return 1;
7198
7522
  if (binding.size) return parseFloat(props[binding.size]) || 0;
7199
- if (flags.has('CATEGORICAL') || binding.value == null) return 1;
7523
+ if (flags.has('CATEGORICAL') || binding.value == null || binding.value === '$item$') return 1;
7200
7524
  return parseFloat(props[binding.value]) || 0;
7201
7525
  }
7202
7526
 
@@ -7294,7 +7618,12 @@
7294
7618
  key = `${snapped.x}:${snapped.y}`;
7295
7619
  }
7296
7620
  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); }
7621
+ 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); }
7622
+ // the members' own records, for the tooltip's {{theme.item.data}}
7623
+ // (flat's __add_data tables them, numbered, up to 50)
7624
+ if (f.properties.raw && (!cell.raws || cell.raws.length < 50)) {
7625
+ (cell.raws = cell.raws || []).push(f.properties.raw);
7626
+ }
7298
7627
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7299
7628
  cell.value += f.properties.value;
7300
7629
  if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
@@ -7316,7 +7645,8 @@
7316
7645
  // titleRaw: the cell's first record — flat titles an aggregated
7317
7646
  // item by it (itemTitle)
7318
7647
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}),
7319
- titleRaw: cell.first.properties.raw || cell.first.properties }
7648
+ titleRaw: cell.first.properties.raw || cell.first.properties,
7649
+ ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}) }
7320
7650
  : cell.first.properties;
7321
7651
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
7322
7652
  // a field aggregation's cell key (the field value): the categories of
@@ -7383,6 +7713,18 @@
7383
7713
  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
7714
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7385
7715
  if (!cell.titleRaw) cell.titleRaw = f.properties.titleRaw || f.properties.raw || null;
7716
+ // the members' own records, for the tooltip's {{theme.item.data}} —
7717
+ // flat's __add_data tables them (numbered, up to 50) for aggregated
7718
+ // items too; references only, so no copy is made. A member can itself
7719
+ // be a pre-aggregated cluster node, whose records it carries in raws
7720
+ if (!cell.raws) cell.raws = [];
7721
+ if (cell.raws.length < 50) {
7722
+ if (f.properties.raw) cell.raws.push(f.properties.raw);
7723
+ else if (f.properties.raws) for (const r of f.properties.raws) {
7724
+ if (cell.raws.length >= 50) break;
7725
+ cell.raws.push(r);
7726
+ }
7727
+ }
7386
7728
  cell.counts[f.properties.cat] += f.properties.value;
7387
7729
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
7388
7730
  // point_count is the aggregated-record count of a multi-record
@@ -7397,6 +7739,7 @@
7397
7739
  return {
7398
7740
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
7399
7741
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}),
7742
+ ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}),
7400
7743
  ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}) }
7401
7744
  };
7402
7745
  });
@@ -7441,13 +7784,25 @@
7441
7784
  return count;
7442
7785
  }
7443
7786
 
7787
+ // flat matches type flags as substrings (szFlag.match(/SYMBOL/)), so a
7788
+ // "SYMBOLS" flag (PNRR pages) is a SYMBOL chart there. With only the symbol
7789
+ // "label" (maptheme.js 21833: the value as text, TEXTONLY: text alone) it is
7790
+ // the LABEL chart this engine draws as _buildLabelChartLayers.
7791
+ function symbolsFlagCompat(flags, style) {
7792
+ if (!flags.has('SYMBOLS') || flags.has('SYMBOL') || flags.has('LABEL')) return flags;
7793
+ const out = new Set(flags);
7794
+ const symbols = [].concat((style && style.symbols) || []);
7795
+ out.add(symbols.length && symbols.every(v => String(v) === 'label') ? 'LABEL' : 'SYMBOL');
7796
+ return out;
7797
+ }
7798
+
7444
7799
  class LayerRuntime {
7445
7800
  // spec: a normalizeTheme() result — never a raw LayerBuilder
7446
7801
  constructor(spec, fc, mapOptions) {
7447
7802
  this.name = spec.name;
7448
7803
  this.binding = spec.binding;
7449
- this.flags = spec.flags;
7450
7804
  this.style = spec.style;
7805
+ this.flags = symbolsFlagCompat(spec.flags, spec.style);
7451
7806
  this.meta = spec.meta;
7452
7807
  // GL-PORT COMPAT: retained only for ixmaps.getThemeObj()'s szFilter
7453
7808
  // (real ixmaps-flat global compat shim, see _globalThemeRegistry) —
@@ -7481,6 +7836,7 @@
7481
7836
  // ICON_ATLAS_RESET_AFTER for why this is necessary at all).
7482
7837
  this._iconGeneration = 0;
7483
7838
  this._iconsSinceAtlasReset = 0;
7839
+ this._iconAreaSinceAtlasReset = 0;
7484
7840
  this._prepare();
7485
7841
  }
7486
7842
 
@@ -7503,9 +7859,11 @@
7503
7859
  _cacheIcon(key, icon) {
7504
7860
  if (!this._iconCache.has(key)) {
7505
7861
  this._iconsSinceAtlasReset++;
7506
- if (this._iconsSinceAtlasReset > ICON_ATLAS_RESET_AFTER) {
7862
+ this._iconAreaSinceAtlasReset += (icon.width || 0) * (icon.height || 0);
7863
+ if (this._iconsSinceAtlasReset > ICON_ATLAS_RESET_AFTER || this._iconAreaSinceAtlasReset > ICON_ATLAS_AREA_RESET_AFTER) {
7507
7864
  this._iconGeneration++;
7508
7865
  this._iconsSinceAtlasReset = 0;
7866
+ this._iconAreaSinceAtlasReset = 0;
7509
7867
  }
7510
7868
  }
7511
7869
  if (this._iconCache.size >= ICON_CACHE_MAX) {
@@ -7646,6 +8004,10 @@
7646
8004
  // a page could in principle use both at once, though no current
7647
8005
  // ported page does.
7648
8006
  this._runtimeFilterExpr = '';
8007
+ // flat's setThemeTimeFrame(id, min, max): records whose style.timefield
8008
+ // value lies in [min, max) — null = no time filter. AND'd with the
8009
+ // filters above in _rebuildActiveFeatures.
8010
+ this._timeFrame = null;
7649
8011
 
7650
8012
  // GRIDSIZE layers (PLOT curves-chart, or its grid-mesh companion)
7651
8013
  // also carry AGGREGATE in their type string, but they bin into a
@@ -7842,6 +8204,44 @@
7842
8204
  this._rebuildActiveFeatures();
7843
8205
  }
7844
8206
 
8207
+ // setThemeTimeFrame: min/max in ms or any Date-parsable value; a null /
8208
+ // NaN bound is open-ended, both null clears the frame.
8209
+ setTimeFrame(min, max) {
8210
+ const toMs = v => (v == null || v === '' ? NaN : (typeof v === 'number' ? v : new Date(v).getTime()));
8211
+ const lo = toMs(min), hi = toMs(max);
8212
+ // flat: with the slider at its start position (frame starts at or
8213
+ // before the earliest record) every item is shown, not just the first window
8214
+ const field = this.binding && this.binding.time;
8215
+ if (field && !isNaN(lo)) {
8216
+ if (this._timeMin === undefined) {
8217
+ let m = Infinity;
8218
+ for (const f of this.features) {
8219
+ const v = f.properties && f.properties[field];
8220
+ const t = typeof v === 'number' ? v : new Date(v).getTime();
8221
+ if (!isNaN(t) && t < m) m = t;
8222
+ }
8223
+ this._timeMin = m;
8224
+ }
8225
+ if (lo <= this._timeMin) { this._timeFrame = null; this._rebuildActiveFeatures(); return; }
8226
+ }
8227
+ this._timeFrame = isNaN(lo) && isNaN(hi) ? null : { min: lo, max: hi };
8228
+ this._rebuildActiveFeatures();
8229
+ }
8230
+
8231
+ // predicate over a record's properties for the active time frame
8232
+ _timePredicate() {
8233
+ const tf = this._timeFrame;
8234
+ if (!tf) return null;
8235
+ const field = this.binding && this.binding.time;
8236
+ if (!field) return null;
8237
+ return props => {
8238
+ const v = props && props[field];
8239
+ const t = typeof v === 'number' ? v : new Date(v).getTime();
8240
+ if (isNaN(t)) return false;
8241
+ return (isNaN(tf.min) || t >= tf.min) && (isNaN(tf.max) || t < tf.max);
8242
+ };
8243
+ }
8244
+
7845
8245
  // `_featuresByCategory` (plain per-category bucketing — NOT yet
7846
8246
  // grid-aggregated; that's a separate, later step gated by AGGREGATE
7847
8247
  // alone, see _computeAggregatedItems) is needed whenever this runtime
@@ -7872,7 +8272,8 @@
7872
8272
  replaceFeatures(fc) {
7873
8273
  // new data → new deck.gl layers (see _dataLayerId)
7874
8274
  this._dataGen = (this._dataGen || 0) + 1;
7875
- const facetFilters = this.facetFilters, runtimeFilterExpr = this._runtimeFilterExpr;
8275
+ this._timeMin = undefined;
8276
+ const facetFilters = this.facetFilters, runtimeFilterExpr = this._runtimeFilterExpr, timeFrame = this._timeFrame;
7876
8277
  this.binding = this._specBinding;
7877
8278
  this.features = applyField100(filterFlatValues(this._withDensity(fc.features), this.binding, this.flags), this.binding, this.flags, this.style);
7878
8279
  this.binding = field100Binding(this.binding, this.flags);
@@ -7882,6 +8283,7 @@
7882
8283
  this._prepare();
7883
8284
  this.facetFilters = facetFilters;
7884
8285
  this._runtimeFilterExpr = runtimeFilterExpr;
8286
+ this._timeFrame = timeFrame;
7885
8287
  this._rebuildActiveFeatures();
7886
8288
  }
7887
8289
 
@@ -7894,6 +8296,27 @@
7894
8296
  // the next redraw.
7895
8297
  _dataLayerId(base) { return this._dataGen ? `${base}-d${this._dataGen}` : base; }
7896
8298
 
8299
+ // deck.gl compares a layer's data by reference: a new object makes it
8300
+ // re-process every polygon (on the PNRR page ~300 ms per refresh for
8301
+ // 8 000 comuni, at every pan tick). Polygon data that doesn't depend
8302
+ // on the view is therefore handed over as the same object until its
8303
+ // inputs change.
8304
+ _featureCollection() {
8305
+ if (!this._featuresFC || this._featuresFC.features !== this.features) {
8306
+ this._featuresFC = { type: 'FeatureCollection', features: this.features };
8307
+ }
8308
+ return this._featuresFC;
8309
+ }
8310
+ // the same for derived data: build() runs only when an entry of key
8311
+ // (compared by identity) differs from the last call's
8312
+ _cachedPolygonData(slot, key, build) {
8313
+ const last = this[slot];
8314
+ if (last && last.key.length === key.length && last.key.every((v, i) => v === key[i])) return last.data;
8315
+ const data = build();
8316
+ this[slot] = { key, data };
8317
+ return data;
8318
+ }
8319
+
7897
8320
  _usesAggregationIndex() {
7898
8321
  return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !this.flags.has('GRIDSIZE');
7899
8322
  }
@@ -7905,8 +8328,10 @@
7905
8328
  if (isAggregatedCategoricalChoropleth(this)) {
7906
8329
  const expr = this._runtimeFilterExpr;
7907
8330
  const clauses = Array.from(this.facetFilters.entries());
7908
- if (!expr && !clauses.length) { this._activeFeatures = null; return; }
7909
- const pred = expr ? flatFilterPredicate(String(expr)) : null;
8331
+ const timePred = this._timePredicate();
8332
+ if (!expr && !clauses.length && !timePred) { this._activeFeatures = null; return; }
8333
+ const wherePred = expr ? flatFilterPredicate(String(expr)) : null;
8334
+ const pred = wherePred && timePred ? (r => wherePred(r) && timePred(r)) : (wherePred || timePred);
7910
8335
  this._activeFeatures = this.features.map(f => {
7911
8336
  const rows = f.properties && f.properties[AGGREGATED_ROWS];
7912
8337
  if (!rows) return f;
@@ -7924,11 +8349,13 @@
7924
8349
  // comment) if one is active — reusing applyWhereFilter, the SAME
7925
8350
  // parser the layer's load-time .filter(expr) already uses, just
7926
8351
  // invoked again here instead of once at build time.
7927
- const base = this._runtimeFilterExpr
8352
+ let base = this._runtimeFilterExpr
7928
8353
  ? applyWhereFilter({ type: 'FeatureCollection', features: this.features }, this._runtimeFilterExpr).features
7929
8354
  : this.features;
8355
+ const timePred = this._timePredicate();
8356
+ if (timePred) base = base.filter(f => timePred(f.properties));
7930
8357
  if (this.facetFilters.size === 0) {
7931
- this._activeFeatures = this._runtimeFilterExpr ? base : null;
8358
+ this._activeFeatures = (this._runtimeFilterExpr || timePred) ? base : null;
7932
8359
  } else {
7933
8360
  const clauses = Array.from(this.facetFilters.entries());
7934
8361
  this._activeFeatures = base.filter(f =>
@@ -8298,10 +8725,11 @@
8298
8725
  // formatting) instead of an inline SVG chart — one line per present
8299
8726
  // category, each showing ITS OWN value, never summed across
8300
8727
  // 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.
8728
+ // - theme.item.data (a table of the hovered record's raw fields)
8729
+ // tables an individual point's record, and an aggregated item's
8730
+ // member records (numbered, up to 50 — flat's __add_data does the
8731
+ // same); only a group without retained records (cluster-level
8732
+ // groups) resolves to an empty string there.
8305
8733
  // an item's title as flat gives it (maptheme.js 11838-11886): the title
8306
8734
  // field of its record — of an aggregated item its first record; an
8307
8735
  // aggregated grid cell (no aggregation field) of more than 3 records,
@@ -8317,7 +8745,7 @@
8317
8745
  return title;
8318
8746
  }
8319
8747
 
8320
- buildTooltipHtml(object) {
8748
+ buildTooltipHtml(object, fDark) {
8321
8749
  // no .meta({tooltip}): flat's default template (tooltip_mustache.js 394)
8322
8750
  // — the theme title, the item's title and its chart
8323
8751
  const template = this.meta.tooltip || FLAT_DEFAULT_TOOLTIP;
@@ -8326,11 +8754,16 @@
8326
8754
  'include https://unpkg.com/mustache@4.2.0/mustache.min.js before ixmaps-gl.js.');
8327
8755
  return null;
8328
8756
  }
8329
- return global.Mustache.render(template, this._buildTooltipContext(object));
8757
+ return youtubeClickToPlay(global.Mustache.render(template, this._buildTooltipContext(object, fDark)));
8330
8758
  }
8331
8759
 
8332
- _buildTooltipContext(object) {
8333
- const props = object.properties;
8760
+ _buildTooltipContext(object, fDark) {
8761
+ let props = object.properties;
8762
+ // a FEATURE|CATEGORICAL polygon is the raw data record itself, not the
8763
+ // {raw, cat, value} wrapper the other layer kinds build
8764
+ if (props && !props.raw && (this.flags.has('FEATURE') || this.flags.has('FEATURES')) && this.flags.has('CATEGORICAL')) {
8765
+ props = { raw: props, cat: this._resolveClassIndex(props[this.binding.value]) };
8766
+ }
8334
8767
  const isGroup = !!props.counts;
8335
8768
  const chartHtml = this._renderItemChartHtml(props);
8336
8769
 
@@ -8342,7 +8775,7 @@
8342
8775
  chart: chartHtml,
8343
8776
  item: {
8344
8777
  chart: chartHtml,
8345
- data: isGroup ? '' : this._renderItemDataTableHtml(props.raw),
8778
+ data: isGroup ? this._renderGroupDataTableHtml(props.raws, fDark) : this._renderItemDataTableHtml(props.raw, fDark),
8346
8779
  value: this._hasValue(isGroup ? props.total : props.value) ? this._formatTooltipValue(isGroup ? props.total : props.value) : '',
8347
8780
  label: isGroup ? '' : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : ''),
8348
8781
  // flat: the item's title is its titlefield value (binding title/
@@ -8485,14 +8918,49 @@
8485
8918
  }
8486
8919
 
8487
8920
  // theme.item.data approximation — a plain field:value table of the
8488
- // hovered record's own raw properties (individual points only).
8489
- _renderItemDataTableHtml(raw) {
8921
+ // hovered record's own raw properties (individual points only) — flat's
8922
+ // own row markup (tooltip_mustache.js 568-590, main.css .tooltip): the
8923
+ // field name right-aligned, the value left, a dotted rule above the
8924
+ // value, the table at 0.85em with flat's min-width. Light is flat
8925
+ // exactly (#cccccc labels, #000000 values, dotted black); dark keeps
8926
+ // the same visibility hierarchy inverted - DIM labels, BRIGHT values -
8927
+ // with its own colors, a dotted #555 rule and #888 record pills
8928
+ _renderItemDataTableHtml(raw, fDark) {
8490
8929
  if (!raw) return '';
8930
+ const value = fDark ? '#e0e4ea' : '#000000';
8931
+ const label = fDark ? '#787d85' : '#cccccc';
8932
+ const rule = fDark ? '#555' : 'black';
8491
8933
  const rows = Object.keys(raw)
8492
8934
  .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>`)
8935
+ .map(k => `<tr><td></td><td style="text-align:right;vertical-align:top;color:${label}">${k}</td>` +
8936
+ `<td style="text-align:left;vertical-align:top;color:${value};padding:0 0.2em 0 0.8em;border-top:dotted ${rule} 0.1px">` +
8937
+ `${this._isNumericValue(raw[k]) ? this._formatTooltipValue(raw[k]) : raw[k]}</td></tr>`)
8494
8938
  .join('');
8495
- return `<table style="font-size:0.95em;border-collapse:collapse">${rows}</table>`;
8939
+ return `<table style="font-size:0.85em;min-width:300px;border-collapse:collapse">${rows}</table>`;
8940
+ }
8941
+
8942
+ // an aggregated item's own records, numbered, up to 50 — like real
8943
+ // flat's __add_data (tooltip_mustache.js 490-570: one numbered block
8944
+ // per record when the item holds more than one). A single-record
8945
+ // group renders like an individual point.
8946
+ _renderGroupDataTableHtml(raws, fDark) {
8947
+ if (!raws || !raws.length) return '';
8948
+ if (raws.length === 1) return this._renderItemDataTableHtml(raws[0], fDark);
8949
+ const value = fDark ? '#e0e4ea' : '#000000';
8950
+ const label = fDark ? '#787d85' : '#cccccc';
8951
+ const rule = fDark ? '#555' : 'black';
8952
+ const pill = fDark ? '#888' : 'black';
8953
+ const rows = raws.slice(0, 50).map((raw, d) =>
8954
+ '<tr><td style="font-size:1em;">&nbsp;</td></tr>' +
8955
+ `<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>` +
8956
+ Object.keys(raw)
8957
+ .filter(k => k !== 'geometry')
8958
+ .map(k => `<tr><td></td><td style="text-align:right;vertical-align:top;color:${label}">${k}</td>` +
8959
+ `<td style="text-align:left;vertical-align:top;color:${value};padding:0 0.2em 0 0.8em;border-top:dotted ${rule} 0.1px">` +
8960
+ `${this._isNumericValue(raw[k]) ? this._formatTooltipValue(raw[k]) : raw[k]}</td></tr>`)
8961
+ .join('')
8962
+ ).join('');
8963
+ return `<table style="font-size:0.85em;min-width:300px;border-collapse:collapse">${rows}</table>`;
8496
8964
  }
8497
8965
 
8498
8966
  _isNumericValue(v) {
@@ -8619,11 +9087,19 @@
8619
9087
  const cs = this.style.colorscheme;
8620
9088
  const raw = Array.isArray(cs) ? cs[0] : cs;
8621
9089
  const filled = raw !== 'none';
9090
+ // FEATURE|CATEGORICAL: polygons coloured by their category (flat colours
9091
+ // each shape by its class), not one flat fill
9092
+ const byCategory = filled && this.flags.has('CATEGORICAL') && this.categoryIndexByLabel && this.binding.value
9093
+ && this.categoryColorsRgb && this.categoryColorsRgb.length;
9094
+ const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
9095
+ const nodataRgb = parseCssColor(this.style.nodatacolor) || [255, 255, 255];
8622
9096
  const shadow = filled && flatShadowOn(this.style, this.features.length, zoom)
8623
9097
  ? this._buildShadowLayers(hexOrNamedToRgb(raw), zoom, bbox) : [];
8624
9098
  return shadow.concat([new GeoJsonLayer({
8625
9099
  id: this._dataLayerId(`ix-features-${this.name}`),
8626
- data: { type: 'FeatureCollection', features: this.features },
9100
+ data: this._featureCollection(),
9101
+ // FEATURE|CATEGORICAL polygons carry the data record: hover / click tooltips
9102
+ pickable: !!byCategory,
8627
9103
  // linecolor "none": no outline (a FEATURES theme of points with
8628
9104
  // colorscheme and linecolor "none" draws nothing, as flat)
8629
9105
  stroked: styleLineColor(this.style.linecolor) !== 'none',
@@ -8634,7 +9110,13 @@
8634
9110
  // flat puts fillopacity into the shape's fill-opacity only
8635
9111
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
8636
9112
  // (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],
9113
+ ...(byCategory ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
9114
+ getFillColor: byCategory
9115
+ ? f => {
9116
+ const cat = this._resolveClassIndex(f.properties[this.binding.value]);
9117
+ return [...(cat != null ? this.categoryColorsRgb[cat] : nodataRgb).slice(0, 3), catAlpha];
9118
+ }
9119
+ : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
8638
9120
  getLineColor: this.style.linecolor
8639
9121
  ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
8640
9122
  : [130, 130, 130],
@@ -8708,8 +9190,8 @@
8708
9190
  // single winning class, so no `cat`/tooltip chart line, just the
8709
9191
  // blended color directly); AGGREGATE CATEGORICAL on one field — the
8710
9192
  // 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
9193
+ // A plain CATEGORICAL choropleth matches the raw value of the polygon's
9194
+ // row against the category labels. Geometry + properties are already the joined
8713
9195
  // FeatureCollection from joinChoroplethFeatures. `zoom` is only used
8714
9196
  // for style.fillopacity:"auto" (see resolveAutoFillOpacity) — every
8715
9197
  // other branch here is zoom-independent.
@@ -8735,65 +9217,76 @@
8735
9217
  // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
8736
9218
  // (aggregatedCategoricalClass), the biggest part per class over all
8737
9219
  // 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 => {
9220
+ // the polygons' classes don't depend on the view or on marked
9221
+ // classes (those only change the colors, see updateTriggers below):
9222
+ // the data is rebuilt only when its inputs change
9223
+ const dataKey = [source, this._dataGen, this.categoryIndexByLabel, this.partsA, this._classSeparate,
9224
+ dopacityActive ? baseOpacity : null, JSON.stringify(this.style), JSON.stringify(this.binding)];
9225
+ const collection = this._cachedPolygonData('_choroplethData', dataKey, () => {
9226
+ const aggCat = isAggregatedCategoricalChoropleth(this) && this.categoryIndexByLabel;
9227
+ let aggOf = null, aggMaxA = null;
8754
9228
  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
- };
9229
+ const nCats = this.categoryLabels.length;
9230
+ aggOf = new Map();
9231
+ aggMaxA = new Array(nCats).fill(0);
9232
+ source.forEach(f => {
9233
+ const rows = f.properties && f.properties[AGGREGATED_ROWS];
9234
+ if (!rows) return;
9235
+ const agg = aggregatedCategoricalClass(rows, this.binding, this.flags, this.categoryIndexByLabel, nCats);
9236
+ if (!agg) return;
9237
+ aggOf.set(f, agg);
9238
+ agg.parts.forEach((v, i) => { if (v > aggMaxA[i]) aggMaxA[i] = v; });
9239
+ });
8772
9240
  }
8773
- if (isDominant) {
8774
- const dom = this._resolveDominantClass(f.properties);
8775
- const value = dom ? dom.value : undefined;
9241
+ const data = source.map(f => {
9242
+ if (aggCat) {
9243
+ const agg = aggOf.get(f);
9244
+ return {
9245
+ type: 'Feature',
9246
+ geometry: f.geometry,
9247
+ properties: {
9248
+ // no part above 0: no item in flat, not painted
9249
+ value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null, parts: agg ? agg.parts : undefined,
9250
+ dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
9251
+ }
9252
+ };
9253
+ }
9254
+ if (isComposeColor) {
9255
+ return {
9256
+ type: 'Feature',
9257
+ geometry: f.geometry,
9258
+ properties: { raw: f.properties, composedColor: this._resolveComposedColor(f.properties) }
9259
+ };
9260
+ }
9261
+ if (isDominant) {
9262
+ const dom = this._resolveDominantClass(f.properties);
9263
+ const value = dom ? dom.value : undefined;
9264
+ return {
9265
+ type: 'Feature',
9266
+ geometry: f.geometry,
9267
+ properties: {
9268
+ value, raw: f.properties, cat: dom ? dom.index : null,
9269
+ dopacityAlpha: dopacityActive ? this._resolveDopacityAlpha(f, value, baseOpacity) : null
9270
+ }
9271
+ };
9272
+ }
9273
+ // a polygon without a data row is no item in flat: no value
9274
+ const hasRow = f.properties && Object.keys(f.properties).length > 0;
9275
+ // CATEGORICAL matches the raw value against the category labels
9276
+ // (a text value has no numeric form: flatNumber would give NaN)
9277
+ const rawVal = hasRow ? f.properties[this.binding.value] : undefined;
9278
+ const value = this.categoryIndexByLabel ? rawVal : (hasRow ? flatNumber(rawVal, this.flags) : NaN);
9279
+ const cat = this._resolveClassIndex(value);
8776
9280
  return {
8777
9281
  type: 'Feature',
8778
9282
  geometry: f.geometry,
8779
9283
  properties: {
8780
- value, raw: f.properties, cat: dom ? dom.index : null,
9284
+ value, raw: f.properties, cat,
8781
9285
  dopacityAlpha: dopacityActive ? this._resolveDopacityAlpha(f, value, baseOpacity) : null
8782
9286
  }
8783
9287
  };
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
- };
9288
+ });
9289
+ return { type: 'FeatureCollection', features: data };
8797
9290
  });
8798
9291
  // A marked class (legend row click / ixmaps.markThemeClass) as real
8799
9292
  // ixmaps-flat shows it on a choropleth (MapTheme.markClass,
@@ -8815,9 +9308,13 @@
8815
9308
  return evidenceMode === 'isolate' ? 'hide' : 'gray';
8816
9309
  };
8817
9310
  const grayOf = ([r, g, b]) => { const y = Math.round(0.299 * r + 0.587 * g + 0.114 * b); return [y, y, y]; };
9311
+ // deck.gl re-runs the color accessors only for new data or a changed trigger
9312
+ const colorKey = JSON.stringify([[...this._markedClasses], !!this._onMarksChanged, evidenceMode,
9313
+ this.categoryColorsRgb, fallbackRgb, classAlpha, lineRgb]);
8818
9314
  return [new GeoJsonLayer({
8819
9315
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
8820
- data: { type: 'FeatureCollection', features: data },
9316
+ data: collection,
9317
+ updateTriggers: { getFillColor: colorKey, getLineColor: colorKey },
8821
9318
  pickable: true,
8822
9319
  stroked: true,
8823
9320
  filled: true,
@@ -9890,7 +10387,11 @@
9890
10387
  this._valueMedian = sorted[Math.floor((sorted.length - 1) / 2)];
9891
10388
  }
9892
10389
 
9893
- this._aggregateStatsCache = { zoom, outlier, normalize, breaks };
10390
+ // flat's default normalsizevalue is the largest aggregated size (maptheme.js 5021-5022)
10391
+ let sizeMax = 0;
10392
+ for (const v of rawSizeTotals) if (Math.abs(v) > sizeMax) sizeMax = Math.abs(v);
10393
+
10394
+ this._aggregateStatsCache = { zoom, outlier, normalize, breaks, sizeMax };
9894
10395
  return this._aggregateStatsCache;
9895
10396
  }
9896
10397
 
@@ -9914,6 +10415,12 @@
9914
10415
  // _computeAggregatedItems below — a plain (non-AGGREGATE)
9915
10416
  // BUBBLE/CHART theme never reads them.
9916
10417
  if (this.flags.has('AGGREGATE')) this._ensureClusterIndices(zoom);
10418
+ // AGGREGATE sums without a normalsizevalue: flat's default is the
10419
+ // largest aggregated value (defaultNormalSizeValue leaves it unset, the
10420
+ // sizes depend on the aggregation)
10421
+ if (this.flags.has('AGGREGATE') && !this.flags.has('CATEGORICAL') && !styleNum(this.style.normalsizevalue) && !this.binding.size) {
10422
+ this._maxSizeValue = this._ensureAggregateStats(zoom).sizeMax || undefined;
10423
+ }
9917
10424
  let { individual, groups } = this._computeAggregatedItems(bbox, zoom);
9918
10425
  // Far-hemisphere cull under globe projection — see
9919
10426
  // isOnVisibleHemisphere's own comment for why this is needed at
@@ -9930,7 +10437,7 @@
9930
10437
  }
9931
10438
  const layers = [];
9932
10439
 
9933
- if (this.flags.has('NOOUTLIER') || this.flags.has('NORMALIZE') || this._rangeClassed) {
10440
+ if (this.flags.has('NOOUTLIER')|| this.flags.has('NORMALIZE') || this._rangeClassed) {
9934
10441
  const stats = this._ensureAggregateStats(zoom);
9935
10442
 
9936
10443
  // the quantity NOOUTLIER/NORMALIZE/classes act on: the separate
@@ -10041,6 +10548,10 @@
10041
10548
  // _buildTooltipContext's own `isGroup` check already uses).
10042
10549
  const sizeValueOf = d => d.properties.counts ? d.properties.total : d.properties.value;
10043
10550
  const combined = individual.concat(groups).sort((a, b) => sizeValueOf(a) - sizeValueOf(b));
10551
+ // maxcharts: like the flat engine (maptheme.js nMaxCharts), draw only the
10552
+ // biggest N items — combined is ascending, so drop from the front
10553
+ const maxCharts = Math.round(styleNum(this.style.maxcharts)) || 0;
10554
+ if (maxCharts > 0 && combined.length > maxCharts) combined.splice(0, combined.length - maxCharts);
10044
10555
  if (this.flags.has('USER')) {
10045
10556
  const userLayers = this._buildUserChartLayers(combined, liveZoom, sizeValueOf);
10046
10557
  if (userLayers) return layers.concat(userLayers);
@@ -10063,7 +10574,16 @@
10063
10574
  const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
10064
10575
  const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10065
10576
  // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
10066
- const alignOf = this._chartAlignFn(liveZoom, radiusOf);
10577
+ // MULTIQUAD / MULTISQUARE: the items at one position side by side
10578
+ // (multiQuadOffsets), added to the chart position
10579
+ const baseAlign = this._chartAlignFn(liveZoom, radiusOf);
10580
+ const multi = flatFlag(this.flags, 'MULTIQUAD') || flatFlag(this.flags, 'MULTISQUARE')
10581
+ ? multiQuadOffsets(combined, NORMAL_RADIUS_PX * objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1),
10582
+ styleNum(this.style.rangescale) || 1, styleNum(this.style.gridx) || 0)
10583
+ : null;
10584
+ const alignOf = multi
10585
+ ? 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 })
10586
+ : baseAlign;
10067
10587
 
10068
10588
  // SHADOW (style.shadow, flat's gate: maxshadow, shadowupper/lower — flatShadowOn): a soft
10069
10589
  // drop shadow under each symbol, see _getShadowIcon
@@ -10145,28 +10665,42 @@
10145
10665
  // (#888888) over a half-opaque white halo (stroke font/7); its first
10146
10666
  // baseline one font below the box (BOTTOMTITLE) or 0.7 font above it.
10147
10667
  // Box and title are removed outside boxupper/boxlower.
10668
+ // Flat draws box and title in the chart group, which is scaled by
10669
+ // style.scale: the units below are u · scale. Its size measure W is
10670
+ // the chart's width (2r; with MULTIQUAD the width of the whole grid
10671
+ // at the position, the box enclosing all of it); flat's chart size
10672
+ // is 30 units.
10148
10673
  const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
10149
10674
  const circular = flatFlag(this.flags, 'CIRCULAR');
10150
- const unitPx = objectZoomFactor(liveZoom, this.mapOptions);
10675
+ const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
10151
10676
  const boxMarginU = styleNum(this.style.boxmargin) || 2;
10152
10677
  const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
10153
10678
  const titleFontPx = 5 * unitPx * (styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1);
10679
+ // flat: fill-opacity (boxopacity || 1) on the raw style value — the
10680
+ // usual string "0" is transparent; typed here, a given 0 is too
10681
+ const boxOpacity = isNaN(styleNum(this.style.boxopacity)) ? 1 : styleNum(this.style.boxopacity);
10154
10682
  const bottomTitle = flatFlag(this.flags, 'BOTTOMTITLE');
10155
10683
  const alignRight = /right/.test(String(this.style.align || ''));
10156
- const boxLayout = boxShown ? combined.map(d => {
10684
+ // with MULTIQUAD only the first item of a position gets its box/title
10685
+ const boxLayout = boxShown ? combined.filter(d => !multi || multi.index.get(d) === 0).map(d => {
10157
10686
  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);
10687
+ const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
10688
+ const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
10689
+ const w = chart[2] - chart[0];
10690
+ const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * w / 30);
10159
10691
  const boxR = circular ? r * 1.1 + margin : r;
10160
10692
  let title = null;
10161
10693
  if (titleShown) {
10162
10694
  const text = this._itemTitle(d.properties);
10163
10695
  if (text) {
10164
10696
  const f = titleFontPx;
10165
- const lines = wrapTitleLines(text, f, boxR * 2).split('\n');
10697
+ const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
10166
10698
  const width = Math.max(...lines.map(l => titleTextWidth(l, f)));
10167
10699
  // 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);
10700
+ const base = circular
10701
+ ? (bottomTitle ? boxR + f : -boxR - 0.7 * f - (lines.length - 1) * f)
10702
+ : (bottomTitle ? chart[3] + f : chart[1] - 0.7 * f - (lines.length - 1) * f);
10703
+ const x = circular ? unitPx / 2 : (alignRight ? chart[2] : chart[0]);
10170
10704
  const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
10171
10705
  const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
10172
10706
  // arial: ascent 0.905, descent 0.212 of the font size
@@ -10176,16 +10710,16 @@
10176
10710
  }
10177
10711
  let rect = null;
10178
10712
  if (!circular) {
10179
- const e = [-r, -r, r, r];
10713
+ const e = chart.slice();
10180
10714
  // with a title flat trims 0.1 font off the top of chart + title
10181
10715
  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
10716
  rect = [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin];
10183
10717
  }
10184
- return { d, r, boxR, rect, title };
10718
+ return { d, r, w, boxR, rect, title };
10185
10719
  }) : null;
10186
10720
  if (boxShown && circular) {
10187
10721
  const boxRgb = hexOrNamedToRgb(this.style.boxcolor || '#eeeeee');
10188
- const boxAlpha = Math.round(255 * (styleNum(this.style.boxopacity) || 1));
10722
+ const boxAlpha = Math.round(255 * boxOpacity);
10189
10723
  const borderRgb = hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
10190
10724
  // ScatterplotLayer has no pixel offset: a moved chart (align,
10191
10725
  // offsetx/offsety) gets its circle as an icon
@@ -10218,20 +10752,47 @@
10218
10752
  const borderStyle = String(this.style.borderstyle || '');
10219
10753
  const boxLook = {
10220
10754
  fill: this.style.boxcolor || 'white',
10221
- fillOpacity: styleNum(this.style.boxopacity) || 1,
10755
+ fillOpacity: boxOpacity,
10222
10756
  stroke: borderStyle === 'none' ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
10223
10757
  strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
10224
10758
  };
10225
10759
  const borderWidth = styleNum(this.style.borderwidth) || 1;
10226
10760
  const borderRadius = styleNum(this.style.borderradius);
10227
- boxLayout.forEach(b => {
10761
+ const strokeOf = b => borderWidth * 0.2 * b.w / 30;
10762
+ // a box larger than BOX_ICON_MAX_PX (a MULTIQUAD grid of thousands
10763
+ // of items) would need a huge canvas: it is drawn as a polygon in
10764
+ // map coordinates instead (square corners)
10765
+ const bigBox = b => Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) > BOX_ICON_MAX_PX;
10766
+ const iconBoxes = boxLayout.filter(b => !bigBox(b)), polygonBoxes = boxLayout.filter(bigBox);
10767
+ iconBoxes.forEach(b => {
10228
10768
  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);
10769
+ isNaN(borderRadius) ? 0 : borderRadius * Math.min(unitPx, b.w / 30),
10770
+ strokeOf(b), boxLook);
10231
10771
  });
10232
- layers.push(new IconLayer({
10772
+ if (polygonBoxes.length) {
10773
+ const fillRgb = hexOrNamedToRgb(boxLook.fill);
10774
+ const strokeRgb = boxLook.stroke ? hexOrNamedToRgb(boxLook.stroke) : null;
10775
+ layers.push(new GeoJsonLayer({
10776
+ id: `ix-box-polygons-${this.name}`,
10777
+ data: polygonBoxes.map(b => {
10778
+ const o = alignOf(b.d), c = b.d.geometry.coordinates, r = b.rect;
10779
+ const at = (x, y) => pixelOffsetLngLat(c, [x + o[0], y + o[1]], liveZoom);
10780
+ return { type: 'Feature', properties: { stroke: strokeOf(b) },
10781
+ 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])]] } };
10782
+ }),
10783
+ pickable: false,
10784
+ filled: boxLook.fillOpacity > 0,
10785
+ stroked: !!strokeRgb,
10786
+ getFillColor: [...fillRgb.slice(0, 3), Math.round(255 * boxLook.fillOpacity)],
10787
+ getLineColor: strokeRgb ? [...strokeRgb.slice(0, 3), Math.round(255 * boxLook.strokeOpacity)] : [0, 0, 0, 0],
10788
+ getLineWidth: f => f.properties.stroke,
10789
+ lineWidthUnits: 'pixels',
10790
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10791
+ }));
10792
+ }
10793
+ if (iconBoxes.length) layers.push(new IconLayer({
10233
10794
  id: `ix-box-${this.name}-g${this._iconGeneration}`,
10234
- data: boxLayout, pickable: false,
10795
+ data: iconBoxes, pickable: false,
10235
10796
  getPosition: b => b.d.geometry.coordinates,
10236
10797
  getIcon: b => b.icon,
10237
10798
  // the icon is drawn at 2× with a stroke padding around the box
@@ -10251,7 +10812,9 @@
10251
10812
  ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d))
10252
10813
  : this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
10253
10814
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
10254
- getColor: d => [255, 255, 255, this._iconAlpha(d)],
10815
+ // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
10816
+ // so a near-transparent theme doesn't show opaque 1-px-cell clusters
10817
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (d.properties.counts && this.style.fillopacity != null ? fillOpacity : 1))],
10255
10818
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
10256
10819
  sizeUnits: 'pixels',
10257
10820
  billboard: true,
@@ -10816,6 +11379,7 @@
10816
11379
  // - themes: getThemeObj (flat's theme object, with its data table and
10817
11380
  // the items on the map), getThemeDefinitionObj, getThemes,
10818
11381
  // changeThemeStyle / removeTheme / setBasemapOpacity (map name first),
11382
+ // setThemeTimeFrame (themeId, min, max),
10819
11383
  // refreshTheme, markThemeClass / unmarkThemeClass
10820
11384
  // - data: ixmaps.data (facets: getFacets, showFacets, …), the
10821
11385
  // embeddedSVG.window tables (setExternalData is with the data loading)
@@ -10863,7 +11427,7 @@
10863
11427
  // htmlgui_flat.js). Its methods act on the last built map; called while
10864
11428
  // the map is still building, they run once it is ready.
10865
11429
  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'];
11430
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
10867
11431
  const _mapHandle = {};
10868
11432
  for (const m of MAP_HANDLE_METHODS) {
10869
11433
  _mapHandle[m] = (...args) => {
@@ -11090,6 +11654,7 @@
11090
11654
  const style = Object.assign({}, rt.style);
11091
11655
  if (rt.binding.size && !style.sizefield) style.sizefield = rt.binding.size;
11092
11656
  if (rt.binding.title && !style.titlefield) style.titlefield = rt.binding.title;
11657
+ if (rt.binding.time && !style.timefield) style.timefield = rt.binding.time;
11093
11658
  style.type = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
11094
11659
  const data = Object.assign({}, rt._definition && rt._definition.data);
11095
11660
  if (!data.name) data.name = themeDataName(rt);
@@ -11449,7 +12014,7 @@
11449
12014
  }
11450
12015
 
11451
12016
  global.ixmaps = {
11452
- layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
12017
+ layer, Layer: layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
11453
12018
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
11454
12019
  markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
11455
12020
  map: mapHandle, getZoom, getThemeDefinitionObj, embeddedSVG,
@@ -11470,6 +12035,8 @@
11470
12035
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
11471
12036
  changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
11472
12037
  removeTheme: (szMap, szId) => _mapHandle.remove(szId),
12038
+ // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
12039
+ setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
11473
12040
  setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
11474
12041
  // this engine's version (flat's own ixmaps.version numbers flat)
11475
12042
  glVersion: IXMAPS_GL_VERSION,
@@ -11494,7 +12061,7 @@
11494
12061
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
11495
12062
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
11496
12063
  global.__ixmapsGlInternals = {
11497
- IXMAPS_GL_VERSION, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
12064
+ IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, multiQuadOffsets, pixelOffsetLngLat,
11498
12065
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
11499
12066
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
11500
12067
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,