Skip to content

Commit 1007e66

Browse files
beetlebugorgclaude
andcommitted
fix(web): declutter text by S-52 importance, not anchor draw-order
The general text fanned into nine per-anchor style layers (text-<halign>- <valign>) because text-anchor was historically not data-driven. MapLibre keeps one collision index but places symbols layer-by-layer, so a label's survival depended on WHICH anchor sublayer drew first (center-anchored always beat right-anchored) rather than on its importance. MapLibre 5.24 makes text-anchor and symbol-sort-key data-driven, so collapse the nine layers into one `text` layer: - text-anchor: data-driven match on the baked halign/valign (placement unchanged). - symbol-sort-key: ranked by S-52 §14.4 text group (tgrp) — important text (11) > names (21/26/29) > descriptive, minus font size for within-tier ties. Lower wins, so a dense approach thins to the navigationally important labels. - light-text keeps its own earlier always-wins layer, now with a size-based sort-key. Collapse the two live updaters (setScheme restyle, setMariner text-group filter) onto the single `text` id and drop the now-dead TEXT_VARIANTS/textAnchor. Pure client change, no re-bake. Verified on server-mode d5 Annapolis: same label budget but channel/feature names now survive collisions; text-group toggle and day/night restyle still work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9b3a756 commit 1007e66

2 files changed

Lines changed: 53 additions & 41 deletions

