dsh-theme-endfield 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -33
- package/client.js +492 -121
- package/docs/audio-notifications.md +1 -1
- package/docs/design-language.md +1 -1
- package/docs/engineering-notes.md +44 -26
- package/docs/features.md +9 -6
- package/docs/glass.md +2 -2
- package/docs/preview.gif +0 -0
- package/docs/testing.md +41 -8
- package/index.js +7 -0
- package/lib/audio.js +30 -5
- package/lib/slots.js +3 -2
- package/package.json +8 -8
package/client.js
CHANGED
|
@@ -133,7 +133,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
133
133
|
|
|
134
134
|
Both are loopback pages, so DSH resolves the connection to 'host' mode
|
|
135
135
|
and the values land on disk; a change of port does not move them because
|
|
136
|
-
nothing lives in browser storage any more.
|
|
136
|
+
nothing lives in browser storage any more. (0.2.1-rc.2 removed the `mode`
|
|
137
|
+
field from the form snapshot entirely — writes then always ride
|
|
138
|
+
`remote.settings.mutate()` to the Host document, so "is this page
|
|
139
|
+
loopback?" is no longer a question that generation can answer or needs.)
|
|
137
140
|
|
|
138
141
|
Value model. Namespace fields are the tails of the old localStorage keys
|
|
139
142
|
and are stored as the same strings, so the semantics (and any older
|
|
@@ -187,15 +190,17 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
187
190
|
floating element the user did not ask for. */
|
|
188
191
|
balanceCapsule: '0',
|
|
189
192
|
/* 渠道额度胶囊的右侧百分比读数选「已用」还是「剩余」——两个值都有真实语义,
|
|
190
|
-
不存在默认就错的答案,所以默认
|
|
191
|
-
|
|
193
|
+
不存在默认就错的答案,所以默认 remaining(与左侧主数字的剩余额度语义一致,
|
|
194
|
+
也与 features.md 的字段表一致)。仅影响 credits 模式的右侧百分比槽位。 */
|
|
192
195
|
creditDisplay: 'remaining',
|
|
193
196
|
/* 余额胶囊的停靠位 —— center(顶部居中)| left(靠左)| right(靠右)。
|
|
194
197
|
仅当胶囊开启时有意义:选择靠边停靠可让开侧边栏与顶部工具栏的交叠区。 */
|
|
195
198
|
balanceCapsulePosition: 'center',
|
|
196
199
|
/* 防遮挡的两个子开关 —— 侧边栏展开自动隐藏 / 指针靠近自动隐藏。
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
侧栏展开是主要遮挡源但属于「用户主动打开」的动作,默认关;指针靠近
|
|
201
|
+
则在用户不知情时收起内容,默认开。用户抱怨的遮挡由 hover-hide 兜底,
|
|
202
|
+
单独关掉其中一个的出口保留在这里(开关语义不一致是有意的:
|
|
203
|
+
sidebarHide 读 === '1',hoverHide 读 !== '0',见 11-prefs-fields.js)。 */
|
|
199
204
|
balanceCapsuleSidebarHide: '0',
|
|
200
205
|
balanceCapsuleHoverHide: '1',
|
|
201
206
|
/* 音频通知 (host half: lib/audio.js). Two live slots — the prompt that
|
|
@@ -305,13 +310,16 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
305
310
|
buggy build already wrote: 'contourAnim' -> 'contour-anim'. Derived from
|
|
306
311
|
the table (not from the schema, which cannot know it) so the two can never
|
|
307
312
|
drift, and only for fields that really do have a distinct legacy double:
|
|
308
|
-
every single-word field maps to itself and is skipped
|
|
313
|
+
every single-word field maps to itself and is skipped, and so is the bare
|
|
314
|
+
localStorage-era key ('contourRenderer') whose prefix-strip yields the
|
|
315
|
+
empty string — it never had a legacy spelling, and admitting '' as a
|
|
316
|
+
"legacy key" would put a nonsense entry in the map. */
|
|
309
317
|
const PREFS_FIELD_TO_LEGACY_KEY = (() => {
|
|
310
318
|
const m = {}
|
|
311
319
|
for (const field of Object.keys(PREFS_KEY_TO_FIELD)) {
|
|
312
320
|
const f = PREFS_KEY_TO_FIELD[field]
|
|
313
321
|
const legacy = field.slice(PREFS_NS.length + 1)
|
|
314
|
-
if (legacy !== f) m[f] = legacy
|
|
322
|
+
if (legacy !== '' && legacy !== f) m[f] = legacy
|
|
315
323
|
}
|
|
316
324
|
return m
|
|
317
325
|
})()
|
|
@@ -370,7 +378,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
370
378
|
const v = prefsGet(BALANCE_POSITION_KEY)
|
|
371
379
|
return BALANCE_POSITION_OPTIONS.indexOf(v) >= 0 ? v : 'center'
|
|
372
380
|
}
|
|
373
|
-
/*
|
|
381
|
+
/* 防遮挡子开关。两者默认值不同,读取语义也随之不同:sidebarHide 默认关
|
|
382
|
+
(读 === '1'),hoverHide 默认开(读 !== '0')——见 10-prefs-store.js 的注释。 */
|
|
374
383
|
const BALANCE_SIDEBAR_HIDE_KEY = 'balanceCapsuleSidebarHide'
|
|
375
384
|
const BALANCE_HOVER_HIDE_KEY = 'balanceCapsuleHoverHide'
|
|
376
385
|
const isBalanceSidebarHideOn = () => prefsGet(BALANCE_SIDEBAR_HIDE_KEY) === '1'
|
|
@@ -384,7 +393,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
384
393
|
so session credentials and the host-side badge cache (TTL 120s, failure
|
|
385
394
|
TTL 15s) come for free; the page only ever names the provider.
|
|
386
395
|
The id table IS the plugin's contract: usage.badge answers for these
|
|
387
|
-
|
|
396
|
+
thirteen, and anything else — notably the built-in DeepSeek models — keeps
|
|
388
397
|
the wallet display, so an absent plugin can never blank the capsule. */
|
|
389
398
|
const JET_HUB_RPC_SCOPE = '/api'
|
|
390
399
|
const JET_HUB_RPC_CHANNEL = 'jet-hub'
|
|
@@ -554,6 +563,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
554
563
|
writable, mode }. set() returns Promise<boolean>: false
|
|
555
564
|
means the Host refused or SKIPPED the write (the classic
|
|
556
565
|
case being memory mode on a non-loopback page).
|
|
566
|
+
0.2.1-rc.2 keeps this seam but DELETED `mode`: the
|
|
567
|
+
snapshot is { status, value, base, user, revision,
|
|
568
|
+
writable } and every accepted set() is a
|
|
569
|
+
remote.settings.mutate() against the Host document.
|
|
570
|
+
Treat a missing `mode` as durable; only an explicit
|
|
571
|
+
mode:'memory' (0.1.7 on a non-loopback page) refuses.
|
|
557
572
|
<=0.1.5-rc.2 binder = ctx.get('settingsScope') / ctx.settingsScope
|
|
558
573
|
binder.bind({ namespace, decode }) -> scope with the same
|
|
559
574
|
snapshot fields, and a fire-and-forget set().
|
|
@@ -732,10 +747,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
732
747
|
const dbg = (...a) => { try { if (typeof console !== 'undefined' && console.warn) console.warn('[dsh-theme-endfield:prefs]', ...a) } catch (e) { /* noop */ } }
|
|
733
748
|
|
|
734
749
|
/* --- Durable write gate -----------------------------------------------
|
|
735
|
-
A scope snapshot from @deepseek-ai/dsh-client-ui-settings carries
|
|
736
|
-
flags that must ALL hold before a scope.set can durably
|
|
737
|
-
|
|
738
|
-
'memory' (non-loopback) never persists.
|
|
750
|
+
A scope snapshot from @deepseek-ai/dsh-client-ui-settings carries the
|
|
751
|
+
flags below; the ones that must ALL hold before a scope.set can durably
|
|
752
|
+
land are:
|
|
739
753
|
status === 'ready' the Host's describe view actually SERVES this
|
|
740
754
|
namespace and a decoded value stands. It stays
|
|
741
755
|
'loading' before the first accepted section and
|
|
@@ -746,6 +760,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
746
760
|
a 'loading'/'unavailable' host scope reaches no
|
|
747
761
|
durable store.
|
|
748
762
|
writable=== true the Host document accepts writes.
|
|
763
|
+
mode === 'host' OPTIONAL. Loopback page syncing the Host
|
|
764
|
+
document; 'memory' (a non-loopback page) never
|
|
765
|
+
persists. @deepseek-ai/dsh-client-ui-settings
|
|
766
|
+
0.2.1-rc.2 DELETED this field (and the whole
|
|
767
|
+
`persistence` concept) along with the CSS
|
|
768
|
+
'memory' mode, so on that generation the
|
|
769
|
+
snapshot is only { status, value, base, user,
|
|
770
|
+
revision, writable } and a missing `mode` means
|
|
771
|
+
"no persistence policy to consult": every
|
|
772
|
+
write rides ctx.remote.settings.mutate() to the
|
|
773
|
+
Host document. Only a snapshot that explicitly
|
|
774
|
+
says mode:'memory' (0.1.7-rc.*, a non-loopback
|
|
775
|
+
page) may refuse the write; `undefined` must be
|
|
776
|
+
treated as durable, or a 0.2.1 page would hold
|
|
777
|
+
every edit forever and log exactly the
|
|
778
|
+
`mode= undefined` boot report this gate is
|
|
779
|
+
meant to explain.
|
|
749
780
|
Gating on snap.writable ALONE is the old bug: a host-mode describe view
|
|
750
781
|
answers writable=true even while this namespace is still unserved, so the
|
|
751
782
|
old code fired scope.set into a namespace the host had not registered,
|
|
@@ -759,7 +790,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
759
790
|
if (!scope) return null
|
|
760
791
|
try { return scope.getSnapshot() } catch (e) { return null }
|
|
761
792
|
}
|
|
762
|
-
|
|
793
|
+
/* `mode` is a 0.1.7-only snapshot field (see the gate note above): it is
|
|
794
|
+
absent on 0.2.1-rc.2+, where absence means "always durable", and only an
|
|
795
|
+
explicit mode:'memory' still vetoes a write. */
|
|
796
|
+
const prefsDurablyServed = (snap) => !!snap
|
|
797
|
+
&& (snap.mode === undefined || snap.mode === 'host')
|
|
798
|
+
&& snap.status === 'ready'
|
|
799
|
+
&& !!snap.writable
|
|
763
800
|
/* One field write through the bound transport, with one settlement contract
|
|
764
801
|
for both generations. The legacy scope.set() is fire-and-forget; a
|
|
765
802
|
ConfigForm's set() returns Promise<boolean>, where false means the Host
|
|
@@ -772,6 +809,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
772
809
|
promise resolves, so a synchronous dirty mark would make every toggle
|
|
773
810
|
emit a redundant second write. A late repair beats a duplicate write.
|
|
774
811
|
@returns true when the write was issued (not when it was accepted). */
|
|
812
|
+
/* Set by the dispose effect below. A scope.set() promise that settles after
|
|
813
|
+
the run ended must not re-arm the retry loop: every reader it would consult
|
|
814
|
+
is nulled, so the loop would tick its full budget against a dead run. */
|
|
815
|
+
let prefsDisposed = false
|
|
775
816
|
const prefsWriteField = (field, value) => {
|
|
776
817
|
const scope = prefsScope
|
|
777
818
|
if (!scope || typeof scope.set !== 'function') return false
|
|
@@ -783,6 +824,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
783
824
|
}
|
|
784
825
|
if (result && typeof result.then === 'function') {
|
|
785
826
|
result.then((ok) => {
|
|
827
|
+
if (prefsDisposed) return
|
|
786
828
|
if (ok === false) {
|
|
787
829
|
dbg('set REFUSED by the host', field, value)
|
|
788
830
|
prefsDirty.add(field)
|
|
@@ -791,6 +833,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
791
833
|
prefsDirty.delete(field)
|
|
792
834
|
}
|
|
793
835
|
}, (e) => {
|
|
836
|
+
if (prefsDisposed) return
|
|
794
837
|
dbg('set REJECTED', field, value, String(e && e.message || e))
|
|
795
838
|
prefsDirty.add(field)
|
|
796
839
|
})
|
|
@@ -858,6 +901,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
858
901
|
prefsRetryCount = 0
|
|
859
902
|
}
|
|
860
903
|
const prefsScheduleRetry = () => {
|
|
904
|
+
if (prefsDisposed) return
|
|
861
905
|
// Nothing held any more: no reason to keep ticking.
|
|
862
906
|
if (prefsDirty.size === 0) { prefsStopRetry(); return }
|
|
863
907
|
// A durably-served scope needs no timer — its subscription replays fast.
|
|
@@ -1168,8 +1212,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
1168
1212
|
it to guesswork. */
|
|
1169
1213
|
const prefsReportBoot = () => {
|
|
1170
1214
|
const snap = prefsSnapshotOf(prefsScope)
|
|
1171
|
-
|
|
1172
|
-
|
|
1215
|
+
// Health is the same predicate the write gate uses — `mode` is absent on
|
|
1216
|
+
// 0.2.1-rc.2+, so requiring mode:'host' here would print a boot report on
|
|
1217
|
+
// a perfectly working page (that is exactly the `mode= undefined` line
|
|
1218
|
+
// this report itself used to emit).
|
|
1219
|
+
const healthy = prefsDurablyServed(snap) && prefsDirty.size === 0
|
|
1173
1220
|
if (healthy) return
|
|
1174
1221
|
dbg('boot report (preferences did NOT reach a durable section):',
|
|
1175
1222
|
'boundNs=', prefsScopeNs, 'kind=', prefsScopeKind,
|
|
@@ -1235,6 +1282,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
1235
1282
|
// provider-owned, so leaving our listener behind would leak it into the
|
|
1236
1283
|
// next run of this plugin in the same page.
|
|
1237
1284
|
ctx.effect(() => () => {
|
|
1285
|
+
prefsDisposed = true
|
|
1238
1286
|
prefsListeners.length = 0
|
|
1239
1287
|
prefsReleaseSubscription()
|
|
1240
1288
|
prefsScope = null
|
|
@@ -1812,6 +1860,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
1812
1860
|
let contourTrailListening = false
|
|
1813
1861
|
let contourTrailPainted = false
|
|
1814
1862
|
let contourMotionQuery = null
|
|
1863
|
+
/* A DPR change (window moved between monitors, browser zoom) leaves the CSS
|
|
1864
|
+
geometry untouched, so the ResizeObserver never fires and the sheet keeps
|
|
1865
|
+
rasterizing at the old device ratio. The 'resolution' query fires exactly
|
|
1866
|
+
when devicePixelRatio changes. */
|
|
1867
|
+
let contourResolutionQuery = null
|
|
1815
1868
|
/* Last applied animation state. Declared HERE, above every function that touches
|
|
1816
1869
|
it, because contourTeardown() assigns it and is itself reachable from
|
|
1817
1870
|
unmount() — a `let` declared further down would still be in its temporal dead
|
|
@@ -1827,9 +1880,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
1827
1880
|
of one sheet roughly constant in frame AREA. The cap is set so every ordinary
|
|
1828
1881
|
window keeps the shipped 6px grid byte-for-byte (1440x900 is 36k cells,
|
|
1829
1882
|
1920x1080 is 58k); only genuinely large canvases coarsen, and the spline still
|
|
1830
|
-
rounds the coarser polyline at a 1px stroke.
|
|
1831
|
-
|
|
1832
|
-
|
|
1883
|
+
rounds the coarser polyline at a 1px stroke. (The worker's backing-store
|
|
1884
|
+
limit is a different axis — the canvas is frame-sized whatever the grid —
|
|
1885
|
+
and is handled where frames are sent; see contourWorkerDprFor below.) */
|
|
1833
1886
|
const CONTOUR_MAX_CELLS = 60000
|
|
1834
1887
|
/* Bump radius floor, in grid samples. The radius was `(0.05..0.14) * min(w, h)`,
|
|
1835
1888
|
which ties the terrain's feature size to the frame's SHORTER side: in a short
|
|
@@ -1867,6 +1920,25 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
1867
1920
|
while (step < 40 && cells(step) > CONTOUR_MAX_CELLS) step += 1
|
|
1868
1921
|
return step
|
|
1869
1922
|
}
|
|
1923
|
+
/* DEVICE-RATIO BUDGET for the worker renderer. The worker rejects any frame
|
|
1924
|
+
whose backing store would pass its 8M-pixel budget (src/contour/
|
|
1925
|
+
contour-worker.js), and a canvas at the frame's full device size blows past
|
|
1926
|
+
it on the common large/HiDPI viewports — 1080p@2x, 1440p@1.5x and 4K@1x are
|
|
1927
|
+
all ~8.3M device pixels, so the first frame threw 'invalid frame', latched
|
|
1928
|
+
the failure, and the advertised Worker/WebGL option never engaged for those
|
|
1929
|
+
users at all. The sampling step cannot help: the canvas is the whole frame
|
|
1930
|
+
at device resolution regardless of the grid. Scale the device ratio down to
|
|
1931
|
+
the budget instead of dooming the off-main-thread renderer — the canvas
|
|
1932
|
+
keeps its CSS size, and at these sizes the few percent of missing device
|
|
1933
|
+
pixels are invisible at a 1px stroke. Below the budget the ratio passes
|
|
1934
|
+
through unchanged, so every ordinary window is bit-identical. */
|
|
1935
|
+
const CONTOUR_WORKER_PIXEL_BUDGET = 8000000
|
|
1936
|
+
const contourWorkerDprFor = (w, h, dpr) => {
|
|
1937
|
+
/* 99% of the budget: the worker rounds each axis to whole device pixels,
|
|
1938
|
+
and the two roundings can push the product a fraction of a percent over. */
|
|
1939
|
+
const budget = Math.sqrt((CONTOUR_WORKER_PIXEL_BUDGET * 0.99) / (w * h))
|
|
1940
|
+
return budget < dpr ? Math.max(0.1, budget) : dpr
|
|
1941
|
+
}
|
|
1870
1942
|
/* VIEWPORT-ADAPTIVE DENSITY. The shipped look was tuned on a desktop frame:
|
|
1871
1943
|
20 levels over the +/-1.45 span and 22 gaussian bumps. Those are per-AREA
|
|
1872
1944
|
constants, but the sheet is judged per-VIEWPORT -- a phone-width frame
|
|
@@ -2933,7 +3005,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
2933
3005
|
const contourRefresh = (geometry) => {
|
|
2934
3006
|
if (contourWorker !== null) {
|
|
2935
3007
|
if (!contourGeom || document.hidden) return
|
|
2936
|
-
const ratio =
|
|
3008
|
+
const ratio = contourWorkerDprFor(contourGeom.w, contourGeom.h,
|
|
3009
|
+
Math.min(2, Math.max(.1, window.devicePixelRatio || 1)))
|
|
2937
3010
|
const pending = contourWorker.pending
|
|
2938
3011
|
contourWorker.pending = {w:contourGeom.w,h:contourGeom.h,dpr:ratio,phase:contourPhase,
|
|
2939
3012
|
color:contourStroke(),geometry:geometry || !!(pending && pending.geometry)}
|
|
@@ -3025,6 +3098,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3025
3098
|
contourSwitchSig = ''
|
|
3026
3099
|
contourApplySwitches()
|
|
3027
3100
|
}
|
|
3101
|
+
/* A DPR-only change re-rasterizes at the new ratio; a change landing during a
|
|
3102
|
+
scroll pause is remembered and applied by the resume path, exactly like a
|
|
3103
|
+
ResizeObserver callback. */
|
|
3104
|
+
const onContourResolutionChange = () => {
|
|
3105
|
+
if (contourHost === null) return
|
|
3106
|
+
if (contourScrollPaused) { contourResizePending = true; return }
|
|
3107
|
+
if (contourSizeTo(contourHost)) contourRefresh(true)
|
|
3108
|
+
}
|
|
3028
3109
|
const contourSizeTo = (host) => {
|
|
3029
3110
|
const r = host.getBoundingClientRect()
|
|
3030
3111
|
const w = Math.max(1, Math.round(r.width))
|
|
@@ -3130,6 +3211,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3130
3211
|
contourMotionQuery.removeEventListener('change', onContourEnvironmentChange)
|
|
3131
3212
|
}
|
|
3132
3213
|
contourMotionQuery = null
|
|
3214
|
+
if (contourResolutionQuery !== null && typeof contourResolutionQuery.removeEventListener === 'function') {
|
|
3215
|
+
contourResolutionQuery.removeEventListener('change', onContourResolutionChange)
|
|
3216
|
+
}
|
|
3217
|
+
contourResolutionQuery = null
|
|
3133
3218
|
contourDisposeWorker()
|
|
3134
3219
|
if (contourRo !== null) {
|
|
3135
3220
|
contourRo.disconnect()
|
|
@@ -3149,6 +3234,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3149
3234
|
landscape would visibly snap backwards before resuming. A fresh mount at
|
|
3150
3235
|
phase 0 has no such transient — which is exactly what this restores. */
|
|
3151
3236
|
contourPhase = 0
|
|
3237
|
+
/* A scroll pause engaged when the layer died must not outlive it: a remount
|
|
3238
|
+
would come up gated by contourWantsAnim's paused check with no scrollend
|
|
3239
|
+
ever coming to lift it (the scrolled element may itself be gone), so the
|
|
3240
|
+
sheet would render but never morph until the user scrolled again. The
|
|
3241
|
+
pending timer goes with the flags. */
|
|
3242
|
+
if (contourScrollTimer !== null && typeof clearTimeout === 'function') clearTimeout(contourScrollTimer)
|
|
3243
|
+
contourScrollTimer = null
|
|
3244
|
+
contourScrollPaused = false
|
|
3245
|
+
contourResizePending = false
|
|
3152
3246
|
// Nothing is drawn any more, so the next mount must re-apply the switch rather
|
|
3153
3247
|
// than trust a signature describing a canvas that no longer exists.
|
|
3154
3248
|
contourSwitchSig = ''
|
|
@@ -3228,6 +3322,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3228
3322
|
if (typeof contourMotionQuery.addEventListener === 'function') {
|
|
3229
3323
|
contourMotionQuery.addEventListener('change', onContourEnvironmentChange)
|
|
3230
3324
|
}
|
|
3325
|
+
contourResolutionQuery = window.matchMedia('(resolution)')
|
|
3326
|
+
if (typeof contourResolutionQuery.addEventListener === 'function') {
|
|
3327
|
+
contourResolutionQuery.addEventListener('change', onContourResolutionChange)
|
|
3328
|
+
}
|
|
3231
3329
|
}
|
|
3232
3330
|
contourStartWorker(line)
|
|
3233
3331
|
wrap.setAttribute('data-endfield-renderer', contourWorker ? 'starting' : 'main-canvas2d')
|
|
@@ -3287,7 +3385,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3287
3385
|
// Let the page observer declared above re-attach the layer as the app renders.
|
|
3288
3386
|
contourSyncHook = syncContour
|
|
3289
3387
|
/* BEGIN GENERATED CONTOUR WORKER */
|
|
3290
|
-
const CONTOUR_WORKER_SOURCE = "/* Dedicated contour worker. Kernel is extracted from client.js by the build script.\n * MIT; original terrain Copyright (c) 2026 ymh0000123. */\nconst CONTOUR_STEP = 6, CONTOUR_LEVELS = 20, CONTOUR_SPAN = 1.45\nconst CONTOUR_MIN_LEN = 40, CONTOUR_MIN_RING_BOX = 21\nconst CONTOUR_KEEP_LEN = CONTOUR_MIN_LEN * 1.35, CONTOUR_KEEP_RING = CONTOUR_MIN_RING_BOX * 1.5\nconst CONTOUR_MIN_CROSSINGS = 3\n/* The kernel's own constants. The build script extracts FUNCTIONS only, so anything\n contourBuild / contourBuildCandidate / contourDrawLines close over has to be mirrored\n here by hand: the grid cap and the bump-radius floor, plus the Chaikin pass budget\n `smoothPath` reads. A missing name is a ReferenceError inside the worker. */\nconst CONTOUR_MAX_CELLS = 60000, CONTOUR_MIN_BUMPSAMPLES = 4\nconst CONTOUR_SMOOTH_FULL = 8000, CONTOUR_SMOOTH_LIMIT = 20000\nconst CONTOUR_MIN_INK = 80\n/* Viewport-adaptive density (see src/client/20-contour.js): contourBuildCandidate,\n contourCoverageScore and contourExtract all call these, so the mirrored names\n have to come across or the worker throws at its first frame. The expressions are\n identical to the kernel's. */\nconst CONTOUR_DENSITY_ANCHOR = 640\nconst contourMinSide = (w, h) => {\n return Math.min(w, h)\n}\nconst contourDensityT = (w, h) => {\n return Math.max(0, Math.min(1,\n (contourMinSide(w, h) - 320) / (CONTOUR_DENSITY_ANCHOR - 320)))\n}\nconst contourLevelsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (CONTOUR_LEVELS - 10)))\n}\nconst contourBumpsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (22 - 10)))\n}\nlet contourSeed = 1, contourField = null, contourGeom = null, contourPaths = []\nlet contourLineCv = null, canvas = null, painter = null, stroke = 'rgba(0,0,0,0)', rasterizer = null\nconst contourStroke = () => stroke\nconst contourRng = (seed) => {\n let a = seed >>> 0\n return () => {\n a = (a + 0x6D2B79F5) >>> 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n }\nconst contourStepFor = (w, h) => {\n let step = CONTOUR_STEP\n const cells = (s) => (Math.ceil(w / s) + 1) * (Math.ceil(h / s) + 1)\n while (step < 40 && cells(step) > CONTOUR_MAX_CELLS) step += 1\n return step\n }\nconst contourBuild = (w, h) => {\n /* ACCEPT-OR-REROLL. A random layout is not automatically a GOOD layout, and\n this is the concrete lesson from making the seed per-load: the old fixed\n seed had silently guaranteed a well-spread field, and once real randomness\n arrived, some layouts left regions with no contour lines at all. Measured on\n the 8x5 coverage grid (\"near-empty\" = under 0.6% ink):\n independent uniform placement 5 failures in 12 seeds (up to 3 cells)\n stratified placement alone 6 failures in 24 seeds (down to 0.00%)\n Stratification fixes clumping but cannot fix the real mechanism: lines\n appear only where the field CROSSES one of the 21 fixed levels, so a region\n that is locally flat between two levels is blank no matter how the bumps\n sit. Forcing a gradient steep enough to guarantee a crossing per cell would\n take ~9.5 parallel lines across the width, which reads as stripes, not\n terrain -- so distorting the field is the wrong lever.\n Instead the candidate layout is CHECKED against the same invariant the test\n asserts, and rejected if it fails. Each attempt is cheap (one field\n evaluation on a coarse grid, no extraction, no drawing) and bounded, so the\n worst case is a handful of evaluations at mount/resize time only.\n\n The two halves are BOTH load-bearing, which was verified rather than\n assumed -- with the validator in place but placement reverted to uniform,\n 4 of 8 loads exhausted the 12-attempt cap and shipped a fallback layout\n (one run in four still rendered a blank cell). Stratification is what makes\n an acceptable layout the common case: mean 2.5 candidates, max 6, never at\n the cap. Validation is what makes it a guarantee. */\n const attempts = 32\n const step = contourStepFor(w, h)\n let best = null\n for (let attempt = 0; attempt < attempts; attempt++) {\n const cand = contourBuildCandidate(w, h, attempt, step)\n const score = contourCoverageScore(cand, w, h)\n /* Prefer more crossing bands, then more ink in the thinnest region. The\n tiebreak only matters if every attempt fails validation and the best of the\n 32 ships — measured at 0 of 200 seeds with the floor in place. */\n if (best === null || score.worst > best.score.worst\n || (score.worst === best.score.worst && score.ink > best.score.ink)) best = { cand, score }\n // Comfortably above the 0.6%-ink failure line, in field terms: every cell\n // must contain a spread of values wider than one level gap, so at least one\n // level is guaranteed to cross it.\n if (score.ok) break\n }\n contourField = best.cand.field\n contourGeom = { w, h, cols: best.cand.cols, rows: best.cand.rows, step }\n }\nconst contourBuildCandidate = (w, h, salt, step) => {\n const cols = Math.ceil(w / step) + 1\n const rows = Math.ceil(h / step) + 1\n /* Bump count adapts to the viewport -- see the note on VIEWPORT-ADAPTIVE\n DENSITY. At or above the 640px short side this is the shipped 22, and a\n narrow frame gets fewer islands so they stay separated instead of\n stacking into bullseyes. */\n const K = contourBumpsFor(w, h)\n const rnd = contourRng((contourSeed + salt * 0x9E3779B1) >>> 0)\n const m = Math.min(w, h)\n const bx = new Float32Array(K), by = new Float32Array(K)\n const ba = new Float32Array(K), bs = new Float32Array(K)\n const dx = new Float32Array(K), dy = new Float32Array(K)\n /* STRATIFIED placement, not independent uniform draws.\n\n Uniform sampling clumps: measured over 12 random seeds it left up to 3\n near-empty cells. Jittered grid instead -- the viewport is cut into a\n near-square lattice of at least K cells and each bump is placed at a random\n point inside its own cell. That keeps placement random while making a large\n empty patch geometrically impossible. Cells are ordered by a Fisher-Yates\n shuffle so the bump INDEX carries no positional bias: index drives\n amplitude, radius and drift below, and walking cells in raster order would\n correlate \"left side of the screen\" with \"first sizes drawn\".\n The lattice spans the same -0.1..1.1 over-scan as before, so islands are\n still cut by the viewport edges rather than all sitting fully inside. */\n const gx = Math.max(1, Math.round(Math.sqrt(K * (w / Math.max(1, h)))))\n const gy = Math.max(1, Math.ceil(K / gx))\n const cells = []\n for (let j = 0; j < gy; j++) for (let i = 0; i < gx; i++) cells.push(i + j * gx)\n for (let i = cells.length - 1; i > 0; i--) {\n const j = Math.floor(rnd() * (i + 1))\n const t = cells[i]; cells[i] = cells[j]; cells[j] = t\n }\n const spanX = 1.2 * w, spanY = 1.2 * h\n for (let k = 0; k < K; k++) {\n const cell = cells[k % cells.length]\n const ci = cell % gx\n const cj = Math.floor(cell / gx)\n // Random point inside this cell, in the over-scanned -0.1..1.1 space.\n bx[k] = -0.1 * w + ((ci + rnd()) / gx) * spanX\n by[k] = -0.1 * h + ((cj + rnd()) / gy) * spanY\n // Mixed sign gives peaks AND basins; equal signs would read as one blob.\n ba[k] = (rnd() < 0.5 ? -1 : 1) * (0.6 + rnd() * 0.9)\n /* Radius: relative to the frame for the shipped look, but never below\n CONTOUR_MIN_BUMPSAMPLES grid samples or the field aliases — see the note on\n that constant. This floor is the fix for the tangled/piled-up lines. */\n bs[k] = Math.max(step * CONTOUR_MIN_BUMPSAMPLES, (0.05 + rnd() * 0.09) * m)\n dx[k] = rnd() * 2 - 1\n dy[k] = rnd() * 2 - 1\n }\n /* Base undulation: three long, low-amplitude sine ridges spanning the whole\n viewport. Reason this exists, from reviewing the first render: a sum of\n gaussians decays to EXACTLY zero between islands, so the field there is\n perfectly flat, no level ever crosses it, and the result had large blank\n patches that exposed the construction. Real terrain has no such voids. The\n ridges are far too gentle to create islands of their own — they just tilt\n the whole sheet enough that contour lines keep running through the gaps,\n which is what turns isolated bullseyes into one continuous landscape. */\n const W2 = new Float32Array(9)\n for (let i = 0; i < 3; i++) {\n W2[i * 3] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, w) // x freq\n W2[i * 3 + 1] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, h) // y freq\n W2[i * 3 + 2] = rnd() * Math.PI * 2 // phase\n }\n const hCount = (cols - 1) * rows\n const eCount = hCount + cols * (rows - 1)\n const field = {\n cols, rows, step, K, bx, by, ba, bs, dx, dy, hCount, W2,\n F: new Float32Array(cols * rows),\n previous: new Float32Array(cols * rows),\n hasPrevious: false,\n smooth: new Float32Array(cols * rows),\n // Exact row-block bounds include both rows and the shared right vertex.\n // They reject empty regions without changing retained cells or path order.\n blockCols: Math.ceil((cols - 1) / 16),\n blockMin: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n blockMax: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n boundsReady: false,\n ex: new Float32Array(eCount),\n ey: new Float32Array(eCount),\n es: new Int32Array(eCount).fill(-1),\n n1: new Int32Array(eCount).fill(-1),\n n2: new Int32Array(eCount).fill(-1),\n seen: new Int32Array(eCount).fill(-1),\n touched: new Int32Array(eCount),\n seq: 0,\n }\n return { field, cols, rows }\n }\nconst contourCoverageScore = (cand, w, h) => {\n const f = cand.field\n // Evaluate at phase 0: the accepted layout must be sound as first painted.\n const prev = contourField\n contourField = f\n contourEvaluate(0)\n contourField = prev\n const { cols, rows, F } = f\n const GX = 8, GY = 5\n const span = CONTOUR_SPAN\n /* Judge against the density this viewport will actually DRAW, not the\n shipped constant: a narrow frame extracts fewer levels, so its per-quad\n incidence count is proportionally smaller. Scoring it on CONTOUR_LEVELS\n would demand 2x the ink of a 10-level sheet and push the re-roll loop to\n its cap on every small window. */\n const levelStep = (span * 2) / contourLevelsFor(w, h)\n let worst = Infinity\n let ok = true\n let ink = Infinity\n for (let gy = 0; gy < GY; gy++) {\n for (let gx = 0; gx < GX; gx++) {\n const i0 = Math.floor(gx * (cols - 1) / GX), i1 = Math.ceil((gx + 1) * (cols - 1) / GX)\n const j0 = Math.floor(gy * (rows - 1) / GY), j1 = Math.ceil((gy + 1) * (rows - 1) / GY)\n let mn = Infinity, mx = -Infinity\n /* The ink proxy is accumulated over the QUADS of this region, in the same\n walk that takes its min/max — see CONTOUR_MIN_INK. */\n let regionInk = 0\n for (let j = j0; j <= j1 && j < rows; j++) {\n const row = j * cols\n for (let i = i0; i <= i1 && i < cols; i++) {\n const v = F[row + i]\n if (v < mn) mn = v\n if (v > mx) mx = v\n if (j < j1 && j < rows - 1 && i < i1 && i < cols - 1) {\n const a = v, b = F[row + i + 1]\n const c = F[row + cols + i], d = F[row + cols + i + 1]\n let qmn = a < b ? a : b, qmx = a > b ? a : b\n if (c < qmn) qmn = c; if (c > qmx) qmx = c\n if (d < qmn) qmn = d; if (d > qmx) qmx = d\n if (qmn < -span) qmn = -span\n if (qmx > span) qmx = span\n if (qmx > qmn) regionInk += Math.floor(qmx / levelStep) - Math.ceil(qmn / levelStep) + 1\n }\n }\n }\n // Clamp to the drawn level range: values beyond +/-SPAN produce no lines.\n const lo = Math.max(mn, -span), hi = Math.min(mx, span)\n // How many level boundaries fall inside this cell's clamped range.\n const crossings = hi <= lo ? 0\n : Math.floor(hi / levelStep) - Math.ceil(lo / levelStep) + 1\n if (crossings < worst) worst = crossings\n if (crossings < CONTOUR_MIN_CROSSINGS) ok = false\n if (regionInk < ink) ink = regionInk\n if (regionInk < CONTOUR_MIN_INK) ok = false\n }\n }\n return { ok, worst, ink }\n }\nconst contourEvaluate = (phase) => {\n const f = contourField\n if (f !== null) f.boundsReady = false\n if (f === null) return\n const { cols, rows, step, K, bx, by, ba, bs, dx, dy, F, W2 } = f\n /* Seed the sheet with the base undulation instead of zero, so the gaps\n between islands still have a gradient for the levels to cross. Separable\n evaluation: sin(a+b) is expanded so the y term is computed once per row\n rather than once per cell, which keeps this pass cheap. */\n const BASE = 0.62\n for (let i = 0; i < 3; i++) {\n const fx = W2[i * 3], fy = W2[i * 3 + 1], ph = W2[i * 3 + 2] + phase * 0.11\n const amp = BASE / 3\n for (let j = 0; j < rows; j++) {\n const yb = fy * (j * step) + ph\n const sy = Math.sin(yb), cy2 = Math.cos(yb)\n const row = j * cols\n for (let c2 = 0; c2 < cols; c2++) {\n const xb = fx * (c2 * step)\n // sin(xb + yb) without a per-cell sin() of the sum\n const v = Math.sin(xb) * cy2 + Math.cos(xb) * sy\n if (i === 0) F[row + c2] = amp * v\n else F[row + c2] += amp * v\n }\n }\n }\n for (let k = 0; k < K; k++) {\n const s = bs[k]\n const amp = s * 0.55\n const cx = bx[k] + Math.sin(phase * dx[k] + k * 1.7) * amp\n const cy = by[k] + Math.cos(phase * dy[k] + k * 2.3) * amp\n const a = ba[k]\n const inv = 1 / (2 * s * s)\n const rad = 2.6 * s\n let i0 = Math.floor((cx - rad) / step)\n let i1 = Math.ceil((cx + rad) / step)\n let j0 = Math.floor((cy - rad) / step)\n let j1 = Math.ceil((cy + rad) / step)\n if (i0 < 0) i0 = 0\n if (j0 < 0) j0 = 0\n if (i1 > cols - 1) i1 = cols - 1\n if (j1 > rows - 1) j1 = rows - 1\n for (let j = j0; j <= j1; j++) {\n const ddy = j * step - cy\n const dy2 = ddy * ddy\n const row = j * cols\n for (let i = i0; i <= i1; i++) {\n const ddx = i * step - cx\n const q = (ddx * ddx + dy2) * inv\n if (q < 6.76) {\n let weight = Math.exp(-q)\n if (q > 4.8) {\n const t = (q - 4.8) / (6.76 - 4.8)\n const fade = 1 - t * t * (3 - 2 * t)\n weight *= fade\n }\n F[row + i] += a * weight\n }\n }\n }\n }\n const smooth = f.smooth\n for (let pass = 0; pass < 5; pass++) {\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n for (let i = 0; i < cols; i++) {\n const left = F[row + Math.max(0, i - 1)]\n const center = F[row + i]\n const right = F[row + Math.min(cols - 1, i + 1)]\n smooth[row + i] = (left + 2 * center + right) * 0.25\n }\n }\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n const up = Math.max(0, j - 1) * cols\n const down = Math.min(rows - 1, j + 1) * cols\n for (let i = 0; i < cols; i++) {\n F[row + i] = (smooth[up + i] + 2 * smooth[row + i] + smooth[down + i]) * 0.25\n }\n }\n }\n /* Track the field continuously between animation samples. Marching squares\n can change an entire path at once when a saddle crosses a level; blending\n the sampled field keeps that topology change from appearing as a twitch. */\n if (f.hasPrevious && f.previous !== undefined) {\n for (let i = 0; i < F.length; i++) {\n f.previous[i] = f.previous[i] * 0.65 + F[i] * 0.35\n F[i] = f.previous[i]\n }\n } else if (f.previous !== undefined) {\n f.previous.set(F)\n }\n f.hasPrevious = true\n }\nconst contourExtractLevel = (L, out) => {\n const f = contourField\n const { cols, rows, step, F, ex, ey, es, n1, n2, seen, touched, hCount } = f\n const st = ++f.seq\n let tn = 0\n const pt = (id, i0, j0, i1, j1) => {\n if (es[id] === st) return id\n const a = F[j0 * cols + i0]\n const b = F[j1 * cols + i1]\n let t = (L - a) / (b - a)\n if (!(t >= 0)) t = 0\n else if (t > 1) t = 1\n ex[id] = (i0 + (i1 - i0) * t) * step\n ey[id] = (j0 + (j1 - j0) * t) * step\n es[id] = st\n n1[id] = -1\n n2[id] = -1\n touched[tn++] = id\n return id\n }\n const link = (a, b) => {\n if (n1[a] < 0) n1[a] = b\n else if (n2[a] < 0) n2[a] = b\n if (n1[b] < 0) n1[b] = a\n else if (n2[b] < 0) n2[b] = a\n }\n for (let j = 0; j < rows - 1; j++) {\n const row = j * cols\n for (let i = 0; i < cols - 1; i++) {\n if (f.boundsReady && i % 16 === 0) {\n const block = j * f.blockCols + Math.floor(i / 16)\n if (L <= f.blockMin[block] || L > f.blockMax[block]) {\n i = Math.min(i + 16, cols - 1) - 1\n continue\n }\n }\n const p0 = row + i\n const p1 = p0 + 1\n const p3 = p0 + cols\n const p2 = p3 + 1\n const v0 = F[p0], v1 = F[p1], v2 = F[p2], v3 = F[p3]\n let mn = v0, mx = v0\n if (v1 < mn) mn = v1; else if (v1 > mx) mx = v1\n if (v2 < mn) mn = v2; else if (v2 > mx) mx = v2\n if (v3 < mn) mn = v3; else if (v3 > mx) mx = v3\n // Whole cell on one side of the level: nothing crosses it.\n if (L <= mn || L > mx) continue\n const idx = (v0 > L ? 1 : 0) | (v1 > L ? 2 : 0) | (v2 > L ? 4 : 0) | (v3 > L ? 8 : 0)\n const T = () => pt(j * (cols - 1) + i, i, j, i + 1, j)\n const B = () => pt((j + 1) * (cols - 1) + i, i, j + 1, i + 1, j + 1)\n const Le = () => pt(hCount + j * cols + i, i, j, i, j + 1)\n const Ri = () => pt(hCount + j * cols + i + 1, i + 1, j, i + 1, j + 1)\n switch (idx) {\n case 1: case 14: link(T(), Le()); break\n case 2: case 13: link(T(), Ri()); break\n case 3: case 12: link(Le(), Ri()); break\n case 4: case 11: link(Ri(), B()); break\n case 6: case 9: link(T(), B()); break\n case 7: case 8: link(Le(), B()); break\n // Ambiguous saddles use the bilinear asymptotic decider. The sign of\n // a*c-b*d selects whether the diagonal high/low regions are connected;\n // using the cell average alone is wrong when opposite corners differ in\n // magnitude and produces the long V-shaped joins seen in the render.\n case 5: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle > 0) { link(T(), Ri()); link(Le(), B()) }\n else { link(T(), Le()); link(Ri(), B()) }\n break\n }\n case 10: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle < 0) { link(T(), Le()); link(Ri(), B()) }\n else { link(T(), Ri()); link(Le(), B()) }\n break\n }\n }\n }\n }\n const walk = (start) => {\n const path = []\n let cur = start\n let prev = -1\n for (;;) {\n path.push(ex[cur], ey[cur])\n seen[cur] = st\n const a = n1[cur]\n const b = n2[cur]\n let nx = -1\n if (a >= 0 && a !== prev && seen[a] !== st) nx = a\n else if (b >= 0 && b !== prev && seen[b] !== st) nx = b\n if (nx < 0) {\n // Closed loop: step back onto the first point so the ring has no gap.\n if ((a === start || b === start) && path.length > 4) path.push(ex[start], ey[start])\n break\n }\n prev = cur\n cur = nx\n }\n return path\n }\n /* Reject debris before it reaches the draw list. Judged on the path's\n ON-CANVAS geometry, so an off-grid sliver with no visible pixels is\n dropped even when its raw length looks respectable. See the note on\n CONTOUR_MIN_LEN / CONTOUR_MIN_RING_BOX for the measurements behind both\n thresholds. */\n const W = contourGeom !== null ? contourGeom.w : 0\n const H = contourGeom !== null ? contourGeom.h : 0\n const keep = (p) => {\n if (p.length < 8) return false\n // Visible length, plus the bounding box of the part actually on screen.\n let vis = 0\n let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity\n let seenIn = false\n for (let k = 0; k < p.length; k += 2) {\n const x = p[k], y = p[k + 1]\n const inside = x >= 0 && x <= W && y >= 0 && y <= H\n if (inside) {\n seenIn = true\n if (x < minx) minx = x\n if (x > maxx) maxx = x\n if (y < miny) miny = y\n if (y > maxy) maxy = y\n }\n if (k >= 2) {\n const px2 = p[k - 2], py2 = p[k - 1]\n const prevIn = px2 >= 0 && px2 <= W && py2 >= 0 && py2 <= H\n if (inside && prevIn) {\n const dx = x - px2, dy = y - py2\n vis += Math.sqrt(dx * dx + dy * dy)\n }\n }\n }\n if (!seenIn) return false // entirely off-canvas: pure debris\n if (vis < CONTOUR_KEEP_LEN) return false\n /* A tiny CLOSED ring is an apex bullseye and reads as a dot. Open chains of\n the same extent are left alone: they are the visible corner of a stroke\n that continues off-canvas, and clipping one would punch a hole in a line\n the user can see running to the edge. */\n const gapx = p[0] - p[p.length - 2]\n const gapy = p[1] - p[p.length - 1]\n const closed = (gapx * gapx + gapy * gapy) < 4\n if (closed && (maxx - minx) < CONTOUR_KEEP_RING\n && (maxy - miny) < CONTOUR_KEEP_RING) return false\n return true\n }\n /* TANGENCY NEEDLES. Where a level runs nearly TANGENT to the field, the true\n isoline has a smooth, very high curvature tip. Marching squares interpolates\n linearly on a 10px grid, so it cannot represent that tip: it emits a hairpin\n that goes out and comes straight back, with a BASE (the gap between the\n apex's two neighbours) far narrower than the 1px stroke. Measured on the real\n output, worst case: apex 6.26px out from a base of 0.831px.\n\n At that width the outbound and return strokes paint the SAME pixels, so the\n pair does not read as a narrow valley — only the protruding whisker shows,\n which is precisely the \"irregular sharp angle\" in issue #3. Smoothing cannot\n help: the midpoint spline faithfully reproduces a feature that is genuinely\n in the geometry, so it has to be removed here, at the source.\n\n The whole hairpin is collapsed (see the note on the merge below). Both tests\n are required and were measured over 24 frames (152.6k vertices, 1.18Mpx of\n ink):\n base < 2px the stroke cannot resolve it (a 1px line is ~1px wide)\n turn > 90 it doubles back rather than merely turning a corner\n That is 0.7 vertices per frame and 0.0037% of total ink -- artifact removal,\n not thinning. Real narrow features are untouched: turns over 90 degrees have\n a median base of 4.03px, well clear of the cutoff, and the whole 8-12px base\n band (29562 vertices) has a p99 turn of only 21.7 degrees. */\n const deneedle = (p) => {\n const n = p.length / 2\n if (n < 4) return p\n /* Scan first and return the ORIGINAL array when there is nothing to do, so\n the overwhelmingly common path allocates nothing at 24fps. */\n let found = false\n for (let k = 1; k < n - 1; k++) {\n const bx = p[(k + 1) * 2] - p[(k - 1) * 2]\n const by = p[(k + 1) * 2 + 1] - p[(k - 1) * 2 + 1]\n if (bx * bx + by * by >= 4) continue // base >= 2px: keep\n const ax = p[k * 2] - p[(k - 1) * 2]\n const ay = p[k * 2 + 1] - p[(k - 1) * 2 + 1]\n const cx = p[(k + 1) * 2] - p[k * 2]\n const cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n // turn > 90 degrees <=> the two segment vectors point against each other.\n if (ax * cx + ay * cy < 0) { found = true; break }\n }\n if (!found) return p\n const gx = p[0] - p[(n - 1) * 2]\n const gy = p[1] - p[(n - 1) * 2 + 1]\n const closed = (gx * gx + gy * gy) < 4\n /* COLLAPSE THE WHOLE NEEDLE, not just its tip. Dropping the apex alone leaves\n the base itself as a real segment, and that was measured to be worse than\n the disease: a 0.831px stub inherits the reversal as TWO ~78-degree turns\n (10.20 -> 0.83 -> 10.25px). The apex AND its far neighbour are therefore\n both consumed, and the surviving previous vertex is pulled onto the base\n midpoint -- a sub-pixel move (half of at most 2px) that no 1px stroke can\n show, leaving one smooth vertex where the hairpin was.\n Each test uses the SURVIVING previous vertex, so a run of needles collapses\n progressively instead of each test being fooled by a neighbour that is\n itself about to be consumed. */\n const q = [p[0], p[1]]\n let k = 1\n while (k < n - 1) {\n const px = q[q.length - 2], py = q[q.length - 1]\n const bx = p[(k + 1) * 2] - px, by = p[(k + 1) * 2 + 1] - py\n if (bx * bx + by * by < 4) {\n const ax = p[k * 2] - px, ay = p[k * 2 + 1] - py\n const cx = p[(k + 1) * 2] - p[k * 2], cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n if (ax * cx + ay * cy < 0) {\n if (k + 1 < n - 1) {\n q[q.length - 2] = (px + p[(k + 1) * 2]) / 2\n q[q.length - 1] = (py + p[(k + 1) * 2 + 1]) / 2\n k += 2\n continue\n }\n // The far neighbour is the final vertex, which must survive to keep an\n // endpoint (or a ring's closure) intact: consume only the apex.\n k += 1\n continue\n }\n }\n q.push(p[k * 2], p[k * 2 + 1])\n k += 1\n }\n q.push(p[(n - 1) * 2], p[(n - 1) * 2 + 1])\n /* A ring is closed by REPEATING its start vertex, and the merge above may have\n nudged that start. Re-anchor the repeat so the ring stays exactly closed and\n both keep() and the cyclic draw path still classify it as one. */\n if (closed) {\n q[q.length - 2] = q[0]\n q[q.length - 1] = q[1]\n }\n return q\n }\n // Open chains first (they have a free end), then whatever remains is a loop.\n // Doing it in this order stops a ring being entered mid-way and split in two.\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st && n2[id] < 0) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n }\nconst contourExtract = (phase, trailPoints, trailNow) => {\n if (contourField === null) return\n contourEvaluate(phase)\n if (trailPoints && trailPoints.length) contourOverlayTrail(contourField, trailPoints, trailNow)\n const f = contourField\n // One O(cells) range pass is shared by all 21 extraction levels. Scratch\n // survives across frames; no resolution, smoothing or density is reduced.\n for (let j = 0; j < f.rows - 1; j++) {\n const row = j * f.cols\n for (let block = 0; block < f.blockCols; block++) {\n const begin = block * 16\n const end = Math.min(begin + 16, f.cols - 1)\n let min = Infinity, max = -Infinity\n for (let i = begin; i <= end; i++) {\n const a = f.F[row + i], b = f.F[row + f.cols + i]\n if (a < min) min = a\n if (a > max) max = a\n if (b < min) min = b\n if (b > max) max = b\n }\n const k = j * f.blockCols + block\n f.blockMin[k] = min\n f.blockMax[k] = max\n }\n }\n f.boundsReady = true\n contourPaths = []\n const span = CONTOUR_SPAN\n /* Level count follows the viewport: the same VIEWPORT-ADAPTIVE DENSITY\n contourBuildCandidate() laid the bumps out with. At or above the 640px\n short side this is exactly the shipped loop, so desktop output is\n unchanged. */\n const levels = contourLevelsFor(contourGeom.w, contourGeom.h)\n const stepL = (span * 2) / levels\n for (let n = 0; n <= levels; n++) {\n contourExtractLevel(-span + n * stepL, contourPaths)\n }\n }\nconst contourDrawLines = () => {\n if (contourLineCv === null || contourGeom === null) return\n const ctx = contourLineCv.getContext('2d')\n if (!ctx) return\n const { w, h } = contourGeom\n /* Geometry stays in CSS px; scale the context to the backing store that\n contourSizeTo sized at (capped) devicePixelRatio, so strokes rasterise\n at device resolution instead of being upsampled into blur on HiDPI\n screens. Derived from the canvas itself, and skipped entirely at a 1x\n store or when the context has no setTransform (the spliced-in test\n harnesses), so a 1x render is byte-identical to before. */\n const scale = (w > 0 && typeof contourLineCv.width === 'number' && contourLineCv.width > 0 && contourLineCv.width !== w)\n ? contourLineCv.width / w : 1\n if (scale !== 1 && typeof ctx.setTransform === 'function') ctx.setTransform(scale, 0, 0, scale, 0, 0)\n ctx.clearRect(0, 0, w, h)\n ctx.strokeStyle = contourStroke()\n ctx.lineWidth = 1\n ctx.lineJoin = 'round'\n /* Marching squares emits one vertex per grid-cell edge. Three Chaikin passes\n cut local corners before the clamped cubic B-spline rounds broad bends.\n This reduces angularity without changing the field or adding another\n extraction pass. Open endpoints remain fixed; closed rings wrap cyclically.\n\n PASS COUNT IS SIZE-ADAPTIVE: see CONTOUR_SMOOTH_FULL. Every ordinary window\n keeps three passes; a bigger sheet drops to two and then one. */\n const smoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) return source\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n /* Flat scratch throughout: the previous shape built one [x, y] array per\n point PER PASS — ~14 small arrays per source vertex, ~65k per sheet at\n 1440x900 — which is pure GC inside the animation frame on both painters. */\n let points = closed ? source.slice(0, source.length - 2) : source.slice()\n const passes = count <= CONTOUR_SMOOTH_FULL ? 3 : (count <= CONTOUR_SMOOTH_LIMIT ? 2 : 1)\n for (let pass = 0; pass < passes; pass++) {\n const n = points.length / 2\n const limit = closed ? n : n - 1\n const next = new Array(n * 4)\n let w2 = 0\n if (!closed) { next[w2++] = points[0]; next[w2++] = points[1] }\n for (let k = 0; k < limit; k++) {\n const a = k * 2\n const b = ((k + 1) % n) * 2\n const ax = points[a], ay = points[a + 1]\n const bx = points[b], by = points[b + 1]\n next[w2++] = ax * 0.75 + bx * 0.25\n next[w2++] = ay * 0.75 + by * 0.25\n next[w2++] = ax * 0.25 + bx * 0.75\n next[w2++] = ay * 0.25 + by * 0.75\n }\n if (!closed) { next[w2++] = points[points.length - 2]; next[w2++] = points[points.length - 1] }\n points = w2 === next.length ? next : next.slice(0, w2)\n }\n if (!closed) return points\n // Re-anchor the repeated start vertex so the ring stays exactly closed.\n const out = points.slice()\n out.push(out[0], out[1])\n return out\n }\n /* Chaikin removes local grid noise. A constrained Catmull-Rom cubic then\n gives each join one shared tangent. The handle cap prevents overshoot at\n narrow saddles while the larger tangent factor removes long rounded-polygon\n bends that remain visible with midpoint quadratics. */\n const drawSmoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) {\n ctx.moveTo(source[0], source[1])\n for (let k = 2; k < source.length; k += 2) ctx.lineTo(source[k], source[k + 1])\n return\n }\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n const limit = closed ? count - 1 : count\n const at = (index) => {\n const k = closed\n ? (index + limit) % limit\n : Math.max(0, Math.min(limit - 1, index))\n return k * 2\n }\n /* Tangents from scalars, with squared lengths wherever the comparison allows\n it. The previous shape allocated eight [x, y] arrays and ran up to six\n Math.hypot per SEGMENT — tens of thousands of segments per frame, on both\n the WebGL painter and the canvas fallback. Math.sqrt replaces hypot because\n every argument here is a pixel delta, far from hypot's overflow range. */\n const tangentAt = (index, out) => {\n const i = at(index)\n const p = at(index - 1)\n const n = at(index + 1)\n const cx = source[i], cy = source[i + 1]\n const inX = cx - source[p], inY = cy - source[p + 1]\n const outX = source[n] - cx, outY = source[n + 1] - cy\n let tx, ty, cap\n if (!closed && index === 0) {\n tx = outX * 0.4\n ty = outY * 0.4\n cap = Math.sqrt(outX * outX + outY * outY) * 0.55\n } else if (!closed && index === limit - 1) {\n tx = inX * 0.4\n ty = inY * 0.4\n cap = Math.sqrt(inX * inX + inY * inY) * 0.55\n } else {\n tx = (source[n] - source[p]) * 0.32\n ty = (source[n + 1] - source[p + 1]) * 0.32\n cap = Math.sqrt(Math.min(inX * inX + inY * inY, outX * outX + outY * outY)) * 0.62\n }\n const len2 = tx * tx + ty * ty\n if (len2 > cap * cap && len2 > 0) {\n const s = cap / Math.sqrt(len2)\n tx *= s\n ty *= s\n }\n out[0] = tx\n out[1] = ty\n }\n ctx.moveTo(source[0], source[1])\n const segments = closed ? limit : limit - 1\n const t0 = [0, 0]\n const t1 = [0, 0]\n for (let k = 0; k < segments; k++) {\n const s = at(k)\n const e = at(k + 1)\n tangentAt(k, t0)\n tangentAt(k + 1, t1)\n ctx.bezierCurveTo(\n source[s] + t0[0], source[s + 1] + t0[1],\n source[e] - t1[0], source[e + 1] - t1[1],\n source[e], source[e + 1],\n )\n }\n /* Mark a ring as a RING. The cyclic tangents above already make the seam C1\n and the final span already lands exactly on the start point, so this adds\n no geometry — but without it the canvas treats the path as open and butts\n two caps together at the seam instead of joining them, which is defect (2)\n of issue #3. contour-cusps.test.js guards this. */\n if (closed) ctx.closePath()\n }\n const isRing = (p) => {\n const n = p.length - 2\n return n >= 2 && (p[0] - p[n]) ** 2 + (p[1] - p[n + 1]) ** 2 < 4\n }\n /* ONE STROKE, RINGS FIRST — and the ORDER matters for the canvas fallback, not\n for looks. `closePath()` on a real 2D context finalises the current subpath by\n splicing it into the path accumulated so far, so its cost grows with\n everything already in that path (measured on the canvas fallback: 18 calls per\n sheet averaging 0.278 ms, max 1.2 ms, ~16% of the frame). Drawing the rings\n while the path still holds only other rings bounds every splice to ring\n geometry. Output is unchanged either way: canvas stroke coverage is a union,\n and the WebGL painter blends with MAX (commutative), so subpath order cannot\n affect the result. Stroking each path separately would also fix the cost but\n double-composites overlapping strokes and visibly darkens every crossing —\n the very \"piled-up lines\" look this avoids. */\n ctx.beginPath()\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (isRing(p)) drawSmoothPath(smoothPath(p))\n }\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (!isRing(p)) drawSmoothPath(smoothPath(p))\n }\n ctx.stroke()\n }\n/* MIT; contributed by higekibaka. GPU stroke painter from Endfield Glass. */\nconst CURVE_TOLERANCE_PX = 0.04;\nconst STRIDE = 6;\nclass StrokeSegments {\n data = new Float32Array(4096 * STRIDE);\n used = 0;\n tolerance = CURVE_TOLERANCE_PX;\n x = 0;\n y = 0;\n startX = 0;\n startY = 0;\n first = 0;\n reset() {\n this.used = 0;\n this.first = 0;\n }\n moveTo(x, y) {\n this.x = this.startX = x;\n this.y = this.startY = y;\n this.first = this.used;\n }\n lineTo(x, y) {\n if (x === this.x && y === this.y) return;\n if (this.used + STRIDE > this.data.length) {\n const next = new Float32Array(this.data.length * 2);\n next.set(this.data);\n this.data = next;\n }\n const i = this.used;\n this.data[i] = this.x;\n this.data[i + 1] = this.y;\n this.data[i + 2] = x;\n this.data[i + 3] = y;\n this.data[i + 4] = i === this.first ? 1 : 0;\n this.data[i + 5] = 1;\n if (i > this.first) this.data[i - 1] = 0;\n this.used += STRIDE;\n this.x = x;\n this.y = y;\n }\n closePath() {\n this.lineTo(this.startX, this.startY);\n if (this.used > this.first) {\n this.data[this.first + 4] = 0;\n this.data[this.used - 1] = 0;\n }\n }\n bezierCurveTo(ax, ay, bx, by, x, y) {\n this.cubic(this.x, this.y, ax, ay, bx, by, x, y, 0);\n }\n cubic(x0, y0, x1, y1, x2, y2, x3, y3, depth) {\n const dx = x3 - x0, dy = y3 - y0, length2 = dx * dx + dy * dy;\n const tolerance2 = this.tolerance * this.tolerance;\n const t1 = length2 > 0 ? Math.max(0, Math.min(1, ((x1 - x0) * dx + (y1 - y0) * dy) / length2)) : 0;\n const t2 = length2 > 0 ? Math.max(0, Math.min(1, ((x2 - x0) * dx + (y2 - y0) * dy) / length2)) : 0;\n const e1x = x1 - x0 - t1 * dx, e1y = y1 - y0 - t1 * dy;\n const e2x = x2 - x0 - t2 * dx, e2y = y2 - y0 - t2 * dy;\n if (e1x * e1x + e1y * e1y <= tolerance2 && e2x * e2x + e2y * e2y <= tolerance2 || depth >= 16) {\n this.lineTo(x3, y3);\n return;\n }\n const a = (x0 + x1) / 2, b = (y0 + y1) / 2, c = (x1 + x2) / 2, d = (y1 + y2) / 2;\n const e = (x2 + x3) / 2, f = (y2 + y3) / 2, g = (a + c) / 2, h = (b + d) / 2;\n const i = (c + e) / 2, j = (d + f) / 2, k = (g + i) / 2, l = (h + j) / 2;\n this.cubic(x0, y0, a, b, g, h, k, l, depth + 1);\n this.cubic(k, l, i, j, e, f, x3, y3, depth + 1);\n }\n}\nfunction parseStrokeColor(value) {\n const m = value.match(/^rgba?\\(\\s*([\\d.]+)\\s*,\\s*([\\d.]+)\\s*,\\s*([\\d.]+)(?:\\s*,\\s*([\\d.]+))?\\s*\\)$/);\n if (!m) return null;\n const values = [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255, m[4] === void 0 ? 1 : Number(m[4])];\n return values.every((v) => Number.isFinite(v) && v >= 0 && v <= 1) ? values : null;\n}\nconst VERTEX = `#version 300 es\nprecision highp float;\nlayout(location=0) in vec4 segment;\nlayout(location=1) in vec2 caps;\nuniform vec2 viewportSize;\nuniform vec2 scale;\nuniform float width;\nout vec2 local;\nflat out float segmentLength;\nflat out float radius;\nflat out vec2 endCaps;\nvoid main() {\n vec2 a = segment.xy * scale, b = segment.zw * scale;\n vec2 delta = b-a;\n segmentLength = max(length(delta), 0.000001);\n vec2 tangent = delta / segmentLength;\n vec2 normal = vec2(-tangent.y, tangent.x);\n radius = 0.5 * width * scale.x * scale.y * length(segment.zw-segment.xy) / segmentLength;\n float margin = radius + 1.0;\n // Two triangles per segment, generated without a second vertex buffer.\n vec2 corners[6] = vec2[6](vec2(0,-1),vec2(1,-1),vec2(0,1),vec2(0,1),vec2(1,-1),vec2(1,1));\n vec2 corner = corners[gl_VertexID];\n local = vec2(mix(-margin, segmentLength+margin, corner.x), corner.y * margin);\n vec2 pixel = a + tangent * local.x + normal * local.y;\n gl_Position = vec4(pixel.x/viewportSize.x*2.0-1.0, 1.0-pixel.y/viewportSize.y*2.0, 0, 1);\n endCaps = caps;\n}`;\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 local;\nflat in float segmentLength;\nflat in float radius;\nflat in vec2 endCaps;\nuniform vec4 color;\nout vec4 outputColor;\nvoid main() {\n vec2 nearest = vec2(clamp(local.x, 0.0, segmentLength), 0);\n float distance = length(local-nearest)-radius;\n if (endCaps.x > 0.5) distance = max(distance, -local.x);\n if (endCaps.y > 0.5) distance = max(distance, local.x-segmentLength);\n float coverage = clamp(0.5-distance, 0.0, 1.0);\n float alpha = color.a * coverage;\n outputColor = vec4(color.rgb * alpha, alpha);\n}`;\nfunction createWebGLContourContext(canvas, onContextLost) {\n const gl = canvas.getContext(\"webgl2\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n preserveDrawingBuffer: false\n });\n if (!gl) return null;\n let program = null;\n let buffer = null;\n let vao = null;\n const shaders = [];\n const events = canvas;\n const lost = () => onContextLost?.();\n const releaseContext = gl.getExtension(\"WEBGL_lose_context\");\n const release = () => {\n events.removeEventListener?.(\"webglcontextlost\", lost);\n if (buffer) gl.deleteBuffer(buffer);\n if (vao) gl.deleteVertexArray(vao);\n if (program) gl.deleteProgram(program);\n for (const shader of shaders) gl.deleteShader(shader);\n buffer = null;\n vao = null;\n program = null;\n shaders.length = 0;\n releaseContext?.loseContext();\n };\n try {\n const compile = (type, source) => {\n const shader = gl.createShader(type);\n if (!shader) throw new Error(\"WebGL shader allocation failed\");\n shaders.push(shader);\n gl.shaderSource(shader, source);\n gl.compileShader(shader);\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(\"Contour shader: \" + gl.getShaderInfoLog(shader));\n return shader;\n };\n const vs = compile(gl.VERTEX_SHADER, VERTEX), fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);\n program = gl.createProgram();\n buffer = gl.createBuffer();\n vao = gl.createVertexArray();\n if (!program || !buffer || !vao) throw new Error(\"WebGL allocation failed\");\n gl.attachShader(program, vs);\n gl.attachShader(program, fs);\n gl.linkProgram(program);\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(\"Contour shader link: \" + gl.getProgramInfoLog(program));\n gl.useProgram(program);\n gl.bindVertexArray(vao);\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n gl.enableVertexAttribArray(0);\n gl.vertexAttribPointer(0, 4, gl.FLOAT, false, STRIDE * 4, 0);\n gl.vertexAttribDivisor(0, 1);\n gl.enableVertexAttribArray(1);\n gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 16);\n gl.vertexAttribDivisor(1, 1);\n gl.disable(gl.DEPTH_TEST);\n gl.disable(gl.CULL_FACE);\n gl.disable(gl.DITHER);\n gl.enable(gl.BLEND);\n gl.blendEquation(gl.MAX);\n gl.blendFunc(gl.ONE, gl.ONE);\n const viewport = gl.getUniformLocation(program, \"viewportSize\");\n const scaling = gl.getUniformLocation(program, \"scale\");\n const color = gl.getUniformLocation(program, \"color\");\n const width = gl.getUniformLocation(program, \"width\");\n const segments = new StrokeSegments();\n let sx = 1, sy = 1, capacity = 0, disposed = false;\n const context = {\n strokeStyle: \"rgba(16,17,16,0.16)\",\n lineWidth: 1,\n lineJoin: \"round\",\n setTransform(a, b, c, d, e, f) {\n if (b !== 0 || c !== 0 || e !== 0 || f !== 0 || a <= 0 || d <= 0) throw new Error(\"Unsupported contour transform\");\n sx = a;\n sy = d;\n segments.tolerance = CURVE_TOLERANCE_PX / Math.max(sx, sy);\n },\n clearRect() {\n if (disposed || gl.isContextLost()) throw new Error(\"Contour WebGL context lost\");\n gl.viewport(0, 0, canvas.width, canvas.height);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n },\n beginPath: () => segments.reset(),\n moveTo: (x, y) => segments.moveTo(x, y),\n lineTo: (x, y) => segments.lineTo(x, y),\n bezierCurveTo: (a, b, c, d, e, f) => segments.bezierCurveTo(a, b, c, d, e, f),\n closePath: () => segments.closePath(),\n stroke() {\n const rgba = parseStrokeColor(context.strokeStyle);\n if (!rgba) throw new Error(\"Unsupported contour stroke color\");\n if (segments.used === 0) {\n gl.flush();\n return;\n }\n gl.uniform2f(viewport, canvas.width, canvas.height);\n gl.uniform2f(scaling, sx, sy);\n gl.uniform4fv(color, rgba);\n gl.uniform1f(width, context.lineWidth);\n if (capacity < segments.data.byteLength) {\n capacity = segments.data.byteLength;\n gl.bufferData(gl.ARRAY_BUFFER, capacity, gl.STREAM_DRAW);\n }\n gl.bufferSubData(gl.ARRAY_BUFFER, 0, segments.data, 0, segments.used);\n gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, segments.used / STRIDE);\n gl.flush();\n },\n dispose() {\n if (disposed) return;\n disposed = true;\n segments.data = new Float32Array(0);\n segments.used = 0;\n release();\n }\n };\n events.addEventListener?.(\"webglcontextlost\", lost);\n return context;\n } catch (error) {\n release();\n throw error;\n }\n}\n\nself.onmessage = ({data}) => {\n try {\n if (data.type === 'init') {\n if (canvas !== null) throw new Error('duplicate worker init')\n canvas = data.canvas; contourSeed = data.seed\n painter = createWebGLContourContext(canvas, () => self.postMessage({type:'error',message:'WebGL context lost'}))\n rasterizer = painter ? 'worker-webgl2' : 'worker-canvas2d'\n if (!painter) painter = canvas.getContext('2d', {willReadFrequently:true})\n if (!painter) throw new Error('worker canvas unavailable')\n contourLineCv = {getContext:()=>painter, get width(){return canvas.width},get height(){return canvas.height}}\n self.postMessage({type:'initialized',rasterizer})\n } else if (data.type === 'frame') {\n if (!canvas || !painter) throw new Error('worker not initialized')\n const {w,h,dpr,phase,geometry,color,seq}=data\n if (![w,h,dpr,phase,seq].every(Number.isFinite) || w<1 || h<1 || dpr<=0 || dpr>2\n || Math.round(w*dpr)*Math.round(h*dpr)>8000000 || !/^#[0-9a-f]{8}$/i.test(color)) throw new Error('invalid frame')\n const resized = !contourGeom || contourGeom.w!==w || contourGeom.h!==h\n if (resized) contourBuild(w,h)\n const width=Math.round(w*dpr),height=Math.round(h*dpr)\n if(canvas.width!==width)canvas.width=width\n if(canvas.height!==height)canvas.height=height\n const c=color.slice(1)\n stroke=`rgba(${parseInt(c.slice(0,2),16)},${parseInt(c.slice(2,4),16)},${parseInt(c.slice(4,6),16)},${parseInt(c.slice(6,8),16)/255})`\n if(geometry || resized)contourExtract(phase)\n painter.setTransform(1,0,0,1,0,0)\n contourDrawLines()\n self.postMessage({type:'painted',seq,rasterizer})\n } else if (data.type === 'dispose') {\n if(painter && painter.dispose)painter.dispose()\n contourField=null;contourPaths=[];painter=null;canvas=null\n self.close()\n }\n } catch(error) { self.postMessage({type:'error',message:String(error.message || error)}) }\n}\nself.postMessage({type:'ready'})\n"
|
|
3388
|
+
const CONTOUR_WORKER_SOURCE = "/* Dedicated contour worker. Kernel is extracted from client.js by the build script.\n * MIT; original terrain Copyright (c) 2026 ymh0000123. */\nconst CONTOUR_STEP = 6, CONTOUR_LEVELS = 20, CONTOUR_SPAN = 1.45\nconst CONTOUR_MIN_LEN = 40, CONTOUR_MIN_RING_BOX = 21\nconst CONTOUR_KEEP_LEN = CONTOUR_MIN_LEN * 1.35, CONTOUR_KEEP_RING = CONTOUR_MIN_RING_BOX * 1.5\nconst CONTOUR_MIN_CROSSINGS = 3\n/* The kernel's own constants. The build script extracts FUNCTIONS only, so anything\n contourBuild / contourBuildCandidate / contourDrawLines close over has to be mirrored\n here by hand: the grid cap and the bump-radius floor, plus the Chaikin pass budget\n `smoothPath` reads. A missing name is a ReferenceError inside the worker. */\nconst CONTOUR_MAX_CELLS = 60000, CONTOUR_MIN_BUMPSAMPLES = 4\nconst CONTOUR_SMOOTH_FULL = 8000, CONTOUR_SMOOTH_LIMIT = 20000\nconst CONTOUR_MIN_INK = 80\n/* Viewport-adaptive density (see src/client/20-contour.js): contourBuildCandidate,\n contourCoverageScore and contourExtract all call these, so the mirrored names\n have to come across or the worker throws at its first frame. The expressions are\n identical to the kernel's. */\nconst CONTOUR_DENSITY_ANCHOR = 640\nconst contourMinSide = (w, h) => {\n return Math.min(w, h)\n}\nconst contourDensityT = (w, h) => {\n return Math.max(0, Math.min(1,\n (contourMinSide(w, h) - 320) / (CONTOUR_DENSITY_ANCHOR - 320)))\n}\nconst contourLevelsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (CONTOUR_LEVELS - 10)))\n}\nconst contourBumpsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (22 - 10)))\n}\nlet contourSeed = 1, contourField = null, contourGeom = null, contourPaths = []\nlet contourLineCv = null, canvas = null, painter = null, stroke = 'rgba(0,0,0,0)', rasterizer = null\nconst contourStroke = () => stroke\nconst contourRng = (seed) => {\n let a = seed >>> 0\n return () => {\n a = (a + 0x6D2B79F5) >>> 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n }\nconst contourStepFor = (w, h) => {\n let step = CONTOUR_STEP\n const cells = (s) => (Math.ceil(w / s) + 1) * (Math.ceil(h / s) + 1)\n while (step < 40 && cells(step) > CONTOUR_MAX_CELLS) step += 1\n return step\n }\nconst contourOverlayTrail = (field, points, now) => {\n const { cols, rows, step, F } = field\n // A smooth Gaussian is added AFTER the ambient smoothing/EMA. It never\n // enters previous, so it responds immediately and leaves no temporal ghost.\n // 28 CSS px approximates the original 26 px kernel after spatial smoothing.\n const sigma = 28, radius = sigma * Math.sqrt(2 * 6.76)\n const inv = 1 / (2 * sigma * sigma)\n for (let rank = 0; rank < points.length; rank++) {\n const point = points[points.length - 1 - rank]\n const age = now - point.t\n if (age < 0 || age > 2700) continue\n const quota = rank === 0 ? .9 : .6 * .2 * Math.pow(.8, rank - 1)\n const amplitude = quota * Math.exp(-age / 900)\n const i0 = Math.max(0, Math.floor((point.x - radius) / step))\n const i1 = Math.min(cols - 1, Math.ceil((point.x + radius) / step))\n const j0 = Math.max(0, Math.floor((point.y - radius) / step))\n const j1 = Math.min(rows - 1, Math.ceil((point.y + radius) / step))\n for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {\n const q = ((i * step - point.x) ** 2 + (j * step - point.y) ** 2) * inv\n if (q >= 6.76) continue\n const t = Math.max(0, (q - 4.8) / (6.76 - 4.8))\n F[j * cols + i] += amplitude * Math.exp(-q) * (1 - t * t * (3 - 2 * t))\n }\n }\n }\nconst contourBuild = (w, h) => {\n /* ACCEPT-OR-REROLL. A random layout is not automatically a GOOD layout, and\n this is the concrete lesson from making the seed per-load: the old fixed\n seed had silently guaranteed a well-spread field, and once real randomness\n arrived, some layouts left regions with no contour lines at all. Measured on\n the 8x5 coverage grid (\"near-empty\" = under 0.6% ink):\n independent uniform placement 5 failures in 12 seeds (up to 3 cells)\n stratified placement alone 6 failures in 24 seeds (down to 0.00%)\n Stratification fixes clumping but cannot fix the real mechanism: lines\n appear only where the field CROSSES one of the 21 fixed levels, so a region\n that is locally flat between two levels is blank no matter how the bumps\n sit. Forcing a gradient steep enough to guarantee a crossing per cell would\n take ~9.5 parallel lines across the width, which reads as stripes, not\n terrain -- so distorting the field is the wrong lever.\n Instead the candidate layout is CHECKED against the same invariant the test\n asserts, and rejected if it fails. Each attempt is cheap (one field\n evaluation on a coarse grid, no extraction, no drawing) and bounded, so the\n worst case is a handful of evaluations at mount/resize time only.\n\n The two halves are BOTH load-bearing, which was verified rather than\n assumed -- with the validator in place but placement reverted to uniform,\n 4 of 8 loads exhausted the 12-attempt cap and shipped a fallback layout\n (one run in four still rendered a blank cell). Stratification is what makes\n an acceptable layout the common case: mean 2.5 candidates, max 6, never at\n the cap. Validation is what makes it a guarantee. */\n const attempts = 32\n const step = contourStepFor(w, h)\n let best = null\n for (let attempt = 0; attempt < attempts; attempt++) {\n const cand = contourBuildCandidate(w, h, attempt, step)\n const score = contourCoverageScore(cand, w, h)\n /* Prefer more crossing bands, then more ink in the thinnest region. The\n tiebreak only matters if every attempt fails validation and the best of the\n 32 ships — measured at 0 of 200 seeds with the floor in place. */\n if (best === null || score.worst > best.score.worst\n || (score.worst === best.score.worst && score.ink > best.score.ink)) best = { cand, score }\n // Comfortably above the 0.6%-ink failure line, in field terms: every cell\n // must contain a spread of values wider than one level gap, so at least one\n // level is guaranteed to cross it.\n if (score.ok) break\n }\n contourField = best.cand.field\n contourGeom = { w, h, cols: best.cand.cols, rows: best.cand.rows, step }\n }\nconst contourBuildCandidate = (w, h, salt, step) => {\n const cols = Math.ceil(w / step) + 1\n const rows = Math.ceil(h / step) + 1\n /* Bump count adapts to the viewport -- see the note on VIEWPORT-ADAPTIVE\n DENSITY. At or above the 640px short side this is the shipped 22, and a\n narrow frame gets fewer islands so they stay separated instead of\n stacking into bullseyes. */\n const K = contourBumpsFor(w, h)\n const rnd = contourRng((contourSeed + salt * 0x9E3779B1) >>> 0)\n const m = Math.min(w, h)\n const bx = new Float32Array(K), by = new Float32Array(K)\n const ba = new Float32Array(K), bs = new Float32Array(K)\n const dx = new Float32Array(K), dy = new Float32Array(K)\n /* STRATIFIED placement, not independent uniform draws.\n\n Uniform sampling clumps: measured over 12 random seeds it left up to 3\n near-empty cells. Jittered grid instead -- the viewport is cut into a\n near-square lattice of at least K cells and each bump is placed at a random\n point inside its own cell. That keeps placement random while making a large\n empty patch geometrically impossible. Cells are ordered by a Fisher-Yates\n shuffle so the bump INDEX carries no positional bias: index drives\n amplitude, radius and drift below, and walking cells in raster order would\n correlate \"left side of the screen\" with \"first sizes drawn\".\n The lattice spans the same -0.1..1.1 over-scan as before, so islands are\n still cut by the viewport edges rather than all sitting fully inside. */\n const gx = Math.max(1, Math.round(Math.sqrt(K * (w / Math.max(1, h)))))\n const gy = Math.max(1, Math.ceil(K / gx))\n const cells = []\n for (let j = 0; j < gy; j++) for (let i = 0; i < gx; i++) cells.push(i + j * gx)\n for (let i = cells.length - 1; i > 0; i--) {\n const j = Math.floor(rnd() * (i + 1))\n const t = cells[i]; cells[i] = cells[j]; cells[j] = t\n }\n const spanX = 1.2 * w, spanY = 1.2 * h\n for (let k = 0; k < K; k++) {\n const cell = cells[k % cells.length]\n const ci = cell % gx\n const cj = Math.floor(cell / gx)\n // Random point inside this cell, in the over-scanned -0.1..1.1 space.\n bx[k] = -0.1 * w + ((ci + rnd()) / gx) * spanX\n by[k] = -0.1 * h + ((cj + rnd()) / gy) * spanY\n // Mixed sign gives peaks AND basins; equal signs would read as one blob.\n ba[k] = (rnd() < 0.5 ? -1 : 1) * (0.6 + rnd() * 0.9)\n /* Radius: relative to the frame for the shipped look, but never below\n CONTOUR_MIN_BUMPSAMPLES grid samples or the field aliases — see the note on\n that constant. This floor is the fix for the tangled/piled-up lines. */\n bs[k] = Math.max(step * CONTOUR_MIN_BUMPSAMPLES, (0.05 + rnd() * 0.09) * m)\n dx[k] = rnd() * 2 - 1\n dy[k] = rnd() * 2 - 1\n }\n /* Base undulation: three long, low-amplitude sine ridges spanning the whole\n viewport. Reason this exists, from reviewing the first render: a sum of\n gaussians decays to EXACTLY zero between islands, so the field there is\n perfectly flat, no level ever crosses it, and the result had large blank\n patches that exposed the construction. Real terrain has no such voids. The\n ridges are far too gentle to create islands of their own — they just tilt\n the whole sheet enough that contour lines keep running through the gaps,\n which is what turns isolated bullseyes into one continuous landscape. */\n const W2 = new Float32Array(9)\n for (let i = 0; i < 3; i++) {\n W2[i * 3] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, w) // x freq\n W2[i * 3 + 1] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, h) // y freq\n W2[i * 3 + 2] = rnd() * Math.PI * 2 // phase\n }\n const hCount = (cols - 1) * rows\n const eCount = hCount + cols * (rows - 1)\n const field = {\n cols, rows, step, K, bx, by, ba, bs, dx, dy, hCount, W2,\n F: new Float32Array(cols * rows),\n previous: new Float32Array(cols * rows),\n hasPrevious: false,\n smooth: new Float32Array(cols * rows),\n // Exact row-block bounds include both rows and the shared right vertex.\n // They reject empty regions without changing retained cells or path order.\n blockCols: Math.ceil((cols - 1) / 16),\n blockMin: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n blockMax: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n boundsReady: false,\n ex: new Float32Array(eCount),\n ey: new Float32Array(eCount),\n es: new Int32Array(eCount).fill(-1),\n n1: new Int32Array(eCount).fill(-1),\n n2: new Int32Array(eCount).fill(-1),\n seen: new Int32Array(eCount).fill(-1),\n touched: new Int32Array(eCount),\n seq: 0,\n }\n return { field, cols, rows }\n }\nconst contourCoverageScore = (cand, w, h) => {\n const f = cand.field\n // Evaluate at phase 0: the accepted layout must be sound as first painted.\n const prev = contourField\n contourField = f\n contourEvaluate(0)\n contourField = prev\n const { cols, rows, F } = f\n const GX = 8, GY = 5\n const span = CONTOUR_SPAN\n /* Judge against the density this viewport will actually DRAW, not the\n shipped constant: a narrow frame extracts fewer levels, so its per-quad\n incidence count is proportionally smaller. Scoring it on CONTOUR_LEVELS\n would demand 2x the ink of a 10-level sheet and push the re-roll loop to\n its cap on every small window. */\n const levelStep = (span * 2) / contourLevelsFor(w, h)\n let worst = Infinity\n let ok = true\n let ink = Infinity\n for (let gy = 0; gy < GY; gy++) {\n for (let gx = 0; gx < GX; gx++) {\n const i0 = Math.floor(gx * (cols - 1) / GX), i1 = Math.ceil((gx + 1) * (cols - 1) / GX)\n const j0 = Math.floor(gy * (rows - 1) / GY), j1 = Math.ceil((gy + 1) * (rows - 1) / GY)\n let mn = Infinity, mx = -Infinity\n /* The ink proxy is accumulated over the QUADS of this region, in the same\n walk that takes its min/max — see CONTOUR_MIN_INK. */\n let regionInk = 0\n for (let j = j0; j <= j1 && j < rows; j++) {\n const row = j * cols\n for (let i = i0; i <= i1 && i < cols; i++) {\n const v = F[row + i]\n if (v < mn) mn = v\n if (v > mx) mx = v\n if (j < j1 && j < rows - 1 && i < i1 && i < cols - 1) {\n const a = v, b = F[row + i + 1]\n const c = F[row + cols + i], d = F[row + cols + i + 1]\n let qmn = a < b ? a : b, qmx = a > b ? a : b\n if (c < qmn) qmn = c; if (c > qmx) qmx = c\n if (d < qmn) qmn = d; if (d > qmx) qmx = d\n if (qmn < -span) qmn = -span\n if (qmx > span) qmx = span\n if (qmx > qmn) regionInk += Math.floor(qmx / levelStep) - Math.ceil(qmn / levelStep) + 1\n }\n }\n }\n // Clamp to the drawn level range: values beyond +/-SPAN produce no lines.\n const lo = Math.max(mn, -span), hi = Math.min(mx, span)\n // How many level boundaries fall inside this cell's clamped range.\n const crossings = hi <= lo ? 0\n : Math.floor(hi / levelStep) - Math.ceil(lo / levelStep) + 1\n if (crossings < worst) worst = crossings\n if (crossings < CONTOUR_MIN_CROSSINGS) ok = false\n if (regionInk < ink) ink = regionInk\n if (regionInk < CONTOUR_MIN_INK) ok = false\n }\n }\n return { ok, worst, ink }\n }\nconst contourEvaluate = (phase) => {\n const f = contourField\n if (f !== null) f.boundsReady = false\n if (f === null) return\n const { cols, rows, step, K, bx, by, ba, bs, dx, dy, F, W2 } = f\n /* Seed the sheet with the base undulation instead of zero, so the gaps\n between islands still have a gradient for the levels to cross. Separable\n evaluation: sin(a+b) is expanded so the y term is computed once per row\n rather than once per cell, which keeps this pass cheap. */\n const BASE = 0.62\n for (let i = 0; i < 3; i++) {\n const fx = W2[i * 3], fy = W2[i * 3 + 1], ph = W2[i * 3 + 2] + phase * 0.11\n const amp = BASE / 3\n for (let j = 0; j < rows; j++) {\n const yb = fy * (j * step) + ph\n const sy = Math.sin(yb), cy2 = Math.cos(yb)\n const row = j * cols\n for (let c2 = 0; c2 < cols; c2++) {\n const xb = fx * (c2 * step)\n // sin(xb + yb) without a per-cell sin() of the sum\n const v = Math.sin(xb) * cy2 + Math.cos(xb) * sy\n if (i === 0) F[row + c2] = amp * v\n else F[row + c2] += amp * v\n }\n }\n }\n for (let k = 0; k < K; k++) {\n const s = bs[k]\n const amp = s * 0.55\n const cx = bx[k] + Math.sin(phase * dx[k] + k * 1.7) * amp\n const cy = by[k] + Math.cos(phase * dy[k] + k * 2.3) * amp\n const a = ba[k]\n const inv = 1 / (2 * s * s)\n const rad = 2.6 * s\n let i0 = Math.floor((cx - rad) / step)\n let i1 = Math.ceil((cx + rad) / step)\n let j0 = Math.floor((cy - rad) / step)\n let j1 = Math.ceil((cy + rad) / step)\n if (i0 < 0) i0 = 0\n if (j0 < 0) j0 = 0\n if (i1 > cols - 1) i1 = cols - 1\n if (j1 > rows - 1) j1 = rows - 1\n for (let j = j0; j <= j1; j++) {\n const ddy = j * step - cy\n const dy2 = ddy * ddy\n const row = j * cols\n for (let i = i0; i <= i1; i++) {\n const ddx = i * step - cx\n const q = (ddx * ddx + dy2) * inv\n if (q < 6.76) {\n let weight = Math.exp(-q)\n if (q > 4.8) {\n const t = (q - 4.8) / (6.76 - 4.8)\n const fade = 1 - t * t * (3 - 2 * t)\n weight *= fade\n }\n F[row + i] += a * weight\n }\n }\n }\n }\n const smooth = f.smooth\n for (let pass = 0; pass < 5; pass++) {\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n for (let i = 0; i < cols; i++) {\n const left = F[row + Math.max(0, i - 1)]\n const center = F[row + i]\n const right = F[row + Math.min(cols - 1, i + 1)]\n smooth[row + i] = (left + 2 * center + right) * 0.25\n }\n }\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n const up = Math.max(0, j - 1) * cols\n const down = Math.min(rows - 1, j + 1) * cols\n for (let i = 0; i < cols; i++) {\n F[row + i] = (smooth[up + i] + 2 * smooth[row + i] + smooth[down + i]) * 0.25\n }\n }\n }\n /* Track the field continuously between animation samples. Marching squares\n can change an entire path at once when a saddle crosses a level; blending\n the sampled field keeps that topology change from appearing as a twitch. */\n if (f.hasPrevious && f.previous !== undefined) {\n for (let i = 0; i < F.length; i++) {\n f.previous[i] = f.previous[i] * 0.65 + F[i] * 0.35\n F[i] = f.previous[i]\n }\n } else if (f.previous !== undefined) {\n f.previous.set(F)\n }\n f.hasPrevious = true\n }\nconst contourExtractLevel = (L, out) => {\n const f = contourField\n const { cols, rows, step, F, ex, ey, es, n1, n2, seen, touched, hCount } = f\n const st = ++f.seq\n let tn = 0\n const pt = (id, i0, j0, i1, j1) => {\n if (es[id] === st) return id\n const a = F[j0 * cols + i0]\n const b = F[j1 * cols + i1]\n let t = (L - a) / (b - a)\n if (!(t >= 0)) t = 0\n else if (t > 1) t = 1\n ex[id] = (i0 + (i1 - i0) * t) * step\n ey[id] = (j0 + (j1 - j0) * t) * step\n es[id] = st\n n1[id] = -1\n n2[id] = -1\n touched[tn++] = id\n return id\n }\n const link = (a, b) => {\n if (n1[a] < 0) n1[a] = b\n else if (n2[a] < 0) n2[a] = b\n if (n1[b] < 0) n1[b] = a\n else if (n2[b] < 0) n2[b] = a\n }\n for (let j = 0; j < rows - 1; j++) {\n const row = j * cols\n for (let i = 0; i < cols - 1; i++) {\n if (f.boundsReady && i % 16 === 0) {\n const block = j * f.blockCols + Math.floor(i / 16)\n if (L <= f.blockMin[block] || L > f.blockMax[block]) {\n i = Math.min(i + 16, cols - 1) - 1\n continue\n }\n }\n const p0 = row + i\n const p1 = p0 + 1\n const p3 = p0 + cols\n const p2 = p3 + 1\n const v0 = F[p0], v1 = F[p1], v2 = F[p2], v3 = F[p3]\n let mn = v0, mx = v0\n if (v1 < mn) mn = v1; else if (v1 > mx) mx = v1\n if (v2 < mn) mn = v2; else if (v2 > mx) mx = v2\n if (v3 < mn) mn = v3; else if (v3 > mx) mx = v3\n // Whole cell on one side of the level: nothing crosses it.\n if (L <= mn || L > mx) continue\n const idx = (v0 > L ? 1 : 0) | (v1 > L ? 2 : 0) | (v2 > L ? 4 : 0) | (v3 > L ? 8 : 0)\n const T = () => pt(j * (cols - 1) + i, i, j, i + 1, j)\n const B = () => pt((j + 1) * (cols - 1) + i, i, j + 1, i + 1, j + 1)\n const Le = () => pt(hCount + j * cols + i, i, j, i, j + 1)\n const Ri = () => pt(hCount + j * cols + i + 1, i + 1, j, i + 1, j + 1)\n switch (idx) {\n case 1: case 14: link(T(), Le()); break\n case 2: case 13: link(T(), Ri()); break\n case 3: case 12: link(Le(), Ri()); break\n case 4: case 11: link(Ri(), B()); break\n case 6: case 9: link(T(), B()); break\n case 7: case 8: link(Le(), B()); break\n // Ambiguous saddles use the bilinear asymptotic decider. The sign of\n // a*c-b*d selects whether the diagonal high/low regions are connected;\n // using the cell average alone is wrong when opposite corners differ in\n // magnitude and produces the long V-shaped joins seen in the render.\n case 5: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle > 0) { link(T(), Ri()); link(Le(), B()) }\n else { link(T(), Le()); link(Ri(), B()) }\n break\n }\n case 10: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle < 0) { link(T(), Le()); link(Ri(), B()) }\n else { link(T(), Ri()); link(Le(), B()) }\n break\n }\n }\n }\n }\n const walk = (start) => {\n const path = []\n let cur = start\n let prev = -1\n for (;;) {\n path.push(ex[cur], ey[cur])\n seen[cur] = st\n const a = n1[cur]\n const b = n2[cur]\n let nx = -1\n if (a >= 0 && a !== prev && seen[a] !== st) nx = a\n else if (b >= 0 && b !== prev && seen[b] !== st) nx = b\n if (nx < 0) {\n // Closed loop: step back onto the first point so the ring has no gap.\n if ((a === start || b === start) && path.length > 4) path.push(ex[start], ey[start])\n break\n }\n prev = cur\n cur = nx\n }\n return path\n }\n /* Reject debris before it reaches the draw list. Judged on the path's\n ON-CANVAS geometry, so an off-grid sliver with no visible pixels is\n dropped even when its raw length looks respectable. See the note on\n CONTOUR_MIN_LEN / CONTOUR_MIN_RING_BOX for the measurements behind both\n thresholds. */\n const W = contourGeom !== null ? contourGeom.w : 0\n const H = contourGeom !== null ? contourGeom.h : 0\n const keep = (p) => {\n if (p.length < 8) return false\n // Visible length, plus the bounding box of the part actually on screen.\n let vis = 0\n let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity\n let seenIn = false\n for (let k = 0; k < p.length; k += 2) {\n const x = p[k], y = p[k + 1]\n const inside = x >= 0 && x <= W && y >= 0 && y <= H\n if (inside) {\n seenIn = true\n if (x < minx) minx = x\n if (x > maxx) maxx = x\n if (y < miny) miny = y\n if (y > maxy) maxy = y\n }\n if (k >= 2) {\n const px2 = p[k - 2], py2 = p[k - 1]\n const prevIn = px2 >= 0 && px2 <= W && py2 >= 0 && py2 <= H\n if (inside && prevIn) {\n const dx = x - px2, dy = y - py2\n vis += Math.sqrt(dx * dx + dy * dy)\n }\n }\n }\n if (!seenIn) return false // entirely off-canvas: pure debris\n if (vis < CONTOUR_KEEP_LEN) return false\n /* A tiny CLOSED ring is an apex bullseye and reads as a dot. Open chains of\n the same extent are left alone: they are the visible corner of a stroke\n that continues off-canvas, and clipping one would punch a hole in a line\n the user can see running to the edge. */\n const gapx = p[0] - p[p.length - 2]\n const gapy = p[1] - p[p.length - 1]\n const closed = (gapx * gapx + gapy * gapy) < 4\n if (closed && (maxx - minx) < CONTOUR_KEEP_RING\n && (maxy - miny) < CONTOUR_KEEP_RING) return false\n return true\n }\n /* TANGENCY NEEDLES. Where a level runs nearly TANGENT to the field, the true\n isoline has a smooth, very high curvature tip. Marching squares interpolates\n linearly on a 10px grid, so it cannot represent that tip: it emits a hairpin\n that goes out and comes straight back, with a BASE (the gap between the\n apex's two neighbours) far narrower than the 1px stroke. Measured on the real\n output, worst case: apex 6.26px out from a base of 0.831px.\n\n At that width the outbound and return strokes paint the SAME pixels, so the\n pair does not read as a narrow valley — only the protruding whisker shows,\n which is precisely the \"irregular sharp angle\" in issue #3. Smoothing cannot\n help: the midpoint spline faithfully reproduces a feature that is genuinely\n in the geometry, so it has to be removed here, at the source.\n\n The whole hairpin is collapsed (see the note on the merge below). Both tests\n are required and were measured over 24 frames (152.6k vertices, 1.18Mpx of\n ink):\n base < 2px the stroke cannot resolve it (a 1px line is ~1px wide)\n turn > 90 it doubles back rather than merely turning a corner\n That is 0.7 vertices per frame and 0.0037% of total ink -- artifact removal,\n not thinning. Real narrow features are untouched: turns over 90 degrees have\n a median base of 4.03px, well clear of the cutoff, and the whole 8-12px base\n band (29562 vertices) has a p99 turn of only 21.7 degrees. */\n const deneedle = (p) => {\n const n = p.length / 2\n if (n < 4) return p\n /* Scan first and return the ORIGINAL array when there is nothing to do, so\n the overwhelmingly common path allocates nothing at 24fps. */\n let found = false\n for (let k = 1; k < n - 1; k++) {\n const bx = p[(k + 1) * 2] - p[(k - 1) * 2]\n const by = p[(k + 1) * 2 + 1] - p[(k - 1) * 2 + 1]\n if (bx * bx + by * by >= 4) continue // base >= 2px: keep\n const ax = p[k * 2] - p[(k - 1) * 2]\n const ay = p[k * 2 + 1] - p[(k - 1) * 2 + 1]\n const cx = p[(k + 1) * 2] - p[k * 2]\n const cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n // turn > 90 degrees <=> the two segment vectors point against each other.\n if (ax * cx + ay * cy < 0) { found = true; break }\n }\n if (!found) return p\n const gx = p[0] - p[(n - 1) * 2]\n const gy = p[1] - p[(n - 1) * 2 + 1]\n const closed = (gx * gx + gy * gy) < 4\n /* COLLAPSE THE WHOLE NEEDLE, not just its tip. Dropping the apex alone leaves\n the base itself as a real segment, and that was measured to be worse than\n the disease: a 0.831px stub inherits the reversal as TWO ~78-degree turns\n (10.20 -> 0.83 -> 10.25px). The apex AND its far neighbour are therefore\n both consumed, and the surviving previous vertex is pulled onto the base\n midpoint -- a sub-pixel move (half of at most 2px) that no 1px stroke can\n show, leaving one smooth vertex where the hairpin was.\n Each test uses the SURVIVING previous vertex, so a run of needles collapses\n progressively instead of each test being fooled by a neighbour that is\n itself about to be consumed. */\n const q = [p[0], p[1]]\n let k = 1\n while (k < n - 1) {\n const px = q[q.length - 2], py = q[q.length - 1]\n const bx = p[(k + 1) * 2] - px, by = p[(k + 1) * 2 + 1] - py\n if (bx * bx + by * by < 4) {\n const ax = p[k * 2] - px, ay = p[k * 2 + 1] - py\n const cx = p[(k + 1) * 2] - p[k * 2], cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n if (ax * cx + ay * cy < 0) {\n if (k + 1 < n - 1) {\n q[q.length - 2] = (px + p[(k + 1) * 2]) / 2\n q[q.length - 1] = (py + p[(k + 1) * 2 + 1]) / 2\n k += 2\n continue\n }\n // The far neighbour is the final vertex, which must survive to keep an\n // endpoint (or a ring's closure) intact: consume only the apex.\n k += 1\n continue\n }\n }\n q.push(p[k * 2], p[k * 2 + 1])\n k += 1\n }\n q.push(p[(n - 1) * 2], p[(n - 1) * 2 + 1])\n /* A ring is closed by REPEATING its start vertex, and the merge above may have\n nudged that start. Re-anchor the repeat so the ring stays exactly closed and\n both keep() and the cyclic draw path still classify it as one. */\n if (closed) {\n q[q.length - 2] = q[0]\n q[q.length - 1] = q[1]\n }\n return q\n }\n // Open chains first (they have a free end), then whatever remains is a loop.\n // Doing it in this order stops a ring being entered mid-way and split in two.\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st && n2[id] < 0) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n }\nconst contourExtract = (phase, trailPoints, trailNow) => {\n if (contourField === null) return\n contourEvaluate(phase)\n if (trailPoints && trailPoints.length) contourOverlayTrail(contourField, trailPoints, trailNow)\n const f = contourField\n // One O(cells) range pass is shared by all 21 extraction levels. Scratch\n // survives across frames; no resolution, smoothing or density is reduced.\n for (let j = 0; j < f.rows - 1; j++) {\n const row = j * f.cols\n for (let block = 0; block < f.blockCols; block++) {\n const begin = block * 16\n const end = Math.min(begin + 16, f.cols - 1)\n let min = Infinity, max = -Infinity\n for (let i = begin; i <= end; i++) {\n const a = f.F[row + i], b = f.F[row + f.cols + i]\n if (a < min) min = a\n if (a > max) max = a\n if (b < min) min = b\n if (b > max) max = b\n }\n const k = j * f.blockCols + block\n f.blockMin[k] = min\n f.blockMax[k] = max\n }\n }\n f.boundsReady = true\n contourPaths = []\n const span = CONTOUR_SPAN\n /* Level count follows the viewport: the same VIEWPORT-ADAPTIVE DENSITY\n contourBuildCandidate() laid the bumps out with. At or above the 640px\n short side this is exactly the shipped loop, so desktop output is\n unchanged. */\n const levels = contourLevelsFor(contourGeom.w, contourGeom.h)\n const stepL = (span * 2) / levels\n for (let n = 0; n <= levels; n++) {\n contourExtractLevel(-span + n * stepL, contourPaths)\n }\n }\nconst contourDrawLines = () => {\n if (contourLineCv === null || contourGeom === null) return\n const ctx = contourLineCv.getContext('2d')\n if (!ctx) return\n const { w, h } = contourGeom\n /* Geometry stays in CSS px; scale the context to the backing store that\n contourSizeTo sized at (capped) devicePixelRatio, so strokes rasterise\n at device resolution instead of being upsampled into blur on HiDPI\n screens. Derived from the canvas itself, and skipped entirely at a 1x\n store or when the context has no setTransform (the spliced-in test\n harnesses), so a 1x render is byte-identical to before. */\n const scale = (w > 0 && typeof contourLineCv.width === 'number' && contourLineCv.width > 0 && contourLineCv.width !== w)\n ? contourLineCv.width / w : 1\n if (scale !== 1 && typeof ctx.setTransform === 'function') ctx.setTransform(scale, 0, 0, scale, 0, 0)\n ctx.clearRect(0, 0, w, h)\n ctx.strokeStyle = contourStroke()\n ctx.lineWidth = 1\n ctx.lineJoin = 'round'\n /* Marching squares emits one vertex per grid-cell edge. Three Chaikin passes\n cut local corners before the clamped cubic B-spline rounds broad bends.\n This reduces angularity without changing the field or adding another\n extraction pass. Open endpoints remain fixed; closed rings wrap cyclically.\n\n PASS COUNT IS SIZE-ADAPTIVE: see CONTOUR_SMOOTH_FULL. Every ordinary window\n keeps three passes; a bigger sheet drops to two and then one. */\n const smoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) return source\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n /* Flat scratch throughout: the previous shape built one [x, y] array per\n point PER PASS — ~14 small arrays per source vertex, ~65k per sheet at\n 1440x900 — which is pure GC inside the animation frame on both painters. */\n let points = closed ? source.slice(0, source.length - 2) : source.slice()\n const passes = count <= CONTOUR_SMOOTH_FULL ? 3 : (count <= CONTOUR_SMOOTH_LIMIT ? 2 : 1)\n for (let pass = 0; pass < passes; pass++) {\n const n = points.length / 2\n const limit = closed ? n : n - 1\n const next = new Array(n * 4)\n let w2 = 0\n if (!closed) { next[w2++] = points[0]; next[w2++] = points[1] }\n for (let k = 0; k < limit; k++) {\n const a = k * 2\n const b = ((k + 1) % n) * 2\n const ax = points[a], ay = points[a + 1]\n const bx = points[b], by = points[b + 1]\n next[w2++] = ax * 0.75 + bx * 0.25\n next[w2++] = ay * 0.75 + by * 0.25\n next[w2++] = ax * 0.25 + bx * 0.75\n next[w2++] = ay * 0.25 + by * 0.75\n }\n if (!closed) { next[w2++] = points[points.length - 2]; next[w2++] = points[points.length - 1] }\n points = w2 === next.length ? next : next.slice(0, w2)\n }\n if (!closed) return points\n // Re-anchor the repeated start vertex so the ring stays exactly closed.\n const out = points.slice()\n out.push(out[0], out[1])\n return out\n }\n /* Chaikin removes local grid noise. A constrained Catmull-Rom cubic then\n gives each join one shared tangent. The handle cap prevents overshoot at\n narrow saddles while the larger tangent factor removes long rounded-polygon\n bends that remain visible with midpoint quadratics. */\n const drawSmoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) {\n ctx.moveTo(source[0], source[1])\n for (let k = 2; k < source.length; k += 2) ctx.lineTo(source[k], source[k + 1])\n return\n }\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n const limit = closed ? count - 1 : count\n const at = (index) => {\n const k = closed\n ? (index + limit) % limit\n : Math.max(0, Math.min(limit - 1, index))\n return k * 2\n }\n /* Tangents from scalars, with squared lengths wherever the comparison allows\n it. The previous shape allocated eight [x, y] arrays and ran up to six\n Math.hypot per SEGMENT — tens of thousands of segments per frame, on both\n the WebGL painter and the canvas fallback. Math.sqrt replaces hypot because\n every argument here is a pixel delta, far from hypot's overflow range. */\n const tangentAt = (index, out) => {\n const i = at(index)\n const p = at(index - 1)\n const n = at(index + 1)\n const cx = source[i], cy = source[i + 1]\n const inX = cx - source[p], inY = cy - source[p + 1]\n const outX = source[n] - cx, outY = source[n + 1] - cy\n let tx, ty, cap\n if (!closed && index === 0) {\n tx = outX * 0.4\n ty = outY * 0.4\n cap = Math.sqrt(outX * outX + outY * outY) * 0.55\n } else if (!closed && index === limit - 1) {\n tx = inX * 0.4\n ty = inY * 0.4\n cap = Math.sqrt(inX * inX + inY * inY) * 0.55\n } else {\n tx = (source[n] - source[p]) * 0.32\n ty = (source[n + 1] - source[p + 1]) * 0.32\n cap = Math.sqrt(Math.min(inX * inX + inY * inY, outX * outX + outY * outY)) * 0.62\n }\n const len2 = tx * tx + ty * ty\n if (len2 > cap * cap && len2 > 0) {\n const s = cap / Math.sqrt(len2)\n tx *= s\n ty *= s\n }\n out[0] = tx\n out[1] = ty\n }\n ctx.moveTo(source[0], source[1])\n const segments = closed ? limit : limit - 1\n const t0 = [0, 0]\n const t1 = [0, 0]\n for (let k = 0; k < segments; k++) {\n const s = at(k)\n const e = at(k + 1)\n tangentAt(k, t0)\n tangentAt(k + 1, t1)\n ctx.bezierCurveTo(\n source[s] + t0[0], source[s + 1] + t0[1],\n source[e] - t1[0], source[e + 1] - t1[1],\n source[e], source[e + 1],\n )\n }\n /* Mark a ring as a RING. The cyclic tangents above already make the seam C1\n and the final span already lands exactly on the start point, so this adds\n no geometry — but without it the canvas treats the path as open and butts\n two caps together at the seam instead of joining them, which is defect (2)\n of issue #3. contour-cusps.test.js guards this. */\n if (closed) ctx.closePath()\n }\n const isRing = (p) => {\n const n = p.length - 2\n return n >= 2 && (p[0] - p[n]) ** 2 + (p[1] - p[n + 1]) ** 2 < 4\n }\n /* ONE STROKE, RINGS FIRST — and the ORDER matters for the canvas fallback, not\n for looks. `closePath()` on a real 2D context finalises the current subpath by\n splicing it into the path accumulated so far, so its cost grows with\n everything already in that path (measured on the canvas fallback: 18 calls per\n sheet averaging 0.278 ms, max 1.2 ms, ~16% of the frame). Drawing the rings\n while the path still holds only other rings bounds every splice to ring\n geometry. Output is unchanged either way: canvas stroke coverage is a union,\n and the WebGL painter blends with MAX (commutative), so subpath order cannot\n affect the result. Stroking each path separately would also fix the cost but\n double-composites overlapping strokes and visibly darkens every crossing —\n the very \"piled-up lines\" look this avoids. */\n ctx.beginPath()\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (isRing(p)) drawSmoothPath(smoothPath(p))\n }\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (!isRing(p)) drawSmoothPath(smoothPath(p))\n }\n ctx.stroke()\n }\n/* MIT; contributed by higekibaka. GPU stroke painter from Endfield Glass. */\nconst CURVE_TOLERANCE_PX = 0.04;\nconst STRIDE = 6;\nclass StrokeSegments {\n data = new Float32Array(4096 * STRIDE);\n used = 0;\n tolerance = CURVE_TOLERANCE_PX;\n x = 0;\n y = 0;\n startX = 0;\n startY = 0;\n first = 0;\n reset() {\n this.used = 0;\n this.first = 0;\n }\n moveTo(x, y) {\n this.x = this.startX = x;\n this.y = this.startY = y;\n this.first = this.used;\n }\n lineTo(x, y) {\n if (x === this.x && y === this.y) return;\n if (this.used + STRIDE > this.data.length) {\n const next = new Float32Array(this.data.length * 2);\n next.set(this.data);\n this.data = next;\n }\n const i = this.used;\n this.data[i] = this.x;\n this.data[i + 1] = this.y;\n this.data[i + 2] = x;\n this.data[i + 3] = y;\n this.data[i + 4] = i === this.first ? 1 : 0;\n this.data[i + 5] = 1;\n if (i > this.first) this.data[i - 1] = 0;\n this.used += STRIDE;\n this.x = x;\n this.y = y;\n }\n closePath() {\n this.lineTo(this.startX, this.startY);\n if (this.used > this.first) {\n this.data[this.first + 4] = 0;\n this.data[this.used - 1] = 0;\n }\n }\n bezierCurveTo(ax, ay, bx, by, x, y) {\n this.cubic(this.x, this.y, ax, ay, bx, by, x, y, 0);\n }\n cubic(x0, y0, x1, y1, x2, y2, x3, y3, depth) {\n const dx = x3 - x0, dy = y3 - y0, length2 = dx * dx + dy * dy;\n const tolerance2 = this.tolerance * this.tolerance;\n const t1 = length2 > 0 ? Math.max(0, Math.min(1, ((x1 - x0) * dx + (y1 - y0) * dy) / length2)) : 0;\n const t2 = length2 > 0 ? Math.max(0, Math.min(1, ((x2 - x0) * dx + (y2 - y0) * dy) / length2)) : 0;\n const e1x = x1 - x0 - t1 * dx, e1y = y1 - y0 - t1 * dy;\n const e2x = x2 - x0 - t2 * dx, e2y = y2 - y0 - t2 * dy;\n if (e1x * e1x + e1y * e1y <= tolerance2 && e2x * e2x + e2y * e2y <= tolerance2 || depth >= 16) {\n this.lineTo(x3, y3);\n return;\n }\n const a = (x0 + x1) / 2, b = (y0 + y1) / 2, c = (x1 + x2) / 2, d = (y1 + y2) / 2;\n const e = (x2 + x3) / 2, f = (y2 + y3) / 2, g = (a + c) / 2, h = (b + d) / 2;\n const i = (c + e) / 2, j = (d + f) / 2, k = (g + i) / 2, l = (h + j) / 2;\n this.cubic(x0, y0, a, b, g, h, k, l, depth + 1);\n this.cubic(k, l, i, j, e, f, x3, y3, depth + 1);\n }\n}\nfunction parseStrokeColor(value) {\n const m = value.match(/^rgba?\\(\\s*([\\d.]+)\\s*,\\s*([\\d.]+)\\s*,\\s*([\\d.]+)(?:\\s*,\\s*([\\d.]+))?\\s*\\)$/);\n if (!m) return null;\n const values = [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255, m[4] === void 0 ? 1 : Number(m[4])];\n return values.every((v) => Number.isFinite(v) && v >= 0 && v <= 1) ? values : null;\n}\nconst VERTEX = `#version 300 es\nprecision highp float;\nlayout(location=0) in vec4 segment;\nlayout(location=1) in vec2 caps;\nuniform vec2 viewportSize;\nuniform vec2 scale;\nuniform float width;\nout vec2 local;\nflat out float segmentLength;\nflat out float radius;\nflat out vec2 endCaps;\nvoid main() {\n vec2 a = segment.xy * scale, b = segment.zw * scale;\n vec2 delta = b-a;\n segmentLength = max(length(delta), 0.000001);\n vec2 tangent = delta / segmentLength;\n vec2 normal = vec2(-tangent.y, tangent.x);\n radius = 0.5 * width * scale.x * scale.y * length(segment.zw-segment.xy) / segmentLength;\n float margin = radius + 1.0;\n // Two triangles per segment, generated without a second vertex buffer.\n vec2 corners[6] = vec2[6](vec2(0,-1),vec2(1,-1),vec2(0,1),vec2(0,1),vec2(1,-1),vec2(1,1));\n vec2 corner = corners[gl_VertexID];\n local = vec2(mix(-margin, segmentLength+margin, corner.x), corner.y * margin);\n vec2 pixel = a + tangent * local.x + normal * local.y;\n gl_Position = vec4(pixel.x/viewportSize.x*2.0-1.0, 1.0-pixel.y/viewportSize.y*2.0, 0, 1);\n endCaps = caps;\n}`;\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 local;\nflat in float segmentLength;\nflat in float radius;\nflat in vec2 endCaps;\nuniform vec4 color;\nout vec4 outputColor;\nvoid main() {\n vec2 nearest = vec2(clamp(local.x, 0.0, segmentLength), 0);\n float distance = length(local-nearest)-radius;\n if (endCaps.x > 0.5) distance = max(distance, -local.x);\n if (endCaps.y > 0.5) distance = max(distance, local.x-segmentLength);\n float coverage = clamp(0.5-distance, 0.0, 1.0);\n float alpha = color.a * coverage;\n outputColor = vec4(color.rgb * alpha, alpha);\n}`;\nfunction createWebGLContourContext(canvas, onContextLost) {\n const gl = canvas.getContext(\"webgl2\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n preserveDrawingBuffer: false\n });\n if (!gl) return null;\n let program = null;\n let buffer = null;\n let vao = null;\n const shaders = [];\n const events = canvas;\n const lost = () => onContextLost?.();\n const releaseContext = gl.getExtension(\"WEBGL_lose_context\");\n const release = () => {\n events.removeEventListener?.(\"webglcontextlost\", lost);\n if (buffer) gl.deleteBuffer(buffer);\n if (vao) gl.deleteVertexArray(vao);\n if (program) gl.deleteProgram(program);\n for (const shader of shaders) gl.deleteShader(shader);\n buffer = null;\n vao = null;\n program = null;\n shaders.length = 0;\n releaseContext?.loseContext();\n };\n try {\n const compile = (type, source) => {\n const shader = gl.createShader(type);\n if (!shader) throw new Error(\"WebGL shader allocation failed\");\n shaders.push(shader);\n gl.shaderSource(shader, source);\n gl.compileShader(shader);\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(\"Contour shader: \" + gl.getShaderInfoLog(shader));\n return shader;\n };\n const vs = compile(gl.VERTEX_SHADER, VERTEX), fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);\n program = gl.createProgram();\n buffer = gl.createBuffer();\n vao = gl.createVertexArray();\n if (!program || !buffer || !vao) throw new Error(\"WebGL allocation failed\");\n gl.attachShader(program, vs);\n gl.attachShader(program, fs);\n gl.linkProgram(program);\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(\"Contour shader link: \" + gl.getProgramInfoLog(program));\n gl.useProgram(program);\n gl.bindVertexArray(vao);\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n gl.enableVertexAttribArray(0);\n gl.vertexAttribPointer(0, 4, gl.FLOAT, false, STRIDE * 4, 0);\n gl.vertexAttribDivisor(0, 1);\n gl.enableVertexAttribArray(1);\n gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 16);\n gl.vertexAttribDivisor(1, 1);\n gl.disable(gl.DEPTH_TEST);\n gl.disable(gl.CULL_FACE);\n gl.disable(gl.DITHER);\n gl.enable(gl.BLEND);\n gl.blendEquation(gl.MAX);\n gl.blendFunc(gl.ONE, gl.ONE);\n const viewport = gl.getUniformLocation(program, \"viewportSize\");\n const scaling = gl.getUniformLocation(program, \"scale\");\n const color = gl.getUniformLocation(program, \"color\");\n const width = gl.getUniformLocation(program, \"width\");\n const segments = new StrokeSegments();\n let sx = 1, sy = 1, capacity = 0, disposed = false;\n const context = {\n strokeStyle: \"rgba(16,17,16,0.16)\",\n lineWidth: 1,\n lineJoin: \"round\",\n setTransform(a, b, c, d, e, f) {\n if (b !== 0 || c !== 0 || e !== 0 || f !== 0 || a <= 0 || d <= 0) throw new Error(\"Unsupported contour transform\");\n sx = a;\n sy = d;\n segments.tolerance = CURVE_TOLERANCE_PX / Math.max(sx, sy);\n },\n clearRect() {\n if (disposed || gl.isContextLost()) throw new Error(\"Contour WebGL context lost\");\n gl.viewport(0, 0, canvas.width, canvas.height);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n },\n beginPath: () => segments.reset(),\n moveTo: (x, y) => segments.moveTo(x, y),\n lineTo: (x, y) => segments.lineTo(x, y),\n bezierCurveTo: (a, b, c, d, e, f) => segments.bezierCurveTo(a, b, c, d, e, f),\n closePath: () => segments.closePath(),\n stroke() {\n const rgba = parseStrokeColor(context.strokeStyle);\n if (!rgba) throw new Error(\"Unsupported contour stroke color\");\n if (segments.used === 0) {\n gl.flush();\n return;\n }\n gl.uniform2f(viewport, canvas.width, canvas.height);\n gl.uniform2f(scaling, sx, sy);\n gl.uniform4fv(color, rgba);\n gl.uniform1f(width, context.lineWidth);\n if (capacity < segments.data.byteLength) {\n capacity = segments.data.byteLength;\n gl.bufferData(gl.ARRAY_BUFFER, capacity, gl.STREAM_DRAW);\n }\n gl.bufferSubData(gl.ARRAY_BUFFER, 0, segments.data, 0, segments.used);\n gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, segments.used / STRIDE);\n gl.flush();\n },\n dispose() {\n if (disposed) return;\n disposed = true;\n segments.data = new Float32Array(0);\n segments.used = 0;\n release();\n }\n };\n events.addEventListener?.(\"webglcontextlost\", lost);\n return context;\n } catch (error) {\n release();\n throw error;\n }\n}\n\nself.onmessage = ({data}) => {\n try {\n if (data.type === 'init') {\n if (canvas !== null) throw new Error('duplicate worker init')\n canvas = data.canvas; contourSeed = data.seed\n painter = createWebGLContourContext(canvas, () => self.postMessage({type:'error',message:'WebGL context lost'}))\n rasterizer = painter ? 'worker-webgl2' : 'worker-canvas2d'\n if (!painter) painter = canvas.getContext('2d', {willReadFrequently:true})\n if (!painter) throw new Error('worker canvas unavailable')\n contourLineCv = {getContext:()=>painter, get width(){return canvas.width},get height(){return canvas.height}}\n self.postMessage({type:'initialized',rasterizer})\n } else if (data.type === 'frame') {\n if (!canvas || !painter) throw new Error('worker not initialized')\n const {w,h,dpr,phase,geometry,color,seq}=data\n if (![w,h,dpr,phase,seq].every(Number.isFinite) || w<1 || h<1 || dpr<=0 || dpr>2\n || Math.round(w*dpr)*Math.round(h*dpr)>8000000 || !/^#[0-9a-f]{8}$/i.test(color)) throw new Error('invalid frame')\n const resized = !contourGeom || contourGeom.w!==w || contourGeom.h!==h\n if (resized) contourBuild(w,h)\n const width=Math.round(w*dpr),height=Math.round(h*dpr)\n if(canvas.width!==width)canvas.width=width\n if(canvas.height!==height)canvas.height=height\n const c=color.slice(1)\n stroke=`rgba(${parseInt(c.slice(0,2),16)},${parseInt(c.slice(2,4),16)},${parseInt(c.slice(4,6),16)},${parseInt(c.slice(6,8),16)/255})`\n if(geometry || resized)contourExtract(phase)\n painter.setTransform(1,0,0,1,0,0)\n contourDrawLines()\n self.postMessage({type:'painted',seq,rasterizer})\n } else if (data.type === 'dispose') {\n if(painter && painter.dispose)painter.dispose()\n contourField=null;contourPaths=[];painter=null;canvas=null\n self.close()\n }\n } catch(error) { self.postMessage({type:'error',message:String(error.message || error)}) }\n}\nself.postMessage({type:'ready'})\n"
|
|
3291
3389
|
/* END GENERATED CONTOUR WORKER */
|
|
3292
3390
|
const onContourVisibility = () => {
|
|
3293
3391
|
if (document.hidden && contourWorker) contourWorker.pending = null
|
|
@@ -3698,7 +3796,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3698
3796
|
scrollAnimRun()
|
|
3699
3797
|
}
|
|
3700
3798
|
|
|
3701
|
-
/* A reader gesture wins immediately
|
|
3799
|
+
/* A reader gesture wins immediately: wheel, touch, pointer-down and ANY
|
|
3800
|
+
keydown with focus inside the scrollport all count — the tail snaps back to
|
|
3801
|
+
the app's own geometry instead of decaying. The list is deliberately broader
|
|
3802
|
+
than navigation keys: a lingering lag under a text selection (Ctrl+C) reads
|
|
3803
|
+
as broken rendering, not as smoothness. */
|
|
3702
3804
|
const scrollAnimOnIntent = (entry) => { if (entry.offset !== 0) scrollAnimClear(entry) }
|
|
3703
3805
|
|
|
3704
3806
|
const scrollAnimDetach = (entry) => {
|
|
@@ -3766,6 +3868,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3766
3868
|
cheaper than the work the rest of this hook already does per mutation. */
|
|
3767
3869
|
const syncScrollAnim = () => {
|
|
3768
3870
|
if (typeof document === 'undefined') return
|
|
3871
|
+
ensureScrollAnimEnvWatch()
|
|
3769
3872
|
const on = scrollAnimActive()
|
|
3770
3873
|
if (!on && scrollAnimPorts.size === 0) { scrollAnimStop(); return }
|
|
3771
3874
|
const live = document.querySelectorAll('[data-conversation-scroll]')
|
|
@@ -3808,18 +3911,43 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3808
3911
|
scrollAnimDetach(entry)
|
|
3809
3912
|
scrollAnimPorts.delete(port)
|
|
3810
3913
|
}
|
|
3914
|
+
/* The environment watchers go with the run too. destroyScrollAnim runs on
|
|
3915
|
+
every master-switch OFF (unmount) and on run dispose, while the fragment
|
|
3916
|
+
itself is evaluated once per apply() — a listener registered at fragment
|
|
3917
|
+
scope would survive destroy, and after a disable -> enable cycle without a
|
|
3918
|
+
page load the old closure's syncScrollAnim would attach a SECOND set of
|
|
3919
|
+
port listeners and a second rAF loop alongside the new run's, both writing
|
|
3920
|
+
the same columns' transforms. */
|
|
3921
|
+
if (scrollAnimEnvWatched) {
|
|
3922
|
+
scrollAnimEnvWatched = false
|
|
3923
|
+
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
3924
|
+
document.removeEventListener('visibilitychange', onScrollAnimEnvironmentChange)
|
|
3925
|
+
}
|
|
3926
|
+
if (scrollAnimMotionQuery !== null && typeof scrollAnimMotionQuery.removeEventListener === 'function') {
|
|
3927
|
+
scrollAnimMotionQuery.removeEventListener('change', onScrollAnimEnvironmentChange)
|
|
3928
|
+
}
|
|
3929
|
+
scrollAnimMotionQuery = null
|
|
3930
|
+
}
|
|
3811
3931
|
}
|
|
3812
3932
|
const onScrollAnimEnvironmentChange = () => {
|
|
3813
3933
|
if (!scrollAnimActive()) scrollAnimSnap()
|
|
3814
3934
|
else syncScrollAnim()
|
|
3815
3935
|
}
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
if (
|
|
3822
|
-
|
|
3936
|
+
/* Attached on the first sync (which every mount/reconcile path calls) and
|
|
3937
|
+
removed in destroyScrollAnim — NOT registered unconditionally at fragment
|
|
3938
|
+
scope, for the ghost-animator reason destroyScrollAnim documents. */
|
|
3939
|
+
let scrollAnimEnvWatched = false
|
|
3940
|
+
const ensureScrollAnimEnvWatch = () => {
|
|
3941
|
+
if (scrollAnimEnvWatched) return
|
|
3942
|
+
scrollAnimEnvWatched = true
|
|
3943
|
+
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
|
3944
|
+
document.addEventListener('visibilitychange', onScrollAnimEnvironmentChange)
|
|
3945
|
+
}
|
|
3946
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
3947
|
+
scrollAnimMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
3948
|
+
if (typeof scrollAnimMotionQuery.addEventListener === 'function') {
|
|
3949
|
+
scrollAnimMotionQuery.addEventListener('change', onScrollAnimEnvironmentChange)
|
|
3950
|
+
}
|
|
3823
3951
|
}
|
|
3824
3952
|
}
|
|
3825
3953
|
// Let the page observer declared above re-attach as the app renders.
|
|
@@ -3855,6 +3983,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3855
3983
|
// to outlive it so a stalled flourish can still never leave the app covered.
|
|
3856
3984
|
let loaderKill = null
|
|
3857
3985
|
let loaderDone = false
|
|
3986
|
+
/* The DOMContentLoaded deferral runLoader can install while <body> is still
|
|
3987
|
+
missing; the run's dispose effect removes it (removeEventListener is a no-op
|
|
3988
|
+
once a { once } listener has fired). */
|
|
3989
|
+
let loaderDomReadyPending = null
|
|
3858
3990
|
const clearLoaderTimers = () => {
|
|
3859
3991
|
if (loaderRaf !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(loaderRaf)
|
|
3860
3992
|
loaderRaf = null
|
|
@@ -3923,7 +4055,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
3923
4055
|
// animation (loaderDone stays false, so the retry is the real first run).
|
|
3924
4056
|
if (!document.body) {
|
|
3925
4057
|
if (typeof document.addEventListener === 'function') {
|
|
3926
|
-
|
|
4058
|
+
/* Kept in a variable the run's dispose effect can remove: a bundle that
|
|
4059
|
+
evaluated pre-body and a run disposed before DOMContentLoaded must not
|
|
4060
|
+
wire the dead closure back into the page. */
|
|
4061
|
+
loaderDomReadyPending = () => { runLoader() }
|
|
4062
|
+
document.addEventListener('DOMContentLoaded', loaderDomReadyPending, { once: true })
|
|
3927
4063
|
}
|
|
3928
4064
|
return
|
|
3929
4065
|
}
|
|
@@ -4294,10 +4430,17 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
4294
4430
|
let thunderRebindTimer = null
|
|
4295
4431
|
/* Retry budget for thunderRebind, bounded like every other retry in this
|
|
4296
4432
|
file (rebindPrefs 40, settle watch 20): a permanently absent sessions
|
|
4297
|
-
service must not leave a 120 ms poll running for the life of the page.
|
|
4298
|
-
|
|
4299
|
-
the
|
|
4433
|
+
service must not leave a 120 ms poll running for the life of the page. The
|
|
4434
|
+
budget is two-phase — 100 fast tries (~12s) for the ordinary cold boot, then
|
|
4435
|
+
a slow phase (one service probe every 5s, bounded) for the runtime that
|
|
4436
|
+
arrives later than that, e.g. on a slow mirror. Once a service IS seen the
|
|
4437
|
+
counter resets, so a later service loss gets a fresh budget. */
|
|
4300
4438
|
let thunderRebindAttempts = 0
|
|
4439
|
+
/* Two-phase shape of the budget above: ~12s of fast tries, then up to twenty
|
|
4440
|
+
more minutes of one cheap ctx.get probe every 5s. */
|
|
4441
|
+
const THUNDER_REBIND_FAST_LIMIT = 100
|
|
4442
|
+
const THUNDER_REBIND_SLOW_MS = 5000
|
|
4443
|
+
const THUNDER_REBIND_SLOW_END = 340
|
|
4301
4444
|
let thunderWatchedId = null
|
|
4302
4445
|
// null = nothing readable observed yet, so the next value is a baseline.
|
|
4303
4446
|
let thunderLastRunning = null
|
|
@@ -4370,17 +4513,21 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
4370
4513
|
if (thunderRebindTimer !== null && typeof clearTimeout === 'function') clearTimeout(thunderRebindTimer)
|
|
4371
4514
|
thunderRebindTimer = null
|
|
4372
4515
|
/* Service not there yet: keep retrying rather than giving up for good, since
|
|
4373
|
-
the only reason to be here is that the feature is switched on —
|
|
4374
|
-
|
|
4516
|
+
the only reason to be here is that the feature is switched on — fast tries
|
|
4517
|
+
first, then a slow probe cadence, both bounded (see the budget note). */
|
|
4375
4518
|
if (sessions === undefined) {
|
|
4376
|
-
if (thunderRebindAttempts >=
|
|
4519
|
+
if (thunderRebindAttempts >= THUNDER_REBIND_SLOW_END) {
|
|
4377
4520
|
dbg('thunder rebind gave up: no sessions service after', thunderRebindAttempts, 'tries')
|
|
4378
4521
|
return
|
|
4379
4522
|
}
|
|
4380
4523
|
thunderRebindAttempts += 1
|
|
4381
|
-
if (typeof setTimeout === 'function')
|
|
4524
|
+
if (typeof setTimeout === 'function') {
|
|
4525
|
+
thunderRebindTimer = setTimeout(thunderRebind,
|
|
4526
|
+
thunderRebindAttempts > THUNDER_REBIND_FAST_LIMIT ? THUNDER_REBIND_SLOW_MS : 120)
|
|
4527
|
+
}
|
|
4382
4528
|
return
|
|
4383
4529
|
}
|
|
4530
|
+
thunderRebindAttempts = 0
|
|
4384
4531
|
// The list subscription may have been skipped earlier (no service then), so
|
|
4385
4532
|
// attach it as soon as one exists.
|
|
4386
4533
|
thunderSubscribeList(sessions)
|
|
@@ -4517,8 +4664,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
4517
4664
|
let audioAttentionObserver = null
|
|
4518
4665
|
let audioAttentionFrame = null
|
|
4519
4666
|
let audioAttentionKind = null
|
|
4667
|
+
/* Attach the observer exactly once per watch, retrying until <body> exists:
|
|
4668
|
+
a first sync that ran pre-body used to start the interval with no observer
|
|
4669
|
+
and every later sync short-circuited on the running timer, leaving that page
|
|
4670
|
+
load on the 1s poll forever. The tick is the retry point. */
|
|
4671
|
+
const audioAttentionEnsureObserver = () => {
|
|
4672
|
+
if (audioAttentionObserver !== null) return
|
|
4673
|
+
if (typeof MutationObserver !== 'function' || typeof document === 'undefined' || !document.body) return
|
|
4674
|
+
audioAttentionObserver = new MutationObserver(audioAttentionSchedule)
|
|
4675
|
+
try {
|
|
4676
|
+
audioAttentionObserver.observe(document.body, { childList: true, subtree: true })
|
|
4677
|
+
} catch (e) {
|
|
4678
|
+
audioAttentionObserver = null
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4520
4681
|
const audioAttentionTick = () => {
|
|
4521
4682
|
try {
|
|
4683
|
+
audioAttentionEnsureObserver()
|
|
4522
4684
|
const kind = detectPendingInteraction()
|
|
4523
4685
|
if (kind === null) {
|
|
4524
4686
|
audioAttentionKind = null
|
|
@@ -4547,14 +4709,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
4547
4709
|
if (wanted && audioAttentionTimer === null && typeof setInterval === 'function') {
|
|
4548
4710
|
audioAttentionKind = null
|
|
4549
4711
|
audioAttentionTimer = setInterval(audioAttentionTick, AUDIO_ATTENTION_POLL_MS)
|
|
4550
|
-
|
|
4551
|
-
audioAttentionObserver = new MutationObserver(audioAttentionSchedule)
|
|
4552
|
-
try {
|
|
4553
|
-
audioAttentionObserver.observe(document.body, { childList: true, subtree: true })
|
|
4554
|
-
} catch (e) {
|
|
4555
|
-
audioAttentionObserver = null
|
|
4556
|
-
}
|
|
4557
|
-
}
|
|
4712
|
+
audioAttentionEnsureObserver()
|
|
4558
4713
|
audioAttentionTick()
|
|
4559
4714
|
} else if (!wanted && audioAttentionTimer !== null) {
|
|
4560
4715
|
stopAudioAttentionWatch()
|
|
@@ -4614,10 +4769,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
4614
4769
|
const BALANCE_KEY = 'balanceCapsule'
|
|
4615
4770
|
const isBalanceCapsuleOn = () => prefsGet(BALANCE_KEY) === '1'
|
|
4616
4771
|
/* What the credits mode's RIGHT-hand percentage slot reads: 已用xx% (the
|
|
4617
|
-
plugin's own badge reading) or 剩余xx%. Anything other than '
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
readContourRenderer treats its own select. */
|
|
4772
|
+
plugin's own badge reading) or 剩余xx%. Anything other than 'used' reads as
|
|
4773
|
+
'remaining' — the preference is a two-literal choice, stored as exactly one
|
|
4774
|
+
of them, and 'remaining' is the default the field ships with. */
|
|
4621
4775
|
const CREDIT_DISPLAY_KEY = 'creditDisplay'
|
|
4622
4776
|
const readCreditDisplay = () => (prefsGet(CREDIT_DISPLAY_KEY) === 'used' ? 'used' : 'remaining')
|
|
4623
4777
|
let balanceEl = null
|
|
@@ -4824,6 +4978,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
4824
4978
|
creditProviderResolved = false
|
|
4825
4979
|
creditsRebindAttempts = 0
|
|
4826
4980
|
creditsWatchedStore = null
|
|
4981
|
+
/* The per-provider fetch stamps go too. They are the throttle's memory, and
|
|
4982
|
+
a destroy (preview remount, channel teardown) is a fresh load as far as
|
|
4983
|
+
the capsule is concerned: keeping the stamps would throttle the first
|
|
4984
|
+
post-remount fetch on the pre-destroy timestamp and leave the channel
|
|
4985
|
+
read stuck on '--' for up to the whole 5-minute floor — which is exactly
|
|
4986
|
+
the recovery the preview's collapse is supposed to perform, and which the
|
|
4987
|
+
unthrottled wallet path already gets. */
|
|
4988
|
+
for (const stampKey of Object.keys(creditLastFetch)) delete creditLastFetch[stampKey]
|
|
4827
4989
|
if (balanceSidebarRo !== null) {
|
|
4828
4990
|
try { balanceSidebarRo.disconnect() } catch (e) { /* already gone */ }
|
|
4829
4991
|
balanceSidebarRo = null
|
|
@@ -5148,31 +5310,34 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
5148
5310
|
const next = (typeof provider === 'string' && JET_HUB_PROVIDER_LABELS[provider] !== undefined)
|
|
5149
5311
|
? provider
|
|
5150
5312
|
: null
|
|
5151
|
-
|
|
5313
|
+
const previous = creditProvider
|
|
5314
|
+
if (next !== previous || (next !== null && !creditProviderResolved)) {
|
|
5152
5315
|
creditProvider = next
|
|
5153
5316
|
creditProviderResolved = next !== null
|
|
5154
5317
|
creditPaintIdle()
|
|
5155
5318
|
}
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5319
|
+
/* A switch (the resolved provider differs from the painted one) forces the
|
|
5320
|
+
fetch so the read follows the channel immediately; an ordinary publish
|
|
5321
|
+
resolving the SAME provider keeps the per-provider rate floor, exactly as
|
|
5322
|
+
the creditsRebind note promises. Both the 60s poll and the debounced
|
|
5323
|
+
store-subscription path pass force=false and rely on this detector. */
|
|
5324
|
+
if (next !== null) await creditsApply(next, force === true || next !== previous)
|
|
5160
5325
|
}
|
|
5161
5326
|
|
|
5162
5327
|
/* Store events land here; a channel flip re-reads the directory and
|
|
5163
5328
|
repaints. Debounced so a burst of publishes collapses into one read.
|
|
5164
|
-
The
|
|
5165
|
-
delayed read resolves differs from the
|
|
5166
|
-
forced
|
|
5167
|
-
the throttle. */
|
|
5329
|
+
The read passes force=false: creditsRefresh itself is the switch
|
|
5330
|
+
detector — if the provider the delayed read resolves differs from the
|
|
5331
|
+
one painted, the fetch is forced; ordinary publishes (balance churn,
|
|
5332
|
+
model-list updates) keep the throttle. */
|
|
5168
5333
|
const creditsRebind = () => {
|
|
5169
5334
|
if (balanceEl === null) return
|
|
5170
5335
|
if (creditsRebindTimer !== null && typeof clearTimeout === 'function') clearTimeout(creditsRebindTimer)
|
|
5171
5336
|
creditsRebindTimer = null
|
|
5172
|
-
if (typeof setTimeout !== 'function') { creditsRefresh(
|
|
5337
|
+
if (typeof setTimeout !== 'function') { creditsRefresh(false); return }
|
|
5173
5338
|
creditsRebindTimer = setTimeout(() => {
|
|
5174
5339
|
creditsRebindTimer = null
|
|
5175
|
-
creditsRefresh(
|
|
5340
|
+
creditsRefresh(false)
|
|
5176
5341
|
}, 150)
|
|
5177
5342
|
}
|
|
5178
5343
|
|
|
@@ -6577,6 +6742,37 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
6577
6742
|
[data-cordis-decline]:hover:not(:disabled) {
|
|
6578
6743
|
background: #d6281d !important;
|
|
6579
6744
|
}
|
|
6745
|
+
/* ---------- 审批白条的前提:祖先不能是 containing block(issue #41) ----------
|
|
6746
|
+
The band below fills the VIEWPORT only while no ancestor between the
|
|
6747
|
+
panel and the viewport is a containing block for fixed descendants.
|
|
6748
|
+
DSH 0.2.2-rc.1 declared will-change: transform on the composer seat,
|
|
6749
|
+
which builds that containing block exactly like a real transform does
|
|
6750
|
+
(CSS Will Change: a property that would make the element generate a
|
|
6751
|
+
containing block MUST do so when named in will-change). The approval
|
|
6752
|
+
panel replaces the composer and therefore lives INSIDE that seat, so
|
|
6753
|
+
position: fixed; inset: 0 started resolving against the seat's
|
|
6754
|
+
padding box instead of the viewport. Fixed also takes the panel out of
|
|
6755
|
+
flow, which left the seat with no in-flow child: its height collapsed
|
|
6756
|
+
to 0, the layer resolved to the seat's width by ZERO height, and the
|
|
6757
|
+
card centred on that line straddled the window's bottom edge (fixture:
|
|
6758
|
+
layer 720x0, card running 95px past the viewport). Only the top strip
|
|
6759
|
+
reading 「● 等待审批」 survived — the reported screenshot, and the
|
|
6760
|
+
152-second approval that followed.
|
|
6761
|
+
The fix is one declaration on the ancestor, not on the band: CSS
|
|
6762
|
+
cannot escape a containing block, so a transformed/will-changed
|
|
6763
|
+
ancestor is not something the band can style its way out of. Dropping
|
|
6764
|
+
the hint only while a panel is mounted keeps the app's compositing
|
|
6765
|
+
hint for every other frame. Verified live in Chrome: computed
|
|
6766
|
+
will-change flips to auto and the layer measures the full viewport;
|
|
6767
|
+
deleting this declaration at runtime collapses the same layer back to
|
|
6768
|
+
zero height, and re-inserting it restores the viewport rect
|
|
6769
|
+
(test/approval-band-transform.test.js covers both directions).
|
|
6770
|
+
Both anchors are listed because the [class$=] hook dies the moment
|
|
6771
|
+
upstream appends a second class to the seat, and [data-composer-seat]
|
|
6772
|
+
is the e2e hook the app itself declares. */
|
|
6773
|
+
body:has([data-approval-key]) :is([data-composer-seat], [class$='_composerSeat']) {
|
|
6774
|
+
will-change: auto !important;
|
|
6775
|
+
}
|
|
6580
6776
|
/* ---------- 审批弹窗:全屏白条 + 上下遮罩(Endfield 寻访确认框) ----------
|
|
6581
6777
|
The shipped panel is a rounded warn-bordered card parked inside the
|
|
6582
6778
|
composer column (max-width: --dsh-chat-content-width). The reference
|
|
@@ -6594,9 +6790,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
6594
6790
|
CSS-module SUFFIXES, each rule scoped under the root so a generic
|
|
6595
6791
|
'_card' / '_body' elsewhere in the app is untouched.
|
|
6596
6792
|
position:fixed escapes the composer column's width cap and the
|
|
6597
|
-
frame's overflow clip
|
|
6598
|
-
|
|
6599
|
-
|
|
6793
|
+
frame's overflow clip, and it lands on the viewport only while the
|
|
6794
|
+
seat does not build a containing block — the rule above the block is
|
|
6795
|
+
what keeps that true (issue #41), not the absence of ancestors with a
|
|
6796
|
+
transform. An earlier revision of this note claimed a transformed
|
|
6797
|
+
ancestor would merely leave the band in flow, "degraded position,
|
|
6798
|
+
never a broken dialog"; that was wrong, and #41 is the proof: fixed
|
|
6799
|
+
does not fall back to static, it keeps laying out against a seat that
|
|
6800
|
+
has just been emptied, so the band collapses instead of degrading. */
|
|
6600
6801
|
[data-approval-key] {
|
|
6601
6802
|
position: fixed !important;
|
|
6602
6803
|
inset: 0 !important;
|
|
@@ -7274,7 +7475,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
7274
7475
|
|
|
7275
7476
|
Note the + is the ONLY composer element whose class contains '_add'; the
|
|
7276
7477
|
scope cannot reach the settings wrappers, the diff lines or the address bar.
|
|
7277
|
-
Verified by test/hover-check.js against real hovered pixels in both
|
|
7478
|
+
Verified by test/tools/hover-check.js against real hovered pixels in both
|
|
7278
7479
|
palettes and both schemes. */
|
|
7279
7480
|
body[data-ds-dark-theme] :is([data-composer-seat], [class$='_composerSeat'], [class$='_composerHero']) [class*='_add'] {
|
|
7280
7481
|
color: var(--edge-accent) !important;
|
|
@@ -7832,6 +8033,27 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
7832
8033
|
body[data-ds-dark-theme] .endfield-settings-group-title {
|
|
7833
8034
|
color: var(--edge-status-dark);
|
|
7834
8035
|
}
|
|
8036
|
+
/* ---------- Settings page: 二级折叠子节 ----------
|
|
8037
|
+
折叠头是全宽 button:文字颜色继承面板自身(主题关闭时仍是 app 的
|
|
8038
|
+
ink,可读性由 app 令牌保证),只有悬停/聚焦态借用组头同款的
|
|
8039
|
+
palette 配色对 —— 静止的目录保持中性,交互时的反馈才着色。
|
|
8040
|
+
类名避开主题 hover 反转规则的语义子串(item/row/card/tab…),
|
|
8041
|
+
那条 !important 规则碰不到这个控件。
|
|
8042
|
+
body 以 [hidden] 收起而非条件渲染:行不离开 DOM,display:none
|
|
8043
|
+
是显式兜底,防止宿主或 UA 样式表把 hidden 覆盖回去。 */
|
|
8044
|
+
.endfield-settings-sub-toggle:hover {
|
|
8045
|
+
color: var(--edge-status-light);
|
|
8046
|
+
}
|
|
8047
|
+
body[data-ds-dark-theme] .endfield-settings-sub-toggle:hover {
|
|
8048
|
+
color: var(--edge-status-dark);
|
|
8049
|
+
}
|
|
8050
|
+
.endfield-settings-sub-toggle:focus-visible {
|
|
8051
|
+
outline: 1px solid currentColor;
|
|
8052
|
+
outline-offset: 2px;
|
|
8053
|
+
}
|
|
8054
|
+
.endfield-settings-sub-body[hidden] {
|
|
8055
|
+
display: none;
|
|
8056
|
+
}
|
|
7835
8057
|
/* ================= 雷霆大字 (娱乐模式) =================
|
|
7836
8058
|
The task-boundary announcement. Fixed, centred, above the shell overlay
|
|
7837
8059
|
layer and the app's own dialogs but BELOW the boot plate (2147483000), so
|
|
@@ -7850,7 +8072,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
7850
8072
|
2.11:1 on bg-layer-1, i.e. the same "on cream, #fff500 is not a colour
|
|
7851
8073
|
choice, it is an erasure" failure this theme already documents for the
|
|
7852
8074
|
watermark and the turn-status label — except here it was pure white. Caught
|
|
7853
|
-
by rendering the plate and measuring the pixels (test/thunder-shot.js), not
|
|
8075
|
+
by rendering the plate and measuring the pixels (test/tools/thunder-shot.js), not
|
|
7854
8076
|
by reading the CSS.
|
|
7855
8077
|
|
|
7856
8078
|
Measured white-vs-scrimmed-surface, ink #101110 over each light surface:
|
|
@@ -8419,6 +8641,39 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
8419
8641
|
[data-endfield-balance][data-endfield-balance-shade][data-endfield-balance-position] {
|
|
8420
8642
|
transform: translateY(-6px);
|
|
8421
8643
|
}
|
|
8644
|
+
|
|
8645
|
+
/* ---------- Ink fills need an inverted foreground in light mode ----------
|
|
8646
|
+
--dsw-alias-state-business-primary is ink in the light palette (see the
|
|
8647
|
+
neutralize block above), and consumers paint it as a FILL while their
|
|
8648
|
+
glyph stays on --dsw-alias-label-primary — which is ink too. Fill and
|
|
8649
|
+
glyph then resolve to #101110 and the control reads as a solid black
|
|
8650
|
+
rectangle: the host's active settings tab lost its own label, and
|
|
8651
|
+
third-party switches lost their knob.
|
|
8652
|
+
|
|
8653
|
+
Fixing it here rather than by repainting the token keeps the light
|
|
8654
|
+
palette's ink/paper language: the fill stays ink, only the glyph moves
|
|
8655
|
+
to the host's pure white. Not --dsw-alias-label-primary-inverted: this
|
|
8656
|
+
sheet already repaints that role to ink in both palettes (see the warm
|
|
8657
|
+
label block), so the knob would stay invisible.
|
|
8658
|
+
|
|
8659
|
+
Both selectors are structural rather than hash-named: the tab hook is
|
|
8660
|
+
the ARIA contract, and the switch hook matches the semantic suffix
|
|
8661
|
+
every switch carries — dsh-better-display's pinned class and the
|
|
8662
|
+
host's own CSS-module switches alike end in _switch, and the
|
|
8663
|
+
[data-on] guard keeps the rule to the checked state, so an unchecked
|
|
8664
|
+
knob keeps its ink fill. The hash form this replaces (the shape PR #37
|
|
8665
|
+
first shipped) is exactly what selector-guard's part 1 fails on: the
|
|
8666
|
+
suffix survives any rehash, the pinned class does not.
|
|
8667
|
+
|
|
8668
|
+
The tab hook is scoped to dialogs on purpose: the app's top-level nav
|
|
8669
|
+
is role=tab too, but it sits on paper with an ink underline, so its
|
|
8670
|
+
selected label must stay ink. */
|
|
8671
|
+
body:not([data-ds-dark-theme]) [role='dialog'] [role='tab'][aria-selected='true'] {
|
|
8672
|
+
color: var(--dsw-static-neutral-bluish-00) !important;
|
|
8673
|
+
}
|
|
8674
|
+
body:not([data-ds-dark-theme]) [class$='_switch'][data-on]::after {
|
|
8675
|
+
background: var(--dsw-static-neutral-bluish-00) !important;
|
|
8676
|
+
}
|
|
8422
8677
|
`)
|
|
8423
8678
|
syncRadiusMode()
|
|
8424
8679
|
syncGlass()
|
|
@@ -8580,9 +8835,18 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
8580
8835
|
groupBg: '背景',
|
|
8581
8836
|
groupAnim: '动画',
|
|
8582
8837
|
groupFun: '娱乐',
|
|
8583
|
-
/*
|
|
8584
|
-
|
|
8585
|
-
|
|
8838
|
+
/* 二级折叠子节的标题(组 > 子节 > 行)。子节头是「名称 + 拉丁大写 +
|
|
8839
|
+
状态摘要」加一个随展开态翻转的箭头;与组头一样,拉丁行在英文界面下
|
|
8840
|
+
与中文名重复时省略。状态摘要复用各行已有的 on/off 文案,不新增词汇。 */
|
|
8841
|
+
subContour: '等高线',
|
|
8842
|
+
subWatermark: '水印',
|
|
8843
|
+
subScrollAnim: '输出滚动动画',
|
|
8844
|
+
subLoader: '启动加载动画',
|
|
8845
|
+
subThunder: '雷霆大字',
|
|
8846
|
+
subBalance: '余额胶囊',
|
|
8847
|
+
subAudioSlots: '通知槽位',
|
|
8848
|
+
subAudioSound: '音量与音源',
|
|
8849
|
+
subAudioMeta: '预留与诊断',
|
|
8586
8850
|
themeRow: '终末地主题',
|
|
8587
8851
|
themeOn: '开启主题',
|
|
8588
8852
|
themeOff: '关闭主题',
|
|
@@ -8607,6 +8871,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
8607
8871
|
contourTrailOn: '开启轨迹',
|
|
8608
8872
|
contourTrailOff: '关闭轨迹',
|
|
8609
8873
|
contourTrailHint: '鼠标移动时局部扰动等高线并逐渐恢复;仅动态模式生效,静态或减少动态效果时暂停',
|
|
8874
|
+
contourTrailHintWorker: 'Worker/WebGL 渲染暂不支持轨迹,切回 Canvas 渲染后生效',
|
|
8610
8875
|
contourAnimRow: '动态等高线',
|
|
8611
8876
|
contourAnimOn: '开启动态',
|
|
8612
8877
|
contourAnimOff: '切为静态',
|
|
@@ -8770,6 +9035,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
8770
9035
|
groupBg: 'BACKGROUND',
|
|
8771
9036
|
groupAnim: 'ANIMATION',
|
|
8772
9037
|
groupFun: 'ENTERTAINMENT',
|
|
9038
|
+
subContour: 'CONTOUR',
|
|
9039
|
+
subWatermark: 'WORDMARK',
|
|
9040
|
+
subScrollAnim: 'SCROLL',
|
|
9041
|
+
subLoader: 'BOOT',
|
|
9042
|
+
subThunder: 'THUNDER',
|
|
9043
|
+
subBalance: 'BALANCE',
|
|
9044
|
+
subAudioSlots: 'SLOTS',
|
|
9045
|
+
subAudioSound: 'SOUND',
|
|
9046
|
+
subAudioMeta: 'RESERVED',
|
|
8773
9047
|
themeRow: 'Endfield theme',
|
|
8774
9048
|
themeOn: 'Turn on',
|
|
8775
9049
|
themeOff: 'Turn off',
|
|
@@ -8794,6 +9068,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
8794
9068
|
contourTrailOn: 'Enable trail',
|
|
8795
9069
|
contourTrailOff: 'Disable trail',
|
|
8796
9070
|
contourTrailHint: 'Locally deforms contours near the mouse, then fades; pauses in static or reduced-motion mode',
|
|
9071
|
+
contourTrailHintWorker: 'Not drawn by the Worker/WebGL renderer; switch back to Canvas to use it',
|
|
8797
9072
|
contourAnimRow: 'Animated contours',
|
|
8798
9073
|
contourAnimOn: 'Animate',
|
|
8799
9074
|
contourAnimOff: 'Make static',
|
|
@@ -9025,6 +9300,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9025
9300
|
const [audioDiag, setAudioDiag] = R.useState(isAudioDiagOn())
|
|
9026
9301
|
const [hostState, setHostState] = R.useState(null)
|
|
9027
9302
|
const [previewNote, setPreviewNote] = R.useState('')
|
|
9303
|
+
/* 二级折叠子节的展开表。一个对象存全部子节(id -> bool),而不是每个
|
|
9304
|
+
子节一个 useState:这些子节由同一个 render 函数内联构造,没有独立
|
|
9305
|
+
的组件类型,逐节建 hook 既无法命名也无法稳定计数。缺省(未登记)
|
|
9306
|
+
即收起,所以每次打开设置页都从最紧凑的目录开始;这是纯 UI 状态,
|
|
9307
|
+
不进持久化偏好(durable section 只收功能开关)。测试桩的 setter
|
|
9308
|
+
是 no-op,同样依赖「body 始终渲染、仅以 hidden 收起」来在收起态
|
|
9309
|
+
下保持整棵行树可遍历。 */
|
|
9310
|
+
const [openSubs, setOpenSubs] = R.useState({})
|
|
9311
|
+
const toggleSub = (id) => setOpenSubs((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
9028
9312
|
const refreshHostState = () => {
|
|
9029
9313
|
if (typeof fetch !== 'function') return
|
|
9030
9314
|
fetch(AUDIO_STATE_URL, { headers: { accept: 'application/json' } })
|
|
@@ -9191,13 +9475,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9191
9475
|
const next = !isEnabled()
|
|
9192
9476
|
prefsSet(ENABLED_KEY, next ? '1' : '0')
|
|
9193
9477
|
setEnabled(next)
|
|
9194
|
-
if (next) {
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9478
|
+
if (next) {
|
|
9479
|
+
mount()
|
|
9480
|
+
syncWatermarkVisibility()
|
|
9481
|
+
syncContour()
|
|
9482
|
+
/* The same list the initial mount block and reconcileFromPrefs run:
|
|
9483
|
+
on a scope that is NOT durably served no authoritative echo ever
|
|
9484
|
+
comes back to run reconcileFromPrefs, so every layer gated on its
|
|
9485
|
+
own switch has to be reconciled here or it stays dead until the
|
|
9486
|
+
next page load — the trap the thunder sync used to document. */
|
|
9487
|
+
syncScrollAnim()
|
|
9488
|
+
syncThunder()
|
|
9489
|
+
syncAudioAttentionWatch()
|
|
9490
|
+
syncBalanceCapsule()
|
|
9491
|
+
} else {
|
|
9492
|
+
unmount()
|
|
9493
|
+
syncWatermarkVisibility()
|
|
9494
|
+
}
|
|
9201
9495
|
}
|
|
9202
9496
|
const toggleContour = () => {
|
|
9203
9497
|
const next = !isContourOn()
|
|
@@ -9420,7 +9714,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9420
9714
|
title: audioOn ? '' : t('audioNeedOn'),
|
|
9421
9715
|
}, t('audioTest') + ' · ' + t(labelKey))
|
|
9422
9716
|
const toggleAudio = () => {
|
|
9423
|
-
const next = !
|
|
9717
|
+
const next = !isAudioOn()
|
|
9424
9718
|
prefsSet(AUDIO_ENABLED_KEY, next ? '1' : '0')
|
|
9425
9719
|
setAudioOn(next)
|
|
9426
9720
|
// The attention watcher is gated on this switch, so it has to be
|
|
@@ -9429,13 +9723,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9429
9723
|
if (next) playPreview('turn-done')
|
|
9430
9724
|
}
|
|
9431
9725
|
const toggleAudioStart = () => {
|
|
9432
|
-
const next = !
|
|
9726
|
+
const next = !isAudioStartOn()
|
|
9433
9727
|
prefsSet(AUDIO_TURN_START_KEY, next ? '1' : '0')
|
|
9434
9728
|
setAudioStart(next)
|
|
9435
9729
|
if (next) playPreview('turn-start')
|
|
9436
9730
|
}
|
|
9437
9731
|
const toggleAudioBoot = () => {
|
|
9438
|
-
const next = !
|
|
9732
|
+
const next = !isAudioBootOn()
|
|
9439
9733
|
prefsSet(AUDIO_BOOT_KEY, next ? '1' : '0')
|
|
9440
9734
|
setAudioBoot(next)
|
|
9441
9735
|
// Preview the boot slot itself: the real one fires from the loader,
|
|
@@ -9443,7 +9737,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9443
9737
|
if (next) playPreview('boot')
|
|
9444
9738
|
}
|
|
9445
9739
|
const toggleAudioDone = () => {
|
|
9446
|
-
const next = !
|
|
9740
|
+
const next = !isAudioDoneOn()
|
|
9447
9741
|
prefsSet(AUDIO_TURN_DONE_KEY, next ? '1' : '0')
|
|
9448
9742
|
setAudioDone(next)
|
|
9449
9743
|
if (next) playPreview('turn-done')
|
|
@@ -9454,12 +9748,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9454
9748
|
setAudioVolume(clamped)
|
|
9455
9749
|
}
|
|
9456
9750
|
const toggleAudioHumanOnly = () => {
|
|
9457
|
-
const next = !
|
|
9751
|
+
const next = !isAudioHumanOnly()
|
|
9458
9752
|
prefsSet(AUDIO_HUMAN_ONLY_KEY, next ? '1' : '0')
|
|
9459
9753
|
setAudioHumanOnly(next)
|
|
9460
9754
|
}
|
|
9461
9755
|
const toggleAudioDiag = () => {
|
|
9462
|
-
const next = !
|
|
9756
|
+
const next = !isAudioDiagOn()
|
|
9463
9757
|
prefsSet(AUDIO_DIAG_KEY, next ? '1' : '0')
|
|
9464
9758
|
setAudioDiag(next)
|
|
9465
9759
|
}
|
|
@@ -9507,10 +9801,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9507
9801
|
return rows.join(' · ')
|
|
9508
9802
|
}
|
|
9509
9803
|
const pageStyle = { maxWidth: '640px', padding: '4px 0 16px' }
|
|
9510
|
-
/* The
|
|
9804
|
+
/* The switches are grouped into five concerns so the page can be
|
|
9511
9805
|
scanned instead of read as a flat list: 主题 (master switch +
|
|
9512
|
-
appearance), 背景 (contour sheet + watermark), 动画 (
|
|
9513
|
-
娱乐 (雷霆大字 announcements
|
|
9806
|
+
appearance), 背景 (contour sheet + watermark), 动画 (output scroll
|
|
9807
|
+
animation + boot loader), 娱乐 (雷霆大字 announcements), 通知 (audio
|
|
9808
|
+
notifications).
|
|
9514
9809
|
Each group is an editorial numbered header; rows keep their stable
|
|
9515
9810
|
React keys. The last row of each group drops its divider so the next
|
|
9516
9811
|
group header's own rule is the only line between groups.
|
|
@@ -9541,6 +9836,48 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9541
9836
|
}
|
|
9542
9837
|
/** "<row label>: <on|off>" — one spelling for every status row. */
|
|
9543
9838
|
const stateOf = (on) => t(on ? 'on' : 'off')
|
|
9839
|
+
/* 二级折叠子节(组 > 子节 > 行)。头部是全宽按钮:左侧小刻线 + 中文名
|
|
9840
|
+
+ 拉丁大写(与组头同一套 editorial 语言,但整体降一级),右侧可选
|
|
9841
|
+
状态摘要 + 随展开态翻转的 +/− 刻字箭头;展开态同时由 aria-expanded
|
|
9842
|
+
表达,提示不写进行内(收起的九个子节头会把同一段文案重复九次)。
|
|
9843
|
+
body 始终渲染、仅以 hidden 属性收起:行不离开元素树,store 订阅、
|
|
9844
|
+
disabled 推导与测试的树遍历都不因折叠状态而改变;status 摘要让用户
|
|
9845
|
+
在收起时仍能看到该节主开关的读数。类名避开主题 hover 反转规则的
|
|
9846
|
+
语义子串(item/row/card/tab…),折叠头的文字颜色全程自持。 */
|
|
9847
|
+
const subSection = (id, key, status, rows) => {
|
|
9848
|
+
const open = openSubs[id] === true
|
|
9849
|
+
const bodyId = 'endfield-sub-' + id
|
|
9850
|
+
const head = [
|
|
9851
|
+
R.createElement('span', { key: 'mark', 'aria-hidden': 'true', style: { width: '3px', height: '11px', flex: '0 0 auto', background: 'currentColor', opacity: 0.55 } }),
|
|
9852
|
+
R.createElement('span', { key: 'cn', style: { fontSize: '12px', fontWeight: 600, letterSpacing: '0.12em', lineHeight: '1.5' } }, t(key)),
|
|
9853
|
+
]
|
|
9854
|
+
const latin = LOCALE_EN[key]
|
|
9855
|
+
if (latin !== undefined && latin !== t(key)) {
|
|
9856
|
+
head.push(R.createElement('span', { key: 'en', style: { fontSize: '10px', fontWeight: 500, letterSpacing: '0.18em', opacity: 0.6, lineHeight: '1.5' } }, latin))
|
|
9857
|
+
}
|
|
9858
|
+
head.push(R.createElement('span', { key: 'gap', 'aria-hidden': 'true', style: { flex: '1 1 auto' } }))
|
|
9859
|
+
if (status !== undefined) {
|
|
9860
|
+
head.push(R.createElement('span', { key: 'status', style: { fontSize: '11px', opacity: 0.66, lineHeight: '1.5' } }, status))
|
|
9861
|
+
}
|
|
9862
|
+
head.push(R.createElement('span', { key: 'arrow', 'aria-hidden': 'true', style: { flex: '0 0 auto', fontSize: '13px', lineHeight: '1' } }, open ? '−' : '+'))
|
|
9863
|
+
return R.createElement('div', { key: 'sub-' + id, className: 'endfield-settings-sub' }, [
|
|
9864
|
+
R.createElement('button', {
|
|
9865
|
+
key: 'head',
|
|
9866
|
+
type: 'button',
|
|
9867
|
+
className: 'endfield-settings-sub-toggle',
|
|
9868
|
+
onClick: () => toggleSub(id),
|
|
9869
|
+
'aria-expanded': open,
|
|
9870
|
+
'aria-controls': bodyId,
|
|
9871
|
+
style: {
|
|
9872
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
|
|
9873
|
+
padding: '10px 0', background: 'transparent', border: 'none',
|
|
9874
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1)',
|
|
9875
|
+
color: 'inherit', textAlign: 'left', cursor: 'pointer',
|
|
9876
|
+
},
|
|
9877
|
+
}, head),
|
|
9878
|
+
R.createElement('div', { key: 'sub-body-' + id, id: bodyId, hidden: !open, className: 'endfield-settings-sub-body' }, rows),
|
|
9879
|
+
])
|
|
9880
|
+
}
|
|
9544
9881
|
const row = (key, last, children) => R.createElement('div', { key, style: last ? { ...rowStyle, borderBottom: 'none' } : rowStyle }, children)
|
|
9545
9882
|
return R.createElement('div', { className: 'endfield-settings', style: pageStyle }, [
|
|
9546
9883
|
/* --- 01 主题:总开关在最前,随后是配色与圆角 --- */
|
|
@@ -9599,6 +9936,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9599
9936
|
/* --- 02 背景:等高线 + 水印,各自的主开关在前、附属开关在后 --- */
|
|
9600
9937
|
R.createElement('div', { key: 'group-bg' }, [
|
|
9601
9938
|
groupTitle('02', 'groupBg', false),
|
|
9939
|
+
subSection('contour', 'subContour', stateOf(contourOn), [
|
|
9602
9940
|
row('contour', false, [
|
|
9603
9941
|
R.createElement('span', { style: labelStyle },
|
|
9604
9942
|
t('contourRow') + t('sep') + stateOf(contourOn),
|
|
@@ -9632,7 +9970,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9632
9970
|
row('contour-trail', false, [
|
|
9633
9971
|
R.createElement('span', { style: labelStyle },
|
|
9634
9972
|
t('contourTrailRow') + t('sep') + stateOf(contourTrailOn),
|
|
9635
|
-
R.createElement('span', { style: hintStyle },
|
|
9973
|
+
R.createElement('span', { style: hintStyle },
|
|
9974
|
+
// Say so when the selected renderer cannot draw the trail,
|
|
9975
|
+
// rather than letting the switch look broken.
|
|
9976
|
+
(contourTrailOn && contourRenderer === 'worker-webgl')
|
|
9977
|
+
? t('contourTrailHintWorker')
|
|
9978
|
+
: t('contourTrailHint')
|
|
9979
|
+
)
|
|
9636
9980
|
),
|
|
9637
9981
|
R.createElement('button', {
|
|
9638
9982
|
type: 'button', onClick: toggleContourTrail,
|
|
@@ -9697,6 +10041,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9697
10041
|
title: contourOn ? '' : t('contourAnimNeedLayer'),
|
|
9698
10042
|
}, t(contourScrollPause ? 'contourScrollPauseOff' : 'contourScrollPauseOn'))
|
|
9699
10043
|
]),
|
|
10044
|
+
]),
|
|
10045
|
+
subSection('watermark', 'subWatermark', stateOf(wmOn), [
|
|
9700
10046
|
row('watermark', false, [
|
|
9701
10047
|
R.createElement('span', { style: labelStyle }, t('watermarkRow') + t('sep') + stateOf(wmOn)),
|
|
9702
10048
|
R.createElement('button', { type: 'button', onClick: toggleWm, style: btnStyleFor(wmOn) }, t(wmOn ? 'watermarkOff' : 'watermarkOn'))
|
|
@@ -9717,10 +10063,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9717
10063
|
title: wmOn ? '' : t('wmPersistNeedWm'),
|
|
9718
10064
|
}, t(wmPersist ? 'wmPersistOff' : 'wmPersistOn'))
|
|
9719
10065
|
]),
|
|
10066
|
+
]),
|
|
9720
10067
|
]),
|
|
9721
10068
|
/* --- 03 动画:输出滚动动画 + 启动加载动画 --- */
|
|
9722
10069
|
R.createElement('div', { key: 'group-anim' }, [
|
|
9723
10070
|
groupTitle('03', 'groupAnim', false),
|
|
10071
|
+
subSection('scroll', 'subScrollAnim', stateOf(scrollAnim), [
|
|
9724
10072
|
row('scroll-anim', false, [
|
|
9725
10073
|
R.createElement('span', { style: labelStyle },
|
|
9726
10074
|
t('scrollAnimRow') + t('sep') + stateOf(scrollAnim),
|
|
@@ -9758,6 +10106,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9758
10106
|
}, t(SCROLL_ANIM_LEVEL_COPY[level])))
|
|
9759
10107
|
)
|
|
9760
10108
|
]),
|
|
10109
|
+
]),
|
|
10110
|
+
subSection('loader', 'subLoader', stateOf(loaderOn), [
|
|
9761
10111
|
row('loader', true, [
|
|
9762
10112
|
R.createElement('span', { style: labelStyle },
|
|
9763
10113
|
t('loaderRow') + t('sep') + stateOf(loaderOn),
|
|
@@ -9781,10 +10131,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9781
10131
|
R.createElement('button', { type: 'button', onClick: toggleLoader, style: btnStyleFor(loaderOn) }, t(loaderOn ? 'loaderOff' : 'loaderOn'))
|
|
9782
10132
|
)
|
|
9783
10133
|
]),
|
|
10134
|
+
]),
|
|
9784
10135
|
]),
|
|
9785
10136
|
/* --- 04 娱乐:雷霆大字(主开关 + 入场动画子开关) --- */
|
|
9786
10137
|
R.createElement('div', { key: 'group-fun' }, [
|
|
9787
10138
|
groupTitle('04', 'groupFun', false),
|
|
10139
|
+
subSection('thunder', 'subThunder', stateOf(thunderOn), [
|
|
9788
10140
|
row('thunder', false, [
|
|
9789
10141
|
R.createElement('span', { style: labelStyle },
|
|
9790
10142
|
t('thunderRow') + t('sep') + stateOf(thunderOn),
|
|
@@ -9825,6 +10177,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9825
10177
|
title: thunderOn ? '' : t('thunderNeed'),
|
|
9826
10178
|
}, t(thunderAnim ? 'thunderAnimOff' : 'thunderAnimOn'))
|
|
9827
10179
|
]),
|
|
10180
|
+
]),
|
|
10181
|
+
subSection('balance', 'subBalance', stateOf(balanceOn), [
|
|
9828
10182
|
/* --- 顶部余额胶囊:同样属于「娱乐」组的悬浮层 --- */
|
|
9829
10183
|
row('balance-capsule', false, [
|
|
9830
10184
|
R.createElement('span', { style: labelStyle },
|
|
@@ -9875,11 +10229,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9875
10229
|
t('balancePosition' + value.charAt(0).toUpperCase() + value.slice(1)))))
|
|
9876
10230
|
]),
|
|
9877
10231
|
/* --- 防遮挡两个子开关:侧栏展开自动隐藏 / 指针靠近自动隐藏 ---
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
10232
|
+
Sidebar-hide ships OFF (opening the sidebar is the user's own
|
|
10233
|
+
deliberate action); hover-hide ships ON (the capsule moving out
|
|
10234
|
+
of the pointer's way is the un-asked-for behaviour). Each toggle
|
|
10235
|
+
can silence its rule individually. The switches stay enabled
|
|
10236
|
+
with the capsule off (a pre-configuration the next mount
|
|
10237
|
+
honours), mirroring the position select above; the hint states
|
|
10238
|
+
what fades and when. */
|
|
9883
10239
|
row('balance-capsule-sidebar-hide', false, [
|
|
9884
10240
|
R.createElement('span', { style: labelStyle },
|
|
9885
10241
|
t('balanceSidebarHideRow') + t('sep') + stateOf(balanceSidebarHide),
|
|
@@ -9891,7 +10247,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9891
10247
|
type: 'button',
|
|
9892
10248
|
onClick: toggleBalanceSidebarHide,
|
|
9893
10249
|
style: btnStyleFor(balanceSidebarHide),
|
|
9894
|
-
}, t(balanceSidebarHide ? '
|
|
10250
|
+
}, t(balanceSidebarHide ? 'off' : 'on'))
|
|
9895
10251
|
]),
|
|
9896
10252
|
row('balance-capsule-hover-hide', false, [
|
|
9897
10253
|
R.createElement('span', { style: labelStyle },
|
|
@@ -9904,7 +10260,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9904
10260
|
type: 'button',
|
|
9905
10261
|
onClick: toggleBalanceHoverHide,
|
|
9906
10262
|
style: btnStyleFor(balanceHoverHide),
|
|
9907
|
-
}, t(balanceHoverHide ? '
|
|
10263
|
+
}, t(balanceHoverHide ? 'off' : 'on'))
|
|
9908
10264
|
]),
|
|
9909
10265
|
/* --- 渠道额度读数:右侧百分比显示已用 or 剩余 ---
|
|
9910
10266
|
The row lives directly under the capsule switch because it only
|
|
@@ -9927,6 +10283,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9927
10283
|
}, CREDIT_DISPLAY_OPTIONS.map((value) => R.createElement('option', { key: value, value },
|
|
9928
10284
|
t(value === 'used' ? 'creditDisplayUsed' : 'creditDisplayRemaining'))))
|
|
9929
10285
|
]),
|
|
10286
|
+
]),
|
|
9930
10287
|
]),
|
|
9931
10288
|
/* --- 05 音频:两个生效槽位 + 两个预留槽位 ---
|
|
9932
10289
|
Every row states WHEN it fires, because that is the whole contract
|
|
@@ -9934,6 +10291,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9934
10291
|
not fire yet, so a switch that does nothing cannot read as broken. */
|
|
9935
10292
|
R.createElement('div', { key: 'group-audio' }, [
|
|
9936
10293
|
groupTitle('05', 'groupAudio', false),
|
|
10294
|
+
subSection('slots', 'subAudioSlots', stateOf(audioOn), [
|
|
9937
10295
|
row('audio', false, [
|
|
9938
10296
|
R.createElement('span', { style: labelStyle },
|
|
9939
10297
|
t('audioRow') + t('sep') + stateOf(audioOn),
|
|
@@ -9993,6 +10351,21 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
9993
10351
|
}, t(audioDone ? 'audioDoneOff' : 'audioDoneOn'))
|
|
9994
10352
|
)
|
|
9995
10353
|
]),
|
|
10354
|
+
row('audio-human', true, [
|
|
10355
|
+
R.createElement('span', { style: labelStyle },
|
|
10356
|
+
t('audioHumanOnlyRow') + t('sep') + t(audioHumanOnly ? 'audioHumanOnlyOn' : 'audioHumanOnlyOff'),
|
|
10357
|
+
R.createElement('span', { style: hintStyle },
|
|
10358
|
+
t(audioHumanOnly ? 'audioHumanOnlyHintOn' : 'audioHumanOnlyHintOff')
|
|
10359
|
+
)
|
|
10360
|
+
),
|
|
10361
|
+
R.createElement('button', {
|
|
10362
|
+
type: 'button', onClick: toggleAudioHumanOnly,
|
|
10363
|
+
style: btnStyleFor(audioHumanOnly, !audioOn), disabled: !audioOn,
|
|
10364
|
+
title: audioOn ? '' : t('audioNeedOn'),
|
|
10365
|
+
}, t(audioHumanOnly ? 'audioHumanOnlyOff' : 'audioHumanOnlyOn'))
|
|
10366
|
+
]),
|
|
10367
|
+
]),
|
|
10368
|
+
subSection('sound', 'subAudioSound', String(audioVolume) + '%', [
|
|
9996
10369
|
row('audio-volume', false, [
|
|
9997
10370
|
R.createElement('span', { style: labelStyle },
|
|
9998
10371
|
t('audioVolumeRow') + t('sep') + String(audioVolume) + '%',
|
|
@@ -10011,24 +10384,6 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
10011
10384
|
R.createElement('span', { style: { ...labelStyle, minWidth: '38px', textAlign: 'right' } }, String(audioVolume) + '%')
|
|
10012
10385
|
)
|
|
10013
10386
|
]),
|
|
10014
|
-
row('audio-attention', false, [
|
|
10015
|
-
R.createElement('span', { style: labelStyle },
|
|
10016
|
-
t('audioAttentionRow') + t('sep') + stateOf(audioTestReady()),
|
|
10017
|
-
R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
|
|
10018
|
-
),
|
|
10019
|
-
R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
|
|
10020
|
-
audioTestButton('attention', 'audioSlotAttention')
|
|
10021
|
-
)
|
|
10022
|
-
]),
|
|
10023
|
-
row('audio-fail', false, [
|
|
10024
|
-
R.createElement('span', { style: labelStyle },
|
|
10025
|
-
t('audioTurnFailRow') + t('sep') + stateOf(audioTestReady()),
|
|
10026
|
-
R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
|
|
10027
|
-
),
|
|
10028
|
-
R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
|
|
10029
|
-
audioTestButton('turn-fail', 'audioSlotFail')
|
|
10030
|
-
)
|
|
10031
|
-
]),
|
|
10032
10387
|
row('audio-source', false, [
|
|
10033
10388
|
R.createElement('span', { style: labelStyle },
|
|
10034
10389
|
t('audioFileRow') + t('sep') + sourceSummary(),
|
|
@@ -10039,7 +10394,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
10039
10394
|
style: btnStyleFor(false),
|
|
10040
10395
|
}, t('audioRefresh'))
|
|
10041
10396
|
]),
|
|
10042
|
-
row('audio-dir',
|
|
10397
|
+
row('audio-dir', true, [
|
|
10043
10398
|
R.createElement('span', { style: labelStyle },
|
|
10044
10399
|
t('audioSoundDirRow') + t('sep') + (readAudioSoundDir() || t('audioSoundDirDefault')),
|
|
10045
10400
|
R.createElement('span', { style: hintStyle }, t('audioSoundDirHint'))
|
|
@@ -10056,18 +10411,27 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
10056
10411
|
})
|
|
10057
10412
|
)
|
|
10058
10413
|
]),
|
|
10059
|
-
|
|
10414
|
+
]),
|
|
10415
|
+
/* 预留槽位(需要你回应 / 出错)与诊断日志同节:三行都没有
|
|
10416
|
+
主开关,行内的试听按钮与诊断开关就是这一节的全部。 */
|
|
10417
|
+
subSection('reserved', 'subAudioMeta', undefined, [
|
|
10418
|
+
row('audio-attention', false, [
|
|
10060
10419
|
R.createElement('span', { style: labelStyle },
|
|
10061
|
-
t('
|
|
10062
|
-
R.createElement('span', { style: hintStyle },
|
|
10063
|
-
t(audioHumanOnly ? 'audioHumanOnlyHintOn' : 'audioHumanOnlyHintOff')
|
|
10064
|
-
)
|
|
10420
|
+
t('audioAttentionRow') + t('sep') + stateOf(audioTestReady()),
|
|
10421
|
+
R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
|
|
10065
10422
|
),
|
|
10066
|
-
R.createElement('
|
|
10067
|
-
|
|
10068
|
-
|
|
10069
|
-
|
|
10070
|
-
|
|
10423
|
+
R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
|
|
10424
|
+
audioTestButton('attention', 'audioSlotAttention')
|
|
10425
|
+
)
|
|
10426
|
+
]),
|
|
10427
|
+
row('audio-fail', false, [
|
|
10428
|
+
R.createElement('span', { style: labelStyle },
|
|
10429
|
+
t('audioTurnFailRow') + t('sep') + stateOf(audioTestReady()),
|
|
10430
|
+
R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
|
|
10431
|
+
),
|
|
10432
|
+
R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
|
|
10433
|
+
audioTestButton('turn-fail', 'audioSlotFail')
|
|
10434
|
+
)
|
|
10071
10435
|
]),
|
|
10072
10436
|
row('audio-diag', true, [
|
|
10073
10437
|
R.createElement('span', { style: labelStyle },
|
|
@@ -10078,6 +10442,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
10078
10442
|
),
|
|
10079
10443
|
R.createElement('button', { type: 'button', onClick: toggleAudioDiag, style: btnStyleFor(audioDiag) }, t(audioDiag ? 'audioDiagOff' : 'audioDiagOn'))
|
|
10080
10444
|
]),
|
|
10445
|
+
]),
|
|
10081
10446
|
]),
|
|
10082
10447
|
])
|
|
10083
10448
|
}
|
|
@@ -10105,6 +10470,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
|
|
|
10105
10470
|
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
10106
10471
|
document.removeEventListener('DOMContentLoaded', watermarkObserverLate)
|
|
10107
10472
|
document.removeEventListener('DOMContentLoaded', contourSchemeObserverLate)
|
|
10473
|
+
// The boot loader's pre-body deferral too: a dead run must not be wired
|
|
10474
|
+
// back in to mount an unstyled plate.
|
|
10475
|
+
if (loaderDomReadyPending !== null) {
|
|
10476
|
+
document.removeEventListener('DOMContentLoaded', loaderDomReadyPending)
|
|
10477
|
+
loaderDomReadyPending = null
|
|
10478
|
+
}
|
|
10108
10479
|
}
|
|
10109
10480
|
if (watermarkRaf !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(watermarkRaf)
|
|
10110
10481
|
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') window.removeEventListener('resize', onWatermarkResize)
|