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
2 changes: 1 addition & 1 deletion Architecture.md
Original file line number Diff line number Diff line change
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 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); 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
3 changes: 2 additions & 1 deletion docs/guides/cross-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ Rules and limits:
numeric column on an axis set to `type="date"` cannot be linked, and date and
linear axis types do not mix. A reversed axis links only to reversed axes.
- Locking the axes of one figure locks every axis linked to them, at the range
that figure shows.
that figure shows. A linked figure that showed another range re-aggregates at
the locked range.
- After a double-click autorange, each figure autoranges to its own data, so
linked ranges can differ until the next zoom. In update mode a figure
filtered by another figure's selection fits its filtered rows, while the
Expand Down
40 changes: 34 additions & 6 deletions flexviz/adapters/js/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,23 +145,42 @@ function fvTryLockAxis(figUid, axisId) {
window.fvStoreAxisLockRanges(figUid, ranges);
return ranges;
}
// Figures linked on one column rarely autorange to bit-identical bounds (a line
// shows [3e-13, 9999.0000000001] where a histogram shows [0, 9999]), so numeric
// bounds within 1e-9 of the span count as equal. Date strings compare exactly.
function fvSameRange(a, b) {
if (JSON.stringify(a) === JSON.stringify(b)) return true;
if (!a || !b || ![...a, ...b].every(v => typeof v === 'number')) return false;
const tolerance = 1e-9 * Math.abs(b[1] - b[0]);
return Math.abs(a[0] - b[0]) < tolerance && Math.abs(a[1] - b[1]) < tolerance;
}
// Lock an axis and every axis linked to it at the range this figure shows. A
// group keeps one range (the spec validator rejects unequal lock ranges), so a
// member that autoranged to other data takes this figure's range. Returns the
// other figures whose display changed.
// member that autoranged to other data takes this figure's range. The server
// aggregated that member for its own range, so the group range also becomes
// the viewport of the group. A group whose members already show one range
// stays display-only: a viewport at Plotly's padded autorange can change the
// bins. Returns the other figures whose display changed and the viewport keys
// written.
function fvLockAxisGroup(figUid, axisId) {
const ranges = fvTryLockAxis(figUid, axisId);
if (!ranges) return [];
if (!ranges) return { others: [], written: [] };
const range = ranges[axisId];
const others = [];
let apart = false;
for (const key of fvLinkedKeys(figUid + '/' + axisId)) {
const [memberFigUid, memberAxisId] = key.split('/');
if (memberFigUid === figUid) continue;
const shown = (window.fvCaptureAxisDisplayRanges?.(memberFigUid, memberAxisId) || {})[memberAxisId];
if (!fvSameRange(shown, range)) apart = true;
window.fvSetAxisLocked(memberFigUid, memberAxisId, true);
if (range) window.fvStoreAxisLockRanges(memberFigUid, { [memberAxisId]: range });
others.push(memberFigUid);
}
return others;
const written = apart && range
? fvWriteViewport(figUid + '/' + axisId, { min: range[0], max: range[1] })
: [];
return { others, written };
}
window.fvOnToggleAxisLocks = async function(figUid) {
if (!figUid) return;
Expand All @@ -178,9 +197,12 @@ window.fvOnToggleAxisLocks = async function(figUid) {
)];
const touchedFigUids = fvFiguresOfKeys(keys);
const moved = new Set();
const written = [];
if (shouldLock) {
for (const axisId of availableAxes) {
fvLockAxisGroup(figUid, axisId).forEach(other => moved.add(other));
const locked = fvLockAxisGroup(figUid, axisId);
locked.others.forEach(other => moved.add(other));
written.push(...locked.written);
}
} else {
for (const key of keys) {
Expand All @@ -195,6 +217,7 @@ window.fvOnToggleAxisLocks = async function(figUid) {
}
await Promise.all([...moved].map(touched => window.fvApplyAxisLocks?.(touched)));
window.fvUpdateLockAllAxesButton?.();
await fvCommitViewportChange(figUid, written);
};
// ── Global "Lock All Axes" toolbar button ──────────────────────────────────
// Returns true only when every figure with lockable axes is fully locked.
Expand Down Expand Up @@ -226,15 +249,18 @@ window.fvOnLockAllAxes = async function() {
if (!uids.length) return;
const shouldLock = !window.fvAreAllFiguresLocked();
const applyPromises = [];
const written = [];
for (const figUid of uids) {
const availableAxes = fvCurrentLockableAxes(figUid);
if (!availableAxes.length) continue;
if (shouldLock) {
for (const axisId of availableAxes) {
// An earlier group member already locked this axis at the group range.
if (window.fvIsAxisLocked(figUid, axisId)) continue;
fvLockAxisGroup(figUid, axisId)
const locked = fvLockAxisGroup(figUid, axisId);
locked.others
.forEach(other => applyPromises.push(Promise.resolve(window.fvApplyAxisLocks?.(other))));
written.push(...locked.written);
}
window.fvUpdateAxisLockButtons?.(figUid);
window.fvSyncFigureModeForAxisLocks?.(figUid);
Expand All @@ -249,6 +275,8 @@ window.fvOnLockAllAxes = async function() {
}
await Promise.all(applyPromises);
window.fvUpdateLockAllAxesButton?.();
// One request for every group the locks wrote.
await fvCommitViewportChange(null, written);
};

// Per-figure reset. Resets ONLY this figure: clears its viewport (zoom) and
Expand Down
114 changes: 114 additions & 0 deletions tests/test_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6916,6 +6916,33 @@ def _dashboard_url_linked(
}))"""


def _dashboard_url_linked_pairs(
port: int, pairs: list[tuple[str, str]]
) -> tuple[str, list[str]]:
"""Pairs of lines, each pair linked on x. ``short`` spans 0..999 and
``long`` 0..9999, so a pair on both autoranges apart."""
from flexviz.dashboard import Dashboard
from flexviz.server import register_source
from flexviz.spec import encode_spec

df = pl.DataFrame({"long": range(10_000)}).with_columns(
short=pl.col("long") // 10, val=pl.col("long").cast(pl.Float64)
)
register_source("_browser_linked_pairs", df)
dash = Dashboard(df)
for x_a, x_b in pairs:
a = dash.add_figure()
a.add_line(x=x_a, y="val", n_points=200)
b = dash.add_figure()
b.add_line(x=x_b, y="val", n_points=200)
dash.link_axes(a, b, axis="x")
spec = dash.to_spec(source_name="_browser_linked_pairs")
return (
f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}&renderer=plotly",
[f.uid for f in spec.figures],
)


@pytest.mark.browser
class TestLinkedAxesBrowser:
@staticmethod
Expand Down Expand Up @@ -7149,6 +7176,93 @@ def test_lock_all_keeps_linked_groups_valid(self, page: Page, server_port: int):
page.wait_for_timeout(1_000)
assert statuses == [200]

def test_lock_aggregates_a_member_at_the_range_it_shows(
self, page: Page, server_port: int
):
url, uids = _dashboard_url_linked_pairs(server_port, [("short", "long")])
posts = self._open(page, url)

page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']")
page.wait_for_timeout(1_500)

# B takes A's range, and the server re-aggregates B for that range.
window = page.evaluate("""() => {
const gd = divs[1];
const [lo, hi] = gd._fullLayout.xaxis.range;
const xs = gd.data[0].x;
return {hi, n: xs.length, inside: xs.filter(x => lo <= x && x <= hi).length};
}""")
assert window["hi"] < 1_000, window
assert window["n"] > 100 and window["inside"] == window["n"], window
assert len(posts) == 1, [p["event"] for p in posts]
assert posts[0]["event"]["viewport_keys"] == [f"{uids[0]}/x", f"{uids[1]}/x"]
viewport = page.evaluate("DASHBOARD_SPEC.state.viewport")
ranges = page.evaluate("DASHBOARD_SPEC.client_state.axis_lock_ranges")
assert viewport[f"{uids[1]}/x"] == ranges[f"{uids[1]}/x"], (viewport, ranges)

def test_lock_all_sends_one_request_for_every_group(
self, page: Page, server_port: int
):
url, uids = _dashboard_url_linked_pairs(
server_port, [("short", "long"), ("short", "long")]
)
posts = self._open(page, url)

page.click("#fv-btn-lock-all")
page.wait_for_timeout(1_500)

assert len(posts) == 1, [p["event"] for p in posts]
assert sorted(posts[0]["event"]["viewport_keys"]) == sorted(
f"{uid}/x" for uid in uids
)

def test_lock_on_members_that_show_one_range_posts_nothing(
self, page: Page, server_port: int
):
url, _ = _dashboard_url_linked_pairs(server_port, [("long", "long")])
posts = self._open(page, url)

page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']")
page.wait_for_timeout(1_000)

assert posts == []
assert page.evaluate("DASHBOARD_SPEC.state.viewport") == {}

def test_lock_on_a_line_and_histogram_of_one_column_posts_nothing(
self, page: Page, server_port: int
):
from flexviz.dashboard import Dashboard
from flexviz.server import register_source
from flexviz.spec import encode_spec

# The line and the histogram autorange to ranges that differ by float
# noise only, so the lock has nothing to fix.
df = pl.DataFrame({"long": range(10_000)}).with_columns(
val=pl.col("long").cast(pl.Float64)
)
register_source("_browser_linked_hist", df)
dash = Dashboard(df)
dash.add_figure().add_line(x="long", y="val", n_points=200)
dash.add_figure().add_histogram(x="long", bins=50)
dash.link_axes(on="long")
spec = dash.to_spec(source_name="_browser_linked_hist")
posts = self._open(
page,
f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
"&renderer=plotly",
)
shown = page.evaluate(
"[...document.querySelectorAll('.js-plotly-plot')]"
".map(gd => gd._fullLayout.xaxis.range)"
)
assert shown[0] != shown[1], shown

page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']")
page.wait_for_timeout(1_000)

assert posts == []
assert page.evaluate("DASHBOARD_SPEC.state.viewport") == {}

def test_overlay_owner_in_the_group_gets_its_background(
self, page: Page, server_port: int
):
Expand Down
Loading