diff --git a/REPORT_THREEJS.md b/REPORT_THREEJS.md deleted file mode 100644 index d98337c..0000000 --- a/REPORT_THREEJS.md +++ /dev/null @@ -1,102 +0,0 @@ -# Three.js / UI Refinement Report - -Branch: `three-js-lifting` — 8 commits, one feature per commit, no push. -Backend untouched: no endpoint, model, or contract changes (verified by the -full backend test suite and by the unchanged `backend/` tree). - -## Summary of major improvements - -### Phase 1 — Three.js correctness -1. **HiDPI rendering** — the renderer ignored `devicePixelRatio` (blurry on - retina). Now `setPixelRatio(min(dpr, 2))`, re-applied on resize for - monitor moves; `powerPreference: 'high-performance'`. -2. **Render loop** — replaced the `setTimeout(rAF)` hybrid with a pure - `requestAnimationFrame` loop and timestamp-based FPS cap (`ctx.fps` still - the configurable bound). Frames are vsync-aligned and the loop auto-pauses - in hidden tabs. -3. **GPU resource disposal** — scene graphs orphaned when a new command cut - off the redo branch were never disposed (geometry/material/texture leak on - repeated network loads). Commands now have an optional `dispose()` hook; - `LoadNetworkCommand` frees its exclusively-owned snapshot. Undo history - stays fully intact — only provably unreachable scenes are disposed. - -### Phase 2 — UX / interaction -4. **Smooth eased wheel zoom** — wheel sets a clamped target scale (same - 0.2–2 bounds); the render loop eases toward it. `prefers-reduced-motion` - users keep instant zoom; the target resets on network load. -5. **Smoother pan/orbit/lasso** — the v2 10px drag gate made orbit jump in - ~10° increments; delta-based interactions now update every 2px. Held-key - node/layer transforms keep the 10px step (they apply a fixed increment per - event, so their speed is unchanged). -6. **Lasso leak fix** — the lasso rectangle allocated a new geometry+material - per pointer move without disposal; material is now shared, geometry - disposed on rebuild/release. -7. **Hover raycast throttling** — raycasting every node sphere per mousemove - janks large networks; moves now record the latest position and the render - loop does one hover check per frame. -8. **Picking crash fix (found during validation)** — `intersectObjects` - defaults to recursive, so hover rays hit plane children (node spheres, - labels, coord lines) whose uuids aren't in the picked list; - `findIndexByUuid` returned -1 and `ctx.layers[-1].plane` threw on hover. - Hover/double-click picking is now non-recursive (also cheaper). - `drag_controls` was deliberately left recursive — its uuid gate encodes - the v2 "don't drag a layer while over a node" behavior. - -### Phase 3 — Postprocessing (`frontend/src/three/postprocessing.ts`) -9. **Optional subtle bloom** — `EffectComposer` + `UnrealBloomPass` - (strength 0.35, radius 0.3, threshold 0.8) behind a "Node Glow" checkbox - in Scene Actions. Guard rails: - - active only over dark backgrounds (luma < 0.5) — bloom on the white - theme would wash the scene out; theme switches toggle it automatically; - - defaults off on mobile UAs; - - when inactive the plain `renderer.render` path runs — zero extra GPU - cost; - - labels are DOM overlays, so they're never bloomed. - -### Phase 4 — UI polish (CSS only, no semantic changes) -10. **Cohesive dark chrome** — shared design tokens (accent, frosted - translucent surface, hairline border) across navbar, canvas-control - overlay card, node description card, footer and loader; system font - stack; the solid-blue v2 footer became a quiet translucent bar; the - 120px loader became a slim centered ring. Added `:focus-visible` - outlines and reduced-motion handling for spinner + zoom. - -## Files changed -- `frontend/src/actions/screen.ts` — renderer config, DPR, rAF loop, composer wiring -- `frontend/src/actions/canvas_controls.ts` — eased zoom, drag thresholds, lasso disposal, hover coalescing -- `frontend/src/actions/canvas_controls.test.ts` — updated zoom test for easing -- `frontend/src/actions/node.ts`, `frontend/src/actions/layer.ts` — non-recursive picking -- `frontend/src/commands/base.ts`, `frontend/src/commands/scene.ts` — `dispose()` hook -- `frontend/src/three/runtime.ts` — `disposeSnapshot` -- `frontend/src/three/postprocessing.ts` — new (bloom module) -- `frontend/src/ui/scene.ts` — Node Glow toggle -- `frontend/src/style.css` — design tokens + chrome polish - -## Validation commands run (all green) -- `backend`: `uv run pytest` (58 passed), `uv run ruff check .`, `uv run mypy app` -- `frontend`: `npx vitest run` (105 tests, 15 files), `npm run lint`, - `npx tsc --noEmit`, `npm run build`, `npx playwright test` (e2e suite) - -## Playwright flows tested -- Existing e2e: load example → Fruchterman-Reingold layout → clustered layout → export session. -- Custom scripts: full-canvas hover sweeps (crash regression), eased-zoom - scale assertions, middle-drag orbit, double-click select + Ctrl+Z/Ctrl+Shift+Z - undo/redo, bloom toggle on/off screenshots, viewport resize (1440/900/700) - + reload, responsive screenshots at desktop/tablet/mobile widths. -- Console: zero errors in every run (only headless-GL driver warnings from - screenshot ReadPixels, environmental). - -## Remaining risks / follow-ups -- **Undo stack is unbounded** — long sessions with many network loads retain - all previous scenes for undo (CPU+GPU). Disposal now covers the redo side; - a capped undo stack with eviction-time `dispose()` is the natural next step. -- **Bloom on WebGL context loss** is untested; the composer is created once - and never rebuilt. -- **Node hover check still maps `ctx.nodeObjects` to a new array per check** - (once per frame now, not per event); for 10k+ nodes consider a persistent - array or BVH. -- Lighting (`AmbientLight` + two point lights) left untouched to preserve the - established look; a soft key/fill setup could improve depth perception if a - visual refresh is ever wanted. -- The `#info:not(:has(table))` rule assumes the pre-network overlay contains - no table; if that markup changes, revisit. diff --git a/frontend/e2e/themes.spec.ts b/frontend/e2e/themes.spec.ts new file mode 100644 index 0000000..48c2af2 --- /dev/null +++ b/frontend/e2e/themes.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test' + +// Theme bar (v2 port): buttons appear after a network loads; clicking a +// preset recolours the scene; undo restores the previous theme. The canvas +// is opaque to the DOM, so assertions read window.__arena.ctx (applyTheme +// sets ctx.edgeDefaultColor, distinct per theme). + +function edgeColor() { + return () => + (window as unknown as { __arena: { ctx: { edgeDefaultColor: string } } }) + .__arena.ctx.edgeDefaultColor +} + +test('theme buttons attach on load and switch scene theme (undoable)', async ({ + page, +}) => { + await page.goto('/') + await page.waitForFunction( + () => '__arena' in (window as unknown as Record) + ) + + // No buttons before a network exists. + await expect(page.locator('#themeDiv .themeButton')).toHaveCount(0) + + await page.getByRole('tab', { name: 'File' }).click() + await page.getByRole('button', { name: 'Load Example' }).click() + await expect(page.locator('#file_status')).toContainText('Loaded network') + + await expect(page.locator('#themeDiv .themeButton')).toHaveCount(3) + + await page.locator('#lightThemeButton').click() + expect(await page.evaluate(edgeColor())).toBe('#5c5c5c') + // Regression: floor_color input is the picker-path source of truth, so a + // theme toggle must update it — otherwise hover-exit repaint reverts layers + // to the stale (dark) color. + await expect(page.locator('#floor_color')).toHaveValue('#8aa185') + + await page.locator('#grayThemeButton').click() + expect(await page.evaluate(edgeColor())).toBe('#6e2a5a') + + await page.locator('#darkThemeButton').click() + expect(await page.evaluate(edgeColor())).toBe('#ffffff') + + // Undo walks back dark → gray. + await page.locator('#undoButton').click() + expect(await page.evaluate(edgeColor())).toBe('#6e2a5a') +}) diff --git a/frontend/index.html b/frontend/index.html index b193499..03fcdae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -187,8 +187,20 @@
+ + -
Waiting for Network to be uploaded.
+
+
+ No network loaded yet. Open the File tab to upload a network or + load an example. +
diff --git a/frontend/src/actions/nav_controls.ts b/frontend/src/actions/nav_controls.ts index f03f32e..b37ab6e 100644 --- a/frontend/src/actions/nav_controls.ts +++ b/frontend/src/actions/nav_controls.ts @@ -183,24 +183,26 @@ function axisRowsHtml( } const INFO_HTML = ` - 1. Zoom: Mouse Wheel
- 2. Pan: Click Drag Scene / Arrow Keys
- 3. Orbit: Mouse Middle Drag
- 4. Drag Layer: Click Drag
- 5. Rotate Layer: Z / X / C + Click Drag
- 6. Move Selected Nodes: Z / C + Click Drag
- 7. Node/Layer Selection: Double Click
- 8. Lasso Nodes: Shift + Click Drag
- 9. Unselect All Nodes: Double Click Scene + - + ${axisRowsHtml('sliderValue1', 'Angle: 5˚', 'sceneRotateSlider', { min: 1, max: 15, value: 5, step: 1 }, '')} - + ${axisRowsHtml('sliderValue2', 'Angle: 5˚', 'layerRotateSlider', { min: 1, max: 15, value: 5, step: 1 }, '')} - + @@ -214,7 +216,7 @@ const INFO_HTML = ` - + @@ -242,6 +244,35 @@ function holdAction(name: string): (() => void) | null { return map[name] ?? null } +// Real stop/start affordance for the render-pause button (previously a +// static "Stop:Render..." label that never reflected the actual flag). +function setPauseButtonState(paused: boolean): void { + const btn = document.getElementById('interLayerEdgesRenderPauseButton') + if (!btn) return + btn.classList.toggle('is-paused', paused) + btn.innerHTML = paused + ? 'Render Inter-Layer Edges' + : '❚❚Pause Inter-Layer Edges' + btn.setAttribute( + 'aria-label', + paused + ? 'Resume inter-layer edge rendering' + : 'Pause inter-layer edge rendering' + ) +} + +// Up/down chevron reflecting whether #info is currently expanded. +function setControlsToggleState(open: boolean): void { + const btn = document.getElementById('displayCanvasControlsButton') + if (!btn) return + btn.classList.toggle('is-open', open) + btn.innerHTML = `${open ? '▴' : '▾'}Navigation Controls` + btn.setAttribute( + 'aria-label', + open ? 'Hide navigation controls' : 'Show navigation controls' + ) +} + // v2 attached the controls on first network build (canvasControlsAttached // flag); #info keeps its "waiting for network" hint until then. export function registerNavControls(): void { @@ -257,20 +288,25 @@ function attachNavControls(): void { if (!buttonsDiv || !info) return buttonsDiv.innerHTML = ` -
- ` + + ` document .getElementById('interLayerEdgesRenderPauseButton')! - .addEventListener('click', toggleInterLayerEdgesRendering) + .addEventListener('click', () => { + setPauseButtonState(toggleInterLayerEdgesRendering()) + }) document .getElementById('displayCanvasControlsButton')! .addEventListener('click', () => { - info.style.display = - info.style.display === 'none' ? 'inline-block' : 'none' + const open = info.style.display === 'none' + info.style.display = open ? 'inline-block' : 'none' + setControlsToggleState(open) }) info.innerHTML = INFO_HTML info.style.display = 'inline-block' + setPauseButtonState(ctx.interLayerEdgesRenderPauseFlag) + setControlsToggleState(true) document .getElementById('recenterButton')! diff --git a/frontend/src/actions/node.ts b/frontend/src/actions/node.ts index 54aa112..ea2a151 100644 --- a/frontend/src/actions/node.ts +++ b/frontend/src/actions/node.ts @@ -12,16 +12,23 @@ import { findIndexByUuid, getCaseInsensitiveIndices, getRandomArbitrary, + setRandomSeed, } from '../utils' import { raycaster, setRaycaster } from './screen' +// v2 set.seed(123): fixed so every network load scatters to the same layout. +const SCRAMBLE_SEED = 123 + // v2 node.js scrambleNodes — random y/z spread onto each node's layer plane. +// Re-seeds first so the same network always scatters identically (Load Example +// and uploads are reproducible). export function scrambleNodes( yMin = ctx.yBoundMin, yMax = ctx.yBoundMax, zMin = ctx.zBoundMin, zMax = ctx.zBoundMax ): void { + setRandomSeed(SCRAMBLE_SEED) for (const node of ctx.nodeObjects) { node.translateY(getRandomArbitrary(yMin, yMax)) node.translateZ(getRandomArbitrary(zMin, zMax)) diff --git a/frontend/src/actions/themes.ts b/frontend/src/actions/themes.ts index 2084edc..baae11a 100644 --- a/frontend/src/actions/themes.ts +++ b/frontend/src/actions/themes.ts @@ -4,6 +4,8 @@ // /api/config (v2 CHANNEL_COLORS_LIGHT/DARK). import { bus } from '../bus' +import { history } from '../commands/base' +import { ChangeThemeCommand } from '../commands/scene' import { store } from '../store' import { ctx } from '../three' import { assignChannelColorsFromPalette, redrawIntraLayerEdges } from './edge' @@ -61,8 +63,14 @@ export function applyTheme(name: string, fromInit = false): void { if (!fromInit) { // v2 repaintLayersFromPicker: switch layers to picker priority with the - // theme's floor color + // theme's floor color. The floor_color input is the source of truth on the + // picker path (repaintLayers()/Layer.getColor() read it on hover-exit), so + // update it too — otherwise hover-exit reverts to the stale (dark) color. ctx.layerColorPrioritySource = 'picker' + const floorInput = + typeof document !== 'undefined' && + (document.getElementById('floor_color') as HTMLInputElement | null) + if (floorInput) floorInput.value = theme.floor repaintLayers(theme.floor) redrawIntraLayerEdges() ctx.renderInterLayerEdgesFlag = true @@ -75,3 +83,39 @@ export function applyTheme(name: string, fromInit = false): void { export function registerThemeListener(): void { bus.on('theme:changed', ({ theme }) => applyTheme(theme)) } + +// Port of v2 attachThemeButtons: the fixed top-right Light/Dark/Gray bar. +// Attached on first network load (themes only recolour a live scene), routed +// through ChangeThemeCommand so theme switches are undoable. +export function registerThemeButtons(): void { + const off = bus.on('network:loaded', () => { + attachThemeButtons() + off() + }) +} + +let currentTheme = 'dark' // matches the app's default dark chrome + +function attachThemeButtons(): void { + const themeDiv = document.getElementById('themeDiv') + if (!themeDiv || themeDiv.childElementCount > 0) return + for (const name of Object.keys(THEMES)) { + const btn = document.createElement('button') + btn.type = 'button' + btn.id = `${name}ThemeButton` + btn.className = 'themeButton' + btn.textContent = name.charAt(0).toUpperCase() + name.slice(1) + btn.addEventListener('click', () => { + history.execute( + new ChangeThemeCommand( + name, + () => currentTheme, + (t) => { + currentTheme = t + } + ) + ) + }) + themeDiv.appendChild(btn) + } +} diff --git a/frontend/src/event_listeners.ts b/frontend/src/event_listeners.ts index 14c5fdf..fe76e22 100644 --- a/frontend/src/event_listeners.ts +++ b/frontend/src/event_listeners.ts @@ -5,6 +5,7 @@ // sceneZoom, keyPressed, right-click menu) live in canvas_controls — deferred // until that action lands. Only the window-level listeners are wired here. +import { Collapse } from 'bootstrap' import { bus } from './bus' import { history } from './commands/base' import { resetScreen } from './actions/screen' @@ -13,6 +14,21 @@ export function registerGlobalListeners(): void { window.addEventListener('resize', resetScreen) window.addEventListener('keydown', handleUndoRedo) wireHistoryButtons() + wireMobileNavCollapse() +} + +// On small screens the navbar is a collapsed menu; picking a tab should close +// it so the chosen panel/scene isn't left buried under the open menu. +function wireMobileNavCollapse(): void { + const menu = document.getElementById('navbarTabs') + if (!menu) return + for (const link of menu.querySelectorAll('.navbar-nav .nav-link')) { + link.addEventListener('click', () => { + if (menu.classList.contains('show')) { + Collapse.getOrCreateInstance(menu).hide() + } + }) + } } function wireHistoryButtons(): void { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ac4e1bf..828e961 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -17,6 +17,7 @@ import { initEdgePanel } from './ui/edge' import { initFpsPanel } from './ui/fps' import { initDataPanel } from './ui/data' import { initHelpPanel } from './ui/help' +import { registerDrawerToggle } from './ui/drawer_toggle' import { store } from './store' import { ctx, Scene } from './three' import { history } from './commands/base' @@ -26,7 +27,7 @@ import { registerLayerDragControls } from './actions/drag_controls' import { registerLabelRendering } from './actions/labels' import { registerNavControls } from './actions/nav_controls' import { setRenderer, resetScreen, animate } from './actions/screen' -import { registerThemeListener } from './actions/themes' +import { registerThemeButtons, registerThemeListener } from './actions/themes' import { registerGlobalListeners } from './event_listeners' async function main(): Promise { @@ -63,8 +64,10 @@ async function main(): Promise { initFpsPanel() initDataPanel() initHelpPanel() + registerDrawerToggle() registerGlobalListeners() registerThemeListener() + registerThemeButtons() registerLabelRendering() registerCanvasControls() registerLayerDragControls() diff --git a/frontend/src/style.css b/frontend/src/style.css index c8d1b68..99940b5 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -3,29 +3,168 @@ with their panels. */ /* Design tokens shared by the 2D chrome so it reads as one system with the - dark canvas. */ + dark canvas. One cool "instrument" palette; a single blue accent. */ :root { - --arena-accent: #4f8ff7; - --arena-surface: rgba(20, 24, 31, 0.82); - --arena-border: rgba(255, 255, 255, 0.1); + --arena-accent: #5b9bff; + --arena-accent-hover: #7db0ff; + --arena-accent-rgb: 91, 155, 255; + --arena-bg: #0a0d13; + --arena-surface: rgba(18, 23, 32, 0.86); + --arena-surface-solid: #12171f; + --arena-elevated: rgba(28, 34, 45, 0.92); + --arena-border: rgba(255, 255, 255, 0.09); + --arena-border-strong: rgba(255, 255, 255, 0.16); + --arena-text: #e2e8f2; + --arena-text-muted: #94a1b5; + --arena-radius: 10px; + --arena-radius-sm: 7px; + --arena-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + --arena-ease: cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Retint Bootstrap's dark theme so every .btn / .form-control / .nav / .table + inherits the palette instead of being restyled one component at a time. */ +[data-bs-theme='dark'] { + --bs-body-bg: #0a0d13; + --bs-body-color: #e2e8f2; + --bs-secondary-color: #94a1b5; + --bs-tertiary-bg: rgba(18, 23, 32, 0.86); + --bs-border-color: rgba(255, 255, 255, 0.12); + --bs-primary: #5b9bff; + --bs-primary-rgb: 91, 155, 255; + --bs-link-color: #7db0ff; + --bs-link-color-rgb: 125, 176, 255; + --bs-link-hover-color: #a3c6ff; + --bs-body-font-family: + system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; } body { - font-family: - system-ui, - -apple-system, - 'Segoe UI', - Roboto, - 'Helvetica Neue', - sans-serif; + font-family: var(--bs-body-font-family); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } /* Frosted navbar so the canvas glows through without hurting legibility. */ #navBar { background-color: var(--arena-surface) !important; - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); border-bottom: 1px solid var(--arena-border); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.4); + min-height: 56px; +} + +#navBar .navbar-nav .nav-link { + font-size: 0.9rem; + font-weight: 500; + color: var(--arena-text-muted); + padding: 0.4rem 0.72rem; + border-radius: var(--arena-radius-sm); + transition: + color 0.15s var(--arena-ease), + background-color 0.15s var(--arena-ease); +} + +#navBar .navbar-nav .nav-link:hover { + color: var(--arena-text); + background-color: rgba(255, 255, 255, 0.05); +} + +#navBar .navbar-nav .nav-link.active { + color: #fff; + background-color: rgba(var(--arena-accent-rgb), 0.16); + box-shadow: inset 0 0 0 1px rgba(var(--arena-accent-rgb), 0.4); +} + +/* Undo / redo group */ +#navBar .btn-outline-secondary { + --bs-btn-color: var(--arena-text-muted); + --bs-btn-border-color: var(--arena-border-strong); + --bs-btn-hover-bg: rgba(255, 255, 255, 0.06); + --bs-btn-hover-border-color: var(--arena-accent); + --bs-btn-hover-color: var(--arena-text); + font-weight: 500; +} + +/* Shared component polish -------------------------------------------------- */ +.btn { + --bs-btn-font-weight: 500; + border-radius: var(--arena-radius-sm); + transition: + transform 0.12s var(--arena-ease), + filter 0.15s var(--arena-ease), + background-color 0.15s var(--arena-ease), + box-shadow 0.15s var(--arena-ease); +} + +.btn-primary { + --bs-btn-bg: var(--arena-accent); + --bs-btn-border-color: var(--arena-accent); + --bs-btn-hover-bg: var(--arena-accent-hover); + --bs-btn-hover-border-color: var(--arena-accent-hover); + --bs-btn-active-bg: #4a88ec; + --bs-btn-color: #06101f; + --bs-btn-hover-color: #06101f; + box-shadow: 0 4px 14px rgba(var(--arena-accent-rgb), 0.28); +} + +.btn:not(:disabled):hover { + transform: translateY(-1px); +} + +.btn:not(:disabled):active { + transform: translateY(0) scale(0.985); +} + +.form-control, +.form-select { + background-color: rgba(255, 255, 255, 0.03); + border-color: var(--arena-border-strong); + border-radius: var(--arena-radius-sm); + color: var(--arena-text); + transition: + border-color 0.15s var(--arena-ease), + box-shadow 0.15s var(--arena-ease); +} + +.form-control:focus, +.form-select:focus { + background-color: rgba(255, 255, 255, 0.04); + border-color: var(--arena-accent); + box-shadow: 0 0 0 3px rgba(var(--arena-accent-rgb), 0.22); +} + +.form-control::file-selector-button { + background-color: rgba(var(--arena-accent-rgb), 0.14); + color: var(--arena-text); + border: 0; + border-right: 1px solid var(--arena-border); +} + +.form-label { + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--arena-text-muted); + margin-bottom: 0.35rem; +} + +.form-check-input:checked { + background-color: var(--arena-accent); + border-color: var(--arena-accent); +} + +.form-check-input:focus { + border-color: var(--arena-accent); + box-shadow: 0 0 0 3px rgba(var(--arena-accent-rgb), 0.22); +} + +.form-range::-webkit-slider-thumb { + background: var(--arena-accent); +} +.form-range::-moz-range-thumb { + background: var(--arena-accent); } :focus-visible { @@ -33,26 +172,165 @@ body { outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + #app { position: fixed; inset: 0; z-index: 0; } -/* Panels overlay the canvas below the fixed navbar. */ +/* Action panels open as a right-side drawer so the 3D scene stays visible; + only Home and Help take the full view (they are reading, not operating on + the scene). Main View's pane is display:none, so the canvas shows through. */ #panelContent .tab-pane { position: fixed; top: 56px; /* navbar height */ - left: 0; right: 0; bottom: 0; + width: min(440px, 94vw); overflow-y: auto; - /* Above the #info / nav-control overlays (z-index 2) so an open panel - covers them; Main View's pane is display:none, so the canvas + controls - stay visible there. */ - z-index: 5; + overscroll-behavior: contain; + z-index: 5; /* above the #info / nav-control overlays (z-index 2) */ + background-color: var(--arena-surface); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + border-left: 1px solid var(--arena-border); + box-shadow: var(--arena-shadow); + /* extra bottom padding clears the fixed footer bar */ + padding: 1.4rem 1.35rem calc(3rem + env(safe-area-inset-bottom, 0px)); +} + +/* Slide the drawer in when it becomes the active pane. */ +#panelContent .tab-pane.show { + animation: drawer-in 0.28s var(--arena-ease); +} + +@keyframes drawer-in { + from { + transform: translateX(16px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Collapse handle for the right-side drawer: sits on the seam between the + open drawer and the canvas, or on the viewport edge once the drawer is + closed, so a panel can be dismissed/reopened without the navbar tabs. */ +.panel-drawer-toggle { + position: fixed; + top: 50%; + right: 0; + transform: translateY(-50%); + z-index: 6; /* above the drawer's z-index 5 */ + width: 20px; + height: 64px; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--arena-text-muted); + background-color: var(--arena-surface); + border: 1px solid var(--arena-border); + border-right: 0; + border-radius: var(--arena-radius-sm) 0 0 var(--arena-radius-sm); + box-shadow: var(--arena-shadow); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; + transition: + right 0.28s var(--arena-ease), + color 0.15s var(--arena-ease), + background-color 0.15s var(--arena-ease); +} + +.panel-drawer-toggle:hover { + color: var(--arena-text); + background-color: var(--arena-elevated); +} + +.panel-drawer-toggle.is-open { + color: var(--arena-accent); +} + +/* Home and Help are full-width reading surfaces, not scene controls. + Selector carries two ids to out-specify the base .tab-pane drawer rule. */ +#panelContent #panel-home, +#panelContent #panel-help { + left: 0; + width: auto; background-color: var(--bs-body-bg); - padding: 1rem; + backdrop-filter: none; + -webkit-backdrop-filter: none; + border-left: 0; + box-shadow: none; + padding: 1.6rem 2rem; +} + +/* Wide data tables get a roomier drawer. */ +#panel-data { + width: min(680px, 96vw); +} + +/* Inside a drawer the old responsive column wrappers would shrink content to a + fraction of the (already narrow) drawer — let panel content use full width. */ +#panelContent .tab-pane:not(#panel-home):not(#panel-help) [class*='col-'] { + flex: 0 0 100%; + max-width: 100%; + width: 100%; + padding-left: 0; + padding-right: 0; +} + +/* Per-layer selection rows: name on the left, Hide/Labels toggles on the right, + never wrapping into an unreadable pile in the narrow drawer. */ +.layer-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.55rem; + margin-bottom: 0.3rem; + border: 1px solid var(--arena-border); + border-radius: var(--arena-radius-sm); + background: rgba(255, 255, 255, 0.02); + transition: border-color 0.15s var(--arena-ease); +} + +.layer-row:hover { + border-color: var(--arena-border-strong); +} + +.layer-row__name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + margin: 0; +} + +.layer-row__opt { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex: 0 0 auto; + font-size: 0.8rem; + color: var(--arena-text-muted); + margin: 0; + cursor: pointer; +} + +.layer-row__opt .form-check-input { + margin: 0; } /* Main View has no panel content: keep it invisible so the canvas shows. */ @@ -67,17 +345,17 @@ body { z-index: 2; font-size: 12px; color: var(--bs-body-color); - pointer-events: none; /* passive hint text — must not block panel clicks */ } /* Nav control buttons + Scene/Layers/Nodes control table (port of v2 arena3dweb.css canvas-control rules; positions adapted to the fixed - navbar). */ + navbar). Higher z-index than #info so the toggle/pause buttons are never + clipped by the controls panel below them, regardless of exact heights. */ #navControlButtonsDiv { position: fixed; top: 64px; left: 10px; - z-index: 2; + z-index: 3; } .displayCanvasControls { @@ -97,6 +375,71 @@ body { border-color: var(--arena-accent); } +/* Icon + label toggle buttons (render-pause, controls disclosure): the icon + swaps per state instead of the label alone carrying the meaning. */ +.nav-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; +} + +.nav-toggle-icon { + color: var(--arena-accent); + font-size: 10px; + line-height: 1; +} + +/* Paused = an actual stopped state worth calling out, not just a hover tint. */ +.nav-toggle-btn.is-paused { + border-color: rgba(var(--arena-accent-rgb), 0.5); + background-color: rgba(var(--arena-accent-rgb), 0.1); +} + +.nav-toggle-btn.is-paused .nav-toggle-icon { + color: #ffb454; +} + +/* Theme bar (port of v2 #themeDiv/.themeButton): fixed top-right, one button + per THEMES preset, each previewing its own scene colors. */ +#themeDiv { + position: fixed; + top: 64px; + right: 10px; + z-index: 3; + display: flex; + gap: 4px; +} + +.themeButton { + width: 56px; + padding: 3px 0; + font-size: 12px; + font-weight: bold; + border: 1px solid var(--arena-border); + border-radius: 6px; + transition: border-color 0.15s ease; +} + +.themeButton:hover { + border-color: var(--arena-accent); +} + +#lightThemeButton { + color: #5c5c5c; + background-color: #ffffff; +} + +#darkThemeButton { + color: #ffffff; + background-color: #000000; +} + +#grayThemeButton { + color: #6e2a5a; + background-color: #bababa; +} + #info { width: 258px; top: 150px; /* below the nav control buttons */ @@ -110,32 +453,139 @@ body { -webkit-backdrop-filter: blur(8px); } -/* The overlay hides while empty (before a network is loaded it only shows - the waiting hint, which should stay a quiet unboxed line). */ +/* Before a network is loaded, #info holds only the waiting hint: present it as + a centered empty-state card over the canvas instead of a top-left line. */ #info:not(:has(table)) { - background: none; - border: none; - backdrop-filter: none; - -webkit-backdrop-filter: none; + top: 50%; + left: 50%; + right: auto; + transform: translate(-50%, -50%); + width: auto; + max-width: 340px; + max-height: none; + text-align: center; + font-size: 14px; + line-height: 1.6; + color: var(--arena-text-muted); + padding: 1.5rem 1.75rem; + border-radius: var(--arena-radius); } -/* #info itself stays click-through (hint text); the controls inside accept - clicks. */ -#info table, -#info button { - pointer-events: auto; +#info:not(:has(table))::before { + content: '◇'; + display: block; + font-size: 1.6rem; + color: var(--arena-accent); + margin-bottom: 0.5rem; +} + +/* Before a network loads, #info is just passive hint text and must not block + canvas interaction. Once it holds the controls table it becomes a real + panel — including its own scrollbar — so it needs normal pointer events. */ +#info:not(:has(table)) { + pointer-events: none; +} + +/* Navigation shortcut cheat-sheet: action left, keys right. */ +#navShortcuts { + list-style: none; + margin: 0; + padding: 0; +} + +#navShortcuts li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 3px 0; + color: var(--arena-text-muted); +} + +#navShortcuts li span:last-child { + white-space: nowrap; + text-align: right; +} + +#navShortcuts li + li { + border-top: 1px solid var(--arena-border); +} + +#navShortcuts kbd { + display: inline-block; + padding: 1px 5px; + font-size: 10px; + line-height: 1.5; + color: var(--arena-text); + background: var(--arena-elevated); + border: 1px solid var(--arena-border-strong); + border-bottom-width: 2px; + border-radius: 4px; +} + +#navShortcuts kbd + kbd { + margin-left: 3px; } #canvasControls_table { color: var(--bs-body-color); + width: 100%; + margin-top: 0.5rem; +} + +#canvasControls_table h5 { + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--arena-text); + margin: 0.35rem 0; +} + +#canvasControls_table #recenterButton { + width: 100%; + margin: 0.25rem 0 0.5rem; + padding: 0.35rem 0.6rem; + font-size: 0.82rem; + color: var(--arena-text); + background: rgba(var(--arena-accent-rgb), 0.14); + border: 1px solid rgba(var(--arena-accent-rgb), 0.4); + border-radius: var(--arena-radius-sm); + transition: + background-color 0.15s var(--arena-ease), + border-color 0.15s var(--arena-ease); +} + +#canvasControls_table #recenterButton:hover { + background: rgba(var(--arena-accent-rgb), 0.24); + border-color: var(--arena-accent); } .border_tr { - border-top: 10px solid lightgray; + border-top: 1px solid var(--arena-border-strong); +} + +.border_tr td { + padding-top: 0.5rem; +} + +.controlsSubLabel { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--arena-text-muted); + padding: 0.1rem 0 0.3rem; +} + +.canvasSlider { + width: 100%; + accent-color: var(--arena-accent); + cursor: pointer; } .labelDrop { vertical-align: bottom; + color: var(--arena-text-muted); + font-variant-numeric: tabular-nums; } .raiseSlider { @@ -163,6 +613,16 @@ body { width: 40px; height: 40px; cursor: pointer; + border-radius: var(--arena-radius-sm); + transition: background-color 0.15s var(--arena-ease); +} + +.canvasControls:hover { + background-color: rgba(var(--arena-accent-rgb), 0.12); +} + +.canvasControls:active { + background-color: rgba(var(--arena-accent-rgb), 0.22); } .image_expandLayers, @@ -331,19 +791,31 @@ body { #closeButton { position: absolute; - right: 0; - background-color: red; - border: 1px solid gray; - border-radius: 5px; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--arena-text-muted); + background-color: rgba(255, 255, 255, 0.06); + border: 1px solid var(--arena-border); + border-radius: var(--arena-radius-sm); + transition: + color 0.15s var(--arena-ease), + background-color 0.15s var(--arena-ease), + border-color 0.15s var(--arena-ease); } #closeButton:hover { - background-color: #ff756b; - border: 1px solid white; + color: #fff; + background-color: rgba(220, 70, 60, 0.85); + border-color: rgba(220, 70, 60, 0.9); } #closeButton:active { - background-color: #ab150a; + background-color: rgba(180, 40, 32, 0.9); } /* Footer — quiet translucent bar instead of v2's solid blue banner. */ @@ -375,60 +847,217 @@ footer a:hover { #logo1 { background-image: url(/images/logo.png); background-repeat: no-repeat; - width: 200px; - height: 48px; - margin-bottom: 10px; + background-size: contain; + width: 220px; + height: 52px; } -/* Help panel — inner tab bar + content (port of v2 arena3dweb.css help rules). */ -#helpDiv .tab { +/* Home panel ---------------------------------------------------------------- */ +.home-wrap { + max-width: 1080px; + margin: 0 auto; + padding-bottom: 4rem; +} + +.home-hero { + padding: 0.5rem 0 1.75rem; + border-bottom: 1px solid var(--arena-border); + margin-bottom: 2rem; +} + +.home-tagline { + margin: 0.75rem 0 0; + font-size: 1.05rem; + color: var(--arena-text-muted); + letter-spacing: 0.01em; +} + +.home-grid { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + gap: 2.5rem; + align-items: start; +} + +.home-figure { + margin: 0; + border: 1px solid var(--arena-border); + border-radius: var(--arena-radius); overflow: hidden; - background-color: #f1f1f1; + background: rgba(255, 255, 255, 0.02); +} + +.home-figure img { + display: block; width: 100%; } +.home-intro p { + font-size: 1rem; + line-height: 1.7; + color: var(--arena-text); + max-width: 62ch; +} + +.home-actions { + display: grid; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.home-action { + display: block; + padding: 0.85rem 1.05rem; + border: 1px solid var(--arena-border-strong); + border-radius: var(--arena-radius); + text-decoration: none; + background: rgba(255, 255, 255, 0.02); + transition: + transform 0.15s var(--arena-ease), + border-color 0.15s var(--arena-ease), + background-color 0.15s var(--arena-ease); +} + +.home-action:hover { + transform: translateY(-2px); + border-color: var(--arena-accent); + background: rgba(var(--arena-accent-rgb), 0.08); +} + +.home-action--primary { + border-color: rgba(var(--arena-accent-rgb), 0.55); + background: rgba(var(--arena-accent-rgb), 0.12); +} + +.home-action__title { + display: block; + font-weight: 600; + color: var(--arena-text); +} + +.home-action__sub { + display: block; + font-size: 0.85rem; + color: var(--arena-text-muted); + margin-top: 0.15rem; +} + +.home-cite { + margin-top: 2.75rem; + padding: 1.5rem 1.6rem; + border: 1px solid var(--arena-border); + border-radius: var(--arena-radius); + background: rgba(255, 255, 255, 0.015); +} + +.home-cite__heading { + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--arena-text-muted); + margin: 0 0 1rem; +} + +.home-cite p { + font-size: 0.9rem; + line-height: 1.6; + color: var(--arena-text-muted); + margin-bottom: 1rem; +} + +.home-cite p:last-child { + margin-bottom: 0; +} + +@media (max-width: 768px) { + #panel-home { + padding: 1.25rem 1rem; + } + .home-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } +} + +/* Help panel — inner tab bar + content, themed to match the dark chrome. */ +#helpDiv { + max-width: 1080px; + margin: 0 auto; +} + +#helpDiv .tab { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + width: 100%; + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--arena-border); +} + #helpDiv .tab button { - background-color: inherit; - float: left; - border: none; + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid var(--arena-border); + border-radius: 999px; outline: none; cursor: pointer; - padding: 14px 16px; - transition: 0.3s; - font-size: 17px; - color: #000; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + font-weight: 500; + color: var(--arena-text-muted); + transition: + color 0.15s var(--arena-ease), + background-color 0.15s var(--arena-ease), + border-color 0.15s var(--arena-ease); } #helpDiv .tab button:hover { - background-color: #ddd; + color: var(--arena-text); + border-color: var(--arena-border-strong); + background-color: rgba(255, 255, 255, 0.06); } #helpDiv .tab button.active { - background-color: #ccc; + color: #06101f; + background-color: var(--arena-accent); + border-color: var(--arena-accent); } #helpDiv .tabcontent { display: none; - padding: 1%; width: 100%; + max-width: 78ch; + line-height: 1.65; word-break: break-word; - text-align: justify; +} + +#helpDiv .tabcontent h2, +#helpDiv .tabcontent h3 { + font-weight: 600; + letter-spacing: -0.01em; + margin-top: 1.75rem; } #helpDiv .tabcontent img { max-width: 100%; + border: 1px solid var(--arena-border); + border-radius: var(--arena-radius-sm); } #helpDiv pre { overflow-x: auto; - padding: 8px; - background-color: rgba(128, 128, 128, 0.15); + padding: 0.85rem 1rem; + border: 1px solid var(--arena-border); + border-radius: var(--arena-radius-sm); + background-color: rgba(255, 255, 255, 0.04); + color: var(--arena-text); } #helpDiv .numbering { - color: red; - font-weight: bold; - font-size: 18px; + color: var(--arena-accent); + font-weight: 700; + font-size: 1.05rem; } #helpDiv .indent { diff --git a/frontend/src/ui/drawer_toggle.ts b/frontend/src/ui/drawer_toggle.ts new file mode 100644 index 0000000..f4ad662 --- /dev/null +++ b/frontend/src/ui/drawer_toggle.ts @@ -0,0 +1,66 @@ +// Collapse handle for the right-side action-panel drawer (File/Layouts/ +// Scene/.../Data — everything except Home, Main View and Help, which aren't +// canvas-overlay drawers). Lets a drawer be dismissed/reopened without going +// back through the navbar tabs. + +import { showTab } from './tabs' + +const NON_DRAWER_IDS = new Set(['panel-home', 'panel-main-view', 'panel-help']) + +let lastDrawerSelector: string | null = null + +// Bootstrap fires shown.bs.tab as soon as the (unanimated) nav button itself +// finishes activating — before the target pane's own fade transition adds its +// 'show' class. Querying '.tab-pane.show' at that moment can still match the +// pane being deactivated, so read the pane id straight from the event's +// tab-trigger target instead of asking the DOM which pane is "showing". +function activePaneId(): string | null { + return document.querySelector('#panelContent .tab-pane.active')?.id ?? null +} + +function isDrawerPane(id: string | null): boolean { + return !!id && !NON_DRAWER_IDS.has(id) +} + +function updateToggle(paneId?: string | null): void { + const btn = document.getElementById('panelDrawerToggle') + if (!btn) return + const id = paneId ?? activePaneId() + + if (id === 'panel-home' || id === 'panel-help') { + // Full-width reading panes, not a drawer to collapse. + btn.style.display = 'none' + return + } + + const open = isDrawerPane(id) + if (open) lastDrawerSelector = `#${id}` + + btn.style.display = 'flex' + btn.classList.toggle('is-open', open) + btn.innerHTML = open ? '›' : '‹' + btn.setAttribute('aria-label', open ? 'Hide panel' : 'Show panel') + btn.style.right = + open && id + ? `${document.getElementById(id)!.getBoundingClientRect().width}px` + : '0' +} + +export function registerDrawerToggle(): void { + const btn = document.getElementById('panelDrawerToggle') + if (!btn) return + + btn.addEventListener('click', () => { + if (isDrawerPane(activePaneId())) showTab('#panel-main-view') + else if (lastDrawerSelector) showTab(lastDrawerSelector) + }) + + document.addEventListener('shown.bs.tab', (e) => { + const paneSelector = (e.target as HTMLElement).getAttribute( + 'data-bs-target' + ) + updateToggle(paneSelector?.replace('#', '')) + }) + window.addEventListener('resize', () => updateToggle()) + updateToggle() +} diff --git a/frontend/src/ui/file.ts b/frontend/src/ui/file.ts index 6f9477b..a420fef 100644 --- a/frontend/src/ui/file.ts +++ b/frontend/src/ui/file.ts @@ -6,6 +6,7 @@ import { loadNetwork, loadSession } from '../actions/network' import { applyNodeAttributes } from '../actions/node' import { startLoader, finishLoader } from '../actions/screen' import { exportSession } from '../actions/session' +import { showTab } from './tabs' const FILE_HTML = `
@@ -46,6 +47,7 @@ async function onUploadNetwork(file: File): Promise { status( `Loaded network: ${data.layers.length} layers, ${data.nodes.length} nodes.` ) + showTab('#panel-main-view') // reveal the scene; drawer toggle shows closed } catch (err) { status(err instanceof Error ? err.message : 'Network upload failed.', true) } finally { @@ -59,6 +61,7 @@ async function onLoadSession(file: File): Promise { const session = await api.importSession(file) loadSession(session) status('Session loaded.') + showTab('#panel-main-view') // reveal the scene; drawer toggle shows closed } catch (err) { status(err instanceof Error ? err.message : 'Session load failed.', true) } finally { diff --git a/frontend/src/ui/home.ts b/frontend/src/ui/home.ts index 660d3c6..0061bee 100644 --- a/frontend/src/ui/home.ts +++ b/frontend/src/ui/home.ts @@ -3,45 +3,60 @@ import { showTab } from './tabs' const HOME_HTML = ` -
-
-
- Main View +
+
+
+

Interactive 3D visualization of multilayered networks

+
+ +
+
+ A multilayered network rendered across stacked 3D layers +
+
+

+ Arena3Dweb is the first fully interactive, dependency-free web application for + visualizing multi-layered graphs in 3D space. Integrate multiple networks in a single view + with their intra- and inter-layer connections, apply a wide range of layout algorithms to + selected layers individually or in combination, and highlight topological features. Layers + and the whole scene can be translated, rotated, and scaled, while node position, color, and + size adjust on-the-fly. The current version supports weighted and unweighted undirected graphs. +

+ + +
-
-

- Arena3Dweb is the first, fully interactive and dependency-free, web application which allows the - visualization of multi-layered graphs in 3D space. With Arena3Dweb, users can integrate multiple networks - in a single view along with their intra- and inter-layer connections. For clearer and more informative views, users - can choose between a plethora of layout algorithms and apply them on a set of selected layers either individually or - in combination. Users can align networks and highlight node topological features, whereas each layer as well as the - whole scene can be translated, rotated and scaled in 3D space. User-selected edge colors can be used to highlight - important paths, while node positioning, coloring and resizing can be adjusted on-the-fly. In its current version, - Arena3Dweb supports weighted and unweighted undirected graphs. -

- → To start using Arena3Dweb click on the File menu tab and upload a network. -

- → Visit the Help/Examples page to download some Arena3Dweb example files. -

- → Get started by uploading your networks here. -

+ +
+

Please cite Arena3Dweb

- Please acknowledge the use of Arena3Dweb by citing the following publications:

- - Kokoli, M., Karatzas, E., Baltoumas, F.A., Schneider, R., Pafilis, E., Paragkamian, S., Doncheva, N.T., Jensen, L.J. - and Pavlopoulos, G., 2022.
+ Kokoli, M., Karatzas, E., Baltoumas, F.A., Schneider, R., Pafilis, E., Paragkamian, S., Doncheva, N.T., Jensen, L.J. + and Pavlopoulos, G., 2022. Arena3Dweb: interactive 3D visualization of multilayered networks - supporting multiple directional information channels, clustering analysis and application integration,
- NAR Genomics and Bioinformatics, 5(2), p.lqad053.
- doi: https://doi.org/10.1093/nargab/lqad053; + supporting multiple directional information channels, clustering analysis and application integration. + NAR Genomics and Bioinformatics, 5(2), p.lqad053.
+ doi: 10.1093/nargab/lqad053 +  ·  PubMed: 37260509 -

- - Karatzas, E., Baltoumas, F.A., Panayiotou, N.A., Schneider, R. and Pavlopoulos, G.A., 2021.
- Arena3Dweb: Interactive 3D visualization of multilayered networks,
- Nucleic Acids Research, 49(W1), pp.W36-W45.
- doi: https://doi.org/10.1093/nar/gkab278; +

+

+ Karatzas, E., Baltoumas, F.A., Panayiotou, N.A., Schneider, R. and Pavlopoulos, G.A., 2021. + Arena3Dweb: Interactive 3D visualization of multilayered networks. + Nucleic Acids Research, 49(W1), pp.W36-W45.
+ doi: 10.1093/nar/gkab278 +  ·  PubMed: 33885790

-
+
` diff --git a/frontend/src/ui/layouts.ts b/frontend/src/ui/layouts.ts index a9aa9c7..f9f8177 100644 --- a/frontend/src/ui/layouts.ts +++ b/frontend/src/ui/layouts.ts @@ -102,14 +102,14 @@ function buildLayerCheckboxes(): void { container.innerHTML = '' ctx.layers.forEach((layer, i) => { const row = document.createElement('div') - row.className = 'form-check-inline' + row.className = 'layer-row' row.innerHTML = ` - - - - - + + + ` const [sel, hide, labels] = row.querySelectorAll('input') sel.addEventListener('change', () => selectLayer(i, sel.checked)) diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 2a73475..723e79c 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -27,9 +27,26 @@ export function findIndexByUuid( return array.findIndex((object) => object.uuid === uuid) } -// Random float in [min, max). +// Seeded PRNG (mulberry32) so a given network always scatters to the same +// layout — Load Example and uploads are reproducible instead of random each +// time. Call setRandomSeed() to restart the sequence. Mirrors the backend's +// random.seed(123) for layout algorithms. +let rngState = 123 >>> 0 + +export function setRandomSeed(seed: number): void { + rngState = seed >>> 0 +} + +function seededRandom(): number { + rngState = (rngState + 0x6d2b79f5) | 0 + let t = Math.imul(rngState ^ (rngState >>> 15), 1 | rngState) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 +} + +// Random float in [min, max) from the seeded sequence. export function getRandomArbitrary(min: number, max: number): number { - return Math.random() * (max - min) + min + return seededRandom() * (max - min) + min } export function toRadians(angle: number): number {
Scene
Rotation Controls
Rotation Controls
Layers
Rotation Controls
Rotation Controls
Translation Controls
Translation Controls
x1
Nodes
Translation Controls
Translation Controls