ixmaps-gl 0.2.10 → 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 +5 -4
  2. package/ixmaps-gl.js +824 -164
  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.10';
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.24/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.24 (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.24 — 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,
@@ -2644,6 +2709,73 @@
2644
2709
  }
2645
2710
 
2646
2711
  // ---------------------------------------------------------------
2712
+ // flat's map attribution (ui/html/mappage.html #attribution-div): the
2713
+ // page's text bottom left on the map; hidden while empty ("null" counts
2714
+ // as empty, htmlgui_flat.js 522-526). Here a MapLibre control in the
2715
+ // bottom-left corner, styled like MapLibre's own attribution (the
2716
+ // basemap credit, bottom right — same line, same pill: white, 12px
2717
+ // radius, 12px/20px Helvetica Neue, links rgba(0,0,0,0.75)).
2718
+ // ---------------------------------------------------------------
2719
+ let _attributionCss = false;
2720
+ function createAttribution(map) {
2721
+ if (!_attributionCss && typeof document !== 'undefined') {
2722
+ _attributionCss = true;
2723
+ const css = document.createElement('style');
2724
+ css.textContent = '.ixmaps-gl-attribution a{color:rgba(0,0,0,0.75);text-decoration:none}'
2725
+ + '.ixmaps-gl-attribution a:hover{color:inherit;text-decoration:underline}';
2726
+ document.head.appendChild(css);
2727
+ }
2728
+ const box = document.createElement('div');
2729
+ box.className = 'maplibregl-ctrl ixmaps-gl-attribution';
2730
+ box.style.cssText = 'background:#fff;border-radius:12px;padding:2px 8px;min-height:20px;box-sizing:content-box;'
2731
+ + 'font:12px/20px "Helvetica Neue",Arial,Helvetica,sans-serif;color:#000;display:none;';
2732
+ map.addControl({ onAdd: () => box, onRemove: () => box.remove() }, 'bottom-left');
2733
+ let text = '';
2734
+ return {
2735
+ set(t) {
2736
+ text = t == null || t === 'null' ? '' : String(t);
2737
+ box.innerHTML = text;
2738
+ box.style.display = text ? 'block' : 'none';
2739
+ },
2740
+ get() { return text; }
2741
+ };
2742
+ }
2743
+
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
+
2647
2779
  // Hover tooltips and the click-to-pin tooltip of a map, built once per map
2648
2780
  // by MapBuilder.build(). ctx: the builder (its map options, for the
2649
2781
  // tooltip look), the MapLibre map, its container element and
@@ -2653,6 +2785,12 @@
2653
2785
  // ---------------------------------------------------------------
2654
2786
  function createTooltips(ctx) {
2655
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
+ };
2656
2794
  // Click-to-pin tooltip: matches the real ixmaps engine's own
2657
2795
  // click-pins-the-tooltip convention in pan mode (a hover tooltip is
2658
2796
  // transient; a click keeps one visible until dismissed). This is a
@@ -2662,28 +2800,101 @@
2662
2800
  // at click time (not live-updating), but its screen POSITION is kept
2663
2801
  // in sync with the map on every pan/zoom via map.project().
2664
2802
  let pinned = null; // { runtime, object, lngLat }
2665
- // Tooltip look, like the legend's (flatLegendLook): flat's light
2666
- // tooltip (tooltip_mustache.js: white 0.95, #444 text, thin black
2667
- // border, 5px radius) on light basemaps, this engine's dark tooltip
2668
- // 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
2669
2809
  // myMap.then(...).options({basemapopacity})) count.
