Skip to content

Integrate tile57 engine#40

Merged
beetlebugorg merged 110 commits into
mainfrom
feature/tile57
Jul 6, 2026
Merged

Integrate tile57 engine#40
beetlebugorg merged 110 commits into
mainfrom
feature/tile57

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

https://github.com/beetlebugorg/tile57

This replaces the Go tile engine with a more robust Zig engine accessed via CGO.

beetlebugorg and others added 30 commits June 30, 2026 16:04
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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=<dir>` serves a live set; `cp bake --tile57`
and `cp serve --tile57 <ENC_ROOT>` 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) <noreply@anthropic.com>
…point

Server-side plumbing so the client can render from the engine's MapLibre
style (the "use the tile57 style for tile57 tiles" direction):

- /api/style.json now reads the FULL mariner from the query (marinerFromQuery)
  — display categories, contours, boundary/point style, text groups, dates,
  viewingGroupsOff, sizeScale, scaminFilterGate — so the engine bakes the live
  display state into the style.
- SET-AWARE: the chart-source tiles URL derives from ?set, so the same endpoint
  serves the live --tile57 set AND any registered tile57-baked pack (not just a
  hardcoded "tile57" set). 404 when the set isn't registered.
- POST /api/style-diff {from,to}: engine-computed op array (tile57.StyleDiff)
  for flicker-free mariner toggles — the client applies setFilter/setPaintProperty
  in place instead of re-fetching the whole style.
- gzip /api/style.json and /api/style-diff (the style is multi-MB; ~37x smaller).
- Binding-compat with the updated tile57 Go binding: PickAttrs enum (PickInclude)
  and Style(minZoom,maxZoom).

Both the default CGO-free build and -tags tile57 compile; the default build keeps
501 stubs for the tile57-only endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… & baked packs)

On the tile57 backend the client now renders from the engine's MapLibre style
(/api/style.json) instead of the JS builder — ONE style source, so text halo,
LocalOffsets, colours and SCAMIN all come from the engine (no JS-vs-engine drift;
this is what fixes the "--tile57-bake has a halo, --tile57 doesn't" mismatch).

- Engine mode activates for the live "tile57" serve set AND for a single tile57-baked
  pack (_engineSet, chosen from the active server sets; re-evaluated in setServerSets so
  packs activated post-boot are caught). The server 404s /api/style.json for a non-tile57
  set, so the probe is the real gate. Multi-pack installs fall back to the JS builder.
- buildStyle() is a wrapper: _engineStyleMerged (grafts the client basemap + no-data +
  overlays onto the engine chart layers) or _buildStyleJS (the Go-backend path, unchanged).
- Flicker-free mariner toggles: POST /api/style-diff, apply the op array in place
  (setFilter/setPaintProperty), debounced so a boot burst coalesces to one diff; rebuild
  fallback. No setStyle per toggle.
- SCAMIN filter-gate controller: the engine emits ONE live-filtered layer per render-type
  (scaminFilterGate) instead of ~1200 per-value buckets; the client rewrites the current
  display-scale denominator on ladder-boundary crossings (_scaminUpdate). Guards on
  isStyleLoaded so it never touches the map mid-load.

Go-backend rendering (the JS builder) is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-client "bakeEngine" setting (defaulting to "go") could override the
--tile57-bake launch default and, after a few UI bakes, silently pin a tile57
server back to the Go baker → Go-baked packs rendered by the JS builder (halo,
SCAMIN buckets, text drift). A tile57-capable binary now ALWAYS bakes with
libtile57; the CGO-free build still falls back to the Go per-band baker.

Removes the client bakeEngine setting, the Advanced → "Bake engine" toggle, and
the /api/health bakeEngines capability advertisement (all now vestigial).

Phase 1 of specs/tile57-only-engine.md — independent of the full removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds scripts/xbuild-tile57.sh + `make xbuild-tile57`: cross-compile the CGO
binary that statically links libtile57, using Zig as the C toolchain. Proven for
linux + windows (amd64/arm64) — all four cross-link cleanly from any host with
Zig alone. Per target it builds libtile57 for that arch (normalizing Zig's
Windows tile57.lib → libtile57.a), cross-builds with `zig cc -target …`, then
restores the host lib.

darwin is deliberately excluded and built natively on a macOS CI runner: with
GOOS=darwin, Go's own crypto/x509 links -framework Security/CoreFoundation, which
Zig doesn't bundle. Spiking all six targets (specs/tile57-only-engine.md phase 2)
established this; the flip of the default build + the goreleaser/CI rework land
with the Go-engine deletion (phase 4), where they're coherent and no longer
coupled to the CGO-free variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a per-band libtile57 bake so the district/demo/widget workflows keep
working once the Go baker is gone (Phase 4a of specs/tile57-only-engine.md,
"native parity first"). Cells are grouped into navigational bands by compilation
scale (the same BandForScale mapping the Go baker uses) and each band's subset is
baked on its own via tile57.BakeCells into <out-stem>-<slug>.pmtiles, so the
archive is naturally clipped to that band's coverage. Honors --max-zoom and
--overzoom; writes the same {aux, districts:[{band,bounds,file}]} manifest shape
the Go baker's --bands emits, so the frontend's per-band MultiArchive loads it
unchanged. Verified on the S-64 TDS: 30 cells → 5 valid per-band PMTiles archives
+ manifest.

Also DRYs the two existing manifest writers into writeManifestJSON.

NOTE: these archives carry tiles only. tile57 tiles render from the ENGINE style,
and rendering per-band SEPARATE archives needs a multi-source style — the job the
JS builder does today and what Phase 5 (multi-pack engine style) must replace. So
the render side of this parity is on Phase 5's critical path, as the spec predicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… sole baker

Rips out the entire Go tile-baking + S-101 portrayal engine (~12.7k LOC):
internal/engine/{bake,portrayal,mvt,assets,s101,tile} plus the Go baker guts,
the server's Go dynamic per-tile generator (tile.go + /api/tile route) and Go
bake fallback, and the Go asset emitter. The native libtile57 engine is now the
ONLY tile/portrayal/asset engine.

Kept as the surviving CGO-free metadata layer: internal/engine/baker slimmed to
just cell parse (ParseCellBytes/WithUpdates/Coverage), CellData, ExtractCellMeta,
and the compilation-scale → band mapping (moved out of the deleted bake pkg into
baker/bands.go), used by the chart library, cell index, and the tile57 bake path.
s101catalog kept as the embedded catalogue FS the tile57 asset emitter reads.

The -tags tile57 split stays for now: the CGO-free default build still COMPILES
(CI stays green) but bake/emit-assets error via stubs — you must build with
`make build-tile57`. Flipping the default to CGO+tile57 (and the CI/goreleaser
rework) is the next step. `cp bake` is now tile57-only (--bands / bundle);
`cp bake --bands` no longer needs an explicit --tile57.

Verified: both builds compile, full test suite passes, `cp bake --bands` bakes
the S-64 TDS (30 cells → 5 per-band archives + manifest). Drops the unused
gopher-lua dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate chartplotter to tile57's breaking ABI refactor:
- BakeCells → BakePmtiles([]Cell, BakeOpts); CellInput → Cell; the pick enum →
  BakeOpts.OmitPickAttrs.
- BakeBundle(input, outDir, BakeOpts, progress) (was 9 positional args).
- The 4 asset generators (Colortables/Linestyles/SpriteAtlas/PatternAtlas) →
  one BakeAssets(catalogDir) → Assets. emitS101Assets now takes a catalog DIR
  ("" = libtile57's EMBEDDED catalogue) and drops the catalogFS/namedFromDir
  plumbing entirely — so `cp emit-assets` / serve asset emission no longer needs
  the Go s101catalog embed or an external --s101 dir (verified: emits all 6 files
  from the embedded catalogue).
- Live serve: OpenCells([]Cell) → streaming Open(path) (reads the ENC_ROOT from
  disk on demand; zips staged to a temp dir first).

Both builds compile, full tests pass; verified `cp bake --bands` (S-64 TDS) and
`cp emit-assets` against the new lib.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te 404s

Two live-render bugs surfaced now that libtile57 is the sole engine (no Go/JS
fallback):

1. /api/style.json 404 for a baked pack. The endpoint defaulted ?set to the
   literal "tile57" (the live serve set) and 404'd otherwise — a holdover from
   "engine style only for the live set, else fall back to the JS builder." With
   the Go engine gone, EVERY pack renders from the engine style. New
   resolveStyleSet() maps the requested ?set to a real registered set (the
   requested one, else the sole pack), and styleCtx derives the tiles URL from
   the RESOLVED set — so a client asking for set=tile57 against a single baked
   noaa-d1 pack now gets that pack's style pointing at /tiles/noaa-d1/. (An
   ambiguous multi-pack install still 404s → Phase 5 multi-source composition.)

2. /sprite@2x.{json,png} 404s. The client renders chart symbols via its own
   SpriteBuilder (map.addImage from the tile57-custom sprite.json/png it fetches
   itself), so carrying the engine style's `sprite` field only made MapLibre fetch
   a standard @2x atlas that doesn't exist. Drop `sprite` from _engineStyleMerged.

Verified: baked the S-64 TDS as a bundle into a noaa/d1 cache layout, served it,
and /api/style.json?set=tile57 → 200 with tiles=/tiles/noaa-d1/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…clamp"

The tile57 ABI migration set BakeOpts.MaxZoom = 0 for the bundle bake intending
"no clamp", but MaxZoom 0 clamps every band down to z0 — an empty archive (the
old positional call passed 24 and its comment warned exactly this). Result: every
imported/baked pack had a 550-byte empty chart.pmtiles (maxzoom 0), so the client
loaded the engine style but got HTTP 204 for every tile → nothing rendered.

Use MaxZoom 24 (the ABI's no-clamp sentinel) in both BakeBundle callers. Verified:
the S-64 TDS bundle goes from 550 bytes → 7.5 MB, TileJSON maxzoom 0 → 18, and
tiles serve real MVT at z16–18.

Also: a corrupt cell libtile57 tolerates bakes n=1 with an inverted bbox (no
coverage); guard on that (and n==0) so such an import errors and registers no
empty pack instead of silently succeeding — restores TestImportJobErrorPath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…57 split

libtile57 is the sole engine, so the build-tag split is gone: the 5 tile57_*.go
files become unconditional and the 5 tile57_*_off.go CGO-free stubs are deleted.
A plain `go build ./...` now links libtile57 (CGO required); there is no
CGO-free build. Removes the now-dead tile57Available / bakeTile57Available consts,
the Server.BakeEngine field, and the --tile57-bake serve flag (imports always bake
tile57 bundles). The tile57 tests now run under plain `go test` too.

Next: flip `make build` to this, and rework ci.yml/goreleaser to install Zig +
build libtile57 (they still assume the CGO-free build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`make build` now builds the lib with Zig on demand and CGO-links it — libtile57
is the sole engine and embeds the S-101 catalogue, so there is no embed_s101
sync step. `build-tile57` is a back-compat alias for `build`. `serve-tile57`
drops the removed --tile57-bake flag. `version` reports the libtile57 version
instead of the (now vestigial) Go s101catalog embed; s101catalog has no Go
importers left (its custom-overlay symbols belong in libtile57 now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The -tags removal made the default build CGO+libtile57, so CI must check out the
sibling chartplotter-native repo, install Zig, and build the static lib before
gofmt/vet/test/build. Both jobs now do this.

CAVEAT: untested (can't run GitHub Actions locally). Needs a test push to confirm
the mlugg/setup-zig version and the two-checkout paths. If chartplotter-native is
private, add a PAT/repo secret and pass it as `token:` on that checkout (the
default GITHUB_TOKEN can't read a different private repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…101 path

The old CGO-free `make xbuild` can't work anymore (the libtile57 binding needs
cgo), so `xbuild` is now an alias of `xbuild-tile57` (zig cc cross-compile,
linux+windows; darwin on a Mac runner). Drops the embed_s101 tag from the script
and target — the S-101 catalogue lives in libtile57, so there's no sync/embed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… sole engine

Phase 6 of specs/tile57-only-engine.md: CLAUDE.md described a CGO-free build with
an opt-in -tags tile57 backend. That's inverted now — libtile57 is the sole engine
linked via CGO, CGO_ENABLED=0 no longer builds, and cross-compilation is preserved
with Zig as the C toolchain. Update the overview, commands, and conventions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e 5)

With libtile57 the sole engine, a multi-pack install (several NOAA district
bundles) must render entirely from the engine style — there's no JS-builder
multi-source fallback. The server now composes ONE MapLibre style spanning every
active pack: ?set is a CSV (default = all registered), and each set's style is
built with its OWN scamin ladder + top zoom, then merged — each "chart" source
becomes "chart-<set>" and each layer id gets a "--<set>" suffix (source ref
rewritten, backgrounds dropped, sprite/glyphs shared). Sets are ordered
coarse→fine. Single-set requests keep the original single-source style unchanged.

Client: _initEngineStyle adopts ALL active sets (drops the sole-set gating /
JS-builder pick), sends the CSV as ?set, and unions the scamin ladders across
packs for the filter-gate. The style-diff endpoint stays single-set (a multi-pack
CSV 404s → the client falls back to a full re-fetch).

Verified: two baked packs → /api/style.json composes 2 sources (chart-noaa-d1,
chart-noaa-d5) + 708 layers; a single ?set keeps 1 source + 355 layers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…machinery

s101catalog (the Go embedded-catalogue package) has had no importers since asset
emission moved to libtile57's embedded catalogue, and its only custom-overlay
symbol (NEWOBJ01 "!") is handled natively by libtile57 (s57_mvt.zig). Delete the
package, the Makefile sync-s101 target + S101_EMBED_DIR/S101_CUSTOM/S101_CACHE
vars, and the .gitignore embed-dir entry.

Simplify serve/serve-demo/docs-shots to not force --s101 — the catalogue lives in
libtile57 now; --s101 stays an optional external override (for rule iteration).
Strip the now-dead `make sync-s101` step from release.yml (it + .goreleaser.yaml
still need the full CGO/Zig rework — flagged in-file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…riority

pickCmp sorted by draw_prio first, so a LNDARE fill (S-52 priority 12)
outranked every point symbol below priority 12 and features on land were
effectively unclickable — the pick report always led with the land polygon.
A click is aimed at the discrete object under the cursor; the
skin-of-the-earth polygon it sits on is context. Rank point < line < area
(labels last) first and apply §10.8.4's priority ordering within each rank.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l .a content

With bin/chartplotter already present, go build's action ID (which ignores
libtile57.a's content) matched the existing buildid and silently skipped the
link, so a rebuilt engine never reached the output. rm -f the binary first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-mode load

setServerSets is the one setStyle path that ENTERS engine mode, and unlike
_engineRestyle / _engineRebuild it had no post-apply _scaminUpdate. The engine
style bakes the filter-gate clause at curDenom=0 (show-all); every other setStyle
path re-injects the live display-scale cutoff after the new style loads, but the
boot / set-switch path did not — so `_scaminBandLast` stayed -1 and every
SCAMIN-gated feature (soundings, symbols, text) drew at ALL scales until the
first `move` fired _scaminUpdate.

Symptom: opening a share-link (or just loading the app and not panning) showed the
fully-ungated, massively over-cluttered chart — e.g. at 1:75,000 the whole sheet
was peppered with 1:59,999-SCAMIN spot soundings that S-52 says drop by ~z11.9.
A single pan self-healed it, which masked the bug.

Fix: after the source rebuild in setServerSets, re-inject the cutoff once the new
engine style has settled (once "idle" → _scaminLayersCache=null; _scaminUpdate(true)),
mirroring the force-apply the other setStyle paths already do. See scamin-layers.md
(client half of the filter-gate).

Verified against the live app: on a fresh load with no interaction, band now
transitions -1 → 7 automatically and the spot-sounding pepper is decluttered,
while dangers / contours / depth areas / quality boundaries remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
maxTileCacheZoomLevels 5→20 covers the full z3.5–18 range so switching
between overview and approach scales hits the cache instead of re-requesting.
fadeDuration 300→100ms so newly-loaded tiles appear faster during pan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…/geo)

pkg/s100/{catalog,fc,instructions,symbols} and pkg/geo have had zero
importers since the Go bake/portrayal engine was deleted — libtile57 owns
all portrayal. The untracked internal/engine/s101catalog data tree is
removed from disk too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ials

The app should know as little S-57 as possible — libtile57 is the engine.
What survives in Go is only what tile57's ABI can't provide yet: per-cell
header/coverage metadata (DSID/DSPM + M_COVR) for band grouping and the
chart library, CATALOG.031 titles on import, and the full feature parse
the NMEA simulator's water mask needs.

Deleted capabilities (all zero-caller since the Go baker went away):
- conformance validation (conformance.go, ValidateConformance, Warnings)
- derived coastline-coincident + explicit edge-masking outputs
  (MaskCoastlineCoincidentBoundaries, BoundaryLines/Lines/Quapos) —
  tile57 masks edges natively
- the R-tree spatial index + FeaturesInBounds/FeatureCount query path
  (drops the rtreego dep; bounds still derive from M_COVR)
- pkg/s57 convenience API: Parser interface/NewParser/Parse/ParseFS,
  attributes.go schemas, Bounds ops, unused DSID getters + UsageBand/
  CoordinateUnits/DepthUnits types, SOUNDG DEPTHS synthesis
- catalog: Catalog.Bounds, ParseCatalogFS, CRC/Comment fields
- baker: ParseCellWithUpdates/IsBaseCell/IsUpdateCell/Band.ZoomRange
  (tile57 applies .001+ updates itself on every bake/serve path)

The server cell index now uses the cheap M_COVR-only coverage parse
instead of a full feature parse per cell.

Also drops the orphaned srwiley SVG-raster deps left from the deleted
Go sprite emitter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine style's chart source carried minzoom 9 for every pack: build()
and the style-diff path hardcoded minZoom=0, and the old libtile57 ABI
treated 0 as "engine default" (9). MapLibre never requests vector tiles
below a source's minzoom, so zooms 0-8 rendered blank even when the baked
archive holds tiles from z0 (noaa-d5: header minzoom 0, real content at
z0-8, nothing on screen below z9).

styleCtxForSet now carries meta.MinZoom and both style paths pass it;
paired with the libtile57 fix (chartplotter-native eba6d2a) that emits the
caller's minzoom verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ve frame

Every ladder-boundary crossing rewrote all gated layers' filters mid-zoom
(measured: 14 bursts x 68 setFilter calls in one z8-to-z16 sweep, each burst
~110ms of main-thread validation plus a full worker re-parse + symbol
re-placement of every chart source). That was the zoom jank, and the reload
churn read as symbols blinking out mid-gesture.

Crossings now apply once the camera settles (moveend + 120ms, cancelled on
movestart, retried past mid-load isStyleLoaded windows), and the setFilter
loop skips MapLibre's filter validation ({validate:false} - filters are
engine-generated, not user input). Verified headless: 0 setFilter during a
15s continuous zoom, one coalesced ~3ms apply after settle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settle-only gating left a long continuous zoom rendering a stale cutoff
until moveend. While the map is actively zooming (isZooming — pans still
wait for settle), a crossed ladder boundary now applies immediately but at
most every 500ms; the moveend pass remains the final exact apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gate

Pre-lands the client half of ../tile57's band-handoff spec: carry-down
features from the next-coarser band arrive tagged smax (the handoff
denominator), gated by ["<", ["coalesce",["get","smax"],0], N]. The
gate injector now sets that N together with the scamin clause's. No-op on
tiles that don't carry the clause yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine's band-handoff carry-down puts the smax clause on EVERY chart
layer (carried fills/lines land in base layers with no scamin clause);
those layers must receive the live denominator at ladder crossings too, or
carried coarse content stays hidden at its baked placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
beetlebugorg and others added 28 commits July 2, 2026 22:29
…r zoom

The straggler sweep reloaded (re-fetched + re-parsed) every tile of every
chart source on the first idle after EVERY gate apply — i.e. after every zoom
settle that crossed a SCAMIN band. On a multi-pack install that re-fetches
all tiles across all sources on every zoom, a big part of 'tiles crawl in
after you stop scrolling'. The race it guards is a one-time BOOT condition
(later tiles parse under the broadcast filters), so sweep once and never again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… explosion

The per-value SCAMIN bucket layers (~18 kinds × up to 33 values × N packs =
thousands of style layers) can instead be collapsed to ~1 layer per kind using
the engine's EXISTING merged zoom-expression gate (style.zig zoom_gate): with an
empty SCAMIN manifest and the filter-gate off, each *_scamin layer carries the
self-evaluating clause [">=",["zoom"],log2(DENOM_Z0/scamin)] (smax/oscl likewise
use their .zoom_k variant), so the live zoom gates the features with NO client
setFilter and NO source reload. No engine change — just pass an empty manifest.

Server: styleCtxForSet skips the meta.Scamin population when ?scaminMerge is set,
and marinerFromQuery forces ScaminFilterGate off so the empty manifest reaches the
zoom_gate branch (which style.zig checks after filter_gate). Works for the single,
multi-pack, and style-diff paths (all read the same query).

Client: ?scaminmerge sets this._scaminMerged, which (a) sends scaminMerge=1 instead
of scaminFilterGate=1 on the style request, and (b) early-returns the entire client
SCAMIN injection path (_scaminUpdate / _scaminApplySettled / _scaminForceWhenReady
and the straggler sweep), since the zoom-expression self-gates. Normal (non-merged)
mode is byte-for-byte unchanged — this is a pure A/B toggle, default off.

Verified (single-cell tile57 set, SCAMIN [6000..44999]):
- layer count: default #sm buckets 65 *_scamin layers → merged 13 (0 '#sm' ids,
  all 13 carrying the zoom-expression clause); scales to the ~5009→~160 win on a
  9-pack install.
- SCAMIN still gates: at z13 the source carries scamin [17999,21999,29999,44999]
  but only 44999 renders (the three smaller-scamin features are gated off at the
  SAME tiles); more features appear as you zoom in, none at z11.
- zero reloads on zoom: an easeTo z13→16.4→11.3 issues setFilter=0 _reloadSource=0
  in merged mode vs setFilter=37 _reloadSource=1 in the filter-gate mode.
- z14 settled render is pixel-identical between the two modes.

Caveat: the merged gate snaps to integer OGC zoom (["zoom"] in a filter is
evaluated at the tile's integer zoom), whereas the filter-gate injects the
latitude/pxPitch physical display-scale denominator — so the two can differ by up
to one ladder boundary right at a crossing (observed z13: merged shows 44999,
filter-gate shows 29999+44999).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…explosion

?scaminmerge uses the engine's zoom_gate mode (empty manifest → one
self-evaluating [>=,zoom,...] layer per kind instead of per-value #sm
buckets). Collapses ~5009→~160 layers on a 9-pack install, and — the point —
ZERO setFilter/reload on zoom (vs 37+1/crossing), killing the settle reload
storm. A/B toggle; default (bucket) path byte-identical. Tradeoff: integer-
zoom snap near ladder boundaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
?scaminmerge proved out, so merged (zoom-expression) mode is now the default:
no param → the client requests scaminMerge=1 and the whole injection path
stays disabled → zero setFilter/reload on zoom (verified). This is the fix
for the 'tiles crawl in a second after you stop scrolling' lag. Opt back to
the exact per-value filter-gate with ?scaminexact for comparison or
exact-declutter needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feet got the same 1–2 decimals as metric, so a depth showed "12.3 ft". Feet
read as whole numbers on charts — give ft one fewer decimal (10–100 ft → whole),
keeping one decimal only under 10 ft for shoal detail. Metric/NM unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Working-state checkpoint of the in-flight migration:
- tile57 is now a git submodule (github.com/beetlebugorg/tile57) built on demand;
  Makefile/go.mod/CLAUDE.md/Dockerfile/CI/docs updated to match.
- Server bakes one archive PER PROVIDER (noaa/ienc/user); districts live under
  <provider>/ENC_ROOT/<district>/ (new provider.go, import_packs.go; import.go,
  tilesets.go, http.go, tile57_bake/style, cellindex/cells/prefs updated). A
  bodyless POST /api/import?set=<provider> re-bakes a provider from its cached
  ENC_ROOT.
- Web client tracks the provider model (chart-library, chart-service, chartplotter,
  coverage-boxes, bands) + the cell-boundary debug overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Provider-enc-root renamed packs from noaa-d<N> to bare providers (noaa/ienc/user),
but _rebuildAllPerBand still matched the old /^noaa-d(\d+)$/ pattern, so it matched
nothing and silently did "nothing to rebuild". Re-bake each installed provider via
the server's cache-rebake (bodyless POST /api/import?set=<provider>) — the server
re-bakes every district from its cached ENC_ROOT into the provider's one archive.
Drops the now-unused client cell-list plumbing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…transparency)

S-101 ColorFill emits "TOKEN,alpha" (TRFCF,0.75 for traffic-separation zones;
CHGRF,0.5; NODTA,0.5), stored verbatim in color_token. The palette match looked up
the whole "TRFCF,0.75" string, missed the bare "TRFCF" key, fell to the opaque
magenta FALLBACK — and area fills carry no fill-opacity, so the alpha was dropped
twice. Split the suffix: match the base token and fold alpha into an rgba fill-color
(fill-opacity is not data-driven in MapLibre; fill-color is). Tokens without a comma
are matched whole (no regression). Scoped to areasFillColor — line/text tokens carry
no alpha. Verified by evaluating the expression: TRFCF,0.75 -> rgba(203,169,249,0.75).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ined

map.getStyle() RETURNS undefined (doesn't throw) before the style loads, so the
try/catch missed it and `style.layers` threw at init (`can't access property
"layers", style is undefined`), aborting _renderInstalledSets. Add `if (!style)
return null;`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er schema (Phase 2 W8)

The engine now emits 6 merged source-layers (areas, area_patterns, lines,
point_symbols, soundings, text — the `_scamin` twins and complex/sector lines
folded in). The client's engine-mode render path needs NO functional change: it
grafts the engine's chart layers verbatim (_engineStyleMerged) and its SCAMIN
filter-gate controller (_scaminGatedLayers / setScaminDenom) + chartLayerIds
select layers structurally (filter shape / `chart` source prefix), never by
hardcoded source-layer name. debug-snapshot.mjs is likewise fully generic.

This only refreshes chartLayerIds' illustrative comment, which still enumerated
the retired v1 names (complex_lines / sector_lines / *_scamin). The v1 name
literals that remain live in the JS style builder (chart-style.mjs /
chart-sources.mjs — dropped in engine mode, kept for the Go backend) and the
query/pick lists (search-box / dev-tools / chartplotter — already dual-schema
safe: querying a missing source-layer is a no-op and chartplotter strips the
`_scamin` suffix); both are intentionally left on v1 until the W9 library
re-bake, so the user's not-yet-rebaked v1 tiles keep rendering.

node --check clean; web unit tests green (42 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…style; drop the per-value bucket path

The param-less /api/style.json used to populate the engine's SCAMIN manifest
from the set metadata, which drove the per-value #sm bucket explosion (~835
layers). The client always sent ?scaminMerge=1 to avoid it, but a bare request
(or any direct API consumer) got the 835-layer style.

Now the merged band-independent style is the unconditional default: styleCtxForSet
no longer populates ctx.scamin, so the engine emits its self-gating zoom-gate
(one layer per render family, ~19 total). ?scaminexact still opts into the exact
live filter-gate. The manifest is no longer needed for the style — it rides the
TileJSON (served by the tile route) for the filter-gate client's ladder crossings.

Removed the now-dead meta.Scamin population, the ?scaminMerge special-casing
(its behavior is the default), the inert ?scamin override, and the unused
queryBool helper.

Verified end to end against an isolated live-set server: /api/style.json (no
params) = 19 layers over the 6 source-layers with the zoom-gate clause; ?scamin=…
also 19 (buckets are gone); ?scaminexact = 19 with the filter-gate clause.
gofmt/vet clean; server package tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cross-compile comments

The spec doc was removed in a specs/ cleanup; the CGO-only / `zig cc` cross-compile
rationale it documented is now captured inline. Strip the two dangling pointers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gine

The <chart-plotter widget> demo (web/demo.html) is server-less: no
/api/style.json to adopt, so it fell back to the JS chart-style builder
(buildChartLayers), which drifts from the tile57 engine's own portrayal
(text offsets, etc.). Wire it to the SAME engine the server uses, run
client-side from the vendored WASM style engine.

- Vendor @beetlebug/tile57-style-engine (index.js + .d.ts + style-engine.wasm,
  the tile57/1 committed submodule pin) into web/vendor/tile57-style-engine/.
  make demo copies web/vendor into the demo bundle; the wasm is fetched
  relative to `assets`.
- _initEngineStyle: where a server-less client early-returned, now load the
  WASM once (_loadClientEngine), generateStyle(<client mariner→MarinerSettings>),
  and fan the engine's chart layers across the per-band PMTiles sources
  (chart-<slug>) exactly like the server's mergeStyles — group-outer/band-inner
  (coarse→fine) with the coarse-band maxzoom caps, so the draw order and
  cross-band stacking mirror buildChartLayers. Flip _engineMode/_engineClient;
  _scaminMerged stays on (the engine layers self-gate SCAMIN on the web-mercator
  zoom, so no client filter-gate).
- _engineStyleMerged: client branch keeps the JS builder's per-band chart
  SOURCES (live PMTiles URLs/zoom/encoding) and grafts the engine LAYERS.
  Server-backed mode is UNCHANGED.
- Restyle (scheme/mariner) regenerates via WASM + setStyle(diff:true) instead
  of the server-only /api/style-diff.

The JS builder (chart-style.mjs / buildChartLayers) stays in place as the
fallback until the tile57/2 WASM lands. Verified against the tile57/1 demo:
base portrayal (fills/coastline/contours/areas) matches pixel-close; the
residual delta is SCAMIN-gated mark density (physical-scale vs web-mercator
gate) + engine text-offset corrections, converging as you zoom in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ax gate

Baked tiles are the only tile path (engine d78b28a):

- serve loses --tile57 / registerTile57Set / the serve-tile57 Make target —
  charts enter through import/bake and are served from the baked archives;
  the dynamic-set mechanism itself stays (plugin tiles use it).
  orderedUpdates moves to tile57_bake.go (its remaining consumer).
- Client: the band-handoff smax matcher half of setScaminDenom is retired —
  the engine style emits no smax clause (cross-band occlusion is baked
  geometry now); the denom injector handles scamin + oscl only. The debug
  gates snapshot reports scamin/oscl per layer accordingly.

go vet + go test green; web suite 42/42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… overscale margin

The uniform 1:MIN_DETAIL_SCALE floor let the camera zoom into water whose
finest covering band has no tiles at that depth — blank chart under an honest
overscale HUD (MapLibre cannot stretch absent vector tiles; 404s resolve as
empty, parent fallback needs a genuinely errored tile). _applyScaleFloor now
also caps max zoom at BAND_MAXZOOM[finest covering band] + OVERSCALE_MARGIN
(+FLOOR_GIVE), matching the bake's capped overscale fill-up
(bake_enc.FILLUP_DZ = 2): the camera stops exactly where tiles stop. The
finest covering band comes from the active-cells bbox index at the view
centre; no active cells (widget/empty library) leaves the uniform floor
alone — OVERSCALE_MARGIN finally gets its long-promised consumer.

Web suite 42/42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ile decoder

MapLibre reads a vector source's `encoding` only at source CREATION; archives
load after the initial (mvt-default) style is built, so an MLT archive (the
tile57 default bake format) could never switch a live source's decoder in
place — the stale mvt decoder parsed MLT bytes as MVT and MapLibre threw
"Unable to parse the tile". Track the encoding each style build commits per
source and rebuild() (recreate sources) instead of refresh() when a loaded
archive disagrees; zoom ranges keep updating in place.
… guard

The encoding comparison in _updateSourceZoom ran after the
!src/maxzoom-undefined continue, so an archive loading while the style was
still materializing (no live source yet) never flagged the rebuild — the
stale decoder survived until an unrelated restyle. The check compares the
archive against our own _srcEncoding bookkeeping, not the live source, so it
now runs first; the in-place zoom sync keeps the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the per-band widget fan (P5)

The coverage-clipped composite resolves best-available INSIDE the archive, so
the per-band archive split (and the client-side six-source re-composition it
required) is obsolete:

- bake: `-o *.pmtiles|*.mbtiles` writes ONE flat merged archive (+ --manifest
  with a single band-less entry + aux.zip); any other -o stays a bundle
  directory. --bands and --overzoom are retired (MinZoom 0 — the coarsest
  populated band extends down to the world view, which covered --overzoom).
- Makefile: districts bake to noaa-d<NN>.pmtiles (no stamps), serve-widget
  links/manifests single archives, demo/chart1 drop --bands.
- widget client: a band-less manifest entry loads into the single `all`
  source only (legacy banded entries keep the old fan). The merged archive is
  a SPARSE pyramid (each band bakes to its native max + FILLUP_DZ), which a
  single source can't describe with one maxzoom, so:
  · the pmtiles protocol now ERRORS an absent tile when any ancestor holds
    data (directory-only hasTile probes on PMTilesArchive/MultiArchive) —
    MapLibre retains/loads the parent and renders it stretched one level
    (a delivered-empty tile would paint permanent blank);
  · the camera stops at the data depth: _applyScaleFloor probes
    dataMaxZoomAt (deepest directory entry at the view centre) when there is
    no active-cell index, capping max zoom at depth + 1 stretch level —
    the widget twin of the server-mode band cap.

Verified on the Annapolis demo (one 13.6MB demo.pmtiles): crisp full detail
at z13 Annapolis; coastal-only water renders stretched at +1 past its
fill-up depth and the camera clamps there (z15 request → z14.15) instead of
blank water/hatch; suite 42/42, go vet/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oach/harbor charts

The Severn/Magothy, Bay Bridge, Kent Island, and South/West/Rhode river cells
join the curated set so the demo zooms from the whole bay down to the docks
across the immediate area. The Makefile's DEMO_CELLS default (which overrides
the script's when driven by `make demo`) is synced to the same list.
Depth/height readouts (the shared units.format and the pick-report number
formatter) picked decimals purely by magnitude, so feet could show a decimal
(e.g. "8.3 ft", and the pick report showed "12.3 ft"). Charts read feet as
whole numbers; metric carries the tenths digit feet drops.

Now: unit "ft" formats to 0 decimals always; unit "m" to at most 1. Other
units (nm/km/kn/…) keep the compact by-magnitude decimals unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bake -o *.pmtiles used the in-memory BakePmtiles path: every cell of a band
parsed+portrayed at once, no coarse riders, no super-tile locality, no
TILE57_SUPER_DZ/TILE57_LRU_BUDGET tuning, and the whole archive buffered in
memory before writing. District-scale flat bakes (the Makefile's
noaa-d<NN>.pmtiles) got the worst of both worlds.

runTile57Archive now drives BakeBundle (the streaming bakeRoot: geometry LRU,
coarse riders, capped fill-up, tiles streamed to disk) into a temp dir beside
-o and renames the finished tiles/chart.pmtiles onto -o, discarding the
bundle scaffolding. minzoom 0 — the coarsest populated band extends to the
world view, same as before. aux.zip now collects from the input tree without
reading cells into memory (collectAuxDir); zip inputs stage their aux content
beside the cells so the walk finds it (tile57Input previously dropped it).
The in-memory bbox fold + per-cell meta pass are gone with the old path.

Verified: 3-cell root → one 5.4MB flat pmtiles (zoom 0..16, riders in the
band log, coastal extend_min to z0), band-less manifest entry with real
bounds, coverage-edge view renders both sides. go build/vet green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…up depth

The server-mode camera cap allows BAND_MAXZOOM + margin; tiles only exist to
band max + TILE57_FILLUP_DZ (default 1 now), so the constant follows — a
margin past the baked depth would re-open a blank window. The widget path
probes real archive depth and self-adjusts regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng nothing

tile57Input handed any lone directory straight to the engine, but libtile57's
baker only reads extracted .000 cells — a directory of NOAA exchange-set .zip
bundles (the `make demo` cache, an IENC download) has none, so the bake covered
nothing ("tile57: bundle bake covered nothing"). Pass a lone directory through
as-is only when it ALREADY holds a .000 (a real ENC_ROOT); otherwise fall to
collectCells, which walks + unpacks the zips into the temp cell dir. Restores
`make demo`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ENC feature attachments (TXTDSC text / PICREP pictures) shipped in a
companion .aux.zip that the client unzipped (hosted) or the server
extracted per-file via /api/aux (dynamic). Replace both with LOOSE
static files: the baker writes a companion aux/ dir (files + index.json)
via one shared helper (auxfiles.WriteDir), used identically by the CLI
bake and the C-ABI server bake, so output matches.

- Server: GET /aux/index.json (manifest) + GET /aux/<stored> served as
  plain files from the aux/ dir; /api/aux removed. Legacy .aux.zip
  companions are still indexed + served (back-compat), so districts baked
  before the loose layout keep resolving until re-baked.
- Client: AuxStore.load(manifestUrl) is the single path — static fetch of
  index.json + per-file GETs, identical online (server /aux) or offline
  (a bundle's aux/index.json on any static host / file://). loadApi gone;
  a legacy .aux.zip URL is still auto-detected and unzipped.

Works offline with no zip and no server. Verified end-to-end: CLI bake
emits the aux dir + manifest; server serves /aux; browser resolves text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworked the cursor-pick panel for readability and stable layout:

- Two-column table: dim KEY, dark VALUE, 1/4 : 3/4 split, left-aligned,
  generous row rhythm; genuinely block content (TXTDSC/PICREP) stacks
  full-width. Body scrolls (card capped to the viewport) instead of the
  whole card running off-screen.
- Dropped duplicate labels: the acronym only shows when it differs from
  the resolved name (empty catalogue printed "CATLMK CATLMK").
- Cycle controls (◀ n/n ▶) moved into the fixed header and the source
  cell stacked under the class name, so neither jumps as you step across
  features whose subtitle/meta differ.
- ←/→ step through the picked stack (mirroring the header arrows),
  ignored in text fields and only while a multi-feature pick is open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… stale)

The server-less widget/demo runs the tile57 chartstyle engine CLIENT-side
via the vendored WASM (web/vendor/tile57-style-engine) — the same engine the
server exposes at /api/style.json, just without a backend. But `make demo`
only copied web/vendor verbatim, so a committed .wasm that lagged the engine
shipped a STALE style: its *_scamin layers were emitted ungated, so SCAMIN
scale-gating silently vanished and every buoy / light / sector arc drew at
every zoom (the whole chart piled into an overview blob).

The engine already gates correctly (empty manifest → the self-gating zoom
filter, style.zig zoom_gate); only the shipped binary was old. Make the demo
own it:

- New `vendor-style-engine` target: `zig build wasm` in $(TILE57) → re-vendor
  style-engine.wasm + index.js/.d.ts into web/vendor/tile57-style-engine.
- `demo` depends on it, so the bundle's WASM always tracks the current engine.
- Re-vendored the current .wasm (was stale → dropped SCAMIN gating).

Verified: with the fresh WASM the demo's overview is clean (0 point symbols
rendered at z6, 119 scamin-gated layers) vs. thousands ungated before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pinned engine was 42 commits behind main. Bump it to the current
main tip so the default (no-go.work) build tracks the live engine — incl.
the cover-clip FULL/EMPTY/SEAM pre-pass. .gitmodules already tracks main,
so `git submodule update --remote` keeps it moving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	web/src/chart-canvas/chart-canvas.mjs
@beetlebugorg beetlebugorg merged commit 9d15716 into main Jul 6, 2026
4 checks passed
@beetlebugorg beetlebugorg deleted the feature/tile57 branch July 6, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant