---
name: bic-charts
description: Build a web app or dashboard containing BIC-generated charts — a bubble map, a table with in-cell bars, linked cross-filtering. Use whenever the bic-chart MCP tools are available and the task is "put a chart / dashboard in this app".
---

# Building an app with BIC charts

The golden path, end to end. Following it costs a handful of tool calls; deriving it from
first principles costs about thirty, and the places people lose time are all called out
below because they were all measured.

**The mental model.** A BIC chart is *generated source you own* — a plain
`render(container, data, options)` function you commit to your repo. There is no API key at
run time, no per-render cost, and no network call. `@bicharts/chart-host` runs it; the MCP
generates it. Generation happens **once, at build time, by you** — never in your users'
browsers.

## 0. Decide what to pull (only if you are starting from a data MODEL)

Skip this if you already have a table. If you are holding a semantic model, a warehouse schema or
a database, **you** pick the fields — the tools take a table, not a model, and they cannot see
what you can. Three decisions, and the third is the one that goes wrong.

**Fields and filters** follow from the question. *"All loans over the last 6 months, highlighting
those which defaulted, paying attention to credit score"* wants `LoanId`, `Defaulted`,
`CreditScore`, filtered to six months.

**A highlight is a COLUMN, not a styling note.** "Highlighting those which defaulted" means
`Defaulted` travels as its own categorical column. There is no instruction channel that can colour
rows the data does not distinguish.

**GRAIN is a choice, and the default is usually wrong for these charts.** Almost every BI query
aggregates — `GROUP BY`, `SUMMARIZECOLUMNS`, a pivot. But that sentence is asking about *the
distribution of individual loans*, which needs **one row per loan**. Aggregate it and you get a
clean bar chart of average credit score by default status: a valid chart, answering a different
question, and nothing downstream will flag it. Ask yourself which you meant:

| you want | pull | you get |
| --- | --- | --- |
| how do individual loans spread out | **one row per loan** | beeswarm, strip plot, scatter, histogram |
| how do the groups compare on a total | one row per group | bars, treemap, pie |
| how does it move over time | one row per period (per series) | lines, area, streamgraph |

**Aim with `CHART-SHAPES.md`** (beside this file). It lists every chart the engine can build and
what each one needs — minimum columns by role, category ceilings, draw caps, viewport floors,
which renderers can draw it. It is GENERATED from the chart catalogue, so it cannot drift from what
the engine will actually accept. Read the LIMITS column carefully: **the units differ** — a sankey
is capped on EDGES and a chord on NODES, so `<=80 edges` is the tighter constraint on the same
picture, not the looser one.

**Then profile before you commit.** `assess_data_shape` is free, local, and needs no credentials —
it never leaves your machine. Run it on a candidate pull, look at what came back, and adjust the
query until the shape is what you meant. Only then spend a metered `list_eligible_charts` call.
Getting the grain wrong is cheap to fix here and expensive to fix after generation. For a second
chart, read each measure's `collinearWithMeasures` and `maxAbsMeasureCorrelation`: two measures
that move together (a correlation near 1) only repeat each other in a partner chart, and one that
doesn't is the one worth showing beside it.

**When several charts genuinely fit, ASK — do not quietly pick one.** A shape that supports a
beeswarm often supports a violin, a box plot and a strip plot too, and those answer subtly
different questions. Say what you found and let the person choose:

> Your data supports a few readings. A **beeswarm** shows every loan as a point (best if you want
> to see individual outliers); a **violin** shows the shape of each group's distribution; a **box
> plot** is the most compact if you mainly want medians and spread. Which do you want?

The exception is when the request already names one, or when only one type survives — then just
build it. The rule exists because silently choosing is how a user ends up with a valid chart that
answers a question they did not ask.

## 1. Scaffold (one command block)

```bash
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install d3@7 @bicharts/chart-host
npm install -D @types/d3
```

`d3` is a peer requirement of the **chart**, not of chart-host. `@types/d3` only for
TypeScript. chart-host has no runtime dependencies.

### Blazor WebAssembly (.NET 10)

