Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions web/src/chart-canvas/chart-canvas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
39 changes: 23 additions & 16 deletions web/src/chart-canvas/chart-sources.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "<district>-<band>" (e.g. noaa-d5-general).
Expand All @@ -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-<slug> source)
this._scaminValues = []; // distinct SCAMIN denominators seen in tiles → per-SCAMIN bucket layers
Expand Down Expand Up @@ -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;
}
Expand Down
33 changes: 33 additions & 0 deletions web/src/chart-canvas/chart-sources.scamin-physical.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
});
41 changes: 34 additions & 7 deletions web/src/chart-canvas/chart-style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
// "<sl>_scamin" source-layer (id "<id>-scamin"). The original now only ever
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions web/src/chart-canvas/chart-style.sizescale.test.mjs
Original file line number Diff line number Diff line change
@@ -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, <original>].
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");
});
7 changes: 7 additions & 0 deletions web/src/chartplotter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
Expand Down Expand Up @@ -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
Expand Down