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
1 change: 1 addition & 0 deletions web/src/chart-canvas/chart-canvas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions web/src/chart-canvas/chart-sources.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 30 additions & 14 deletions web/src/chart-canvas/chart-style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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));
}
}
}
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
40 changes: 40 additions & 0 deletions web/src/chart-canvas/chart-style.overscale.test.mjs
Original file line number Diff line number Diff line change
@@ -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-<band>", 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);
});