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
19 changes: 8 additions & 11 deletions web/src/chart-canvas/chart-canvas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) — `<chart-plotter>`.
customElements.define("chart-canvas", ChartCanvas);
75 changes: 45 additions & 30 deletions web/src/chart-canvas/chart-style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
})();