@nika-js/onlymap 0.7.6 → 0.8.1

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 (70) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -1
  3. package/dist/{LercDecode.es-sRuTJq5Y.js → LercDecode.es-DKhLh4TO.js} +1 -1
  4. package/dist/{basemap-BCGMmc2F.js → basemap-BjXMRr_M.js} +538 -502
  5. package/dist/basemap.d.ts +23 -0
  6. package/dist/cartograph/api.d.ts +45 -0
  7. package/dist/cartograph/atlas.d.ts +50 -0
  8. package/dist/cartograph/core-loader.d.ts +35 -0
  9. package/dist/cartograph/cvd.d.ts +106 -0
  10. package/dist/cartograph/element-base.d.ts +26 -0
  11. package/dist/cartograph/elements/om-atlas.d.ts +47 -0
  12. package/dist/cartograph/elements/om-cartograph.d.ts +76 -0
  13. package/dist/cartograph/elements/om-frame.d.ts +121 -0
  14. package/dist/cartograph/elements/om-graticule.d.ts +24 -0
  15. package/dist/cartograph/elements/om-image.d.ts +12 -0
  16. package/dist/cartograph/elements/om-legend.d.ts +16 -0
  17. package/dist/cartograph/elements/om-north.d.ts +11 -0
  18. package/dist/cartograph/elements/om-scalebar.d.ts +13 -0
  19. package/dist/cartograph/elements/om-shape.d.ts +16 -0
  20. package/dist/cartograph/elements/om-text.d.ts +9 -0
  21. package/dist/cartograph/georef.d.ts +126 -0
  22. package/dist/cartograph/graticule.d.ts +70 -0
  23. package/dist/cartograph/html-data.d.ts +27 -0
  24. package/dist/cartograph/index.d.ts +1 -0
  25. package/dist/cartograph/layout.d.ts +23 -0
  26. package/dist/cartograph/legend.d.ts +75 -0
  27. package/dist/cartograph/live-frame.d.ts +83 -0
  28. package/dist/cartograph/paint.d.ts +54 -0
  29. package/dist/cartograph/refs.d.ts +33 -0
  30. package/dist/cartograph/render.d.ts +64 -0
  31. package/dist/cartograph/scalebar.d.ts +91 -0
  32. package/dist/cartograph/schema.d.ts +38 -0
  33. package/dist/cartograph/standalone.d.ts +1 -0
  34. package/dist/cartograph/textlayout.d.ts +45 -0
  35. package/dist/cartograph/tokens.d.ts +45 -0
  36. package/dist/cartograph/url-actions.d.ts +26 -0
  37. package/dist/cartograph/validate.d.ts +38 -0
  38. package/dist/cartograph/zip.d.ts +22 -0
  39. package/dist/cartograph.css +1 -0
  40. package/dist/cartograph.js +2797 -0
  41. package/dist/cartograph.standalone.js +7669 -0
  42. package/dist/crs-DsDQ4Q4i.js +71 -0
  43. package/dist/download.d.ts +6 -0
  44. package/dist/elements/om-map.d.ts +2 -0
  45. package/dist/feature-access.d.ts +14 -0
  46. package/dist/field-access.d.ts +1 -10
  47. package/dist/{geoparquet-CK9IjEjH.js → geoparquet-D9pYDrCk.js} +1 -1
  48. package/dist/{index-CN_tn36D.js → index-BGQidVp5.js} +1 -1
  49. package/dist/{index-CIEJyv5x.js → index-BdKNZcs-.js} +1 -1
  50. package/dist/index-CcLC9jE5.js +4798 -0
  51. package/dist/{index-C1ZQGJxa.js → index-CqtubBZ4.js} +1 -1
  52. package/dist/{index-CNRYpb0x.js → index-D6R4z3Zs.js} +6296 -6203
  53. package/dist/{index-CW3bdfHB.js → index-Zm8MxpvR.js} +2 -2
  54. package/dist/index.d.ts +1 -1
  55. package/dist/{lerc-Bb2JZ92v.js → lerc-BdHcL8pi.js} +2 -2
  56. package/dist/onlymap.standalone.js +10777 -10648
  57. package/dist/onlymapjs.js +12 -11
  58. package/dist/{raster-DpYssEe7.js → raster-B7rPpOTP.js} +3 -3
  59. package/dist/{raster-pipeline-BVCB-0yq.js → raster-pipeline-X2-mnFQx.js} +1 -1
  60. package/dist/runtime-core.d.ts +42 -0
  61. package/dist/snapshot.d.ts +17 -0
  62. package/dist/units.d.ts +9 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/{zarr-DNb4iqbL.js → zarr-BF-buFRB.js} +2 -2
  65. package/docs/cartograph.md +396 -0
  66. package/llms.txt +1 -0
  67. package/onlymapjs.html-data.json +978 -0
  68. package/package.json +17 -6
  69. package/skills/onlymapjs/SKILL.md +2 -2
  70. package/skills/onlymapjs/references/syntax.md +27 -2
