ixmaps-gl 0.2.33 → 0.2.34

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 +145 -5
  2. package/ixmaps-gl.js +26 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -114,8 +114,8 @@ page. It currently implements:
114
114
  `window.__setFacetFilter`) for building filterable sidebars
115
115
  - Globe (orthographic) projection alongside flat Mercator
116
116
 
117
- Not yet implemented: the `QUAD`/`WAFFLE` base types. Custom SYMBOL shapes are supported
118
- as image-URL symbols (`symbols: [...]`), not as built-in shape variants.
117
+ Not yet implemented: the `QUAD`/`WAFFLE` base types. SYMBOL shapes: the built-in ones and image-URL
118
+ symbols are described under [Symbols](#symbols).
119
119
  The top-of-file comment in [`ixmaps-gl.js`](./ixmaps-gl.js) is the single source of truth
120
120
  for scope; this list only summarises it.
121
121
 
@@ -329,12 +329,152 @@ so zoom-dependent page state (e.g. a basemap opacity ramp) changes in step with
329
329
  hooks) aren't supported. `test/project-report.mjs` shows which themes
330
330
  of your own project files gl can render.
331
331
 
332
+ ### Symbols
333
+
334
+ `CHART|SYMBOL` draws one symbol per item (per category with `CATEGORICAL`, per part with `SEQUENCE`).
335
+ `style.symbols` names them, by color class: class `i` (the `i`-th entry of `values`, else the range
336
+ class) takes `symbols[i]`, and the last entry repeats when the array is shorter than the classes.
337
+
338
+ - **Built-in shapes:** `circle` (default), `square`, `diamond` (= `carot`), `triangle`, `roundrect`,
339
+ `hexagon`, `cross`, `label`, `empty`. An unknown name draws a circle.
340
+ - **Image symbols:** a URL ending in `.svg`, `.png`, `.jpg` or `.jpeg` (a query or `#` may follow), or a
341
+ `data:image/svg+xml|png|jpeg` URI. The image is drawn as is, not tinted by the class color. An SVG is
342
+ fetched and rasterized once per URL (a `viewBox` is enough, it needs no width/height); a PNG/JPEG is
343
+ loaded by deck.gl. A page opened from `file://` cannot fetch symbol files: serve it over http(s) or use
344
+ `data:` URIs.
345
+ - **By record field:** `symbolfield: "type"` with `symbolvalues: ["a", "b"]` maps the value of that field
346
+ to `symbols[i]` by position, independent of the color class (for individual items, not aggregated cells).
347
+
348
+ ```js
349
+ .type("CHART|SYMBOL|CATEGORICAL")
350
+ .style({ values: ["school", "shop"], colorscheme: ["#d9534f", "#428bca"],
351
+ symbols: ["icons/school.svg", "icons/shop.svg"] })
352
+ ```
353
+
354
+ ### Zoom gates: `chartupper` and `chartlower`
355
+
356
+ `style.chartupper` / `style.chartlower` (`layerupper` / `layerlower` when unset) take a scale `"1:N"`. The
357
+ theme is **hidden when zoomed out beyond `chartupper`** (the scale denominator is above N) and **hidden when
358
+ zoomed in to `chartlower` or closer** (the scale denominator is at or below N). So `chartlower: "1:20000"`
359
+ shows the layer only while the view is zoomed out past 1:20000, and `chartupper: "1:20000"` only from there on
360
+ in. The scale is flat's, about `442913385 / 2^z` for the zoom `z` you give `.view()` on a Mercator map:
361
+
362
+ | zoom | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
363
+ |---|---|---|---|---|---|---|---|---|
364
+ | scale 1: | 432533 | 216266 | 108133 | 54067 | 27033 | 13517 | 6758 | 3379 |
365
+
366
+ `featureupper`/`featurelower` gate FEATURE themes the same way, `glowupper`/`glowlower` the glow halo.
367
+
368
+ ### Aggregation grid and symbol size
369
+
370
+ `AGGREGATE` groups items into grid cells. The cell size is read, first match wins, from:
371
+
372
+ 1. `aggregation` (flat's alias `aggregationscale`): scale/width pairs, e.g. `["1:1", "3px", "1:500000", "1px"]`,
373
+ the width of the last pair for which the view is zoomed out past `1:N` (the example gives 3 px, and 1 px once
374
+ the view is zoomed out past 1:500000); `"Npx"` is screen pixels, a bare number meters
375
+ 2. `gridwidthpx`: screen pixels, constant over zoom (the recommended spelling for a fixed cell)
376
+ 3. `gridwidth: "Npx"`: the same, as a string
377
+ 4. `gridwidth: N` (a bare number): meters, the same cells at every zoom
378
+ 5. none of them: no grid, items stay at their positions
379
+
380
+ With `CATEGORICAL|COUNT` and no size field the size of a symbol follows the number of records in its cell:
381
+ radius = `15 px × scale × (count / normalsizevalue)^(1/sizepow)`, scaled with the zoom (`objectscaling`) and
382
+ clamped to a minimum and maximum radius.
383
+ `normalsizevalue` is the count drawn at normal size, the dataset's maximum when unset. A `normalsizevalue` of
384
+ `30` therefore draws a single-record cell at 1/30 of the normal radius: pick it near the biggest cell you expect.
385
+
386
+ ### Basemaps (`mapType`)
387
+
388
+ `mapType` is resolved in this order; a name that matches nothing falls back to the last two rules, so an
389
+ unknown flat name such as `VT_TONER_LITE` draws CARTO Positron:
390
+
391
+ | `mapType` | style |
392
+ |---|---|
393
+ | `ArcGIS - Topo` (`terrain`), `ArcGIS - Light Gray Base`, `ArcGIS - Ocean Basemap`, `ArcGIS - Hillshade` | Esri raster tiles |
394
+ | `CARTO - Voyager` (`VT_VOYAGER`), `CARTO - Voyager (no labels)` (`VT_VOYAGER_LIGHT`), `CARTO - Positron (no labels)`, `CARTO - Dark Matter (no labels)` | CARTO vector styles |
395
+ | `OpenFreeMap - Liberty`, `OpenFreeMap - Bright`, `OpenFreeMap - Positron` | OpenFreeMap vector styles |
396
+ | any name containing `dark`, `black`, `night` or `matter` | CARTO Dark Matter |
397
+ | everything else | CARTO Positron |
398
+
399
+ ### Adding and replacing themes at runtime
400
+
401
+ `ixmaps.map().add(theme, "replace")` adds the theme the first time and swaps it on later calls, matched by
402
+ the theme id, `style.name` or `meta.name`. Give the theme a `name` in `.meta()`: the name passed to
403
+ `ixmaps.layer("name", …)` is not the id, and without one `"replace"` cannot find the old theme and adds a second. `ixmaps.map().remove(id)` removes it.
404
+
405
+ ### Text on labels
406
+
407
+ `CHART|LABEL` (with `TEXTONLY` for the text alone) writes the item's value. A text field bound as the *value*
408
+ reads as 0, as in flat, and shows nothing visible; the console says so once. To show a text, as in flat, keep a
409
+ numeric `value` (or `size`) binding and name the text field in `style.valuefield`, or `"$title$"` for the
410
+ `title` binding:
411
+
412
+ ```js
413
+ .binding({ geo: "geometry", value: "count" })
414
+ .type("CHART|LABEL|TEXTONLY|VALUES")
415
+ .style({ valuefield: "name" })
416
+ ```
417
+
418
+ With `CATEGORICAL` the value field holds the categories and `valuefield` names the text. The text is drawn as a
419
+ single-color glyph with a halo, so an emoji comes out as a monochrome silhouette (seen in a headless Chromium
420
+ test): use image symbols (see Symbols) for colored icons.
421
+
422
+ ### Runtime filtering
423
+
424
+ A theme's records can be filtered in place, without redefining it, with flat's `changeThemeStyle`:
425
+
426
+ ```js
427
+ ixmaps.changeThemeStyle("mytheme", 'filter:WHERE "type" = "school"', "set");
428
+ ixmaps.changeThemeStyle("mytheme", "filter", "remove");
429
+ ```
430
+
431
+ The same through the handles, `ixmaps.map().changeThemeStyle(id, style, flag)` and
432
+ `ixmaps.map().theme(id).changeStyle(style, flag)`; with `null` as the id every theme but the FEATURE ones is
433
+ changed. The expression is flat's filter grammar: `WHERE field op value` clauses joined by `AND` with `=`, `!=`,
434
+ `<>`, `<`, `>`, `<=`, `>=`, `BETWEEN … AND …`, `IN (…)`, `LIKE` and `NOT` (see `test/unit/filter.test.mjs`).
435
+ (`npm run runtime` in `test/` checks these calls in a browser.)
436
+
437
+ ### Porting a flat page
438
+
439
+ How the calls of a flat page map to gl; "tested" means `test/runtime.mjs` runs it in a browser.
440
+
441
+ | flat | gl |
442
+ |---|---|
443
+ | `ixmaps.Map(div, opts, function (map) { … })` | the same; the callback gets the map builder (`view`, `options`, `layer`, `on`, …) |
444
+ | `var myMap = ixmaps.Map(div, opts).view(…)`, `myMap.then(map => …)` | the same, `myMap` is the builder and a thenable |
445
+ | `myMap.layer("name").data(…)…define()` | the same, before and after the map is built (tested) |
446
+ | `myMap.layer(theme)`, `myMap.layer(theme, "direct")` after the map is built | the same (tested); up to v0.2.33 these silently added nothing |
447
+ | `ixmaps.map().layer(theme)`, `ixmaps.map().add(theme, "replace")` | the same (tested) |
448
+ | `ixmaps.changeThemeStyle(id, 'filter:…', "set")` | the same (tested), see Runtime filtering |
449
+ | `map.getBounds()` | `ixmaps.map().getBounds()`, from v0.2.33 |
450
+ | `map.on(event, fn)` | `map.on` / `map.off`, see Map events |
451
+ | `ixmaps.hideTheme(id)` / `ixmaps.showTheme(id)` | not provided: `ixmaps.map().theme(id).hide()` / `.show()` or `ixmaps.map().setThemeVisible(id, bool)` |
452
+ | `ixmaps.flyTo(…)` | not provided: `ixmaps.map().view([lat, lng], zoom)` |
453
+
454
+ ### `CHART|USER` draw function
455
+
456
+ `style.userdraw` names a function (`ixmaps[name]`, or a dotted path on `window`). It is called as
457
+ `fn(document, opt)` for each chart, after an optional `name_init(document, {target, theme})` once per draw.
458
+ `opt` holds `target` (the SVG group to draw into), `theme`, `value`, `values`, `size`, `maxSize`, `color`,
459
+ `class`, `flag`, `ccolor`, `item` (`{szColor, szLabel, szTitle, nCount, nSize}`) and `dbRecord` (the item's data
460
+ record, also the place to read the category). `size` is in flat's chart units, `maxSize` (300) being the
461
+ normal radius: one unit is `15 px × scale × zoom factor / 300`. The chart is drawn around 0,0.
462
+
463
+ ### Headless, CI and offline
464
+
465
+ A headless Chromium has no GPU: start it with `--use-gl=swiftshader --enable-unsafe-swiftshader
466
+ --ignore-gpu-blocklist`. deck.gl then logs "GPU stall due to ReadPixels", which is harmless. The engine loads
467
+ `maplibre-gl@6.11.2` (module and css) and `deck.gl@9.4.0` and `mustache@4.2.0` from unpkg, `data.js` from
468
+ jsDelivr (`gh/gjrichter/data.js`), and on demand `d3-array@3.2.4` and `d3-contour@4.0.2` (jsDelivr) and the
469
+ `ixmaps-grammar` validator; mirror these to run offline. Page-level script and data URLs are not
470
+ configurable yet.
471
+
332
472
  ## Quick start
333
473
 
334
474
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
335
475
 
336
476
  ```html
337
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.33/ixmaps-gl.js"></script>
477
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.34/ixmaps-gl.js"></script>
338
478
  ```
339
479
 
340
480
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -342,13 +482,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
342
482
  browser script, which defines the global `ixmaps`):
