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/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 = 'V33'
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
- hint: '拖拽旋转 · 滚轮缩放 · 点击节点查看',
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
- hint: 'drag to rotate · wheel to zoom · click a node',
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 = max(1.5, aSize * uSizeMul * uFocal / w);',
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
- const NODE_FS = [
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 core = pow(1.0 - r, 1.9);',
569
- ' float spark = pow(max(0.0, 1.0 - r * 1.9), 6.0);',
570
- ' gl_FragColor = vec4(vColor * (core * 0.85 + spark), vAlpha * core);',
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 = state.nodes[i]
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
- ? 5.5 + Math.min(7, Math.sqrt(node.count || 1) * 1.6)
787
- : 2.4 + Math.min(4.5, Math.sqrt(node.count || 1) * 1.3)
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
- const radius = isHub ? 6 + rand() * 10 : 18 + Math.pow(rand(), 0.65) * 34
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
- state.warmup = previous ? 70 : 210
810
- state.simBudget = previous ? 100 : 70
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 repulsion = 95
820
-
821
- for (let i = 0; i < n; i += 1) {
822
- const ix = i * 3
823
- const px = pos[ix]
824
- const py = pos[ix + 1]
825
- const pz = pos[ix + 2]
826
- let fx = 0
827
- let fy = 0
828
- let fz = 0
829
- for (let j = i + 1; j < n; j += 1) {
830
- const jx = j * 3
831
- let dx = px - pos[jx]
832
- let dy = py - pos[jx + 1]
833
- let dz = pz - pos[jx + 2]
834
- let d2 = dx * dx + dy * dy + dz * dz
835
- if (d2 < 0.02) {
836
- d2 = 0.02
837
- dx += 0.01
838
- dy += 0.01
839
- dz += 0.01
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
- const inv = repulsion / (d2 * Math.sqrt(d2))
842
- fx += dx * inv
843
- fy += dy * inv
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
- const force = (d - 11) * 0.05
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 = state.nodes.some((node) => node.kind === 'root')
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
- if (state.nodes[i].kind === 'root') {
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
- if (r > CLOUD_RADIUS) {
936
- const s = CLOUD_RADIUS / r
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, spin)
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
- // A pulse rides the pipe itself: only a hair of bow, so it always reads as
1105
- // travelling on the drawn line rather than floating beside it.
1106
- const lift = Math.sin(Math.PI * t) * 0.16
1107
- state.flowPos[i * 3] = ax + (bx - ax) * ease + lift * 0.3
1108
- state.flowPos[i * 3 + 1] = ay + (by - ay) * ease + lift
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.85)
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.uSizeMul, 1.0)
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.uSizeMul, 3.2)
1210
- gl.uniform1f(nodeLoc.uAlphaMul, 0.075)
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
- linkAlpha.push(0.2, 0.2)
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) ? 1 : 0.92
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
- const node = hitTest(event.clientX, event.clientY)
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.0012)
1608
- state.targetDist = Math.max(28, Math.min(340, state.targetDist * factor))
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
- resetView,
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
- onPick: (node) => {
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
- 'label',
2519
- { className: 'dshea-spin' },
2520
- h('span', { className: 'dshea-spin-label' }, COPY.spin),
2521
- h('input', {
2522
- type: 'range',
2523
- min: 0,
2524
- max: 100,
2525
- step: 1,
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) {