Topic 16.2
Filters and Cross-Filtering
In one line
Keep every filter in one object that every chart reads, write it into the address so a link recreates the view, let a click on one chart filter the others, and always say what is showing and how to reset it.
Think of it like this
The filters of an online shop. You tick "blue" and "size 42", and the list, the count and the price range all change together. The address changes too, so you can send the link to a friend. A "clear all" button puts everything back. Nothing in the shop keeps its own secret copy of what you ticked.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- filter state
- The one object that holds every active filter. Charts read it and never keep their own copy.
- cross-filtering
- A selection in one chart filters the other charts, but not itself.
- query string
- The part of an address after
?, such as?weekday=Sat.URLSearchParamsreads and writes it. - replaceState
- Changes the address without adding a Back-button step.
pushStateadds one. - clamp
- Pull a value back inside its allowed range: 2030 becomes the last day of the data.
- empty state
- What a chart shows when no row passes the filter: a sentence and a way out, not a blank box.
Step by step
01Read the address safely
The first example writes readFilter(search) and writeFilter(filter). readFilter accepts any text and always returns a valid filter plus a list of what it ignored.
Seven different addresses go through it. The garbage ones do not throw: they become the nearest valid filter, and the log says what happened.
02One state, many charts
The second example has one filter object and one setFilter. It draws weekday bars and a line of days. Every click goes through setFilter, which redraws both and prints the sentence of what is showing.
The script clicks Saturday, then picks June, then picks a single Monday. That last combination matches no day, and the charts show the empty state instead of NaN.
03Say what is showing
After every change the sentence above the charts is rebuilt from the filter and the row count: Saturdays, 1 to 30 June: 4 days. It is a live region, so a screen reader hears it.
04Reset
The Reset button sets the filter to { from: null, to: null, weekday: null } and calls the same setFilter. It is hidden when nothing is filtered, and always present in the empty state.
Try it yourself
- 1
Make the bars obey every filter
In
render, changerows.filter(inRange)torows.filter(passAll). After you click Saturday, every other weekday bar drops to zero, and there is nothing left to click to switch to Monday. - 2
Test the address
In the first example add
"?weekday=sat"toaddresses. It prints?weekday=sat -> (no filter) [weekday ignored]: the check is case sensitive, so lower case is rejected. Decide whether your app should accept it. If so, look the text up case-insensitively and keep the matching entry ofDAYS(Sat), not the typed text, so that the rest of the code still sees one spelling.
Code & diagrams
The reader never throws: whatever is typed into the address bar becomes a valid filter, and the list says what it had to fix.
Console
(nothing was printed)Expected console output
?from=2025-06-01&to=2025-06-30&weekday=Sat -> ?from=2025-06-01&to=2025-06-30&weekday=Sat
(empty) -> (no filter)
?from=abc -> (no filter) [from ignored]
?from=2025-02-30 -> (no filter) [from ignored]
?weekday=Funday -> (no filter) [weekday ignored]
?from=2025-09-01&to=2025-06-01 -> ?from=2025-06-01&to=2025-09-01 [range swapped]
?from=2024-12-01&to=2026-03-01 -> ?from=2025-01-01&to=2025-12-31 [from clamped, to clamped]Every click goes through the same setFilter. The bars ignore the weekday filter, so Saturday stays selectable while it is picked.
Preview
Console
Nothing logged yet.Expected console output
Whole year: 365 days | bars with data: 7 | dots: 365
Saturdays: 52 days | bars with data: 7 | dots: 52
Saturdays, 1 Jun to 30 Jun: 4 days | bars with data: 7 | dots: 4
Saturdays, 2 Jun to 2 Jun: 0 days | bars with data: 1 | dots: 0
empty state visible: true
Saturdays, 1 Jan to 31 Jan: 0 days | bars with data: 0 | dots: 0
NaN in any drawing attribute: false
Whole year: 365 days | bars with data: 7 | dots: 365The preview's page has no address of its own to change, so this one is shown, not run.
// On every change: the address follows the filter, without a new Back-button step
history.replaceState(null, "", writeFilter(filter) || location.pathname);
// When the page opens, and when the user presses Back or Forward
setFilter(readFilter(location.search).filter);
addEventListener("popstate", () => setFilter(readFilter(location.search).filter));Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Skip the empty check
In the second example, remove the two guards: change d3.max(totals.values()) ?? 0 to d3.max(totals.values()) and [0, max || 1] to [0, max]. Then click Jan 2026 (no data).
Myth vs fact
Myth
Each chart can keep its own filter and tell the others.
Fact
Copies drift apart. One state read by everyone cannot disagree with itself.
Myth
Putting the filter in the URL is only for sharing.
Fact
It also makes reload, Back and the wall display restore the same view, and it makes a view easy to test.
Myth
A selected bar should hide the other bars.
Fact
Then you could not change your choice. The chart you click should skip its own filter.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Treat the address like a form field a stranger filled in: validate and clamp every value, and never put it in
innerHTML. Show the cleaned filter back in the interface so a bad link is visible. - ▸
Browsers limit how often
history.replaceStatemay be called, and a brush that writes the URL on every move can hit that limit. Safari is widely reported to throw aSecurityErrorafter about 100 calls in 30 seconds (no official figure is published), and Chrome logs a throttling warning and drops the calls, so the address stops following the filter. Debounce it, or write when the brush ends. - ▸
Filtering is a pure function of
(rows, filter). Test it without a browser (Topic 16.7), and memoise it if the same filter is read by many charts: compute the filtered rows once persetFilter.
Remember this
- 1
The problem this solves: when each chart remembers its own selection, the charts disagree (the line shows June, the bars show the year) and nobody can share or restore a view. The fix is one filter, in one place, read by every chart.
- 2
One filter state. A plain object such as
{ from, to, weekday }withnullmeaning "not filtered". One function,setFilter(change), merges a change into it and then redraws every chart from the same filtered rows. Charts never store a filter. This is also how Phase 8'sselectRangeworked: one place decides, followers obey. - 3
Cross-filtering. Clicking a bar in one chart filters the others. The clicked chart does not filter itself: if the weekday bars hid every other weekday after you picked Saturday, you could not pick Monday. So each chart is drawn from the rows that pass every filter except its own. The line obeys all filters, the weekday bars obey only the date range.
- 4
The URL is state. Writing the filter into the address (
?from=2025-06-01&to=2025-06-30&weekday=Sat) gives you shareable links, a working reload and a way to open the same view on the wall screen. Usehistory.replaceStatewhile the filter moves (a brush fires dozens of times a second) andpushStateonly for a finished choice, or the Back button becomes a stack of tiny steps. - 5
The URL is also untrusted input. Anyone can type
?from=abc&weekday=Funday&to=1999-01-01. Read it with a function that checks every value: an invalid date is ignored, a reversed range is swapped, a range outside the data is clamped, an unknown weekday is ignored. The first example prints what each case becomes. - 6
Always show what is active, and how to undo it. One sentence above the charts (
Saturdays in June: 4 days) and one Reset button that is visible whenever a filter is on. People forget what they filtered, and a filtered chart that looks like the whole year misleads. - 7
When the filters leave nothing.
d3.max([])isundefined, so a scale built from it givesNaNand the chart paintsNaNattributes. Check for empty rows first and show an empty state that names the cause (No days match these filters) with a Reset button. A blank chart makes people think the page is broken. - 8
Scale. Filtering 365 rows is instant. At hundreds of thousands of rows, filtering on every mouse move is too slow: debounce the brush, pre-aggregate (Phase 12), or use an indexing library such as Crossfilter (the library behind dc.js). The structure stays the same: one state, one
setFilter.
Explain it without notes
Why does the clicked chart skip its own filter in cross-filtering?
Why validate a filter read from the URL?
What should a chart show when the filters leave no rows?
When do you use replaceState and when pushState?
Practice
Add a minRides filter to readFilter that accepts only integers from 0 to 5000 and ignores the rest. What does ?minRides=-5 give?
Write the sentence for the filter { weekday: "Sat", from: null, to: null }. Which words does a screen reader need that a sighted reader gets from the highlighted bar?
Trade-offs
- ↔
URL state makes views shareable, but long filters make long links and leak what people look at into logs and browser history. Keep private filters out of the address.
- ↔
Cross-filtering is fast to learn but hard to read when five charts react at once. Limit it to two or three linked charts.
Done when you can
I can keep every filter in one object and one
setFilter.I can draw each chart from the rows that pass every filter except its own.
I can write the filter into the URL and read it back safely.
I can show what is filtered and a Reset button.
I can show an empty state when no row passes, with no NaN anywhere.