@bicharts/chart-mcp 0.6.36 → 0.6.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-mcp",
3
- "version": "0.6.36",
3
+ "version": "0.6.46",
4
4
  "description": "MCP (Model Context Protocol) stdio server for BIC AI charts: profile a dataset locally, then generate chart code that has passed the BIC backend's render gates. Works with any MCP-capable client (Claude Code, Claude Desktop, Cursor, Copilot Studio). Requires a BIC trial or paid account.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -12,7 +12,8 @@
12
12
  "plotly",
13
13
  "bic"
14
14
  ],
15
- "homepage": "https://bizintelligencechampions.com",
15
+ "homepage": "https://bizintelligencechampions.com/regiabi/developers?loc=npmmcp",
16
+ "mcpName": "com.regiabi/chart-mcp",
16
17
  "author": "CodeX Enterprises LLC",
17
18
  "license": "SEE LICENSE IN LICENSE.txt",
18
19
  "type": "module",
@@ -46,8 +47,8 @@
46
47
  "zod": "^3.23.0"
47
48
  },
48
49
  "devDependencies": {
49
- "@bicharts/chart-host": "^0.6.36",
50
- "@bicharts/shape-core": "^0.6.36",
50
+ "@bicharts/chart-host": "^0.6.45",
51
+ "@bicharts/shape-core": "^0.6.45",
51
52
  "@types/node": "^20.0.0",
52
53
  "@types/papaparse": "^5.3.14",
53
54
  "esbuild": "^0.28.1",
@@ -51,7 +51,10 @@ picture, not the looser one.
51
51
  **Then profile before you commit.** `assess_data_shape` is free, local, and needs no credentials —
52
52
  it never leaves your machine. Run it on a candidate pull, look at what came back, and adjust the
53
53
  query until the shape is what you meant. Only then spend a metered `list_eligible_charts` call.
54
- Getting the grain wrong is cheap to fix here and expensive to fix after generation.
54
+ Getting the grain wrong is cheap to fix here and expensive to fix after generation. For a second
55
+ chart, read each measure's `collinearWithMeasures` and `maxAbsMeasureCorrelation`: two measures
56
+ that move together (a correlation near 1) only repeat each other in a partner chart, and one that
57
+ doesn't is the one worth showing beside it.
55
58
 
56
59
  **When several charts genuinely fit, ASK — do not quietly pick one.** A shape that supports a
57
60
  beeswarm often supports a violin, a box plot and a strip plot too, and those answer subtly
@@ -77,6 +80,66 @@ npm install -D @types/d3
77
80
  `d3` is a peer requirement of the **chart**, not of chart-host. `@types/d3` only for
78
81
  TypeScript. chart-host has no runtime dependencies.
79
82
 
83
+ ### Blazor WebAssembly (.NET 10)
84
+
85
+ A chart runs in the browser, so a Blazor app hosts it through JavaScript: **one** small JS
86
+ module owns the charts and their cross-filter group, esbuild bundles it during `dotnet build`,
87
+ and two Razor components (section 3) hand it the elements to draw into. No chart payload ever
88
+ crosses the interop boundary, and nothing here calls a server at run time.
89
+
90
+ ```powershell
91
+ dotnet new blazorwasm -o MyCharts -f net10.0
92
+ cd MyCharts
93
+ dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature
94
+ npm init -y
95
+ npm install d3@7 @bicharts/chart-host
96
+ npm install -D esbuild
97
+ ```
98
+
99
+ - **`npm init -y` comes FIRST, in the project folder.** With no `package.json` there, `npm install`
100
+ walks up and installs into the nearest parent folder that has one - a real consequence: it edited
101
+ a `package.json` two levels above the project. If an install is refused or interrupted, run the
102
+ `npm init -y` line again before retrying.
103
+ - **`global.json`** pins the build to the newest installed .NET 10 SDK. Without it a machine that
104
+ also has a newer or preview SDK builds with that one.
105
+ - These blocks are written against `@bicharts/chart-host` 0.6.33 or later; every name they import
106
+ is exported there.
107
+ - npm 11 may warn that esbuild's postinstall is "not yet covered by allowScripts". The build doesn't
108
+ need it: esbuild's platform binary arrives as an ordinary dependency, and the bundling target runs.
109
+
110
+ Add this to the `.csproj`, inside `<Project>`, whole:
111
+
112
+ ```xml
113
+ <PropertyGroup>
114
+ <DefaultItemExcludes>$(DefaultItemExcludes);node_modules/**</DefaultItemExcludes>
115
+ </PropertyGroup>
116
+ <Target Name="BundleBicCharts" BeforeTargets="BeforeBuild">
117
+ <Exec Command="npm ci" Condition="!Exists('node_modules')" />
118
+ <RemoveDir Directories="wwwroot/js/bic" />
119
+ <Exec Command="npx esbuild Scripts/bic.entry.js --bundle --format=esm --splitting --minify --outdir=wwwroot/js/bic" />
120
+ <ItemGroup>
121
+ <Content Include="wwwroot/js/bic/**" Exclude="@(Content)" />
122
+ </ItemGroup>
123
+ </Target>
124
+ ```
125
+
126
+ Each line is there because leaving it out fails quietly:
127
+
128
+ - **`node_modules/**` excluded** - otherwise MSBuild globs thousands of package files into the
129
+ project on every build.
130
+ - **The source lives in `Scripts/`, the bundle in `wwwroot/js/bic/`** - `wwwroot` is published
131
+ as-is, and the unbundled entry (bare `import "d3"`) cannot load in a browser.
132
+ - **`--splitting`** - map geometry (about 1.3 MB across three assets) stays in chunks fetched
133
+ only when a map asks for one. Without it every page downloads every basemap.
134
+ - **The `Content` item inside the target** - the bundle is written DURING the build, after the
135
+ project already globbed `wwwroot`. Without it a clean build (a fresh clone, CI, `dotnet
136
+ publish`) ships no bundle and the page's `import` 404s, while your own incremental builds
137
+ keep working.
138
+ - **`RemoveDir`** - chunk names are content hashes, so stale chunks would pile up.
139
+
140
+ `.gitignore`: `bin/`, `obj/`, `node_modules/`, `wwwroot/js/bic/` (build output). Commit
141
+ `package.json` and `package-lock.json` - the target's `npm ci` needs the lock.
142
+
80
143
  ## 2. Generate — say what the output must BE, not just what it should look like
81
144
 
82
145
  Call `generate_chart` once per chart, with `out_dir` so the artifacts land on disk.
@@ -103,6 +166,18 @@ disqualifies it), `renderer: "D3"` for a web app, `width`/`height`, and
103
166
  **Generation takes minutes.** Fire the calls for independent charts **in parallel** rather
