diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index fe6f682..0bae109 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -50,7 +50,7 @@ // Baking runs server-side; the client only renders tiles. import { PMTilesArchive, registerPmtilesProtocol } from "./pmtiles-source.mjs"; import { convertDistance, unitSuffix } from "../lib/units.mjs"; -import { zoomForScale } from "../lib/util.mjs"; // shared scale↔zoom (512-tile MapLibre resolution) +import { zoomForScale, DEFAULT_PX_PITCH_MM, clampPxPitch } from "../lib/util.mjs"; // shared scale↔zoom (512-tile MapLibre resolution) import * as S52 from "./s52-style.mjs"; import { SpriteBuilder } from "./sprite-builder.mjs"; // Chart SOURCE / ARCHIVE management lives in its own stateful collaborator now (the @@ -68,6 +68,12 @@ import { import { buildChartLayers, PAT_PREFIX } from "./chart-style.mjs"; const FEATURE_SCALE = 0.01 / 0.35278; +// The baker emits feature pixel sizes (icon `scale`, `width_px`, `font_size_px`, +// pattern raster) as if 1 px = 1 typographic point = 0.35278 mm (72 DPI). To render +// at TRUE physical size we multiply every size by 0.35278/pxPitch (see _scaleSizes +// in chart-style.mjs / _featureSizeScale below). On the default CSS pixel (0.2645 mm) +// that is ≈1.333×; a calibrated screen pitch makes it exact. +const BAKED_FEATURE_PITCH_MM = 0.35278; // Linear (constant-velocity) easing for the follow camera — see updateFollow. The // default ease-in/out would stall at each fix boundary, reading as a step. const LINEAR = (t) => t; @@ -128,6 +134,7 @@ export class ChartCanvas extends HTMLElement { this._sprite = {}; this._patterns = {}; this._atlasPpu = 0.08; + this._pxPitch = undefined; // calibrated CSS-pixel pitch (mm); undefined → CSS reference. Drives _featureSizeScale. this._active = "day"; this._spriteImg = null; this._patternsImg = null; @@ -178,6 +185,7 @@ export class ChartCanvas extends HTMLElement { assets, getMap: () => this._map, rebuild: () => this._map && this._map.setStyle(this.buildStyle(), { diff: false, validate: false }), + getPxPitch: () => this._pxPitch, // SCAMIN gates on the calibrated physical scale (in-place re-gate) }); // Shadow DOM: MapLibre CSS must live inside the shadow root, plus a sized @@ -633,6 +641,23 @@ export class ChartCanvas extends HTMLElement { for (const k in osmPaint) setIf("osm", k, osmPaint[k]); } + // Feature-size multiplier that renders baked (point-pixel) sizes at true physical + // size on this screen: 0.35278 mm/baked-px ÷ the (calibrated) CSS-pixel pitch. On + // the default CSS pixel (0.2645 mm) ≈1.333×; calibration makes it exact. + _featureSizeScale() { + return BAKED_FEATURE_PITCH_MM / clampPxPitch(this._pxPitch || DEFAULT_PX_PITCH_MM); + } + + // Set the calibrated CSS-pixel pitch (mm) and rebuild the style so every feature + // size (icons/lines/text/halos/patterns) re-renders at true physical size. Driven + // by the shell's screen-calibration setting, mirroring the scale-readout path. + setPxPitch(mm) { + const v = (typeof mm === "number" && mm > 0) ? mm : undefined; + if (v === this._pxPitch) return; + this._pxPitch = v; + if (this._map && this._sources) this._map.setStyle(this.buildStyle(), { diff: false, validate: false }); + } + // Switch the basemap live: "coastline" (offline GSHHG land/lakes), "osm" // (online OpenStreetMap raster), or "osmvec" (hosted OSM vector .pmtiles). // Rebuilds the style from buildStyle() so the basemap sources/layers swap @@ -1106,12 +1131,17 @@ export class ChartCanvas extends HTMLElement { // setScheme/setMariner) keep reading them unchanged. const scaminLat = this._sources.scaminLat != null ? this._sources.scaminLat : (this._map ? this._map.getCenter().lat : 0); + // True-physical feature sizing: scale the baked (point-pixel) sizes to this + // screen. Recompute the pattern raster ratio to match, so AP fills register at + // the same physical size when styleimagemissing re-fires after setStyle. + const sizeScale = this._featureSizeScale(); + this._patternPixelRatio = (0.08 / FEATURE_SCALE) / sizeScale; const { layers: chartLayers, layerBase, variants, layerVis } = buildChartLayers({ mariner: this._mariner, palette: this._palette(), atlasPpu: this._atlasPpu, osm: this._osmBasemap(), scheme: this._active, server: this._sources.server, serverSets: this._sources.sets, scaminValues: this._sources.scaminValues, scaminLat, bandsHidden: this._bandsHidden, - ignoreScamin: this._ignoreScamin, + ignoreScamin: this._ignoreScamin, sizeScale, pxPitch: this._pxPitch, }); this._layerBase = layerBase; this._variants = variants; this._layerVis = layerVis; diff --git a/web/src/chart-canvas/chart-sources.mjs b/web/src/chart-canvas/chart-sources.mjs index 3df2f54..89a4a93 100644 --- a/web/src/chart-canvas/chart-sources.mjs +++ b/web/src/chart-canvas/chart-sources.mjs @@ -15,6 +15,7 @@ // Where a method used to do `this._map.setStyle(this.buildStyle(), …)` it calls // `this.rebuild()`; where it needs the map it calls `this.getMap()`. import { PMTilesArchive, MultiArchive } from "./pmtiles-source.mjs"; +import { zoomForScalePhysical } from "../lib/util.mjs"; // NOAA ENC navigational-purpose bands (the rescheming standard) → one vector // source each, baked over [min,max] and overzoomed above max (see bake.zig @@ -71,22 +72,26 @@ export const SCAMIN_BUCKET_LAYERS = new Set(["point_symbols", "soundings", "text const SCAMIN_LAT_REBUILD_DEG = 2; // The display zoom at which a 1:N (scamin) feature first becomes visible at the -// given latitude: the zoom whose display-scale denominator equals scamin. FRACTIONAL -// — used directly as a MapLibre layer minzoom, which gives the exact S-52 §8.4 -// cutoff (display scale ≤ SCAMIN ⇒ shown) with no client-side per-zoom computation. +// given latitude: the zoom whose PHYSICAL display-scale denominator equals scamin. +// FRACTIONAL — used directly as a MapLibre layer minzoom, which gives the exact +// S-52 cutoff with no client-side per-zoom computation. // -// SCAMIN is a PRODUCER scale (a real 1:N paper scale), so it is gated against the -// PHYSICAL display scale — MapLibre's true 512-tile geometry (z0 denom 279541132 = -// M_PER_PX_Z0_PHYS / OGC_PX_M), NOT the band-pyramid's nominal 256 coordinate. That -// is exactly half the nominal denom, so the cutoff sits ~1 zoom lower: a 1:59999 -// feature now survives until the screen truly reads ~1:59999 instead of ~1:30000. -// The deterministic OGC pixel (0.28mm) is used (not the calibratable HUD px-pitch) -// so this matches the baker's scaminZoom tile floor, which has no screen to measure. -export function scaminDisplayZoom(scamin, lat) { +// SCAMIN is "the minimum scale at which the object may be displayed" (S-57 attr +// 133); S-57 Appendix B.1 §2.2.7 defines it as "the display scale below which the +// object is no longer displayed", and S-52 6.1.1 defines Display Scale as the TRUE +// on-glass ratio [distance on display]/[distance on earth]. So we gate against the +// physical display scale at the (calibrated) screen pixel pitch — the SAME scale the +// HUD readout and over-scale use — NOT a fixed web/OGC pixel. zoomForScalePhysical is +// the inverse of that scale, so a SCAMIN 1:N feature vanishes exactly when the screen +// reads 1:N. pxPitch omitted → the CSS-reference pixel (util default). +// +// The baker floors each SCAMIN feature into tiles at floor(scaminZoom) using the +// deterministic OGC pixel (it has no screen). Real screens are FINER than that pixel, +// so this client gate lands at/above the baked floor — the tile always carries the +// feature where we reveal it (gating later than the floor is the safe direction). +export function scaminDisplayZoom(scamin, lat, pxPitch) { if (!scamin) return 0; - const denomZ0 = 279541132.0 * Math.cos((lat * Math.PI) / 180); - if (denomZ0 <= scamin) return 0; - return Math.max(0, Math.min(24, Math.log2(denomZ0 / scamin))); + return zoomForScalePhysical(scamin, lat, pxPitch); } // Server sets are baked PER BAND, named "-" (e.g. noaa-d5-general). @@ -105,10 +110,11 @@ export function bandOfSet(name) { } export class ChartSources { - constructor({ assets, getMap, rebuild }) { + constructor({ assets, getMap, rebuild, getPxPitch }) { this.assets = assets; // resolved assets base URL (trailing "/") this.getMap = getMap; // () => live MapLibre map (or null) this.rebuild = rebuild; // () => map.setStyle(buildStyle(), {diff:false,validate:false}) + this.getPxPitch = getPxPitch || (() => undefined); // () => calibrated CSS-pixel pitch (mm); drives SCAMIN gating this._ver = 0; // chart-tile cache-bust token (see refresh) this._bands = {}; // band slug → MultiArchive of that band's loaded packs (chart- source) this._scaminValues = []; // distinct SCAMIN denominators seen in tiles → per-SCAMIN bucket layers @@ -251,12 +257,13 @@ export class ChartSources { const m = this.getMap(); if (!m) return; const lat = m.getCenter().lat; + const pitch = this.getPxPitch(); let style; try { style = m.getStyle(); } catch (e) { return; } for (const L of (style && style.layers) || []) { const hit = /#sm(\d+(?:\.\d+)?)$/.exec(L.id); if (!hit) continue; - try { m.setLayerZoomRange(L.id, scaminDisplayZoom(+hit[1], lat), L.maxzoom != null ? L.maxzoom : 24); } catch (e) { /* layer removed mid-update */ } + try { m.setLayerZoomRange(L.id, scaminDisplayZoom(+hit[1], lat, pitch), L.maxzoom != null ? L.maxzoom : 24); } catch (e) { /* layer removed mid-update */ } } this._scaminLat = lat; } diff --git a/web/src/chart-canvas/chart-sources.scamin-physical.test.mjs b/web/src/chart-canvas/chart-sources.scamin-physical.test.mjs new file mode 100644 index 0000000..b817a44 --- /dev/null +++ b/web/src/chart-canvas/chart-sources.scamin-physical.test.mjs @@ -0,0 +1,33 @@ +// Verifies SCAMIN gating is on the TRUE physical display scale (S-57 B.1 §2.2.7 / +// S-52 Display Scale), not a fixed web pixel: a SCAMIN 1:N feature must become +// visible exactly when the screen reads 1:N — at the (calibrated) pixel pitch. +// Run: node --test web/src/chart-canvas/chart-sources.scamin-physical.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import { scaminDisplayZoom } from "./chart-sources.mjs"; +import { scaleDenomPhysical, DEFAULT_PX_PITCH_MM } from "../lib/util.mjs"; + +test("a SCAMIN 1:N feature's cutoff zoom reads exactly 1:N on the physical scale", () => { + const lat = 38.97; // Annapolis + for (const scamin of [17999, 21999, 29999, 44999]) { + for (const pitch of [DEFAULT_PX_PITCH_MM, 0.254, 0.20]) { + const z = scaminDisplayZoom(scamin, lat, pitch); + const denomAtCutoff = scaleDenomPhysical(z, lat, pitch); + // The displayed scale at the cutoff zoom equals the SCAMIN value (≤0.1% slack). + assert.ok(Math.abs(denomAtCutoff - scamin) / scamin < 1e-3, + `scamin ${scamin} @ pitch ${pitch}: cutoff reads 1:${Math.round(denomAtCutoff)}`); + } + } +}); + +test("finer pixel pitch pushes the cutoff to a HIGHER zoom (feature hides earlier zooming out)", () => { + const lat = 38.97; + const coarse = scaminDisplayZoom(17999, lat, 0.2645); // CSS reference + const fine = scaminDisplayZoom(17999, lat, 0.20); // dense screen + assert.ok(fine > coarse, `fine pitch ${fine} should exceed coarse ${coarse}`); +}); + +test("no pitch arg falls back to the CSS-reference pixel", () => { + const lat = 38.97; + assert.equal(scaminDisplayZoom(17999, lat), scaminDisplayZoom(17999, lat, DEFAULT_PX_PITCH_MM)); +}); diff --git a/web/src/chart-canvas/chart-style.mjs b/web/src/chart-canvas/chart-style.mjs index a67f7fe..1ca5e47 100644 --- a/web/src/chart-canvas/chart-style.mjs +++ b/web/src/chart-canvas/chart-style.mjs @@ -102,7 +102,30 @@ function textLayers(mariner, palette) { }, }]; } -function buildLayers(mariner, palette, atlasPpu, osm) { +// Multiply every PIXEL-VALUED size property by `k` so S-52 features render at +// their true physical size on THIS screen. The baker emits sizes (icon `scale`, +// `width_px`, `font_size_px`) as if 1 CSS px = 1 typographic point (0.35278 mm / +// 72 DPI); but a CSS px is 1/96 in (0.2645 mm) — and the actual screen may differ +// again. The element computes k = 0.35278 / pxPitch and passes it in, scaling +// icons/lines/text/halos together (line-dasharray is in line-width units, so it +// scales for free). Only sizes are touched — colours/filters/placement are unchanged. +function _scaleSizes(layers, k) { + if (!(k > 0) || Math.abs(k - 1) < 1e-6) return layers; + const mul = (v) => (v == null ? v : ["*", k, v]); + for (const L of layers) { + if (L.layout) { + if (L.layout["icon-size"] != null) L.layout["icon-size"] = mul(L.layout["icon-size"]); + if (L.layout["text-size"] != null) L.layout["text-size"] = mul(L.layout["text-size"]); + } + if (L.paint) { + if (L.paint["line-width"] != null) L.paint["line-width"] = mul(L.paint["line-width"]); + if (L.paint["text-halo-width"] != null) L.paint["text-halo-width"] = mul(L.paint["text-halo-width"]); + } + } + return layers; +} + +function buildLayers(mariner, palette, atlasPpu, osm, sizeScale) { // Over an OSM basemap (raster or vector), let its detailed land show through: // drop the chart's own land fills so OSM land isn't painted over. Filter by // colour token, not class, so it catches LNDARE (LANDA) AND built-up land @@ -201,7 +224,9 @@ function buildLayers(mariner, palette, atlasPpu, osm) { ]; // Template chart layers (source "chart" is a placeholder rewritten per band // by expandChartLayers). Their `filter` is the intrinsic (base) filter. - const tmpl = base.concat(complexLineLayers(palette), top, textLayers(mariner, palette)); + // Scale all pixel sizes to true physical size BEFORE the SCAMIN split so the + // *_scamin clones (which share these layout/paint objects) inherit it. + const tmpl = _scaleSizes(base.concat(complexLineLayers(palette), top, textLayers(mariner, palette)), sizeScale); // SCAMIN AREA/LINE split: each template layer reading one of the four area/line // source-layers is IMMEDIATELY FOLLOWED BY a clone reading the matching // "_scamin" source-layer (id "-scamin"). The original now only ever @@ -324,10 +349,12 @@ export function buildChartLayers({ server, serverSets, scaminValues, scaminLat, // chart-source state (already resolved) bandsHidden, // Set (this._bandsHidden) ignoreScamin, // DEBUG: drop the per-SCAMIN display gate (show everything in-band) + sizeScale = 1, // px→true-physical feature-size multiplier (0.35278/pxPitch); see _scaleSizes + pxPitch, // calibrated CSS-pixel pitch (mm) → SCAMIN gates on the true physical scale }) { active = scheme || "day"; const layerBase = {}, variants = {}, layerVis = {}; - const tmpl = buildLayers(mariner, palette, atlasPpu, osm); + const tmpl = buildLayers(mariner, palette, atlasPpu, osm, sizeScale); const out = []; // Group each base template layer with the *_scamin clone that _withScamin placed // immediately after it (tagged _baseId), so the pair expands TOGETHER per band @@ -396,13 +423,13 @@ export function buildChartLayers({ // value). NOT quantized → SCAMIN is still honoured exactly. const floor = set.min || 0; const lowVals = [], hiVals = []; - for (const sc of scaminVals) (scaminDisplayZoom(sc, lat) <= floor + 1e-6 ? lowVals : hiVals).push(sc); + for (const sc of scaminVals) (scaminDisplayZoom(sc, lat, pxPitch) <= floor + 1e-6 ? lowVals : hiVals).push(sc); const noFilter = lowVals.length ? ["any", ["!", ["has", "scamin"]], ["in", ["get", "scamin"], ["literal", lowVals]]] : ["!", ["has", "scamin"]]; mk("#no", and(noFilter), undefined); for (const sc of hiVals) { - mk("#sm" + sc, and(["==", ["get", "scamin"], sc]), scaminDisplayZoom(sc, lat)); + mk("#sm" + sc, and(["==", ["get", "scamin"], sc]), scaminDisplayZoom(sc, lat, pxPitch)); } } else { mk("", base, undefined); @@ -467,13 +494,13 @@ export function buildChartLayers({ // the layer count without quantizing (see the server path for the rationale). const floor = dmin || 0; const lowVals = [], hiVals = []; - for (const sc of scaminValues) (scaminDisplayZoom(sc, lat) <= floor + 1e-6 ? lowVals : hiVals).push(sc); + for (const sc of scaminValues) (scaminDisplayZoom(sc, lat, pxPitch) <= floor + 1e-6 ? lowVals : hiVals).push(sc); const noFilter = lowVals.length ? ["any", ["!", ["has", "scamin"]], ["in", ["get", "scamin"], ["literal", lowVals]]] : ["!", ["has", "scamin"]]; mk("#no", and(noFilter), dmin || undefined); for (const sc of hiVals) { - mk("#sm" + sc, and(["==", ["get", "scamin"], sc]), scaminDisplayZoom(sc, lat)); + mk("#sm" + sc, and(["==", ["get", "scamin"], sc]), scaminDisplayZoom(sc, lat, pxPitch)); } } else { mk("", base, dmin || undefined); diff --git a/web/src/chart-canvas/chart-style.sizescale.test.mjs b/web/src/chart-canvas/chart-style.sizescale.test.mjs new file mode 100644 index 0000000..a8fd090 --- /dev/null +++ b/web/src/chart-canvas/chart-style.sizescale.test.mjs @@ -0,0 +1,44 @@ +// Verifies the true-physical feature-size scaling: buildChartLayers({ sizeScale }) +// must multiply every pixel-valued size (icon-size / text-size / line-width / +// text-halo-width) by sizeScale, and leave them untouched when sizeScale == 1. +// Run: node --test web/src/chart-canvas/chart-style.sizescale.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildChartLayers } from "./chart-style.mjs"; + +function build(sizeScale) { + return buildChartLayers({ + mariner: {}, palette: {}, atlasPpu: 0.08, osm: false, scheme: "day", + server: false, serverSets: [], scaminValues: [], scaminLat: 0, + bandsHidden: new Set(), ignoreScamin: true, sizeScale, + }).layers; +} + +// A size expression wrapped by _scaleSizes looks like ["*", k, ]. +function wrappedBy(v, k) { + return Array.isArray(v) && v[0] === "*" && v[1] === k; +} + +test("sizeScale wraps icon-size / text-size / line-width / halo with [*, k, …]", () => { + const k = 1.3338; + const layers = build(k); + const lineSolid = layers.find((L) => (L._baseId || L.id || "").startsWith("lines-solid") || L.id?.startsWith("lines-solid@")); + const pointSym = layers.find((L) => L.id?.startsWith("point_symbols@")); + const text = layers.find((L) => L.id?.startsWith("text@")); + + assert.ok(lineSolid, "a lines-solid variant exists"); + assert.ok(wrappedBy(lineSolid.paint["line-width"], k), "line-width scaled"); + assert.ok(pointSym, "a point_symbols variant exists"); + assert.ok(wrappedBy(pointSym.layout["icon-size"], k), "icon-size scaled"); + assert.ok(text, "a text variant exists"); + assert.ok(wrappedBy(text.layout["text-size"], k), "text-size scaled"); + assert.ok(wrappedBy(text.paint["text-halo-width"], k), "text-halo-width scaled"); +}); + +test("sizeScale == 1 leaves sizes untouched (no [*, 1, …] wrapper)", () => { + const layers = build(1); + const lineSolid = layers.find((L) => L.id?.startsWith("lines-solid@")); + assert.ok(lineSolid, "a lines-solid variant exists"); + // Original line-width is ["coalesce", ["get","width_px"], 1] — NOT a "*" wrapper. + assert.equal(lineSolid.paint["line-width"][0], "coalesce"); +}); diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index e7fb5a4..47df21c 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -516,6 +516,11 @@ export class ChartPlotter extends HTMLElement { // Apply persisted display prefs. if (this._scheme !== "day") this._plotter.setScheme(this._scheme); this.setAttribute("data-scheme", this._scheme); + // Calibrated CSS-pixel pitch drives true-physical feature sizing in the renderer + // (the same calibration the scale readout uses). Push it before the first frame. + if (typeof this._pxPitch === "number" && this._plotter.setPxPitch) { + try { this._plotter.setPxPitch(this._pxPitch); } catch (e) { console.warn(e); } + } if (Object.keys(this._mariner).length) { try { this._plotter.setMariner(this._mariner); } catch (e) { console.warn(e); } } @@ -1699,6 +1704,8 @@ export class ChartPlotter extends HTMLElement { try { localStorage.setItem(LS_PX_PITCH, JSON.stringify(this._pxPitch ?? null)); } catch (e) { /* quota/private */ } this._persistSettings(); if (this._hud) this._hud.updateHud(); + // Re-render features at true physical size for the new pitch (icons/lines/text). + if (this._plotter && this._plotter.setPxPitch) { try { this._plotter.setPxPitch(this._pxPitch); } catch (e) { console.warn(e); } } } // Fetch the server-persisted display settings at boot and adopt them over the