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.
- holonomy_explorer/__init__.py +1 -0
- holonomy_explorer/__main__.py +79 -0
- holonomy_explorer/batching.py +182 -0
- holonomy_explorer/common.py +187 -0
- holonomy_explorer/curvature.py +154 -0
- holonomy_explorer/datasets.py +346 -0
- holonomy_explorer/graphs.py +286 -0
- holonomy_explorer/server.py +172 -0
- holonomy_explorer/static/app.js +435 -0
- holonomy_explorer/static/charts.js +114 -0
- holonomy_explorer/static/index.html +45 -0
- holonomy_explorer/static/layout.js +85 -0
- holonomy_explorer/static/style.css +121 -0
- holonomy_explorer/static/view.js +195 -0
- holonomy_explorer/topology.py +118 -0
- holonomy_lib/__init__.py +9 -0
- holonomy_lib/_graph_utils.py +52 -0
- holonomy_lib/_quadrature.py +74 -0
- holonomy_lib/algebra/__init__.py +32 -0
- holonomy_lib/algebra/eigsh_sparse.py +251 -0
- holonomy_lib/algebra/lanczos.py +361 -0
- holonomy_lib/algebra/linear.py +180 -0
- holonomy_lib/algebra/solvers.py +547 -0
- holonomy_lib/convex/__init__.py +27 -0
- holonomy_lib/convex/least_squares.py +184 -0
- holonomy_lib/discrete_geometry/__init__.py +38 -0
- holonomy_lib/discrete_geometry/forman.py +242 -0
- holonomy_lib/discrete_geometry/ricci.py +855 -0
- holonomy_lib/experimental/__init__.py +13 -0
- holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
- holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
- holonomy_lib/hyperbolic/__init__.py +27 -0
- holonomy_lib/hyperbolic/frechet_mean.py +381 -0
- holonomy_lib/hyperbolic/heat_kernel.py +608 -0
- holonomy_lib/hyperbolic/manifold_inner.py +81 -0
- holonomy_lib/info_geometry/__init__.py +64 -0
- holonomy_lib/info_geometry/divergences.py +224 -0
- holonomy_lib/info_geometry/fisher.py +179 -0
- holonomy_lib/lie/__init__.py +54 -0
- holonomy_lib/lie/so3.py +385 -0
- holonomy_lib/lie/spherical_harmonics.py +189 -0
- holonomy_lib/manifolds/__init__.py +53 -0
- holonomy_lib/manifolds/comparison.py +343 -0
- holonomy_lib/manifolds/fixed_rank.py +453 -0
- holonomy_lib/manifolds/lorentz.py +699 -0
- holonomy_lib/manifolds/lorentzian.py +567 -0
- holonomy_lib/manifolds/product.py +425 -0
- holonomy_lib/manifolds/spd.py +504 -0
- holonomy_lib/manifolds/stereographic.py +680 -0
- holonomy_lib/optimization/__init__.py +42 -0
- holonomy_lib/optimization/base.py +65 -0
- holonomy_lib/optimization/sgd.py +112 -0
- holonomy_lib/py.typed +0 -0
- holonomy_lib/sheaf/__init__.py +74 -0
- holonomy_lib/sheaf/graph_sheaf.py +352 -0
- holonomy_lib/sheaf/laplacian.py +529 -0
- holonomy_lib/simplicial/__init__.py +55 -0
- holonomy_lib/simplicial/_face_lookup.py +99 -0
- holonomy_lib/simplicial/complex.py +168 -0
- holonomy_lib/simplicial/sparse_complex.py +145 -0
- holonomy_lib/simplicial/vietoris_rips.py +277 -0
- holonomy_lib/spectral/__init__.py +55 -0
- holonomy_lib/spectral/diffusion_map.py +169 -0
- holonomy_lib/spectral/effective_resistance.py +199 -0
- holonomy_lib/spectral/embedding.py +131 -0
- holonomy_lib/spectral/heat_kernel.py +236 -0
- holonomy_lib/spectral/laplacian.py +441 -0
- holonomy_lib/spectral/magnetic.py +351 -0
- holonomy_lib/spectral/spectral_dimension.py +104 -0
- holonomy_lib/tensor_calculus/__init__.py +23 -0
- holonomy_lib/tensor_calculus/decomposition.py +208 -0
- holonomy_lib/topology/__init__.py +28 -0
- holonomy_lib/topology/_filtration.py +147 -0
- holonomy_lib/topology/_reduction.py +227 -0
- holonomy_lib/topology/hodge.py +250 -0
- holonomy_lib/topology/persistent_homology.py +301 -0
- holonomy_lib-0.8.0.dist-info/METADATA +161 -0
- holonomy_lib-0.8.0.dist-info/RECORD +82 -0
- holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
- holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
- holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
- holonomy_lib-0.8.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""A small standard-library HTTP transport; computation lives in scene modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from http import HTTPStatus
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
|
|
17
|
+
from . import batching, common, curvature, datasets, graphs, topology
|
|
18
|
+
from .common import Request, json_safe
|
|
19
|
+
|
|
20
|
+
ROUTES: dict[str, Callable[[Request], Request]] = {
|
|
21
|
+
"graphs": graphs.compute,
|
|
22
|
+
"curvature": curvature.compute,
|
|
23
|
+
"topology": topology.compute,
|
|
24
|
+
"batching": batching.compute,
|
|
25
|
+
}
|
|
26
|
+
CONTENT_TYPES = {
|
|
27
|
+
".html": "text/html; charset=utf-8",
|
|
28
|
+
".css": "text/css; charset=utf-8",
|
|
29
|
+
".js": "text/javascript; charset=utf-8",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configuration(request: Request) -> Request:
|
|
34
|
+
"""Expose limits and defaults so the browser does not invent compute values."""
|
|
35
|
+
context = common.Context.from_request(request)
|
|
36
|
+
return context.finish(
|
|
37
|
+
cuda_available=torch.cuda.is_available(),
|
|
38
|
+
limits={
|
|
39
|
+
"nodes": common.MAX_NODES,
|
|
40
|
+
"points": common.MAX_POINTS,
|
|
41
|
+
"curvature_points": common.MAX_CURVATURE_POINTS,
|
|
42
|
+
"batch": common.MAX_BATCH_SIZE,
|
|
43
|
+
"persistence_batch": common.MAX_PERSISTENCE_BATCH,
|
|
44
|
+
"persistence_size": common.MAX_PERSISTENCE_SIZE,
|
|
45
|
+
"repeats": common.MAX_REPEATS,
|
|
46
|
+
"flow_steps": common.MAX_FLOW_STEPS,
|
|
47
|
+
"flow_frames": common.MAX_FLOW_FRAMES,
|
|
48
|
+
},
|
|
49
|
+
defaults={
|
|
50
|
+
"nodes": common.DEFAULT_NODES,
|
|
51
|
+
"points_count": common.DEFAULT_POINTS,
|
|
52
|
+
"seed": common.DEFAULT_SEED,
|
|
53
|
+
"noise": common.DEFAULT_NOISE,
|
|
54
|
+
"blocks": datasets.DEFAULT_BLOCKS,
|
|
55
|
+
"p_in": datasets.DEFAULT_P_IN,
|
|
56
|
+
"p_out": datasets.DEFAULT_P_OUT,
|
|
57
|
+
"radius": datasets.DEFAULT_RADIUS,
|
|
58
|
+
"clique_size": datasets.DEFAULT_CLIQUE_SIZE,
|
|
59
|
+
"cliques": datasets.DEFAULT_CLIQUES,
|
|
60
|
+
"steps": graphs.DEFAULT_FLOW_STEPS,
|
|
61
|
+
"surgery_period": graphs.DEFAULT_SURGERY_PERIOD,
|
|
62
|
+
"surgery_threshold": graphs.DEFAULT_SURGERY_THRESHOLD,
|
|
63
|
+
"dt": graphs.DEFAULT_FLOW_DT,
|
|
64
|
+
"repeats": common.DEFAULT_REPEATS,
|
|
65
|
+
"size": batching.DEFAULT_PROBLEM_SIZE,
|
|
66
|
+
"batches": common.DEFAULT_SWEEP,
|
|
67
|
+
},
|
|
68
|
+
heat_time_range=[graphs.MIN_HEAT_TIME, graphs.MAX_HEAT_TIME],
|
|
69
|
+
max_seed=datasets.MAX_SEED,
|
|
70
|
+
max_surgery_threshold=graphs.MAX_SURGERY_THRESHOLD,
|
|
71
|
+
curvature_points=curvature.DEFAULT_CURVATURE_POINTS,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _reject_constant(value: str) -> Any:
|
|
76
|
+
raise ValueError(f"JSON must not contain {value}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_server(host: str, port: int, device: str, dtype: str) -> ThreadingHTTPServer:
|
|
80
|
+
"""Build the server without starting it, for CLI and embedded local use."""
|
|
81
|
+
defaults = {"device": device, "dtype": dtype}
|
|
82
|
+
compute_lock = threading.Lock()
|
|
83
|
+
static = resources.files("holonomy_explorer").joinpath("static")
|
|
84
|
+
|
|
85
|
+
class Handler(BaseHTTPRequestHandler):
|
|
86
|
+
def setup(self) -> None:
|
|
87
|
+
super().setup()
|
|
88
|
+
self.connection.settimeout(common.REQUEST_TIMEOUT_SECONDS)
|
|
89
|
+
|
|
90
|
+
def _send(self, status: HTTPStatus, payload: bytes, content_type: str) -> None:
|
|
91
|
+
self.send_response(status)
|
|
92
|
+
self.send_header("Content-Type", content_type)
|
|
93
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
94
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
95
|
+
self.send_header("Cache-Control", "no-store")
|
|
96
|
+
self.send_header(
|
|
97
|
+
"Content-Security-Policy",
|
|
98
|
+
(
|
|
99
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
|
100
|
+
"connect-src 'self'; img-src 'self'; object-src 'none'; "
|
|
101
|
+
"base-uri 'none'; frame-ancestors 'none'"
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
self.end_headers()
|
|
105
|
+
self.wfile.write(payload)
|
|
106
|
+
|
|
107
|
+
def _json(self, status: HTTPStatus, payload: Request) -> None:
|
|
108
|
+
encoded = json.dumps(json_safe(payload), allow_nan=False).encode("utf-8")
|
|
109
|
+
self._send(status, encoded, "application/json; charset=utf-8")
|
|
110
|
+
|
|
111
|
+
def do_GET(self) -> None:
|
|
112
|
+
path = urlsplit(self.path).path
|
|
113
|
+
name = "index.html" if path == "/" else path.removeprefix("/static/")
|
|
114
|
+
if path != "/" and not path.startswith("/static/"):
|
|
115
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": "unknown resource"})
|
|
116
|
+
return
|
|
117
|
+
# Only flat package assets can be served, never arbitrary filesystem paths.
|
|
118
|
+
if not name or "/" in name or "\\" in name or name.startswith("."):
|
|
119
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": "invalid resource path"})
|
|
120
|
+
return
|
|
121
|
+
asset = static.joinpath(name)
|
|
122
|
+
suffix = "." + name.rsplit(".", 1)[-1]
|
|
123
|
+
if not asset.is_file() or suffix not in CONTENT_TYPES:
|
|
124
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": "unknown resource"})
|
|
125
|
+
return
|
|
126
|
+
self._send(HTTPStatus.OK, asset.read_bytes(), CONTENT_TYPES[suffix])
|
|
127
|
+
|
|
128
|
+
def do_POST(self) -> None:
|
|
129
|
+
try:
|
|
130
|
+
if self.headers.get_content_type() != "application/json":
|
|
131
|
+
raise ValueError("Content-Type must be application/json")
|
|
132
|
+
origin = self.headers.get("Origin")
|
|
133
|
+
if origin and urlsplit(origin).netloc != self.headers.get("Host"):
|
|
134
|
+
raise ValueError("cross-origin requests are not accepted")
|
|
135
|
+
if self.headers.get("Transfer-Encoding"):
|
|
136
|
+
raise ValueError("chunked request bodies are not supported")
|
|
137
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
138
|
+
if not 0 < length <= common.MAX_REQUEST_BYTES:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"body must contain 1 to {common.MAX_REQUEST_BYTES} bytes"
|
|
141
|
+
)
|
|
142
|
+
raw = self.rfile.read(length)
|
|
143
|
+
if len(raw) != length:
|
|
144
|
+
raise ValueError("incomplete request body")
|
|
145
|
+
request = json.loads(raw, parse_constant=_reject_constant)
|
|
146
|
+
if not isinstance(request, dict):
|
|
147
|
+
raise ValueError("request must be a JSON object")
|
|
148
|
+
route = urlsplit(self.path).path
|
|
149
|
+
if not route.startswith("/api/"):
|
|
150
|
+
raise ValueError("unknown API route")
|
|
151
|
+
name = route.removeprefix("/api/")
|
|
152
|
+
function = configuration if name == "config" else ROUTES.get(name)
|
|
153
|
+
if function is None:
|
|
154
|
+
raise ValueError("unknown API route")
|
|
155
|
+
if not compute_lock.acquire(blocking=False):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
"another computation is running; try again when it finishes"
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
response = function({**defaults, **request})
|
|
161
|
+
finally:
|
|
162
|
+
compute_lock.release()
|
|
163
|
+
self._json(HTTPStatus.OK, response)
|
|
164
|
+
except Exception as error:
|
|
165
|
+
# Library exceptions are visible beside the triggering control.
|
|
166
|
+
# HTTP/JSON parsing errors follow the same small response contract.
|
|
167
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
|
|
168
|
+
|
|
169
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
170
|
+
print(f"explorer: {format % args}", file=sys.stderr)
|
|
171
|
+
|
|
172
|
+
return ThreadingHTTPServer((host, port), Handler)
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import {VIEW, $, fmt, node, table, metrics, parameters, section, CanvasView, color, curvatureScale, curvatureColor, sequential, community} from './view.js';
|
|
2
|
+
import {spectrumChart, topologyCharts, batchCharts} from './charts.js';
|
|
3
|
+
import {relaxLayout} from './layout.js';
|
|
4
|
+
|
|
5
|
+
const SCENES = {
|
|
6
|
+
graphs: {title:'Graphs', description:'Positions start from the two non-trivial Laplacian eigenvectors and are relaxed for legibility. Layout is presentation, not a computed result; overlays and values come from the library.', view:'Spectral + relaxed · presentation', hint:'Hover for labels; click to choose the source'},
|
|
7
|
+
curvature: {title:'Curvature', description:'Move continuously between hyperbolic, flat and spherical charts. All geodesics and distances come from the κ-stereographic manifold.', view:'κ-stereographic chart', hint:'Click to add points; click points to select a pair'},
|
|
8
|
+
topology: {title:'Topology', description:'A point cloud, its Vietoris–Rips filtration, and the persistence of components and loops. ε is the maximum edge length, in coordinate units.', view:'Vietoris–Rips complex', hint:'Click to add a point'},
|
|
9
|
+
batching: {title:'Batching', description:'Measure the library at different batch sizes, with a warm-up before each shape. Persistence reduction is sequential per batch member and uses CPU bookkeeping, so it is expected to scale differently.', view:'Synchronized wall-time measurements', hint:'CPU and available GPU'},
|
|
10
|
+
};
|
|
11
|
+
let config, active = 'graphs', revision = 0, pending = false, busy = false, debounce, playTimer;
|
|
12
|
+
const state = Object.fromEntries(Object.keys(SCENES).map(key => [key,{params:{},data:null,points:[],selected:[],event:0,epsilon:0,frame:0,transition:1,hover:null,layout:'relaxed',layoutKey:null,relaxed:null}]));
|
|
13
|
+
const view = new CanvasView($('canvas'));
|
|
14
|
+
|
|
15
|
+
async function api(name, request) {
|
|
16
|
+
const response = await fetch(`/api/${name}`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(request)});
|
|
17
|
+
const result = await response.json();
|
|
18
|
+
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function error(message = '') { $('error').textContent = message; $('error').hidden = !message; }
|
|
22
|
+
function control(label, input) { const wrapper = node('label'); wrapper.append(node('span',label),input); return wrapper; }
|
|
23
|
+
function select(key,label,options,value) {
|
|
24
|
+
const input = node('select',null,{name:key});
|
|
25
|
+
options.forEach(option => { const [value,text] = Array.isArray(option)?option:[option,option]; input.append(node('option',text,{value})); });
|
|
26
|
+
input.value = value; return control(label,input);
|
|
27
|
+
}
|
|
28
|
+
function numeric(key,label,value,min,max,step = 1) {
|
|
29
|
+
return control(label,node('input',null,{name:key,type:'number',value,min,max,step}));
|
|
30
|
+
}
|
|
31
|
+
function range(key,label,value,min,max,step) {
|
|
32
|
+
const wrapper = control(label,node('input',null,{name:key,type:'range',value,min,max,step}));
|
|
33
|
+
const text = wrapper.firstChild; text.className='range-label'; text.append(node('output',fmt(value))); return wrapper;
|
|
34
|
+
}
|
|
35
|
+
function group(...children) { const result = node('div',null,{class:'control-grid'}); result.append(...children); return result; }
|
|
36
|
+
function help(text) { return node('p',text,{class:'help'}); }
|
|
37
|
+
function action(label,callback) { const button = node('button',label,{type:'button'}); button.addEventListener('click',callback); return button; }
|
|
38
|
+
function coordinatesControls() {
|
|
39
|
+
const row = group(numeric('_x','x',0,-Number.MAX_VALUE,Number.MAX_VALUE,'any'),numeric('_y','y',0,-Number.MAX_VALUE,Number.MAX_VALUE,'any'));
|
|
40
|
+
const buttons = node('div',null,{class:'button-row'});
|
|
41
|
+
buttons.append(action('Add point',() => {
|
|
42
|
+
const form = $('controls');
|
|
43
|
+
addPoint([Number(form.elements.namedItem('_x').value),Number(form.elements.namedItem('_y').value)]);
|
|
44
|
+
}),action('Clear',() => {
|
|
45
|
+
const current=state[active]; current.points=[]; current.selected=[];
|
|
46
|
+
if(active==='topology') current.params.cloud='custom';
|
|
47
|
+
if(active==='curvature') Object.assign(current.params,{connections:'all',pair_a:0,pair_b:1});
|
|
48
|
+
buildControls(); schedule();
|
|
49
|
+
}));
|
|
50
|
+
return [row,buttons];
|
|
51
|
+
}
|
|
52
|
+
function buildControls() {
|
|
53
|
+
const form=$('controls'), current=state[active], p=current.params, defaults=config.defaults, limits=config.limits;
|
|
54
|
+
form.replaceChildren();
|
|
55
|
+
const append=(...items)=>form.append(...items);
|
|
56
|
+
if(active==='graphs') {
|
|
57
|
+
append(select('graph','Graph',[
|
|
58
|
+
['karate',"Zachary’s karate club"],['sbm','Stochastic block model'],['barbell','Barbell'],
|
|
59
|
+
['cliques','Ring of cliques'],['geometric','Random geometric'],['custom','Paste edge list'],
|
|
60
|
+
],p.graph));
|
|
61
|
+
append(select('_layout','Layout',[['relaxed','Spectral + relaxed'],['spectral','Spectral only']],current.layout));
|
|
62
|
+
if(['sbm','geometric','custom'].includes(p.graph)) append(numeric('nodes','Nodes',p.nodes,1,limits.nodes));
|
|
63
|
+
if(p.graph==='sbm') append(numeric('blocks','Blocks',p.blocks,1,p.nodes),group(numeric('p_in','p within',p.p_in,0,1,'any'),numeric('p_out','p between',p.p_out,0,1,'any')));
|
|
64
|
+
if(['barbell','cliques'].includes(p.graph)) append(numeric('clique_size','Nodes per clique',p.clique_size,2,Math.floor(limits.nodes/2)));
|
|
65
|
+
if(p.graph==='cliques') append(numeric('cliques','Cliques',p.cliques,2,Math.floor(limits.nodes/2)));
|
|
66
|
+
if(p.graph==='geometric') append(numeric('radius','Connection radius',p.radius,0,Math.SQRT2,'any'));
|
|
67
|
+
if(p.graph==='custom') { const input=node('textarea',p.edges || '',{name:'edges',placeholder:'0 1\n1 2\n2 0 0.5','aria-label':'Edge list'}); append(control('i j [weight], one edge per line',input),help('Zero-based IDs. Missing IDs are isolated vertices. Self-loops are dropped.')); }
|
|
68
|
+
if(['sbm','geometric'].includes(p.graph)) append(numeric('seed','Seed',p.seed,0,config.max_seed));
|
|
69
|
+
append(select('overlay','Overlay',[
|
|
70
|
+
['none','Plain graph'],['forman','Forman curvature'],['ollivier','Ollivier–Ricci curvature'],['flow','Ricci flow + surgery'],
|
|
71
|
+
['heat','Heat diffusion'],['resistance','Effective resistance'],['spectrum','Laplacian spectrum'],
|
|
72
|
+
],p.overlay));
|
|
73
|
+
if(['heat','resistance'].includes(p.overlay)) append(numeric('source','Source node',p.source,0,(current.data?.nodes || limits.nodes)-1));
|
|
74
|
+
if(p.overlay==='heat') append(range('_log_time','Diffusion time log₁₀ t',Math.log10(p.time),Math.log10(config.heat_time_range[0]),Math.log10(config.heat_time_range[1]),'any'),help(`t = ${fmt(p.time)} · logarithmic slider`));
|
|
75
|
+
if(p.overlay==='flow') append(
|
|
76
|
+
group(numeric('steps','Flow steps',p.steps,1,limits.flow_steps),numeric('surgery_period','Surgery period',p.surgery_period,1,p.steps)),
|
|
77
|
+
numeric('surgery_threshold','Surgery threshold × initial mean',p.surgery_threshold,Number.EPSILON,config.max_surgery_threshold,'any'),
|
|
78
|
+
numeric('dt','Step size dt',p.dt,Number.EPSILON,1,'any'),
|
|
79
|
+
help(`At most ${limits.flow_frames} frames. Each frame runs from the original adjacency.`),
|
|
80
|
+
);
|
|
81
|
+
} else if(active==='curvature') {
|
|
82
|
+
append(range('kappa','Curvature κ',p.kappa,-2,2,'any'),help('κ < 0: open ball. κ > 0: conservative hemisphere. Hollow points are excluded.'),...coordinatesControls());
|
|
83
|
+
if(current.points.length>1) {
|
|
84
|
+
const options=current.points.map((_,i)=>[String(i),`Point ${i}`]);
|
|
85
|
+
append(select('connections','Geodesics',[['all','All pairs'],['selected','Selected pair']],p.connections));
|
|
86
|
+
if(p.connections==='selected') append(group(select('pair_a','From',options,String(p.pair_a)),select('pair_b','To',options,String(p.pair_b))));
|
|
87
|
+
}
|
|
88
|
+
append(help(`${current.points.length} / ${limits.curvature_points} points. Distances use the library’s conformal normalization.`));
|
|
89
|
+
} else if(active==='topology') {
|
|
90
|
+
append(select('cloud','Point cloud',[
|
|
91
|
+
['circle','Noisy circle'],['two_circles','Two circles'],['figure_eight','Figure eight'],['annulus','Annulus'],['clusters','Gaussian clusters'],['custom','Draw / edit points'],
|
|
92
|
+
],p.cloud));
|
|
93
|
+
if(p.cloud!=='custom') append(group(numeric('points_count','Points',p.points_count,1,limits.points),numeric('seed','Seed',p.seed,0,config.max_seed)),numeric('noise','Noise σ (coordinate units)',p.noise,0,1,'any'));
|
|
94
|
+
append(...coordinatesControls());
|
|
95
|
+
if(current.data) append(range('_epsilon','Radius ε',current.epsilon,0,current.data.events.at(-1)||1,'any'),help('Bars and counts are precomputed once. Moving ε only changes the view.'));
|
|
96
|
+
append(help(`At most ${limits.points} points; H₀ = components, H₁ = loops.`));
|
|
97
|
+
} else {
|
|
98
|
+
append(select('primitive','Primitive',[['heat','Heat kernel on random graphs'],['distance','Stereographic pairwise distances'],['persistence','Persistence diagrams (H₀, H₁)']],p.primitive));
|
|
99
|
+
const persistence=p.primitive==='persistence';
|
|
100
|
+
append(numeric('size',persistence?'Points per cloud':'Nodes / points per item',p.size,1,persistence?limits.persistence_size:limits.nodes),
|
|
101
|
+
group(numeric('repeats','Measured repeats',p.repeats,1,limits.repeats),numeric('seed','Seed',p.seed,0,config.max_seed)),
|
|
102
|
+
help(`Batch sizes: ${defaults.batches.filter(b=>b<=(persistence?limits.persistence_batch:limits.batch)).join(', ')}. One warm-up per shape. GPU is compared when available.`));
|
|
103
|
+
}
|
|
104
|
+
const run=node('button',active==='batching'?'Run sweep':'Compute',{type:'submit',class:'primary',id:'run'});
|
|
105
|
+
append(run,node('span','',{class:'busy-note',id:'busy-note'})); setBusy(busy);
|
|
106
|
+
}
|
|
107
|
+
function readControls() {
|
|
108
|
+
const current=state[active];
|
|
109
|
+
for(const input of $('controls').elements) {
|
|
110
|
+
if(!input.name || input.name.startsWith('_')) continue;
|
|
111
|
+
current.params[input.name]=(input.type==='number'||input.type==='range')?Number(input.value):input.value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function requestBody(scene) {
|
|
115
|
+
const current=state[scene], request={...current.params,device:$('device').value,dtype:$('dtype').value};
|
|
116
|
+
if(scene==='curvature') {
|
|
117
|
+
request.points=current.points;
|
|
118
|
+
if(request.connections==='selected' && current.points.length>1) request.pairs=[[Number(request.pair_a),Number(request.pair_b)]];
|
|
119
|
+
delete request.connections; delete request.pair_a; delete request.pair_b;
|
|
120
|
+
}
|
|
121
|
+
if(scene==='topology' && request.cloud==='custom') request.points=current.points;
|
|
122
|
+
return request;
|
|
123
|
+
}
|
|
124
|
+
function setBusy(value) {
|
|
125
|
+
if($('run')) { $('run').disabled=value; $('run').textContent=value?'Computing…':active==='batching'?'Run sweep':'Compute'; }
|
|
126
|
+
if($('busy-note')) $('busy-note').textContent=value?'Running the library on the selected device':'';
|
|
127
|
+
$('status-state').textContent=value?'Computing…':'Ready'; $('controls').setAttribute('aria-busy',String(value));
|
|
128
|
+
}
|
|
129
|
+
function schedule() { revision++; pending=true; void drain(); }
|
|
130
|
+
function flowProgress(message) {
|
|
131
|
+
$('busy-note').textContent=message; $('status-state').textContent=message;
|
|
132
|
+
}
|
|
133
|
+
async function computeFlow(request, version) {
|
|
134
|
+
// A frame is a cumulative rerun, never a continuation of the last adjacency.
|
|
135
|
+
// Separate requests make completed frames visible without a streaming protocol.
|
|
136
|
+
let result=await api('graphs',{...request,frame_step:0});
|
|
137
|
+
const steps=result.flow_plan.slice(1), current=state.graphs;
|
|
138
|
+
const publish=()=>{
|
|
139
|
+
current.data=result; current.frame=result.frames.length-1; current.transition=1;
|
|
140
|
+
render();
|
|
141
|
+
$('status-device').textContent=`${result.device} · ${result.dtype}`;
|
|
142
|
+
$('status-time').textContent=`Library calls: ${fmt(result.elapsed_ms)} ms`;
|
|
143
|
+
};
|
|
144
|
+
if(version!==revision) return result;
|
|
145
|
+
publish();
|
|
146
|
+
for (const [index,step] of steps.entries()) {
|
|
147
|
+
flowProgress(`Frame ${index+1}/${steps.length} · step ${step}/${steps.at(-1)}`);
|
|
148
|
+
const next=await api('graphs',{...request,frame_step:step});
|
|
149
|
+
if(version!==revision) return next;
|
|
150
|
+
result={...next,coordinates:result.coordinates,
|
|
151
|
+
frames:[...result.frames,next.frames.at(-1)],
|
|
152
|
+
frame_steps:[...result.frame_steps,step],
|
|
153
|
+
flow_warning_count:result.flow_warning_count+next.flow_warning_count,
|
|
154
|
+
flow_last_warning:next.flow_last_warning||result.flow_last_warning,
|
|
155
|
+
elapsed_ms:result.elapsed_ms+next.elapsed_ms,calls:[...result.calls,...next.calls]};
|
|
156
|
+
publish();
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
async function drain() {
|
|
161
|
+
// Serialize compute requests, and never replace newer controls with a stale
|
|
162
|
+
// response. Aborting a fetch would not cancel the underlying torch operation.
|
|
163
|
+
if(busy) return;
|
|
164
|
+
busy=true; setBusy(true);
|
|
165
|
+
while(pending) {
|
|
166
|
+
pending=false;
|
|
167
|
+
const scene=active, version=revision;
|
|
168
|
+
try {
|
|
169
|
+
error();
|
|
170
|
+
const request=requestBody(scene);
|
|
171
|
+
const response=scene==='graphs' && request.overlay==='flow'
|
|
172
|
+
? await computeFlow(request,version) : await api(scene,request);
|
|
173
|
+
if(version!==revision) continue;
|
|
174
|
+
const current=state[scene], requestKey=JSON.stringify(request);
|
|
175
|
+
current.data=response;
|
|
176
|
+
if(scene==='topology') {
|
|
177
|
+
current.points=response.points;
|
|
178
|
+
if(current.requestKey!==requestKey) { current.event=response.initial_event; current.epsilon=response.initial_epsilon; }
|
|
179
|
+
}
|
|
180
|
+
current.requestKey=requestKey;
|
|
181
|
+
if(scene==='graphs') { current.frame=response.frames?response.frames.length-1:0; current.transition=1; }
|
|
182
|
+
buildControls(); render();
|
|
183
|
+
$('status-device').textContent=`${response.device} · ${response.dtype}`;
|
|
184
|
+
$('status-time').textContent=`Library calls: ${fmt(response.elapsed_ms)} ms`;
|
|
185
|
+
} catch(reason) { if(version===revision) { error(reason.message); $('empty').textContent='Computation failed. Adjust the controls and try again.'; } }
|
|
186
|
+
}
|
|
187
|
+
busy=false; setBusy(false);
|
|
188
|
+
}
|
|
189
|
+
function activate(scene) {
|
|
190
|
+
clearTimeout(debounce);
|
|
191
|
+
clearInterval(playTimer); playTimer=null;
|
|
192
|
+
active=scene;
|
|
193
|
+
document.querySelectorAll('[data-scene]').forEach(button=>{
|
|
194
|
+
if(button.dataset.scene===scene) button.setAttribute('aria-current','page'); else button.removeAttribute('aria-current');
|
|
195
|
+
});
|
|
196
|
+
const meta=SCENES[scene]; $('title').textContent=meta.title; $('description').textContent=meta.description;
|
|
197
|
+
$('view-label').textContent=meta.view; $('hint').textContent=meta.hint;
|
|
198
|
+
buildControls(); render(); schedule();
|
|
199
|
+
}
|
|
200
|
+
function legendText(text) { $('legend').replaceChildren(node('span',text)); }
|
|
201
|
+
function legendRange(label,range,kind='diverging') {
|
|
202
|
+
$('legend').replaceChildren(node('span',label),node('span',fmt(range[0])),node('span',null,{class:`gradient ${kind}`}),node('span',fmt(range[1])));
|
|
203
|
+
}
|
|
204
|
+
function graphPositions(data,current) {
|
|
205
|
+
if(current.layout==='spectral') return data.coordinates;
|
|
206
|
+
const key=JSON.stringify([data.coordinates,data.edges]);
|
|
207
|
+
if(current.layoutKey!==key) {
|
|
208
|
+
current.relaxed=relaxLayout(data.coordinates,data.edges); current.layoutKey=key;
|
|
209
|
+
}
|
|
210
|
+
return current.relaxed;
|
|
211
|
+
}
|
|
212
|
+
function drawGraph(data,current) {
|
|
213
|
+
const positions=graphPositions(data,current);
|
|
214
|
+
view.begin(positions);
|
|
215
|
+
$('view-label').textContent=current.layout==='relaxed'?'Spectral + relaxed · presentation':'Spectral only';
|
|
216
|
+
const scale=data.edge_values?curvatureScale(data.range):null;
|
|
217
|
+
const frame=data.frames?.[current.frame], previous=data.frames?.[Math.max(0,current.frame-1)];
|
|
218
|
+
data.edges.forEach(([a,b],index)=>{
|
|
219
|
+
let alpha=1, stroke=color('edge');
|
|
220
|
+
if(data.edge_values) stroke=curvatureColor(data.edge_values[index],scale);
|
|
221
|
+
if(frame) {
|
|
222
|
+
const was=previous[a][b]>0, now=frame[a][b]>0;
|
|
223
|
+
alpha=now?1:was?1-current.transition:0;
|
|
224
|
+
}
|
|
225
|
+
view.path([positions[a],positions[b]],stroke,VIEW.edgeWidth,alpha);
|
|
226
|
+
});
|
|
227
|
+
const finite=data.node_values?.filter(Number.isFinite) || [], range=finite.length?[Math.min(...finite),Math.max(...finite)]:[0,0];
|
|
228
|
+
positions.forEach((point,index)=>{
|
|
229
|
+
const fill=data.node_values?sequential(data.node_values[index],range):frame && current.frame===data.frames.length-1?community(data.final_labels[index]):color('node');
|
|
230
|
+
view.dot(point,fill,null,data.node_values && data.node_values[index]==null,index===data.source && ['heat','resistance'].includes(data.overlay)?VIEW.graphNodeRadius*1.5:VIEW.graphNodeRadius);
|
|
231
|
+
});
|
|
232
|
+
view.graphLabels(positions,[current.hover,data.source]);
|
|
233
|
+
if(data.edge_values) legendRange(`Curvature (dimensionless) · ${scale.note}`,scale.range,scale.kind);
|
|
234
|
+
else if(data.node_values) { legendRange(data.value_label,range,'sequential'); if(data.node_values.includes(null)) $('legend').append(node('span','Hollow = ∞ / unavailable')); }
|
|
235
|
+
else if(frame) legendText(`Step ${data.frame_steps[current.frame]} · final colors identify connected components`);
|
|
236
|
+
else legendText('Unweighted node markers · edge weights in the details panel');
|
|
237
|
+
}
|
|
238
|
+
function graphDetails(data,current) {
|
|
239
|
+
clearInterval(playTimer); playTimer=null;
|
|
240
|
+
const details=$('details'); details.replaceChildren(metrics([['nodes',data.nodes],['edges',data.edges.length]]));
|
|
241
|
+
details.append(section('Connectivity',node('p',`Component sizes: ${data.component_sizes.join(', ')}. λ₂ = ${fmt(data.spectral_gap)}.`)));
|
|
242
|
+
if(data.frames) {
|
|
243
|
+
details.append(section(data.flow_complete?'Final flow components':'Current flow components',node('p',`Component sizes: ${data.final_sizes.join(', ')}.`)),node('p',data.flow_method));
|
|
244
|
+
if(data.flow_complete && data.faction_note) details.append(section('Observed karate split',node('p',`Wrong-side nodes: ${data.faction_wrong_side??'not a two-way partition'}. ${data.faction_note}`)));
|
|
245
|
+
if(data.flow_warning_count) {
|
|
246
|
+
const notices=node('details');
|
|
247
|
+
notices.append(node('summary',`Solver warnings: ${data.flow_warning_count}`),node('p',data.flow_last_warning));
|
|
248
|
+
details.append(notices);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if(data.node_values) details.append(section('Values by node',table(['Node','Value'],data.node_values.map((value,index)=>[index,value]))));
|
|
252
|
+
else details.append(section('Values by edge',table(['Edge','Weight',...(data.edge_values?['κ']:[])],data.edges.map(([a,b],index)=>[`${a} ↔ ${b}`,data.weights[index],...(data.edge_values?[data.edge_values[index]]:[])]))));
|
|
253
|
+
if(data.reference_check) details.append(section('Reference check',node('p',`Triangle edge / bridge expected: ${data.reference_check.expected.map(fmt).join(', ')}. Observed: ${data.reference_check.observed.map(fmt).join(', ')}.`)));
|
|
254
|
+
details.append(section('Parameters',parameters(data.parameters)));
|
|
255
|
+
if(data.parameters.citation) details.append(node('p',data.parameters.citation));
|
|
256
|
+
$('charts').replaceChildren(spectrumChart(data));
|
|
257
|
+
const playback=$('playback'); playback.hidden=!data.frames; playback.replaceChildren();
|
|
258
|
+
if(data.frames) {
|
|
259
|
+
const play=action(playTimer?'Pause':'Play',()=>{
|
|
260
|
+
if(playTimer) { clearInterval(playTimer); playTimer=null; play.textContent='Play'; return; }
|
|
261
|
+
play.textContent='Pause';
|
|
262
|
+
if(current.frame===data.frames.length-1) { current.frame=0; current.transition=1; }
|
|
263
|
+
playTimer=setInterval(()=>{
|
|
264
|
+
if(active!=='graphs'||current.frame>=data.frames.length-1) { clearInterval(playTimer); playTimer=null; play.textContent='Play'; return; }
|
|
265
|
+
current.frame++; slider.value=current.frame; animateFlow(data,current);
|
|
266
|
+
},VIEW.flowMs);
|
|
267
|
+
});
|
|
268
|
+
const slider=node('input',null,{type:'range',min:0,max:data.frames.length-1,value:current.frame,step:1,'aria-label':'Flow frame'});
|
|
269
|
+
slider.addEventListener('input',()=>{current.frame=Number(slider.value);current.transition=1;drawGraph(data,current);});
|
|
270
|
+
playback.append(play,slider,node('span',`${data.frames.length} frames`));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function animateFlow(data,current) {
|
|
274
|
+
const start=performance.now(); current.transition=0;
|
|
275
|
+
const frame=()=>{
|
|
276
|
+
if(active!=='graphs'||state.graphs.data!==data) return;
|
|
277
|
+
current.transition=Math.min(1,(performance.now()-start)/VIEW.transitionMs); drawGraph(data,current);
|
|
278
|
+
if(current.transition<1) requestAnimationFrame(frame);
|
|
279
|
+
}; requestAnimationFrame(frame);
|
|
280
|
+
}
|
|
281
|
+
function drawCurvature(data,current) {
|
|
282
|
+
// The API supplies the ball or hemisphere radius; flat space uses the grid's
|
|
283
|
+
// coordinate radius. Out-of-domain points must not shrink the chart itself.
|
|
284
|
+
const extent=data.boundary_radius || Math.max(0,...data.grid.flat().map(point=>Math.hypot(...point))) || VIEW.figureExtent;
|
|
285
|
+
view.begin(data.points,[-extent,extent,-extent,extent]);
|
|
286
|
+
data.grid.forEach(curve=>view.path(curve,color('grid'),VIEW.gridWidth));
|
|
287
|
+
if(data.boundary_radius) view.circle(data.boundary_radius);
|
|
288
|
+
data.curves.forEach(curve=>view.path(curve,color('accent')));
|
|
289
|
+
data.points.forEach((point,index)=>view.dot(point,current.selected.includes(index)?color('positive'):color('node'),index,!data.valid[index]));
|
|
290
|
+
if(data.mean) view.dot(data.mean.point,color('negative'),'μ',!data.mean.converged);
|
|
291
|
+
legendText(`${data.chart_note}${data.boundary_radius?` · boundary radius ${fmt(data.boundary_radius)}`:''}`);
|
|
292
|
+
}
|
|
293
|
+
function curvatureDetails(data) {
|
|
294
|
+
const details=$('details'); details.replaceChildren(metrics([['valid points',data.included_indices.length],['κ',data.parameters.kappa]]));
|
|
295
|
+
if(data.valid.some(value=>!value)) details.append(node('p','Hollow points lie outside the current domain and are excluded.'));
|
|
296
|
+
details.append(section('Pairwise geodesic distances',table(['Point',...data.included_indices],data.distances.map((row,index)=>[data.included_indices[index],...row]))));
|
|
297
|
+
details.append(section('Coordinates',table(['Point','x','y'],data.points.map((point,index)=>[index,...point]))));
|
|
298
|
+
if(data.mean) details.append(section('Fréchet estimate',node('p',`μ = (${data.mean.point.map(fmt).join(', ')}). Riemannian gradient residual: ${fmt(data.mean.residual)}. ${data.mean.converged?'Within dtype-based tolerance.':'Not converged.'}`)));
|
|
299
|
+
if(data.parameters.kappa>0) details.append(node('p','Mean is omitted for positive curvature.'));
|
|
300
|
+
details.append(section('Parameters',parameters(data.parameters)));
|
|
301
|
+
$('charts').replaceChildren(); $('playback').hidden=true;
|
|
302
|
+
}
|
|
303
|
+
function drawTopology(data,current) {
|
|
304
|
+
view.begin(data.points,data.points.length?null:[-VIEW.figureExtent,VIEW.figureExtent,-VIEW.figureExtent,VIEW.figureExtent]);
|
|
305
|
+
const epsilon=current.epsilon;
|
|
306
|
+
let labeledEdge=null;
|
|
307
|
+
data.triangles.forEach((triangle,index)=>{ if(data.triangle_radii[index]<=epsilon) view.path(triangle.map(i=>data.points[i]),color('triangle'),VIEW.gridWidth,VIEW.triangleOpacity,true); });
|
|
308
|
+
data.edges.forEach(([a,b],index)=>{ if(data.edge_radii[index]<=epsilon) {
|
|
309
|
+
view.path([data.points[a],data.points[b]],color('edge'));
|
|
310
|
+
if(labeledEdge==null || data.edge_radii[index]>data.edge_radii[labeledEdge]) labeledEdge=index;
|
|
311
|
+
} });
|
|
312
|
+
// One visible edge supplies a distance reference without labeling a dense
|
|
313
|
+
// complex throughout. Its endpoints and the ruler share the point transform.
|
|
314
|
+
if(labeledEdge!=null) {
|
|
315
|
+
const [a,b]=data.edges[labeledEdge];
|
|
316
|
+
view.edgeLength(data.points[a],data.points[b],data.edge_radii[labeledEdge]);
|
|
317
|
+
}
|
|
318
|
+
data.points.forEach((point,index)=>view.dot(point,color('node'),index));
|
|
319
|
+
view.scaleBar();
|
|
320
|
+
legendText(`ε = ${fmt(epsilon)} · H₀: green · H₁: blue · filled triangles at diameter ≤ ε`);
|
|
321
|
+
}
|
|
322
|
+
function topologyDetails(data,current) {
|
|
323
|
+
const index=current.event, betti=data.betti[index], epsilon=current.epsilon;
|
|
324
|
+
const details=$('details'); details.replaceChildren(metrics([['β₀ components',betti[0]],['β₁ loops',betti[1]]]));
|
|
325
|
+
details.append(section('Filtration at ε',parameters({epsilon,points:data.points.length,edges:data.edge_counts[index],triangles:data.triangle_counts[index]})),
|
|
326
|
+
node('p','Intervals include birth and exclude death. Infinite bars end in an arrow. Zero-length bars never contribute to Betti counts.'),
|
|
327
|
+
section('Square reference',node('p',data.reference_check.passed?'Passed: one H₁ bar [1, √2).':'Failed: current library emits extra bars. Treat H₁ counts as provisional.')),
|
|
328
|
+
section('Parameters',parameters(data.parameters)));
|
|
329
|
+
$('charts').replaceChildren(topologyCharts(data,epsilon)); $('playback').hidden=true;
|
|
330
|
+
}
|
|
331
|
+
function batchingDetails(data) {
|
|
332
|
+
$('charts').replaceChildren(batchCharts(data));
|
|
333
|
+
$('details').replaceChildren(section('Measurement',node('p',data.timing_note)),section('Parameters',parameters(data.parameters)),
|
|
334
|
+
section('Devices',node('p',[...new Set(data.rows.map(row=>row.device))].join(', '))),
|
|
335
|
+
node('p','One warm-up for each device and batch shape. Median is plotted with min–max whiskers. Timings include any host work inside the primitive.'),
|
|
336
|
+
node('p','Changing dtype changes the computation; compare rows within a single sweep.'));
|
|
337
|
+
$('data-table').replaceChildren(node('h2','All measurements (milliseconds)'),table(
|
|
338
|
+
['Device','B','Min / batch','Median / batch','Max / batch','Min / item','Median / item','Max / item','All repeats'],
|
|
339
|
+
data.rows.map(row=>[row.device,row.batch,row.min_ms,row.median_ms,row.max_ms,row.per_item_min_ms,row.per_item_ms,row.per_item_max_ms,row.samples_ms.map(fmt).join(', ')])));
|
|
340
|
+
legendText('CPU: green · GPU: blue · one primitive call processes the full batch'); $('playback').hidden=true;
|
|
341
|
+
}
|
|
342
|
+
function render() {
|
|
343
|
+
const current=state[active], data=current.data;
|
|
344
|
+
document.querySelector('.visual-panel').classList.toggle('batch-mode',active==='batching');
|
|
345
|
+
document.querySelector('.visual-panel').classList.toggle('topology-mode',active==='topology');
|
|
346
|
+
$('data-table').hidden=active!=='batching'||!data;
|
|
347
|
+
$('warnings').replaceChildren(); $('empty').hidden=Boolean(data);
|
|
348
|
+
if(!data) { view.begin([]); $('charts').replaceChildren(); $('details').replaceChildren(); $('legend').replaceChildren(); $('playback').hidden=true; $('empty').textContent='Waiting for the library…'; return; }
|
|
349
|
+
data.warnings.forEach(message=>$('warnings').append(node('p',message)));
|
|
350
|
+
if(active==='graphs') { drawGraph(data,current); graphDetails(data,current); }
|
|
351
|
+
else if(active==='curvature') { drawCurvature(data,current); curvatureDetails(data); }
|
|
352
|
+
else if(active==='topology') { drawTopology(data,current); topologyDetails(data,current); }
|
|
353
|
+
else batchingDetails(data);
|
|
354
|
+
const callDetails=node('details');
|
|
355
|
+
callDetails.append(node('summary','Library call timings (ms)'),table(['Function','ms','Arguments'],data.calls.map(call=>[call.function.split('.').slice(-2).join('.'),call.elapsed_ms,Object.entries(call.parameters).map(([key,value])=>`${key}=${fmt(value)}`).join(', ')])));
|
|
356
|
+
$('details').append(callDetails);
|
|
357
|
+
}
|
|
358
|
+
function addPoint(point) {
|
|
359
|
+
const current=state[active], cap=active==='curvature'?config.limits.curvature_points:config.limits.points;
|
|
360
|
+
if(point.some(value=>!Number.isFinite(value))) { error('Point coordinates must be finite.'); return; }
|
|
361
|
+
if(current.points.length>=cap) { error(`The interactive limit is ${cap} points. Clear the cloud to start again.`); return; }
|
|
362
|
+
current.points=[...current.points,point];
|
|
363
|
+
if(active==='topology') current.params.cloud='custom';
|
|
364
|
+
buildControls(); schedule();
|
|
365
|
+
}
|
|
366
|
+
$('canvas').addEventListener('click',event=>{
|
|
367
|
+
const current=state[active], data=current.data; if(!data) return;
|
|
368
|
+
if(active==='graphs') {
|
|
369
|
+
const index=view.nearest(event,graphPositions(data,current)); if(index==null) return;
|
|
370
|
+
current.params.source=index; if(!['heat','resistance'].includes(current.params.overlay)) current.params.overlay='heat';
|
|
371
|
+
buildControls(); schedule();
|
|
372
|
+
} else if(active==='curvature') {
|
|
373
|
+
const index=view.nearest(event,data.points);
|
|
374
|
+
if(index==null) addPoint(view.inverse(view.click(event)));
|
|
375
|
+
else {
|
|
376
|
+
current.selected=[...current.selected.filter(value=>value!==index),index].slice(-2);
|
|
377
|
+
if(current.selected.length===2) Object.assign(current.params,{connections:'selected',pair_a:current.selected[0],pair_b:current.selected[1]});
|
|
378
|
+
buildControls(); schedule();
|
|
379
|
+
}
|
|
380
|
+
} else if(active==='topology') addPoint(view.inverse(view.click(event)));
|
|
381
|
+
});
|
|
382
|
+
$('canvas').addEventListener('pointermove',event=>{
|
|
383
|
+
if(active!=='graphs'||!state.graphs.data) return;
|
|
384
|
+
const current=state.graphs, next=view.nearest(event,graphPositions(current.data,current));
|
|
385
|
+
if(current.hover!==next) { current.hover=next; drawGraph(current.data,current); }
|
|
386
|
+
});
|
|
387
|
+
$('canvas').addEventListener('pointerleave',()=>{
|
|
388
|
+
if(active==='graphs'&&state.graphs.data) { state.graphs.hover=null; drawGraph(state.graphs.data,state.graphs); }
|
|
389
|
+
});
|
|
390
|
+
$('controls').addEventListener('submit',event=>{event.preventDefault();if($('controls').reportValidity()){readControls();schedule();}});
|
|
391
|
+
$('controls').addEventListener('input',event=>{
|
|
392
|
+
const input=event.target; if(input.type!=='range') return;
|
|
393
|
+
input.closest('label')?.querySelector('output')?.replaceChildren(fmt(Number(input.value)));
|
|
394
|
+
if(input.name==='_epsilon') {
|
|
395
|
+
const current=state.topology, value=Number(input.value);
|
|
396
|
+
current.epsilon=value;
|
|
397
|
+
// Counts are server-computed at filtration events. Between events only the
|
|
398
|
+
// cursor moves; the browser does not run another persistence computation.
|
|
399
|
+
const next=current.data.events.findIndex(radius=>radius>value); current.event=next<0?current.data.events.length-1:Math.max(0,next-1);
|
|
400
|
+
render(); return;
|
|
401
|
+
}
|
|
402
|
+
if(input.name==='_log_time') state.graphs.params.time=10**Number(input.value); else readControls();
|
|
403
|
+
clearTimeout(debounce); debounce=setTimeout(schedule,VIEW.debounceMs);
|
|
404
|
+
});
|
|
405
|
+
$('controls').addEventListener('change',event=>{
|
|
406
|
+
const input=event.target;
|
|
407
|
+
if(input.name==='_layout') { state.graphs.layout=input.value; if(state.graphs.data) drawGraph(state.graphs.data,state.graphs); return; }
|
|
408
|
+
if(!input.name||input.name.startsWith('_')||input.type==='range') return;
|
|
409
|
+
readControls();
|
|
410
|
+
if(active==='graphs' && ['graph','nodes','clique_size','cliques','edges'].includes(input.name)) state.graphs.params.source=0;
|
|
411
|
+
if(active==='batching' && input.name==='primitive' && state.batching.params.primitive==='persistence') state.batching.params.size=Math.min(state.batching.params.size,config.limits.persistence_size);
|
|
412
|
+
if(['graph','overlay','cloud','connections','primitive'].includes(input.name)) buildControls();
|
|
413
|
+
if($('controls').reportValidity()) schedule();
|
|
414
|
+
});
|
|
415
|
+
document.querySelectorAll('[data-scene]').forEach(button=>button.addEventListener('click',()=>{ location.hash=button.dataset.scene; }));
|
|
416
|
+
[$('device'),$('dtype')].forEach(input=>input.addEventListener('change',schedule));
|
|
417
|
+
new ResizeObserver(()=>{if(config) render();}).observe($('canvas'));
|
|
418
|
+
matchMedia('(prefers-color-scheme: dark)').addEventListener('change',()=>{if(config) render();});
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
config=await api('config',{});
|
|
422
|
+
if(config.cuda_available) $('device').append(node('option','CUDA / ROCm',{value:config.device.startsWith('cuda')?config.device:'cuda'}));
|
|
423
|
+
$('device').value=config.device; $('dtype').value=config.dtype;
|
|
424
|
+
const d=config.defaults;
|
|
425
|
+
state.graphs.params={...d,graph:'karate',overlay:'forman',source:0,time:1,edges:''};
|
|
426
|
+
state.curvature.params={kappa:-1,connections:'all',pair_a:0,pair_b:1};
|
|
427
|
+
state.curvature.points=config.curvature_points;
|
|
428
|
+
state.topology.params={cloud:'circle',points_count:d.points_count,noise:d.noise,seed:d.seed};
|
|
429
|
+
state.batching.params={primitive:'heat',size:d.size,repeats:d.repeats,seed:d.seed};
|
|
430
|
+
// The URL hash names the scene (#topology), so a scene can be linked to and
|
|
431
|
+
// back/forward move between scenes. Unknown hashes fall back to graphs.
|
|
432
|
+
const fromHash=()=>{ const name=location.hash.slice(1); return Object.hasOwn(SCENES,name) ? name : 'graphs'; };
|
|
433
|
+
addEventListener('hashchange',()=>{ if(fromHash()!==active) activate(fromHash()); });
|
|
434
|
+
activate(fromHash());
|
|
435
|
+
} catch(reason) { error(reason.message); $('status-state').textContent='Connection failed'; }
|