Crossfilter
Why everything that narrows the page is a filter — one clause on one page selection, the timeline and the canvas included — why "kept" is decided once and only drawn differently per view, why a position is an encoding and "no position" a state of its own, and what that changes in the graph, the timeline and the gestures.
Decided 11 October 2026. The thesis is approved, with the decisions at the end of the page. It is being implemented in the migration order below, one pull request per step; Step 3 is designed in full here and Status says where each step is. The page maps how the page filtered before, names every place where two parts disagreed, and states the abstraction that replaces them.
The thesis: everything is a filter
A lasso, a click, a marquee, a chart's pick or brush, a search, the timeline's window, a filter bar control and a rule's "Show" are all the same thing: one clause, from one source, on the page's one selection. No source is special, and none decides on its own what is kept. The clauses intersect, Mosaic's model, which #112 chose: every interactor publishes one clause from one source, and a crossfilter is their conjunction (Mosaic, Selection).
What is kept is one predicate, derived once, and every view reads it. Views differ only in how they draw what is not kept, never in deciding it:
| View | Kept | Not kept |
|---|---|---|
| Canvas | in colour | greyed, for context |
| Chart, figure | the aggregate over what is kept | the total behind it, in grey |
| List, table, Ask's answer | the rows | absent |
A position is an encoding, not a filter. x and y place a vertex the way colour paints it
(Position is a channel). A vertex with no position has
not been filtered out; it has nowhere to be drawn. That is a state of its own, and it is shown as
one.
How the page filtered before
Every source already publishes into a Mosaic Selection, and most consumers read it. The paths
differ, though, and that is where the parts disagree.
| Source | Publishes | Form | Into |
|---|---|---|---|
| Canvas click, marquee, lasso | publish(), core/client.ts:110 | key IN (ids) | the page, from the graph's own client, which is exempt from it |
| Search | publish() | key IN (ids) | the page |
| Dashboard tile pick, brush; filter bar controls | the tile's clause | column ("Person.country" = …) | the dashboard's own crossfilter, bridged to the page by semiJoinOf(rootKey, relation) (keasy dashboard-view.tsx:50) |
| Timeline window | ChartTimeline's brush | column (date BETWEEN …) | the timeline's own crossfilter, bridged by semiJoinOf(key, timeline) (parts/graph-timeline.tsx:39-40, core/source.ts:371-377) |
| Rules "Show N", "Show what conforms" | keasy rules-validation.ts:210,246 | column (subject IN (SELECT …)) | the page |
| Ask's answer | key IN (…) | identity | the page |
| Map placement | not a clause | — | x/y bindings; a vertex missing either is NaN (render/placement.ts:29) |
| Consumer | Reads |
|---|---|
| Canvas colour | highlighted() → visibleOf(mask, pick) (render/motion.ts:34) |
| Counts, legend | litOf(mask, pick) → drawnOf (core/kept.ts:56, since #167) |
Toolbar, frame() | store.visible() → visibleOf |
| Search results | its own inside, by the clause rule (core/source.ts:266-290) |
| Timeline bars | the page minus its own clause (crossfilter exemption), clipped to the window |
| Dashboard tiles | the page minus their own clauses |
| Ask | the page's clauses, skipping those its relation cannot answer, and saying so |
Where the parts disagree
-
"Kept" is computed in more than one place. The store holds
mask, the page without the canvas's own clause, because Mosaic exempts a client from the clause it publishes. ThenvisibleOf(canvas, toolbar,frame()) andlitOf(counts, legend) each narrow it to the pick again (core/kept.ts:34-64): two functions for one answer. Until #167 the counts readmaskalone and said 40 match while the toolbar said 31. The design page already claims "what is in full colour is computed once"; the code has drifted from it. -
A clause a type cannot answer means two opposite things. In column form, a vertex table without the column is not filtered by it and stays in colour (
core/client.ts:73-87, the clause rule,core/source.ts:252-257). In semi-join form, the same type is not kept and is greyed (core/source.ts:360-365). The timeline uses the second on purpose (why); a column clause published straight to the page (the rules'subject IN …, any host's raw clause) uses the first. Two chips that look alike in the bar grey different things. -
The timeline and the dashboard are islands. Each owns a crossfilter and a bridge; the graph's search and the canvas publish straight to the page; the rules publish a column clause to the page. Four routes into one selection. The timeline is the same mechanism as a dashboard tile, with its own copy of the wiring (
parts/graph-timeline.tsx:39-40). -
The search decides "kept" itself. It evaluates the subset with its own
inside, by the column rule, so a table that answers none of the clauses is "wholly inside" (core/source.ts:281-282): divergence 2 again, in a third place. -
Ask and the dashboard answer the same clause differently. A dashboard over a relation that cannot answer the timeline's window shows no rows; Ask skips the clause and says it did. One of them is drawing "not kept" and the other is deciding it.
-
⌘ and Alt copy other sources' result into the canvas's clause. With no canvas clause, a modified gesture starts from
getVisible()(parts/gesture.ts:158), so the canvas's clause becomes "what the search kept, less Madrid": the canvas computes the other sources' result and stores it as its own. It reads right while those sources hold, and goes stale when one changes. -
What a filter greys is drawn so faint it reads as hidden. Points greyed out are drawn at
pointGreyoutOpacity: 0.1and links at0.025(render/graph-model.ts:132-133). On a map, with small marks on a light background, a greyed point is close to invisible. See below. -
The labels read the filter without the canvas's clause.
topOfandlabelledare handed the store'smask(parts/graph-canvas.tsx:66,94,98), so under a lasso a vertex outside it can still be labelled while it is drawn greyed. -
"Match" counted vertices that are not drawn. On a map, the counts read N match · P of T placed with
N = |kept|, and the toolbar|visible|, both including kept vertices with no position, while the edges count only placed ends (core/kept.ts). On the Nobel map, a window counted prizes with no affiliation that are nowhere on the canvas. Neither count is drawn now: what the filters keep is theFilterBarreadout's, over the page's relation. -
An empty set means opposite things.
clauseSemiJoin(key, [])keeps nothing; the graph'spublishturns no ids into a withdrawal (core/client.ts:110-120), which keeps everything. The property tests found it: a source that publishes an empty pick through one constructor or the other changes the page in opposite directions.
Does a filter hide anything under Map placement?
No. Only a missing position hides. drawnOf (core/kept.ts:68-96) and the renderer agree: a
vertex whose x or y is NaN is not drawn and is counted under "No position"; every other vertex
is drawn, in colour when kept and greyed when not. A range on lat from the filter bar greys the
airports outside it; it does not unplace them, because positions are bound once from the columns
and never from the filtered rows.
What makes it look like hiding is real, though, and it is two things together:
- The greyout is a tenth. At 0.1 opacity a greyed point on a light map is nearly invisible, so a window over 1900–1960 on the Nobel map seems to remove every other prize.
- The timeline greys every type without the column. On the Nobel map, universities have no date, so with a window set they are all greyed (0 of 370), and at 0.1 they vanish with the rest.
So the behaviour is already one rule. The picture does not show it.
Formal semantics
The words above are defined here once, and every rule and test on this page refers to these definitions. They are Mosaic's model, written out (Heer & Moritz, Mosaic: An Architecture for Scalable & Interoperable Data Views, IEEE TVCG 2024 (VIS 2023); Selection), with the two rules this page adds: what a clause means for a type that cannot answer it, and position.
Vertices and types. The corpus is a finite set of vertices V, partitioned into types; every vertex v belongs to exactly one type T(v), a table with a set of columns cols(T). Each vertex has a key k(v), unique in V.
Clauses. A clause c is a predicate over the rows of a table, naming a set of columns
cols(c). It is evaluated as SQL WHERE evaluates it, so a comparison with NULL is unknown and
an unknown row is not kept. An identity clause names only the key: k IN (…), k NOT IN (…) or
k IN (SELECT …), whatever relation its subquery reads.
Answerability. A type T answers c when cols(c) ⊆ cols(T). An identity clause is answered by every type.
The page. Each source s holds at most one clause, c_s; publishing again replaces it, and
only null withdraws it. A clause over an empty set of keys is a clause, and keeps nothing. The page's selection is the set C = { c_s } — Mosaic's one clause per source,
resolved by conjunction (intersect).
Kept. For a clause and a vertex,
ans(c, v) = c(v) if T(v) answers c, and false otherwise,
and
kept_C(v) = ⋀_{c ∈ C} ans(c, v), with kept_∅(v) = true.
The second case of ans is the rule decided here: a clause a type cannot answer keeps none of that
type. It is what Cosmograph's isBetween(column, range) does to a point with no value
(Timeline), and what a dashboard's relation already does with a semi-join
on its root: a client applies every clause it is handed.
A client's own view. A chart that publishes from source s and is filtered by the same selection draws against
kept_C^¬s(v) = ⋀_{c ∈ C, c ≠ c_s} ans(c, v)
— Mosaic's crossfilter resolution, which omits the clauses a client published, and crossfilter.js's groups, which ignore their own dimension's filter (API reference). It is defined only to say how such a chart draws its context under its own brush. It is never exposed as what is kept: every count, colour, label and camera reads kept_C, and kept_C = kept_C^¬s ∧ ans(c_s, ·).
Placed. Under bound positions, with columns x and y,
placed(v) = x(v) is a finite number ∧ y(v) is a finite number,
and under a layout placed(v) = true. placed does not depend on C.
What each view draws. The canvas draws v in colour when kept_C(v) ∧ placed(v), greyed when ¬kept_C(v) ∧ placed(v), and not at all when ¬placed(v). A link (u, w) is kept when kept_C(u) ∧ kept_C(w) and drawn when placed(u) ∧ placed(w). A chart draws its aggregate over kept_C^¬s — over kept_C when it published nothing — with its total behind in grey; a list or a table holds the rows of kept_C. This is Vega-Lite's model of a selection as a conditional encoding over the same data, not a second dataset (selections).
Invariants
Each is a property of the definitions, and each has a test that checks it from the predicate and the data, never from pixels or text.
- Order does not matter. kept_C depends on the set C only: ∧ is commutative and associative, so publishing the same clauses in any order, or replacing a clause and putting it back, gives the same kept.
- Withdrawing never narrows; adding never widens. C′ ⊆ C ⇒ kept_C ⊆ kept_C′.
- One clause per source. Publishing c′_s gives kept_{(C ∖ {c_s}) ∪ {c′_s}}: the source's earlier clause has no effect left.
- No filter moves a vertex out of place. placed is the same for every C.
- One reading. The canvas's colour, the labels, the toolbar,
frame()and the counts all read kept_C; no consumer reads a mask that leaves a source out. - The counts. On a map, match = |kept_C ∧ placed|, placed = |placed|, total = |V|; edges kept = |{(u, w) : kept_C(u) ∧ kept_C(w) ∧ placed(u) ∧ placed(w)}|. Under a layout, placed = V.
- The frame.
frame()fits exactly { v : kept_C(v) ∧ placed(v) }, or every placed vertex when C = ∅. - The exemption is drawing only. For every source s, kept_C ⊆ kept_C^¬s, and no consumer other than s's own chart reads kept_C^¬s.
- Alt with no canvas clause. Alt over a set X sets c_canvas = k ∉ X, so kept becomes kept_C ∧ ¬X, and stays equal to (the other clauses) ∧ ¬X when another source changes — invariant 1 and 3, not a copy of another source's result.
What a number on this page must come from
No value here is chosen by eye. Each is a cited default or follows from a stated criterion:
-
Greyed colour: cosmos.gl 3.4.0's, read from its shader (
dist/index.js:5239-5256): with nopointGreyoutColor, mix(colour, 0.2, 0.65) on a background of luminance below 0.65 (index.js:141) and mix(colour, max(background, 0.8), 0.65) otherwise. -
Greyed opacity: derived per theme from the criterion below (The greyout); links at cosmos.gl's default,
linkGreyoutOpacity = 0.1. -
The criterion they are checked against, WCAG 2.2 SC 1.4.11 Non-text Contrast, with contrast ratios computed from relative luminance as WCAG defines them:
- a kept mark carries the information, so each category colour against the background is at least 3 : 1;
- kept versus not kept is what a filter's result is read from. 1.4.11 asks 3 : 1 between the states of a user interface component; a mark on the canvas is not a component, so this is our criterion by extension, not WCAG's requirement: each kept colour against its own greyed colour is at least 3 : 1;
- a greyed mark is context, not information required to understand the filter, so 1.4.11 does not require 3 : 1 against the background; it is required to be distinguishable from the background, a ratio above 1 : 1, so that greyed never reads as hidden.
The derivation maximises the opacity under the second; the third is then checked, not assumed — an opacity of 0 would fail it — and holds on every shipped theme (1.014 : 1 at least). The checks are unit tests over the eight themes' tokens, and The greyout reports every ratio.
The abstraction
One selection, one route in
Every source publishes one clause on the page's one selection, on the relation it reads. A
client of the same relation reads it as published; a client of another reads it crossed into
identity form, key IN (SELECT key FROM <relation> WHERE …), by the page's resolver — the timeline,
a dashboard and the canvas alike, with no route of their own (Step 3).
The timeline stops being a special path: it is ChartTimeline over vertices.
A clause a type cannot answer keeps none of that type, everywhere. It is the timeline's rule and
Cosmograph's (a point with no value answers isBetween false and is greyed,
Timeline). GraphClient asks only the vertex tables that answer every
clause, under all of them, and the search's inside reads the same rule (#174); a clause on another
relation reaches the graph already crossed by key, which every vertex table answers. The rule is held
per table rather than by the NULL a type without the column holds in the vertices union: a
clause that tests for NULL would keep that type, and a column whose type differs across tables is
not in the union at all.
One "kept", derived once
The store exposes one kept(); nothing else reads mask. It is the page's predicate with every
clause in it, the canvas's own included. Mosaic's exemption is how a chart draws its own brush
(a dimension's group ignores its own filter, as
crossfilter.js
does, so the bars it is brushing do not vanish under the brush). It is a drawing choice of that
view. The canvas does not need it: its pick is drawn in colour exactly because it is kept. Whether
the canvas's clause is folded in by the coordinator or as a local bitmask is an implementation
detail; there is one function, and the canvas, the counts, the legend, the toolbar, frame() and
the search's results all call it. visibleOf and litOf become that one function.
One visibility rule
| State | Canvas | Counts and legend |
|---|---|---|
| kept | colour | N match |
| not kept | greyed | the rest of of M |
| no position (Map placement only) | not drawn | placed / No position, apart from any filter |
"No position" stays hidden on the canvas and explicit everywhere else. cosmos.gl draws nothing
for a NaN position, Vega-Lite drops invalid x/y from a continuous scale by default
(invalid), and Kepler.gl does not plot
rows without coordinates. What we add is that it is never mistaken for a filter: the legend's
No position group and the counts' placed already say it (#156); a filter never moves a vertex
into it, and it is counted apart from what a filter keeps. The alternative, parking unplaced vertices
in a margin strip, is listed under decisions.
The verbs act on their own source's clause, nothing more
| Verb | Acts on |
|---|---|
| drag, click | the canvas's clause becomes what it caught |
| ⌘/Ctrl + drag | the canvas's clause, with what it caught added; with no clause, what it caught |
| Alt + drag | the canvas's clause, less what it caught; with no clause, a clause key NOT IN (caught) |
| Esc | a drag in progress, then the tool, then the canvas's clause |
| a chip's × | that chip's clause |
| Clear | every clause |
frame() | what is kept |
The one change is the second half of ⌘ and Alt. Today they start from what is in full colour
(divergence 6). Under the thesis, Alt over a search's Spain publishes not Madrid from the canvas,
which intersects with Spain and reads the same, Spain less Madrid, but stays right when the
search changes, because the canvas never copied it. ⌘ with no canvas clause adds nothing a filter
holds back, as today: a filter filters, and GraphCounts' N hidden by other filters says why.
The greyout
Decided 11 October 2026: each theme derives its own greyout opacity from its tokens — the largest that keeps every kept colour at 3 : 1 from itself greyed out. It replaces cosmos.gl's defaults, which measured 2.03 – 2.65 : 1 on the shipped themes.
The model. For a colour c the canvas paints and the background b, both sRGB in 0..1:
- g(c), cosmos.gl's greyed colour (3.4.0,
dist/index.js:5239-5256, nopointGreyoutColor): mix(c, (0.2, 0.2, 0.2), 0.65) when cosmos.gl's luminance of b, 0.2126 b_r + 0.7152 b_g + 0.0722 b_b on the 0..1 channels, is below 0.65 (index.js:141), else mix(c, max(b, 0.8), 0.65). - d_α(c) = α · g(c) + (1 − α) · b per channel — the greyed mark at opacity α composited over the background, one mark over the page — each channel then rounded to a byte, as the screen shows it.
- L(x), WCAG's relative luminance: each channel linearised, x/12.92 if x ≤ 0.04045, else ((x + 0.055)/1.055)^2.4, then 0.2126 R + 0.7152 G + 0.0722 B; CR(x, y) = (max L + 0.05) / (min L + 0.05).
The criterion. P(α): for every painted colour c with CR(c, b) ≥ 3, CR(c, d_α(c)) ≥ 3
(WCAG 2.2 SC 1.4.11, by extension to the kept/greyed state). A colour under 3 : 1 to the background
fails 1.4.11 as a kept mark at every opacity; the theme gate already holds every shipped theme's
--chart-1…8 at 3 : 1 to --background (themes.test.ts), and such a colour is left out of P.
The opacity. α* = max { α ∈ {0, 0.001, …, 1} : P(α′) for every α′ ≤ α }. P(0) holds, since d_0(c) = b. The search walks up from 0 and stops before the first opacity that fails, so every smaller opacity holds P too; it does not assume CR(c, d_α(c)) is monotone in α, which it need not be, since the channels move towards g(c) by different amounts. With no colour left in P, there is no distinction to protect and cosmos.gl's default, full opacity, stands.
At runtime, not at build. The canvas resolves its colours from the live theme's tokens when it
paints (paint), and α* is computed from those colours and that background in appearance — a
theme switched at runtime, or a host's own theme, gets its own value; a table built from the eight
shipped themes would cover neither. It costs at most 1,000 × the painted colours luminance pairs per
paint.
Measured on the shipped themes (the eight category colours and --muted-foreground):
| Theme | α* | kept : background | kept : greyed | greyed : background |
|---|---|---|---|---|
| Catppuccin Latte | 0.049 | 3.06 | 3.010 | 1.014 |
| Catppuccin Mocha | 0.812 | 5.67 | 3.001 | 1.891 |
| Kanzo | 0.557 | 3.79 | 3.004 | 1.262 |
| Kanzo dark | 0.704 | 5.19 | 3.005 | 1.727 |
| Lo-fi | 0.725 | 4.07 | 3.005 | 1.355 |
| Lo-fi dark | 0.901 | 7.42 | 3.003 | 2.472 |
| Nord | 0.058 | 3.05 | 3.003 | 1.016 |
| Nord dark | 0.250 | 3.32 | 3.017 | 1.093 |
Where a palette sits barely over 3 : 1 to its background — Nord, Catppuccin Latte — the criterion leaves almost no room for context: the greyed marks are just distinguishable from the page (1.014 – 1.016 : 1). That is the palette's margin, not the greyout's; widening it is the theme's decision.
Links keep cosmos.gl's default, linkGreyoutOpacity = 0.1, in place of our earlier 0.025. A kept
link is drawn under 3 : 1 to the background on purpose — linkOpacity and the distance fade — so SC
1.4.11 sets no value to derive the greyed one from.
Held by packages/graph/src/render/greyout.test.ts, which reads the eight themes' tokens from
@kanzo-tech/theme and checks P with that package's own WCAG contrast at α* and at every
thousandth below it, that α* + 0.001 breaks it, and that the greyed mark is distinguishable from
the background at α*; packages/graph/src/render/renderer.test.ts, "greys
out at the opacity derived from the colours it painted and its background, links at cosmos.gl's
default".
What changes, and what disappears
@kanzo-tech/graph:GraphStore.kept()replacesmask,visible(),visibleOf,litOf;GraphClientreadsverticesand keeps none of a table that cannot answer a clause, andgraph/unfilterablegoes;GraphTimelinegoes forChartTimeline relation="vertices";gesture.ts's Alt-with-no-clause publishes an exclusion; the greyout values change.@kanzo-tech/mosaic: the page's resolver,crossfilterOver(joins), with a clause's relation (onRelation) and a client's;bridgeSelectionandsemiJoinOfgo; aclauseExclude(key, ids)besideclauseSemiJoin.- keasy: the rules publish on
vertices; Ask's skipped clauses become the answer's relation keeps none of them (no rows, as a dashboard), or Ask states it the same way the dashboard does. That is decision 3.
Migration, in order: one kept() and its tests (no behaviour change); the greyout measured and
set; one selection across relations, the timeline on it; the graph's, the search's and Ask's
column rule keeping none instead of skipping; Alt's exclusion clause. Each step is its own PR with the gesture, renderer and
counts tests that hold the rule today.
Step 3: one selection across relations
Decided 11 October 2026, and implemented. The principle is the owner's: leave the magic to
vgplot and Mosaic; a host does not manage selections, bridges or keys. It replaces the proposal
that stood here — GraphTimeline as a ChartTimeline inside a Crossing of a source, a selection
per group of clients bridged to the page, and one way to retract through the clause's source — which
still had a selection per group, a bridge per selection and a retraction path across them.
What a host writes
<MosaicProvider coordinator={coordinator} joins={joins}>
<FilterBar />
<GraphCanvas /> {/* the graph is one more client */}
<ChartTimeline relation="vertices" field="date" />
<RelationPicker value={relation} onValueChange={setRelation} />
<Dashboard relation={relation} />
</MosaicProvider>No selection, no bridge, no key. joins is the corpus's join graph (readJoinGraph), which holds
one more type, vertices: every vertex table as one relation, NULL where a table has no such
column.
Who does what
- vgplot and Mosaic, untouched. The page's one selection is a Mosaic crossfilter: a chart
never filters itself, the interactors (brush, toggle, play) publish into it, the coordinator
re-queries, pre-aggregated views serve a brush, and a chip's × is
selection.reset([clause]), which calls the clause's source back. - Ours, the one thing Mosaic cannot do: a clause published on relation A read by a client on
relation B. One
SelectionResolver— Mosaic's own extension point,new Selection(resolver), whosepredicate(clauses, active, client)receives the client (@uwdata/mosaic-core0.32.0,Selection.ts) — built overjoinsbycrossfilterOver(joins). A clause carries the relation it was published on (meta.relation,onRelation); a client exposes the one it reads now (relation). Every part does both: a chart's interactors publish on itsrelation, its marks read it, and SQL handed to a part is named by aMosaicClients relationaround it.
Formally
For a clause c published on relation A with predicate p_c, and a client on relation B, let
root(A) be the key of A's root and its type, and steps(B) the key of each step of B with its
type, in path order. Two types meet when they are one, or one is a union (of) holding the
other. Then the resolver hands the client
r(c, B) = p_c if A = B; r(c, B) = k IN (SELECT key_A FROM A WHERE ⋀{c′ on A} p{c′}) for the first (k, t) ∈ steps(B) whose type meets root(A)'s; r(c, B) = FALSE when no step meets it.
Every clause published on one relation crosses together — on a joined relation, a root with a
row where a and b hold is not a root with a row where a and one where b. A clause or a client
that names no relation is Mosaic's: the clause as published. kept is then the conjunction of
Formal semantics, read per client: ans(c, v) is false for a type with no step
of A's root type, and over vertices a column clause keeps none of a type without the column,
because GraphClient asks only the tables that answer every clause (#174).
A relation that changes
A filter is the page's, not the chart's that made it. A chart's relation is reactive, and a
dashboard switched to another relation — by a RelationPicker, or by an answer's Open on the
dashboard — unmounts its tiles. Neither takes a clause off the page. The clause stays on the page's
one selection, a chip in the FilterBar named as clauseLabel names it, and the resolver crosses
it into the new relation by key, as it crosses every other clause — the crossing Ask already reads
its answers under. No other client needs anything, because the resolver reads each client's relation
when it asks. A filter goes only when the reader removes it: its chip, the chart's own clear or
re-pick, Clear every filter, or taking the filter off the dashboard that draws it.
A part publishes under a place, and the place outlives the part (clause-place.ts). A chart's
place is keyed by its title, its relation, the place in its plot and the interactor's kind and
fields; a control's by its relation and its clause's type and fields. A chart mounted again takes its
place back: it is handed the clause the page still holds there, draws the pick from it and
republishes it for its own marks, so its own bars are exempt again and a new pick replaces the old
one rather than sitting beside it. A key is held by one part at a time; a twin on screen gets a
place of its own. Where a chart's grammar changes in place, its old clause stays the page's and the
new interactor takes the place its own key names.
It replaced withdrawal on unmount, which had every chart and control take what it published off
the page when it unmounted or its relation changed, so that a filter on one relation never reached
the next. In Keasy, recording a demo: a pick of Physics on a dashboard over
LaureateAward > category > Category, Ask answering under the page's filter: Category.name
Physics, then the dashboard switched to LaureateAward > university > University — and the pick
was gone. The chip went with the chart, and the next answer was over every category, with nothing on
screen to say so. The reference is Kibana: a filter in its filter bar belongs to the app, not to the
visualization that added it, and stays across visualizations until it is removed. It is also what
this page already claimed — one selection for the page, one count
of what it keeps — and what the canvas already did: a pick belongs to the page, and is retracted
where it shows, its chip.
What withdrawal guarded against does not come back. It kept a chart that unmounted from leaving
its clause filtering the page with nothing on screen to clear it. The FilterBar draws every clause
on the page as a chip with a ×, whoever published it and whether or not it is mounted, so there is
something on screen to clear it — on a page with a FilterBar, which is the condition: a page that
filters and draws no bar has nothing but the part's remount and selection.reset() to clear a
clause whose part has gone. And the two windows a rebuild once left on the timeline cannot return
across a remount, because a remount publishes under the source it published under before.
What would reverse it: readers taking a kept filter for a wrong answer — a chip from a view they
left read as noise rather than as the filter they set. Then a clause carries the view it was made in
and the bar groups or dims its chip by it, still without withdrawing it. Or a page that filters with
no FilterBar becoming a shape this library supports: then a part on such a page withdraws on
unmount, and a page with a bar keeps its filters.
Held by packages/graph/src/parts/relation-switch.test.tsx, "stays on the page as a chip, filters
the new relation by key, goes with its chip, and is drawn again where it was made";
packages/graph/src/parts/timeline.test.tsx, "keeps its window on the page when it unmounts,
undelivered or not, and draws it again when it mounts";
packages/ui/src/charts/chart-inputs.test.tsx, "disconnects its client on unmount and leaves its
clause the page's, taking it back when it mounts again", "leaves its clause the page's on unmount,
and reads it back into the box when it mounts again".
Pre-aggregation stays where the clause is the client's own
Mosaic's pre-aggregator indexes the active clause's columns against the client's table
(PreAggregator.ts, request → preaggregateInfo). For a crossed client that view is built on
columns of another relation: a failed statement where the column is missing, and where a column of
the same name exists, the wrong rows. preaggregateOwnRelation(coordinator), which the provider
installs once per coordinator, answers no view for a client the active clause crosses into, so it
asks the plain query the resolver crosses; a client of the clause's own relation is pre-aggregated
exactly as before. Measured on the test corpus (crossing-preagg.test.ts): a brush on Places'
lon over 1–3 keeps Places 11–13; a tile of places reads it from a mosaic.preagg_* view; a tile of
people reads [] with the guard, and lon 1–3 of people — three wrong rows — without it.
The load waits for the charts, not for one part
The graph's first load waits for every chart on its page that has not drawn its first answer: a
chart holds the page's first draw (holdFirstDraw on the page's selection) until its first answer
is drawn, fails, or stops asking under a MosaicClients that is off. It replaces the hold the
graph's own timeline part took on the store.
What went
MosaicProvider's crossfilter, selected, registerSelection, reset and retract (the
registry walk upstream); useCrossfilter and useSelected; ChartRoot's table, as and
filterBy and ChartTimeline's table, as, filterBy and paceBy — relation, and the page's
selection; Dashboard's publish and the selection per dashboard; bridgeSelection, bridged,
clauseReadings and semiJoinOf; GraphTimeline and timelineOf; graph/unfilterable
(with #174).
What would reverse it
- A relation whose keys do not share a space — two corpora on one page, whose
dense_ids collide: then a key is a pair and the crossing a tuple semi-join. - A path ambiguous enough to need choosing — a relation with two steps of the clause's root type where the first is the wrong one often enough to matter: then a clause names the step it crosses into, and the resolver stops picking the first.
- Mosaic exposing a per-client pre-aggregation veto, or a resolver hook it calls before
pre-aggregating: then
preaggregateOwnRelation, which wraps the pre-aggregator's publicrequest, goes for it. - A measured cost of the crossing subquery at the largest corpus — it reads the relation the
clause was published on once per crossed client per update: then the keys are materialised once
per clause. At 200,000 people in DuckDB-WASM under Node the canvas's query over the
verticesunion measured the same as over the per-table union it keeps for the clause rule (median of 15: 19.7 ms against 19.9 ms for a column clause, 25.9 ms against 29.7 ms for 500 ids); the graph benchmark in a browser was not run for this step.
Held by packages/mosaic/src/crossfilter.test.ts, "hands a clause to a client of the relation it
was published on as it was published", "crosses a clause into another relation by the key of the
step of its root's type", "keeps none of a relation with no step of the clause's type", "crosses
every clause on one relation together, and leaves clauses that name no relation as published",
"reads the client's relation when it asks, so a client that moves needs nothing else", "is asked for
a clause on the client's own relation, and not for one crossed into it";
packages/graph/src/core/crossing.property.test.ts, "reads a clause on one relation from another
exactly as the semi-join on its root's key, and as nothing without a step of its type", "retracts a
chip's clause for every reader, whatever relation it was published on";
packages/graph/src/core/crossing-preagg.test.ts, "pre-aggregates a client of the clause's own
relation, and asks a crossed one the plain query", "would index a crossed client by a column of the
same name on its own table without it — the wrong rows";
packages/graph/src/core/one-selection.test.ts, "filters the graph by what the tiles' brushes keep,
as the root vertices they keep";
packages/graph/src/parts/timeline.test.tsx, "holds the graph's loading until its bars are drawn,
as any chart on its page, and mounts its band once", "crosses a window on it into every other
relation, keeping none of a type without the column".
Status
| Step | Pull request | State |
|---|---|---|
One kept(), labels and counts read it (divergences 1, 8, 9) | #173 | merged |
| The greyout, derived per theme | #177 | merged |
| One selection across relations — the timeline, the dashboard and the graph on it (divergence 3) | this branch | implemented, merged over #174, #175, #177 and #179 |
| A clause a type cannot answer keeps none of it — graph, search, Ask (divergences 2, 4, 5) | #174 | merged |
| Alt's exclusion, and an empty set keeps nothing (divergences 6, 10) | #175 | merged; its exclusion is published on vertices |
Risks
- Keeping none instead of skipping changes what a host's raw column clause does to types without the column: they go from in colour to greyed. Every in-repo source publishes on its relation; a host that publishes raw columns would see the change, and the changeset says it.
- An exclusion clause is a second kind of canvas clause. The chip has to read it (not 3 vertices), and Clear and Esc treat it like any other.
- Folding the canvas's clause into
keptwithout the exemption must not cost a round trip per lasso: the canvas's clause iskey IN (ids), so the local bitmask stays; only the number of functions changes.
Decided
- Everything is a filter. One clause per source on the page's one selection, one
kept, per-view drawing only. - A clause a type cannot answer keeps none of that type, everywhere, Ask included. On the canvas the type is greyed; a dashboard tile shows no rows; Ask no longer skips such a clause and reads around it: its answer is read under every clause, so it has no rows, and it says which clause its relation cannot answer, as a dashboard's chip does.
- Alt with no canvas clause publishes
key NOT IN (caught). This reverses part of #112's decision, which started ⌘ and Alt from what is in full colour (the canvas's tools): Alt now acts on the canvas's own clause only, and the result stays right when another source changes. ⌘ with no canvas clause is what it caught. - "No position" is not drawn, is explicit in the legend and the counts, and no filter ever moves a vertex into it.
- The greyout is derived per theme from its own tokens — the largest opacity that keeps every kept colour at 3 : 1 from itself greyed — replacing cosmos.gl's defaults, which measured 2.03 – 2.65 : 1 (The greyout). Links keep cosmos.gl's default, 0.1.
References
- Heer & Moritz, Mosaic: An Architecture for Scalable & Interoperable Data Views, IEEE TVCG 2024 (VIS 2023): clients, selections, clauses and crossfilter resolution.
- W3C, Understanding SC 1.4.11 Non-text Contrast (WCAG 2.2), and the contrast ratio and relative luminance it uses.
- Mosaic, Selection and
core: clauses, resolution (
single,union,intersect), crossfilter omitting a client's own clauses,filterBy. - crossfilter.js, API reference: a group ignores its own dimension's filter.
- Vega-Lite, selections (
empty, conditional encodings: a selection is drawn, not removed) and invalid data. - Cosmograph
Timeline, read in@cosmograph/cosmograph2.5.1:isBetween(column, range)from a client of its own, points without a value greyed (Timeline). - cosmos.gl 3.4.0
config.d.ts:pointGreyoutOpacity,pointGreyoutColor,linkGreyoutOpacity. - Kepler.gl, filters: rows outside a filter are not drawn; rows without coordinates are not plotted.
Timeline
Why a time filter is a histogram whose brush owns its clause and sticks to its bars, why play runs inside the brushed range, accumulating, through the brush's own clause and at the page's pace, why its window reaches the page as identity and greys what has no time, why time is read from the schema and never guessed, and why a date on a relation is a vertex — and what would reverse each rule.
One read per datum
Why every read the library makes for a host — a relation's fields, an answer's rows — is a TanStack Query query inside the library's own hook, on the host's QueryClient, with no queryOptions surface; and what would reverse it.