104
167
  than in sequence — it is the difference between ~4 minutes and ~12.
105
168
 
169
+ **Where the files land.** `out_dir` and `csv_path` resolve against the MCP server's working
170
+ directory (where the agent session started), not your project, so pass absolute paths.
171
+ React: `src/charts/<name>`. Blazor: `wwwroot/charts/<name>` - the page fetches them as static
172
+ files. The language auto-detect also reads that working directory, and a .NET project gives it
173
+ nothing to read, so for Blazor name `renderer: "D3"` (chart-host runs D3 charts).
174
+
175
+ **With `out_dir`, the reply leaves the code out** (`include_code: false` is the default then; pass
176
+ it explicitly on an older chart-mcp). The code is already on disk, and a reply that carries it can
177
+ outgrow your client's tool-output limit: a 68 KB choropleth once came back as "result exceeds
178
+ maximum allowed tokens", saved to a file the agent then had to parse. Everything else you need -
179
+ `geo.kind`, the plugins, the credits, the paths - stays in the reply.
180
+
106
181
  ## 3. Wire it up
107
182
 
108
183
  Import the generated code as raw text and the sample payload as JSON:
@@ -148,6 +223,237 @@ change), and translating row indices across a filtered payload is the hazard bel
148
223
  Sizing is yours. Give each `<BicChart>` a sized container and pass `width`/`height` in
149
224
  `options` — a chart handed `undefined` dimensions draws nothing visible.
