holonomy-lib 0.8.0__py3-none-any.whl

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.
Files changed (82) hide show
  1. holonomy_explorer/__init__.py +1 -0
  2. holonomy_explorer/__main__.py +79 -0
  3. holonomy_explorer/batching.py +182 -0
  4. holonomy_explorer/common.py +187 -0
  5. holonomy_explorer/curvature.py +154 -0
  6. holonomy_explorer/datasets.py +346 -0
  7. holonomy_explorer/graphs.py +286 -0
  8. holonomy_explorer/server.py +172 -0
  9. holonomy_explorer/static/app.js +435 -0
  10. holonomy_explorer/static/charts.js +114 -0
  11. holonomy_explorer/static/index.html +45 -0
  12. holonomy_explorer/static/layout.js +85 -0
  13. holonomy_explorer/static/style.css +121 -0
  14. holonomy_explorer/static/view.js +195 -0
  15. holonomy_explorer/topology.py +118 -0
  16. holonomy_lib/__init__.py +9 -0
  17. holonomy_lib/_graph_utils.py +52 -0
  18. holonomy_lib/_quadrature.py +74 -0
  19. holonomy_lib/algebra/__init__.py +32 -0
  20. holonomy_lib/algebra/eigsh_sparse.py +251 -0
  21. holonomy_lib/algebra/lanczos.py +361 -0
  22. holonomy_lib/algebra/linear.py +180 -0
  23. holonomy_lib/algebra/solvers.py +547 -0
  24. holonomy_lib/convex/__init__.py +27 -0
  25. holonomy_lib/convex/least_squares.py +184 -0
  26. holonomy_lib/discrete_geometry/__init__.py +38 -0
  27. holonomy_lib/discrete_geometry/forman.py +242 -0
  28. holonomy_lib/discrete_geometry/ricci.py +855 -0
  29. holonomy_lib/experimental/__init__.py +13 -0
  30. holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
  31. holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
  32. holonomy_lib/hyperbolic/__init__.py +27 -0
  33. holonomy_lib/hyperbolic/frechet_mean.py +381 -0
  34. holonomy_lib/hyperbolic/heat_kernel.py +608 -0
  35. holonomy_lib/hyperbolic/manifold_inner.py +81 -0
  36. holonomy_lib/info_geometry/__init__.py +64 -0
  37. holonomy_lib/info_geometry/divergences.py +224 -0
  38. holonomy_lib/info_geometry/fisher.py +179 -0
  39. holonomy_lib/lie/__init__.py +54 -0
  40. holonomy_lib/lie/so3.py +385 -0
  41. holonomy_lib/lie/spherical_harmonics.py +189 -0
  42. holonomy_lib/manifolds/__init__.py +53 -0
  43. holonomy_lib/manifolds/comparison.py +343 -0
  44. holonomy_lib/manifolds/fixed_rank.py +453 -0
  45. holonomy_lib/manifolds/lorentz.py +699 -0
  46. holonomy_lib/manifolds/lorentzian.py +567 -0
  47. holonomy_lib/manifolds/product.py +425 -0
  48. holonomy_lib/manifolds/spd.py +504 -0
  49. holonomy_lib/manifolds/stereographic.py +680 -0
  50. holonomy_lib/optimization/__init__.py +42 -0
  51. holonomy_lib/optimization/base.py +65 -0
  52. holonomy_lib/optimization/sgd.py +112 -0
  53. holonomy_lib/py.typed +0 -0
  54. holonomy_lib/sheaf/__init__.py +74 -0
  55. holonomy_lib/sheaf/graph_sheaf.py +352 -0
  56. holonomy_lib/sheaf/laplacian.py +529 -0
  57. holonomy_lib/simplicial/__init__.py +55 -0
  58. holonomy_lib/simplicial/_face_lookup.py +99 -0
  59. holonomy_lib/simplicial/complex.py +168 -0
  60. holonomy_lib/simplicial/sparse_complex.py +145 -0
  61. holonomy_lib/simplicial/vietoris_rips.py +277 -0
  62. holonomy_lib/spectral/__init__.py +55 -0
  63. holonomy_lib/spectral/diffusion_map.py +169 -0
  64. holonomy_lib/spectral/effective_resistance.py +199 -0
  65. holonomy_lib/spectral/embedding.py +131 -0
  66. holonomy_lib/spectral/heat_kernel.py +236 -0
  67. holonomy_lib/spectral/laplacian.py +441 -0
  68. holonomy_lib/spectral/magnetic.py +351 -0
  69. holonomy_lib/spectral/spectral_dimension.py +104 -0
  70. holonomy_lib/tensor_calculus/__init__.py +23 -0
  71. holonomy_lib/tensor_calculus/decomposition.py +208 -0
  72. holonomy_lib/topology/__init__.py +28 -0
  73. holonomy_lib/topology/_filtration.py +147 -0
  74. holonomy_lib/topology/_reduction.py +227 -0
  75. holonomy_lib/topology/hodge.py +250 -0
  76. holonomy_lib/topology/persistent_homology.py +301 -0
  77. holonomy_lib-0.8.0.dist-info/METADATA +161 -0
  78. holonomy_lib-0.8.0.dist-info/RECORD +82 -0
  79. holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
  80. holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
  81. holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
  82. holonomy_lib-0.8.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,114 @@
