ixmaps-gl 0.2.4 → 0.2.7

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 +14 -4
  2. package/ixmaps-gl.js +613 -49
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,9 +42,19 @@ page. It currently implements:
42
42
  - **CHART\|SYMBOL\|GLOW\|CATEGORICAL\|AGGREGATE\|COUNT\|RELOCATE\|VALUES** — the
43
43
  bubble-map pipeline: AGGREGATE on ixmaps-flat's grid (hexagonal, or square with
44
44
  `RECT`; cell width from `aggregation`/`gridwidth`/`gridwidthpx`, values summed per
45
- cell, class breaks and legend from the aggregated cells), dynamic sizing, glow,
45
+ cell, class breaks and legend from the aggregated cells), dynamic sizing, glow (between
46
+ `glowlower` and `glowupper`),
46
47
  multi-point grouping, on-bubble value labels, `NORMALIZE`, chart boxes (`BOX`, `CIRCULARBOX`)
47
48
  with the item title above or below (`TITLE`, `BOTTOMTITLE`), between `boxlower` and `boxupper`
49
+ - **CHART\|LABEL** — flat's value label: a rounded box sized by the bubble radius of the size value
50
+ in the item color, with `VALUES` the value in bold; with `TEXTONLY` only the text, over a halo of
51
+ the theme color
52
+ - Chart values (`VALUES`, labels) as flat prints them: the `valuefield` value, flat's number format
53
+ (thousands with spaces, `valuedecimals`, years without breaks), `units` after a space
54
+ - `align` (`left`, `2left`, `right`, `above`, `below`, …) and `offsetx`/`offsety` move a whole
55
+ chart — symbol, glow, shadow, box, title and value — as flat does
56
+ - A range theme of at most two classes gets flat's one-row legend (the value range, or for a
57
+ `$item$` count the label or field name)
48
58
  - **CHART\|USER** — a page's own chart function (`style.userdraw`: `ixmaps.<name>(SVGDocument, opt)`
49
59
  and its `_init`, as flat calls them), drawn in flat's chart units and shown as an icon at flat's size
50
60
  - **DOT** — the simplest base symbol (fixed-radius, unclustered points)
@@ -193,7 +203,7 @@ of your own project files gl can render.
193
203
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
194
204
 
195
205
  ```html
196
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.4/ixmaps-gl.js"></script>
206
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.7/ixmaps-gl.js"></script>
197
207
  ```
198
208
 
199
209
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -201,13 +211,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
201
211
  browser script, which defines the global `ixmaps`):
202
212
 
203
213
  ```html
204
- <script src="https://unpkg.com/ixmaps-gl@0.2.4/ixmaps-gl.js"></script>
214
+ <script src="https://unpkg.com/ixmaps-gl@0.2.7/ixmaps-gl.js"></script>
205
215
  ```
206
216
 
207
217
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
208
218
 