2670
- const tooltipLook = () => {
2810
+ const tooltipIsDark = () => {
2671
2811
  const o = parseFloat(builder._engineOptions.basemapopacity);
2672
- const look = flatLegendLook(builder.mapOptions.mapType, isNaN(o) ? 1 : o, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
2673
- return look.dark
2674
- ? { background: 'rgb(41,50,60)', color: 'rgb(200,205,214)', border: 'none', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }
2675
- : { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
2676
- 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;
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' });
2677
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>`;
2678
2839
  const pinnedTooltipEl = document.createElement('div');
2679
2840
  pinnedTooltipEl.style.cssText = 'position:absolute;top:0;left:0;z-index:6;display:none;' +
2680
- 'pointer-events:auto;max-width:320px;max-height:320px;overflow:auto;' +
2681
- 'padding:0.6em 1.6em 0.6em 0.7em;font-size:0.85em;';
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
+ }
2682
2858
 
2683
2859
  function updatePinnedTooltipPosition() {
2684
2860
  if (!pinned) return;
2685
2861
  const pt = map.project(pinned.lngLat);
2686
- 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 });
2687
2898
  }
2688
2899
 
2689
2900
  // A stable per-geometry anchor coordinate for the identity check
@@ -2717,13 +2928,19 @@
2717
2928
  }
2718
2929
 
2719
2930
  // content lives in its own child so re-rendering (innerHTML) never
2720
- // 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;
2721
2937
  const pinnedContentEl = document.createElement('div');
2722
- pinnedTooltipEl.appendChild(pinnedContentEl);
2938
+ pinnedPaneEl.appendChild(pinnedContentEl);
2939
+ pinnedTooltipEl.appendChild(pinnedPaneEl);
2723
2940
 
2724
2941
  function renderPinnedTooltip() {
2725
2942
  if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
2726
- const html = pinned.runtime.buildTooltipHtml(pinned.object);
2943
+ const html = pinned.runtime.buildTooltipHtml(pinned.object, tooltipIsDark());
2727
2944
  if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
2728
2945
  pinnedContentEl.innerHTML = html;
2729
2946
  Object.assign(pinnedTooltipEl.style, tooltipLook());
@@ -2764,7 +2981,15 @@
2764
2981
  // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
2765
2982
  // the feature is its d
2766
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
+
2767
2991
  function tooltipFor({ object: picked, layer }) {
2992
+ if (isPanMode()) return null;
2768
2993
  const object = pickedFeature(picked);
2769
2994
  if (!object || !layer) return null;
2770
2995
  // suppress the hover tooltip ONLY for the specific item that's
@@ -2774,8 +2999,10 @@
2774
2999
  if (pinned && isSameAsPinned(layer.id, object)) return null;
2775
3000
  const rt = findRuntimeForLayerId(layer.id);
2776
3001
  if (!rt) return null;
2777
- const html = rt.buildTooltipHtml(object);
2778
- return html ? { html, style: Object.assign({ fontSize: '0.85em', padding: '0.5em 0.7em', maxWidth: '320px' }, 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;
2779
3006
  }
2780
3007
  function clickFor(picked) {
2781
3008
  const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
@@ -2835,6 +3062,7 @@
2835
3062
  }
2836
3063
  function mount() {
2837
3064
  map.on('move', updatePinnedTooltipPosition);
3065
+ watchHoverTooltip();
2838
3066
  if (el.parentElement) {
2839
3067
  el.parentElement.style.position = el.parentElement.style.position || 'relative';
2840
3068
  el.parentElement.appendChild(pinnedTooltipEl);
@@ -3040,7 +3268,10 @@
3040
3268
  });
3041
3269
  legendStack.style.display = shown ? 'flex' : 'none';
3042
3270
  };
3043
- 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');
3044
3275
  // One theme's panel — at build time for every theme, and again for a
3045
3276
  // theme defined later (map.layer(...) in a myMap.then(...) chain,
3046
3277
  // defineLayer, loadProject); removeTheme removes it with the theme.
@@ -3076,8 +3307,16 @@
3076
3307
  // flat's look (flatLegendLook) unless the page picks one with
3077
3308
  // this engine's own legendtheme
3078
3309
  const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3079
- const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
3080
- 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);
3081
3320
  const legendColors = isLightLegend
3082
3321
  ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3083
3322
  rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
@@ -3085,7 +3324,10 @@
3085
3324
  : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3086
3325
  rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3087
3326
  selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3088
- 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;
3089
3331
  // max-height:66% resolves against el.parentElement's own
3090
3332
  // height (the map container, which always has a definite
3091
3333
  // height for the map itself to render into) since this panel
@@ -3175,7 +3417,11 @@
3175
3417
  (rt._activeFeatures || rt.features).forEach(f => {
3176
3418
  const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
3177
3419
  if (idx == null) return;
3178
- 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;
3179
3425
  if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3180
3426
  if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3181
3427
  totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
@@ -3587,7 +3833,13 @@
3587
3833
  // needs (a chart library, a user chart) — page code, like a <script>
3588
3834
  // tag; loaded in order before the layers (see loadRequiredScripts)
3589
3835
  require(url) { this._required = (this._required || []).concat(url); return this; }
3590
- attribution(a) { this._attributionText = a; return this; }
3836
+ // flat's .attribution(text): the page's attribution, bottom left on the
3837
+ // map (createAttribution) — also after the map is built
3838
+ attribution(a) {
3839
+ this._attributionText = a;
3840
+ if (this._setAttribution) this._setAttribution(a);
3841
+ return this;
3842
+ }
3591
3843
  legend(html) { this._legendHtml = html; return this; }
3592
3844
  // GL-PORT COMPAT: real ixmaps-flat's map.layer(name) returns a NEW
3593
3845
  // per-layer builder for a .data()...define() chain on the LAYER
@@ -3606,6 +3858,8 @@
3606
3858
  this._layerBuilders.push(nameOrBuilder);
3607
3859
  return this;
3608
3860
  }
3861
+ // .Layer() — alias of .layer()
3862
+ Layer(nameOrBuilder) { return this.layer(nameOrBuilder); }
3609
3863
  // GL-PORT COMPAT: real ixmaps-flat's map.on("layerdraw", cb) — only
3610
3864
  // "layerdraw" is actually wired up (see build()'s own note on how
3611
3865
  // it's translated into onRedraw + a synthetic per-runtime event); any
@@ -3781,13 +4035,10 @@
3781
4035
  : resolveBasemapStyleUrl(this.mapOptions.mapType),
3782
4036
  center: [lon, lat],
3783
4037
  zoom: this._viewZoom != null && this._viewZoom !== '' ? flatToMapLibreZoom(Number(this._viewZoom)) : 8,
3784
- // .attribution(a) (MapBuilder, above) was stored but never read —
3785
- // unlike .legend()'s parallel _legendHtml, which the splash/legend
3786
- // block below actually renders. MapLibre's own AttributionControl
3787
- // is added automatically (not disabled anywhere in this file) and
3788
- // accepts extra text via customAttribution, appended alongside the
3789
- // basemap's own required CARTO/OpenStreetMap credit rather than
3790
- // replacing it.
4038
+ // MapLibre's own AttributionControl (bottom right) keeps the
4039
+ // basemap's required CARTO/OpenStreetMap credit; the page's own
4040
+ // .attribution() goes bottom left, as flat shows it
4041
+ // (createAttribution).
3791
4042
  //
3792
4043
  // A color `mapType` (mapTypeColor truthy — see
3793
4044
  // buildBlankBackgroundStyle above) has no real basemap at all, so
@@ -3798,7 +4049,6 @@
3798
4049
  // customAttribution's own required-credit behavior for a REAL
3799
4050
  // basemap is left untouched.
3800
4051
  ...(mapTypeColor ? { attributionControl: false } : {}),
3801
- ...(this._attributionText ? { customAttribution: this._attributionText } : {}),
3802
4052
  // MapLibre's default (true) is kept: with false it also stops the
3803
4053
  // zoom-out where the world gets narrower than the window, so a
3804
4054
  // world map can't open fully zoomed out. The deck.gl overlay takes
@@ -3808,6 +4058,16 @@
3808
4058
  // world once, at the cost of that zoom limit.
3809
4059
  renderWorldCopies: this._engineOptions.worldcopies !== false
3810
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
+ }
3811
4071
  _boundsSource = () => {
3812
4072
  const b = map.getBounds();
3813
4073
  return [{ lat: b.getSouth(), lng: b.getWest() }, { lat: b.getNorth(), lng: b.getEast() }];
@@ -3902,9 +4162,11 @@
3902
4162
  const overlay = new MapLibreOverlay({
3903
4163
  interleaved: true,
3904
4164
  layers: [],
3905
- // pointer becomes a hand over anything pickable (bubbles/points),
3906
- // so hovering something clickable actually looks clickable
3907
- 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')),
3908
4170
  // (guarded: see createTooltips)
3909
4171
  getTooltip: tooltips.getTooltip,
3910
4172
  onClick: tooltips.onClick
@@ -3912,6 +4174,10 @@
3912
4174
  map.addControl(overlay);
3913
4175
  map.addControl(new maplibregl.NavigationControl(), 'top-left');
3914
4176
  tooltips.mount();
4177
+ // the page's attribution (.attribution(), Map option attribution)
4178
+ const attributionBox = createAttribution(map);
4179
+ attributionBox.set(this._attributionText != null ? this._attributionText : this.mapOptions.attribution);
4180
+ this._setAttribution = text => attributionBox.set(text);
3915
4181
 
3916
4182
  if (this._legendHtml && el.parentElement) {
3917
4183
  const legendEl = document.createElement('div');
@@ -4083,6 +4349,11 @@
4083
4349
  mapType: (id) => engineApi.setMapType(id),
4084
4350
  // flat's getMapTypeId(): the basemap type name in use
4085
4351
  getMapTypeId: () => String(builder.mapOptions.mapType || ''),
4352
+ // flat's attribution (htmlgui.js htmlgui_set/getAttributionString)
4353
+ setAttribution: (text) => { builder.attribution(text); return engineApi; },
4354
+ // flat's map handle ixmaps.map().attribution(text) (htmlgui_flat.js 1381)
4355
+ attribution: (text) => engineApi.setAttribution(text),
4356
+ getAttribution: () => attributionBox.get(),
4086
4357
  setBasemapOpacity: (delta, mode) => {
4087
4358
  _basemapOpacity = mode === 'relative'
4088
4359
  ? Math.max(0, Math.min(1, _basemapOpacity + (parseFloat(delta) || 0)))
@@ -4172,6 +4443,18 @@
4172
4443
  refresh();
4173
4444
  }
4174
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
+ },
4175
4458
  // shows/hides a whole theme (all the deck.gl layers its
4176
4459
  // buildDeckLayers would otherwise produce) without touching its
4177
4460
  // data or style — for toggle controls like the real page's
@@ -4257,6 +4540,8 @@
4257
4540
  };
4258
4541
  return lb;
4259
4542
  },
4543
+ // .Layer() — alias of .layer()
4544
+ Layer: (nameOrBuilder) => engineApi.layer(nameOrBuilder),
4260
4545
  // Real ixmaps-flat's loadProject (htmlgui.js loadProject →
4261
4546
  // setProjectJSON → continueSetProjectJSON): src is a project object,
4262
4547
  // a JSON string, or a URL (fetched as DATA — nothing a project names
@@ -4589,7 +4874,15 @@
4589
4874
  const svg = String(m.map || '');
4590
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`);
4591
4876
  if (typeof map.setProjection === 'function') {
4592
- 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
+ }
4593
4886
  catch (e) { console.warn('[ixmaps-gl] loadProject: map.setProjection failed (needs maplibre-gl >=5.0.1)', e); }
4594
4887
  } else {
4595
4888
  console.warn('[ixmaps-gl] loadProject: map.setProjection not available on the loaded maplibre-gl build (needs >=5.0.1) — projection unchanged');
@@ -5135,6 +5428,8 @@
5135
5428
  // earlier hand-tuned 12.5 drew every symbol 1.2× (with the reference
5136
5429
  // below, 1.3×) smaller than flat.
5137
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;
5138
5433
  // Flat's own map scale, which dynamic object scaling compares with
5139
5434
  // normalSizeScale (mapscript2.js 2849: dx = nTrueMapScale · nZoomScale /
5140
5435
  // nNormalSizeScale), read from a real flat page: the generic Mercator
@@ -5381,6 +5676,16 @@
5381
5676
 
5382
5677
  function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue) {
5383
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
+ }
5384
5689
  // Real default (maptheme.js ~line 5059) when the theme sets no
5385
5690
  // normalsizevalue: the dataset's own max value on the bound size
5386
5691
  // field (maxSizeValue, from LayerRuntime._prepare), not a fixed
@@ -5585,6 +5890,8 @@
5585
5890
  const field = binding.size ? binding.size
5586
5891
  : (symbols && !flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && binding.value && !String(binding.value).includes('|') ? binding.value : null);
5587
5892
  if (!field) return undefined;
5893
+ // "$item$": every record counts 1
5894
+ if (field === '$item$') return features.length ? 1 : 0;
5588
5895
  return features.reduce((max, f) => {
5589
5896
  const v = parseFloat(f.properties[field]);
5590
5897
  return isNaN(v) ? max : Math.max(max, v);
@@ -6184,6 +6491,13 @@
6184
6491
  // researched safe range (~1480-2980 distinct 48px icons before a real
6185
6492
  // device's WebGL MAX_TEXTURE_SIZE is at risk).
6186
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;
6187
6501
 
6188
6502
  // Shared packing layout for a multi-category group's sub-bubbles — used
6189
6503
  // both to draw the packed icon raster (_buildBubbleIcon) and to position
@@ -6228,6 +6542,56 @@
6228
6542
  };
6229
6543
  }
6230
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
+
6231
6595
  // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
6232
6596
  // 22296-22395): one symbol per category part of a chart, in category
6233
6597
  // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
@@ -7150,9 +7514,13 @@
7150
7514
  // set (e.g. roma: summed Pericolosita, normalized to 0..1).
7151
7515
  // (flat's per-item nSize holds a record count in that last case, but it
7152
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).
7153
7520
  function resolveAggregateValue(binding, flags, props) {
7521
+ if (binding.size === '$item$') return 1;
7154
7522
  if (binding.size) return parseFloat(props[binding.size]) || 0;
7155
- if (flags.has('CATEGORICAL') || binding.value == null) return 1;
7523
+ if (flags.has('CATEGORICAL') || binding.value == null || binding.value === '$item$') return 1;
7156
7524
  return parseFloat(props[binding.value]) || 0;
7157
7525
  }
7158
7526
 
@@ -7250,7 +7618,12 @@
7250
7618
  key = `${snapped.x}:${snapped.y}`;
7251
7619
  }
