@bicharts/chart-host 0.6.113 → 0.6.115

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 CHANGED
@@ -113,6 +113,50 @@ of the affordance, they are re-applied on every render (so a manual `classList`
113
113
  the next data change), and getting row indices right across a filtered payload is the hazard
114
114
  above.
115
115
 
116
+ ### A panel whose query takes the page filter
117
+
118
+ A chart whose DAX query is filtered by a page filter (`filterQuery(dax, treatAs(filter, "Table[Column]"))` from
119
+ `@bicharts/chart-host/dax`) gets rows that are already narrowed, so it can't see the filter. `scopedBy` tells it,
120
+ and its titles say so ("North America · United States only"):
121
+
122
+ ```tsx
123
+ <BicChart id="kpis" code={kpiCode} d3={d3} data={kpiRows} scopedBy={country} />
124
+ ```
125
+
126
+ `treatAs` maps the key columns it's given, in the filter's order, so a query can take only part of a compound key.
127
+ `treatAs(cell, "Accounts[Region]")` on a Region × Stage pick filters by the region alone, and a stage-label pick
128
+ (any region) doesn't filter that query at all. Name the key columns the query takes, so the scope line names only
129
+ those:
130
+
131
+ ```tsx
132
+ <BicChart id="cases" code={casesCode} d3={d3} data={caseRows} scopedBy={{ filter: cell, takes: ["Region"] }} />
133
+ ```
134
+
135
+ A cell pick then reads "Europe", not "Europe → 3-Propose", and a pick whose taken parts are all "any" adds
136
+ nothing to the line, since that query keeps every row. A bare filter, `{ filter }` or a `takes` that names every
137
+ key column says the whole key. `label` sets what the line calls the filter. Outside React, `filterScopeOf` from
138
+ `@bicharts/chart-host/page` takes the same list and returns `options.filterScope`.
139
+
140
+ ### Several panels that pick the same key
141
+
142
+ A map, a table and a scatter that each pick a country are ONE pick. Give their filters one `group`, and keep each
143
+ panel's query taking every member but its own:
144
+
145
+ ```tsx
146
+ const fromMap = useBicFilter("Country", { label: "Country (map)", group: "country" });
147
+ const fromTable = useBicFilter("Country", { label: "Country (table)", group: "country" });
148
+ const mapQuery = filterQuery(MAP_DAX, treatAs(fromTable, "Geo[Country]"));
149
+ const tableQuery = filterQuery(TABLE_DAX, treatAs(fromMap, "Geo[Country]"));
150
+ // <MapChart selects={fromMap} scopedBy={[fromTable]} /> <TableChart selects={fromTable} scopedBy={[fromMap]} />
151
+ ```
152
+
153
+ A pick in one member clears the others, so the most recent pick is the page's one pick, and only the member holding
154
+ it is active: one chip, one scope line. Every member's chart shows the pick, so clicking the picked mark again in
155
+ whichever panel shows it clears it, and the panel the reader picked in keeps all its marks. A list beside them (the
156
+ open items for the picked countries) reads the members' keys, which are that one pick. Without `group` the filters
157
+ are separate picks, ANDed: a country picked on the map and then in the table is held twice, and clicking it again on
158
+ the map leaves the page filtered by the table's. Outside React, `scope.filter(column, { group })` does the same.
159
+
116
160
  ## Vanilla
117
161
 
118
162
  ```js
@@ -287,8 +331,34 @@ the host's own write cannot be told apart from it.
287
331
  createChartHost(el, { code, data, options: { dimOpacity: 0.15 } });
288
332
  ```
289
333
 
