Skip to content

Commit 1a10897

Browse files
authored
Merge pull request #32 from beetlebugorg/feat/overscale-indication
feat(web): enable the S-52 overscale pattern AP(OVERSC01) at scale bo…
2 parents 42bead2 + 5f0e298 commit 1a10897

4 files changed

Lines changed: 78 additions & 14 deletions

File tree

web/src/chart-canvas/chart-canvas.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,7 @@ export class ChartCanvas extends HTMLElement {
11801180
scheme: this._active,
11811181
server: this._sources.server, serverSets: this._sources.sets,
11821182
scaminValues: this._sources.scaminValues, scaminLat, bandsHidden: this._bandsHidden,
1183+
bandsPresent: new Set(this._sources.loadedBands()),
11831184
ignoreScamin: this._ignoreScamin, sizeScale, pxPitch: this._pxPitch,
11841185
});
11851186
this._layerBase = layerBase; this._variants = variants; this._layerVis = layerVis;

web/src/chart-canvas/chart-sources.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ export class ChartSources {
130130
get server() { return this._server; }
131131
get sets() { return this._serverSets; }
132132
get scaminValues() { return this._scaminValues; }
133+
// Band slugs that currently have data — server: the active sets' bands; pmtiles:
134+
// the loaded per-band archives. Drives the overscale-pattern gate (a band gets the
135+
// AP(OVERSC01) hatch only when a FINER band is present, i.e. a real scale boundary).
136+
loadedBands() {
137+
if (this._server) return [...new Set(this._serverSets.map((s) => s.band).filter(Boolean))];
138+
return Object.keys(this._bands);
139+
}
133140
// The latitude the SCAMIN bucket minzooms are computed at. Falls back to the
134141
// map's LIVE centre latitude until the first idle pass sets it — without this,
135142
// the initial style (and server mode, which never ran the discovery loop that

web/src/chart-canvas/chart-style.mjs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -334,19 +334,17 @@ function _pushScaminProbes(out, server) {
334334
// above the band's native max (where the chart is grossly enlarged, ≥ ~×2 its
335335
// compilation scale). Inserted right after the band's base fill so a finer band's
336336
// opaque fill covers it — the hatch survives only on coarse-only overscale patches.
337-
// No-op for the finest band / merged "all" set (nothing coarser to enlarge). S-52
338-
// §10.1.10.2; display priority 3, viewing group 21030.
339-
function _pushOverscale(out, source, band, layerVis, showOverscale, bandsHidden) {
340-
// DISABLED: the old "hatch wherever a band overzooms past its native max"
341-
// heuristic over-triggers — it paints AP(OVERSC01) on plain zoom-in of the
342-
// best-available chart, which S-52 §10.1.10.1 says must show ONLY the "×N"
343-
// indication, never the pattern. Real ECDIS show the area pattern only at a
344-
// genuine scale boundary (a coarser cell enlarged ≥×2 in a finer cell's hole,
345-
// §10.1.10.2) — that wants a baked overscale_areas layer (task #3). Until then,
346-
// no auto-hatch (the HUD still shows the ×N overscale indication).
347-
return; // eslint-disable-line no-unreachable
337+
// S-52 §10.1.10.2; display priority 3, viewing group 21030.
338+
//
339+
// `finerPresent` is the spec gate: emit ONLY when a finer band is loaded, so a real
340+
// chart-scale boundary exists and this band can show through a finer band's hole
341+
// (grossly overscaled → pattern). When this band IS the finest available, plain
342+
// zoom-in is "deliberate overscale of best-available" and must show ONLY the ×N
343+
// overscale indication, never the pattern (§10.1.10.1) — so we emit nothing and the
344+
// HUD ×N stands alone. No-op for the merged "all" set (no per-band layering).
345+
function _pushOverscale(out, source, band, layerVis, showOverscale, bandsHidden, finerPresent) {
348346
const nm = CHART_BANDS.find((b) => b.slug === band);
349-
if (!nm || band === "all" || nm.max >= 18) return;
347+
if (!nm || band === "all" || nm.max >= 18 || !finerPresent) return;
350348
const id = "overscale@" + source;
351349
const vis = showOverscale === false ? "none" : "visible";
352350
layerVis[id] = vis;
@@ -386,6 +384,7 @@ export function buildChartLayers({
386384
scheme, // active scheme branch ("day"/"dusk"/"night") = this._active
387385
server, serverSets, scaminValues, scaminLat, // chart-source state (already resolved)
388386
bandsHidden, // Set (this._bandsHidden)
387+
bandsPresent = new Set(), // Set of band slugs that have data — gates the overscale pattern
389388
ignoreScamin, // DEBUG: drop the per-SCAMIN display gate (show everything in-band)
390389
sizeScale = 1, // px→true-physical feature-size multiplier (0.35278/pxPitch); see _scaleSizes
391390
pxPitch, // calibrated CSS-pixel pitch (mm) → SCAMIN gates on the true physical scale
@@ -394,6 +393,23 @@ export function buildChartLayers({
394393
const layerBase = {}, variants = {}, layerVis = {};
395394
const tmpl = buildLayers(mariner, palette, atlasPpu, osm, sizeScale);
396395
const out = [];
396+
// Overscale-pattern gate (S-52 §10.1.10.2): a band gets the AP(OVERSC01) hatch only
397+
// when a strictly-FINER band is present in the loaded set — i.e. a real chart-scale
398+
// boundary exists for it to show through. The finest band present is the
399+
// best-available data, so its plain zoom-in is the ×N-only case (§10.1.10.1).
400+
const _bandRank = (slug) => CHART_BANDS.findIndex((b) => b.slug === slug);
401+
const _presentRanks = [...bandsPresent]
402+
.filter((slug) => slug && slug !== "all")
403+
.map(_bandRank)
404+
.filter((i) => i >= 0);
405+
const _finestPresentRank = _presentRanks.length ? Math.max(..._presentRanks) : -1;
406+
// Emit the hatch for `slug` only when this band is itself present AND a strictly
407+
// finer band is also present (the pmtiles path iterates ALL bands, so the present
408+
// check matters). The finest present band never qualifies — it's best-available.
409+
const finerBandPresent = (slug) => {
410+
const r = _bandRank(slug);
411+
return r >= 0 && bandsPresent.has(slug) && r < _finestPresentRank;
412+
};
397413
// Group each base template layer with the *_scamin clone that _withScamin placed
398414
// immediately after it (tagged _baseId), so the pair expands TOGETHER per band
399415
// below — both fill paths iterate group-outer, band-mid, member-inner. Expanding
@@ -479,7 +495,7 @@ export function buildChartLayers({
479495
// interleaved per band, so a finer band's opaque fill covers it where finer
480496
// data exists — the hatch is left only on the coarse-only (overscale) patches
481497
// such as open water shown enlarged. S-52 §10.1.10.2.
482-
if (L.id === "areas") _pushOverscale(out, "chart-" + set.name, set.band, layerVis, undefined, bandsHidden);
498+
if (L.id === "areas") _pushOverscale(out, "chart-" + set.name, set.band, layerVis, undefined, bandsHidden, finerBandPresent(set.band));
483499
}
484500
}
485501
}
@@ -543,7 +559,7 @@ export function buildChartLayers({
543559
} else {
544560
mk("", base, dmin || undefined);
545561
}
546-
if (L.id === "areas") _pushOverscale(out, "chart-" + band.slug, band.slug, layerVis, undefined, bandsHidden);
562+
if (L.id === "areas") _pushOverscale(out, "chart-" + band.slug, band.slug, layerVis, undefined, bandsHidden, finerBandPresent(band.slug));
547563
}
548564
}
549565
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Verifies the S-52 §10.1.10.2 overscale-pattern gate in buildChartLayers: the
2+
// AP(OVERSC01) hatch (layer id "overscale@chart-<band>", fill-pattern pat:OVERSC01)
3+
// is emitted for a band ONLY when a strictly-FINER band is present (a real chart-scale
4+
// boundary). The finest band present is best-available data — plain zoom-in of it is
5+
// the ×N-only case (§10.1.10.1), so it must get NO pattern.
6+
// Run: node --test web/src/chart-canvas/chart-style.overscale.test.mjs
7+
import test from "node:test";
8+
import assert from "node:assert/strict";
9+
import { buildChartLayers, PAT_PREFIX } from "./chart-style.mjs";
10+
11+
function overscaleLayers(bandsPresent) {
12+
return buildChartLayers({
13+
mariner: {}, palette: {}, atlasPpu: 0.08, osm: false, scheme: "day",
14+
server: false, serverSets: [], scaminValues: [], scaminLat: 0,
15+
bandsHidden: new Set(), bandsPresent: new Set(bandsPresent),
16+
ignoreScamin: true, sizeScale: 1,
17+
}).layers.filter((L) => L.id.startsWith("overscale@"));
18+
}
19+
20+
test("coarser band gets the hatch only when a finer band is present", () => {
21+
const ids = overscaleLayers(["coastal", "harbor"]).map((L) => L.id);
22+
assert.ok(ids.includes("overscale@chart-coastal"), "coastal hatches (harbor is finer)");
23+
assert.ok(!ids.includes("overscale@chart-harbor"), "harbor is finest present — no hatch (×N only)");
24+
});
25+
26+
test("the overscale layer paints the OVERSC01 fill-pattern over areas", () => {
27+
const L = overscaleLayers(["coastal", "harbor"]).find((x) => x.id === "overscale@chart-coastal");
28+
assert.ok(L, "coastal overscale layer exists");
29+
assert.equal(L.type, "fill");
30+
assert.equal(L["source-layer"], "areas");
31+
assert.equal(L.paint["fill-pattern"], PAT_PREFIX + "OVERSC01");
32+
});
33+
34+
test("a single band present (best-available) never hatches", () => {
35+
assert.equal(overscaleLayers(["harbor"]).length, 0, "lone harbor: no pattern, ×N indication only");
36+
});
37+
38+
test("no bands present (default) emits no overscale layers", () => {
39+
assert.equal(overscaleLayers([]).length, 0);
40+
});

0 commit comments

Comments
 (0)