7252
7620
  let cell = cells.get(key);
7253
- 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
+ }
7254
7627
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7255
7628
  cell.value += f.properties.value;
7256
7629
  if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
@@ -7269,7 +7642,11 @@
7269
7642
  const geometry = { type: 'Point', coordinates: [ll.lng, ll.lat] };
7270
7643
  const hasClass = cell.first.properties.classValue !== undefined;
7271
7644
  let props = cell.n > 1
7272
- ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}) }
7645
+ // titleRaw: the cell's first record — flat titles an aggregated
7646
+ // item by it (itemTitle)
7647
+ ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}),
7648
+ titleRaw: cell.first.properties.raw || cell.first.properties,
7649
+ ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}) }
7273
7650
  : cell.first.properties;
7274
7651
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
7275
7652
  // a field aggregation's cell key (the field value): the categories of
@@ -7335,6 +7712,19 @@
7335
7712
  let cell = cells.get(key);
7336
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); }
7337
7714
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
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
+ }
7338
7728
  cell.counts[f.properties.cat] += f.properties.value;
7339
7729
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
7340
7730
  // point_count is the aggregated-record count of a multi-record
@@ -7348,7 +7738,9 @@
7348
7738
  const ll = atCellCenter && cellPx ? worldPixelToLngLat(cell.cx, cell.cy, zoom) : worldPixelToLngLat(cell.sumX / cell.n, cell.sumY / cell.n, zoom);
