Skip to content

Commit 4454324

Browse files
beetlebugorgclaude
andcommitted
Merge SCAMIN merged-layer toggle (?scaminmerge): collapse the bucket 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>
2 parents b1c5981 + 52dd0e6 commit 4454324

3 files changed

Lines changed: 153 additions & 2 deletions

File tree

internal/engine/server/tile57_style.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,20 @@ func (s *Server) styleCtxForSet(r *http.Request, q url.Values, set string) tile5
283283
scamin: parseBands(q.Get("scamin")),
284284
bands: parseBands(q.Get("bands")),
285285
}
286+
// ?scaminMerge collapses the per-value #sm bucket layers (and the filter-gate
287+
// layers) into ONE zoom-gated layer per render-type by handing the engine an EMPTY
288+
// SCAMIN manifest. With the filter-gate forced off (see marinerFromQuery), an empty
289+
// manifest hits style.zig's zoom_gate branch, which self-gates on the live zoom via
290+
// [">=",["zoom"],log2(DENOM_Z0/scamin)] — no per-value buckets, no client setFilter
291+
// or source reload. So skip the meta.Scamin population and leave the manifest empty
292+
// (a ?scamin override still wins — it's populated above from the query).
293+
mergeScamin := queryBool(q, "scaminMerge")
286294
if src, ok := s.lookupSet(set); ok {
287295
meta := src.Meta()
288296
ctx.minZoom = uint32(meta.MinZoom) // the set's real tile floor — MapLibre requests nothing below the source minzoom
289297
ctx.maxZoom = uint32(meta.MaxZoom) // live ENC = z18 (berthing); don't clamp to the engine's z16 default
290298
ctx.encoding = tile57.EncodingFormat(meta.TileType)
291-
if len(ctx.scamin) == 0 {
299+
if len(ctx.scamin) == 0 && !mergeScamin {
292300
ctx.scamin = make([]int32, len(meta.Scamin))
293301
for i, v := range meta.Scamin {
294302
ctx.scamin[i] = int32(v)
@@ -320,6 +328,13 @@ func orDefault(v, def string) string {
320328
return v
321329
}
322330

331+
// queryBool reports whether a query param is set to a truthy value ("1"/"true"),
332+
// matching the client's bool serialization (marinerFromQuery's boolP).
333+
func queryBool(q url.Values, key string) bool {
334+
v := q.Get(key)
335+
return v == "1" || v == "true"
336+
}
337+
323338
// requestOrigin reconstructs the scheme://host the client reached us on, for the
324339
// absolute URLs MapLibre needs in the style document.
325340
func requestOrigin(r *http.Request) string {
@@ -401,6 +416,14 @@ func marinerFromQuery(q url.Values) tile57.Mariner {
401416
// scamin-layers.md: one live-filtered layer per render-type instead of per-value
402417
// #sm bucket layers (client rewrites curDenom via setFilter on boundary crossings).
403418
boolP("scaminFilterGate", &m.ScaminFilterGate)
419+
// ?scaminMerge is the A/B collapse: an empty SCAMIN manifest (styleCtxForSet) plus
420+
// the filter-gate OFF drives the engine's zoom_gate branch — one self-gating
421+
// zoom-expression layer per render-type, no client setFilter/reload. Force the
422+
// filter-gate off so the empty manifest can't be captured by the filter_gate branch
423+
// (which precedes zoom_gate in style.zig), regardless of what the client sends.
424+
if queryBool(q, "scaminMerge") {
425+
m.ScaminFilterGate = false
426+
}
404427
floatP("sizeScale", &m.SizeScale)
405428
m.ViewingGroupsOff = parseBands(q.Get("viewingGroupsOff")) // CSV of vg ids turned off
406429
return m

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@ export class ChartCanvas extends HTMLElement {
172172
try { return !new URLSearchParams(location.search).has("noScaminGate"); }
173173
catch (e) { return true; }
174174
})();
175+
// ?scaminmerge (A/B): collapse the SCAMIN filter-gate/bucket layer explosion
176+
// (~18 kinds × up to 33 SCAMIN values × N packs) down to ~1 layer per kind by
177+
// asking the engine for its MERGED zoom-expression gate (empty manifest →
178+
// style.zig zoom_gate). That clause self-gates on the live zoom, so the entire
179+
// client SCAMIN injection path (_scaminUpdate / _scaminApplySettled /
180+
// _scaminForceWhenReady + the straggler sweep) is UNNECESSARY and disabled below
181+
// — no setFilter, no source reload on zoom. Trade-off: integer-zoom snap (the
182+
// clause steps at whole zoom levels, not the exact physical crossing). Like
183+
// _ignoreScamin, a per-page-load constant (change the URL + reload to flip).
184+
this._scaminMerged = (() => {
185+
try { return new URLSearchParams(location.search).has("scaminmerge"); }
186+
catch (e) { return false; }
187+
})();
175188
this._engineScaminValues = []; // SCAMIN ladder (from the set tilejson) — the crossing boundaries
176189
this._scaminBandLast = -1; // last-applied band index (count of ladder values below curDenom)
177190
this._scaminApplyT = 0; // settle timer for the deferred gate apply (see _scaminUpdate)
@@ -1412,7 +1425,10 @@ export class ChartCanvas extends HTMLElement {
14121425
boolK("dateDependent"); boolK("highlightDateDependent");
14131426
if (m.dateView) p.set("dateView", m.dateView);
14141427
if (this._ignoreScamin) p.set("ignoreScamin", "1");
1415-
if (this._scaminGate) p.set("scaminFilterGate", "1");
1428+
// Merged mode asks the engine for the zoom-expression gate (empty manifest,
1429+
// filter-gate off) instead of the per-value/filter-gate bucket layers.
1430+
if (this._scaminMerged) p.set("scaminMerge", "1");
1431+
else if (this._scaminGate) p.set("scaminFilterGate", "1");
14161432
p.set("sizeScale", String(this._featureSizeScale()));
14171433
if (m.viewingGroupsOff && m.viewingGroupsOff.length) p.set("viewingGroupsOff", m.viewingGroupsOff.join(","));
14181434
return p.toString();
@@ -1540,6 +1556,9 @@ export class ChartCanvas extends HTMLElement {
15401556
// mid-load, so a bare _scaminUpdate(true) silently no-ops and the chart
15411557
// stays blank until the next crossing (user: "map goes away until refresh").
15421558
_scaminForceWhenReady() {
1559+
// Merged mode's engine style self-gates on the live zoom (zoom-expression clause),
1560+
// so there is no client cutoff to inject — every force path is a no-op.
1561+
if (this._scaminMerged) return;
15431562
const m = this._map;
15441563
if (!m) return;
15451564
if (!m.isStyleLoaded || !m.isStyleLoaded()) { m.once("idle", () => this._scaminForceWhenReady()); return; }
@@ -1553,6 +1572,7 @@ export class ChartCanvas extends HTMLElement {
15531572
// until the style is ready. movestart clears the timer (gesture resumed).
15541573
_scaminApplySettled(delay) {
15551574
clearTimeout(this._scaminApplyT);
1575+
if (this._scaminMerged) return; // merged mode self-gates on zoom — no settle apply
15561576
this._scaminApplyT = setTimeout(() => {
15571577
const m = this._map;
15581578
if (!this._scaminGate || !this._engineMode || !m) return;
@@ -1573,6 +1593,10 @@ export class ChartCanvas extends HTMLElement {
15731593
// per move frame. With N active sets there are ~17×N gated layers but still one
15741594
// reload per source; the setFilter loop itself is main-thread (validation skipped).
15751595
_scaminUpdate(force) {
1596+
// Merged mode: the engine's zoom-expression gate hides/shows features itself, so
1597+
// there is nothing to setFilter — early-return kills the whole injection loop
1598+
// (this also covers the mid-zoom `move` hook, which funnels through here).
1599+
if (this._scaminMerged) return;
15761600
// Only in engine mode, and only once the style is loaded (getStyle() is undefined
15771601
// and setFilter throws before that — the `move`/`load` hooks can fire mid-load).
15781602
if (!this._scaminGate || !this._engineMode || !this._map) return;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Tests the ?scaminmerge A/B toggle on <chart-canvas>: in merged mode the client
2+
// asks the engine for its zoom-expression SCAMIN gate (scaminMerge=1, NOT
3+
// scaminFilterGate) and DISABLES the whole client SCAMIN injection path
4+
// (_scaminUpdate / _scaminApplySettled / _scaminForceWhenReady early-return, so no
5+
// setFilter / source reload fires on zoom). Normal mode is unchanged.
6+
// Run: node --test web/src/chart-canvas/chart-canvas.scaminmerge.test.mjs
7+
//
8+
// <chart-canvas> extends HTMLElement and calls customElements.define at load, so we
9+
// shim the two custom-element globals before importing — the module is otherwise
10+
// node-safe (maplibre is a lazy dynamic import). We drive the pure methods via
11+
// prototype.call(stub) so no DOM/map is constructed.
12+
import test from "node:test";
13+
import assert from "node:assert/strict";
14+
15+
globalThis.HTMLElement = globalThis.HTMLElement || class {};
16+
globalThis.customElements = globalThis.customElements || { define() {} };
17+
18+
const { ChartCanvas } = await import("./chart-canvas.mjs");
19+
const proto = ChartCanvas.prototype;
20+
21+
// Minimal `this` for _marinerQuery: it reads _mariner (an object of settings, all
22+
// omitted here so every key is skipped), _active, _engineSet, the three SCAMIN
23+
// flags, and _featureSizeScale().
24+
function marinerStub(over) {
25+
return Object.assign({
26+
_mariner: {},
27+
_active: "day",
28+
_engineSet: "tile57",
29+
_ignoreScamin: false,
30+
_scaminMerged: false,
31+
_scaminGate: true,
32+
_featureSizeScale: () => 1,
33+
}, over);
34+
}
35+
36+
test("_marinerQuery: merged mode sends scaminMerge=1 and NOT scaminFilterGate", () => {
37+
const q = new URLSearchParams(proto._marinerQuery.call(marinerStub({ _scaminMerged: true })));
38+
assert.equal(q.get("scaminMerge"), "1");
39+
assert.equal(q.get("scaminFilterGate"), null);
40+
assert.equal(q.get("set"), "tile57");
41+
});
42+
43+
test("_marinerQuery: normal (non-merged) mode sends scaminFilterGate=1 and NOT scaminMerge", () => {
44+
const q = new URLSearchParams(proto._marinerQuery.call(marinerStub({ _scaminMerged: false, _scaminGate: true })));
45+
assert.equal(q.get("scaminFilterGate"), "1");
46+
assert.equal(q.get("scaminMerge"), null);
47+
});
48+
49+
test("_scaminForceWhenReady: merged mode is a no-op (never calls _scaminUpdate)", () => {
50+
let updates = 0;
51+
const stub = {
52+
_scaminMerged: true,
53+
_map: { isStyleLoaded: () => true, once: () => { throw new Error("must not defer"); } },
54+
_scaminUpdate: () => { updates++; },
55+
};
56+
proto._scaminForceWhenReady.call(stub);
57+
assert.equal(updates, 0, "merged mode must not inject a cutoff");
58+
});
59+
60+
test("_scaminForceWhenReady: normal mode DOES inject the cutoff (calls _scaminUpdate(true))", () => {
61+
const args = [];
62+
const stub = {
63+
_scaminMerged: false,
64+
_map: { isStyleLoaded: () => true },
65+
_scaminLayersCache: {}, _chartLayerIdsCache: {},
66+
_scaminUpdate: (force) => { args.push(force); },
67+
};
68+
proto._scaminForceWhenReady.call(stub);
69+
assert.deepEqual(args, [true], "normal mode re-injects the live cutoff");
70+
});
71+
72+
test("_scaminUpdate: merged mode early-returns before any setFilter (even with all other guards open)", () => {
73+
let setFilters = 0;
74+
// All the NON-merged guards are deliberately satisfied, so _scaminMerged is the
75+
// ONLY thing that can stop the injection loop.
76+
const stub = {
77+
_scaminMerged: true,
78+
_scaminGate: true,
79+
_engineMode: true,
80+
_map: {
81+
isStyleLoaded: () => true,
82+
getZoom: () => 13, getCenter: () => ({ lat: 38.9 }),
83+
getLayer: () => ({}), getFilter: () => ["all"],
84+
setFilter: () => { setFilters++; },
85+
},
86+
_pxPitch: undefined,
87+
_engineScaminValues: [30000, 12000],
88+
_scaminGatedLayers: () => { throw new Error("must not scan gated layers in merged mode"); },
89+
};
90+
proto._scaminUpdate.call(stub, true);
91+
assert.equal(setFilters, 0, "merged mode must issue zero setFilter (the zoom-expression self-gates)");
92+
});
93+
94+
test("_scaminApplySettled: merged mode schedules no settle apply", async () => {
95+
let scheduled = false;
96+
const realSetTimeout = globalThis.setTimeout;
97+
globalThis.setTimeout = (fn, d) => { scheduled = true; return realSetTimeout(fn, d); };
98+
try {
99+
proto._scaminApplySettled.call({ _scaminMerged: true, _scaminApplyT: 0 }, 120);
100+
assert.equal(scheduled, false, "merged mode must not arm the settle timer");
101+
} finally {
102+
globalThis.setTimeout = realSetTimeout;
103+
}
104+
});

0 commit comments

Comments
 (0)