From 49cacd2b80ea8901c8c32065113144a6c3d5bae3 Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Wed, 30 Sep 2026 10:40:37 +0200 Subject: [PATCH 1/7] refactor(adapters): remove the ECharts renderer --- Architecture.md | 66 +-- CHANGELOG.md | 3 + docs/guides/cross-filtering.md | 1 - docs/guides/customizing.md | 4 - flexviz/adapters/__init__.py | 2 +- flexviz/adapters/echarts_adapter.py | 581 ------------------- flexviz/adapters/js/echarts/init.js | 393 ------------- flexviz/adapters/js/echarts/render.js | 280 --------- flexviz/adapters/js/echarts/series.js | 423 -------------- flexviz/adapters/registry.py | 21 - flexviz/adapters/runtime.py | 16 +- flexviz/dashboard.py | 2 +- flexviz/figure.py | 4 +- flexviz/server.py | 4 +- flexviz/trace/hist2d.py | 2 +- tests/test_adapters.py | 179 +----- tests/test_browser.py | 757 ++----------------------- tests/test_html_adapters.py | 546 +----------------- tests/test_integration.py | 38 +- tests/test_path_predicate_selection.py | 212 ++----- 20 files changed, 153 insertions(+), 3381 deletions(-) delete mode 100644 flexviz/adapters/echarts_adapter.py delete mode 100644 flexviz/adapters/js/echarts/init.js delete mode 100644 flexviz/adapters/js/echarts/render.js delete mode 100644 flexviz/adapters/js/echarts/series.js diff --git a/Architecture.md b/Architecture.md index 29c74e1..76df46c 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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 │ └────────────────────────────────────────────────────────────────┘ ``` @@ -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. @@ -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/registry.py b/flexviz/adapters/registry.py index 6200438..c4fd0e2 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, - ), - ), } diff --git a/flexviz/adapters/runtime.py b/flexviz/adapters/runtime.py index 549c161..3e75fbc 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. @@ -16,8 +16,6 @@ - ``_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 """ from __future__ import annotations @@ -50,12 +48,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 +76,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 +121,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/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..9b08091 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 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/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/tests/test_adapters.py b/tests/test_adapters.py index c8b51c9..154fbc2 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,25 +218,10 @@ 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)], + [("plotly", 432, 432)], ) def test_notebook_iframe_loads_the_page_from_the_server( self, server_port, monkeypatch, renderer, height, iframe_height @@ -300,92 +284,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 +451,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`.""" @@ -817,10 +653,3 @@ 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) diff --git a/tests/test_browser.py b/tests/test_browser.py index c5936af..2c57d69 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,24 +67,6 @@ 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.""" from flexviz.dashboard import Dashboard @@ -181,35 +162,6 @@ def _dashboard_url_boxplot(port: int, renderer: str = "plotly") -> str: 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}" - - def _dashboard_url_static_pie(port: int, renderer: str = "plotly") -> str: """Single-figure dashboard whose Plotly panel should not render a control bar.""" from flexviz.dashboard import Dashboard @@ -232,46 +184,6 @@ def _dashboard_url_static_pie(port: int, renderer: str = "plotly") -> str: 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 - - 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: @@ -678,22 +590,13 @@ def _dashboard_url_line_grouped_bar_target(port: int, renderer: str = "plotly") 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) + 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; - }""") + return page.evaluate( + """() => document.querySelector('.js-plotly-plot').data.length""" + ) def _trace_layer(trace_id: str | None) -> str | None: @@ -814,13 +717,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 # --------------------------------------------------------------------------- @@ -1992,7 +1888,7 @@ def test_boxplot_page_loads_and_chart_renders(self, page: Page, server_port: int assert len(charts) >= 1, f"Expected >=1 Plotly chart, found {len(charts)}" -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) +@pytest.mark.parametrize("renderer", ["plotly"]) class TestGroupedBrowser: def test_grouped_line_renders_children_not_parent( self, page: Page, server_port: int, renderer: str @@ -2033,454 +1929,18 @@ def test_grouped_bar_renders_children( # --------------------------------------------------------------------------- -# 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, - }, - ] - - -# --------------------------------------------------------------------------- -# 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"]) +@pytest.mark.parametrize("renderer", ["plotly"]) class TestCrossFilterBrowser: """Test cross-filter selection across 3 figures in a headless browser.""" @@ -2783,7 +2243,7 @@ def capture(req: PWRequest) -> None: assert selections == [] or selections is None -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) +@pytest.mark.parametrize("renderer", ["plotly"]) class TestOverlayBrowser: def test_overlay_toggle_with_cached_bg_avoids_warmup( self, page: Page, server_port: int, renderer: str @@ -2876,8 +2336,6 @@ def test_overlay_heatmap_shows_single_colorbar( self, page: Page, server_port: int, renderer: str ): """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") @@ -2954,8 +2412,6 @@ def test_overlay_map_fades_background_under_filtered_layer( self, page: Page, server_port: int, renderer: str, 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( @@ -3046,20 +2502,11 @@ def test_overlay_reuses_same_color_and_mutes_background( 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, @@ -3110,25 +2557,12 @@ 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, })) ); }""", @@ -3180,20 +2614,10 @@ 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, @@ -3482,7 +2906,7 @@ def test_dashboard_renders_in_an_iframe_on_another_origin( # --------------------------------------------------------------------------- -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) +@pytest.mark.parametrize("renderer", ["plotly"]) class TestShareUrlState: """Verify that Share → navigate preserves viewport and cross-filter state.""" @@ -3584,18 +3008,8 @@ def test_share_preserves_cross_filter_effect_on_data( 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 +3024,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', @@ -3652,17 +3057,8 @@ def test_share_preserves_cross_filter_effect_on_data( 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}, ) @@ -3752,16 +3148,10 @@ def test_share_draws_selection_boxes( """({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}, ) @@ -3836,20 +3226,8 @@ 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}, @@ -4401,7 +3779,7 @@ def _dashboard_url_hover_minmax_shared_x(port: int, renderer: str) -> str: return f"http://127.0.0.1:{port}/view?spec={encoded}&renderer={renderer}" -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) +@pytest.mark.parametrize("renderer", ["plotly"]) class TestLinkedHoverBrowser: def test_hover_button_present_and_inactive_by_default( self, page: Page, server_port: int, renderer: str @@ -4474,9 +3852,6 @@ def test_plotly_hover_shows_crosshair_on_other_figure( self, page: Page, server_port: int, renderer: str ): """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) page.goto(url) _wait_for_init(page, renderer) @@ -4507,9 +3882,6 @@ def test_plotly_hover_shows_crosshair_on_other_figure( 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) page.goto(url) _wait_for_init(page, renderer) @@ -4530,9 +3902,6 @@ def test_plotly_unhover_clears_crosshairs( def test_plotly_axis_hover_emits_crosshair_on_shared_axes( self, page: Page, server_port: int, renderer: str ): - 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 @@ -4566,9 +3935,6 @@ def test_plotly_axis_hover_emits_no_visual_to_source_figure( self, page: Page, server_port: int, renderer: str ): """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) page.goto(url) _wait_for_init(page, renderer) @@ -4589,9 +3955,6 @@ def test_plotly_axis_hover_emits_no_visual_to_source_figure( 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) page.goto(url) _wait_for_init(page, renderer) @@ -4612,9 +3975,6 @@ def test_plotly_minmax_hover_links_even_when_not_near_exact_point( self, page: Page, server_port: int, renderer: str ): """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) page.goto(url) _wait_for_init(page, renderer) @@ -4777,8 +4137,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 @@ -4858,8 +4216,6 @@ def test_hover_band_is_clipped_to_the_plot_area( 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} ) @@ -4923,8 +4279,6 @@ def test_mouse_hover_on_a_date_axis_links_guide_and_band( """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 @@ -6904,9 +6258,7 @@ def _dashboard_url_draggable(port: int, renderer: str) -> str: def _chart_bounding_boxes(page: Page, renderer: str) -> 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,7 +6282,7 @@ def _drag_grid_item_right(page: Page, sel: str) -> None: page.wait_for_timeout(800) -@pytest.mark.parametrize("renderer", ["plotly", "echarts"]) +@pytest.mark.parametrize("renderer", ["plotly"]) class TestDraggableGridBrowser: """Draggable Gridstack layout — verify charts are visible and drag is backend-free.""" @@ -6958,29 +6310,14 @@ def test_charts_have_rendered_data( _wait_for_chart(page, renderer) 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 diff --git a/tests/test_html_adapters.py b/tests/test_html_adapters.py index 7ddb50e..88687ca 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 @@ -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 '