7349
7739
  return {
7350
7740
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
7351
- properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}) }
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 } : {}),
7743
+ ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}) }
7352
7744
  };
7353
7745
  });
7354
7746
  }
@@ -7392,13 +7784,25 @@
7392
7784
  return count;
7393
7785
  }
7394
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
+
7395
7799
  class LayerRuntime {
7396
7800
  // spec: a normalizeTheme() result — never a raw LayerBuilder
7397
7801
  constructor(spec, fc, mapOptions) {
7398
7802
  this.name = spec.name;
7399
7803
  this.binding = spec.binding;
7400
- this.flags = spec.flags;
7401
7804
  this.style = spec.style;
7805
+ this.flags = symbolsFlagCompat(spec.flags, spec.style);
7402
7806
  this.meta = spec.meta;
7403
7807
  // GL-PORT COMPAT: retained only for ixmaps.getThemeObj()'s szFilter
7404
7808
  // (real ixmaps-flat global compat shim, see _globalThemeRegistry) —
@@ -7432,6 +7836,7 @@
7432
7836
  // ICON_ATLAS_RESET_AFTER for why this is necessary at all).
7433
7837
  this._iconGeneration = 0;
7434
7838
  this._iconsSinceAtlasReset = 0;
7839
+ this._iconAreaSinceAtlasReset = 0;
7435
7840
  this._prepare();
7436
7841
  }
7437
7842
 
@@ -7454,9 +7859,11 @@
7454
7859
  _cacheIcon(key, icon) {
7455
7860
  if (!this._iconCache.has(key)) {
7456
7861
  this._iconsSinceAtlasReset++;
7457
- 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) {
7458
7864
  this._iconGeneration++;
7459
7865
  this._iconsSinceAtlasReset = 0;
7866
+ this._iconAreaSinceAtlasReset = 0;
7460
7867
  }
7461
7868
  }
7462
7869
  if (this._iconCache.size >= ICON_CACHE_MAX) {
@@ -7597,6 +8004,10 @@
7597
8004
  // a page could in principle use both at once, though no current
7598
8005
  // ported page does.
7599
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;
7600
8011
 
7601
8012
  // GRIDSIZE layers (PLOT curves-chart, or its grid-mesh companion)
7602
8013
  // also carry AGGREGATE in their type string, but they bin into a
@@ -7793,6 +8204,44 @@
7793
8204
  this._rebuildActiveFeatures();
7794
8205
  }
7795
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
+
7796
8245
  // `_featuresByCategory` (plain per-category bucketing — NOT yet
7797
8246
  // grid-aggregated; that's a separate, later step gated by AGGREGATE
7798
8247
  // alone, see _computeAggregatedItems) is needed whenever this runtime
@@ -7823,7 +8272,8 @@
7823
8272
  replaceFeatures(fc) {
7824
8273
  // new data → new deck.gl layers (see _dataLayerId)
7825
8274
  this._dataGen = (this._dataGen || 0) + 1;
7826
- const facetFilters = this.facetFilters, runtimeFilterExpr = this._runtimeFilterExpr;
8275
+ this._timeMin = undefined;
8276
+ const facetFilters = this.facetFilters, runtimeFilterExpr = this._runtimeFilterExpr, timeFrame = this._timeFrame;
7827
8277
  this.binding = this._specBinding;
7828
8278
  this.features = applyField100(filterFlatValues(this._withDensity(fc.features), this.binding, this.flags), this.binding, this.flags, this.style);
7829
8279
  this.binding = field100Binding(this.binding, this.flags);
@@ -7833,6 +8283,7 @@
7833
8283
  this._prepare();
7834
8284
  this.facetFilters = facetFilters;
7835
8285
  this._runtimeFilterExpr = runtimeFilterExpr;
8286
+ this._timeFrame = timeFrame;
7836
8287
  this._rebuildActiveFeatures();
7837
8288
  }
7838
8289
 
@@ -7845,6 +8296,27 @@
7845
8296
  // the next redraw.
7846
8297
  _dataLayerId(base) { return this._dataGen ? `${base}-d${this._dataGen}` : base; }
7847
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
+
7848
8320
  _usesAggregationIndex() {
7849
8321
  return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !this.flags.has('GRIDSIZE');
7850
8322
  }
@@ -7856,8 +8328,10 @@
7856
8328
  if (isAggregatedCategoricalChoropleth(this)) {
7857
8329
  const expr = this._runtimeFilterExpr;
7858
8330
  const clauses = Array.from(this.facetFilters.entries());
7859
- if (!expr && !clauses.length) { this._activeFeatures = null; return; }
7860
- 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);
7861
8335
  this._activeFeatures = this.features.map(f => {
7862
8336
  const rows = f.properties && f.properties[AGGREGATED_ROWS];
7863
8337
  if (!rows) return f;
@@ -7875,11 +8349,13 @@
7875
8349
  // comment) if one is active — reusing applyWhereFilter, the SAME
7876
8350
  // parser the layer's load-time .filter(expr) already uses, just
7877
8351
  // invoked again here instead of once at build time.
7878
- const base = this._runtimeFilterExpr
8352
+ let base = this._runtimeFilterExpr
7879
8353
  ? applyWhereFilter({ type: 'FeatureCollection', features: this.features }, this._runtimeFilterExpr).features
7880
8354
  : this.features;
8355
+ const timePred = this._timePredicate();
8356
+ if (timePred) base = base.filter(f => timePred(f.properties));
7881
8357
  if (this.facetFilters.size === 0) {
7882
- this._activeFeatures = this._runtimeFilterExpr ? base : null;
8358
+ this._activeFeatures = (this._runtimeFilterExpr || timePred) ? base : null;
7883
8359
  } else {
7884
8360
  const clauses = Array.from(this.facetFilters.entries());
7885
8361
  this._activeFeatures = base.filter(f =>
@@ -8249,22 +8725,45 @@
8249
8725
  // formatting) instead of an inline SVG chart — one line per present
8250
8726
  // category, each showing ITS OWN value, never summed across
8251
8727
  // categories (same rule as the VALUES bubble labels).
8252
- // - theme.item.data (a table of the hovered record's raw fields) is
8253
- // only available for an individual, unaggregated point — an
8254
- // aggregated/grouped bubble has no single underlying record to
8255
- // tabulate, so it resolves to an empty string there.
8256
- buildTooltipHtml(object) {
8257
- if (!this.meta.tooltip) return null;
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.
8733
+ // an item's title as flat gives it (maptheme.js 11838-11886): the title
8734
+ // field of its record — of an aggregated item its first record; an
8735
+ // aggregated grid cell (no aggregation field) of more than 3 records,
8736
+ // or without a title, is titled "(n)"
8737
+ _itemTitle(props) {
8738
+ const raw = props.raw || props.titleRaw;
8739
+ const title = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '';
8740
+ const aggregated = !!(props.counts || props.cluster);
8741
+ if (aggregated && this._clusterRadiusPx && !this._clusterField) {
8742
+ const n = groupRecordCount(props);
8743
+ if (!title || n > 3) return '(' + n + ')';
8744
+ }
8745
+ return title;
8746
+ }
8747
+
8748
+ buildTooltipHtml(object, fDark) {
8749
+ // no .meta({tooltip}): flat's default template (tooltip_mustache.js 394)
8750
+ // — the theme title, the item's title and its chart
8751
+ const template = this.meta.tooltip || FLAT_DEFAULT_TOOLTIP;
8258
8752
  if (!global.Mustache) {
8259
- console.warn('[ixmaps-gl] .meta({tooltip}) is set but Mustache.js is not loaded — ' +
8753
+ console.warn('[ixmaps-gl] tooltips need Mustache.js, which is not loaded — ' +
8260
8754
  'include https://unpkg.com/mustache@4.2.0/mustache.min.js before ixmaps-gl.js.');
8261
8755
  return null;
8262
8756
  }
8263
- return global.Mustache.render(this.meta.tooltip, this._buildTooltipContext(object));
8757
+ return youtubeClickToPlay(global.Mustache.render(template, this._buildTooltipContext(object, fDark)));
8264
8758
  }
8265
8759
 
8266
- _buildTooltipContext(object) {
8267
- 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
+ }
8268
8767
  const isGroup = !!props.counts;
8269
8768
  const chartHtml = this._renderItemChartHtml(props);
8270
8769
 
@@ -8276,14 +8775,12 @@
8276
8775
  chart: chartHtml,
8277
8776
  item: {
8278
8777
  chart: chartHtml,
8279
- data: isGroup ? '' : this._renderItemDataTableHtml(props.raw),
8778
+ data: isGroup ? this._renderGroupDataTableHtml(props.raws, fDark) : this._renderItemDataTableHtml(props.raw, fDark),
8280
8779
  value: this._hasValue(isGroup ? props.total : props.value) ? this._formatTooltipValue(isGroup ? props.total : props.value) : '',
8281
8780
  label: isGroup ? '' : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : ''),
8282
8781
  // flat: the item's title is its titlefield value (binding title/
8283
8782
  // titlefield, e.g. the comune name); else the class label
8284
- title: isGroup ? '' : (this.binding.title && props.raw && props.raw[this.binding.title] != null
8285
- ? String(props.raw[this.binding.title])
8286
- : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : '')),
8783
+ title: this._itemTitle(props) || (isGroup ? '' : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : '')),
8287
8784
  count: isGroup ? (props.recordCounts ? props.recordCounts.reduce((a, c) => a + c, 0) : '') : 1,
8288
8785
  class: isGroup ? '' : props.cat
8289
8786
  }
@@ -8316,11 +8813,16 @@
8316
8813
  // breakdown, one line per present category, each with its own legend
8317
8814
  // color swatch and its OWN value (never summed across categories).
8318
8815
  _renderItemChartHtml(props) {
8816
+ const unitOf = this.style.units ? ' ' + this.style.units : '';
8817
+ // an AGGREGATE CATEGORICAL choropleth polygon: its sum per category
8818
+ if (props.parts) {
8819
+ const labels = this.categoryDisplayLabels || this.categoryLabels || [];
8820
+ return tooltipTable(props.parts.map((v, i) => (v > 0 ? { rgb: this.categoryColorsRgb[i], label: labels[i] || '(n/d)', value: this._formatTooltipValue(v) + unitOf } : null)).filter(Boolean));
8821
+ }
8319
8822
  if (this.flags.has('CHOROPLETH') && (this.flags.has('DOMINANT') || this.flags.has('COMPOSECOLOR')) && props.raw) {
8320
8823
  return this._renderMultiFieldBarsHtml(props.raw);
8321
8824
  }
8322
- const swatch = rgb => `<span style="display:inline-block;width:0.7em;height:0.7em;border-radius:50%;background:rgb(${rgb.join(',')});margin-right:0.4em;vertical-align:middle"></span>`;
8323
- const unit = this.style.units ? ' ' + this.style.units : '';
8825
+ const unit = unitOf;
8324
8826
 
8325
8827
  // PLOT|GRIDSIZE curve cells: properties.values is an array parallel
8326
8828
  // to _plotCategories() (e.g. one FERITI sum per year), not a single
@@ -8330,10 +8832,10 @@
8330
8832
  if (Array.isArray(props.values)) {
8331
8833
  const categories = this._isItemPlot() ? this._itemPlotLabels() : this._plotCategories();
8332
8834
  const labels = this._isItemPlot() ? categories : Array.isArray(this.style.label) ? this.style.label.map(String) : categories;
8333
- return props.values.map((v, i) => {
8835
+ return tooltipTable(props.values.map((v, i) => {
8334
8836
  if (v == null || isNaN(v)) return null;
8335
- return `<div>${labels[i] || categories[i] || i}: ${this._formatTooltipValue(v)}${unit}</div>`;
8336
- }).filter(Boolean).join('');
8837
+ return { rgb: null, label: labels[i] || categories[i] || i, value: this._formatTooltipValue(v) + unit };
8838
+ }).filter(Boolean));
8337
8839
  }
8338
8840
 
8339
8841
  if (props.counts) {
@@ -8352,19 +8854,17 @@
8352
8854
  // zoom until something resets it. Root cause of the "trackpad
8353
8855
  // zoom sometimes just stops updating" reports.
8354
8856
  const labels = this.categoryDisplayLabels || this.categoryLabels || [];
8355
- return labels.map((label, i) => {
8857
+ return tooltipTable(labels.map((label, i) => {
8356
8858
  if (!(props.counts[i] > 0)) return null;
8357
- const rgb = this.categoryColorsRgb[i];
8358
- return `<div>${swatch(rgb)}${label || '(n/d)'}: ${this._formatTooltipValue(props.counts[i])}${unit}</div>`;
8359
- }).filter(Boolean).join('');
8859
+ return { rgb: this.categoryColorsRgb[i], label: label || '(n/d)', value: this._formatTooltipValue(props.counts[i]) + unit };
8860
+ }).filter(Boolean));
8360
8861
  }
8361
8862
  if (props.cat != null) {
8362
8863
  const label = this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : '';
8363
8864
  const rgb = this.categoryColorsRgb ? this.categoryColorsRgb[props.cat] : null;
8364
8865
  // no bound size field (e.g. a plain DOT|CATEGORICAL layer) -> just
8365
8866
  // the category label, no ": undefined" value suffix
8366
- const valueSuffix = this._hasValue(props.value) ? `: ${this._formatTooltipValue(props.value)}${unit}` : '';
8367
- return `<div>${rgb ? swatch(rgb) : ''}${label}${valueSuffix}</div>`;
8867
+ return tooltipTable([{ rgb, label, value: this._hasValue(props.value) ? this._formatTooltipValue(props.value) + unit : '' }]);
8368
8868
  }
8369
8869
  return '';
8370
8870
  }
@@ -8418,14 +8918,49 @@
8418
8918
  }
8419
8919
 
8420
8920
  // theme.item.data approximation — a plain field:value table of the
8421
- // hovered record's own raw properties (individual points only).
8422
- _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) {
8423
8929
  if (!raw) return '';
8930
+ const value = fDark ? '#e0e4ea' : '#000000';
8931
+ const label = fDark ? '#787d85' : '#cccccc';
8932
+ const rule = fDark ? '#555' : 'black';
8424
8933
  const rows = Object.keys(raw)
8425
8934
  .filter(k => k !== 'geometry')
8426
- .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>`)
8427
8938
  .join('');
8428
- return `<table style="font-size:0.85em;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>`;
8429
8964
  }
8430
8965
 
8431
8966
  _isNumericValue(v) {
@@ -8552,11 +9087,19 @@
8552
9087
  const cs = this.style.colorscheme;
8553
9088
  const raw = Array.isArray(cs) ? cs[0] : cs;
8554
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];
8555
9096
  const shadow = filled && flatShadowOn(this.style, this.features.length, zoom)
8556
9097
  ? this._buildShadowLayers(hexOrNamedToRgb(raw), zoom, bbox) : [];
8557
9098
  return shadow.concat([new GeoJsonLayer({
8558
9099
  id: this._dataLayerId(`ix-features-${this.name}`),
8559
- data: { type: 'FeatureCollection', features: this.features },
9100
+ data: this._featureCollection(),
9101
+ // FEATURE|CATEGORICAL polygons carry the data record: hover / click tooltips
9102
+ pickable: !!byCategory,
8560
9103
  // linecolor "none": no outline (a FEATURES theme of points with
8561
9104
  // colorscheme and linecolor "none" draws nothing, as flat)
8562
9105
  stroked: styleLineColor(this.style.linecolor) !== 'none',
@@ -8567,7 +9110,13 @@
8567
9110
  // flat puts fillopacity into the shape's fill-opacity only
8568
9111
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
8569
9112
  // (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
8570
- 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],
8571
9120
  getLineColor: this.style.linecolor
8572
9121
  ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
8573
9122
  : [130, 130, 130],
@@ -8641,8 +9190,8 @@
8641
9190
  // single winning class, so no `cat`/tooltip chart line, just the
8642
9191
  // blended color directly); AGGREGATE CATEGORICAL on one field — the
8643
9192
  // dominant category of each polygon's records (aggregatedCategoricalClass).
8644
- // A plain CATEGORICAL choropleth (exact match of one row's value) is not
8645
- // 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
8646
9195
  // FeatureCollection from joinChoroplethFeatures. `zoom` is only used
8647
9196
  // for style.fillopacity:"auto" (see resolveAutoFillOpacity) — every
8648
9197
  // other branch here is zoom-independent.
@@ -8668,65 +9217,76 @@
8668
9217
  // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
8669
9218
  // (aggregatedCategoricalClass), the biggest part per class over all
8670
9219
  // polygons for the opacity (flat's nMaxA)
8671
- const aggCat = isAggregatedCategoricalChoropleth(this) && this.categoryIndexByLabel;
8672
- let aggOf = null, aggMaxA = null;
8673
- if (aggCat) {
8674
- const nCats = this.categoryLabels.length;
8675
- aggOf = new Map();
8676
- aggMaxA = new Array(nCats).fill(0);
8677
- source.forEach(f => {
8678
- const rows = f.properties && f.properties[AGGREGATED_ROWS];
8679
- if (!rows) return;
8680
- const agg = aggregatedCategoricalClass(rows, this.binding, this.flags, this.categoryIndexByLabel, nCats);
8681
- if (!agg) return;
8682
- aggOf.set(f, agg);
8683
- agg.parts.forEach((v, i) => { if (v > aggMaxA[i]) aggMaxA[i] = v; });
8684
- });
8685
- }
8686
- 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;
8687
9228
  if (aggCat) {
8688
- const agg = aggOf.get(f);
8689
- return {
8690
- type: 'Feature',
8691
- geometry: f.geometry,
8692
- properties: {
8693
- // no part above 0: no item in flat, not painted
8694
- value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null,
8695
- dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
8696
- }
8697
- };
8698
- }
8699
- if (isComposeColor) {
8700
- return {
8701
- type: 'Feature',
8702
- geometry: f.geometry,
8703
- properties: { raw: f.properties, composedColor: this._resolveComposedColor(f.properties) }
8704
- };
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
+ });
8705
9240
  }
8706
- if (isDominant) {
8707
- const dom = this._resolveDominantClass(f.properties);
8708
- 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);
8709
9280
  return {
8710
9281
  type: 'Feature',
8711
9282
  geometry: f.geometry,
8712
9283
  properties: {
8713
- value, raw: f.properties, cat: dom ? dom.index : null,
9284
+ value, raw: f.properties, cat,
8714
9285
  dopacityAlpha: dopacityActive ? this._resolveDopacityAlpha(f, value, baseOpacity) : null
8715
9286
  }
8716
9287
  };
8717
- }
8718
- // a polygon without a data row is no item in flat: no value
8719
- const hasRow = f.properties && Object.keys(f.properties).length > 0;
8720
- const value = hasRow ? flatNumber(f.properties[this.binding.value], this.flags) : NaN;
8721
- const cat = this._resolveClassIndex(value);
8722
- return {
8723
- type: 'Feature',
8724
- geometry: f.geometry,
8725
- properties: {
8726
- value, raw: f.properties, cat,
8727
- dopacityAlpha: dopacityActive ? this._resolveDopacityAlpha(f, value, baseOpacity) : null
8728
- }
8729
- };
9288
+ });
9289
+ return { type: 'FeatureCollection', features: data };
8730
9290
  });