package/dist/onlymapjs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { aC as e, aD as r, aE as t, aF as o, aG as b, aH as i, ap as l, aI as n, aJ as S, aK as c, aL as E, aM as L, aN as g, aO as T, aP as _, aQ as A, aR as d, aS as p, aT as I, aU as m, aV as D, aW as M, aX as u, aY as y, aZ as O, a_ as f, ao as R, a$ as N, aw as P, b0 as F, b1 as h, b2 as C, W as B, b3 as v, b4 as x, b5 as G, b6 as U, b7 as W, b8 as w, b9 as Y, ba as X, bb as H, bc as k, bd as J, be as K, bf as V, bg as z, bh as Q, bi as Z, bj as $, bk as j, bl as q, bm as aa, bn as sa, bo as ea, bp as ra, bq as ta, br as oa, bs as ba, bt as ia, bu as la, bv as na, bw as Sa, bx as ca, by as Ea, bz as La, bA as ga, bB as Ta, bC as _a, bD as Aa, bE as da, bF as pa, bG as Ia, bH as ma, bI as Da, bJ as Ma, bK as ua, bL as ya, bM as Oa, bN as fa, bO as Ra, bP as Na, bQ as Pa, bR as Fa, bS as ha, bT as Ca, bU as Ba, bV as va, bW as xa, bX as Ga, bY as Ua, bZ as Wa, b_ as wa, b$ as Ya, c0 as Xa, c1 as Ha, c2 as ka, c3 as Ja, c4 as Ka, c5 as Va, c6 as za, c7 as Qa, c8 as Za, c9 as $a, ca as ja, cb as qa, cc as as } from "./index-CNRYpb0x.js";
1
+ import { aC as e, aD as r, aE as t, aF as o, aG as b, aH as i, ap as l, aI as n, aJ as S, aK as c, aL as E, aM as L, aN as g, aO as T, aP as _, aQ as d, aR as A, aS as p, aT as I, aU as m, aV as D, aW as u, aX as M, aY as y, aZ as O, a_ as f, ao as R, a$ as N, aw as P, b0 as F, b1 as h, b2 as C, W as B, b3 as v, b4 as x, b5 as G, b6 as U, b7 as W, b8 as w, b9 as Y, ba as X, bb as H, bc as k, bd as J, be as K, bf as V, bg as z, bh as Q, bi as Z, bj as $, bk as j, bl as q, bm as aa, bn as sa, bo as ea, bp as ra, bq as ta, br as oa, bs as ba, bt as ia, bu as la, bv as na, bw as Sa, bx as ca, by as Ea, bz as La, bA as ga, bB as Ta, bC as _a, bD as da, bE as Aa, bF as pa, bG as Ia, bH as ma, bI as Da, bJ as ua, bK as Ma, bL as ya, bM as Oa, bN as fa, bO as Ra, bP as Na, bQ as Pa, bR as Fa, bS as ha, bT as Ca, bU as Ba, bV as va, bW as xa, bX as Ga, bY as Ua, bZ as Wa, b_ as wa, b$ as Ya, c0 as Xa, c1 as Ha, c2 as ka, c3 as Ja, c4 as Ka, c5 as Va, c6 as za, c7 as Qa, c8 as Za, c9 as $a, ca as ja, cb as qa, cc as as, cd as ss } from "./index-D6R4z3Zs.js";
2
2
  export {
3
3
  e as ALL_POSITION_VALUES,
4
4
  r as AUDIT_EXEMPTIONS,
@@ -15,14 +15,14 @@ export {
15
15
  g as FOLD_HYSTERESIS_PX,
16
16
  T as FOLD_SIDES,
17
17
  _ as FOLD_SINGLE_SIDED_RATIO,
18
- A as GeoJsonLayer,
19
- d as IconLayer,
18
+ d as GeoJsonLayer,
19
+ A as IconLayer,
20
20
  p as LEGACY_POSITION_ALIASES,
21
21
  I as LIGHTING_PRESET_NAMES,
22
22
  m as Layer,
23
23
  D as LayerExtension,
24
- M as MANAGED_SLOTS,
25
- u as MapController,
24
+ u as MANAGED_SLOTS,
25
+ M as MapController,
26
26
  y as OmMap,
27
27
  O as ScatterplotLayer,
28
28
  f as ScenegraphLayer,
@@ -69,14 +69,14 @@ export {
69
69
  ga as lineStringLengthMeters,
70
70
  Ta as loadIfc,
71
71
  _a as loadRasterModule,
72
- Aa as midpoint,
73
- da as mountForTest,
72
+ da as midpoint,
73
+ Aa as mountForTest,
74
74
  pa as niceNumber,
75
75
  Ia as normalizeData,
76
76
  ma as parseDurationMs,
77
77
  Da as parseTerrainAttrs,
78
- Ma as parseUnitSystem,
79
- ua as parseWidgetStyle,
78
+ ua as parseUnitSystem,
79
+ Ma as parseWidgetStyle,
80
80
  ya as pathLengthMeters,
81
81
  Oa as registerAction,
82
82
  fa as registerBasemap,
@@ -105,6 +105,7 @@ export {
105
105
  Za as snapshotDescriptorIR,
106
106
  $a as snapshotIR,
107
107
  ja as solarAzElDegrees,
108
- qa as validateManifest,
109
- as as validateManifestString
108
+ qa as subscribeLicense,
109
+ as as validateManifest,
110
+ ss as validateManifestString
110
111
  };
@@ -1,5 +1,5 @@
1
- import { c as se, t as ze, i as wt, a as Be, s as St, C as Pt, b as bt, F as Ct, A as Lt, d as It, R as fe, e as Gt, p as vt, m as Et, f as Ft, g as At, r as _e, h as jt, j as Rt, k as Ut, l as Mt, n as Ot } from "./raster-pipeline-BVCB-0yq.js";
2
- import { ax as Kt, ay as xt } from "./index-CNRYpb0x.js";
1
+ import { c as se, t as ze, i as wt, a as Be, s as St, C as Pt, b as bt, F as Ct, A as Lt, d as It, R as fe, e as Gt, p as vt, m as Et, f as Ft, g as At, r as _e, h as jt, j as Rt, k as Ut, l as Mt, n as Ot } from "./raster-pipeline-X2-mnFQx.js";
2
+ import { ax as Kt, ay as xt } from "./index-D6R4z3Zs.js";
3
3
  import ye from "./index-CW1n5LdO.js";
4
4
  function Dt(e, t) {
5
5
  const n = e.length / 3, r = new Uint8ClampedArray(n * 4), o = 0, i = n, a = n * 2;
@@ -1070,7 +1070,7 @@ j.set(f.Zstd, () => import("./zstd-jXobGRcq.js").then((e) => e.decode));
1070
1070
  j.set(f.Jpeg, () => Promise.resolve(le));
1071
1071
  j.set(f.Jpeg6, () => Promise.resolve(le));
1072
1072
  j.set(f.Webp, () => Promise.resolve(le));
1073
- j.set(f.Lerc, () => import("./lerc-Bb2JZ92v.js").then((e) => e.l).then((e) => e.decode));
1073
+ j.set(f.Lerc, () => import("./lerc-BdHcL8pi.js").then((e) => e.l).then((e) => e.decode));
1074
1074
  async function ce(e, t, n) {
1075
1075
  const r = j.get(t);
1076
1076
  if (!r)
@@ -1,5 +1,5 @@
1
1
  import { w as de } from "./mgrs-BY9bIvp4.js";
2
- import { am as he, an as fe, ao as se, ap as oe, aq as me, b as pe, ar as ge, l as Z, as as ve, d as be, at as xe, au as Pe, av as ye, aw as ie } from "./index-CNRYpb0x.js";
2
+ import { am as he, an as fe, ao as se, ap as oe, aq as me, b as pe, ar as ge, l as Z, as as ve, d as be, at as xe, au as Pe, av as ye, aw as ie } from "./index-D6R4z3Zs.js";
3
3
  function Te(r, e, t) {
4
4
  const { projectedCorners: n } = e, { topLeft: s, topRight: o, bottomRight: i, bottomLeft: a } = n, c = t(s[0], s[1]), u = t(o[0], o[1]), l = t(i[0], i[1]), h = t(a[0], a[1]), f = [
5
5
  c,
@@ -283,6 +283,8 @@ export declare class RuntimeCore {
283
283
  private drawCaptureActive;
284
284
  /** setDragPan's last value, inverted — see standaloneControllerOverrides. */
285
285
  private dragPanSuppressed;
286
+ /** setMaxPitch's last value — the controller's tilt ceiling, or null for the default. */
287
+ private maxPitch;
286
288
  /** Retained descriptors — what the per-frame channel re-applies against. */
287
289
  private lastIRs;
288
290
  /** layerId → effect-driven plain-prop patches (the per-frame channel). */
@@ -592,6 +594,36 @@ export declare class RuntimeCore {
592
594
  }>;
593
595
  /** Force one renderer draw without capturing (issue #37 — whenSettled's completed-render guarantee; a fresh page's first capture otherwise misses content that has never drawn). */
594
596
  forceDraw(): void;
597
+ /**
598
+ * Serializes capture-resolution overrides: one at a time per core, because
599
+ * the override mutates shared renderer state and two concurrent exports
600
+ * would each restore the other's ratio.
601
+ */
602
+ private captureRatioQueue;
603
+ /**
604
+ * Run `body` with the renderer temporarily rasterizing at `scale` captured
605
+ * pixels per CSS pixel (spec: "Snapshot API" — the `snapshot({scale})`
606
+ * seam). Restores the display resolution afterwards, always.
607
+ *
608
+ * Three things make this correct rather than merely plausible, each learned
609
+ * from the machinery it touches:
610
+ *
611
+ * 1. It is a SCOPED override, not a per-capture argument, because
612
+ * `captureStable()` calls `snapshot()` repeatedly in its own loop —
613
+ * a per-call parameter could never reach those inner captures.
614
+ * 2. It re-awaits `whenSettled()` after changing the ratio. The basemap
615
+ * path captures inside a single `map.once("render")`, which fires long
616
+ * before a re-rasterized style has redrawn its tiles and glyphs.
617
+ * 3. The adapter suppresses the camera-move events MapLibre's resize()
618
+ * emits, so a host app never sees a phantom pan mid-export.
619
+ *
620
+ * Deliberately NOT a DOM resize of the map element: that would change the
621
+ * camera's viewport, re-trigger widget fold, and alter which tiles load —
622
+ * a different picture, not a sharper one.
623
+ */
624
+ withCapturePixelRatio<T>(scale: number, body: () => Promise<T>, opts?: {
625
+ timeout?: number;
626
+ }): Promise<T>;
595
627
  snapshot(): Promise<HTMLCanvasElement>;
596
628
  /**
597
629
  * Live basemap change (spec: "Basemap presets & switching"), both paths:
@@ -684,6 +716,16 @@ export declare class RuntimeCore {
684
716
  */
685
717
  setDragPan(active: boolean): void;
686
718
  private standaloneControllerOverrides;
719
+ /**
720
+ * Cap how far the user can tilt, in degrees, or null for the default.
721
+ *
722
+ * On the CONTROLLER rather than the camera, in both modes: clamping a pitch
723
+ * after the gesture produced it makes the view overshoot and spring back,
724
+ * while a maximum simply refuses to go further. A cartograph frame uses this
725
+ * — it can only georeference a tilt its own shape allows, and beyond that the
726
+ * frame has no scale bar, north arrow or graticule at all.
727
+ */
728
+ setMaxPitch(maxPitch: number | null): void;
687
729
  /**
688
730
  * Used by the built-in `zoom-controls` widget's emitted zoom-in/zoom-out
689
731
  * intents. Programmatic, so — unlike a user drag/scroll — standalone mode
@@ -10,6 +10,23 @@ export interface SnapshotOptions {
10
10
  quality?: number;
11
11
  /** Output shape — default "dataURL" (what print/export pipelines embed directly); "blob" for uploads/files. */
12
12
  as?: "dataURL" | "blob";
13
+ /**
14
+ * Capture resolution as ABSOLUTE captured-bitmap pixels per CSS pixel of the
15
+ * map's layout box — not a multiplier on the current device ratio. A 190mm
16
+ * frame printed at 300 dpi wants `scale: 300/96`, whatever screen it was
17
+ * composed on.
18
+ *
19
+ * Omitted (the default) the capture happens at whatever the renderer is
20
+ * already drawing at, byte-for-byte the behaviour every existing caller
21
+ * gets. Supplied, the renderer is temporarily re-rasterized at that ratio,
22
+ * allowed to reconverge, captured, and restored — so a print export is a
23
+ * genuine high-resolution render, not an upscaled screenshot.
24
+ *
25
+ * Real ceilings apply (GPU max texture size, canvas area); the renderer
26
+ * clamps and the resulting capture is simply smaller than asked for, which
27
+ * `renderCartograph` surfaces as an effective-dpi warning.
28
+ */
29
+ scale?: number;
13
30
  }
14
31
  export declare function serializeSnapshot(canvas: HTMLCanvasElement, opts: SnapshotOptions): Promise<string | Blob>;
15
32
  /**
package/dist/units.d.ts CHANGED
@@ -55,6 +55,15 @@ export declare function densityToKgM3(raw: number, system: UnitSystem): number;
55
55
  * correct for sub-unit spans (`value < 1`) where a digit-count rule breaks.
56
56
  */
57
57
  export declare function niceNumber(value: number): number;
58
+ /**
59
+ * The largest 1/2/5 ×10ⁿ value ≤ `value`.
60
+ *
61
+ * Printed scale bars and coordinate grids use the stricter cartographic
62
+ * series; the interactive measure widget keeps {@link niceNumber}'s denser
63
+ * 1/2/3/5 series. Keeping both policies here makes that distinction explicit
64
+ * without duplicating the logarithmic snapping maths.
65
+ */
66
+ export declare function niceNumberBelow(value: number): number;
58
67
  /**
59
68
  * Scale-bar helper: given the max distance (meters) that fits the bar's pixel
60
69
  * budget, return the nice round distance to actually draw and its label, in the
package/dist/version.d.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  * the build rootDir, and a `define` would need repeating across vite/vitest/
6
6
  * vite-node configs.
7
7
  */
8
- export declare const LIBRARY_VERSION = "0.7.6";
8
+ export declare const LIBRARY_VERSION = "0.8.1";
@@ -1,5 +1,5 @@
1
- import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, j as pr, k as mr } from "./raster-pipeline-BVCB-0yq.js";
2
- import { ap as gr } from "./index-CNRYpb0x.js";
1
+ import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, j as pr, k as mr } from "./raster-pipeline-X2-mnFQx.js";
2
+ import { ap as gr } from "./index-D6R4z3Zs.js";
3
3
  import $t from "./index-CW1n5LdO.js";
4
4
  var Et;
5
5
  function h(e, t, n) {
@@ -0,0 +1,396 @@
1
+ # Cartographs — print-composition pages
2
+
3
+ > **Available in 0.8.0.** This document is the
4
+ > normative element/attribute reference for the `@nika-js/onlymap/cartograph` entry
5
+ > (issue [onlymap-js#41](https://github.com/NikaGeospatial/onlymap-js/issues/41)).
6
+ > **Phases 0–4 are implemented**: the page, static AND live georeferenced frames, the
7
+ > locator inset, scale bar, north indicator, derived legend, graticule, colour-vision
8
+ > simulation, shapes, images, text with tokens, atlas iteration, the validator, print-DPI
9
+ > export and printing, and the URL triggers. Consumers:
10
+ > [nika-agent#68](https://github.com/NikaGeospatial/nika-agent/issues/68) (whose §2 defers
11
+ > to this document) and qgis2carto.
12
+
13
+ A **cartograph** is a standalone HTML file describing a printed page of maps: a page in
14
+ millimetres carrying placed map frames (live `<om-map>` mounts or georeferenced rasters)
15
+ and cartographic furniture — legend, scale bar, north indicator, graticule, text, shapes,
16
+ images — plus atlas iteration. It prints
17
+ to PDF at true page size via CSS `@page` and exports PNG/JPEG at print DPI through
18
+ `renderCartograph()`.
19
+
20
+ ```html
21
+ <!doctype html>
22
+ <html>
23
+ <head>
24
+ <meta charset="utf-8">
25
+ <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.8.0/dist/cartograph.standalone.js"></script>
26
+ <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.8.0/dist/cartograph.css">
27
+ </head>
28
+ <body>
29
+ <om-cartograph cartograph-id="cg-lot-12" format="cartograph/2" size="A4" theme="minimal"
30
+ title="Lot 12 — parcel survey" attribution="© Survey Dept 2026">
31
+
32
+ <om-text id="title" kind="title" x="10" y="8" w="150" h="12">{{title}}</om-text>
33
+
34
+ <!-- A georeferenced raster. The CORNERS place it, not the pixels. -->
35
+ <om-frame id="main" mode="static" x="10" y="24" w="190" h="190"
36
+ crs="EPSG:3857" corners="[[103.80,1.32],[103.82,1.32],[103.82,1.30],[103.80,1.30]]"
37
+ style="left:10mm;top:24mm;width:190mm;height:190mm">
38
+ <img src="lot-12.png" alt="">
39
+ </om-frame>
40
+
41
+ <om-scalebar id="bar" for="main" x="10" y="222" w="70" h="9" kind="double" units="metric"></om-scalebar>
42
+ <om-north id="north" for="main" x="186" y="220" w="14" h="18" kind="rose"></om-north>
43
+ <om-text id="credit" kind="attribution" x="10" y="240" w="190" h="6">
44
+ 1:{{frame.main.scale}} · {{frame.main.crs}} · {{attribution}}
45
+ </om-text>
46
+ </om-cartograph>
47
+ </body>
48
+ </html>
49
+ ```
50
+
51
+ ## Ground rules
52
+
53
+ - **Millimetres everywhere.** Every placed element carries `x y w h` in mm from the page's
54
+ top-left, plus `rotation` (degrees clockwise), `z` (stacking override — document order is
55
+ z-order by default), `locked` (editor-only, inert), `hidden` (skipped in render and print).
56
+ CSS defines 1mm ≡ 96/25.4 px, so screen preview and print agree by construction.
57
+ - **Attributes are the source of truth.** The runtime re-asserts inline mm styles from the
58
+ placement attributes on every change. Producers should ALSO write those inline styles
59
+ (as in the example above) so the page positions correctly with JavaScript disabled; the
60
+ validator raises an **error** if the two ever disagree.
61
+ - **`kind`, never `style`.** Every discriminator — text role, shape type, scale-bar style,
62
+ north-indicator style, graticule style — is spelled `kind`. `style` is the global HTML
63
+ attribute the runtime writes placement into, so a discriminator living there would be
64
+ erased on the first render.
65
+ - **One package, zero cost to maps.** The cartograph is a separate build entry; the core map
66
+ bundle does not grow, and a cartograph whose frames are all static never loads the map
67
+ runtime at all (CI-enforced by `dev/assert-bundle-cdn-safe.mjs`).
68
+ - **Themes are CSS.** `theme=` selects a set of `--om-carto-*` custom properties (`default`,
69
+ `dark`, `sepia`, `blueprint`, `minimal`); a user theme is a `<style>` block overriding the
70
+ same properties. Furniture reads its **computed** colour, so an exported PNG matches the
71
+ screen without a second theme table.
72
+
73
+ ## Elements
74
+
75
+ The authoritative attribute lists (with editor IntelliSense) are generated from
76
+ `src/cartograph/schema.ts` into `onlymapjs.html-data.json`.
77
+
78
+ | Element | Purpose | Status |
79
+ |---|---|---|
80
+ | `<om-cartograph>` | Page root: `cartograph-id`, `format="cartograph/2"`, `size` (A5–A0/Letter/Legal) or `width`/`height` (mm), `orientation`, `theme`, `title`, `attribution`; print controls `bleed`, `crop-marks`, `safe-zone`, `dpi`, `flatten`, `color-profile`; `cvd`; `allow-url-actions` | implemented |
81
+ | `<om-frame>` | A placed map. `mode="static"`: a georeferenced `<img>` child placed by `crs` + `corners`. `mode="live"` (the default): mounts `<om-map>` from `src=` or an inline child with the frame's own `center/zoom/bearing/pitch`; `overview-of` draws another frame's footprint | implemented |
82
+ | `<om-text>` | Text block — `kind` (title/text/attribution), `font-size` (pt), `weight`, `align`, `font`, `color`, `bg`; body may use `{{tokens}}` | implemented |
83
+ | `<om-scalebar>` | Scale bar — `for`, `units` (metric/imperial/nautical), `segments`, `segment-length`, `kind` (single/double/line/ticks) | implemented |
84
+ | `<om-north>` | North indicator — `for`, `kind` (arrow/rose). Direction is derived from the frame's georeference | implemented |
85
+ | `<om-shape>` | `kind` (rect/ellipse/line/arrow), `fill`, `fill-opacity`, `stroke`, `stroke-width`, `radius` | implemented |
86
+ | `<om-image>` | Logo or photo — `src`, `fit` (contain/cover/fill) | implemented |
87
+ | `<om-legend>` / `<om-legend-row>` | Derived legend — `for`, `title`, `columns`, `derived`; rows computed from the frame's layers, never stored. Children are overrides (`layer`, `match`, `label`, `hide`, `order`, `color`, `shape`), or literal rows when `derived="false"` | implemented |
88
+ | `<om-graticule>` | Coordinate grid — `for`, `crs`, `interval`/`-x`/`-y`, `kind` (solid/cross/markers), `labels`, `format` (`dms`). Geographic by default; a projected `crs` draws that projection's grid | implemented |
89
+ | `<om-atlas>` | One page per feature — `for`, `layer`, `filter`, `sort`, `filename`, `page-name`. Frames follow with `atlas-fit="feature"` + `atlas-margin` | implemented |
90
+
91
+ ## Georeferencing
92
+
93
+ Both frame modes expose one interface, so no piece of furniture needs to know which kind of
94
+ frame it is attached to:
95
+
96
+ ```ts
97
+ interface FrameGeoref {
98
+ crs: string;
99
+ corners: [LngLat, LngLat, LngLat, LngLat]; // tl, tr, br, bl — always lon/lat
100
+ widthMm: number; heightMm: number;
101
+ degenerate: boolean;
102
+ project(lngLat): { xMm, yMm } | null;
103
+ unproject(xMm, yMm): LngLat | null;
104
+ groundMetersPerMm(): number;
105
+ }
106
+ ```
107
+
108
+ **The homography is fitted in the frame's CRS plane, never in raw lon/lat.** A map image is
109
+ a projective transform of its own projection plane; it is *not* a projective transform of
110
+ longitude/latitude, because Mercator's `y = R·ln(tan(π/4+φ/2))` is nonlinear in latitude. A
111
+ fit in degrees reproduces the four corners exactly and misplaces everything between them —
112
+ on a frame spanning 40° of latitude, the centre lands ~11 mm out on an A4 page. So:
113
+
114
+ ```
115
+ project(lngLat) = H · crsForward(lngLat)
116
+ ```
117
+
118
+ `crsForward` is closed-form for EPSG:3857 (spherical Mercator) and EPSG:4326 (plate carrée,
119
+ where the forward *is* identity — which is why a naive lon/lat fit happens to be correct for
120
+ equirectangular rasters and wrong for everything else). Any other projected CRS resolves
121
+ through the core's lazy proj4; `crs-def="+proj=…"` covers CRSs the library does not bundle.
122
+
123
+ `corners` are authored in lon/lat in tl, tr, br, bl order, so a producer never needs proj4
124
+ to place a frame. Longitudes are unwrapped around the first corner, so an
125
+ antimeridian-crossing frame fits a continuous plane. A collinear or coincident quad is
126
+ `degenerate`: furniture refuses to draw rather than drawing something wrong, and the
127
+ validator raises an error.
128
+
129
+ ## Live frames
130
+
131
+ A live frame mounts the real map runtime with the FRAME's own camera:
132
+
133
+ ```html
134
+ <om-frame id="main" x="10" y="24" w="150" h="150"
135
+ center="[103.81, 1.31]" zoom="14" bearing="0" pitch="0"
136
+ src="../maps/map-abc.html"></om-frame>
137
+
138
+ <!-- A second frame on the same map, wider, drawing the first one's footprint. -->
139
+ <om-frame id="locator" x="165" y="24" w="35" h="28"
140
+ center="[103.81, 1.31]" zoom="9" src="../maps/map-abc.html"
141
+ overview-of="main" overview-stroke="#e63946"></om-frame>
142
+ ```
143
+
144
+ - **The frame's camera wins.** The referenced document's own `center`/`zoom` are ignored,
145
+ which is what lets the same map appear twice on one page at different extents.
146
+ - **Corners come from the camera**, through the same `WebMercatorViewport` the renderer
147
+ uses, so the furniture agrees with the pixels. A view pitched past roughly 60° cannot be
148
+ georeferenced as a quad — its top edge reaches the horizon — and the frame reports itself
149
+ `degenerate` rather than inventing coordinates.
150
+ - **`src=` documents are adopted, not injected.** The fetched subtree is rebuilt element by
151
+ element: `<script>` blocks are dropped except the inert `application/json` ones that carry
152
+ inline layer data, and `on*` handler attributes never survive. An inline `<om-map>` child
153
+ is *not* sanitized — the author already owns that document. Cross-origin `src=` needs CORS.
154
+ - **The frame's keyline is an inset shadow, not a border**, so `w`/`h` is exactly the mapped
155
+ extent. (A border would shrink the content box and put a silent ~0.3% error into
156
+ everything derived from the georeference.)
157
+ - Frames expose `ready` (settling on success *or* failure) and fire `om-frame-ready` /
158
+ `om-frame-error`; a failed frame shows a visible inline placeholder rather than an empty
159
+ box, which on a printed page is indistinguishable from a design choice.
160
+
161
+ ### Export and print resolution
162
+
163
+ `renderCartograph()` and `print()` capture each live frame through the core's
164
+ `snapshot({ scale })` — a genuine re-render at the target resolution, not an upscaled
165
+ screenshot. `scale` is absolute captured pixels per CSS pixel (300 dpi is `300/96`), so an
166
+ export is identical whatever screen composed it. The renderer is restored to display
167
+ resolution before the call resolves.
168
+
169
+ Printing goes through `cartographEl.print({ dpi })` (or `?print=1`), which swaps each live
170
+ canvas for a print-resolution `<img>` first and restores it on `afterprint`. This is
171
+ necessary, not decorative: a WebGL canvas prints blank or at screen resolution, because its
172
+ drawing buffer is not preserved and the print rasterizer never re-renders it. An unassisted
173
+ Ctrl+P therefore cannot produce a high-DPI page — the print dialog opens synchronously and
174
+ captures are asynchronous.
175
+
176
+ ## The derived legend
177
+
178
+ `<om-legend for="main">` computes its rows from the frame's map at render time and never
179
+ stores them, so the legend cannot drift out of agreement with the map it describes.
180
+
181
+ Rows come from the mounted map's own IR (`getLayers()` → `meta.legend`, `meta.label`,
182
+ `meta.color`) — the same values the core's legend widget reads, so both legends describing
183
+ one map say the same thing. That is also why rows are NOT re-derived from the authored
184
+ colour attributes: `classify=` computes its class breaks from the *data*, and rasters
185
+ resolve their domain at load, so a static re-read would silently disagree for exactly the
186
+ layers whose symbology was computed rather than authored.
187
+
188
+ Swatch geometry carries meaning and follows the layer type: an area chip for polygons, a
189
+ bar for lines, a dot for points, a gradient for rasters. `<om-legend-row>` children are
190
+ overrides:
191
+
192
+ ```html
193
+ <om-legend for="main" title="Legend" x="266" y="70" w="50" h="80">
194
+ <om-legend-row layer="parcels" label="Zoning"></om-legend-row>
195
+ <om-legend-row layer="parcels" match="residential" label="Housing"></om-legend-row>
196
+ <om-legend-row layer="basemap-labels" hide></om-legend-row>
197
+ </om-legend>
198
+ ```
199
+
200
+ `match=` targets one category or class entry, keyed on its **original** label — so
201
+ relabelling an entry does not break the reference to it. Categories beyond 12 collapse into
202
+ a "+N more" row. A static frame has no layers to derive from: there the producer writes
203
+ literal rows and sets `derived="false"` (the validator warns if a derived legend points at
204
+ a static frame).
205
+
206
+ ## Graticules
207
+
208
+ `<om-graticule for="main">` draws a coordinate grid. With no `crs` it draws meridians and
209
+ parallels at whole degrees; give it a projected `crs` and it draws that projection's grid —
210
+ a UTM sheet's kilometre squares — resolving proj4 lazily, so a page that only wants degrees
211
+ never loads it. `interval` is in the grid's own units (degrees or metres), and an omitted
212
+ interval is chosen automatically on a 1-2-5 series.
213
+
214
+ Lines are **densified**, not drawn corner to corner: a straight line in one coordinate
215
+ system is a curve in another, and a two-point meridian would visibly bow away from the truth
216
+ on a wide or rotated frame. `kind` selects solid lines, crosses at the intersections, or
217
+ edge markers; `labels` places them outside (the default), inside, or not at all; and
218
+ `format="dms"` prints degrees-minutes-seconds.
219
+
220
+ ## Colour-vision simulation
221
+
222
+ `cvd="deuteranopia"` (or `?cvd=`, boot-only) renders the whole page as a reader with that
223
+ condition sees it — a review aid for the moment before printing, when the palette can still
224
+ change.
225
+
226
+ `lintLegendColours(entries)` answers the question that matters: would any two legend entries
227
+ become indistinguishable? It simulates each of the four conditions and reports pairs whose
228
+ CIEDE2000 distance falls below 10, naming the worst condition. Pairs already too close for
229
+ normal vision are skipped — that is a different palette bug, and reporting it here would
230
+ send the author looking in the wrong place. The validator runs this over literal legend rows
231
+ automatically.
232
+
233
+ The matrices are Viénot, Brettel & Mollon (1999) applied in **linear** RGB. That combination
234
+ is load-bearing: the widely-copied matrices that operate on sRGB directly are invertible, so
235
+ no two colours ever map to the same output and a lint built on them can never report a
236
+ collapse at all.
237
+
238
+ ## Atlas — one page per feature
239
+
240
+ The labour multiplier the format exists for: one sheet per parcel, per corridor segment,
241
+ per site, from a single authored page.
242
+
243
+ ```html
244
+ <om-frame id="main" x="8" y="20" w="132" h="120"
245
+ center="[103.81, 1.31]" zoom="13"
246
+ atlas-fit="feature" atlas-margin="0.25"> … </om-frame>
247
+
248
+ <om-atlas for="main" layer="parcels" sort="lot" filter="$area > 500"
249
+ filename="lot-{{atlas.lot}}-{{atlas.name}}" page-name="{{atlas.name}}"></om-atlas>
250
+ ```
251
+
252
+ Seeking a page refits every frame with `atlas-fit="feature"` to that feature's bounds (padded
253
+ by `atlas-margin`), re-resolves `{{atlas.<field>}}` tokens, re-derives every scale bar — the
254
+ scale genuinely changes per page, so a bar sized for one sheet would lie on the next — and
255
+ fires `om-atlas-page`. The page element exposes `cartographEl.atlas` with `count`, `index`,
256
+ `current`, `seek(i)`, `next()`, `filenames()` and `pageName(i)`.
257
+
258
+ Three rules make an unattended N-page export trustworthy:
259
+
260
+ - **The map's own filtering applies.** A layer's declarative `filter`/`categoryFilter` hide
261
+ features from the map, so paging over the raw rows would print sheets for features the map
262
+ is not drawing. `filter=` on the atlas is an additional predicate in the **core expression
263
+ language** — the same syntax `om-layer` filters use.
264
+ - **Pages are snapshotted when iteration starts**, so a streaming or polling layer cannot
265
+ change the page count halfway through an export.
266
+ - **A layer with no local rows cannot drive an atlas.** Tiled and asset-backed layers keep
267
+ their features on a server; that is a validation error, not an empty run.
268
+
269
+ `sort="lot"` / `"-lot"` (comma-separated for several keys) orders the pages, with features
270
+ missing the key last in **both** directions. `filename=` is a token pattern, sanitized for
271
+ the filesystem, and duplicates are suffixed so two features with the same name cannot
272
+ silently overwrite each other.
273
+
274
+ ### Exporting an atlas
275
+
276
+ ```ts
277
+ const { blob, filenames, pages } = await renderCartographAtlas(page, { dpi: 300 });
278
+ // blob is ONE zip; pass { page: 2 } for a single sheet (the ?page= host contract).
279
+ ```
280
+
281
+ Pages render **sequentially through the one live map** — seeking moves the existing frames
282
+ rather than cloning the page. Cloning would mount a WebGL context per page, and browsers cap
283
+ live contexts at roughly 8–16, so an atlas of any real size would quietly start emitting
284
+ blank sheets. The result is a single ZIP because browsers block or prompt on a loop of N
285
+ downloads; entries are stored uncompressed, since PNGs are already compressed and a
286
+ compression library has no business in a bundle this size.
287
+
288
+ ## Programmatic surface
289
+
290
+ ```ts
291
+ import {
292
+ renderCartograph, renderCartographAtlas,
293
+ validateCartograph, validateCartographString,
294
+ createFrameGeoref, buildScalebar, lintLegendColours, applyUrlActions,
295
+ } from "@nika-js/onlymap/cartograph";
296
+
297
+ // Export at print resolution. Static-only pages need no map runtime.
298
+ const { blob, widthPx, heightPx, dpi, warnings } = await renderCartograph(page, { dpi: 300 });
299
+
300
+ // Validate — the core's ValidationResult shape, so a host folds cartograph
301
+ // diagnostics into agent tool results with the code it already has for maps.
302
+ const { valid, errors, warnings: warns } = validateCartographString(html);
303
+ ```
304
+
305
+ `renderCartograph` clamps to the browser's canvas budget rather than failing silently, and
306
+ reports the resolution it actually delivered in `dpi` plus a message in `warnings`. Rendering
307
+ the same static page twice produces byte-identical output on the same browser build
308
+ (cross-browser identity is not claimed — canvas PNG encoding is unspecified).
309
+
310
+ **Validation** is synchronous and headless (it needs a DOM implementation for `DOMParser`
311
+ in node — happy-dom or jsdom, exactly like the core's `validateManifestString`). Inline
312
+ `<om-map>` manifests are deliberately **not** deep-validated there, because delegating would
313
+ load the whole map runtime just to check a document. Hosts that already have the core loaded
314
+ use `await validateCartographDeep(root)`, which adds the inline manifests' own entries
315
+ prefixed with their frame (`om-frame#main > om-layer#roads: …`).
316
+
317
+ ## Tokens
318
+
319
+ Resolved at render time, in order: atlas feature → frame → page.
320
+
321
+ | Token | Resolves to |
322
+ |---|---|
323
+ | `{{title}}` `{{attribution}}` `{{notes}}` | the matching `<om-cartograph>` attribute |
324
+ | `{{date}}` | today, ISO (`2026-09-08`) |
325
+ | `{{frame.<id>.scale}}` | that frame's representative-fraction denominator, e.g. `25 000` |
326
+ | `{{frame.<id>.crs}}` | that frame's CRS |
327
+ | `{{scale}}` `{{crs}}` | the sole frame's values — ambiguous (and warned) when the page has more than one frame |
328
+ | `{{atlas.<field>}}` | the current atlas feature's field, plus `{{atlas.index}}` and `{{atlas.count}}` |
329
+
330
+ Substitution is **text-node only**: a token value containing markup lands as text, never as
331
+ live DOM. Unknown tokens render literally and are reported by the validator.
332
+
333
+ ## Host triggers (URL actions)
334
+
335
+ For hosts with no embedded browser (the QGIS plugin), the query string is the automation
336
+ surface. They are **opt-in**: a cartograph acts on them only when its root carries
337
+ `allow-url-actions`, so hosting a cartograph file does not expose drive-by print or download.
338
+
339
+ | Parameter | Effect |
340
+ |---|---|
341
+ | `?export=png&dpi=N` | waits for frames + fonts, renders, downloads. The fully automatable path. `format` may also be `jpeg`. |
342
+ | `?export=png&page=N` | one sheet of an atlas, **numbered from 1** like `{{atlas.index}}` and the page filenames. Errors on a page the atlas does not have, or on a cartograph with no `<om-atlas>` — a plugin driving a run sheet by sheet must not receive sheet 1 five times. |
343
+ | `?print=1` | waits, then opens the print dialog. Human-in-the-loop by nature — no host can drive a print dialog to a saved PDF unattended. |
344
+ | `?cvd=<type>` | boot-only colour-vision simulation; never persisted by an editor. |
345
+
346
+ All of them are ignored when the embedding page sets `data-om-host="editor"` on `<html>`.
347
+
348
+ ## Printing
349
+
350
+ `<om-cartograph>` injects `@page { size: <w>mm <h>mm; margin: 0 }` from its own page box
351
+ (plus `bleed`, when set). **Chromium honors `@page size`, so print-to-PDF is true physical
352
+ size.** Firefox and Safari do not support the `size` descriptor and print to the selected
353
+ paper with scaling — a documented limitation, not a bug to work around. Furniture is SVG and
354
+ text in the DOM, so PDF text stays selectable text.
355
+
356
+ ## Print-shop attributes
357
+
358
+ | Attribute | Effect |
359
+ |---|---|
360
+ | `bleed="3"` | Grows the printed sheet 3mm on every side. The page box stays the TRIM size, so a placed element's `x`/`y` are still measured from the trim corner; content is simply allowed to spill into the bleed, which is the point of it. |
361
+ | `crop-marks` | Trim marks at the four corners, drawn **in the bleed** — so it needs `bleed` to have anywhere to go (the validator says so). |
362
+ | `safe-zone="8"` | An on-screen guide showing where content risks being trimmed. **Never printed and never exported** — a guide on the deliverable is worse than no guide. |
363
+ | `flatten` | Print one raster instead of a page of text, SVG and images. Loses selectable text, so it is opt-in — but some print RIPs mishandle SVG or CSS filters, and a flattened sheet that prints correctly beats a vector one that does not. |
364
+ | `color-profile` | Informational tag only. The runtime never converts colour. |
365
+
366
+ ## No-JS fallback
367
+
368
+ A static-only cartograph reads correctly with JavaScript disabled — iOS QuickLook, mail and
369
+ chat previews, file managers. `om-cartograph:not(:defined)` renders the page-shaped block;
370
+ static frames' `<img>` children, text and shapes show at the producer-written inline mm
371
+ positions; live-frame internals stay hidden. This mirrors `src/fallback.css`, including the
372
+ `@media (scripting:)` branches that stop a banner flashing during a slow load.
373
+
374
+ ## Licensing and telemetry
375
+
376
+ On the free plan every cartograph carries a small foot credit — "Built with
377
+ OnlyMap by NIKA · free for non-commercial use" — injected at the page's
378
+ bottom-left. It is part of the free license's attribution condition, so it is
379
+ included in print and PNG export. Two ways it lifts:
380
+
381
+ - **Author your own credit.** Any `<om-text>` whose text mentions OnlyMap
382
+ (e.g. `rendered with @nika-js/onlymap/cartograph` in an attribution line)
383
+ satisfies the condition and suppresses the injected one — style it however
384
+ the sheet needs.
385
+ - **A paid plan.** When a live frame's map runtime verifies a paid key, the
386
+ credit is removed (a static-only page loads no runtime, so the credit
387
+ stays; the map inside a live frame is what carries `license-key`).
388
+
389
+ Telemetry: a live frame's map beacon includes `cartograph` in its feature
390
+ census — same endpoint, nothing new sent. Static-only pages load no runtime
391
+ and send nothing.
392
+
393
+ ## Non-goals
394
+
395
+ SVG export; multi-page non-atlas documents (`om-page` is reserved-inert); projections other
396
+ than Web Mercator on **live** frames; automatic label placement.
package/llms.txt CHANGED
@@ -43,6 +43,7 @@ Programmatic/native bridge rule: `MapController.setLayers()` accepts normal func
43
43
  - `<om-behavior on="click|hover|drag|load|data-loaded" layer="..." action="...">` — declarative interaction. Built-in actions: `show-overlay`, `hide-overlay`, `show-tooltip`, `hide-tooltip`, `toggle-layer`, `set-pickable` (`{layer, pickable: true|false|"3d"}` — runtime picking/popup toggle, story-capturable; the per-layer popup on/off switch a viewer-facing export needs), `filter-layer`, `highlight-feature`, `zoom-to-feature`, `set-basemap`, `undo`, `redo`; scene/tool actions `set-lighting`, `set-terrain`, `set-clip-box` (`{min,max,invert?,highlight?}` / `{clear:true}`), `clip-box-edit` (`{editing}`), `export-region-3d` (`{target?, format?:"glb"|"b3dm"}` — what the draw widget's `export-3d` button emits), the draw actions `draw-mode` (`{target?:"sketch", mode:"point"|"line"|"polygon"|null}` — null exits drawing), `draw-commit`, `draw-cancel`, `draw-delete` (removes the last shape), `draw-clear`, `draw-config` (`{target?, autosave?, fillColor?, lineColor?}`) and `draw-save` (`{target?, format?:"download"|"file-system"|"both"}`) — every one of these drives the same store a `data="draw:<target>"` layer reads, so a custom toolbar can replace `<om-widget type="draw">` entirely; and the measure actions `measure-mode` (`{mode:"distance"|"area"|"volume"|null}`), `measure-units`, `measure-clear`, `measure-config` (`{profile?, baseSurface?, density?, swell?, shrink?, deadband?}`), `measure-flat-target-plane` (`{flat}`). One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
44
44
  - Undo/redo is built in: user-facing manifest changes (layer toggles, filter changes, basemap switches, element add/remove, drawn sketches) are recorded automatically — the manifest is the state. `<om-widget type="undo-redo">` renders the buttons; Cmd/Ctrl-Z, Shift-Cmd/Ctrl-Z, and Ctrl-Y work on any map (text inputs keep their native undo). Camera moves, hover effects, and story playback are deliberately NOT undo steps. Widget scripts: `ctx.history.canUndo/canRedo` with watch token `history`; `ctx.emit("undo")`/`ctx.emit("redo")`.
45
45
  - `<om-fallback>` — static no-JS fallback, direct child of `<om-map>` (one per map, no attributes, plain HTML content — links allowed). Shown ONLY where scripts never run (chat-app/email file previews — iOS QuickLook renders HTML attachments with JS off — file managers, sandboxed webviews); hidden automatically once the map boots. GOOD PRACTICE: include one on every complete page, especially pages that may be shared as a file. To PRODUCE a shareable single file, `npx @nika-js/onlymap export map.html` embeds every relative data/src/scenegraph URL as a data: URL (format detection preserved — the original extension rides a #name.ext fragment), pins relative library refs to the CDN, adds a generic om-fallback when missing, and warns when embedded data is heavy (>1 MB per file / >5 MB total) or when a source cannot be embedded (shapefile sidecars, COG Range streams, Zarr directories, {z}/{x}/{y} templates — those need hosting) ("This interactive map requires JavaScript — open this file in a web browser", plus a hosted-version link when one exists). Without one, the stylesheet shows a generic text-only banner. The gate is pure CSS (`om-map:not(:defined)` in onlymapjs.css) refined by the `scripting` media feature on 2023+ engines: with scripting DISABLED the fallback shows instantly; with scripting ENABLED it never flashes during a slow load and only surfaces after a ~4s grace with load-failure wording (blocked/unreachable bundle). The CSS must load without JS — a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages; a bundler-emitted stylesheet is fine in npm projects.
46
+ - PRINT LAYOUTS (`@nika-js/onlymap/cartograph`, separate lazy entry — the core bundle does not grow): `<om-cartograph size="A3" orientation="landscape">` is a standalone page measured in MILLIMETRES carrying `<om-frame>`s (live `<om-map>` with the frame's own camera, or `mode="static"` georeferenced rasters placed by `crs`+`corners`) plus cartographic furniture: `<om-legend for="...">` (rows DERIVED from the live map's own symbology incl. classified breaks; author literal `<om-legend-row>`s with `derived="false"`), `<om-scalebar>` (metric|imperial|nautical), `<om-north>`, `<om-graticule>` (4326/3857/UTM), `<om-shape>`, `<om-image>`, `<om-text>` with `{{title}}`/`{{date}}`/`{{frame.<id>.scale}}`/`{{atlas.<field>}}` tokens. `overview-of="<frame-id>"` draws a locator inset's footprint. A live frame caps the tilt of the map it adopts (`max-pitch` on `<om-map>`, also settable by hand for kiosk maps) at the steepest pitch its own box can still georeference — beyond it the frame's top edge reaches the horizon and it loses scale bar, north, graticule and export; the limit rises with zoom and with a wider box. `<om-atlas for="<frame-id>" layer="<layer-id>">` renders one page per feature (sequentially through ONE live map — WebGL context limits make cloned pages fail at scale) and exports a ZIP. Print via `page.print()` or `?print=1` (true page size via @page; live frames swap to print-DPI captures first), PNG via `renderCartograph(page, {dpi})` or `?export=png&dpi=300`. `bleed`/`crop-marks`/`safe-zone` (screen-only guide)/`flatten` are press-shop attributes; `cvd="deuteranopia|protanopia|tritanopia|achromatopsia"` simulates colour-vision deficiencies page-wide and `lintLegendColours()` flags collapsing palettes. Free plan: a small foot credit is injected — suppressed by any authored `om-text` crediting OnlyMap, or by a paid key on a live frame's map. Deep reference: docs/cartograph.md.
46
47
  - Animation: `transition="get-fill-color 800ms, get-radius 400ms"` on a layer GPU-animates prop changes (also smooths streaming updates via `get-position`). Camera: the `fly-to` action takes `center`/`zoom`/`pitch`/`bearing`/`duration` (e.g. `duration="2s"`) — use it in behaviors or `data-emit` buttons; `zoom-to-feature` also accepts `duration`.
47
48
  - Pull-model frame rendering (external video frameworks such as Remotion): mark the map `data-om-recording` (the determinism switch — instant camera, no transitions/gesture interrupts, byte-stable `snapshot()`), then per frame `story.seek(t, {interpolateCamera: true})` + `await mapEl.whenSettled()` + capture; the same frame is byte-identical across processes and orderings. Story effect verbs (fade/pulse/trace/populate) evaluate as pure functions of story time under the switch — a seek landing mid-trace renders the half-drawn outline; only effects dispatched OUTSIDE a story (behaviors, ctx.emit) snap to their end state. `onlymapjs record` remains the built-in push-model path; `@nika-js/onlymap-remotion` packages the pull-model path for Remotion.
48
49
  - Travel-map animation: a `trace` story step takes `follow` (camera rides the drawing tip along the path) and `easing="linear|ease-in|ease-out|ease-in-out"`; combine with a TripsLayer route + pins toggled by later steps, and export with `npx onlymapjs record`.