Topic 8.4
Highlight, Filter and Legend Toggles
In one line
Highlighting dims everything except one thing, filtering takes things out. Keep all the changing facts in one state object, draw from it in one function, and decide in advance what the chart says when every item is filtered out.
Think of it like this
A theatre spotlight. It lights one actor and leaves the others in dim light: everyone is still on stage. A filter is different: it sends some actors off stage. Charts need both, and readers should always know which one is happening.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- state
- All the facts about the chart that can change: what is hovered, what is pinned, what is hidden.
- event delegation
- One listener on a parent that handles events from its children.
- aria-pressed
- An attribute that tells assistive technology a toggle button is on or off.
- empty state
- What the screen shows when there is nothing to show, with a way out.
Step by step
01One state, one render
Both examples share the setup: 25 stations, summed over the year from station-month.csv, coloured by district. state holds the hovered id, the pinned id and the set of hidden districts. render() is the only code that changes the chart.
state.pinned = state.pinned === id ? null : id; // the event handler
render(); // everything else follows02Highlight by hover, by tap and by key
The first example sends the events a mouse user and a phone user would produce, and counts the bars at full strength after each. Notice the "hover another while pinned" line: the pinned bar still wins.
03Filter with the legend and plan the empty state
The second example clicks the legend buttons: one district, two, back, then all six. At the end the message appears, and "Show all" brings everything back. The last lines show the double-binding mistake.
04See the whole loop
Every event only changes state. render() is the single place that draws, so hover, pin, legend and the empty message can never disagree.
Try it yourself
- 1
Dim less, or more
In the shared setup change
0.35to0.15. The other bars almost vanish. Hover in the editor: the highlighted bar is clear, but you can no longer see where it stands among the others. - 2
Stop the empty message
In the second example's setup, change
state.hidden.size < districts.lengthtotrue. After "hide every district" the message stays hidden and the chart is just faded colours: the reader has no clue what happened.
Code & diagrams
Hover the real bars in the editor too: the same state drives them.
Preview
Console
Nothing logged yet.Expected console output
start | full strength: 25 | hover: null | pinned: null
mouse over Quay | full strength: 1 | hover: S01 | pinned: null
mouse leaves | full strength: 25 | hover: null | pinned: null
tap Library | full strength: 1 | hover: null | pinned: S04
finger lifts | full strength: 1 | hover: null | pinned: S04
hover another while pinned | full strength: 1 | hover: S08 | pinned: S04
tap the same bar again | full strength: 25 | hover: null | pinned: null
pin it again | full strength: 1 | hover: null | pinned: S04
Escape | full strength: 25 | hover: null | pinned: null
rect elements in the chart: 25The legend's buttons are created by render() and reused by the join. Because the listener is on the legend itself, they never need their own.
Preview
Console
Nothing logged yet.Expected console output
start | full strength: 25 | empty message: hidden
hide Harbour | full strength: 20 | empty message: hidden
hide Old Town too | full strength: 15 | empty message: hidden
show Harbour again | full strength: 20 | empty message: hidden
hide every district | full strength: 0 | empty message: shown
press Show all | full strength: 25 | empty message: hidden
rect elements after all that: 25 (the join reused them)
handler runs for ONE click after 3 redraws: 3Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to clear the hover
In the first example, delete the line .on("pointerleave", () => { state.hover = null; render(); }) (keep the .on("click", …) that follows).
Break #2
Add the listener on every redraw
In the second example, delete one of the three renderBad(); calls.
Myth vs fact
Myth
Highlighting and filtering are the same thing.
Fact
Highlighting keeps every item in view and adds emphasis. Filtering removes items. Readers expect to be able to tell which one they are in.
Myth
Each event handler can update the part of the chart it cares about.
Fact
Then every handler must know about every other. One state object and one render function keep them consistent.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A
Setin the state is mutable, so a framework that compares by identity (React, Phase 14) will not see a change. In React, create a newSetfor every update. - ▸
Dimming to opacity 0.35 keeps the other bars readable (the first try-it shows how thin 0.15 gets). Dim with
opacity, not by changing the colour, so the district colours stay recognisable and a dimmed bar is still the same bar. Never encode meaning in the dimming alone: a pinned bar also needs a visible outline or label for readers who cannot see small opacity differences.
Remember this
- 1
The problem this solves: 25 coloured bars are hard to compare in one look. The reader wants to follow one station, or look at one district. Highlighting and filtering let them focus without losing the picture.
- 2
Keep all the facts that change in one object. The example uses
state = { hover, pinned, hidden }. Events never touch the bars. They changestateand callrender().render()readsstateand draws everything. That removes a whole class of bugs: a highlight that stays after the pointer leaves, a legend that says "hidden" while the bar is visible, two handlers that disagree. - 3
Draw with a keyed join.
render()runs on every change, so it must reuse the elements:.data(data, (d) => d.id).join("rect")(Topic 5.4). The example ends with 25rectelements after every kind of change, not 25 new ones per redraw. - 4
Hover, tap and Escape. Hover helps mouse users but not phones (Topic 8.1). So a click, which a tap also produces, pins a bar: click the same bar again to unpin, press Escape to clear. The rule "a pinned bar wins over hover" is one line:
state.pinned ?? state.hover. Keyboard users get the same highlight when each bar hastabindex="0"and itsfocusandblurevents set and clearstate.hover; the build does this for the station bars. - 5
A legend is a set of buttons. Real
<button aria-pressed="true">elements get keyboard focus, Enter and Space, and a screen reader says "pressed" or "not pressed". A clickable<rect>in an SVG legend has none of that. Attach one listener to the legend container and readevent.target.closest("button")(event delegation, Topic 5.6). One listener survives every redraw. A listener added inside the function that redraws is added again each time: after three redraws one click calls the handler three times, and a toggle that runs an even number of times does nothing. - 6
Plan the empty state. If the reader hides every district, there is nothing to draw. A blank chart looks broken. Show "No districts selected" with a Show all button, as the example does, or do not let the last item be hidden. Decide also whether filtered bars keep their place (the example fades them, so the chart does not jump) or leave and the others re-spread (easier to compare the rest, but harder to follow what moved).
Explain it without notes
Why keep hover, pinned and hidden in one state object and draw in one function?
How does a phone user highlight a bar if hover does not exist?
Why is a listener added inside render() a bug, and how does event delegation avoid it?
Practice
Make the legend keyboard-friendly: confirm Tab reaches each button and Enter toggles it. What in the example already provides this?
Add a "Solo" behaviour: double-click a legend button to hide every district except that one.
Trade-offs
- ↔
Fading filtered bars keeps the layout stable, but wastes space. Removing them uses the space but moves everything, which makes the reader re-find their place.
Done when you can
I can keep hover, pinned and hidden in one state object and draw from it in one function.
I can highlight by tap and by keyboard, not only by hover, and clear it again.
I can build a legend of real buttons with one delegated listener.
I can show an empty state with a way back.