A chart runs in the browser, so a Blazor app hosts it through JavaScript: **one** small JS
module owns the charts and their cross-filter group, esbuild bundles it during `dotnet build`,
and two Razor components (section 3) hand it the elements to draw into. No chart payload ever
crosses the interop boundary, and nothing here calls a server at run time.

```powershell
dotnet new blazorwasm -o MyCharts -f net10.0
cd MyCharts
dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature
npm init -y
npm install d3@7 @bicharts/chart-host
npm install -D esbuild
```

- **`npm init -y` comes FIRST, in the project folder.** With no `package.json` there, `npm install`
  walks up and installs into the nearest parent folder that has one - a real consequence: it edited
  a `package.json` two levels above the project. If an install is refused or interrupted, run the
  `npm init -y` line again before retrying.
- **`global.json`** pins the build to the newest installed .NET 10 SDK. Without it a machine that
  also has a newer or preview SDK builds with that one.
- These blocks are written against `@bicharts/chart-host` 0.6.33 or later; every name they import
  is exported there.
- npm 11 may warn that esbuild's postinstall is "not yet covered by allowScripts". The build doesn't
  need it: esbuild's platform binary arrives as an ordinary dependency, and the bundling target runs.

Add this to the `.csproj`, inside `<Project>`, whole:

```xml
<PropertyGroup>
  <DefaultItemExcludes>$(DefaultItemExcludes);node_modules/**</DefaultItemExcludes>
</PropertyGroup>
<Target Name="BundleBicCharts" BeforeTargets="BeforeBuild">
  <Exec Command="npm ci" Condition="!Exists('node_modules')" />
  <RemoveDir Directories="wwwroot/js/bic" />
  <Exec Command="npx esbuild Scripts/bic.entry.js --bundle --format=esm --splitting --minify --outdir=wwwroot/js/bic" />
  <ItemGroup>
    <Content Include="wwwroot/js/bic/**" Exclude="@(Content)" />
  </ItemGroup>
</Target>
```

Each line is there because leaving it out fails quietly:

- **`node_modules/**` excluded** - otherwise MSBuild globs thousands of package files into the
  project on every build.
- **The source lives in `Scripts/`, the bundle in `wwwroot/js/bic/`** - `wwwroot` is published
  as-is, and the unbundled entry (bare `import "d3"`) cannot load in a browser.
- **`--splitting`** - map geometry (about 1.3 MB across three assets) stays in chunks fetched
  only when a map asks for one. Without it every page downloads every basemap.
- **The `Content` item inside the target** - the bundle is written DURING the build, after the
  project already globbed `wwwroot`. Without it a clean build (a fresh clone, CI, `dotnet
  publish`) ships no bundle and the page's `import` 404s, while your own incremental builds
  keep working.
- **`RemoveDir`** - chunk names are content hashes, so stale chunks would pile up.

`.gitignore`: `bin/`, `obj/`, `node_modules/`, `wwwroot/js/bic/` (build output). Commit
`package.json` and `package-lock.json` - the target's `npm ci` needs the lock.

## 2. Generate — say what the output must BE, not just what it should look like

Call `generate_chart` once per chart, with `out_dir` so the artifacts land on disk.

The single most important habit: **`prompt` is a suggestion lane.** It steers style and
emphasis, and it competes with the chart lane's own guidance about summarizing. When your
app *depends* on the shape of the output, use the contract parameters instead — they are
authoritative, and `required_columns` is verified in the generated code with an automatic
correction:

| Parameter | Use it when |
| --- | --- |
| `rows_policy: "all"` | every source row must appear — a row-per-entity table, a point per location. Without it the engine may legitimately show a Top-N with an "Other" rollup. |
| `required_columns: [...]` | a field must be visible because your app's interactions depend on it (e.g. the column you colour or filter by). |
| `forbid_derived_columns: true` | your app owns the analytics; the chart is only the display. Stops per-capita / share-of-total / rank columns being added. |

Asking for these in `prompt` prose instead is the most expensive mistake available here: it
routinely costs two or three extra generations, and each one is minutes of wall-clock.