209
219
  ```html
210
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.4/ixmaps-gl.js"></script>
220
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.7/ixmaps-gl.js"></script>
211
221
  <div id="map_div" style="position:absolute;inset:0;"></div>
212
222
 
213
223
  <script>
package/ixmaps-gl.js CHANGED
@@ -47,7 +47,8 @@
47
47
  // binding.lookup — see joinChoroplethFeatures. HEADTAIL breaks are flat's
48
48
  // (headTailBreaks); LOG/POW2/POW3 are recognized type flags but not
49
49
  // implemented, see KNOWN_INERT_FLAGS. CHART|USER draws a page's own chart
50
- // function (style.userdraw, _buildUserChartLayers). These
50
+ // function (style.userdraw, _buildUserChartLayers), CHART|LABEL flat's
51
+ // value label, boxed or TEXTONLY (_buildLabelChartLayers). These
51
52
  // base types are dispatched by buildDeckLayers in the real engine's own
52
53
  // precedence order — DOT is checked first because the real engine's DOT
53
54
  // bypasses the whole drawChart/modifier pipeline rather than being
@@ -80,7 +81,7 @@
80
81
  // ---------------------------------------------------------------
81
82
  // this engine's release (= package.json "version", checked by
82
83
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
83
- const IXMAPS_GL_VERSION = '0.2.4';
84
+ const IXMAPS_GL_VERSION = '0.2.7';
84
85
 
85
86
  const LIB_URLS = {
86
87
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -306,7 +307,7 @@
306
307
  // crafted link must not be able to load arbitrary code into the page.
307
308
  // ---------------------------------------------------------------
308
309
  // v0.1.6: knows this engine's aliases, lookupfield rule, meta↔style, builder methods, project loading
309
- const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.21/dist/validate.mjs';
310
+ const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.23/dist/validate.mjs';
310
311
  // a page may set ixmaps.validate BEFORE loading this script — the
311
312
  // `global.ixmaps = {...}` export below would otherwise overwrite it
312
313
  const _preloadValidate = global.ixmaps && global.ixmaps.validate;
@@ -1533,11 +1534,137 @@
1533
1534
  // - '2narrow'/'3narrow', '2wide'/'3wide': cumulative-step variants.
1534
1535
  // Channels are FLOORED, as the source's own hex encoding does
1535
1536
  // (hh.charAt(Math.floor(v / 16)) + hh.charAt(v % 16) truncates the
1536
- // fraction), and clamped to 0-255. Flat's named palettes (spectrum,
1537
- // viridis, tableau, ...) are not ported: null, the caller's fallback.
1537
+ // fraction), and clamped to 0-255. The named palettes are
1538
+ // flatPaletteScheme's; spectrum/spectral (flat's colour-wheel generator)
1539
+ // is not ported: null, the caller's fallback.
1538
1540
  const FLAT_PALETTE_SCHEMES = /^(spectrum|spectral|office|mineral|minaral|pastel|harvest|fruit|kmeansp?|pimp|intense|fluo|tableau(10|20)?|viridis|plasma|magma)$/i;
1539
1541
  const FLAT_SWEEP_MODES = ['auto', 'linear', 'dynamic', '2colors', '2wide', '2narrow', '2low', '2high', '3colors', '3wide', '3narrow', '3low', '3high'];
1540
1542
 
1543
+ // flat's named palettes (colorscheme.js 832-1177, 1197-1268), the tables
1544
+ // copied from flat's source: ["N", "tableau", offset] takes N colors of the
1545
+ // palette from offset on, repeating it cyclically
1546
+ // (_circ_createPaletteColorScheme); viridis / plasma / magma are 32-stop
1547
+ // sequential maps resampled to N stops with linear interpolation
1548
+ // (_circ_createSequentialPaletteColorScheme). flat matches each name in
1549
+ // three spellings only — lower, UPPER, Title ("Minaral" for mineral).
1550
+ const FLAT_QUALITATIVE_PALETTES = {
1551
+ office: [
1552
+ '#9999FF', '#993366', '#FFFFCC', '#CCFFFF', '#660066', '#FF8080', '#0066CC', '#CCCCFF',
1553
+ '#000080', '#FF00FF', '#FFFF00', '#00FFFF', '#800080', '#800000', '#008080', '#0000FF',
1554
+ '#00CCFF', '#CCFFFF', '#CCFFCC', '#FFFF99', '#99CCFF', '#FF99CC', '#CC99FF', '#FFCC99'
1555
+ ],
1556
+ mineral: [
1557
+ '#F3898B', '#7BFECD', '#B3B07B', '#49BA85', '#FEDBFE', '#847FBA', '#FEA869', '#17BCC4',
1558
+ '#DC686D', '#28803C', '#FFFF00', '#C09B43', '#746FC0', '#9C9C9C', '#EDFEA5', '#0000FF',
1559
+ '#00E04D', '#86A9CE', '#B37B9D', '#9FD8B3', '#FEB676', '#C09671', '#87CFFE', '#00A7C7'
1560
+ ],
1561
+ pastel: [
1562
+ '#D2D2D2', '#9DC0C0', '#DFC7AA', '#A1D197', '#E2A6A6', '#CBA6CB', '#FEA4A4', '#A8ACD1',
1563
+ '#C8D89A', '#F3C4D8', '#E9E15E', '#EEEEEE', '#C0AB79', '#E2E17F', '#B4E1FE', '#E8DDFE',
1564
+ '#E1FEEB', '#FEF782', '#C3FFC3', '#CEFE87', '#8CFEB3', '#D2D2D2', '#9DC0C0', '#DFC7AA'
1565
+ ],
1566
+ harvest: [
1567
+ '#C06549', '#FFD700', '#BDB76B', '#F7B567', '#CEC395', '#CD9B1D', '#F0E68C', '#A7AF5E',
1568
+ '#C09058', '#8B4513', '#AC96AC', '#698B69', '#8B6914', '#8B8B00', '#FFFBC3', '#BDB056',
1569
+ '#DCCEDB', '#FEF782', '#FEEAC6', '#FFC7AE', '#A6B655', '#DB6700', '#E5A100', '#F7D3B3'
1570
+ ],
1571
+ fruit: [
1572
+ '#1F77B4', '#AEC7E8', '#FF7F0E', '#FFBB78', '#2CA02C', '#99DF8B', '#D62728', '#FF9896',
1573
+ '#966ABE', '#C5B0D5', '#8C564B', '#C49C94', '#E377C2', '#F7B6D2', '#7E7E7E', '#C7C7C7',
1574
+ '#BCBD22', '#DBDB8D', '#18BECF', '#9EDAE5', '#1F77B4', '#AEC7E8'
1575
+ ],
1576
+ kmeans: [
1577
+ '#c17cd3', '#91c15d', '#6a70d7', '#bab440', '#513688', '#5dc67f', '#993888', '#37d8b0',
1578
+ '#e3586f', '#36dee6', '#d66044', '#47b795', '#ab396c', '#568429', '#e07db5', '#3c7c3d',
1579
+ '#628bd5', '#cb8832', '#ad4258', '#a2863e', '#ad4248', '#9c4629'
1580
+ ],
1581
+ kmeansp: [
1582
+ '#ffd1b2', '#a5b2e9', '#effcc2', '#e1c3f8', '#acc692', '#d899b7', '#bbfdd9', '#e8a197',
1583
+ '#8ceceb', '#fab9a0', '#6dc5b7', '#ffc0bc', '#66b8bd', '#dcc992', '#7db3c8', '#fffedb',
1584
+ '#ffdfff', '#84b5ac', '#ffebf2', '#a2aead', '#e1f3ff', '#b5e6ff'
1585
+ ],
1586
+ pimp: [
1587
+ '#b09234', '#5f3dc1', '#4ca735', '#b54ade', '#7e9a36', '#cb43b3', '#4a9f61', '#d93f76',
1588
+ '#3a9e88', '#da4631', '#7876dc', '#d57a29', '#5e90cd', '#814b1b', '#554f95', '#396829',
1589
+ '#c275ba', '#75702e', '#89376c', '#bf7f51', '#923432', '#d37075'
1590
+ ],
1591
+ intense: [
1592
+ '#c98ab6', '#5bbb42', '#6035bd', '#9dac3c', '#c450da', '#66b888', '#d84fa9', '#44733a',
1593
+ '#7370d7', '#d19231', '#443672', '#db4d32', '#4ca5b0', '#d5466d', '#829fdb', '#8b3b26',
1594
+ '#506793', '#a69358', '#873987', '#4f4b21', '#d58873', '#79354c'
1595
+ ],
1596
+ fluo: [
1597
+ '#ecd730', '#4ddded', '#c0ee32', '#f6ac8d', '#64ea51', '#eebd5e', '#5be9c8', '#d9db55',
1598
+ '#77e7a1', '#c6e552', '#8ac793', '#95e354', '#a5e3ad', '#dff782', '#67ee8b', '#e2d680',
1599
+ '#a5e47d', '#b0d490', '#9fc658', '#d1f5a5', '#b4db6c', '#c1d271'
1600
+ ],
1601
+ tableau: [
1602
+ '#4e79a7', '#a0cbe8', '#f28e2b', '#ffbe7d', '#59a14f', '#8cd17d', '#b6992d', '#f1ce63',
1603
+ '#499894', '#86bcb6', '#e15759', '#ff9d9a', '#79706e', '#bab0ac', '#d37295', '#fabfd2',
1604
+ '#b07aa1', '#d4a6c8', '#9d7660', '#d7b5a6'
1605
+ ],
1606
+ tableau10: [
1607
+ '#1F77B4', '#FF7F0E', '#2CA02C', '#D62728', '#9467BD', '#8C564B', '#E377C2', '#7F7F7F',
1608
+ '#BCBD22', '#17BECF', '#1F77B4', '#FF7F0E', '#2CA02C', '#D62728', '#9467BD', '#8C564B',
1609
+ '#E377C2', '#7F7F7F', '#BCBD22', '#17BECF'
1610
+ ],
1611
+ tableau20: [
1612
+ '#1F77B4', '#AEC7E8', '#FF7F0E', '#FFBB78', '#2CA02C', '#98DF8A', '#D62728', '#FF9896',
1613
+ '#9467BD', '#C5B0D5', '#8C564B', '#C49C94', '#E377C2', '#F7B6D2', '#7F7F7F', '#C7C7C7',
1614
+ '#BCBD22', '#DBDB8D', '#17BECF', '#9EDAE5'
1615
+ ]
1616
+ };
1617
+ const FLAT_SEQUENTIAL_PALETTES = {
1618
+ viridis: [
1619
+ '#440154', '#470D60', '#48186A', '#482475', '#472E7C', '#453882', '#424186', '#3E4C8A',
1620
+ '#3A548C', '#365D8D', '#32658E', '#2E6D8E', '#2B758E', '#287D8E', '#25848E', '#228C8D',
1621
+ '#1F948C', '#1E9C89', '#20A386', '#25AB82', '#2EB37C', '#3ABA76', '#48C16E', '#58C765',
1622
+ '#69CD5B', '#7FD34E', '#93D741', '#A8DB34', '#BDDF26', '#D5E21A', '#EAE51A', '#FDE725'
1623
+ ],
1624
+ plasma: [
1625
+ '#0D0887', '#220690', '#310597', '#41049D', '#4E02A2', '#5B01A5', '#6700A8', '#7501A8',
1626
+ '#8104A7', '#8D0BA5', '#9814A0', '#A21D9A', '#AD2793', '#B6308B', '#BF3984', '#C7427C',
1627
+ '#CF4C74', '#D6556D', '#DD5E66', '#E3685F', '#E97257', '#EF7C51', '#F3874A', '#F79143',
1628
+ '#FA9C3C', '#FCA934', '#FDB52E', '#FDC229', '#FCCE25', '#F9DD25', '#F5EB27', '#F0F921'
1629
+ ],
1630
+ magma: [
1631
+ '#000004', '#030312', '#0A0822', '#140E36', '#1E1149', '#2A115C', '#38106C', '#471078',
1632
+ '#54137D', '#601880', '#6D1D81', '#792282', '#882781', '#942C80', '#A1307E', '#AE347B',
1633
+ '#BD3977', '#CA3E72', '#D6456C', '#E24D66', '#EC5860', '#F3655C', '#F8745C', '#FB835F',
1634
+ '#FD9266', '#FEA36F', '#FEB27A', '#FEC185', '#FECF92', '#FDE0A1', '#FCEEB0', '#FCFDBF'
1635
+ ]
1636
+ };
1637
+ const FLAT_PALETTE_NAMES = (() => {
1638
+ const names = {};
1639
+ for (const key of [...Object.keys(FLAT_QUALITATIVE_PALETTES), ...Object.keys(FLAT_SEQUENTIAL_PALETTES)]) {
1640
+ names[key] = key; names[key.toUpperCase()] = key;
1641
+ names[key === 'mineral' ? 'Minaral' : key[0].toUpperCase() + key.slice(1)] = key;
1642
+ }
1643
+ return names;
1644
+ })();
1645
+ const hexRgb = h => [parseInt(h.substr(1, 2), 16), parseInt(h.substr(3, 2), 16), parseInt(h.substr(5, 2), 16)];
1646
+ function flatPaletteScheme(name, nColors, offset) {
1647
+ const key = FLAT_PALETTE_NAMES[name];
1648
+ if (!key) return null;
1649
+ nColors = Math.max(1, Number(nColors) || 1);
1650
+ const seq = FLAT_SEQUENTIAL_PALETTES[key];
1651
+ if (seq) {
1652
+ const out = [];
1653
+ for (let i = 0; i < nColors; i++) {
1654
+ const pos = (nColors < 2 ? 0 : i / (nColors - 1)) * (seq.length - 1);
1655
+ const i0 = Math.floor(pos), i1 = Math.min(seq.length - 1, i0 + 1), f = pos - i0;
1656
+ const a = hexRgb(seq[i0]), b = hexRgb(seq[i1]);
1657
+ out.push(a.map((v, k) => Math.round(v + (b[k] - v) * f)));
1658
+ }
1659
+ return out;
1660
+ }
1661
+ const colors = FLAT_QUALITATIVE_PALETTES[key];
1662
+ const off = Number(offset) || 0;
1663
+ const out = [];
1664
+ for (let i = 0; i < nColors; i++) out.push(hexRgb(colors[(off + i) % colors.length]));
1665
+ return out;
1666
+ }
1667
+
1541
1668
  function isGeneratedColorScheme(colorscheme) {
1542
1669
  return Array.isArray(colorscheme) && colorscheme.length > 0 && !isNaN(Number(colorscheme[0])) && String(colorscheme[0]).trim() !== '';
1543
1670
  }
@@ -1549,6 +1676,7 @@
1549
1676
  }
1550
1677
 
1551
1678
  function flatColorSweep(cc1, cc2, nSteps, nParam1, nParam2) {
1679
+ if (typeof cc1 === 'string' && FLAT_PALETTE_NAMES[cc1]) return flatPaletteScheme(cc1, nSteps, cc2);
1552
1680
  if (typeof cc1 === 'string' && FLAT_PALETTE_SCHEMES.test(cc1)) return null;
1553
1681
  nSteps = Number(nSteps);
1554
1682
  const [r1, g1, b1] = flatColorRgb(cc1, [255, 255, 255]);
@@ -2066,7 +2194,7 @@
2066
2194
  // edit it by hand; `npm run unit` fails when it drifts.
2067
2195
  // ---------------------------------------------------------------
2068
2196
  // <grammar:binding-aliases>
2069
- // generated from ixmaps-grammar 0.1.21 (ixmaps-flat 1.0.41, eaaf2b7 2026-09-20) — 52 aliases
2197
+ // generated from ixmaps-grammar 0.1.23 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2070
2198
  const FLAT_BINDING_ALIASES = {
2071
2199
  "aggregation": "style.aggregationfield",
2072
2200
  "aggregationfield": "style.aggregationfield",
@@ -2128,7 +2256,7 @@
2128
2256
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2129
2257
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2130
2258
  // <grammar:meta-keys>
2131
- // generated from ixmaps-grammar 0.1.21 — 5 meta keys
2259
+ // generated from ixmaps-grammar 0.1.23 — 5 meta keys
2132
2260
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2133
2261
  // </grammar:meta-keys>
2134
2262
 
@@ -2312,7 +2440,7 @@
2312
2440
  const STYLE_NUMBER_KEYS = ['linewidth', 'fillopacity', 'scale', 'classes', 'valuedecimals', 'normalsizevalue',
2313
2441
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
2314
2442
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
2315
- 'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow'];
2443
+ 'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety'];
2316
2444
  function toNumberIfNumeric(v) {
2317
2445
  if (typeof v !== 'string' || !v.trim()) return v;
2318
2446
  const n = Number(v);
@@ -2914,7 +3042,7 @@
2914
3042
  // no-op.
2915
3043
  const base = layerId.replace(/-g\d+$/, '');
2916
3044
  return runtimes.find(r => {
2917
- if (base === `ix-bubbles-${r.name}`) return r.flags.has('CHART') && (r.flags.has('SYMBOL') || r.flags.has('USER'));
3045
+ if (base === `ix-bubbles-${r.name}`) return isSymbolChart(r.flags);
2918
3046
  if (base === `ix-dot-${r.name}`) return r.flags.has('DOT');
2919
3047
  if (base === `ix-choropleth-${r.name}`) return r.flags.has('CHOROPLETH');
2920
3048
  if (base === `ix-features-${r.name}`) return r.flags.has('FEATURE') || r.flags.has('FEATURES');
@@ -4509,6 +4637,36 @@
4509
4637
  // form: SPACE there is flat's plain "." thousands.
4510
4638
  const flatCompactLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
4511
4639
  && (rt.partsA || []).length >= 5 && !(Array.isArray(rt.style.label) && rt.style.label.length);
4640
+ // flat's single-color legend (legend.js 765-769, 1039-1046): a range
4641
+ // theme of at most 2 classes without label/ranges, not
4642
+ // CATEGORICAL, is one row — the swatch and "min ... max unit",
4643
+ // or, when every value is 1 (a "$item$" count), its label or
4644
+ // value field
4645
+ const flatSingleRowLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
4646
+ && (rt.partsA || []).length <= 2 && !(Array.isArray(rt.style.label) && rt.style.label.length)
4647
+ && !(Array.isArray(rt.style.ranges) && rt.style.ranges.length);
4648
+ if (flatSingleRowLegend) {
4649
+ renderRows = function() {
4650
+ const rgb = rt.categoryColorsRgb[0] || [128, 128, 128];
4651
+ const unit = String(rt.style.legendunits || rt.style.units || '').replace(/ /g, '&nbsp;');
4652
+ const vMin = styleNum(rt.style.minvalue) || rt._valueMin, vMax = styleNum(rt.style.maxvalue) || rt._valueMax;
4653
+ const label = rt.style.label != null && !Array.isArray(rt.style.label) ? String(rt.style.label) : String(rt.binding.value || '');
4654
+ const text = vMin !== 1 || vMax !== 1
4655
+ ? flatFormatValue(vMin, 2, 'BLANK') + ' &nbsp;... ' + flatFormatValue(vMax, 2, 'BLANK') + ' ' + unit
4656
+ : label;
4657
+ const marked = rt._markedClasses.has(0);
4658
+ rowsEl.innerHTML = '<div class="ix-legend-row" data-idx="0" style="display:flex;align-items:center;gap:5px;padding:4px 3px;cursor:pointer;border-radius:4px;'
4659
+ + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';">'
4660
+ + '<span style="flex:0 0 auto;width:1.6em;height:0.8em;background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');"></span>'
4661
+ + '<span>' + text + '</span></div>';
4662
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
4663
+ row.addEventListener('click', () => {
4664
+ if (rt._markedClasses.has(0)) window.ixmaps.unmarkThemeClass(rt.name, 0);
4665
+ else window.ixmaps.markThemeClass(rt.name, 0);
4666
+ });
4667
+ });
4668
+ };
4669
+ } else
4512
4670
  if (flatCompactLegend) {
4513
4671
  renderRows = function() {
4514
4672
  const n = rt.categoryColorsRgb.length;
@@ -5432,26 +5590,6 @@
5432
5590
  }, 0);
5433
5591
  }
5434
5592
 
5435
- function roundBubbleValue(value, style) {
5436
- const parsedDecimals = styleNum(style.valuedecimals);
5437
- const decimals = !isNaN(parsedDecimals) ? parsedDecimals : (value < 1 ? 1 : 0);
5438
- return decimals > 0 ? value.toFixed(decimals) : String(Math.round(value));
5439
- }
5440
-
5441
- // DIFFERENCE, RELATIVE and SIGN: a positive value gets "+", zero "+/-"
5442
- // (maptheme.js 20990-21017, "make a positive sign, if positive value is
5443
- // result of a diff operation")
5444
- function formatBubbleValue(value, style, flags) {
5445
- const rounded = roundBubbleValue(value, style);
5446
- const unit = style.units || '';
5447
- const text = unit.length && unit.length <= 5 ? rounded + unit : rounded;
5448
- if (flags && (flags.has('DIFFERENCE') || flags.has('RELATIVE') || flags.has('SIGN'))) {
5449
- if (value === 0) return '+/-' + text;
5450
- if (value > 0) return '+' + text;
5451
- }
5452
- return text;
5453
- }
5454
-
5455
5593
  // Records behind one drawn chart item: a group's per-category record
5456
5594
  // counts summed, else a Supercluster cluster's point_count, else 1.
5457
5595
  // recordCounts only exists on RELOCATE groups (groupCoLocated); a plain
@@ -5466,6 +5604,130 @@
5466
5604
  return Math.min(radiusPx * 0.8, radiusPx * (3.3 / Math.max(1, text.length))) * (valueScale || 1);
5467
5605
  }
5468
5606
 
5607
+ // the themes drawn by the symbol chart pipeline (_buildChartLayers): flat's
5608
+ // BUBBLE/SQUARE/LABEL branch (maptheme.js 20647-20652), symbols, user charts
5609
+ function isSymbolChart(flags) {
5610
+ return flags.has('CHART') && (flags.has('SYMBOL') || flags.has('USER') || flags.has('LABEL'));
5611
+ }
5612
+
5613
+ // flat's __formatValue (mapscript.js 6268-6365) as maptheme's formatValue
5614
+ // calls it: 0 and values beyond ±10^12 as they are, else rounded twice
5615
+ // (one decimal more, then the precision — 25.446 → 25.45 → 25.5),
5616
+ // thousands grouped with spaces unless noBreaks (formatValue's NOBREAKS
5617
+ // for a theme of values within 1000-3000, i.e. years: maptheme.js 26364)
5618
+ function flatGroupedValue(value, decimals, noBreaks) {
5619
+ if (!isFinite(value)) return String(value);
5620
+ if (value === 0) return '0';
5621
+ if (value > 1e12 || value < -1e12) return String(value);
5622
+ const clip = Math.pow(10, decimals);
5623
+ const flatRounded = Math.round(Number(value.toFixed(decimals + 1)) * clip) / clip;
5624
+ const [intPart, dec] = flatRounded.toFixed(decimals).split('.');
5625
+ const grouped = noBreaks ? intPart : intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
5626
+ return dec ? `${grouped}.${dec}` : grouped;
5627
+ }
5628
+ // maptheme.js formatValue's NOBREAKS switch (26368): theme min > 999 and max < 3000
5629
+ function flatNoBreaks(vMin, vMax) {
5630
+ return vMin > 999 && vMax < 3000;
5631
+ }
5632
+
5633
+ // flat's chart position (maptheme.js 24441-24530 and 24603, the non-PLOT,
5634
+ // non-grid case), measured on flat: the chart group is moved by −ptNull.
5635
+ // ptNull = (offsetx, −offsety) · unit · symbolScale — unit is flat's
5636
+ // normalX(1) on screen (object zoom × style.scale), symbolScale the
5637
+ // SYMBOL branch's r / chart size (21993), 1 for BUBBLE/LABEL
5638
+ // then align overwrites/adds: o is the chart's own ptNull.y as its
5639
+ // branch left it (ptNullOrig — BUBBLE/LABEL and SYMBOL: r + 5 units,
5640
+ // TEXTONLY: r; 21111, 23204), h half the chart size (BUBBLE/LABEL:
5641
+ // the item's radius, SYMBOL: the theme's normal radius)
5642
+ // So "left" puts the chart's left edge o to the right of the point (and
5643
+ // drops offsetx), "2left" one more h. Returns deck.gl's pixel offset.
5644
+ function flatChartAlignOffset(style, g) {
5645
+ const num = v => (isNaN(styleNum(v)) ? 0 : styleNum(v));
5646
+ const sym = g.symbolScale || 1;
5647
+ let x = num(style.offsetx) * g.unit * sym, y = -num(style.offsety) * g.unit * sym;
5648
+ const a = String(style.align || '');
5649
+ if (a) {
5650
+ const h = g.half, o = g.orig;
5651
+ if (/center/.test(a)) y = 0;
5652
+ if (/bottom/.test(a)) y = o || h * 2 / 5;
5653
+ if (/above/.test(a)) y = o || h;
5654
+ if (/2above/.test(a)) y += h;
5655
+ if (/below/.test(a)) y = -(o || h);
5656
+ if (/2below/.test(a)) y -= h;
5657
+ if (/top/.test(a)) y = -(o || h);
5658
+ if (/left/.test(a)) x = -(o || h);
5659
+ if (/2left/.test(a)) x -= h;
5660
+ if (/right/.test(a)) x = o || h;
5661
+ if (/2right/.test(a)) x += h;
5662
+ if (/23right/.test(a)) x = h * 2 / 3;
5663
+ if (/baseline/.test(a)) y += h * 2 / 5;
5664
+ }
5665
+ return [-x || 0, -y || 0];
5666
+ }
5667
+ // which of flat's chart branches draws a symbol-pipeline theme: BUBBLE /
5668
+ // SQUARE / LABEL (maptheme.js 20647) come before SYMBOL (21122)
5669
+ function flatChartBranch(flags) {
5670
+ const t = flags.typeString != null ? flags.typeString : [...flags].join('|');
5671
+ return /BUBBLE|SQUARE|LABEL/.test(t) ? 'bubble' : 'symbol';
5672
+ }
5673
+
5674
+ // flat's ColorScheme.getDerivateColor (colorscheme.js 46-75): each
5675
+ // channel × f, floored, capped at 255; brightening (f > 1) first lifts
5676
+ // every channel to at least 90 and, with one at 250 or more, uses
5677
+ // max(0.9 f, 1.1)
5678
+ function flatDerivateRgb(rgb, f) {
5679
+ let [r, g, b] = rgb;
5680
+ if (f > 1) {
5681
+ r = Math.max(r, 90); g = Math.max(g, 90); b = Math.max(b, 90);
5682
+ if (r >= 250 || g >= 250 || b >= 250) f = Math.max(f * 0.9, 1.1);
5683
+ }
5684
+ return [r, g, b].map(c => Math.min(255, Math.floor(c * f)));
5685
+ }
5686
+ // ChartColors.textColor (colorscheme.js 104-116): darker on light colors
5687
+ function flatChartTextRgb(rgb) {
5688
+ return flatDerivateRgb(rgb, rgb[0] + rgb[1] + rgb[2] > 450 ? 0.6 : 3);
5689
+ }
5690
+
5691
+ // a chart's VALUES text (maptheme.js 20979-21017): an explicit valuefield
5692
+ // prints that field of the record ($title$: the title; a non-number as
5693
+ // is), else the value; numbers formatted with valuedecimals (flat's `||`:
5694
+ // 0 or unset → 1 decimal below 1 — or when the max value is at most 1 —
5695
+ // else none) plus the unit, which flat stores with a leading space unless
5696
+ // it starts with "." (9675) and appends only up to 5 characters.
5697
+ // A CATEGORICAL bubble prints its size value when size is bound to a
5698
+ // field (its value, see resolveAggregateValue), as flat does; with
5699
+ // valuefield set to its categorical field, the class name
5700
+ // (_categoryValueRecord for an aggregated part).
5701
+ // pure: the record an aggregated part of category i stands for in its
5702
+ // value text — with style.valuefield the theme's CATEGORICAL field, flat
5703
+ // prints the class name (the records' own valuefield value); else none
5704
+ function categoryValueRecord(style, binding, flags, labels, i) {
5705
+ const field = style.valuefield;
5706
+ if (!field || field !== binding.value || !flags.has('CATEGORICAL') || !labels || labels[i] == null) return null;
5707
+ return { [field]: labels[i] };
5708
+ }
5709
+ function flatValueText(raw, title, value, style, flags, opts = {}) {
5710
+ const units = style.units ? String(style.units) : '';
5711
+ const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
5712
+ const unitText = unit.length <= 5 ? unit : '';
5713
+ const dec = v => styleNum(style.valuedecimals) || ((v < 1 || (opts.maxValue != null && opts.maxValue <= 1)) ? 1 : 0);
5714
+ const signed = (v, text) => {
5715
+ if (flags && (flags.has('DIFFERENCE') || flags.has('RELATIVE') || flags.has('SIGN'))) {
5716
+ if (v === 0) return '+/-' + text;
5717
+ if (v > 0) return '+' + text;
5718
+ }
5719
+ return text;
5720
+ };
5721
+ const field = style.valuefield;
5722
+ if (field === '$title$') return title != null ? String(title) : '';
5723
+ if (field && raw && raw[field] != null && raw[field] !== '') {
5724
+ if (isNaN(raw[field])) return String(raw[field]);
5725
+ const v = Number(raw[field]);
5726
+ return signed(v, flatGroupedValue(v, styleNum(style.valuedecimals) || (v < 1 ? 1 : 0), opts.noBreaks) + unitText);
5727
+ }
5728
+ return signed(value, flatGroupedValue(value, dec(value), opts.noBreaks) + unitText);
5729
+ }
5730
+
5469
5731
  // .style({valueupper: "1:200000"}) — the real engine's fHideValues gate
5470
5732
  // (maptheme.js ~line 16969/19542): value LABELS (not the whole layer,
5471
5733
  // that's clipupper, out of scope) hide once the map is zoomed out past
@@ -5495,6 +5757,16 @@
5495
5757
  const scale = scaleDenominatorAt(zoom);
5496
5758
  return !!((upper && scale > upper) || (lower && scale <= lower));
5497
5759
  }
5760
+ // .style({glowupper, glowlower}) — flat's glow gate (maptheme.js
5761
+ // 1726-1734, 16986-16990): the GLOW halo is drawn at scales at or below
5762
+ // glowupper and at or above glowlower (both inclusive)
5763
+ function glowHiddenByScale(style, zoom) {
5764
+ const denom = v => (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5765
+ const upper = denom(style.glowupper), lower = denom(style.glowlower);
5766
+ if (!upper && !lower) return false;
5767
+ const scale = scaleDenominatorAt(zoom);
5768
+ return !!((upper && scale > upper) || (lower && scale < lower));
5769
+ }
5498
5770
  function themeHiddenByScale(flags, style, zoom) {
5499
5771
  return (flags.has('FEATURE') || flags.has('FEATURES')) ? featuresHiddenByScale(style, zoom) : chartHiddenByScale(style, zoom);
5500
5772
  }
@@ -6646,6 +6918,8 @@
6646
6918
  const fields = String(binding.value).split('|');
6647
6919
  const valueOf = flags && flags.has('DIFFERENCE') && fields.length > 1
6648
6920
  ? p => cellAggregatedValues({ sums: fields.map(k => parseFloat(p[k]) || 0), counts: fields.map(() => 1) }, flags, style)[0]
6921
+ // "$item$" is flat's record count: 1 per record
6922
+ : binding.value === '$item$' ? () => 1
6649
6923
  : p => (p && Object.keys(p).length ? flatNumber(p[binding.value], flags) : NaN);
6650
6924
  const values = features
6651
6925
  .map(f => valueOf(f.properties))
@@ -7361,7 +7635,8 @@
7361
7635
  properties: {
7362
7636
  value: this._seriesDifference ? seriesValue(f.properties) : this._resolveAggregateValue(f.properties),
7363
7637
  ...(this._seriesDifference ? { series: seriesOf(f.properties) } : {}),
7364
- ...(this._classSeparate ? { classValue: parseFloat(f.properties[this.binding.value]) || 0 } : {}),
7638
+ // "$item$": flat's record count, 1 per record
7639
+ ...(this._classSeparate ? { classValue: this.binding.value === '$item$' ? 1 : parseFloat(f.properties[this.binding.value]) || 0 } : {}),
7365
7640
  ...(this._aggregateField100 ? { value100: parseFloat(f.properties[f100]) || 0 } : {}),
7366
7641
  raw: f.properties
7367
7642
  }
@@ -7476,7 +7751,7 @@
7476
7751
  _dataLayerId(base) { return this._dataGen ? `${base}-d${this._dataGen}` : base; }
7477
7752
 
7478
7753
  _usesAggregationIndex() {
7479
- return (this.flags.has('AGGREGATE') || (this.flags.has('CHART') && (this.flags.has('SYMBOL') || this.flags.has('USER')))) && !this.flags.has('GRIDSIZE');
7754
+ return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !this.flags.has('GRIDSIZE');
7480
7755
  }
7481
7756
 
7482
7757
  _rebuildActiveFeatures() {
@@ -7707,6 +7982,35 @@
7707
7982
  return this._glowIconCache.get(key);
7708
7983
  }
7709
7984
 
7985
+ // SHADOW on chart symbols — flat's chart drop shadow (maptheme.js 17097-17160, 18709-18749):
7986
+ // an SVG filter that blurs the symbol by nShadowBlur (3) REAL screen pixels, offsets it by
7987
+ // nShadowDx/Dy (1.5/2.5 px) and darkens it to 0.1 × its color at the source alpha. Here: a
7988
+ // blurred disc as a MASK icon (tinted per item via getColor), one per size step, so the blur
7989
+ // stays ~blurPx on screen whatever the symbol radius (a single scaled icon would blur big
7990
+ // bubbles more). The disc spans 2/3 of the canvas: the rest is room for the blur.
7991
+ // `shape`: the symbol's own (drawSymbolPath, as _buildSingleIcon) — flat's filter shadows
7992
+ // whatever the symbol is, e.g. a RECT square
7993
+ _getShadowIcon(radiusPx, blurPx, shape) {
7994
+ shape = shape || 'circle';
7995
+ const step = Math.max(0, Math.min(7, Math.round(Math.log2(Math.max(1, radiusPx)))));
7996
+ const key = `shadow-${shape}-${step}-${blurPx}`;
7997
+ if (!this._glowIconCache.has(key)) {
7998
+ const size = 96, disc = 32, c = size / 2;
7999
+ const sigma = Math.max(0.5, Math.min(12, blurPx * disc / Math.pow(2, step)));
8000
+ const canvas = document.createElement('canvas');
8001
+ canvas.width = size; canvas.height = size;
8002
+ const ctx = canvas.getContext('2d');
8003
+ // the disc itself is drawn off-canvas; only its blurred shadow lands at the center
8004
+ ctx.shadowColor = 'rgba(255,255,255,1)';
8005
+ ctx.shadowBlur = sigma * 2;
8006
+ ctx.shadowOffsetX = size * 4;
8007
+ ctx.fillStyle = '#fff';
8008
+ ctx.beginPath(); drawSymbolPath(ctx, shape, c - size * 4, c, disc); ctx.closePath(); ctx.fill();
8009
+ this._glowIconCache.set(key, { url: canvas.toDataURL(), width: size, height: size, anchorX: c, anchorY: c, mask: true, id: key });
8010
+ }
8011
+ return this._glowIconCache.get(key);
8012
+ }
8013
+
7710
8014
  _buildBubbleIcon(counts, colors) {
7711
8015
  // The cache key quantizes each count's RATIO to the group's total,
7712
8016
  // not the raw count — computeBubblePackLayout's radii are purely
@@ -8022,11 +8326,10 @@
8022
8326
  // flat's __formatValue (mapscript.js 6268ff) rounds twice: first to
8023
8327
  // one decimal more (toFixed), then to the precision (Math.round) —
8024
8328
  // 25.446 → "25.45" → 25.5, where a single toFixed(1) gives 25.4
8025
- const clip = Math.pow(10, decimals);
8026
- const flatRounded = Math.round(Number(num.toFixed(decimals + 1)) * clip) / clip;
8027
- const [intPart, dec] = flatRounded.toFixed(decimals).split('.');
8028
- const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
8029
- return dec ? `${grouped}.${dec}` : grouped;
8329
+ // 0 keeps its decimals here (the class labels follow flat's legend
8330
+ // toFixed(2), legend.js 636), unlike the chart values' formatValue
8331
+ if (num === 0) return num.toFixed(decimals);
8332
+ return flatGroupedValue(num, decimals);
8030
8333
  }
8031
8334
 
8032
8335
  // Per-icon alpha for the legend's isolate-on-click behavior (see
@@ -8111,7 +8414,7 @@
8111
8414
  // USER charts (a page's own chart function, style.userdraw) go the
8112
8415
  // chart pipeline too, drawn by _buildUserChartLayers (bubbles when the
8113
8416
  // page has no such function)
8114
- if (this.flags.has('CHART') && (this.flags.has('SYMBOL') || this.flags.has('USER'))) return this._buildChartLayers(zoom, bbox, liveZoom, globeCenter);
8417
+ if (isSymbolChart(this.flags)) return this._buildChartLayers(zoom, bbox, liveZoom, globeCenter);
8115
8418
  console.warn(`[ixmaps-gl] layer "${this.name}": type "${[...this.flags].join('|')}" has no implemented renderer`);
8116
8419
  return [];
8117
8420
  }
@@ -9495,6 +9798,7 @@
9495
9798
  const userLayers = this._buildUserChartLayers(combined, liveZoom, sizeValueOf);
9496
9799
  if (userLayers) return layers.concat(userLayers);
9497
9800
  }
9801
+ if (this.flags.has('LABEL')) return layers.concat(this._buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf));
9498
9802
  const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
9499
9803
  // .style({linecolor, linewidth}) — an individual icon's own border,
9500
9804
  // see _buildSingleIcon's own comment. Real ixmaps-flat symbol
@@ -9506,12 +9810,42 @@
9506
9810
  const singleBorderColorRgb = this.style.linecolor && styleLineColor(this.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(this.style.linecolor)) : null;
9507
9811
  const singleBorderWidthPx = styleNum(this.style.linewidth) || 0;
9508
9812
 
9813
+ const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
9814
+ const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
9815
+ // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
9816
+ const alignOf = this._chartAlignFn(liveZoom, radiusOf);
9817
+
9818
+ // SHADOW (style.shadow, flat's gate: maxshadow, shadowupper/lower — flatShadowOn): a soft
9819
+ // drop shadow under each symbol, see _getShadowIcon
9820
+ if (flatShadowOn(this.style, combined.length, liveZoom)) {
9821
+ const blurPx = styleNum(this.style.shadowblur) || 3;
9822
+ const num = (v, d) => (isNaN(styleNum(v)) ? d : styleNum(v));
9823
+ const dx = num(this.style.shadowdx, 1.5), dy = num(this.style.shadowdy, 2.5);
9824
+ layers.push(new IconLayer({
9825
+ id: `ix-symbolshadow-${this.name}-g${this._iconGeneration}`,
9826
+ data: combined, pickable: false,
9827
+ getPosition: d => d.geometry.coordinates,
9828
+ getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts ? 'circle' : this._resolveSymbolShape(d.properties)),
9829
+ getSize: d => radiusOf(d) * 3, // disc = 2/3 of the icon → its diameter is 2r
9830
+ getPixelOffset: alignOf.active ? d => { const o = alignOf(d); return [o[0] + dx, o[1] + dy]; } : [dx, dy],
9831
+ getColor: d => {
9832
+ const rgb = itemRgb(d);
9833
+ const gray = Math.round(Math.min(255, 0.1 * (rgb[0] + rgb[1] + rgb[2])));
9834
+ return [gray, gray, gray, Math.round(this._iconAlpha(d) * fillOpacity)];
9835
+ },
9836
+ sizeUnits: 'pixels',
9837
+ billboard: true,
9838
+ parameters: ICON_LAYER_GLOBE_PARAMETERS,
9839
+ updateTriggers: { getIcon: [liveZoom, blurPx], getSize: liveZoom }
9840
+ }));
9841
+ }
9842
+
9509
9843
  // GLOW: gradient-texture halo (see _getGlowIcon for why this diverges
9510
9844
  // from the real engine's literal flat-circle formula). Individual
9511
9845
  // and cluster glows keep their own size multiplier (11 vs 9 — a lone
9512
9846
  // point and a packed cluster read differently at the same radius)
9513
9847
  // but share one sorted layer, same as the main bubbles below.
9514
- if (this.flags.has('GLOW')) {
9848
+ if (this.flags.has('GLOW') && !glowHiddenByScale(this.style, liveZoom)) {
9515
9849
  layers.push(new IconLayer({
9516
9850
  id: `ix-glow-${this.name}-g${this._iconGeneration}`,
9517
9851
  data: combined, pickable: false,
@@ -9519,6 +9853,22 @@
9519
9853
  getIcon: d => this._getGlowIcon(this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat]),
9520
9854
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * (d.properties.counts ? 9 : 11),
9521
9855
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
9856
+ ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
9857
+ sizeUnits: 'pixels',
9858
+ billboard: true,
9859
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
9860
+ }));
9861
+ } else if (this.flags.has('AURA') && !this.flags.has('ZOOM')) {
9862
+ // AURA (flat maptheme.js 20924-20930, the GLOW branch's `else`): a circle at 1.3 × the
9863
+ // radius behind the symbol, in the line color (else the symbol's), at 0.3 opacity
9864
+ layers.push(new IconLayer({
9865
+ id: `ix-aura-${this.name}-g${this._iconGeneration}`,
9866
+ data: combined, pickable: false,
9867
+ getPosition: d => d.geometry.coordinates,
9868
+ getIcon: d => this._buildSingleIcon(singleBorderColorRgb || itemRgb(d), 0.3, 'circle', null, 0),
9869
+ getSize: d => radiusOf(d) * 2 * 1.3,
9870
+ getColor: d => [255, 255, 255, this._iconAlpha(d)],
9871
+ ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
9522
9872
  sizeUnits: 'pixels',
9523
9873
  billboard: true,
9524
9874
  parameters: ICON_LAYER_GLOBE_PARAMETERS
@@ -9589,7 +9939,21 @@
9589
9939
  const boxRgb = hexOrNamedToRgb(this.style.boxcolor || '#eeeeee');
9590
9940
  const boxAlpha = Math.round(255 * (styleNum(this.style.boxopacity) || 1));
9591
9941
  const borderRgb = hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
9592
- layers.push(new ScatterplotLayer({
9942
+ // ScatterplotLayer has no pixel offset: a moved chart (align,
9943
+ // offsetx/offsety) gets its circle as an icon
9944
+ if (alignOf.active) layers.push(new IconLayer({
9945
+ id: `ix-box-${this.name}-g${this._iconGeneration}`,
9946
+ data: boxLayout, pickable: false,
9947
+ getPosition: b => b.d.geometry.coordinates,
9948
+ getIcon: b => this._buildSingleIcon(boxRgb, boxAlpha / 255, 'circle', borderRgb,
9949
+ Math.round(unitPx / 20 * BUBBLE_ICON_SIZE / Math.max(1, 2 * b.boxR) * 4) / 4),
9950
+ getSize: b => b.boxR * 2,
9951
+ getPixelOffset: b => alignOf(b.d),
9952
+ sizeUnits: 'pixels',
9953
+ billboard: true,
9954
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
9955
+ }));
9956
+ else layers.push(new ScatterplotLayer({
9593
9957
  id: `ix-box-${this.name}`,
9594
9958
  data: boxLayout, pickable: false,
9595
9959
  getPosition: b => b.d.geometry.coordinates,
@@ -9624,7 +9988,7 @@
9624
9988
  getIcon: b => b.icon,
9625
9989
  // the icon is drawn at 2× with a stroke padding around the box
9626
9990
  getSize: b => b.icon.height / 2,
9627
- getPixelOffset: b => [(b.rect[0] + b.rect[2]) / 2, (b.rect[1] + b.rect[3]) / 2],
9991
+ getPixelOffset: b => { const o = alignOf(b.d); return [(b.rect[0] + b.rect[2]) / 2 + o[0], (b.rect[1] + b.rect[3]) / 2 + o[1]]; },
9628
9992
  sizeUnits: 'pixels',
9629
9993
  billboard: true,
9630
9994
  parameters: ICON_LAYER_GLOBE_PARAMETERS
@@ -9640,6 +10004,7 @@
9640
10004
  : this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
9641
10005
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
9642
10006
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
10007
+ ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
9643
10008
  sizeUnits: 'pixels',
9644
10009
  billboard: true,
9645
10010
  parameters: ICON_LAYER_GLOBE_PARAMETERS
@@ -9664,13 +10029,13 @@
9664
10029
  getTextAnchor: b => b.title.anchor,
9665
10030
  // top of the text ≈ first baseline − 0.8 font
9666
10031
  getAlignmentBaseline: 'top',
9667
- getPixelOffset: b => [b.title.x, b.title.top],
10032
+ getPixelOffset: b => { const o = alignOf(b.d); return [b.title.x + o[0], b.title.top + o[1]]; },
9668
10033
  parameters: ICON_LAYER_GLOBE_PARAMETERS
9669
10034
  }));
9670
10035
  }
9671
10036
 
9672
10037
  // VALUES: bold value label centered on each bubble (see
9673
- // formatBubbleValue/valuesFontSizePx above). Labels are their own
10038
+ // flatValueText/valuesFontSizePx above). Labels are their own
9674
10039
  // layers, added last in the array so they draw on top of every icon
9675
10040
  // layer (a small bubble's label can still show over a bigger
9676
10041
  // neighboring bubble's icon — a known, accepted gap). Entries whose
@@ -9679,14 +10044,16 @@
9679
10044
  // small to bother" gate.
9680
10045
  if (this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom)) {
9681
10046
  const valueScale = styleNum(this.style.valuescale) || 1;
10047
+ const textOpts = this._valueTextOpts();
9682
10048
 
9683
10049
  const pointLabels = individual.reduce((out, d) => {
9684
10050
  const radius = valueRadius(d.properties.value, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
9685
10051
  // flat's VALUES label prints the value (nValuesA), not the size
9686
- const text = formatBubbleValue(d.properties.classValue !== undefined ? d.properties.classValue : d.properties.value, this.style, this.flags);
10052
+ const text = flatValueText(d.properties.raw, this.binding.title && d.properties.raw ? d.properties.raw[this.binding.title] : undefined,
10053
+ d.properties.classValue !== undefined ? d.properties.classValue : d.properties.value, this.style, this.flags, textOpts);
9687
10054
  const fontSize = valuesFontSizePx(radius, text, valueScale);
9688
10055
  if (fontSize > VALUES_MIN_FONT_PX) {
9689
- out.push({ geometry: d.geometry, text, fontSize, color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[d.properties.cat])) });
10056
+ out.push({ geometry: d.geometry, text, fontSize, color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[d.properties.cat])), pixelOffset: alignOf(d) });
9690
10057
  }
9691
10058
  return out;
9692
10059
  }, []);
@@ -9710,14 +10077,14 @@
9710
10077
 
9711
10078
  present.forEach((p, i) => {
9712
10079
  // one part for a range-classed cell: its class value, if separate
9713
- const text = formatBubbleValue(d.properties.classTotal !== undefined && present.length === 1 ? d.properties.classTotal : p.c, this.style, this.flags);
10080
+ const text = flatValueText(this._categoryValueRecord(p.i), null, d.properties.classTotal !== undefined && present.length === 1 ? d.properties.classTotal : p.c, this.style, this.flags, textOpts);
9714
10081
  const subRadiusPx = radii[i] * fitScale * pxPerCanvasUnit;
9715
10082
  const fontSize = valuesFontSizePx(subRadiusPx, text, valueScale);
9716
10083
  if (fontSize > VALUES_MIN_FONT_PX) {
9717
10084
  out.push({
9718
10085
  geometry: d.geometry, text, fontSize,
9719
10086
  color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[p.i])),
9720
- pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit, offsets[i].y * fitScale * pxPerCanvasUnit]
10087
+ pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]]
9721
10088
  });
9722
10089
  }
9723
10090
  });
@@ -9736,13 +10103,210 @@
9736
10103
  getTextAnchor: 'middle',
9737
10104
  getAlignmentBaseline: 'center'
9738
10105
  };
9739
- if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, ...textLayerCommonProps }));
10106
+ if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, ...(alignOf.active ? { getPixelOffset: d => d.pixelOffset } : {}), ...textLayerCommonProps }));
9740
10107
  if (groupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: groupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps }));
9741
10108
  }
9742
10109
 
9743
10110
  return layers;
9744
10111
  }
9745
10112
 
10113
+ // flat's chart position for this build (flatChartAlignOffset): a
10114
+ // function item → pixel offset, the same for every part of one chart
10115
+ // (symbol, glow, shadow, box, title, value text). unit is flat's
10116
+ // normalX(1) on screen, the object zoom × style.scale.
10117
+ _chartAlignFn(liveZoom, radiusOf, textOnly = false) {
10118
+ const st = this.style;
10119
+ const none = () => [0, 0];
10120
+ none.active = false;
10121
+ if (!st.align && !styleNum(st.offsetx) && !styleNum(st.offsety)) return none;
10122
+ const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
10123
+ const symbol = flatChartBranch(this.flags) === 'symbol';
10124
+ const normalR = NORMAL_RADIUS_PX * unit; // flat's normalX(chart size / 2)
10125
+ const cache = new Map();
10126
+ const fn = d => {
10127
+ let v = cache.get(d);
10128
+ if (v) return v;
10129
+ const r = radiusOf(d);
10130
+ v = flatChartAlignOffset(st, { unit, symbolScale: symbol ? r / (2 * normalR) : 1, half: symbol ? normalR : r, orig: r + (textOnly ? 0 : 5 * unit) });
10131
+ cache.set(d, v);
10132
+ return v;
10133
+ };
10134
+ fn.active = true;
10135
+ return fn;
10136
+ }
10137
+
10138
+ // the record an aggregated part of category i stands for in its value
10139
+ // text: with style.valuefield the theme's categorical field, flat prints
10140
+ // the class name (the records' own valuefield value) — otherwise none
10141
+ _categoryValueRecord(i) { return categoryValueRecord(this.style, this.binding, this.flags, this.categoryLabels, i); }
10142
+
10143
+ // value-text options (flatValueText): flat's formatValue NOBREAKS for a
10144
+ // theme of values within 1000-3000, the max value for the decimals
10145
+ _valueTextOpts() {
10146
+ // the range stats (_valueMin/_valueMax, per zoom for AGGREGATE) when
10147
+ // there are any, else the value field's range over the data (cached)
10148
+ let vMin = this._valueMin, vMax = this._valueMax;
10149
+ if (vMin === undefined) {
10150
+ if (this._valueTextRangeFor !== this.features) {
10151
+ let lo = Infinity, hi = -Infinity;
10152
+ const field = this.binding.value;
10153
+ for (const f of this.features || []) {
10154
+ const v = field === '$item$' ? 1 : parseFloat(f.properties && f.properties[field]);
10155
+ if (isNaN(v)) continue;
10156
+ if (v < lo) lo = v;
10157
+ if (v > hi) hi = v;
10158
+ }
10159
+ this._valueTextRangeFor = this.features;
10160
+ this._valueTextRange = [lo, hi];
10161
+ }
10162
+ [vMin, vMax] = this._valueTextRange;
10163
+ }
10164
+ return { noBreaks: flatNoBreaks(vMin, vMax), maxValue: styleNum(this.style.normalsizevalue) || (isFinite(vMax) ? vMax : undefined) };
10165
+ }
10166
+
10167
+ // CHART|LABEL (maptheme.js 20647-21037), at the chart radius r of the
10168
+ // size value:
10169
+ // - LABEL: a rounded rectangle 2.05r wide and 1.6·f high (f = min(0.8r,
10170
+ // 3.4r / the value's text length)) from −r and −0.83f, corners 2r/15
10171
+ // (normalX(2r / normal radius)), in the item color at fillopacity
10172
+ // (default 1), stroked in linecolor or the color × 0.7 (NOLINES: none)
10173
+ // linewidth (0.1) · unit · √(r / normal radius) wide (OUTLINE:
10174
+ // unit · min(1, r / normal radius)), opaque with OUTLINE or a
10175
+ // linecolor, else 1 below fillopacity 0.5, 0.3 above; with VALUES the
10176
+ // text in bold, min(0.8r, 3.3r / its length) · valuescale, in
10177
+ // valuecolor / textcolor, else ChartColors.textColor of a class
10178
+ // color (white for a single-color theme). Flat's drop shadow rects
10179
+ // (20950) never draw: they need fOrigShadow without fShadow, which
10180
+ // the shadow gate (16740-16745) leaves equal.
10181
+ // - LABEL|TEXTONLY: no box, only an invisible hit rect (the
10182
+ // hover/tooltip target) and, with VALUES, the text at r·0.8·valuescale
10183
+ // in normal weight, valuecolor/textcolor (else black), over a halo in
10184
+ // the item color (stroke font/7, opacity 0.5).
10185
+ // Both moved by the chart position (flatChartAlignOffset). The text is
10186
+ // the hover target of TEXTONLY, the box of LABEL.
10187
+ _buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf) {
10188
+ const textOnly = this.flags.has('TEXTONLY');
10189
+ const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
10190
+ if (textOnly && !showValues) return [];
10191
+ const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10192
+ const alignOf = this._chartAlignFn(liveZoom, radiusOf, textOnly);
10193
+ const valueScale = styleNum(this.style.valuescale) || 1;
10194
+ const textOverride = this.style.valuecolor || this.style.textcolor;
10195
+ const opts = this._valueTextOpts();
10196
+ const itemRgb = props => this.categoryColorsRgb[props.counts ? dominant(props.counts) : props.cat] || [128, 128, 128];
10197
+ const valueOf = props => (props.counts ? props.total : (props.classValue !== undefined ? props.classValue : props.value));
10198
+ const textOf = props => flatValueText(props.counts ? this._categoryValueRecord(dominant(props.counts)) : props.raw,
10199
+ this.binding.title && props.raw ? props.raw[this.binding.title] : undefined, valueOf(props), this.style, this.flags, opts);
10200
+
10201
+ if (textOnly) {
10202
+ const textRgb = textOverride ? hexOrNamedToRgb(textOverride) : [0, 0, 0];
10203
+ const labels = [];
10204
+ for (const d of combined) {
10205
+ const r = radiusOf(d);
10206
+ const fontSize = r * 0.8 * valueScale;
10207
+ if (!(fontSize > 0)) continue;
10208
+ labels.push({ ...d, text: textOf(d.properties), fontSize, halo: itemRgb(d.properties), pixelOffset: alignOf(d) });
10209
+ }
10210
+ // TextLayer's outlineColor is one per layer: a layer per halo color
10211
+ const byHalo = new Map();
10212
+ for (const l of labels) {
10213
+ const key = l.halo.slice(0, 3).join(',');
10214
+ if (!byHalo.has(key)) byHalo.set(key, []);
10215
+ byHalo.get(key).push(l);
10216
+ }
10217
+ return [...byHalo.values()].map((data, i) => new TextLayer({
10218
+ // the ix-bubbles- id (generation-like suffix) routes hover/click to this theme
10219
+ id: `ix-bubbles-${this.name}-g${i}`,
10220
+ data, pickable: true,
10221
+ getPosition: d => d.geometry.coordinates,
10222
+ getText: d => d.text,
10223
+ getSize: d => d.fontSize,
10224
+ getColor: d => [...textRgb.slice(0, 3), this._iconAlpha(d)],
10225
+ getPixelOffset: d => d.pixelOffset,
10226
+ sizeUnits: 'pixels',
10227
+ fontFamily: 'arial',
10228
+ fontWeight: 'normal',
10229
+ characterSet: 'auto',
10230
+ fontSettings: { sdf: true },
10231
+ outlineWidth: 1 / 7,
10232
+ outlineColor: [...data[0].halo.slice(0, 3), 128],
10233
+ getTextAnchor: 'middle',
10234
+ getAlignmentBaseline: 'center',
10235
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10236
+ }));
10237
+ }
10238
+
10239
+ const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
10240
+ const normalR = NORMAL_RADIUS_PX * unit;
10241
+ const fillOpacity = styleNum(this.style.fillopacity) || 1;
10242
+ const lineWidth = styleNum(this.style.linewidth) || 0.1;
10243
+ const outline = flatFlag(this.flags, 'OUTLINE');
10244
+ const lineColor = this.style.linecolor && styleLineColor(this.style.linecolor) !== 'none' ? styleLineColor(this.style.linecolor) : null;
10245
+ const noLines = flatFlag(this.flags, 'NOLINES');
10246
+ const strokeOpacity = outline || lineColor ? 1 : (fillOpacity < 0.5 ? 1 : 0.3);
10247
+ const multiColor = (this.categoryColorsRgb || []).length > 1;
10248
+ const css = rgb => `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
10249
+ // the box is sized by the value's own text, not the valuefield's
10250
+ const boxStyle = Object.assign({}, this.style, { valuefield: undefined });
10251
+ const boxes = [], texts = [];
10252
+ for (const d of combined) {
10253
+ const r = radiusOf(d);
10254
+ if (!(r > 0)) continue;
10255
+ const props = d.properties;
10256
+ const rgb = itemRgb(props);
10257
+ const boxText = flatValueText(null, null, valueOf(props), boxStyle, this.flags, opts);
10258
+ const f = Math.min(r * 0.8, r * 3.4 / Math.max(1, boxText.length));
10259
+ const ratio = r / normalR;
10260
+ const icon = this._buildBoxIcon(2.05 * r, 1.6 * f, 2 * r / 15,
10261
+ outline ? unit * Math.min(1, ratio) : unit * lineWidth * Math.sqrt(ratio),
10262
+ { fill: css(rgb), fillOpacity, stroke: noLines ? null : (lineColor || css(flatDerivateRgb(rgb, 0.7))), strokeOpacity });
10263
+ const o = alignOf(d);
10264
+ // the box's center relative to the chart's: x from −r to 1.05r, y from −0.83f to 0.77f
10265
+ boxes.push({ ...d, icon, pixelOffset: [o[0] + 0.025 * r, o[1] - 0.03 * f] });
10266
+ if (showValues) {
10267
+ const text = textOf(props);
10268
+ const fontSize = Math.min(r * 0.8, r * 3.3 / Math.max(1, text.length)) * valueScale;
10269
+ const color = textOverride ? hexOrNamedToRgb(textOverride) : (multiColor ? flatChartTextRgb(rgb) : [255, 255, 255]);
10270
+ if (fontSize > 0) texts.push({ ...d, text, fontSize, color, pixelOffset: o });
10271
+ }
10272
+ }
10273
+ const layers = [];
10274
+ if (boxes.length) {
10275
+ layers.push(new IconLayer({
10276
+ id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
10277
+ data: boxes, pickable: true,
10278
+ getPosition: d => d.geometry.coordinates,
10279
+ getIcon: d => d.icon,
10280
+ // the icon is drawn at 2× with a stroke padding around the box
10281
+ getSize: d => d.icon.height / 2,
10282
+ getPixelOffset: d => d.pixelOffset,
10283
+ getColor: d => [255, 255, 255, this._iconAlpha(d)],
10284
+ sizeUnits: 'pixels',
10285
+ billboard: true,
10286
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10287
+ }));
10288
+ }
10289
+ if (texts.length) {
10290
+ layers.push(new TextLayer({
10291
+ id: `ix-labels-${this.name}`,
10292
+ data: texts, pickable: false,
10293
+ getPosition: d => d.geometry.coordinates,
10294
+ getText: d => d.text,
10295
+ getSize: d => d.fontSize,
10296
+ getColor: d => [...d.color.slice(0, 3), this._iconAlpha(d)],
10297
+ getPixelOffset: d => d.pixelOffset,
10298
+ sizeUnits: 'pixels',
10299
+ fontFamily: 'arial',
10300
+ fontWeight: 'bold',
10301
+ characterSet: 'auto',
10302
+ getTextAnchor: 'middle',
10303
+ getAlignmentBaseline: 'center',
10304
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10305
+ }));
10306
+ }
10307
+ return layers;
10308
+ }
10309
+
9746
10310
  // pure: oneHot → see the pure-function sections above the class
9747
10311
  _oneHot(cat, value) { return oneHot(cat, value, this.categoryLabels.length); }
9748
10312
 
@@ -10190,8 +10754,8 @@
10190
10754
  resolveComposedColor, computeRangeClasses, colorSchemeClassCount, resolveDopacityAlpha,
10191
10755
  flatColorSweep, applyClassesToColorScheme, resolveClassColors, flatOutlierStats, parseCssColor, flatLegendLook,
10192
10756
  flatToMapLibreZoom, mapLibreToFlatZoom, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
10193
- fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, formatBubbleValue, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
10194
- applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
10757
+ fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
10758
+ applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
10195
10759
  flatFormatValue,
10196
10760
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
10197
10761
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.4",
3
+ "version": "0.2.7",
4
4
  "description": "MapLibre GL + deck.gl reimplementation of the ixmaps-flat declarative mapping engine",
5
5
  "main": "ixmaps-gl.js",
6
6
  "browser": "ixmaps-gl.js",