Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion frontend/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,21 @@ export async function selectValue(page: Page, selector: string, value: string) {
}, value)
}

interface CyNode {
id(): string
position(k: 'x' | 'y'): number
data(key: string): unknown
pstyle(key: string): { value: unknown }
}

declare global {
interface Window {
__norma: {
cy: {
nodes(): {
length: number
map<T>(f: (n: { id(): string; position(k: 'x' | 'y'): number }) => T): T[]
map<T>(f: (n: CyNode) => T): T[]
find(f: (n: CyNode) => boolean): CyNode | undefined
}
edges(): { length: number }
}
Expand Down
58 changes: 58 additions & 0 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,61 @@ test('OmniPath search is case-insensitive: a lowercase query is sent to the API
await expect.poll(() => requestedUrl).toContain('partners=EGFR')
expect(requestedUrl).not.toContain('partners=egfr')
})

test('Upload Data panels report loading progress at the bottom of their own section', async ({
page,
}) => {
await page.goto('/')

// Examples: opening one reports progress in #examplesStatus, not #normaStatus
await page.locator('.section[data-tint="examples"] h3').click()
await page.locator('#btnSample').click()
await expect(page.locator('#examplesStatus .note.ok')).toContainText(/opened/i)

// Files: reading an uploaded file reports progress in #normaStatus (already
// covered structurally elsewhere; here we just confirm it still ends well)
await page.locator('#filesSection h3').click()
await page.setInputFiles('#normaFileInput', {
name: 'network2.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Source\tTarget\nX\tY\n'),
})
await expect(page.locator('#normaStatus .note.ok').first()).toContainText(/added/i)

// Open saved work: a settings file reports progress in #savedWorkStatus
await page.locator('.section[data-tint="saved"] h3').click()
await page.setInputFiles('#configFileInput', {
name: 'norma-settings.json',
mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify({ themeSelect: 'dark' })),
})
await expect(page.locator('#savedWorkStatus .note.ok')).toContainText(/applied settings/i)
})

test('multi-group nodes keep pie-slice coloring under WebGL rendering', async ({ page }) => {
// Cytoscape's WebGL renderer takes a "simple shape" fast path (solid
// background, drawn as WebGL geometry) for any node whose background-fill
// is 'solid' -- that check never looks at pie-*-background-* styles, so a
// node with pie slices used to render as a flat color under WebGL. The fix
// gives such nodes a one-stop gradient fill, which is enough to route them
// through the full (pie-aware) draw path.
await page.goto(`/norma.html?example=${EXAMPLE}&webgl=1`)
await expect(page.locator('#tabNetwork')).toHaveAttribute('aria-selected', 'true')
await expect.poll(() => nodeCount(page)).toBeGreaterThan(0)
await selectValue(page, '#nodeFillSelect', 'groups')

const styles = await page.evaluate(() => {
const nodes = window.__norma.cy.nodes()
const multi = nodes.find((n) => n.data('pieSize2') !== '0%')
const single = nodes.find((n) => n.data('pieSize2') === '0%')
return {
multiFill: multi?.pstyle('background-fill').value,
multiPieSize2: multi?.data('pieSize2'),
singleFill: single?.pstyle('background-fill').value,
}
})
expect(styles.multiPieSize2).not.toBe('0%')
expect(styles.multiFill).toBe('radial-gradient')
// single-group nodes stay on the fast "simple shape" path
expect(styles.singleFill).toBe('solid')
})
3 changes: 2 additions & 1 deletion frontend/norma.html
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ <h3 role="button" tabindex="0" aria-expanded="false">Examples<button type="butto
<button class="primary" id="btnSample" title="Opens the example in a new view">Open example</button>
<button id="btnClear" title="Empty the current view; files stay in the lists">Clear view</button>
</div>
<div id="examplesStatus" class="status" role="status" aria-live="polite"></div>
</div>

