diff --git a/Architecture.md b/Architecture.md index 29c74e1..f28db6f 100644 --- a/Architecture.md +++ b/Architecture.md @@ -2,7 +2,7 @@ **flexviz** — renderer-agnostic, scalable, linked visualizations for large datasets. -**Spec version:** `0.5` — `GeoHistogram2D` params no longer carry `bin_boundaries`. +**Spec version:** `0.7`. ## Core Properties @@ -26,8 +26,7 @@ - **Request-wide domain resolution** — before aggregation, the engine resolves each active trace's needed column bounds (`FlexTrace.domain_cols`) with one batched `LFQueryBuilder.physical_minmax` call per scope. An unzoomed histogram takes the union of its same-figure siblings' domains so their bars stay aligned. The scopes split by `FlexTrace.domain_follows_filter`. `Histogram`, `Histogram2D` and `GeoHistogram2D` take unfiltered bounds, so their bin edges are filter-stable and the brushed subset stays comparable with the whole. A `LinePlot` x-width bucket grid (grouped or not, on both source kinds) takes the cross-filtered rows' x extent in update mode, distributing its rendering budget over the filtered domain. A zoomed grid takes the viewport and an overlay-mode grid stays unfiltered, because the background layer pins the axis. The unfiltered bounds are memoized when the source is `static` (see the caching carve-out in the Server Layer); the filtered ones are request-local and never memoized. Implemented trace types: **LinePlot**, **Histogram**, **BoxPlot**, **BarPlot**, **PiePlot**, **TreeMap**, **Histogram2D**, **GeoHistogram2D**, **CorrHeatmap**, **GeoLine** -Implemented renderers: **PlotlyAdapter** (line, histogram, box, bar, pie, treemap, heatmap, choroplethmap, scattermap) · **EChartsAdapter** (line, histogram, bar, pie, heatmap) -=> EchartsAdapter is currently deprecated. Very distant future we will update this. +Implemented renderers: **PlotlyAdapter** (line, histogram, box, bar, pie, treemap, heatmap, choroplethmap, scattermap) Python ≥ 3.10 · Polars · FastAPI · Uvicorn · Pydantic · flexviz_polars (Rust + maturin) @@ -107,7 +106,6 @@ Python ≥ 3.10 · Polars · FastAPI · Uvicorn · Pydantic · flexviz_polars (R ┌────────────────────────────────────────────────────────────────┐ │ ADAPTER LAYER (renderer-specific) │ │ PlotlyAdapter — HTML + Plotly.js 4 CDN (line/hist/box/bar/pie/heatmap/scattermap) │ -│ EChartsAdapter — HTML + ECharts 5 CDN (line/hist/bar/pie/heatmap) │ │ AbstractAdapter — shared toolbar, delivery helpers │ └────────────────────────────────────────────────────────────────┘ ``` @@ -530,7 +528,7 @@ fig.add_line(x="timestamp", y="value", name="Sensor A", n_points=1000, add_gaps= - **Collect engine**: the builder's own collects use `engine="streaming"` when the source reads from storage (its unoptimized plan roots at `SCAN [...]`) and `engine="in-memory"` for a resident frame, fixed per source (`LFQueryBuilder.collect_engine`) rather than left to `"auto"`. The line bucket plan, the grouped histogram plan and the domain probe (filtered or not) are the exceptions: all stream on both source kinds. The same `is_scan` signal picks the kernel-vs-native formulation above. - Viewport restriction, ungrouped lines: an ungrouped x-width line on a resident frame is sorted by contract, so its viewport is a binary-searched, zero-copy `slice(search_sorted(lo), search_sorted(hi) - start)`. The scan plan passes an `is_between` mask into `pairs_plan` instead. A resident `nth` line slices only when the x column was asserted sorted (`assume_sorted` / `check_line_x`, surfaced via `LFQueryBuilder.sorted_cols` and threaded by the engine as `sorted_cols`), and takes a dtype-aware `is_between` mask otherwise. Performance-only choice — `tests/test_trace_line.py::TestSortedViewportSlice` asserts the slice returns exactly what the mask returns. Grouped lines and both scan plans always mask (the filter runs frame-level, before `group_by` or the gather). - The engine normalizes descending viewport ranges (reversed plotly axes report high-to-low) to `lo <= hi` at ingestion — `_normalize_viewports` in `engine.py`, the first step of both `process` and `build_cubes` — so neither formulation nor a cube domain ever sees a reversed pair. -- `_to_update`: unpacks struct → `{"x": series, "y": series}`, gapless. Gap (`null`) breaks across large x jumps are a client-side display concern, inserted at render time by `fvApplyLineGaps` (`adapters/js/plotly/traces.js`) for every line trace — init, commit, and live cube. +- `_to_update`: unpacks struct → `{"x": series, "y": series}`, gapless. Gap (`null`) breaks across large x jumps are a client-side display concern, inserted at render time by `fvApplyLineGaps` (`adapters/js/runtime/cube.js`) for every line trace — init, commit, and live cube. - Range-brush selections emit a `ClauseFilter(range=...)` per axis; the predicate compiler applies typed `is_between` filters. ### Histogram @@ -670,7 +668,6 @@ fig.add_geo_histogram2d(lat="lat", lon="lon", histfunc="mean", z="temperature") - Cross-filtering: Plotly geo selections are derived from the selected choropleth bin ids (`locations`, built by the client with the rectangles) and collapsed to one lon/lat bounding box, then emitted as a `SelectionPredicate` with two `ClauseFilter(range=...)` clauses on the trace's `lon` and `lat` columns. - Public style API: `color_scale`, `color_range` and `color_norm`; defaults are `"Viridis"`, `"auto"` and `"linear"`. - PlotlyAdapter renders as a `choroplethmap` trace with OpenStreetMap base tiles. A `choroplethmap` has no trace opacity, so the adapter fades an overlay background through the marker opacity of each cell (`applyChoroplethLayerOpacity`). -- ECharts geo rendering is not yet supported. ### CorrHeatmap @@ -704,13 +701,12 @@ fig.add_corr_heatmap(columns=["a", "b"], color_scale="RdBu", color_range="auto") - `color_scale` is the name of a plotly.js built-in color scale (`_COLOR_SCALE_OPTIONS` in `trace/_hist_helpers.py`). The traces match it without regard to case, store the Plotly spelling (`"viridis"` becomes `"Viridis"`) and reject any other name, because Plotly silently draws its automatic scale for a name it does not know. - `color_norm` is `"linear"` or `"log"` and exists on `Histogram2D` and `GeoHistogram2D` only. `CorrHeatmap` values lie in [-1, 1], so it has no `color_norm` and its `from_trace_spec()` rejects a spec with one. A missing `color_norm` means linear. With `"log"`, a fixed `color_range` stays in data units and must be above 0. The trace check is the only guard for this: in the browser, `logTickValues` never ends for a range from 0. - Defaults are owned by the trace classes: `Histogram2D` and `GeoHistogram2D` materialize `"Viridis"` / `"auto"` / `"linear"`; `CorrHeatmap` materializes signed vs absolute defaults based on `absolute`. -- Generated specs must include explicit `display.color_scale` and `display.color_range`, so adapters read them instead of re-deriving heatmap defaults. The Plotly adapter only reads these fields: the builders (`Figure.to_spec()`, `Dashboard.to_spec()`) and the server (for a decoded spec, see [Request Flows](#request-flows)) hand it specs that the traces built. A caller that passes a hand-made spec to the adapter directly skips this. The deprecated ECharts adapter still checks them. +- Generated specs must include explicit `display.color_scale` and `display.color_range`, so adapters read them instead of re-deriving heatmap defaults. The Plotly adapter only reads these fields: the builders (`Figure.to_spec()`, `Dashboard.to_spec()`) and the server (for a decoded spec, see [Request Flows](#request-flows)) hand it specs that the traces built. A caller that passes a hand-made spec to the adapter directly skips this. - `from_trace_spec()` on the heatmap traces remains the single backward-compat normalization point for older specs missing those style keys. - A figure holds at most one heatmap-like trace (`histogram2d`, `corr_heatmap`, `geo_histogram2d`): each one is opaque and draws its own figure-wide colorbar, so a second one hides the first and duplicates the colorbar. `FigureSpec` checks this when it is built or parsed, so the builder, a decoded spec and every request get the same rule. A heatmap together with non-heatmap traces, such as a line over a `histogram2d`, stays allowed. - Renderer split: - Plotly receives `color_scale` in the Plotly spelling that the trace stored. The server rebuilds a decoded spec, so an older lowercase name still renders its scale. With a linear norm, it applies `zmin` / `zmax` only when `color_range` is fixed. - Plotly log norm: Plotly has no log color axis, so the client colors by `log10(z)`, pins the color range in log10 space and labels the colorbar ticks with round data values. Only the colors change: the hover shows the raw values, and a cell at or below 0, which has no log, has no color but keeps its hover and its selection. The transform (`applyLogColorNorm` in `plotly/traces.js`) runs when a trace is built from its template, so it covers every delta, from the server or the cube. - - ECharts maps a supported set of heatmap scale names (`Viridis`, `Cividis`, `Blues`, `Greens`, `Reds`, `RdBu`), without regard to case, to local color arrays for one per-figure `visualMap`, and ignores `color_norm`. ### Dtype-Aware Filtering @@ -883,9 +879,9 @@ FlexEngine Because the engine keys its recompute on event type, `fvOnResetPanel` derives the emitted type from the resulting state: `selection` when filters remain, `deselect` when none remain, `viewport` when only the viewport changed. Every variant names the cleared keys in `viewport_keys`, so a viewport-only reset re-aggregates only this figure. The global toolbar **Reset** (`fvOnReset`) instead clears all unlocked viewports + selections and emits `init`; resets and double-click autorange never clear a locked axis's viewport key, because the lock pins the displayed range and the engine aggregates at the state range; the global **Deselect** clears selections only and **keeps zoom** (emitting `deselect`). -**Linked axes (`ClientState.axis_links`):** each group lists viewport keys that always hold one range. The client keeps them equal with one writer, `fvWriteViewport` (`runtime/state.js`): a zoom, pan, double-click autorange or panel reset writes (or deletes) every key of the group. `fvCommitViewportChange` then redraws the other moved figures from state (the relayout handler ignores what `Plotly.react` emits, so the redraws post nothing) and sends one viewport event naming every written key, only when a changed axis binds a trace somewhere. The engine needs no link knowledge: the event's `viewport_keys` pick the figures, and the partition rule keeps each figure unfiltered by its own selection. A lock toggle reaches every member and pins all of them at the range the clicked figure shows (`fvLockAxisGroup`; Lock All skips an axis a group member already locked), so a locked member pins the group. When a member shows another range, the server aggregated it for that other range, so the lock also writes the group range with `fvWriteViewport`, and the toggle commits it with `fvCommitViewportChange` (Lock All sends one commit for all groups). A group whose members already show one range (each bound within 1e-9 of the span) stays display-only: a viewport at Plotly's padded autorange can change the bins, because a zoomed grid snaps to a lattice. When members differ, the clicked figure gets the group viewport too (the validator requires equal viewport values), so a heatmap there can re-bin, for example from 7 to 8 rows on integer data. An unlock writes no viewport: a group whose members showed other ranges keeps its viewport keys, and a display-only group has none. The `DashboardSpec` validator rejects a link the client cannot keep: fewer than two keys, a key in two groups, two axes of one figure, an axis other than x/y, an axis without a data column (count axes, bars, maps), an axis type other than unset, `-`, `linear` and `date` (a `category` range is in positions, and a `log` axis cannot show a range at or below 0, which a linear member can zoom to), mixed reversed and normal axes (any `reversed` autorange variant or a descending fixed range), unequal viewport values (`None` = absent), mixed locks or unequal lock ranges. Every entry point (builder, `/share`, `/view`, import, `flexvizApply`, each request) runs it. The column types need the source schema, so `check_axis_link_types` runs in the builder and, with the registered sources, in `/share`, `/view` (400) and `/dashboard/update` (422): only numeric, `Date` and `Datetime` axes link (`Time` and `Duration` render as category axes, issue #79), and a numeric column on a `date` axis is refused. All axes of a group then have one kind: numeric, or temporal with one time zone and one effective Plotly axis type (`date` reports strings, `linear` numbers). Limits: after a double-click autorange each member autoranges on its own data (in update mode a figure filtered by a selection fits its filtered rows); ECharts (deprecated) does not write linked keys. +**Linked axes (`ClientState.axis_links`):** each group lists viewport keys that always hold one range. The client keeps them equal with one writer, `fvWriteViewport` (`runtime/state.js`): a zoom, pan, double-click autorange or panel reset writes (or deletes) every key of the group. `fvCommitViewportChange` then redraws the other moved figures from state (the relayout handler ignores what `Plotly.react` emits, so the redraws post nothing) and sends one viewport event naming every written key, only when a changed axis binds a trace somewhere. The engine needs no link knowledge: the event's `viewport_keys` pick the figures, and the partition rule keeps each figure unfiltered by its own selection. A lock toggle reaches every member and pins all of them at the range the clicked figure shows (`fvLockAxisGroup`; Lock All skips an axis a group member already locked), so a locked member pins the group. When a member shows another range, the server aggregated it for that other range, so the lock also writes the group range with `fvWriteViewport`, and the toggle commits it with `fvCommitViewportChange` (Lock All sends one commit for all groups). A group whose members already show one range (each bound within 1e-9 of the span) stays display-only: a viewport at Plotly's padded autorange can change the bins, because a zoomed grid snaps to a lattice. When members differ, the clicked figure gets the group viewport too (the validator requires equal viewport values), so a heatmap there can re-bin, for example from 7 to 8 rows on integer data. An unlock writes no viewport: a group whose members showed other ranges keeps its viewport keys, and a display-only group has none. The `DashboardSpec` validator rejects a link the client cannot keep: fewer than two keys, a key in two groups, two axes of one figure, an axis other than x/y, an axis without a data column (count axes, bars, maps), an axis type other than unset, `-`, `linear` and `date` (a `category` range is in positions, and a `log` axis cannot show a range at or below 0, which a linear member can zoom to), mixed reversed and normal axes (any `reversed` autorange variant or a descending fixed range), unequal viewport values (`None` = absent), mixed locks or unequal lock ranges. Every entry point (builder, `/share`, `/view`, import, `flexvizApply`, each request) runs it. The column types need the source schema, so `check_axis_link_types` runs in the builder and, with the registered sources, in `/share`, `/view` (400) and `/dashboard/update` (422): only numeric, `Date` and `Datetime` axes link (`Time` and `Duration` render as category axes, issue #79), and a numeric column on a `date` axis is refused. All axes of a group then have one kind: numeric, or temporal with one time zone and one effective Plotly axis type (`date` reports strings, `linear` numbers). Limits: after a double-click autorange each member autoranges on its own data (in update mode a figure filtered by a selection fits its filtered rows). -**Treemap / pie multi-click:** successive clicks on the same figure append OR predicates via `fvUpsertPathPredicate` in the shared runtime (Plotly and ECharts). Re-clicking the same node toggles that predicate off; refining along one branch (parent → child or child → parent) replaces the broader/narrower predicate instead of accumulating redundant filters. *UX note:* this follows common additive-filter BI patterns; we should periodically reassess whether modifier keys or explicit multi-select mode would better match natural visual exploration for hierarchical charts. +**Treemap / pie multi-click:** successive clicks on the same figure append OR predicates via `fvUpsertPathPredicate` in the shared runtime (Plotly). Re-clicking the same node toggles that predicate off; refining along one branch (parent → child or child → parent) replaces the broader/narrower predicate instead of accumulating redundant filters. *UX note:* this follows common additive-filter BI patterns; we should periodically reassess whether modifier keys or explicit multi-select mode would better match natural visual exploration for hierarchical charts. **Grouped architecture:** the engine no longer discovers groups or fabricates child traces. Group membership is decided in the grouped Polars query, and traces own the conversion from grouped result frames to child payloads. @@ -1002,7 +998,7 @@ The response cache covers only the **unfiltered *and* viewport-free** computatio - `init` is viewport-free whenever `state.viewport` is empty (the global reset clears it first); - `deselect` clears selections but **preserves zoom**, so a deselect issued while zoomed is viewport-dependent and bypasses the cache. -In the engine the short-circuit only fires when *every* delta-producing trace is a viewport-free cache hit; if any deliverable trace is zoomed, the request falls through to a normal recompute of all traces (the viewport-free ones are still stored for a future fully-unzoomed request). The cache is engine-hosted (injected `CacheBackend`), in-process (a Redis/disk backend swaps in through the same interface), and mirrored client-side as a whole-response `Map` (`runtime/cache.js`); the client cache is additionally gated on **no figure being zoomed** (a single zoomed figure disqualifies the whole-dashboard entry). Re-registering an existing source name clears the cache wholesale (its data may have changed); registering a new name leaves other sources' entries intact. The server never tracks client cache state; the set of cacheable sources is embedded into the bootstrap (`FV_CACHEABLE_SOURCES`). The same carve-out and invalidation hook cover the second, byte-bounded **cube-blob cache** (see "Cube Pre-Aggregation & Live Brushing" below) — re-registering a source clears both. +In the engine the short-circuit only fires when *every* delta-producing trace is a viewport-free cache hit; if any deliverable trace is zoomed, the request falls through to a normal recompute of all traces (the viewport-free ones are still stored for a future fully-unzoomed request). The cache is engine-hosted (injected `CacheBackend`), in-process (a Redis or disk backend can implement the same interface), and mirrored client-side as a whole-response `Map` (`runtime/cache.js`); the client cache is additionally gated on **no figure being zoomed** (a single zoomed figure disqualifies the whole-dashboard entry). Re-registering an existing source name clears the cache wholesale (its data may have changed); registering a new name leaves other sources' entries intact. The server never tracks client cache state; the set of cacheable sources is embedded into the bootstrap (`FV_CACHEABLE_SOURCES`). The same carve-out and invalidation hook cover the second, byte-bounded **cube-blob cache** (see "Cube Pre-Aggregation & Live Brushing" below) — re-registering a source clears both. A `static` source also memoizes each column's resolved unfiltered min/max (`LFQueryBuilder.physical_minmax`) on the builder for the source's lifetime, so a cached request never re-scans the data to resolve bin-edge domains. Static means a resident frame or a `cache=True` scan. A `cache=False` scan recomputes those bounds on every request that needs them, so an uncached reset always sees the current source data. Re-registering a source with raw data or a new builder replaces the builder and drops the memo. Re-registering the same builder object invalidates nothing, and the server warns. @@ -1012,7 +1008,7 @@ A `static` source also memoizes each column's resolved unfiltered min/max (`LFQu |--------|---------------------|---------------------------------------------------| | `POST` | `/dashboard/update` | Dashboard interaction; returns per-figure deltas | | `POST` | `/share` | Encode spec → shareable URL | -| `GET` | `/view` | Render shared spec (`?renderer=plotly\|echarts`) | +| `GET` | `/view` | Render shared spec (`?renderer=plotly`) | | `GET` | `/h/{n}` | Render `flexviz history` entry `n` (`renderer` defaults to the recorded URL's) | | `GET` | `/sources` | List registered source names (health check) | | `GET` | `/cache/stats` | Cache hits/misses/entries + cacheable sources | @@ -1484,7 +1480,7 @@ AbstractAdapter (ABC) JS source lives under `adapters/js/` as small modules. `adapters/runtime.py` concatenates them into the renderer bundles **at import time**, in pure Python — no Node and no build step. The -bundle composition (which sources go into `shared` / `plotly` / `echarts`) is declared in +bundle composition (which sources go into `shared` / `plotly`) is declared in `runtime.py`; editing any file under `adapters/js/` takes effect on the next import. ``` @@ -1503,20 +1499,16 @@ adapters/js/ │ ├── selections.js ← selection list/predicate helpers │ ├── selection-summary.js ← per-figure selection summary UI │ └── hover.js ← linked-hover dispatch -├── plotly/ -│ ├── traces.js ← configsByFig, trace template builders -│ ├── render.js ← _fvRenderFigure, axis helpers, lock/capture -│ ├── events.js ← relayout/selected/deselect/click handlers -│ ├── hover.js ← Plotly crosshair helpers -│ └── init.js ← bindFigure, startup IIFE, fallback newPlot for undrawn figures -└── echarts/ - ├── series.js ← chartsByFig, series template builders - ├── render.js ← _fvRenderFigure, brush/zoom helpers - └── init.js ← echarts.init calls, event wiring, window._fvResizeChart +└── plotly/ + ├── traces.js ← configsByFig, trace template builders + ├── render.js ← _fvRenderFigure, axis helpers, lock/capture + ├── events.js ← relayout/selected/deselect/click handlers + ├── hover.js ← Plotly crosshair helpers + └── init.js ← bindFigure, startup IIFE, fallback newPlot for undrawn figures ``` -`runtime.py` assembles three bundles from these sources at import: `shared` (panel.js + -runtime/*.js + toolbar.js), `plotly` (plotly/*.js), and `echarts` (echarts/*.js); `theme.css` +`runtime.py` assembles two bundles from these sources at import: `shared` (panel.js + +runtime/*.js + toolbar.js) and `plotly` (plotly/*.js); `theme.css` and `gridstack-bridge.js` are served verbatim. Each adapter's ` - - -{toolbar_html} - {dashboard.container_html} - {self._filter_strip_html()} - - -""" diff --git a/flexviz/adapters/js/echarts/init.js b/flexviz/adapters/js/echarts/init.js deleted file mode 100644 index 10e6cc4..0000000 --- a/flexviz/adapters/js/echarts/init.js +++ /dev/null @@ -1,393 +0,0 @@ -// === ECharts adapter — startup and event wiring === -// Requires: series.js, render.js - -// Renderer-specific resize callback used by gridstack-bridge.js on resizestop. -window._fvResizeChart = function(el) { - var c = el.querySelector('[id^="fv-chart-"]'); - if (!c) return; - var idx = parseInt(c.id.replace('fv-chart-', '')); - var uid = FIG_UIDS[idx]; - if (uid && chartsByFig[uid]) chartsByFig[uid].resize(); -}; - -const figureModesByUid = Object.fromEntries( - FIG_UIDS.map((figUid, idx) => [figUid, figSupportsZoomPan[idx] ? 'zoom' : 'select']) -); - -function axisOptionForFamily(chart, axisFamily) { - const option = chart && chart.getOption && chart.getOption(); - if (!option) return null; - const key = axisFamily === 'y' ? 'yAxis' : 'xAxis'; - const axis = option[key]; - return Array.isArray(axis) ? (axis[0] || null) : (axis || null); -} - -function normalizeEChartsRangeForAxis(chart, axisFamily, range) { - if (!Array.isArray(range)) return range; - const axis = axisOptionForFamily(chart, axisFamily); - if (!axis || axis.type !== 'time') return range; - return range.map(value => { - const num = Number(value); - if (!Number.isFinite(num)) return value; - return new Date(num).toISOString(); - }); -} - -function clearFigureSelection(figUid) { - const remainingSelections = window.fvClearFigureSelectionFromList?.( - figUid, - DASHBOARD_SPEC.state.selections || [] - ) || []; - window.fvSetSelectionState?.(remainingSelections); - _brushAreasByFig[figUid] = []; - postDashboardUpdate({ - type: 'selection', - axis_ranges: {}, - force_update: true, - figure_uid: figUid, - }); -} - -function handleEChartsClick(params, figUid) { - const figSpec = figSpecByUid[figUid]; - if (!figSpec) return; - const logicalUid = stripLayerSuffix((params && params.seriesId) || ''); - const tsSpec = (figSpec.traces || []).find(ts => ts.uid === logicalUid); - if (!tsSpec) return; - - let clauses = null; - if (params.seriesType === 'pie') { - const labels = tsSpec.backend_data && tsSpec.backend_data.labels; - if (!labels) return; - const labelCols = Array.isArray(labels) ? labels : [labels]; - const clicked = params.name; - if (clicked == null) return; - let parts; - if (labelCols.length === 1) { - parts = [String(clicked)]; - } else { - try { parts = JSON.parse(String(clicked)); } - catch (e) { return; } - if (!Array.isArray(parts) || parts.length !== labelCols.length) return; - } - clauses = labelCols.map((column, idx) => ({ column, values: [parts[idx]] })); - } else if (params.seriesType === 'treemap') { - const path = (tsSpec.params && tsSpec.params.path) || []; - const nodeId = String((params.data && params.data.id) || ''); - const parts = nodeId.split('/').slice(1).map(value => decodeURIComponent(String(value))); - if (!parts.length) { - if (window.fvFigureSelection?.(figUid, DASHBOARD_SPEC.state.selections || [])) { - clearFigureSelection(figUid); - } - return; - } - clauses = parts.map((value, idx) => ({ column: path[idx], values: [value] })); - } - - if (!clauses) return; - const predicate = { clauses }; - const existing = window.fvFigureSelection?.(figUid, DASHBOARD_SPEC.state.selections || []); - const newPredicates = window.fvUpsertPathPredicate?.( - existing ? (existing.predicates || []) : [], - predicate - ) || []; - if (!newPredicates.length) { - if (existing) clearFigureSelection(figUid); - return; - } - const nextSelection = { predicates: newPredicates }; - const nextSelections = window.fvReplaceFigureSelection?.( - figUid, - nextSelection, - DASHBOARD_SPEC.state.selections || [] - ) || []; - window.fvSetSelectionState?.(nextSelections); - postDashboardUpdate({ - type: 'selection', - axis_ranges: {}, - force_update: true, - figure_uid: figUid, - }); -} - -function updateModeIndicator(figUid) { - const figIdx = figUidToIdx[figUid]; - if (figIdx === undefined) return; - const controls = window.fvPanelControlRoot?.(figIdx); - if (!controls) return; - const currentMode = figureModesByUid[figUid] || 'zoom'; - const supportsZoomPan = figSupportsZoomPan[figIdx]; - const axesLocked = window.fvAreCurrentAxesLocked?.(figUid) === true; - for (const btn of controls.querySelectorAll('.fv-mode-btn')) { - const mode = btn.dataset.mode; - btn.classList.toggle('mode-active', mode === currentMode); - btn.disabled = mode === 'pan' - || (!supportsZoomPan && mode !== 'select') - || (axesLocked && mode === 'zoom'); - } - for (const btn of controls.querySelectorAll('.fv-mode-action-btn[data-action="reset-panel"]')) { - btn.disabled = !supportsZoomPan; - } - window.fvUpdateAxisLockButtons?.(figUid); - const warn = controls.querySelector('.fv-mode-warn'); - if (warn) warn.textContent = axesLocked ? 'axes locked' : ''; -} - -function setFigureMode(figUid, mode) { - const figIdx = figUidToIdx[figUid]; - if (figIdx === undefined || mode === 'pan') return; - if (window.fvAreCurrentAxesLocked?.(figUid) && mode === 'zoom') return; - const chart = chartsByFig[figUid]; - if (!chart) return; - figureModesByUid[figUid] = mode; - if (mode === 'select') { - chart.dispatchAction({ - type: 'takeGlobalCursor', - key: 'brush', - brushOption: { brushType: 'rect', brushMode: 'single' }, - }); - } else { - chart.dispatchAction({ - type: 'takeGlobalCursor', - key: 'brush', - brushOption: { brushType: false }, - }); - } - updateModeIndicator(figUid); -} - -window.fvSyncFigureModeForAxisLocks = function(figUid) { - if (window.fvAreCurrentAxesLocked?.(figUid) && (figureModesByUid[figUid] || 'zoom') === 'zoom') { - setFigureMode(figUid, 'select'); - return; - } - updateModeIndicator(figUid); -}; - -// ---- initialise one ECharts instance per figure ----------------- -FIG_UIDS.forEach((figUid, fi) => { - const container = document.getElementById('fv-chart-' + fi); - const chart = echarts.init(container); - chart.setOption(INITIAL_OPTIONS[figUid]); - chartsByFig[figUid] = chart; - - // Resize when container changes. - const ro = new ResizeObserver(() => chart.resize()); - ro.observe(container); - - // ---- datazoom: map to viewport event ------------------------- - // Use a debounce to avoid flooding the backend during scroll zoom. - // _applyingDeltas guards against re-entrancy: applyDeltasToFig calls - // setOption (which resets dataZoom), which would otherwise fire another - // datazoom event and undo the zoom. - let dzTimer = null; - chart.on('datazoom', function(params) { - if (_applyingDeltas) return; - clearTimeout(dzTimer); - dzTimer = setTimeout(() => { - let startVal, endVal; - if (params.startValue !== undefined) { - startVal = params.startValue; - endVal = params.endValue; - } else if (params.batch && params.batch.length) { - startVal = params.batch[0].startValue; - endVal = params.batch[0].endValue; - } - if (startVal === undefined || endVal === undefined) { - const opt = chart.getOption(); - const dz = opt.dataZoom && opt.dataZoom[0]; - if (dz) { startVal = dz.startValue; endVal = dz.endValue; } - } - if (startVal === undefined || endVal === undefined) return; - if (window.fvIsAxisLocked?.(figUid, 'x')) { - window.fvApplyAxisLocks?.(figUid); - return; - } - if (!DASHBOARD_SPEC.state) DASHBOARD_SPEC.state = {}; - if (!DASHBOARD_SPEC.state.viewport) DASHBOARD_SPEC.state.viewport = {}; - // Write viewport state before posting. - DASHBOARD_SPEC.state.viewport[figUid + '/x'] = { min: startVal, max: endVal }; - window.fvUpdateAxisLockButtons?.(figUid); - postDashboardUpdate({ - type: 'viewport', - axis_ranges: { x: [startVal, endVal] }, - force_update: false, - figure_uid: figUid, - }); - }, 150); - }); - - // ---- brushEnd: map to selection event ------------------------- - // brushEnd fires once when the user finishes drawing a brush area, - // with complete coordRange for the selected cartesian region. - chart.on('brushEnd', function(params) { - const areas = params.areas; - if (!areas || !areas.length) { - clearFigureSelection(figUid); - return; - } - const coordRange = areas[0].coordRange; - if (!coordRange || !coordRange[0]) return; - const xRange = normalizeEChartsRangeForAxis(chart, 'x', coordRange[0]); - const yRange = (coordRange.length > 1 && coordRange[1]) - ? normalizeEChartsRangeForAxis(chart, 'y', coordRange[1]) - : null; - const figSpec = figSpecByUid[figUid]; - const cartesian = (figSpec && figSpec.traces || []).filter( - ts => Array.isArray(ts.axes) && ts.axes.length > 0 && ts.backend_data - ); - const seen = new Set(); - const predicates = []; - for (const ts of cartesian) { - const { xCol, yCol } = window.fvColsForTrace?.(ts) || {}; - const key = JSON.stringify([xCol, yCol]); - if (seen.has(key)) continue; - seen.add(key); - const clauses = []; - if (xCol && xRange) clauses.push({ column: xCol, range: xRange }); - if (yCol && yRange) clauses.push({ column: yCol, range: yRange }); - if (clauses.length) predicates.push({ clauses }); - } - if (!predicates.length) return; - - const nextSelections = window.fvReplaceFigureSelection?.( - figUid, - { predicates }, - DASHBOARD_SPEC.state.selections || [] - ) || []; - window.fvSetSelectionState?.(nextSelections); - _brushAreasByFig[figUid] = areas; - postDashboardUpdate({ - type: 'selection', - axis_ranges: {}, - force_update: true, - figure_uid: figUid, - }); - }); - - if ((figSpecByUid[figUid].traces || []).some(ts => ts.trace_type === 'pie' || ts.trace_type === 'treemap')) { - chart.on('click', function(params) { handleEChartsClick(params, figUid); }); - } - - chart.on('mouseover', function(params) { handleEChartsHover(params, figUid); }); - chart.on('mouseout', function() { handleEChartsUnhover(); }); -}); - -function showEChartsCrosshair(figUid, axis, value) { - const chart = chartsByFig[figUid]; - if (!chart) return; - chart.dispatchAction({ - type: 'updateAxisPointer', - currTrigger: 'mousemove', - dataByCoordSys: [{ - dataByAxis: [{ axisDim: axis, axisIndex: 0, value }], - }], - }); -} - -function clearAllEChartsCrosshairs() { - for (const figUid of FIG_UIDS) { - const chart = chartsByFig[figUid]; - if (chart) { - chart.dispatchAction({ type: 'hideTip' }); - chart.dispatchAction({ type: 'updateAxisPointer', currTrigger: 'leave' }); - } - } -} -window.fvClearAllCrosshairs = clearAllEChartsCrosshairs; -window.fvClearAllHoverVisuals = clearAllEChartsCrosshairs; - -function _fvShowCrosshair(figUid, axis, value) { - showEChartsCrosshair(figUid, axis, value); -} -function _fvClearAllCrosshairs() { clearAllEChartsCrosshairs(); } - -window.__fvApplyHoverVisuals = function(figUid, visuals) { - for (const visual of (visuals || [])) { - if (visual.type === 'x_guide') { - showEChartsCrosshair(figUid, 'x', visual.value); - } else if (visual.type === 'y_guide') { - showEChartsCrosshair(figUid, 'y', visual.value); - } - } -}; - -function handleEChartsHover(params, sourceFigUid) { - const mode = getHoverMode(DASHBOARD_SPEC); - if (mode === 'off') return; - const rawUid = params.seriesId || ''; - const logicalUid = stripLayerSuffix(rawUid); - const resolvedUid = childUidToParentUid[logicalUid] || logicalUid; - const ts = traceSpecByUid[resolvedUid]; - if (!ts || !ts.hover || !ts.hover.source_modes || !ts.hover.source_modes.length) return; - - const values = {}; - const columns = {}; - if (Array.isArray(params.value)) { - if (params.value[0] !== undefined && params.value[0] !== null && ts.backend_data && ts.backend_data.x) { - values.x = params.value[0]; - columns.x = ts.backend_data.x; - } - if (params.value[1] !== undefined && params.value[1] !== null && ts.backend_data && ts.backend_data.y) { - values.y = params.value[1]; - columns.y = ts.backend_data.y; - } - } - if (!Object.keys(columns).length) return; - - clearAllEChartsCrosshairs(); - const event = { - sourceFigUid, - sourceTraceUid: rawUid, - kind: 'point', - values, - columns, - bounds: {}, - coordSpace: 'cartesian', - key: null, - }; - const visualsByFig = planHoverVisuals( - event, mode, hoverTargetsByColumn, hoverSourceByTrace, hoverEdgesByTraceUid, - { implementedAxisBandTargetTraceTypes: IMPLEMENTED_AXIS_BAND_TARGET_TRACE_TYPES } - ); - for (const [figUid, visuals] of visualsByFig) { - dispatchHoverVisuals(figUid, visuals); - } -} - -function handleEChartsUnhover() { - clearAllEChartsCrosshairs(); -} - -// ECharts-specific hook overrides (placed after shared hooks from toolbar.js) -window.fvOnReset = async function() { - window.fvClearUnlockedViewports?.(); - window.fvSetSelectionState?.([]); - window.fvResetRuntimeCache?.(); - await postDashboardUpdate({type: 'init', axis_ranges: {}, force_update: true}); -}; -window.fvOnDeselect = async function() { - window.fvSetSelectionState?.([]); - await postDashboardUpdate({type: 'deselect', axis_ranges: {}, force_update: true}); -}; - -(async () => { - for (const figUid of FIG_UIDS) { - const figIdx = figUidToIdx[figUid]; - if (figIdx === undefined) continue; - window.fvBindPanelControls?.(figUid, { - setMode(mode) { setFigureMode(figUid, mode); }, - resetPanel() { window.fvOnResetPanel?.(figUid); }, - toggleAxisLocks() { window.fvOnToggleAxisLocks?.(figUid); }, - }); - updateModeIndicator(figUid); - } - - if (!(await restoreDashboardFromSpec())) { - console.error('flexviz: initial load failed, panels are empty'); - } - - for (const figUid of FIG_UIDS) { - updateModeIndicator(figUid); - } -})(); diff --git a/flexviz/adapters/js/echarts/render.js b/flexviz/adapters/js/echarts/render.js deleted file mode 100644 index 08713c8..0000000 --- a/flexviz/adapters/js/echarts/render.js +++ /dev/null @@ -1,280 +0,0 @@ -// === ECharts adapter — rendering and viewport sync === -// Requires: series.js, state.js - -function heatmapFiniteExtent(z) { - let vMin = Infinity; - let vMax = -Infinity; - for (const row of (z || [])) { - for (const value of (row || [])) { - if (typeof value !== 'number' || !Number.isFinite(value)) continue; - if (value < vMin) vMin = value; - if (value > vMax) vMax = value; - } - } - if (vMin === Infinity || vMax === -Infinity) return [0, 1]; - if (vMin === vMax) { - const pad = Math.abs(vMin) * 0.01 || 1; - return [vMin - pad, vMax + pad]; - } - return [vMin, vMax]; -} - -function heatmapVisualMapForTrace(ts, updates) { - const colorRange = heatmapColorRange(ts); - const extent = colorRange === 'auto' - ? heatmapFiniteExtent((updates && updates.z) || []) - : colorRange; - return { - min: extent[0], - max: extent[1], - calculable: true, - inRange: { color: heatmapColors(ts) }, - }; -} - -function boxAxisPatchForFigure(figUid) { - const figSpec = figSpecByUid[figUid]; - if (!figSpec) return null; - const boxTraces = (figSpec.traces || []).filter(ts => ts.trace_type === 'box'); - if (!boxTraces.length) return null; - - const categories = []; - const seen = new Set(); - let orientation = null; - - function rememberLabel(updates) { - if (!updates) return; - if (typeof updates.orientation === 'string' && !orientation) { - orientation = updates.orientation; - } - const label = updates.x0 ?? updates.y0; - if (label == null) return; - const key = String(label); - if (seen.has(key)) return; - seen.add(key); - categories.push(key); - } - - for (const ts of boxTraces) { - if (isGroupedParent(ts)) { - const grouped = groupedDataByParent[figUid][ts.uid] || { base: [], bg: [], fg: [] }; - for (const layer of ['base', 'bg', 'fg']) { - for (const child of (grouped[layer] || [])) { - rememberLabel(child && child.updates); - } - } - } else { - const layers = ensureLayerData(ts.uid); - for (const layer of ['base', 'bg', 'fg']) { - rememberLabel(layers[layer]); - } - } - } - - if (!categories.length) return null; - if (orientation === 'h') { - return { yAxis: { type: 'category', data: categories } }; - } - return { xAxis: { type: 'category', data: categories } }; -} - -function isTemporalAxisValue(value) { - if (value instanceof Date) return Number.isFinite(value.valueOf()); - if (typeof value !== 'string') return false; - if (!/[A-Za-zT:\-\/]/.test(value)) return false; - return Number.isFinite(Date.parse(value)); -} - -function timeAxisPatchForSeries(series) { - for (const item of (series || [])) { - const data = (item && item.data) || []; - for (const point of data) { - const xValue = Array.isArray(point) - ? point[0] - : (point && Array.isArray(point.value) ? point.value[0] : null); - if (isTemporalAxisValue(xValue)) { - return { xAxis: { type: 'time', min: 'dataMin', max: 'dataMax' } }; - } - if (xValue != null) return null; - } - } - return null; -} - -function brushAreasForFigure(figUid) { - const figSpec = figSpecByUid[figUid]; - const sel = window.fvFigureSelection?.( - figUid, - (DASHBOARD_SPEC.state && DASHBOARD_SPEC.state.selections) || [] - ); - const areas = []; - if (!sel || !sel.predicates || !figSpec) return areas; - const cartesian = (figSpec.traces || []).find( - ts => Array.isArray(ts.axes) && ts.axes.length > 0 && ts.backend_data - ); - if (!cartesian) return areas; - const { xCol, yCol } = window.fvColsForTrace?.(cartesian) || {}; - for (const pred of (sel.predicates || [])) { - const xClause = (pred.clauses || []).find(clause => clause.column === xCol && clause.range); - const yClause = (pred.clauses || []).find(clause => clause.column === yCol && clause.range); - if (!xClause && !yClause) continue; - areas.push({ - brushType: 'rect', - coordRange: yClause ? [xClause ? xClause.range : null, yClause.range].filter(Boolean) : [xClause.range], - xAxisIndex: 0, - yAxisIndex: 0, - }); - } - return areas; -} - -function syncBrushAreasForFigure(figUid) { - const chart = chartsByFig[figUid]; - if (!chart) return; - const areas = brushAreasForFigure(figUid); - _brushAreasByFig[figUid] = areas; - try { - chart.dispatchAction({ type: 'brush', areas }); - } catch (e) { - // ECharts may throw when clearing brush on certain chart states (e.g. empty areas - // after a brush-select was active). Swallow to keep the render pipeline alive. - } -} - -// Guard flag: set while calling setOption so the datazoom event fired by -// the dataZoom reset inside setOption is ignored (prevents feedback loop). -let _applyingDeltas = false; - -function _fvRenderFigure(figUid) { - const chart = chartsByFig[figUid]; - if (!chart) return; - _applyingDeltas = true; - const series = buildSeriesForFigure(figUid); - const axisPatch = {}; - const opt = chart.getOption(); - const xAxis = Array.isArray(opt.xAxis) ? opt.xAxis[0] : opt.xAxis; - if (xAxis && xAxis.type === 'value') { - axisPatch.xAxis = { min: 'dataMin', max: 'dataMax' }; - } - // Heatmap axes: update category data from first heatmap trace's updates - const figSpec = figSpecByUid[figUid]; - if (figSpec) { - for (const ts of figSpec.traces) { - if (ts.trace_type === 'histogram2d' || ts.trace_type === 'corr_heatmap') { - const layers = layerDataByUid[ts.uid]; - const u = (layers && (layers.base || layers.fg || layers.bg)) || {}; - if (u.x && u.y) { - axisPatch.xAxis = { type: 'category', data: u.x.map(String) }; - axisPatch.yAxis = { type: 'category', data: u.y.map(String) }; - axisPatch.visualMap = heatmapVisualMapForTrace(ts, u); - } - break; - } - } - const boxAxisPatch = boxAxisPatchForFigure(figUid); - if (boxAxisPatch) { - Object.assign(axisPatch, boxAxisPatch); - } - const timeAxisPatch = timeAxisPatchForSeries(series); - if (timeAxisPatch) { - Object.assign(axisPatch, timeAxisPatch); - } - } - - // Y-axis anchoring: pin to bg extent when overlay fg is visible - const overlayMode = (DASHBOARD_SPEC.state && DASHBOARD_SPEC.state.cross_filter_mode) === 'overlay'; - const selections = (DASHBOARD_SPEC.state && DASHBOARD_SPEC.state.selections) || []; - const sourceFigure = figureHasSelectionSource(figUid, selections); - const hasFg = overlayMode && selections.length > 0 && !sourceFigure; - if (hasFg && bgYExtentByFig[figUid]) { - const ext = bgYExtentByFig[figUid]; - axisPatch.yAxis = { min: ext[0], max: ext[1] }; - } - - const displayRanges = { - ...(window.fvAxisLockRangesForFigure?.(figUid) || {}), - ...figureViewportRanges(figUid), - }; - const xLocked = window.fvIsAxisLocked?.(figUid, 'x') === true; - const dataZoom = displayRanges.x - ? [{ - startValue: displayRanges.x[0], - endValue: displayRanges.x[1], - disabled: xLocked, - zoomOnMouseWheel: !xLocked, - moveOnMouseWheel: !xLocked, - moveOnMouseMove: !xLocked, - }] - : [{ - start: 0, - end: 100, - disabled: xLocked, - zoomOnMouseWheel: !xLocked, - moveOnMouseWheel: !xLocked, - moveOnMouseMove: !xLocked, - }]; - - chart.setOption( - { series, dataZoom, ...axisPatch }, - { replaceMerge: ['series'] } - ); - _applyingDeltas = false; - syncBrushAreasForFigure(figUid); - window.fvUpdateAxisLockButtons?.(figUid); -} - -function _fvResetRendererCache() { - for (const figUid of FIG_UIDS) { - _brushAreasByFig[figUid] = []; - } -} - -window.fvCaptureAxisDisplayRanges = function(figUid, axisFamily) { - if (String(axisFamily || '').charAt(0) !== 'x') return {}; - const chart = chartsByFig[figUid]; - if (!chart) return {}; - const opt = chart.getOption(); - const dz = opt.dataZoom && opt.dataZoom[0]; - if (!dz) return {}; - if (dz.startValue !== undefined && dz.endValue !== undefined) { - return { x: [dz.startValue, dz.endValue] }; - } - return {}; -}; - -window.fvHasLockableCurrentAxis = function(figUid, axisFamily) { - return Object.keys(window.fvCaptureAxisDisplayRanges?.(figUid, axisFamily) || {}).length > 0; -}; - -window.fvApplyAxisLocks = function(figUid) { - const chart = chartsByFig[figUid]; - if (!chart) return; - const ranges = { - ...(window.fvAxisLockRangesForFigure?.(figUid) || {}), - ...figureViewportRanges(figUid), - }; - const xLocked = window.fvIsAxisLocked?.(figUid, 'x') === true; - if (!ranges.x) { - chart.setOption({ - dataZoom: [{ - start: 0, - end: 100, - disabled: xLocked, - zoomOnMouseWheel: !xLocked, - moveOnMouseWheel: !xLocked, - moveOnMouseMove: !xLocked, - }], - }); - return; - } - chart.setOption({ - dataZoom: [{ - startValue: ranges.x[0], - endValue: ranges.x[1], - disabled: xLocked, - zoomOnMouseWheel: !xLocked, - moveOnMouseWheel: !xLocked, - moveOnMouseMove: !xLocked, - }], - }); -}; diff --git a/flexviz/adapters/js/echarts/series.js b/flexviz/adapters/js/echarts/series.js deleted file mode 100644 index 20423fe..0000000 --- a/flexviz/adapters/js/echarts/series.js +++ /dev/null @@ -1,423 +0,0 @@ -// === ECharts adapter — series construction === -// Requires: state.js (figSpecByUid, layerDataByUid, groupedDataByParent, etc.) -// Requires: INITIAL_OPTIONS, ECHARTS_HEATMAP_COLOR_SCALES set by Python init - -const chartsByFig = {}; -const _brushAreasByFig = {}; -window.__fvBrushAreasByFig = _brushAreasByFig; -const seriesTemplateByUid = {}; -const CATEGORY_DIMMED_OPACITY = 0.28; - -function heatmapColorScale(ts) { - const display = (ts && ts.display) || {}; - if (!Object.prototype.hasOwnProperty.call(display, 'color_scale')) { - throw new Error('Generated heatmap specs must include explicit color_scale and color_range defaults.'); - } - const colorScale = display.color_scale; - if (typeof colorScale !== 'string' || !colorScale) { - throw new Error('heatmap color_scale must be a non-empty string'); - } - return colorScale.trim().toLowerCase(); -} - -function heatmapColorRange(ts) { - const display = (ts && ts.display) || {}; - if (!Object.prototype.hasOwnProperty.call(display, 'color_range')) { - throw new Error('Generated heatmap specs must include explicit color_scale and color_range defaults.'); - } - return display.color_range; -} - -function heatmapColors(ts) { - const colorScale = heatmapColorScale(ts); - const colors = ECHARTS_HEATMAP_COLOR_SCALES[colorScale]; - if (!colors) { - throw new Error('Unsupported ECharts heatmap color_scale: ' + colorScale); - } - return colors; -} - -function applySeriesColor(series, color) { - if (!series || !color) return series; - const next = { ...series }; - next.lineStyle = { ...(series.lineStyle || {}), color }; - next.itemStyle = { ...(series.itemStyle || {}), color }; - return next; -} - -function buildEChartsTreemapData(updates) { - const labels = updates.labels || []; - const parents = updates.parents || []; - const ids = updates.ids || []; - const values = updates.values || []; - const marker = updates.marker || {}; - const colors = marker.colors || []; - const root = { id: 'root', name: '', children: [] }; - const nodeMap = { root }; - - for (let i = 0; i < labels.length; i++) { - const id = String(ids[i] || labels[i] || ''); - if (id === 'root') continue; - const node = { - id, - name: String(labels[i] ?? ''), - value: values[i] || 0, - children: [], - }; - if (colors[i]) { - node.itemStyle = { color: colors[i] }; - } - nodeMap[id] = node; - } - for (let i = 0; i < labels.length; i++) { - const id = String(ids[i] || labels[i] || ''); - if (id === 'root') continue; - const parentId = String(parents[i] || ''); - const parent = nodeMap[parentId] || root; - const node = nodeMap[id]; - const parentColor = parent && parent.itemStyle && parent.itemStyle.color; - if (parentColor) { - node.itemStyle = { - ...(node.itemStyle || {}), - borderColor: parentColor, - borderWidth: 3, - }; - } - parent.children.push(node); - } - return root.children; -} - -function echartsItemSatisfiesPredicate(ts, item, predicate) { - if (!ts || !item || !predicate) return false; - if (ts.trace_type === 'pie') { - const labels = ts.backend_data && ts.backend_data.labels; - if (!labels) return false; - const labelCols = Array.isArray(labels) ? labels : [labels]; - const rawName = item.name; - if (rawName == null) return false; - let parts; - if (labelCols.length === 1) { - parts = [String(rawName)]; - } else { - try { parts = JSON.parse(String(rawName)); } - catch (e) { return false; } - if (!Array.isArray(parts) || parts.length !== labelCols.length) return false; - } - return (predicate.clauses || []).every(clause => { - const idx = labelCols.indexOf(clause.column); - if (idx < 0) return true; - return (clause.values || []).map(String).includes(String(parts[idx])); - }); - } - if (ts.trace_type === 'treemap') { - const path = (ts.params && ts.params.path) || []; - const itemId = String((item && item.id) || ''); - if (!itemId.startsWith('root/')) return false; - const idParts = itemId.split('/').slice(1).map(v => decodeURIComponent(String(v))); - return (predicate.clauses || []).every(clause => { - const idx = path.indexOf(clause.column); - if (idx < 0) return true; - if (idx >= idParts.length) return false; - return (clause.values || []).map(String).includes(idParts[idx]); - }); - } - return false; -} - -function applyCategorySelectionToData(figUid, ts, data) { - if (ts.trace_type !== 'pie' && ts.trace_type !== 'treemap') return data; - const predicates = window.fvPredicatesForFigure?.( - figUid, - (DASHBOARD_SPEC.state && DASHBOARD_SPEC.state.selections) || [] - ) || []; - return data.map(item => { - const selected = !predicates.length || predicates.some( - predicate => echartsItemSatisfiesPredicate(ts, item, predicate) - ); - const next = { - ...item, - itemStyle: { - ...((item && item.itemStyle) || {}), - opacity: selected ? 1 : CATEGORY_DIMMED_OPACITY, - }, - }; - if (ts.trace_type === 'treemap' && Array.isArray(item.children)) { - next.children = applyCategorySelectionToData(figUid, ts, item.children); - } - return next; - }); -} - -// Initialise series templates from the Python-generated INITIAL_OPTIONS -FIG_UIDS.forEach(figUid => { - const figSpec = figSpecByUid[figUid]; - let colorIndex = 0; - (INITIAL_OPTIONS[figUid].series || []).forEach(series => { - const tsSpec = figSpec.traces.find(ts => ts.uid === series.id); - const color = (tsSpec && tsSpec.display && tsSpec.display.color) - || _fvPalette[colorIndex % _fvPalette.length]; - seriesTemplateByUid[series.id] = applySeriesColor(cloneObj(series), color); - ensureLayerData(series.id); - colorIndex += 1; - }); -}); - -function makeEChartsSeries(ts, uid, name, color) { - const seriesId = uid || ts.uid; - const seriesName = name || ((ts.display && ts.display.name) || ts.uid); - if (ts.trace_type === 'line') { - const s = { - id: seriesId, - type: 'line', - name: seriesName, - data: [], - showSymbol: false, - smooth: false, - }; - if (color) { - s.lineStyle = { color }; - s.itemStyle = { color }; - } - return s; - } - if (ts.trace_type === 'histogram' || ts.trace_type === 'bar') { - const s = { - id: seriesId, - type: 'bar', - name: seriesName, - data: [], - }; - if (ts.trace_type === 'histogram') s.barMaxWidth = 40; - const barMode = (ts.display && ts.display.bar_mode) || (ts.params && ts.params.bar_mode) || 'group'; - if (ts.trace_type === 'bar' && barMode === 'stack') { - s.stack = 'bar'; - } - if (color) s.itemStyle = { color }; - return s; - } - if (ts.trace_type === 'pie') { - const hole = (ts.params && ts.params.hole) || 0; - const inner = hole ? (hole * 100) + '%' : '0%'; - return { - id: seriesId, - type: 'pie', - name: seriesName, - data: [], - radius: [inner, '75%'], - }; - } - if (ts.trace_type === 'box') { - const s = { - id: seriesId, - type: 'boxplot', - name: seriesName, - data: [], - }; - if (color) s.itemStyle = { color }; - return s; - } - if (ts.trace_type === 'treemap') { - return { - id: seriesId, - type: 'treemap', - name: seriesName, - data: [], - roam: false, - nodeClick: false, - breadcrumb: { show: false }, - label: { show: true, formatter: '{b}' }, - upperLabel: { show: true }, - itemStyle: { - borderColor: '#fafaf8', - borderWidth: 2, - gapWidth: 1, - }, - levels: [ - { - itemStyle: { - borderColor: '#fafaf8', - borderWidth: 2, - gapWidth: 1, - }, - }, - { - upperLabel: { show: true, height: 24 }, - itemStyle: { - borderColor: '#fafaf8', - borderWidth: 3, - gapWidth: 6, - }, - }, - { - itemStyle: { - borderColor: '#fafaf8', - borderWidth: 2, - gapWidth: 2, - }, - }, - ], - }; - } - if (ts.trace_type === 'histogram2d' || ts.trace_type === 'corr_heatmap') { - return { - id: seriesId, - type: 'heatmap', - name: seriesName, - data: [], - }; - } - throw new Error('Unsupported trace type ' + ts.trace_type); -} - -function seriesDataFromUpdates(updates, template) { - if (template && template.type === 'pie') { - const labels = updates.labels || []; - const values = updates.values || []; - const marker = updates.marker || {}; - const colors = marker.colors || []; - return labels.map((label, i) => { - const item = { name: label, value: values[i] }; - if (colors[i]) item.itemStyle = { color: colors[i] }; - return item; - }); - } - if (template && template.type === 'heatmap') { - const xs = updates.x || []; - const ys = updates.y || []; - const z = updates.z || []; - const data = []; - for (let j = 0; j < ys.length; j++) { - for (let i = 0; i < xs.length; i++) { - const row = z[j] || []; - const value = row[i]; - data.push([i, j, value === undefined ? null : value]); - } - } - return data; - } - if (template && template.type === 'boxplot') { - const lower = updates.lowerfence || []; - const q1 = updates.q1 || []; - const median = updates.median || []; - const q3 = updates.q3 || []; - const upper = updates.upperfence || []; - const label = updates.x0 ?? updates.y0 ?? template.name; - return lower.map((_, i) => ({ - name: String(label), - value: [lower[i], q1[i], median[i], q3[i], upper[i]], - })); - } - if (template && template.type === 'treemap') { - return buildEChartsTreemapData(updates); - } - const xs = updates.x || []; - const ys = updates.y || []; - return xs.map((x, i) => [x, ys[i]]); -} - -function buildSeriesFromTemplate(figUid, ts, template, logicalUid, renderLayer, updates, opacity, zValue) { - if (!template) return null; - let data = seriesDataFromUpdates(updates || {}, template); - data = applyCategorySelectionToData(figUid, ts, data); - const series = { - ...template, - id: rendererUid(logicalUid, renderLayer), - data, - opacity, - z: zValue, - }; - series.lineStyle = { ...(template.lineStyle || {}), opacity }; - series.itemStyle = { ...(template.itemStyle || {}), opacity }; - return series; -} - -function buildGroupedEChartsChildren(figUid, parentUid, childResults, renderLayer, opacity, zValue) { - const figSpec = figSpecByUid[figUid]; - const parentSpec = figSpec && figSpec.traces.find(ts => ts.uid === parentUid); - if (!parentSpec) return []; - return childResults.map(cr => { - const color = ensureGroupColor(parentSpec, cr.group_value_key); - const template = makeEChartsSeries(parentSpec, cr.uid, cr.group_value_key, color); - return buildSeriesFromTemplate( - figUid, - parentSpec, - template, - cr.uid, - renderLayer, - cr.updates || {}, - opacity, - zValue - ); - }).filter(Boolean); -} - -function buildSeriesForFigure(figUid) { - const figSpec = figSpecByUid[figUid]; - const overlayMode = (DASHBOARD_SPEC.state && DASHBOARD_SPEC.state.cross_filter_mode) === 'overlay'; - const selections = (DASHBOARD_SPEC.state && DASHBOARD_SPEC.state.selections) || []; - const hasSelections = selections.length > 0; - const sourceFigure = figureHasSelectionSource(figUid, selections); - const showForeground = overlayMode && hasSelections && !sourceFigure; - const backgroundOpacity = overlayMode && hasSelections && !sourceFigure ? OVERLAY_BG_OPACITY : 1; - const bgDataLayer = backgroundDataLayerForFigure(figUid); - const series = []; - for (const ts of figSpec.traces) { - if (isGroupedParent(ts)) { - const childLayers = groupedDataByParent[figUid][ts.uid] || { base: [], bg: [], fg: [] }; - if (overlayMode) { - series.push( - ...buildGroupedEChartsChildren( - figUid, ts.uid, childLayers[bgDataLayer] || [], 'bg', - backgroundOpacity, hasSelections && !sourceFigure ? 1 : 2 - ) - ); - if (showForeground) { - series.push( - ...buildGroupedEChartsChildren(figUid, ts.uid, childLayers.fg || [], 'fg', 1, 3) - ); - } - } else { - series.push( - ...buildGroupedEChartsChildren(figUid, ts.uid, childLayers.base || [], 'base', 1, 2) - ); - } - } else { - const template = seriesTemplateByUid[ts.uid]; - const layers = ensureLayerData(ts.uid); - if (overlayMode) { - const bgSeries = buildSeriesFromTemplate( - figUid, ts, template, ts.uid, 'bg', layers[bgDataLayer] || {}, - backgroundOpacity, hasSelections && !sourceFigure ? 1 : 2 - ); - if (bgSeries) series.push(bgSeries); - if (showForeground) { - const fgSeries = buildSeriesFromTemplate( - figUid, - ts, - template, - ts.uid, - 'fg', - layers.fg || {}, - 1, - 3 - ); - if (fgSeries) series.push(fgSeries); - } - } else { - const baseSeries = buildSeriesFromTemplate( - figUid, - ts, - template, - ts.uid, - 'base', - layers.base || {}, - 1, - 2 - ); - if (baseSeries) series.push(baseSeries); - } - } - } - return series; -} diff --git a/flexviz/adapters/js/plotly/hover.js b/flexviz/adapters/js/plotly/hover.js index bc88c18..f832df6 100644 --- a/flexviz/adapters/js/plotly/hover.js +++ b/flexviz/adapters/js/plotly/hover.js @@ -194,7 +194,6 @@ function clearAllPlotlyCrosshairs() { renderHoverOverlay(figUid); } } -window.fvClearAllCrosshairs = clearAllPlotlyCrosshairs; window.fvClearAllHoverVisuals = clearAllPlotlyCrosshairs; function suspendHoverForDrag() { @@ -435,6 +434,3 @@ function handlePlotlyUnhover() { if (_hoverSuspendedForDrag) return; clearAllPlotlyCrosshairs(); } - -// Legacy alias used by some test helpers -function _fvClearAllCrosshairs() { clearAllPlotlyCrosshairs(); } diff --git a/flexviz/adapters/js/plotly/traces.js b/flexviz/adapters/js/plotly/traces.js index dd10714..859b385 100644 --- a/flexviz/adapters/js/plotly/traces.js +++ b/flexviz/adapters/js/plotly/traces.js @@ -506,7 +506,7 @@ function buildTracesForFigure(figUid) { const bgDataLayer = backgroundDataLayerForFigure(figUid); const traces = []; for (const ts of figSpec.traces) { - const barMode = (ts.display && ts.display.bar_mode) || (ts.params && ts.params.bar_mode) || 'group'; + const barMode = (ts.display && ts.display.bar_mode) || 'group'; const forceBarOffsetgroup = showForeground && barMode !== 'stack'; if (isGroupedParent(ts)) { const childLayers = groupedDataByParent[figUid][ts.uid] || { base: [], bg: [], fg: [] }; @@ -544,9 +544,7 @@ function baseBarmodeForFigure(figSpec) { if (!figSpec) return null; const barTs = figSpec.traces.find(ts => ts.trace_type === 'bar'); if (barTs) { - return (barTs.display && barTs.display.bar_mode) - || (barTs.params && barTs.params.bar_mode) - || 'group'; + return (barTs.display && barTs.display.bar_mode) || 'group'; } return figSpec.traces.some(ts => ts.trace_type === 'histogram') ? 'group' : null; } diff --git a/flexviz/adapters/js/runtime/overlay.js b/flexviz/adapters/js/runtime/overlay.js index 686ff78..41a7430 100644 --- a/flexviz/adapters/js/runtime/overlay.js +++ b/flexviz/adapters/js/runtime/overlay.js @@ -22,7 +22,6 @@ window.fvResetRuntimeCache = function() { for (const layerKey of layerKeys) setGroupedLayerData(fig.uid, parentUid, layerKey, [], seq); } } - if (typeof _fvResetRendererCache === 'function') _fvResetRendererCache(); }; async function restoreDashboardFromSpec() { window.fvResetRuntimeCache?.(); diff --git a/flexviz/adapters/js/runtime/state.js b/flexviz/adapters/js/runtime/state.js index b2c7fe1..0346782 100644 --- a/flexviz/adapters/js/runtime/state.js +++ b/flexviz/adapters/js/runtime/state.js @@ -191,8 +191,7 @@ function stripLayerSuffix(uid) { return uid.slice(0, -RENDER_LAYER_SUFFIX.bg.length); if (uid.endsWith(RENDER_LAYER_SUFFIX.fg)) return uid.slice(0, -RENDER_LAYER_SUFFIX.fg.length); - // Backward compatibility for existing rendered ids. - return uid.replace(/::(bg|fg)$/, ''); + return uid; } function cloneObj(obj) { return JSON.parse(JSON.stringify(obj)); diff --git a/flexviz/adapters/js/toolbar.js b/flexviz/adapters/js/toolbar.js index 5769015..fecfd87 100644 --- a/flexviz/adapters/js/toolbar.js +++ b/flexviz/adapters/js/toolbar.js @@ -388,12 +388,7 @@ function fvInitHoverDropdown() { const btn = document.getElementById('fv-hover-btn'); if (!btn) return; - // Ensure client_state exists; coerce any legacy per-mode value onto on/off. if (!DASHBOARD_SPEC.client_state) DASHBOARD_SPEC.client_state = {}; - const cur = DASHBOARD_SPEC.client_state.hover_mode; - if (cur !== 'on' && cur !== 'off') { - DASHBOARD_SPEC.client_state.hover_mode = cur ? 'on' : 'off'; - } // Hover is offered only when at least one linkable source→target pair exists. const gates = { diff --git a/flexviz/adapters/plotly_adapter.py b/flexviz/adapters/plotly_adapter.py index 3099909..7d0c3c4 100644 --- a/flexviz/adapters/plotly_adapter.py +++ b/flexviz/adapters/plotly_adapter.py @@ -340,9 +340,7 @@ def _build_dashboard_html( (ts for ts in fig_spec.traces if ts.trace_type == "bar"), None ) if bar_ts is not None: - layout_obj["barmode"] = bar_ts.display.get( - "bar_mode", bar_ts.params.get("bar_mode", "group") - ) + layout_obj["barmode"] = bar_ts.display.get("bar_mode", "group") elif any(ts.trace_type == "histogram" for ts in fig_spec.traces): # Plotly's default barmode is 'stack', which is wrong for # independent-variable histograms. Mirror in JS baseBarmodeForFigure. @@ -511,7 +509,7 @@ def _plotly_trace_obj(ts: Any, name: str, color: str | None) -> dict: obj = {"uid": ts.uid, "type": "bar", "name": name, "x": [], "y": []} if ts.params.get("orientation") == "h": obj["orientation"] = "h" - bar_mode = ts.display.get("bar_mode", ts.params.get("bar_mode", "group")) + bar_mode = ts.display.get("bar_mode", "group") if bar_mode != "stack": obj["offsetgroup"] = ts.uid obj["alignmentgroup"] = "fv-bars" diff --git a/flexviz/adapters/registry.py b/flexviz/adapters/registry.py index 6200438..dd11a14 100644 --- a/flexviz/adapters/registry.py +++ b/flexviz/adapters/registry.py @@ -54,19 +54,6 @@ def build_adapter(self) -> AbstractAdapter: } ) -ECHARTS_TRACE_TYPES = frozenset( - { - "line", - "histogram", - "box", - "bar", - "pie", - "treemap", - "histogram2d", - "corr_heatmap", - } -) - _RENDERERS: dict[str, RendererDefinition] = { "plotly": RendererDefinition( name="plotly", @@ -76,14 +63,6 @@ def build_adapter(self) -> AbstractAdapter: supported_trace_types=PLOTLY_TRACE_TYPES, ), ), - "echarts": RendererDefinition( - name="echarts", - adapter_import_path="flexviz.adapters.echarts_adapter.EChartsAdapter", - capabilities=RendererCapabilities( - name="echarts", - supported_trace_types=ECHARTS_TRACE_TYPES, - ), - ), } @@ -91,16 +70,12 @@ def supported_renderers() -> tuple[str, ...]: return tuple(_RENDERERS) -def normalize_renderer_name(renderer: str) -> str: - name = str(renderer).strip().lower() - if name not in _RENDERERS: +def get_renderer_definition(renderer: str) -> RendererDefinition: + definition = _RENDERERS.get(renderer) + if definition is None: supported = ", ".join(repr(item) for item in supported_renderers()) raise ValueError(f"Unknown renderer {renderer!r}. Supported: {supported}.") - return name - - -def get_renderer_definition(renderer: str) -> RendererDefinition: - return _RENDERERS[normalize_renderer_name(renderer)] + return definition def build_adapter(renderer: str) -> AbstractAdapter: diff --git a/flexviz/adapters/runtime.py b/flexviz/adapters/runtime.py index 549c161..0e661fb 100644 --- a/flexviz/adapters/runtime.py +++ b/flexviz/adapters/runtime.py @@ -1,7 +1,7 @@ """Shared FlexViz client-side JS runtime. This module provides the single source of truth for all renderer-agnostic -JS logic used by both the Plotly and ECharts adapters. The runtime +JS logic used by the Plotly adapter. The runtime handles delta application, overlay caching, grouped-child reconciliation, linked-hover dispatch, toolbar hooks, and state management. @@ -11,13 +11,8 @@ picked up on the next import. The concatenated strings are cached in module globals; the public ``*_js()`` / ``theme_css()`` accessors just return them. -Each adapter must define the following globals **before** the shared -runtime is included: - -- ``_fvAllFigUids`` — ``string[]`` of all figure UIDs -- ``_fvRenderFigure(figUid)`` — re-render one figure via the renderer -- ``_fvClearAllCrosshairs()`` — clear hover guides from all figures -- ``_fvShowCrosshair(figUid, axis, value)`` — draw a crosshair line +The shared runtime calls hooks that each adapter must define. The Shared +Runtime section of ``Architecture.md`` lists them. """ from __future__ import annotations @@ -50,12 +45,6 @@ "plotly/init.js", ] -_ECHARTS_SOURCES: list[str] = [ - "echarts/series.js", - "echarts/render.js", - "echarts/init.js", -] - def _read(rel: str) -> str: return (_JS_DIR / rel).read_text(encoding="utf-8") @@ -84,7 +73,6 @@ def _concat(sources: list[str]) -> str: _GRIDSTACK_BRIDGE_JS = _read("gridstack-bridge.js") _SHARED_RUNTIME_JS = _concat(_SHARED_SOURCES) _PLOTLY_BUNDLE_JS = _concat(_PLOTLY_SOURCES) -_ECHARTS_BUNDLE_JS = _concat(_ECHARTS_SOURCES) _FAVICON_B64 = _b64("favicon.png") _PAGE_HEAD_HTML = ( "FlexViz\n" @@ -130,11 +118,6 @@ def plotly_bundle_js() -> str: return _PLOTLY_BUNDLE_JS -def echarts_bundle_js() -> str: - """Return the ECharts-specific JS bundle.""" - return _ECHARTS_BUNDLE_JS - - def page_head_html() -> str: """Return the page title and inline favicon link for a dashboard page. diff --git a/flexviz/cache.py b/flexviz/cache.py index c9adfb2..0d3c3a8 100644 --- a/flexviz/cache.py +++ b/flexviz/cache.py @@ -167,12 +167,6 @@ def get_cache() -> CacheBackend: return _cache -def set_cache_backend(backend: CacheBackend) -> None: - """Swap the global cache backend (e.g. for a Redis/disk backend).""" - global _cache - _cache = backend - - def get_cube_cache() -> CacheBackend: """Return the process-global cube-blob cache (byte-bounded LRU). @@ -184,12 +178,6 @@ def get_cube_cache() -> CacheBackend: return _cube_cache -def set_cube_cache_backend(backend: CacheBackend) -> None: - """Swap the global cube cache backend (e.g. for a Redis/disk backend).""" - global _cube_cache - _cube_cache = backend - - def set_source_cacheable(name: str, flag: bool) -> None: """Record whether a named source opts into caching. diff --git a/flexviz/dashboard.py b/flexviz/dashboard.py index b6141cc..860a63e 100644 --- a/flexviz/dashboard.py +++ b/flexviz/dashboard.py @@ -462,7 +462,7 @@ def show( Parameters ---------- renderer: - ``"plotly"`` (default) or ``"echarts"``. + ``"plotly"`` (the only renderer). source_name: Name under which the shared backend LazyFrame is registered with the server's data-source registry. Defaults to the diff --git a/flexviz/figure.py b/flexviz/figure.py index 3fa23ac..688f67d 100644 --- a/flexviz/figure.py +++ b/flexviz/figure.py @@ -139,7 +139,7 @@ class Figure: - A list of ``FlexTrace`` instances - A layout config dict (renderer hints, e.g. ``title``) - The Figure does not know about Plotly, Echarts, or any other renderer. + The Figure does not know about Plotly or any other renderer. Rendering is delegated to an adapter chosen via ``show(renderer=...)``. """ @@ -867,7 +867,7 @@ def show( Parameters ---------- renderer: - ``"plotly"`` (default) or ``"echarts"``. + ``"plotly"`` (the only renderer). source_name: Name under which the figure's backend LazyFrame is registered with the server's data-source registry. Defaults to the @@ -916,13 +916,6 @@ def show( ) -def _registered_sources() -> list[str]: - """Return names of already-registered sources (avoids double-registration).""" - from .server import _sources - - return list(_sources) - - def _effective_live_brush(live_brush: str | None, effective_cache: bool) -> str: """Couple ``live_brush`` to caching: cubes are only built for ``cache=True`` sources (the "data is static" contract), so live-brush cannot function diff --git a/flexviz/server.py b/flexviz/server.py index 0415d5c..9ed2376 100644 --- a/flexviz/server.py +++ b/flexviz/server.py @@ -524,7 +524,7 @@ async def view(spec: str, renderer: str = "plotly") -> HTMLResponse: spec: URL-safe base64-encoded gzip-compressed JSON spec string. renderer: - ``"plotly"`` (default) or ``"echarts"``. + ``"plotly"`` (the only renderer). """ # Page-relative base: every API endpoint is a sibling of /view, so "." # resolves correctly in the browser behind any reverse proxy — including @@ -549,7 +549,7 @@ async def history_view(n: int, renderer: str | None = None) -> HTMLResponse: n: 1-based entry number, as recorded by ``flexviz history add``. renderer: - ``"plotly"`` or ``"echarts"``; defaults to the renderer in the + ``"plotly"``; defaults to the renderer in the recorded URL, else ``"plotly"``. """ from flexviz import history diff --git a/flexviz/trace/bar.py b/flexviz/trace/bar.py index 232c5ea..f95f18f 100644 --- a/flexviz/trace/bar.py +++ b/flexviz/trace/bar.py @@ -261,7 +261,7 @@ def get_aggregation_spec( ) def _to_update(self, df: pl.DataFrame) -> TraceResult: - """Backward-compatible alias for grouped query output parsing.""" + """Bar always runs grouped, so the abstract hook forwards.""" return self._to_grouped_update(df) def _to_grouped_update(self, df: pl.DataFrame) -> TraceResult: @@ -328,17 +328,9 @@ def _make_updates(labels: list, values: list) -> dict: @classmethod def from_trace_spec(cls, spec: TraceSpec) -> BarPlot: bar_mode = spec.display.get("bar_mode", "group") - if "x" in spec.backend_data: - # Backward compat: old specs stored {"x": labels_col, "y": values_col} - labels = spec.backend_data["x"] - old_y = spec.backend_data["y"] - old_agg = spec.params.get("agg", "sum") - values = None if old_agg == "count" else old_y - agg = "sum" if old_agg == "count" else old_agg - else: - labels = spec.backend_data["labels"] - values = spec.backend_data.get("values") - agg = spec.params.get("agg", "sum") + labels = spec.backend_data["labels"] + values = spec.backend_data.get("values") + agg = spec.params.get("agg", "sum") trace = cls( labels=labels, values=values, diff --git a/flexviz/trace/base.py b/flexviz/trace/base.py index 865dd1d..5e449b9 100644 --- a/flexviz/trace/base.py +++ b/flexviz/trace/base.py @@ -327,9 +327,6 @@ def group_by_cols(self) -> tuple[str, ...] | None: def group_domain_key(self) -> str | None: return self._params.get("group_domain_key") - def is_grouped_parent(self) -> bool: - return self.group_by_cols is not None - def _to_grouped_update(self, df_grouped: pl.DataFrame) -> TraceResult: """Convert a grouped result frame into a parent-scoped ``TraceResult``. diff --git a/flexviz/trace/corr_heatmap.py b/flexviz/trace/corr_heatmap.py index b6f5352..9323355 100644 --- a/flexviz/trace/corr_heatmap.py +++ b/flexviz/trace/corr_heatmap.py @@ -64,14 +64,6 @@ class CorrHeatmap(FlexTrace): True: (0.0, 1.0), } - @classmethod - def default_color_scale(cls, *, absolute: bool) -> str: - return cls.DEFAULT_COLOR_SCALE_BY_ABSOLUTE[bool(absolute)] - - @classmethod - def default_color_range(cls, *, absolute: bool) -> HeatmapColorRange: - return cls.DEFAULT_COLOR_RANGE_BY_ABSOLUTE[bool(absolute)] - @classmethod def _normalize_color_scale(cls, color_scale: str | None, *, absolute: bool) -> str: return normalize_heatmap_color_scale( diff --git a/flexviz/trace/hist2d.py b/flexviz/trace/hist2d.py index 652f47d..a1e39cf 100644 --- a/flexviz/trace/hist2d.py +++ b/flexviz/trace/hist2d.py @@ -3,7 +3,7 @@ Bins two numeric columns into a 2D grid and counts occurrences (or applies ``"sum"``, ``"mean"``, ``"min"``, or ``"max"`` via *histfunc*). Returns ``{x: [...centers], y: [...centers], z: [[counts]]}`` — the standard format -for Plotly ``heatmap`` and ECharts ``heatmap`` series. +for Plotly ``heatmap`` series. When *z* is omitted the trace counts rows per bin (implicit count). When *z* is given, *histfunc* is required and must be one of ``"sum"``, ``"mean"``, diff --git a/flexviz/trace/line.py b/flexviz/trace/line.py index fe40630..65b6dad 100644 --- a/flexviz/trace/line.py +++ b/flexviz/trace/line.py @@ -880,7 +880,7 @@ def _to_update( Gaps (null breaks across large x jumps) are a client-side display concern, inserted at render time by ``fvApplyLineGaps`` - (``adapters/js/plotly/traces.js``); the server emits gapless x/y. + (``adapters/js/runtime/cube.js``); the server emits gapless x/y. """ raw: pl.Series = df_agg[self.uid].item() df_line = raw.explode(empty_as_null=True).struct.unnest() diff --git a/flexviz/trace/pie.py b/flexviz/trace/pie.py index 6d515d8..2e4c101 100644 --- a/flexviz/trace/pie.py +++ b/flexviz/trace/pie.py @@ -229,8 +229,7 @@ def _to_grouped_update(self, df: pl.DataFrame) -> TraceResult: def from_trace_spec(cls, spec: TraceSpec) -> PiePlot: old_agg = spec.params.get("agg", "sum") stored_values = spec.backend_data.get("values") - # Backward compat: old specs always had "values"; if agg was "count" the - # values column was ignored — reconstruct as values=None. + # A pie with no values column stores agg="count"; rebuild it as values=None. values = None if old_agg == "count" else stored_values agg = "sum" if old_agg == "count" else old_agg trace = cls( diff --git a/tests/test_adapters.py b/tests/test_adapters.py index c8b51c9..929022f 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,8 +1,7 @@ -"""Unit tests for adapter Python APIs: shared toolbar and -ECharts ``_build_initial_option``. +"""Unit tests for adapter Python APIs. -Covers PlotlyAdapter and EChartsAdapter, plus the shared toolbar building -blocks on AbstractAdapter. +Covers PlotlyAdapter, plus the shared toolbar building blocks on +AbstractAdapter. """ from __future__ import annotations @@ -219,28 +218,9 @@ def test_geo_figures_keep_zoom_pan_enabled(self, trace_type): ) -# ---- EChartsAdapter -------------------------------------------------------- - - -class TestEChartsParseEvent: - def test_empty_returns_none(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - assert EChartsAdapter().parse_event({}) is None - - def test_unknown_dict_returns_none(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - assert EChartsAdapter().parse_event({"foo": "bar"}) is None - - class TestNotebookDelivery: - @pytest.mark.parametrize( - ("renderer", "height", "iframe_height"), - [("plotly", 432, 432), ("echarts", 432, 512)], - ) def test_notebook_iframe_loads_the_page_from_the_server( - self, server_port, monkeypatch, renderer, height, iframe_height + self, server_port, monkeypatch ): """The page runs on the server's own origin, so it needs no CORS.""" import sys @@ -265,14 +245,14 @@ def test_notebook_iframe_loads_the_page_from_the_server( dash.add_figure().add_line(x="ts", y="val") spec = dash.to_spec(source_name="_browser_test") server_url = f"http://127.0.0.1:{server_port}" - build_adapter(renderer).show_dashboard( - spec, server_url=server_url, notebook=True, height=height + build_adapter("plotly").show_dashboard( + spec, server_url=server_url, notebook=True, height=432 ) (iframe,) = shown assert iframe["src"].startswith(f"{server_url}/view?spec=") - assert iframe["src"].endswith(f"&renderer={renderer}") - assert iframe["height"] == iframe_height + assert iframe["src"].endswith("&renderer=plotly") + assert iframe["height"] == 432 class TestBrowserDelivery: @@ -300,92 +280,6 @@ async def in_kernel(): assert waited == ([] if in_notebook else [1]) -class TestEChartsInitialOption: - def test_line_series(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec( - uid="e1", trace_type="line", display={"name": "MyLine", "color": "#ff0000"} - ) - fig_spec = FigureSpec(traces=[ts]) - option = EChartsAdapter._build_initial_option(fig_spec, 400) - assert option["series"][0]["type"] == "line" - assert option["series"][0]["id"] == "e1" - assert option["series"][0]["showSymbol"] is False - - def test_histogram_series(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec(uid="e2", trace_type="histogram", display={"name": "MyHist"}) - fig_spec = FigureSpec(traces=[ts]) - option = EChartsAdapter._build_initial_option(fig_spec, 400) - assert option["series"][0]["type"] == "bar" - assert option["series"][0]["id"] == "e2" - - def test_has_datazoom(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec(uid="e1", trace_type="line", display={}) - fig_spec = FigureSpec(traces=[ts]) - option = EChartsAdapter._build_initial_option(fig_spec, 400) - assert "dataZoom" in option - assert option["dataZoom"][0]["type"] == "inside" - - def test_unknown_trace_type_raises(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec(uid="e1", trace_type="scatter", display={}) - fig_spec = FigureSpec(traces=[ts]) - with pytest.raises(ValueError, match="unsupported trace type"): - EChartsAdapter._build_initial_option(fig_spec, 400) - - def test_box_series_type(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec( - uid="ec-box", - trace_type="box", - backend_data={"y": "val"}, - display={"name": "MyBox", "color": "#ff6600"}, - ) - fig_spec = FigureSpec(uid="fig-box", traces=[ts]) - opt = EChartsAdapter._build_initial_option(fig_spec, 400) - assert opt["series"][0]["type"] == "boxplot" - assert opt["series"][0]["id"] == "ec-box" - assert opt["series"][0]["itemStyle"]["color"] == "#ff6600" - assert opt["xAxis"]["type"] == "category" - assert opt["yAxis"]["type"] == "value" - - def test_treemap_series_type(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec( - uid="ec-tm", - trace_type="treemap", - params={"path": ["continent", "country"], "agg": "sum"}, - display={"name": "Pop"}, - ) - fig_spec = FigureSpec(uid="fig-tm", traces=[ts]) - opt = EChartsAdapter._build_initial_option(fig_spec, 400) - assert opt["series"][0]["type"] == "treemap" - assert opt["series"][0]["id"] == "ec-tm" - assert opt["series"][0]["nodeClick"] is False - assert opt["series"][0]["roam"] is False - assert opt["series"][0]["breadcrumb"]["show"] is False - assert opt["series"][0]["label"]["show"] is True - assert opt["series"][0]["upperLabel"]["show"] is True - assert "xAxis" not in opt - assert "yAxis" not in opt - - def test_initial_option_hides_toolbox(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec(uid="e1", trace_type="line", display={}) - fig_spec = FigureSpec(traces=[ts]) - opt = EChartsAdapter._build_initial_option(fig_spec, 400) - assert opt["toolbox"]["show"] is False - - # ---- PlotlyAdapter box trace (dashboard HTML) ------------------------------ @@ -553,68 +447,6 @@ def test_stack_bar_bootstrap_does_not_force_offsetgroup(self): assert "alignmentgroup" not in obj -class TestEChartsBarTrace: - def test_bar_series_in_initial_option(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec( - uid="ec-bar", - trace_type="bar", - backend_data={"x": "cat", "y": "val"}, - params={"agg": "sum", "orientation": "v", "bar_mode": "group"}, - display={"name": "Revenue", "bar_mode": "group"}, - ) - fig_spec = FigureSpec(uid="fig-ec", traces=[ts]) - opt = EChartsAdapter._build_initial_option(fig_spec, 400) - assert len(opt["series"]) == 1 - assert opt["series"][0]["type"] == "bar" - assert opt["xAxis"]["type"] == "category" - - def test_bar_horizontal_swaps_axes(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec( - uid="ec-bh", - trace_type="bar", - backend_data={"x": "cat", "y": "val"}, - params={"agg": "sum", "orientation": "h", "bar_mode": "group"}, - display={"name": "H", "bar_mode": "group"}, - ) - fig_spec = FigureSpec(uid="fig-ech", traces=[ts]) - opt = EChartsAdapter._build_initial_option(fig_spec, 400) - assert opt["xAxis"]["type"] == "value" - assert opt["yAxis"]["type"] == "category" - - def test_stack_bar_adds_stack_property(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec( - uid="ec-stack", - trace_type="bar", - backend_data={"x": "cat", "y": "val"}, - params={"agg": "sum", "orientation": "v", "bar_mode": "stack"}, - display={"name": "S", "bar_mode": "stack"}, - ) - fig_spec = FigureSpec(uid="fig-stack", traces=[ts]) - opt = EChartsAdapter._build_initial_option(fig_spec, 400) - assert opt["series"][0].get("stack") == "bar" - assert "barMode" not in opt - - def test_stack_bar_uses_legacy_param_fallback(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - ts = TraceSpec( - uid="ec-stack-legacy", - trace_type="bar", - backend_data={"x": "cat", "y": "val"}, - params={"agg": "sum", "orientation": "v", "bar_mode": "stack"}, - display={"name": "Legacy"}, - ) - fig_spec = FigureSpec(uid="fig-stack-legacy", traces=[ts]) - opt = EChartsAdapter._build_initial_option(fig_spec, 400) - assert opt["series"][0].get("stack") == "bar" - - class TestFigureSelectDirection: """`_figure_select_direction` derives Plotly band-brush geometry from the figure's `kind="range"` traces' `selection.axis_columns`.""" @@ -818,9 +650,28 @@ def test_unknown_show_kwarg_raises(self): with pytest.raises(TypeError, match="draggable"): PlotlyAdapter().show_dashboard(DashboardSpec(), draggable=False) - def test_unknown_show_kwarg_raises_on_echarts(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - from flexviz.spec import DashboardSpec - with pytest.raises(TypeError, match="draggable"): - EChartsAdapter().show_dashboard(DashboardSpec(), draggable=False) +class TestRendererRegistry: + def test_plotly_supports_every_registered_trace(self): + """Plotly is the primary renderer, so a trace it misses fails at /view.""" + from flexviz.adapters.registry import PLOTLY_TRACE_TYPES + from flexviz.trace import _REGISTRY + + assert PLOTLY_TRACE_TYPES == set(_REGISTRY) + + def test_unsupported_trace_type_names_the_figure(self): + from flexviz.adapters import validate_dashboard_renderer + + trace = TraceSpec(uid="t-1", trace_type="not_in_plotly") + figure = FigureSpec(uid="fig-1", layout={"title": "Map"}, traces=[trace]) + with pytest.raises( + ValueError, + match="'plotly' does not support trace type 'not_in_plotly' in figure 'Map'", + ): + validate_dashboard_renderer("plotly", DashboardSpec(figures=[figure])) + + def test_renderer_name_must_match_exactly(self): + from flexviz.adapters import build_adapter + + with pytest.raises(ValueError, match="Unknown renderer 'Plotly'"): + build_adapter("Plotly") diff --git a/tests/test_browser.py b/tests/test_browser.py index c5936af..f9a3362 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -13,7 +13,6 @@ ----- - Page loads and charts render (DOM elements present). - Global toolbar buttons are present. -- Scroll zoom on ECharts triggers a ``/dashboard/update`` POST. - Global Reset and Deselect buttons fire the expected toolbar actions. """ @@ -68,26 +67,8 @@ def _start_server(port: int) -> None: raise RuntimeError(f"Server did not start on port {port}") -def _start_demo_server(port: int) -> None: - """Start the demo FastAPI server on *port* in a daemon thread.""" - from demo.server import app as demo_app - - config = uvicorn.Config(demo_app, host="127.0.0.1", port=port, log_level="error") - server = uvicorn.Server(config) - t = threading.Thread(target=server.run, daemon=True) - t.start() - deadline = time.time() + 10 - while time.time() < deadline: - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.2): - return - except OSError: - time.sleep(0.1) - raise RuntimeError(f"Demo server did not start on port {port}") - - -def _dashboard_url(port: int, renderer: str, n_figures: int = 2) -> str: - """Return a URL that serves an N-figure dashboard via the given renderer.""" +def _dashboard_url(port: int, n_figures: int = 2) -> str: + """Return a URL that serves an N-figure dashboard.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source from flexviz.spec import encode_spec @@ -102,10 +83,10 @@ def _dashboard_url(port: int, renderer: str, n_figures: int = 2) -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_cached(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_cached(port: int) -> str: """Dashboard whose source opts into caching (cache=True).""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -122,7 +103,7 @@ def _dashboard_url_cached(port: int, renderer: str = "plotly") -> str: spec = dash.to_spec(source_name=source_name) encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" def _dashboard_url_selection_duplicate_repro(port: int) -> str: @@ -160,10 +141,10 @@ def _dashboard_url_selection_duplicate_repro(port: int) -> str: dash.add_figure().add_histogram(x="x", bins=21) encoded = encode_spec(dash.to_spec(source_name=source_name)) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer=plotly" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_boxplot(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_boxplot(port: int) -> str: """Single-figure dashboard with a box plot trace (Plotly smoke tests).""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -178,39 +159,10 @@ def _dashboard_url_boxplot(port: int, renderer: str = "plotly") -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" - - -def _dashboard_url_datetime_line(port: int, renderer: str = "echarts") -> str: - """Single-figure dashboard with datetime x values.""" - from datetime import datetime, timedelta, timezone - - from flexviz.dashboard import Dashboard - from flexviz.server import register_source - from flexviz.spec import encode_spec - - base = datetime(2024, 1, 1, tzinfo=timezone.utc) - df = pl.DataFrame( - { - "ts": [base + timedelta(hours=i) for i in range(96)], - "val": [float((i * 7) % 31) for i in range(96)], - "group": ["A"] * 48 + ["B"] * 48, - } - ) - source_name = "_browser_datetime_line" - register_source(source_name, df) - - dash = Dashboard(df) - dash.add_figure(title="Datetime Line").add_line( - x="ts", y="val", group_by="group", n_points=96, assume_sorted_x=True - ) - spec = dash.to_spec(source_name=source_name) - - encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_static_pie(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_static_pie(port: int) -> str: """Single-figure dashboard whose Plotly panel should not render a control bar.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -229,52 +181,10 @@ def _dashboard_url_static_pie(port: int, renderer: str = "plotly") -> str: spec = dash.to_spec(source_name="_browser_static_pie") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" - - -def _dashboard_url_treemap_colormap(port: int, renderer: str = "echarts") -> str: - """Single treemap with deterministic colors for structure checks.""" - from flexviz.dashboard import Dashboard - from flexviz.server import register_source - from flexviz.spec import LayoutSpec, encode_spec + return f"http://127.0.0.1:{port}/view?spec={encoded}" - df = pl.DataFrame( - { - "source": ["Solar"] * 4 + ["Wind"] * 4, - "country": ["DE", "ES", "FR", "IT"] * 2, - "value": [5.0, 4.0, 3.0, 2.0, 8.0, 7.0, 6.0, 5.0], - } - ) - source_name = "_browser_treemap_colormap" - register_source(source_name, df) - dash = Dashboard(df) - dash.add_figure(title="Treemap").add_treemap( - path=["source", "country"], - values="value", - agg="sum", - color_map={ - "Solar": "#e3a24d", - "Wind": "#5b8db8", - "DE": "#2f2f2f", - "ES": "#4f4f4f", - "FR": "#6f6f6f", - "IT": "#9a9a9a", - }, - ) - spec = dash.to_spec(source_name=source_name, layout=LayoutSpec(draggable=False)) - - encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" - - -def _demo_url(port: int, renderer: str = "echarts") -> str: - return f"http://127.0.0.1:{port}/demo?renderer={renderer}" - - -def _dashboard_url_grouped_line_multi_group_by( - port: int, renderer: str = "plotly" -) -> str: +def _dashboard_url_grouped_line_multi_group_by(port: int) -> str: """Single grouped-line dashboard with composite group values for legend tests.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -297,10 +207,10 @@ def _dashboard_url_grouped_line_multi_group_by( spec = dash.to_spec(source_name=source_name) encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_treemap_with_line(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_treemap_with_line(port: int) -> str: """Two-figure dashboard: grouped line target + treemap source.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -326,10 +236,10 @@ def _dashboard_url_treemap_with_line(port: int, renderer: str = "plotly") -> str spec = dash.to_spec(source_name=source_name, layout=LayoutSpec(draggable=False)) encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_treemap_pie_selection(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_treemap_pie_selection(port: int) -> str: """Dashboard with source bar target, two-level treemap, and pie source.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -352,12 +262,10 @@ def _dashboard_url_treemap_pie_selection(port: int, renderer: str = "plotly") -> spec = dash.to_spec(source_name=source_name, layout=LayoutSpec(draggable=False)) encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_plotly_selection_box( - port: int, source_kind: str, renderer: str = "plotly" -) -> str: +def _dashboard_url_plotly_selection_box(port: int, source_kind: str) -> str: """Two-figure dashboard for Plotly source selection-box regressions.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -385,7 +293,7 @@ def _dashboard_url_plotly_selection_box( spec = dash.to_spec(source_name=source_name, layout=LayoutSpec(draggable=False)) encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" def _dashboard_url_hist2d_overlay(port: int, **hist2d_kwargs) -> str: @@ -410,7 +318,7 @@ def _dashboard_url_hist2d_overlay(port: int, **hist2d_kwargs) -> str: spec = dash.to_spec(source_name="_browser_hist2d_overlay") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer=plotly" + return f"http://127.0.0.1:{port}/view?spec={encoded}" def _dashboard_url_geo_overlay(port: int, **geo_kwargs) -> str: @@ -439,10 +347,10 @@ def _dashboard_url_geo_overlay(port: int, **geo_kwargs) -> str: spec = dash.to_spec(source_name="_browser_geo_overlay") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer=plotly" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_hist2d(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_hist2d(port: int) -> str: """Single-figure dashboard with a Histogram2D trace.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -462,7 +370,7 @@ def _dashboard_url_hist2d(port: int, renderer: str = "plotly") -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" def _dashboard_url_weekday_hist2d(port: int) -> str: @@ -493,7 +401,7 @@ def _dashboard_url_weekday_hist2d(port: int) -> str: spec = dash.to_spec(source_name=source_name) encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer=plotly" + return f"http://127.0.0.1:{port}/view?spec={encoded}" def _geo_browser_df() -> pl.DataFrame: @@ -509,7 +417,7 @@ def _geo_browser_df() -> pl.DataFrame: return pl.DataFrame(rows) -def _dashboard_url_geo(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_geo(port: int) -> str: """Single-figure dashboard with a geo histogram trace.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -529,10 +437,10 @@ def _dashboard_url_geo(port: int, renderer: str = "plotly") -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_geo_with_line(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_geo_with_line(port: int) -> str: """Two-figure dashboard: geo histogram source + linked line target.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -554,12 +462,11 @@ def _dashboard_url_geo_with_line(port: int, renderer: str = "plotly") -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" def _dashboard_url_grouped( port: int, - renderer: str, trace_type: str = "line", n_figures: int = 1, ) -> str: @@ -599,10 +506,10 @@ def _dashboard_url_grouped( spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_line_hist_target(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_line_hist_target(port: int) -> str: """Two-figure dashboard: line source + histogram target.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -619,10 +526,10 @@ def _dashboard_url_line_hist_target(port: int, renderer: str = "plotly") -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_line_multi_hist_target(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_line_multi_hist_target(port: int) -> str: """Two-figure dashboard: line source + two histogram target traces.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -647,10 +554,10 @@ def _dashboard_url_line_multi_hist_target(port: int, renderer: str = "plotly") - spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_line_grouped_bar_target(port: int, renderer: str = "plotly") -> str: +def _dashboard_url_line_grouped_bar_target(port: int) -> str: """Two-figure dashboard: line source + grouped bar target.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -674,34 +581,25 @@ def _dashboard_url_line_grouped_bar_target(port: int, renderer: str = "plotly") spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _wait_for_chart(page: Page, renderer: str) -> None: - if renderer == "plotly": - page.wait_for_selector(".js-plotly-plot", timeout=15_000) - else: - page.wait_for_selector("[id^='fv-chart-']", timeout=15_000) +def _wait_for_chart(page: Page) -> None: + page.wait_for_selector(".js-plotly-plot", timeout=15_000) -def _grouped_child_count(page: Page, renderer: str) -> int: - if renderer == "plotly": - return page.evaluate( - """() => document.querySelector('.js-plotly-plot').data.length""" - ) - return page.evaluate("""() => { - const el = document.querySelector("[id^='fv-chart-']"); - const chart = echarts.getInstanceByDom(el); - return chart.getOption().series.length; - }""") +def _grouped_child_count(page: Page) -> int: + return page.evaluate( + """() => document.querySelector('.js-plotly-plot').data.length""" + ) def _trace_layer(trace_id: str | None) -> str | None: if not trace_id: return None - if trace_id.endswith(("__fv_layer_bg", "::bg")): + if trace_id.endswith("__fv_layer_bg"): return "bg" - if trace_id.endswith(("__fv_layer_fg", "::fg")): + if trace_id.endswith("__fv_layer_fg"): return "fg" return None @@ -716,7 +614,7 @@ def _layer_traces(rendered: list[dict], layer: str) -> list[dict]: def _dashboard_url_saved_viewport( - port: int, renderer: str = "plotly", x_range: tuple[float, float] = (120.0, 260.0) + port: int, x_range: tuple[float, float] = (120.0, 260.0) ) -> tuple[str, tuple[float, float]]: """A one-figure dashboard whose spec carries a saved x viewport. @@ -738,7 +636,7 @@ def _dashboard_url_saved_viewport( encoded = encode_spec(spec) return ( - f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}", + f"http://127.0.0.1:{port}/view?spec={encoded}", x_range, ) @@ -814,13 +712,6 @@ def server_port() -> Generator[int, None, None]: yield port -@pytest.fixture(scope="module") -def demo_server_port() -> Generator[int, None, None]: - port = _free_port() - _start_demo_server(port) - yield port - - # --------------------------------------------------------------------------- # Plotly adapter smoke tests # --------------------------------------------------------------------------- @@ -828,7 +719,7 @@ def demo_server_port() -> Generator[int, None, None]: class TestPlotlyBrowser: def test_page_loads_without_browser_errors(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "plotly") + url = _dashboard_url(server_port) browser_errors: list[str] = [] page.on("pageerror", lambda exc: browser_errors.append(str(exc))) @@ -846,7 +737,7 @@ def test_page_loads_without_browser_errors(self, page: Page, server_port: int): assert browser_errors == [] def test_page_loads_and_charts_render(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "plotly") + url = _dashboard_url(server_port) page.goto(url) # Wait for Plotly chart containers to appear. page.wait_for_selector(".js-plotly-plot", timeout=15_000) @@ -861,7 +752,7 @@ def test_open_renders_each_figure_once(self, page: Page, server_port: int): A second unconditional pass over all of them redraws identical data inside the window a user waits on. """ - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.add_init_script(_COUNT_RENDERS_JS) page.goto(url) _wait_for_init(page, "plotly") @@ -880,7 +771,7 @@ def test_open_draws_once_per_figure_after_the_request( Plotly.react plots a div that was never plotted, so no stub render is needed to hold a place for the response. """ - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.add_init_script(_TRACE_DRAWS_JS) page.goto(url) _wait_for_init(page, "plotly") @@ -902,7 +793,7 @@ def test_saved_viewport_is_applied_on_open(self, page: Page, server_port: int): the figures a second time, repeats that work inside the window a user waits on. """ - url, x_range = _dashboard_url_saved_viewport(server_port, "plotly") + url, x_range = _dashboard_url_saved_viewport(server_port) events: list[str] = [] page.on( "request", @@ -929,7 +820,7 @@ def test_saved_viewport_is_applied_on_open(self, page: Page, server_port: int): assert len(renders) == 1, f"expected one data render, got {renders}" def test_toolbar_buttons_present(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "plotly") + url = _dashboard_url(server_port) page.goto(url) page.wait_for_selector("#fv-btn-reset", timeout=10_000) for btn_id in ( @@ -947,7 +838,7 @@ def test_toolbar_buttons_present(self, page: Page, server_port: int): def test_plotly_control_bar_renders_below_plot_with_a11y_labels( self, page: Page, server_port: int ): - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) page.goto(url) _wait_for_init(page, "plotly") @@ -986,14 +877,14 @@ def test_plotly_control_bar_renders_below_plot_with_a11y_labels( assert all(btn["tabIndex"] >= 0 for btn in geometry["buttons"]) def test_plotly_static_figure_omits_control_bar(self, page: Page, server_port: int): - page.goto(_dashboard_url_static_pie(server_port, "plotly")) + page.goto(_dashboard_url_static_pie(server_port)) _wait_for_init(page, "plotly") assert page.locator(".fv-panel-bar").count() == 0 def test_mode_toggle_click_does_not_post_dashboard_update( self, page: Page, server_port: int ): - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -1016,7 +907,7 @@ def capture(req: PWRequest) -> None: assert dragmode == "pan" def test_reset_button_fires_dashboard_update(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "plotly") + url = _dashboard_url(server_port) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1037,7 +928,7 @@ def capture(req: PWRequest) -> None: ) def test_deselect_button_fires_dashboard_update(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "plotly") + url = _dashboard_url(server_port) update_requests: list[str] = [] page.on( "request", @@ -1062,7 +953,7 @@ def test_cached_source_reset_issues_no_dashboard_update( ): """With a cache=True source, the init response is reused client-side, so clicking Reset (same unfiltered output) issues no /dashboard/update.""" - url = _dashboard_url_cached(server_port, "plotly") + url = _dashboard_url_cached(server_port) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1091,7 +982,7 @@ def test_cached_source_deselect_while_zoomed_issues_dashboard_update( """Regression: the client cache holds the viewport-free response, so a deselect issued while a figure is zoomed must NOT be served from cache — it must round-trip to the server for the viewport-correct result.""" - url = _dashboard_url_cached(server_port, "plotly") + url = _dashboard_url_cached(server_port) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1129,7 +1020,7 @@ def test_cached_source_panel_reset_after_zoom_served_from_cache( autorange — with no other figure cross-filtering it and no axis lock — is served from the figure-scoped client cache (the unfiltered slice already held from init), so it issues no /dashboard/update.""" - url = _dashboard_url_cached(server_port, "plotly") + url = _dashboard_url_cached(server_port) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1169,7 +1060,7 @@ def test_panel_reset_clean_figure_is_noop(self, page: Page, server_port: int): """Case 3c: a per-figure reset of a figure that is not zoomed, sources no selection, and has no other cross-filters in play changes nothing — so it must issue no /dashboard/update at all.""" - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1194,7 +1085,7 @@ def test_panel_reset_unzoomed_target_is_noop(self, page: Page, server_port: int) cross-filter target (another figure sources the selection) changes nothing — it stays filtered-by-others at autorange — so it must issue no /dashboard/update and must preserve the incoming selection.""" - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1242,7 +1133,7 @@ def test_cached_source_locked_figure_reset_served_from_cache( back to full autorange is served from the figure-scoped client cache just like an unlocked one — no /dashboard/update — and the lock range survives.""" - url = _dashboard_url_cached(server_port, "plotly") + url = _dashboard_url_cached(server_port) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1289,7 +1180,7 @@ def test_line_y_only_zoom_does_not_post(self, page: Page, server_port: int): """A line is x-bound: zooming only the y-axis changes no data, so the client must suppress the /dashboard/update POST — yet still persist the new y range locally (for share/restore).""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1318,7 +1209,7 @@ def capture(req: PWRequest) -> None: def test_line_x_zoom_posts_once(self, page: Page, server_port: int): """The binding axis (x) of a line must still round-trip on zoom.""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1341,7 +1232,7 @@ def capture(req: PWRequest) -> None: def test_line_y_autorange_does_not_post(self, page: Page, server_port: int): """Double-click autorange on the non-binding y-axis must not round-trip.""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_requests: list[str] = [] def capture(req: PWRequest) -> None: @@ -1365,7 +1256,7 @@ def capture(req: PWRequest) -> None: def test_filter_summary_strip_shows_global_chip_and_source_panel_echo( self, page: Page, server_port: int ): - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) _wait_for_init(page, "plotly") @@ -1404,7 +1295,7 @@ def test_filter_summary_strip_shows_global_chip_and_source_panel_echo( def test_filter_chip_remove_clears_only_its_source_selection( self, page: Page, server_port: int ): - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) _wait_for_init(page, "plotly") @@ -1456,7 +1347,7 @@ def test_filter_chip_remove_clears_only_its_source_selection( def test_filter_summary_joins_multi_clause_and_or_predicates( self, page: Page, server_port: int ): - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) page.goto(url) _wait_for_init(page, "plotly") @@ -1493,9 +1384,9 @@ def test_filter_summary_joins_multi_clause_and_or_predicates( assert summary["panel0"] == "ts 1.25 to 6.25 | val 10 to 20 OR ts 30 to 40" def test_hist2d_uses_control_bar_for_selection(self, page: Page, server_port: int): - url = _dashboard_url_hist2d(server_port, "plotly") + url = _dashboard_url_hist2d(server_port) page.goto(url) - _wait_for_chart(page, "plotly") + _wait_for_chart(page) page.wait_for_selector("#fv-bar-0[role='toolbar']", timeout=10_000) assert page.locator("#fv-bar-0 .fv-mode-btn[data-mode='select']").count() == 1 assert page.locator(".modebar").count() == 0 @@ -1504,8 +1395,8 @@ def test_hist2d_default_color_scale_renders_viridis( self, page: Page, server_port: int ): """The default color scale reaches Plotly as its built-in Viridis.""" - page.goto(_dashboard_url_hist2d(server_port, "plotly")) - _wait_for_chart(page, "plotly") + page.goto(_dashboard_url_hist2d(server_port)) + _wait_for_chart(page) colorscale = page.evaluate( "() => document.querySelector('.js-plotly-plot')._fullData[0].colorscale" ) @@ -1515,7 +1406,7 @@ def test_hist2d_default_color_scale_renders_viridis( def test_hist2d_box_select_button_emits_selection( self, page: Page, server_port: int ): - url = _dashboard_url_hist2d(server_port, "plotly") + url = _dashboard_url_hist2d(server_port) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -1527,7 +1418,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_chart(page, "plotly") + _wait_for_chart(page) page.locator("#fv-bar-0 .fv-mode-btn[data-mode='select']").click() page.wait_for_timeout(300) @@ -1699,7 +1590,7 @@ def test_histogram_brush_selection_keeps_plotly_selection_box( assert browser_errors == [] def test_horizontal_drag_zoom_works_with_hover(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_bodies: list[dict] = [] page_errors: list[str] = [] @@ -1748,7 +1639,7 @@ def capture(req: PWRequest) -> None: def test_geo_histogram_zoom_posts_coordinates_and_updates_trace( self, page: Page, server_port: int ): - url = _dashboard_url_geo(server_port, "plotly") + url = _dashboard_url_geo(server_port) update_bodies: list[dict] = [] response_statuses: list[int] = [] @@ -1849,7 +1740,7 @@ def capture_request(req: PWRequest) -> None: def test_geo_histogram_keeps_zoom_pan_mode_enabled( self, page: Page, server_port: int ): - url = _dashboard_url_geo(server_port, "plotly") + url = _dashboard_url_geo(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -1878,7 +1769,7 @@ def test_geo_histogram_keeps_zoom_pan_mode_enabled( def test_geo_histogram_selection_filters_linked_line( self, page: Page, server_port: int ): - url = _dashboard_url_geo_with_line(server_port, "plotly") + url = _dashboard_url_geo_with_line(server_port) update_bodies: list[dict] = [] response_statuses: list[int] = [] @@ -1985,30 +1876,29 @@ def capture_request(req: PWRequest) -> None: assert after_line_count < before_line_count def test_boxplot_page_loads_and_chart_renders(self, page: Page, server_port: int): - url = _dashboard_url_boxplot(server_port, "plotly") + url = _dashboard_url_boxplot(server_port) page.goto(url) page.wait_for_selector(".js-plotly-plot", timeout=15_000) charts = page.query_selector_all(".js-plotly-plot") assert len(charts) >= 1, f"Expected >=1 Plotly chart, found {len(charts)}" -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) class TestGroupedBrowser: def test_grouped_line_renders_children_not_parent( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): - url = _dashboard_url_grouped(server_port, renderer, trace_type="line") + url = _dashboard_url_grouped(server_port, trace_type="line") page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) page.wait_for_timeout(500) - assert _grouped_child_count(page, renderer) == 2 + assert _grouped_child_count(page) == 2 def test_grouped_line_viewport_removes_hidden_child( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): - url = _dashboard_url_grouped(server_port, renderer, trace_type="line") + url = _dashboard_url_grouped(server_port, trace_type="line") page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") page.evaluate("""async () => { const figUid = DASHBOARD_SPEC.figures[0].uid; @@ -2020,474 +1910,33 @@ def test_grouped_line_viewport_removes_hidden_child( }); }""") page.wait_for_timeout(2_000) - assert _grouped_child_count(page, renderer) == 1 + assert _grouped_child_count(page) == 1 - def test_grouped_bar_renders_children( - self, page: Page, server_port: int, renderer: str - ): - url = _dashboard_url_grouped(server_port, renderer, trace_type="bar") + def test_grouped_bar_renders_children(self, page: Page, server_port: int): + url = _dashboard_url_grouped(server_port, trace_type="bar") page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) page.wait_for_timeout(500) - assert _grouped_child_count(page, renderer) == 2 - - -# --------------------------------------------------------------------------- -# ECharts adapter smoke tests -# --------------------------------------------------------------------------- - - -class TestEChartsBrowser: - def test_page_loads_and_charts_render(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "echarts") - page.goto(url) - # ECharts renders into canvas elements inside fv-chart-* containers. - page.wait_for_selector("[id^='fv-chart-']", timeout=15_000) - charts = page.query_selector_all("[id^='fv-chart-']") - assert len(charts) >= 2, f"Expected >=2 ECharts containers, found {len(charts)}" - - def test_toolbar_buttons_present(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "echarts") - page.goto(url) - page.wait_for_selector("#fv-btn-reset", timeout=10_000) - for btn_id in ( - "fv-btn-reset", - "fv-btn-deselect", - "fv-btn-cfmode", - "fv-btn-grid", - "fv-btn-share", - "fv-btn-export", - "fv-btn-import", - ): - btn = page.query_selector(f"#{btn_id}") - assert btn is not None, f"Toolbar button #{btn_id} not found" - - def test_live_option_has_no_toolbox(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "echarts") - page.goto(url) - _wait_for_init(page, "echarts") - toolbox_visible = page.evaluate("""() => { - const chart = echarts.getInstanceByDom(document.querySelector("[id^='fv-chart-']")); - const option = chart && chart.getOption(); - const toolbox = Array.isArray(option && option.toolbox) ? option.toolbox[0] : option && option.toolbox; - return !!(toolbox && toolbox.show); - }""") - assert toolbox_visible is False - - def test_datetime_line_uses_time_axis_and_renders_points( - self, page: Page, server_port: int - ): - url = _dashboard_url_datetime_line(server_port, "echarts") - page.goto(url) - _wait_for_init(page, "echarts") - state = page.evaluate("""() => { - const chart = echarts.getInstanceByDom(document.querySelector("[id^='fv-chart-']")); - const option = chart.getOption(); - const axis = Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis; - const series = option.series || []; - return { - xAxisType: axis && axis.type, - seriesCount: series.length, - pointCounts: series.map(item => (item.data || []).length), - }; - }""") - assert state["xAxisType"] == "time" - assert state["seriesCount"] == 2 - assert all(count > 0 for count in state["pointCounts"]) - - def test_scroll_zoom_triggers_dashboard_update(self, page: Page, server_port: int): - """Scroll zoom on ECharts must trigger a /dashboard/update POST.""" - url = _dashboard_url(server_port, "echarts") - update_bodies: list[dict] = [] - - def capture(req: PWRequest) -> None: - if "/dashboard/update" in req.url and req.method == "POST": - try: - update_bodies.append(json.loads(req.post_data or "{}")) - except Exception: - pass - - page.on("request", capture) - page.goto(url) - # Wait for canvas elements (charts fully initialised). - page.wait_for_selector("canvas", timeout=15_000) - initial_count = len(update_bodies) - - # Scroll on the first chart container to trigger zoom. - chart_container = page.query_selector("[id^='fv-chart-']") - assert chart_container is not None - box = chart_container.bounding_box() - cx = box["x"] + box["width"] / 2 - cy = box["y"] + box["height"] / 2 - page.mouse.move(cx, cy) - page.mouse.wheel(0, -300) # scroll up = zoom in - # ECharts debounces datazoom 150 ms; wait generously. - page.wait_for_timeout(1_500) - - viewport_events = [ - b - for b in update_bodies[initial_count:] - if b.get("event", {}).get("type") == "viewport" - ] - assert len(viewport_events) >= 1, ( - "Scroll zoom must produce at least one viewport /dashboard/update POST. " - f"Got update events: {[b.get('event', {}).get('type') for b in update_bodies[initial_count:]]}" - ) - - def test_locked_axes_ignore_scroll_zoom(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "echarts") - update_bodies: list[dict] = [] - - def capture(req: PWRequest) -> None: - if "/dashboard/update" in req.url and req.method == "POST": - try: - update_bodies.append(json.loads(req.post_data or "{}")) - except Exception: - pass - - page.on("request", capture) - page.goto(url) - _wait_for_init(page, "echarts") - page.wait_for_selector( - "#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']:not([disabled])" - ) - - page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']") - page.wait_for_function("""() => { - const figUid = DASHBOARD_SPEC.figures[0].uid; - return DASHBOARD_SPEC.client_state.axis_locks[figUid + '/x'] === true - && !!DASHBOARD_SPEC.client_state.axis_lock_ranges[figUid + '/x']; - }""") - - before = page.evaluate("""() => { - const figUid = DASHBOARD_SPEC.figures[0].uid; - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-0')); - const option = chart.getOption(); - const dz = (option.dataZoom || [])[0] || {}; - return { - lockRange: DASHBOARD_SPEC.client_state.axis_lock_ranges[figUid + '/x'], - viewport: DASHBOARD_SPEC.state.viewport[figUid + '/x'] || null, - startValue: dz.startValue, - endValue: dz.endValue, - }; - }""") - count_before = len(update_bodies) - - chart_container = page.query_selector("#fv-chart-0") - assert chart_container is not None - box = chart_container.bounding_box() - cx = box["x"] + box["width"] / 2 - cy = box["y"] + box["height"] / 2 - page.mouse.move(cx, cy) - page.mouse.wheel(0, -300) - page.wait_for_timeout(1000) - - after = page.evaluate("""() => { - const figUid = DASHBOARD_SPEC.figures[0].uid; - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-0')); - const option = chart.getOption(); - const dz = (option.dataZoom || [])[0] || {}; - return { - viewport: DASHBOARD_SPEC.state.viewport[figUid + '/x'] || null, - startValue: dz.startValue, - endValue: dz.endValue, - }; - }""") - - viewport_events = [ - body.get("event", {}) - for body in update_bodies[count_before:] - if body.get("event", {}).get("type") == "viewport" - ] - assert viewport_events == [] - assert after["viewport"] is None - assert after["startValue"] == before["startValue"] - assert after["endValue"] == before["endValue"] - - def test_reset_button_clears_viewport(self, page: Page, server_port: int): - url = _dashboard_url(server_port, "echarts") - update_requests: list[str] = [] - page.on( - "request", - lambda req: ( - update_requests.append(req.url) - if "/dashboard/update" in req.url - else None - ), - ) - page.goto(url) - page.wait_for_selector("canvas", timeout=15_000) - initial_count = len(update_requests) - - page.click("#fv-btn-reset") - page.wait_for_timeout(1_000) - assert len(update_requests) > initial_count, ( - "Clicking Reset must fire at least one /dashboard/update request" - ) - - def test_share_url_roundtrip(self, page: Page, server_port: int): - """Share button must produce a URL that loads the same dashboard.""" - url = _dashboard_url(server_port, "echarts") - page.goto(url) - page.wait_for_selector("canvas", timeout=15_000) - - # Intercept the alert / clipboard write from the Share button. - share_urls: list[str] = [] - - def handle_dialog(dialog): - share_urls.append(dialog.message) - dialog.dismiss() - - page.on("dialog", handle_dialog) - page.click("#fv-btn-share") - page.wait_for_timeout(1_000) - - if share_urls: - # If an alert was shown with the URL, navigate to it. - share_url = share_urls[0].strip() - if share_url.startswith("http"): - page.goto(share_url) - page.wait_for_selector("[id^='fv-chart-']", timeout=15_000) - charts = page.query_selector_all("[id^='fv-chart-']") - assert len(charts) >= 2 - - def test_pie_click_filters_and_toggles_clear(self, page: Page, server_port: int): - url = _dashboard_url_treemap_pie_selection(server_port, "echarts") - page.goto(url) - _wait_for_init(page, "echarts") - - page.evaluate("""() => { - const figUid = DASHBOARD_SPEC.figures[2].uid; - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-2')); - const series = (chart.getOption().series || [])[0]; - const item = (series.data || []).find(entry => entry && entry.name === 'solar'); - handleEChartsClick({ - seriesType: 'pie', - seriesId: series.id, - name: item.name, - data: item, - }, figUid); - }""") - page.wait_for_function( - "() => (DASHBOARD_SPEC.state.selections || []).length === 1" - ) - page.wait_for_timeout(800) - - selection = page.evaluate("DASHBOARD_SPEC.state.selections[0]") - clauses = selection["predicates"][0]["clauses"] - assert clauses == [{"column": "source", "values": ["solar"]}] - - target_labels = page.evaluate("""() => { - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-0')); - const series = (chart.getOption().series || [])[0] || {}; - return (series.data || []).map(entry => Array.isArray(entry) ? entry[0] : entry.name); - }""") - assert target_labels == ["solar"] - - page.evaluate("""() => { - const figUid = DASHBOARD_SPEC.figures[2].uid; - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-2')); - const series = (chart.getOption().series || [])[0]; - const item = (series.data || []).find(entry => entry && entry.name === 'solar'); - handleEChartsClick({ - seriesType: 'pie', - seriesId: series.id, - name: item.name, - data: item, - }, figUid); - }""") - page.wait_for_function( - "() => (DASHBOARD_SPEC.state.selections || []).length === 0" - ) - - def test_treemap_leaf_click_filters_with_full_path( - self, page: Page, server_port: int - ): - url = _dashboard_url_treemap_pie_selection(server_port, "echarts") - page.goto(url) - _wait_for_init(page, "echarts") - - page.evaluate("""() => { - const figUid = DASHBOARD_SPEC.figures[1].uid; - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-1')); - const series = (chart.getOption().series || [])[0]; - function findNode(nodes, wantedId) { - for (const node of (nodes || [])) { - if (node && node.id === wantedId) return node; - const child = findNode(node && node.children, wantedId); - if (child) return child; - } - return null; - } - const node = findNode(series.data || [], 'root/solar/NL'); - handleEChartsClick({ - seriesType: 'treemap', - seriesId: series.id, - data: node, - }, figUid); - }""") - page.wait_for_function( - "() => (DASHBOARD_SPEC.state.selections || []).length === 1" - ) - page.wait_for_timeout(800) - - selection = page.evaluate("DASHBOARD_SPEC.state.selections[0]") - clauses = selection["predicates"][0]["clauses"] - assert {item["column"]: item["values"] for item in clauses} == { - "source": ["solar"], - "country": ["NL"], - } - - def test_treemap_option_exposes_named_colored_top_level_nodes( - self, page: Page, server_port: int - ): - url = _dashboard_url_treemap_colormap(server_port, "echarts") - page.goto(url) - _wait_for_init(page, "echarts") - top_nodes = page.evaluate("""() => { - const chart = echarts.getInstanceByDom(document.querySelector("[id^='fv-chart-']")); - const series = (chart.getOption().series || [])[0] || {}; - return (series.data || []).map(node => ({ - id: node.id, - name: node.name, - color: node.itemStyle && node.itemStyle.color, - childCount: (node.children || []).length, - })); - }""") - assert top_nodes == [ - {"id": "root/Solar", "name": "Solar", "color": "#e3a24d", "childCount": 4}, - {"id": "root/Wind", "name": "Wind", "color": "#5b8db8", "childCount": 4}, - ] - - -@pytest.mark.skip( - reason="EChartsAdapter is deprecated (see CLAUDE.md); the ECharts demo " - "canvas does not render on this branch. Skipped pending removal of the " - "ECharts adapter rather than expanding the deprecated path." -) -class TestDemoEChartsBrowser: - def test_demo_datetime_selection_filters_targets_and_formats_timestamp( - self, page: Page, demo_server_port: int - ): - url = _demo_url(demo_server_port, "echarts") - page.goto(url) - _wait_for_init(page, "echarts") - - before = page.evaluate("""() => { - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-1')); - return (chart.getOption().series || []).map(series => - (series.data || []).reduce((acc, item) => acc + (Array.isArray(item) ? item[1] : 0), 0) - ); - }""") - - page.evaluate("""async () => { - const figUid = DASHBOARD_SPEC.figures[0].uid; - const selection = { - source_figure_uid: figUid, - predicates: [{ - clauses: [{ - column: 'timestamp', - range: ['2017-09-11T18:28:06.603Z', '2017-12-27T22:17:01.514Z'], - }], - }], - }; - window.fvSetSelectionState?.([selection]); - await postDashboardUpdate({ - type: 'selection', - force_update: true, - }); - }""") - page.wait_for_function("""() => { - const text = document.getElementById('fv-filter-chips')?.textContent || ''; - return (DASHBOARD_SPEC.state.selections || []).length === 1 - && text.includes('timestamp') - && text.includes('UTC'); - }""") - page.wait_for_timeout(1000) - - after = page.evaluate("""() => { - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-1')); - const text = document.getElementById('fv-filter-chips')?.textContent || ''; - return { - sums: (chart.getOption().series || []).map(series => - (series.data || []).reduce((acc, item) => acc + (Array.isArray(item) ? item[1] : 0), 0) - ), - text, - }; - }""") - - assert all( - after_sum < before_sum - for after_sum, before_sum in zip(after["sums"], before) - ) - assert "2017-09-11 18:28:06.603 UTC" in after["text"] - assert "1505108886603" not in after["text"] - - def test_demo_treemap_uses_parent_bundle_borders( - self, page: Page, demo_server_port: int - ): - url = _demo_url(demo_server_port, "echarts") - page.goto(url) - _wait_for_init(page, "echarts") - - treemap_state = page.evaluate("""() => { - const figIdx = DASHBOARD_SPEC.figures.findIndex(fig => - (fig.traces || []).some(ts => ts.trace_type === 'treemap') - ); - const chart = echarts.getInstanceByDom(document.getElementById('fv-chart-' + figIdx)); - const series = (chart.getOption().series || [])[0] || {}; - const top = (series.data || []).map(node => ({ - name: node.name, - color: node.itemStyle && node.itemStyle.color, - childBorder: node.children && node.children[0] && node.children[0].itemStyle && node.children[0].itemStyle.borderColor, - childWidth: node.children && node.children[0] && node.children[0].itemStyle && node.children[0].itemStyle.borderWidth, - })); - return { - levels: (series.levels || []).length, - top, - }; - }""") - - assert treemap_state["levels"] >= 3 - assert treemap_state["top"][:2] == [ - { - "name": "Solar", - "color": "#e3a24d", - "childBorder": "#e3a24d", - "childWidth": 3, - }, - { - "name": "Wind", - "color": "#5b8db8", - "childBorder": "#5b8db8", - "childWidth": 3, - }, - ] + assert _grouped_child_count(page) == 2 # --------------------------------------------------------------------------- -# Cross-filter browser tests (parametrized for both adapters) +# Cross-filter browser tests # --------------------------------------------------------------------------- def _wait_for_init(page: Page, renderer: str) -> None: """Wait until the initial data load has populated the charts.""" - if renderer == "plotly": - page.wait_for_selector(".js-plotly-plot", timeout=15_000) - else: - page.wait_for_selector("canvas", timeout=15_000) + page.wait_for_selector(".js-plotly-plot", timeout=15_000) # Allow the init POST round-trip to complete. page.wait_for_timeout(2_000) -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) class TestCrossFilterBrowser: """Test cross-filter selection across 3 figures in a headless browser.""" - def test_cross_filter_A_updates_B_and_C( - self, page: Page, server_port: int, renderer: str - ): - url = _dashboard_url(server_port, renderer, n_figures=3) + def test_cross_filter_A_updates_B_and_C(self, page: Page, server_port: int): + url = _dashboard_url(server_port, n_figures=3) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2499,7 +1948,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") initial_count = len(update_bodies) # Trigger a selection on figure A via JS. @@ -2524,8 +1973,8 @@ def capture(req: PWRequest) -> None: ] assert len(selection_events) >= 1, "Expected at least one selection event POST" - def test_deselect_clears_filter(self, page: Page, server_port: int, renderer: str): - url = _dashboard_url(server_port, renderer, n_figures=3) + def test_deselect_clears_filter(self, page: Page, server_port: int): + url = _dashboard_url(server_port, n_figures=3) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2537,7 +1986,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Select on A, then deselect via toolbar. page.evaluate("""() => { @@ -2570,11 +2019,11 @@ def capture(req: PWRequest) -> None: ) def test_remove_selection_posts_type_matching_state( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): # The server treats "deselect" as unfiltered, so the posted type must # match the selections the posted state carries. - url = _dashboard_url(server_port, renderer, n_figures=3) + url = _dashboard_url(server_port, n_figures=3) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2583,7 +2032,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") page.evaluate("""() => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); @@ -2608,10 +2057,8 @@ def capture(req: PWRequest) -> None: assert body["event"]["type"] == "deselect" assert body["spec"]["state"]["selections"] == [] - def test_panel_reset_clears_sourced_selection( - self, page: Page, server_port: int, renderer: str - ): - url = _dashboard_url(server_port, renderer, n_figures=3) + def test_panel_reset_clears_sourced_selection(self, page: Page, server_port: int): + url = _dashboard_url(server_port, n_figures=3) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2623,7 +2070,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Inject a selection sourced from figure 0. page.evaluate("""() => { @@ -2665,14 +2112,14 @@ def capture(req: PWRequest) -> None: ) def test_panel_reset_of_target_figure_keeps_incoming_filter( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Resetting a *zoomed* figure that is only a cross-filter *target* (the filter is sourced by another figure) must reset only its viewport via a 'viewport' event, keep the incoming selection, and not disturb the source. (An *unzoomed* target reset is a no-op — see ``test_panel_reset_unzoomed_target_is_noop``.)""" - url = _dashboard_url(server_port, renderer, n_figures=3) + url = _dashboard_url(server_port, n_figures=3) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2684,7 +2131,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Selection sourced from figure 0, filtering figures 1 and 2; plus a # viewport on figure 1 so its reset actually clears something (otherwise @@ -2733,10 +2180,8 @@ def capture(req: PWRequest) -> None: keys = viewport_events[-1]["event"]["viewport_keys"] assert keys and all(k.startswith(f"{fig1_uid}/") for k in keys), keys - def test_reset_clears_zoom_and_filter( - self, page: Page, server_port: int, renderer: str - ): - url = _dashboard_url(server_port, renderer, n_figures=3) + def test_reset_clears_zoom_and_filter(self, page: Page, server_port: int): + url = _dashboard_url(server_port, n_figures=3) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2748,7 +2193,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Select on A first. page.evaluate("""() => { @@ -2783,12 +2228,11 @@ def capture(req: PWRequest) -> None: assert selections == [] or selections is None -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) class TestOverlayBrowser: def test_overlay_toggle_with_cached_bg_avoids_warmup( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2800,7 +2244,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") page.evaluate("""() => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); @@ -2827,9 +2271,9 @@ def capture(req: PWRequest) -> None: assert "init" not in toggle_events def test_overlay_toggle_without_cached_bg_warms_once( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -2841,7 +2285,7 @@ def capture(req: PWRequest) -> None: page.on("request", capture) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") page.evaluate("""() => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); @@ -2872,12 +2316,8 @@ def capture(req: PWRequest) -> None: assert toggle_events.count("init") == 1 assert toggle_events.count("selection") >= 1 - def test_overlay_heatmap_shows_single_colorbar( - self, page: Page, server_port: int, renderer: str - ): + def test_overlay_heatmap_shows_single_colorbar(self, page: Page, server_port: int): """Overlay cross-filter on histogram2d must expose only the fg colorbar.""" - if renderer != "plotly": - pytest.skip("Plotly-only colorbar regression") url = _dashboard_url_hist2d_overlay(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -2951,11 +2391,9 @@ def test_overlay_heatmap_shows_single_colorbar( ], ) def test_overlay_map_fades_background_under_filtered_layer( - self, page: Page, server_port: int, renderer: str, geo_kwargs: dict + self, page: Page, server_port: int, geo_kwargs: dict ): """A geo_histogram2d target draws both layers; the colorbar moves to fg.""" - if renderer != "plotly": - pytest.skip("ECharts has no map traces") errors: list[str] = [] page.on("pageerror", lambda exc: errors.append(str(exc))) page.on( @@ -3022,11 +2460,11 @@ def test_overlay_map_fades_background_under_filtered_layer( assert min(layer["raw"]) <= 0 < max(layer["raw"]) def test_overlay_reuses_same_color_and_mutes_background( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") page.evaluate("""() => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); @@ -3045,24 +2483,14 @@ def test_overlay_reuses_same_color_and_mutes_background( page.wait_for_timeout(1_500) rendered = page.evaluate( - """(renderer) => { - if (renderer === 'plotly') { - const fig = document.querySelectorAll('.js-plotly-plot')[1]; - return (fig.data || []).map(t => ({ - id: t.uid, - opacity: t.opacity ?? 1, - color: (t.line && t.line.color) || (t.marker && t.marker.color) || null, - })); - } - const el = document.querySelectorAll("[id^='fv-chart-']")[1]; - const chart = echarts.getInstanceByDom(el); - return (chart.getOption().series || []).map(s => ({ - id: s.id, - opacity: s.opacity ?? ((s.itemStyle && s.itemStyle.opacity) ?? 1), - color: (s.lineStyle && s.lineStyle.color) || (s.itemStyle && s.itemStyle.color) || null, + """() => { + const fig = document.querySelectorAll('.js-plotly-plot')[1]; + return (fig.data || []).map(t => ({ + id: t.uid, + opacity: t.opacity ?? 1, + color: (t.line && t.line.color) || (t.marker && t.marker.color) || null, })); }""", - renderer, ) bg = next(item for item in rendered if _trace_layer(item.get("id")) == "bg") @@ -3072,13 +2500,13 @@ def test_overlay_reuses_same_color_and_mutes_background( assert abs(fg["opacity"] - 1.0) < 1e-9 def test_overlay_reset_clears_fg_and_restores_full_opacity( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """After Reset in overlay+selection mode, the fg trace must be removed and the bg trace must return to full opacity (1.0).""" - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # 1. Enable overlay mode. page.click("#fv-btn-cfmode") @@ -3109,30 +2537,16 @@ def test_overlay_reset_clears_fg_and_restores_full_opacity( # 5. After reset, no fg trace must remain; bg trace must be at full opacity. rendered = page.evaluate( - """(renderer) => { - if (renderer === 'plotly') { - // Check all Plotly figures — none should have an fg-layer trace. - const figs = document.querySelectorAll('.js-plotly-plot'); - return Array.from(figs).flatMap(gd => - (gd.data || []).map(t => ({ - id: t.uid, - opacity: t.opacity !== undefined ? t.opacity : 1, - })) - ); - } - const charts = Array.from( - document.querySelectorAll("[id^='fv-chart-']") - ).map(el => echarts.getInstanceByDom(el)).filter(Boolean); - return charts.flatMap(chart => - (chart.getOption().series || []).map(s => ({ - id: s.id, - opacity: s.opacity !== undefined ? s.opacity - : ((s.itemStyle && s.itemStyle.opacity) !== undefined - ? s.itemStyle.opacity : 1), + """() => { + // Check all Plotly figures — none should have an fg-layer trace. + const figs = document.querySelectorAll('.js-plotly-plot'); + return Array.from(figs).flatMap(gd => + (gd.data || []).map(t => ({ + id: t.uid, + opacity: t.opacity !== undefined ? t.opacity : 1, })) ); }""", - renderer, ) fg_traces = _layer_traces(rendered, "fg") @@ -3146,13 +2560,13 @@ def test_overlay_reset_clears_fg_and_restores_full_opacity( ) def test_overlay_reset_then_new_selection_shows_correct_overlay( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """After Reset followed by a new selection in overlay mode, the overlay must show bg at low opacity and fg at full opacity again.""" - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # 1. Enable overlay mode, make a selection, then reset. page.click("#fv-btn-cfmode") @@ -3179,24 +2593,13 @@ def test_overlay_reset_then_new_selection_shows_correct_overlay( # 3. The non-source figure (index 1) must show bg at low opacity + fg at 1.0. rendered = page.evaluate( - """(renderer) => { - if (renderer === 'plotly') { - const fig = document.querySelectorAll('.js-plotly-plot')[1]; - return (fig.data || []).map(t => ({ - id: t.uid, - opacity: t.opacity !== undefined ? t.opacity : 1, - })); - } - const el = document.querySelectorAll("[id^='fv-chart-']")[1]; - const chart = echarts.getInstanceByDom(el); - return (chart.getOption().series || []).map(s => ({ - id: s.id, - opacity: s.opacity !== undefined ? s.opacity - : ((s.itemStyle && s.itemStyle.opacity) !== undefined - ? s.itemStyle.opacity : 1), + """() => { + const fig = document.querySelectorAll('.js-plotly-plot')[1]; + return (fig.data || []).map(t => ({ + id: t.uid, + opacity: t.opacity !== undefined ? t.opacity : 1, })); }""", - renderer, ) bg = next((t for t in rendered if _trace_layer(t.get("id")) == "bg"), None) @@ -3219,7 +2622,7 @@ class TestOverlayBrowserPlotlySafeLayerIds: def test_multi_histogram_overlay_offsets_by_logical_trace( self, page: Page, server_port: int ): - url = _dashboard_url_line_multi_hist_target(server_port, "plotly") + url = _dashboard_url_line_multi_hist_target(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -3282,7 +2685,7 @@ def test_multi_histogram_overlay_offsets_by_logical_trace( def test_multi_histogram_no_selection_uses_group_barmode( self, page: Page, server_port: int ): - url = _dashboard_url_line_multi_hist_target(server_port, "plotly") + url = _dashboard_url_line_multi_hist_target(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -3297,7 +2700,7 @@ def test_multi_histogram_no_selection_uses_group_barmode( def test_hist_target_reset_and_deselect_clear_fg_without_selector_errors( self, page: Page, server_port: int ): - url = _dashboard_url_line_hist_target(server_port, "plotly") + url = _dashboard_url_line_hist_target(server_port) console_messages: list[str] = [] page.on("console", lambda msg: console_messages.append(msg.text)) page.goto(url) @@ -3382,7 +2785,7 @@ def test_hist_target_reset_and_deselect_clear_fg_without_selector_errors( def test_grouped_bar_target_reset_clears_fg_without_selector_errors( self, page: Page, server_port: int ): - url = _dashboard_url_line_grouped_bar_target(server_port, "plotly") + url = _dashboard_url_line_grouped_bar_target(server_port) console_messages: list[str] = [] page.on("console", lambda msg: console_messages.append(msg.text)) page.goto(url) @@ -3449,9 +2852,7 @@ def test_a_page_from_another_origin_cannot_read_the_server( # loopback, so no browser network rule blocks the request: only the # server's CORS headers decide whether the page may read the answer. page.goto( - _dashboard_url(server_port, "plotly", n_figures=1).replace( - "127.0.0.1", "localhost" - ) + _dashboard_url(server_port, n_figures=1).replace("127.0.0.1", "localhost") ) result = page.evaluate( """async url => { @@ -3467,7 +2868,7 @@ def test_dashboard_renders_in_an_iframe_on_another_origin( ): # A notebook embeds the server's page this way: the parent page has # another origin, the dashboard in the iframe calls its own server. - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) page.set_content(f'') frame = page.wait_for_selector("iframe").content_frame() frame.wait_for_function( @@ -3482,14 +2883,13 @@ def test_dashboard_renders_in_an_iframe_on_another_origin( # --------------------------------------------------------------------------- -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) class TestShareUrlState: """Verify that Share → navigate preserves viewport and cross-filter state.""" - def test_share_preserves_zoom(self, page: Page, server_port: int, renderer: str): - url = _dashboard_url(server_port, renderer, n_figures=2) + def test_share_preserves_zoom(self, page: Page, server_port: int): + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Programmatically set a viewport state. page.evaluate("""() => { @@ -3520,10 +2920,10 @@ def handle_dialog(dialog): return new URL(data.url, window.location.href).href; }""") assert share_resp, "Share must return a URL" - share_url = share_resp + f"&renderer={renderer}" + share_url = share_resp page.goto(share_url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") restored_vp = page.evaluate("DASHBOARD_SPEC.state.viewport") assert restored_vp, "Viewport state must be restored from shared URL" @@ -3532,12 +2932,10 @@ def handle_dialog(dialog): f"Expected a viewport key like 'figUid/x', got {vp_keys}" ) - def test_share_preserves_cross_filter( - self, page: Page, server_port: int, renderer: str - ): - url = _dashboard_url(server_port, renderer, n_figures=2) + def test_share_preserves_cross_filter(self, page: Page, server_port: int): + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Programmatically add a selection. page.evaluate("""() => { @@ -3558,44 +2956,33 @@ def test_share_preserves_cross_filter( return new URL(data.url, window.location.href).href; }""") assert share_url - share_url += f"&renderer={renderer}" page.goto(share_url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") restored_sels = page.evaluate("DASHBOARD_SPEC.state.selections") assert len(restored_sels) >= 1, "Selections must be restored from shared URL" assert restored_sels[0]["predicates"][0]["clauses"][0]["range"] == [100, 300] def test_share_preserves_cross_filter_effect_on_data( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """End-to-end: A selection on A must still narrow B after Share URL round-trip.""" - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # On the first page: capture B's init count, apply a selection on A, and # verify that B's visible data is narrowed. Then obtain a Share URL via # the same /share payload that the toolbar uses. result = page.evaluate( - """async ({renderer}) => { + """async () => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); const uidA = figUids[0]; const uidB = figUids[1]; - let initCountB, selCountB; - - if (renderer === 'plotly') { - const idxB = figUidToIdx[uidB]; - initCountB = tracesByFig[idxB][0].x.length; - } else { - const chartB = chartsByFig[uidB]; - const optB = chartB.getOption(); - initCountB = (optB.series && optB.series[0] && optB.series[0].data - ? optB.series[0].data.length - : 0); - } + const idxB = figUidToIdx[uidB]; + const initCountB = tracesByFig[idxB][0].x.length; const sel = { source_figure_uid: uidA, @@ -3610,16 +2997,7 @@ def test_share_preserves_cross_filter_effect_on_data( // Allow the selection response to apply. await new Promise(r => setTimeout(r, 1000)); - if (renderer === 'plotly') { - const idxB = figUidToIdx[uidB]; - selCountB = tracesByFig[idxB][0].x.length; - } else { - const chartB2 = chartsByFig[uidB]; - const optB2 = chartB2.getOption(); - selCountB = (optB2.series && optB2.series[0] && optB2.series[0].data - ? optB2.series[0].data.length - : 0); - } + const selCountB = tracesByFig[idxB][0].x.length; const resp = await fetch(SERVER_URL + '/share', { method: 'POST', @@ -3634,37 +3012,26 @@ def test_share_preserves_cross_filter_effect_on_data( shareUrl: new URL(data.url, window.location.href).href, }; }""", - {"renderer": renderer}, ) init_count_b = result["initCountB"] sel_count_b = result["selCountB"] - share_url = result["shareUrl"] + f"&renderer={renderer}" + share_url = result["shareUrl"] assert sel_count_b < init_count_b, "Selection on A must narrow B before sharing" # Now open the shared URL and assert that B remains narrowed. page.goto(share_url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") restored_count_b = page.evaluate( - """({renderer}) => { + """() => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); const uidB = figUids[1]; - if (renderer === 'plotly') { - const idxB = figUidToIdx[uidB]; - return tracesByFig[idxB][0].x.length; - } else { - const chartB = chartsByFig[uidB]; - const optB = chartB.getOption(); - const data = (optB.series && optB.series[0] && optB.series[0].data) - ? optB.series[0].data - : []; - return data.length; - } + const idxB = figUidToIdx[uidB]; + return tracesByFig[idxB][0].x.length; }""", - {"renderer": renderer}, ) assert restored_count_b == sel_count_b, ( @@ -3672,12 +3039,10 @@ def test_share_preserves_cross_filter_effect_on_data( "to the same narrowed data as before sharing" ) - def test_share_preserves_zoom_and_filter( - self, page: Page, server_port: int, renderer: str - ): - url = _dashboard_url(server_port, renderer, n_figures=2) + def test_share_preserves_zoom_and_filter(self, page: Page, server_port: int): + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Set both viewport and selection state. page.evaluate("""() => { @@ -3698,10 +3063,9 @@ def test_share_preserves_zoom_and_filter( return new URL((await resp.json()).url, window.location.href).href; }""") assert share_url - share_url += f"&renderer={renderer}" page.goto(share_url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") restored_vp = page.evaluate("DASHBOARD_SPEC.state.viewport") restored_sels = page.evaluate("DASHBOARD_SPEC.state.selections") @@ -3709,18 +3073,16 @@ def test_share_preserves_zoom_and_filter( assert len(restored_sels) >= 1, "Selections must be restored" assert restored_sels[0]["predicates"][0]["clauses"][0]["range"] == [100, 300] - def test_share_draws_selection_boxes( - self, page: Page, server_port: int, renderer: str - ): + def test_share_draws_selection_boxes(self, page: Page, server_port: int): """Share URL must restore visible selection boxes on the source figure.""" - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Programmatically add a selection with both x and y ranges so that # a rectangular selection box can be drawn by the renderer. share_url = page.evaluate( - """async ({renderer}) => { + """async () => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); const uidA = figUids[0]; DASHBOARD_SPEC.state.selections = [{ @@ -3738,32 +3100,23 @@ def test_share_draws_selection_boxes( const data = await resp.json(); return new URL(data.url, window.location.href).href; }""", - {"renderer": renderer}, ) assert share_url - share_url += f"&renderer={renderer}" # Open the shared URL and assert that a selection box is present # on the source figure. page.goto(share_url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") has_box = page.evaluate( - """({renderer}) => { + """() => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); const uidA = figUids[0]; - if (renderer === 'plotly') { - const idxA = figUidToIdx[uidA]; - const layout = layoutsByFig[idxA] || {}; - const sels = layout.selections || []; - return Array.isArray(sels) && sels.length > 0; - } else { - const areasByFig = window.__fvBrushAreasByFig || {}; - const areas = areasByFig[uidA] || []; - return Array.isArray(areas) && areas.length > 0; - } + const idxA = figUidToIdx[uidA]; + const layout = layoutsByFig[idxA] || {}; + const sels = layout.selections || []; + return Array.isArray(sels) && sels.length > 0; }""", - {"renderer": renderer}, ) assert has_box, ( @@ -3771,15 +3124,15 @@ def test_share_draws_selection_boxes( ) def test_share_preserves_zoomed_aggregation_and_cross_filter( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Repro: zoom A + select A, then Share → reload must apply both.""" - url = _dashboard_url(server_port, renderer, n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") share_url = page.evaluate( - """async ({renderer}) => { + """async () => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); const uidA = figUids[0]; const uidB = figUids[1]; @@ -3812,16 +3165,14 @@ def test_share_preserves_zoomed_aggregation_and_cross_filter( const data = await resp.json(); return new URL(data.url, window.location.href).href; }""", - {"renderer": renderer}, ) assert share_url - share_url += f"&renderer={renderer}" page.goto(share_url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") restored = page.evaluate( - """({renderer}) => { + """() => { const figUids = DASHBOARD_SPEC.figures.map(f => f.uid); const uidA = figUids[0]; const uidB = figUids[1]; @@ -3836,23 +3187,10 @@ def test_share_preserves_zoomed_aggregation_and_cross_filter( }; } - function echartsCountAndRange(uid) { - const chart = chartsByFig[uid]; - const opt = chart.getOption(); - const data = (opt.series && opt.series[0] && opt.series[0].data) ? opt.series[0].data : []; - const xs = data.map(p => p[0]); - return { - n: xs.length, - min: xs.length ? Math.min(...xs) : null, - max: xs.length ? Math.max(...xs) : null, - }; - } - - const a = (renderer === 'plotly') ? plotlyCountAndRange(uidA) : echartsCountAndRange(uidA); - const b = (renderer === 'plotly') ? plotlyCountAndRange(uidB) : echartsCountAndRange(uidB); + const a = plotlyCountAndRange(uidA); + const b = plotlyCountAndRange(uidB); return {a, b}; }""", - {"renderer": renderer}, ) # A should be aggregated to the zoomed viewport. @@ -4352,7 +3690,7 @@ def test_share_roundtrip_through_prefix_stripping_proxy( # --------------------------------------------------------------------------- -def _dashboard_url_hover(port: int, renderer: str) -> str: +def _dashboard_url_hover(port: int) -> str: """Two-figure dashboard: fig0 = line(x=ts, y=val), fig1 = line(x=ts, y=val). Both share the 'ts' and 'val' columns so crosshairs should sync.""" from flexviz.dashboard import Dashboard @@ -4370,10 +3708,10 @@ def _dashboard_url_hover(port: int, renderer: str) -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _dashboard_url_hover_minmax_shared_x(port: int, renderer: str) -> str: +def _dashboard_url_hover_minmax_shared_x(port: int) -> str: """Two minmax-downsampled line figures that only share x (user repro shape).""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -4398,17 +3736,16 @@ def _dashboard_url_hover_minmax_shared_x(port: int, renderer: str) -> str: spec = dash.to_spec(source_name="_browser_test") encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) class TestLinkedHoverBrowser: def test_hover_button_present_and_inactive_by_default( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): - url = _dashboard_url_hover(server_port, renderer) + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") btn = page.query_selector("#fv-hover-btn") assert btn is not None, "Hover dropdown button must be present" @@ -4422,10 +3759,10 @@ def test_hover_button_present_and_inactive_by_default( f"client_state.hover_mode must default to 'off', got {mode!r}" ) - def test_hover_toggle_turns_on(self, page: Page, server_port: int, renderer: str): - url = _dashboard_url_hover(server_port, renderer) + def test_hover_toggle_turns_on(self, page: Page, server_port: int): + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Clicking the toggle turns linked hover on. page.click("#fv-hover-btn") @@ -4441,10 +3778,10 @@ def test_hover_toggle_turns_on(self, page: Page, server_port: int, renderer: str ) assert btn.get_attribute("aria-pressed") == "true" - def test_hover_toggle_turns_off(self, page: Page, server_port: int, renderer: str): - url = _dashboard_url_hover(server_port, renderer) + def test_hover_toggle_turns_off(self, page: Page, server_port: int): + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Toggle on, then off. page.click("#fv-hover-btn") @@ -4458,12 +3795,10 @@ def test_hover_toggle_turns_off(self, page: Page, server_port: int, renderer: st assert "Off" in (btn.text_content() or "") assert btn.get_attribute("aria-pressed") == "false" - def test_hover_targets_built_at_load( - self, page: Page, server_port: int, renderer: str - ): - url = _dashboard_url_hover(server_port, renderer) + def test_hover_targets_built_at_load(self, page: Page, server_port: int): + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") targets = page.evaluate("hoverTargetsByColumn") assert targets is not None, "hoverTargetsByColumn must be defined" @@ -4471,15 +3806,12 @@ def test_hover_targets_built_at_load( assert len(targets["ts"]) >= 1, "ts column must have at least one target entry" def test_plotly_hover_shows_crosshair_on_other_figure( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Axis mode hover must add a guide to the other figure.""" - if renderer == "echarts": - pytest.skip("Plotly-specific test") - - url = _dashboard_url_hover(server_port, renderer) + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") guides_after = page.evaluate("""() => { const fig0Uid = DASHBOARD_SPEC.figures[0].uid; @@ -4504,15 +3836,10 @@ def test_plotly_hover_shows_crosshair_on_other_figure( "Axis mode hover on fig0 must add at least one guide to fig1" ) - def test_plotly_unhover_clears_crosshairs( - self, page: Page, server_port: int, renderer: str - ): - if renderer == "echarts": - pytest.skip("Plotly-specific test") - - url = _dashboard_url_hover(server_port, renderer) + def test_plotly_unhover_clears_crosshairs(self, page: Page, server_port: int): + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") guides_after = page.evaluate("""() => { const fig0Uid = DASHBOARD_SPEC.figures[0].uid; @@ -4528,18 +3855,15 @@ def test_plotly_unhover_clears_crosshairs( assert guides_after == 0, "Unhover must remove all hover guides" def test_plotly_axis_hover_emits_crosshair_on_shared_axes( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): - if renderer == "echarts": - pytest.skip("Plotly-specific test") - # fig0 and fig1 are both line(x=ts, y=val), so they share BOTH the x and # the y column. Axis-mode hover projects the hovered point onto every # shared axis, so the target should show a full crosshair: one x-guide # and one y-guide. - url = _dashboard_url_hover(server_port, renderer) + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") linked_shape = page.evaluate("""() => { const fig0Uid = DASHBOARD_SPEC.figures[0].uid; @@ -4563,15 +3887,12 @@ def test_plotly_axis_hover_emits_crosshair_on_shared_axes( ) def test_plotly_axis_hover_emits_no_visual_to_source_figure( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Spec rule: no linked visual is emitted to the source figure.""" - if renderer == "echarts": - pytest.skip("Plotly-specific test") - - url = _dashboard_url_hover(server_port, renderer) + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") source_guides = page.evaluate("""() => { const fig0Uid = DASHBOARD_SPEC.figures[0].uid; @@ -4586,15 +3907,10 @@ def test_plotly_axis_hover_emits_no_visual_to_source_figure( assert source_guides == 0, "Source figure must receive no linked hover visuals" - def test_hover_no_visual_when_off( - self, page: Page, server_port: int, renderer: str - ): - if renderer == "echarts": - pytest.skip("Plotly-specific test") - - url = _dashboard_url_hover(server_port, renderer) + def test_hover_no_visual_when_off(self, page: Page, server_port: int): + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") guides_after = page.evaluate("""() => { const fig0Uid = DASHBOARD_SPEC.figures[0].uid; @@ -4609,15 +3925,12 @@ def test_hover_no_visual_when_off( assert guides_after == 0, "No hover visual when mode is 'off'" def test_plotly_minmax_hover_links_even_when_not_near_exact_point( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Regression: linked hover should work with minmax traces without pixel-perfect point hit.""" - if renderer == "echarts": - pytest.skip("Plotly-specific test") - - url = _dashboard_url_hover_minmax_shared_x(server_port, renderer) + url = _dashboard_url_hover_minmax_shared_x(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Enable linked hover via the toggle page.click("#fv-hover-btn") @@ -4674,13 +3987,11 @@ def test_plotly_minmax_hover_links_even_when_not_near_exact_point( ) assert after["guides"] >= 1, "Linked figure should receive crosshair from hover" - def test_hover_mode_persists_through_share( - self, page: Page, server_port: int, renderer: str - ): + def test_hover_mode_persists_through_share(self, page: Page, server_port: int): """Toggle hover on, share, reload — client_state.hover_mode must still be 'on'.""" - url = _dashboard_url_hover(server_port, renderer) + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Toggle linked hover on page.click("#fv-hover-btn") @@ -4697,10 +4008,9 @@ def test_hover_mode_persists_through_share( return new URL(data.url, window.location.href).href; }""") assert share_url - share_url += f"&renderer={renderer}" page.goto(share_url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") mode = page.evaluate( "DASHBOARD_SPEC.client_state && DASHBOARD_SPEC.client_state.hover_mode" @@ -4712,7 +4022,7 @@ def test_hover_mode_persists_through_share( assert "On" in (btn.text_content() or ""), "Button must show 'On' after restore" def test_hover_dropdown_hidden_for_single_figure_dashboard( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Dropdown must be hidden when there are no linkable source-target pairs.""" from flexviz.figure import Figure @@ -4729,10 +4039,10 @@ def test_hover_dropdown_hidden_for_single_figure_dashboard( dash_spec = DashboardSpec(figures=[spec.figure], state=spec.state) encoded = encode_spec(dash_spec) - url = f"http://127.0.0.1:{server_port}/view?spec={encoded}&renderer={renderer}" + url = f"http://127.0.0.1:{server_port}/view?spec={encoded}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") wrapper = page.query_selector("#fv-hover-dropdown") if wrapper: @@ -4741,13 +4051,11 @@ def test_hover_dropdown_hidden_for_single_figure_dashboard( "Hover dropdown must be hidden for single-figure dashboard" ) - def test_hover_toggle_aria_pressed_state( - self, page: Page, server_port: int, renderer: str - ): + def test_hover_toggle_aria_pressed_state(self, page: Page, server_port: int): """aria-pressed must track the on/off toggle state.""" - url = _dashboard_url_hover(server_port, renderer) + url = _dashboard_url_hover(server_port) page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") btn = page.query_selector("#fv-hover-btn") if btn is None: @@ -4769,7 +4077,7 @@ def test_hover_toggle_aria_pressed_state( @pytest.mark.browser_context_args(timezone_id="Europe/Brussels") def test_temporal_hover_band_lands_on_the_bin_edge_off_utc( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """A histogram's date x axis carries bin edges as epoch-ms numbers (the ``[lo, step, n]`` triple of ``TestBinEdgeTripleBrowser``). The @@ -4777,8 +4085,6 @@ def test_temporal_hover_band_lands_on_the_bin_edge_off_utc( browser timezone other than UTC (Brussels here) catches a conversion that re-derives the value through ``new Date(ms)``, which shifts by the local offset.""" - if renderer == "echarts": - pytest.skip("Plotly-specific test") import datetime as dt from flexviz.dashboard import Dashboard @@ -4798,10 +4104,7 @@ def test_temporal_hover_band_lands_on_the_bin_edge_off_utc( dash.add_figure().add_line(x="ts", y="val") # fig0: axis source dash.add_figure().add_histogram(x="ts", bins=10) # fig1: axis target, date x spec = dash.to_spec(source_name="_browser_edges_hist_date") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer=plotly" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) _wait_for_init(page, "plotly") @@ -4852,14 +4155,12 @@ def test_temporal_hover_band_lands_on_the_bin_edge_off_utc( ids=["linear", "log"], ) def test_hover_band_is_clipped_to_the_plot_area( - self, page: Page, server_port: int, renderer: str, log_x, zoom, probe + self, page: Page, server_port: int, log_x, zoom, probe ): """A zoomed grid snaps outward, so its first bin starts left of the plot. On a log axis the lattice puts that edge at 0, which has no log position. The band must still show, clipped to the plot area, and not spill over the y-axis labels.""" - if renderer == "echarts": - pytest.skip("Plotly-specific test") df = pl.DataFrame( {"v": [10 ** (3 * i / 999) for i in range(1000)], "y": [0.0] * 1000} ) @@ -4918,13 +4219,11 @@ def build(dash): assert result["right"] == pytest.approx(result["expectedRight"], abs=0.5) def test_mouse_hover_on_a_date_axis_links_guide_and_band( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """On a date axis Plotly reports the hovered x as a date string. The linked guide and the histogram band are placed in epoch-ms, so the hovered value must reach them as epoch-ms too.""" - if renderer == "echarts": - pytest.skip("Plotly-specific test") import datetime as dt n = 300 @@ -4990,9 +4289,8 @@ class TestCellHoverBrowser: """Browser tests for Phase 3 cell hover mode.""" @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) def test_hover_toggle_available_for_hist_dashboard( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """The hover toggle must be offered when a histogram shares a column with a line (a linkable source→target pair exists).""" @@ -5017,10 +4315,10 @@ def test_hover_toggle_available_for_hist_dashboard( fig2.add_histogram(x="ts", bins=10) spec = dash.to_spec(source_name="_browser_cell_test") encoded = encode_spec(spec) - url = f"http://127.0.0.1:{server_port}/view?spec={encoded}&renderer={renderer}" + url = f"http://127.0.0.1:{server_port}/view?spec={encoded}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") btn = page.query_selector("#fv-hover-btn") assert btn is not None, "Hover toggle must be present" @@ -5031,10 +4329,7 @@ def test_hover_toggle_available_for_hist_dashboard( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) - def test_cell_hover_emits_x_band_to_line_target( - self, page: Page, server_port: int, renderer: str - ): + def test_cell_hover_emits_x_band_to_line_target(self, page: Page, server_port: int): """Cell hover on histogram must emit x_band to linked line figure.""" import polars as pl @@ -5057,10 +4352,10 @@ def test_cell_hover_emits_x_band_to_line_target( fig2.add_histogram(x="ts", bins=10) spec = dash.to_spec(source_name="_browser_cell_test2") encoded = encode_spec(spec) - url = f"http://127.0.0.1:{server_port}/view?spec={encoded}&renderer={renderer}" + url = f"http://127.0.0.1:{server_port}/view?spec={encoded}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") guides = page.evaluate("""() => { const fig0Uid = DASHBOARD_SPEC.figures[0].uid; @@ -5093,10 +4388,7 @@ def test_cell_hover_emits_x_band_to_line_target( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) - def test_cell_hover_no_visual_to_source_figure( - self, page: Page, server_port: int, renderer: str - ): + def test_cell_hover_no_visual_to_source_figure(self, page: Page, server_port: int): """Cell hover must not emit any visual to the source figure.""" import polars as pl @@ -5118,10 +4410,10 @@ def test_cell_hover_no_visual_to_source_figure( fig2.add_histogram(x="ts", bins=10) spec = dash.to_spec(source_name="_browser_cell_test3") encoded = encode_spec(spec) - url = f"http://127.0.0.1:{server_port}/view?spec={encoded}&renderer={renderer}" + url = f"http://127.0.0.1:{server_port}/view?spec={encoded}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") source_guides = page.evaluate("""() => { const fig1Uid = DASHBOARD_SPEC.figures[1].uid; @@ -5148,10 +4440,7 @@ def test_cell_hover_no_visual_to_source_figure( assert source_guides == 0, "Source figure must receive no linked visuals" @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) - def test_axis_mode_histogram_source_emits_guide( - self, page: Page, server_port: int, renderer: str - ): + def test_axis_mode_histogram_source_emits_guide(self, page: Page, server_port: int): """A histogram declares ``axis`` as a source mode, so hovering a bar in axis mode must emit an x-guide on a line target sharing the column — it must not be swallowed as a cell event because the bar has bin edges.""" @@ -5169,12 +4458,9 @@ def test_axis_mode_histogram_source_emits_guide( dash.add_figure().add_line(x="ts", y="val") # fig0: axis target dash.add_figure().add_histogram(x="ts", bins=10) # fig1: axis source spec = dash.to_spec(source_name="_browser_hist_axis_src") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer={renderer}" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") guides = page.evaluate("""() => { const lineFigUid = DASHBOARD_SPEC.figures[0].uid; @@ -5201,9 +4487,8 @@ def test_axis_mode_histogram_source_emits_guide( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) def test_hover_toggle_hidden_for_geo_only_cell_source( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """The hover toggle must stay hidden when only an unimplemented geo cell source exists with no other linkable pair.""" @@ -5226,10 +4511,10 @@ def test_hover_toggle_hidden_for_geo_only_cell_source( fig1.add_line(x="lon", y="lat") spec = dash.to_spec(source_name="_browser_cell_geo_only") encoded = encode_spec(spec) - url = f"http://127.0.0.1:{server_port}/view?spec={encoded}&renderer={renderer}" + url = f"http://127.0.0.1:{server_port}/view?spec={encoded}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") wrapper = page.query_selector("#fv-hover-dropdown") if wrapper: @@ -5239,9 +4524,8 @@ def test_hover_toggle_hidden_for_geo_only_cell_source( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) def test_cell_hover_uses_target_axis_for_band_orientation( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Cell fallback bands must use target axis mapping, not source axis role.""" from flexviz.dashboard import Dashboard @@ -5265,10 +4549,10 @@ def test_cell_hover_uses_target_axis_for_band_orientation( fig1.add_line(x="shared", y="src_x") spec = dash.to_spec(source_name="_browser_cell_target_axis") encoded = encode_spec(spec) - url = f"http://127.0.0.1:{server_port}/view?spec={encoded}&renderer={renderer}" + url = f"http://127.0.0.1:{server_port}/view?spec={encoded}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") emitted = page.evaluate("""() => { const sourceFigUid = DASHBOARD_SPEC.figures[0].uid; @@ -5305,9 +4589,8 @@ def test_cell_hover_uses_target_axis_for_band_orientation( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) def test_grouped_histogram_hover_edges_keyed_by_parent( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Grouped histogram bin edges must be resolvable under the logical parent uid (target lookups use the parent uid, not child uids).""" @@ -5326,12 +4609,9 @@ def test_grouped_histogram_hover_edges_keyed_by_parent( dash = Dashboard(df) dash.add_figure().add_histogram(x="sin", group_by="country", bins=12) spec = dash.to_spec(source_name="_browser_grouped_hist_cells") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer={renderer}" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") edges = page.evaluate("""() => { const parentUid = DASHBOARD_SPEC.figures[0].traces[0].uid; @@ -5343,9 +4623,8 @@ def test_grouped_histogram_hover_edges_keyed_by_parent( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) def test_axis_hover_line_emits_band_on_grouped_histograms( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """Axis-mode hover on a grouped line must place a bin-band on grouped histograms sharing the column — including a histogram that bins that @@ -5373,12 +4652,9 @@ def test_axis_hover_line_emits_band_on_grouped_histograms( x="sin", group_by="country", bins=12 ) # fig2 vert spec = dash.to_spec(source_name="_browser_axis_grouped_hist") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer={renderer}" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") result = page.evaluate("""() => { const lineFigUid = DASHBOARD_SPEC.figures[0].uid; @@ -5413,9 +4689,8 @@ def test_axis_hover_line_emits_band_on_grouped_histograms( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) def test_axis_hover_histogram_x_source_emits_y_guide_on_line( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """A vertical (x=) histogram bins its column on x, but a line that plots that same column on y must receive a HORIZONTAL (y) guide at the bin @@ -5435,12 +4710,9 @@ def test_axis_hover_histogram_x_source_emits_y_guide_on_line( dash.add_figure().add_line(x="x", y="sin") # fig0: sin on y axis dash.add_figure().add_histogram(x="sin", bins=10) # fig1: sin on x axis spec = dash.to_spec(source_name="_browser_axis_hist_x_to_line") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer={renderer}" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") axes = page.evaluate("""() => { const lineFigUid = DASHBOARD_SPEC.figures[0].uid; @@ -5466,9 +4738,8 @@ def test_axis_hover_histogram_x_source_emits_y_guide_on_line( ) @pytest.mark.browser - @pytest.mark.parametrize("renderer", ["plotly"]) def test_cell_hover_1d_histogram_emits_band_not_cell_on_hist2d( - self, page: Page, server_port: int, renderer: str + self, page: Page, server_port: int ): """A 1D histogram only constrains one axis, so a 2D target (histogram2d) must receive a band/strip on the shared-column axis — never a single @@ -5490,12 +4761,9 @@ def test_cell_hover_1d_histogram_emits_band_not_cell_on_hist2d( x="x", y="sin", x_bins=10, y_bins=8 ) # fig1: sin on y spec = dash.to_spec(source_name="_browser_cell_1d_to_2d") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer={renderer}" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) - _wait_for_init(page, renderer) + _wait_for_init(page, "plotly") # Drive the cell projection directly: a 1D cell event (constrains only x) # whose column maps to the hist2d's y axis must yield a y-band, not a rect. @@ -5586,10 +4854,7 @@ def test_histogram_edges_and_hover_cell_lookup(self, page: Page, server_port: in dash.add_figure().add_line(x="ts", y="val") # fig0: axis source dash.add_figure().add_histogram(x="val", bins=10) # fig1: axis target spec = dash.to_spec(source_name="_browser_edges_hist") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer=plotly" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) _wait_for_init(page, "plotly") @@ -5658,10 +4923,7 @@ def test_histogram2d_edges_and_hover_cell_lookup( x="a", y="b", x_bins=6, y_bins=5 ) # fig1: target spec = dash.to_spec(source_name="_browser_edges_hist2d") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer=plotly" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) _wait_for_init(page, "plotly") @@ -5719,10 +4981,7 @@ def test_temporal_histogram2d_source_hover_links_the_cell( dash.add_figure().add_histogram2d(x="t", y="b", x_bins=6, y_bins=5) # source dash.add_figure().add_histogram2d(x="t", y="b", x_bins=6, y_bins=5) # target spec = dash.to_spec(source_name="_browser_edges_hist2d_date") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer=plotly" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) _wait_for_init(page, "plotly") @@ -5759,10 +5018,7 @@ def test_hover_lookup_puts_edge_values_in_the_server_bin( dash.add_figure().add_histogram(x="v", bins=3) # fig1: 1-D target dash.add_figure().add_histogram2d(x="v", y="w", x_bins=3, y_bins=3) # fig2 spec = dash.to_spec(source_name="_browser_edges_lookup") - url = ( - f"http://127.0.0.1:{server_port}/view?" - f"spec={encode_spec(spec)}&renderer=plotly" - ) + url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" page.goto(url) _wait_for_init(page, "plotly") @@ -5806,7 +5062,7 @@ def bounds(edges, i): assert band["h2y"] == bounds(result["yEdges"], row), value def test_geo_histogram2d_edges_and_rectangles(self, page: Page, server_port: int): - url = _dashboard_url_geo(server_port, "plotly") + url = _dashboard_url_geo(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -5891,7 +5147,7 @@ class TestResetCleanupBrowser: def test_reset_clears_plotly_selection_boxes(self, page: Page, server_port: int): """Clicking toolbar Reset must clear Plotly's visual selection rectangle.""" - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) page.goto(url) _wait_for_init(page, "plotly") @@ -5935,7 +5191,7 @@ def test_reset_fires_exactly_one_server_call( ): """A reset button posts one request, although no guard is on while the response redraws the figures and clears the selection box.""" - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -5967,7 +5223,7 @@ def test_modebar_home_preserves_selections(self, page: Page, server_port: int): type:'reset' with selections:[] — wiping the cross-filter state. Now they send type:'viewport' with the current selections preserved. """ - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -6026,7 +5282,7 @@ def test_panel_reset_clears_sourced_selection_and_scopes_viewport( selection (sends deselect/selection event) and clears its viewport. Other figures' viewports are not affected. """ - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -6094,7 +5350,7 @@ def test_axis_lock_toggle_captures_range_and_panel_reset_preserves_it( """Locking an axis stores its current range; resetting an otherwise-clean locked figure leaves the lock intact and is a no-op (axis locks are view-only, so nothing the figure shows changes — see #32).""" - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -6158,7 +5414,7 @@ def test_axis_lock_before_zoom_uses_autorange_after_data_update( self, page: Page, server_port: int ): """Initial no-zoom locks must not freeze Plotly's empty bootstrap range.""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -6325,7 +5581,7 @@ def test_locked_axis_relayout_posts_only_unlocked_axis( the locked x is pruned and snapped back, the y persists CLIENT-SIDE only — a line's y is not a recompute axis, so the fvNeedsFetch gate (commit bfcdf8a) suppresses the no-op POST entirely.""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -6393,7 +5649,7 @@ def test_unlocked_recompute_axis_relayout_posts_and_prunes_locked_axis( """The positive case of the fvNeedsFetch gate: with y locked, a relayout touching both axes still POSTs (the line's x re-aggregates) and the POSTed event names only the unlocked x as changed.""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) update_bodies: list[dict] = [] def capture(req: PWRequest) -> None: @@ -6456,7 +5712,7 @@ def test_global_reset_preserves_axis_lock_ranges( self, page: Page, server_port: int ): """Global reset clears viewport/selections but keeps explicit axis locks.""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) page.goto(url) _wait_for_init(page, "plotly") @@ -6522,7 +5778,7 @@ def _shown(page: Page) -> dict: def test_resets_keep_a_locked_zoomed_axis(self, page: Page, server_port: int): """Panel reset, global reset and double-click leave a locked, zoomed axis at its lock range, and the data stays aggregated inside that range.""" - url = _dashboard_url(server_port, "plotly", n_figures=1) + url = _dashboard_url(server_port, n_figures=1) posts: list[dict] = [] page.on( "request", @@ -6568,7 +5824,7 @@ class TestLegendVisibilityBrowser: def test_grouped_line_multi_group_by_hidden_trace_stays_hidden_after_zoom( self, page: Page, server_port: int ): - url = _dashboard_url_grouped_line_multi_group_by(server_port, "plotly") + url = _dashboard_url_grouped_line_multi_group_by(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -6607,7 +5863,7 @@ class TestTreemapClickBrowser: """Plotly treemap clicks should drive FlexViz selection state consistently.""" def test_treemap_click_toggle_keeps_root_view(self, page: Page, server_port: int): - url = _dashboard_url_treemap_with_line(server_port, "plotly") + url = _dashboard_url_treemap_with_line(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -6665,7 +5921,7 @@ def click_treemap_category(category: str) -> None: def test_treemap_leaf_selection_filters_with_parent_path( self, page: Page, server_port: int ): - url = _dashboard_url_treemap_pie_selection(server_port, "plotly") + url = _dashboard_url_treemap_pie_selection(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -6711,7 +5967,7 @@ def test_treemap_parent_then_leaf_selection_highlights_leaf_only( self, page: Page, server_port: int ): page.set_viewport_size({"width": 1400, "height": 800}) - url = _dashboard_url_treemap_pie_selection(server_port, "plotly") + url = _dashboard_url_treemap_pie_selection(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -6838,7 +6094,7 @@ class TestPieClickBrowser: """Plotly pie click toggles should leave visible source feedback.""" def test_pie_click_dims_unselected_slices(self, page: Page, server_port: int): - url = _dashboard_url_treemap_pie_selection(server_port, "plotly") + url = _dashboard_url_treemap_pie_selection(server_port) page.goto(url) _wait_for_init(page, "plotly") @@ -6882,7 +6138,7 @@ def test_pie_click_dims_unselected_slices(self, page: Page, server_port: int): # --------------------------------------------------------------------------- -def _dashboard_url_draggable(port: int, renderer: str) -> str: +def _dashboard_url_draggable(port: int) -> str: """Return a URL for a 2-figure draggable-grid dashboard.""" from flexviz.dashboard import Dashboard from flexviz.server import register_source @@ -6899,14 +6155,12 @@ def _dashboard_url_draggable(port: int, renderer: str) -> str: spec = dash.to_spec(source_name="_browser_drag_test", layout=LayoutSpec()) encoded = encode_spec(spec) - return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" + return f"http://127.0.0.1:{port}/view?spec={encoded}" -def _chart_bounding_boxes(page: Page, renderer: str) -> list[dict]: +def _chart_bounding_boxes(page: Page) -> list[dict]: """Return bounding boxes for all chart containers.""" - if renderer == "plotly": - return [el.bounding_box() for el in page.query_selector_all(".js-plotly-plot")] - return [el.bounding_box() for el in page.query_selector_all("[id^='fv-chart-']")] + return [el.bounding_box() for el in page.query_selector_all(".js-plotly-plot")] def _drag_grid_item_right(page: Page, sel: str) -> None: @@ -6930,65 +6184,45 @@ def _drag_grid_item_right(page: Page, sel: str) -> None: page.wait_for_timeout(800) -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) class TestDraggableGridBrowser: """Draggable Gridstack layout — verify charts are visible and drag is backend-free.""" - def test_charts_are_visible(self, page: Page, server_port: int, renderer: str): + def test_charts_are_visible(self, page: Page, server_port: int): """Charts must have non-zero width and height after the page loads.""" - url = _dashboard_url_draggable(server_port, renderer) + url = _dashboard_url_draggable(server_port) page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) # Wait for the init POST to complete so charts have data. page.wait_for_timeout(2_000) - boxes = _chart_bounding_boxes(page, renderer) + boxes = _chart_bounding_boxes(page) assert len(boxes) == 2, f"Expected 2 chart containers, got {len(boxes)}" for i, box in enumerate(boxes): assert box is not None, f"Chart {i} has no bounding box (not in DOM)" assert box["width"] > 0, f"Chart {i} width is 0 — chart is invisible" assert box["height"] > 0, f"Chart {i} height is 0 — chart is invisible" - def test_charts_have_rendered_data( - self, page: Page, server_port: int, renderer: str - ): + def test_charts_have_rendered_data(self, page: Page, server_port: int): """Charts must contain actual rendered trace data after the init POST.""" - url = _dashboard_url_draggable(server_port, renderer) + url = _dashboard_url_draggable(server_port) page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) page.wait_for_timeout(2_000) - if renderer == "plotly": - # Each .js-plotly-plot should have at least one trace with x data. - trace_counts = page.evaluate("""() => - Array.from(document.querySelectorAll('.js-plotly-plot')) - .map(el => (el.data || []).filter(t => t.x && t.x.length > 0).length) - """) - assert all(c > 0 for c in trace_counts), ( - f"Some Plotly charts have no rendered data: {trace_counts}" - ) - else: - # Each ECharts instance should have at least one series with data. - series_counts = page.evaluate("""() => - Array.from(document.querySelectorAll("[id^='fv-chart-']")) - .map(el => { - const chart = echarts.getInstanceByDom(el); - if (!chart) return 0; - const series = chart.getOption().series || []; - return series.filter(s => s.data && s.data.length > 0).length; - }) - """) - assert all(c > 0 for c in series_counts), ( - f"Some ECharts instances have no rendered data: {series_counts}" - ) + # Each .js-plotly-plot should have at least one trace with x data. + trace_counts = page.evaluate("""() => + Array.from(document.querySelectorAll('.js-plotly-plot')) + .map(el => (el.data || []).filter(t => t.x && t.x.length > 0).length) + """) + assert all(c > 0 for c in trace_counts), ( + f"Some Plotly charts have no rendered data: {trace_counts}" + ) - def test_drag_does_not_trigger_backend_request( - self, page: Page, server_port: int, renderer: str - ): + def test_drag_does_not_trigger_backend_request(self, page: Page, server_port: int): """Dragging a Gridstack item must NOT fire a /dashboard/update request.""" - url = _dashboard_url_draggable(server_port, renderer) + url = _dashboard_url_draggable(server_port) page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) page.wait_for_timeout(2_000) backend_calls: list[str] = [] @@ -7021,13 +6255,11 @@ def _capture(req: PWRequest) -> None: f"Dragging a grid item fired {new_calls} backend request(s) — should be 0" ) - def test_drag_updates_spec_layout( - self, page: Page, server_port: int, renderer: str - ): + def test_drag_updates_spec_layout(self, page: Page, server_port: int): """After page load, DASHBOARD_SPEC.layout.grid_items must be populated from Gridstack.""" - url = _dashboard_url_draggable(server_port, renderer) + url = _dashboard_url_draggable(server_port) page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) page.wait_for_timeout(2_000) # grid_items must be populated immediately from GridStack.init(), before any drag. @@ -7074,13 +6306,11 @@ def test_drag_updates_spec_layout( f"after={(new_item['x'], new_item['y'])}" ) - def test_grid_toggle_locks_and_unlocks_drag( - self, page: Page, server_port: int, renderer: str - ): + def test_grid_toggle_locks_and_unlocks_drag(self, page: Page, server_port: int): """Toolbar grid button should lock and unlock Gridstack drag/resize.""" - url = _dashboard_url_draggable(server_port, renderer) + url = _dashboard_url_draggable(server_port) page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) page.wait_for_timeout(2_000) btn = page.query_selector("#fv-btn-grid") @@ -7137,14 +6367,12 @@ def test_grid_toggle_locks_and_unlocks_drag( ) -@pytest.mark.parametrize("renderer", ["plotly"]) class TestLockedLayoutBrowser: """A read-only embed: no layout button, and panel height honours GridItem.h.""" def _url( self, port: int, - renderer: str, h: int, draggable: bool = False, gap: str = "8px", @@ -7178,25 +6406,21 @@ def _url( ], ), ) - return ( - f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}&renderer={renderer}" - ) + return f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}" - def test_layout_button_is_hidden(self, page: Page, server_port: int, renderer: str): - page.goto(self._url(server_port, renderer, 7)) + def test_layout_button_is_hidden(self, page: Page, server_port: int): + page.goto(self._url(server_port, 7)) page.wait_for_selector("#fv-btn-reset", timeout=10_000) - _wait_for_chart(page, renderer) + _wait_for_chart(page) btn = page.query_selector("#fv-btn-grid") assert btn is None or not btn.is_visible(), "Layout button should be hidden" - def test_grid_item_height_drives_the_panel( - self, page: Page, server_port: int, renderer: str - ): + def test_grid_item_height_drives_the_panel(self, page: Page, server_port: int): """h is h * 80 px on both layout paths, gap included.""" for h in (4, 7): for draggable in (True, False): - page.goto(self._url(server_port, renderer, h, draggable, gap="24px")) - _wait_for_chart(page, renderer) + page.goto(self._url(server_port, h, draggable, gap="24px")) + _wait_for_chart(page) page.wait_for_timeout(1_000) sel = ".grid-stack-item" if draggable else ".fv-dashboard-item" items = page.query_selector_all(sel) @@ -7214,9 +6438,7 @@ def test_grid_item_height_drives_the_panel( visible_gap = panels[1]["y"] - panels[0]["y"] - panels[0]["height"] assert abs(visible_gap - 24) <= 1, visible_gap - def test_report_embed_height_matches_the_page( - self, page: Page, server_port: int, renderer: str - ): + def test_report_embed_height_matches_the_page(self, page: Page, server_port: int): """A report iframe is tall enough that the embedded page never scrolls.""" from flexviz.report import _iframe_height @@ -7224,9 +6446,9 @@ def test_report_embed_height_matches_the_page( # content height instead of the viewport's. page.set_viewport_size({"width": 1280, "height": 300}) for draggable in (True, False): - url = self._url(server_port, renderer, 4, draggable, gap="24px") + url = self._url(server_port, 4, draggable, gap="24px") page.goto(url) - _wait_for_chart(page, renderer) + _wait_for_chart(page) page.wait_for_timeout(1_000) measured = page.evaluate("() => document.documentElement.scrollHeight") assert abs(_iframe_height(url) - measured) <= 1, (draggable, measured) @@ -7240,7 +6462,7 @@ def test_report_page_sanitizes_the_markdown_html( from flexviz.report import to_html monkeypatch.chdir(tmp_path) - history.add(_dashboard_url(server_port, "plotly")) + history.add(_dashboard_url(server_port)) md = "# Findings\n\n\n\nfv:1\n" out = tmp_path / "report.html" out.write_text(to_html(md), encoding="utf-8") @@ -7291,7 +6513,7 @@ def _dashboard_url_linked( group = next(g for g in (x_group, y_group) if key in g) spec.state.viewport.update(dict.fromkeys(group, AxisRange(min=lo, max=hi))) return ( - f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}&renderer=plotly", + f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}", uids, ) @@ -7324,7 +6546,7 @@ def _dashboard_url_linked_pairs( dash.link_axes(a, b, axis="x") spec = dash.to_spec(source_name="_browser_linked_pairs") return ( - f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}&renderer=plotly", + f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}", [f.uid for f in spec.figures], ) @@ -7630,8 +6852,7 @@ def test_lock_on_a_line_and_histogram_of_one_column_posts_nothing( spec = dash.to_spec(source_name="_browser_linked_hist") posts = self._open( page, - f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" - "&renderer=plotly", + f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}", ) shown = page.evaluate( "[...document.querySelectorAll('.js-plotly-plot')]" @@ -7817,7 +7038,7 @@ def test_a_late_response_still_updates_what_a_newer_request_left_alone( ): """A brush on C re-aggregates A and B. A zoom on A before that response re-aggregates only A. The late brush response must still filter B.""" - url = _dashboard_url(server_port, "plotly", n_figures=3) + url = _dashboard_url(server_port, n_figures=3) posts = self._open(page, url) held = _hold_first_update(page) @@ -7839,7 +7060,7 @@ def test_a_late_response_still_fills_the_layers_a_newer_one_left_alone( """Overlay mode, with a brush on C. A zoom on A re-aggregates the bg and fg of A. A newer brush on C re-aggregates only the fg of A. The late zoom response must still set the bg of A, but not its older fg.""" - url = _dashboard_url(server_port, "plotly", n_figures=3) + url = _dashboard_url(server_port, n_figures=3) posts = self._open(page, url) page.evaluate("window.flexvizApply({state: {cross_filter_mode: 'overlay'}})") page.evaluate(_SELECT_TS, [2, [100, 300]]) @@ -7869,7 +7090,7 @@ def test_a_late_response_still_fills_the_layers_a_newer_one_left_alone( def test_a_late_zoom_response_does_not_undo_a_reset( self, page: Page, server_port: int, button: str ): - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) posts = self._open(page, url) held = _hold_first_update(page) @@ -7894,7 +7115,7 @@ def test_a_late_zoom_response_does_not_undo_a_reset( def test_a_zoom_while_a_reset_is_pending_posts_and_keeps_its_data( self, page: Page, server_port: int, button: str ): - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) posts = self._open(page, url) uids = page.evaluate("DASHBOARD_SPEC.figures.map(f => f.uid)") page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [100, 200]})") @@ -7918,7 +7139,7 @@ def test_a_zoom_while_a_reset_is_pending_posts_and_keeps_its_data( def test_the_guard_ignores_only_its_figure_until_its_last_operation_ends( self, page: Page, server_port: int ): - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) posts = self._open(page, url) uids = page.evaluate("DASHBOARD_SPEC.figures.map(f => f.uid)") page.evaluate( @@ -7946,7 +7167,7 @@ def test_the_guard_ignores_only_its_figure_until_its_last_operation_ends( def test_the_guard_ignores_a_deselect_on_its_figure( self, page: Page, server_port: int ): - url = _dashboard_url(server_port, "plotly", n_figures=2) + url = _dashboard_url(server_port, n_figures=2) posts = self._open(page, url) page.evaluate(_SELECT_TS, [0, [100, 300]]) _wait_settled(page, 1) @@ -7994,7 +7215,7 @@ def test_an_owner_zoomed_under_a_filter_gets_an_unfiltered_background( ): # Two owners only: a third figure would lose its background on the # zoom, and the warm-up init would then refresh every figure's. - self._open(page, _dashboard_url(server_port, "plotly", n_figures=2)) + self._open(page, _dashboard_url(server_port, n_figures=2)) page.evaluate("""() => { const uids = DASHBOARD_SPEC.figures.map(f => f.uid); window.fvSetSelectionState([[0, [50, 450]], [1, [200, 250]]].map( @@ -8018,7 +7239,7 @@ def test_a_panel_reset_under_a_filter_marks_the_background_stale( ): # A reset that clears a zoom and a selection together is a selection # event with viewport keys, not a viewport event. - self._open(page, _dashboard_url(server_port, "plotly", n_figures=2)) + self._open(page, _dashboard_url(server_port, n_figures=2)) page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [100, 400]})") _wait_settled(page, 1) page.evaluate("""() => { @@ -8052,7 +7273,7 @@ def _color_norm_url(port: int, source: str, df: pl.DataFrame, build) -> str: dash = Dashboard(df) build(dash) spec = dash.to_spec(source_name=source) - return f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}&renderer=plotly" + return f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}" # Rendered heatmap/choropleth traces of every Plotly figure, reduced to the @@ -8680,10 +7901,7 @@ def test_saved_viewport_opens_at_its_range(self, page: Page, server_port: int): spec.state.viewport[f"{spec.figures[0].uid}/x"] = AxisRange( min=100.0, max=10_000.0 ) - page.goto( - f"http://127.0.0.1:{server_port}/view" - f"?spec={encode_spec(spec)}&renderer=plotly" - ) + page.goto(f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}") _wait_for_init(page, "plotly") self._wait_for_data_inside(page, 99, 10_001) diff --git a/tests/test_cache.py b/tests/test_cache.py index 90ac80f..59b3723 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -157,16 +157,6 @@ def test_default_cube_cache_budget_is_512_mb(): assert InMemoryByteLRUCache().stats()["max_bytes"] == 512 * 2**20 -def test_cube_cache_singleton_swap(): - original = cache_mod.get_cube_cache() - try: - replacement = InMemoryByteLRUCache(max_bytes=10) - cache_mod.set_cube_cache_backend(replacement) - assert cache_mod.get_cube_cache() is replacement - finally: - cache_mod.set_cube_cache_backend(original) - - # --------------------------------------------------------------------------- # Engine integration # --------------------------------------------------------------------------- diff --git a/tests/test_html_adapters.py b/tests/test_html_adapters.py index 7ddb50e..e8c6aca 100644 --- a/tests/test_html_adapters.py +++ b/tests/test_html_adapters.py @@ -10,7 +10,6 @@ from __future__ import annotations -import json from html.parser import HTMLParser import pytest @@ -482,7 +481,7 @@ def test_hover_event_wiring(self, html): assert "plotly_unhover" in html def test_hover_clear_fn_exposed(self, html): - assert "fvClearAllCrosshairs" in html + assert "window.fvClearAllHoverVisuals =" in html def test_hover_state_uses_client_state(self, html): assert "window._hoverEnabled" not in html @@ -688,332 +687,6 @@ def test_plotly_rebuild_preserves_legend_visibility(self): assert "applyLegendVisibility(trace, logicalUid);" in html -# --------------------------------------------------------------------------- -# ECharts HTML tests -# --------------------------------------------------------------------------- - - -class TestEChartsHtml: - @pytest.fixture() - def html(self, two_fig_spec): - from flexviz.adapters.echarts_adapter import EChartsAdapter - - return EChartsAdapter()._build_dashboard_html( - two_fig_spec, server_url="http://localhost:9999" - ) - - @pytest.fixture() - def initial_option(self, two_fig_spec): - """Parsed initial ECharts option dict for the first figure.""" - from flexviz.adapters.echarts_adapter import EChartsAdapter - - return EChartsAdapter()._build_initial_option( - two_fig_spec.figures[0], fig_height=400 - ) - - # Bug 7: applyDeltasToFig must reset dataZoom to show 100% of new data. - def test_apply_deltas_resets_datazoom(self, html): - # After setting series data for a viewport update, the chart must - # reset dataZoom to start:0 / end:100 so the downsampled data fills - # the visible window. - assert "start: 0" in html or '"start": 0' in html or "start:0" in html, ( - "applyDeltasToFig must reset dataZoom start to 0 after applying new data" - ) - assert "end: 100" in html or '"end": 100' in html or "end:100" in html, ( - "applyDeltasToFig must reset dataZoom end to 100 after applying new data" - ) - - # Bug 8: Selection must use brushEnd event for complete coordinates. - def test_brush_uses_brushend_event(self, html): - assert "brushEnd" in html, ( - "ECharts adapter must use 'brushEnd' event (fires once with complete " - "coordRange) instead of 'brushSelected' for cross-filter selections" - ) - - def test_brush_config_has_axis_index(self, initial_option): - # The brush component (not dataZoom) must link to axis indices so that - # brushEnd coordRange is populated with cartesian coordinates. - brush = initial_option.get("brush", {}) - assert "xAxisIndex" in brush, ( - "initial option brush config must include xAxisIndex so that " - "brushEnd coordRange contains x-axis data coordinates" - ) - - # Bug 9a: Per-figure Select button must be removed. - def test_no_per_figure_select_button(self, html): - assert "btn-select" not in html, ( - "ECharts adapter must not emit per-figure Select buttons; " - "selection is handled by the shared panel controls" - ) - - def test_toolbox_hidden_in_initial_option(self, initial_option): - assert initial_option["toolbox"]["show"] is False - - def test_axis_lock_renderer_hooks_present(self, html): - assert "fvCaptureAxisDisplayRanges" in html - assert "fvApplyAxisLocks" in html - assert "axis_lock_ranges" in html - - def test_shared_panel_wrapper_present(self, html): - assert "' not in html - assert "\\u003c/script\\u003e\\u003cscript" in html - - def test_layout_gap_is_sanitized_for_style_blocks(self): - from flexviz.adapters.echarts_adapter import EChartsAdapter - from flexviz.spec import LayoutSpec - - spec = DashboardSpec( - figures=[FigureSpec(uid="fig1", traces=[])], - layout=LayoutSpec(gap='8px;}'), - ) - html = EChartsAdapter()._build_dashboard_html(spec, server_url="http://test") - assert '