diff --git a/Architecture.md b/Architecture.md index 8500747..5512de9 100644 --- a/Architecture.md +++ b/Architecture.md @@ -141,7 +141,7 @@ VisualizationSpec │ empty/no fetch trigger, while server reconstruction may derive. └── state: InteractionState ├── viewport: Dict[str, AxisRange | GeoViewportCoordinates | None] - │ ├── cartesian keys store AxisRange + │ ├── cartesian keys store AxisRange in data units, on a log axis too │ └── `"{figure_uid}/coordinates"` stores raw map corner points │ as `[[lon, lat], ...]` ├── selections: List[SelectionState] @@ -876,7 +876,7 @@ 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. 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 whose range is not in data units (only unset, `-`, `linear` and `date` pass; `log` and `category` do not), 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. 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. **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. @@ -1541,6 +1541,7 @@ When `DashboardSpec.layout.draggable=True`, both adapters render figures as Grid - Supported trace types: **line**, **histogram**, **box**, **bar**, **pie**, **treemap**, **heatmap** (histogram2d / corr_heatmap). - **Per-figure mode toggle** (Zoom | Pan | CF) rendered at top-right of each figure; Plotly modebar hidden entirely (`displayModeBar: false`). Zoom and Pan buttons stay enabled for cartesian and Plotly map figures (geo histogram / geo line) because map `dragmode` drives viewport relayouts; they are disabled for non-navigable figures such as pie, treemap, and corr_heatmap, where CF is active by default. CF sets `dragmode: 'select'` for range-based cross-filter drag. Configurable per-figure default mode is a future TODO. - Events: `plotly_relayout` → viewport (including figure-scoped viewport reset that preserves selections) · `plotly_selected` → selection · `plotly_deselect` → deselect · `plotly_click` → categorical cross-filter (pie and treemap). +- Log axes: Plotly holds a log axis range in log10 units, while `state.viewport` and `client_state.axis_lock_ranges` hold data units, as the server and the selections do. `plotlyRangeToData` and `plotlyRangeFromData` (`plotly/render.js`) convert where the two meet: the relayout handler, the lock capture and the selection band fill (the full axis range of a one-axis selection box) read Plotly ranges, and `syncLayoutViewport` and `fvApplyAxisLocks` write them. The declared axis `type` decides (`isLogAxis`), because the first render writes the layout before Plotly draws the div, and Plotly never infers a log axis. A type on the axis wins over one from the layout template, as in Plotly. The exponent is clamped to the float range, so a far zoom-out never stores `Infinity` (sent as `null`) or 0 (no log). - **Click-based cross-filtering** (`plotly_click`): for pie traces, the clicked slice label is decoded against the source trace's `labels` columns and emitted as one `ClauseFilter` per label column. For treemap, the clicked node's path becomes one `ClauseFilter` per `path` column from leaf depth to root. Clicking an already-selected node (matched structurally by `_selectionMatches`) deselects. Treemap clicks arrive as `plotly_treemapclick`, and `handleClick` returns `false`, which cancels the Plotly drill-in: the treemap stays at its root level. - **Predicate-based selection wire format**: `handleClick` and `handleSelected` build `SelectionPredicate` objects directly from each clicked node, brush rectangle, or geo polygon, reading the source trace's `backend_data` to populate column names. The shared runtime (`runtime.py`) helpers (`selectionSourceFigureUids`, `figureHasSelectionSource`) inspect predicates rather than legacy field names. - Geo viewport relayout uses Plotly map `._derived.coordinates` when available and stores it in shared spec state as `state.viewport["{figure_uid}/coordinates"] = [[lon, lat], ...]`. diff --git a/flexviz/adapters/js/plotly/events.js b/flexviz/adapters/js/plotly/events.js index 20463d3..e83f740 100644 --- a/flexviz/adapters/js/plotly/events.js +++ b/flexviz/adapters/js/plotly/events.js @@ -246,7 +246,9 @@ function handleRelayout(relayout, figUid) { } const complete = {}; for (const [k, v] of Object.entries(ranges)) { - if (v[0] != null && v[1] != null) complete[k] = v; + if (v[0] != null && v[1] != null) { + complete[k] = plotlyRangeToData(figUidToIdx[figUid], plotlyAxisKey(k), v); + } } if (hasAuto && Object.keys(complete).length === 0) { // Per-axis autorange (double-click): clear only the autoranged axes locally diff --git a/flexviz/adapters/js/plotly/render.js b/flexviz/adapters/js/plotly/render.js index 0db1cbe..8537530 100644 --- a/flexviz/adapters/js/plotly/render.js +++ b/flexviz/adapters/js/plotly/render.js @@ -9,6 +9,28 @@ function plotlyAxisId(layoutKey) { return layoutKey.replace(/^(x|y)axis(\d*)$/, '$1$2'); } +// Plotly holds a log axis range in log10 units. Client state holds data units, +// as the server and the selections do. The declared type decides: the first +// render writes the layout before Plotly draws the div, and Plotly never +// infers a log axis. A type on the axis wins over one from the layout +// template, as in Plotly. The exponent is clamped to the float range: 10 ** 309 +// is Infinity, which JSON sends as null, and 10 ** -324 is 0, which has no log. +function isLogAxis(figIdx, layoutKey) { + const layout = layoutsByFig[figIdx]; + const type = layout?.[layoutKey]?.type ?? layout?.template?.layout?.[layoutKey]?.type; + return type === 'log'; +} + +function plotlyRangeToData(figIdx, layoutKey, range) { + return isLogAxis(figIdx, layoutKey) + ? range.map(v => 10 ** Math.min(Math.max(v, -323), 308)) + : range; +} + +function plotlyRangeFromData(figIdx, layoutKey, range) { + return isLogAxis(figIdx, layoutKey) ? range.map(Math.log10) : range; +} + // The number of programmatic Plotly operations in progress, per figure. Plotly // emits an event on the div that an operation changed, so while a figure has // one, its handlers ignore its events. Other figures stay interactive. @@ -43,7 +65,7 @@ window.fvCaptureAxisDisplayRanges = function(figUid, axisFamily) { const axId = plotlyAxisId(layoutKey); const range = axisObj && axisObj.range; if (Array.isArray(range) && range.length === 2) { - out[axId] = [range[0], range[1]]; + out[axId] = plotlyRangeToData(figIdx, layoutKey, [range[0], range[1]]); } } return out; @@ -63,7 +85,7 @@ window.fvApplyAxisLocks = function(figUid, changedAxisId) { for (const [axId, range] of Object.entries(ranges)) { if (!/^(x|y)\d*$/.test(axId)) continue; const key = plotlyAxisKey(axId); - update[key + '.range'] = range; + update[key + '.range'] = plotlyRangeFromData(figIdx, key, range); update[key + '.autorange'] = false; } const changedFamily = String(changedAxisId || '').charAt(0); @@ -88,11 +110,15 @@ function _axisRangeForSelectionBox(figUid, axisProp) { && divs[figIdx]._fullLayout && divs[figIdx]._fullLayout[axisKey] && divs[figIdx]._fullLayout[axisKey].range; - if (Array.isArray(fullRange) && fullRange.length === 2) return fullRange; + if (Array.isArray(fullRange) && fullRange.length === 2) { + return plotlyRangeToData(figIdx, axisKey, fullRange); + } const layoutRange = layoutsByFig[figIdx] && layoutsByFig[figIdx][axisKey] && layoutsByFig[figIdx][axisKey].range; - if (Array.isArray(layoutRange) && layoutRange.length === 2) return layoutRange; + if (Array.isArray(layoutRange) && layoutRange.length === 2) { + return plotlyRangeToData(figIdx, axisKey, layoutRange); + } return null; } @@ -313,7 +339,7 @@ function syncLayoutViewport(figUid) { for (const [axId, range] of Object.entries(cartesianRanges)) { const key = plotlyAxisKey(axId); if (!layout[key]) layout[key] = {}; - layout[key].range = range; + layout[key].range = plotlyRangeFromData(figIdx, key, range); layout[key].autorange = false; } } diff --git a/flexviz/spec.py b/flexviz/spec.py index 15dfd58..ab5a3b0 100644 --- a/flexviz/spec.py +++ b/flexviz/spec.py @@ -610,10 +610,15 @@ def _check_axis_links(self) -> DashboardSpec: "categorical traces and maps cannot be linked)" ) axis = _layout_axis(figure, axis_id) - if axis.get("type") not in _LINKABLE_AXIS_TYPES: + axis_type = axis.get("type") + if axis_type not in _LINKABLE_AXIS_TYPES: + reason = ( + "log axes are not linkable yet" + if axis_type == "log" + else "its range is in positions, not data values" + ) raise ValueError( - f"{axis.get('type')} axis {key!r} cannot be linked: its " - "range is not in data units" + f"{axis_type} axis {key!r} cannot be linked: {reason}" ) is_reversed[key] = _axis_reversed(axis) for rule, value_of in ( @@ -628,8 +633,10 @@ def _check_axis_links(self) -> DashboardSpec: return self -# Plotly axis types whose range is in data units. A log range is in log10 -# units and a category range in positions, so copying one would be wrong. +# Plotly axis types the client can link. A category range is in positions, so +# copying one would be wrong. A log axis is not linkable yet: a linear member of +# its group can zoom to a range at or below 0, which a log axis cannot show, and +# a group of log axes only is not designed yet. _LINKABLE_AXIS_TYPES = (None, "-", "linear", "date") diff --git a/tests/test_browser.py b/tests/test_browser.py index f83705a..1c3ffe2 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -7961,3 +7961,167 @@ def test_overlay_cross_filter_keeps_both_layers_in_log_space( else: assert (fg["zmin"], fg["zmax"]) == pytest.approx((0.0, 3.0)) assert fg["colorbar"]["ticktext"] == ["1", "10", "100", "1K"] + + +@pytest.mark.browser +class TestLogAxisBrowser: + """Plotly holds a log axis range in log10 units. Client state holds data units.""" + + # x from 1 to 1e6 on a log grid. + _DF = pl.DataFrame( + { + "x": [10 ** (6 * i / 999) for i in range(1000)], + "y": [float(i) for i in range(1000)], + } + ) + + @staticmethod + def _build(dash) -> None: + dash.add_figure().add_line(x="x", y="y", n_points=100).update_layout( + xaxis={"type": "log"} + ) + + @staticmethod + def _wait_for_data_inside(page: Page, lo: float, hi: float) -> None: + page.wait_for_function( + """([lo, hi]) => { + const xs = divs[0].data[0].x.filter(v => v != null); + return xs.length && Math.min(...xs) >= lo && Math.max(...xs) <= hi; + }""", + arg=[lo, hi], + timeout=10_000, + ) + + @staticmethod + def _shown_range(page: Page) -> list[float]: + return page.evaluate("() => divs[0]._fullLayout.xaxis.range") + + @pytest.mark.parametrize( + "layout", + [ + {"xaxis": {"type": "log"}}, + {"template": {"layout": {"xaxis": {"type": "log"}}}}, + ], + ids=["axis_type", "template"], + ) + def test_drag_zoom_keeps_the_viewport_in_data_units( + self, page: Page, server_port: int, layout: dict + ): + page.goto( + _color_norm_url( + server_port, + "_browser_log_axis_zoom", + self._DF, + lambda dash: ( + dash.add_figure() + .add_line(x="x", y="y", n_points=100) + .update_layout(**layout) + ), + ) + ) + _wait_for_init(page, "plotly") + + box = page.locator("#fv-plot-0 .nsewdrag").bounding_box() + assert box is not None + y = box["y"] + box["height"] * 0.5 + page.mouse.move(box["x"] + box["width"] * 0.3, y) + page.mouse.down() + page.mouse.move(box["x"] + box["width"] * 0.6, y, steps=20) + page.mouse.up() + page.wait_for_function( + "() => DASHBOARD_SPEC.state.viewport[DASHBOARD_SPEC.figures[0].uid + '/x']" + ) + + # Plotly shows a log axis range in log10 units. + shown = self._shown_range(page) + [viewport] = page.evaluate("() => flexvizState().state.viewport").values() + lo, hi = viewport["min"], viewport["max"] + assert (lo, hi) == pytest.approx([10**v for v in shown]) + self._wait_for_data_inside(page, lo, hi) + # The redraw with the new data writes the viewport back in log10 units. + assert self._shown_range(page) == pytest.approx(shown) + + def test_far_zoom_out_sends_finite_bounds(self, page: Page, server_port: int): + page.goto( + _color_norm_url(server_port, "_browser_log_axis_far", self._DF, self._build) + ) + _wait_for_init(page, "plotly") + + # 10 ** 320 is Infinity in a double, which JSON sends as null. + with page.expect_response("**/dashboard/update") as response: + page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [-5, 320]})") + assert response.value.ok + + def test_saved_viewport_opens_at_its_range(self, page: Page, server_port: int): + from flexviz.dashboard import Dashboard + from flexviz.server import register_source + from flexviz.spec import AxisRange, encode_spec + + register_source("_browser_log_axis_saved", self._DF) + dash = Dashboard(self._DF) + self._build(dash) + spec = dash.to_spec(source_name="_browser_log_axis_saved") + spec.state.viewport[f"{spec.figures[0].uid}/x"] = AxisRange( + min=100.0, max=10_000.0 + ) + page.goto( + f"http://127.0.0.1:{server_port}/view" + f"?spec={encode_spec(spec)}&renderer=plotly" + ) + _wait_for_init(page, "plotly") + + self._wait_for_data_inside(page, 99, 10_001) + assert self._shown_range(page) == pytest.approx([2, 4]) + + def test_lock_keeps_its_range_in_data_units(self, page: Page, server_port: int): + page.goto( + _color_norm_url( + server_port, "_browser_log_axis_lock", self._DF, self._build + ) + ) + _wait_for_init(page, "plotly") + page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [2, 4]})") + self._wait_for_data_inside(page, 99, 10_001) + + page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']") + lock = page.evaluate("""() => flexvizState().client_state.axis_lock_ranges[ + DASHBOARD_SPEC.figures[0].uid + '/x']""") + assert (lock["min"], lock["max"]) == pytest.approx((100.0, 10_000.0)) + + # A locked axis snaps back to its lock range after a double-click and + # after a global reset. + for act in [ + "() => Plotly.relayout(divs[0], {'xaxis.autorange': true})", + "() => fvOnReset()", + ]: + page.evaluate(act) + page.wait_for_timeout(800) + assert self._shown_range(page) == pytest.approx([2, 4]), act + self._wait_for_data_inside(page, 99, 10_001) + + def test_selection_band_spans_a_log_count_axis(self, page: Page, server_port: int): + page.goto( + _color_norm_url( + server_port, + "_browser_log_axis_band", + self._DF, + lambda d: ( + d.add_figure() + .add_histogram(x="x", bins=20) + .update_layout(yaxis={"type": "log"}) + ), + ) + ) + _wait_for_init(page, "plotly") + page.evaluate("""() => flexvizApply({state: {selections: [{ + source_figure_uid: DASHBOARD_SPEC.figures[0].uid, + predicates: [{clauses: [{column: 'x', range: [100000, 500000]}]}], + }]}})""") + shown = page.evaluate("""() => ({ + box: divs[0]._fullLayout.selections[0], + yrange: divs[0]._fullLayout.yaxis.range, + })""") + # An x-only selection is a band: Plotly places a selection in data units. + assert [shown["box"]["y0"], shown["box"]["y1"]] == pytest.approx( + [10**v for v in shown["yrange"]] + ) diff --git a/tests/test_spec.py b/tests/test_spec.py index 06ed249..b863f98 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -311,7 +311,7 @@ def test_log_axis_is_rejected(self): data = _linked_dashboard(xaxis={"type": "log"}) a, b, *_ = _uids(data) data["client_state"]["axis_links"] = [[f"{a}/x", f"{b}/x"]] - with pytest.raises(ValidationError, match="log axis"): + with pytest.raises(ValidationError, match="log axes are not linkable yet"): DashboardSpec.model_validate(data) @pytest.mark.parametrize( @@ -333,7 +333,7 @@ def test_mixed_reversed_axes_are_rejected(self, xaxis): DashboardSpec.model_validate(data) @pytest.mark.parametrize("axis_type", ["log", "category", "multicategory"]) - def test_axis_types_outside_data_units_are_rejected(self, axis_type): + def test_unlinkable_axis_types_are_rejected(self, axis_type): data = _linked_dashboard(xaxis={"type": axis_type}) a, b, *_ = _uids(data) data["client_state"]["axis_links"] = [[f"{a}/x", f"{b}/x"]]