Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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], ...]`.
Expand Down
4 changes: 3 additions & 1 deletion flexviz/adapters/js/plotly/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions flexviz/adapters/js/plotly/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
Expand Down
17 changes: 12 additions & 5 deletions flexviz/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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")


Expand Down
Loading
Loading