File tree

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import {
6565
// three bookkeeping maps (_layerBase/_variants/_layerVis) the live updaters below
6666
// read. PAT_PREFIX (the fill-pattern image namespace) is homed there too — used by
6767
// the layer builder AND this element's registerPattern — and imported back here.
68-
import { buildChartLayers, PAT_PREFIX, TEXT_VARIANTS } from "./chart-style.mjs";
68+
import { buildChartLayers, PAT_PREFIX } from "./chart-style.mjs";
6969

7070
const FEATURE_SCALE = 0.01 / 0.35278;
7171
// Linear (constant-velocity) easing for the follow camera — see updateFollow. The
@@ -613,10 +613,8 @@ export class ChartCanvas extends HTMLElement {
613613
setIf("contour-labels", "text-color", this.contourLabelColor());
614614
setIf("contour-labels", "text-halo-color", this.textHaloColor());
615615
setIf("complex-lines", "line-color", this.colorExpr("color_token"));
616-
for (const v of TEXT_VARIANTS) {
617-
setIf(v.id, "text-color", this.textColor());
618-
setIf(v.id, "text-halo-color", this.textHaloColor());
619-
}
616+
setIf("text", "text-color", this.textColor());
617+
setIf("text", "text-halo-color", this.textHaloColor());
620618
setIf("light-text", "text-color", this.textColor());
621619
setIf("light-text", "text-halo-color", this.textHaloColor());
622620
// Basemap (sea background + offline coastline) is scheme-aware too.
@@ -917,14 +915,13 @@ export class ChartCanvas extends HTMLElement {
917915
if (keys.includes("showLightDescriptions")) {
918916
this._eachLayer("light-text", (id) => this._setVis(id, this._mariner.showLightDescriptions === false ? "none" : "visible"));
919917
}
920-
// S-52 §14.5 text groups: re-derive each text variant's BASE filter (so it
918+
// S-52 §14.5 text groups: re-derive the text layer's BASE filter (so it
921919
// survives a later applyFeatureFilters category re-apply) when any group
922920
// toggle (or light descriptions, which also feeds the general group-23
923921
// clause) changes. Instant — no re-bake.
924922
if (keys.some((k) => k === "textImportant" || k === "textNames" || k === "textOther" || k === "showLightDescriptions")) {
925923
const notLight = ["!=", ["get", "class"], "LIGHTS"];
926-
const grp = this.textGroupFilter();
927-
for (const v of TEXT_VARIANTS) this.setBaseFilter(v.id, ["all", notLight, v.filter, grp]);
924+
this.setBaseFilter("text", ["all", notLight, this.textGroupFilter()]);
928925
}
929926
// Display category (multi-select) and boundary symbolization both filter
930927
// every chart layer by a baked per-feature tag (cat / bnd) — re-apply the
@@ -1171,9 +1168,9 @@ export class ChartCanvas extends HTMLElement {
11711168
}
11721169
}
11731170

1174-
// textAnchor + TEXT_VARIANTS (the S-52 halign/valign → text-anchor sublayer
1175-
// templates) live in chart-style.mjs now; TEXT_VARIANTS is imported above for
1176-
// setScheme/setMariner's per-variant text restyle.
1171+
// The S-52 halign/valign → text-anchor mapping and the collision sort-key live in
1172+
// chart-style.mjs now (one data-driven `text` layer); setScheme/setMariner restyle
1173+
// and refilter that single id directly.
11771174

11781175
// Custom element names must contain a hyphen (HTML spec) — `<chart-plotter>`.
11791176
customElements.define("chart-canvas", ChartCanvas);

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

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,53 @@ function complexLineLayers(palette) {
4343
paint: { "line-color": S52.colorExpr("color_token", undefined, palette), "line-width": ["coalesce", ["get", "width_px"], 1] },
4444
}];
4545
}
46+
// S-52 halign/valign → a DATA-DRIVEN MapLibre text-anchor. text-anchor became a
47+
// property-function (zoom-and-feature) property, so all general text now rides ONE
48+
// collidable layer instead of nine per-anchor sublayers. That matters for
49+
// DECLUTTERING: MapLibre keeps a single collision index but places symbols
50+
// layer-by-layer, so nine sublayers made a label's survival depend on WHICH anchor
51+
// it drew in (text-center-* always beat text-right-*), not on importance. One layer
52+
// + symbol-sort-key lets S-52 text priority decide who survives, globally.
53+
const TEXT_ANCHOR = (function () {
54+
// middle/baseline/center valigns collapse to the "center" row; top/bottom keep.
55+
const vrow = ["match", ["coalesce", ["get", "valign"], "middle"], "top", "top", "bottom", "bottom", "center"];
56+
const key = ["concat", vrow, "|", ["coalesce", ["get", "halign"], "center"]];
57+
return ["match", key,
58+
"center|left", "left", "center|right", "right", "center|center", "center",
59+
"top|center", "top", "bottom|center", "bottom",
60+
"top|left", "top-left", "top|right", "top-right",
61+
"bottom|left", "bottom-left", "bottom|right", "bottom-right",
62+
"center"]; // default: dead-centre
63+
})();
64+
65+
// Collision priority (S-52 §14.4 text grouping / S-100 Part 9 text placement):
66+
// LOWER sort-key = placed FIRST = wins. Rank by the baked `tgrp` (DISPLAY param) so
67+
// important text (11) outranks geographic/feature names (21/26/29), which outrank
68+
// descriptive text (nature of seabed 25, magnetic variation 27, heights, …). Within
69+
// a tier the LARGER label wins (subtract font size) — a dense approach (Annapolis)
70+
// then thins to the navigationally important labels instead of an anchor-order
71+
// lottery. Tiers are spaced 50 apart so font size only ever breaks WITHIN-tier ties.
72+
const TEXT_SORT_KEY = ["-",
73+
["match", ["coalesce", ["get", "tgrp"], -1],
74+
11, 0, // important text
75+
[21, 26, 29], 100, // geographic / feature names
76+
23, 50, // light description (a stray non-light group-23 label)
77+
150], // descriptive / other / unknown
78+
["coalesce", ["get", "font_size_px"], 10]];
79+
4680
function textLayers(mariner, palette) {
4781
// LIGHTS characteristic text is drawn by its OWN always-on layer (see the
4882
// "light-text" layer in buildLayers) so it can't be decluttered behind a
49-
// verbose name label — exclude it from the general (collidable) text layers.
83+
// verbose name label — exclude it from the general (collidable) text layer.
5084
const notLight = ["!=", ["get", "class"], "LIGHTS"];
51-
return TEXT_VARIANTS.map((v) => ({
52-
id: v.id, type: "symbol", source: "chart", "source-layer": "text",
53-
filter: ["all", notLight, v.filter, S52.textGroupFilter(mariner)],
85+
return [{
86+
id: "text", type: "symbol", source: "chart", "source-layer": "text",
87+
filter: ["all", notLight, S52.textGroupFilter(mariner)],
5488
layout: {
5589
"text-field": ["coalesce", ["get", "text"], ""], "text-font": FONT,
56-
"text-size": ["coalesce", ["get", "font_size_px"], 11], "text-anchor": v.anchor,
90+
"text-size": ["coalesce", ["get", "font_size_px"], 11],
91+
"text-anchor": TEXT_ANCHOR,
92+
"symbol-sort-key": TEXT_SORT_KEY,
5793
"text-allow-overlap": false, "text-optional": true,
5894
visibility: "visible",
5995
},
@@ -64,7 +100,7 @@ function textLayers(mariner, palette) {
64100
"text-halo-width": 1.4,
65101
"text-halo-blur": 0.5,
66102
},
67-
}));
103+
}];
68104
}
69105
function buildLayers(mariner, palette, atlasPpu, osm) {
70106
// Over an OSM basemap (raster or vector), let its detailed land show through:
@@ -141,6 +177,9 @@ function buildLayers(mariner, palette, atlasPpu, osm) {
141177
// Left-justify so a merged multi-line light label's lines align on their
142178
// left edge (e.g. stacked "Mo(U)W 20s 50m 17M" / "Mo(U)R 20s 50m 15M").
143179
"text-justify": "left",
180+
// Within the light layer, the brighter/larger characteristic wins a
181+
// collision (bigger font → smaller sort-key → placed first).
182+
"symbol-sort-key": ["-", 0, ["coalesce", ["get", "font_size_px"], 10]],
144183
"text-allow-overlap": false, "text-optional": true,
145184
// Light descriptions (LIGHTS06 characteristics) — individually
146185
// selectable per S-52 (default on); toggled by showLightDescriptions.
@@ -434,27 +473,3 @@ export function buildChartLayers({
434473
return { layers: out, layerBase, variants, layerVis };
435474
}
436475

437-
// S-52 halign/valign → MapLibre text-anchor, one decluttered sublayer per
438-
// (halign × valign-group) with a constant anchor (text-anchor isn't data-driven).
439-
function textAnchor(h, v) {
440-
const vv = v === "top" ? "top" : v === "bottom" ? "bottom" : "center";
441-
const hh = h === "left" ? "left" : h === "right" ? "right" : "center";
442-
if (vv === "center" && hh === "center") return "center";
443-
if (vv === "center") return hh;
444-
if (hh === "center") return vv;
445-
return vv + "-" + hh;
446-
}
447-
export const TEXT_VARIANTS = (function () {
448-
const out = [];
449-
for (const h of ["left", "center", "right"]) {
450-
for (const vg of ["top", "center", "bottom"]) {
451-
const anchor = textAnchor(h, vg === "center" ? "middle" : vg);
452-
const hf = ["==", ["coalesce", ["get", "halign"], "center"], h];
453-
const vf = vg === "center"
454-
? ["match", ["coalesce", ["get", "valign"], "middle"], ["middle", "baseline", "center"], true, false]
455-
: ["==", ["coalesce", ["get", "valign"], "middle"], vg];
456-
out.push({ id: "text-" + h + "-" + vg, anchor, filter: ["all", hf, vf] });
457-
}
458-
}
459-
return out;
460-
})();

0 commit comments

Comments
 (0)