290
- **A glyph on each selected mark.** `options.selectionGlyph` (`"dot"` or `"check"`; absent or
291
- `"none"` draws nothing, the default) puts a small glyph on every selected mark, in a layer the host
334
+ **The selection marker: an outline or a glyph on each selected mark.** The dim alone fails where a
335
+ selected mark's colour sits close to the canvas: a diverging ramp's centre is a neutral just off the
336
+ canvas, and the dim pulls every other mark toward it too, so a selected region near the centre reads
337
+ the same as the dimmed ones. Two options choose what marks the selection beyond the dim:
338
+
339
+ - `options.selectionGlyph`, the *selection marker* on every chart that isn't a region map (the key
340
+ keeps its first name): `"auto"` (the default), `"none"`, `"dot"`, `"check"` or `"outline"`.
341
+ `"auto"` is an outline on a chart that declares a neutral-centre ramp (the shared value ramp's
342
+ diverging legend, `.llm-value-ramp[data-kind="diverging"]`) and nothing on any other chart, which
343
+ draws exactly as it did before.
344
+ - `options.mapSelectionGlyph`, the marker on a *region map* (a chart whose rows carry the host's
345
+ `__geoIso__` join column: a choropleth, a cartogram, a province map): `"outline"` (the default),
346
+ `"none"`, `"dot"` or `"check"`. A point map places its rows by coordinates and has no join column,
347
+ so it takes `selectionGlyph`.
348
+
349
+ An outline (`.lch-sel-outline`, in the same host layer as the glyphs) is each selected AREA mark's own
350
+ shape (a filled path, rect or polygon) stroked 2px in the chart's text colour (`themeFg`, else the
351
+ colour the container's text inherits) over a halo of its canvas (`themeBg`). It's drawn over the chart,
352
+ so a border shared with a neighbour isn't painted over, and the marks themselves are neither restyled
353
+ nor moved: the keyboard's order, a chart's transitions and what a click hits are unchanged. The chart's own
354
+ controls (every element it declares `lch-control`, such as a map's zoom pad) are holes in the outline (`controlBoxesIn`, a mask): a
355
+ region that runs under the pad is outlined up to it, never across the buttons. A line or a
356
+ point keeps its geometry and gets no outline. `resolveOptions` gives the whole choice as
357
+ `selectionMarker` and `mapSelectionMarker`; `selectionMarkerFor` and the traits behind it
358
+ (`isRegionMapData`, `hasNeutralCentreRamp`) and the painter (`collectOutlineMarks`,
359
+ `paintSelectionOutlines`) are exported for a host that draws its own layer.
360
+
361
+ A glyph (`"dot"` or `"check"`) is a small mark on every selected mark, in a layer the host
292
362
  owns inside the container (`.bic-selection-glyphs`, each glyph `.lch-sel-glyph`, never hit-tested).
293
363
  It is painted after every selection change and, on a redraw, after the layout pass and the fit, so
294
364
  nothing measures it as part of the chart. Only a mark the reader can see gets one: a mark hidden by
@@ -311,13 +381,14 @@ every point. The generate answer says so (`suppressSelectionGlyph`, read by shap
311
381
  `parseGenerateResponse`); pass it as `options.suppressSelectionGlyph`, or leave it in the payload's
312
382
  `meta` (`data.meta.suppressSelectionGlyph`) and the host promotes it, the way it promotes
313
383
  `geoPoint`. Keep it with the chart's code, so a redraw from a cache honors it too.
314
- `onSelectionGlyphs` reports what each paint did (`{ mode, drawn, total, suppressed }`), and is
315
- called only when a glyph mode is set:
384
+ The same fact keeps an outline off such a chart. `onSelectionGlyphs` reports what each paint did
385
+ (`{ mode, drawn, total, suppressed }`, `mode` one of `"dot"`, `"check"` or `"outline"`), and is called
386
+ only when the chart's marker is one of those:
316
387
 
317
388
  ```js
318
389
  createChartHost(el, {
319
390
  code, data,
320
- options: { selectionGlyph: "dot", suppressSelectionGlyph: answer.suppressSelectionGlyph },
391
+ options: { selectionGlyph: "dot", mapSelectionGlyph: "outline", suppressSelectionGlyph: answer.suppressSelectionGlyph },
321
392
  onSelectionGlyphs: r => console.debug("glyphs", r),
322
393
  });
323
394
  ```
package/dist/adapters.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  censusHitBands,
4
4
  ensureCrossfilterHitTargets,
5
5
  hitBandFlag
6
- } from "./chunk-AEO5SFEQ.mjs";
6
+ } from "./chunk-YOCYWON4.mjs";
7
7
  import {
8
8
  clickWasDrag,
9
9
  collectD3GlyphCenters,
@@ -12,13 +12,13 @@ import {
12
12
  markKeyOf,
13
13
  rowIdxsFromMark,
14
14
  selectionRuleOf
15
- } from "./chunk-KGVNZR23.mjs";
15
+ } from "./chunk-PQKLWYKK.mjs";
16
16
  import {
17
17
  AXIS_FILTER_CLASS,
18
18
  LEGEND_MARK_CLASS,
19
19
  MARK_CLASS,
20
20
  ROW_IDX_ATTR
21
- } from "./chunk-KY4FI2HA.mjs";
21
+ } from "./chunk-GXR7T6G5.mjs";
22
22
  import "./chunk-GHODWOAL.mjs";
