From 5f0e2982f2bb4df373fc79a73025cb953998c9f9 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 21:09:02 -0400 Subject: [PATCH] feat(web): enable the S-52 overscale pattern AP(OVERSC01) at scale boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enable the vertical-line overscale hatch that marks grossly-overscaled coarse data at a chart-scale boundary (S-52 §10.1.10.2), which had been disabled because the old heuristic over-triggered. The pattern paints client-side over a coarse band's already-baked `areas`, inserted right below finer bands' opaque fills — so the layering confines the hatch to the holes where a coarser ENC shows through (the "area compiled from the smaller-scale ENC"). The fix is the spec gate: a band hatches only when a strictly FINER band is loaded (a real scale boundary exists). The finest band present never hatches — plain zoom-in of best-available data is the ×N-only case (§10.1.10.1), already shown in the HUD status bar. - chart-style.mjs: re-enable _pushOverscale; add the bandsPresent-driven finerBandPresent() gate; thread it to the server + pmtiles call sites. - chart-sources.mjs: loadedBands() (server: active sets' bands; pmtiles: loaded archives). - chart-canvas.mjs: pass bandsPresent into buildChartLayers. - chart-style.overscale.test.mjs: gate tests (coarse hatches iff a finer band is present; finest/lone/none never hatch; paints pat:OVERSC01). No re-bake: OVERSC01 is already in the pattern atlas (a catalogue AreaFill) and registers lazily via styleimagemissing. Known limitation: the gate is "a finer band present in the loaded set", not "at this location", so a genuinely coarse-only region can still hatch when finer charts are loaded elsewhere. The exact fix needs a baked overscale_areas layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chart-canvas/chart-canvas.mjs | 1 + web/src/chart-canvas/chart-sources.mjs | 7 +++ web/src/chart-canvas/chart-style.mjs | 44 +++++++++++++------ .../chart-style.overscale.test.mjs | 40 +++++++++++++++++ 4 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 web/src/chart-canvas/chart-style.overscale.test.mjs diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index d104eec..9d0795b 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -1180,6 +1180,7 @@ export class ChartCanvas extends HTMLElement { scheme: this._active, server: this._sources.server, serverSets: this._sources.sets, scaminValues: this._sources.scaminValues, scaminLat, bandsHidden: this._bandsHidden, + bandsPresent: new Set(this._sources.loadedBands()), 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 89a4a93..c650815 100644 --- a/web/src/chart-canvas/chart-sources.mjs +++ b/web/src/chart-canvas/chart-sources.mjs @@ -130,6 +130,13 @@ export class ChartSources { get server() { return this._server; } get sets() { return this._serverSets; } get scaminValues() { return this._scaminValues; } + // Band slugs that currently have data — server: the active sets' bands; pmtiles: + // the loaded per-band archives. Drives the overscale-pattern gate (a band gets the + // AP(OVERSC01) hatch only when a FINER band is present, i.e. a real scale boundary). + loadedBands() { + if (this._server) return [...new Set(this._serverSets.map((s) => s.band).filter(Boolean))]; + return Object.keys(this._bands); + } // The latitude the SCAMIN bucket minzooms are computed at. Falls back to the // map's LIVE centre latitude until the first idle pass sets it — without this, // the initial style (and server mode, which never ran the discovery loop that diff --git a/web/src/chart-canvas/chart-style.mjs b/web/src/chart-canvas/chart-style.mjs index a930c6b..55590d2 100644 --- a/web/src/chart-canvas/chart-style.mjs +++ b/web/src/chart-canvas/chart-style.mjs @@ -334,19 +334,17 @@ function _pushScaminProbes(out, server) { // above the band's native max (where the chart is grossly enlarged, ≥ ~×2 its // compilation scale). Inserted right after the band's base fill so a finer band's // opaque fill covers it — the hatch survives only on coarse-only overscale patches. -// No-op for the finest band / merged "all" set (nothing coarser to enlarge). S-52 -// §10.1.10.2; display priority 3, viewing group 21030. -function _pushOverscale(out, source, band, layerVis, showOverscale, bandsHidden) { - // DISABLED: the old "hatch wherever a band overzooms past its native max" - // heuristic over-triggers — it paints AP(OVERSC01) on plain zoom-in of the - // best-available chart, which S-52 §10.1.10.1 says must show ONLY the "×N" - // indication, never the pattern. Real ECDIS show the area pattern only at a - // genuine scale boundary (a coarser cell enlarged ≥×2 in a finer cell's hole, - // §10.1.10.2) — that wants a baked overscale_areas layer (task #3). Until then, - // no auto-hatch (the HUD still shows the ×N overscale indication). - return; // eslint-disable-line no-unreachable +// S-52 §10.1.10.2; display priority 3, viewing group 21030. +// +// `finerPresent` is the spec gate: emit ONLY when a finer band is loaded, so a real +// chart-scale boundary exists and this band can show through a finer band's hole +// (grossly overscaled → pattern). When this band IS the finest available, plain +// zoom-in is "deliberate overscale of best-available" and must show ONLY the ×N +// overscale indication, never the pattern (§10.1.10.1) — so we emit nothing and the +// HUD ×N stands alone. No-op for the merged "all" set (no per-band layering). +function _pushOverscale(out, source, band, layerVis, showOverscale, bandsHidden, finerPresent) { const nm = CHART_BANDS.find((b) => b.slug === band); - if (!nm || band === "all" || nm.max >= 18) return; + if (!nm || band === "all" || nm.max >= 18 || !finerPresent) return; const id = "overscale@" + source; const vis = showOverscale === false ? "none" : "visible"; layerVis[id] = vis; @@ -386,6 +384,7 @@ export function buildChartLayers({ scheme, // active scheme branch ("day"/"dusk"/"night") = this._active server, serverSets, scaminValues, scaminLat, // chart-source state (already resolved) bandsHidden, // Set (this._bandsHidden) + bandsPresent = new Set(), // Set of band slugs that have data — gates the overscale pattern 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 @@ -394,6 +393,23 @@ export function buildChartLayers({ const layerBase = {}, variants = {}, layerVis = {}; const tmpl = buildLayers(mariner, palette, atlasPpu, osm, sizeScale); const out = []; + // Overscale-pattern gate (S-52 §10.1.10.2): a band gets the AP(OVERSC01) hatch only + // when a strictly-FINER band is present in the loaded set — i.e. a real chart-scale + // boundary exists for it to show through. The finest band present is the + // best-available data, so its plain zoom-in is the ×N-only case (§10.1.10.1). + const _bandRank = (slug) => CHART_BANDS.findIndex((b) => b.slug === slug); + const _presentRanks = [...bandsPresent] + .filter((slug) => slug && slug !== "all") + .map(_bandRank) + .filter((i) => i >= 0); + const _finestPresentRank = _presentRanks.length ? Math.max(..._presentRanks) : -1; + // Emit the hatch for `slug` only when this band is itself present AND a strictly + // finer band is also present (the pmtiles path iterates ALL bands, so the present + // check matters). The finest present band never qualifies — it's best-available. + const finerBandPresent = (slug) => { + const r = _bandRank(slug); + return r >= 0 && bandsPresent.has(slug) && r < _finestPresentRank; + }; // Group each base template layer with the *_scamin clone that _withScamin placed // immediately after it (tagged _baseId), so the pair expands TOGETHER per band // below — both fill paths iterate group-outer, band-mid, member-inner. Expanding @@ -479,7 +495,7 @@ export function buildChartLayers({ // interleaved per band, so a finer band's opaque fill covers it where finer // data exists — the hatch is left only on the coarse-only (overscale) patches // such as open water shown enlarged. S-52 §10.1.10.2. - if (L.id === "areas") _pushOverscale(out, "chart-" + set.name, set.band, layerVis, undefined, bandsHidden); + if (L.id === "areas") _pushOverscale(out, "chart-" + set.name, set.band, layerVis, undefined, bandsHidden, finerBandPresent(set.band)); } } } @@ -543,7 +559,7 @@ export function buildChartLayers({ } else { mk("", base, dmin || undefined); } - if (L.id === "areas") _pushOverscale(out, "chart-" + band.slug, band.slug, layerVis, undefined, bandsHidden); + if (L.id === "areas") _pushOverscale(out, "chart-" + band.slug, band.slug, layerVis, undefined, bandsHidden, finerBandPresent(band.slug)); } } } diff --git a/web/src/chart-canvas/chart-style.overscale.test.mjs b/web/src/chart-canvas/chart-style.overscale.test.mjs new file mode 100644 index 0000000..f4a7232 --- /dev/null +++ b/web/src/chart-canvas/chart-style.overscale.test.mjs @@ -0,0 +1,40 @@ +// Verifies the S-52 §10.1.10.2 overscale-pattern gate in buildChartLayers: the +// AP(OVERSC01) hatch (layer id "overscale@chart-", fill-pattern pat:OVERSC01) +// is emitted for a band ONLY when a strictly-FINER band is present (a real chart-scale +// boundary). The finest band present is best-available data — plain zoom-in of it is +// the ×N-only case (§10.1.10.1), so it must get NO pattern. +// Run: node --test web/src/chart-canvas/chart-style.overscale.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildChartLayers, PAT_PREFIX } from "./chart-style.mjs"; + +function overscaleLayers(bandsPresent) { + return buildChartLayers({ + mariner: {}, palette: {}, atlasPpu: 0.08, osm: false, scheme: "day", + server: false, serverSets: [], scaminValues: [], scaminLat: 0, + bandsHidden: new Set(), bandsPresent: new Set(bandsPresent), + ignoreScamin: true, sizeScale: 1, + }).layers.filter((L) => L.id.startsWith("overscale@")); +} + +test("coarser band gets the hatch only when a finer band is present", () => { + const ids = overscaleLayers(["coastal", "harbor"]).map((L) => L.id); + assert.ok(ids.includes("overscale@chart-coastal"), "coastal hatches (harbor is finer)"); + assert.ok(!ids.includes("overscale@chart-harbor"), "harbor is finest present — no hatch (×N only)"); +}); + +test("the overscale layer paints the OVERSC01 fill-pattern over areas", () => { + const L = overscaleLayers(["coastal", "harbor"]).find((x) => x.id === "overscale@chart-coastal"); + assert.ok(L, "coastal overscale layer exists"); + assert.equal(L.type, "fill"); + assert.equal(L["source-layer"], "areas"); + assert.equal(L.paint["fill-pattern"], PAT_PREFIX + "OVERSC01"); +}); + +test("a single band present (best-available) never hatches", () => { + assert.equal(overscaleLayers(["harbor"]).length, 0, "lone harbor: no pattern, ×N indication only"); +}); + +test("no bands present (default) emits no overscale layers", () => { + assert.equal(overscaleLayers([]).length, 0); +});