8731
9291
  // A marked class (legend row click / ixmaps.markThemeClass) as real
8732
9292
  // ixmaps-flat shows it on a choropleth (MapTheme.markClass,
@@ -8748,9 +9308,13 @@
8748
9308
  return evidenceMode === 'isolate' ? 'hide' : 'gray';
8749
9309
  };
8750
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]);
8751
9314
  return [new GeoJsonLayer({
8752
9315
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
8753
- data: { type: 'FeatureCollection', features: data },
9316
+ data: collection,
9317
+ updateTriggers: { getFillColor: colorKey, getLineColor: colorKey },
8754
9318
  pickable: true,
8755
9319
  stroked: true,
8756
9320
  filled: true,
@@ -9720,7 +10284,8 @@
9720
10284
  return {
9721
10285
  geometry: f.geometry,
9722
10286
  properties: { counts: this._oneHot(f.properties.cat, f.properties.point_count), total: f.properties.value,
9723
- ...(f.properties.classValue !== undefined ? { classTotal: f.properties.classValue } : {}) }
10287
+ ...(f.properties.classValue !== undefined ? { classTotal: f.properties.classValue } : {}),
10288
+ ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
9724
10289
  };
9725
10290
  });
9726
10291
  return { individual, groups };
@@ -9822,7 +10387,11 @@
9822
10387
  this._valueMedian = sorted[Math.floor((sorted.length - 1) / 2)];
9823
10388
  }
