everydaytok commited on
Commit
ea2bd79
Β·
verified Β·
1 Parent(s): aa26b54

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +120 -107
index.html CHANGED
@@ -57,8 +57,8 @@
57
  <span id="b-data" class="px-1.5 py-0.5 rounded bg-pink-900/60 text-pink-300 border border-pink-800/60">HOUSNG</span>
58
  </div>
59
  <div class="flex items-center gap-2 ml-1">
60
- <!-- shared-vertex counter, hidden when 0 -->
61
- <span id="shared-lbl" class="text-[7px] text-slate-700"></span>
62
  <span id="q-lbl" class="text-[8px] text-slate-600">Q:0</span>
63
  <div id="run-dot" class="w-2 h-2 rounded-full bg-slate-700"></div>
64
  <button onclick="openDrawer()" class="text-[10px] bg-blue-700 hover:bg-blue-600 px-2 py-1 rounded font-bold">βš™ DIALS</button>
@@ -135,15 +135,16 @@
135
  </div>
136
 
137
  <!-- CROSS CONNECT -->
138
- <div class="col-span-2 bg-slate-900 rounded p-3 border border-violet-900/50">
139
- <div class="text-violet-400 text-[9px] font-bold mb-1">
140
  CROSS-CONNECT
141
- <span class="text-slate-600 font-normal ml-1">structural shared vertices</span>
142
  </div>
143
  <div class="text-[9px] text-slate-500 mb-2">
144
- OFF β†’ n independent parallel hourglasses (default)<br>
145
- ON β†’ boundary hidden nodes that visually overlap become one<br>
146
- &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;shared vertex with springs to both neighbouring dims
 
147
  </div>
148
  <button id="drawer-cross-btn" onclick="toggleCross()"
149
  class="w-full py-2 rounded text-xs font-bold border border-slate-700 bg-slate-800 text-slate-400">
@@ -256,62 +257,59 @@
256
  const cfg = { mode: 'training', architecture: 'additive' };
257
  const topo = { inputs: 1, upper: 3, lower: 3 };
258
  let crossConnect = false;
259
- let nShared = 0;
 
260
 
261
  // ── CROSS CONNECT ─────────────────────────────────────────────────────────────
262
  async function toggleCross() {
263
  const res = await fetch('/toggle_cross', { method: 'POST' });
264
  const data = await res.json();
265
  crossConnect = data.cross_connect;
266
- nShared = data.n_shared;
 
267
  updateCrossUI();
268
  meshPlotted = false;
269
  }
270
 
271
  function updateCrossUI() {
272
- const btn1 = document.getElementById('b-cross');
273
- const btn2 = document.getElementById('drawer-cross-btn');
274
- const info = document.getElementById('cross-info');
275
- const slbl = document.getElementById('shared-lbl');
276
- const n = topo.inputs;
277
- const u = topo.upper;
278
- const l = topo.lower;
279
- const ns = nShared || (crossConnect && n >= 2
280
- ? (n-1) * ((u >= 2 ? 1 : 0) + (l >= 2 ? 1 : 0))
281
- : 0);
282
 
283
  if (crossConnect) {
284
- btn1.className = 'px-1.5 py-0.5 rounded border font-bold text-[8px] transition-all bg-violet-800 text-violet-200 border-violet-600';
285
  btn1.innerText = 'CROSS:ON';
286
- btn2.className = 'w-full py-2 rounded text-xs font-bold border border-violet-600 bg-violet-900 text-violet-200';
287
  btn2.innerText = 'CROSS-CONNECT: ON (click to disable)';
288
- info.innerText = ns
289
- ? `${ns} shared vertex${ns !== 1 ? 'es' : ''} β€” boundary nodes merged`
290
- : 'No merges (need Dβ‰₯2 and U/Lβ‰₯2)';
291
- slbl.innerText = ns ? `βŠ•${ns}` : '';
292
- slbl.className = 'text-[7px] text-violet-400';
293
  } else {
294
  btn1.className = 'px-1.5 py-0.5 rounded border font-bold text-[8px] transition-all bg-slate-900 text-slate-600 border-slate-700';
295
  btn1.innerText = 'CROSS:OFF';
296
  btn2.className = 'w-full py-2 rounded text-xs font-bold border border-slate-700 bg-slate-800 text-slate-400';
297
  btn2.innerText = 'CROSS-CONNECT: OFF (click to enable)';
298
- info.innerText = `${n} independent parallel hourglass${n !== 1 ? 'es' : ''}`;
299
- slbl.innerText = '';
300
  }
301
  }
