// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ 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 { View } from "@perspective-dev/client"; import { createNumberFormatter, createDatetimeFormatter, createDateFormatter, sourceColumn, type NumberFormatConfig, type DateFormatConfig, } from "@perspective-dev/viewer/src/ts/column-format.js"; import type { ColumnDataMap } from "../data/view-reader"; import { LazyRowFetcher } from "../data/lazy-row"; import { formatTickValue, formatDateTickValue } from "../layout/ticks"; import type { WebGLContextManager } from "../webgl/context-manager"; import { ZoomController, type ZoomConfig, } from "../interaction/zoom-controller"; import { DEFAULT_FACET_CONFIG, DEFAULT_PLUGIN_CONFIG, type ChartImplementation, type FacetConfig, type PluginConfig, } from "./chart"; import { TooltipController, type HostSink, type TooltipCallbacks, type UserClickPayload, type UserSelectPayload, } from "../interaction/tooltip-controller"; import type { PerspectiveClickDetail } from "../event-detail"; import type { ViewConfig } from "@perspective-dev/client"; import { resolveThemeFromVars, type Theme } from "../theme/theme"; import { requestRender as scheduleRender } from "../render/scheduler"; // TODO I don't know if this is the behavior we want. On the plus side, this // ad-hoc formatter scales well to small and large data ranges, making a good // guess at the right format without user input. On the minus side, this // behavior is inconsistent with datagrid and the rest of the app, and the ad-hoc // surprising behavior when overriding one field in `number_format` and suddenly // the entire formatter is replaced. const REGRESSION_BEHAVIOR = true; /** * Locale-aware fallback formatter applied to numeric tooltip / legend * values when the column has no `number_format` configured. Two * fractional digits matches the legacy datagrid default and gives * tooltips a stable display width. */ const DEFAULT_VALUE_FORMATTER: (v: number) => string = ((): (( v: number, ) => string) => { if (REGRESSION_BEHAVIOR) { return formatTickValue; } else { const intl = createNumberFormatter("float"); return (v) => intl.format(v); } })(); /** * Locale-aware fallback formatter for datetime tooltip / legend values * when the column has no `date_format` configured. Uses the locale * default (no `dateStyle` / `timeStyle`) to match what most users * expect from an `Intl.DateTimeFormat()` constructed with no options. */ const DEFAULT_DATETIME_FORMATTER: (v: number) => string = ((): (( v: number, ) => string) => { if (REGRESSION_BEHAVIOR) { return formatDateTickValue; } else { const intl = createDatetimeFormatter(); return (v) => intl.format(v); } })(); /** * Base class for WebGL chart implementations. Owns the common lifecycle * plumbing (canvas wiring, viewer config setters, tooltip controller) * so each concrete chart only implements data pipeline, rendering, and * destruction hooks. * * ## Frame lifecycle (three phases) * * Every render of a chart passes through three phases: * * 1. `uploadAndRender(glManager, columns, startRow, endRow)`. * Driven by the plugin wrapper once per data chunk. The subclass * runs its build pipeline (axis/series resolution, record * generation, domain accumulation) and pushes typed-array results * into GPU buffers via `glManager.bufferPool`. Most charts also * compile their shaders lazily here on first call. * * 2. `requestRender(glManager)` — single entrypoint for triggering a * paint. Routes through the module-level scheduler * ([render/scheduler.ts]) which coalesces by glManager and runs * `_fullRender` + `awaitGpuFence` + `endFrame` on the next RAF. * Concurrent requests collapse to one `_fullRender` per frame and * fence waits across charts run in parallel, so per-chart latency * is bounded by that chart's own GPU work. * * 3. `_fullRender(glManager)` — the subclass implements its own draw * loop: resolve visible domains from the zoom controller, build * projection matrices, call into its glyph draw helpers, and paint * the chrome overlay (axes, legend, tooltip). * * `destroy()` is called by the plugin wrapper on teardown. It detaches * tooltip listeners, then invokes the subclass's `destroyInternal()` * to free chart-specific GL resources. * * ## What subclasses implement * - `uploadAndRender` — phase 1; ends by `await this.requestRender(glManager)`. * - `tooltipCallbacks()` — return chart-specific hover/click handlers. * - `_fullRender` — phase 3; must be safe to call with no data * (subclass guards on its own state machine — empty trees, missing * programs, etc — and returns early without touching GL). * - `destroyInternal` — release chart-specific resources. * * `getZoomConfig()` is an optional override; default = both axes * zoom-unlocked. See {@link ZoomConfig}. */ export abstract class AbstractChart implements ChartImplementation { // Access is `public` so the per-chart helper modules // (e.g. `./bar/bar-build.ts`) can read/write these without fighting // TypeScript's `protected` check. The underscore prefix marks them // as internal by convention. _glManager: WebGLContextManager | null = null; _gridlineCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; _chromeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; /** * Host-supplied CSS-variable map. The host snapshots its DOM via * `snapshotThemeVars(el)` and ships it over the control channel; * the chart decodes via `resolveThemeFromVars` lazily in * `_resolveTheme()`. The chart never reads the DOM itself (it * always runs inside `WorkerRenderer`, possibly off-thread). */ _themeVars: Record = {}; _zoomController: ZoomController | null = null; /** * Per-facet zoom controllers. Populated when `zoom_mode === * "independent"` and the chart enters faceted mode; each facet's * render path reads its own viewport from the matching entry. * * Shared-zoom mode leaves this empty; `_zoomController` is the * single domain used for every facet. */ _facetZoomControllers: ZoomController[] = []; _columnSlots: (string | null)[] = []; _groupBy: string[] = []; _splitBy: string[] = []; _columnTypes: Record = {}; /** * Effective shared-axis flags for the most recent faceted frame. * Derived per-frame from `_facetConfig.shared_x_axis` / * `shared_y_axis` and `zoom_mode` via * {@link computeEffectiveFacetFlags} — independent-zoom mode forces * both off because an outer axis band has no single domain it could * display. Stored here (rather than mutated back onto * `_facetConfig`) so the user's configured shared-axis preferences * survive a "shared → independent → shared" round-trip. Read by * chrome-overlay code (e.g. `renderFacetedChromeOverlay`, * `renderFacetedHeatmapChromeOverlay`) after the main render pass * sets them. */ _lastEffectiveSharedX = false; _lastEffectiveSharedY = false; /** * Source-column types for `group_by` columns — sourced from * `table.schema()` (plain columns) merged with `view.expression_schema()` * (expression-typed group_bys). Distinct from `_columnTypes` (which * is the post-aggregation `view.schema()` map): the level-type * lookup for `__ROW_PATH_N__` columns must use the unaggregated * type, since `view.schema()` doesn't key these synthetic columns. */ _groupByTypes: Record = {}; _columnsConfig: Record = {}; /** * Pre-compiled per-column value formatters, keyed by the **source** * column name (synthetic split-by paths are normalized via * `sourceColumn`). Rebuilt by `setColumnsConfig` from the active * plugin's `column_config_schema` output, then consulted by axis / * tooltip / legend paths via {@link getColumnFormatter}. * * `undefined` means "no configured formatter for this column" — the * caller falls back to the chart's hand-rolled tick formatter. */ _columnFormatters: Map string> = new Map(); _defaultChartType: string | undefined = undefined; _facetConfig: FacetConfig = { ...DEFAULT_FACET_CONFIG }; /** * Plugin-scoped global configuration. Updated by `setPluginConfig` * (driven from the host's `plugin.restore()`) and read by render- * path glyphs (`line_width_px`, `point_size_px`, etc.) and by the * build pipelines (`auto_alt_y_axis`, `band_inner_frac`, * `bar_inner_pad`). Defaults preserve the previous compile-time * constants so first-frame rendering before `restore()` matches * the pre-refactor output. */ _pluginConfig: PluginConfig = { ...DEFAULT_PLUGIN_CONFIG }; _tooltip = new TooltipController(); /** * Reference to the active host sink, captured in {@link attachTooltip}. * Used to emit `perspective-click` / `perspective-global-filter` user * events back to the host. Distinct from `_tooltip._host` to avoid * reaching into the tooltip controller's internals. */ _hostSink: HostSink | null = null; /** * Promise chain that serializes user-event emissions so a rapid * pin → unpin sequence stays in order even when `buildClickDetail` * awaits `_lazyRows.fetchRow`. Without the queue, click 1's async * row fetch could resolve AFTER click 2's synchronous `emitUnselect` * — flipping the host's observed event order. All emit helpers * (`emitClickAndSelect`, `emitUserClick`, `emitUserSelect`, * `emitUnselect`) chain through this. */ _emitQueue: Promise = Promise.resolve(); /** * Cached resolved theme — populated on first `_resolveTheme()` call, * cleared by `invalidateTheme()` (driven from `plugin.restyle()`). * `getComputedStyle` / `getPropertyValue` reads cost ~100µs each; * zoom/hover dispatch redraws at 60Hz so we resolve once and reuse. */ _theme: Theme | null = null; /** * On-demand single-row fetcher used by lazy tooltip column * lookups. Reset on every `setView` call; subclasses read * `_lazyRows.fetchRow(rowIdx)` from their hover/pin paths and * compare a captured serial against the current hovered/pinned * state at resolution time, so stale fetches never paint. * * Can be `null` on chart types that don't surface the View * (unit-tested charts) or before the first `draw`. */ _lazyRows: LazyRowFetcher | null = null; // ChartImplementation setters (trivial stores) setGridlineCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void { this._gridlineCanvas = canvas; } setChromeCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void { this._chromeCanvas = canvas; } setTheme(vars: Record): void { this._themeVars = vars; this._theme = null; } setZoomController(zc: ZoomController): void { this._zoomController = zc; zc.configure(this.getZoomConfig()); } /** * Resolve the zoom controller that owns facet `idx`. In shared-zoom * mode (default) this is always the chart's single `_zoomController`. * In independent-zoom mode the router provisions one controller per * facet; this returns the matching entry, allocating on demand so * the render path never has to check `zoom_mode` itself. */ getZoomControllerForFacet(idx: number): ZoomController | null { if (this._facetConfig.zoom_mode === "shared") { return this._zoomController; } if (!this._zoomController) { return null; } let zc = this._facetZoomControllers[idx]; if (!zc) { zc = new ZoomController(); zc.configure(this.getZoomConfig()); this._facetZoomControllers[idx] = zc; } return zc; } /** * Derive the effective shared-X / shared-Y flags for the current * frame and stamp them onto `_lastEffectiveSharedX/Y` for downstream * chrome-overlay code to consume. Independent-zoom mode forces both * shared flags off — the outer axis band cannot display per-cell * viewports — without mutating the user's stored `_facetConfig`. * * Returns `{ independentZoom, effectiveSharedX, effectiveSharedY }` * for callers that need the values immediately (e.g. to pass * `xAxis: "outer" | "cell"` into `buildFacetGrid`). */ computeEffectiveFacetFlags(): { independentZoom: boolean; effectiveSharedX: boolean; effectiveSharedY: boolean; } { const independentZoom = this._facetConfig.zoom_mode === "independent"; const effectiveSharedX = !independentZoom && this._facetConfig.shared_x_axis; const effectiveSharedY = !independentZoom && this._facetConfig.shared_y_axis; this._lastEffectiveSharedX = effectiveSharedX; this._lastEffectiveSharedY = effectiveSharedY; return { independentZoom, effectiveSharedX, effectiveSharedY }; } /** * Wire every active zoom controller's layout pointer for the * supplied facet cells. In shared-zoom mode every * `getZoomControllerForFacet(i)` returns the same `_zoomController`, * so iterating past the first cell would just re-write the same * pointer — `break`-on-shared keeps the cost O(1) and avoids the * subtle bug where every facet's `updateLayout` overwrites the * previous one with the last cell's layout. */ syncFacetZoomLayouts( cells: ReadonlyArray<{ layout: import("../layout/plot-layout").PlotLayout; }>, ): void { const independent = this._facetConfig.zoom_mode === "independent"; for (let i = 0; i < cells.length; i++) { this.getZoomControllerForFacet(i)?.updateLayout(cells[i].layout); if (!independent) { return; } } } /** * Set base domain on every zoom controller owned by this chart. */ setZoomBaseDomain( xMin: number, xMax: number, yMin: number, yMax: number, ): void { if (this._zoomController) { this._zoomController.setBaseDomain(xMin, xMax, yMin, yMax); } for (const zc of this._facetZoomControllers) { if (zc) { zc.setBaseDomain(xMin, xMax, yMin, yMax); } } } /** * Zoom-controller config for this chart type. Subclasses override to * pin an axis (e.g. bar charts pin the categorical axis). Default: * both axes freely zoomable. */ protected getZoomConfig(): ZoomConfig { return {}; } setColumnSlots(slots: (string | null)[]): void { this._columnSlots = slots; } setViewPivots(groupBy: string[], splitBy: string[]): void { this._groupBy = groupBy; this._splitBy = splitBy; } setColumnTypes(schema: Record): void { this._columnTypes = schema; this._rebuildColumnFormatters(); } /** * Clear any `domain_mode: "expand"` accumulator state. Driven by * `plugin.draw()` (a fresh `draw` always indicates a view-level * change — viewer config, filters, sorts, etc. — that invalidates * the previously-accumulated extent) and by the worker's * `resetAllZooms` path (user clicked "Reset Zoom"). `plugin.update()` * deliberately does *not* call this — same view, more data, the * accumulator should keep growing. No-op on the base; chart * families that hold accumulator fields override. */ resetExpandedDomain(): void {} setGroupByTypes(schema: Record): void { this._groupByTypes = schema; } setColumnsConfig(cfg: Record): void { this._columnsConfig = cfg ?? {}; this._rebuildColumnFormatters(); } /** * Rebuild {@link _columnFormatters} from `_columnsConfig` + * `_columnTypes`. Called from both `setColumnsConfig` and * `setColumnTypes` since either side of the (config, types) pair * can arrive first depending on the host's restore order. Idempotent. */ private _rebuildColumnFormatters(): void { this._columnFormatters = new Map(); for (const [name, columnCfg] of Object.entries(this._columnsConfig)) { // `_columnTypes` is the post-aggregation `view.schema()` map // and doesn't key group_by source columns; fall back to // `_groupByTypes` so a configured `date_format` on a // group_by column (e.g. an "Order Date" pivot) still // compiles to an `Intl.DateTimeFormat` rather than being // silently dropped. const type = this._columnTypes[name] ?? this._groupByTypes[name]; const fmt = this._compileColumnFormatter(type, columnCfg); if (fmt) { this._columnFormatters.set(name, fmt); } } } private _compileColumnFormatter( type: string | undefined, cfg: Record | undefined, ): ((v: number) => string) | undefined { if (!type || !cfg) { return undefined; } if (type === "integer" || type === "float") { const numberFormat = cfg.number_format as | NumberFormatConfig | undefined; if (!numberFormat) { return undefined; } const intl = createNumberFormatter(type, numberFormat); return (v) => intl.format(v); } if (type === "datetime") { const dateFormat = cfg.date_format as DateFormatConfig | undefined; if (!dateFormat) { return undefined; } const intl = createDatetimeFormatter(dateFormat); return (v) => intl.format(v); } if (type === "date") { const dateFormat = cfg.date_format as DateFormatConfig | undefined; if (!dateFormat) { return undefined; } const intl = createDateFormatter(dateFormat); return (v) => intl.format(v); } return undefined; } /** * Returns the formatter for `columnName` if one has been configured * (via `column_config_schema` + the user's sidebar choices), else a * type-appropriate fallback for the chart context. * * @param columnName May be a synthetic split-by path * (`|...|`); the source column is recovered * internally before lookup. * @param context `"tick"` returns `undefined` when no per-column * formatter is configured, so the receiving axis renderer can * apply its own step-aware default (adaptive date precision from * tick spacing, K/M/B suffixes for numerics). `"value"` returns * a precise `Intl.NumberFormat` / `Intl.DateTimeFormat` fallback — * appropriate for tooltips, legends, overlays where the caller * invokes the formatter directly and needs a guaranteed function. */ getColumnFormatter( columnName: string | null | undefined, context: "tick", ): ((v: number) => string) | undefined; getColumnFormatter( columnName: string | null | undefined, context?: "value", ): (v: number) => string; getColumnFormatter( columnName: string | null | undefined, context: "tick" | "value" = "value", ): ((v: number) => string) | undefined { if (columnName) { const formatter = this._columnFormatters.get( sourceColumn(columnName), ); if (formatter) { return formatter; } } if (context === "tick") { return undefined; } // `_columnTypes` is the post-aggregation schema and doesn't // key group_by source columns (their post-aggregate form is // `__ROW_PATH_N__`); fall back to `_groupByTypes` so date / // datetime group_by axes don't get formatted as numbers. const sourceName = columnName ? sourceColumn(columnName) : undefined; const type = sourceName ? (this._columnTypes[sourceName] ?? this._groupByTypes[sourceName]) : undefined; if (type === "date" || type === "datetime") { return DEFAULT_DATETIME_FORMATTER; } return DEFAULT_VALUE_FORMATTER; } setDefaultChartType(chartType: string): void { this._defaultChartType = chartType; } setFacetConfig(cfg: FacetConfig): void { this._facetConfig = { ...cfg }; } /** * Apply plugin-scoped global config. Stores `cfg` for later reads * and mirrors the overlapping fields onto adjacent state so deep * render code keeps reading the single struct it already does: * * - `facet_mode` / `facet_zoom_mode` sync into `_facetConfig` so * `cartesian-render.ts` (and the treemap/sunburst grid checks) * keep working unchanged. * - `series_zoom_mode` toggles the `_autoFitValue` flag declared * on `CategoricalYChart` ("dynamic" = refit on zoom, "fixed" = * pinned to full extent). Harmless write on charts that don't * expose the field. * * Render-path uniform fields (`line_width_px`, `point_size_px`, * `wick_width_px`, `ohlc_line_width_px`) are read directly from * `_pluginConfig` by their respective glyphs on each draw — no * sync needed. Build-time fields (`auto_alt_y_axis`, * `band_inner_frac`, `bar_inner_pad`) are read by the pipeline * inputs in `uploadAndRender`; they take effect on next data load. */ setPluginConfig(cfg: PluginConfig): void { this._pluginConfig = { ...cfg }; this._facetConfig = { ...this._facetConfig, facet_mode: cfg.facet_mode, zoom_mode: cfg.facet_zoom_mode, }; (this as { _autoFitValue?: boolean })._autoFitValue = cfg.series_zoom_mode === "dynamic"; } /** * Lazily decode the host-supplied theme vars. Subsequent calls hit * the cache until `invalidateTheme()` clears it. Render-path * callers should always read theme values through this method so * the parsed `Theme` (gradient stops, palette, etc.) amortizes * across an entire frame. */ _resolveTheme(): Theme { if (!this._theme) { this._theme = resolveThemeFromVars(this._themeVars); } return this._theme; } /** * Drop the cached theme so the next `_resolveTheme()` call re-decodes * from `_themeVars`. Wired to `plugin.restyle()` — the host pushes * a fresh var snapshot before invalidating. */ invalidateTheme(): void { this._theme = null; } /** * Install a new view for lazy row fetches. Disposes any prior * fetcher and dismisses the pinned tooltip — the prior pinned * row index has no guaranteed correspondence in the new view * (pivot / filter / sort changes can all reshuffle rows). */ setView(view: View): void { if (this._lazyRows) { this._lazyRows.dispose(); } this._lazyRows = new LazyRowFetcher(view); // A view change (filter / pivot / sort / schema) implicitly // dismisses any active pin — the prior row index has no // guaranteed correspondence in the new view. Emit a matching // `selected: false` so downstream filter-coordinated consumers // can roll back their derived state. const wasPinned = this._tooltip.isPinned; this._tooltip.dismiss(); if (wasPinned) { this.emitUnselect(); } } /** * Build the chart-specific {@link TooltipCallbacks} object — the * `onHover` / `onLeave` / `onClickPre` / `onPin` / `onDblClick` * surface that mediates between the cursor and chart state. * Subclasses override this; the base returns a no-op pair. */ protected tooltipCallbacks(): TooltipCallbacks { return { onHover: () => {}, onLeave: () => {}, }; } /** * Wire the chart's `TooltipController` for virtual-dispatch * `InteractionEvent`s forwarded from the host, and install the * host sink that materializes pinned tooltips and cursor changes * host-side. */ attachTooltip(host: HostSink): void { this._tooltip.attach(this.tooltipCallbacks()); this._tooltip.setHost(host); this._hostSink = host; } /** * Build a `PerspectiveClickDetail` payload from a per-family * resolved click target. Fetches the source-view row via * `_lazyRows` (returns `row: {}` if the row can't be resolved — * e.g., aggregate / density cells), and concatenates the * `group_by` and `split_by` pivot values into a * `viewer.restore({ filter })`-shaped patch. * * Mirrors the filter-building logic in datagrid's * `getCellConfig` ([packages/viewer-datagrid/src/ts/get_cell_config.ts]), * but operates on `AbstractChart` state rather than a `DatagridModel`. */ async buildClickDetail(target: { rowIdx: number | null; columnName: string; groupByValues: (string | number | null)[]; splitByValues: (string | number | null)[]; }): Promise { let row: Record = {}; if (target.rowIdx != null && target.rowIdx >= 0 && this._lazyRows) { try { const r = await this._lazyRows.fetchRow(target.rowIdx); row = Object.fromEntries(r); } catch { // Fetcher may have been disposed mid-flight; treat as // "no row" and emit the filter-only detail anyway. row = {}; } } const filter: Array<[string, "==", string | number]> = []; for (let i = 0; i < this._groupBy.length; i++) { const v = target.groupByValues[i]; if (v != null && v !== "") { filter.push([this._groupBy[i], "==", v]); } } for (let i = 0; i < this._splitBy.length; i++) { const v = target.splitByValues[i]; if (v != null && v !== "") { filter.push([this._splitBy[i], "==", v]); } } return { row, column_names: [target.columnName], config: { filter } as Partial, }; } /** * Forward a `perspective-click` to the host. No-op when the chart * has not been wired to a host sink (e.g., unit-tested charts). * Synchronous; callers needing ordering with async emits should * chain through `_emitQueue`. */ emitUserClick(detail: PerspectiveClickDetail): void { const payload: UserClickPayload = { row: detail.row, column_names: detail.column_names, config: detail.config as { filter?: unknown[] }, }; this._hostSink?.emitUserClick?.(payload); } /** * Forward a `perspective-global-filter` to the host. The host * transport materializes a `PerspectiveSelectDetail` from this plus * its cached previous-insert config and dispatches. Synchronous. */ emitUserSelect(args: { selected: boolean; row: Record; column_names: string[]; insertConfig: Partial; }): void { const payload: UserSelectPayload = { selected: args.selected, row: args.row, column_names: args.column_names, insertConfig: args.insertConfig as { filter?: unknown[] }, }; this._hostSink?.emitUserSelect?.(payload); } /** * Convenience: fire both `perspective-click` and * `perspective-global-filter` (`selected: true`) from a resolved * click target. Used by chart families where every click both * "selects" and "filters" (series, heatmap, candlestick, scatter, * treemap-leaf, etc.). Treemap branch / breadcrumb gestures use * the lower-level helpers directly. * * Chains through `_emitQueue` so the row-fetch await can't reorder * this emit behind a follow-up `emitUnselect`. */ emitClickAndSelect(target: { rowIdx: number | null; columnName: string; groupByValues: (string | number | null)[]; splitByValues: (string | number | null)[]; }): Promise { const next = this._emitQueue.then(async () => { const detail = await this.buildClickDetail(target); this.emitUserClick(detail); this.emitUserSelect({ selected: true, row: detail.row, column_names: detail.column_names, insertConfig: detail.config, }); }); // Swallow errors on the chain so a single failure doesn't // poison subsequent emits; surface to console for debugging. this._emitQueue = next.catch((e) => { console.error("emitClickAndSelect failed", e); }); return next; } /** * Fire a `perspective-global-filter` with `selected: false`. Used * by treemap / sunburst breadcrumb navigation and by chart-base's * own `setView` when a view change implicitly dismisses any active * pin. Chains through `_emitQueue` so it lands AFTER any in-flight * `emitClickAndSelect`. */ emitUnselect( args: { row?: Record; column_names?: string[]; } = {}, ): void { const next = this._emitQueue.then(() => { this.emitUserSelect({ selected: false, row: args.row ?? {}, column_names: args.column_names ?? [], insertConfig: { filter: [] }, }); }); this._emitQueue = next.catch((e) => { console.error("emitUnselect failed", e); }); } // Render entrypoint /** * Public coalesced render. Routes through the module-level * scheduler so concurrent calls collapse to one `_fullRender` per * RAF and the host blitter receives one bitmap per frame. The * returned promise resolves after this chart's `awaitGpuFence` + * `endFrame` chain — independent of other charts in the same * RAF, which run their fence waits in parallel. * * Every render-triggering caller — upload chunks, zoom / pan, * resize, theme invalidation, host-driven redraws — calls this. * The only sanctioned bypass is `snapshotPng`, which calls * `_fullRender` directly to keep the GL backbuffer intact for * `gl.readPixels`. */ requestRender(glManager: WebGLContextManager): Promise { return scheduleRender(glManager, () => this._fullRender(glManager)); } // Lifecycle destroy(): void { this._tooltip.detach(); this._tooltip.dismiss(); if (this._lazyRows) { this._lazyRows.dispose(); this._lazyRows = null; } this.destroyInternal(); } // Abstract surface abstract uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, ): Promise; abstract _fullRender(glManager: WebGLContextManager): void; /** * Release chart-specific GL/CPU resources. `destroy` calls this. */ protected abstract destroyInternal(): void; }