9824
10389
 
9825
- 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 };
9826
10395
  return this._aggregateStatsCache;
9827
10396
  }
9828
10397
 
@@ -9846,6 +10415,12 @@
9846
10415
  // _computeAggregatedItems below — a plain (non-AGGREGATE)
9847
10416
  // BUBBLE/CHART theme never reads them.
9848
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
+ }
9849
10424
  let { individual, groups } = this._computeAggregatedItems(bbox, zoom);
9850
10425
  // Far-hemisphere cull under globe projection — see
9851
10426
  // isOnVisibleHemisphere's own comment for why this is needed at
@@ -9862,7 +10437,7 @@
9862
10437
  }
9863
10438
  const layers = [];
9864
10439
 
9865
- if (this.flags.has('NOOUTLIER') || this.flags.has('NORMALIZE') || this._rangeClassed) {
10440
+ if (this.flags.has('NOOUTLIER')|| this.flags.has('NORMALIZE') || this._rangeClassed) {
9866
10441
  const stats = this._ensureAggregateStats(zoom);
9867
10442
 
9868
10443
  // the quantity NOOUTLIER/NORMALIZE/classes act on: the separate
@@ -9973,6 +10548,10 @@
9973
10548
  // _buildTooltipContext's own `isGroup` check already uses).
9974
10549
  const sizeValueOf = d => d.properties.counts ? d.properties.total : d.properties.value;
9975
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);
9976
10555
  if (this.flags.has('USER')) {
9977
10556
  const userLayers = this._buildUserChartLayers(combined, liveZoom, sizeValueOf);
9978
10557
  if (userLayers) return layers.concat(userLayers);
@@ -9995,7 +10574,16 @@
9995
10574
  const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
9996
10575
  const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
9997
10576
  // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
9998
- 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;
9999
10587
 
10000
10588
  // SHADOW (style.shadow, flat's gate: maxshadow, shadowupper/lower — flatShadowOn): a soft
10001
10589
  // drop shadow under each symbol, see _getShadowIcon
@@ -10077,30 +10665,42 @@
10077
10665
  // (#888888) over a half-opaque white halo (stroke font/7); its first
10078
10666
  // baseline one font below the box (BOTTOMTITLE) or 0.7 font above it.
10079
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.
10080
10673
  const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
10081
10674
  const circular = flatFlag(this.flags, 'CIRCULAR');
10082
- const unitPx = objectZoomFactor(liveZoom, this.mapOptions);
10675
+ const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
10083
10676
  const boxMarginU = styleNum(this.style.boxmargin) || 2;
10084
10677
  const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
10085
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);
10086
10682
  const bottomTitle = flatFlag(this.flags, 'BOTTOMTITLE');
10087
10683
  const alignRight = /right/.test(String(this.style.align || ''));
10088
- 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 => {
10089
10686
  const r = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10090
- 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);
10091
10691
  const boxR = circular ? r * 1.1 + margin : r;
10092
10692
  let title = null;
10093
10693
  if (titleShown) {
10094
- const raw = d.properties.raw;
10095
- const n = groupRecordCount(d.properties);
10096
- const text = n > 3 ? '(' + n + ')' : (raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '');
10694
+ const text = this._itemTitle(d.properties);
10097
10695
  if (text) {
10098
10696
  const f = titleFontPx;
10099
- const lines = wrapTitleLines(text, f, boxR * 2).split('\n');
10697
+ const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
10100
10698
  const width = Math.max(...lines.map(l => titleTextWidth(l, f)));
10101
10699
  // first baseline; lines go down one font each
10102
- const base = bottomTitle ? boxR + f : -boxR - 0.7 * f - (lines.length - 1) * f;
10103
- 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]);
10104
10704
  const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
10105
10705
  const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
10106
10706
  // arial: ascent 0.905, descent 0.212 of the font size
@@ -10110,16 +10710,16 @@
10110
10710
  }