Other parameters worth knowing: `chart_type` (a preference, honored unless the data shape
disqualifies it), `renderer: "D3"` for a web app, `width`/`height`, and
`reasoning_mode: "1P"` when you want the cheapest, fastest pass.

**Generation takes minutes.** Fire the calls for independent charts **in parallel** rather
than in sequence — it is the difference between ~4 minutes and ~12.

**Where the files land.** `out_dir` and `csv_path` resolve against the MCP server's working
directory (where the agent session started), not your project, so pass absolute paths.
React: `src/charts/<name>`. Blazor: `wwwroot/charts/<name>` - the page fetches them as static
files. The language auto-detect also reads that working directory, and a .NET project gives it
nothing to read, so for Blazor name `renderer: "D3"` (chart-host runs D3 charts).

**With `out_dir`, the reply leaves the code out** (`include_code: false` is the default then; pass
it explicitly on an older chart-mcp). The code is already on disk, and a reply that carries it can
outgrow your client's tool-output limit: a 68 KB choropleth once came back as "result exceeds
maximum allowed tokens", saved to a file the agent then had to parse. Everything else you need -
`geo.kind`, the plugins, the credits, the paths - stays in the reply.

## 3. Wire it up

Import the generated code as raw text and the sample payload as JSON:

```tsx
import * as d3 from "d3";
import { BicChart, BicChartGroup } from "@bicharts/chart-host/react";
import mapCode from "./charts/map/chart.js?raw";
import tableCode from "./charts/table/chart.js?raw";
import payload from "./charts/map/data.sample.json";

// data.sample.json rows are POSITIONAL arrays; the group wants row OBJECTS.
const columns = payload.columns;
const rows = payload.rows.map(r =>
  Object.fromEntries(columns.map((c, i) => [c.name, r[i]])));
```

### The coordinated dashboard — the recipe, memorized

```tsx
<BicChartGroup columns={columns} rows={rows} point={{ city: "City", state: "StateOrProvince" }}>
  <BicChart id="map"   code={mapCode}   d3={d3} geoKind="north-america" respondsWith="highlight" />
  <BicChart id="table" code={tableCode} d3={d3} />
</BicChartGroup>
```

That is all of it. The map **highlights** (keeps every bubble, dims the rest) and the table
**filters**. Cross-filtering is mutual by default, a chart never filters itself, and
`group.clear()` clears.

Do **not** hand-roll dimming out of the `lch-*` CSS classes. They are an implementation
detail, they are re-applied on every render (so a `classList` poke is lost at the next data
change), and translating row indices across a filtered payload is the hazard below.

> **The row-index hazard.** `__rowIdx__` is a position *within the payload a chart
> received*, not an id in your table. Re-render a chart with filtered rows and its payload
> renumbers from zero, so the same integer denotes a different record. `BicChartGroup` owns
> that mapping. Comparing indices across two charts by hand does not throw — it quietly
> filters to the wrong thing.

### Sizing

Sizing is yours. Give each `<BicChart>` a sized container and pass `width`/`height` in
`options` — a chart handed `undefined` dimensions draws nothing visible.

### Blazor - the same group over JS interop

No React here, so the group is chart-host's framework-free `createChartGroup` - the object
`<BicChartGroup>` wraps, with the same rules (a chart never filters itself, mutual by default,
`respondsWith: "highlight"` keeps every mark). Three files, copied whole. The gestures are
chart-host's, with nothing to wire: a click selects, clicking the same mark again clears,
Ctrl/Cmd/Shift-click adds or removes one mark, and a click on empty canvas clears.

`Scripts/bic.entry.js` - the only JavaScript the app owns:

```js
import * as d3base from "d3";
import { assembleD3, createChartGroup, createChartHost, loadGeo } from "@bicharts/chart-host";

const d3 = assembleD3(d3base);                           // + any plugins a chart's contract names
const at = (path) => new URL(path, document.baseURI);   // honours <base href> on a sub-path host
async function text(path) {
    const r = await fetch(at(path));
    if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
    return r.text();
}
// Draw for the canvas the chart actually sits on - the nearest opaque background behind it. A page
// that follows the reader's light/dark preference switches that background; a light-only page keeps
// it light, and the chart's text stays dark there even when the reader's system is dark.
function canvasOf(el) {
    for (let e = el; e; e = e.parentElement) {
        const m = getComputedStyle(e).backgroundColor.match(/[0-9.]+/g);
        if (m && (m.length < 4 || +m[3] > 0.5)) return m.slice(0, 3).map(Number);
    }
    return [255, 255, 255];
}
const theme = (el) => {
    const [r, g, b] = canvasOf(el);
    return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128
        ? { themeFg: "#f3f4f6", themeBg: `rgb(${r}, ${g}, ${b})`, themeAccent: "#118dff" }
        : { themeFg: "#252423", themeAccent: "#118dff" };
};

// ONE group over ONE source table: a data.sample.json's positional rows, as objects.
export async function createGroup(sampleUrl, dotnet) {
    const sample = JSON.parse(await text(sampleUrl));
    const cols = sample.columns;
    const rows = sample.rows.map(r => Object.fromEntries(cols.map((c, i) => [c.name, r[i]])));
    const group = createChartGroup(cols, rows);
    const off = group.onChange(sel => dotnet.invokeMethodAsync("OnSelectionChanged", sel.sourceId, [...sel.rows]));
    return {
        async mount(el, spec) {                          // spec: { id, dir, height, respondsWith, geoKind }
            const code = await text(`${spec.dir}/chart.js`);   // TEXT: chart-host compiles it and injects d3
            if (spec.geoKind) await loadGeo(spec.geoKind);     // BEFORE the first render: render() is synchronous
            const opts = spec.respondsWith ? { respondsWith: spec.respondsWith } : {};
            const host = createChartHost(el, {
                code, d3, geoKind: spec.geoKind || undefined,
                data: group.memberPayload(spec.id, opts).payload,
                options: { width: Math.floor(el.getBoundingClientRect().width), height: spec.height, ...theme(el) },
            });
            host.render();
            const member = group.attach(spec.id, host, opts);  // its clicks publish SOURCE rows
            let w = Math.floor(el.getBoundingClientRect().width);
            const ro = new ResizeObserver(() => {         // LATER resizes only; the first size is measured above
                const now = Math.floor(el.getBoundingClientRect().width);
                if (now > 0 && now !== w) { w = now; host.setOptions({ width: now }); }
            });
            ro.observe(el);
            const mq = matchMedia("(prefers-color-scheme: dark)");
            const onScheme = () => host.setOptions(theme(el));   // the page restyled itself; follow it
            mq.addEventListener("change", onScheme);
            let alive = true;
            return {
                setOptions(patch) { if (alive) host.setOptions(patch); },   // live restyle, never a regeneration
                destroy() {
                    if (!alive) return;
                    alive = false;
                    ro.disconnect();
                    mq.removeEventListener("change", onScheme);
                    member.detach();
                    host.destroy();                       // stops animation timers too
                },
            };
        },
        clear() { group.clear(); },
        dispose() { off(); group.destroy(); },
    };
}
```

`Components/BicChartGroup.razor` - holds the JS group; its children wait for it:

```razor
@implements IAsyncDisposable
@inject IJSRuntime JS

<CascadingValue Value="this" IsFixed="true">
    @ChildContent
</CascadingValue>

@code {
    /// <summary>The data.sample.json whose rows are the one source table every chart draws from.</summary>
    [Parameter, EditorRequired] public string Source { get; set; } = "";
    [Parameter] public RenderFragment? ChildContent { get; set; }
    /// <summary>The chart that made the selection (null when cleared) and its SOURCE rows.</summary>
    [Parameter] public EventCallback<(string? SourceId, int[] Rows)> OnSelection { get; set; }

    readonly TaskCompletionSource<IJSObjectReference> ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
    IJSObjectReference? module, group;
    DotNetObjectReference<BicChartGroup>? self;

    public Task<IJSObjectReference> Ready => ready.Task;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        try
        {
            module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/bic/bic.entry.js");
            self = DotNetObjectReference.Create(this);
            group = await module.InvokeAsync<IJSObjectReference>("createGroup", Source, self);
            ready.TrySetResult(group);
        }
        catch (Exception e) { ready.TrySetException(e); throw; }
    }

    [JSInvokable]
    public Task OnSelectionChanged(string? sourceId, int[] rows) => OnSelection.InvokeAsync((sourceId, rows));

    public async Task ClearAsync()
    {
        if (group is not null) await group.InvokeVoidAsync("clear");
    }

    public async ValueTask DisposeAsync()
    {
        try
        {
            if (group is not null) { await group.InvokeVoidAsync("dispose"); await group.DisposeAsync(); }
            if (module is not null) await module.DisposeAsync();
        }
        catch (JSDisconnectedException) { }   // Blazor Server: the circuit is already gone
        self?.Dispose();
    }
}
```

`Components/BicChart.razor` - one chart:

```razor
@implements IAsyncDisposable

<div @ref="el" class="bic-chart" data-bic-chart="@Id" style="height:@(Height)px"></div>

@code {
    [CascadingParameter] public BicChartGroup Group { get; set; } = default!;
    [Parameter, EditorRequired] public string Id { get; set; } = "";
    /// <summary>The generate_chart out_dir under wwwroot, e.g. "charts/map".</summary>
    [Parameter, EditorRequired] public string Dir { get; set; } = "";
    [Parameter] public int Height { get; set; } = 420;
    /// <summary>"highlight" keeps every mark and dims the rest; the default filters.</summary>
    [Parameter] public string? RespondsWith { get; set; }
    /// <summary>A map's geo.kind from the generate_chart result (e.g. "us-state-name").</summary>
    [Parameter] public string? GeoKind { get; set; }

    ElementReference el;
    IJSObjectReference? chart;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        var group = await Group.Ready;
        chart = await group.InvokeAsync<IJSObjectReference>("mount", el,
            new { id = Id, dir = Dir, height = Height, respondsWith = RespondsWith, geoKind = GeoKind });
    }

    /// <summary>A live restyle: merged into the chart's options and repainted.</summary>
    public async Task SetOptionsAsync(object patch)
    {
        if (chart is not null) await chart.InvokeVoidAsync("setOptions", patch);
    }

    public async ValueTask DisposeAsync()
    {
        try
        {
            if (chart is not null) { await chart.InvokeVoidAsync("destroy"); await chart.DisposeAsync(); }
        }
        catch (JSDisconnectedException) { }
    }
}
```

Add `@using MyCharts.Components` to `_Imports.razor`, then on a page:

```razor
<BicChartGroup Source="charts/map/data.sample.json" OnSelection="OnSelection" @ref="group">
    <button data-bic-clear disabled="@(selected == 0)" @onclick="() => group!.ClearAsync()">Clear</button>
    <BicChart Id="map"  Dir="charts/map"  Height="480" RespondsWith="highlight" GeoKind="us-state-name" @ref="map" />
    <BicChart Id="bars" Dir="charts/bars" Height="360" />
</BicChartGroup>

@code {
    BicChartGroup? group;
    BicChart? map;
    int selected;
    void OnSelection((string? SourceId, int[] Rows) s) { selected = s.Rows.Length; StateHasChanged(); }
    // a restyle control: await map!.SetOptionsAsync(new { palette = new[] { "#1b9e77", "#d95f02" } });
}
```

- **Source** - every chart generated from the same CSV carries the same rows, so any chart's
  `data.sample.json` is the source table. Take the map's: it also holds the `__geo*` columns
  the tool resolved.
