dsh-error-atlas 0.11.2 → 0.11.25
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/CHANGELOG.md +139 -0
- package/README.md +42 -8
- package/bin/atlas-digest.mjs +14 -5
- package/bin/atlas-retro.mjs +5 -3
- package/client.js +959 -90
- package/docs/icon-180.png +0 -0
- package/index.js +227 -20
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +9 -3
- package/test/run.mjs +235 -10
package/client.js
CHANGED
|
@@ -19,6 +19,13 @@ const NS = 'error-atlas'
|
|
|
19
19
|
const POLL_MS = 6000
|
|
20
20
|
const FOV = Math.PI / 3.3
|
|
21
21
|
const CLOUD_RADIUS = 34
|
|
22
|
+
// Program hubs orbit outside the error cloud, tethered to it by their links,
|
|
23
|
+
// so a program reads as a satellite instead of another node in the ball.
|
|
24
|
+
const SATELLITE_RADIUS = 46
|
|
25
|
+
// Above this many nodes the exact O(n²) repulsion is replaced by a grid: the
|
|
26
|
+
// pair count would otherwise freeze the panel while the atlas settles.
|
|
27
|
+
const GRID_THRESHOLD = 400
|
|
28
|
+
const REPULSION_CUTOFF = 30
|
|
22
29
|
// Per-frame yaw at 100% on the slider. The slider spans a wide range on
|
|
23
30
|
// purpose: the low end is a calm drift and the top end is unmistakably
|
|
24
31
|
// spinning, so moving the control is visible without staring at it. One
|
|
@@ -197,7 +204,7 @@ const NS = 'error-atlas'
|
|
|
197
204
|
// Bumped on every client change so the panel itself can prove which bundle is
|
|
198
205
|
// live. The chip next to the title shows it; if it is stale the page is running
|
|
199
206
|
// an older bundle and no amount of editing here will be visible.
|
|
200
|
-
const BUILD = '
|
|
207
|
+
const BUILD = 'V51'
|
|
201
208
|
|
|
202
209
|
const ZH = {
|
|
203
210
|
nav: '错误星图',
|
|
@@ -237,13 +244,26 @@ const NS = 'error-atlas'
|
|
|
237
244
|
exported: '已导出',
|
|
238
245
|
exportFailed: '导出失败',
|
|
239
246
|
spin: '自转',
|
|
247
|
+
scale: '大小',
|
|
248
|
+
halo: '光晕',
|
|
249
|
+
flow: '光点',
|
|
250
|
+
spread: '间距',
|
|
251
|
+
nodeColors: '节点',
|
|
252
|
+
linkColors: '线条',
|
|
253
|
+
linkCore: '核心辐条',
|
|
254
|
+
linkHub: '枢纽辐条',
|
|
255
|
+
linkProject: '程序系绳',
|
|
256
|
+
linkLesson: '教训线',
|
|
257
|
+
linkStale: '失效教训',
|
|
258
|
+
style: '外观',
|
|
240
259
|
pause: '暂停',
|
|
241
260
|
resume: '继续',
|
|
242
261
|
clear: '清空',
|
|
243
262
|
clearConfirm: '清空全部运行时错误记录?此操作不可撤销。\n(青色的「开发错误」与紫色的「任务复盘」都是保留的,不会被清空)',
|
|
244
263
|
recent: '最近错误',
|
|
245
264
|
recentSub: 'RECENT EVENTS',
|
|
246
|
-
|
|
265
|
+
hint: '拖拽旋转 · 滚轮缩放 · 点击节点以其为中心',
|
|
266
|
+
focused: '已以「{name}」为中心',
|
|
247
267
|
hintPaused: '已暂停 · 拖拽仍可环视 · 点「继续」恢复自转',
|
|
248
268
|
count: '次数',
|
|
249
269
|
lastSeen: '最近',
|
|
@@ -344,13 +364,26 @@ const NS = 'error-atlas'
|
|
|
344
364
|
exported: 'Exported',
|
|
345
365
|
exportFailed: 'Export failed',
|
|
346
366
|
spin: 'Spin',
|
|
367
|
+
scale: 'Size',
|
|
368
|
+
halo: 'Glow',
|
|
369
|
+
flow: 'Pulse',
|
|
370
|
+
spread: 'Spread',
|
|
371
|
+
nodeColors: 'Nodes',
|
|
372
|
+
linkColors: 'Lines',
|
|
373
|
+
linkCore: 'Core spokes',
|
|
374
|
+
linkHub: 'Hub spokes',
|
|
375
|
+
linkProject: 'Program tethers',
|
|
376
|
+
linkLesson: 'Lesson wires',
|
|
377
|
+
linkStale: 'Failed lessons',
|
|
378
|
+
style: 'Look',
|
|
347
379
|
pause: 'Pause',
|
|
348
380
|
resume: 'Resume',
|
|
349
381
|
clear: 'Clear',
|
|
350
382
|
clearConfirm: 'Clear every runtime error record? This cannot be undone.\n(cyan dev errors and violet retrospectives are kept.)',
|
|
351
383
|
recent: 'Recent errors',
|
|
352
384
|
recentSub: 'RECENT EVENTS',
|
|
353
|
-
|
|
385
|
+
hint: 'drag to rotate · wheel to zoom · click a node to orbit it',
|
|
386
|
+
focused: 'orbiting “{name}”',
|
|
354
387
|
hintPaused: 'Paused · you can still orbit · click Resume',
|
|
355
388
|
count: 'Count',
|
|
356
389
|
lastSeen: 'Last',
|
|
@@ -542,6 +575,8 @@ const NS = 'error-atlas'
|
|
|
542
575
|
'attribute float aAlpha;',
|
|
543
576
|
'uniform mat4 uVP;',
|
|
544
577
|
'uniform float uFocal;',
|
|
578
|
+
'uniform float uRefDist;',
|
|
579
|
+
'uniform float uMaxSize;',
|
|
545
580
|
'uniform float uSizeMul;',
|
|
546
581
|
'uniform float uAlphaMul;',
|
|
547
582
|
'varying vec3 vColor;',
|
|
@@ -550,14 +585,16 @@ const NS = 'error-atlas'
|
|
|
550
585
|
' vec4 clip = uVP * vec4(aPosition, 1.0);',
|
|
551
586
|
' gl_Position = clip;',
|
|
552
587
|
' float w = max(0.01, clip.w);',
|
|
553
|
-
' gl_PointSize =
|
|
588
|
+
' gl_PointSize = clamp(aSize * uSizeMul * uFocal / max(uRefDist, 1.0), 1.5, uMaxSize);',
|
|
554
589
|
' float fog = clamp(2.4 - w * 0.009, 0.08, 1.0);',
|
|
555
590
|
' vAlpha = aAlpha * uAlphaMul * fog;',
|
|
556
591
|
' vColor = aColor;',
|
|
557
592
|
'}',
|
|
558
593
|
].join('\n')
|
|
559
594
|
|
|
560
|
-
|
|
595
|
+
// A soft radial falloff, kept separate from the solid node core: this is the glow
|
|
596
|
+
// that makes a dense atlas read as a nebula instead of a pile of dots.
|
|
597
|
+
const HALO_FS = [
|
|
561
598
|
'precision mediump float;',
|
|
562
599
|
'varying vec3 vColor;',
|
|
563
600
|
'varying float vAlpha;',
|
|
@@ -565,9 +602,28 @@ const NS = 'error-atlas'
|
|
|
565
602
|
' vec2 p = gl_PointCoord - vec2(0.5);',
|
|
566
603
|
' float r = length(p) * 2.0;',
|
|
567
604
|
' if (r >= 1.0) discard;',
|
|
568
|
-
' float
|
|
569
|
-
'
|
|
570
|
-
' gl_FragColor = vec4(
|
|
605
|
+
' float g = pow(1.0 - r, 2.1);',
|
|
606
|
+
' vec3 tint = mix(vColor, vec3(1.0), 0.25);',
|
|
607
|
+
' gl_FragColor = vec4(tint * g * 1.35, vAlpha * g);',
|
|
608
|
+
'}',
|
|
609
|
+
].join('\n')
|
|
610
|
+
|
|
611
|
+
const NODE_FS = [
|
|
612
|
+
'precision mediump float;',
|
|
613
|
+
'varying vec3 vColor;',
|
|
614
|
+
'varying float vAlpha;',
|
|
615
|
+
'void main() {',
|
|
616
|
+
' vec2 p = gl_PointCoord - vec2(0.5);',
|
|
617
|
+
' float r = length(p) * 2.0;',
|
|
618
|
+
// The solid core only fills the middle of the sprite: a point-sized dot with a
|
|
619
|
+
// crisp rim reads as a node, while a disc that fills the whole quad reads as a ball.
|
|
620
|
+
' float q = r * 1.75;',
|
|
621
|
+
' if (q >= 1.0) discard;',
|
|
622
|
+
' float edge = smoothstep(1.0, 0.86, q);',
|
|
623
|
+
' float lit = 0.6 + 0.4 * (1.0 - q * q);',
|
|
624
|
+
' float core = edge * lit;',
|
|
625
|
+
' float spark = pow(max(0.0, 1.0 - q * 2.0), 8.0) * 0.45;',
|
|
626
|
+
' gl_FragColor = vec4(vColor * (core + spark), vAlpha * edge * 0.9);',
|
|
571
627
|
'}',
|
|
572
628
|
].join('\n')
|
|
573
629
|
|
|
@@ -629,6 +685,61 @@ const NS = 'error-atlas'
|
|
|
629
685
|
// ─────────────────────────────────────────────────────────────────────────
|
|
630
686
|
// renderer: force layout + WebGL draw + label/hit projection
|
|
631
687
|
// ─────────────────────────────────────────────────────────────────────────
|
|
688
|
+
function hashOf(id) {
|
|
689
|
+
let hash = 2166136261
|
|
690
|
+
for (let k = 0; k < id.length; k += 1) {
|
|
691
|
+
hash = ((hash ^ id.charCodeAt(k)) * 16777619) >>> 0
|
|
692
|
+
}
|
|
693
|
+
return hash
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The stage grows with the atlas: 240 nodes keep the old 34 unit core, 2400 need
|
|
697
|
+
// a wider one, otherwise the islands melt back into a single ball.
|
|
698
|
+
function cloudRadiusFor(n) {
|
|
699
|
+
return CLOUD_RADIUS * Math.max(1, Math.sqrt(Math.max(1, n) / 240))
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Orbit radius for the program satellites: close enough to read as part of the map. */
|
|
703
|
+
function satelliteRadiusFor(cloud) {
|
|
704
|
+
return Math.max(SATELLITE_RADIUS, cloud * 1.45)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Deterministic orbit slot for a hub, so a refresh never reshuffles the map. */
|
|
708
|
+
function hubSlot(node, islandRadius, satRadius) {
|
|
709
|
+
const hash = hashOf(node.id)
|
|
710
|
+
if (node.kind === 'sessionhub') {
|
|
711
|
+
const angle = ((hash % 997) / 997) * Math.PI * 2
|
|
712
|
+
const tilt = (((hash >>> 10) % 991) / 991) * 0.9 - 0.45
|
|
713
|
+
const ring = islandRadius * (0.92 + ((hash >>> 20) % 100) / 420)
|
|
714
|
+
return [
|
|
715
|
+
Math.cos(angle) * Math.cos(tilt) * ring,
|
|
716
|
+
Math.sin(tilt) * ring,
|
|
717
|
+
Math.sin(angle) * Math.cos(tilt) * ring,
|
|
718
|
+
]
|
|
719
|
+
}
|
|
720
|
+
if (node.kind === 'toolhub') {
|
|
721
|
+
const angle = ((hash % 991) / 991) * Math.PI * 2
|
|
722
|
+
const ring = islandRadius * 0.34
|
|
723
|
+
return [
|
|
724
|
+
Math.cos(angle) * ring,
|
|
725
|
+
(((hash >>> 8) % 100) / 100 - 0.5) * ring * 0.5,
|
|
726
|
+
Math.sin(angle) * ring,
|
|
727
|
+
]
|
|
728
|
+
}
|
|
729
|
+
if (node.kind === 'project') {
|
|
730
|
+
const angle = ((hash % 997) / 997) * Math.PI * 2
|
|
731
|
+
const tilt = (((hash >>> 10) % 991) / 991) * 1.15 - 0.575
|
|
732
|
+
const ring = satRadius * (0.9 + ((hash >>> 20) % 100) / 500)
|
|
733
|
+
return [
|
|
734
|
+
Math.cos(angle) * Math.cos(tilt) * ring,
|
|
735
|
+
Math.sin(tilt) * ring,
|
|
736
|
+
Math.sin(angle) * Math.cos(tilt) * ring,
|
|
737
|
+
]
|
|
738
|
+
}
|
|
739
|
+
return [0, 0, 0]
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
|
|
632
743
|
function createRenderer(canvas, labelHost, handlers) {
|
|
633
744
|
const gl =
|
|
634
745
|
canvas.getContext('webgl', { antialias: true, alpha: true, premultipliedAlpha: false, powerPreference: 'high-performance' }) ||
|
|
@@ -637,6 +748,7 @@ const NS = 'error-atlas'
|
|
|
637
748
|
if (!gl) return null
|
|
638
749
|
|
|
639
750
|
const nodeProgram = createProgram(gl, NODE_VS, NODE_FS)
|
|
751
|
+
const haloProgram = typeof HALO_FS === 'string' ? createProgram(gl, NODE_VS, HALO_FS) : null
|
|
640
752
|
const linkProgram = createProgram(gl, LINK_VS, LINK_FS)
|
|
641
753
|
|
|
642
754
|
const nodeLoc = {
|
|
@@ -646,9 +758,23 @@ const NS = 'error-atlas'
|
|
|
646
758
|
alpha: gl.getAttribLocation(nodeProgram, 'aAlpha'),
|
|
647
759
|
uVP: gl.getUniformLocation(nodeProgram, 'uVP'),
|
|
648
760
|
uFocal: gl.getUniformLocation(nodeProgram, 'uFocal'),
|
|
761
|
+
uRefDist: gl.getUniformLocation(nodeProgram, 'uRefDist'),
|
|
762
|
+
uMaxSize: gl.getUniformLocation(nodeProgram, 'uMaxSize'),
|
|
649
763
|
uSizeMul: gl.getUniformLocation(nodeProgram, 'uSizeMul'),
|
|
650
764
|
uAlphaMul: gl.getUniformLocation(nodeProgram, 'uAlphaMul'),
|
|
651
765
|
}
|
|
766
|
+
const haloLoc = {
|
|
767
|
+
position: gl.getAttribLocation(haloProgram, 'aPosition'),
|
|
768
|
+
color: gl.getAttribLocation(haloProgram, 'aColor'),
|
|
769
|
+
size: gl.getAttribLocation(haloProgram, 'aSize'),
|
|
770
|
+
alpha: gl.getAttribLocation(haloProgram, 'aAlpha'),
|
|
771
|
+
uVP: gl.getUniformLocation(haloProgram, 'uVP'),
|
|
772
|
+
uFocal: gl.getUniformLocation(haloProgram, 'uFocal'),
|
|
773
|
+
uRefDist: gl.getUniformLocation(haloProgram, 'uRefDist'),
|
|
774
|
+
uMaxSize: gl.getUniformLocation(haloProgram, 'uMaxSize'),
|
|
775
|
+
uSizeMul: gl.getUniformLocation(haloProgram, 'uSizeMul'),
|
|
776
|
+
uAlphaMul: gl.getUniformLocation(haloProgram, 'uAlphaMul'),
|
|
777
|
+
}
|
|
652
778
|
const linkLoc = {
|
|
653
779
|
position: gl.getAttribLocation(linkProgram, 'aPosition'),
|
|
654
780
|
color: gl.getAttribLocation(linkProgram, 'aColor'),
|
|
@@ -681,6 +807,11 @@ const NS = 'error-atlas'
|
|
|
681
807
|
links: [],
|
|
682
808
|
count: 0,
|
|
683
809
|
pos: new Float32Array(0),
|
|
810
|
+
anchor: new Float32Array(0),
|
|
811
|
+
islandHub: new Int32Array(0),
|
|
812
|
+
degree: new Int32Array(0),
|
|
813
|
+
anchored: new Uint8Array(0),
|
|
814
|
+
cloudRadius: CLOUD_RADIUS,
|
|
684
815
|
vel: new Float32Array(0),
|
|
685
816
|
mass: new Float32Array(0),
|
|
686
817
|
color: new Float32Array(0),
|
|
@@ -707,6 +838,14 @@ const NS = 'error-atlas'
|
|
|
707
838
|
pitch: -0.14,
|
|
708
839
|
dist: 96,
|
|
709
840
|
targetDist: 96,
|
|
841
|
+
scale: 1,
|
|
842
|
+
halo: 1,
|
|
843
|
+
flowScale: 1,
|
|
844
|
+
// How far apart the nodes sit. Positions only: node and pulse diameters keep
|
|
845
|
+
// their own dials, so the wire lengths can breathe without resizing anything.
|
|
846
|
+
spread: 1,
|
|
847
|
+
focus: new Float32Array(3),
|
|
848
|
+
focusTarget: new Float32Array(3),
|
|
710
849
|
selected: null,
|
|
711
850
|
hovered: null,
|
|
712
851
|
neighbors: new Set(),
|
|
@@ -730,8 +869,13 @@ const NS = 'error-atlas'
|
|
|
730
869
|
raf: 0,
|
|
731
870
|
disposed: false,
|
|
732
871
|
alphaDirty: true,
|
|
872
|
+
// Colour filters: node kinds and link roles the user has switched off.
|
|
873
|
+
hiddenKinds: new Set(),
|
|
874
|
+
hiddenLinks: new Set(),
|
|
875
|
+
linkRoles: [],
|
|
733
876
|
signature: null,
|
|
734
877
|
needsFit: true,
|
|
878
|
+
fitDist: 96,
|
|
735
879
|
clock: 0,
|
|
736
880
|
// Search is a client-side lens over the snapshot: matching nodes keep their
|
|
737
881
|
// light, everything else fades so the shape of an answer is visible at once.
|
|
@@ -770,35 +914,191 @@ const NS = 'error-atlas'
|
|
|
770
914
|
const pos = state.pos
|
|
771
915
|
const mass = state.mass
|
|
772
916
|
const size = state.size
|
|
917
|
+
const nodes = state.nodes
|
|
773
918
|
let seed = 1337
|
|
774
919
|
const rand = () => {
|
|
775
920
|
seed = (seed * 1664525 + 1013904223) >>> 0
|
|
776
921
|
return seed / 4294967296
|
|
777
922
|
}
|
|
923
|
+
|
|
924
|
+
const cloud = cloudRadiusFor(n)
|
|
925
|
+
const satRadius = satelliteRadiusFor(cloud)
|
|
926
|
+
// Each window needs room: 34 units of arc per island, capped so the satellites
|
|
927
|
+
// always stay outside the ring.
|
|
928
|
+
let sessionCount = 0
|
|
929
|
+
for (let i = 0; i < n; i += 1) if (nodes[i].kind === 'sessionhub') sessionCount += 1
|
|
930
|
+
const islandRadius = Math.min(satRadius * 0.9, Math.max(cloud * 0.74, (sessionCount * 34) / (Math.PI * 2)))
|
|
931
|
+
state.cloudRadius = cloud
|
|
932
|
+
state.anchor = new Float32Array(n * 3)
|
|
933
|
+
state.anchored = new Uint8Array(n)
|
|
934
|
+
|
|
935
|
+
const degree = new Int32Array(n)
|
|
936
|
+
for (let k = 0; k < state.pairs.length; k += 2) {
|
|
937
|
+
degree[state.pairs[k]] += 1
|
|
938
|
+
degree[state.pairs[k + 1]] += 1
|
|
939
|
+
}
|
|
940
|
+
state.degree = degree
|
|
941
|
+
|
|
942
|
+
const islandHub = new Int32Array(n).fill(-1)
|
|
943
|
+
state.islandHub = islandHub
|
|
944
|
+
const hubIndex = new Map()
|
|
945
|
+
for (let i = 0; i < n; i += 1) if (nodes[i].kind === 'sessionhub') hubIndex.set(nodes[i].id, i)
|
|
946
|
+
|
|
947
|
+
const hubAnchors = new Map()
|
|
948
|
+
for (let i = 0; i < n; i += 1) {
|
|
949
|
+
const node = nodes[i]
|
|
950
|
+
if (isHubNode(node.kind) && node.kind !== 'project') hubAnchors.set(node.id, hubSlot(node, islandRadius, satRadius))
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// A failure belongs to the session window it happened in. That island anchor is
|
|
954
|
+
// weak on purpose: the hub springs still decide the shape, the anchor only keeps
|
|
955
|
+
// one island from bleeding into the next one.
|
|
956
|
+
const islandOf = new Map()
|
|
957
|
+
for (const link of state.links) {
|
|
958
|
+
const a = nodes[link.a]
|
|
959
|
+
const b = nodes[link.b]
|
|
960
|
+
if (!a || !b) continue
|
|
961
|
+
if (a.kind === 'sessionhub' && !isHubNode(b.kind) && !islandOf.has(b.id)) islandOf.set(b.id, a.id)
|
|
962
|
+
else if (b.kind === 'sessionhub' && !isHubNode(a.kind) && !islandOf.has(a.id)) islandOf.set(a.id, b.id)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// A retrospective has no direct window link: it borrows the island of the failures it
|
|
966
|
+
// wrote a lesson about, so the learning stays next to the mistakes it came from.
|
|
967
|
+
for (const link of state.links) {
|
|
968
|
+
const a = nodes[link.a]
|
|
969
|
+
const b = nodes[link.b]
|
|
970
|
+
if (!a || !b) continue
|
|
971
|
+
if (islandOf.has(a.id) && !islandOf.has(b.id) && !isHubNode(b.kind)) islandOf.set(b.id, islandOf.get(a.id))
|
|
972
|
+
else if (islandOf.has(b.id) && !islandOf.has(a.id) && !isHubNode(a.kind)) islandOf.set(a.id, islandOf.get(b.id))
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
// A program satellite is placed on the side of the map where its own failures are:
|
|
977
|
+
// average the islands of the errors it is linked to and park it on that heading.
|
|
978
|
+
// A hash angle put a program next to the cluster on one refresh and opposite it on
|
|
979
|
+
// the next, which is why "docs" and "dsh-error-atlas" looked stranded.
|
|
980
|
+
const projectPull = new Map()
|
|
981
|
+
for (const link of state.links) {
|
|
982
|
+
const a = nodes[link.a]
|
|
983
|
+
const b = nodes[link.b]
|
|
984
|
+
if (!a || !b) continue
|
|
985
|
+
const project = a.kind === 'project' ? a : b.kind === 'project' ? b : null
|
|
986
|
+
if (!project) continue
|
|
987
|
+
const other = project === a ? b : a
|
|
988
|
+
if (isHubNode(other.kind)) continue
|
|
989
|
+
const island = islandOf.get(other.id)
|
|
990
|
+
const base = island ? hubAnchors.get(island) : null
|
|
991
|
+
if (!base) continue
|
|
992
|
+
const acc = projectPull.get(project.id) || [0, 0, 0]
|
|
993
|
+
acc[0] += base[0]
|
|
994
|
+
acc[1] += base[1]
|
|
995
|
+
acc[2] += base[2]
|
|
996
|
+
projectPull.set(project.id, acc)
|
|
997
|
+
}
|
|
998
|
+
// Programs whose failures share one window would all land on the same heading, so
|
|
999
|
+
// their headings are fanned out around that direction instead of stacking.
|
|
1000
|
+
const wanted = []
|
|
1001
|
+
for (const [id, acc] of projectPull) {
|
|
1002
|
+
const horizontal = Math.hypot(acc[0], acc[2])
|
|
1003
|
+
if (horizontal < 1e-3) continue
|
|
1004
|
+
wanted.push({ id, angle: Math.atan2(acc[2], acc[0]), y: acc[1] })
|
|
1005
|
+
}
|
|
1006
|
+
wanted.sort((left, right) => left.angle - right.angle)
|
|
1007
|
+
const taken = []
|
|
1008
|
+
const angularGap = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)))
|
|
1009
|
+
for (const item of wanted) {
|
|
1010
|
+
let angle = item.angle
|
|
1011
|
+
let step = 1
|
|
1012
|
+
while (taken.some((other) => angularGap(other, angle) < 0.36)) {
|
|
1013
|
+
const swing = Math.ceil(step / 2) * 0.42
|
|
1014
|
+
angle = item.angle + (step % 2 === 1 ? swing : -swing)
|
|
1015
|
+
step += 1
|
|
1016
|
+
}
|
|
1017
|
+
taken.push(angle)
|
|
1018
|
+
const ring = satRadius * (0.94 + (hashOf(item.id) % 60) / 600)
|
|
1019
|
+
hubAnchors.set(item.id, [Math.cos(angle) * ring, item.y * 0.06, Math.sin(angle) * ring])
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// A program with no linked failure yet still gets its place on the ring, so it never
|
|
1023
|
+
// collapses into the cloud just because it has nothing to point at.
|
|
1024
|
+
for (let i = 0; i < n; i += 1) {
|
|
1025
|
+
const node = nodes[i]
|
|
1026
|
+
if (node.kind === 'project' && !hubAnchors.has(node.id)) hubAnchors.set(node.id, hubSlot(node, islandRadius, satRadius))
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Anchors inside one island are spread with a golden-angle spiral, so the island is
|
|
1030
|
+
// a volume and not a ring of equidistant failures.
|
|
1031
|
+
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
|
|
1032
|
+
const islandLeaves = new Map()
|
|
1033
|
+
for (const islandId of islandOf.values()) islandLeaves.set(islandId, (islandLeaves.get(islandId) || 0) + 1)
|
|
1034
|
+
const islandSeen = new Map()
|
|
1035
|
+
state.islandMode = islandOf.size > 0
|
|
1036
|
+
|
|
778
1037
|
for (let i = 0; i < n; i += 1) {
|
|
779
|
-
const node =
|
|
1038
|
+
const node = nodes[i]
|
|
780
1039
|
const isRoot = node.kind === 'root'
|
|
781
1040
|
const isHub = isHubNode(node.kind)
|
|
782
1041
|
mass[i] = isRoot ? 60 : isHub ? 9 : 1
|
|
783
1042
|
size[i] = isRoot
|
|
784
1043
|
? 11
|
|
785
1044
|
: isHub
|
|
786
|
-
?
|
|
787
|
-
:
|
|
1045
|
+
? 4.2 + Math.min(4.6, Math.sqrt(node.count || 1) * 1.15)
|
|
1046
|
+
: 1.5 + Math.min(2.6, Math.sqrt(node.count || 1) * 0.82)
|
|
788
1047
|
if (isRoot) {
|
|
789
1048
|
pos[i * 3] = 0
|
|
790
1049
|
pos[i * 3 + 1] = 0
|
|
791
1050
|
pos[i * 3 + 2] = 0
|
|
1051
|
+
state.anchored[i] = 1
|
|
792
1052
|
continue
|
|
793
1053
|
}
|
|
1054
|
+
if (hubAnchors.has(node.id)) {
|
|
1055
|
+
const [ax, ay, az] = hubAnchors.get(node.id)
|
|
1056
|
+
state.anchor[i * 3] = ax
|
|
1057
|
+
state.anchor[i * 3 + 1] = ay
|
|
1058
|
+
state.anchor[i * 3 + 2] = az
|
|
1059
|
+
state.anchored[i] = 1
|
|
1060
|
+
} else {
|
|
1061
|
+
const island = islandOf.get(node.id)
|
|
1062
|
+
const base = island ? hubAnchors.get(island) : null
|
|
1063
|
+
if (base) {
|
|
1064
|
+
// Spread a window's failures through the volume of its own island instead of
|
|
1065
|
+
// stacking every anchor on one point: a single point turns the island into a
|
|
1066
|
+
// repulsion bomb and the failures end up pinned to the clamp shell.
|
|
1067
|
+
const total = islandLeaves.get(island) || 1
|
|
1068
|
+
const seen = islandSeen.get(island) || 0
|
|
1069
|
+
islandSeen.set(island, seen + 1)
|
|
1070
|
+
const u = (seen + 0.5) / total
|
|
1071
|
+
const radius = cloud * 0.30 * Math.cbrt(u)
|
|
1072
|
+
const phi = Math.acos(1 - 2 * u)
|
|
1073
|
+
const theta = GOLDEN_ANGLE * seen
|
|
1074
|
+
state.anchor[i * 3] = base[0] + radius * Math.sin(phi) * Math.cos(theta)
|
|
1075
|
+
state.anchor[i * 3 + 1] = base[1] + radius * Math.cos(phi)
|
|
1076
|
+
state.anchor[i * 3 + 2] = base[2] + radius * Math.sin(phi) * Math.sin(theta)
|
|
1077
|
+
} else {
|
|
1078
|
+
const radius = cloud * 0.4 * Math.cbrt(rand())
|
|
1079
|
+
const phi = Math.acos(1 - 2 * rand())
|
|
1080
|
+
const theta = 2 * Math.PI * rand()
|
|
1081
|
+
state.anchor[i * 3] = radius * Math.sin(phi) * Math.cos(theta)
|
|
1082
|
+
state.anchor[i * 3 + 1] = radius * Math.cos(phi)
|
|
1083
|
+
state.anchor[i * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta)
|
|
1084
|
+
}
|
|
1085
|
+
const owner = island ? hubIndex.get(island) : undefined
|
|
1086
|
+
if (owner !== undefined) islandHub[i] = owner
|
|
1087
|
+
}
|
|
794
1088
|
const kept = previous && previous.get(node.id)
|
|
795
|
-
if (kept) {
|
|
1089
|
+
if (kept && state.anchored[i] === 0) {
|
|
796
1090
|
pos[i * 3] = kept[0]
|
|
797
1091
|
pos[i * 3 + 1] = kept[1]
|
|
798
1092
|
pos[i * 3 + 2] = kept[2]
|
|
799
1093
|
continue
|
|
800
1094
|
}
|
|
801
|
-
|
|
1095
|
+
if (state.anchored[i]) {
|
|
1096
|
+
pos[i * 3] = state.anchor[i * 3]
|
|
1097
|
+
pos[i * 3 + 1] = state.anchor[i * 3 + 1]
|
|
1098
|
+
pos[i * 3 + 2] = state.anchor[i * 3 + 2]
|
|
1099
|
+
continue
|
|
1100
|
+
}
|
|
1101
|
+
const radius = cloud * (0.25 + rand() * 0.55)
|
|
802
1102
|
const theta = rand() * Math.PI * 2
|
|
803
1103
|
const phi = Math.acos(2 * rand() - 1)
|
|
804
1104
|
pos[i * 3] = radius * Math.sin(phi) * Math.cos(theta)
|
|
@@ -806,8 +1106,11 @@ const NS = 'error-atlas'
|
|
|
806
1106
|
pos[i * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta)
|
|
807
1107
|
}
|
|
808
1108
|
state.vel.fill(0)
|
|
809
|
-
|
|
810
|
-
|
|
1109
|
+
// Big atlases settle with fewer, shorter passes: the layout has to be usable at
|
|
1110
|
+
// 2400 nodes, and a 210 frame warm-up there would stall the first paint for seconds.
|
|
1111
|
+
const warm = Math.max(40, Math.min(210, Math.round(90000 / Math.max(1, n))))
|
|
1112
|
+
state.warmup = previous ? Math.min(60, warm) : warm
|
|
1113
|
+
state.simBudget = previous ? 60 : Math.max(24, Math.min(70, Math.round(48000 / Math.max(1, n))))
|
|
811
1114
|
}
|
|
812
1115
|
|
|
813
1116
|
function simulate() {
|
|
@@ -816,36 +1119,104 @@ const NS = 'error-atlas'
|
|
|
816
1119
|
const pos = state.pos
|
|
817
1120
|
const vel = state.vel
|
|
818
1121
|
const mass = state.mass
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
if (
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1122
|
+
const nodes = state.nodes
|
|
1123
|
+
// With islands the anchors decide the shape, so repulsion only has to stop nodes
|
|
1124
|
+
// from overlapping; 95 was calibrated for one sparse cloud and pins dense islands
|
|
1125
|
+
// to their clamp shell. Without sessions keep the old value.
|
|
1126
|
+
const repulsion = state.islandMode ? 30 : 95
|
|
1127
|
+
|
|
1128
|
+
if (n > GRID_THRESHOLD) {
|
|
1129
|
+
// Uniform grid: only nearby pairs repel. The far field is covered by the island
|
|
1130
|
+
// anchors, so truncating the 1/d² tail does not collapse the layout.
|
|
1131
|
+
const cell = REPULSION_CUTOFF
|
|
1132
|
+
const buckets = new Map()
|
|
1133
|
+
for (let i = 0; i < n; i += 1) {
|
|
1134
|
+
const ix = i * 3
|
|
1135
|
+
const key =
|
|
1136
|
+
Math.floor(pos[ix] / cell) + ',' + Math.floor(pos[ix + 1] / cell) + ',' + Math.floor(pos[ix + 2] / cell)
|
|
1137
|
+
const list = buckets.get(key)
|
|
1138
|
+
if (list) list.push(i)
|
|
1139
|
+
else buckets.set(key, [i])
|
|
1140
|
+
}
|
|
1141
|
+
for (let i = 0; i < n; i += 1) {
|
|
1142
|
+
const ix = i * 3
|
|
1143
|
+
const px = pos[ix]
|
|
1144
|
+
const py = pos[ix + 1]
|
|
1145
|
+
const pz = pos[ix + 2]
|
|
1146
|
+
const bx = Math.floor(px / cell)
|
|
1147
|
+
const by = Math.floor(py / cell)
|
|
1148
|
+
const bz = Math.floor(pz / cell)
|
|
1149
|
+
let fx = 0
|
|
1150
|
+
let fy = 0
|
|
1151
|
+
let fz = 0
|
|
1152
|
+
for (let ox = -1; ox <= 1; ox += 1) {
|
|
1153
|
+
for (let oy = -1; oy <= 1; oy += 1) {
|
|
1154
|
+
for (let oz = -1; oz <= 1; oz += 1) {
|
|
1155
|
+
const list = buckets.get(bx + ox + ',' + (by + oy) + ',' + (bz + oz))
|
|
1156
|
+
if (!list) continue
|
|
1157
|
+
for (let m = 0; m < list.length; m += 1) {
|
|
1158
|
+
const j = list[m]
|
|
1159
|
+
if (j <= i) continue
|
|
1160
|
+
const jx = j * 3
|
|
1161
|
+
let dx = px - pos[jx]
|
|
1162
|
+
let dy = py - pos[jx + 1]
|
|
1163
|
+
let dz = pz - pos[jx + 2]
|
|
1164
|
+
let d2 = dx * dx + dy * dy + dz * dz
|
|
1165
|
+
if (d2 > REPULSION_CUTOFF * REPULSION_CUTOFF) continue
|
|
1166
|
+
if (d2 < 0.02) {
|
|
1167
|
+
d2 = 0.02
|
|
1168
|
+
dx += 0.01
|
|
1169
|
+
dy += 0.01
|
|
1170
|
+
dz += 0.01
|
|
1171
|
+
}
|
|
1172
|
+
// Two failures of the same window share one island and are expected to
|
|
1173
|
+
// sit close: full repulsion would push the whole island onto its clamp
|
|
1174
|
+
// shell. Strangers keep the full push, which is what separates islands.
|
|
1175
|
+
const soft = state.islandHub[i] >= 0 && state.islandHub[i] === state.islandHub[j] ? 0.3 : 1
|
|
1176
|
+
const inv = (repulsion * soft) / (d2 * Math.sqrt(d2))
|
|
1177
|
+
fx += dx * inv
|
|
1178
|
+
fy += dy * inv
|
|
1179
|
+
fz += dz * inv
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
vel[ix] += fx / mass[i]
|
|
1185
|
+
vel[ix + 1] += fy / mass[i]
|
|
1186
|
+
vel[ix + 2] += fz / mass[i]
|
|
1187
|
+
}
|
|
1188
|
+
} else {
|
|
1189
|
+
for (let i = 0; i < n; i += 1) {
|
|
1190
|
+
const ix = i * 3
|
|
1191
|
+
const px = pos[ix]
|
|
1192
|
+
const py = pos[ix + 1]
|
|
1193
|
+
const pz = pos[ix + 2]
|
|
1194
|
+
let fx = 0
|
|
1195
|
+
let fy = 0
|
|
1196
|
+
let fz = 0
|
|
1197
|
+
for (let j = i + 1; j < n; j += 1) {
|
|
1198
|
+
const jx = j * 3
|
|
1199
|
+
let dx = px - pos[jx]
|
|
1200
|
+
let dy = py - pos[jx + 1]
|
|
1201
|
+
let dz = pz - pos[jx + 2]
|
|
1202
|
+
let d2 = dx * dx + dy * dy + dz * dz
|
|
1203
|
+
if (d2 < 0.02) {
|
|
1204
|
+
d2 = 0.02
|
|
1205
|
+
dx += 0.01
|
|
1206
|
+
dy += 0.01
|
|
1207
|
+
dz += 0.01
|
|
1208
|
+
}
|
|
1209
|
+
// As in the grid path: siblings inside one island tolerate each other.
|
|
1210
|
+
const soft = state.islandHub[i] >= 0 && state.islandHub[i] === state.islandHub[j] ? 0.3 : 1
|
|
1211
|
+
const inv = (repulsion * soft) / (d2 * Math.sqrt(d2))
|
|
1212
|
+
fx += dx * inv
|
|
1213
|
+
fy += dy * inv
|
|
1214
|
+
fz += dz * inv
|
|
840
1215
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
fz += dz * inv
|
|
1216
|
+
vel[ix] += fx / mass[i]
|
|
1217
|
+
vel[ix + 1] += fy / mass[i]
|
|
1218
|
+
vel[ix + 2] += fz / mass[i]
|
|
845
1219
|
}
|
|
846
|
-
vel[ix] += fx / mass[i]
|
|
847
|
-
vel[ix + 1] += fy / mass[i]
|
|
848
|
-
vel[ix + 2] += fz / mass[i]
|
|
849
1220
|
}
|
|
850
1221
|
|
|
851
1222
|
const pairs = state.pairs
|
|
@@ -859,7 +1230,34 @@ const NS = 'error-atlas'
|
|
|
859
1230
|
const dy = pos[jx + 1] - pos[ix + 1]
|
|
860
1231
|
const dz = pos[jx + 2] - pos[ix + 2]
|
|
861
1232
|
const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 0.01
|
|
862
|
-
|
|
1233
|
+
// Not every edge means the same thing: a failure is pulled tight to its window,
|
|
1234
|
+
// loosely to its tool, and only tethered to the core.
|
|
1235
|
+
const ka = nodes[i].kind
|
|
1236
|
+
const kb = nodes[j].kind
|
|
1237
|
+
let target = 12
|
|
1238
|
+
let stiffness = 0.02
|
|
1239
|
+
if (state.links[k] && state.links[k].lesson) {
|
|
1240
|
+
target = 18
|
|
1241
|
+
stiffness = 0.02
|
|
1242
|
+
} else if (ka === 'sessionhub' || kb === 'sessionhub') {
|
|
1243
|
+
target = 10
|
|
1244
|
+
stiffness = 0.07
|
|
1245
|
+
} else if (ka === 'project' || kb === 'project') {
|
|
1246
|
+
target = 13
|
|
1247
|
+
stiffness = 0.03
|
|
1248
|
+
} else if (ka === 'root' || kb === 'root') {
|
|
1249
|
+
target = 14
|
|
1250
|
+
stiffness = 0.004
|
|
1251
|
+
}
|
|
1252
|
+
// A hub with hundreds of links must not be dragged inward by all of them at
|
|
1253
|
+
// once, or the program satellites sink into the error cloud.
|
|
1254
|
+
// Only satellites need the degree damping: a window hub is the spine of its island,
|
|
1255
|
+
// and weakening those links lets the island flatten onto its clamp shell.
|
|
1256
|
+
if (ka === 'project' || kb === 'project') {
|
|
1257
|
+
const hubDegree = Math.max(state.degree[i] || 0, state.degree[j] || 0)
|
|
1258
|
+
if (hubDegree > 12) stiffness *= 12 / hubDegree
|
|
1259
|
+
}
|
|
1260
|
+
const force = (d - target) * stiffness
|
|
863
1261
|
const ux = (dx / d) * force
|
|
864
1262
|
const uy = (dy / d) * force
|
|
865
1263
|
const uz = (dz / d) * force
|
|
@@ -871,6 +1269,16 @@ const NS = 'error-atlas'
|
|
|
871
1269
|
vel[jx + 2] -= uz / mass[j]
|
|
872
1270
|
}
|
|
873
1271
|
|
|
1272
|
+
// Island anchors: hubs are pinned on their slot, failures are held loosely so the
|
|
1273
|
+
// hub springs still decide the local shape.
|
|
1274
|
+
for (let i = 0; i < n; i += 1) {
|
|
1275
|
+
const ix = i * 3
|
|
1276
|
+
const k = state.anchored[i] ? 0.16 : 0.8
|
|
1277
|
+
vel[ix] += (state.anchor[ix] - pos[ix]) * k
|
|
1278
|
+
vel[ix + 1] += (state.anchor[ix + 1] - pos[ix + 1]) * k
|
|
1279
|
+
vel[ix + 2] += (state.anchor[ix + 2] - pos[ix + 2]) * k
|
|
1280
|
+
}
|
|
1281
|
+
|
|
874
1282
|
for (let i = 0; i < n; i += 1) {
|
|
875
1283
|
const ix = i * 3
|
|
876
1284
|
let vx = (vel[ix] - pos[ix] * 0.0016) * 0.8
|
|
@@ -903,7 +1311,7 @@ const NS = 'error-atlas'
|
|
|
903
1311
|
// Without this the repulsion drifts the whole cloud past the camera and the
|
|
904
1312
|
// graph silently leaves the screen. When a DSH core node exists it *is* the
|
|
905
1313
|
// anchor, so the cloud is never recentred away from it.
|
|
906
|
-
const hasRoot =
|
|
1314
|
+
const hasRoot = nodes.some((node) => node.kind === 'root')
|
|
907
1315
|
let cx = 0
|
|
908
1316
|
let cy = 0
|
|
909
1317
|
let cz = 0
|
|
@@ -917,9 +1325,11 @@ const NS = 'error-atlas'
|
|
|
917
1325
|
cy /= n
|
|
918
1326
|
cz /= n
|
|
919
1327
|
}
|
|
1328
|
+
const cloud = state.cloudRadius || CLOUD_RADIUS
|
|
920
1329
|
for (let i = 0; i < n; i += 1) {
|
|
921
1330
|
const ix = i * 3
|
|
922
|
-
|
|
1331
|
+
const kind = nodes[i].kind
|
|
1332
|
+
if (kind === 'root') {
|
|
923
1333
|
pos[ix] = 0
|
|
924
1334
|
pos[ix + 1] = 0
|
|
925
1335
|
pos[ix + 2] = 0
|
|
@@ -932,8 +1342,9 @@ const NS = 'error-atlas'
|
|
|
932
1342
|
let py = pos[ix + 1] - cy
|
|
933
1343
|
let pz = pos[ix + 2] - cz
|
|
934
1344
|
const r = Math.sqrt(px * px + py * py + pz * pz)
|
|
935
|
-
|
|
936
|
-
|
|
1345
|
+
const limit = kind === 'project' ? satelliteRadiusFor(cloud) * 1.3 : isHubNode(kind) ? cloud * 1.35 : cloud * 1.12
|
|
1346
|
+
if (r > limit) {
|
|
1347
|
+
const s = limit / r
|
|
937
1348
|
px *= s
|
|
938
1349
|
py *= s
|
|
939
1350
|
pz *= s
|
|
@@ -944,10 +1355,49 @@ const NS = 'error-atlas'
|
|
|
944
1355
|
pos[ix] = px
|
|
945
1356
|
pos[ix + 1] = py
|
|
946
1357
|
pos[ix + 2] = pz
|
|
1358
|
+
|
|
1359
|
+
const owner = state.islandHub[i]
|
|
1360
|
+
if (owner >= 0 && owner !== i) {
|
|
1361
|
+
const ox = owner * 3
|
|
1362
|
+
let dx = pos[ix] - pos[ox]
|
|
1363
|
+
let dy = pos[ix + 1] - pos[ox + 1]
|
|
1364
|
+
let dz = pos[ix + 2] - pos[ox + 2]
|
|
1365
|
+
const d = Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
1366
|
+
const reach = cloud * 0.42
|
|
1367
|
+
if (d > reach) {
|
|
1368
|
+
const s = reach / d
|
|
1369
|
+
pos[ix] = pos[ox] + dx * s
|
|
1370
|
+
pos[ix + 1] = pos[ox + 1] + dy * s
|
|
1371
|
+
pos[ix + 2] = pos[ox + 2] + dz * s
|
|
1372
|
+
vel[ix] *= 0.4
|
|
1373
|
+
vel[ix + 1] *= 0.4
|
|
1374
|
+
vel[ix + 2] *= 0.4
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
947
1377
|
}
|
|
948
1378
|
}
|
|
949
1379
|
|
|
950
1380
|
/** Frame the whole cloud, so nothing starts outside the viewport. */
|
|
1381
|
+
function scaling(out, k) {
|
|
1382
|
+
out[0] = k
|
|
1383
|
+
out[1] = 0
|
|
1384
|
+
out[2] = 0
|
|
1385
|
+
out[3] = 0
|
|
1386
|
+
out[4] = 0
|
|
1387
|
+
out[5] = k
|
|
1388
|
+
out[6] = 0
|
|
1389
|
+
out[7] = 0
|
|
1390
|
+
out[8] = 0
|
|
1391
|
+
out[9] = 0
|
|
1392
|
+
out[10] = k
|
|
1393
|
+
out[11] = 0
|
|
1394
|
+
out[12] = 0
|
|
1395
|
+
out[13] = 0
|
|
1396
|
+
out[14] = 0
|
|
1397
|
+
out[15] = 1
|
|
1398
|
+
return out
|
|
1399
|
+
}
|
|
1400
|
+
|
|
951
1401
|
function fitView() {
|
|
952
1402
|
const n = state.count
|
|
953
1403
|
if (n === 0) return
|
|
@@ -961,10 +1411,11 @@ const NS = 'error-atlas'
|
|
|
961
1411
|
const width = Math.max(1, canvas.clientWidth || 900)
|
|
962
1412
|
const height = Math.max(1, canvas.clientHeight || 600)
|
|
963
1413
|
const focal = 0.5 * height / Math.tan(FOV / 2)
|
|
964
|
-
const fitted = (maxR * 1.32 * focal) / (Math.min(width, height) * 0.4)
|
|
1414
|
+
const fitted = (maxR * state.spread * 1.32 * focal) / (Math.min(width, height) * 0.4)
|
|
965
1415
|
const dist = Math.max(48, Math.min(300, fitted))
|
|
966
1416
|
state.dist = dist
|
|
967
1417
|
state.targetDist = dist
|
|
1418
|
+
state.fitDist = dist
|
|
968
1419
|
}
|
|
969
1420
|
|
|
970
1421
|
function computeMatrices(width, height) {
|
|
@@ -978,8 +1429,17 @@ const NS = 'error-atlas'
|
|
|
978
1429
|
const ry = rotationY(new Float32Array(16), state.yaw)
|
|
979
1430
|
const rx = rotationX(new Float32Array(16), state.pitch)
|
|
980
1431
|
multiply(spin, ry, rx)
|
|
1432
|
+
// Pull the orbit centre onto the picked node before spinning, so the camera
|
|
1433
|
+
// circles that node instead of the middle of the whole cloud.
|
|
1434
|
+
const focus = translation(new Float32Array(16), -state.focus[0], -state.focus[1], -state.focus[2])
|
|
1435
|
+
const centred = new Float32Array(16)
|
|
1436
|
+
multiply(centred, spin, focus)
|
|
1437
|
+
// Spreading is a pure scale about the orbit centre: after the focus translation
|
|
1438
|
+
// the centre is the origin, so scaling here widens every wire without moving
|
|
1439
|
+
// the node the camera is circling.
|
|
1440
|
+
multiply(centred, scaling(new Float32Array(16), state.spread), centred)
|
|
981
1441
|
const t = translation(new Float32Array(16), 0, 0, -state.dist)
|
|
982
|
-
multiply(view, t,
|
|
1442
|
+
multiply(view, t, centred)
|
|
983
1443
|
const vp = new Float32Array(16)
|
|
984
1444
|
multiply(vp, proj, view)
|
|
985
1445
|
return vp
|
|
@@ -1012,7 +1472,7 @@ const NS = 'error-atlas'
|
|
|
1012
1472
|
depth[i] = cw
|
|
1013
1473
|
sx[i] = (cx / cw * 0.5 + 0.5) * width
|
|
1014
1474
|
sy[i] = (0.5 - cy / cw * 0.5) * height
|
|
1015
|
-
radii[i] = (state.size[i] * focal) / cw * 0.5 + 2
|
|
1475
|
+
radii[i] = (state.size[i] * state.scale * focal) / cw * 0.5 + 2
|
|
1016
1476
|
}
|
|
1017
1477
|
}
|
|
1018
1478
|
|
|
@@ -1101,12 +1561,11 @@ const NS = 'error-atlas'
|
|
|
1101
1561
|
const by = pos[to3 + 1]
|
|
1102
1562
|
const bz = pos[to3 + 2]
|
|
1103
1563
|
const ease = t * t * (3 - 2 * t)
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
state.flowPos[i * 3] =
|
|
1108
|
-
state.flowPos[i * 3 +
|
|
1109
|
-
state.flowPos[i * 3 + 2] = az + (bz - az) * ease + lift * 0.25
|
|
1564
|
+
// No bow at all: the link is a straight line, so the pulse has to be a point
|
|
1565
|
+
// on that line, otherwise it visibly floats off the wire.
|
|
1566
|
+
state.flowPos[i * 3] = ax + (bx - ax) * ease
|
|
1567
|
+
state.flowPos[i * 3 + 1] = ay + (by - ay) * ease
|
|
1568
|
+
state.flowPos[i * 3 + 2] = az + (bz - az) * ease
|
|
1110
1569
|
|
|
1111
1570
|
const fresh = state.learnRetros.has(edge.retroId) ? state.learnBoost : 0
|
|
1112
1571
|
let dim = 1
|
|
@@ -1152,7 +1611,7 @@ const NS = 'error-atlas'
|
|
|
1152
1611
|
uploadLinks()
|
|
1153
1612
|
gl.useProgram(linkProgram)
|
|
1154
1613
|
gl.uniformMatrix4fv(linkLoc.uVP, false, vp)
|
|
1155
|
-
gl.uniform1f(linkLoc.uAlphaMul, 0.
|
|
1614
|
+
gl.uniform1f(linkLoc.uAlphaMul, 0.68)
|
|
1156
1615
|
gl.bindBuffer(gl.ARRAY_BUFFER, linkBuffers.position)
|
|
1157
1616
|
gl.enableVertexAttribArray(linkLoc.position)
|
|
1158
1617
|
gl.vertexAttribPointer(linkLoc.position, 3, gl.FLOAT, false, 0, 0)
|
|
@@ -1171,7 +1630,8 @@ const NS = 'error-atlas'
|
|
|
1171
1630
|
gl.useProgram(nodeProgram)
|
|
1172
1631
|
gl.uniformMatrix4fv(nodeLoc.uVP, false, vp)
|
|
1173
1632
|
gl.uniform1f(nodeLoc.uFocal, focalDevice)
|
|
1174
|
-
gl.uniform1f(nodeLoc.
|
|
1633
|
+
gl.uniform1f(nodeLoc.uRefDist, state.fitDist || 96)
|
|
1634
|
+
gl.uniform1f(nodeLoc.uSizeMul, state.flowScale)
|
|
1175
1635
|
gl.uniform1f(nodeLoc.uAlphaMul, 1.0)
|
|
1176
1636
|
gl.bindBuffer(gl.ARRAY_BUFFER, flowBuffers.position)
|
|
1177
1637
|
gl.enableVertexAttribArray(nodeLoc.position)
|
|
@@ -1193,6 +1653,7 @@ const NS = 'error-atlas'
|
|
|
1193
1653
|
gl.useProgram(nodeProgram)
|
|
1194
1654
|
gl.uniformMatrix4fv(nodeLoc.uVP, false, vp)
|
|
1195
1655
|
gl.uniform1f(nodeLoc.uFocal, focalDevice)
|
|
1656
|
+
gl.uniform1f(nodeLoc.uRefDist, state.fitDist || 96)
|
|
1196
1657
|
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position)
|
|
1197
1658
|
gl.enableVertexAttribArray(nodeLoc.position)
|
|
1198
1659
|
gl.vertexAttribPointer(nodeLoc.position, 3, gl.FLOAT, false, 0, 0)
|
|
@@ -1206,12 +1667,54 @@ const NS = 'error-atlas'
|
|
|
1206
1667
|
gl.enableVertexAttribArray(nodeLoc.alpha)
|
|
1207
1668
|
gl.vertexAttribPointer(nodeLoc.alpha, 1, gl.FLOAT, false, 0, 0)
|
|
1208
1669
|
|
|
1209
|
-
gl.uniform1f(nodeLoc.
|
|
1210
|
-
gl.uniform1f(nodeLoc.
|
|
1211
|
-
gl.drawArrays(gl.POINTS, 0, state.count)
|
|
1212
|
-
|
|
1213
|
-
gl.uniform1f(nodeLoc.uSizeMul, 1.0)
|
|
1670
|
+
gl.uniform1f(nodeLoc.uMaxSize, 44.0)
|
|
1671
|
+
gl.uniform1f(nodeLoc.uSizeMul, state.scale)
|
|
1214
1672
|
gl.uniform1f(nodeLoc.uAlphaMul, 0.95)
|
|
1673
|
+
|
|
1674
|
+
if (haloProgram && state.halo > 0.02) {
|
|
1675
|
+
gl.useProgram(haloProgram)
|
|
1676
|
+
gl.uniformMatrix4fv(haloLoc.uVP, false, vp)
|
|
1677
|
+
gl.uniform1f(haloLoc.uFocal, focalDevice)
|
|
1678
|
+
gl.uniform1f(haloLoc.uRefDist, state.fitDist || 96)
|
|
1679
|
+
gl.uniform1f(haloLoc.uMaxSize, 420.0)
|
|
1680
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position)
|
|
1681
|
+
gl.enableVertexAttribArray(haloLoc.position)
|
|
1682
|
+
gl.vertexAttribPointer(haloLoc.position, 3, gl.FLOAT, false, 0, 0)
|
|
1683
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.color)
|
|
1684
|
+
gl.enableVertexAttribArray(haloLoc.color)
|
|
1685
|
+
gl.vertexAttribPointer(haloLoc.color, 3, gl.FLOAT, false, 0, 0)
|
|
1686
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size)
|
|
1687
|
+
gl.enableVertexAttribArray(haloLoc.size)
|
|
1688
|
+
gl.vertexAttribPointer(haloLoc.size, 1, gl.FLOAT, false, 0, 0)
|
|
1689
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.alpha)
|
|
1690
|
+
gl.enableVertexAttribArray(haloLoc.alpha)
|
|
1691
|
+
gl.vertexAttribPointer(haloLoc.alpha, 1, gl.FLOAT, false, 0, 0)
|
|
1692
|
+
// Two additive layers make a bulb, not an airbrush: a tight bright corona
|
|
1693
|
+
// plus a wide faint spill.
|
|
1694
|
+
gl.uniform1f(haloLoc.uSizeMul, (1.0 + 1.7 * state.halo) * state.scale)
|
|
1695
|
+
gl.uniform1f(haloLoc.uAlphaMul, 0.42 * Math.min(1.3, state.halo))
|
|
1696
|
+
gl.drawArrays(gl.POINTS, 0, state.count)
|
|
1697
|
+
gl.uniform1f(haloLoc.uSizeMul, (2.3 + 4.4 * state.halo) * state.scale)
|
|
1698
|
+
gl.uniform1f(haloLoc.uAlphaMul, 0.13 * Math.min(1.5, state.halo))
|
|
1699
|
+
gl.drawArrays(gl.POINTS, 0, state.count)
|
|
1700
|
+
|
|
1701
|
+
// Attribute state is global in WebGL1, so the node pass has to re-bind its
|
|
1702
|
+
// own pointers after the halo program used the same buffers.
|
|
1703
|
+
gl.useProgram(nodeProgram)
|
|
1704
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position)
|
|
1705
|
+
gl.enableVertexAttribArray(nodeLoc.position)
|
|
1706
|
+
gl.vertexAttribPointer(nodeLoc.position, 3, gl.FLOAT, false, 0, 0)
|
|
1707
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.color)
|
|
1708
|
+
gl.enableVertexAttribArray(nodeLoc.color)
|
|
1709
|
+
gl.vertexAttribPointer(nodeLoc.color, 3, gl.FLOAT, false, 0, 0)
|
|
1710
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size)
|
|
1711
|
+
gl.enableVertexAttribArray(nodeLoc.size)
|
|
1712
|
+
gl.vertexAttribPointer(nodeLoc.size, 1, gl.FLOAT, false, 0, 0)
|
|
1713
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.alpha)
|
|
1714
|
+
gl.enableVertexAttribArray(nodeLoc.alpha)
|
|
1715
|
+
gl.vertexAttribPointer(nodeLoc.alpha, 1, gl.FLOAT, false, 0, 0)
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1215
1718
|
gl.drawArrays(gl.POINTS, 0, state.count)
|
|
1216
1719
|
}
|
|
1217
1720
|
|
|
@@ -1227,6 +1730,10 @@ const NS = 'error-atlas'
|
|
|
1227
1730
|
for (const node of state.labelNodes) {
|
|
1228
1731
|
const el = state.labels.get(node.id)
|
|
1229
1732
|
if (!el) continue
|
|
1733
|
+
if (state.hiddenKinds.has(node.kind)) {
|
|
1734
|
+
el.style.opacity = '0'
|
|
1735
|
+
continue
|
|
1736
|
+
}
|
|
1230
1737
|
const idx = state.byId.get(node.id)
|
|
1231
1738
|
if (idx === undefined || state.depth[idx] <= 0) {
|
|
1232
1739
|
el.style.opacity = '0'
|
|
@@ -1288,6 +1795,8 @@ const NS = 'error-atlas'
|
|
|
1288
1795
|
}
|
|
1289
1796
|
// frame-rate independent easing: 0.08 per 60Hz frame
|
|
1290
1797
|
state.dist += (state.targetDist - state.dist) * (1 - Math.pow(0.92, dt))
|
|
1798
|
+
const focusEase = 1 - Math.pow(0.86, dt)
|
|
1799
|
+
for (let k = 0; k < 3; k += 1) state.focus[k] += (state.focusTarget[k] - state.focus[k]) * focusEase
|
|
1291
1800
|
|
|
1292
1801
|
if (state.alphaDirty) {
|
|
1293
1802
|
const sel = state.selected
|
|
@@ -1299,6 +1808,7 @@ const NS = 'error-atlas'
|
|
|
1299
1808
|
else if (state.neighbors.has(id)) state.alpha[i] = 0.85
|
|
1300
1809
|
else state.alpha[i] = 0.1
|
|
1301
1810
|
if (matches) state.alpha[i] = matches.has(id) ? Math.max(state.alpha[i], 0.95) : state.alpha[i] * 0.12
|
|
1811
|
+
if (state.hiddenKinds.size > 0 && state.hiddenKinds.has(state.nodes[i].kind)) state.alpha[i] = 0
|
|
1302
1812
|
}
|
|
1303
1813
|
state.alphaDirty = false
|
|
1304
1814
|
}
|
|
@@ -1347,10 +1857,12 @@ const NS = 'error-atlas'
|
|
|
1347
1857
|
nodes.forEach((node, index) => {
|
|
1348
1858
|
byId.set(node.id, index)
|
|
1349
1859
|
})
|
|
1860
|
+
state.indexById = byId
|
|
1350
1861
|
const links = []
|
|
1351
1862
|
const pairs = []
|
|
1352
1863
|
const linkColor = []
|
|
1353
1864
|
const linkAlpha = []
|
|
1865
|
+
const linkRoles = []
|
|
1354
1866
|
for (const link of rawLinks) {
|
|
1355
1867
|
const a = byId.get(link && link.s)
|
|
1356
1868
|
const b = byId.get(link && link.t)
|
|
@@ -1366,13 +1878,22 @@ const NS = 'error-atlas'
|
|
|
1366
1878
|
const lc = stale ? STALE_COLOR : KIND_COLOR.retro || [0.78, 0.62, 1]
|
|
1367
1879
|
linkColor.push(lc[0], lc[1], lc[2], lc[0], lc[1], lc[2])
|
|
1368
1880
|
linkAlpha.push(stale ? 0.66 : 0.46, stale ? 0.66 : 0.46)
|
|
1881
|
+
linkRoles.push(stale ? 'stale' : 'lesson')
|
|
1369
1882
|
continue
|
|
1370
1883
|
}
|
|
1371
1884
|
const ca = KIND_COLOR[nodes[a].kind] || [1, 1, 1]
|
|
1372
1885
|
const cb = KIND_COLOR[nodes[b].kind] || [1, 1, 1]
|
|
1373
1886
|
linkColor.push((ca[0] + cb[0]) * 0.5, (ca[1] + cb[1]) * 0.5, (ca[2] + cb[2]) * 0.5)
|
|
1374
1887
|
linkColor.push((ca[0] + cb[0]) * 0.5, (ca[1] + cb[1]) * 0.5, (ca[2] + cb[2]) * 0.5)
|
|
1375
|
-
|
|
1888
|
+
// The tether to a satellite is drawn a little brighter than a hub spoke:
|
|
1889
|
+
// it is the only thing that tells you which ball the program belongs to.
|
|
1890
|
+
// Spokes to the core are the most numerous edges in the graph, so they stay
|
|
1891
|
+
// dim: the islands should be readable before the web around the core is.
|
|
1892
|
+
const satelliteEdge = nodes[a].kind === 'project' || nodes[b].kind === 'project'
|
|
1893
|
+
const rootSpoke = nodes[a].kind === 'root' || nodes[b].kind === 'root'
|
|
1894
|
+
const alpha = satelliteEdge ? 0.34 : rootSpoke ? 0.16 : 0.26
|
|
1895
|
+
linkAlpha.push(alpha, alpha)
|
|
1896
|
+
linkRoles.push(satelliteEdge ? 'project' : rootSpoke ? 'core' : 'hub')
|
|
1376
1897
|
}
|
|
1377
1898
|
|
|
1378
1899
|
const count = nodes.length
|
|
@@ -1417,6 +1938,8 @@ const NS = 'error-atlas'
|
|
|
1417
1938
|
state.linkVerts = new Float32Array(links.length * 6)
|
|
1418
1939
|
state.linkColor = new Float32Array(linkColor)
|
|
1419
1940
|
state.linkAlpha = new Float32Array(linkAlpha)
|
|
1941
|
+
state.linkBaseAlpha = Float32Array.from(linkAlpha)
|
|
1942
|
+
state.linkRoles = linkRoles
|
|
1420
1943
|
state.sx = new Float32Array(count)
|
|
1421
1944
|
state.sy = new Float32Array(count)
|
|
1422
1945
|
state.depth = new Float32Array(count)
|
|
@@ -1427,7 +1950,7 @@ const NS = 'error-atlas'
|
|
|
1427
1950
|
state.color[i * 3] = color[0]
|
|
1428
1951
|
state.color[i * 3 + 1] = color[1]
|
|
1429
1952
|
state.color[i * 3 + 2] = color[2]
|
|
1430
|
-
state.baseAlpha[i] = isHubNode(nodes[i].kind) ?
|
|
1953
|
+
state.baseAlpha[i] = isHubNode(nodes[i].kind) ? 0.94 : 0.74
|
|
1431
1954
|
state.alpha[i] = state.baseAlpha[i]
|
|
1432
1955
|
}
|
|
1433
1956
|
for (let i = 0; i < count; i += 1) {
|
|
@@ -1530,6 +2053,28 @@ const NS = 'error-atlas'
|
|
|
1530
2053
|
return best >= 0 ? state.nodes[best] : null
|
|
1531
2054
|
}
|
|
1532
2055
|
|
|
2056
|
+
/** Circle the camera around one node: its position becomes the orbit centre. */
|
|
2057
|
+
function focusOn(node) {
|
|
2058
|
+
if (!node || !state.indexById) return false
|
|
2059
|
+
const index = state.indexById.get(node.id)
|
|
2060
|
+
if (index === undefined) return false
|
|
2061
|
+
state.focusTarget[0] = state.pos[index * 3]
|
|
2062
|
+
state.focusTarget[1] = state.pos[index * 3 + 1]
|
|
2063
|
+
state.focusTarget[2] = state.pos[index * 3 + 2]
|
|
2064
|
+
// Close in until the picked node is clearly the subject: small nodes pull the
|
|
2065
|
+
// camera quite close, hubs stay a little further out so their satellites fit.
|
|
2066
|
+
const size = state.size[index] || 4
|
|
2067
|
+
state.targetDist = Math.max(12, Math.min(state.targetDist, 26 + size * 1.7))
|
|
2068
|
+
return true
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function clearFocus() {
|
|
2072
|
+
state.focusTarget[0] = 0
|
|
2073
|
+
state.focusTarget[1] = 0
|
|
2074
|
+
state.focusTarget[2] = 0
|
|
2075
|
+
state.targetDist = state.fitDist || 96
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1533
2078
|
function resetView() {
|
|
1534
2079
|
state.yaw = 0.55
|
|
1535
2080
|
state.pitch = -0.14
|
|
@@ -1537,6 +2082,7 @@ const NS = 'error-atlas'
|
|
|
1537
2082
|
state.warmup = 120
|
|
1538
2083
|
state.simBudget = 0
|
|
1539
2084
|
state.needsFit = true
|
|
2085
|
+
clearFocus()
|
|
1540
2086
|
}
|
|
1541
2087
|
|
|
1542
2088
|
function relayout() {
|
|
@@ -1585,7 +2131,11 @@ const NS = 'error-atlas'
|
|
|
1585
2131
|
/* ignore */
|
|
1586
2132
|
}
|
|
1587
2133
|
if (state.moved > 6) return
|
|
1588
|
-
|
|
2134
|
+
const node = hitTest(event.clientX, event.clientY)
|
|
2135
|
+
// Where you click is what you orbit: the picked node becomes the camera centre,
|
|
2136
|
+
// and a click on empty space returns the camera to the middle of the cloud.
|
|
2137
|
+
if (node) focusOn(node)
|
|
2138
|
+
else clearFocus()
|
|
1589
2139
|
if (handlers && typeof handlers.onPick === 'function') handlers.onPick(node)
|
|
1590
2140
|
}
|
|
1591
2141
|
|
|
@@ -1604,8 +2154,8 @@ const NS = 'error-atlas'
|
|
|
1604
2154
|
|
|
1605
2155
|
function onWheel(event) {
|
|
1606
2156
|
event.preventDefault()
|
|
1607
|
-
const factor = Math.exp((event.deltaY || 0) * 0.
|
|
1608
|
-
state.targetDist = Math.max(
|
|
2157
|
+
const factor = Math.exp((event.deltaY || 0) * 0.0021)
|
|
2158
|
+
state.targetDist = Math.max(12, Math.min(420, state.targetDist * factor))
|
|
1609
2159
|
}
|
|
1610
2160
|
|
|
1611
2161
|
function onContextMenu(event) {
|
|
@@ -1632,10 +2182,43 @@ const NS = 'error-atlas'
|
|
|
1632
2182
|
setQuery,
|
|
1633
2183
|
setLabels,
|
|
1634
2184
|
hitTest,
|
|
1635
|
-
|
|
2185
|
+
resetView,
|
|
2186
|
+
focusOn,
|
|
2187
|
+
clearFocus,
|
|
1636
2188
|
relayout,
|
|
1637
2189
|
// Test hook only: lets a headless harness inspect the live render state.
|
|
1638
2190
|
__state: () => state,
|
|
2191
|
+
setScale(value) {
|
|
2192
|
+
state.scale = Math.max(0.08, Math.min(3, Number(value) || 1))
|
|
2193
|
+
},
|
|
2194
|
+
|
|
2195
|
+
setHalo(value) {
|
|
2196
|
+
state.halo = Math.max(0, Math.min(4, Number(value) || 0))
|
|
2197
|
+
},
|
|
2198
|
+
|
|
2199
|
+
setFlowScale(value) {
|
|
2200
|
+
state.flowScale = Math.max(0.2, Math.min(4, Number(value) || 1))
|
|
2201
|
+
},
|
|
2202
|
+
|
|
2203
|
+
setSpread(value) {
|
|
2204
|
+
state.spread = Math.max(0.35, Math.min(2.4, Number(value) || 1))
|
|
2205
|
+
},
|
|
2206
|
+
|
|
2207
|
+
setFilters(nodeKinds, linkRoles) {
|
|
2208
|
+
state.hiddenKinds = new Set(Array.isArray(nodeKinds) ? nodeKinds : [])
|
|
2209
|
+
state.hiddenLinks = new Set(Array.isArray(linkRoles) ? linkRoles : [])
|
|
2210
|
+
state.alphaDirty = true
|
|
2211
|
+
const base = state.linkBaseAlpha
|
|
2212
|
+
if (base && state.linkAlpha) {
|
|
2213
|
+
for (let i = 0; i < state.linkRoles.length; i += 1) {
|
|
2214
|
+
const off = state.hiddenLinks.has(state.linkRoles[i]) ? 0 : 1
|
|
2215
|
+
state.linkAlpha[i * 2] = base[i * 2] * off
|
|
2216
|
+
state.linkAlpha[i * 2 + 1] = base[i * 2 + 1] * off
|
|
2217
|
+
}
|
|
2218
|
+
uploadLinks()
|
|
2219
|
+
}
|
|
2220
|
+
},
|
|
2221
|
+
|
|
1639
2222
|
setSpin(value) {
|
|
1640
2223
|
const next = Number(value)
|
|
1641
2224
|
if (!Number.isFinite(next)) return
|
|
@@ -1734,6 +2317,41 @@ const NS = 'error-atlas'
|
|
|
1734
2317
|
.dshea-stat-label { font-size: 9px; letter-spacing: 0.16em; color: rgba(236, 238, 243, 0.42); text-transform: uppercase; }
|
|
1735
2318
|
.dshea-spacer { flex: 1 1 auto; }
|
|
1736
2319
|
.dshea-actions { display: flex; align-items: center; gap: 7px; }
|
|
2320
|
+
.dshea-colors { display: flex; align-items: center; gap: 3px; padding: 2px 6px; border: 1px solid rgba(255, 122, 132, 0.16); border-radius: 999px; }
|
|
2321
|
+
.dshea-colors-label { font: 600 8px/1 ui-monospace, monospace; letter-spacing: 0.14em; color: var(--dsw-alias-label-tertiary, #8a8f98); margin-right: 2px; }
|
|
2322
|
+
.dshea-colors-count { font: 600 8px/1 ui-monospace, monospace; color: rgba(255, 255, 255, 0.32); margin-right: 2px; }
|
|
2323
|
+
.dshea-chip {
|
|
2324
|
+
width: 10px;
|
|
2325
|
+
height: 10px;
|
|
2326
|
+
padding: 0;
|
|
2327
|
+
border: 1px solid var(--chip);
|
|
2328
|
+
border-radius: 50%;
|
|
2329
|
+
background: transparent;
|
|
2330
|
+
opacity: 0.34;
|
|
2331
|
+
cursor: pointer;
|
|
2332
|
+
transition: opacity 120ms ease, box-shadow 120ms ease;
|
|
2333
|
+
}
|
|
2334
|
+
.dshea-chip.is-on { opacity: 1; background: var(--chip); border-color: var(--chip); box-shadow: 0 0 6px var(--chip); }
|
|
2335
|
+
.dshea-chip:not(.is-on) { border-style: dashed; opacity: 0.28; }
|
|
2336
|
+
.dshea-chip:hover { opacity: 0.85; }
|
|
2337
|
+
.dshea-style {
|
|
2338
|
+
display: none;
|
|
2339
|
+
position: absolute;
|
|
2340
|
+
top: 52px;
|
|
2341
|
+
right: 14px;
|
|
2342
|
+
z-index: 6;
|
|
2343
|
+
flex-direction: column;
|
|
2344
|
+
gap: 9px;
|
|
2345
|
+
min-width: 240px;
|
|
2346
|
+
padding: 12px 14px;
|
|
2347
|
+
border: 1px solid rgba(255, 122, 132, 0.28);
|
|
2348
|
+
border-radius: 10px;
|
|
2349
|
+
background: rgba(10, 8, 10, 0.94);
|
|
2350
|
+
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.55);
|
|
2351
|
+
}
|
|
2352
|
+
.dshea-style.is-open { display: flex; }
|
|
2353
|
+
.dshea-style .dshea-spin { justify-content: space-between; }
|
|
2354
|
+
.dshea-style .dshea-range { flex: 1 1 auto; margin: 0 8px; }
|
|
1737
2355
|
.dshea-btn {
|
|
1738
2356
|
appearance: none;
|
|
1739
2357
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
@@ -2092,8 +2710,57 @@ const NS = 'error-atlas'
|
|
|
2092
2710
|
const [data, setData] = useState(() => withDev(null))
|
|
2093
2711
|
const [failure, setFailure] = useState(null)
|
|
2094
2712
|
const [selectedId, setSelectedId] = useState(null)
|
|
2713
|
+
const [focusedLabel, setFocusedLabel] = useState(null)
|
|
2095
2714
|
const [hoveredNode, setHoveredNode] = useState(null)
|
|
2096
2715
|
const [showLog, setShowLog] = useState(true)
|
|
2716
|
+
const [scale, setScale] = useState(() => {
|
|
2717
|
+
try {
|
|
2718
|
+
const raw = Number(window.localStorage.getItem('dsh-error-atlas-scale'))
|
|
2719
|
+
return Number.isFinite(raw) && raw >= 10 && raw <= 300 ? raw : 100
|
|
2720
|
+
} catch {
|
|
2721
|
+
return 100
|
|
2722
|
+
}
|
|
2723
|
+
})
|
|
2724
|
+
const [flowScale, setFlowScale] = useState(() => {
|
|
2725
|
+
try {
|
|
2726
|
+
const raw = Number(window.localStorage.getItem('dsh-error-atlas-flow'))
|
|
2727
|
+
return Number.isFinite(raw) && raw >= 20 && raw <= 400 ? raw : 100
|
|
2728
|
+
} catch {
|
|
2729
|
+
return 100
|
|
2730
|
+
}
|
|
2731
|
+
})
|
|
2732
|
+
const [spread, setSpread] = useState(() => {
|
|
2733
|
+
try {
|
|
2734
|
+
const raw = Number(window.localStorage.getItem('dsh-error-atlas-spread'))
|
|
2735
|
+
return Number.isFinite(raw) && raw >= 35 && raw <= 240 ? raw : 100
|
|
2736
|
+
} catch {
|
|
2737
|
+
return 100
|
|
2738
|
+
}
|
|
2739
|
+
})
|
|
2740
|
+
const readFilters = (key) => {
|
|
2741
|
+
try {
|
|
2742
|
+
const raw = window.localStorage.getItem(key)
|
|
2743
|
+
const list = raw ? JSON.parse(raw) : []
|
|
2744
|
+
return Array.isArray(list) ? list.filter((item) => typeof item === 'string') : []
|
|
2745
|
+
} catch {
|
|
2746
|
+
return []
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
const [offKinds, setOffKinds] = useState(() => readFilters('dsh-error-atlas-off-kinds'))
|
|
2750
|
+
const [offLinks, setOffLinks] = useState(() => readFilters('dsh-error-atlas-off-links'))
|
|
2751
|
+
const offKindsRef = useRef(offKinds)
|
|
2752
|
+
const offLinksRef = useRef(offLinks)
|
|
2753
|
+
offKindsRef.current = offKinds
|
|
2754
|
+
offLinksRef.current = offLinks
|
|
2755
|
+
const [styleOpen, setStyleOpen] = useState(false)
|
|
2756
|
+
const [halo, setHalo] = useState(() => {
|
|
2757
|
+
try {
|
|
2758
|
+
const raw = Number(window.localStorage.getItem('dsh-error-atlas-halo'))
|
|
2759
|
+
return Number.isFinite(raw) && raw >= 0 && raw <= 400 ? raw : 100
|
|
2760
|
+
} catch {
|
|
2761
|
+
return 100
|
|
2762
|
+
}
|
|
2763
|
+
})
|
|
2097
2764
|
const [spin, setSpin] = useState(() => {
|
|
2098
2765
|
try {
|
|
2099
2766
|
const saved = Number(window.localStorage.getItem('dsh-error-atlas-spin'))
|
|
@@ -2217,10 +2884,11 @@ const NS = 'error-atlas'
|
|
|
2217
2884
|
try {
|
|
2218
2885
|
renderer = createRenderer(canvas, labelHost, {
|
|
2219
2886
|
onHover: (node) => setHoveredNode(node || null),
|
|
2220
|
-
|
|
2887
|
+
onPick: (node) => {
|
|
2221
2888
|
const next = node ? node.id : null
|
|
2222
2889
|
setSelectedId(next)
|
|
2223
2890
|
setHoveredNode(node || null)
|
|
2891
|
+
setFocusedLabel(node ? String(node.label || node.name || node.id).slice(0, 30) : null)
|
|
2224
2892
|
},
|
|
2225
2893
|
onOpen: (node) => {
|
|
2226
2894
|
const id = sessionIdOf(node)
|
|
@@ -2271,6 +2939,11 @@ const NS = 'error-atlas'
|
|
|
2271
2939
|
useEffect(() => {
|
|
2272
2940
|
const renderer = rendererRef.current
|
|
2273
2941
|
if (renderer) renderer.setSpin(spin / 100)
|
|
2942
|
+
if (renderer) renderer.setScale(scale / 100)
|
|
2943
|
+
if (renderer) renderer.setHalo(halo / 100)
|
|
2944
|
+
if (renderer) renderer.setFlowScale(flowScale / 100)
|
|
2945
|
+
if (renderer) renderer.setSpread(spread / 100)
|
|
2946
|
+
if (renderer) renderer.setFilters(offKindsRef.current, offLinksRef.current)
|
|
2274
2947
|
}, [spin])
|
|
2275
2948
|
|
|
2276
2949
|
useEffect(() => {
|
|
@@ -2303,6 +2976,76 @@ const NS = 'error-atlas'
|
|
|
2303
2976
|
setPaused(next)
|
|
2304
2977
|
}, [])
|
|
2305
2978
|
|
|
2979
|
+
const toggleKind = useCallback((kind) => {
|
|
2980
|
+
const next = offKindsRef.current.includes(kind)
|
|
2981
|
+
? offKindsRef.current.filter((item) => item !== kind)
|
|
2982
|
+
: offKindsRef.current.concat(kind)
|
|
2983
|
+
setOffKinds(next)
|
|
2984
|
+
try {
|
|
2985
|
+
window.localStorage.setItem('dsh-error-atlas-off-kinds', JSON.stringify(next))
|
|
2986
|
+
} catch {
|
|
2987
|
+
/* storage is best effort */
|
|
2988
|
+
}
|
|
2989
|
+
if (rendererRef.current) rendererRef.current.setFilters(next, offLinksRef.current)
|
|
2990
|
+
}, [])
|
|
2991
|
+
|
|
2992
|
+
const toggleLink = useCallback((role) => {
|
|
2993
|
+
const next = offLinksRef.current.includes(role)
|
|
2994
|
+
? offLinksRef.current.filter((item) => item !== role)
|
|
2995
|
+
: offLinksRef.current.concat(role)
|
|
2996
|
+
setOffLinks(next)
|
|
2997
|
+
try {
|
|
2998
|
+
window.localStorage.setItem('dsh-error-atlas-off-links', JSON.stringify(next))
|
|
2999
|
+
} catch {
|
|
3000
|
+
/* storage is best effort */
|
|
3001
|
+
}
|
|
3002
|
+
if (rendererRef.current) rendererRef.current.setFilters(offKindsRef.current, next)
|
|
3003
|
+
}, [])
|
|
3004
|
+
|
|
3005
|
+
const onSpread = useCallback((value) => {
|
|
3006
|
+
const next = Math.max(35, Math.min(240, Number(value) || 100))
|
|
3007
|
+
setSpread(next)
|
|
3008
|
+
try {
|
|
3009
|
+
window.localStorage.setItem('dsh-error-atlas-spread', String(next))
|
|
3010
|
+
} catch {
|
|
3011
|
+
/* storage is best effort */
|
|
3012
|
+
}
|
|
3013
|
+
if (rendererRef.current) rendererRef.current.setSpread(next / 100)
|
|
3014
|
+
}, [])
|
|
3015
|
+
|
|
3016
|
+
const onFlowScale = useCallback((value) => {
|
|
3017
|
+
const next = Math.max(20, Math.min(400, Number(value) || 100))
|
|
3018
|
+
setFlowScale(next)
|
|
3019
|
+
try {
|
|
3020
|
+
window.localStorage.setItem('dsh-error-atlas-flow', String(next))
|
|
3021
|
+
} catch {
|
|
3022
|
+
/* storage is best effort */
|
|
3023
|
+
}
|
|
3024
|
+
if (rendererRef.current) rendererRef.current.setFlowScale(next / 100)
|
|
3025
|
+
}, [])
|
|
3026
|
+
|
|
3027
|
+
const onHalo = useCallback((value) => {
|
|
3028
|
+
const next = Math.max(0, Math.min(400, Number(value) || 0))
|
|
3029
|
+
setHalo(next)
|
|
3030
|
+
try {
|
|
3031
|
+
window.localStorage.setItem('dsh-error-atlas-halo', String(next))
|
|
3032
|
+
} catch {
|
|
3033
|
+
/* storage is best effort */
|
|
3034
|
+
}
|
|
3035
|
+
if (rendererRef.current) rendererRef.current.setHalo(next / 100)
|
|
3036
|
+
}, [])
|
|
3037
|
+
|
|
3038
|
+
const onScale = useCallback((value) => {
|
|
3039
|
+
const next = Math.max(10, Math.min(300, Number(value) || 100))
|
|
3040
|
+
setScale(next)
|
|
3041
|
+
try {
|
|
3042
|
+
window.localStorage.setItem('dsh-error-atlas-scale', String(next))
|
|
3043
|
+
} catch {
|
|
3044
|
+
/* storage is best effort */
|
|
3045
|
+
}
|
|
3046
|
+
if (rendererRef.current) rendererRef.current.setScale(next / 100)
|
|
3047
|
+
}, [])
|
|
3048
|
+
|
|
2306
3049
|
const onSpin = useCallback((value) => {
|
|
2307
3050
|
const next = Number(value)
|
|
2308
3051
|
if (!Number.isFinite(next)) return
|
|
@@ -2460,6 +3203,25 @@ const NS = 'error-atlas'
|
|
|
2460
3203
|
|
|
2461
3204
|
const rateLabel = paused ? '0.0°/s' : ((spin / 100) * SPIN_DEG_PER_SEC).toFixed(1) + '°/s'
|
|
2462
3205
|
|
|
3206
|
+
const chipRow = (label, items, off, onToggle) =>
|
|
3207
|
+
h(
|
|
3208
|
+
'div',
|
|
3209
|
+
{ className: 'dshea-colors', title: label },
|
|
3210
|
+
h('span', { className: 'dshea-colors-label' }, label),
|
|
3211
|
+
h('span', { className: 'dshea-colors-count' }, items.length - off.length + '/' + items.length),
|
|
3212
|
+
items.map(([value, color, name]) =>
|
|
3213
|
+
h('button', {
|
|
3214
|
+
key: value,
|
|
3215
|
+
type: 'button',
|
|
3216
|
+
className: 'dshea-chip' + (off.includes(value) ? '' : ' is-on'),
|
|
3217
|
+
style: { '--chip': color },
|
|
3218
|
+
title: (off.includes(value) ? '✕ ' : '✓ ') + (name || value),
|
|
3219
|
+
onClick: () => onToggle(value),
|
|
3220
|
+
'aria-pressed': off.includes(value) ? 'false' : 'true',
|
|
3221
|
+
}),
|
|
3222
|
+
),
|
|
3223
|
+
)
|
|
3224
|
+
|
|
2463
3225
|
const header = h(
|
|
2464
3226
|
'div',
|
|
2465
3227
|
{ className: 'dshea-header' },
|
|
@@ -2486,6 +3248,33 @@ const NS = 'error-atlas'
|
|
|
2486
3248
|
h(
|
|
2487
3249
|
'div',
|
|
2488
3250
|
{ className: 'dshea-actions' },
|
|
3251
|
+
chipRow(
|
|
3252
|
+
COPY.nodeColors,
|
|
3253
|
+
[
|
|
3254
|
+
['agent', '#ff4453', 'AGENT'],
|
|
3255
|
+
['tool', '#ffb570', 'TOOL'],
|
|
3256
|
+
['exit', '#ffd68f', 'EXIT'],
|
|
3257
|
+
['config', '#d89eff', 'CONFIG'],
|
|
3258
|
+
['sessionhub', '#ffd9e4', 'SESSION'],
|
|
3259
|
+
['project', '#7fd4ff', 'PROJECT'],
|
|
3260
|
+
['dev', '#7ff3e0', 'DEV'],
|
|
3261
|
+
['retro', '#c7a4ff', 'RETRO'],
|
|
3262
|
+
],
|
|
3263
|
+
offKinds,
|
|
3264
|
+
toggleKind,
|
|
3265
|
+
),
|
|
3266
|
+
chipRow(
|
|
3267
|
+
COPY.linkColors,
|
|
3268
|
+
[
|
|
3269
|
+
['core', '#b4514f', COPY.linkCore],
|
|
3270
|
+
['hub', '#c98b62', COPY.linkHub],
|
|
3271
|
+
['project', '#7fd4ff', COPY.linkProject],
|
|
3272
|
+
['lesson', '#c7a4ff', COPY.linkLesson],
|
|
3273
|
+
['stale', '#ff5b4d', COPY.linkStale],
|
|
3274
|
+
],
|
|
3275
|
+
offLinks,
|
|
3276
|
+
toggleLink,
|
|
3277
|
+
),
|
|
2489
3278
|
h('button', { type: 'button', className: 'dshea-btn', onClick: onRefresh, disabled: busy }, COPY.refresh),
|
|
2490
3279
|
h('input', {
|
|
2491
3280
|
type: 'search',
|
|
@@ -2515,24 +3304,14 @@ const NS = 'error-atlas'
|
|
|
2515
3304
|
paused ? COPY.resume : COPY.pause,
|
|
2516
3305
|
),
|
|
2517
3306
|
h(
|
|
2518
|
-
'
|
|
2519
|
-
{
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
value: spin,
|
|
2527
|
-
className: 'dshea-range',
|
|
2528
|
-
// Both handlers on purpose: `onChange` is React's normal range
|
|
2529
|
-
// path, `onInput` keeps the control alive even if an intermediate
|
|
2530
|
-
// layer swallows the synthetic change event.
|
|
2531
|
-
onChange: (event) => onSpin(event.target.value),
|
|
2532
|
-
onInput: (event) => onSpin(event.target.value),
|
|
2533
|
-
}),
|
|
2534
|
-
h('span', { className: 'dshea-spin-value' }, Math.round(spin) + '%'),
|
|
2535
|
-
h('span', { className: 'dshea-spin-rate' }, rateLabel),
|
|
3307
|
+
'button',
|
|
3308
|
+
{
|
|
3309
|
+
type: 'button',
|
|
3310
|
+
className: 'dshea-btn dshea-style-btn' + (styleOpen ? ' is-on' : ''),
|
|
3311
|
+
onClick: () => setStyleOpen((open) => !open),
|
|
3312
|
+
'aria-expanded': styleOpen ? 'true' : 'false',
|
|
3313
|
+
},
|
|
3314
|
+
COPY.style,
|
|
2536
3315
|
),
|
|
2537
3316
|
h(
|
|
2538
3317
|
'label',
|
|
@@ -2567,6 +3346,95 @@ const NS = 'error-atlas'
|
|
|
2567
3346
|
),
|
|
2568
3347
|
)
|
|
2569
3348
|
|
|
3349
|
+
const stylePanel = h(
|
|
3350
|
+
'div',
|
|
3351
|
+
{ className: styleOpen ? 'dshea-style is-open' : 'dshea-style' },
|
|
3352
|
+
h(
|
|
3353
|
+
'label',
|
|
3354
|
+
{ className: 'dshea-spin' },
|
|
3355
|
+
h('span', { className: 'dshea-spin-label' }, COPY.spin),
|
|
3356
|
+
h('input', {
|
|
3357
|
+
type: 'range',
|
|
3358
|
+
min: 0,
|
|
3359
|
+
max: 100,
|
|
3360
|
+
step: 1,
|
|
3361
|
+
value: spin,
|
|
3362
|
+
className: 'dshea-range',
|
|
3363
|
+
// Both handlers on purpose: `onChange` is React's normal range
|
|
3364
|
+
// path, `onInput` keeps the control alive even if an intermediate
|
|
3365
|
+
// layer swallows the synthetic change event.
|
|
3366
|
+
onChange: (event) => onSpin(event.target.value),
|
|
3367
|
+
onInput: (event) => onSpin(event.target.value),
|
|
3368
|
+
}),
|
|
3369
|
+
h('span', { className: 'dshea-spin-value' }, Math.round(spin) + '%'),
|
|
3370
|
+
h('span', { className: 'dshea-spin-rate' }, rateLabel),
|
|
3371
|
+
),
|
|
3372
|
+
h(
|
|
3373
|
+
'label',
|
|
3374
|
+
{ className: 'dshea-spin dshea-size' },
|
|
3375
|
+
h('span', { className: 'dshea-spin-label' }, COPY.scale),
|
|
3376
|
+
h('input', {
|
|
3377
|
+
type: 'range',
|
|
3378
|
+
min: 10,
|
|
3379
|
+
max: 300,
|
|
3380
|
+
step: 5,
|
|
3381
|
+
value: scale,
|
|
3382
|
+
className: 'dshea-range',
|
|
3383
|
+
onChange: (event) => onScale(event.target.value),
|
|
3384
|
+
onInput: (event) => onScale(event.target.value),
|
|
3385
|
+
}),
|
|
3386
|
+
h('span', { className: 'dshea-spin-value' }, Math.round(scale) + '%'),
|
|
3387
|
+
),
|
|
3388
|
+
h(
|
|
3389
|
+
'label',
|
|
3390
|
+
{ className: 'dshea-spin dshea-halo' },
|
|
3391
|
+
h('span', { className: 'dshea-spin-label' }, COPY.halo),
|
|
3392
|
+
h('input', {
|
|
3393
|
+
type: 'range',
|
|
3394
|
+
min: 0,
|
|
3395
|
+
max: 400,
|
|
3396
|
+
step: 5,
|
|
3397
|
+
value: halo,
|
|
3398
|
+
className: 'dshea-range',
|
|
3399
|
+
onChange: (event) => onHalo(event.target.value),
|
|
3400
|
+
onInput: (event) => onHalo(event.target.value),
|
|
3401
|
+
}),
|
|
3402
|
+
h('span', { className: 'dshea-spin-value' }, Math.round(halo) + '%'),
|
|
3403
|
+
),
|
|
3404
|
+
h(
|
|
3405
|
+
'label',
|
|
3406
|
+
{ className: 'dshea-spin dshea-spread' },
|
|
3407
|
+
h('span', { className: 'dshea-spin-label' }, COPY.spread),
|
|
3408
|
+
h('input', {
|
|
3409
|
+
type: 'range',
|
|
3410
|
+
min: 35,
|
|
3411
|
+
max: 240,
|
|
3412
|
+
step: 5,
|
|
3413
|
+
value: spread,
|
|
3414
|
+
className: 'dshea-range',
|
|
3415
|
+
onChange: (event) => onSpread(event.target.value),
|
|
3416
|
+
onInput: (event) => onSpread(event.target.value),
|
|
3417
|
+
}),
|
|
3418
|
+
h('span', { className: 'dshea-spin-value' }, Math.round(spread) + '%'),
|
|
3419
|
+
),
|
|
3420
|
+
h(
|
|
3421
|
+
'label',
|
|
3422
|
+
{ className: 'dshea-spin dshea-flow' },
|
|
3423
|
+
h('span', { className: 'dshea-spin-label' }, COPY.flow),
|
|
3424
|
+
h('input', {
|
|
3425
|
+
type: 'range',
|
|
3426
|
+
min: 20,
|
|
3427
|
+
max: 400,
|
|
3428
|
+
step: 5,
|
|
3429
|
+
value: flowScale,
|
|
3430
|
+
className: 'dshea-range',
|
|
3431
|
+
onChange: (event) => onFlowScale(event.target.value),
|
|
3432
|
+
onInput: (event) => onFlowScale(event.target.value),
|
|
3433
|
+
}),
|
|
3434
|
+
h('span', { className: 'dshea-spin-value' }, Math.round(flowScale) + '%'),
|
|
3435
|
+
),
|
|
3436
|
+
)
|
|
3437
|
+
|
|
2570
3438
|
const legend = h(
|
|
2571
3439
|
'div',
|
|
2572
3440
|
{ className: 'dshea-legend' },
|
|
@@ -2584,6 +3452,7 @@ const NS = 'error-atlas'
|
|
|
2584
3452
|
// The hint carries the two honesty notes: how much of the graph is truncated and
|
|
2585
3453
|
// how many signatures the noise rules are hiding, plus the navigation gesture.
|
|
2586
3454
|
const hintBits = []
|
|
3455
|
+
if (focusedLabel) hintBits.push(fill(COPY.focused, { name: focusedLabel }))
|
|
2587
3456
|
if (stats && stats.truncated > 0) {
|
|
2588
3457
|
hintBits.push(fill(COPY.truncatedHint, { shown: stats.shown, cut: stats.truncated }))
|
|
2589
3458
|
}
|
|
@@ -2784,7 +3653,7 @@ const NS = 'error-atlas'
|
|
|
2784
3653
|
switchButton(COPY.memoryInject, injectOn, { inject: !injectOn }),
|
|
2785
3654
|
)
|
|
2786
3655
|
|
|
2787
|
-
return h('div', { className: 'dshea-root' }, header, stage, trendRow, memoryRow, boardView, logStrip)
|
|
3656
|
+
return h('div', { className: 'dshea-root' }, header, stylePanel, stage, trendRow, memoryRow, boardView, logStrip)
|
|
2788
3657
|
}
|
|
2789
3658
|
|
|
2790
3659
|
function statCell(label, value) {
|