10111
10711
  let rect = null;
10112
10712
  if (!circular) {
10113
- const e = [-r, -r, r, r];
10713
+ const e = chart.slice();
10114
10714
  // with a title flat trims 0.1 font off the top of chart + title
10115
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]); }
10116
10716
  rect = [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin];
10117
10717
  }
10118
- return { d, r, boxR, rect, title };
10718
+ return { d, r, w, boxR, rect, title };
10119
10719
  }) : null;
10120
10720
  if (boxShown && circular) {
10121
10721
  const boxRgb = hexOrNamedToRgb(this.style.boxcolor || '#eeeeee');
10122
- const boxAlpha = Math.round(255 * (styleNum(this.style.boxopacity) || 1));
10722
+ const boxAlpha = Math.round(255 * boxOpacity);
10123
10723
  const borderRgb = hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
10124
10724
  // ScatterplotLayer has no pixel offset: a moved chart (align,
10125
10725
  // offsetx/offsety) gets its circle as an icon
@@ -10152,20 +10752,47 @@
10152
10752
  const borderStyle = String(this.style.borderstyle || '');
10153
10753
  const boxLook = {
10154
10754
  fill: this.style.boxcolor || 'white',
10155
- fillOpacity: styleNum(this.style.boxopacity) || 1,
10755
+ fillOpacity: boxOpacity,
10156
10756
  stroke: borderStyle === 'none' ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
10157
10757
  strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
10158
10758
  };
10159
10759
  const borderWidth = styleNum(this.style.borderwidth) || 1;
10160
10760
  const borderRadius = styleNum(this.style.borderradius);
10161
- 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 => {
10162
10768
  b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
10163
- isNaN(borderRadius) ? 0 : borderRadius * Math.min(unitPx, b.r * 2 / 30),
10164
- borderWidth * 0.2 * b.r * 2 / 30, boxLook);
10769
+ isNaN(borderRadius) ? 0 : borderRadius * Math.min(unitPx, b.w / 30),
10770
+ strokeOf(b), boxLook);
10165
10771
  });
