Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~30 min· topic 2 of 8

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. URLSearchParams reads and writes it.
replaceState
Changes the address without adding a Back-button step. pushState adds 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.

One state, many chartsdiagram
Rendering diagram…

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

    Make the bars obey every filter

    In render, change rows.filter(inRange) to rows.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. 2

    Test the address

    In the first example add "?weekday=sat" to addresses. 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 of DAYS (Sat), not the typed text, so that the rest of the code still sees one spelling.

Code & diagrams

Reading a filter from the address, safely New tab

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]
One filter, two charts, an empty state New tab

Every click goes through the same setFilter. The bars ignore the weekday filter, so Saturday stays selectable while it is picked.

Preview

Press Run to see the page.

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: 365
In your project: keep the address in step (not live)js

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

terminal
$ Console
── what you'll see ──
Error: <rect> attribute y: Expected length, "NaN".
Error: <rect> attribute height: Expected length, "NaN".
(these two lines repeat for each of the 7 bars)

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.replaceState may be called, and a brush that writes the URL on every move can hit that limit. Safari is widely reported to throw a SecurityError after 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 per setFilter.

Remember this

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

    One filter state. A plain object such as { from, to, weekday } with null meaning "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's selectRange worked: one place decides, followers obey.

  3. 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. 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. Use history.replaceState while the filter moves (a brush fires dozens of times a second) and pushState only for a finished choice, or the Back button becomes a stack of tiny steps.

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

    When the filters leave nothing. d3.max([]) is undefined, so a scale built from it gives NaN and the chart paints NaN attributes. 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. 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

01

Why does the clicked chart skip its own filter in cross-filtering?

02

Why validate a filter read from the URL?

03

What should a chart show when the filters leave no rows?

04

When do you use replaceState and when pushState?

Practice

01

Add a minRides filter to readFilter that accepts only integers from 0 to 5000 and ignores the rest. What does ?minRides=-5 give?

02

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.