150
225
 
226
+ ### Blazor - the same group over JS interop
227
+
228
+ No React here, so the group is chart-host's framework-free `createChartGroup` - the object
229
+ `<BicChartGroup>` wraps, with the same rules (a chart never filters itself, mutual by default,
230
+ `respondsWith: "highlight"` keeps every mark). Three files, copied whole. The gestures are
231
+ chart-host's, with nothing to wire: a click selects, clicking the same mark again clears,
232
+ Ctrl/Cmd/Shift-click adds or removes one mark, and a click on empty canvas clears.
233
+
234
+ `Scripts/bic.entry.js` - the only JavaScript the app owns:
235
+
236
+ ```js
237
+ import * as d3base from "d3";
238
+ import { assembleD3, createChartGroup, createChartHost, loadGeo } from "@bicharts/chart-host";
239
+
240
+ const d3 = assembleD3(d3base); // + any plugins a chart's contract names
241
+ const at = (path) => new URL(path, document.baseURI); // honours <base href> on a sub-path host
242
+ async function text(path) {
243
+ const r = await fetch(at(path));
244
+ if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
245
+ return r.text();
246
+ }
247
+ // Draw for the canvas the chart actually sits on - the nearest opaque background behind it. A page
248
+ // that follows the reader's light/dark preference switches that background; a light-only page keeps
249
+ // it light, and the chart's text stays dark there even when the reader's system is dark.
250
+ function canvasOf(el) {
251
+ for (let e = el; e; e = e.parentElement) {
252
+ const m = getComputedStyle(e).backgroundColor.match(/[0-9.]+/g);
253
+ if (m && (m.length < 4 || +m[3] > 0.5)) return m.slice(0, 3).map(Number);
254
+ }
255
+ return [255, 255, 255];
256
+ }
257
+ const theme = (el) => {
258
+ const [r, g, b] = canvasOf(el);
259
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128
260
+ ? { themeFg: "#f3f4f6", themeBg: `rgb(${r}, ${g}, ${b})`, themeAccent: "#118dff" }
261
+ : { themeFg: "#252423", themeAccent: "#118dff" };
262
+ };
263
+
264
+ // ONE group over ONE source table: a data.sample.json's positional rows, as objects.
265
+ export async function createGroup(sampleUrl, dotnet) {
266
+ const sample = JSON.parse(await text(sampleUrl));
267
+ const cols = sample.columns;
268
+ const rows = sample.rows.map(r => Object.fromEntries(cols.map((c, i) => [c.name, r[i]])));
269
+ const group = createChartGroup(cols, rows);
270
+ const off = group.onChange(sel => dotnet.invokeMethodAsync("OnSelectionChanged", sel.sourceId, [...sel.rows]));
271
+ return {
272
+ async mount(el, spec) { // spec: { id, dir, height, respondsWith, geoKind }
273
+ const code = await text(`${spec.dir}/chart.js`); // TEXT: chart-host compiles it and injects d3
274
+ if (spec.geoKind) await loadGeo(spec.geoKind); // BEFORE the first render: render() is synchronous
275
+ const opts = spec.respondsWith ? { respondsWith: spec.respondsWith } : {};
276
+ const host = createChartHost(el, {
277
+ code, d3, geoKind: spec.geoKind || undefined,
278
+ data: group.memberPayload(spec.id, opts).payload,
279
+ options: { width: Math.floor(el.getBoundingClientRect().width), height: spec.height, ...theme(el) },
280
+ });
281
+ host.render();
282
+ const member = group.attach(spec.id, host, opts); // its clicks publish SOURCE rows
283
+ let w = Math.floor(el.getBoundingClientRect().width);
284
+ const ro = new ResizeObserver(() => { // LATER resizes only; the first size is measured above
285
+ const now = Math.floor(el.getBoundingClientRect().width);
286
+ if (now > 0 && now !== w) { w = now; host.setOptions({ width: now }); }
287
+ });
288
+ ro.observe(el);
289
+ const mq = matchMedia("(prefers-color-scheme: dark)");
290
+ const onScheme = () => host.setOptions(theme(el)); // the page restyled itself; follow it
291
+ mq.addEventListener("change", onScheme);
292
+ let alive = true;
293
+ return {
294
+ setOptions(patch) { if (alive) host.setOptions(patch); }, // live restyle, never a regeneration
295
+ destroy() {
296
+ if (!alive) return;
297
+ alive = false;
298
+ ro.disconnect();
299
+ mq.removeEventListener("change", onScheme);
300
+ member.detach();
301
+ host.destroy(); // stops animation timers too
302
+ },
303
+ };
304
+ },
305
+ clear() { group.clear(); },
306
+ dispose() { off(); group.destroy(); },
307
+ };
308
+ }
309
+ ```
310
+
311
+ `Components/BicChartGroup.razor` - holds the JS group; its children wait for it:
312
+
313
+ ```razor
314
+ @implements IAsyncDisposable
315
+ @inject IJSRuntime JS
316
+
317
+ <CascadingValue Value="this" IsFixed="true">
318
+ @ChildContent
319
+ </CascadingValue>
320
+
321
+ @code {
322
+ /// <summary>The data.sample.json whose rows are the one source table every chart draws from.</summary>
323
+ [Parameter, EditorRequired] public string Source { get; set; } = "";
324
+ [Parameter] public RenderFragment? ChildContent { get; set; }
325
+ /// <summary>The chart that made the selection (null when cleared) and its SOURCE rows.</summary>
326
+ [Parameter] public EventCallback<(string? SourceId, int[] Rows)> OnSelection { get; set; }
327
+
328
+ readonly TaskCompletionSource<IJSObjectReference> ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
329
+ IJSObjectReference? module, group;
330
+ DotNetObjectReference<BicChartGroup>? self;
331
+
332
+ public Task<IJSObjectReference> Ready => ready.Task;
333
+
334
+ protected override async Task OnAfterRenderAsync(bool firstRender)
335
+ {
336
+ if (!firstRender) return;
337
+ try
338
+ {
339
+ module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/bic/bic.entry.js");
340
+ self = DotNetObjectReference.Create(this);
341
+ group = await module.InvokeAsync<IJSObjectReference>("createGroup", Source, self);
342
+ ready.TrySetResult(group);
343
+ }
344
+ catch (Exception e) { ready.TrySetException(e); throw; }
345
+ }
346
+
347
+ [JSInvokable]
348
+ public Task OnSelectionChanged(string? sourceId, int[] rows) => OnSelection.InvokeAsync((sourceId, rows));
349
+
350
+ public async Task ClearAsync()
351
+ {
352
+ if (group is not null) await group.InvokeVoidAsync("clear");
353
+ }
354
+
355
+ public async ValueTask DisposeAsync()
356
+ {
357
+ try
358
+ {
359
+ if (group is not null) { await group.InvokeVoidAsync("dispose"); await group.DisposeAsync(); }
360
+ if (module is not null) await module.DisposeAsync();
361
+ }
362
+ catch (JSDisconnectedException) { } // Blazor Server: the circuit is already gone
363
+ self?.Dispose();
364
+ }
365
+ }
366
+ ```
367
+
368
+ `Components/BicChart.razor` - one chart:
369
+
370
+ ```razor
371
+ @implements IAsyncDisposable
372
+
373
+ <div @ref="el" class="bic-chart" data-bic-chart="@Id" style="height:@(Height)px"></div>
374
+
375
+ @code {
376
+ [CascadingParameter] public BicChartGroup Group { get; set; } = default!;
377
+ [Parameter, EditorRequired] public string Id { get; set; } = "";
378
+ /// <summary>The generate_chart out_dir under wwwroot, e.g. "charts/map".</summary>
379
+ [Parameter, EditorRequired] public string Dir { get; set; } = "";
380
+ [Parameter] public int Height { get; set; } = 420;
381
+ /// <summary>"highlight" keeps every mark and dims the rest; the default filters.</summary>
382
+ [Parameter] public string? RespondsWith { get; set; }
383
+ /// <summary>A map's geo.kind from the generate_chart result (e.g. "us-state-name").</summary>
384
+ [Parameter] public string? GeoKind { get; set; }
385
+
386
+ ElementReference el;
387
+ IJSObjectReference? chart;
388
+
389
+ protected override async Task OnAfterRenderAsync(bool firstRender)
390
+ {
391
+ if (!firstRender) return;
392
+ var group = await Group.Ready;
393
+ chart = await group.InvokeAsync<IJSObjectReference>("mount", el,
394
+ new { id = Id, dir = Dir, height = Height, respondsWith = RespondsWith, geoKind = GeoKind });
395
+ }
396
+
397
+ /// <summary>A live restyle: merged into the chart's options and repainted.</summary>
398
+ public async Task SetOptionsAsync(object patch)
399
+ {
400
+ if (chart is not null) await chart.InvokeVoidAsync("setOptions", patch);
401
+ }
402
+
403
+ public async ValueTask DisposeAsync()
404
+ {
405
+ try
406
+ {
407
+ if (chart is not null) { await chart.InvokeVoidAsync("destroy"); await chart.DisposeAsync(); }
408
+ }
409
+ catch (JSDisconnectedException) { }
410
+ }
411
+ }
412
+ ```
413
+
414
+ Add `@using MyCharts.Components` to `_Imports.razor`, then on a page:
415
+
416
+ ```razor
417
+ <BicChartGroup Source="charts/map/data.sample.json" OnSelection="OnSelection" @ref="group">
418
+ <button data-bic-clear disabled="@(selected == 0)" @onclick="() => group!.ClearAsync()">Clear</button>
419
+ <BicChart Id="map" Dir="charts/map" Height="480" RespondsWith="highlight" GeoKind="us-state-name" @ref="map" />
420
+ <BicChart Id="bars" Dir="charts/bars" Height="360" />
421
+ </BicChartGroup>
422
+
423
+ @code {
424
+ BicChartGroup? group;
425
+ BicChart? map;
426
+ int selected;
427
+ void OnSelection((string? SourceId, int[] Rows) s) { selected = s.Rows.Length; StateHasChanged(); }
428
+ // a restyle control: await map!.SetOptionsAsync(new { palette = new[] { "#1b9e77", "#d95f02" } });
429
+ }
430
+ ```
431
+
432
+ - **Source** - every chart generated from the same CSV carries the same rows, so any chart's
433
+ `data.sample.json` is the source table. Take the map's: it also holds the `__geo*` columns
434
+ the tool resolved.
435
+ - **Restyle** - a chart honours only the options its code reads, so a live restyle control has to
436
+ set one of those. The generate result's `optionsRead` lists them for that chart, and its
437
+ `integrationContract` says what each does. The common ones: `palette` (a categorical colour
438
+ list), `colorScaleLow`/`colorScaleHigh` (a sequential ramp's ends, what a choropleth reads),
439
+ `maxMapPoints` (a point map's cap). An option not in `optionsRead` does nothing on that chart.
440
+ - **GeoKind** - the result's `geo.kind` (the text reply's `Geo:` line). Without it a map draws
441
+ its marks over no land. `loadGeo` takes that geometry from chart-host itself, so the
442
+ `data.geo.json` the tool also writes beside a map is the same shapes as a file: the page never
443
+ fetches it.
444
+ - **Interop only in `OnAfterRenderAsync(firstRender)`** - an `ElementReference` is empty until
445
+ the element is rendered, and under prerendering there is no JavaScript yet. The components
446
+ above already do this, so they run unchanged under Blazor Server or Auto.
447
+ - **Dispose** - `destroy` stops a chart's timers and detaches it from the group; skip it and an
448
+ animated chart keeps ticking after you navigate away.
449
+ - **Measure, then mount** - the chart takes the element's width once it is laid out, so give
450
+ `.bic-chart` its width in CSS (a block element in a sized column); the height is the
451
+ `Height` parameter. A `display:none` container measures 0 and draws nothing.
452
+ - **Light and dark** - `theme(el)` reads the background the chart sits on, so the chart follows the
453
+ PAGE. For a page that follows the reader, switch the page's own colours in CSS
454
+ (`@media (prefers-color-scheme: dark) { ... }` on `body` or the chart's card) and the charts follow
455
+ on the next `change` of the media query.
456
+
151
457
  ## 4. Things that will otherwise cost you time
152
458
 
153
459
  - **Place names, not coordinates.** If your data has city/state/ZIP but no lat/lon, pass a
@@ -159,9 +465,10 @@ Sizing is yours. Give each `<BicChart>` a sized container and pass `width`/`heig
159
465
  imported; chart-host strips the clause before compiling. Either form runs anywhere.
160
466
  - **D3 plugins.** Some charts call `d3.sankey`, `d3.hexbin` etc., which live in separate
161
467
  packages. `requiredD3Plugins(code)` tells you which to install **before** rendering;
162
- `structuredContent.plugins` on the MCP result says the same thing.
163
- - **Read `integration_contract`** in the MCP result if you need anything beyond this file —
164
- it describes the exact payload and option shape for the chart you just generated.
468
+ `requiredD3Plugins` on the MCP result says the same thing.
469
+ - **Read `integrationContract`** in the MCP result if you need anything beyond this file —
470
+ it describes the exact payload and option shape for the chart you just generated, and the
471
+ framework-free wiring (`createChartGroup` + `createChartHost` + `attach`).
165
472
  - **Basemap geometry.** The React `<BicChart>` fetches the geometry for its `geoKind`
166
473
  itself (chart-host ≥ 0.4.1) and re-renders when it lands. To skip the one-frame basemap
167
474
  pop-in, `await loadGeo("north-america")` (from `@bicharts/chart-host`) before the first
@@ -172,6 +479,98 @@ Sizing is yours. Give each `<BicChart>` a sized container and pass `width`/`heig
172
479
 
173
480
  Verify in a real browser, not jsdom — a chart can pass a DOM-shape assertion and paint
174
481
  nothing. Assert marks carry `data-row-idx`, that clicking one changes the other chart, and
175
- that `Clear` restores both. If a check fails, suspect the probe before the product: query
176
- `fillOpacity` as well as `opacity` when testing dimming, and match buttons by role rather
177
- than by text that also appears in hint copy.
482
+ that `Clear` restores both. Each chart answers a selection in its own way, so assert per role:
483
+
484
+ | Chart | What it shows while something is selected |
485
+ | --- | --- |
486
+ | the one you clicked | `lch-has-selection` on its container, `lch-mark-selected` on the marks you picked; every mark stays |
487
+ | a `respondsWith: "highlight"` member | the same two classes, on the sibling's rows; every mark stays |
488
+ | a filtering member (the default) | NEITHER class: it redraws with only the selected rows, so its count of row marks drops |
489
+
490
+ Count row marks by DISTINCT single `data-row-idx` values. A legend swatch or an axis label
491
+ carries the same attribute with a comma-joined list of every row it covers (`"1,2,4,5"`), and a
492
+ one-row table's header cells carry that row's index too, so `[data-row-idx]` alone over-counts.
493
+ Read the selection from those classes rather than from paint: dimming is opacity on most charts,
494
+ but a chart may answer a highlight by recolouring its selected marks instead. If a check fails,
495
+ suspect the probe before the product: query `fillOpacity` as well as `opacity` when testing
496
+ dimming, click with `force` (a chart's zoom or tooltip overlay can sit over a mark at some
497
+ sizes), and match buttons by role rather than by text that also appears in hint copy.
498
+
499
+ ### Checking a Blazor app
500
+
501
+ Install the browser driver INTO the project - its `package.json` is there, so nothing walks up - and run
502
+ the check below against the running app. Never `npm install` in a fresh scratch folder for this:
503
+ `npm init -y` refuses a folder name that starts with a dot (`.verify` fails as "Invalid name"), and
504
+ the install that follows then writes into the nearest parent folder that has a `package.json`.
505
+
506
+ ```powershell
507
+ npm install -D playwright
508
+ npx playwright install chromium
509
+ dotnet run --urls http://localhost:5080 # leave it running; a second terminal for the next line
510
+ node check.mjs http://localhost:5080/
511
+ ```
512
+
513
+ `check.mjs` reads the two `[data-bic-chart]` containers and the `[data-bic-clear]` button the blocks above carry,
514
+ and asserts each chart by its role in the table: the clicked chart shows its mark, the sibling answers
515
+ (classes for a highlight member, fewer rows for a filtering one), Clear restores both, Ctrl-click adds and
516
+ removes one mark, and no page error is raised. It passes unchanged on a map + bubble pair and a map +
517
+ table pair.
518
+
519
+ ```js
520
+ // node check.mjs http://localhost:5080/ - drive the running app in headless Chromium and check each chart
521
+ // answers a selection the way its role says (see the table above). Exits 1 on any failure.
522
+ import { chromium } from "playwright";
523
+
524
+ const url = process.argv[2] ?? "http://localhost:5080/";
525
+ const browser = await chromium.launch();
526
+ const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
527
+ const errors = [];
528
+ page.on("pageerror", e => errors.push(String(e)));
529
+ let failed = 0;
530
+ const check = (ok, what) => { console.log((ok ? "PASS " : "FAIL ") + what); if (!ok) failed++; };
531
+
532
+ await page.goto(url, { waitUntil: "networkidle" });
533
+ await page.waitForFunction(() => {
534
+ const cs = [...document.querySelectorAll("[data-bic-chart]")];
535
+ return cs.length >= 2 && cs.every(c => c.querySelector(".d3-mark[data-row-idx]"));
536
+ }, null, { timeout: 30000 });
537
+
538
+ // Per chart: its distinct SINGLE-row marks (a legend swatch or a header lists several rows, comma-joined),
539
+ // whether it shows a selection, and how many marks are lit.
540
+ const state = () => page.$$eval("[data-bic-chart]", cs => cs.map(c => {
541
+ const rows = [...c.querySelectorAll(".d3-mark[data-row-idx]")].map(m => m.getAttribute("data-row-idx"))
542
+ .filter(v => v && !v.includes(","));
543
+ return { rows: [...new Set(rows)], selected: c.classList.contains("lch-has-selection"),
544
+ lit: c.querySelectorAll(".lch-mark-selected").length };
545
+ }));
546
+ const click = (chart, row, modifiers = []) =>
547
+ page.locator("[data-bic-chart]").nth(chart).locator(`.d3-mark[data-row-idx="${row}"]`).first()
548
+ .click({ force: true, modifiers }); // force: an overlay may sit over a mark
549
+ const settle = () => page.waitForTimeout(600);
550
+ const clear = async () => { await page.click("[data-bic-clear]"); await settle(); };
551
+
552
+ const rest = await state();
553
+ for (const [src, dst] of [[0, 1], [1, 0]]) {
554
+ await click(src, rest[src].rows.at(-1));
555
+ await settle();
556
+ const now = await state();
557
+ check(now[src].selected && now[src].lit > 0, `chart ${src + 1} shows the mark it was clicked on`);
558
+ // A highlight member shows the classes; a filtering member redraws with fewer rows.
559
+ check(now[dst].selected || now[dst].rows.length < rest[dst].rows.length, `chart ${dst + 1} answers it`);
560
+ await clear();
561
+ const back = await state();
562
+ check(!back[src].selected && back[dst].rows.length === rest[dst].rows.length, `Clear restores both`);
563
+ }
564
+
565
+ await click(0, rest[0].rows[0]); await settle();
566
+ const one = (await state())[0].lit;
567
+ await click(0, rest[0].rows[1], ["ControlOrMeta"]); await settle();
568
+ check((await state())[0].lit > one, "Ctrl-click adds a mark");
569
+ await click(0, rest[0].rows[1], ["ControlOrMeta"]); await settle();
570
+ check((await state())[0].lit === one, "Ctrl-click on a selected mark removes just that one");
571
+ await clear();
572
+
573
+ check(errors.length === 0, "no page errors" + (errors.length ? ": " + errors.join(" | ") : ""));
574
+ await browser.close();
575
+ process.exit(failed ? 1 : 0);
576
+ ```