<div class="section collapsible collapsed" id="filesSection" data-tint="files">
Expand Down Expand Up @@ -199,12 +200,12 @@ <h3 role="button" tabindex="0" aria-expanded="false">Open saved work<button type
<button type="button" id="btnSessionOpen">Open session file…</button>
<input type="file" id="sessionFileInput" accept=".json,application/json" hidden>
</div>
<div id="sessionStatus" class="status" role="status" aria-live="polite"></div>
<div class="menu-sep"><span>Settings file (.json) <span class="lib-hint">display settings only</span></span></div>
<div class="row" style="margin-top:0;">
<button id="btnImportConfigTrigger">Open settings file…</button>
</div>
<input type="file" id="configFileInput" accept="application/json,.json" style="display:none;">
<div id="savedWorkStatus" class="status" role="status" aria-live="polite"></div>
</div>

</div>
Expand Down
39 changes: 30 additions & 9 deletions frontend/src/cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import { webglPreference } from './enrichment'
wins) stays fixed: node, node.dimmed, ..., edge, ..., edge.highlighted. */
export const SELECTION_YELLOW = '#facc15'

// WebGL drawing is chosen once, when the canvas is created (see Display → Performance).
export const WEBGL_ACTIVE = (() => {
try {
return webglPreference()
} catch (e) {
return false
}
})()