23
23
 
24
24
  // src/adapters/types.ts
package/dist/card.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /*! @bicharts/chart-host — Apache-2.0. Bundled reference data: GeoNames (https://www.geonames.org/) CC BY 4.0; Natural Earth and US Census/TIGER (public domain). Full text: NOTICE in this package. */
2
2
  import {
3
3
  computeSelectionCard
4
- } from "./chunk-OLCUKXKL.mjs";
5
- import "./chunk-FIG5DIW7.mjs";
4
+ } from "./chunk-YSRMSDRP.mjs";
5
+ import "./chunk-VPFP35XI.mjs";
6
6
  import "./chunk-GHODWOAL.mjs";
7
7
 
8
8
  // src/selectionCardChrome.ts
@@ -43,6 +43,11 @@ function toKeys(input, width) {
43
43
  return input.length === width ? [input.slice()] : [];
44
44
  }
45
45
  var nextId = 0;
46
+ var peersOf = /* @__PURE__ */ new WeakMap();
47
+ var pickSubsOf = /* @__PURE__ */ new WeakMap();
48
+ var firePick = (f) => {
49
+ for (const m of Array.from(peersOf.get(f) ?? [f])) for (const cb of Array.from(pickSubsOf.get(m) ?? [])) cb();
50
+ };
46
51
  function createFilter(columns, opts = {}) {
47
52
  const cols = Array.isArray(columns) ? columns.slice() : [columns];
48
53
  if (!cols.length || cols.some((c) => typeof c !== "string" || !c)) {
@@ -71,7 +76,11 @@ function createFilter(columns, opts = {}) {
71
76
  rows = nextRows;
72
77
  reason = why;
73
78
  version++;
79
+ if (nextKeys.length) {
80
+ for (const p of Array.from(peersOf.get(filter) ?? [])) if (p !== filter && p.active) p.clear(why);
81
+ }
74
82
  for (const cb of Array.from(subs)) cb(filter, why);
83
+ firePick(filter);
75
84
  };
76
85
  const dedupe = (ks) => {
77
86
  const seen2 = /* @__PURE__ */ new Set();
@@ -94,6 +103,15 @@ function createFilter(columns, opts = {}) {
94
103
  const text = parts.filter(shown).map(String).join(" \u2192 ");
95
104
  return text || k.filter(shown).map(String).join(" \u2192 ");
96
105
  };
106
+ const partsOf = (k, more, names) => {
107
+ const s = keyString(k);
108
+ const pos = cols.map((c, i) => names.includes(c) && !isAnyValue(k[i]) ? i : -1).filter((i) => i >= 0);
109
+ const row = (more ?? []).find((r) => keyString(keyOfRow(r, cols)) === s) ?? seen.get(s);
110
+ const speaksFor = (d, j) => cols.includes(d) ? cols.indexOf(d) : display.length === cols.length ? j : -1;
111
+ const shown = (p) => p != null && p !== "" && !isAnyValue(p);
112
+ const text = row ? display.filter((d, j) => pos.includes(speaksFor(d, j))).map((d) => row[d]).filter(shown).map(String).join(" \u2192 ") : "";
113
+ return text || pos.map((i) => k[i]).filter(shown).map(String).join(" \u2192 ");
114
+ };
97
115
  let warnedKeep = false;
98
116
  const filter = {
99
117
  id: opts.id ?? `f${++nextId}`,
@@ -137,9 +155,9 @@ function createFilter(columns, opts = {}) {
137
155
  get text() {
138
156
  return keys.map((k) => displayOf(k)).join(", ");
139
157
  },
140
- textOf(key, more) {
141
- const k = Array.isArray(key) ? key : [key];
142
- return displayOf(k.slice(0, cols.length), more);
158
+ textOf(key, more, parts) {
159
+ const k = (Array.isArray(key) ? key : [key]).slice(0, cols.length);
160
+ return parts && cols.some((c) => !parts.includes(c)) ? partsOf(k, more, parts) : displayOf(k, more);
143
161
  },
144
162
  get reason() {
145
163
  return reason;
@@ -198,8 +216,23 @@ function createFilter(columns, opts = {}) {
198
216
  return () => {
199
217
  subs.delete(cb);
200
218
  };
219
+ },
220
+ group: opts.group || null,
221
+ get pick() {
222
+ const peers = peersOf.get(filter);
223
+ if (!peers) return keys.length ? filter : null;
224
+ for (const p of peers) if (p.active) return p;
225
+ return null;
226
+ },
227
+ onPick(cb) {
228
+ pickSubs.add(cb);
229
+ return () => {
230
+ pickSubs.delete(cb);
231
+ };
201
232
  }
202
233
  };
234
+ const pickSubs = /* @__PURE__ */ new Set();
235
+ pickSubsOf.set(filter, pickSubs);
203
236
  return filter;
204
237
  }
205
238
  function createFilterScope() {
@@ -211,6 +244,35 @@ function createFilterScope() {
211
244
  const notify = () => {
212
245
  for (const cb of Array.from(subs)) cb(scope);
213
246
  };
247
+ const groups = /* @__PURE__ */ new Map();
248
+ const join = (f) => {
249
+ const name = f.group;
250
+ if (!name) return;
251
+ const members = groups.get(name) ?? /* @__PURE__ */ new Set();
252
+ const first = members.values().next().value;
253
+ if (first && first.columns.join("") !== f.columns.join("")) {
254
+ try {
255
+ console.warn(`[chart-host] filter "${f.label}" is keyed by ${f.columns.join(", ")} but its group "${name}" by ${first.columns.join(", ")}, so it stays out of the group: give one pick's filters the same key columns.`);
256
+ } catch {
257
+ }
258
+ return;
259
+ }
260
+ if (f.active && [...members].some((m) => m.active)) f.clear("page");
261
+ members.add(f);
262
+ groups.set(name, members);
263
+ peersOf.set(f, members);
264
+ firePick(f);
265
+ };
266
+ const leave = (f) => {
267
+ const members = f.group ? groups.get(f.group) : void 0;
268
+ if (!members?.has(f)) return;
269
+ members.delete(f);
270
+ peersOf.delete(f);
271
+ if (!members.size) groups.delete(f.group);
272
+ firePick(f);
273
+ const rest = members.values().next().value;
274
+ if (rest) firePick(rest);
275
+ };
214
276
  const scope = {
215
277
  get filters() {
216
278
  return list.slice();
@@ -227,6 +289,7 @@ function createFilterScope() {
227
289
  if (!offs.has(f)) {
228
290
  list.push(f);
229
291
  offs.set(f, f.onChange(() => notify()));
292
+ join(f);
230
293
  notify();
231
294
  }
232
295
  return () => {
@@ -236,6 +299,7 @@ function createFilterScope() {
236
299
  offs.delete(f);
237
300
  const i = list.indexOf(f);
238
301
  if (i >= 0) list.splice(i, 1);
302
+ leave(f);
239
303
  notify();
240
304
  };
241
305
  },
@@ -278,10 +342,15 @@ function createFilterScope() {
278
342
  },
279
343
  restore(view) {
280
344
  if (!view || view.v !== 1) return;
345
+ const restored = /* @__PURE__ */ new Set();
281
346
  for (const f of list) {
282
347
  const keys = view.filters?.[f.key];
283
- if (keys && keys.length) f.set(keys, "page");
284
- else f.clear("page");
348
+ const g = f.group && peersOf.get(f) ? f.group : null;
349
+ if (g && restored.has(g)) continue;
350
+ if (keys && keys.length) {
351
+ f.set(keys, "page");
352
+ if (g) restored.add(g);
353
+ } else f.clear("page");
285
354
  }
286
355
  for (const [k, c] of ctl) {
287
356
  const values = view.controls?.[k];
@@ -291,19 +360,47 @@ function createFilterScope() {
291
360
  };
292
361
  return scope;
293
362
  }
363
+ function scopeFilterOf(src) {
364
+ if (!src || typeof src !== "object") return null;
365
+ if (Array.isArray(src.keys)) return src;
366
+ const f = src.filter;
367
+ return f && typeof f === "object" ? f : null;
368
+ }
369
+ var warnedTakes = /* @__PURE__ */ new Set();
294
370
  function filterScopeOf(filters) {
295
371
  const list = Array.isArray(filters) ? filters : filters ? [filters] : [];
296
- const active = list.filter((f) => f && f.active);
297
372
  const values = [];
298
- for (const f of active) {
373
+ const fields = [];
374
+ for (const src of list) {
375
+ const f = scopeFilterOf(src);
376
+ if (!f || !f.active) continue;
377
+ const entry = src === f ? null : src;
378
+ const takes = entry?.takes;
379
+ for (const c of takes ?? []) {
380
+ if (f.columns.includes(c) || warnedTakes.has(`${f.id}${c}`)) continue;
381
+ warnedTakes.add(`${f.id}${c}`);
382
+ try {
383
+ console.warn(`[chart-host] scopedBy: filter "${f.label}" isn't keyed by ${c} (its key: ${f.columns.join(", ")}), so its scope line can't take it - name the filter's own key columns in takes.`);
384
+ } catch {
385
+ }
386
+ }
387
+ const part = takes ? f.columns.filter((c) => takes.includes(c)) : f.columns;
388
+ const whole = part.length === f.columns.length;
389
+ if (!whole) {
390
+ const at = part.map((c) => f.columns.indexOf(c));
391
+ if (!at.length || f.keys.some((k) => at.every((i) => isAnyValue(k[i])))) continue;
392
+ }
393
+ let said = false;
299
394
  for (const k of f.keys) {
300
- const t = f.textOf(k).trim();
395
+ const t = (whole ? f.textOf(k) : f.textOf(k, void 0, part)).trim();
396
+ if (t) said = true;
301
397
  if (t && !values.includes(t)) values.push(t);
302
398
  }
399
+ if (!whole && !said) continue;
400
+ const label = entry?.label || (whole || f.label !== f.columns.join(" / ") ? f.label : part.join(" / "));
401
+ if (label && !fields.includes(label)) fields.push(label);
303
402
  }
304
403
  if (!values.length) return void 0;
305
- const fields = [];
306
- for (const f of active) if (f.label && !fields.includes(f.label)) fields.push(f.label);
307
404
  return { field: fields.join(", "), values };
308
405
  }
309
406
  var LINKED_HOVER_CLASS = "bic-linked-hover";
@@ -339,18 +436,21 @@ var sortedEq = (a, b) => {
339
436
  return x.every((v, i) => v === y[i]);
340
437
  };
341
438
  function bindFilter(host, filter, opts) {
439
+ const pickOf = () => filter.pick ?? filter;
342
440
  const wanted = () => {
343
- if (!filter.active) return [];
441
+ const src = pickOf();
442
+ if (!src.active) return [];
344
443
  const out = [];
345
444
  const n = opts.rowCount();
346
445
  for (let i = 0; i < n; i++) {
347
446
  const r = opts.rowAt(i);
348
- if (r && filter.has(r)) out.push(i);
447
+ if (r && src.has(r)) out.push(i);
349
448
  }
350
449
  return out;
351
450
  };
352
451
  const hiddenKeys = () => {
353
- if (!filter.active) return [];
452
+ const src = pickOf();
453
+ if (!src.active) return [];
354
454
  const carried = /* @__PURE__ */ new Set();
355
455
  const n = opts.rowCount();
356
456
  for (let i = 0; i < n; i++) {
@@ -364,7 +464,7 @@ function bindFilter(host, filter, opts) {
364
464
  }
365
465
  return false;
366
466
  };
367
- return filter.keys.filter((k) => k.some(isAnyValue) ? !anyRowMatches(k) : !carried.has(keyString(k)));
467
+ return src.keys.filter((k) => k.some(isAnyValue) ? !anyRowMatches(k) : !carried.has(keyString(k)));
368
468
  };
369
469
  const paint = () => {
370
470
  const want = wanted();
@@ -379,7 +479,8 @@ function bindFilter(host, filter, opts) {
379
479
  if (source === "host") return;
380
480
  const keep = meta?.keepHidden ? hiddenKeys() : [];
381
481
  if (!idxs.length && !keep.length) {
382
- if (filter.active) filter.clear("clear");
482
+ const src = pickOf();
483
+ if (src.active) src.clear("clear");
383
484
  return;
384
485
  }
385
486
  const rows = idxs.map((i) => opts.rowAt(i)).filter((r) => !!r);
@@ -389,8 +490,20 @@ function bindFilter(host, filter, opts) {
389
490
  filter.set([label], "click");
390
491
  return;
391
492
  }
493
+ const group = meta?.fromMark && !keep.length ? sharedKey(rows) : null;
494
+ if (group) {
495
+ filter.set([group], "click");
496
+ return;
497
+ }
392
498
  filter.selectRows(rows, "click", keep);
393
499
  });
500
+ const sharedKey = (rows) => {
501
+ const key = filter.columns.map((c) => {
502
+ const v = rows[0][c];
503
+ return rows.every((r) => keyString([r[c]]) === keyString([v])) ? v : ANY_VALUE;
504
+ });
505
+ return key.some(isAnyValue) && !key.every(isAnyValue) ? key : null;
506
+ };
394
507
  const labelKey = (rows) => {
395
508
  const n = opts.rowCount();
396
509
  const key = filter.columns.map((c) => {
@@ -404,7 +517,7 @@ function bindFilter(host, filter, opts) {
404
517
  });
405
518
  return key.some(isAnyValue) && !key.every(isAnyValue) ? key : null;
406
519
  };
407
- const offFilter = filter.onChange(() => paint());
520
+ const offFilter = filter.onPick ? filter.onPick(() => paint()) : filter.onChange(() => paint());
408
521
  const el = opts.container ?? null;
409
522
  let offHoverDom = () => {
410
523
  };
@@ -486,6 +599,7 @@ export {
486
599
  isAnyValue,
487
600
  createFilter,
488
601
  createFilterScope,
602
+ scopeFilterOf,
489
603
  filterScopeOf,
490
604
  LINKED_HOVER_CLASS,
491
605
  payloadRowReader,