diff --git a/.github/actions/install-deps/action.yaml b/.github/actions/install-deps/action.yaml index e3ebe0c328..d900edb255 100644 --- a/.github/actions/install-deps/action.yaml +++ b/.github/actions/install-deps/action.yaml @@ -64,6 +64,17 @@ runs: with: cmake-version: "3.29.6" + - name: Setup MSVC environment (Windows) + if: ${{ runner.os == 'Windows' }} + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: ${{ inputs.arch == 'aarch64' && 'amd64_arm64' || 'x64' }} + + - name: Force Ninja generator (Windows) + if: ${{ runner.os == 'Windows' }} + shell: bash + run: echo "CMAKE_GENERATOR=Ninja" >> "$GITHUB_ENV" + - name: Install pnpm uses: pnpm/action-setup@v3 with: diff --git a/Cargo.lock b/Cargo.lock index fd9c14910b..fca64e66e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,9 +496,9 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -2451,7 +2451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.4.1", + "heck 0.5.0", "itertools 0.12.1", "log", "multimap", @@ -4016,7 +4016,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/package.json b/package.json index 994857359e..b255dbb4bf 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "llvm": "17.0.6", "pyodide": "0.29.3", "engines": { - "node": ">=16 <23" + "node": ">=16 <24" }, "workspaces": [ "tools/test", diff --git a/packages/viewer-charts/src/ts/config.ts b/packages/viewer-charts/src/ts/config.ts index bc73ed736a..8b6b2f0ee3 100644 --- a/packages/viewer-charts/src/ts/config.ts +++ b/packages/viewer-charts/src/ts/config.ts @@ -33,7 +33,27 @@ export const RUNTIME_MODE: "worker" | "inprocess" = "worker"; * `ImageBitmap` over the control channel; the host blits the bitmap * into the visible canvas via `drawImage`. */ -export const RENDER_BLIT_MODE: "direct" | "blit" = "direct"; +export const RENDER_BLIT_MODE: "direct" | "blit" = "blit"; + +/** + * Number of shared WebGL contexts in pooled blit mode. + * + * The browser caps live contexts per agent (~16 in Chromium) and + * force-loses the oldest past that cap, so a page with more charts than + * the cap cannot give each its own context. When `> 0` *and* + * `RENDER_BLIT_MODE === "blit"`, every chart borrows one of this many + * shared contexts (round-robin, sticky for the chart's lifetime) instead + * of allocating its own — decoupling live-context count from chart + * count. N charts render through K = this many contexts; the scheduler + * serializes renders that land on the same context. + * + * `0` disables pooling (every chart gets its own context — the original + * behavior). Pooling never applies to `"direct"` mode, which renders + * into the host's transferred visible canvas and is permanently 1:1 with + * a context; keep pages that need more than ~16 simultaneous charts on + * `"blit"`. + */ +export const RENDER_CONTEXT_POOL_SIZE: number = 4; /** * Strict-mode validation for `BufferPool.upload`. diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index 81d30ca74e..815cb918ad 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -125,6 +125,22 @@ const GLOBAL_STYLES = (() => { return [sheet]; })(); +/** + * Process-global GL presentation strategy, shared by *every* chart-type + * plugin in this renderer. Seeded from the build-time {@link RENDER_BLIT_MODE} + * and overridable at runtime via + * {@link HTMLPerspectiveViewerWebGLPluginElement.setBlitMode}. + * + * Module scope (not per-instance) on purpose: blit-vs-direct is a + * whole-renderer decision — in `"blit"` mode the worker shares a pool of + * GL contexts across all charts ([webgl/context-pool.ts]), so a page + * can't sensibly mix strategies per chart. Every per-chart-type subclass + * registered in [index.ts] reads this one value when it builds its + * renderer, so setting it once (before the first chart renders) applies + * to all of them. + */ +let BLIT_MODE: "direct" | "blit" = RENDER_BLIT_MODE; + export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement implements IPerspectiveViewerPlugin @@ -139,7 +155,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement private _rendererPromise: Promise | null = null; private _rawEventForwarder: RawEventForwarder | null = null; private _generation = 0; - private _renderBlitMode: "direct" | "blit" = RENDER_BLIT_MODE; private _resetClickAbort: AbortController | null = null; /** @@ -257,13 +272,41 @@ export class HTMLPerspectiveViewerWebGLPluginElement return this._rendererPromise; } - this._rendererPromise = this._buildRenderer(view).then((r) => { - this._renderer = r; - this._setupInteraction(r); - return r; - }); + // `_buildRenderer` is async — it awaits `getClient`, `getTable`, + // and a full worker handshake — so a `disconnectedCallback` + // (toggle / chart-type switch) frequently lands *before* this + // `.then` runs. At that point `this._renderer` is still null, + // so `delete()`'s `if (this._renderer)` teardown is skipped and + // the freshly-built transport (and its WebGL context) would be + // assigned to a detached element and leak — one context per + // raced toggle, until the browser evicts the oldest. + // + // `delete()` sets `_rendererPromise = null`, so promise identity + // is the dispose signal: if `this._rendererPromise` no longer + // points at *this* build when it resolves, the element was + // deleted (or a reconnect started a newer build) and we destroy + // the orphan instead of adopting it. Promise identity — not + // `_generation`, which every `draw`/`update` bumps — is the + // right token: a rapid draw→update must NOT tear down the + // single in-flight build it shares via this memoized promise. + const p: Promise = this._buildRenderer(view).then( + (r) => { + if (this._rendererPromise !== p) { + r.destroy(); + throw new Error("renderer disposed during init"); + } + + this._renderer = r; + this._setupInteraction(r); + return r; + }, + ); - return this._rendererPromise; + // Swallow the dispose rejection so it doesn't surface as an + // unhandled rejection; `_drawImpl` catches it and bails. + p.catch(() => {}); + this._rendererPromise = p; + return p; } /** @@ -350,15 +393,28 @@ export class HTMLPerspectiveViewerWebGLPluginElement }, pluginConfig: this._pluginConfig, defaultChartType: this._chartType.default_chart_type, - renderBlitMode: this._renderBlitMode, + renderBlitMode: BLIT_MODE, }); return transport; } - setBlitMode(mode: "direct" | "blit") { - console.assert(this._initialized, "Already initialized"); - this._renderBlitMode = mode; + /** + * Select the GL presentation strategy for *all* chart-type plugins + * in this renderer. Static + process-global: the value is shared by + * every per-chart-type subclass, so it must be set once before the + * charts that should use it build their renderers (a renderer reads + * {@link BLIT_MODE} at construction in `_buildRenderer`; charts + * already built keep their mode until torn down and rebuilt). + * + * - `"direct"` — each chart owns a GL context 1:1 with its visible + * canvas (lowest latency; bounded by the browser's ~16-context + * cap). + * - `"blit"` — charts render off-screen and share a pool of GL + * contexts ([webgl/context-pool.ts]), so a page can exceed the cap. + */ + static setBlitMode(mode: "direct" | "blit") { + BLIT_MODE = mode; } get_static_config(): PluginStaticConfig { @@ -499,7 +555,15 @@ export class HTMLPerspectiveViewerWebGLPluginElement private async _drawImpl(view: View): Promise { const gen = ++this._generation; - const renderer = await this._ensureRenderer(view); + let renderer: RendererTransport; + try { + renderer = await this._ensureRenderer(view); + } catch { + // Renderer was disposed mid-init (element disconnected + // during the async build) — nothing to draw. + return; + } + if (this._generation !== gen) { return; } diff --git a/packages/viewer-charts/src/ts/render/scheduler.ts b/packages/viewer-charts/src/ts/render/scheduler.ts index 4b44de5a49..649e44f2dc 100644 --- a/packages/viewer-charts/src/ts/render/scheduler.ts +++ b/packages/viewer-charts/src/ts/render/scheduler.ts @@ -177,6 +177,41 @@ export function deferIfDraining( ops.push(op); } +/** + * Drop every scheduler reference to `glManager` — called from + * `WebGLContextManager`'s owning renderer at teardown, *before* + * `glManager.destroy()` loses the GL context. + * + * Without this, a frame queued for the next RAF (an `Entry` in + * `pending`) or a `deferIfDraining` op still parked in `deferred` + * would survive the destroy and, on the next `drain()`, drive + * `fullRender` / `present` against a context-lost manager. In blit + * mode that surfaces as `endFrame`'s `transferToImageBitmap` throwing + * "Cannot transfer to ImageBitmap because WebGL context is lost" — + * logged as "scheduler: present failed". In direct mode it's a wasted + * paint against a dead context. + * + * Outstanding waiters for a destroyed manager are resolved (not + * rejected): the caller asked to tear the chart down, so its awaited + * `draw()` should observe a clean no-op rather than an error it would + * have to suppress. An in-flight `present()` (manager in `inFlight`) + * is left to unwind on its own — its `finally` clause will not find a + * `deferred` entry to flush, and the manager is already gone from + * `pending`, so it cannot re-enqueue. + */ +export function unregister(glManager: WebGLContextManager): void { + const entry = pending.get(glManager); + if (entry) { + pending.delete(glManager); + for (const w of entry.waiters) { + w.resolve(); + } + } + + deferred.delete(glManager); + inFlight.delete(glManager); +} + /** * Test-only: clear pending state. Production callers must not use * this — outstanding waiters are silently dropped. @@ -230,31 +265,39 @@ export function _resetForTest(): void { async function drain(): Promise { const snapshot = Array.from(pending.values()); pending.clear(); - const ready: Entry[] = []; + + // Group ready entries by the GL context behind each manager. Under + // pooled blit mode many managers share one context (one drawing + // buffer): their renders MUST serialize — interleaving two charts' + // paints into one canvas would corrupt the bitmap the first ships. + // In the default 1:1 mode every manager has its own backend, so + // every group has exactly one entry and all groups run in parallel, + // exactly as before pooling. + const groups = new Map(); for (const entry of snapshot) { - try { - // Apply any dimension change recorded by - // `glManager.requestResize` *before* the paint, in the - // same un-yielded synchronous Phase 1 loop. This pairs - // the canvas-clearing `canvas.width = N` assignment - // with the immediately-following `_fullRender`, so the - // browser's compositor only ever observes the canvas - // post-paint. In direct/in-process modes the visible - // canvas IS the GL canvas, and a clear-without-matching- - // paint in the previous task would otherwise present an - // empty frame to the user. - entry.glManager.applyPendingResize(); - entry.fullRender(); - ready.push(entry); - } catch (err) { - console.error("scheduler: fullRender threw", err); + // The browser force-loses the oldest context when a page + // exceeds its per-agent WebGL context cap (~16). A frame queued + // before that eviction would paint + present against a dead + // context; skip it and settle its waiters cleanly rather than + // letting `endFrame`'s `transferToImageBitmap` throw. + if (entry.glManager.isContextLost()) { for (const w of entry.waiters) { - w.reject(err); + w.resolve(); } + + continue; + } + + const key = entry.glManager.backendId; + const group = groups.get(key); + if (group) { + group.push(entry); + } else { + groups.set(key, [entry]); } } - await Promise.all(ready.map(present)); + await Promise.all(Array.from(groups.values()).map(presentGroup)); // Now (and only now) clear rafId. If new requests landed during // this drain, schedule the next RAF. @@ -264,16 +307,51 @@ async function drain(): Promise { } } +/** + * Render every entry sharing one GL context, sequentially: each chart + * gets the shared drawing buffer to itself for a full + * `beginFrame` → `fullRender` → `awaitGpuFence` → `endFrame` (present) + * cycle before the next chart touches it. Groups for *different* + * contexts run concurrently (via `Promise.all` in `drain`), so K pooled + * contexts give K-way parallelism and the 1:1 default keeps full + * cross-chart overlap. + * + * The synchronous prefix of each group (`beginFrame` + `fullRender`) + * runs before its first `awaitGpuFence` yields, so when groups run + * concurrently all first-chart draws are still submitted before any + * fence wait — preserving the GPU overlap the old two-phase drain had. + */ +async function presentGroup(entries: Entry[]): Promise { + for (const entry of entries) { + await present(entry); + } +} + async function present(entry: Entry): Promise { - // Mark this glManager as in-flight *synchronously*, before the - // first await. `Promise.all(ready.map(present))` calls each - // `present` synchronously to collect its returned promise, so - // every entry's glManager is registered in `inFlight` before - // any fence-wait yields and before any sibling message handler - // can run. Mutations posted by sibling handlers (resize, clear) - // route through `deferIfDraining` and queue into `deferred` - // until the `finally` block flushes them. + // Mark this glManager as in-flight *synchronously*, before the first + // await, so sibling message handlers' canvas mutations (resize, + // clear) route through `deferIfDraining` into `deferred` until the + // `finally` flushes them. inFlight.add(entry.glManager); + try { + // Apply the dimension change and (in pooled mode) reset shared + // GL state in the same un-yielded step as the paint that fills + // the buffer. Pairing the canvas-clearing `canvas.width = N` + // with `_fullRender` keeps the compositor from ever observing a + // cleared-but-unpainted canvas (visible flicker in direct/in- + // process modes, where the visible canvas IS the GL canvas). + entry.glManager.beginFrame(); + entry.fullRender(); + } catch (err) { + console.error("scheduler: fullRender threw", err); + for (const w of entry.waiters) { + w.reject(err); + } + + flushDeferred(entry.glManager); + return; + } + try { await entry.glManager.awaitGpuFence(); entry.glManager.endFrame(); @@ -296,21 +374,26 @@ async function present(entry: Entry): Promise { w.reject(err); } } finally { - // Bitmap shipped (or error reported). Re-open the canvas to - // mutations and flush any deferred ops in arrival order. - // Deferred ops may call `requestRender`; the resulting - // entry queues into `pending` and the drain's tail check - // picks it up for the next RAF. - inFlight.delete(entry.glManager); - const ops = deferred.get(entry.glManager); - if (ops) { - deferred.delete(entry.glManager); - for (const op of ops) { - try { - op(); - } catch (err) { - console.error("scheduler: deferred op threw", err); - } + flushDeferred(entry.glManager); + } +} + +/** + * Re-open a glManager's canvas to mutations and flush any ops deferred + * during its present, in arrival order. Deferred ops may call + * `requestRender`; the resulting entry queues into `pending` and the + * drain's tail check picks it up for the next RAF. + */ +function flushDeferred(glManager: WebGLContextManager): void { + inFlight.delete(glManager); + const ops = deferred.get(glManager); + if (ops) { + deferred.delete(glManager); + for (const op of ops) { + try { + op(); + } catch (err) { + console.error("scheduler: deferred op threw", err); } } } diff --git a/packages/viewer-charts/src/ts/webgl/context-manager.ts b/packages/viewer-charts/src/ts/webgl/context-manager.ts index 54e49ba565..41ffb6582f 100644 --- a/packages/viewer-charts/src/ts/webgl/context-manager.ts +++ b/packages/viewer-charts/src/ts/webgl/context-manager.ts @@ -11,137 +11,125 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { ShaderRegistry } from "./shader-registry"; -import { SHADER_MANIFEST } from "./shader-manifest"; import { BufferPool } from "./buffer-pool"; +import { GLContext, type WebGLCanvas } from "./gl-context"; -export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas; +export type { WebGLCanvas } from "./gl-context"; export interface WebGLContextManagerOptions { /** * If `true`, compile + link every shader in `SHADER_MANIFEST` - * during construction (and again after a context-loss/restore - * cycle). Trades ~30-100ms of init time for elimination of the - * compile/link cost on the first-frame path of every chart type - * that ends up rendered. Default `false` keeps the original - * lazy-compile behavior — programs are compiled on first - * `getOrCreate(name, ...)` call from a glyph's render path. + * during context construction (and again after a context-loss/ + * restore cycle). Trades ~30-100ms of init time for elimination of + * the compile/link cost on the first-frame path. Ignored when the + * manager is constructed against an already-built shared + * {@link GLContext} — that context owns the precompile decision. + * Default `false` keeps lazy-compile behavior. */ precompile?: boolean; } +/** + * Per-chart render handle. Owns the chart's GPU **buffers** and frame + * bookkeeping, but only *borrows* its GL **context** — either a private + * one it constructs (the default 1:1 mode, where the manager is handed a + * raw canvas) or a shared {@link GLContext} from the + * [ContextPool](./context-pool.ts) (pooled blit mode, where many + * managers share K contexts to stay under the browser's per-agent + * context cap). + * + * Buffers are always per-manager: {@link BufferPool} keys buffers by + * name, so two charts sharing one pool would stomp each other's `"x"` / + * `"y"` / `"color"`. Shaders, the GL context, and the canvas drawing + * buffer are shared per {@link GLContext}; the scheduler serializes + * renders of managers that share a context so the shared drawing buffer + * only ever holds one chart's frame at a time (see {@link beginFrame}). + */ export class WebGLContextManager { - private _canvas: WebGLCanvas; - private _gl: WebGL2RenderingContext | WebGLRenderingContext; - private _isWebGL2: boolean; - private _shaders: ShaderRegistry; + private _ctx: GLContext; + private _ownsCtx: boolean; + private _disposeRestore: () => void; private _buffers: BufferPool; private _uploadedCount = 0; private _cssWidth = 0; private _cssHeight = 0; private _dpr = 1; - private _precompile: boolean; + private _destroyed = false; private _frameCallback: ((bitmap: ImageBitmap) => void) | null = null; /** * Per-instance `MessageChannel` used by `_yieldToTask` to resume a - * polling `awaitGpuFence` loop on the next task. Allocated lazily — - * the polling path is rarely hit when the GPU is idle, and many - * `WebGLContextManager` instances never need one. - * + * polling `awaitGpuFence` loop on the next task. Allocated lazily. * Must be per-instance: a module-level singleton races when two - * managers poll concurrently. Both call sites assign - * `port1.onmessage = resolve`, the second assignment overwrites the - * first, and the first poll's promise never settles — leaving its - * `awaitGpuFence` hung. The hang propagates up through the render - * scheduler's `present` → `uploadAndRender` → the worker's - * `uploadChunkAck` → the host's `with_typed_arrays` callback, - * stalling `draw()` indefinitely. (Now that fence waits across - * different `WebGLContextManager`s run in parallel inside one - * scheduler drain, the per-instance discipline matters even - * more — concurrent poll loops are the common case, not the - * exception.) - * - * Cost of per-instance allocation: one extra `MessageChannel` (two - * `MessagePort`s, ~negligible bytes, zero idle CPU) per chart on - * top of the per-chart proxy channel that the transport already - * holds. Bounded by chart count; safe even for pathological pages - * with hundreds of charts. The alternative — allocating a fresh - * channel on every `_yieldToTask` call — would churn ports on every - * fence poll (potentially many per frame on slow GPUs), which is - * far worse for the structured-clone subsystem than holding one - * port pair for the manager's lifetime. + * managers poll concurrently (the second `onmessage` assignment + * clobbers the first's resolver, hanging that fence wait). Now that + * fence waits across managers run in parallel within one scheduler + * drain, concurrent poll loops are the common case. */ private _yieldChannel: MessageChannel | null = null; - constructor(canvas: WebGLCanvas, options: WebGLContextManagerOptions = {}) { - this._canvas = canvas; - this._precompile = options.precompile ?? false; - const gl2 = canvas.getContext("webgl2", { - antialias: true, - alpha: true, - premultipliedAlpha: false, - }); + private _pendingYieldResolve: (() => void) | null = null; - if (gl2) { - this._gl = gl2 as WebGL2RenderingContext; - this._isWebGL2 = true; + /** + * @param source A raw canvas (manager constructs and **owns** a + * private {@link GLContext}) or an existing shared `GLContext` + * (manager borrows it; the pool owns its lifecycle). + */ + constructor( + source: WebGLCanvas | GLContext, + options: WebGLContextManagerOptions = {}, + ) { + if (source instanceof GLContext) { + this._ctx = source; + this._ownsCtx = false; } else { - const gl1 = canvas.getContext("webgl", { - antialias: true, - alpha: true, - premultipliedAlpha: false, + this._ctx = new GLContext(source, { + precompile: options.precompile ?? false, }); - - if (!gl1) { - throw new Error("WebGL is not supported"); - } - - this._gl = gl1 as WebGLRenderingContext; - this._isWebGL2 = false; + this._ownsCtx = true; } - this._shaders = new ShaderRegistry(this._gl); - this._buffers = new BufferPool(this._gl); - - if (this._precompile) { - this._shaders.precompile(SHADER_MANIFEST); - } + this._buffers = new BufferPool(this._ctx.gl); - // Both `HTMLCanvasElement` and `OffscreenCanvas` dispatch - // `webglcontextlost` / `webglcontextrestored` events on the - // canvas itself. `addEventListener` exists on both. - (canvas as EventTarget).addEventListener( - "webglcontextlost", - (e: Event) => { - e.preventDefault(); - }, - ); - - (canvas as EventTarget).addEventListener("webglcontextrestored", () => { - this._shaders.releaseAll(); + // Rebuild this tenant's buffers after a context restore. The + // shared `GLContext` rebuilds the shader cache itself, once. + this._disposeRestore = this._ctx.onRestore(() => { this._buffers.releaseAll(); - this._shaders = new ShaderRegistry(this._gl); - this._buffers = new BufferPool(this._gl); + this._buffers = new BufferPool(this._ctx.gl); this._uploadedCount = 0; - - // Re-prime the cache after restore so post-recovery - // first-frame doesn't re-pay the lazy compile cost. - if (this._precompile) { - this._shaders.precompile(SHADER_MANIFEST); - } }); } get gl(): WebGL2RenderingContext | WebGLRenderingContext { - return this._gl; + return this._ctx.gl; + } + + /** + * Identifies the shared GL context behind this manager. The + * scheduler groups pending renders by this id: managers on the same + * backend serialize (they share one drawing buffer); managers on + * different backends render in parallel. In the default 1:1 mode + * every manager has its own backend, so every render is independent + * — identical to the pre-pooling behavior. + */ + get backendId(): number { + return this._ctx.id; + } + + /** + * True once this manager's context has been lost — its own + * {@link destroy}, or a browser eviction of the shared context. + */ + isContextLost(): boolean { + return this._destroyed || this._ctx.isContextLost(); } get isWebGL2(): boolean { - return this._isWebGL2; + return this._ctx.isWebGL2; } get shaders(): ShaderRegistry { - return this._shaders; + return this._ctx.shaders; } get bufferPool(): BufferPool { @@ -158,11 +146,10 @@ export class WebGLContextManager { /** * Resize the GL canvas's bitmap to match the host's CSS layout. The - * Host is responsible for measuring the DOM element (or otherwise - * deciding the target CSS size) and the device pixel ratio — the - * manager itself does not touch DOM, so the same code path works - * whether the canvas is an `HTMLCanvasElement` (in-process) or an - * `OffscreenCanvas` (in-process via transfer, or in a worker). + * host measures the DOM element and DPR — the manager never touches + * DOM, so the same path works for an `HTMLCanvasElement` + * (in-process), a transferred `OffscreenCanvas` (direct/worker), or + * a pool-shared `OffscreenCanvas` (pooled blit). */ resize(cssWidth: number, cssHeight: number, dpr: number): void { this._cssWidth = cssWidth; @@ -171,11 +158,11 @@ export class WebGLContextManager { const width = Math.round(cssWidth * dpr); const height = Math.round(cssHeight * dpr); - - if (this._canvas.width !== width || this._canvas.height !== height) { - this._canvas.width = width; - this._canvas.height = height; - this._gl.viewport(0, 0, width, height); + const canvas = this._ctx.canvas; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + this._ctx.gl.viewport(0, 0, width, height); } } @@ -191,28 +178,16 @@ export class WebGLContextManager { } | null = null; /** - * Record a dimension change to be applied at the start of the - * next render's Phase 1, *before* `_fullRender` runs. The actual - * `canvas.width = N` assignment (which clears the drawing buffer - * per the WebGL spec) happens inside `applyPendingResize()`, - * paired in the same synchronous task as the paint that fills - * the new buffer. - * - * Why split the dimension change off from the existing - * {@link resize} method: in direct / in-process modes the - * GL canvas IS the host's visible canvas, and `canvas.width = N` - * is immediately observable to the browser's compositor as a - * cleared buffer. If the resize lands in the message handler - * (one task) but the matching `_fullRender` lands in the next - * RAF (a later task), the compositor cycles between them and - * presents one full frame of empty canvas — visible flicker. - * Deferring the dimension change to the same RAF as the paint - * eliminates the inter-frame gap; both happen inside Phase 1's - * un-yielded loop. + * Record a dimension change to be applied at the start of the next + * render's Phase 1, before `_fullRender` runs. The actual + * `canvas.width = N` assignment (which clears the drawing buffer per + * the WebGL spec) happens inside {@link applyPendingResize} / + * {@link beginFrame}, paired in the same synchronous task as the + * paint that fills the new buffer — eliminating the one-frame blank + * the compositor would otherwise show in direct/in-process modes + * (where the GL canvas IS the visible canvas). * - * Multiple `requestResize` calls before the next render coalesce - * to last-write-wins — five rapid width changes from a window - * drag produce one resize+paint, not five. + * Multiple calls before the next render coalesce to last-write-wins. */ requestResize(cssWidth: number, cssHeight: number, dpr: number): void { this._pendingResize = { cssWidth, cssHeight, dpr }; @@ -220,11 +195,7 @@ export class WebGLContextManager { /** * Apply any pending dimension change recorded by - * {@link requestResize}. Called by the scheduler's Phase 1 - * (immediately before each entry's `fullRender`) and by the - * `snapshotPng` bypass path. Returns `true` when a resize was - * applied, `false` when there was nothing pending — useful for - * callers that want to skip a no-op render. + * {@link requestResize}. Returns `true` when a resize was applied. */ applyPendingResize(): boolean { if (!this._pendingResize) { @@ -238,61 +209,99 @@ export class WebGLContextManager { } /** - * Last CSS width passed to `resize()`. + * Prepare the shared GL surface for *this* manager's render. Called + * by the scheduler immediately before `_fullRender` (and by the + * `snapshotPng` bypass). + * + * - **Own context (1:1):** identical to the old Phase-1 step — apply + * only a *pending* resize. The context isn't shared, so there is + * no sibling state to reset and no need to touch the canvas when + * dimensions are unchanged. + * - **Shared context (pooled):** a co-tenant chart rendered last and + * left the shared canvas at *its* dimensions with *its* GL state. + * Force the canvas to this manager's dimensions and reset the + * global GL state a sibling might have left dirty (bound + * framebuffer, viewport, scissor/depth enables) so frames can't + * bleed across charts. Per-frame draw state (blend, clear) is + * re-established by the chart's `clearAndSetupFrame`. */ + beginFrame(): void { + if (this._ownsCtx) { + this.applyPendingResize(); + return; + } + + // Honor a queued `requestResize` (it carries the latest dims but + // hasn't touched the canvas yet), then force the shared canvas + // to this manager's dims — a co-tenant chart may have left it at + // its own size. `resize` is a no-op when dims already match. + const pending = this._pendingResize; + this._pendingResize = null; + if (pending) { + this.resize(pending.cssWidth, pending.cssHeight, pending.dpr); + } else { + this.resize(this._cssWidth, this._cssHeight, this._dpr); + } + + const gl = this._ctx.gl; + const canvas = this._ctx.canvas; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.disable(gl.SCISSOR_TEST); + gl.disable(gl.DEPTH_TEST); + this._ctx.resetVertexArrayState(); + } + get cssWidth(): number { return this._cssWidth; } - /** - * Last CSS height passed to `resize()`. - */ get cssHeight(): number { return this._cssHeight; } - /** - * Last device pixel ratio passed to `resize()`. - */ get dpr(): number { return this._dpr; } clear(): void { - this._gl.clearColor(0, 0, 0, 0); - this._gl.clear(this._gl.COLOR_BUFFER_BIT | this._gl.DEPTH_BUFFER_BIT); + this._ctx.gl.clearColor(0, 0, 0, 0); + this._ctx.gl.clear( + this._ctx.gl.COLOR_BUFFER_BIT | this._ctx.gl.DEPTH_BUFFER_BIT, + ); this._uploadedCount = 0; } /** * Register a per-frame hook invoked at the end of each render. In - * blit-mode rendering, the worker installs a callback that - * transfers an `ImageBitmap` from `_canvas` (an `OffscreenCanvas`) - * back to the host so the visible display canvas can `drawImage` - * it. In direct mode the callback is left null and `endFrame` is a - * no-op. - * - * Pass `null` to detach. + * blit-mode rendering the worker installs a callback that transfers + * an `ImageBitmap` from the canvas back to the host. In direct mode + * the callback is null and `endFrame` is a no-op. Pass `null` to + * detach. */ setFrameCallback(cb: ((bitmap: ImageBitmap) => void) | null): void { this._frameCallback = cb; } /** - * Called by chart impls at the bottom of `_fullRender` (and any - * other path that produces a complete frame). When a frame - * callback is registered AND the GL surface is an - * `OffscreenCanvas`, ship its current contents as an - * `ImageBitmap` to the host. Otherwise no-op — direct-mode - * rendering has nothing to ship; the visible canvas already holds - * the drawing buffer. + * Ship the current canvas contents as an `ImageBitmap` to the host + * when a frame callback is registered and the surface is an + * `OffscreenCanvas`. Otherwise no-op (direct mode paints straight to + * the visible drawing buffer). */ endFrame(): void { if (!this._frameCallback) { return; } - const canvas = this._canvas as + // A context lost mid-frame (our own `destroy`, or a browser + // eviction between paint and present) makes + // `transferToImageBitmap` throw. Bail quietly. + if (this.isContextLost()) { + return; + } + + const canvas = this._ctx.canvas as | OffscreenCanvas | (HTMLCanvasElement & { transferToImageBitmap?: never }); if ( @@ -308,30 +317,17 @@ export class WebGLContextManager { /** * Resolve when every GL command submitted up to this call has been - * executed by the GPU. - * - * On WebGL2 this issues a `fenceSync(SYNC_GPU_COMMANDS_COMPLETE)` - * and polls `clientWaitSync` with a zero timeout, yielding to the - * task queue between polls. The first poll passes - * `SYNC_FLUSH_COMMANDS_BIT` so the fence becomes reachable without - * a separate `gl.flush()`. - * - * On WebGL1 there is no fenceSync; we fall back to the blocking - * `gl.finish()`. This is acceptable in a worker — never call this - * from the main thread on a heavy frame. - * - * Used as a per-frame "GPU is idle" barrier so callers can serialize - * follow-on work (`endFrame` snapshot, present roundtrip, the next - * chunk upload) against actual GPU completion instead of the - * implicit, implementation-defined timing of `transferToImageBitmap`. + * executed by the GPU. WebGL2 issues a `fenceSync` and polls + * `clientWaitSync` (yielding to the task queue between polls); WebGL1 + * falls back to a blocking `gl.finish()` (acceptable in a worker). */ async awaitGpuFence(): Promise { - if (!this._isWebGL2) { - this._gl.finish(); + if (!this._ctx.isWebGL2) { + this._ctx.gl.finish(); return; } - const gl = this._gl as WebGL2RenderingContext; + const gl = this._ctx.gl as WebGL2RenderingContext; const fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); if (!fence) { gl.finish(); @@ -356,6 +352,10 @@ export class WebGLContextManager { flags = 0; await this._yieldToTask(); + + if (this._destroyed) { + return; + } } } finally { gl.deleteSync(fence); @@ -367,48 +367,54 @@ export class WebGLContextManager { } /** - * Yield to the task queue between fence polls. We avoid - * `setTimeout(0)` because Chromium clamps nested `setTimeout` to - * ~4ms in workers, which would inflate the measured cost of - * `awaitGpuFence`. A reused per-instance `MessageChannel` lands the - * resume in the next task with sub-ms latency. - * - * `addEventListener(..., { once: true })` is used over - * `port1.onmessage = ...` so concurrent in-flight resumes (should - * any path ever introduce them) cannot clobber each other's - * resolvers — the previous module-level singleton lost a resolver - * on every overlap and hung one chart's `draw()` indefinitely. + * Yield to the task queue between fence polls. `setTimeout(0)` is + * avoided because Chromium clamps nested worker `setTimeout` to + * ~4ms; a reused per-instance `MessageChannel` resumes in the next + * task with sub-ms latency. `{ once: true }` over `onmessage = ...` + * so concurrent resumes can't clobber each other's resolvers. */ private _yieldToTask(): Promise { if (!this._yieldChannel) { this._yieldChannel = new MessageChannel(); - // `addEventListener` does not auto-start a `MessagePort` — - // only `onmessage = ...` does. Start once at allocation so - // posted resumes are actually delivered. this._yieldChannel.port1.start(); } return new Promise((resolve) => { const ch = this._yieldChannel!; - ch.port1.addEventListener("message", () => resolve(), { - once: true, - }); + this._pendingYieldResolve = resolve; + ch.port1.addEventListener( + "message", + () => { + this._pendingYieldResolve = null; + resolve(); + }, + { once: true }, + ); ch.port2.postMessage(null); }); } destroy(): void { + this._destroyed = true; + this._frameCallback = null; + this._disposeRestore(); this._buffers.releaseAll(); - this._shaders.releaseAll(); + if (this._pendingYieldResolve) { + const resolve = this._pendingYieldResolve; + this._pendingYieldResolve = null; + resolve(); + } + if (this._yieldChannel) { this._yieldChannel.port1.close(); this._yieldChannel.port2.close(); this._yieldChannel = null; } - const ext = this._gl.getExtension("WEBGL_lose_context"); - if (ext) { - ext.loseContext(); + // Only lose the GL context when we own it. A shared context + // outlives any one tenant — the pool destroys it. + if (this._ownsCtx) { + this._ctx.destroy(); } } } diff --git a/packages/viewer-charts/src/ts/webgl/context-pool.ts b/packages/viewer-charts/src/ts/webgl/context-pool.ts new file mode 100644 index 0000000000..2b26798c32 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/context-pool.ts @@ -0,0 +1,78 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { GLContext } from "./gl-context"; + +/** + * Renderer-scope pool of shared {@link GLContext}s for pooled blit mode. + * + * The browser caps live WebGL contexts per agent (~16 in Chromium) and + * force-loses the oldest past that cap. A page with more than ~16 charts + * therefore can't give each its own context. This pool caps the number + * of live contexts at `size`, distributing renderers across them + * round-robin (sticky for a renderer's lifetime — a renderer keeps the + * same context so its resident GPU buffers and any cached textures/FBOs + * stay valid frame to frame). N charts render through K = `size` + * contexts; the scheduler serializes renders that land on the same + * context. + * + * Pooling applies to blit mode only: direct mode renders into the host's + * transferred visible canvas, which is permanently 1:1 with a context. + * + * One pool per renderer scope (the worker, or the main thread in + * in-process mode). Contexts are created lazily on first `acquire` and + * live until renderer-scope teardown — like the contexts they replace, + * they are not refcounted, because a shared context outlives any single + * borrowing renderer. + */ +export class ContextPool { + private _size: number; + private _precompile: boolean; + private _contexts: GLContext[] = []; + private _next = 0; + + constructor(size: number, options: { precompile?: boolean } = {}) { + this._size = Math.max(1, size); + this._precompile = options.precompile ?? false; + } + + /** + * Borrow a context for a new renderer. Fills the pool to `size` + * before reusing, then round-robins. A dead context (browser + * eviction) is replaced in place so a borrower never receives a lost + * context. + */ + acquire(): GLContext { + let ctx: GLContext; + if (this._contexts.length < this._size) { + ctx = this._make(); + this._contexts.push(ctx); + } else { + const idx = this._next % this._contexts.length; + this._next++; + if (this._contexts[idx].isContextLost()) { + this._contexts[idx] = this._make(); + } + + ctx = this._contexts[idx]; + } + + return ctx; + } + + private _make(): GLContext { + // Initial size is arbitrary — every render resizes the shared + // canvas to the borrowing renderer's dimensions in `beginFrame`. + const canvas = new OffscreenCanvas(1, 1); + return new GLContext(canvas, { precompile: this._precompile }); + } +} diff --git a/packages/viewer-charts/src/ts/webgl/gl-context.ts b/packages/viewer-charts/src/ts/webgl/gl-context.ts new file mode 100644 index 0000000000..6c095871c5 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/gl-context.ts @@ -0,0 +1,191 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { ShaderRegistry } from "./shader-registry"; +import { SHADER_MANIFEST } from "./shader-manifest"; + +export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas; + +/** + * The unit the browser actually counts against its per-agent WebGL + * context cap (~16 in Chromium): one canvas + one GL context + the + * shader programs compiled against it. + * + * Factored out of {@link WebGLContextManager} so that *many* managers + * (one per chart) can share *one* GL context. The browser limit is on + * contexts, not on the buffers/programs living inside one — a single + * context can host an arbitrary number of independent + * `WebGLBuffer`s/programs (bounded only by memory). So N charts can + * render through K ≪ N contexts as long as each keeps its own + * `BufferPool` (buffers are keyed by name; two charts would otherwise + * collide) and renders are serialized per context (the shared drawing + * buffer can hold one chart's frame at a time). See + * [context-pool.ts](./context-pool.ts) and the scheduler's per-backend + * grouping in [../render/scheduler.ts](../render/scheduler.ts). + * + * Shaders are shared per context: programs are immutable GLSL once + * compiled (uniforms are set per draw), so co-tenant charts reuse them + * and only pay the compile cost once per context. + */ +let NEXT_CONTEXT_ID = 1; + +export class GLContext { + readonly id: number; + readonly canvas: WebGLCanvas; + readonly gl: WebGL2RenderingContext | WebGLRenderingContext; + readonly isWebGL2: boolean; + shaders: ShaderRegistry; + private _maxVertexAttribs: number; + private _angle: ANGLE_instanced_arrays | null = null; + private _precompile: boolean; + private _destroyed = false; + + /** + * Per-tenant hooks fired after a `webglcontextrestored`. Each + * {@link WebGLContextManager} on this context registers one that + * rebuilds its `BufferPool` (the shaders are rebuilt here, once, for + * all tenants). On an intentional {@link destroy} these are dropped + * without firing. + */ + private _onRestore = new Set<() => void>(); + + constructor(canvas: WebGLCanvas, options: { precompile?: boolean } = {}) { + this.id = NEXT_CONTEXT_ID++; + this.canvas = canvas; + this._precompile = options.precompile ?? false; + const gl2 = canvas.getContext("webgl2", { + antialias: true, + alpha: true, + premultipliedAlpha: false, + }); + + if (gl2) { + this.gl = gl2 as WebGL2RenderingContext; + this.isWebGL2 = true; + } else { + const gl1 = canvas.getContext("webgl", { + antialias: true, + alpha: true, + premultipliedAlpha: false, + }); + + if (!gl1) { + throw new Error("WebGL is not supported"); + } + + this.gl = gl1 as WebGLRenderingContext; + this.isWebGL2 = false; + } + + this.shaders = new ShaderRegistry(this.gl); + if (this._precompile) { + this.shaders.precompile(SHADER_MANIFEST); + } + + this._maxVertexAttribs = this.gl.getParameter( + this.gl.MAX_VERTEX_ATTRIBS, + ) as number; + if (!this.isWebGL2) { + this._angle = this.gl.getExtension("ANGLE_instanced_arrays"); + } + + // Both `HTMLCanvasElement` and `OffscreenCanvas` dispatch + // `webglcontextlost` / `webglcontextrestored` on the canvas. + (canvas as EventTarget).addEventListener( + "webglcontextlost", + (e: Event) => { + // Don't reserve the slot for a restore once we've + // intentionally torn the context down — `destroy()` + // calls `loseContext()`, which fires this event. + if (this._destroyed) { + return; + } + + e.preventDefault(); + }, + ); + + (canvas as EventTarget).addEventListener("webglcontextrestored", () => { + // Rebuild the shared program cache once for the whole + // context, then let each tenant rebuild its own buffers. + this.shaders.releaseAll(); + this.shaders = new ShaderRegistry(this.gl); + if (this._precompile) { + this.shaders.precompile(SHADER_MANIFEST); + } + + for (const cb of this._onRestore) { + cb(); + } + }); + } + + /** + * Return every vertex-attribute slot to its default state: disabled, + * with a zero instance divisor. Vertex-array state (enable flags, + * pointers, divisors) is global to a context's default VAO — there + * are no per-chart VAOs in this codebase — so when many charts share + * one pooled context, a slot a prior chart enabled stays enabled for + * the next chart, now pointing at a buffer the prior chart's + * teardown deleted. Its next `drawArraysInstanced` then fails GL + * validation: "no buffer is bound to enabled attribute", silently + * dropping the draw. + * + * Glyphs already (re)enable + bind + set the divisor for every slot + * they use on each frame, so clearing to the default here is a safe + * baseline — it only removes the *leftover* enables a sibling chart + * left behind. Called from {@link WebGLContextManager.beginFrame} in + * shared (pooled) mode only; the 1:1 default never shares a context + * and so keeps its original, reset-free path. + */ + resetVertexArrayState(): void { + const gl = this.gl; + for (let i = 0; i < this._maxVertexAttribs; i++) { + gl.disableVertexAttribArray(i); + if (this.isWebGL2) { + (gl as WebGL2RenderingContext).vertexAttribDivisor(i, 0); + } else { + this._angle?.vertexAttribDivisorANGLE(i, 0); + } + } + } + + /** + * Register a tenant's post-restore rebuild hook. Returns a disposer. + */ + onRestore(cb: () => void): () => void { + this._onRestore.add(cb); + return () => this._onRestore.delete(cb); + } + + /** + * True once lost — either our own {@link destroy} or a browser + * eviction of the oldest context past the per-agent cap. + */ + isContextLost(): boolean { + return this._destroyed || this.gl.isContextLost(); + } + + get destroyed(): boolean { + return this._destroyed; + } + + destroy(): void { + this._destroyed = true; + this._onRestore.clear(); + this.shaders.releaseAll(); + const ext = this.gl.getExtension("WEBGL_lose_context"); + if (ext) { + ext.loseContext(); + } + } +} diff --git a/packages/viewer-charts/src/ts/worker/renderer.worker.ts b/packages/viewer-charts/src/ts/worker/renderer.worker.ts index bf461a97a4..f5689c8660 100644 --- a/packages/viewer-charts/src/ts/worker/renderer.worker.ts +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -16,6 +16,8 @@ import type { Client, Table, View } from "@perspective-dev/client"; import type * as wasm_module_type from "@perspective-dev/viewer/dist/wasm/perspective-viewer.js"; import { WebGLContextManager } from "../webgl/context-manager"; +import { ContextPool } from "../webgl/context-pool"; +import { RENDER_CONTEXT_POOL_SIZE } from "../config"; import { ChartImplementation } from "../charts/chart"; import { ZoomController } from "../interaction/zoom-controller"; import { @@ -37,7 +39,7 @@ import { viewToColumnDataMap } from "../data/view-reader"; import { loadFontDeduped } from "./font-loader"; import { dispatch } from "./dispatch"; import { installSessionHost } from "./session-host"; -import { deferIfDraining } from "../render/scheduler"; +import { deferIfDraining, unregister } from "../render/scheduler"; /** * Sentinel thrown inside the `with_typed_arrays` callback when a newer @@ -76,6 +78,25 @@ async function resolveChartImpl( return await factory(); } +/** + * Renderer-scope shared context pool for pooled blit mode. Lazily + * created on first use so non-blit / non-pooled scopes never allocate + * one. One per renderer scope (the worker, or the main thread in + * in-process mode) — `WorkerRenderer`s in the same scope share its K + * contexts. + */ +let CONTEXT_POOL: ContextPool | null = null; + +function getContextPool(precompile: boolean): ContextPool { + if (!CONTEXT_POOL) { + CONTEXT_POOL = new ContextPool(RENDER_CONTEXT_POOL_SIZE, { + precompile, + }); + } + + return CONTEXT_POOL; +} + export class WorkerRenderer { chartImpl: ChartImplementation; glManager: WebGLContextManager; @@ -131,20 +152,35 @@ export class WorkerRenderer { this.chartImpl = new ImplClass(); - // Direct mode hands us the host's transferred `.webgl-canvas`. - // Blit mode omits it — the renderer owns its own offscreen - // surface and posts each completed frame back as an - // `ImageBitmap` via the `endFrame` callback wired below. - const glCanvas = - msg.glCanvas ?? - new OffscreenCanvas( - Math.max(1, Math.round(msg.cssWidth * msg.dpr)), - Math.max(1, Math.round(msg.cssHeight * msg.dpr)), + // Three surfaces, by mode: + // - direct: the host's transferred `.webgl-canvas` (1:1 with a + // context, permanently). + // - pooled blit: borrow one of K shared contexts so live- + // context count stays bounded regardless of chart count. + // - unpooled blit: a private offscreen surface per chart. + // Blit modes omit `msg.glCanvas`; the renderer ships each frame + // as an `ImageBitmap` via the `endFrame` callback wired below. + const pooled = + msg.renderMode === "blit" && + !msg.glCanvas && + RENDER_CONTEXT_POOL_SIZE > 0; + + if (pooled) { + this.glManager = new WebGLContextManager( + getContextPool(msg.precompileShaders ?? false).acquire(), ); + } else { + const glCanvas = + msg.glCanvas ?? + new OffscreenCanvas( + Math.max(1, Math.round(msg.cssWidth * msg.dpr)), + Math.max(1, Math.round(msg.cssHeight * msg.dpr)), + ); - this.glManager = new WebGLContextManager(glCanvas, { - precompile: msg.precompileShaders ?? false, - }); + this.glManager = new WebGLContextManager(glCanvas, { + precompile: msg.precompileShaders ?? false, + }); + } if (msg.renderMode === "blit") { this.glManager.setFrameCallback((bitmap) => { @@ -528,12 +564,14 @@ export class WorkerRenderer { * Composite the three layers into a single PNG `Blob`. */ async snapshotPng(): Promise { - // Snapshot bypasses the scheduler's drain, so it must - // mirror Phase 1's "apply pending resize before paint" - // step itself — otherwise a snapshot taken after a resize - // message but before the next drain would render at the - // previous dimensions. - this.glManager.applyPendingResize(); + // Snapshot bypasses the scheduler's drain, so it must mirror + // the per-frame prep itself — apply any pending resize, and in + // pooled blit mode size the shared canvas to this chart and + // reset shared GL state — otherwise a snapshot taken after a + // resize message but before the next drain (or after a co-tenant + // chart left the shared canvas at its own size) would render at + // the wrong dimensions. + this.glManager.beginFrame(); this.chartImpl._fullRender(this.glManager); const gl = this.glManager.gl; const glCanvas = gl.canvas as OffscreenCanvas; @@ -574,6 +612,11 @@ export class WorkerRenderer { } destroy(): void { + // Drop any frame this manager still has queued in the + // module-level scheduler *before* losing its GL context, so a + // next-RAF `drain()` can't paint/present against a dead + // context (the "scheduler: present failed" path). + unregister(this.glManager); this.chartImpl.destroy(); this.glManager.destroy(); } diff --git a/packages/viewer-charts/src/ts/worker/session-host.ts b/packages/viewer-charts/src/ts/worker/session-host.ts index afe7d32204..904962eb79 100644 --- a/packages/viewer-charts/src/ts/worker/session-host.ts +++ b/packages/viewer-charts/src/ts/worker/session-host.ts @@ -21,6 +21,20 @@ import { dispatch } from "./dispatch"; const RENDERERS = new Map(); +/** + * Sessions whose `destroy` arrived before their async `bootstrap` + * resolved. Bootstrap does multiple awaits (wasm `initSync`, font + * loads, `open_table`, lazy chart-impl import); a host that connects + * then immediately disconnects a viewer can post `init` and `destroy` + * back-to-back, with `destroy` landing while the renderer is still + * being built. The plain `RENDERERS.get(sessionId)` lookup in the + * destroy branch would miss it and the bootstrap would then register a + * renderer (and its WebGL context) that nothing ever tears down — + * leaking one context per raced toggle until the browser evicts the + * oldest. We tombstone the id here and honor it when bootstrap lands. + */ +const PENDING_DESTROY = new Set(); + function postSession( sessionId: number, msg: WorkerMsg, @@ -89,9 +103,18 @@ export function installSessionHost( bootstrap(msg as InitMsg, makeSessionPort(sessionId)) .then((r) => { + // A `destroy` raced ahead of bootstrap completing — + // honor it now instead of leaking the freshly-built + // renderer + its WebGL context. + if (PENDING_DESTROY.delete(sessionId)) { + dispatch(r, { kind: "destroy" }); + return; + } + RENDERERS.set(sessionId, r); }) .catch((err) => { + PENDING_DESTROY.delete(sessionId); postSession(sessionId, { kind: "error", message: String(err), @@ -105,6 +128,12 @@ export function installSessionHost( if (r) { dispatch(r, msg); RENDERERS.delete(sessionId); + } else { + // Renderer not registered yet — bootstrap is still in + // flight. Tombstone the id so the bootstrap `.then` + // tears it down on arrival instead of registering a + // leaked context. + PENDING_DESTROY.add(sessionId); } return; diff --git a/packages/viewer-charts/test/ts/chart-type-switch.spec.ts b/packages/viewer-charts/test/ts/chart-type-switch.spec.ts new file mode 100644 index 0000000000..3001c27b5b --- /dev/null +++ b/packages/viewer-charts/test/ts/chart-type-switch.spec.ts @@ -0,0 +1,154 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Chart-type-switch vertex-attribute bleed (pooled blit). + * + * In pooled blit mode many chart types share a small set of GL contexts. + * Vertex-array state (attribute enables + divisors) is global to a + * context's default VAO, and glyphs only ever *enable* attribute slots — + * none disables them. So when one chart type renders and is then torn + * down (its buffers deleted) and a *different* chart type lands on the + * same pooled context, the slots the first type left enabled now point + * at deleted buffers. The new type's `drawArraysInstanced` fails GL + * validation — `INVALID_OPERATION: no buffer is bound to enabled + * attribute` — and paints nothing; as more types cycle, more pooled + * contexts get poisoned and rendering collapses. + * + * `WebGLContextManager.beginFrame` resets the shared context's + * vertex-array state before each pooled render, which fixes this. The + * test cycles every chart type (twice, so the K pooled contexts are each + * reused by a different chart program) and asserts every type renders + * after its switch — and that no attribute-bleed GL error fired. + * + * Requires a build with the pooling changes + the fix. `setBlitMode` is + * the process-global static, so flipping it once puts every chart-type + * plugin into pooled blit. + */ + +import type { ConsoleMessage, Page } from "@playwright/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { expect, test } from "@perspective-dev/test"; +import { + calibratePlotBaseline, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +/** + * Glyph-dense configs spanning distinct instanced-attribute layouts + * (points / lines / bars / heatmap), so consecutive switches produce the + * high-attribute-count → low-attribute-count transition that leaves a + * slot enabled across charts. Mirrors `frame-timing.spec.ts`'s set. + */ +const FIXTURES: { name: string; config: ViewerConfigUpdate }[] = [ + { + name: "X/Y Scatter", + config: { plugin: "X/Y Scatter", columns: ["Quantity", "Postal Code"] }, + }, + { name: "Y Line", config: { plugin: "Y Line", columns: ["Profit"] } }, + { name: "Y Area", config: { plugin: "Y Area", columns: ["Profit"] } }, + { + name: "Y Bar", + config: { plugin: "Y Bar", group_by: ["State"], columns: ["Profit"] }, + }, + { + name: "X Bar", + config: { plugin: "X Bar", group_by: ["State"], columns: ["Profit"] }, + }, + { + name: "Heatmap", + config: { + plugin: "Heatmap", + group_by: ["State"], + split_by: ["Region"], + columns: ["Profit"], + }, + }, +]; + +/** + * The precise GL validation failure the bug raises. Best-effort: browser + * GL warnings from the worker may or may not surface via + * `page.on("console")`, so the per-type render assertion below is the + * hard gate; this just adds signal when the message is captured. + */ +const ATTR_BLEED = /no buffer is bound to enabled attribute|INVALID_OPERATION/i; + +function collectAttrErrors(page: Page): string[] { + const hits: string[] = []; + page.on("console", (m: ConsoleMessage) => { + if (ATTR_BLEED.test(m.text())) { + hits.push(m.text()); + } + }); + + page.on("pageerror", (e: Error) => { + if (ATTR_BLEED.test(String(e))) { + hits.push(String(e)); + } + }); + + return hits; +} + +test.describe("Chart-type switch (pooled blit)", () => { + test("cycling every chart type never bleeds vertex-attribute state", async ({ + page, + }) => { + test.setTimeout(180_000); + await gotoBasic(page); + const attrErrors = collectAttrErrors(page); + + // Flip the whole renderer into pooled blit once, via the global + // static setter (reached through the activated plugin's + // constructor), then tear that renderer down so the cycle below + // rebuilds it pooled. + await restoreChart(page, FIXTURES[0].config); + await page.evaluate(() => { + const plugin = ( + document.querySelector("perspective-viewer") as any + ).getPlugin(); + plugin.constructor.setBlitMode("blit"); + plugin.delete(); + }); + + // Two passes over the type list. With a pool of K contexts, + // round-robin sticky assignment means types K+1, K+2, … reuse a + // context a different type already rendered on — the exact + // condition that surfaces leftover-enabled attributes. + const order = [...FIXTURES, ...FIXTURES]; + const results: { name: string; pixels: number }[] = []; + for (const fx of order) { + await restoreChart(page, fx.config); + await waitOneFrame(page); + const pixels = await calibratePlotBaseline(page); + results.push({ name: fx.name, pixels }); + } + + // Every chart type must have rendered after its switch. A type + // that landed on a poisoned pooled context fails + // `drawArraysInstanced` and paints nothing — its plot-pixel + // count collapses to ~0. This is the deterministic, capture- + // independent signal. + for (const r of results) { + expect( + r.pixels, + `${r.name} rendered ${r.pixels} plot pixels after switch — ` + + `likely an attribute-bleed draw failure on a shared context`, + ).toBeGreaterThan(0); + } + + expect(attrErrors).toEqual([]); + }); +}); diff --git a/packages/viewer-charts/test/ts/context-leak.spec.ts b/packages/viewer-charts/test/ts/context-leak.spec.ts new file mode 100644 index 0000000000..5c5b3ba34c --- /dev/null +++ b/packages/viewer-charts/test/ts/context-leak.spec.ts @@ -0,0 +1,142 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ConsoleMessage, Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import { + assertPlotNeverBlank, + calibratePlotBaseline, + captureFrames, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const SCATTER = { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], +}; + +const TABLE_NAME = "load-viewer-csv"; +const TOGGLE_COUNT = 12; + +function collectLeakErrors(page: Page): string[] { + const hits: string[] = []; + page.on("console", (m: ConsoleMessage) => { + hits.push(m.text()); + }); + + page.on("pageerror", (e: Error) => { + hits.push(String(e)); + }); + + return hits; +} + +async function toggleSecondViewer(page: Page, cfg: object): Promise { + await page.evaluate( + async ({ cfg, tableName }) => { + const worker = (window as any).__TEST_WORKER__; + const table = await worker.open_table(tableName); + const v = document.createElement("perspective-viewer"); + document.body.appendChild(v); + await v.load(table); + await v.restore(cfg); + }, + { cfg, tableName: TABLE_NAME }, + ); + + await waitOneFrame(page); + await waitOneFrame(page); + await page.evaluate(() => { + document.querySelector("perspective-viewer[data-toggle]")?.remove(); + }); +} + +async function toggleSecondViewerRacingInit( + page: Page, + cfg: object, +): Promise { + await page.evaluate( + async ({ cfg, tableName }) => { + const worker = (window as any).__TEST_WORKER__; + const table = await worker.open_table(tableName); + const v = document.createElement("perspective-viewer"); + document.body.appendChild(v); + await v.load(table); + v.restore(cfg).catch(() => {}); + await new Promise((resolve) => + setTimeout(() => { + v.remove(); + resolve(); + }, 0), + ); + }, + { cfg, tableName: TABLE_NAME }, + ); + + await waitOneFrame(page); +} + +test.describe("WebGL context leak", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + await restoreChart(page, SCATTER); + }); + + test("repeated full toggles never evict the first chart's context", async ({ + page, + }) => { + // test.setTimeout(180_000); + const leakErrors = collectLeakErrors(page); + const baseline = await calibratePlotBaseline(page); + expect(baseline).toBeGreaterThan(0); + const threshold = Math.max(1, Math.floor(baseline * 0.5)); + for (let i = 0; i < TOGGLE_COUNT; i++) { + await toggleSecondViewer(page, SCATTER); + } + + // Force the first viewer to re-render through its own renderer. + // If its context had been evicted by a leak, the worker can no + // longer paint it and the plot region comes back blank. + await restoreChart(page, SCATTER); + const frames = await captureFrames(page, async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }); + + assertPlotNeverBlank(frames, threshold); + expect(leakErrors).toEqual([]); + }); + + test("toggling during renderer init never leaks a context", async ({ + page, + }) => { + // test.setTimeout(180_000); + const leakErrors = collectLeakErrors(page); + const baseline = await calibratePlotBaseline(page); + expect(baseline).toBeGreaterThan(0); + const threshold = Math.max(1, Math.floor(baseline * 0.5)); + for (let i = 0; i < TOGGLE_COUNT; i++) { + await toggleSecondViewerRacingInit(page, SCATTER); + } + + await restoreChart(page, SCATTER); + const frames = await captureFrames(page, async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }); + + assertPlotNeverBlank(frames, threshold); + expect(leakErrors).toEqual([]); + }); +}); diff --git a/packages/viewer-charts/test/ts/context-pool.spec.ts b/packages/viewer-charts/test/ts/context-pool.spec.ts new file mode 100644 index 0000000000..b5c797d0f5 --- /dev/null +++ b/packages/viewer-charts/test/ts/context-pool.spec.ts @@ -0,0 +1,101 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ConsoleMessage, Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import { + calibrateAllBaselines, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const SCATTER = { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], +}; + +const TABLE_NAME = "load-viewer-csv"; + +const VIEWER_COUNT = 18; + +const LEAK_SIGNATURE = + /too many active webgl contexts|context is lost|present failed|transferToImageBitmap/i; + +function collectLeakErrors(page: Page): string[] { + const hits: string[] = []; + page.on("console", (m: ConsoleMessage) => { + if (LEAK_SIGNATURE.test(m.text())) { + hits.push(m.text()); + } + }); + + page.on("pageerror", (e: Error) => { + if (LEAK_SIGNATURE.test(String(e))) { + hits.push(String(e)); + } + }); + + return hits; +} + +test.describe("Pooled blit WebGL contexts", () => { + test("more simultaneous charts than the context cap all render", async ({ + page, + }) => { + test.setTimeout(240_000); + await gotoBasic(page); + const leakErrors = collectLeakErrors(page); + + await restoreChart(page, SCATTER); + await page.evaluate(() => { + const plugin = ( + document.querySelector("perspective-viewer") as any + ).getPlugin(); + plugin.constructor.setBlitMode("blit"); + plugin.delete(); + }); + await restoreChart(page, SCATTER); + + for (let i = 1; i < VIEWER_COUNT; i++) { + await page.evaluate( + async ({ cfg, tableName }) => { + const worker = (window as any).__TEST_WORKER__; + const table = await worker.open_table(tableName); + const v = document.createElement("perspective-viewer"); + document.body.appendChild(v); + await (v as any).load(table); + await (v as any).restore(cfg); + }, + { cfg: SCATTER, tableName: TABLE_NAME }, + ); + } + + await waitOneFrame(page); + await waitOneFrame(page); + + const baselines = await calibrateAllBaselines(page); + expect(baselines.length).toBe(VIEWER_COUNT); + const ref = Math.max(...baselines); + expect(ref).toBeGreaterThan(0); + + for (let i = 0; i < baselines.length; i++) { + expect( + baselines[i], + `viewer ${i} of ${VIEWER_COUNT} rendered ${baselines[i]} ` + + `plot pixels (ref ${ref}) — likely an evicted context`, + ).toBeGreaterThanOrEqual(ref * 0.5); + } + + expect(leakErrors).toEqual([]); + }); +}); diff --git a/packages/viewer-charts/test/ts/frame-timing.spec.ts b/packages/viewer-charts/test/ts/frame-timing.spec.ts index f131be5e47..d2169532a2 100644 --- a/packages/viewer-charts/test/ts/frame-timing.spec.ts +++ b/packages/viewer-charts/test/ts/frame-timing.spec.ts @@ -36,45 +36,11 @@ import { const PLOT_CX = 640; const PLOT_CY = 360; -/** - * Pan-blank threshold as a fraction of the quiescent baseline. - */ const BLANK_THRESHOLD_FRACTION = 0; -/** - * Render modes to exercise. Each test in the suite runs twice — one - * group per mode. `setBlitMode` on the plugin element flips the - * config on the next renderer construction; we call it after the - * plugin's first activation (so its `_initialized` assertion passes), - * then `plugin.delete()` to tear down the default-mode renderer, and - * re-restore to rebuild in the requested mode. - * - * The two modes have meaningfully different compositor behavior: - * - * - `"blit"`: visible canvas is host-side 2D; worker ships - * `transferToImageBitmap` bitmaps over postMessage. Bitmap is - * fence-synchronized — host blits only fully-painted frames. - * - `"direct"`: visible canvas is `transferControlToOffscreen`'d - * to the worker; the browser's compositor reads it directly, - * unsynchronized with the scheduler's fence. Mid-render and - * just-resized states can be observed by the compositor. - * - * The same blank-frame invariant must hold under both. Direct-mode - * tests previously failed when the resize message handler - * synchronously cleared the visible canvas one task before the next - * RAF's render landed; the in-RAF resize fix - * (`glManager.requestResize` + `applyPendingResize` inside Phase 1) - * pairs the clear with the matching paint in a single un-yielded - * task so the compositor never observes the cleared intermediate. - */ const RENDER_MODES = ["blit", "direct"] as const; type RenderMode = (typeof RENDER_MODES)[number]; -/** - * Each chart-type fixture: a viewer config that produces a useful - * baseline (enough glyphs in the plot region for blank-detection - * to work), and the per-test scaling for the blank threshold. - */ interface ChartFixture { name: string; config: ViewerConfigUpdate; @@ -129,60 +95,29 @@ const FIXTURES: ChartFixture[] = [ }, ]; -/** - * Setup helper: restore the chart in the requested render mode, - * calibrate a quiescent baseline, compute the blank-frame threshold. - * - * The mode-switching dance: - * - * 1. First `restoreChart` activates the plugin (its - * `connectedCallback` runs and `_initialized` becomes true) - * and builds the renderer in the bundle's default mode. - * 2. `plugin.setBlitMode(mode)` records the desired mode for the - * next renderer build. Calling after step 1 means the - * `console.assert(this._initialized, ...)` inside `setBlitMode` - * passes silently. - * 3. `plugin.delete()` destroys the existing renderer transport - * so step 4's draw triggers a fresh `_ensureRenderer` → - * `_buildRenderer` that picks up the new mode. - * 4. Second `restoreChart` rebuilds the renderer in the requested - * mode and re-renders the chart. - * - * Cost: one extra restore per test setup. Worth it to avoid the - * `console.assert` log noise of a pre-activation `setBlitMode`. - */ async function setupChart( page: Page, fixture: ChartFixture, mode: RenderMode, ): Promise<{ baseline: number; threshold: number }> { - // Step 1: activate the plugin in default mode. await restoreChart(page, fixture.config); - // Steps 2 + 3: switch mode + tear down the default-mode - // renderer. The plugin element stays in the DOM; only its - // `RendererTransport` is destroyed. await page.evaluate( ({ mode }) => { const viewer = document.querySelector( "perspective-viewer", ) as unknown as { getPlugin(): unknown }; const plugin = viewer.getPlugin() as { - setBlitMode(mode: "blit" | "direct"): void; + constructor: { setBlitMode(mode: "blit" | "direct"): void }; delete(): void; }; - plugin.setBlitMode(mode); + plugin.constructor.setBlitMode(mode); plugin.delete(); }, { mode }, ); - // Step 4: re-restore. Triggers `draw` → `_ensureRenderer` → - // `_buildRenderer` with `_renderBlitMode = mode`. await restoreChart(page, fixture.config); - - // Two extra RAFs to make sure all paint has settled before we - // measure baseline. `restoreChart` already awaits one. await waitOneFrame(page); await waitOneFrame(page); const baseline = await calibratePlotBaseline(page); diff --git a/packages/viewer-charts/test/ts/helpers.ts b/packages/viewer-charts/test/ts/helpers.ts index d830ac05c1..e3dab136d9 100644 --- a/packages/viewer-charts/test/ts/helpers.ts +++ b/packages/viewer-charts/test/ts/helpers.ts @@ -36,13 +36,6 @@ export async function gotoBasic(page: Page): Promise { }); } -/** - * Restore the viewer with `config`, then wait one animation frame so - * the chart's scheduled render (`requestRender` → scheduler RAF → - * `_fullRender`) has fired. By the time this returns, WebGL draw - * commands have been issued to the GL context and `page.screenshot()` - * will capture them. - */ export async function restoreChart( page: Page, config: ViewerConfigUpdate, @@ -50,7 +43,7 @@ export async function restoreChart( await page.evaluate( async (c) => { const viewer = document.querySelector("perspective-viewer")!; - await (viewer as any).restore(c); + await viewer.restore(c); }, config as unknown as Record, ); @@ -206,12 +199,10 @@ export async function captureFrames( return visit(document); }; - // Cache the canvas reference across ticks. let cachedCanvas: HTMLCanvasElement | null = null; // Sampler canvas: the visible `.webgl-canvas` may have // any of three context modes: - // // - blit mode: 2D context (host blits worker bitmaps // onto it). `getImageData` works directly. // - direct mode: `transferControlToOffscreen` — @@ -220,16 +211,11 @@ export async function captureFrames( // `getImageData` impossible. // - in-process mode: WebGL context owned by main // thread. `getContext("2d")` returns null. - // - // The unifying invariant: in all three modes the canvas - // is a valid image source for `drawImage`. Routing the - // sample through a 2D sampler canvas — `drawImage` copy - // followed by `getImageData` on the sampler — reads - // pixels in every mode without any production code - // change. The sampler is sized to the requested region, - // resized lazily as the source canvas dimensions change. const sampler = document.createElement("canvas"); - const samplerCtx = sampler.getContext("2d"); + const samplerCtx = sampler.getContext("2d", { + willReadFrequently: true, + }); + if (!samplerCtx) { throw new Error( "captureFrames: sampler canvas 2D context unavailable", diff --git a/rust/perspective-viewer/test/js/column_settings/xy.spec.ts b/packages/viewer-charts/test/ts/xy_symbols.spec.ts similarity index 89% rename from rust/perspective-viewer/test/js/column_settings/xy.spec.ts rename to packages/viewer-charts/test/ts/xy_symbols.spec.ts index 8a8c3a96d4..a3f9ab258c 100644 --- a/rust/perspective-viewer/test/js/column_settings/xy.spec.ts +++ b/packages/viewer-charts/test/ts/xy_symbols.spec.ts @@ -10,13 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { - PageView as PspViewer, - compareNodes, - expect, - test, -} from "../helpers.ts"; -import { SymbolPair } from "@perspective-dev/test/src/js/models/column_settings"; +import { PageView as PspViewer, expect, test } from "@perspective-dev/test"; import { Page } from "@playwright/test"; const symbols = [ @@ -36,8 +30,8 @@ async function checkSymbolsSection( editVal: string, ) { // setup viewer - let viewer = new PspViewer(page); - let settingsPanel = await viewer.openSettingsPanel(); + const viewer = new PspViewer(page); + const settingsPanel = await viewer.openSettingsPanel(); const symbolsEditor = viewer.columnSettingsSidebar.styleTab.symbolsEditor; const symbolColumn = settingsPanel.container.locator( "div[data-label=Symbol]", @@ -58,12 +52,12 @@ async function checkSymbolsSection( // setup symbols await symbolsEditor.container.waitFor(); - let key = symbolsEditor.container.locator(".column_name"); - let keyInput = symbolsEditor.container.locator("input"); - let valSelect = symbolsEditor.container.locator("select"); - let value = symbolsEditor.container.locator("option[selected]"); + const key = symbolsEditor.container.locator(".column_name"); + const keyInput = symbolsEditor.container.locator("input"); + const valSelect = symbolsEditor.container.locator("select"); + const value = symbolsEditor.container.locator("option[selected]"); - let setKey = async (inputIdx, keyIdx, val) => { + const setKey = async (inputIdx, keyIdx, val) => { await keyInput.nth(inputIdx).clear(); await keyInput.nth(inputIdx).type(val); await page.locator("perspective-dropdown .selected").waitFor(); @@ -72,7 +66,8 @@ async function checkSymbolsSection( val.toLowerCase(), ); }; - let setValue = async (i, val) => { + + const setValue = async (i, val) => { await valSelect.nth(i).selectOption(val); expect(await value.nth(i).textContent()).toBe(val); }; @@ -84,6 +79,7 @@ async function checkSymbolsSection( await setValue(i, symbols[i + 1]); expect(await symbolsEditor.getPairsLength()).toBe(i + 2); } + // edit await symbolsEditor.container.locator(".column_name").first().dblclick(); await setKey(0, 0, editVal); diff --git a/rust/perspective-viewer/test/js/column_settings/datagrid.spec.ts b/packages/viewer-datagrid/test/js/column_settings.spec.ts similarity index 75% rename from rust/perspective-viewer/test/js/column_settings/datagrid.spec.ts rename to packages/viewer-datagrid/test/js/column_settings.spec.ts index 73aa6229fb..771cc6f0d7 100644 --- a/rust/perspective-viewer/test/js/column_settings/datagrid.spec.ts +++ b/packages/viewer-datagrid/test/js/column_settings.spec.ts @@ -15,13 +15,13 @@ import { compareNodes, expect, test, -} from "../helpers.ts"; +} from "@perspective-dev/test"; test.describe("Datagrid Column Styles", function () { test.beforeEach(async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); @@ -67,14 +67,14 @@ test.describe("Datagrid Column Styles", function () { }); }); -let runTests = (title: string, beforeEachAndLocalTests: () => void) => { +const runTests = (title: string, beforeEachAndLocalTests: () => void) => { return test.describe(title, () => { beforeEachAndLocalTests.call(this); test("Clicking edit button toggles sidebar", async ({ page }) => { - let view = new PspViewer(page); + const view = new PspViewer(page); await view.openSettingsPanel(); - let editBtn = view.dataGrid.regularTable.editBtnRow + const editBtn = view.dataGrid.regularTable.editBtnRow .locator("th.psp-menu-enabled span") .first(); await editBtn.click(); @@ -88,20 +88,20 @@ let runTests = (title: string, beforeEachAndLocalTests: () => void) => { test("Toggling a column in the sidebar highlights in the plugin", async ({ page, }) => { - let view = new PspViewer(page); - let table = view.dataGrid.regularTable; - let activeColumns = view.settingsPanel.activeColumns; + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; + const activeColumns = view.settingsPanel.activeColumns; await view.openSettingsPanel(); - let col = await activeColumns.getFirstVisibleColumn(); - let name = await col.name.innerText(); + const col = await activeColumns.getFirstVisibleColumn(); + const name = await col.name.innerText(); expect(name).toBeDefined(); - let n = await table.getTitleIdx(name); + const n = await table.getTitleIdx(name); expect(n).toBeGreaterThan(-1); - let nthEditBtn = table.realEditBtns.nth(n); - let selectedEditBtn = table.editBtnRow + const nthEditBtn = table.realEditBtns.nth(n); + const selectedEditBtn = table.editBtnRow .locator(".psp-menu-open") .first(); @@ -119,15 +119,15 @@ let runTests = (title: string, beforeEachAndLocalTests: () => void) => { test("Scrolling the table horizontally keeps the correct column highlighted", async ({ page, }) => { - let view = new PspViewer(page); - let table = view.dataGrid.regularTable; + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; - let thirdTitle = table.columnTitleRow.locator("th").nth(3); - let thirdEditBtn = table.editBtnRow.locator("th").nth(3); - let selectedTitle = table.columnTitleRow + const thirdTitle = table.columnTitleRow.locator("th").nth(3); + const thirdEditBtn = table.editBtnRow.locator("th").nth(3); + const selectedTitle = table.columnTitleRow .locator(".psp-menu-open") .first(); - let selectedEditBtn = table.editBtnRow + const selectedEditBtn = table.editBtnRow .locator(".psp-menu-open") .first(); @@ -164,7 +164,7 @@ runTests("Datagrid Column Styles", () => { test.beforeEach(async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); @@ -173,56 +173,56 @@ runTests("Datagrid Column Styles", () => { // These tests only check that a connection is made between the column settings sidebar // and the plugin itself. They do not need to check the exact contents of the plugin. test.skip("Numeric styling", async ({ page }) => { - let view = new PspViewer(page); - let table = view.dataGrid.regularTable; + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; - let col = await view.getOrCreateColumnByType("numeric"); + const col = await view.getOrCreateColumnByType("numeric"); await col.editBtn.click(); - let name = await col.name.innerText(); + const name = await col.name.innerText(); expect(name).toBeTruthy(); - let td = await table.getFirstCellByColumnName(name); + const td = await table.getFirstCellByColumnName(name); await td.waitFor(); // bg style await view.columnSettingsSidebar.openTab("Style"); - let oldContents = await td.evaluate((node) => node.innerHTML); - let listener = await view.getEventListener( + const oldContents = await td.evaluate((node) => node.innerHTML); + const listener = await view.getEventListener( "perspective-column-style-change", ); await page .locator('div[data-value="Decimal"] select') .selectOption("Percent"); expect(await listener()).toBe(true); - let newContents = await td.evaluate((node) => node.innerHTML); + const newContents = await td.evaluate((node) => node.innerHTML); expect(oldContents).not.toBe(newContents); }); test.skip("Calendar styling", async ({ page }) => { - let view = new PspViewer(page); - let table = view.dataGrid.regularTable; - let col = await view.getOrCreateColumnByType("calendar"); - let name = await col.name.innerText(); + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; + const col = await view.getOrCreateColumnByType("calendar"); + const name = await col.name.innerText(); expect(name).toBeTruthy(); - let td = await table.getFirstCellByColumnName(name); + const td = await table.getFirstCellByColumnName(name); await td.waitFor(); // text style view.assureColumnSettingsOpen(col); await view.columnSettingsSidebar.openTab("Style"); - let checkbox = view.columnSettingsSidebar.container + const checkbox = view.columnSettingsSidebar.container .getByRole("checkbox", { disabled: false }) .first(); - let tdStyle = await td.evaluate((node) => { + const tdStyle = await td.evaluate((node) => { return node.style.cssText; }); - let listener = await view.getEventListener( + const listener = await view.getEventListener( "perspective-column-style-change", ); await checkbox.click(); expect(await listener()).toBe(true); - let newStyle = await td.evaluate((node) => { + const newStyle = await td.evaluate((node) => { return node.style.cssText; }); expect(tdStyle).not.toBe(newStyle); @@ -233,38 +233,38 @@ runTests("Datagrid Column Styles", () => { }); test.skip("String styling", async ({ page }) => { - let view = new PspViewer(page); - let table = view.dataGrid.regularTable; + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; - let col = await view.getOrCreateColumnByType("string"); - let name = await col.name.innerText(); + const col = await view.getOrCreateColumnByType("string"); + const name = await col.name.innerText(); expect(name).toBeTruthy(); - let td = await table.getFirstCellByColumnName(name); + const td = await table.getFirstCellByColumnName(name); await td.waitFor(); // bg color await view.assureColumnSettingsOpen(col); await view.columnSettingsSidebar.openTab("Style"); - let container = view.columnSettingsSidebar.container; - let checkbox = container.getByRole("checkbox").last(); + const container = view.columnSettingsSidebar.container; + const checkbox = container.getByRole("checkbox").last(); await checkbox.waitFor(); - let tdStyle = await td.evaluate((node) => node.style.cssText); - let listener = await view.getEventListener( + const tdStyle = await td.evaluate((node) => node.style.cssText); + const listener = await view.getEventListener( "perspective-column-style-change", ); await checkbox.check(); expect(await listener()).toBe(true); - let newStyle = await td.evaluate((node) => node.style.cssText); + const newStyle = await td.evaluate((node) => node.style.cssText); expect(tdStyle).not.toBe(newStyle); }); }); // Keeping the column sidebar open makes this unncessary. test.skip("Edit highlights go away when view re-draws", async ({ page }) => { - let viewer = new PspViewer(page); + const viewer = new PspViewer(page); await viewer.openSettingsPanel(); - let btn = await viewer.dataGrid.regularTable.getEditBtnByName("Row ID"); + const btn = await viewer.dataGrid.regularTable.getEditBtnByName("Row ID"); await btn.click(); await viewer.settingsPanel.groupby("Ship Mode"); await viewer.columnSettingsSidebar.container.waitFor({ @@ -282,7 +282,7 @@ runTests("Datagrid Column Styles - Split-by", () => { await page.goto("/tools/test/src/html/superstore-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); @@ -294,12 +294,12 @@ runTests("Datagrid Column Styles - Split-by", () => { await page.goto("/tools/test/src/html/superstore-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); - let viewer = new PspViewer(page); - let headers = viewer.dataGrid.regularTable.table + const viewer = new PspViewer(page); + const headers = viewer.dataGrid.regularTable.table .locator("thead") .filter({ hasNot: page @@ -309,7 +309,8 @@ runTests("Datagrid Column Styles - Split-by", () => { }); await viewer.openSettingsPanel(); - let btn = await viewer.dataGrid.regularTable.getEditBtnByName("Sales"); + const btn = + await viewer.dataGrid.regularTable.getEditBtnByName("Sales"); await expect(btn).toBeVisible(); await btn.click(); await expect(headers).not.toBeAttached(); diff --git a/packages/viewer-datagrid/test/js/column_style.spec.js b/packages/viewer-datagrid/test/js/column_style.spec.ts similarity index 89% rename from packages/viewer-datagrid/test/js/column_style.spec.js rename to packages/viewer-datagrid/test/js/column_style.spec.ts index 67a4665a21..505cd76ca4 100644 --- a/packages/viewer-datagrid/test/js/column_style.spec.js +++ b/packages/viewer-datagrid/test/js/column_style.spec.ts @@ -12,22 +12,27 @@ import { test, expect } from "@perspective-dev/test"; import { compareContentsToSnapshot } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; -async function test_column(page, selector, container_class) { +async function test_column( + page: Page, + selector: string, + container_class: string, +): Promise { const { x, y } = await page.evaluate(async (selector) => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.getTable(); await viewer.toggleConfig(); - window.__events__ = []; + (window as any).__events__ = []; viewer.addEventListener("perspective-config-update", (evt) => { - window.__events__.push(evt); + (window as any).__events__.push(evt); }); - const header_button = viewer - .querySelector("perspective-viewer-datagrid") - .shadowRoot.querySelector( - "regular-table thead tr:last-child th" + selector, - ); + const header_button = ( + viewer.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector( + "regular-table thead tr:last-child th" + selector, + ); const rect = header_button.getBoundingClientRect(); return { @@ -53,19 +58,19 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", }); }); const { x, y } = await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; // Await the table load await viewer.getTable(); @@ -73,25 +78,23 @@ test.describe("Column Style Tests", () => { await viewer.toggleConfig(); // Register a listener for `perspective-config-update` event - window.__events__ = []; + (window as any).__events__ = []; viewer.addEventListener("perspective-config-update", (evt) => { console.log(evt.type, evt.detail); - window.__events__.push(evt); + (window as any).__events__.push(evt); }); viewer.addEventListener( "perspective-column-style-change", (evt) => { // console.log(evt.type, evt.detail); - window.__events__.push(evt); + (window as any).__events__.push(evt); }, ); // Find the column config menu button - const header_button = viewer - .querySelector("perspective-viewer-datagrid") - .shadowRoot.querySelector( - "regular-table thead tr:last-child th", - ); + const header_button = ( + viewer.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("regular-table thead tr:last-child th"); // Get the button coords (slightly lower than center // because of the location of the menu button within @@ -113,7 +116,7 @@ test.describe("Column Style Tests", () => { const { x: xx, y: yy } = await page.evaluate(async (style_menu) => { // Find the 'bar' button - const bar_button = style_menu.querySelector("select"); + const bar_button = style_menu.querySelector("select")!; // Get its coords const rect = bar_button.getBoundingClientRect(); @@ -127,12 +130,12 @@ test.describe("Column Style Tests", () => { await page.mouse.click(xx, yy); const count = await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; // Await the plugin rendering await viewer.flush(); // Count the events; - return window.__events__.length; + return (window as any).__events__.length; }); // Expect 1 event @@ -142,13 +145,13 @@ test.describe("Column Style Tests", () => { test.skip("Pulse styling works", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Sales"], @@ -177,13 +180,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Sales"], @@ -211,13 +214,13 @@ test.describe("Column Style Tests", () => { test("Column style menu opens for numeric columns", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", }); }); @@ -229,13 +232,13 @@ test.describe("Column Style Tests", () => { test("Column style label-bar", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ columns_config: { "Row ID": { number_fg_mode: "label-bar", @@ -258,13 +261,13 @@ test.describe("Column Style Tests", () => { test("Column style label-bar on an expression column", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ columns_config: { test: { number_fg_mode: "label-bar", @@ -289,13 +292,13 @@ test.describe("Column Style Tests", () => { test("Column style menu opens for string columns", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", }); }); @@ -317,13 +320,13 @@ test.describe("Column Style Tests", () => { test("Bar foreground on float column with negatives", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Profit"], @@ -345,13 +348,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Profit"], @@ -373,13 +376,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Profit"], @@ -409,13 +412,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Profit"], @@ -427,12 +430,12 @@ test.describe("Column Style Tests", () => { }); const { x, y } = await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); - const editBtn = viewer - .querySelector("perspective-viewer-datagrid") - .shadowRoot.querySelector( - "#psp-column-edit-buttons th.psp-menu-enabled:nth-child(2) span", - ); + const viewer = document.querySelector("perspective-viewer")!; + const editBtn = ( + viewer.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector( + "#psp-column-edit-buttons th.psp-menu-enabled:nth-child(2) span", + ); const rect = editBtn.getBoundingClientRect(); return { @@ -473,13 +476,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Sales"], @@ -501,13 +504,13 @@ test.describe("Column Style Tests", () => { test("integer number_format notation renders in grid", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID"], @@ -529,13 +532,13 @@ test.describe("Column Style Tests", () => { test("string format renders in grid", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "State"], @@ -555,13 +558,13 @@ test.describe("Column Style Tests", () => { test("date date_format renders in grid", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Order Date"], @@ -586,13 +589,13 @@ test.describe("Column Style Tests", () => { test("datetime date_format renders in grid", async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", // Order Date is a datetime in basic-test fixture. @@ -625,13 +628,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); const saved = await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Row ID", "Sales"], @@ -654,13 +657,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); const saved = await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ columns: ["Row ID", "Sales"], plugin_config: { edit_mode: "EDIT" }, @@ -682,13 +685,13 @@ test.describe("Column Style Tests", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); const [first, second] = await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); + const viewer = document.querySelector("perspective-viewer")!; const payload = { plugin: "Datagrid", columns: ["Row ID", "Sales"], diff --git a/rust/perspective-viewer/test/js/column_settings/number_string_format.spec.ts b/packages/viewer-datagrid/test/js/number_string_format.spec.ts similarity index 95% rename from rust/perspective-viewer/test/js/column_settings/number_string_format.spec.ts rename to packages/viewer-datagrid/test/js/number_string_format.spec.ts index b941a58905..e24ee58bce 100644 --- a/rust/perspective-viewer/test/js/column_settings/number_string_format.spec.ts +++ b/packages/viewer-datagrid/test/js/number_string_format.spec.ts @@ -15,14 +15,14 @@ import { compareContentsToSnapshot, test, expect, -} from "../helpers.ts"; +} from "@perspective-dev/test"; import { DataGrid } from "@perspective-dev/test/src/js/models/plugins/datagrid.ts"; test.beforeEach(async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); @@ -53,7 +53,7 @@ test.describe("Number & String Format", () => { test.skip(`Rounding Increment doesn't send when ${name} is open`, async ({ page, }) => { - let view = new PageView(page); + const view = new PageView(page); await view.restore({ settings: true, plugin: "Datagrid", @@ -154,7 +154,7 @@ test.describe("Number & String Format", () => { const decimal = await datagrid.regularTable.table.innerHTML(); await page.pause(); - await compareContentsToSnapshot(decimal, ["decimal"]); + await compareContentsToSnapshot(decimal); await view.restore({ plugin: "Datagrid", columns: ["Profit"], @@ -170,7 +170,7 @@ test.describe("Number & String Format", () => { }); const currency = await datagrid.regularTable.table.innerHTML(); - await compareContentsToSnapshot(currency, ["currency"]); + await compareContentsToSnapshot(currency); await view.restore({ plugin: "Datagrid", columns: ["Profit"], @@ -185,7 +185,7 @@ test.describe("Number & String Format", () => { }); const unit = await datagrid.regularTable.table.innerHTML(); - await compareContentsToSnapshot(unit, ["unit"]); + await compareContentsToSnapshot(unit); await view.restore({ plugin: "Datagrid", columns: ["Profit"], @@ -199,6 +199,6 @@ test.describe("Number & String Format", () => { }); const data = await datagrid.regularTable.table.innerHTML(); - await compareContentsToSnapshot(data, ["raw"]); + await compareContentsToSnapshot(data); }); }); diff --git a/packages/viewer-datagrid/test/js/scroll.spec.js b/packages/viewer-datagrid/test/js/scroll.spec.ts similarity index 90% rename from packages/viewer-datagrid/test/js/scroll.spec.js rename to packages/viewer-datagrid/test/js/scroll.spec.ts index 818db46cee..bb81f12ba5 100644 --- a/packages/viewer-datagrid/test/js/scroll.spec.js +++ b/packages/viewer-datagrid/test/js/scroll.spec.ts @@ -12,16 +12,18 @@ import { test } from "@perspective-dev/test"; import { compareContentsToSnapshot } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; import * as prettier from "prettier"; -async function getDatagridContents(page) { +async function getDatagridContents(page: Page): Promise { const raw = await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer perspective-viewer-datagrid", - ); + ) as any; if (!datagrid) { return "MISSING DATAGRID"; } + const regularTable = datagrid.shadowRoot.querySelector("regular-table"); return regularTable?.innerHTML || ""; }); @@ -31,11 +33,11 @@ async function getDatagridContents(page) { }); } -async function getScrollState(page) { +async function getScrollState(page: Page) { return await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); return { scrollTop: regularTable.scrollTop, @@ -48,29 +50,29 @@ async function getScrollState(page) { }); } -async function getVisibleCellData(page) { +async function getVisibleCellData(page: Page) { return await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); const tbody = regularTable.querySelector("table tbody"); const thead = regularTable.querySelector("table thead"); const headerCells = Array.from( thead.querySelectorAll("tr:last-child th"), - ).map((th) => th.textContent.trim()); + ).map((th: any) => th.textContent.trim()); const firstRow = tbody.querySelector("tr"); const firstRowCells = firstRow - ? Array.from(firstRow.querySelectorAll("td")).map((td) => + ? Array.from(firstRow.querySelectorAll("td")).map((td: any) => td.textContent.trim(), ) : []; const lastRow = tbody.querySelector("tr:last-child"); const lastRowCells = lastRow - ? Array.from(lastRow.querySelectorAll("td")).map((td) => + ? Array.from(lastRow.querySelectorAll("td")).map((td: any) => td.textContent.trim(), ) : []; @@ -88,13 +90,13 @@ test.describe("Datagrid scroll tests with superstore data", () => { test.beforeEach(async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", }); }); @@ -107,7 +109,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); @@ -117,7 +119,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrolledData = await getVisibleCellData(page); @@ -133,7 +135,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollTop = @@ -142,7 +144,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrollState = await getScrollState(page); @@ -152,10 +154,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { scrollState.scrollTop + scrollState.clientHeight, ).toBeGreaterThanOrEqual(scrollState.scrollHeight - 1); - compareContentsToSnapshot( - await getDatagridContents(page), - "vertical-scroll-to-bottom.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); test("Vertical scroll position is preserved after flush", async ({ @@ -164,7 +163,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollTop = 500; @@ -172,7 +171,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrollStateAfterFlush = await getScrollState(page); @@ -187,7 +186,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollLeft = 450; @@ -195,7 +194,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrolledData = await getVisibleCellData(page); @@ -213,7 +212,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); @@ -226,7 +225,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrollState = await getScrollState(page); @@ -236,10 +235,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { scrollState.scrollLeft + scrollState.clientWidth, ).toBeGreaterThanOrEqual(scrollState.scrollWidth - 1); - compareContentsToSnapshot( - await getDatagridContents(page), - "horizontal-scroll-to-right-edge.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); test("Horizontal scroll position is preserved after flush", async ({ @@ -248,7 +244,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollLeft = 300; @@ -256,7 +252,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrollStateAfterFlush = await getScrollState(page); @@ -273,7 +269,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollTop = 800; @@ -282,7 +278,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrolledData = await getVisibleCellData(page); @@ -297,17 +293,14 @@ test.describe("Datagrid scroll tests with superstore data", () => { initialData.headerCells, ); - compareContentsToSnapshot( - await getDatagridContents(page), - "combined-scroll-viewport.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); test("Scroll to bottom-right corner", async ({ page }) => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollTop = @@ -318,7 +311,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrollState = await getScrollState(page); @@ -326,10 +319,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { test.expect(scrollState.scrollTop).toBeGreaterThan(0); test.expect(scrollState.scrollLeft).toBeGreaterThan(0); - compareContentsToSnapshot( - await getDatagridContents(page), - "scroll-to-bottom-right-corner.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); }); @@ -338,7 +328,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { page, }) => { await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", group_by: ["State", "City"], columns: ["Sales", "Quantity", "Profit"], @@ -348,7 +338,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollTop = 500; @@ -356,20 +346,17 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); - compareContentsToSnapshot( - await getDatagridContents(page), - "vertical-scroll-with-group-by.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); test("Horizontal scroll with split_by shows correct column groups", async ({ page, }) => { await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", split_by: ["Ship Mode"], columns: ["Sales", "Quantity", "Profit"], @@ -379,7 +366,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollLeft = 300; @@ -387,13 +374,10 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); - compareContentsToSnapshot( - await getDatagridContents(page), - "horizontal-scroll-with-split-by.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); }); @@ -404,7 +388,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollTop = 500; @@ -413,13 +397,13 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ columns: ["State", "City"], }); }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrollState = await getScrollState(page); @@ -434,7 +418,7 @@ test.describe("Datagrid scroll tests with superstore data", () => { await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer-datagrid", - ); + ) as any; const regularTable = datagrid.shadowRoot.querySelector("regular-table"); regularTable.scrollTop = 500; @@ -442,13 +426,13 @@ test.describe("Datagrid scroll tests with superstore data", () => { }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ filter: [["State", "==", "California"]], }); }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); }); const scrollState = await getScrollState(page); diff --git a/packages/viewer-datagrid/test/js/superstore.spec.js b/packages/viewer-datagrid/test/js/superstore.spec.ts similarity index 74% rename from packages/viewer-datagrid/test/js/superstore.spec.js rename to packages/viewer-datagrid/test/js/superstore.spec.ts index 2e00eed91a..8525297537 100644 --- a/packages/viewer-datagrid/test/js/superstore.spec.js +++ b/packages/viewer-datagrid/test/js/superstore.spec.ts @@ -16,16 +16,18 @@ import { run_standard_tests, runPerspectiveEventClickTest, } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; import * as prettier from "prettier"; -async function getDatagridContents(page) { +async function getDatagridContents(page: Page): Promise { const raw = await page.evaluate(async () => { const datagrid = document.querySelector( "perspective-viewer perspective-viewer-datagrid", - ); + ) as any; if (!datagrid) { return "MISSING DATAGRID"; } + const regularTable = datagrid.shadowRoot.querySelector("regular-table"); return regularTable?.innerHTML || ""; }); @@ -39,13 +41,13 @@ test.describe("Datagrid with superstore data set", () => { test.beforeEach(async ({ page }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", }); }); @@ -55,7 +57,7 @@ test.describe("Datagrid with superstore data set", () => { test("Row headers are printed correctly", async ({ page }) => { await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", group_by: ["Ship Date"], split_by: ["Ship Mode"], @@ -63,10 +65,7 @@ test.describe("Datagrid with superstore data set", () => { }); }); - compareContentsToSnapshot( - await getDatagridContents(page), - "row-headers-are-printed-correctly.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); test("Column headers are printed correctly, split_by a date column", async ({ @@ -74,13 +73,13 @@ test.describe("Datagrid with superstore data set", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", columns: ["Sales", "Profit"], group_by: ["State"], @@ -89,10 +88,7 @@ test.describe("Datagrid with superstore data set", () => { }); }); - compareContentsToSnapshot( - await getDatagridContents(page), - "column-headers-are-printed-correctly-split-by-a-date-column.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); test("A filtered-to-empty dataset with group_by and split_by does not error internally", async ({ @@ -100,13 +96,13 @@ test.describe("Datagrid with superstore data set", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", columns: ["Sales", "Profit"], group_by: ["State"], @@ -115,10 +111,7 @@ test.describe("Datagrid with superstore data set", () => { }); }); - compareContentsToSnapshot( - await getDatagridContents(page), - "a-filtered-to-empty-dataset-with-group-by-and-split-by-does-not-error-internally.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); test("An editable datagrid is editable through mouse interaction", async ({ @@ -126,19 +119,29 @@ test.describe("Datagrid with superstore data set", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", plugin_config: { edit_mode: "EDIT", }, columns: ["State", "City", "Customer ID"], }); + + const { resolve, promise } = Promise.withResolvers(); + window.__promise__ = promise; + const view = await document + .querySelector("perspective-viewer")! + .getView(); + + await view.on_update(() => { + resolve(true); + }); }); await shadow_type( @@ -153,8 +156,9 @@ test.describe("Datagrid with superstore data set", () => { ); const result = await page.evaluate(async () => { + await window.__promise__; const view = await document - .querySelector("perspective-viewer") + .querySelector("perspective-viewer")! .getView(); const json = await view.to_json_string({ end_row: 4 }); return json; @@ -170,13 +174,13 @@ test.describe("Datagrid with superstore data set", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); - const td = await page.evaluateHandle(async () => { - await document.querySelector("perspective-viewer").restore({ + const td: any = await page.evaluateHandle(async () => { + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", plugin_config: { edit_mode: "EDIT", @@ -184,18 +188,20 @@ test.describe("Datagrid with superstore data set", () => { columns: ["State", "City", "Customer ID"], }); - return document - .querySelector("perspective-viewer-datagrid") - .shadowRoot.querySelector("table tbody tr td"); + return ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("table tbody tr td"); }); await td.click(); await td.asElement().fill("Test"); - await page.evaluate(() => document.activeElement.blur()); + await page.evaluate(() => + (document.activeElement as HTMLElement | null)?.blur(), + ); const result = await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); + await document.querySelector("perspective-viewer")!.flush(); const view = await document - .querySelector("perspective-viewer") + .querySelector("perspective-viewer")! .getView(); const json = await view.to_json_string({ end_row: 4 }); @@ -215,36 +221,33 @@ test.describe("Datagrid regressions", () => { }) => { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); - const td = await page.evaluateHandle(async () => { - const elem = await document - .querySelector("perspective-viewer-datagrid") - .shadowRoot.querySelector("regular-table"); + const td: any = await page.evaluateHandle(async () => { + const elem = ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("regular-table"); elem.scrollLeft = 5000; await elem.draw(); - return document - .querySelector("perspective-viewer-datagrid") - .shadowRoot.querySelector("table thead tr th:nth-child(3)"); + return ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("table thead tr th:nth-child(3)"); }); await td.click(); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").flush(); - const elem = await document - .querySelector("perspective-viewer-datagrid") - .shadowRoot.querySelector("regular-table"); + await document.querySelector("perspective-viewer")!.flush(); + const elem = ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("regular-table"); elem.scrollLeft = 0; await elem.draw(); }); - compareContentsToSnapshot( - await getDatagridContents(page), - "sorting-a-column-does-not-break-viewport-emtadata.txt", - ); + await compareContentsToSnapshot(await getDatagridContents(page)); }); }); @@ -254,13 +257,13 @@ test.describe("Datagrid with superstore arrow data set", () => { "/tools/test/src/html/all-types-small-multi-arrow-test.html", ); await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { await new Promise((x) => setTimeout(x, 10)); } }); await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ + await document.querySelector("perspective-viewer")!.restore({ plugin: "Datagrid", }); }); diff --git a/packages/viewer-datagrid/test/js/tsconfig.json b/packages/viewer-datagrid/test/js/tsconfig.json new file mode 100644 index 0000000000..5cdea6dce5 --- /dev/null +++ b/packages/viewer-datagrid/test/js/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "allowImportingTsExtensions": true + }, + "include": ["./**/*.ts"] +} diff --git a/packages/workspace/src/themes/pro-dark.css b/packages/workspace/src/themes/pro-dark.css index ed8af1cd72..a8ee5a175c 100644 --- a/packages/workspace/src/themes/pro-dark.css +++ b/packages/workspace/src/themes/pro-dark.css @@ -80,6 +80,7 @@ perspective-workspace { --psp-expression--function--color: #22a0ce; --psp-expression--error--color: rgb(255, 136, 136); --psp-calendar--filter: invert(1); + --psp-input--filter: invert(1); --psp-warning--color: #242526; --psp-warning--background: var(--psp--color); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4cdb959073..8cef746ab5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,7 +42,6 @@ catalog: # Dev Dependencies "@clickhouse/client-web": "^1.12.0" "@duckdb/duckdb-wasm": "1.33.1-dev18.0" - "@duckdb/duckdb-wasm-shell": "^1.30.0" "@fontsource/roboto-mono": "4.5.10" "@iarna/toml": "3.0.0" "@jupyterlab/builder": "^4" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index c50148cdcb..4e8277af92 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -11,6 +11,6 @@ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ [toolchain] -channel = "nightly-2026-01-01" +channel = "nightly-2026-06-01" components = ["rustfmt", "clippy", "rust-src"] targets = ["wasm32-unknown-unknown", "wasm32-unknown-emscripten"] diff --git a/rust/perspective-client/src/rust/config/aggregates.rs b/rust/perspective-client/src/rust/config/aggregates.rs index c771fd5385..e7f2e07af5 100644 --- a/rust/perspective-client/src/rust/config/aggregates.rs +++ b/rust/perspective-client/src/rust/config/aggregates.rs @@ -52,9 +52,9 @@ impl From for view_config::AggList { fn from(value: Aggregate) -> Self { view_config::AggList { aggregations: match value { - Aggregate::SingleAggregate(x) => vec![format!("{}", x)], + Aggregate::SingleAggregate(x) => vec![x], Aggregate::MultiAggregate(x, y) => { - vec![format!("{}", x), format!("{}", y.join(","))] + vec![x, y.join(",")] }, }, } diff --git a/rust/perspective-python/Cargo.toml b/rust/perspective-python/Cargo.toml index 7ecabceb6f..5a1796f20f 100644 --- a/rust/perspective-python/Cargo.toml +++ b/rust/perspective-python/Cargo.toml @@ -51,7 +51,7 @@ protobuf-src = ["perspective-client/protobuf-src"] crate-type = ["cdylib"] [build-dependencies] -cmake = "0.1.50" +cmake = "0.1.58" num_cpus = "^1.16.0" pyo3-build-config = "0.25.1" python-config-rs = "0.1.2" diff --git a/rust/perspective-python/src/client/client_sync.rs b/rust/perspective-python/src/client/client_sync.rs index 46b9cac9a1..e125e3f629 100644 --- a/rust/perspective-python/src/client/client_sync.rs +++ b/rust/perspective-python/src/client/client_sync.rs @@ -165,6 +165,7 @@ impl Client { /// ```python /// table = client.table("x,y\n1,2\n3,4") /// ``` + #[allow(clippy::too_many_arguments)] #[pyo3(signature = (input, limit=None, index=None, name=None, format=None, page_to_disk=None))] pub fn table( &self, diff --git a/rust/perspective-server/Cargo.toml b/rust/perspective-server/Cargo.toml index 8ab995a238..9fa4376b1e 100644 --- a/rust/perspective-server/Cargo.toml +++ b/rust/perspective-server/Cargo.toml @@ -39,7 +39,7 @@ python = [] disable-cpp = [] [build-dependencies] -cmake = "0.1.50" +cmake = "0.1.58" num_cpus = "^1.15.0" shlex = "1.3.0" protobuf-src = { version = "2.1.1" } diff --git a/rust/perspective-server/cpp/perspective/CMakeLists.txt b/rust/perspective-server/cpp/perspective/CMakeLists.txt index dd7e666f74..029ea01403 100644 --- a/rust/perspective-server/cpp/perspective/CMakeLists.txt +++ b/rust/perspective-server/cpp/perspective/CMakeLists.txt @@ -207,6 +207,10 @@ if(PSP_PYTHON_BUILD AND MACOS) set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -undefined dynamic_lookup") endif() +if(EMSCRIPTEN) + set(CMAKE_HAVE_LIBC_PTHREAD TRUE CACHE BOOL "psp: satisfy FindThreads on WASM without enabling pthreads" FORCE) +endif() + # ###################### set(psp_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/src/include") @@ -576,23 +580,12 @@ else() # -s MEMORY_GROWTH_GEOMETRIC_STEP=1.0 \ # -s MEMORY_GROWTH_GEOMETRIC_CAP=536870912 \ + set(PSP_FILESYSTEM_FLAGS "-s NO_FILESYSTEM=1 ") + set(PSP_CLOSURE_FLAGS "--closure=1 ") + # On-disk tables in the browser are backed by OPFS via emscripten's WasmFS. - # `PSP_WASM_OPFS` swaps the default `NO_FILESYSTEM` (memory-only) build for - # `WASMFS` + `FORCE_FILESYSTEM`, and enables the OPFS backend. `JSPI` provides - # the synchronous-looking OPFS access the WasmFS OPFS backend needs without - # SharedArrayBuffer/cross-origin-isolation (`-pthread`). `JSPI` is backwards - # compatible: the module loads and runs in-memory tables fine on non-JSPI - # browsers (it has no `main`/async imports wrapped at instantiation); the JSPI - # API is only reached when an actual OPFS op runs, i.e. when `page_to_disk` is used - # (the JS client gates that on JSPI being present). Closure is incompatible - # with the WasmFS JS glue, so it is disabled in that case. if(PSP_WASM_OPFS) - set(PSP_FILESYSTEM_FLAGS "-s NO_FILESYSTEM=1 ") - set(PSP_CLOSURE_FLAGS "--closure=1 ") add_compile_definitions(PSP_WASM_OPFS_PERSIST=1) - else() - set(PSP_FILESYSTEM_FLAGS "-s NO_FILESYSTEM=1 ") - set(PSP_CLOSURE_FLAGS "--closure=1 ") endif() set(PSP_WASM_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} \ diff --git a/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp b/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp index c7b55f693e..ea5a1a9f8b 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp @@ -934,9 +934,7 @@ ArrowLoader::fill_column( std::int64_t null_count = array->null_count(); if (null_count == 0) { - for (uint32_t i = 0; i < len; ++i) { - col->set_valid(offset + i, true); - } + col->set_valid_range(offset, len); } else { const uint8_t* null_bitmap = array->null_bitmap_data(); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/column.cpp b/rust/perspective-server/cpp/perspective/src/cpp/column.cpp index 369058281c..95408f0ddc 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/column.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/column.cpp @@ -620,6 +620,17 @@ t_column::set_status(t_uindex idx, t_status status) { m_status->set_nth(idx, status); } +void +t_column::set_valid_range(t_uindex offset, t_uindex len) { + if (!is_status_enabled() || len == 0) { + return; + } + static_assert( + sizeof(t_status) == 1, "set_valid_range assumes a 1-byte t_status" + ); + std::memset(m_status->get_nth(offset), STATUS_VALID, len); +} + void t_column::set_scalar(t_uindex idx, t_tscalar value) { COLUMN_CHECK_ACCESS(idx); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp b/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp index 6477d629e7..155538bfe8 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp @@ -102,6 +102,27 @@ t_computed_expression::t_computed_expression( m_column_ids(column_ids), m_dtype(dtype) {} +struct t_computed_expression_cache { + t_computed_expression_cache() = default; + t_computed_expression_cache(const t_computed_expression_cache&) = delete; + t_computed_expression_cache& operator=(const t_computed_expression_cache&) = + delete; + t_computed_expression_cache(t_computed_expression_cache&&) = delete; + t_computed_expression_cache& operator=(t_computed_expression_cache&&) = + delete; + + std::shared_ptr m_current_source_table; + t_uindex m_row_idx{0}; + std::vector> m_values; + std::vector> m_columns; + std::vector m_input_dtypes; + exprtk::symbol_table m_sym_table; + exprtk::expression m_expr; + std::unique_ptr m_function_store; +}; + +t_computed_expression::~t_computed_expression() = default; + void t_computed_expression::compute( const std::shared_ptr& source_table, @@ -110,53 +131,94 @@ t_computed_expression::compute( t_expression_vocab& vocab, t_regex_mapping& regex_mapping ) const { - // TODO: share symtables across pre/re/compute - exprtk::symbol_table sym_table; + auto num_input_columns = m_column_ids.size(); - // pi, infinity, etc. - sym_table.add_constants(); + bool needs_build = !m_cache; + if (!needs_build) { + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + const std::string& column_name = m_column_ids[cidx].second; + if (source_table->get_column(column_name)->get_dtype() + != m_cache->m_input_dtypes[cidx]) { + needs_build = true; + break; + } + } + } - t_uindex row_idx = 0; + if (needs_build) { + m_cache = std::make_unique(); + auto& cache = *m_cache; + + cache.m_current_source_table = source_table; + cache.m_sym_table.add_constants(); // pi, infinity, etc. + + // is_type_validator = false: we are computing values, not type-checking. + cache.m_function_store = std::make_unique( + vocab, + regex_mapping, + false, + cache.m_current_source_table, + pkey_map, + cache.m_row_idx + ); + cache.m_function_store->register_computed_functions(cache.m_sym_table); - // Create a function store, with is_type_validator set to false as we - // are calculating values, not type-checking. - t_computed_function_store function_store( - vocab, regex_mapping, false, source_table, pkey_map, row_idx - ); - function_store.register_computed_functions(sym_table); + // Size exactly once; the symbol table binds a T* into each + // m_values[cidx].second, so this storage must never be reallocated. + cache.m_values.resize(num_input_columns); + cache.m_columns.resize(num_input_columns); + cache.m_input_dtypes.resize(num_input_columns); - exprtk::expression expr_definition; - std::vector> values; - tsl::hopscotch_map> columns; + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + const std::string& column_id = m_column_ids[cidx].first; + const std::string& column_name = m_column_ids[cidx].second; + t_dtype dtype = source_table->get_column(column_name)->get_dtype(); - auto num_input_columns = m_column_ids.size(); - values.resize(num_input_columns); - columns.reserve(num_input_columns); + cache.m_input_dtypes[cidx] = dtype; - for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { - const std::string& column_id = m_column_ids[cidx].first; - const std::string& column_name = m_column_ids[cidx].second; - columns[column_id] = source_table->get_column(column_name); + t_tscalar rval; + rval.clear(); + rval.m_type = dtype; - t_tscalar rval; - rval.clear(); - rval.m_type = columns[column_id]->get_dtype(); + cache.m_values[cidx] = + std::pair(column_id, rval); + cache.m_sym_table.add_variable( + column_id, cache.m_values[cidx].second + ); + } - values[cidx] = std::pair(column_id, rval); - sym_table.add_variable(column_id, values[cidx].second); + cache.m_expr.register_symbol_table(cache.m_sym_table); + + // Load-bearing for compile-once: exprtk constant-folds an all-literal + // function call (e.g. intern('x'), concat('a','b')) to a literal node at + // compile time ONLY when the function reports no side effects. Our + // functions inherit the igeneric_function/ifunction default + // has_side_effects() == true and never call disable_has_side_effects(), + // so such calls are not folded and re-evaluate on every value(). If a + // future exprtk bump flips that default, the first call's result would + // be frozen into the cached AST -- revisit this cache if so. + if (!m_computed_expression_parser.m_parser->compile( + m_parsed_expression_string, cache.m_expr + )) { + std::stringstream ss; + ss << "[t_computed_expression::compute] Failed to parse " + "expression: `" + << m_parsed_expression_string << "`, failed with error: " + << m_computed_expression_parser.m_parser->error() << '\n'; + + PSP_COMPLAIN_AND_ABORT(ss.str()); + } } - expr_definition.register_symbol_table(sym_table); - - if (!m_computed_expression_parser.m_parser->compile( - m_parsed_expression_string, expr_definition - )) { - std::stringstream ss; - ss << "[t_computed_expression::compute] Failed to parse expression: `" - << m_parsed_expression_string << "`, failed with error: " - << m_computed_expression_parser.m_parser->error() << '\n'; + auto& cache = *m_cache; - PSP_COMPLAIN_AND_ABORT(ss.str()); + // Re-point the per-call inputs the compiled function objects read through + // (the source table differs across the master/flattened/delta/prev/current + // tables within a single update), and re-fetch this call's source columns. + cache.m_current_source_table = source_table; + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + cache.m_columns[cidx] = + source_table->get_column(m_column_ids[cidx].second); } // create or get output column using m_expression_alias @@ -167,12 +229,13 @@ t_computed_expression::compute( for (t_uindex ridx = 0; ridx < num_rows; ++ridx) { for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { - const std::string& column_id = m_column_ids[cidx].first; - values[cidx].second.set(columns[column_id]->get_scalar(ridx)); + cache.m_values[cidx].second.set( + cache.m_columns[cidx]->get_scalar(ridx) + ); } - row_idx = ridx; + cache.m_row_idx = ridx; - t_tscalar value = expr_definition.value(); + t_tscalar value = cache.m_expr.value(); if (!value.is_valid() || value.is_none()) { output_column->clear(ridx); @@ -182,7 +245,8 @@ t_computed_expression::compute( output_column->set_scalar(ridx, value); } - function_store.clear_computed_function_state(); + // order()'s accumulator must still be reset at the end of every call. + cache.m_function_store->clear_computed_function_state(); }; const std::string& diff --git a/rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp b/rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp index 4f27aa9f98..b0d098178b 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp @@ -2330,12 +2330,12 @@ make_datetime::operator()(t_parameter_list parameters) { index::index( const t_gstate::t_mapping& pkey_map, - std::shared_ptr source_table, + const std::shared_ptr& source_table, t_uindex& row_idx ) : exprtk::igeneric_function("Z"), m_pkey_map(pkey_map), - m_source_table(std::move(std::move(source_table))), + m_source_table(source_table), m_row_idx(row_idx) {} index::~index() = default; @@ -2355,13 +2355,13 @@ index::operator()(t_parameter_list parameters) { col::col( t_expression_vocab& expression_vocab, bool is_type_validator, - std::shared_ptr source_table, + const std::shared_ptr& source_table, t_uindex& row_idx ) : exprtk::igeneric_function("T"), m_expression_vocab(expression_vocab), m_is_type_validator(is_type_validator), - m_source_table(std::move(std::move(source_table))), + m_source_table(source_table), m_row_idx(row_idx) {} col::~col() = default; @@ -2394,13 +2394,13 @@ col::operator()(t_parameter_list parameters) { vlookup::vlookup( t_expression_vocab& expression_vocab, bool is_type_validator, - std::shared_ptr source_table, + const std::shared_ptr& source_table, t_uindex& row_idx ) : exprtk::igeneric_function("TT"), m_expression_vocab(expression_vocab), m_is_type_validator(is_type_validator), - m_source_table(std::move(std::move(source_table))), + m_source_table(source_table), m_row_idx(row_idx) {} vlookup::~vlookup() = default; diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp index aaf62e3751..737df3ef64 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp @@ -152,12 +152,12 @@ t_ctx_grouped_pkey::get_data( } auto* aggtable = m_tree->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; ++aggidx) { - const std::string& aggname = aggschema.m_columns[aggidx]; - aggcols[aggidx] = aggtable->_get_const_column(aggname); + // resolve by column index (== aggidx); skips the per-iteration + // name->index map lookup + temp string and the schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); } const std::vector& aggspecs = m_config.get_aggregates(); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp index 951e7431b5..909f503045 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp @@ -121,7 +121,7 @@ std::pair t_ctx1::get_min_max(const std::string& colname) const { auto rval = std::make_pair(mknone(), mknone()); auto* aggtable = m_tree->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); + const t_schema& aggschema = aggtable->get_schema(); const auto* col = aggtable->_get_const_column(colname); auto colidx = aggschema.get_colidx(colname); auto depth = m_config.get_num_rpivots(); @@ -182,13 +182,14 @@ t_ctx1::get_data( std::vector aggcols(m_config.get_num_aggregates()); auto* aggtable = m_tree->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); auto none = mknone(); for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; ++aggidx) { - const std::string& aggname = aggschema.m_columns[aggidx]; - aggcols[aggidx] = aggtable->_get_const_column(aggname); + // `aggidx` is the column's position in the aggtable schema, so resolve + // by index — avoids the per-iteration name->index map lookup + a temp + // std::string, and the up-front schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); } const std::vector& aggspecs = m_config.get_aggregates(); @@ -239,13 +240,14 @@ t_ctx1::get_data(const std::vector& rows) const { std::vector aggcols(m_config.get_num_aggregates()); auto* aggtable = m_tree->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); auto none = mknone(); for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; ++aggidx) { - const std::string& aggname = aggschema.m_columns[aggidx]; - aggcols[aggidx] = aggtable->_get_const_column(aggname); + // `aggidx` is the column's position in the aggtable schema, so resolve + // by index — avoids the per-iteration name->index map lookup + a temp + // std::string, and the up-front schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); } const std::vector& aggspecs = m_config.get_aggregates(); @@ -527,18 +529,18 @@ t_ctx1::get_rows_changed() { const auto& deltas = m_tree->get_deltas(); auto eidx = t_uindex(m_traversal->size()); + // `idx` increases monotonically and is pushed at most once, so `rows` is + // already unique and sorted: the per-iteration `std::find` (which made this + // O(n^2)) and the trailing `std::sort` were both dead work. for (t_uindex idx = 0; idx < eidx; ++idx) { t_index ptidx = m_traversal->get_tree_index(idx); // Retrieve delta from storage and check if the row has been changed auto iterators = deltas->get().equal_range(ptidx); - bool unique_ridx = - std::find(rows.begin(), rows.end(), idx) == rows.end(); - if ((iterators.first != iterators.second) && unique_ridx) { + if (iterators.first != iterators.second) { rows.push_back(idx); } } - std::sort(rows.begin(), rows.end()); return rows; } @@ -618,13 +620,14 @@ t_ctx1::pprint() const { std::vector aggcols(m_config.get_num_aggregates()); auto* aggtable = m_tree->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); auto none = mknone(); for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; ++aggidx) { - const std::string& aggname = aggschema.m_columns[aggidx]; - aggcols[aggidx] = aggtable->_get_const_column(aggname); + // `aggidx` is the column's position in the aggtable schema, so resolve + // by index — avoids the per-iteration name->index map lookup + a temp + // std::string, and the up-front schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); } const std::vector& aggspecs = m_config.get_aggregates(); @@ -905,8 +908,8 @@ t_ctx1::unity_init_load_step_end() {} std::shared_ptr t_ctx1::get_table() const { - auto schema = m_tree->get_aggtable()->get_schema(); - auto pivots = m_config.get_row_pivots(); + const t_schema& schema = m_tree->get_aggtable()->get_schema(); + const auto& pivots = m_config.get_row_pivots(); auto tbl = std::make_shared(schema, m_tree->size()); tbl->init(); tbl->extend(m_tree->size()); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp index 8f0d6d595e..15f0589c8d 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp @@ -262,11 +262,11 @@ t_ctx2::get_min_max(const std::string& colname) const { std::vector aggcols(ntrees * n_aggs); for (t_uindex treeidx = 0; treeidx < ntrees; ++treeidx) { auto* aggtable = m_trees[treeidx]->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); for (t_uindex aggidx = 0; aggidx < t_uindex(n_aggs); ++aggidx) { - const std::string& aggname = aggschema.m_columns[aggidx]; + // resolve by column index (== aggidx); skips the name->index map + // lookup + temp string and the schema deep-copy. aggcols[treeidx * n_aggs + aggidx] = - aggtable->_get_const_column(aggname); + aggtable->_get_const_column(aggidx); } } @@ -348,12 +348,11 @@ t_ctx2::get_data( for (t_uindex treeidx = 0; treeidx < ntrees; ++treeidx) { auto* aggtable = m_trees[treeidx]->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); - for (t_uindex aggidx = 0; aggidx < naggs; ++aggidx) { - const std::string& aggname = aggschema.m_columns[aggidx]; + // resolve by column index (== aggidx); skips the per-iteration + // name->index map lookup + temp string and the schema deep-copy. aggcols[treeidx * naggs + aggidx] = - aggtable->_get_const_column(aggname); + aggtable->_get_const_column(aggidx); } } @@ -449,12 +448,11 @@ t_ctx2::get_data(const std::vector& rows) const { for (t_uindex treeidx = 0; treeidx < ntrees; ++treeidx) { auto* aggtable = m_trees[treeidx]->get_aggtable(); - t_schema aggschema = aggtable->get_schema(); - for (t_uindex aggidx = 0; aggidx < naggs; ++aggidx) { - const std::string& aggname = aggschema.m_columns[aggidx]; + // resolve by column index (== aggidx); skips the per-iteration + // name->index map lookup + temp string and the schema deep-copy. aggcols[treeidx * naggs + aggidx] = - aggtable->_get_const_column(aggname); + aggtable->_get_const_column(aggidx); } } @@ -1133,6 +1131,9 @@ t_ctx2::get_rows_changed() { t_uindex ncols = get_num_view_columns(); std::vector rows; std::vector> cells; + if (ncols > 1) { + cells.reserve(nrows * (ncols - 1)); + } // get cells and imbue with additional information for (t_uindex ridx = 0; ridx < nrows; ++ridx) { @@ -1143,6 +1144,10 @@ t_ctx2::get_rows_changed() { auto cells_info = resolve_cells(cells); + // Cells are visited in row-major order and `resolve_cells` preserves it, so + // `m_ridx` is non-decreasing across `cells_info`. Tracking the last appended + // row dedups in O(1) with no allocation and leaves `rows` already ascending, + // replacing both the old O(n^2) `std::find` and the trailing `std::sort`. for (const auto& c : cells_info) { if (c.m_idx < 0) { continue; @@ -1150,14 +1155,12 @@ t_ctx2::get_rows_changed() { const auto& deltas = m_trees[c.m_treenum]->get_deltas(); auto iterators = deltas->get().equal_range(c.m_idx); auto ridx = c.m_ridx; - bool unique_ridx = - std::find(rows.begin(), rows.end(), ridx) == rows.end(); - if ((iterators.first != iterators.second) && unique_ridx) { + bool has_delta = iterators.first != iterators.second; + if (has_delta && (rows.empty() || rows.back() != ridx)) { rows.push_back(ridx); } } - std::sort(rows.begin(), rows.end()); return rows; } diff --git a/rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp b/rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp index 4e7ea77988..a9247d21d4 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp @@ -57,7 +57,7 @@ t_dtree_ctx::build_aggregates() { std::vector columns; std::vector dtypes; - t_schema delta_schema = m_strand_deltas->get_schema(); + const t_schema& delta_schema = m_strand_deltas->get_schema(); for (const auto& spec : m_aggspecs) { auto cinfo = spec.get_output_specs(delta_schema); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp b/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp index eb7e55151f..7245ffb796 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp @@ -719,14 +719,16 @@ t_gnode::send(t_uindex port_id, const t_data_table& fragments) { PSP_TRACE_SENTINEL(); PSP_VERBOSE_ASSERT(m_init, "Cannot `send` to an uninited gnode."); - if (m_input_ports.count(port_id) == 0) { + // Single lookup instead of `count()` + `operator[]` (which would also + // default-insert a null port on a miss). + auto port_iter = m_input_ports.find(port_id); + if (port_iter == m_input_ports.end()) { std::cerr << "Cannot send table to port `" << port_id << "`, which does not exist." << '\n'; return; } - std::shared_ptr& input_port = m_input_ports[port_id]; - input_port->send(fragments); + port_iter->second->send(fragments); } void diff --git a/rust/perspective-server/cpp/perspective/src/cpp/storage.cpp b/rust/perspective-server/cpp/perspective/src/cpp/storage.cpp index 9ae9b513e5..ac5856ec81 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/storage.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/storage.cpp @@ -367,7 +367,7 @@ void t_lstore::reserve_impl(t_uindex capacity, bool allow_shrink) { PSP_TRACE_SENTINEL(); PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); - ensure_resident(); + // ensure_resident(); if ((capacity < m_capacity) && !allow_shrink) { return; } @@ -525,7 +525,7 @@ t_lstore::get_fname() const { void t_lstore::push_back(const void* ptr, t_uindex len) { PSP_TRACE_SENTINEL(); - ensure_resident(); + // ensure_resident(); if (m_size + len >= m_capacity) { reserve(static_cast(m_size + len) ); // reserve() will multiply by m_resize_factor internally @@ -544,13 +544,11 @@ t_lstore::push_back(const void* ptr, t_uindex len) { void* t_lstore::get_ptr(t_uindex offset) { - ensure_resident(); return static_cast(static_cast(m_base) + offset); } const void* t_lstore::get_ptr(t_uindex offset) const { - const_cast(this)->ensure_resident(); return static_cast(static_cast(m_base) + offset); } @@ -572,7 +570,7 @@ void t_lstore::append(const t_lstore& other) { PSP_TRACE_SENTINEL(); PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); - const_cast(other).ensure_resident(); + // const_cast(other).ensure_resident(); push_back(other.m_base, other.size()); } @@ -613,12 +611,6 @@ t_lstore::get_recipe() const { t_lstore_recipe t_lstore::get_clone_recipe() const { t_lstore_recipe rval = get_recipe(); - // `get_recipe()` produces a `from_recipe` recipe that re-maps *this* store's - // backing file — correct for serialization/reconstruction, but wrong for - // cloning a `BACKING_STORE_DISK` store: the copy would map (and, on - // destruction, `rmfile`) the source's file. Clearing `m_from_recipe` makes - // the `t_lstore` constructor mint a fresh, independent backing file for the - // clone. (Memory stores ignore the file entirely, so this is a no-op there.) if (m_backing_store == BACKING_STORE_DISK) { rval.m_from_recipe = false; } @@ -629,7 +621,7 @@ void t_lstore::fill(const t_lstore& other) { PSP_TRACE_SENTINEL(); PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); - const_cast(other).ensure_resident(); + // const_cast(other).ensure_resident(); reserve(other.size()); memcpy(m_base, const_cast(other.m_base), size_t(other.size())); set_size(other.size()); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp b/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp index 7838b1472b..a7ec718a6d 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp @@ -15,6 +15,7 @@ #include #include #include +#include namespace perspective { @@ -684,14 +685,101 @@ t_traversal::get_expanded(std::vector& expanded_tidx) const { void t_traversal::drop_tree_indices(const std::vector& indices) { + if (indices.empty() || m_nodes->empty()) { + return; + } + + auto& nodes = *m_nodes; + const t_index n = static_cast(nodes.size()); + + // The previous implementation removed each dropped strand individually: a + // linear `tree_index_lookup` (O(N)) plus a mid-vector `erase` (O(N)) and a + // per-op `update_sucessors`/`update_ancestors` fix-up, i.e. O(D*N) for D + // drops. Instead, mark every dropped subtree and rebuild the pre-order node + // vector in a single O(N + D) pass, recomputing the relative-parent + // (m_rel_pidx), descendant (m_ndesc) and child (m_nchild) encoding in bulk. + // This mirrors the one-pass rebuild `sort_by` already uses. + tsl::hopscotch_set drop_set; + drop_set.reserve(indices.size()); for (auto idx : indices) { - t_index tvidx = tree_index_lookup(idx, 0); - if (tvidx == INVALID_INDEX) { + drop_set.insert(static_cast(idx)); + } + + // `remap[i]` = number of survivors in [0, i), which is also the new index of + // the survivor at old index `i`. A node is dead iff it lies within the + // subtree span of any dropped node; because the traversal is pre-order and + // dropped subtrees are nested-or-disjoint, a single non-decreasing "dead + // watermark" (the last index known to be inside a dropped subtree) + // classifies every node in one linear scan. `m_tnid` is unique per traversal + // node, so set membership matches what the old per-strand lookup found. + std::vector remap(n + 1); + t_index surv = 0; + t_index dead_until = INVALID_INDEX; + for (t_index i = 0; i < n; ++i) { + if (drop_set.find(nodes[i].m_tnid) != drop_set.end()) { + const t_index end = i + static_cast(nodes[i].m_ndesc); + if (end > dead_until) { + dead_until = end; + } + } + remap[i] = surv; + if (i > dead_until) { + ++surv; + } + } + remap[n] = surv; + + // The root (tnid 0, depth 0) is never a strand and so is never dropped, so + // at least it must survive. A future caller dropping the root would empty + // the traversal and OOB downstream consumers (e.g. get_expanded_span reads + // node 0), so assert the invariant here. + PSP_VERBOSE_ASSERT(surv >= 1, "drop_tree_indices dropped the root node"); + + if (surv == n) { + // No dropped strand was actually present in this traversal. + return; + } + + // Compact survivors into a fresh pre-order vector, recomputing the relative + // encoding from the survivor prefix-sum. + std::vector new_nodes(surv); + for (t_index old_idx = 0; old_idx < n; ++old_idx) { + // Alive iff a survivor was counted at this index. + if ((remap[old_idx + 1] - remap[old_idx]) != 1) { continue; } - remove_subtree(tvidx); + const t_index new_idx = remap[old_idx]; + t_tvnode node = nodes[old_idx]; + + // A surviving node's descendants stay contiguous immediately after it, + // so count the survivors over its original subtree span + // [old_idx, old_end] and subtract the node itself. + const t_index old_end = old_idx + static_cast(node.m_ndesc); + node.m_ndesc = + static_cast(remap[old_end + 1] - remap[old_idx] - 1); + + // A survivor's parent always survives (only whole subtrees are dropped), + // so its new parent index is well-defined. + if (node.m_depth > 0) { + const t_index old_parent = old_idx - node.m_rel_pidx; + node.m_rel_pidx = new_idx - remap[old_parent]; + } + + node.m_nchild = 0; // recomputed in the next pass + new_nodes[new_idx] = node; } + + // Recompute direct-child counts: each surviving non-root node bumps its + // (surviving) parent's count. + for (t_index new_idx = 0; new_idx < surv; ++new_idx) { + const t_tvnode& node = new_nodes[new_idx]; + if (node.m_depth > 0) { + new_nodes[new_idx - node.m_rel_pidx].m_nchild += 1; + } + } + + std::swap(nodes, new_nodes); } bool diff --git a/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp b/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp index eaba09e5dd..488f92ab21 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp @@ -50,7 +50,7 @@ notify_sparse_tree_common( strand_deltas->pprint(); } - auto pivots = tree->get_pivots(); + const auto& pivots = tree->get_pivots(); t_dtree dtree(strands, pivots, tree_sortby); dtree.init(); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp b/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp index 8ce1ffa081..f1b2bd5711 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp @@ -137,11 +137,11 @@ t_view_config::get_used_expressions() { std::inserter(used_cols, used_cols.end()) ); - for (auto i : m_filter) { + for (const auto& i : m_filter) { used_cols.insert(std::get<0>(i)); } - for (auto i : m_sort) { + for (const auto& i : m_sort) { used_cols.insert(i[0]); } diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/column.h b/rust/perspective-server/cpp/perspective/src/include/perspective/column.h index a5f298bae5..907c56ffc4 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/column.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/column.h @@ -126,6 +126,11 @@ class PERSPECTIVE_EXPORT t_column { void set_valid(t_uindex idx, bool valid); + // Mark `[offset, offset + len)` as `STATUS_VALID` in one pass. Bulk + // equivalent of a `set_valid(i, true)` loop, used for the null-free + // chunk fast path in the Arrow loader. + void set_valid_range(t_uindex offset, t_uindex len); + void set_status(t_uindex idx, t_status status); void set_size(t_uindex size); diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h index 4401449cd6..3e9e0963d2 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h @@ -42,6 +42,12 @@ struct PERSPECTIVE_EXPORT t_expression_error { class PERSPECTIVE_EXPORT t_computed_expression; +// Per-expression compiled-expression cache (compile-once / rebind-per-call). +// Defined in computed_expression.cpp and held by `t_computed_expression` via a +// `mutable unique_ptr` so the compiled exprtk AST and the storage its variable +// and function nodes point at stay heap-pinned across calls. +struct t_computed_expression_cache; + class PERSPECTIVE_EXPORT t_computed_expression_parser { public: t_computed_expression_parser(); @@ -154,6 +160,10 @@ class PERSPECTIVE_EXPORT t_computed_expression { t_dtype dtype ); + // Out-of-line: `m_cache` is a unique_ptr to an incomplete type, so the + // destructor must be defined where that type is complete (the .cpp). + ~t_computed_expression(); + void compute( const std::shared_ptr& source_table, const t_gstate::t_mapping& pkey_map, @@ -176,6 +186,12 @@ class PERSPECTIVE_EXPORT t_computed_expression { t_computed_expression_parser m_computed_expression_parser; std::vector> m_column_ids; t_dtype m_dtype; + + // Lazily-built compiled-expression cache (see compute()). `mutable` because + // compute() is const; `unique_ptr` keeps the cache heap-pinned so exprtk's + // bound variable (T*) and function (ifunction*) pointers stay valid across + // calls. Invalidated + rebuilt if an input column's dtype is promoted. + mutable std::unique_ptr m_cache; }; /** diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h index 74418dab45..f4768dbf52 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h @@ -192,10 +192,16 @@ namespace computed_function { // Length of the string FUNCTION_HEADER(length) + // NOTE: `index`/`col`/`vlookup` hold `m_source_table` and `m_row_idx` by + // reference, not by value, so a single compiled expression can be re-pointed + // at a different source table / row on each call without recompiling (see + // `t_computed_expression::compute`). The referents must outlive the function + // object: the compile-once cache owns stable slots, and the throwaway + // precompute/get_dtype stores bind to their local args for their own scope. struct index final : public exprtk::igeneric_function { index( const t_pkey_mapping& pkey_map, - std::shared_ptr source_table, + const std::shared_ptr& source_table, t_uindex& row_idx ); ~index(); @@ -203,14 +209,14 @@ namespace computed_function { private: const t_pkey_mapping& m_pkey_map; - std::shared_ptr m_source_table; + const std::shared_ptr& m_source_table; t_uindex& m_row_idx; }; struct col final : public exprtk::igeneric_function { col(t_expression_vocab& expression_vocab, bool is_type_validator, - std::shared_ptr source_table, + const std::shared_ptr& source_table, t_uindex& row_idx); ~col(); t_tscalar operator()(t_parameter_list parameters) override; @@ -218,7 +224,7 @@ namespace computed_function { private: t_expression_vocab& m_expression_vocab; bool m_is_type_validator; - std::shared_ptr m_source_table; + const std::shared_ptr& m_source_table; t_uindex& m_row_idx; }; @@ -226,7 +232,7 @@ namespace computed_function { vlookup( t_expression_vocab& expression_vocab, bool is_type_validator, - std::shared_ptr source_table, + const std::shared_ptr& source_table, t_uindex& row_idx ); ~vlookup(); @@ -235,7 +241,7 @@ namespace computed_function { private: t_expression_vocab& m_expression_vocab; bool m_is_type_validator; - std::shared_ptr m_source_table; + const std::shared_ptr& m_source_table; t_uindex& m_row_idx; }; diff --git a/rust/perspective-viewer/src/rust/components/style/local_style.rs b/rust/perspective-viewer/src/css/_column_dropdown.css similarity index 50% rename from rust/perspective-viewer/src/rust/components/style/local_style.rs rename to rust/perspective-viewer/src/css/_column_dropdown.css index facbc72354..1a1c9efa50 100644 --- a/rust/perspective-viewer/src/rust/components/style/local_style.rs +++ b/rust/perspective-viewer/src/css/_column_dropdown.css @@ -1,35 +1,14 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -use yew::prelude::*; - -use super::style_cache::*; - -#[derive(Properties)] -pub struct LocalStyleProps { - pub href: (&'static str, &'static str), -} - -impl PartialEq for LocalStyleProps { - fn eq(&self, _other: &Self) -> bool { - true - } -} - -#[function_component(LocalStyle)] -pub fn local_style(props: &LocalStyleProps) -> Html { - if let Some(cache) = use_context::() { - cache.add_style(props.href.0, props.href.1); - } - - html! {} -} +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ Copyright (c) 2017, the Perspective Authors. ┃ + * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ + * ┃ This file is part of the Perspective library, distributed under the terms ┃ + * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +@import "column-dropdown.css"; diff --git a/rust/perspective-viewer/src/css/_dropdown_menu.css b/rust/perspective-viewer/src/css/_dropdown_menu.css new file mode 100644 index 0000000000..955c5393dd --- /dev/null +++ b/rust/perspective-viewer/src/css/_dropdown_menu.css @@ -0,0 +1,14 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ Copyright (c) 2017, the Perspective Authors. ┃ + * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ + * ┃ This file is part of the Perspective library, distributed under the terms ┃ + * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +@import "containers/dropdown-menu.css"; diff --git a/rust/perspective-viewer/src/css/_filter_dropdown.css b/rust/perspective-viewer/src/css/_filter_dropdown.css new file mode 100644 index 0000000000..22c733a418 --- /dev/null +++ b/rust/perspective-viewer/src/css/_filter_dropdown.css @@ -0,0 +1,14 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ Copyright (c) 2017, the Perspective Authors. ┃ + * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ + * ┃ This file is part of the Perspective library, distributed under the terms ┃ + * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +@import "filter-dropdown.css"; diff --git a/rust/perspective-viewer/src/css/_function_dropdown.css b/rust/perspective-viewer/src/css/_function_dropdown.css new file mode 100644 index 0000000000..a02a0a3656 --- /dev/null +++ b/rust/perspective-viewer/src/css/_function_dropdown.css @@ -0,0 +1,14 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ Copyright (c) 2017, the Perspective Authors. ┃ + * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ + * ┃ This file is part of the Perspective library, distributed under the terms ┃ + * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +@import "function-dropdown.css"; diff --git a/rust/perspective-viewer/src/css/_viewer_bundle.css b/rust/perspective-viewer/src/css/_viewer_bundle.css new file mode 100644 index 0000000000..2f51c5ad51 --- /dev/null +++ b/rust/perspective-viewer/src/css/_viewer_bundle.css @@ -0,0 +1,41 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ Copyright (c) 2017, the Perspective Authors. ┃ + * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ + * ┃ This file is part of the Perspective library, distributed under the terms ┃ + * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +@import "dom/checkbox.css"; +@import "dom/scrollbar.css"; +@import "dom/select.css"; + +@import "containers/split-panel.css"; +@import "containers/scroll-panel.css"; +@import "containers/pairs-list.css"; +@import "containers/tabs.css"; + +@import "type-icon.css"; +@import "aggregate-selector.css"; +@import "empty-column.css"; +@import "filter-item.css"; +@import "config-selector.css"; +@import "column-selector.css"; +@import "column-style.css"; +@import "column-symbol-attributes.css"; +@import "column-settings-panel.css"; +@import "expression-editor.css"; +@import "plugin-selector.css"; +@import "plugin-settings-panel.css"; +@import "render-warning.css"; +@import "status-bar.css"; + +@import "form/code-editor.css"; +@import "form/debug.css"; + +@import "viewer.css"; diff --git a/rust/perspective-viewer/src/css/viewer.css b/rust/perspective-viewer/src/css/viewer.css index 1ef023397b..503004701c 100644 --- a/rust/perspective-viewer/src/css/viewer.css +++ b/rust/perspective-viewer/src/css/viewer.css @@ -94,8 +94,8 @@ } :host input[type="number"]::-webkit-inner-spin-button { - transform: scale(1.5); /* Makes the arrows 50% larger */ - filter: invert(1); + transform: scale(1.5) translateX(-5px); /* Makes the arrows 50% larger */ + filter: var(--psp-input--filter); } :host .sidebar_close_button { diff --git a/rust/perspective-viewer/src/rust/components/column_dropdown.rs b/rust/perspective-viewer/src/rust/components/column_dropdown.rs index 83b5ea8042..b9fe40f57d 100644 --- a/rust/perspective-viewer/src/rust/components/column_dropdown.rs +++ b/rust/perspective-viewer/src/rust/components/column_dropdown.rs @@ -22,12 +22,11 @@ use yew::prelude::*; use super::column_selector::InPlaceColumn; use super::portal::PortalModal; +use super::style::StyleSurface; use crate::session::Session; use crate::utils::*; use crate::*; -static CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/css/column-dropdown.css")); - /// Shared state for the column dropdown, updated imperatively. #[derive(Default)] pub struct ColumnDropDownState { @@ -207,6 +206,7 @@ impl Component for ColumnDropDownPortal { html! { Html { props.width, props.width ); - html! { <>{ body } } + html! { <>{ body } } } diff --git a/rust/perspective-viewer/src/rust/components/column_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector.rs index d349ffd6fd..309a1207bc 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector.rs @@ -39,10 +39,8 @@ use self::config_selector::ConfigSelector; use self::inactive_column::*; use super::containers::scroll_panel::*; use super::containers::split_panel::{Orientation, SplitPanel}; -use super::style::LocalStyle; use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal}; use crate::components::containers::scroll_panel_item::ScrollPanelItem; -use crate::css; use crate::presentation::{ColumnLocator, DragDropContainer, Presentation}; use crate::queries::{ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet}; use crate::renderer::*; @@ -519,7 +517,6 @@ impl Component for ColumnSelector { html! { <> - -
wrapper_class="aggregate-selector" diff --git a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs index 33b6d0fcf7..a40450f168 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs @@ -25,8 +25,6 @@ use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPo use crate::components::containers::dragdrop_list::*; use crate::components::containers::select::{Select, SelectItem}; use crate::components::filter_dropdown::{FilterDropDownElement, FilterDropDownPortal}; -use crate::components::style::LocalStyle; -use crate::css; use crate::presentation::Presentation; use crate::renderer::*; use crate::session::drag_drop_update::*; @@ -609,7 +607,6 @@ impl Component for ConfigSelector { html! { <>
-
if group_rollups.len() > 1 { diff --git a/rust/perspective-viewer/src/rust/components/column_selector/empty_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/empty_column.rs index 2a66a3d62f..eaae32e627 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/empty_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/empty_column.rs @@ -17,8 +17,6 @@ use web_sys::*; use yew::prelude::*; use crate::components::column_dropdown::ColumnDropDownElement; -use crate::components::style::LocalStyle; -use crate::css; #[derive(Properties)] pub struct EmptyColumnProps { @@ -70,7 +68,6 @@ impl Component for EmptyColumn { html! {
- -
// diff --git a/rust/perspective-viewer/src/rust/components/column_selector/invalid_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/invalid_column.rs index ea26c28d69..ba01626cb5 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/invalid_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/invalid_column.rs @@ -12,14 +12,10 @@ use yew::prelude::*; -use crate::components::style::LocalStyle; -use crate::css; - #[function_component(InvalidColumn)] pub fn invalid_column() -> Html { html! {
-
} diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs index 31758bdd0e..dad3e3d2c5 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs @@ -31,14 +31,12 @@ use crate::components::containers::sidebar::Sidebar; use crate::components::containers::tab_list::TabList; use crate::components::editable_header::EditableHeaderProps; use crate::components::expression_editor::ExpressionEditorProps; -use crate::components::style::LocalStyle; use crate::components::type_icon::TypeIconType; use crate::presentation::{ColumnLocator, ColumnSettingsTab, Presentation}; use crate::renderer::Renderer; use crate::session::{Session, SessionMetadataRc}; use crate::tasks::{delete_expr, save_expr, update_expr}; use crate::utils::PtrEqRc; -use crate::*; #[derive(Clone, Derivative, Properties)] #[derivative(Debug)] @@ -356,7 +354,6 @@ impl Component for ColumnSettingsPanel { html! { <> - - -
    { for main_pairs }
diff --git a/rust/perspective-viewer/src/rust/components/containers/dropdown_menu.rs b/rust/perspective-viewer/src/rust/components/containers/dropdown_menu.rs index 94f7fa6ed7..10cee2527f 100644 --- a/rust/perspective-viewer/src/rust/components/containers/dropdown_menu.rs +++ b/rust/perspective-viewer/src/rust/components/containers/dropdown_menu.rs @@ -17,8 +17,6 @@ use web_sys::*; use yew::prelude::*; use super::select::SelectItem; -use crate::components::style::LocalStyle; -use crate::*; #[derive(Properties, PartialEq)] pub struct DropDownMenuProps @@ -95,7 +93,7 @@ where html! { { "No Completions" } } }; - html! { <>{ body } } + html! { <>{ body } } } } diff --git a/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs b/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs index a9793778c3..b27367ecf3 100644 --- a/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs +++ b/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs @@ -22,8 +22,6 @@ use yew::prelude::*; use yew::virtual_dom::VChild; use super::scroll_panel_item::ScrollPanelItem; -use crate::components::style::LocalStyle; -use crate::css; use crate::utils::*; #[derive(Properties)] @@ -253,7 +251,7 @@ impl Component for ScrollPanel { } }; - html! { <>{ items } } + html! { <>{ items } } } fn rendered(&mut self, ctx: &Context, _first_render: bool) { diff --git a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs index abac636c2e..6ae627b612 100644 --- a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs +++ b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs @@ -19,9 +19,6 @@ use web_sys::HtmlElement; use yew::html::Scope; use yew::prelude::*; -use crate::components::style::LocalStyle; -use crate::css; - #[derive(Properties, Default)] pub struct SplitPanelProps { pub children: Children, @@ -201,7 +198,6 @@ impl Component for SplitPanel { let count = iter.len(); let contents = html! { <> - for (i, x) in iter { if i == 0 { if count == 1 { diff --git a/rust/perspective-viewer/src/rust/components/containers/tab_list.rs b/rust/perspective-viewer/src/rust/components/containers/tab_list.rs index 90e1ca60a8..00e44986c1 100644 --- a/rust/perspective-viewer/src/rust/components/containers/tab_list.rs +++ b/rust/perspective-viewer/src/rust/components/containers/tab_list.rs @@ -12,8 +12,6 @@ use yew::{Callback, Children, Component, Html, Properties, classes, html}; -use crate::components::style::LocalStyle; -use crate::css; use crate::presentation::ColumnTab; #[derive(Properties, Debug, PartialEq)] @@ -87,7 +85,6 @@ impl Component for TabList { html! { <> -
{ for gutter_tabs }
{ ctx.props().children.iter().nth(self.selected_idx) } diff --git a/rust/perspective-viewer/src/rust/components/datetime_column_style.rs b/rust/perspective-viewer/src/rust/components/datetime_column_style.rs index 3363274831..8c92da2c1b 100644 --- a/rust/perspective-viewer/src/rust/components/datetime_column_style.rs +++ b/rust/perspective-viewer/src/rust/components/datetime_column_style.rs @@ -23,12 +23,10 @@ use wasm_bindgen::prelude::*; use yew::prelude::*; use super::modal::{ModalLink, SetModalLink}; -use super::style::LocalStyle; use crate::components::datetime_column_style::custom::DatetimeStyleCustom; use crate::components::datetime_column_style::simple::DatetimeStyleSimple; use crate::components::form::select_value_field::SelectValueField; use crate::config::*; -use crate::css; use crate::utils::WeakScope; /// Format-only widget for `datetime` columns. Renders the `date_format` @@ -126,7 +124,6 @@ impl Component for DatetimeColumnStyle { fn view(&self, ctx: &Context) -> Html { html! { <> -
if ctx.props().enable_time_config { diff --git a/rust/perspective-viewer/src/rust/components/empty_row.rs b/rust/perspective-viewer/src/rust/components/empty_row.rs index a26997ea12..795eafe5e2 100644 --- a/rust/perspective-viewer/src/rust/components/empty_row.rs +++ b/rust/perspective-viewer/src/rust/components/empty_row.rs @@ -20,8 +20,6 @@ use web_sys::*; use yew::prelude::*; use crate::components::filter_dropdown::FilterDropDownElement; -use crate::components::style::LocalStyle; -use crate::css; #[derive(Properties, Derivative)] #[derivative(Debug)] @@ -87,7 +85,6 @@ impl Component for EmptyRow { html! {
- , error: Option, oninput: Callback>, + /// Monotonically increasing request id used to drop stale /// validation results when the user types faster than the engine /// can validate. validation_req_id: u64, + /// The id of the most recently dispatched validation; the result /// is only applied when its echoed id matches. last_dispatched_req_id: u64, @@ -131,7 +131,6 @@ impl Component for ExpressionEditor { clone!(ctx.props().disabled); html! { <> -
, @@ -220,6 +219,7 @@ impl Component for FilterDropDownPortal { html! { Html { } }; - html! { <>{ body } } + html! { <>{ body } } } fn filter_values( diff --git a/rust/perspective-viewer/src/rust/components/font_loader.rs b/rust/perspective-viewer/src/rust/components/font_loader.rs index 39a6a6bcd1..7ddc1ab059 100644 --- a/rust/perspective-viewer/src/rust/components/font_loader.rs +++ b/rust/perspective-viewer/src/rust/components/font_loader.rs @@ -154,12 +154,12 @@ impl FontLoaderProps { } } - let fut = async { select_all(block_fonts.into_iter()).await.0 }; + let fut = async { select_all(block_fonts).await.0 }; block_promises.push(ApiFuture::new(fut)) } if block_promises.len() != preload_fonts.len() { - web_sys::console::warn_1(&format!("Missing preload fonts {:?}", &preload_fonts).into()); + web_sys::console::warn_1(&format!("Missing preload fonts {:?}", preload_fonts).into()); } let res = join_all(block_promises) diff --git a/rust/perspective-viewer/src/rust/components/form/code_editor.rs b/rust/perspective-viewer/src/rust/components/form/code_editor.rs index d8c81141e4..34672cf091 100644 --- a/rust/perspective-viewer/src/rust/components/form/code_editor.rs +++ b/rust/perspective-viewer/src/rust/components/form/code_editor.rs @@ -19,8 +19,6 @@ use yew::prelude::*; use crate::components::form::highlight::highlight; use crate::components::function_dropdown::{FunctionDropDownElement, FunctionDropDownPortal}; -use crate::components::style::LocalStyle; -use crate::css; use crate::exprtk::{Cursor, tokenize}; use crate::utils::*; @@ -174,7 +172,6 @@ pub fn code_editor(props: &CodeEditorProps) -> Html { clone!(props.disabled); html! { <> -
{ line_numbers }
diff --git a/rust/perspective-viewer/src/rust/components/form/debug.rs b/rust/perspective-viewer/src/rust/components/form/debug.rs index 2e32429e02..0fae01db69 100644 --- a/rust/perspective-viewer/src/rust/components/form/debug.rs +++ b/rust/perspective-viewer/src/rust/components/form/debug.rs @@ -18,8 +18,6 @@ use wasm_bindgen::prelude::*; use yew::prelude::*; use crate::components::form::code_editor::CodeEditor; -use crate::components::style::LocalStyle; -use crate::css; use crate::js::{MimeType, copy_to_clipboard, paste_from_clipboard}; use crate::presentation::*; use crate::renderer::*; @@ -166,8 +164,6 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html { html! { <> - -