diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index 7b92ef0..ba684bc 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -65,7 +65,7 @@ import { // three bookkeeping maps (_layerBase/_variants/_layerVis) the live updaters below // read. PAT_PREFIX (the fill-pattern image namespace) is homed there too — used by // the layer builder AND this element's registerPattern — and imported back here. -import { buildChartLayers, PAT_PREFIX, TEXT_VARIANTS } from "./chart-style.mjs"; +import { buildChartLayers, PAT_PREFIX } from "./chart-style.mjs"; const FEATURE_SCALE = 0.01 / 0.35278; // Linear (constant-velocity) easing for the follow camera — see updateFollow. The @@ -613,10 +613,8 @@ export class ChartCanvas extends HTMLElement { setIf("contour-labels", "text-color", this.contourLabelColor()); setIf("contour-labels", "text-halo-color", this.textHaloColor()); setIf("complex-lines", "line-color", this.colorExpr("color_token")); - for (const v of TEXT_VARIANTS) { - setIf(v.id, "text-color", this.textColor()); - setIf(v.id, "text-halo-color", this.textHaloColor()); - } + setIf("text", "text-color", this.textColor()); + setIf("text", "text-halo-color", this.textHaloColor()); setIf("light-text", "text-color", this.textColor()); setIf("light-text", "text-halo-color", this.textHaloColor()); // Basemap (sea background + offline coastline) is scheme-aware too. @@ -917,14 +915,13 @@ export class ChartCanvas extends HTMLElement { if (keys.includes("showLightDescriptions")) { this._eachLayer("light-text", (id) => this._setVis(id, this._mariner.showLightDescriptions === false ? "none" : "visible")); } - // S-52 §14.5 text groups: re-derive each text variant's BASE filter (so it + // S-52 §14.5 text groups: re-derive the text layer's BASE filter (so it // survives a later applyFeatureFilters category re-apply) when any group // toggle (or light descriptions, which also feeds the general group-23 // clause) changes. Instant — no re-bake. if (keys.some((k) => k === "textImportant" || k === "textNames" || k === "textOther" || k === "showLightDescriptions")) { const notLight = ["!=", ["get", "class"], "LIGHTS"]; - const grp = this.textGroupFilter(); - for (const v of TEXT_VARIANTS) this.setBaseFilter(v.id, ["all", notLight, v.filter, grp]); + this.setBaseFilter("text", ["all", notLight, this.textGroupFilter()]); } // Display category (multi-select) and boundary symbolization both filter // every chart layer by a baked per-feature tag (cat / bnd) — re-apply the @@ -1171,9 +1168,9 @@ export class ChartCanvas extends HTMLElement { } } -// textAnchor + TEXT_VARIANTS (the S-52 halign/valign → text-anchor sublayer -// templates) live in chart-style.mjs now; TEXT_VARIANTS is imported above for -// setScheme/setMariner's per-variant text restyle. +// The S-52 halign/valign → text-anchor mapping and the collision sort-key live in +// chart-style.mjs now (one data-driven `text` layer); setScheme/setMariner restyle +// and refilter that single id directly. // Custom element names must contain a hyphen (HTML spec) — ``. customElements.define("chart-canvas", ChartCanvas); diff --git a/web/src/chart-canvas/chart-style.mjs b/web/src/chart-canvas/chart-style.mjs index 12bcb4b..4cac437 100644 --- a/web/src/chart-canvas/chart-style.mjs +++ b/web/src/chart-canvas/chart-style.mjs @@ -43,17 +43,53 @@ function complexLineLayers(palette) { paint: { "line-color": S52.colorExpr("color_token", undefined, palette), "line-width": ["coalesce", ["get", "width_px"], 1] }, }]; } +// S-52 halign/valign → a DATA-DRIVEN MapLibre text-anchor. text-anchor became a +// property-function (zoom-and-feature) property, so all general text now rides ONE +// collidable layer instead of nine per-anchor sublayers. That matters for +// DECLUTTERING: MapLibre keeps a single collision index but places symbols +// layer-by-layer, so nine sublayers made a label's survival depend on WHICH anchor +// it drew in (text-center-* always beat text-right-*), not on importance. One layer +// + symbol-sort-key lets S-52 text priority decide who survives, globally. +const TEXT_ANCHOR = (function () { + // middle/baseline/center valigns collapse to the "center" row; top/bottom keep. + const vrow = ["match", ["coalesce", ["get", "valign"], "middle"], "top", "top", "bottom", "bottom", "center"]; + const key = ["concat", vrow, "|", ["coalesce", ["get", "halign"], "center"]]; + return ["match", key, + "center|left", "left", "center|right", "right", "center|center", "center", + "top|center", "top", "bottom|center", "bottom", + "top|left", "top-left", "top|right", "top-right", + "bottom|left", "bottom-left", "bottom|right", "bottom-right", + "center"]; // default: dead-centre +})(); + +// Collision priority (S-52 §14.4 text grouping / S-100 Part 9 text placement): +// LOWER sort-key = placed FIRST = wins. Rank by the baked `tgrp` (DISPLAY param) so +// important text (11) outranks geographic/feature names (21/26/29), which outrank +// descriptive text (nature of seabed 25, magnetic variation 27, heights, …). Within +// a tier the LARGER label wins (subtract font size) — a dense approach (Annapolis) +// then thins to the navigationally important labels instead of an anchor-order +// lottery. Tiers are spaced 50 apart so font size only ever breaks WITHIN-tier ties. +const TEXT_SORT_KEY = ["-", + ["match", ["coalesce", ["get", "tgrp"], -1], + 11, 0, // important text + [21, 26, 29], 100, // geographic / feature names + 23, 50, // light description (a stray non-light group-23 label) + 150], // descriptive / other / unknown + ["coalesce", ["get", "font_size_px"], 10]]; + function textLayers(mariner, palette) { // LIGHTS characteristic text is drawn by its OWN always-on layer (see the // "light-text" layer in buildLayers) so it can't be decluttered behind a - // verbose name label — exclude it from the general (collidable) text layers. + // verbose name label — exclude it from the general (collidable) text layer. const notLight = ["!=", ["get", "class"], "LIGHTS"]; - return TEXT_VARIANTS.map((v) => ({ - id: v.id, type: "symbol", source: "chart", "source-layer": "text", - filter: ["all", notLight, v.filter, S52.textGroupFilter(mariner)], + return [{ + id: "text", type: "symbol", source: "chart", "source-layer": "text", + filter: ["all", notLight, S52.textGroupFilter(mariner)], layout: { "text-field": ["coalesce", ["get", "text"], ""], "text-font": FONT, - "text-size": ["coalesce", ["get", "font_size_px"], 11], "text-anchor": v.anchor, + "text-size": ["coalesce", ["get", "font_size_px"], 11], + "text-anchor": TEXT_ANCHOR, + "symbol-sort-key": TEXT_SORT_KEY, "text-allow-overlap": false, "text-optional": true, visibility: "visible", }, @@ -64,7 +100,7 @@ function textLayers(mariner, palette) { "text-halo-width": 1.4, "text-halo-blur": 0.5, }, - })); + }]; } function buildLayers(mariner, palette, atlasPpu, osm) { // Over an OSM basemap (raster or vector), let its detailed land show through: @@ -141,6 +177,9 @@ function buildLayers(mariner, palette, atlasPpu, osm) { // Left-justify so a merged multi-line light label's lines align on their // left edge (e.g. stacked "Mo(U)W 20s 50m 17M" / "Mo(U)R 20s 50m 15M"). "text-justify": "left", + // Within the light layer, the brighter/larger characteristic wins a + // collision (bigger font → smaller sort-key → placed first). + "symbol-sort-key": ["-", 0, ["coalesce", ["get", "font_size_px"], 10]], "text-allow-overlap": false, "text-optional": true, // Light descriptions (LIGHTS06 characteristics) — individually // selectable per S-52 (default on); toggled by showLightDescriptions. @@ -434,27 +473,3 @@ export function buildChartLayers({ return { layers: out, layerBase, variants, layerVis }; } -// S-52 halign/valign → MapLibre text-anchor, one decluttered sublayer per -// (halign × valign-group) with a constant anchor (text-anchor isn't data-driven). -function textAnchor(h, v) { - const vv = v === "top" ? "top" : v === "bottom" ? "bottom" : "center"; - const hh = h === "left" ? "left" : h === "right" ? "right" : "center"; - if (vv === "center" && hh === "center") return "center"; - if (vv === "center") return hh; - if (hh === "center") return vv; - return vv + "-" + hh; -} -export const TEXT_VARIANTS = (function () { - const out = []; - for (const h of ["left", "center", "right"]) { - for (const vg of ["top", "center", "bottom"]) { - const anchor = textAnchor(h, vg === "center" ? "middle" : vg); - const hf = ["==", ["coalesce", ["get", "halign"], "center"], h]; - const vf = vg === "center" - ? ["match", ["coalesce", ["get", "valign"], "middle"], ["middle", "baseline", "center"], true, false] - : ["==", ["coalesce", ["get", "valign"], "middle"], vg]; - out.push({ id: "text-" + h + "-" + vg, anchor, filter: ["all", hf, vf] }); - } - } - return out; -})();