const BASE_STYLE = [
{
selector: 'node',
Expand Down Expand Up @@ -41,6 +50,27 @@ const BASE_STYLE = [
'overlay-opacity': 0,
},
},
// Cytoscape's WebGL renderer skips pie slices for any node it classifies as
// a "simple shape" (a fast path that only reads background-color/opacity
// and border, drawn as WebGL geometry instead of a texture) -- and that
// classification never looks at pie-*-background-* styles, so multi-group
// nodes silently lose their pie coloring under WebGL. pieSize2 is only
// ever non-zero for a node with 2+ visible groups (see
// computeNodeVisualFields); giving those nodes a one-stop gradient fill
// (visually identical to a flat color) is enough to fail the "simple
// shape" check and force the full (pie-aware) draw path, without touching
// plain canvas rendering, where it already works.
...(WEBGL_ACTIVE
? [
{
selector: 'node[pieSize2 != "0%"]',
style: {
'background-fill': 'radial-gradient',
'background-gradient-stop-colors': 'data(color)',
},
},
]
: []),
{
selector: 'node.dimmed',
style: { opacity: 0.12 },
Expand Down Expand Up @@ -153,15 +183,6 @@ export function setStyle(selector, props) {
}
}

// WebGL drawing is chosen once, when the canvas is created (see Display → Performance).
export const WEBGL_ACTIVE = (() => {
try {
return webglPreference()
} catch (e) {
return false
}
})()

export const cy = cytoscape({
container: document.getElementById('cy'),
...(WEBGL_ACTIVE ? { renderer: { name: 'canvas', webgl: true } } : {}),
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1139,17 +1139,20 @@ export function loadSession(obj) {
}

async function openSessionFile(file) {
const task = startProgress('savedWorkStatus', [1])
task.step(0, `Reading "${file.name}"…`)
try {
const obj = JSON.parse(await readFileText(file))
const text = await readFileText(file, (loaded, total) => task.bytes(loaded, total))
const obj = JSON.parse(text)
const r = loadSession(obj)
setStatus('sessionStatus', [
setStatus('savedWorkStatus', [
{
level: 'ok',
text: `Opened the session "${file.name}": ${plural(r.views, 'view')} and ${plural(r.files, 'file')}.`,
},
])
} catch (err) {
setStatus('sessionStatus', [
setStatus('savedWorkStatus', [
{ level: 'error', text: `The session couldn't be opened: ${err.message}` },
])
}
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/layouts/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,10 +949,13 @@ export function init() {

document.getElementById('btnFit').addEventListener('click', () => fitView())

document.getElementById('btnSample').addEventListener('click', () => {
document.getElementById('btnSample').addEventListener('click', async () => {
const sel = document.getElementById('sampleSelect')
const key = sel.value
const title = sel.options[sel.selectedIndex].text.replace(/\s*\([^)]*nodes\)\s*$/, '')
const task = startProgress('examplesStatus', [1])
task.step(0, `Opening "${title}"…`)
await nextPaint()
openInNewView(title, () => {
if (key.startsWith('norma:')) {
loadNormaExampleSet(key.slice(6))
Expand All @@ -966,6 +969,7 @@ export function init() {
loadData(gen())
setStatus('normaStatus', [])
})
setStatus('examplesStatus', [{ level: 'ok', text: `Opened "${title}".` }])
})

document.getElementById('btnClear').addEventListener('click', () => {
Expand Down Expand Up @@ -1001,8 +1005,11 @@ export function init() {
if (!parsed.nodes || !parsed.edges) throw new Error('JSON needs "nodes" and "edges" arrays.')
openInNewView('Pasted JSON', () => loadData(parsed))
document.getElementById('importBox').style.display = 'none'
setStatus('savedWorkStatus', [{ level: 'ok', text: 'Opened the pasted JSON.' }])
} catch (err) {
alert('Could not parse JSON: ' + err.message)
setStatus('savedWorkStatus', [
{ level: 'error', text: `Could not parse JSON: ${err.message}` },
])
}
})

Expand All @@ -1013,15 +1020,21 @@ export function init() {
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0]
if (!file) return
const task = startProgress('savedWorkStatus', [1])
task.step(0, `Reading "${file.name}"…`)
const reader = new FileReader()
reader.onprogress = (ev) => task.bytes(ev.loaded, ev.lengthComputable ? ev.total : file.size)
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result)
if (!parsed.nodes || !parsed.edges)
throw new Error('JSON needs "nodes" and "edges" arrays.')
openInNewView(file.name.replace(/\.json$/i, ''), () => loadData(parsed))
setStatus('savedWorkStatus', [{ level: 'ok', text: `Opened "${file.name}".` }])
} catch (err) {
alert('Could not read file: ' + err.message)
setStatus('savedWorkStatus', [
{ level: 'error', text: `Could not read the file: ${err.message}` },
])
}
}
reader.readAsText(file)
Expand Down Expand Up @@ -1091,13 +1104,21 @@ export function init() {
document.getElementById('configFileInput').addEventListener('change', (e) => {
const file = e.target.files[0]
if (!file) return
const task = startProgress('savedWorkStatus', [1])
task.step(0, `Reading "${file.name}"…`)
const reader = new FileReader()
reader.onprogress = (ev) => task.bytes(ev.loaded, ev.lengthComputable ? ev.total : file.size)
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result)
applyConfig(parsed)
setStatus('savedWorkStatus', [
{ level: 'ok', text: `Applied settings from "${file.name}".` },
])
} catch (err) {
alert('Could not read settings file: ' + err.message)
setStatus('savedWorkStatus', [
{ level: 'error', text: `Could not read settings file: ${err.message}` },
])
}
}
reader.readAsText(file)
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ import {
renderLibraryLists,
selectedNetworks,
setStatus,
startProgress,
} from './layouts/controls'
import { convertArena3dNetwork, isArena3dNetworkText } from './arena3d'

/* ---------- uploads ---------- */
export function readFileText(file) {
// onBytes(loaded, total), if given, is called as the file is read (total
// falls back to the file's own size when the browser can't report progress).
export function readFileText(file, onBytes) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
if (onBytes)
reader.onprogress = (ev) => onBytes(ev.loaded, ev.lengthComputable ? ev.total : file.size)
reader.onload = (ev) => resolve(ev.target.result)
reader.onerror = () =>
reject(new Error('The file could not be read. Check that it is a plain text file.'))
Expand Down Expand Up @@ -55,9 +60,15 @@ export async function handleNormaUploads(fileList) {
const notes = []
const added = []
const addedNotes = new Map()
for (const file of files) {
const task = startProgress(
'normaStatus',
files.map(() => 1)
)
for (let i = 0; i < files.length; i++) {
const file = files[i]
task.step(i, `Reading "${file.name}"…`)
try {
const text = await readFileText(file)
const text = await readFileText(file, (loaded, total) => task.bytes(loaded, total))
checkNormaFileFormat(text)
if ((chosenKind === 'auto' || chosenKind === 'network') && isArena3dNetworkText(text)) {
// an Arena3D network file: a network plus its layers as groups
Expand Down
Loading