302
 
303
  // ── TOPOLOGY ──────────────────────────────────────────────────────────────────
304
  function updateSpringCount() {
305
  const { inputs: n, upper: u, lower: l } = topo;
306
- // With structural merge: each merge removes 2 springs and adds 2 (net=0),
307
- // but removes 1 node. Spring count stays the same; node count reduces.
308
- const ns = crossConnect && n >= 2
309
- ? (n-1) * ((u >= 2 ? 1 : 0) + (l >= 2 ? 1 : 0))
310
- : 0;
311
- const totalSprings = n * (2*u + 2*l); // unchanged by merge
312
- const totalNodes = 3*n + n*u + n*l - ns;
313
  document.getElementById('spring-count').innerText =
314
- `${totalSprings} springs | ${totalNodes} nodes${ns ? ` (${ns} shared vertices)` : ''}`;
315
  document.getElementById('custom-dim-hint').innerText =
316
  n === 1 ? '(single value)' : `(${n} values, comma-separated)`;
317
  updateCrossUI();
@@ -467,22 +465,22 @@ function tab(name) {
467
 
468
  // ── VISUALIZATION ─────────────────────────────────────────────────────────────
469
 
470
- function buildPos(layers, n_inputs, n_upper, n_lower) {
 
 
 
 
 
471
  const pos = {};
472
  const Y = [4.4, 2.1, 0.0, -2.1, -4.4];
473
  const COL_W = n_inputs === 1 ? 0 : Math.min(9.0 / (n_inputs - 1), 4.2);
474
  const halfSp = COL_W * (n_inputs - 1) / 2;
475
  const bSprd = n_inputs === 1 ? 3.8 : Math.min(COL_W * 0.55, 1.8);
476
 
477
- // Build a flat lookup: nid β†’ layer index (to get Y)
478
- const layerOf = {};
479
- layers.forEach((layer, li) => layer.forEach(nid => { layerOf[nid] = li; }));
480
-
481
  layers.forEach((layer, li) => {
482
  const y = Y[li];
483
  layer.forEach(nid => {
484
  const kind = nid[0];
485
- // Parse dim and j from node ID
486
  let dim = 1, j = 1, total = 1;
487
  if ('ABC'.includes(kind)) {
488
  dim = parseInt(nid.slice(1));
@@ -492,7 +490,7 @@ function buildPos(layers, n_inputs, n_upper, n_lower) {
492
  j = parseInt(parts[1]);
493
  total = kind === 'U' ? n_upper : n_lower;
494
  }
495
- const cx = n_inputs === 1 ? 0 : -halfSp + (dim-1)*COL_W;
496
  if ('ABC'.includes(kind)) {
497
  pos[nid] = [cx, y];
498
  } else {
@@ -501,14 +499,25 @@ function buildPos(layers, n_inputs, n_upper, n_lower) {
501
  }
502
  });
503
  });
 
 
 
 
 
 
 
 
 
 
 
504
  return pos;
505
  }
506
 
507
  function busShapes(pos, n_inputs) {
508
  const sh = [];
509
  const mg = n_inputs === 1 ? 1.4 : 0.8;
510
- const xs = k => Object.entries(pos).filter(([id])=>id[0]===k).map(([,v])=>v[0]);
511
- const rect = (mn,mx,yc,hh,fill,stroke) => sh.push({
512
  type:'rect', xref:'x', yref:'y',
513
  x0:mn-mg, x1:mx+mg, y0:yc-hh, y1:yc+hh,
514
  fillcolor:fill, line:{color:stroke, width:2}
@@ -526,35 +535,21 @@ function springColor(k) {
526
  return [`rgb(30,${Math.round(80+t*100)},${Math.round(140+t*115)})`, 1.0+t*3.5];
527
  }
528
 
529
- // A spring is a "shared-vertex spring" if it crosses dimensions:
530
- // e.g. A2β†’U1_3 (dim2 input β†’ dim1's canonical shared node).
531
- // We detect this by checking if the input side dim differs from the node's dim.
532
- function isSharedSpring(key) {
533
- const [u, v] = key.split('β†’');
534
- if (!u || !v) return false;
535
- const getDim = s => {
536
- if ('ABC'.includes(s[0])) return parseInt(s.slice(1));
537
- const p = s.slice(1).split('_'); return parseInt(p[0]);
538
- };
539
- try {
540
- const du = getDim(u), dv = getDim(v);
541
- return !isNaN(du) && !isNaN(dv) && du !== dv;
542
- } catch { return false; }
543
- }
544
-
545
- function buildTraces(nodes, springs, layers, n_inputs, n_upper, n_lower) {
546
- const pos = buildPos(layers, n_inputs, n_upper, n_lower);
547
  const traces = [];
548
 
549
- // Draw shared-vertex springs on top (after normal springs)
550
- const normalEdges = [], sharedEdges = [];
551
  for (const [key, k] of Object.entries(springs)) {
552
  const [u, v] = key.split('β†’');
553
  if (!pos[u] || !pos[v]) continue;
554
- (isSharedSpring(key) ? sharedEdges : normalEdges).push([key, k, u, v]);
555
- }
556
-
557
- for (const [key, k, u, v] of normalEdges) {
558
  const [col, wd] = springColor(k);
559
  traces.push({
560
  type:'scatter', mode:'lines',
@@ -564,36 +559,29 @@ function buildTraces(nodes, springs, layers, n_inputs, n_upper, n_lower) {
564
  });
565
  }
566
 
567
- // Shared-vertex springs drawn in violet with slight dash to distinguish
568
- for (const [key, k, u, v] of sharedEdges) {
569
- const t = Math.min(Math.abs(k) / 6, 1);
570
- const col = `rgb(${Math.round(160+t*60)},80,${Math.round(200+t*55)})`;
571
  traces.push({
572
  type:'scatter', mode:'lines',
573
  x:[pos[u][0], pos[v][0]], y:[pos[u][1], pos[v][1]],
574
- line:{color:col, width:1.8+t*2, dash:'dot'},
575
  hoverinfo:'none', showlegend:false
576
  });
577
  }
578
 
579
- // Nodes
580
- const allN = layers.flat();
 
 
581
  const NCOL = id => {
582
  const k = id[0];
583
  return k==='A'?'#fb923c': k==='B'?'#c084fc': k==='C'?'#38bdf8':
584
- k==='U'?'#4ade80': '#67e8f9';
585
  };
586
- const isIO = id => 'ABC'.includes(id[0]);
587
-
588
- // Mark shared vertices (appear in springs from multiple dims)
589
- const sharedNodes = new Set();
590
- for (const key of Object.keys(springs)) {
591
- if (isSharedSpring(key)) {
592
- const [u, v] = key.split('β†’');
593
- if ('UL'.includes((u||'')[0])) sharedNodes.add(u);
594
- if ('UL'.includes((v||'')[0])) sharedNodes.add(v);
595
- }
596
- }
597
 
598
  traces.push({
599
  type:'scatter', mode:'markers+text',
@@ -607,17 +595,19 @@ function buildTraces(nodes, springs, layers, n_inputs, n_upper, n_lower) {
607
  textfont:{ size:9, color: allN.map(id => NCOL(id)) },
608
  marker:{
609
  size: allN.map(id => {
610
- const v = Math.abs(nodes[id]?.vel ?? 0);
611
- const base = isIO(id) ? 18 : (sharedNodes.has(id) ? 14 : 10);
612
  return base + Math.min(v*30, 8);
613
  }),
614
- color: allN.map(id => sharedNodes.has(id) ? '#a78bfa' : NCOL(id)),
 
615
  opacity: allN.map(id => 0.75 + Math.min(Math.abs(nodes[id]?.vel??0)*1.8, 0.25)),
616
  line:{
617
- width: allN.map(id => sharedNodes.has(id) ? 3.5 : 2.5),
618
  color: allN.map(id =>
619
- sharedNodes.has(id) ? '#7c3aed'
620
- : nodes[id]?.anchored ? '#ef4444' : '#22c55e'
 
621
  )
622
  }
623
  },
@@ -627,8 +617,9 @@ function buildTraces(nodes, springs, layers, n_inputs, n_upper, n_lower) {
627
  return traces;
628
  }
629
 
630
- function meshLayout(layers, n_inputs, n_upper, n_lower) {
631
- const pos = buildPos(layers, n_inputs, n_upper, n_lower);
 
632
  const xMax = Math.max(5.5, n_inputs * 2.8);
633
  return {
634
  margin:{l:8,r:8,t:8,b:8},
@@ -658,7 +649,8 @@ setInterval(async () => {
658
 
659
  syncTopoUI(d.n_inputs, d.n_upper, d.n_lower);
660
  crossConnect = d.cross_connect;
661
- nShared = d.n_shared || 0;
 
662
  updateCrossUI();
663
  document.getElementById('b-alpha').innerText = `Ξ±:${d.back_alpha.toFixed(2)}`;
664
  document.getElementById('b-data').innerText = (d.dataset_type||'').slice(0,6).toUpperCase();
@@ -697,6 +689,20 @@ setInterval(async () => {
697
  <span class="text-slate-700 text-[8px]">v:${(n.vel||0).toFixed(3)}</span>
698
  </div>`;
699
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  const hids = order.filter(id => 'UL'.includes(id[0]));
701
  if (hids.length) {
702
  const avgV = hids.reduce((s,id) => s + Math.abs(d.nodes[id]?.vel||0), 0) / hids.length;
@@ -704,17 +710,23 @@ setInterval(async () => {
704
  }
705
  document.getElementById('pane-nodes').innerHTML = nh;
706
 
707
- // Springs pane β€” shared-vertex springs shown with violet marker
708
  let sh = '';
709
  for (const [key, k] of Object.entries(d.springs)) {
710
- const shared = isSharedSpring(key);
711
- const kc = shared ? 'text-violet-300'
712
- : (k < 0 ? 'text-blue-300' : k > 4 ? 'text-yellow-200' : 'text-purple-300');
713
- const pfx = shared ? '<span class="text-violet-500 mr-1">βŠ•</span>' : '';
714
  sh += `<div class="flex justify-between py-0.5 border-b border-slate-900">
715
- <span>${pfx}<span class="text-slate-500 text-[9px]">${key}</span></span>
716
  <span class="${kc} font-bold text-[10px]">${k.toFixed(4)}</span></div>`;
717
  }
 
 
 
 
 
 
 
 
 
718
  document.getElementById('pane-springs').innerHTML = sh;
719
 
720
  // Logs
@@ -722,9 +734,10 @@ setInterval(async () => {
722
  d.logs.map(l => `<div class="py-0.5 border-b border-slate-900/50">${l}</div>`).join('');
723
 
724
  // Mesh plot
725
- const layerKey = JSON.stringify(d.layers) + d.cross_connect;
726
- const traces = buildTraces(d.nodes, d.springs, d.layers, d.n_inputs, d.n_upper, d.n_lower);
727
- const layout = meshLayout(d.layers, d.n_inputs, d.n_upper, d.n_lower);
 
728
  if (!meshPlotted || layerKey !== lastLayerKey) {
729
  Plotly.newPlot('mesh-plot', traces, layout, {displayModeBar:false, responsive:true});
730
  meshPlotted = true; lastLayerKey = layerKey;
 
57
  <span id="b-data" class="px-1.5 py-0.5 rounded bg-pink-900/60 text-pink-300 border border-pink-800/60">HOUSNG</span>
58
  </div>
59
  <div class="flex items-center gap-2 ml-1">
60
+ <!-- bridge counter, hidden when 0 -->
61
+ <span id="bridge-lbl" class="text-[7px] text-amber-500"></span>
62
  <span id="q-lbl" class="text-[8px] text-slate-600">Q:0</span>
63
  <div id="run-dot" class="w-2 h-2 rounded-full bg-slate-700"></div>
64
  <button onclick="openDrawer()" class="text-[10px] bg-blue-700 hover:bg-blue-600 px-2 py-1 rounded font-bold">βš™ DIALS</button>
 
135
  </div>
136
 
137
  <!-- CROSS CONNECT -->
138
+ <div class="col-span-2 bg-slate-900 rounded p-3 border border-amber-900/50">
139
+ <div class="text-amber-400 text-[9px] font-bold mb-1">
140
  CROSS-CONNECT
141
+ <span class="text-slate-600 font-normal ml-1">passive bridge vertices</span>
142
  </div>
143
  <div class="text-[9px] text-slate-500 mb-2">
144
+ OFF β†’ N independent parallel hourglasses (default)<br>
145
+ ON β†’ a passive bridge vertex is inserted between each adjacent<br>
146
+ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;dimension pair (upper &amp; lower). Bridge springs are fixed<br>
147
+ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;and never learned β€” cross-talk happens through physics only.
148
  </div>
149
  <button id="drawer-cross-btn" onclick="toggleCross()"
150
  class="w-full py-2 rounded text-xs font-bold border border-slate-700 bg-slate-800 text-slate-400">
 
257
  const cfg = { mode: 'training', architecture: 'additive' };
258
  const topo = { inputs: 1, upper: 3, lower: 3 };
259
  let crossConnect = false;
260
+ let nBridges = 0;
261
+ let bridgeK = 0.20;
262
 
263
  // ── CROSS CONNECT ─────────────────────────────────────────────────────────────
264
  async function toggleCross() {
265
  const res = await fetch('/toggle_cross', { method: 'POST' });
266
  const data = await res.json();
267
  crossConnect = data.cross_connect;
268
+ nBridges = data.n_bridges || 0;
269
+ bridgeK = data.bridge_k || 0.20;
270
  updateCrossUI();
271
  meshPlotted = false;
272
  }
273
 
274
  function updateCrossUI() {
275
+ const btn1 = document.getElementById('b-cross');
276
+ const btn2 = document.getElementById('drawer-cross-btn');
277
+ const info = document.getElementById('cross-info');
278
+ const blbl = document.getElementById('bridge-lbl');
279
+ const n = topo.inputs;
280
+
281
+ // Estimated bridge count when server hasn't confirmed yet
282
+ const nb = nBridges || (crossConnect && n >= 2 ? (n - 1) * 2 : 0);
 
 
283
 
284
  if (crossConnect) {
285
+ btn1.className = 'px-1.5 py-0.5 rounded border font-bold text-[8px] transition-all bg-amber-800 text-amber-200 border-amber-600';
286
  btn1.innerText = 'CROSS:ON';
287
+ btn2.className = 'w-full py-2 rounded text-xs font-bold border border-amber-600 bg-amber-900/60 text-amber-200';
288
  btn2.innerText = 'CROSS-CONNECT: ON (click to disable)';
289
+ info.innerText = nb
290
+ ? `${nb} bridge ${nb !== 1 ? 'vertices' : 'vertex'} β€” passive k=${bridgeK}, not learned`
291
+ : 'No bridges (need Dβ‰₯2)';
292
+ blbl.innerText = nb ? `⬑${nb}` : '';
293
+ blbl.className = 'text-[7px] text-amber-500';
294
  } else {
295
  btn1.className = 'px-1.5 py-0.5 rounded border font-bold text-[8px] transition-all bg-slate-900 text-slate-600 border-slate-700';
296
  btn1.innerText = 'CROSS:OFF';
297
  btn2.className = 'w-full py-2 rounded text-xs font-bold border border-slate-700 bg-slate-800 text-slate-400';
298
  btn2.innerText = 'CROSS-CONNECT: OFF (click to enable)';
299
+ info.innerText = `${n} independent parallel hourglass${n !== 1 ? 'es' : ''}`;
300
+ blbl.innerText = '';
301
  }
302
  }
303
 
304
  // ── TOPOLOGY ──────────────────────────────────────────────────────────────────
305
  function updateSpringCount() {
306
  const { inputs: n, upper: u, lower: l } = topo;
307
+ const nb = crossConnect && n >= 2 ? (n - 1) * 2 : 0; // XU + XL per pair
308
+ const learnSprings = n * (2*u + 2*l);
309
+ const bridgeSprings = nb * 2; // each bridge has 2 springs
310
+ const totalNodes = 3*n + n*u + n*l + nb;
 
 
 
311
  document.getElementById('spring-count').innerText =
312
+ `${learnSprings} learned + ${bridgeSprings} bridge springs | ${totalNodes} nodes`;
313
  document.getElementById('custom-dim-hint').innerText =
314
  n === 1 ? '(single value)' : `(${n} values, comma-separated)`;
315
  updateCrossUI();
 
465
 
466
  // ── VISUALIZATION ─────────────────────────────────────────────────────────────
467
 
468
+ /**
469
+ * Build x,y positions for every node.
470
+ * Bridge nodes (XU{d}, XL{d}) sit at the midpoint between their two
471
+ * adjacent boundary hidden nodes, on the same Y row.
472
+ */
473
+ function buildPos(layers, n_inputs, n_upper, n_lower, bridgeNodeIds = []) {
474
  const pos = {};
475
  const Y = [4.4, 2.1, 0.0, -2.1, -4.4];
476
  const COL_W = n_inputs === 1 ? 0 : Math.min(9.0 / (n_inputs - 1), 4.2);
477
  const halfSp = COL_W * (n_inputs - 1) / 2;
478
  const bSprd = n_inputs === 1 ? 3.8 : Math.min(COL_W * 0.55, 1.8);
479
 
 
 
 
 
480
  layers.forEach((layer, li) => {
481
  const y = Y[li];
482
  layer.forEach(nid => {
483
  const kind = nid[0];
 
484
  let dim = 1, j = 1, total = 1;
485
  if ('ABC'.includes(kind)) {
486
  dim = parseInt(nid.slice(1));
 
490
  j = parseInt(parts[1]);
491
  total = kind === 'U' ? n_upper : n_lower;
492
  }
493
+ const cx = n_inputs === 1 ? 0 : -halfSp + (dim - 1) * COL_W;
494
  if ('ABC'.includes(kind)) {
495
  pos[nid] = [cx, y];
496
  } else {
 
499
  }
500
  });
501
  });
502
+
503
+ // Bridge nodes: midpoint between right boundary of dim d and left of dim d+1
504
+ // x = -halfSp + (d - 0.5) * COL_W (simplifies to the column midpoint)
505
+ bridgeNodeIds.forEach(nid => {
506
+ const side = nid[1]; // 'U' or 'L'
507
+ const d = parseInt(nid.slice(2));
508
+ const yVal = side === 'U' ? Y[1] : Y[3]; // same row as upper/lower hidden
509
+ const xVal = -halfSp + (d - 0.5) * COL_W;
510
+ pos[nid] = [xVal, yVal];
511
+ });
512
+
513
  return pos;
514
  }
515
 
516
  function busShapes(pos, n_inputs) {
517
  const sh = [];
518
  const mg = n_inputs === 1 ? 1.4 : 0.8;
519
+ const xs = k => Object.entries(pos).filter(([id]) => id[0] === k).map(([,v]) => v[0]);
520
+ const rect = (mn, mx, yc, hh, fill, stroke) => sh.push({
521
  type:'rect', xref:'x', yref:'y',
522
  x0:mn-mg, x1:mx+mg, y0:yc-hh, y1:yc+hh,
523
  fillcolor:fill, line:{color:stroke, width:2}
 
535
  return [`rgb(30,${Math.round(80+t*100)},${Math.round(140+t*115)})`, 1.0+t*3.5];
536
  }
537
 
538
+ /**
539
+ * Build all Plotly traces.
540
+ * Learnable springs: solid colour based on spring constant.
541
+ * Bridge springs: dashed amber lines.
542
+ * Bridge nodes: amber diamonds, distinct from regular hidden nodes.
543
+ */
544
+ function buildTraces(nodes, springs, bridgeSprings, layers, n_inputs, n_upper, n_lower) {
545
+ const bridgeNodeIds = Object.keys(nodes).filter(id => id[0] === 'X');
546
+ const pos = buildPos(layers, n_inputs, n_upper, n_lower, bridgeNodeIds);
 
 
 
 
 
 
 
 
 
547
  const traces = [];
548
 
549
+ // 1. Learnable spring edges
 
550
  for (const [key, k] of Object.entries(springs)) {
551
  const [u, v] = key.split('β†’');
552
  if (!pos[u] || !pos[v]) continue;
 
 
 
 
553
  const [col, wd] = springColor(k);
554
  traces.push({
555
  type:'scatter', mode:'lines',
 
559
  });
560
  }
561
 
562
+ // 2. Bridge spring edges β€” amber dashed, drawn on top
563
+ for (const key of Object.keys(bridgeSprings)) {
564
+ const [u, v] = key.split('β†’');
565
+ if (!pos[u] || !pos[v]) continue;
566
  traces.push({
567
  type:'scatter', mode:'lines',
568
  x:[pos[u][0], pos[v][0]], y:[pos[u][1], pos[v][1]],
569
+ line:{color:'#f59e0b', width:2.0, dash:'dot'},
570
  hoverinfo:'none', showlegend:false
571
  });
572
  }
573
 
574
+ // 3. All nodes (regular + bridge)
575
+ const regularNodes = layers.flat();
576
+ const allN = [...regularNodes, ...bridgeNodeIds];
577
+
578
  const NCOL = id => {
579
  const k = id[0];
580
  return k==='A'?'#fb923c': k==='B'?'#c084fc': k==='C'?'#38bdf8':
581
+ k==='X'?'#f59e0b': k==='U'?'#4ade80': '#67e8f9';
582
  };
583
+ const isIO = id => 'ABC'.includes(id[0]);
584
+ const isBridge = id => id[0] === 'X';
 
 
 
 
 
 
 
 
 
585
 
586
  traces.push({
587
  type:'scatter', mode:'markers+text',
 
595
  textfont:{ size:9, color: allN.map(id => NCOL(id)) },
596
  marker:{
597
  size: allN.map(id => {
598
+ const v = Math.abs(nodes[id]?.vel ?? 0);
599
+ const base = isIO(id) ? 18 : isBridge(id) ? 13 : 10;
600
  return base + Math.min(v*30, 8);
601
  }),
602
+ symbol: allN.map(id => isBridge(id) ? 'diamond' : 'circle'),
603
+ color: allN.map(id => NCOL(id)),
604
  opacity: allN.map(id => 0.75 + Math.min(Math.abs(nodes[id]?.vel??0)*1.8, 0.25)),
605
  line:{
606
+ width: allN.map(id => isBridge(id) ? 3.0 : 2.5),
607
  color: allN.map(id =>
608
+ isBridge(id) ? '#d97706'
609
+ : nodes[id]?.anchored ? '#ef4444'
610
+ : '#22c55e'
611
  )
612
  }
613
  },
 
617
  return traces;
618
  }
619
 
620
+ function meshLayout(nodes, layers, n_inputs, n_upper, n_lower) {
621
+ const bridgeNodeIds = Object.keys(nodes).filter(id => id[0] === 'X');
622
+ const pos = buildPos(layers, n_inputs, n_upper, n_lower, bridgeNodeIds);
623
  const xMax = Math.max(5.5, n_inputs * 2.8);
624
  return {
625
  margin:{l:8,r:8,t:8,b:8},
 
649
 
650
  syncTopoUI(d.n_inputs, d.n_upper, d.n_lower);
651
  crossConnect = d.cross_connect;
652
+ nBridges = d.n_bridges || 0;
653
+ bridgeK = d.bridge_k || 0.20;
654
  updateCrossUI();
655
  document.getElementById('b-alpha').innerText = `Ξ±:${d.back_alpha.toFixed(2)}`;
656
  document.getElementById('b-data').innerText = (d.dataset_type||'').slice(0,6).toUpperCase();
 
689
  <span class="text-slate-700 text-[8px]">v:${(n.vel||0).toFixed(3)}</span>
690
  </div>`;
691
  });
692
+ // Bridge nodes summary
693
+ const bridgeIds = Object.keys(d.nodes).filter(id => id[0] === 'X');
694
+ if (bridgeIds.length) {
695
+ nh += `<div class="text-[8px] text-amber-600 py-0.5 border-b border-slate-900">
696
+ ⬑ ${bridgeIds.length} bridge nodes β€” passive k=${bridgeK}</div>`;
697
+ bridgeIds.forEach(id => {
698
+ const n = d.nodes[id];
699
+ nh += `<div class="flex justify-between items-center py-0.5 border-b border-slate-900/50">
700
+ <span class="text-amber-500 text-[9px]">β—† ${id}</span>
701
+ <span class="text-amber-300 font-bold">${Number(n.x).toFixed(4)}</span>
702
+ <span class="text-slate-700 text-[8px]">v:${(n.vel||0).toFixed(3)}</span>
703
+ </div>`;
704
+ });
705
+ }
706
  const hids = order.filter(id => 'UL'.includes(id[0]));
707
  if (hids.length) {
708
  const avgV = hids.reduce((s,id) => s + Math.abs(d.nodes[id]?.vel||0), 0) / hids.length;
 
710
  }
711
  document.getElementById('pane-nodes').innerHTML = nh;
712
 
713
+ // Springs pane β€” learnable first, then bridge springs in amber
714
  let sh = '';
715
  for (const [key, k] of Object.entries(d.springs)) {
716
+ const kc = k < 0 ? 'text-blue-300' : k > 4 ? 'text-yellow-200' : 'text-purple-300';
 
 
 
717
  sh += `<div class="flex justify-between py-0.5 border-b border-slate-900">
718
+ <span class="text-slate-500 text-[9px]">${key}</span>
719
  <span class="${kc} font-bold text-[10px]">${k.toFixed(4)}</span></div>`;
720
  }
721
+ const bs = d.bridge_springs || {};
722
+ if (Object.keys(bs).length) {
723
+ sh += `<div class="text-[8px] text-amber-600 py-0.5 border-b border-slate-800 mt-1">⬑ BRIDGE (passive, fixed)</div>`;
724
+ for (const [key, k] of Object.entries(bs)) {
725
+ sh += `<div class="flex justify-between py-0.5 border-b border-slate-900/50">
726
+ <span class="text-amber-700 text-[9px]">${key}</span>
727
+ <span class="text-amber-400 font-bold text-[10px]">${k.toFixed(4)}</span></div>`;
728
+ }
729
+ }
730
  document.getElementById('pane-springs').innerHTML = sh;
731
 
732
  // Logs
 
734
  d.logs.map(l => `<div class="py-0.5 border-b border-slate-900/50">${l}</div>`).join('');
735
 
736
  // Mesh plot
737
+ const layerKey = JSON.stringify(d.layers) + d.cross_connect + d.n_bridges;
738
+ const bridgeSprings = d.bridge_springs || {};
739
+ const traces = buildTraces(d.nodes, d.springs, bridgeSprings, d.layers, d.n_inputs, d.n_upper, d.n_lower);
740
+ const layout = meshLayout(d.nodes, d.layers, d.n_inputs, d.n_upper, d.n_lower);
741
  if (!meshPlotted || layerKey !== lastLayerKey) {
742
  Plotly.newPlot('mesh-plot', traces, layout, {displayModeBar:false, responsive:true});
743
  meshPlotted = true; lastLayerKey = layerKey;