From 4922efb5e44ea27b0e1c4616e97036122c4ea954 Mon Sep 17 00:00:00 2001 From: Fotis Baltoumas Date: Thu, 17 Sep 2026 11:22:06 +0300 Subject: [PATCH 1/5] Added playwright regression test and fixed more bugs --- frontend/e2e/smoke.spec.ts | 39 +++++++++++++++++++++ frontend/src/export/dialog.ts | 4 +-- frontend/src/metrics.ts | 64 +++++++++++++++++++++++++---------- frontend/src/state.ts | 1 - 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 954d01c..99eabcd 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -47,6 +47,45 @@ test('layouts move the nodes', async ({ page }) => { .toBe(true) }) +test('Kamada-Kawai and Stress layouts run in the background worker on a big network', async ({ + page, + request, +}) => { + // Regression test: getFrWorker() (metrics.ts) builds the layout worker by + // stringifying module-private functions and dispatching them by their + // literal source names. This suite runs against the production build + // (see playwright.config.ts), where the bundler's minifier renames those + // functions; the dispatcher's hardcoded names didn't follow, so "kk" and + // "stress" threw "distanceLayout is not defined" inside the worker and + // silently failed (runComputedLayout only reports it into #layoutStatus, + // it never throws or logs). That only happens once a network is big + // enough to take the worker path (> 60 nodes) - a small example never + // exercised it. + const n = 80 + const edges = Array.from({ length: n - 1 }, (_, i) => ({ source: `N${i}`, target: `N${i + 1}` })) + const r = await request.post('/api/external', { data: { name: 'Layout worker test', edges } }) + expect(r.ok()).toBe(true) + const { url } = (await r.json()) as { url: string } + await page.goto(url) + await expect.poll(() => nodeCount(page)).toBe(n) + + await page.locator('#sideTabDisplay').click() + await page.locator('#layoutSection h3').click() + await expect(page.locator('#btnRunLayout')).toBeVisible() + + for (const kind of ['kk', 'stress']) { + const before = await positions(page) + await selectValue(page, '#layoutSelect', kind) + await page.locator('#btnRunLayout').click() + await expect + .poll(async () => JSON.stringify(await positions(page)) !== JSON.stringify(before), { + timeout: 20_000, + }) + .toBe(true) + await expect(page.locator('#layoutStatus')).toBeEmpty() + } +}) + test('tabs switch views, 3D view activates', async ({ page }) => { await openExample(page) for (const [tab, view] of [ diff --git a/frontend/src/export/dialog.ts b/frontend/src/export/dialog.ts index 4b1b760..3485369 100644 --- a/frontend/src/export/dialog.ts +++ b/frontend/src/export/dialog.ts @@ -502,11 +502,11 @@ export function fr3dLayout(ids, edges, opts) { let laidOut = 0 const parts = comps.map((m, c) => { - S.workRange = [laidOut / n, (laidOut + m.length) / n] + globalThis.__normaWorkRange = [laidOut / n, (laidOut + m.length) / n] laidOut += m.length return layoutComponent(m.length, compEdges[c]) }) - S.workRange = [0, 1] + globalThis.__normaWorkRange = [0, 1] // pack: the largest piece in the middle, the others on a shell around it const centers = [[0, 0, 0]] if (parts.length > 1) { diff --git a/frontend/src/metrics.ts b/frontend/src/metrics.ts index 7f8085f..f0d5c44 100644 --- a/frontend/src/metrics.ts +++ b/frontend/src/metrics.ts @@ -348,15 +348,27 @@ export const LARGE_NETWORK_NODES = 700 // Progress of long computations (layouts, edge bundling), reported from // inside their loops as a fraction of the part in workRange. In the worker, // workSink posts it to the page; on the page itself it is not used. -var workSink = null, - workLast = 0 - +// +// These live on globalThis (not as module-scope `var`s, and not on the +// shared `S` state object) because getFrWorker() below rebuilds this +// function's source with .toString() and pastes it into a separate worker +// script that only shares globals, not module bindings: a module-scope var +// or an `S.x` property read/written here would, after the production +// build's minifier renames it, no longer match the plain, unrenamed name +// the worker's hand-written preamble uses to set it up (or `S` wouldn't +// exist there at all) - throwing " is not defined" the moment a +// layout ran in the worker (only reproducible on a network big enough to +// take that path; see the "run in the worker" e2e test in smoke.spec.ts). +// `globalThis.__normaWorkX` is immune: globalThis is never renamed, and +// plain string property names survive the default (non-mangling) minifier. export function workStep(f) { - if (!workSink) return + const sink = globalThis.__normaWorkSink + if (!sink) return const now = Date.now() - if (now - workLast < 80) return - workLast = now - workSink(S.workRange[0] + (S.workRange[1] - S.workRange[0]) * Math.max(0, Math.min(1, f))) + if (now - (globalThis.__normaWorkLast || 0) < 80) return + globalThis.__normaWorkLast = now + const r = globalThis.__normaWorkRange || [0, 1] + sink(r[0] + (r[1] - r[0]) * Math.max(0, Math.min(1, f))) } function fdebBundle(segs, opts) { @@ -723,7 +735,13 @@ function frLayout(ids, edges) { const w = Number.isFinite(e.weight) && e.weight > 0 ? e.weight : 1 all.push({ s, t, w }) }) - const rand = mulberry32(FR_SEED) + // 123, not FR_SEED: this function is also pasted verbatim into the layout + // worker (see getFrWorker() below), which only shares globals with the + // page, not module bindings - a reference to the FR_SEED const would, once + // the production build's minifier renames it, no longer resolve to + // anything there. FR_SEED stays the single source of truth for every + // other (main-thread-only) caller; keep this literal equal to it. + const rand = mulberry32(123) const comps = connectedComponents(ids.length, all) const compOf = new Int32Array(ids.length) const localIdx = new Int32Array(ids.length) @@ -737,11 +755,11 @@ function frLayout(ids, edges) { all.forEach((e) => compEdges[compOf[e.s]].push({ s: localIdx[e.s], t: localIdx[e.t], w: e.w })) let laidOut = 0 const parts = comps.map((members, c) => { - S.workRange = [laidOut / ids.length, (laidOut + members.length) / ids.length] + globalThis.__normaWorkRange = [laidOut / ids.length, (laidOut + members.length) / ids.length] laidOut += members.length return { members, ...frComponent(members.length, compEdges[c], rand) } }) - S.workRange = [0, 1] + globalThis.__normaWorkRange = [0, 1] packComponents(parts) const out = {} parts.forEach((p) => @@ -764,7 +782,6 @@ function frLayout(ids, edges) { the weighted Fruchterman-Reingold layout, and run in the worker. Edge weights are not used: distances are shortest paths in hops. ============================================================ */ -const DIST_LAYOUT_LIMIT = 2500 // nodes per component // all-pairs hop distances of one component (local indices) @@ -1070,11 +1087,16 @@ function distanceLayout(ids, edges, kind) { }) let laidOut = 0 const parts = comps.map((members, c) => { - S.workRange = [laidOut / ids.length, (laidOut + members.length) / ids.length] + globalThis.__normaWorkRange = [laidOut / ids.length, (laidOut + members.length) / ids.length] laidOut += members.length - if (members.length > DIST_LAYOUT_LIMIT) + // 2500, not DIST_LAYOUT_LIMIT: this function is also pasted verbatim + // into the layout worker (see getFrWorker() below), which only shares + // globals with the page, not module bindings - a reference to the + // DIST_LAYOUT_LIMIT const would, once the production build's minifier + // renames it, no longer resolve to anything there. + if (members.length > 2500) throw new Error( - `${kind === 'kk' ? 'Kamada–Kawai' : 'Stress majorization'} handles connected parts of up to ${DIST_LAYOUT_LIMIT.toLocaleString('en-US')} nodes; this network has one of ${members.length.toLocaleString('en-US')}. Use a force-directed layout instead.` + `${kind === 'kk' ? 'Kamada–Kawai' : 'Stress majorization'} handles connected parts of up to ${(2500).toLocaleString('en-US')} nodes; this network has one of ${members.length.toLocaleString('en-US')}. Use a force-directed layout instead.` ) const lay = kind === 'kk' @@ -1083,7 +1105,7 @@ function distanceLayout(ids, edges, kind) { // hop units -> FR-like units so packing gaps look alike return { members, x: lay.x, y: lay.y } }) - S.workRange = [0, 1] + globalThis.__normaWorkRange = [0, 1] packComponents(parts) const out = {} parts.forEach((p) => @@ -1140,7 +1162,7 @@ export function getFrWorker() { ] .map((f) => f.toString()) .join('\n') + - `\nconst FR_SEED = ${FR_SEED};\nconst DIST_LAYOUT_LIMIT = ${DIST_LAYOUT_LIMIT};\nvar workRange = [0, 1], workSink = null, workLast = 0;\n` + + '\n' + // The dispatcher below used to call these by their literal source // names, which broke once the production build's minifier renamed // these (non-exported) functions: the worker still got their bodies @@ -1148,7 +1170,15 @@ export function getFrWorker() { // so the hardcoded names threw "X is not defined". Using .name reads // each function's *current* runtime name, so it always matches the // identifier its .toString() source was declared under. - `onmessage = e => { const m = e.data; let result; workRange = [0, 1]; workLast = 0; workSink = f => postMessage({ id: m.id, progress: f }); try{ result = m.kind === "fdeb" ? ${fdebBundle.name}(m.segs, m.opts) : m.kind === "fr3d" ? ${fr3dLayout.name}(m.ids, m.edges, m.opts) : (m.kind === "kk" || m.kind === "stress") ? ${distanceLayout.name}(m.ids, m.edges, m.kind) : ${frLayout.name}(m.ids, m.edges); } catch(err){ postMessage({ id: m.id, error: String(err && err.message || err) }); return; } postMessage({ id: m.id, result }); };` + // + // globalThis.__normaWork* (set here, read/written by the pasted + // workStep/frLayout/distanceLayout bodies) is the same fix applied to + // *data*: a module-scope var or the shared S object would hit the + // exact same renamed-name mismatch (or, for S, simply not exist in + // the worker at all), since unlike functions, a plain value has no + // .name to recover the identifier by. globalThis and plain string + // property names are never renamed by the minifier, so this is safe. + `onmessage = e => { const m = e.data; let result; globalThis.__normaWorkRange = [0, 1]; globalThis.__normaWorkLast = 0; globalThis.__normaWorkSink = f => postMessage({ id: m.id, progress: f }); try{ result = m.kind === "fdeb" ? ${fdebBundle.name}(m.segs, m.opts) : m.kind === "fr3d" ? ${fr3dLayout.name}(m.ids, m.edges, m.opts) : (m.kind === "kk" || m.kind === "stress") ? ${distanceLayout.name}(m.ids, m.edges, m.kind) : ${frLayout.name}(m.ids, m.edges); } catch(err){ postMessage({ id: m.id, error: String(err && err.message || err) }); return; } postMessage({ id: m.id, result }); };` const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' })) frWorker = new Worker(url) frWorker.onmessage = (e) => { diff --git a/frontend/src/state.ts b/frontend/src/state.ts index a39fe88..4841900 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -17,7 +17,6 @@ export const S = { dataCache: { version: -1, data: null }, historyGestureOpen: false, cyLayoutsRunning: 0, - workRange: [0, 1], frRequestSeq: 0, normaEntrySeq: 0, viewSeq: 0, From 5dfd5034c7bd58f3496ddb1e611223bd4dd43ade Mon Sep 17 00:00:00 2001 From: Fotis Baltoumas Date: Thu, 17 Sep 2026 13:30:32 +0300 Subject: [PATCH 2/5] no hand-duplicated literals for FR_SEED/DIST_LAYOUT_LIMIT --- frontend/src/metrics.ts | 43 +++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/frontend/src/metrics.ts b/frontend/src/metrics.ts index f0d5c44..eaa7139 100644 --- a/frontend/src/metrics.ts +++ b/frontend/src/metrics.ts @@ -735,13 +735,9 @@ function frLayout(ids, edges) { const w = Number.isFinite(e.weight) && e.weight > 0 ? e.weight : 1 all.push({ s, t, w }) }) - // 123, not FR_SEED: this function is also pasted verbatim into the layout - // worker (see getFrWorker() below), which only shares globals with the - // page, not module bindings - a reference to the FR_SEED const would, once - // the production build's minifier renames it, no longer resolve to - // anything there. FR_SEED stays the single source of truth for every - // other (main-thread-only) caller; keep this literal equal to it. - const rand = mulberry32(123) + // globalThis.__normaFrSeed, not FR_SEED directly: see the comment by its + // declaration above (this function also runs inside the layout worker). + const rand = mulberry32(globalThis.__normaFrSeed) const comps = connectedComponents(ids.length, all) const compOf = new Int32Array(ids.length) const localIdx = new Int32Array(ids.length) @@ -782,6 +778,18 @@ function frLayout(ids, edges) { the weighted Fruchterman-Reingold layout, and run in the worker. Edge weights are not used: distances are shortest paths in hops. ============================================================ */ +const DIST_LAYOUT_LIMIT = 2500 +// frLayout/distanceLayout read these off globalThis instead of closing over +// the consts directly, because both functions are also pasted verbatim into +// the layout worker (see getFrWorker() below), which only shares globals +// with the page, not module bindings. A direct FR_SEED/DIST_LAYOUT_LIMIT +// reference would, once the production build's minifier renames the const, +// no longer resolve inside the worker; globalThis and plain string property +// names are never renamed, so this keeps FR_SEED/DIST_LAYOUT_LIMIT above as +// the single source of truth for both places instead of a hand-duplicated +// literal that could silently drift if either value ever changes. +globalThis.__normaFrSeed = FR_SEED +globalThis.__normaDistLayoutLimit = DIST_LAYOUT_LIMIT // nodes per component // all-pairs hop distances of one component (local indices) @@ -1089,14 +1097,13 @@ function distanceLayout(ids, edges, kind) { const parts = comps.map((members, c) => { globalThis.__normaWorkRange = [laidOut / ids.length, (laidOut + members.length) / ids.length] laidOut += members.length - // 2500, not DIST_LAYOUT_LIMIT: this function is also pasted verbatim - // into the layout worker (see getFrWorker() below), which only shares - // globals with the page, not module bindings - a reference to the - // DIST_LAYOUT_LIMIT const would, once the production build's minifier - // renames it, no longer resolve to anything there. - if (members.length > 2500) + // globalThis.__normaDistLayoutLimit, not DIST_LAYOUT_LIMIT directly: see + // the comment by its declaration above (this function also runs inside + // the layout worker). + const limit = globalThis.__normaDistLayoutLimit + if (members.length > limit) throw new Error( - `${kind === 'kk' ? 'Kamada–Kawai' : 'Stress majorization'} handles connected parts of up to ${(2500).toLocaleString('en-US')} nodes; this network has one of ${members.length.toLocaleString('en-US')}. Use a force-directed layout instead.` + `${kind === 'kk' ? 'Kamada–Kawai' : 'Stress majorization'} handles connected parts of up to ${limit.toLocaleString('en-US')} nodes; this network has one of ${members.length.toLocaleString('en-US')}. Use a force-directed layout instead.` ) const lay = kind === 'kk' @@ -1162,7 +1169,13 @@ export function getFrWorker() { ] .map((f) => f.toString()) .join('\n') + - '\n' + + // FR_SEED/DIST_LAYOUT_LIMIT are interpolated here as plain values + // (${FR_SEED}, not the identifier), so this always carries whatever + // they're currently set to on the page - no duplicated literal to + // keep in sync. See the comment by DIST_LAYOUT_LIMIT's declaration + // for why the worker's copies of frLayout/distanceLayout read them + // back off globalThis rather than closing over the consts directly. + `\nglobalThis.__normaFrSeed = ${FR_SEED};\nglobalThis.__normaDistLayoutLimit = ${DIST_LAYOUT_LIMIT};\n` + // The dispatcher below used to call these by their literal source // names, which broke once the production build's minifier renamed // these (non-exported) functions: the worker still got their bodies From e8eb466e17b2654539a25f8315536eabe3e76969 Mon Sep 17 00:00:00 2001 From: Fotis Baltoumas Date: Thu, 17 Sep 2026 14:52:41 +0300 Subject: [PATCH 3/5] fixed importers & responsive UI --- frontend/norma.html | 3 ++ frontend/src/api/wiring.ts | 64 +++++++++++++++++++++++++ frontend/src/enrichment.ts | 96 +++++++++++++++++++++++++++++++------- frontend/src/side_panel.ts | 7 +++ frontend/src/style.css | 76 ++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 18 deletions(-) diff --git a/frontend/norma.html b/frontend/norma.html index d2f6c2c..393d9d8 100644 --- a/frontend/norma.html +++ b/frontend/norma.html @@ -321,6 +321,7 @@