From 6af6e6b6e0cd3eed0b64069a81943c0c7c3c46cf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Tue, 30 Jun 2026 16:04:32 -0400 Subject: [PATCH 01/99] feat(viewing-groups): bake per-feature vg tag + client selection UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-52 §14.5 fine-grained viewing-group selection. The S-101 portrayal already computed a viewing-group number per feature; the baker now carries the MOST-VISIBLE draw's VG onto FeatureBuild and bakes it as the per-feature `vg` tile tag (areas/lines/symbols/text via route(), soundings, and sector-light legs). band(vg) == cat always holds, matching tile57's Portrayal.vg rule for Go↔Zig parity; emitted only when non-zero, so unbanded features' tile footprint is unchanged. Client: a `viewingGroupFilter` deny-list on mariner.viewingGroupsOff (byte-identical to tile57's chartstyle filter), a curated taxonomy of the standard mariner-selectable groups (viewing-groups.mjs, ~32 groups in 7 sections), and a "Viewing groups" settings tab of grouped toggles that add/remove a group's vg ids from the deny-list. Re-applies live (no re-bake) and persists via the existing mariner path. Bundles the settings reorg it builds on — the Display tab split and the cumulative Base/Standard/Other detail-level control — because they share core-settings.mjs (the cumulative `detailLevel` get/set is in the same hunks as the vg get/set). Spec: specs/viewing-groups.md (gitignored, untracked). tile57 engine counterpart already landed (../tile57/specs/viewing-groups.md). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 15 ++ internal/engine/portrayal/build.go | 7 + internal/engine/portrayal/s101build.go | 3 + internal/engine/portrayal/s101build_test.go | 22 +++ web/src/chart-canvas/chart-canvas.mjs | 2 +- web/src/chart-canvas/s52-style.mjs | 15 ++ web/src/chartplotter.mjs | 4 + web/src/core/core-settings.mjs | 159 +++++++++++++++++--- web/src/core/viewing-groups.mjs | 89 +++++++++++ 9 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 web/src/core/viewing-groups.mjs diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 3d102df..4ffca50 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -263,6 +263,7 @@ type routed struct { bcBnd int8 bcPts int16 bcScamin uint32 // SCAMIN denominator (0 = none); emitted as `scamin` for the client's per-SCAMIN bucket layers + bcVG int32 // raw viewing group (S-52 §14.5; 0 = unbanded); emitted as `vg` when non-zero for the mariner's per-viewing-group filter bcHasPts bool bcBase bool // attrs is variable-only; rebuild the base from bc* at emit } @@ -281,6 +282,7 @@ type sectorPrim struct { zMin uint32 natMax uint32 scamin uint32 // SCAMIN denominator of the parent LIGHTS (0 = none); emitted as `scamin` so the client's per-SCAMIN bucket layer gates the exact display cutoff, same as point symbols/text + vg int32 // raw viewing group (S-52 §14.5) of the parent LIGHTS; emitted as `vg` when non-zero // Date validity of the parent LIGHTS (S-52 §10.4.1.1); empty when none. Carried // because sectors tessellate at tile-emit time, after b.curDate* has moved on. dateStart, dateEnd string @@ -311,6 +313,7 @@ type Baker struct { curCell string // dataset name of the cell currently being added (stamped on each feature) curCscl uint32 // compilation-scale denominator of the cell currently being added (per-cell best-available) curScamin uint32 // SCAMIN (1:N min display scale) of the feature currently being expanded; 0 = none + curVG int32 // raw viewing group (S-52 §14.5) of the feature currently being expanded; 0 = unbanded. Baked as `vg` for the mariner's per-viewing-group filter. curObjnam string // OBJNAM of the feature currently being expanded (for the inspector) curLight string // light characteristic string of the current LIGHTS feature (e.g. "Fl.R.4s") curAttrs string // compact JSON of the feature's full S-57 attribute set (acronym→value) for the cursor-pick report (S-52 PresLib §10.8); "" when the feature has none @@ -780,6 +783,7 @@ func (b *Baker) addCell(chart *s57.Chart, pc CellPortrayal) { scamin := intAttr(f.Attributes(), "SCAMIN") b.curScamin = scamin // baked as the `scamin` tag → client per-SCAMIN bucket layers b.recordScamin(scamin) // publish the band's distinct values (manifest → TileJSON) + b.curVG = int32(fb.ViewingGroup) // baked as the `vg` tag → client per-viewing-group filter // Date validity period (S-52 §10.4.1.1) — baked onto each primitive so the // client applies the mandatory current-date filter. b.curDateStart, b.curDateEnd = fb.DateStart, fb.DateEnd @@ -903,6 +907,9 @@ func (b *Baker) routeSoundingGroup(names []string, sc portrayal.SymbolCall, clas {Key: "symbol_names", Value: mvt.StringVal(joined)}, {Key: "scale", Value: mvt.FloatVal(sc.Scale)}, } + if b.curVG != 0 { + attrs = append(attrs, mvt.KeyValue{Key: "vg", Value: mvt.IntVal(int64(b.curVG))}) + } if pts != ptsAlwaysShown { attrs = append(attrs, mvt.KeyValue{Key: "pts", Value: mvt.IntVal(pts)}) } @@ -1009,6 +1016,9 @@ func (b *Baker) attrsFor(r *routed, scratch *[]mvt.KeyValue) []mvt.KeyValue { if r.bcScamin != 0 { out = append(out, mvt.KeyValue{Key: "scamin", Value: mvt.IntVal(int64(r.bcScamin))}) } + if r.bcVG != 0 { + out = append(out, mvt.KeyValue{Key: "vg", Value: mvt.IntVal(int64(r.bcVG))}) + } out = append(out, r.attrs...) *scratch = out return out @@ -1063,6 +1073,7 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr bcCat: int16(catRank(cat)), bcBnd: int8(bnd), bcScamin: b.curScamin, + bcVG: b.curVG, } // pts is omitted for the common case (2): only paper/simplified variant passes // (0/1) carry it, so most features stay lean. @@ -1171,6 +1182,7 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr fig: v, class: class, cell: b.curCell, drawPrio: drawPrio, cat: cat, zMin: zMin, natMax: zr.Max, scamin: b.curScamin, + vg: b.curVG, dateStart: b.curDateStart, dateEnd: b.curDateEnd, legNorm: legNorm, }) @@ -1973,6 +1985,9 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 {Key: "bnd", Value: mvt.IntVal(bndAlwaysShown)}, {Key: "draw_prio", Value: mvt.IntVal(int64(sp.drawPrio))}, } + if sp.vg != 0 { + attrs = append(attrs, mvt.KeyValue{Key: "vg", Value: mvt.IntVal(int64(sp.vg))}) + } // Leg-length variant tag — only on the short/full legs (0/1); arcs and // rings (sleg -1) stay untagged so the client always shows them. if st.sleg >= 0 { diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 1756b97..0913bf0 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -21,6 +21,13 @@ type FeatureBuild struct { Primitives []Primitive DisplayPriority int DisplayCategory int + // ViewingGroup is the raw S-101/S-52 viewing-group number of the feature's + // MOST-VISIBLE draw — the same draw that set DisplayCategory — so + // displayCategoryForViewingGroup(ViewingGroup) == DisplayCategory always holds. + // 0 when the feature carries no banded viewing group. Baked as the per-feature + // `vg` tile tag for the mariner's fine-grained viewing-group selection (S-52 + // §14.5). Mirrors tile57's Portrayal.vg (src/s100/s101_instr.zig). + ViewingGroup int // DateStart/DateEnd/TimeValid carry the feature's date dependency when it has // one (S-57 DATSTA/DATEND fixed window or PERSTA/PEREND periodic window), so the // baker can tag the feature and a date-aware client show/hide it against the diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index b9e8677..57ad495 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -371,6 +371,7 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui var prims []Primitive priority := 0 cat := 0 // unset; resolved from the viewing groups the rule emits + vg := 0 // raw VG of the most-visible draw (the one that set cat), 0 if unbanded var dateStart, dateEnd, timeValid string for _, c := range cmds { if c.Priority > priority { @@ -399,6 +400,7 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui // also carries a standard/other label. if dc := displayCategoryForViewingGroup(c.ViewingGroup); dc != 0 && (cat == 0 || dc < cat) { cat = dc + vg = c.ViewingGroup // the most-visible draw's VG, so band(vg) == cat (tile57 parity) } prims = append(prims, emitPrimitives(c, sg, b.Catalog)...) } @@ -468,6 +470,7 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui Primitives: prims, DisplayPriority: priority, DisplayCategory: cat, + ViewingGroup: vg, DateStart: dateStart, DateEnd: dateEnd, TimeValid: timeValid, diff --git a/internal/engine/portrayal/s101build_test.go b/internal/engine/portrayal/s101build_test.go index b7da0dd..8c2acb0 100644 --- a/internal/engine/portrayal/s101build_test.go +++ b/internal/engine/portrayal/s101build_test.go @@ -201,6 +201,28 @@ func TestS101BuildDisplayCategory(t *testing.T) { } } +// TestS101BuildViewingGroup: a feature stamps the raw viewing group of its +// most-visible draw (LNDARE → 12010), and that VG's band always matches the +// chosen DisplayCategory — the invariant the client relies on when filtering on +// `cat` (category) and `vg` (viewing group) independently. Mirrors tile57's +// Portrayal.vg rule (band(vg) == cat). +func TestS101BuildViewingGroup(t *testing.T) { + b := s101Builder(t) + ring := [][]float64{{0, 0}, {0, 1}, {1, 1}, {1, 0}, {0, 0}} + land := s57.NewFeature(7, "LNDARE", + s57.Geometry{Type: s57.GeometryTypePolygon, Coordinates: ring}, nil) + build, ok := b.Build(&land) + if !ok { + t.Fatal("build failed") + } + if build.ViewingGroup != 12010 { + t.Errorf("LNDARE viewing group = %d, want 12010", build.ViewingGroup) + } + if dc := displayCategoryForViewingGroup(build.ViewingGroup); dc != build.DisplayCategory { + t.Errorf("band(vg %d) = %d, want it to equal cat %d", build.ViewingGroup, dc, build.DisplayCategory) + } +} + // TestS101LightFlareRotation: a LIGHTS feature's flare symbol is rotated (the // catalogue default 135°, screen-referenced). Regression: the CRS-qualified // "Rotation:PortrayalCRS,135" parsed to 0°, so flares never rotated. diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index 9d0795b..d1b184e 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -981,7 +981,7 @@ export class ChartCanvas extends HTMLElement { // Display category (multi-select) and boundary symbolization both filter // every chart layer by a baked per-feature tag (cat / bnd) — re-apply the // combined feature filter. Instant — no re-bake. - if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds" || k === "dateDependent" || k === "dateView" || k === "highlightDateDependent" || k === "showInformCallouts")) { + if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds" || k === "dateDependent" || k === "dateView" || k === "highlightDateDependent" || k === "showInformCallouts" || k === "viewingGroupsOff")) { this.applyFeatureFilters(); } } diff --git a/web/src/chart-canvas/s52-style.mjs b/web/src/chart-canvas/s52-style.mjs index 1b0a592..5538f76 100644 --- a/web/src/chart-canvas/s52-style.mjs +++ b/web/src/chart-canvas/s52-style.mjs @@ -307,11 +307,26 @@ export function dateFilter(mariner) { true]]]; } +// Viewing-group selection (S-52 §14.5, fine-grained content control): each feature +// is baked with its raw viewing-group number `vg` (the most-visible draw's VG, so +// band(vg) === cat). The mariner DENY-LIST mariner.viewingGroupsOff lists the vg ids +// turned off; a feature is hidden iff its vg is in that set. A feature with no `vg` +// (unbanded, or a tile baked before vg existed) always shows. Empty deny-list = no +// filter. Byte-identical to tile57's viewingGroupFilter (src/chartstyle/chartstyle.zig). +export function viewingGroupFilter(mariner) { + const off = mariner.viewingGroupsOff; + if (!off || !off.length) return null; + return ["any", ["!", ["has", "vg"]], ["!", ["in", ["get", "vg"], ["literal", off]]]]; +} + // Combine a layer's intrinsic (base) filter with the live category + // boundary-style filters (the two client-side portrayal axes baked as // per-feature `cat`/`bnd`). export function combineFilters(base, mariner) { const parts = ["all", categoryFilter(mariner), boundaryFilter(mariner), pointStyleFilter(mariner), sectorLegFilter(mariner)]; + // Fine-grained viewing-group deny-list (S-52 §14.5) — null when nothing is off. + const vgf = viewingGroupFilter(mariner); + if (vgf) parts.push(vgf); // Date-dependent display (S-52 §10.4.1.1, mandatory + default-on): hide a // dated feature outside its validity period for the viewing date. The escape // valve mariner.dateDependent === false shows all dates (only dated features diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 9b67998..6c6cc58 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -105,6 +105,10 @@ const DEFAULT_MARINER = { showContourLabels: false, dataQuality: false, showMetaBounds: false, + // Fine-grained viewing-group selection (S-52 §14.5): a DENY-LIST of raw viewing- + // group ids (the baked `vg` tag) the mariner has turned off. Empty = every group + // shown (default). Driven by the "Viewing groups" settings tab; see viewing-groups.mjs. + viewingGroupsOff: [], // Display units for non-depth quantities (distance/height/speed/wind/temp). // Depth has its own metric/imperial toggle (depthUnit, above). See units.mjs. ...UNIT_DEFAULTS, diff --git a/web/src/core/core-settings.mjs b/web/src/core/core-settings.mjs index f118c16..0101cdc 100644 --- a/web/src/core/core-settings.mjs +++ b/web/src/core/core-settings.mjs @@ -1,7 +1,7 @@ // core-settings.mjs — the app's own display settings, expressed as -// contributions. These reproduce the panes the shell used to render inline in -// renderSettings(): General, Text, Units, Depths, Advanced. Everything here is a -// plain contribution (same path a plugin would take) — there is no privileged +// contributions. Tabs: General (app chrome), Display (the S-52 display category + +// viewing-group toggles, grouped), Text, Units, Depths, Advanced. Everything here +// is a plain contribution (same path a plugin would take) — there is no privileged // built-in settings code in the dialog. // // const reg = new SettingsRegistry(); @@ -15,6 +15,7 @@ import { UNIT_CATEGORIES, M_TO_FT } from "../lib/units.mjs"; import { DEFAULT_PX_PITCH_MM } from "../lib/util.mjs"; +import { VIEWING_GROUP_SECTIONS, VG_BY_GROUP_ID } from "./viewing-groups.mjs"; // One shared read path for every core contribution. App-level flags live on the // shell; everything else is a mariner setting (pre-merged with DEFAULT_MARINER, @@ -26,6 +27,17 @@ function coreGet(app, key, def) { if (key === "showCellBounds") return app._showCellBounds; if (key === "showChartRadar") return app._showChartRadar; if (key === "pxPitch") return app._pxPitch; + if (key === "bakeEngine") return app._bakeEngine; + // Synthetic S-52 display-category level (§10.2). Base → Standard → All are + // CUMULATIVE, so the three mariner booleans collapse to one of three levels. + if (key === "detailLevel") return app._mariner.displayOther ? "other" : (app._mariner.displayStandard ? "standard" : "base"); + // Synthetic viewing-group toggle (S-52 §14.5): "vg:" reads ON (shown) + // iff NONE of the group's vg ids are in the mariner's deny-list. See viewing-groups.mjs. + if (key.startsWith("vg:")) { + const vgs = VG_BY_GROUP_ID[key.slice(3)] || []; + const off = app._mariner.viewingGroupsOff || []; + return !vgs.some((v) => off.includes(v)); + } const v = app._mariner[key]; return v === undefined ? def : v; } @@ -38,6 +50,20 @@ function coreSet(app, key, val) { if (key === "showCellBounds") return app._setCellBoundsVisible(val); if (key === "showChartRadar") return app._setChartRadarVisible(val); if (key === "pxPitch") return app.setPxPitch(val); + if (key === "bakeEngine") return app.setBakeEngine(val); + // Cumulative display category (S-52 §10.2): each level implies the ones below + // it. Base is permanent (displayBase always true); Standard adds the standard + // set; All adds the "other" category on top. This can never produce the + // non-conformant Standard-off / Other-on state the old multi-select allowed. + if (key === "detailLevel") return app.applyMariner({ displayBase: true, displayStandard: val !== "base", displayOther: val === "other" }); + // Viewing-group toggle (S-52 §14.5): turning a group OFF adds its vg ids to the + // deny-list; ON removes them. Stored sorted for a stable persisted value. + if (key.startsWith("vg:")) { + const vgs = VG_BY_GROUP_ID[key.slice(3)] || []; + const off = new Set(app._mariner.viewingGroupsOff || []); + for (const v of vgs) val ? off.delete(v) : off.add(v); + return app.applyMariner({ viewingGroupsOff: [...off].sort((a, b) => a - b) }); + } return app.applyMariner({ [key]: val }); } @@ -46,7 +72,11 @@ export function coreSettingsContributions(app) { const get = (k, d) => coreGet(app, k, d); const set = (k, v) => coreSet(app, k, v); - // GENERAL — basemap, detail level, area/point style, and the display toggles. + // GENERAL — app chrome only: the basemap drawn under the chart and the + // off-screen chart pointers. Everything S-52 (the display category + the + // viewing-group toggles) now lives on the dedicated DISPLAY tab below; the + // "Screen calibration" group (the calibration contribution, order 0.5) also + // slots into this tab. const general = { id: "core-general", tab: { id: "general", label: "General" }, @@ -60,11 +90,60 @@ export function coreSettingsContributions(app) { options: [["none", "Disabled"], ["coastline", "Offline"], ["osm", "OSM"], ...(app._osmVecUrl ? [["osmvec", "Vector"]] : [])], }, { - key: "detail", type: "multi", label: "Detail level", - desc: "How much chart detail to show — Base is always on", - locked: [["Base"]], - options: [["displayStandard", "Standard"], ["displayOther", "Other"]], + key: "showChartRadar", type: "toggle", label: "Off-screen chart pointers", + desc: "Edge arrows to installed charts you can't currently see — tap one to fly there", + }, + ], + }; + + // DISPLAY — the S-52 portrayal controls, split into five sub-groups that mirror + // the spec's mental model: pick a detail level (display category, §10.2), then + // add or remove the individual viewing groups within it. Each contribution + // renders as one sub-heading on the shared Display tab. + + // Detail level (display category, S-52 §10.2 / §10.3.4). CUMULATIVE: Base is the + // permanent safe-navigation minimum, Standard adds the normal chart content, All + // adds every other feature. Backed by displayBase/Standard/Other via the + // synthetic "detailLevel" key (coreGet/coreSet), so the control can never reach + // the non-conformant Standard-off / Other-on state the old multi-select allowed. + const displayDetail = { + id: "core-display-detail", + tab: { id: "display", label: "Display" }, + order: 0.6, + group: "Detail level", + get, set, + items: [ + { + key: "detailLevel", type: "segmented", label: "Detail level", + desc: "Display Base is always shown — Standard adds normal chart content, Other adds every remaining feature", + options: [["base", "Base"], ["standard", "Standard"], ["other", "Other"]], }, + ], + }; + + // Water, depth areas & soundings. + const displayWater = { + id: "core-display-water", + tab: "display", + order: 0.7, + group: "Water & soundings", + get, set, + items: [ + { key: "fourShadeWater", type: "toggle", label: "Four-shade water", desc: "Use four depth shades instead of two", default: true }, + { key: "shallowPattern", type: "toggle", label: "Shallow pattern", desc: "Diagonal fill in shallow water" }, + { key: "showSoundings", type: "toggle", label: "Spot soundings", desc: "Individual depth soundings", default: true }, + { key: "showNoData", type: "toggle", label: "No-data hatch", desc: "Hatch areas that have no chart data", default: true }, + ], + }; + + // Point symbols & line styling. + const displaySymbols = { + id: "core-display-symbols", + tab: "display", + order: 0.8, + group: "Symbols & lines", + get, set, + items: [ { key: "boundaryStyle", type: "segmented", label: "Area boundaries", desc: "Line style for area edges", default: "symbolized", @@ -75,24 +154,54 @@ export function coreSettingsContributions(app) { options: [["paper", "Paper-chart"], ["simplified", "Simplified"]], transform: { toView: (b) => (b ? "simplified" : "paper"), fromView: (s) => s === "simplified" }, }, - { key: "fourShadeWater", type: "toggle", label: "Four-shade water", desc: "Use four depth shades instead of two", default: true }, - { key: "showNoData", type: "toggle", label: "No-data hatch", desc: "Hatch areas that have no chart data", default: true }, - { key: "shallowPattern", type: "toggle", label: "Shallow pattern", desc: "Diagonal fill in shallow water" }, - { key: "showSoundings", type: "toggle", label: "Spot soundings", desc: "Individual depth soundings", default: true }, { key: "showFullSectorLines", type: "toggle", label: "Full sector lines", desc: "Draw light sectors to full range, not short stubs" }, + ], + }; + + // Dangers & data quality. + const displayDangers = { + id: "core-display-dangers", + tab: "display", + order: 0.9, + group: "Dangers & quality", + get, set, + items: [ { key: "showIsolatedDangersShallow", type: "toggle", label: "Isolated dangers (shallow)", desc: "Only flag isolated dangers in shallow water" }, { key: "dataQuality", type: "toggle", label: "Data quality", desc: "Survey zones-of-confidence overlay" }, + ], + }; + + // Chart boundaries & informational callouts. + const displayBounds = { + id: "core-display-bounds", + tab: "display", + order: 0.95, + group: "Boundaries & callouts", + get, set, + items: [ + { key: "showScaleBoundaries", type: "toggle", label: "Scale boundaries", desc: "Outline where more detailed charts exist" }, + { key: "showMetaBounds", type: "toggle", label: "Metadata boundaries", desc: "Chart coverage & region indicator lines" }, { key: "showInformCallouts", type: "toggle", label: "Information callouts", desc: "“Additional information available” (i) markers on features that carry notes" }, { key: "highlightDateDependent", type: "toggle", label: "Highlight date-dependent", desc: "Mark features that carry date conditions with the “d” symbol" }, - { key: "showMetaBounds", type: "toggle", label: "Metadata boundaries", desc: "Chart coverage & region indicator lines" }, - { key: "showScaleBoundaries", type: "toggle", label: "Scale boundaries", desc: "Outline where more detailed charts exist", default: true }, - { - key: "showChartRadar", type: "toggle", label: "Off-screen chart pointers", - desc: "Edge arrows to installed charts you can't currently see — tap one to fly there", - }, ], }; + // VIEWING GROUPS — S-52 §14.5 fine-grained content selection. One contribution + // per taxonomy section (→ one sub-heading); each item is a toggle keyed + // "vg:" that adds/removes the group's raw vg ids from the deny-list + // (mariner.viewingGroupsOff; see coreGet/coreSet + viewing-groups.mjs). All + // default ON. Only Standard/Other groups are listed — Display Base is the + // mandatory minimum and never selectable (S-52 §10.2). The first section declares + // the tab; the rest slot into it. Ordered (0.96+) so the tab sits after Display. + const viewingGroups = VIEWING_GROUP_SECTIONS.map((s, i) => ({ + id: `core-vg-${s.id}`, + tab: i === 0 ? { id: "viewing-groups", label: "Viewing groups" } : "viewing-groups", + order: 0.96 + i * 0.001, + group: s.label, + get, set, + items: s.groups.map((g) => ({ key: `vg:${g.id}`, type: "toggle", label: g.label, desc: g.desc, default: true })), + })); + // TEXT — the S-52 text-group toggles. const text = { id: "core-text", @@ -153,7 +262,7 @@ export function coreSettingsContributions(app) { tab: { id: "advanced", label: "Advanced" }, order: 4, get, set, - items: [ + items: () => [ { key: "showCellBounds", type: "toggle", label: "Cell boundaries", desc: "Outline installed charts when zoomed out — tap one to jump to it", @@ -162,7 +271,7 @@ export function coreSettingsContributions(app) { // mandatory current-date filter (default on); turning it off shows // seasonal/expired features regardless of their validity dates. The viewing // date evaluates that filter (and the "Highlight date-dependent" markers, - // toggled under General) against a chosen date for passage planning. + // toggled under Display) against a chosen date for passage planning. { key: "dateDependent", type: "toggle", label: "Hide out-of-date features", desc: "Hide seasonal or expired features outside their validity dates", default: true, @@ -171,6 +280,14 @@ export function coreSettingsContributions(app) { key: "dateView", type: "date", label: "Viewing date", desc: "Evaluate date-dependent features against this date for passage planning (blank = today)", }, + // Server bake engine — shown only when the server reports the native tile57 + // baker (a -tags tile57 build). tile57 bakes a SCAMIN-bucketed bundle; Go is + // the built-in baker. + ...((app._bakeEngines || ["go"]).includes("tile57") ? [{ + key: "bakeEngine", type: "segmented", label: "Bake engine", default: "go", + desc: "Engine that bakes imported charts on the server (native tile57 vs the built-in Go baker)", + options: [["go", "Go"], ["tile57", "tile57 (native)"]], + }] : []), ], }; @@ -229,5 +346,5 @@ export function coreSettingsContributions(app) { }, }; - return [general, calibration, text, units, depths, advanced]; + return [general, displayDetail, displayWater, displaySymbols, displayDangers, displayBounds, ...viewingGroups, calibration, text, units, depths, advanced]; } diff --git a/web/src/core/viewing-groups.mjs b/web/src/core/viewing-groups.mjs new file mode 100644 index 0000000..f57ee17 --- /dev/null +++ b/web/src/core/viewing-groups.mjs @@ -0,0 +1,89 @@ +// viewing-groups.mjs — the S-52 §14.5 viewing-group taxonomy: the standard +// mariner-selectable content groups, each mapping a friendly name to the raw +// S-101 viewing-group number(s) the baker stamps as the per-feature `vg` tile tag +// (internal/engine/portrayal + the native tile57 engine). +// +// Toggling a group OFF adds its `vgs` to mariner.viewingGroupsOff (a DENY-LIST); +// the client's viewingGroupFilter (s52-style.mjs) then hides features whose `vg` +// is in that set. Empty deny-list = every group shown (the default). A feature +// with no `vg` (unbanded, or a tile baked before `vg` existed) always shows. +// +// Only Display Standard (2xxxx) and Display Other (3xxxx/9xxxx) groups are listed: +// Display Base (1xxxx — coastline, safety contour, isolated dangers, land) is the +// mandatory safe-navigation minimum and is NEVER selectable (S-52 §10.2). Ids come +// from portrayal_catalogue.xml . + +export const VIEWING_GROUP_SECTIONS = [ + { + id: "depths", label: "Depths & seabed", + groups: [ + { id: "soundings", label: "Soundings", desc: "Spot depth soundings", vgs: [33010] }, + { id: "depthContours", label: "Depth contours", desc: "Depth contours other than the safety contour", vgs: [33020] }, + { id: "seabed", label: "Seabed & quality", desc: "Nature of seabed, springs, weed/kelp", vgs: [34010, 34020] }, + { id: "subseaDangers", label: "Rocks, wrecks & obstructions", desc: "Non-dangerous underwater rocks, wrecks & obstructions", vgs: [34050, 34051] }, + { id: "cables", label: "Cables & pipelines", desc: "Submarine cables & pipelines, mooring cables", vgs: [34030, 34070, 24010] }, + ], + }, + { + id: "aids", label: "Buoys, beacons & lights", + groups: [ + { id: "buoys", label: "Buoys", desc: "Buoys, light floats & mooring buoys", vgs: [27010, 27011] }, + { id: "beacons", label: "Beacons", desc: "Beacons of all categories", vgs: [27020] }, + { id: "marks", label: "Daymarks & topmarks", desc: "Daymarks, topmarks, distance marks", vgs: [27025, 27030, 27050] }, + { id: "lights", label: "Lights", desc: "Lights & their sectors", vgs: [27070] }, + { id: "fogSignals", label: "Fog signals", desc: "Fog signals & retro-reflectors", vgs: [27080] }, + { id: "radarAids", label: "Radar beacons", desc: "Racons, radar reflectors & ranges", vgs: [27200, 27210, 27220, 27230, 27240] }, + ], + }, + { + id: "areas", label: "Areas & limits", + groups: [ + { id: "restricted", label: "Restricted areas", desc: "Restricted, prohibited & protected areas", vgs: [26010, 26200, 26210] }, + { id: "caution", label: "Caution areas", desc: "Caution areas", vgs: [26150] }, + { id: "anchorages", label: "Anchorages", desc: "Anchorage areas & anchor berths", vgs: [26220] }, + { id: "dumping", label: "Dumping grounds", desc: "Dumping grounds & spoil areas", vgs: [26240] }, + { id: "military", label: "Military & transit areas", desc: "Submarine transit lanes, military practice areas", vgs: [26040] }, + { id: "production", label: "Production & cargo areas", desc: "Cargo transhipment, incineration, production areas", vgs: [26250] }, + ], + }, + { + id: "routes", label: "Routes & traffic", + groups: [ + { id: "leadingLines", label: "Leading & clearing lines", desc: "Leading lines, clearing lines, traffic lanes, deep-water routes", vgs: [25010] }, + { id: "tracks", label: "Recommended tracks", desc: "Recommended tracks, traffic lanes & routes", vgs: [25020] }, + { id: "ferry", label: "Ferry routes", desc: "Ferry routes", vgs: [25030] }, + { id: "fairways", label: "Fairways", desc: "Fairways", vgs: [26050] }, + { id: "archipelagic", label: "Archipelagic sea lanes", desc: "Archipelagic sea lanes", vgs: [26260] }, + ], + }, + { + id: "land", label: "Land & port", + groups: [ + { id: "builtUp", label: "Built-up areas", desc: "Built-up areas", vgs: [22240] }, + { id: "landFeatures", label: "Land features", desc: "Slopes, hills, vegetation, rivers & lakes", vgs: [32010, 32030, 32050] }, + { id: "shoreStructures", label: "Shore & port structures", desc: "Shore structures, harbour & port features", vgs: [32200, 32220, 32400, 32410, 32440] }, + { id: "airports", label: "Airports", desc: "Airports & runways", vgs: [32240] }, + ], + }, + { + id: "services", label: "Services", + groups: [ + { id: "stations", label: "Pilot & signal stations", desc: "Pilot boarding points, signal & traffic stations", vgs: [28010, 28020] }, + { id: "radioStations", label: "Radio, radar & coastguard", desc: "Radio/radar stations, coastguard & rescue stations", vgs: [38010, 38030] }, + { id: "smallCraft", label: "Small-craft facilities", desc: "Small-craft facilities", vgs: [38200, 38210] }, + ], + }, + { + id: "admin", label: "Administrative", + groups: [ + { id: "magVar", label: "Magnetic variation", desc: "Magnetic variation & local anomalies", vgs: [31080] }, + { id: "zones", label: "Maritime zones", desc: "Fishery zones, EEZ, contiguous zones, pollution areas", vgs: [36040, 36050, 36060] }, + { id: "tidal", label: "Tidal & current info", desc: "Tidal levels & tidal-stream / current information", vgs: [33050, 33060] }, + ], + }, +]; + +// groupId → [vg ids], for the settings get/set (core-settings.mjs). +export const VG_BY_GROUP_ID = Object.fromEntries( + VIEWING_GROUP_SECTIONS.flatMap((s) => s.groups.map((g) => [g.id, g.vgs])), +); From 59883b2c74d172556efff2bf0d3100fb4506e0fa Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Tue, 30 Jun 2026 16:43:41 -0400 Subject: [PATCH 02/99] =?UTF-8?q?fix(web):=20settings=20panel=20polish=20?= =?UTF-8?q?=E2=80=94=20stacked=20rows,=20sticky=20section=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three readability fixes to the settings dialog: - Setting rows stack: the description moves to its own full-width line BENEATH the label+control, instead of sharing the narrow label column beside the control (where long text — e.g. "Non-dangerous underwater rocks, wrecks & obstructions" — wrapped hard). - Section headers read as distinct dividers and STICK to the top of the scrolling pane until the next section's header pushes them up (mirrors the chart library's .mcol-head). Pane top padding dropped so the pinned header sits flush with no row peeking through above it. - Remove the duplicate "Screen calibration" group from General (the screen-diagonal method); the dedicated Calibration tab (the S-52 5 mm CHKSYM ruler-measure method) is the single calibration UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/core/core-settings.mjs | 67 +++--------------------- web/src/plugins/settings-dialog.view.mjs | 33 +++++++----- 2 files changed, 27 insertions(+), 73 deletions(-) diff --git a/web/src/core/core-settings.mjs b/web/src/core/core-settings.mjs index 0101cdc..4a4d9f5 100644 --- a/web/src/core/core-settings.mjs +++ b/web/src/core/core-settings.mjs @@ -14,7 +14,6 @@ // SettingsStore here — that's for future plugins. import { UNIT_CATEGORIES, M_TO_FT } from "../lib/units.mjs"; -import { DEFAULT_PX_PITCH_MM } from "../lib/util.mjs"; import { VIEWING_GROUP_SECTIONS, VG_BY_GROUP_ID } from "./viewing-groups.mjs"; // One shared read path for every core contribution. App-level flags live on the @@ -26,7 +25,6 @@ function coreGet(app, key, def) { if (key === "scheme") return app._scheme; if (key === "showCellBounds") return app._showCellBounds; if (key === "showChartRadar") return app._showChartRadar; - if (key === "pxPitch") return app._pxPitch; if (key === "bakeEngine") return app._bakeEngine; // Synthetic S-52 display-category level (§10.2). Base → Standard → All are // CUMULATIVE, so the three mariner booleans collapse to one of three levels. @@ -49,7 +47,6 @@ function coreSet(app, key, val) { if (key === "scheme") return app.applyScheme(val); if (key === "showCellBounds") return app._setCellBoundsVisible(val); if (key === "showChartRadar") return app._setChartRadarVisible(val); - if (key === "pxPitch") return app.setPxPitch(val); if (key === "bakeEngine") return app.setBakeEngine(val); // Cumulative display category (S-52 §10.2): each level implies the ones below // it. Base is permanent (displayBase always true); Standard adds the standard @@ -74,9 +71,8 @@ export function coreSettingsContributions(app) { // GENERAL — app chrome only: the basemap drawn under the chart and the // off-screen chart pointers. Everything S-52 (the display category + the - // viewing-group toggles) now lives on the dedicated DISPLAY tab below; the - // "Screen calibration" group (the calibration contribution, order 0.5) also - // slots into this tab. + // viewing-group toggles) now lives on the dedicated DISPLAY tab below; screen + // calibration has its own "Calibration" tab (plugins/calibration.mjs). const general = { id: "core-general", tab: { id: "general", label: "General" }, @@ -291,60 +287,9 @@ export function coreSettingsContributions(app) { ], }; - // SCREEN CALIBRATION — make the on-screen scale (the 1:N readout, overscale, and - // "go to scale") match a real ruler / other ENCs. We can't know the monitor's - // physical pixel size, so the user enters their display's diagonal and we derive - // the CSS-pixel pitch from window.screen. Stored as pxPitch (mm per CSS pixel); - // the engine scale (bands/SCAMIN/overscale-vs-CSCL) is unaffected. - const cssDiagPx = () => { - const s = window.screen || {}; - return Math.hypot(s.width || 1280, s.height || 800) || 1509; // CSS-pixel screen diagonal - }; - const pitchToInches = (mm) => (cssDiagPx() * mm) / 25.4; // pitch → implied diagonal (in) - const inchesToPitch = (inch) => (inch * 25.4) / cssDiagPx(); // diagonal → pitch (mm/CSS px) - const calibration = { - id: "core-calibration", - tab: { id: "general", label: "General" }, - order: 0.5, - group: "Screen calibration", - get, set, - render(host) { - const pitch = get("pxPitch") || DEFAULT_PX_PITCH_MM; - host.innerHTML = ` - -
-
Enter your display's diagonal so the scale readout matches a real ruler (and other ENCs). Affects only the on-screen scale numbers — not the charts.
- -
Pixel pitch: ${pitch.toFixed(4)} mm
-
`; - const diag = host.querySelector(".cal-diag"); - const out = host.querySelector(".cal-pitch"); - const apply = () => { - const inch = +diag.value; - if (!(inch >= 3 && inch <= 120)) return; - const mm = inchesToPitch(inch); - set("pxPitch", mm); - out.textContent = mm.toFixed(4); - }; - diag.addEventListener("change", apply); - diag.addEventListener("input", apply); - host.querySelector(".cal-reset").addEventListener("click", () => { - set("pxPitch", undefined); - diag.value = pitchToInches(DEFAULT_PX_PITCH_MM).toFixed(1); - out.textContent = DEFAULT_PX_PITCH_MM.toFixed(4); - }); - }, - }; + // (Screen calibration lives in its own "Calibration" tab — plugins/calibration.mjs, + // the S-52 CHKSYM 5 mm ruler-measure method. The old screen-diagonal duplicate that + // used to sit here under General was removed.) - return [general, displayDetail, displayWater, displaySymbols, displayDangers, displayBounds, ...viewingGroups, calibration, text, units, depths, advanced]; + return [general, displayDetail, displayWater, displaySymbols, displayDangers, displayBounds, ...viewingGroups, text, units, depths, advanced]; } diff --git a/web/src/plugins/settings-dialog.view.mjs b/web/src/plugins/settings-dialog.view.mjs index b652187..68e2654 100644 --- a/web/src/plugins/settings-dialog.view.mjs +++ b/web/src/plugins/settings-dialog.view.mjs @@ -23,17 +23,27 @@ export const STYLE = ` .set-rail button { text-align:left; border:none; background:none; color:var(--ui-text-dim); font:inherit; font-size:13px; font-weight:600; padding:9px 11px; border-radius:8px; cursor:pointer; transition:background .1s,color .1s; } .set-rail button:hover { background:var(--ui-surface); color:var(--ui-text); } .set-rail button.sel { background:var(--ui-accent); color:var(--ui-accent-text); } - .set-pane { flex:1 1 0; min-width:0; overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; padding:4px 18px 10px; } - .set-group { margin-top:14px; font-size:11.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--ui-text-faint); padding:0 2px 2px; } - .set-group:first-child { margin-top:4px; } + /* No top padding: the first section header pins flush to the very top, so no row + can peek through the gap above a stuck sticky header. */ + .set-pane { flex:1 1 0; min-width:0; overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; padding:0 18px 10px; } + /* Section header: STICKS to the top of the scrolling pane until the next section's + header slides up and takes its place. A full-bleed opaque bar (the pane sits on + --ui-surface) so rows scroll cleanly underneath. Mirrors .mcol-head in the chart + library. */ + .set-group { position:sticky; top:0; z-index:2; margin:0 -18px; padding:9px 20px 7px; + font-size:11.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; + color:var(--ui-text-dim); background:var(--ui-surface); border-bottom:1px solid var(--ui-border-2); } .set-host { /* a contribution's custom-render slot */ } .set-host .dev-tools { border-top:1px solid var(--ui-border-2); margin-top:8px; } - .set-row { display:flex; align-items:center; gap:14px; padding:13px 2px; border-bottom:1px solid var(--ui-border-2); } + /* The row stacks: a header line (label + control side-by-side) and, beneath it, + the description spanning the FULL row width so it doesn't wrap inside the narrow + label column when a control sits beside it. */ + .set-row { display:flex; flex-direction:column; padding:13px 2px; border-bottom:1px solid var(--ui-border-2); } .set-row:last-child { border-bottom:none; } - .set-row .lbl { display:flex; flex-direction:column; min-width:0; flex:1 1 auto; } - .set-row .lbl .t { font-weight:600; font-size:13.5px; } - .set-row .lbl .d { font-size:12px; color:var(--ui-text-faint); margin-top:3px; line-height:1.45; } + .set-row .set-head { display:flex; align-items:center; gap:14px; } + .set-row .t { font-weight:600; font-size:13.5px; flex:1 1 auto; min-width:0; } + .set-row .d { font-size:12px; color:var(--ui-text-faint); margin-top:6px; line-height:1.45; } .set-row .ctl { flex:none; margin-left:auto; display:flex; align-items:center; gap:6px; } .set-row .ctl input[type=number] { width:58px; text-align:right; border:1px solid var(--ui-border-strong); border-radius:6px; padding:5px 7px; font:inherit; font-size:16px; background:var(--ui-surface); color:var(--ui-text); } .set-row .ctl .unit { color:var(--ui-text-faint); font-size:12px; min-width:14px; } @@ -55,8 +65,7 @@ export const STYLE = ` .set-empty { padding:24px 2px; color:var(--ui-text-faint); font-size:13px; } @media (max-width:560px) { - .set-row { flex-wrap:wrap; gap:8px 14px; } - .set-row .lbl { flex:1 1 60%; } + .set-row .set-head { flex-wrap:wrap; gap:8px 14px; } /* Stack the shell: the rail becomes a horizontal scrolling tab strip above the pane. */ .set-shell { flex-direction:column; max-height:none; } .set-rail { flex:0 0 auto; flex-direction:row; gap:4px; overflow-x:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; @@ -113,9 +122,9 @@ function control(item, value, on) { // A labelled settings row wrapping one control. export function settingRow(item, value, on) { - const desc = item.desc ? `${esc(item.desc)}` : ""; - return `
${esc(item.label)}${desc}
-
${control(item, value, on)}
`; + const desc = item.desc ? `
${esc(item.desc)}
` : ""; + return `
${esc(item.label)} +
${control(item, value, on)}
${desc}
`; } // A group subheading (only when a group has a title). From 34bf1835b6aaddba17383acea071327d48ec0755 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Tue, 30 Jun 2026 16:47:05 -0400 Subject: [PATCH 03/99] feat(tile57): opt-in CGO backend linking native libtile57 Add the native tile57 engine (chartplotter-native, via the ../tile57 symlink) as an OPT-IN tile/asset/style backend, fully behind the `tile57` build tag so the default build and all release/xbuild binaries stay CGO-free (the CLAUDE.md invariant). `make build-tile57` links libtile57; `make serve-tile57 ENC_ROOT=` serves a live set; `cp bake --tile57` and `cp serve --tile57 ` are the runtime flags. - Build: build-tile57/serve-tile57 Makefile targets; go.mod requires the tile57 Go binding via a local replace (../tile57/bindings/go); CLAUDE.md documents the opt-in backend. - CLI/server: tile57_*.go pairs (//go:build tile57 / !tile57) provide the bake/serve/assets/style entry points, with no-op stubs in the default build so nothing tile57-tagged leaks into the CGO-free path. - Server: wire the native baker/style/asset emission into the existing bake + serve + tileset paths. - Web: client adopts the engine-emitted S-52 style/assets (chart-sources, chart-style) and the server-reported bake-engine selector (chartplotter, coverage-boxes). CGO-free build + vet + server tests pass. Binary artifacts (examples/*.wasm, *.cpx, testdata/*) remain intentionally untracked. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 +- Makefile | 47 ++++++- cmd/chartplotter/bake.go | 84 +++++++++++ cmd/chartplotter/serve.go | 22 ++- cmd/chartplotter/tile57_assets.go | 125 +++++++++++++++++ cmd/chartplotter/tile57_assets_off.go | 16 +++ cmd/chartplotter/tile57_bake.go | 17 +++ cmd/chartplotter/tile57_bake_off.go | 11 ++ cmd/chartplotter/tile57_serve.go | 81 +++++++++++ cmd/chartplotter/tile57_serve_off.go | 19 +++ go.mod | 3 + internal/engine/server/http.go | 15 +- internal/engine/server/import.go | 31 +++++ internal/engine/server/prefs.go | 17 ++- internal/engine/server/server_test.go | 4 +- internal/engine/server/tile57_bake.go | 153 +++++++++++++++++++++ internal/engine/server/tile57_bake_off.go | 18 +++ internal/engine/server/tile57_style.go | 118 ++++++++++++++++ internal/engine/server/tile57_style_off.go | 11 ++ internal/engine/server/tilesets.go | 9 ++ web/src/chart-canvas/chart-sources.mjs | 6 +- web/src/chart-canvas/chart-style.mjs | 39 ++++-- web/src/chartplotter.mjs | 30 +++- web/src/plugins/coverage-boxes.mjs | 8 +- 24 files changed, 874 insertions(+), 21 deletions(-) create mode 100644 cmd/chartplotter/tile57_assets.go create mode 100644 cmd/chartplotter/tile57_assets_off.go create mode 100644 cmd/chartplotter/tile57_bake.go create mode 100644 cmd/chartplotter/tile57_bake_off.go create mode 100644 cmd/chartplotter/tile57_serve.go create mode 100644 cmd/chartplotter/tile57_serve_off.go create mode 100644 internal/engine/server/tile57_bake.go create mode 100644 internal/engine/server/tile57_bake_off.go create mode 100644 internal/engine/server/tile57_style.go create mode 100644 internal/engine/server/tile57_style_off.go diff --git a/CLAUDE.md b/CLAUDE.md index 7de8ccb..35c187d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,12 +12,21 @@ bathymetric support is planned. available locally). - `make test` / `make vet` / `make fmt` — run before you commit. - `make serve` — build and serve `web/` on `:8080`. +- `make build-tile57` — OPT-IN CGO build linking the native `tile57` engine + (`../tile57` → chartplotter-native) as the tile/asset/style backend (`-tags + tile57`). `make serve-tile57 ENC_ROOT=` serves a live libtile57 set; + `cp bake --tile57` / `cp serve --tile57 ` are the runtime flags. Needs + the `../tile57` symlink and Zig 0.16. ## Conventions - **Stay CGO-free.** Builds run with `CGO_ENABLED=0` (the cross-compiled release binaries depend on it). Use pure-Go libraries only — e.g. `modernc.org/sqlite`, - not `mattn/go-sqlite3`. Do not add a dependency that needs cgo. + not `mattn/go-sqlite3`. Do not add a dependency that needs cgo. The ONLY + exception is the opt-in `-tags tile57` backend (`make build-tile57`), which links + the native libtile57 engine via CGO; it is fully build-tagged, so the default + build and all release/`xbuild` binaries stay CGO-free. Keep it that way — never + let `tile57`-tagged code leak into a file that the default build compiles. - Use https://www.openbridge.no/ for design and icons. - Match the style of the code around you. - Never run `git add -A` or `git add .`. The repo holds large untracked files diff --git a/Makefile b/Makefile index e6352e7..e625afe 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ S101_PC ?= $(HOME)/Projects/s101-portrayal-catalogue/PortrayalCatalog S101_FC ?= $(HOME)/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml S101_CACHE ?= $(CACHE)/s101 -.PHONY: build xbuild test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages +.PHONY: build build-tile57 tile57-lib serve-tile57 xbuild test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages # Prebaked prod test set (US Inland ENC bundle + the NOAA world archive). # NB: keep these as bare values with NO inline `#` comments — Make folds any @@ -91,6 +91,51 @@ build: ## Build the self-contained shim (embeds web/ + S-101 catalogue) into bin go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter; \ fi +# --- Optional native libtile57 (tile57) CGO backend ---------------------------- +# The default build is CGO-free (a release-binary requirement); this opt-in target +# links the native Zig engine's static library so tiles, bakes, and S-101 assets +# are produced by libtile57 instead of the pure-Go path. TILE57 points at the +# engine repo via the ../tile57 symlink (→ chartplotter-native); override to relocate. +TILE57 ?= ../tile57 +TILE57_LIB := $(TILE57)/zig-out/lib/libtile57.a + +# Build the static library on demand (only when absent). Needs Zig 0.16 on PATH. +$(TILE57_LIB): + @command -v zig >/dev/null 2>&1 || { echo "Zig 0.16 not on PATH and $(TILE57_LIB) missing — install Zig or prebuild the lib"; exit 1; } + @echo "building libtile57.a (zig build in $(TILE57))…" + cd "$(TILE57)" && zig build + +tile57-lib: ## Force-rebuild ../tile57/zig-out/lib/libtile57.a (the native engine static lib) + @command -v zig >/dev/null 2>&1 || { echo "Zig 0.16 not on PATH"; exit 1; } + cd "$(TILE57)" && zig build + +# CGO build linking libtile57 as the tile/asset backend (-tags tile57). Embeds the +# S-101 catalogue too when it's available locally, like `make build`. +build-tile57: $(TILE57_LIB) ## Build bin/chartplotter with the libtile57 CGO backend (needs the ../tile57 symlink + Zig) + @test -f "$(TILE57)/include/tile57.h" || { echo "missing $(TILE57)/include/tile57.h — create the symlink: ln -s ../chartplotter-native ../tile57"; exit 1; } + @if [ -d "$(S101_PC)" ] && [ -f "$(S101_FC)" ]; then \ + $(MAKE) --no-print-directory sync-s101; \ + echo "building libtile57 backend + embedded S-101 catalogue (-tags 'embed_s101 tile57')…"; \ + CGO_ENABLED=1 go build -tags 'embed_s101 tile57' -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter; \ + else \ + echo "S-101 catalogue not found; building libtile57 backend WITHOUT it (needs --s101 at runtime)…"; \ + CGO_ENABLED=1 go build -tags tile57 -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter; \ + fi + @echo "→ $(BIN) (libtile57 backend; serve with --tile57 or bake with --tile57)" + +# Build the FULL app WITH libtile57 compiled in and serve it: the web frontend + +# provisioning / chart-library API, defaulting chart imports to the native tile57 +# bundle baker (--tile57-bake; the Advanced→"Bake engine" UI setting still overrides +# per deployment). No --s101 needed — build-tile57 embeds the catalogue (and tile57 +# bakes with libtile57's own rules). Uses the MAIN $(CACHE) (where the tile57 bundle +# baker writes its packs as //tiles/chart.pmtiles), NOT `serve`'s +# vestigial $(S101_CACHE) subdir — so the chart library you bake with tile57 is the +# one served here. Set ENC_ROOT= to ALSO register a LIVE libtile57 set +# generated on demand from those cells (registered as 'tile57'; /tiles/tile57.json). +serve-tile57: build-tile57 ## Build + serve the full app with libtile57 compiled in (tile57 bake engine; ENC_ROOT=… also adds a live set) + $(BIN) serve --host $(HOST) --port $(PORT) --assets $(ASSETS) --tile57-bake --cache $(CACHE) \ + $(if $(ENC_ROOT),--tile57 "$(ENC_ROOT)") + # Quick cross-platform test builds. CGO is off, so this is pure `go build` per # target — fast cold, near-instant on re-runs thanks to the build cache. Stamps # the same version as `build`; strips symbols (-s -w) and paths (-trimpath) like a diff --git a/cmd/chartplotter/bake.go b/cmd/chartplotter/bake.go index 31270ac..4e8e2a4 100644 --- a/cmd/chartplotter/bake.go +++ b/cmd/chartplotter/bake.go @@ -27,6 +27,7 @@ type bakeCmd struct { Bands bool `help:"Write one gap-clipped archive PER navigational band (-.pmtiles) instead of one merged archive, so the client reproduces the realtime best-available display: each band's source client-overzooms its own data, coarser bands fill finer gaps, none bleed."` S101 string `name:"s101" type:"existingdir" help:"Override the embedded catalogue with an external S-101 PortrayalCatalog directory (for iterating on rules). Requires --s101-fc."` S101FC string `name:"s101-fc" type:"existingfile" help:"S-101 FeatureCatalogue.xml path (with --s101)."` + Tile57 bool `name:"tile57" help:"(requires a -tags tile57 build) Bake with the native libtile57 engine into a self-contained chart BUNDLE (tiles/chart.pmtiles + assets/style-*.json + manifest.json) under -o (treated as a directory). Honors --max-zoom; --bands/--manifest/--overzoom don't apply (the bundle is zoom-banded per cell and self-describing)."` } func (c bakeCmd) Run() error { @@ -39,6 +40,14 @@ func (c bakeCmd) Run() error { } fmt.Fprintln(os.Stderr, "portrayal: S-101 rule engine") } + + // Native libtile57 bundle path (opt-in, -tags tile57): the engine reads the ENC + // from disk and writes a self-contained bundle, so it runs BEFORE the in-memory + // collectCells the Go baker needs. + if c.Tile57 { + return c.runTile57Bundle() + } + cells, aux, err := collectCells(c.In) if err != nil { return err @@ -221,6 +230,81 @@ func (c bakeCmd) runBands(cells map[string]baker.CellData, aux map[string][]byte return nil } +// runTile57Bundle bakes the ENC inputs with the native libtile57 engine into a +// self-contained chart bundle (tiles/chart.pmtiles + per-scheme SCAMIN-bucketed +// style-*.json + assets + manifest.json) under the output directory. The engine +// reads the ENC from disk, so a lone directory or .000 is handed over directly and +// only zips / multiple inputs are staged into a temp directory of cells first. +func (c bakeCmd) runTile57Bundle() error { + input, cleanup, err := c.tile57Input() + if err != nil { + return err + } + defer cleanup() + + outDir := bundleOutDir(c.Out) + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + // nil progress → the lib's built-in per-band console progress (good CLI output). + n, bbox, err := bakeTile57Bundle(input, outDir, c.MaxZoom, nil) + if err != nil { + return err + } + fmt.Printf("baked %d cell(s) → %s/ via libtile57 — bundle: tiles/chart.pmtiles + assets/style-{day,dusk,night}.json + manifest.json (bbox %.4f,%.4f,%.4f,%.4f)\n", + n, outDir, bbox[0], bbox[1], bbox[2], bbox[3]) + return nil +} + +// tile57Input resolves the ENC inputs to a single on-disk path for the bundle +// baker. A lone existing directory or .000 file is used as-is (no cleanup). Zips, +// multiple inputs, or anything else are gathered with collectCells and written to a +// temp directory of cells (returned with a cleanup that removes it). +func (c bakeCmd) tile57Input() (path string, cleanup func(), err error) { + noop := func() {} + if len(c.In) == 1 { + if fi, e := os.Stat(c.In[0]); e == nil && (fi.IsDir() || encExt(c.In[0]) == ".000") { + return c.In[0], noop, nil + } + } + cells, _, err := collectCells(c.In) + if err != nil { + return "", noop, err + } + if len(cells) == 0 { + return "", noop, fmt.Errorf("no .000 base cells found in: %s", strings.Join(c.In, ", ")) + } + dir, err := os.MkdirTemp("", "cp-tile57-enc-") + if err != nil { + return "", noop, err + } + for name, cd := range cells { // name is ".000" + if err := os.WriteFile(filepath.Join(dir, name), cd.Base, 0o644); err != nil { + os.RemoveAll(dir) + return "", noop, err + } + for un, ub := range cd.Updates { // sequential .001+ alongside the base + if err := os.WriteFile(filepath.Join(dir, filepath.Base(un)), ub, 0o644); err != nil { + os.RemoveAll(dir) + return "", noop, err + } + } + } + return dir, func() { os.RemoveAll(dir) }, nil +} + +// bundleOutDir derives the bundle output DIRECTORY from the -o value: a *.pmtiles / +// *.mbtiles path becomes its stem (charts.pmtiles → charts/), otherwise -o is used +// as the directory verbatim. +func bundleOutDir(out string) string { + switch strings.ToLower(filepath.Ext(out)) { + case ".pmtiles", ".mbtiles": + return strings.TrimSuffix(out, filepath.Ext(out)) + default: + return out + } +} + // encExt reports the 3-digit S-57 cell extension (".000" base, ".001"+ updates) // for a path, or "" if it isn't an ENC cell file. func encExt(p string) string { diff --git a/cmd/chartplotter/serve.go b/cmd/chartplotter/serve.go index 860957d..20a1da1 100644 --- a/cmd/chartplotter/serve.go +++ b/cmd/chartplotter/serve.go @@ -7,7 +7,6 @@ import ( "net/http" "os" - "github.com/beetlebugorg/chartplotter/internal/engine/assets" "github.com/beetlebugorg/chartplotter/internal/engine/baker" "github.com/beetlebugorg/chartplotter/internal/engine/s101catalog" "github.com/beetlebugorg/chartplotter/internal/engine/server" @@ -26,6 +25,8 @@ type serveCmd struct { ClearCache bool `name:"clear-cache" help:"On startup, delete the cached baked archives for a clean slate (source ENC is kept)."` S101 string `name:"s101" type:"existingdir" help:"Override the embedded catalogue with an external S-101 PortrayalCatalog directory (for iterating on rules). Every chart baked by the server (chart library imports) uses this catalogue's symbology, and the matching client assets are served. Requires --s101-fc."` S101FC string `name:"s101-fc" type:"existingfile" help:"S-101 FeatureCatalogue.xml path (with --s101)."` + Tile57 string `name:"tile57" type:"path" help:"(requires a -tags tile57 build) Serve a LIVE libtile57-backed tile set from this ENC_ROOT / .zip / .000, generating MVT on demand from the cells instead of prebaking. Registered as the 'tile57' set (point a client at /tiles/tile57.json)."` + Tile57Bake bool `name:"tile57-bake" help:"(requires a -tags tile57 build) DEFAULT server imports to the native libtile57 bundle baker (tiles + SCAMIN-bucketed styles + assets) instead of the Go per-band baker. The Advanced→\"Bake engine\" UI setting overrides this per deployment; this flag just sets the headless default."` } func (c serveCmd) Run() error { @@ -61,7 +62,7 @@ func (c serveCmd) Run() error { if err != nil { return err } - if _, err := assets.EmitS101FS(catalogFS, "daySvgStyle.css", assetDir); err != nil { + if _, err := emitS101Assets(catalogFS, "daySvgStyle.css", assetDir); err != nil { return fmt.Errorf("emit S-101 assets: %w", err) } // The emitted S-101 client assets (colortables/linestyles/sprite/patterns) @@ -97,6 +98,23 @@ func (c serveCmd) Run() error { srv.Version = version srv.ReportStaleCache() // loud warning if any served pack predates this binary + // Optional libtile57 live backend (-tags tile57): generate MVT on demand from + // raw ENC cells instead of serving a prebaked archive. + if c.Tile57 != "" { + if err := registerTile57Set(srv, "tile57", c.Tile57, c.S101); err != nil { + return err + } + } else if tile57Available { + fmt.Println("tile57: libtile57 backend available — pass --tile57 for a live set") + } + if c.Tile57Bake { + if !tile57Available { + return fmt.Errorf("--tile57-bake requires a binary built with -tags tile57 (run `make build-tile57`)") + } + srv.BakeEngine = "tile57" + fmt.Println("tile57: server chart imports will bake native libtile57 bundles") + } + addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port)) remoteNote := "" if allowRemote { diff --git a/cmd/chartplotter/tile57_assets.go b/cmd/chartplotter/tile57_assets.go new file mode 100644 index 0000000..ac19e3d --- /dev/null +++ b/cmd/chartplotter/tile57_assets.go @@ -0,0 +1,125 @@ +//go:build tile57 + +package main + +import ( + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + tile57 "github.com/beetlebugorg/chartplotter-native/bindings/go" +) + +// emitS101Assets generates the client asset files (colortables/linestyles/sprite/ +// patterns) from the S-101 PortrayalCatalog using the native libtile57 C ABI, so +// the served symbology is produced by the SAME engine that renders the tiles. It +// mirrors assets.EmitS101FS's six outputs and replaces it under -tags tile57. +// catalogFS is rooted at a PortrayalCatalog; cssName selects the palette stylesheet +// under Symbols/ (e.g. "daySvgStyle.css"). +func emitS101Assets(catalogFS fs.FS, cssName, dir string) ([]string, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + var written []string + write := func(name string, data []byte) error { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, data, 0o644); err != nil { + return err + } + written = append(written, p) + return nil + } + + // colortables.json — from ColorProfiles/colorProfile.xml. + cp, err := fs.ReadFile(catalogFS, "ColorProfiles/colorProfile.xml") + if err != nil { + return nil, fmt.Errorf("colorProfile.xml: %w", err) + } + ct, err := tile57.Colortables(cp) + if err != nil { + return nil, err + } + if err := write("colortables.json", ct); err != nil { + return nil, err + } + + // linestyles.json — from every LineStyles/*.xml (id = file stem). + lines, err := namedFromDir(catalogFS, "LineStyles", ".xml") + if err != nil { + return nil, err + } + ls, err := tile57.Linestyles(lines) + if err != nil { + return nil, err + } + if err := write("linestyles.json", ls); err != nil { + return nil, err + } + + // sprite atlas — rasterize every Symbols/*.svg against the palette stylesheet. + css, err := fs.ReadFile(catalogFS, path.Join("Symbols", cssName)) + if err != nil { + return nil, fmt.Errorf("%s: %w", cssName, err) + } + symbols, err := namedFromDir(catalogFS, "Symbols", ".svg") + if err != nil { + return nil, err + } + spriteJSON, spritePNG, err := tile57.SpriteAtlas(symbols, css) + if err != nil { + return nil, err + } + if err := write("sprite.json", spriteJSON); err != nil { + return nil, err + } + if err := write("sprite.png", spritePNG); err != nil { + return nil, err + } + + // pattern atlas — tile each AreaFills/*.xml's referenced symbol on its lattice. + fills, err := namedFromDir(catalogFS, "AreaFills", ".xml") + if err != nil { + return nil, err + } + patJSON, patPNG, err := tile57.PatternAtlas(fills, symbols, css) + if err != nil { + return nil, err + } + if err := write("patterns.json", patJSON); err != nil { + return nil, err + } + if err := write("patterns.png", patPNG); err != nil { + return nil, err + } + + fmt.Printf("tile57: emitted S-101 client assets via libtile57 (%d symbols, %d line styles, %d fills)\n", + len(symbols), len(lines), len(fills)) + return written, nil +} + +// namedFromDir reads every file with ext under subdir of catalogFS into a sorted +// []tile57.NamedBytes keyed by file stem. +func namedFromDir(catalogFS fs.FS, subdir, ext string) ([]tile57.NamedBytes, error) { + entries, err := fs.ReadDir(catalogFS, subdir) + if err != nil { + return nil, fmt.Errorf("%s: %w", subdir, err) + } + var out []tile57.NamedBytes + for _, e := range entries { + if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ext) { + continue + } + b, err := fs.ReadFile(catalogFS, path.Join(subdir, e.Name())) + if err != nil { + return nil, err + } + stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + out = append(out, tile57.NamedBytes{ID: stem, Data: b}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} diff --git a/cmd/chartplotter/tile57_assets_off.go b/cmd/chartplotter/tile57_assets_off.go new file mode 100644 index 0000000..6139447 --- /dev/null +++ b/cmd/chartplotter/tile57_assets_off.go @@ -0,0 +1,16 @@ +//go:build !tile57 + +package main + +import ( + "io/fs" + + "github.com/beetlebugorg/chartplotter/internal/engine/assets" +) + +// emitS101Assets uses the pure-Go S-101 asset emitter in the default CGO-free +// build. The tile57 build (tile57_assets.go) generates the same files via the +// libtile57 C ABI instead. +func emitS101Assets(catalogFS fs.FS, cssName, dir string) ([]string, error) { + return assets.EmitS101FS(catalogFS, cssName, dir) +} diff --git a/cmd/chartplotter/tile57_bake.go b/cmd/chartplotter/tile57_bake.go new file mode 100644 index 0000000..1b2085e --- /dev/null +++ b/cmd/chartplotter/tile57_bake.go @@ -0,0 +1,17 @@ +//go:build tile57 + +package main + +import tile57 "github.com/beetlebugorg/chartplotter-native/bindings/go" + +// bakeTile57Bundle bakes an on-disk ENC input (a .000 cell or a directory of cells) +// into a self-contained chart bundle under outDir via the native libtile57 engine. +// maxZoom caps the highest baked zoom (0 = no cap). progress nil uses the lib's +// built-in console progress. Returns the cell count + bbox (west,south,east,north). +func bakeTile57Bundle(input, outDir string, maxZoom int, progress func(tile57.BakeProgress)) (int, [4]float64, error) { + mz := uint8(24) // ABI: 0/24 means "no clamp"; only narrow when the user caps it + if maxZoom > 0 && maxZoom < 24 { + mz = uint8(maxZoom) + } + return tile57.BakeBundle(input, outDir, "", "", "", 0, mz, false /*include pick attrs*/, progress) +} diff --git a/cmd/chartplotter/tile57_bake_off.go b/cmd/chartplotter/tile57_bake_off.go new file mode 100644 index 0000000..4b5f872 --- /dev/null +++ b/cmd/chartplotter/tile57_bake_off.go @@ -0,0 +1,11 @@ +//go:build !tile57 + +package main + +import "fmt" + +// bakeTile57Bundle is the stub used in the default CGO-free build; --tile57 then +// reports how to get a binary that can bake with the native engine. +func bakeTile57Bundle(_, _ string, _ int, _ func(stage uint8, done, total int)) (int, [4]float64, error) { + return 0, [4]float64{}, fmt.Errorf("--tile57 requires a binary built with -tags tile57 (run `make build-tile57`)") +} diff --git a/cmd/chartplotter/tile57_serve.go b/cmd/chartplotter/tile57_serve.go new file mode 100644 index 0000000..317c4c2 --- /dev/null +++ b/cmd/chartplotter/tile57_serve.go @@ -0,0 +1,81 @@ +//go:build tile57 + +package main + +import ( + "fmt" + "sort" + "strings" + + tile57 "github.com/beetlebugorg/chartplotter-native/bindings/go" + "github.com/beetlebugorg/chartplotter/internal/engine/server" + "github.com/beetlebugorg/chartplotter/internal/engine/tilesource" +) + +// tile57Available reports that this binary embeds the libtile57 backend (built +// with -tags tile57). The CGO-free default build links the stub in +// tile57_serve_off.go, where this is false. +const tile57Available = true + +// tile57Source adapts the official libtile57 Go binding's *Source to the host's +// tilesource.TileSource. Tile/Close are promoted from the embedded *Source; only +// Meta needs a shim because the binding returns its own (field-identical) Meta type +// rather than the host's tilesource.TileMeta. +type tile57Source struct{ *tile57.Source } + +func (t tile57Source) Meta() tilesource.TileMeta { + m := t.Source.Meta() + return tilesource.TileMeta{ + MinZoom: m.MinZoom, MaxZoom: m.MaxZoom, + W: m.W, S: m.S, E: m.E, N: m.N, + Gzipped: m.Gzipped, Scamin: m.Scamin, + } +} + +// registerTile57Set opens the ENC inputs under root with libtile57 and registers +// a live tile set (MVT generated on demand from the cells, no prebake) under +// name. rulesDir overrides the S-101 portrayal rules ("" = libtile57's built-in). +func registerTile57Set(srv *server.Server, name, root, rulesDir string) error { + cells, _, err := collectCells([]string{root}) + if err != nil { + return err + } + if len(cells) == 0 { + return fmt.Errorf("tile57: no .000 base cells found under %s", root) + } + inputs := make([]tile57.CellInput, 0, len(cells)) + for name, cd := range cells { + inputs = append(inputs, tile57.CellInput{ + Name: strings.TrimSuffix(name, ".000"), // pick-report "source cell" badge + Base: cd.Base, + Updates: orderedUpdates(cd.Updates), + }) + } + src, err := tile57.OpenCells(inputs, rulesDir, false /*include pick attrs*/) + if err != nil { + return err + } + srv.RegisterTileSet(name, tile57Source{src}) + mn, mx := src.ZoomRange() + fmt.Printf("tile57: live set %q from %d cell(s) (libtile57 %s, zoom %d..%d)\n", + name, len(inputs), tile57.Version(), mn, mx) + return nil +} + +// orderedUpdates returns a cell's update bodies sorted by filename so libtile57 +// applies them in sequence (.001, .002, …). +func orderedUpdates(m map[string][]byte) [][]byte { + if len(m) == 0 { + return nil + } + names := make([]string, 0, len(m)) + for n := range m { + names = append(names, n) + } + sort.Strings(names) + out := make([][]byte, len(names)) + for i, n := range names { + out[i] = m[n] + } + return out +} diff --git a/cmd/chartplotter/tile57_serve_off.go b/cmd/chartplotter/tile57_serve_off.go new file mode 100644 index 0000000..d31a3ea --- /dev/null +++ b/cmd/chartplotter/tile57_serve_off.go @@ -0,0 +1,19 @@ +//go:build !tile57 + +package main + +import ( + "fmt" + + "github.com/beetlebugorg/chartplotter/internal/engine/server" +) + +// tile57Available is false in the default CGO-free build (no libtile57). The +// tagged build (tile57_serve.go) sets it true. +const tile57Available = false + +// registerTile57Set is the stub used when the binary is built without the +// libtile57 backend; --tile57 then reports how to get a capable binary. +func registerTile57Set(_ *server.Server, _, _, _ string) error { + return fmt.Errorf("--tile57 requires a binary built with -tags tile57 (run `make build-tile57`)") +} diff --git a/go.mod b/go.mod index 5e90c99..045bc30 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( require ( github.com/adrianmo/go-nmea v1.3.0 // indirect + github.com/beetlebugorg/chartplotter-native/bindings/go v0.0.0 github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect @@ -34,3 +35,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace github.com/beetlebugorg/chartplotter-native/bindings/go => ../tile57/bindings/go diff --git a/internal/engine/server/http.go b/internal/engine/server/http.go index 481338d..0730654 100644 --- a/internal/engine/server/http.go +++ b/internal/engine/server/http.go @@ -38,6 +38,7 @@ type Server struct { share shareStore // latest "share my view" snapshot (camera + cell list) settings settingsStore // persisted client display settings (/client-settings.json) Version string // build version + BakeEngine string // "" / "go" → Go baker; "tile57" → native libtile57 bundle bake (-tags tile57 only) sets *tileSets // registry of ENABLED tile sets served at /tiles/{set}/… imports *importJobs // background server-side bake jobs (POST /api/import) @@ -105,6 +106,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { lw := &logResponseWriter{ResponseWriter: w, status: http.StatusOK} setSecurityHeaders(lw) switch { + case r.URL.Path == "/api/style.json": + // A complete MapLibre style document generated by the native tile57 engine + // (build-tagged; a 501 stub in the default CGO-free build). + s.serveTile57Style(lw, r) case strings.HasPrefix(r.URL.Path, "/api/"): s.handleAPI(lw, r) case strings.HasPrefix(r.URL.Path, "/tiles/"): @@ -226,8 +231,16 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { } switch { case r.URL.Path == "/api/health": + // Also advertises server capabilities the client gates UI on — notably the + // selectable bake engines (the native libtile57 bundle baker only exists in a + // -tags tile57 build), so the "Advanced → bake engine" toggle shows only when + // "tile57" is offered. + engines := `["go"]` + if bakeTile57Available { + engines = `["go","tile57"]` + } w.Header().Set("Content-Type", jsonCT) - io.WriteString(w, `{"ok":true}`) + fmt.Fprintf(w, `{"ok":true,"version":%q,"bakeEngines":%s}`, s.Version, engines) case r.URL.Path == "/api/cells": s.serveCells(w, r) // GET: names of cells currently in the server's ENC_ROOT cache case r.URL.Path == "/api/ienc/catalog": diff --git a/internal/engine/server/import.go b/internal/engine/server/import.go index a98db07..93ebed1 100644 --- a/internal/engine/server/import.go +++ b/internal/engine/server/import.go @@ -527,6 +527,28 @@ func (s *Server) cachedCellData(csv string) map[string]baker.CellData { return cells } +// bakeEngine returns the effective bake engine for server imports. The persisted +// client setting ("bakeEngine", set from the Advanced settings UI) wins; otherwise +// the launch-flag default (--tile57-bake → BakeEngine) applies. It is forced to +// "go" unless this binary embeds the native libtile57 baker, so a stray setting on +// a CGO-free binary can't disable imports. +func (s *Server) bakeEngine() string { + eng := s.BakeEngine + s.settings.load(s.dataDir) + if body := s.settings.get(); len(body) > 0 { + var peek struct { + BakeEngine string `json:"bakeEngine"` + } + if json.Unmarshal(body, &peek) == nil && peek.BakeEngine != "" { + eng = peek.BakeEngine + } + } + if eng == "tile57" && bakeTile57Available { + return "tile57" + } + return "go" +} + // runImport bakes cells into /tiles/.pmtiles and registers the set. func (s *Server) runImport(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, overzoom, applyUpdates bool) { s.bakeAndRegister(jobID, set, cells, aux, cat, overzoom, applyUpdates) @@ -552,6 +574,15 @@ func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellD } cells = base } + + // Native libtile57 bundle bake (opt-in): one self-describing bundle per set + // (tiles + SCAMIN-bucketed styles + assets), registered as a single set. The + // engine is the "Advanced → bake engine" setting (capability-gated). Falls through + // to the Go per-band path if unhandled. + if s.bakeEngine() == "tile57" && s.bakeBundleTile57(jobID, set, cells, aux, cat, applyUpdates) { + return + } + _ = overzoom // the per-band streaming bake has no all-bands-to-z0 overzoom mode s.imports.update(jobID, func(j *importJob) { // Open on the "prepare" stage (unit "cells"): the bake starts by parsing diff --git a/internal/engine/server/prefs.go b/internal/engine/server/prefs.go index fa1c11d..353174d 100644 --- a/internal/engine/server/prefs.go +++ b/internal/engine/server/prefs.go @@ -87,8 +87,11 @@ func (p *prefs) setDisabled(set string, off bool) { } } -// scanPacks walks the cache and returns every baked pack file keyed by set name -// (basename sans extension) → path. Includes the provider trees plus flat tiles/. +// scanPacks walks the cache and returns every baked pack file keyed by set name → +// path. The Go baker writes //.pmtiles (set = filename); the +// native tile57 bundle writes //tiles/chart.pmtiles, so its set name +// is derived from the provider/pack dirs (mirroring setDir) — otherwise every bundle +// collapses to "chart" and the chart library can't list them after a restart. func scanPacks(cacheDir string) map[string]string { out := map[string]string{} _ = filepath.WalkDir(cacheDir, func(path string, d os.DirEntry, err error) error { @@ -99,6 +102,16 @@ func scanPacks(cacheDir string) map[string]string { return nil } name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + // tile57 bundle: ///tiles/chart.pmtiles → "provider-pack" + // (the reverse of setDir's "/" layout for a "provider-pack" set). + if name == "chart" && filepath.Base(filepath.Dir(path)) == "tiles" { + setDir := filepath.Dir(filepath.Dir(path)) + pack := filepath.Base(setDir) + provider := filepath.Base(filepath.Dir(setDir)) + if provider != "" && provider != "." && pack != "" && pack != "." { + name = strings.ToLower(provider) + "-" + strings.ToLower(pack) + } + } if isSetName(name) { out[name] = path } diff --git a/internal/engine/server/server_test.go b/internal/engine/server/server_test.go index d7de2a7..210014b 100644 --- a/internal/engine/server/server_test.go +++ b/internal/engine/server/server_test.go @@ -93,7 +93,9 @@ func TestAPIHealthAndHostCheck(t *testing.T) { resp, _ := http.Get(ts.URL + "/api/health") got, _ := io.ReadAll(resp.Body) resp.Body.Close() - if strings.TrimSpace(string(got)) != `{"ok":true}` { + // /api/health also advertises server capabilities (version, bakeEngines), so + // match the liveness marker rather than an exact body. + if !strings.Contains(string(got), `"ok":true`) { t.Errorf("health: got %q", got) } diff --git a/internal/engine/server/tile57_bake.go b/internal/engine/server/tile57_bake.go new file mode 100644 index 0000000..8a1272e --- /dev/null +++ b/internal/engine/server/tile57_bake.go @@ -0,0 +1,153 @@ +//go:build tile57 + +package server + +import ( + "log" + "os" + "path/filepath" + "time" + + tile57 "github.com/beetlebugorg/chartplotter-native/bindings/go" + "github.com/beetlebugorg/chartplotter/internal/engine/auxfiles" + "github.com/beetlebugorg/chartplotter/internal/engine/baker" + "github.com/beetlebugorg/chartplotter/internal/engine/tilesource" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// bakeTile57Available reports that this binary embeds the native libtile57 bundle +// baker (built with -tags tile57), so "tile57" is a selectable bake engine. +const bakeTile57Available = true + +// bakeBundleTile57 bakes an import's cells into a self-contained tile57 chart +// bundle under the set's directory (tiles/chart.pmtiles + per-scheme SCAMIN- +// bucketed style-*.json + assets + manifest.json) and registers chart.pmtiles as +// the set. It mirrors the Go path's post-bake tail — aux.zip + per-pack metadata +// sidecar + cell manifest — so a tile57-baked pack is as complete in the chart +// library as a Go-baked one. Returns true once it has handled the bake (success OR +// a recorded error); false only if there's nothing to do. +func (s *Server) bakeBundleTile57(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, applyUpdates bool) bool { + if len(cells) == 0 { + return false + } + fail := func(err error) bool { + log.Printf("import %s (%s): tile57 bundle: %v", jobID, set, err) + s.imports.update(jobID, func(j *importJob) { j.State = "error"; j.Err = err.Error() }) + return true + } + + // Stage THIS import's cells to a temp ENC dir (the engine reads from disk; the + // shared /ENC_ROOT holds every import, so we can't point it there). + encDir, err := os.MkdirTemp("", "cp-tile57-import-") + if err != nil { + return fail(err) + } + defer os.RemoveAll(encDir) + for name, cd := range cells { // name == ".000" + if err := os.WriteFile(filepath.Join(encDir, name), cd.Base, 0o644); err != nil { + return fail(err) + } + if applyUpdates { + for un, ub := range cd.Updates { + if err := os.WriteFile(filepath.Join(encDir, filepath.Base(un)), ub, 0o644); err != nil { + return fail(err) + } + } + } + } + + outDir := s.setDir(set) + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fail(err) + } + s.imports.update(jobID, func(j *importJob) { + j.Phase, j.Band, j.Unit, j.Note, j.Done, j.Total = "bake", "", "cells", "Preparing charts", 0, 0 + }) + // Per-band progress with the band name (like the Go baker). A calm per-band bar: + // it fills 0→100% per band and resets at each band — clamped monotonic WITHIN a + // band (the engine double-counts a band's tiles across its parallel-gen + serial- + // write phases, which would otherwise rewind). A smooth GLOBAL bar needs the + // engine to drive bands in bake order (BandIndex is navigational rank, not bake + // order, so a global mapping leaps around) — deferred; see ../tile57 spec host §3. + curBand, bandDoneMax := -1, 0 + note := func(verb, noun, band string) string { + if band == "" { + return verb + " " + noun + } + return verb + " " + band + " " + noun + } + progress := func(p tile57.BakeProgress) { + s.imports.update(jobID, func(j *importJob) { + j.Phase, j.Band = "bake", p.BandName + if p.Stage == 0 { // portraying this band's cells + j.Unit, j.Note, j.Done, j.Total = "cells", note("Preparing", "charts", p.BandName), p.Done, p.Total + return + } + if p.BandIndex != curBand { // new band → reset the within-band floor + curBand, bandDoneMax = p.BandIndex, 0 + } + if p.Done > bandDoneMax { + bandDoneMax = p.Done + } + j.Unit, j.Note, j.Done, j.Total = "tiles", note("Generating", "tiles", p.BandName), bandDoneMax, p.Total + }) + } + created := time.Now().UTC().Format(time.RFC3339) + // minzoom 0 / maxzoom 24 = the ABI's "no clamp" — bake each cell's full native + // band (maxzoom 0 would clamp every band down to z0, i.e. an empty archive). + n, _, err := tile57.BakeBundle(encDir, outDir, "", "", created, 0, 24, false /*include pick attrs*/, progress) + if err != nil { + return fail(err) + } + + // Register the bundle's chart.pmtiles as the set (replacing any prior merged or + // per-band Go bake of the same district). + chart := filepath.Join(outDir, "tiles", "chart.pmtiles") + src, err := tilesource.Open(chart) + if err != nil { + return fail(err) + } + s.removeMergedSet(set) + for _, band := range s.setsForDistrict(set) { // drop stale per-band Go sets + s.sets.remove(band) + s.packDel(band) + } + s.sets.register(set, src) + s.packAdd(set, chart) + s.prefs.setDisabled(set, false) + if s.Version != "" { + _ = os.WriteFile(chart+bakeVerExt, []byte(s.Version), 0o644) + } + if err := s.writeSetCells(set, cells); err != nil { + log.Printf("import %s: cell manifest %q: %v", jobID, set, err) + } + + // Companion aux.zip (TXTDSC/PICREP) beside the set, so feature attachments still + // serve via /api/aux — same as the Go path's writeAndRegister. + if len(aux) > 0 { + if f, e := os.Create(filepath.Join(outDir, set+".aux.zip")); e == nil { + if _, e := auxfiles.WriteZip(f, aux); e != nil { + log.Printf("import %s: aux %q: %v", jobID, set, e) + } + f.Close() + } + s.auxIdx.invalidate() + } + + // Per-pack metadata sidecar for the chart library (per-cell scale/edition/date/ + // agency/coverage + catalogue titles) — same as the Go path, so pack details + // aren't poorer for a tile57 import. + s.imports.update(jobID, func(j *importJob) { j.Phase, j.Note = "meta", "Reading chart metadata" }) + cellMeta := baker.ExtractCellMeta(cells, func(name string, e error) { + log.Printf("import %s: meta skip %s: %v", jobID, name, e) + }) + meta := buildSetMeta(set, cellMeta, cat) + meta.Imported = created + if err := s.writeSetMeta(set, meta); err != nil { + log.Printf("import %s: write meta %q: %v", jobID, set, err) + } + + s.imports.update(jobID, func(j *importJob) { j.Cells = n; j.State = "done" }) + log.Printf("import %s: baked tile57 bundle %q (%d cell(s)) → %s", jobID, set, n, outDir) + return true +} diff --git a/internal/engine/server/tile57_bake_off.go b/internal/engine/server/tile57_bake_off.go new file mode 100644 index 0000000..4b775ff --- /dev/null +++ b/internal/engine/server/tile57_bake_off.go @@ -0,0 +1,18 @@ +//go:build !tile57 + +package server + +import ( + "github.com/beetlebugorg/chartplotter/internal/engine/baker" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// bakeTile57Available is false in the default CGO-free build (no native baker). +const bakeTile57Available = false + +// bakeBundleTile57 is a stub in the default CGO-free build: the native bundle bake +// needs libtile57 (-tags tile57). Returning false makes bakeAndRegister fall +// through to the Go baker, so a stray BakeEngine="tile57" can't disable imports. +func (s *Server) bakeBundleTile57(_, _ string, _ map[string]baker.CellData, _ map[string][]byte, _ *s57.Catalog, _ bool) bool { + return false +} diff --git a/internal/engine/server/tile57_style.go b/internal/engine/server/tile57_style.go new file mode 100644 index 0000000..cd37ebe --- /dev/null +++ b/internal/engine/server/tile57_style.go @@ -0,0 +1,118 @@ +//go:build tile57 + +package server + +import ( + "net/http" + "strconv" + "strings" + + tile57 "github.com/beetlebugorg/chartplotter-native/bindings/go" +) + +// serveTile57Style returns a complete MapLibre style document generated by the +// native tile57 engine (tile57_style_template + tile57_build_style over the S-52 +// colortables baked into libtile57), so the style itself — not just the tiles and +// symbology assets — comes from the engine. Query params (all optional): +// +// scheme day|dusk|night (default day) +// tiles chart {z}/{x}/{y} URL (default /tiles/tile57/{z}/{x}/{y}.mvt) +// sprite sprite base URL (default /sprite) +// glyphs glyphs URL (default /glyphs/{fontstack}/{range}.pbf) +// bands CSV of band ranks to show (default all) +// +// CORS-open like the tile + asset routes so a static-hosted client can fetch it. +func (s *Server) serveTile57Style(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.Method != http.MethodGet { + apiErr(w, http.StatusMethodNotAllowed, "GET only") + return + } + q := r.URL.Query() + scheme := tile57.SchemeFromString(q.Get("scheme")) + base := requestOrigin(r) + tiles := orDefault(q.Get("tiles"), base+"/tiles/tile57/{z}/{x}/{y}.mvt") + sprite := orDefault(q.Get("sprite"), base+"/sprite") + glyphs := orDefault(q.Get("glyphs"), base+"/glyphs/{fontstack}/{range}.pbf") + + m := tile57.MarinerDefaults() + m.Scheme = scheme + + // SCAMIN buckets: feed build_style the set's SCAMIN manifest + a reference + // latitude so it emits per-value native-minzoom bucket layers (matching the + // offline bundle style). Default to the live "tile57" set; ?set picks another, + // ?scamin / ?lat override. nil manifest ⇒ no buckets (back-compat). + scamin := parseBands(q.Get("scamin")) + lat := 0.0 + var maxZoom uint32 // 0 ⇒ engine default; the set's real top zoom otherwise + if src, ok := s.lookupSet(orDefault(q.Get("set"), "tile57")); ok { + meta := src.Meta() + maxZoom = uint32(meta.MaxZoom) // live ENC = z18 (berthing); don't clamp to the engine's z16 default + if len(scamin) == 0 { + scamin = make([]int32, len(meta.Scamin)) + for i, v := range meta.Scamin { + scamin[i] = int32(v) + } + } + lat = (meta.S + meta.N) / 2 // bounds-centre latitude for the scale→zoom map + } + if v := q.Get("lat"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + lat = f + } + } + + // Generate the style with the chart source's maxzoom pinned to the set's real top + // zoom. The convenience tile57.Style would clamp the template to the engine's z16 + // default and drop the finest (berthing) band's z17–18 detail; MapLibre overzooms + // above maxzoom for free, so there's no reason to bake that ceiling in below 18. + ct, err := tile57.ColortablesDefault() + if err != nil { + apiErr(w, http.StatusInternalServerError, err.Error()) + return + } + tmpl, err := tile57.StyleTemplate(scheme, tiles, sprite, glyphs, 0, maxZoom) + if err != nil { + apiErr(w, http.StatusInternalServerError, err.Error()) + return + } + style, err := tile57.BuildStyle(tmpl, m, ct, parseBands(q.Get("bands")), scamin, lat) + if err != nil { + apiErr(w, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set("Content-Type", jsonCT) + w.Header().Set("Cache-Control", "no-cache") + w.Write(style) +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +// requestOrigin reconstructs the scheme://host the client reached us on, for the +// absolute URLs MapLibre needs in the style document. +func requestOrigin(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host +} + +// parseBands parses a CSV of band ranks ("0,1,2") into a slice; nil for "" (all). +func parseBands(csv string) []int32 { + if csv == "" { + return nil + } + var out []int32 + for _, p := range strings.Split(csv, ",") { + if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { + out = append(out, int32(n)) + } + } + return out +} diff --git a/internal/engine/server/tile57_style_off.go b/internal/engine/server/tile57_style_off.go new file mode 100644 index 0000000..ef30ad4 --- /dev/null +++ b/internal/engine/server/tile57_style_off.go @@ -0,0 +1,11 @@ +//go:build !tile57 + +package server + +import "net/http" + +// serveTile57Style is a stub in the default CGO-free build: tile57 style +// generation needs the native engine, which is only linked under -tags tile57. +func (s *Server) serveTile57Style(w http.ResponseWriter, r *http.Request) { + apiErr(w, http.StatusNotImplemented, "style generation requires a -tags tile57 build (run `make build-tile57`)") +} diff --git a/internal/engine/server/tilesets.go b/internal/engine/server/tilesets.go index 2620609..597c1c9 100644 --- a/internal/engine/server/tilesets.go +++ b/internal/engine/server/tilesets.go @@ -38,6 +38,15 @@ func (ts *tileSets) register(name string, src tilesource.TileSource) { ts.m[name] = src } +// RegisterTileSet registers (or replaces) a tile set under name, served at +// /tiles/{name}/… exactly like a prebaked archive. It is exposed so an alternate +// backend — e.g. the optional libtile57 live source compiled in under +// -tags tile57 — can publish a set the same way discovery registers .pmtiles +// packs. A replaced backend is closed. +func (s *Server) RegisterTileSet(name string, src tilesource.TileSource) { + s.sets.register(name, src) +} + // remove unregisters and closes the set named name. Reports whether it existed. func (ts *tileSets) remove(name string) bool { ts.mu.Lock() diff --git a/web/src/chart-canvas/chart-sources.mjs b/web/src/chart-canvas/chart-sources.mjs index c650815..d2a5831 100644 --- a/web/src/chart-canvas/chart-sources.mjs +++ b/web/src/chart-canvas/chart-sources.mjs @@ -64,7 +64,11 @@ export const BAND_DISPLAY_MIN = { overview: 0, general: 0, coastal: 9, approach: // no-SCAMIN counterparts stay in the original (areas/area_patterns/lines/ // complex_lines) layers — single, always-in-band, NOT bucketed. export const SCAMIN_BUCKET_LAYERS = new Set(["point_symbols", "soundings", "text", "sector_lines", - "areas_scamin", "area_patterns_scamin", "lines_scamin", "complex_lines_scamin"]); + "areas_scamin", "area_patterns_scamin", "lines_scamin", "complex_lines_scamin", + // The native tile57 engine splits SCAMIN point symbols + text into their own + // source-layers (vs. the Go baker's in-layer `scamin` property), so bucket those + // too — without them, tile57's SCAMIN buoys/beacons/lights/labels never show. + "point_symbols_scamin", "text_scamin"]); // Centre-latitude drift (degrees) that triggers a SCAMIN bucket-minzoom rebuild. // The cutoff zoom shifts with cos(lat); 2° keeps the error under ~0.05 zoom at diff --git a/web/src/chart-canvas/chart-style.mjs b/web/src/chart-canvas/chart-style.mjs index 55590d2..d942dbd 100644 --- a/web/src/chart-canvas/chart-style.mjs +++ b/web/src/chart-canvas/chart-style.mjs @@ -200,6 +200,20 @@ function buildLayers(mariner, palette, atlasPpu, osm, sizeScale) { // navigational purpose changes, baked into the scale_boundaries layer. // Standard display, on by default; toggled via mariner.showScaleBoundaries. { id: "scale-boundaries", type: "line", source: "chart", "source-layer": "scale_boundaries", layout: { visibility: mariner.showScaleBoundaries === false ? "none" : "visible" }, paint: { "line-color": S52.colorExpr("color_token", undefined, palette), "line-width": ["coalesce", ["get", "width_px"], 1.5] } }, + // Chart-coverage outline when ZOOMED OUT (to ~z3.5): the meta-object region + // boundary, so a pack still shows WHERE it covers once SCAMIN has suppressed + // every in-cell feature. Today the engine emits only M_NSYS (nav-system / + // IALA A↔B) among the meta classes; the canonical M_COVR data-coverage edge + // is requested in ../tile57 specs/host-canonical-backend.md ("Still needed + // from the engine" §6 — coverage edge baked to z0 for every pack). _rawFilter + // makes it bypass combineFilters AND the SCAMIN bucketing, so it draws + // regardless of the showMetaBounds gate. Capped at z8 → a pure zoom-out + // indicator that yields to the full chart at detail zooms, and hidden when + // showMetaBounds is on (the lines layers draw these classes then — no double). + { id: "meta-boundary", type: "line", source: "chart", "source-layer": "lines", _rawFilter: true, maxzoom: 8, + filter: ["==", ["coalesce", ["get", "class"], ""], "M_NSYS"], + layout: { visibility: mariner.showMetaBounds ? "none" : "visible" }, + paint: { "line-color": S52.colorExpr("color_token", S52.token("CHMGD", "#bf30bf", palette), palette), "line-width": 1.4, "line-dasharray": [4, 2], "line-opacity": 0.85 } }, ]; const top = [ // Point symbols split by ROTATION REFERENCE FRAME (S-52 6.1.1 §3.1.6 / PresLib @@ -279,7 +293,14 @@ function buildLayers(mariner, palette, atlasPpu, osm, sizeScale) { // also hits the clone, so SCAMIN features restyle/toggle identically. (e.g. // contour-labels for DEPCNT, which now live in lines_scamin; safety-contour / // shallow-pattern reading areas_scamin; danger-boundary reading lines_scamin.) - const SCAMIN_SRC = new Set(["areas", "area_patterns", "lines", "complex_lines"]); + // Source-layers whose SCAMIN-bearing primitives live in a SEPARATE "_scamin" + // source-layer (vs. an in-layer `scamin` property). The Go baker splits the four + // area/line layers; the native tile57 engine ALSO splits point_symbols + text + // (its SCAMIN buoys/beacons/lights/labels ride point_symbols_scamin / text_scamin), + // so they must be cloned + bucketed too or every SCAMIN'd symbol/label is invisible + // under tile57. The clones read source-layers that simply don't exist in a Go-baked + // pack, so they render nothing there — safe for both bakers. + const SCAMIN_SRC = new Set(["areas", "area_patterns", "lines", "complex_lines", "point_symbols", "text"]); const withScamin = []; for (const L of tmpl) { withScamin.push(L); @@ -445,12 +466,12 @@ export function buildChartLayers({ // native minzoom, and mirrors the coarse-band maxzoom cap. const mk = (suffix, baseFilter, minzoom) => { const id = L.id + "@" + set.name + suffix; - layerBase[id] = baseFilter; + if (!L._rawFilter) layerBase[id] = baseFilter; // raw layers keep their verbatim filter — exclude from the live re-combine // Register under the ORIGINAL base id for a *_scamin clone (L._baseId), // so every restyle/toggle keyed on the original id reaches the clone too. (variants[L._baseId || L.id] ||= []).push(id); - const { _baseId, ...tmplL } = L; // _baseId is internal — keep it out of the MapLibre layer - const v = { ...tmplL, id, source: "chart-" + set.name, filter: S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, set.band, id, bandsHidden, layerVis) }; + const { _baseId, _rawFilter, ...tmplL } = L; // internal — keep out of the MapLibre layer + const v = { ...tmplL, id, source: "chart-" + set.name, filter: _rawFilter ? baseFilter : S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, set.band, id, bandsHidden, layerVis) }; if (minzoom != null) v.minzoom = minzoom; // band appears at its scale, not the baked floor if (capped) v.maxzoom = CHART_BANDS.find((b) => b.slug === set.band).max; out.push(v); @@ -465,7 +486,7 @@ export function buildChartLayers({ // natively (zero JS/frame). The per-band archive is FLOOR-GATED at bake, so // tile CONTENT controls appearance: client layers need no band minzoom. const scaminVals = set.scamin || []; - if (!ignoreScamin && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminVals.length) { + if (!ignoreScamin && !L._rawFilter && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminVals.length) { // Only materialize a per-value bucket where the SCAMIN cutoff zoom is // ABOVE this set's source floor (set.min). The set's tiles don't load // below set.min, so any SCAMIN whose cutoff is ≤ set.min shows from the @@ -523,12 +544,12 @@ export function buildChartLayers({ // is mirrored from the unbucketed path. const mk = (suffix, baseFilter, minzoom) => { const id = L.id + "@" + band.slug + suffix; - layerBase[id] = baseFilter; + if (!L._rawFilter) layerBase[id] = baseFilter; // raw layers keep their verbatim filter — exclude from the live re-combine // Register under the ORIGINAL base id for a *_scamin clone (L._baseId), // so every restyle/toggle keyed on the original id reaches the clone too. (variants[L._baseId || L.id] ||= []).push(id); - const { _baseId, ...tmplL } = L; // _baseId is internal — keep it out of the MapLibre layer - const v = { ...tmplL, id, source: "chart-" + band.slug, filter: S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, band.slug, id, bandsHidden, layerVis) }; + const { _baseId, _rawFilter, ...tmplL } = L; // internal — keep out of the MapLibre layer + const v = { ...tmplL, id, source: "chart-" + band.slug, filter: _rawFilter ? baseFilter : S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, band.slug, id, bandsHidden, layerVis) }; if (minzoom != null) v.minzoom = minzoom; if (capped) v.maxzoom = band.max; out.push(v); @@ -541,7 +562,7 @@ export function buildChartLayers({ // SCAMIN value (collected from the tiles). Out-of-zoom buckets are skipped by // MapLibre for free, so the extra layers cost nothing at runtime. Features // WITHOUT SCAMIN take the band-gated `#no` variant. Other layers: one variant. - if (!ignoreScamin && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminValues && scaminValues.length) { + if (!ignoreScamin && !L._rawFilter && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminValues && scaminValues.length) { // Only bucket SCAMIN values whose cutoff is ABOVE this band's display floor // (dmin) — values at/below dmin show from the floor anyway (the band isn't // displayed below it), so fold them into the dmin-floored `#no` bucket. Cuts diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 6c6cc58..76c819d 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -175,7 +175,11 @@ const INSPECT_LAYER_LABEL = { point_symbols: "Symbol", soundings: "Sounding", li // Geometry-primitive rank for pick-report sorting (rule 9): points, then lines, // then areas; labels last. (Decode/render lives in .) function pickGeomRank(layer) { - return { point_symbols: 0, soundings: 0, lines: 1, complex_lines: 1, areas: 2, area_patterns: 2, text: 3 }[layer] ?? 9; + // Strip the SCAMIN-variant suffix so tile57's split source-layers (point_symbols_scamin, + // lines_scamin, areas_scamin, text_scamin, …) rank like their base layer — otherwise + // every SCAMIN feature falls to the unknown rank (9) and the point