@bicharts/chart-host
v0.5.19
Published
Run a BIC-generated D3 chart in any web host: compiles the generated render() function, applies the shared option defaults, resolves mark clicks (through tooltip overlays), owns the selection affordance, and translates row indices between cross-filtered c
Readme
@bicharts/chart-host
Run a D3 chart generated by Business Intelligence Champions (BIC) in any web page.
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. This package is the host side: the ~80 lines every integrator would otherwise write, and
the handful they would get subtly wrong.
It is the same contract the BIC Power BI visual implements, minus Power BI — so a chart looks and behaves the same wherever it runs.
What it does for you
- Compiles the generated code and injects your
d3. - Applies the shared option defaults/clamps — the same
resolveOptionsthe visual uses. - Resolves mark clicks — including through a chart's own tooltip overlay, and by geometry when a row has no hittable shape.
- Multi-select on Ctrl / Cmd / Shift — the same gesture Power BI uses. Modifier-clicking toggles each row, so clicking a selected mark again removes it.
- Owns the selection affordance — dims unselected marks via the
lch-*class grammar. - Translates row indices between cross-filtered charts (see the hazard below).
- Pins typography at the container boundary so your page's CSS can't slice chart labels.
- Tears down cleanly — StrictMode-safe, stops animation timers.
Install
npm install @bicharts/chart-host
npm install d3@7 # peer requirement of the CHART, not of this package
npm install -D @types/d3 # TypeScript only — d3 ships no typesThis package itself has no runtime dependencies — the shape profiler is bundled in, and the map geometry loads on demand.
React is an optional peer — a vanilla host never resolves it.
React
import * as d3 from "d3";
import { BicChart, BicChartGroup } from "@bicharts/chart-host/react";
import code from "./charts/revenue/chart.js?raw"; // Vite; any raw-text import works
import payload from "./charts/revenue/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]])));
<BicChartGroup columns={columns} rows={rows}>
<BicChart id="revenue" code={code} d3={d3} options={{ width, height }} />
<BicChart id="detail" code={detailCode} d3={d3} options={{ width, height }} />
</BicChartGroup>Clicking a mark in one chart filters the other. The group owns the source table and the payload→source row mapping — which is the part you must not hand-roll:
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 now denotes a different record. Comparing indices across two charts does not throw — it quietly filters to the wrong thing.
The coordinated dashboard: one chart filters, the other highlights
The pairing almost every dashboard actually wants. A table filters down to the selection; a map keeps every bubble and dims the rest, because filtering a map to one city throws the geography away and leaves nothing to click to change the selection.
<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 the whole recipe. respondsWith="highlight" keeps the full payload and paints the
sibling's selection onto the marks, so the map is re-painted, never re-rendered — it holds
its projection, zoom and basemap while the table filters beside it. The rest follows from rules
the group already enforces:
- A chart never filters itself. The origin keeps all its marks with the selected ones lit, so the gesture stays reversible.
- Mutual by default. Click a bubble → the table filters; click a row → the map highlights.
Add
filteredBy="table"only when you want one-way wiring. - Clearing is
group.clear(), or clicking the selected mark again.
Outside React it is host.selection.highlight(rowIdxs) on the chart that should dim, and
setData() with a filtered payload on the one that should filter. Both notify subscribers with
source "host" — that tag is what stops two linked charts republishing each other's selection
and fighting.
Do not reach for the lch-* classes to do this by hand. They are an implementation detail
of the affordance, they are re-applied on every render (so a manual classList poke is lost at
the next data change), and getting row indices right across a filtered payload is the hazard
above.
Vanilla
import { createChartHost } from "@bicharts/chart-host";
const host = createChartHost(el, { code, data, d3, options: { width: 800, height: 500 } });
host.render();
host.selection.onChange(rowIdxs => { /* cross-filter your other panels */ });
host.setOptions({ colorScaleLow: "#eee" }); // live restyle — never a regeneration
host.destroy(); // call on unmountSizing is yours
width/height have no defaults. A chart draws once at whatever it is handed and does not
re-measure, so a container measured at 0 draws a 0px chart forever. Measure synchronously, and
use a ResizeObserver only for later resizes — gating the first paint on one costs seconds in
a background or headless tab, where frames are barely produced.
useLayoutEffect(() => setW(ref.current.getBoundingClientRect().width), []);Maps
Geometry is not bundled into the entry — the three assets are ~1.3 MB raw, and a bar chart should not pay for a basemap. Choose one:
import { loadGeo } from "@bicharts/chart-host"; // per-asset dynamic import (own chunk)
await loadGeo("us-state-code");
createChartHost(el, { code, data, d3, geoKind: "us-state-code" });
import { geoForKind } from "@bicharts/chart-host/geo"; // or bundle it yourself
createChartHost(el, { code, data, d3, geoKind, geoProvider: geoForKind });
createChartHost(el, { code, data, d3, options: { geo } }); // or pass data.geo.json directlyThe React <BicChart> handles this itself: give it geoKind and it fetches the geometry when
the cache is cold, re-rendering with the basemap when it lands. await loadGeo(kind) before
mounting still works and skips the one-frame pop-in. Outside React, render() is synchronous
by contract, so setting geoKind with no geometry available warns and draws without a
basemap — load or provide geometry first.
D3 plugins
Some charts call d3.sankey(), d3.hexbin() or the voronoi family. Those are separate packages
that attach onto the d3 object — install them and augment the same d3 you pass in:
import * as d3 from "d3";
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
Object.assign(d3, { sankey, sankeyLinkHorizontal });The integration contract returned by generate_chart names exactly which ones your chart
needs. If one is missing, the host throws a message naming the package instead of a bare
d3.sankey is not a function.
You can also ask the code itself, before rendering it — which is what you want when the chart arrives at run time and a blank frame is not an acceptable answer:
import { requiredD3Plugins } from "@bicharts/chart-host";
requiredD3Plugins(code); // -> ["d3-sankey"] (sorted, deduped, [] for core-only d3)It is a static scan, not a trial render: synchronous, side-effect free, and safe to run on generated code you have not executed yet. It errs toward naming a plugin the chart may not reach — a needless install is cheaper than a blank chart.
Selection styling
Generated code emits marks and data-row-idx; it never styles "selected", because only the host
knows what is selected. This package injects the rules, using the same grammar as the Power BI
visual — override them if you want a different look:
.bic-chart-host { --lch-dim-opacity: 0.25; }
.bic-chart-host.lch-has-selection .d3-mark:not(.lch-mark-selected) { opacity: var(--lch-dim-opacity); }Architecture — three layers, and why it matters if you contribute
Read this before adding code. The package has three concerns, and they are independent. Most well-meaning changes that damage it do so by collapsing two of them.
| layer | knows about | must not know about | | --- | --- | --- | | 1 · Core | rows, marks as a concept, selection state, the cross-filter protocol, option resolution | any charting library, any host | | 2 · Renderer adapter | how one library expresses a binding, and where its marks live | the host it is running in | | 3 · Host glue | one host's lifecycle, identity model and settings | a library's internals |
Layer 3 is not in this package — it is the integrator's. createChartHost is the glue for a
plain web page; the BIC Power BI visual has its own, because a report host's lifecycle is
genuinely different. That separation is deliberate: merging them would drag host concepts into a
package whose entire value is not having them.
Layer 2 is the one that is easy to get wrong, because today only one adapter exists (D3) and its assumptions are easy to mistake for universal truths. They are not. Two examples from charts BIC already generates:
- Marks are not always DOM elements. D3 tags them in the DOM (
.d3-mark+data-row-idx). Other libraries carry the row binding in their data model instead, on the trace or point. An abstraction phrased asquerySelectorAll(markClass)is D3's implementation wearing the costume of an interface. - A renderer may support no interaction at all. Some chart types render to a raster image — no DOM, no marks, nothing to hit-test. A null adapter has to be legal.
So: if you are adding support for another charting library, you should be implementing layer 2 and touching nothing else. If your change needs edits in the core to make one library work, the seam is in the wrong place — please open an issue rather than widening the core.
Known wart, and it is ours, not yours. contract.ts currently mixes layers 1 and 2:
MARK_CLASS ("d3-mark") and ROW_IDX_ATTR are D3's binding mechanism, while
XFILTER_REFRESH_EVENT, the lch-* selection classes and DIM_OPACITY_VAR are genuinely
renderer-neutral. The names are historical. Do not take the file's current shape as licence to
add more renderer-specific constants to it.
Versioning
HOST_CONTRACT_VERSION is the grammar version (mark classes, container slots, the cross-filter
event). generate_chart stamps it into every artifact it writes; this package warns if it runs
a chart from a different major. Package SemVer tracks the JS API; the contract version tracks
the DOM/interaction grammar, and they move independently.
Licence and attribution
Apache-2.0 (see LICENSE).
The package embeds geographic reference data, and one source carries a condition, not a
courtesy: city and administrative-region coordinates come from
GeoNames under CC BY 4.0, which requires attribution to travel
with any redistribution — including an application that bundles this package. The credit is in
NOTICE and in a banner comment at the top of every built file, so keeping either satisfies it.
Country polygons (Natural Earth) and US state/ZIP boundaries (US Census/TIGER) are public domain.
If your build strips comments and you do not ship NOTICE, reproduce this line somewhere a
recipient can find it:
This work includes data from GeoNames (https://www.geonames.org/), licensed under CC BY 4.0.
Building from source
npm install && npm run build # esbuild bundle (+ .d.ts) into dist/
npm testThe build inlines @bicharts/shape-core, which lives outside this package root — that is
deliberate, so the repo keeps one copy of the geo detection logic and the published artifact
still stands alone. npm pack rebuilds first (prepack); do not publish a stale dist/.
