Skip to content

Commit 4433efd

Browse files
beetlebugorgclaude
andcommitted
fix(s52): stack chart area fills by band so finer cells win (§10.1.3)
Layers expanded template-outer/band-inner, emitting every band's plain `areas` before every band's `areas-scamin`. A SCAMIN-bearing coarse area (e.g. a coastal BUAARE) then drew above a finer band's plain fill (a harbor DEPARE), so coastal docks painted over harbor water and looked like land. Group each base layer with its *_scamin clone and expand the pair per band (coarse→fine) so the larger-scale cell's fill wins, per S-52 data-overlap handling. Also filter the cursor-pick to the finest covering cell so buried coarser objects don't pollute the report. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eeadc19 commit 4433efd

2 files changed

Lines changed: 48 additions & 6 deletions

File tree

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,19 @@ export function buildChartLayers({
272272
const layerBase = {}, variants = {}, layerVis = {};
273273
const tmpl = buildLayers(mariner, palette, atlasPpu, osm);
274274
const out = [];
275+
// Group each base template layer with the *_scamin clone that _withScamin placed
276+
// immediately after it (tagged _baseId), so the pair expands TOGETHER per band
277+
// below — both fill paths iterate group-outer, band-mid, member-inner. Expanding
278+
// them as two independent template entries (every band's plain `areas`, THEN every
279+
// band's `areas_scamin`) let a COARSE band's SCAMIN area — e.g. a coastal BUAARE —
280+
// stack ABOVE a FINER band's plain fill (a harbor DEPARE), so coastal docks painted
281+
// over harbor water and read as land. Grouping keeps cross-band coarse→fine intact
282+
// within each fill tier while still drawing all fills below lines/symbols/text.
283+
const groups = [];
284+
for (const L of tmpl) {
285+
if (L._baseId && groups.length) groups[groups.length - 1].push(L);
286+
else groups.push([L]);
287+
}
275288
// Server mode: one source per active per-band set (chart-<district>-<band>).
276289
// Iterate template-outer, set-inner — and serverSets is ordered coarse→fine —
277290
// so the global draw order is by S-52 class (all fills, then lines, then symbols,
@@ -283,9 +296,10 @@ export function buildChartLayers({
283296
// "<id>@<set>" so scheme/mariner updates by base id hit every set's copy.
284297
if (server) {
285298
const lat = scaminLat;
286-
for (const L of tmpl) {
287-
const base = L.filter ?? null;
299+
for (const group of groups) {
288300
for (const set of serverSets) {
301+
for (const L of group) {
302+
const base = L.filter ?? null;
289303
const dmin = BAND_DISPLAY_MIN[set.band];
290304
const capped = (set.band === "overview" || set.band === "general") && _capsAtBand(L);
291305
// mk pushes one variant of L for this set — same shape as the pmtiles path's
@@ -344,6 +358,7 @@ export function buildChartLayers({
344358
// data exists — the hatch is left only on the coarse-only (overscale) patches
345359
// such as open water shown enlarged. S-52 §10.1.10.2.
346360
if (L.id === "areas") _pushOverscale(out, "chart-" + set.name, set.band, layerVis, undefined, bandsHidden);
361+
}
347362
}
348363
}
349364
_pushScaminProbes(out, server);
@@ -358,8 +373,9 @@ export function buildChartLayers({
358373
// WITHIN each class preserves best-available (finer fill covers coarser fill),
359374
// while symbols/text now always sit above every band's fills.
360375
const lat = scaminLat;
361-
for (const L of tmpl) {
376+
for (const group of groups) {
362377
for (const band of CHART_BANDS) {
378+
for (const L of group) {
363379
const base = L.filter ?? null;
364380
const dmin = BAND_DISPLAY_MIN[band.slug];
365381
const capped = (band.slug === "overview" || band.slug === "general") && _capsAtBand(L);
@@ -407,6 +423,7 @@ export function buildChartLayers({
407423
}
408424
if (L.id === "areas") _pushOverscale(out, "chart-" + band.slug, band.slug, layerVis, undefined, bandsHidden);
409425
}
426+
}
410427
}
411428
_pushScaminProbes(out, server);
412429
return { layers: out, layerBase, variants, layerVis };

web/src/chartplotter.mjs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ function pickCmp(a, b) {
157157
return pickGeomRank(a.sourceLayer) - pickGeomRank(b.sourceLayer);
158158
}
159159

160+
// S-57 cell names encode the usage band as the 3rd character (e.g. US[1-6]…):
161+
// 1 overview, 2 general, 3 coastal, 4 approach, 5 harbor, 6 berthing — a higher
162+
// digit is a larger-scale (finer) chart. Returns 0 when the name doesn't follow
163+
// the pattern (imported/non-NOAA cells), which the overlap filter treats as
164+
// "unknown band" and never suppresses.
165+
function cellBand(f) {
166+
const c = f.properties && f.properties.cell;
167+
const m = c && /^[A-Za-z]{2}([1-6])/.exec(c);
168+
return m ? +m[1] : 0;
169+
}
170+
160171
// Richness of a feature's geometry for *highlighting*: an area outlines better
161172
// than a line, which outlines better than a centred symbol's anchor point. Used
162173
// to pick which of an object's co-located representations the pick circle/outline
@@ -590,9 +601,13 @@ export class ChartPlotter extends HTMLElement {
590601
// Pinned scale-band pills (pill/cell clicks land inside their wrap).
591602
root.querySelectorAll(".sb-band-wrap.open").forEach((w) => { if (!inside(w)) w.classList.remove("open"); });
592603
// Charts/Settings drawer — but not while the NOAA agreement gate is up (it
593-
// owns the interaction; Escape or its buttons resolve it), and not on its
594-
// own rail buttons (those toggle it via toggleSection()).
604+
// owns the interaction; Escape or its buttons resolve it), not on its own
605+
// rail buttons (those toggle it via toggleSection()), and NOT while the dev
606+
// feature-inspector is armed: it lives in the drawer but its interaction is
607+
// clicking/dragging the MAP, so dismissing the drawer here would disarm it
608+
// (closeDrawer → setInspectMode(false)) and hand the click to the cursor-pick.
595609
if (this._drawerOpen() && !(this._chartLib && this._chartLib.agreementOpen)
610+
&& !(this._devTools && this._devTools.inspecting)
596611
&& !inside(root.getElementById("drawer"))
597612
&& !inside(root.getElementById("charts-btn"))
598613
&& !inside(root.getElementById("settings-btn"))) {
@@ -1094,8 +1109,18 @@ export class ChartPlotter extends HTMLElement {
10941109
// ordering is unchanged) plus the richest geometry under the cursor
10951110
// (area > line > point) on `_hiGeom`, so the highlight traces an area's
10961111
// extent instead of dropping a dot on a centred symbol's anchor.
1112+
// S-52 PresLib §10.1.3 (data overlaps): where cells of different navigational
1113+
// purpose overlap, only ONE cell is displayed for the overlap area — the
1114+
// largest-scale (finest usage band) cell whose coverage applies. query-
1115+
// RenderedFeatures returns every band stacked at the point (a finer cell's
1116+
// opaque fill paints over a coarser one), so a coarser cell's now-hidden
1117+
// objects — e.g. a coastal BUAARE beneath a harbor LNDARE — would otherwise
1118+
// pollute the pick. Keep only the finest band present; coarser bands are
1119+
// suppressed there (unknown-band imports are always kept).
1120+
const finestBand = feats.reduce((m, f) => Math.max(m, cellBand(f)), 0);
1121+
const picked = finestBand ? feats.filter((f) => { const b = cellBand(f); return b === 0 || b >= finestBand; }) : feats;
10971122
const groups = new Map();
1098-
for (const f of feats) {
1123+
for (const f of picked) {
10991124
const p = f.properties || {};
11001125
const key = (p.class || "") + "|" + (p.cell || "") + "|" + (p.s57 || "") + "|" + (p.objnam || "");
11011126
const g = groups.get(key);

0 commit comments

Comments
 (0)