Two charts land here: a bar chart of totals per item, and a time series of daily totals with its seven-day average. Both are D3, both live inside shadow roots, and one of them is an input as well as an output — clicking a bar changes the filter every other panel is reading. Checkpoint: ui-07.

D3 selects the wrong document by default

Almost every D3 example starts like this:

d3.select('#chart')          // searches document — finds nothing here

Inside a component that finds nothing, because the element is in a shadow root and document.querySelector does not descend into shadow trees. That is the encapsulation working; the fix is to give D3 the right root:

const host = this.renderRoot.querySelector('#chart') as HTMLElement;
const svg = d3.select(host).select('svg');

this.renderRoot is the component’s shadow root. Once D3 has a node rather than a selector string, everything downstream — selectAll, data, join, transition — works exactly as documented, because those operate relative to the node you gave them.

The other half is timing. The SVG has to exist before D3 can select it, and in Lit that means updated(), which runs after the DOM is written:

updated(): void {
    this._draw();
}

Not render(), which describes markup, and not the constructor, which runs before anything exists.

Two libraries, one element, no fighting

Lit renders the container; D3 owns everything inside it. Lit’s template is deliberately just a wrapper:

html`
  <figure>
    <svg role="img" aria-label="Total sales by item"><title>Total sales by item</title></svg>
    <figcaption>Sales by item. Click a bar to filter.</figcaption>
  </figure>
`

Then _draw() fills that <svg> with join(). The rule that keeps this from becoming a mess: one owner per subtree. Lit never re-renders the SVG’s children, and D3 never touches anything outside it. Two renderers writing to the same nodes is how you get elements that vanish on an unrelated state change.

The accessibility bits are on the Lit side because they are static. A role and a <title> mean the chart is announced as an image with a name, rather than as a pile of anonymous <rect> elements.

A chart that filters

The bar chart’s click handler calls a store action:

rects.on('click', (_event, d) => toggleFruit(d.item));

That is the whole cross-filter. toggleFruit adds or removes the item, resets the page to 1, and the query runner does the rest — the table, the stat cards, the time series, and the heatmap all update because they read the same store.

Nothing is wired between the chart and those panels. The chart does not know what a table is. This is the moment where the store’s cost pays off: a feature that would otherwise mean threading callbacks through four components is one function call, because the chart is a view of the same state everything else is a view of.

The selected bars are styled by reading selectedFruits during draw, so the chart also shows the filter it caused.

Transitions that respect the user

D3 transitions are the reason the library is here, and they need a condition:

const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const dur = reduce ? 0 : 600;

rects.transition().duration(dur).attr('y', d => y(d.amount));

The CSS kill-switch in the global stylesheet cannot reach these — D3 animates by setting attributes frame by frame in JavaScript, which no media query intercepts. Any animation you drive from code needs this check explicitly. A duration of zero rather than skipping the transition keeps one code path, so the final attribute values are always applied the same way.

The time series does the same for its area and line, and both charts use vector-effect="non-scaling-stroke" for the same reason the sparklines did.

The peek that makes it honest

Both charts import sql-peek, a </> button that shows the SQL behind its panel, read from the lastSql signal the engine course populated:

import './sql-peek.js';

That import is why sql-peek lands in this lesson rather than a later one — a component’s imports are part of its contract, and both charts depend on it.

It is worth pausing on what it displays. Click it on the bar chart and you see the actual GROUP BY with the current filter values written in. The chart is not an abstraction over the data; it is a rendering of a query you can read, copy into the console in lesson 10, and run yourself.

Build it

File Action What goes in it
package.json modify Add d3 and @types/d3
src/components/sql-peek.ts write The </> popover reading lastSql
src/components/chart-section.ts write The bar chart: scales, axes, join, click-to-filter, selected styling, reduced-motion gate
src/components/time-series-chart.ts write Area, line, and the ma7 overlay
index.html modify <time-series-chart> and <chart-section> in the main column
src/index.ts modify Import both components
src/style.css modify Panel styling for the two chart sections

Done when: the bar chart shows eight bars, the time series draws a line and its trailing average, and clicking a bar cross-filters the whole dashboard to roughly 2,500 records — with no <svg> anywhere in document.body outside a shadow root.

Answer key: ui-06...ui-07.

Challenge: make D3 fail the documented way

Change d3.select(host) to d3.select('#chart') and reload. You get no chart and no error — D3 selects an empty selection and applies operations to nothing, which is the single most confusing failure mode of using it inside web components. Then check document.querySelector('#chart') in the console and confirm it returns null while the element is plainly on screen. That is encapsulation, and it is worth meeting deliberately once.

Next lesson: the visualisation that does not use D3 at all, and why.