@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/README.md +35 -3
- package/dist/index.mjs +58 -58
- package/package.json +5 -4
- package/skills/bic-charts/SKILL.md +406 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bicharts/chart-mcp",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
50
|
-
"@bicharts/shape-core": "^0.6.
|
|
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
|
-
`
|
|
163
|
-
- **Read `
|
|
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.
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
```
|