- **Restyle** - a chart honours only the options its code reads, so a live restyle control has to
  set one of those. The generate result's `optionsRead` lists them for that chart, and its
  `integrationContract` says what each does. The common ones: `palette` (a categorical colour
  list), `colorScaleLow`/`colorScaleHigh` (a sequential ramp's ends, what a choropleth reads),
  `maxMapPoints` (a point map's cap). An option not in `optionsRead` does nothing on that chart.
- **GeoKind** - the result's `geo.kind` (the text reply's `Geo:` line). Without it a map draws
  its marks over no land. `loadGeo` takes that geometry from chart-host itself, so the
  `data.geo.json` the tool also writes beside a map is the same shapes as a file: the page never
  fetches it.
- **Interop only in `OnAfterRenderAsync(firstRender)`** - an `ElementReference` is empty until
  the element is rendered, and under prerendering there is no JavaScript yet. The components
  above already do this, so they run unchanged under Blazor Server or Auto.
- **Dispose** - `destroy` stops a chart's timers and detaches it from the group; skip it and an
  animated chart keeps ticking after you navigate away.
- **Measure, then mount** - the chart takes the element's width once it is laid out, so give
  `.bic-chart` its width in CSS (a block element in a sized column); the height is the
  `Height` parameter. A `display:none` container measures 0 and draws nothing.
- **Light and dark** - `theme(el)` reads the background the chart sits on, so the chart follows the
  PAGE. For a page that follows the reader, switch the page's own colours in CSS
  (`@media (prefers-color-scheme: dark) { ... }` on `body` or the chart's card) and the charts follow
  on the next `change` of the media query.

## 4. Things that will otherwise cost you time

- **Place names, not coordinates.** If your data has city/state/ZIP but no lat/lon, pass a
  `point` binding and the host resolves coordinates. Never hardcode a city→coordinate table
  from memory: it breaks silently off famous cities and reports nothing. The host reports
  what it achieved in `options.geoPoint` — `precisionCounts` (rows per tier) and
  `unplaced` — so a chart can annotate honestly instead of implying exact positions.
- **Both code forms work.** `wrap: "esm"` appends `export { render };` so the file can be
  imported; chart-host strips the clause before compiling. Either form runs anywhere.
- **D3 plugins.** Some charts call `d3.sankey`, `d3.hexbin` etc., which live in separate
  packages. `requiredD3Plugins(code)` tells you which to install **before** rendering;
  `requiredD3Plugins` on the MCP result says the same thing.
- **Read `integrationContract`** in the MCP result if you need anything beyond this file —
  it describes the exact payload and option shape for the chart you just generated, and the
  framework-free wiring (`createChartGroup` + `createChartHost` + `attach`).
- **Basemap geometry.** The React `<BicChart>` fetches the geometry for its `geoKind`
  itself (chart-host ≥ 0.4.1) and re-renders when it lands. To skip the one-frame basemap
  pop-in, `await loadGeo("north-america")` (from `@bicharts/chart-host`) before the first
  mount. Outside React, `render()` is synchronous by contract, so that preload is
  **required** — a cold cache draws marks over no land, with only a console warning.

## 5. Check it

Verify in a real browser, not jsdom — a chart can pass a DOM-shape assertion and paint
nothing. Assert marks carry `data-row-idx`, that clicking one changes the other chart, and
that `Clear` restores both. Each chart answers a selection in its own way, so assert per role:

| Chart | What it shows while something is selected |
| --- | --- |
| the one you clicked | `lch-has-selection` on its container, `lch-mark-selected` on the marks you picked; every mark stays |
| a `respondsWith: "highlight"` member | the same two classes, on the sibling's rows; every mark stays |
| a filtering member (the default) | NEITHER class: it redraws with only the selected rows, so its count of row marks drops |

Count row marks by DISTINCT single `data-row-idx` values. A legend swatch or an axis label
carries the same attribute with a comma-joined list of every row it covers (`"1,2,4,5"`), and a
one-row table's header cells carry that row's index too, so `[data-row-idx]` alone over-counts.
Read the selection from those classes rather than from paint: dimming is opacity on most charts,
but a chart may answer a highlight by recolouring its selected marks instead. If a check fails,
suspect the probe before the product: query `fillOpacity` as well as `opacity` when testing
dimming, click with `force` (a chart's zoom or tooltip overlay can sit over a mark at some
sizes), and match buttons by role rather than by text that also appears in hint copy.

### Checking a Blazor app

Install the browser driver INTO the project - its `package.json` is there, so nothing walks up - and run
the check below against the running app. Never `npm install` in a fresh scratch folder for this:
`npm init -y` refuses a folder name that starts with a dot (`.verify` fails as "Invalid name"), and
the install that follows then writes into the nearest parent folder that has a `package.json`.

```powershell
npm install -D playwright
npx playwright install chromium
dotnet run --urls http://localhost:5080      # leave it running; a second terminal for the next line
node check.mjs http://localhost:5080/
```

`check.mjs` reads the two `[data-bic-chart]` containers and the `[data-bic-clear]` button the blocks above carry,
and asserts each chart by its role in the table: the clicked chart shows its mark, the sibling answers
(classes for a highlight member, fewer rows for a filtering one), Clear restores both, Ctrl-click adds and
removes one mark, and no page error is raised. It passes unchanged on a map + bubble pair and a map +
table pair.

```js
// node check.mjs http://localhost:5080/  - drive the running app in headless Chromium and check each chart
// answers a selection the way its role says (see the table above). Exits 1 on any failure.
import { chromium } from "playwright";

const url = process.argv[2] ?? "http://localhost:5080/";
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
const errors = [];
page.on("pageerror", e => errors.push(String(e)));
let failed = 0;
const check = (ok, what) => { console.log((ok ? "PASS  " : "FAIL  ") + what); if (!ok) failed++; };

await page.goto(url, { waitUntil: "networkidle" });
await page.waitForFunction(() => {
    const cs = [...document.querySelectorAll("[data-bic-chart]")];
    return cs.length >= 2 && cs.every(c => c.querySelector(".d3-mark[data-row-idx]"));
}, null, { timeout: 30000 });

// Per chart: its distinct SINGLE-row marks (a legend swatch or a header lists several rows, comma-joined),
// whether it shows a selection, and how many marks are lit.
const state = () => page.$$eval("[data-bic-chart]", cs => cs.map(c => {
    const rows = [...c.querySelectorAll(".d3-mark[data-row-idx]")].map(m => m.getAttribute("data-row-idx"))
        .filter(v => v && !v.includes(","));
    return { rows: [...new Set(rows)], selected: c.classList.contains("lch-has-selection"),
             lit: c.querySelectorAll(".lch-mark-selected").length };
}));
const click = (chart, row, modifiers = []) =>
    page.locator("[data-bic-chart]").nth(chart).locator(`.d3-mark[data-row-idx="${row}"]`).first()
        .click({ force: true, modifiers });                 // force: an overlay may sit over a mark
const settle = () => page.waitForTimeout(600);
const clear = async () => { await page.click("[data-bic-clear]"); await settle(); };

const rest = await state();
for (const [src, dst] of [[0, 1], [1, 0]]) {
    await click(src, rest[src].rows.at(-1));
    await settle();
    const now = await state();
    check(now[src].selected && now[src].lit > 0, `chart ${src + 1} shows the mark it was clicked on`);
    // A highlight member shows the classes; a filtering member redraws with fewer rows.
    check(now[dst].selected || now[dst].rows.length < rest[dst].rows.length, `chart ${dst + 1} answers it`);
    await clear();
    const back = await state();
    check(!back[src].selected && back[dst].rows.length === rest[dst].rows.length, `Clear restores both`);
}

await click(0, rest[0].rows[0]); await settle();
const one = (await state())[0].lit;
await click(0, rest[0].rows[1], ["ControlOrMeta"]); await settle();
check((await state())[0].lit > one, "Ctrl-click adds a mark");
await click(0, rest[0].rows[1], ["ControlOrMeta"]); await settle();
check((await state())[0].lit === one, "Ctrl-click on a selected mark removes just that one");
await clear();

check(errors.length === 0, "no page errors" + (errors.length ? ": " + errors.join(" | ") : ""));
await browser.close();
process.exit(failed ? 1 : 0);
```
