Kanzo UI
The design of Kanzo UI

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:

ViewKeptNot kept
Canvasin colourgreyed, for context
Chart, figurethe aggregate over what is keptthe total behind it, in grey
List, table, Ask's answerthe rowsabsent

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.

SourcePublishesFormInto
Canvas click, marquee, lassopublish(), core/client.ts:110key IN (ids)the page, from the graph's own client, which is exempt from it
Searchpublish()key IN (ids)the page
Dashboard tile pick, brush; filter bar controlsthe tile's clausecolumn ("Person.country" = …)the dashboard's own crossfilter, bridged to the page by semiJoinOf(rootKey, relation) (keasy dashboard-view.tsx:50)
Timeline windowChartTimeline's brushcolumn (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,246column (subject IN (SELECT …))the page
Ask's answerkey IN (…)identitythe page
Map placementnot a clause—x/y bindings; a vertex missing either is NaN (render/placement.ts:29)
ConsumerReads
Canvas colourhighlighted() → visibleOf(mask, pick) (render/motion.ts:34)
Counts, legendlitOf(mask, pick) → drawnOf (core/kept.ts:56, since #167)
Toolbar, frame()store.visible() → visibleOf
Search resultsits own inside, by the clause rule (core/source.ts:266-290)
Timeline barsthe page minus its own clause (crossfilter exemption), clipped to the window
Dashboard tilesthe page minus their own clauses
Askthe page's clauses, skipping those its relation cannot answer, and saying so

Where the parts disagree

  1. "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. Then visibleOf (canvas, toolbar, frame()) and litOf (counts, legend) each narrow it to the pick again (core/kept.ts:34-64): two functions for one answer. Until #167 the counts read mask alone 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.

  2. 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.

  3. 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).

  4. 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.

  5. 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.

  6. ⌘ 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.

  7. What a filter greys is drawn so faint it reads as hidden. Points greyed out are drawn at pointGreyoutOpacity: 0.1 and links at 0.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.

  8. The labels read the filter without the canvas's clause. topOf and labelled are handed the store's mask (parts/graph-canvas.tsx:66,94,98), so under a lasso a vertex outside it can still be labelled while it is drawn greyed.

  9. "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 the FilterBar readout's, over the page's relation.

  10. An empty set means opposite things. clauseSemiJoin(key, []) keeps nothing; the graph's publish turns 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.

  1. 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.
  2. Withdrawing never narrows; adding never widens. C′ ⊆ C ⇒ kept_C ⊆ kept_C′.
  3. One clause per source. Publishing c′_s gives kept_{(C ∖ {c_s}) ∪ {c′_s}}: the source's earlier clause has no effect left.
  4. No filter moves a vertex out of place. placed is the same for every C.
  5. 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.
  6. 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.
  7. The frame. frame() fits exactly { v : kept_C(v) ∧ placed(v) }, or every placed vertex when C = ∅.
  8. 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.
  9. 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 no pointGreyoutColor, 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

StateCanvasCounts and legend
keptcolourN match
not keptgreyedthe rest of of M
no position (Map placement only)not drawnplaced / 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

VerbActs on
drag, clickthe canvas's clause becomes what it caught
⌘/Ctrl + dragthe canvas's clause, with what it caught added; with no clause, what it caught
Alt + dragthe canvas's clause, less what it caught; with no clause, a clause key NOT IN (caught)
Esca drag in progress, then the tool, then the canvas's clause
a chip's ×that chip's clause
Clearevery 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, no pointGreyoutColor): 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 : backgroundkept : greyedgreyed : background
Catppuccin Latte0.0493.063.0101.014
Catppuccin Mocha0.8125.673.0011.891
Kanzo0.5573.793.0041.262
Kanzo dark0.7045.193.0051.727
Lo-fi0.7254.073.0051.355
Lo-fi dark0.9017.423.0032.472
Nord0.0583.053.0031.016
Nord dark0.2503.323.0171.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() replaces mask, visible(), visibleOf, litOf; GraphClient reads vertices and keeps none of a table that cannot answer a clause, and graph/unfilterable goes; GraphTimeline goes for ChartTimeline 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; bridgeSelection and semiJoinOf go; a clauseExclude(key, ids) beside clauseSemiJoin.
  • 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), whose predicate(clauses, active, client) receives the client (@uwdata/mosaic-core 0.32.0, Selection.ts) — built over joins by crossfilterOver(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 its relation, its marks read it, and SQL handed to a part is named by a MosaicClients relation around 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 public request, 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 vertices union 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

StepPull requestState
One kept(), labels and counts read it (divergences 1, 8, 9)#173merged
The greyout, derived per theme#177merged
One selection across relations — the timeline, the dashboard and the graph on it (divergence 3)this branchimplemented, merged over #174, #175, #177 and #179
A clause a type cannot answer keeps none of it — graph, search, Ask (divergences 2, 4, 5)#174merged
Alt's exclusion, and an empty set keeps nothing (divergences 6, 10)#175merged; 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 kept without the exemption must not cost a round trip per lasso: the canvas's clause is key IN (ids), so the local bitmask stays; only the number of functions changes.

Decided

  1. Everything is a filter. One clause per source on the page's one selection, one kept, per-view drawing only.
  2. 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.
  3. 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.
  4. "No position" is not drawn, is explicit in the legend and the counts, and no filter ever moves a vertex into it.
  5. 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

On this page