10166
- 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({
10167
10794
  id: `ix-box-${this.name}-g${this._iconGeneration}`,
10168
- data: boxLayout, pickable: false,
10795
+ data: iconBoxes, pickable: false,
10169
10796
  getPosition: b => b.d.geometry.coordinates,
10170
10797
  getIcon: b => b.icon,
10171
10798
  // the icon is drawn at 2× with a stroke padding around the box
@@ -10185,7 +10812,9 @@
10185
10812
  ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d))
10186
10813
  : this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
10187
10814
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
10188
- 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))],
10189
10818
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
10190
10819
  sizeUnits: 'pixels',
10191
10820
  billboard: true,
@@ -10750,6 +11379,7 @@
10750
11379
  // - themes: getThemeObj (flat's theme object, with its data table and
10751
11380
  // the items on the map), getThemeDefinitionObj, getThemes,
10752
11381
  // changeThemeStyle / removeTheme / setBasemapOpacity (map name first),
11382
+ // setThemeTimeFrame (themeId, min, max),
10753
11383
  // refreshTheme, markThemeClass / unmarkThemeClass
10754
11384
  // - data: ixmaps.data (facets: getFacets, showFacets, …), the
10755
11385
  // embeddedSVG.window tables (setExternalData is with the data loading)
@@ -10761,6 +11391,29 @@
10761
11391
 
10762
11392
  // flat's page hook ixmaps.htmlgui_onNewTheme(szId), called when a theme
10763
11393
  // is created (maptheme.js 927) — with the theme's id (its name)
11394
+ // flat's tooltip for a theme without its own template (ui/js/tools/
11395
+ // tooltip_mustache.js 394)
11396
+ // tooltip_mustache.js 394) — the same parts, structured: the theme title
11397
+ // as h2, the item title as h3, the chart (tooltipTable) below
11398
+ // (inline styles throughout: a page's own h2 / table / td rules must not
11399
+ // restyle the tooltip)
11400
+ const FLAT_DEFAULT_TOOLTIP = "<h2 style='margin:0 0 0.15em;padding:0;font-size:1.25em;font-weight:600;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'>{{theme.title}}</h2>"
11401
+ + "{{#theme.item.title}}<h3 style='margin:0 0 0.4em;padding:0;font-size:1.1em;font-weight:600;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'>{{theme.item.title}}</h3>{{/theme.item.title}}"
11402
+ + "{{theme.item.chart}}";
11403
+ // {{theme.item.chart}} as a table: per row a color swatch (or none), the
11404
+ // label (left, one line) and the value (right-aligned)
11405
+ function tooltipTable(rows) {
11406
+ if (!rows.length) return '';
11407
+ const swatch = rgb => (rgb ? `<span style="display:inline-block;width:0.75em;height:0.75em;border-radius:50%;background:rgb(${rgb.slice(0, 3).join(',')})"></span>` : '');
11408
+ const td = 'border:none;background:transparent;font:inherit;color:inherit;line-height:1.15;';
11409
+ return '<table style="border-collapse:collapse;border:none;background:transparent;margin:0.2em 0;font-size:1em;line-height:1.15">'
11410
+ + rows.map(r => '<tr>'
11411
+ + `<td style="${td}padding:0.05em 0.4em 0.05em 0;vertical-align:middle">${swatch(r.rgb)}</td>`
11412
+ // one line: a long label ends in an ellipsis (full text as its title)
11413
+ + `<td style="${td}padding:0.05em 0.8em 0.05em 0;text-align:left"><span title="${escapeHtml(String(r.label).replace(/<[^>]*>/g, ''))}" style="display:inline-block;max-width:13em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:bottom">${r.label}</span></td>`
11414
+ + `<td style="${td}padding:0.05em 0;text-align:right;white-space:nowrap">${r.value}</td></tr>`).join('')
11415
+ + '</table>';
11416
+ }
10764
11417
  function themeIdOf(rt) {
10765
11418
  return (rt.style && rt.style.name) || (rt.meta && rt.meta.name) || rt.name;
10766
11419
  }
@@ -10774,7 +11427,7 @@
10774
11427
  // htmlgui_flat.js). Its methods act on the last built map; called while
10775
11428
  // the map is still building, they run once it is ready.
10776
11429
  const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
10777
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible'];
11430
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
10778
11431
  const _mapHandle = {};
10779
11432
  for (const m of MAP_HANDLE_METHODS) {
10780
11433
  _mapHandle[m] = (...args) => {
@@ -11001,6 +11654,7 @@
11001
11654
  const style = Object.assign({}, rt.style);
11002
11655
  if (rt.binding.size && !style.sizefield) style.sizefield = rt.binding.size;
11003
11656
  if (rt.binding.title && !style.titlefield) style.titlefield = rt.binding.title;
11657
+ if (rt.binding.time && !style.timefield) style.timefield = rt.binding.time;
11004
11658
  style.type = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
11005
11659
  const data = Object.assign({}, rt._definition && rt._definition.data);
11006
11660
  if (!data.name) data.name = themeDataName(rt);
@@ -11360,7 +12014,7 @@
11360
12014
  }
11361
12015
 
11362
12016
  global.ixmaps = {
11363
- layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
12017
+ layer, Layer: layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
11364
12018
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
11365
12019
  markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
11366
12020
  map: mapHandle, getZoom, getThemeDefinitionObj, embeddedSVG,
@@ -11375,8 +12029,14 @@
11375
12029
  // handle's methods on the last built map
11376
12030
  getCenter: () => (_lastMapApi && _lastMapApi.map ? _lastMapApi.map.getCenter() : null),
11377
12031
  getMapTypeId: () => (_lastMapApi && _lastMapApi.getMapTypeId ? _lastMapApi.getMapTypeId() : ''),
12032
+ // flat's attribution: ixmaps.setAttribution(text) and its htmlgui_ pair
12033
+ setAttribution: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
12034
+ htmlgui_setAttributionString: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
12035
+ htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
11378
12036
  changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
11379
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),
11380
12040
  setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
11381
12041
  // this engine's version (flat's own ixmaps.version numbers flat)
11382
12042
  glVersion: IXMAPS_GL_VERSION,
@@ -11401,7 +12061,7 @@
11401
12061
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
11402
12062
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
11403
12063
  global.__ixmapsGlInternals = {
11404
- IXMAPS_GL_VERSION, 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,
11405
12065
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
11406
12066
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
11407
12067
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,