343
483
 
344
484
  ```html
345
- <script src="https://unpkg.com/ixmaps-gl@0.2.33/ixmaps-gl.js"></script>
485
+ <script src="https://unpkg.com/ixmaps-gl@0.2.34/ixmaps-gl.js"></script>
346
486
  ```
347
487
 
348
488
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
349
489
 
350
490
  ```html
351
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.33/ixmaps-gl.js"></script>
491
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.34/ixmaps-gl.js"></script>
352
492
  <div id="map_div" style="position:absolute;inset:0;"></div>
353
493
 
354
494
  <script>
package/ixmaps-gl.js CHANGED
@@ -100,7 +100,7 @@
100
100
  // ---------------------------------------------------------------
101
101
  // this engine's release (= package.json "version", checked by
102
102
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
103
- const IXMAPS_GL_VERSION = '0.2.33';
103
+ const IXMAPS_GL_VERSION = '0.2.34';
104
104
 
105
105
  const LIB_URLS = {
106
106
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -7628,7 +7628,12 @@ self.onmessage = e => {
7628
7628
  // object form (a pre-built LayerBuilder, e.g. from the global
7629
7629
  // ixmaps.layer(name, cb) factory): existing semantics, chain
7630
7630
  // back on the MAP for further .layer(a).layer(b) calls.
7631
- builder.layer(nameOrBuilder);
7631
+ // Once the map is built build() has consumed its queue, so queuing
7632
+ // here would add nothing, silently: define the theme on the live
7633
+ // map instead (flat's runtime overlay: myMap.layer(theme, "direct"),
7634
+ // the second argument is accepted and not needed)
7635
+ if (resolvedApi) resolvedApi.layer(nameOrBuilder);
7636
+ else builder.layer(nameOrBuilder);
7632
7637
  return promise;
7633
7638
  }
7634
7639
  const lb = new LayerBuilder(nameOrBuilder);
@@ -17958,6 +17963,24 @@ self.onmessage = e => {
17958
17963
  // the item color (stroke font/7, opacity 0.5).
17959
17964
  // Both moved by the chart position (flatChartAlignOffset). The text is
17960
17965
  // the hover target of TEXTONLY, the box of LABEL.
17966
+ // A text field bound as the LABEL's value scans to 0 (flat's __scanValue), so the labels are drawn at the smallest size
17967
+ // with the text "0" — as flat skips such items, nothing visible, and no message. Say how to show a text: flat's
17968
+ // style.valuefield (or "$title$"), with a numeric value or size binding. CATEGORICAL values are categories, not numbers.
17969
+ _warnTextValue(combined) {
17970
+ // looked at once per theme (this runs on every redraw): the first record with a value
17971
+ if (this._checkedTextValue || this.style.valuefield || this.flags.has('CATEGORICAL') || !this.binding.value) return;
17972
+ let sample;
17973
+ for (const d of combined) {
17974
+ const v = d.properties.raw && d.properties.raw[this.binding.value];
17975
+ if (v != null && String(v).trim() !== '') { sample = v; break; }
17976
+ }
17977
+ if (sample === undefined) return;
17978
+ this._checkedTextValue = true;
17979
+ if (!isNaN(scanValue(sample))) return;
17980
+ console.warn(`[ixmaps-gl] theme "${this.name}": LABEL value field "${this.binding.value}" holds text ("${String(sample).slice(0, 20)}"), ` +
17981
+ 'which reads as 0 — as in flat the labels are not visible. To show a text keep a numeric value (or size) binding ' +
17982
+ 'and name the text field in style.valuefield (or "$title$" for the title).');
17983
+ }
17961
17984
  _buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf) {
17962
17985
  const textOnly = this.flags.has('TEXTONLY');
17963
17986
  const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
@@ -17972,6 +17995,7 @@ self.onmessage = e => {
17972
17995
  const textOf = props => flatValueText(props.counts ? this._categoryValueRecord(dominant(props.counts)) : props.raw,
17973
17996
  this.binding.title && props.raw ? props.raw[this.binding.title] : undefined, valueOf(props), this.style, this.flags, opts);
17974
17997
 
17998
+ this._warnTextValue(combined);
17975
17999
  if (textOnly) {
17976
18000
  const textRgb = textOverride ? hexOrNamedToRgb(textOverride) : [0, 0, 0];
17977
18001
  const labels = [];
@@ -17981,12 +18005,6 @@ self.onmessage = e => {
17981
18005
  if (!(fontSize > 0)) continue;
17982
18006
  labels.push({ ...d, text: textOf(d.properties), fontSize, halo: itemRgb(d.properties), pixelOffset: alignOf(d) });
17983
18007
  }
17984
- // a text field read as the value/size scans to 0 (flat's __scanValue), so every label has size 0 and nothing is drawn
17985
- if (!labels.length && combined.length && !this._warnedNoLabels) {
17986
- this._warnedNoLabels = true;
17987
- console.warn(`[ixmaps-gl] theme "${this.name}": LABEL|TEXTONLY drew no labels — the size of all ${combined.length} items is 0. ` +
17988
- 'A non-numeric (text) field bound as value/size reads as 0; bind a numeric size field.');
17989
- }
17990
18008
  // TextLayer's outlineColor is one per layer: a layer per halo color
17991
18009
  const byHalo = new Map();
17992
18010
  for (const l of labels) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
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",