1
+ import {VIEW, fmt, node, color} from './view.js';
2
+
3
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; // W3C SVG namespace, not a network request.
4
+ const CHART = Object.freeze({tickCount: 4, pointRadius: 3, arrowSize: 4, labelBaseline: 14});
5
+ function svgNode(tag, attributes = {}, text = null) {
6
+ const element = document.createElementNS(SVG_NAMESPACE, tag);
7
+ for (const [key,value] of Object.entries(attributes)) element.setAttribute(key, value);
8
+ if (text != null) element.textContent = text;
9
+ return element;
10
+ }
11
+ function chart(title, height = VIEW.chartHeight, width = VIEW.chartWidth) {
12
+ const wrapper = node('div', null, {class:'chart'});
13
+ const svg = svgNode('svg', {viewBox:`0 0 ${width} ${height}`, role:'img', 'aria-label':title});
14
+ wrapper.append(node('h2',title),svg); return [wrapper,svg];
15
+ }
16
+ function line(svg,x1,y1,x2,y2,cls = 'axis') { svg.append(svgNode('line',{x1,y1,x2,y2,class:cls})); }
17
+ function text(svg,x,y,value,anchor = 'middle') { svg.append(svgNode('text',{x,y,'text-anchor':anchor},value)); }
18
+ function axis(svg, xmax, ymax, height = VIEW.chartHeight, w = VIEW.chartWidth, p = VIEW.chartPadding) {
19
+ const x = value => p+value/xmax*(w-2*p), y = value => height-p-value/ymax*(height-2*p);
20
+ line(svg,p,p,p,height-p); line(svg,p,height-p,w-p,height-p);
21
+ for (let i=0;i<=CHART.tickCount;i++) {
22
+ const xv = xmax*i/CHART.tickCount, yv = ymax*i/CHART.tickCount;
23
+ text(svg,x(xv),height-p+CHART.labelBaseline,fmt(xv)); text(svg,p-6,y(yv)+3,fmt(yv),'end');
24
+ }
25
+ return [x,y];
26
+ }
27
+ export function spectrumChart(data) {
28
+ const [wrapper,svg] = chart(`Normalized Laplacian spectrum · λ₂ = ${fmt(data.spectral_gap)}`, VIEW.chartHeight/2);
29
+ const p = VIEW.chartPadding, width = VIEW.chartWidth-2*p, y = VIEW.chartHeight/4;
30
+ const max = Math.max(2,...data.spectrum);
31
+ line(svg,p,y,p+width,y);
32
+ data.spectrum.forEach(value => {
33
+ const x = p+value/max*width;
34
+ line(svg,x,y-10,x,y+10,'plot-line');
35
+ });
36
+ [0,1,2].forEach(value => text(svg,p+value/max*width,y+28,String(value)));
37
+ return wrapper;
38
+ }
39
+ export function topologyCharts(data, epsilon) {
40
+ const result = document.createDocumentFragment();
41
+ const flattened = data.bars.flatMap((bars,dim) => bars.map(bar => ({bar,dim})));
42
+ // At a tied birth, longer intervals come first. Infinite bars sort before
43
+ // finite ones without subtracting Infinity from Infinity in the comparator.
44
+ flattened.sort((left,right)=>{
45
+ const birth=left.bar[0]-right.bar[0];
46
+ if(birth) return birth;
47
+ const leftDeath=left.bar[1]??Infinity, rightDeath=right.bar[1]??Infinity;
48
+ if(leftDeath!==rightDeath) return leftDeath>rightDeath?-1:1;
49
+ return left.dim-right.dim;
50
+ });
51
+ const p=VIEW.barcodePadding, height=Math.max(1,flattened.length)*VIEW.barcodeRow+2*p;
52
+ // Match plot coordinates to CSS pixels. A wide viewport must not multiply the
53
+ // row heights and push the persistence diagram several screens downward.
54
+ const width=Math.max(VIEW.chartWidth,document.getElementById('charts').clientWidth-2*VIEW.chartInset);
55
+ const [barcode,svg] = chart(`Persistence barcode · ${flattened.length} intervals · birth, then longest first · → ∞`,height,width);
56
+ const scroll=node('div',null,{class:'barcode-scroll',tabindex:'0','aria-label':'Persistence barcode; scroll for additional intervals'});
57
+ svg.setAttribute('height',height); scroll.append(svg); barcode.append(scroll);
58
+ const max = data.events.at(-1) || 1;
59
+ const x = value => p+value/max*(width-2*p);
60
+ const row = VIEW.barcodeRow;
61
+ flattened.forEach(({bar:[birth,death],dim},index) => {
62
+ const y = p+(index+0.5)*row;
63
+ const active = birth <= epsilon && (death == null || epsilon < death);
64
+ line(svg,x(birth),y,x(death ?? max),y,`bar ${dim ? 'h1' : ''} ${active ? 'active' : ''}`);
65
+ if (death == null) {
66
+ const tip = x(max), a = CHART.arrowSize;
67
+ svg.append(svgNode('path',{d:`M${tip-a},${y-a} L${tip},${y} L${tip-a},${y+a}`,fill:'none',class:`bar ${dim?'h1':''} ${active?'active':''}`}));
68
+ }
69
+ });
70
+ line(svg,x(epsilon),p,x(epsilon),height-p,'plot-line');
71
+ [0,max/2,max].forEach(value => text(svg,x(value),height-p+CHART.labelBaseline,fmt(value)));
72
+ const [diagram,plot] = chart('Persistence diagram · x: birth ε · y: death ε · top row: ∞',VIEW.diagramHeight,width);
73
+ plot.setAttribute('height',VIEW.diagramHeight);
74
+ const [px,py] = axis(plot,max,max,VIEW.diagramHeight,width,p);
75
+ line(plot,px(0),py(0),px(max),py(max));
76
+ flattened.forEach(({bar:[birth,death],dim}) => {
77
+ const y = death == null ? p/2 : py(death);
78
+ plot.append(svgNode('circle',{cx:px(birth),cy:y,r:CHART.pointRadius,class:dim?'h1-point':'point'}));
79
+ });
80
+ text(plot,width-p,p/2-5,'∞');
81
+ result.append(barcode,diagram); return result;
82
+ }
83
+ function logChart(rows, perItem) {
84
+ const [wrapper,svg] = chart(`${perItem ? 'Time per item' : 'Time per batch'} (ms) · log axes · min / median / max`);
85
+ const p = VIEW.chartPadding, width = VIEW.chartWidth, height = VIEW.chartHeight;
86
+ const lower = row => perItem ? row.per_item_min_ms : row.min_ms;
87
+ const middle = row => perItem ? row.per_item_ms : row.median_ms;
88
+ const upper = row => perItem ? row.per_item_max_ms : row.max_ms;
89
+ const xs = [...new Set(rows.map(row => row.batch))].sort((a,b)=>a-b);
90
+ let ymin = Math.log10(Math.min(...rows.map(lower))), ymax = Math.log10(Math.max(...rows.map(upper)));
91
+ if (ymin === ymax) { ymin -= 0.5; ymax += 0.5; }
92
+ const xmin = Math.log10(xs[0]), xmax = Math.log10(xs.at(-1));
93
+ const x = value => p+(Math.log10(value)-xmin)/(xmax-xmin || 1)*(width-2*p);
94
+ const y = value => height-p-(Math.log10(value)-ymin)/(ymax-ymin)*(height-2*p);
95
+ line(svg,p,p,p,height-p); line(svg,p,height-p,width-p,height-p);
96
+ xs.forEach(value => text(svg,x(value),height-p+CHART.labelBaseline,String(value)));
97
+ for(let i=0;i<=CHART.tickCount;i++) {
98
+ const value = 10**(ymin+(ymax-ymin)*i/CHART.tickCount);
99
+ text(svg,p-5,y(value)+3,Number(value.toPrecision(2)).toString(),'end');
100
+ }
101
+ for (const device of new Set(rows.map(row => row.device))) {
102
+ const series = rows.filter(row=>row.device===device), cls = device==='cpu'?'plot-line':'plot-line secondary';
103
+ svg.append(svgNode('polyline',{points:series.map(row=>`${x(row.batch)},${y(middle(row))}`).join(' '),class:cls}));
104
+ series.forEach(row => {
105
+ const cx = x(row.batch); line(svg,cx,y(lower(row)),cx,y(upper(row)),cls);
106
+ svg.append(svgNode('circle',{cx,cy:y(middle(row)),r:CHART.pointRadius,fill:color(device==='cpu'?'accent':'positive')}));
107
+ });
108
+ }
109
+ text(svg,width/2,height-4,'Batch size B'); return wrapper;
110
+ }
111
+ export function batchCharts(data) {
112
+ const fragment = document.createDocumentFragment();
113
+ fragment.append(logChart(data.rows,false),logChart(data.rows,true)); return fragment;
114
+ }
@@ -0,0 +1,45 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>Holonomy · Explorer</title>
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ <script type="module" src="/static/app.js"></script>
10
+ </head>
11
+ <body>
12
+ <aside class="sidebar">
13
+ <header class="brand"><span class="mark" aria-hidden="true">h</span><div>holonomy<span>LOCAL EXPLORER</span></div></header>
14
+ <nav aria-label="Scenes" id="scenes">
15
+ <button data-scene="graphs" aria-current="page"><span>01</span>Graphs</button>
16
+ <button data-scene="curvature"><span>02</span>Curvature</button>
17
+ <button data-scene="topology"><span>03</span>Topology</button>
18
+ <button data-scene="batching"><span>04</span>Batching</button>
19
+ </nav>
20
+ <form id="controls" aria-label="Scene controls"></form>
21
+ <div class="compute-settings">
22
+ <label>Device<select id="device" aria-label="Compute device"><option value="cpu">CPU</option></select></label>
23
+ <label>Precision<select id="dtype" aria-label="Tensor precision"><option>float64</option><option>float32</option></select></label>
24
+ </div>
25
+ <p class="sidebar-note">PyTorch computation.<br>Offline, in this Python environment.</p>
26
+ </aside>
27
+ <main>
28
+ <header class="scene-header"><div><p class="eyebrow">LIBRARY WORKBENCH</p><h1 id="title">Graphs</h1><p id="description"></p></div><span class="live-label"><i></i>LOCAL</span></header>
29
+ <div id="error" role="alert" hidden></div>
30
+ <div id="warnings" aria-live="polite"></div>
31
+ <div class="workspace">
32
+ <section class="visual-panel" aria-label="Visualization">
33
+ <div class="visual-toolbar"><span id="view-label">Spectral embedding</span><span id="hint"></span></div>
34
+ <div class="canvas-wrap"><canvas id="canvas" tabindex="0" aria-label="Interactive scene. Controls also support coordinate entry."></canvas><div id="empty">Connecting to the local library…</div></div>
35
+ <div id="legend" class="legend"></div>
36
+ <div id="playback" hidden></div>
37
+ <div id="charts" class="charts"></div>
38
+ </section>
39
+ <aside class="details" aria-label="Computed values"><p class="eyebrow">COMPUTED VALUES</p><div id="details"></div></aside>
40
+ </div>
41
+ <section id="data-table" class="data-table" hidden></section>
42
+ </main>
43
+ <footer><span id="status-device">CPU · float64</span><span id="status-state" role="status">Starting…</span><span id="status-time">Library call: —</span></footer>
44
+ </body>
45
+ </html>
@@ -0,0 +1,85 @@
1
+ // Presentation only. Fruchterman and Reingold (1991), Graph Drawing by
2
+ // Force-directed Placement: repulsion k²/d, attraction d²/k, linear cooling.
3
+ // The unit-area layout starts at the spectral coordinates. The small index-based
4
+ // offset separates coincident eigenvector coordinates without a random seed.
5
+ export const RELAXATION = Object.freeze({
6
+ iterations: 180, initialTemperature: 0.08, separationFraction: 0.01,
7
+ });
8
+
9
+ export function relaxLayout(coordinates, edges) {
10
+ const count = coordinates.length;
11
+ if (count < 2) return coordinates.map(point => [...point]);
12
+ const center = [0, 1].map(axis => coordinates.reduce((sum, point) => sum+point[axis], 0)/count);
13
+ const span = Math.max(...[0, 1].map(axis =>
14
+ Math.max(...coordinates.map(point => point[axis]))-Math.min(...coordinates.map(point => point[axis])))) || 1;
15
+ const ideal = Math.sqrt(1/count), separation = ideal*RELAXATION.separationFraction;
16
+ const goldenAngle = Math.PI*(3-Math.sqrt(5));
17
+ const points = coordinates.map((point,index) => [
18
+ (point[0]-center[0])/span + separation*Math.cos(index*goldenAngle),
19
+ (point[1]-center[1])/span + separation*Math.sin(index*goldenAngle),
20
+ ]);
21
+ for (let iteration=0; iteration<RELAXATION.iterations; iteration++) {
22
+ const forces = points.map(() => [0, 0]);
23
+ for (let left=0; left<count; left++) {
24
+ for (let right=left+1; right<count; right++) {
25
+ let dx = points[left][0]-points[right][0], dy = points[left][1]-points[right][1];
26
+ if (dx===0 && dy===0) { dx=separation*Math.cos(right*goldenAngle); dy=separation*Math.sin(right*goldenAngle); }
27
+ const distanceSquared = Math.max(dx*dx+dy*dy, separation*separation);
28
+ const strength = ideal*ideal/distanceSquared;
29
+ forces[left][0] += dx*strength; forces[left][1] += dy*strength;
30
+ forces[right][0] -= dx*strength; forces[right][1] -= dy*strength;
31
+ }
32
+ }
33
+ for (const [left,right] of edges) {
34
+ const dx = points[left][0]-points[right][0], dy = points[left][1]-points[right][1];
35
+ const strength = Math.hypot(dx,dy)/ideal;
36
+ forces[left][0] -= dx*strength; forces[left][1] -= dy*strength;
37
+ forces[right][0] += dx*strength; forces[right][1] += dy*strength;
38
+ }
39
+ const temperature = RELAXATION.initialTemperature*(1-iteration/RELAXATION.iterations);
40
+ points.forEach((point,index) => {
41
+ const [dx,dy] = forces[index], length = Math.hypot(dx,dy);
42
+ const step = length ? Math.min(length,temperature)/length : 0;
43
+ // Keep the unit area used by k fixed. An outlying leaf should not stretch
44
+ // the viewport and compress the denser part of the graph into a small fan.
45
+ point[0] = Math.max(-0.5,Math.min(0.5,point[0]+dx*step));
46
+ point[1] = Math.max(-0.5,Math.min(0.5,point[1]+dy*step));
47
+ });
48
+ }
49
+ return points;
50
+ }
51
+
52
+ // Screen-space budgets for labels, not parameters of the graph computation.
53
+ export const LABELS = Object.freeze({padding: 2, gap: 4, height: 12, rings: 3});
54
+ const intersects = (a,b) => a.x<b.x+b.width && a.x+a.width>b.x && a.y<b.y+b.height && a.y+a.height>b.y;
55
+
56
+ export function placeLabels(points, measure, width, height, radius, priority) {
57
+ const required = [...new Set(priority.filter(index => index!=null && points[index]))];
58
+ const order = [...required,...points.map((_,index)=>index).filter(index=>!required.includes(index))];
59
+ const occupied = [], placed = [];
60
+ const nodes = points.map(([x,y])=>({x:x-radius,y:y-radius,width:2*radius,height:2*radius}));
61
+ for (const index of order) {
62
+ const [x,y] = points[index], text = String(index);
63
+ const boxWidth = measure(text)+2*LABELS.padding, boxHeight = LABELS.height+2*LABELS.padding;
64
+ const candidates = [];
65
+ for (let ring=1; ring<=LABELS.rings; ring++) {
66
+ const offset = radius+LABELS.gap+(ring-1)*boxHeight;
67
+ candidates.push(
68
+ [x+offset,y-boxHeight/2], [x-offset-boxWidth,y-boxHeight/2],
69
+ [x-boxWidth/2,y-offset-boxHeight], [x-boxWidth/2,y+offset],
70
+ );
71
+ }
72
+ // Required labels can use the top margin with a leader line when a dense
73
+ // spectral cluster has no local opening. Reserve them before other labels.
74
+ if (required.includes(index)) {
75
+ for (let left=LABELS.gap; left+boxWidth<width; left+=boxWidth+LABELS.gap)
76
+ candidates.push([left,LABELS.gap]);
77
+ }
78
+ const candidate = candidates.map(([x,y])=>({x,y,width:boxWidth,height:boxHeight})).find(box=>
79
+ box.x>=0 && box.y>=0 && box.x+box.width<=width && box.y+box.height<=height &&
80
+ !occupied.some(other=>intersects(box,other)) && !nodes.some(other=>intersects(box,other)));
81
+ if (!candidate) continue;
82
+ occupied.push(candidate); placed.push({index,text,...candidate});
83
+ }
84
+ return placed;
85
+ }
@@ -0,0 +1,121 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #f4f5f2; --surface: #ffffff; --ink: #232c2a; --muted: #6c7874;
4
+ --border: #dce1dc; --accent: #277767; --accent-soft: #e7f1ed;
5
+ --negative: #b66e43; --positive: #347b91; --neutral: #a5b4ac;
6
+ --h1: #365ad7;
7
+ --warning: #8d5e25; --warning-bg: #faf2e2; --error: #a84439;
8
+ --grid: #e0e6e0; --edge: #b4c1ba; --node: #648a7c; --triangle: #277767;
9
+ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
10
+ --space-5: 24px; --space-6: 32px; --radius: 7px;
11
+ --status-height: 34px; --main-padding: 32px; --header-height: 110px;
12
+ --barcode-max-height: 240px;
13
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #171d1b; --surface: #202825; --ink: #e1e8e3; --muted: #a0afa6;
18
+ --border: #35423b; --accent: #83c4ad; --accent-soft: #293f35;
19
+ --negative: #db9b73; --positive: #7cb5ce; --neutral: #657a6c;
20
+ --h1: #91adff;
21
+ --warning: #dfb981; --warning-bg: #383024; --error: #e59688;
22
+ --grid: #303c35; --edge: #65786c; --node: #8bbb9f; --triangle: #83c4ad;
23
+ }
24
+ }
25
+ * { box-sizing: border-box; }
26
+ body { margin: 0; background: var(--bg); color: var(--ink); display: grid; grid-template-columns: 246px minmax(0,1fr); grid-template-rows: minmax(0,1fr); min-width: 920px; height: 100vh; height: 100dvh; padding-bottom: var(--status-height); overflow: hidden; }
27
+ button, input, select, textarea { font: inherit; color: inherit; }
28
+ button, select { cursor: pointer; }
29
+ button, input, select, textarea { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 7px 9px; }
30
+ button:hover, select:hover { border-color: var(--accent); }
31
+ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
32
+ button:disabled { cursor: wait; opacity: .65; }
33
+ button.primary { color: var(--surface); background: var(--accent); border-color: var(--accent); width: 100%; font-weight: 600; min-height: 38px; }
34
+ .sidebar { background: var(--surface); border-right: 1px solid var(--border); padding: var(--space-5) var(--space-4); min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
35
+ .brand { display: flex; align-items: center; gap: var(--space-3); font-size: 23px; font-weight: 600; letter-spacing: -.6px; padding: 4px 8px 28px; }
36
+ .brand span:not(.mark) { display: block; font-size: 9px; font-weight: 500; letter-spacing: 1.8px; color: var(--muted); }
37
+ .mark { border: 1px solid var(--accent); color: var(--accent); border-radius: 50%; font: italic 30px Georgia, serif; width: 37px; height: 37px; text-align: center; line-height: 34px; }
38
+ nav { display: grid; gap: var(--space-1); border-bottom: 1px solid var(--border); padding-bottom: var(--space-4); margin-bottom: var(--space-5); }
39
+ nav button { text-align: left; border-color: transparent; padding: 9px 12px; background: transparent; }
40
+ nav button span { font-size: 11px; color: var(--muted); margin-right: 12px; font-variant-numeric: tabular-nums; }
41
+ nav button[aria-current] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
42
+ label { display: grid; gap: var(--space-1); color: var(--muted); font-size: 12px; }
43
+ label input, label select, textarea { width: 100%; color: var(--ink); font-size: 13px; min-width: 0; }
44
+ textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, monospace; }
45
+ #controls { display: grid; gap: var(--space-3); }
46
+ .control-grid, .compute-settings { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); }
47
+ .compute-settings { border-top: 1px solid var(--border); margin-top: var(--space-5); padding-top: var(--space-4); }
48
+ .help, .sidebar-note { color: var(--muted); font-size: 11px; margin: 0; }
49
+ .sidebar-note { padding-top: var(--space-3); }
50
+ .button-row { display: flex; gap: var(--space-2); }
51
+ .button-row button { flex: 1; font-size: 12px; }
52
+ .range-label { display: flex; justify-content: space-between; }
53
+ input[type=range] { accent-color: var(--accent); padding: 0; height: 24px; border: none; }
54
+ .busy-note { display: block; font-size: 11px; height: 36px; text-align: center; color: var(--muted); }
55
+ main { padding: var(--main-padding); min-width: 0; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
56
+ .scene-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: var(--space-5); min-height: var(--header-height); }
57
+ .eyebrow { font-size: 10px; letter-spacing: 1.5px; color: var(--muted); margin: 0 0 8px; font-weight: 600; }
58
+ h1 { margin: 0; font-size: 29px; line-height: 1.3; letter-spacing: -.8px; font-weight: 550; }
59
+ #description { color: var(--muted); margin: 8px 0 0; max-width: 660px; font-size: 13px; }
60
+ .live-label { font-size: 10px; letter-spacing: 1px; color: var(--muted); display: flex; align-items: center; gap: 7px; padding-top: 6px; white-space: nowrap; }
61
+ .live-label i { display: block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
62
+ .workspace { display: grid; grid-template-columns: minmax(320px,1fr) 250px; gap: var(--space-4); align-items: start; }
63
+ .visual-panel, .details, .data-table { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; min-width: 0; }
64
+ .visual-toolbar { display: flex; justify-content: space-between; padding: 13px 16px; border-bottom: 1px solid var(--border); gap: 12px; font-size: 12px; }
65
+ #hint { color: var(--muted); text-align: right; font-size: 11px; }
66
+ .canvas-wrap { height: clamp(420px, 58vh, 720px); position: relative; }
67
+ .topology-mode .canvas-wrap { height: clamp(360px, 48vh, 560px); }
68
+ canvas { display: block; width: 100%; height: 100%; touch-action: none; }
69
+ #empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); pointer-events: none; }
70
+ #empty[hidden], [hidden] { display: none !important; }
71
+ .legend { padding: 10px 16px; min-height: 44px; display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); flex-wrap: wrap; }
72
+ .gradient { width: 94px; height: 8px; border-radius: 8px; background: linear-gradient(to right,var(--negative),var(--neutral),var(--positive)); }
73
+ .gradient.sequential { background: linear-gradient(to right,var(--surface),var(--accent)); }
74
+ .gradient.negative { background: linear-gradient(to right,var(--negative),var(--neutral)); }
75
+ .gradient.positive { background: linear-gradient(to right,var(--neutral),var(--positive)); }
76
+ .gradient.zero { background: var(--neutral); }
77
+ .swatch { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 4px; }
78
+ .details { padding: var(--space-4); max-height: calc(100dvh - var(--status-height) - 2 * var(--main-padding) - var(--header-height) - var(--space-5)); overflow: auto; position: sticky; top: 0; }
79
+ .details h2 { font-size: 12px; font-weight: 600; margin: 21px 0 9px; }
80
+ .details p { font-size: 12px; color: var(--muted); }
81
+ .details details { margin-top: var(--space-4); font-size: 11px; }
82
+ .details summary { cursor: pointer; color: var(--muted); }
83
+ .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0 20px; }
84
+ .metric strong { font-size: 25px; font-weight: 500; display: block; font-variant-numeric: tabular-nums; }
85
+ .metric span { font-size: 10px; color: var(--muted); }
86
+ table { border-collapse: collapse; width: 100%; font-size: 11px; font-variant-numeric: tabular-nums; }
87
+ th { text-align: left; font-weight: 500; color: var(--muted); }
88
+ th, td { border-bottom: 1px solid var(--border); padding: 6px 5px; text-align: right; }
89
+ th:first-child, td:first-child { text-align: left; }
90
+ .table-scroll { max-height: 280px; overflow: auto; }
91
+ .parameters { font-size: 11px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 8px; margin: 0; }
92
+ .parameters dt { color: var(--muted); overflow-wrap: anywhere; }
93
+ .parameters dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
94
+ #warnings p, #error { font-size: 12px; padding: 11px 15px; border-radius: var(--radius); margin: 0 0 12px; border: 1px solid var(--border); background: var(--warning-bg); color: var(--warning); }
95
+ #error { color: var(--error); }
96
+ .charts { display: grid; grid-template-columns: 1fr; border-top: 1px solid var(--border); }
97
+ .charts:empty { border: none; }
98
+ .chart { padding: 14px; min-width: 0; }
99
+ .chart + .chart { border-top: 1px solid var(--border); }
100
+ .chart h2 { margin: 0 0 8px; font-size: 11px; font-weight: 500; color: var(--muted); }
101
+ .chart svg { width: 100%; display: block; overflow: visible; color: var(--muted); }
102
+ .barcode-scroll { max-height: var(--barcode-max-height); overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
103
+ .barcode-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
104
+ .chart text { font: 10px system-ui, sans-serif; fill: currentColor; }
105
+ .chart .axis { stroke: var(--border); fill: none; }
106
+ .chart .plot-line { stroke: var(--accent); fill: none; stroke-width: 2; }
107
+ .chart .secondary { stroke: var(--positive); }
108
+ .chart .bar { stroke: var(--accent); stroke-width: 3; opacity: .6; }
109
+ .chart .bar.active { opacity: 1; }
110
+ .chart .h1 { stroke: var(--h1); }
111
+ .chart .point { fill: var(--accent); }
112
+ .chart .h1-point { fill: var(--h1); }
113
+ #playback { padding: 8px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 12px; font-size: 12px; }
114
+ #playback input { flex: 1; min-width: 50px; }
115
+ .data-table { padding: var(--space-4); margin-top: var(--space-4); }
116
+ .data-table h2 { font-size: 14px; margin: 0 0 12px; }
117
+ .batch-mode .canvas-wrap { display: none; }
118
+ .batch-mode .charts { border-top: 0; }
119
+ footer { position: fixed; bottom: 0; left: 0; right: 0; height: var(--status-height); background: var(--surface); border-top: 1px solid var(--border); display: flex; gap: 24px; align-items: center; padding: 0 20px; color: var(--muted); font-size: 11px; z-index: 2; }
120
+ #status-time { margin-left: auto; font-variant-numeric: tabular-nums; }
121
+ @media (max-width: 1150px) { :root { --main-padding: 22px; } body { grid-template-columns: 220px minmax(0,1fr); } .workspace { grid-template-columns: minmax(300px,1fr) 210px; gap: 12px; } .live-label { display: none; } }
@@ -0,0 +1,195 @@
1
+ import {LABELS, placeLabels} from './layout.js';
2
+
3
+ // Drawing constants are screen-space choices; mathematical data comes from Python.
4
+ export const VIEW = Object.freeze({
5
+ nodeRadius: 6, graphNodeRadius: 5, hitRadius: 15, labelOffset: 12,
6
+ fontSize: 11, lineWidth: 1.5, edgeWidth: 0.9, gridWidth: 0.7, flowMs: 900,
7
+ chartWidth: 540, chartHeight: 200, chartPadding: 38,
8
+ barcodeRow: 7, barcodePadding: 24, chartInset: 14, diagramHeight: 180, figureExtent: 1.3,
9
+ debounceMs: 180, transitionMs: 500, triangleOpacity: 0.035,
10
+ });
11
+ // Eight percent on each side reserves room for labels without changing aspect.
12
+ const FIT_MARGIN = 0.08;
13
+ // A unit-wide fallback gives empty and coincident clouds a usable picking area.
14
+ const FIT_FALLBACK_SPAN = 1;
15
+ // A short corner ruler uses the conventional 1, 2, 5 decimal series.
16
+ const SCALE_BAR = Object.freeze({fraction: 0.16, inset: 16, tick: 4, gap: 8, lengths: [1, 2, 5], base: 10});
17
+
18
+ export function fitTransform(points, width, height, extent = null) {
19
+ let bounds = extent;
20
+ if (!bounds) bounds = points.length ? [
21
+ Math.min(...points.map(p=>p[0])), Math.max(...points.map(p=>p[0])),
22
+ Math.min(...points.map(p=>p[1])), Math.max(...points.map(p=>p[1])),
23
+ ] : [-FIT_FALLBACK_SPAN/2,FIT_FALLBACK_SPAN/2,-FIT_FALLBACK_SPAN/2,FIT_FALLBACK_SPAN/2];
24
+ const [xmin,xmax,ymin,ymax] = bounds;
25
+ const dx=xmax-xmin, dy=ymax-ymin;
26
+ // A flat axis imposes no scale constraint. Coincident points need a finite
27
+ // span on both axes so that picking still has an invertible transform.
28
+ const spanX=dx || (dy ? 0 : FIT_FALLBACK_SPAN);
29
+ const spanY=dy || (dx ? 0 : FIT_FALLBACK_SPAN);
30
+ const scale=Math.min(width*(1-2*FIT_MARGIN)/spanX,height*(1-2*FIT_MARGIN)/spanY);
31
+ const cx=(xmin+xmax)/2, cy=(ymin+ymax)/2;
32
+ return {
33
+ scale,
34
+ map: ([x,y])=>[width/2+(x-cx)*scale,height/2-(y-cy)*scale],
35
+ inverse: ([x,y])=>[(x-width/2)/scale+cx,-(y-height/2)/scale+cy],
36
+ };
37
+ }
38
+
39
+ export function scaleBarLength(scale, width) {
40
+ const target=width*SCALE_BAR.fraction/scale;
41
+ const power=SCALE_BAR.base**Math.floor(Math.log10(target));
42
+ return SCALE_BAR.lengths.filter(length=>length*power<=target).at(-1)*power;
43
+ }
44
+ export const $ = (id) => document.getElementById(id);
45
+ export const fmt = (value) => value == null ? '∞ / unavailable' :
46
+ typeof value === 'number' ? (Number.isInteger(value) ? String(value) : Number(value.toPrecision(5)).toString()) : String(value);
47
+ export const color = (name) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim();
48
+ export function node(tag, text, attributes = {}) {
49
+ const element = document.createElement(tag);
50
+ if (text != null) element.textContent = text;
51
+ for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value);
52
+ return element;
53
+ }
54
+ export function table(headers, rows) {
55
+ const table = node('table');
56
+ const head = node('thead'), body = node('tbody'), row = node('tr');
57
+ headers.forEach(value => row.append(node('th', value, {scope: 'col'})));
58
+ head.append(row);
59
+ rows.forEach(values => { const row = node('tr'); values.forEach(value => row.append(node('td', fmt(value)))); body.append(row); });
60
+ table.append(head, body);
61
+ const scroll = node('div', null, {class: 'table-scroll'}); scroll.append(table); return scroll;
62
+ }
63
+ export function metrics(entries) {
64
+ const result = node('div', null, {class: 'metrics'});
65
+ entries.forEach(([label, value]) => { const item = node('div', null, {class: 'metric'}); item.append(node('strong', fmt(value)), node('span', label)); result.append(item); });
66
+ return result;
67
+ }
68
+ export function parameters(values) {
69
+ const result = node('dl', null, {class: 'parameters'});
70
+ Object.entries(values).forEach(([key, value]) => {
71
+ if (Array.isArray(value) || value && typeof value === 'object') return;
72
+ result.append(node('dt', key.replaceAll('_', ' ')), node('dd', fmt(value)));
73
+ }); return result;
74
+ }
75
+ export function section(title, child) {
76
+ const result = node('section'); result.append(node('h2', title), child); return result;
77
+ }
78
+ export class CanvasView {
79
+ constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.map = p => p; this.inverse = p => p; }
80
+ begin(points, extent = null) {
81
+ const {width, height} = this.canvas.getBoundingClientRect();
82
+ // Hidden canvases can receive resize notifications before their scene opens.
83
+ if (!width || !height) return;
84
+ const dpr = window.devicePixelRatio || 1;
85
+ this.canvas.width = Math.round(width * dpr); this.canvas.height = Math.round(height * dpr);
86
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
87
+ this.ctx.clearRect(0, 0, width, height);
88
+ // Spectral-only mode preserves coincident points. Relaxation, when selected,
89
+ // happens separately and never changes the coordinates returned by Python.
90
+ Object.assign(this,fitTransform(points,width,height,extent));
91
+ this.ctx.font = `${VIEW.fontSize}px system-ui, sans-serif`;
92
+ this.width=width; this.height=height;
93
+ }
94
+ scaleBar() {
95
+ const length=scaleBarLength(this.scale,this.width), ctx=this.ctx;
96
+ const x=SCALE_BAR.inset, y=this.height-SCALE_BAR.inset;
97
+ const end=x+length*this.scale, label=`${fmt(length)} coordinate units`;
98
+ ctx.save();
99
+ ctx.fillStyle=color('surface');
100
+ ctx.fillRect(x-SCALE_BAR.tick,y-SCALE_BAR.gap-VIEW.fontSize-SCALE_BAR.tick,
101
+ Math.max(end-x,ctx.measureText(label).width)+2*SCALE_BAR.tick,VIEW.fontSize+SCALE_BAR.gap+2*SCALE_BAR.tick);
102
+ ctx.strokeStyle=color('ink'); ctx.fillStyle=color('ink'); ctx.lineWidth=VIEW.lineWidth;
103
+ ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(end,y);
104
+ for (const at of [x,end]) { ctx.moveTo(at,y-SCALE_BAR.tick); ctx.lineTo(at,y+SCALE_BAR.tick); }
105
+ ctx.stroke(); ctx.fillText(label,x,y-SCALE_BAR.gap); ctx.restore();
106
+ }
107
+ edgeLength(a,b,length) {
108
+ const [ax,ay]=this.map(a), [bx,by]=this.map(b), ctx=this.ctx;
109
+ const text=`d = ${fmt(length)}`, width=ctx.measureText(text).width;
110
+ const x=(ax+bx-width)/2, y=(ay+by)/2;
111
+ ctx.save(); ctx.fillStyle=color('surface');
112
+ ctx.fillRect(x-LABELS.padding,y-VIEW.fontSize,width+2*LABELS.padding,VIEW.fontSize+2*LABELS.padding);
113
+ ctx.fillStyle=color('ink'); ctx.fillText(text,x,y); ctx.restore();
114
+ }
115
+ graphLabels(points, priority) {
116
+ const positions = points.map(point=>this.map(point)), ctx=this.ctx;
117
+ const labels = placeLabels(positions,text=>ctx.measureText(text).width,this.width,this.height,VIEW.graphNodeRadius*1.5,priority);
118
+ for (const label of labels) {
119
+ const [x,y]=positions[label.index];
120
+ ctx.beginPath(); ctx.moveTo(x,y);
121
+ ctx.lineTo(Math.max(label.x,Math.min(x,label.x+label.width)),Math.max(label.y,Math.min(y,label.y+label.height)));
122
+ ctx.strokeStyle=color('edge'); ctx.lineWidth=VIEW.gridWidth; ctx.stroke();
123
+ ctx.fillStyle=color('surface'); ctx.fillRect(label.x,label.y,label.width,label.height);
124
+ ctx.fillStyle=color('ink'); ctx.textBaseline='top';
125
+ ctx.fillText(label.text,label.x+LABELS.padding,label.y+LABELS.padding);
126
+ }
127
+ ctx.textBaseline='alphabetic';
128
+ }
129
+ path(points, stroke = color('edge'), width = VIEW.lineWidth, alpha = 1, fill = false) {
130
+ const ctx = this.ctx; ctx.beginPath(); let begun = false;
131
+ for (const point of points) {
132
+ if (!point || point.some(v => v == null || !Number.isFinite(v))) { begun = false; continue; }
133
+ const [x,y] = this.map(point);
134
+ if (begun) ctx.lineTo(x,y); else ctx.moveTo(x,y);
135
+ begun = true;
136
+ }
137
+ ctx.globalAlpha = alpha; ctx.strokeStyle = stroke; ctx.lineWidth = width;
138
+ if (fill) { ctx.closePath(); ctx.fillStyle = stroke; ctx.fill(); } else ctx.stroke();
139
+ ctx.globalAlpha = 1;
140
+ }
141
+ dot(point, fill = color('node'), label = null, hollow = false, radius = VIEW.nodeRadius) {
142
+ if (!point || point.some(v => v == null || !Number.isFinite(v))) return;
143
+ const [x,y] = this.map(point), ctx = this.ctx;
144
+ ctx.beginPath(); ctx.arc(x,y,radius,0,2*Math.PI);
145
+ ctx.fillStyle = hollow ? color('surface') : fill; ctx.fill();
146
+ ctx.strokeStyle = fill; ctx.lineWidth = VIEW.lineWidth; ctx.stroke();
147
+ if (label != null) { ctx.fillStyle = color('muted'); ctx.fillText(String(label), x+VIEW.labelOffset, y-VIEW.labelOffset/2); }
148
+ }
149
+ circle(radius) {
150
+ const [cx,cy] = this.map([0,0]), [ex] = this.map([radius,0]);
151
+ this.ctx.beginPath(); this.ctx.arc(cx,cy,Math.abs(ex-cx),0,2*Math.PI);
152
+ this.ctx.strokeStyle = color('muted'); this.ctx.setLineDash([VIEW.gridWidth*4,VIEW.gridWidth*4]); this.ctx.stroke(); this.ctx.setLineDash([]);
153
+ }
154
+ click(event) { const rect = this.canvas.getBoundingClientRect(); return [event.clientX-rect.left,event.clientY-rect.top]; }
155
+ nearest(event, points) {
156
+ const [x,y] = this.click(event); let best = null, distance = VIEW.hitRadius;
157
+ points.forEach((point,index) => { const [px,py] = this.map(point), d = Math.hypot(px-x,py-y); if (d < distance) { best = index; distance = d; } });
158
+ return best;
159
+ }
160
+ }
161
+ // Color interpolation is presentation only. Values and their ranges come from the API.
162
+ export function blend(a,b,t) {
163
+ const rgb = value => value.slice(1).match(/.{2}/g).map(v => parseInt(v,16));
164
+ const left = rgb(a), right = rgb(b);
165
+ return `rgb(${left.map((v,i) => Math.round(v+(right[i]-v)*Math.max(0,Math.min(1,t)))).join(',')})`;
166
+ }
167
+ export function diverging(value, range) {
168
+ const span = Math.max(Math.abs(range[0]),Math.abs(range[1])) || 1;
169
+ return blend(color('neutral'), color(value < 0 ? 'negative' : 'positive'), Math.abs(value)/span);
170
+ }
171
+ export function curvatureScale(range) {
172
+ if(range[0]===0 && range[1]===0) return {range,kind:'zero',note:'zero curvature'};
173
+ if (range[0]<0 && range[1]>0) {
174
+ const extent=Math.max(Math.abs(range[0]),Math.abs(range[1]));
175
+ return {range:[-extent,extent],kind:'diverging',note:'diverging, centered at 0'};
176
+ }
177
+ return {range,kind:range[1]<=0?'negative':'positive',note:'sequential'};
178
+ }
179
+ export function curvatureColor(value, scale) {
180
+ if (value==null) return color('muted');
181
+ if (value===0) return color('neutral');
182
+ if (scale.kind==='diverging') return diverging(value,scale.range);
183
+ const [low,high]=scale.range;
184
+ const fraction=high===low?1:scale.kind==='negative'?(high-value)/(high-low):(value-low)/(high-low);
185
+ return blend(color('neutral'),color(scale.kind),fraction);
186
+ }
187
+ export function sequential(value, range) {
188
+ if (value == null) return color('muted');
189
+ return blend(color('surface'),color('accent'), (value-range[0])/(range[1]-range[0] || 1));
190
+ }
191
+ export function community(index) {
192
+ // Separate components need categorical hues; the rest of the UI uses one accent.
193
+ const goldenAngle = 180 * (3-Math.sqrt(5));
194
+ return `hsl(${index*goldenAngle%360} 34% 53%)`;
195
+ }