@bicharts/chart-host 0.5.38 → 0.5.40

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.
@@ -20,8 +20,15 @@ var CONTAINER_SLOT_UI_STATE = "__lchUiState";
20
20
  var HOST_CONTAINER_CLASS = "bic-chart-host";
21
21
  var SELECTION_ACTIVE_CLASS = "lch-has-selection";
22
22
  var MARK_SELECTED_CLASS = "lch-mark-selected";
23
+ var ACTIVE_TICK_CLASS = "lch-tick-active";
23
24
  var DIM_OPACITY_VAR = "--lch-dim-opacity";
24
25
  var DIM_OPACITY_DEFAULT = 0.25;
26
+ function chartOwnsTimeline(container) {
27
+ return !!container && (!!container[CONTAINER_SLOT_XF_CLEAR] || !!container[CONTAINER_SLOT_ANIM_STOP]);
28
+ }
29
+ function periodTickSuppressesFeedback(isAxisTick, ownsTimeline) {
30
+ return isAxisTick && ownsTimeline;
31
+ }
25
32
  var ANIM_PLAY_SPEED_DEFAULT = 1e3;
26
33
  var ANIM_PLAY_SPEED_MIN = 250;
27
34
  var ANIM_PLAY_SPEED_MAX = 5e3;
@@ -1000,6 +1007,85 @@ function createMarkResolver(env) {
1000
1007
  return { isInvisibleStrokePath, distToPathCenterline, refineHitCorridor, findMark, rowIdxsFromMark, penetrateOverlayAt, resolveByGeometry };
1001
1008
  }
1002
1009
 
1010
+ // src/hitTargets.ts
1011
+ var SVG_NS = "http://www.w3.org/2000/svg";
1012
+ var HIT_ATTR = "data-lch-hit";
1013
+ var ELEMENT_CAP = 5e3;
1014
+ function ensureCrossfilterHitTargets(container, doc) {
1015
+ const report = { tagged: 0, hitRects: 0, peFlips: 0, peStrokeOnly: 0 };
1016
+ try {
1017
+ if (!container || typeof container.querySelectorAll !== "function") return report;
1018
+ const els = container.querySelectorAll(
1019
+ `.${MARK_CLASS}, .${LEGEND_MARK_CLASS}, .${AXIS_FILTER_CLASS}`
1020
+ );
1021
+ report.tagged = els.length;
1022
+ if (els.length === 0 || els.length > ELEMENT_CAP) return report;
1023
+ const ownerDoc = doc ?? container.ownerDocument;
1024
+ const view = ownerDoc?.defaultView;
1025
+ const computed = (el) => {
1026
+ try {
1027
+ return view?.getComputedStyle ? view.getComputedStyle(el) : null;
1028
+ } catch {
1029
+ return null;
1030
+ }
1031
+ };
1032
+ for (const el of Array.from(els)) {
1033
+ const tag = String(el.tagName || "").toLowerCase();
1034
+ if (tag === "g") {
1035
+ if (!el.hasAttribute?.(ROW_IDX_ATTR)) continue;
1036
+ if (el.querySelector?.(`rect[${HIT_ATTR}="1"]`)) continue;
1037
+ const cls = el.getAttribute?.("class") || "";
1038
+ const isFurniture = cls.indexOf(LEGEND_MARK_CLASS) >= 0 || cls.indexOf(AXIS_FILTER_CLASS) >= 0;
1039
+ if (!isFurniture) {
1040
+ let hasHittableChild = false;
1041
+ const kids = el.querySelectorAll?.("*") ?? [];
1042
+ for (const kid of Array.from(kids)) {
1043
+ const ktag = String(kid.tagName || "").toLowerCase();
1044
+ if (ktag === "g" || ktag === "tspan" || ktag === "title" || ktag === "defs") continue;
1045
+ const cs = computed(kid);
1046
+ if (!cs || cs.pointerEvents !== "none") {
1047
+ hasHittableChild = true;
1048
+ break;
1049
+ }
1050
+ }
1051
+ if (hasHittableChild) continue;
1052
+ }
1053
+ let bb = null;
1054
+ try {
1055
+ bb = el.getBBox?.();
1056
+ } catch {
1057
+ bb = null;
1058
+ }
1059
+ if (!bb || !(bb.width > 0) || !(bb.height > 0)) continue;
1060
+ const rect = ownerDoc.createElementNS(SVG_NS, "rect");
1061
+ rect.setAttribute("x", String(bb.x));
1062
+ rect.setAttribute("y", String(bb.y));
1063
+ rect.setAttribute("width", String(bb.width));
1064
+ rect.setAttribute("height", String(bb.height));
1065
+ rect.setAttribute("fill", "transparent");
1066
+ rect.setAttribute(HIT_ATTR, "1");
1067
+ if (rect.style) {
1068
+ rect.style.pointerEvents = "all";
1069
+ rect.style.cursor = "pointer";
1070
+ }
1071
+ el.insertBefore(rect, el.firstChild);
1072
+ report.hitRects++;
1073
+ } else {
1074
+ const cs = computed(el);
1075
+ if (cs && cs.pointerEvents === "none") {
1076
+ const fill = String(el.getAttribute?.("fill") ?? cs.fill ?? "").trim().toLowerCase();
1077
+ const unfilled = fill === "none" || fill === "transparent" || fill === "rgba(0, 0, 0, 0)";
1078
+ if (el.style) el.style.pointerEvents = unfilled ? "stroke" : "all";
1079
+ report.peFlips++;
1080
+ if (unfilled) report.peStrokeOnly++;
1081
+ }
1082
+ }
1083
+ }
1084
+ } catch {
1085
+ }
1086
+ return report;
1087
+ }
1088
+
1003
1089
  // src/host.ts
1004
1090
  var parseRowIdxs = (s) => (s || "").split(",").map((x2) => parseInt(x2, 10)).filter((n) => Number.isFinite(n));
1005
1091
  var D3_PLUGIN_PACKAGES = {
@@ -1101,6 +1187,8 @@ function createChartHost(container, config) {
1101
1187
  let destroyed = false;
1102
1188
  let warnedGeo = false;
1103
1189
  let current = null;
1190
+ let activeTickEl = null;
1191
+ let selectionIsPeriod = false;
1104
1192
  const subs = /* @__PURE__ */ new Set();
1105
1193
  const ensureAffordanceStyles = () => {
1106
1194
  try {
@@ -1118,13 +1206,33 @@ function createChartHost(container, config) {
1118
1206
  a host SHOULD be able to theme. Found 2026-07-26 in the React demo, on a chart that
1119
1207
  renders perfectly inside Power BI. */
1120
1208
  .${HOST_CONTAINER_CLASS} { line-height: normal; letter-spacing: normal; }
1209
+ /* BACKGROUND MARKS ARE NOT DATA. A mark whose ${ROW_IDX_ATTR} is EMPTY has no rows in the
1210
+ CURRENT frame \u2014 an animated map's region before its country enters the series. Every
1211
+ affordance below therefore skips it: brightening or dimming it manufactures a shade
1212
+ that appears in no legend, and at 25% grey over white it comes out LIGHTER than plain
1213
+ land, which reads as a real bottom-of-scale value rather than as absence. The guard is
1214
+ exactly [${ROW_IDX_ATTR}=""] \u2014 an ABSENT attribute is a tagged companion layer (an
1215
+ anomaly badge, a highlight stroke) and must keep dimming with its datum. */
1121
1216
  .${HOST_CONTAINER_CLASS} .${MARK_CLASS} { cursor: pointer; transition: opacity 120ms ease-in-out, filter 100ms ease-in-out; }
1122
- .${HOST_CONTAINER_CLASS} .${MARK_CLASS}:hover { filter: brightness(1.1) drop-shadow(0 0 1.5px rgba(0,0,0,0.5)) !important; }
1123
- .${HOST_CONTAINER_CLASS}:has(.${MARK_CLASS}:hover) .${MARK_CLASS}:not(:hover) { opacity: 0.55; }
1124
- .${HOST_CONTAINER_CLASS}.${SELECTION_ACTIVE_CLASS} .${MARK_CLASS}:not(.${MARK_SELECTED_CLASS}) {
1217
+ .${HOST_CONTAINER_CLASS} .${MARK_CLASS}:not([${ROW_IDX_ATTR}=""]):hover { filter: brightness(1.1) drop-shadow(0 0 1.5px rgba(0,0,0,0.5)) !important; }
1218
+ .${HOST_CONTAINER_CLASS}:has(.${MARK_CLASS}:not([${ROW_IDX_ATTR}=""]):hover) .${MARK_CLASS}:not(:hover):not([${ROW_IDX_ATTR}=""]) { opacity: 0.55; }
1219
+ .${HOST_CONTAINER_CLASS}.${SELECTION_ACTIVE_CLASS} .${MARK_CLASS}:not(.${MARK_SELECTED_CLASS}):not([${ROW_IDX_ATTR}=""]) {
1125
1220
  opacity: var(${DIM_OPACITY_VAR}, ${DIM_OPACITY_DEFAULT}) !important;
1126
1221
  }
1127
- .${HOST_CONTAINER_CLASS} .${LEGEND_MARK_CLASS} { cursor: pointer; }`;
1222
+ .${HOST_CONTAINER_CLASS} .${LEGEND_MARK_CLASS} { cursor: pointer; }
1223
+ /* An axis/group header that filters when clicked LOOKS like inert text, and until it is
1224
+ marked there is no way to tell a landed filter from a dead label \u2014 the report reads
1225
+ "the axis labels are not clickable" even when every click resolved. Weight + underline
1226
+ rather than a colour: these labels sit wherever the host page's theme puts them, and
1227
+ the host has no reliable contrast partner out in the axis gutter. This paints NO marks,
1228
+ so it is the one acknowledgement a PERIOD tick can have without breaking the rule that
1229
+ a timeline never dims its own chart. */
1230
+ .${HOST_CONTAINER_CLASS} .${AXIS_FILTER_CLASS} { cursor: pointer; }
1231
+ .${HOST_CONTAINER_CLASS} .${ACTIVE_TICK_CLASS},
1232
+ .${HOST_CONTAINER_CLASS} .${ACTIVE_TICK_CLASS} text {
1233
+ font-weight: 700 !important;
1234
+ text-decoration: underline !important;
1235
+ }`;
1128
1236
  (doc.head || doc.documentElement)?.appendChild(st);
1129
1237
  } catch {
1130
1238
  }
@@ -1133,7 +1241,12 @@ function createChartHost(container, config) {
1133
1241
  try {
1134
1242
  const sel = current;
1135
1243
  const active = !!sel && sel.length > 0;
1136
- container.classList?.[active ? "add" : "remove"](SELECTION_ACTIVE_CLASS);
1244
+ const dim = active && !selectionIsPeriod;
1245
+ container.classList?.[dim ? "add" : "remove"](SELECTION_ACTIVE_CLASS);
1246
+ const lit = container.querySelectorAll(`.${ACTIVE_TICK_CLASS}`);
1247
+ for (const t of Array.from(lit)) t.classList?.remove(ACTIVE_TICK_CLASS);
1248
+ const tickStillDrawn = !!activeTickEl && (typeof container.contains === "function" ? container.contains(activeTickEl) : activeTickEl.isConnected !== false);
1249
+ if (active && tickStillDrawn) activeTickEl.classList?.add(ACTIVE_TICK_CLASS);
1137
1250
  const wanted = new Set(sel ?? []);
1138
1251
  const marks = container.querySelectorAll(`.${MARK_CLASS}[${ROW_IDX_ATTR}], .${LEGEND_MARK_CLASS}[${ROW_IDX_ATTR}]`);
1139
1252
  for (const m2 of Array.from(marks)) {
@@ -1144,8 +1257,10 @@ function createChartHost(container, config) {
1144
1257
  } catch {
1145
1258
  }
1146
1259
  };
1147
- const notify = (rowIdxs, source) => {
1260
+ const notify = (rowIdxs, source, tick) => {
1148
1261
  current = rowIdxs;
1262
+ activeTickEl = rowIdxs.length ? tick ?? null : null;
1263
+ selectionIsPeriod = rowIdxs.length > 0 && periodTickSuppressesFeedback(!!tick, chartOwnsTimeline(container));
1149
1264
  paintSelection();
1150
1265
  for (const cb of subs) {
1151
1266
  try {
@@ -1160,7 +1275,7 @@ function createChartHost(container, config) {
1160
1275
  const d = e?.detail || {};
1161
1276
  xfAt = Date.now();
1162
1277
  if (d.clear) notify([], d.source || "chart");
1163
- else if (d.mark) notify(parseRowIdxs(d.mark.getAttribute?.(ROW_IDX_ATTR)), d.source || "chart");
1278
+ else if (d.mark) notify(parseRowIdxs(d.mark.getAttribute?.(ROW_IDX_ATTR)), d.source || "chart", d.mark);
1164
1279
  };
1165
1280
  const resolver = createMarkResolver({
1166
1281
  root: container,
@@ -1180,17 +1295,18 @@ function createChartHost(container, config) {
1180
1295
  }
1181
1296
  const rows = parseRowIdxs(el.getAttribute(ROW_IDX_ATTR));
1182
1297
  if (!rows.length) return;
1298
+ const tick = el.classList?.contains?.(AXIS_FILTER_CLASS) ? el : null;
1183
1299
  if (e?.ctrlKey || e?.metaKey || e?.shiftKey) {
1184
1300
  const next = new Set(current ?? []);
1185
1301
  for (const r of rows) {
1186
1302
  if (next.has(r)) next.delete(r);
1187
1303
  else next.add(r);
1188
1304
  }
1189
- notify(Array.from(next), "user");
1305
+ notify(Array.from(next), "user", tick);
1190
1306
  return;
1191
1307
  }
1192
1308
  const same = !!current && current.length === rows.length && rows.every((r) => current.includes(r));
1193
- notify(same ? [] : rows, "user");
1309
+ notify(same ? [] : rows, "user", same ? null : tick);
1194
1310
  };
1195
1311
  container.addEventListener(XFILTER_REFRESH_EVENT, onXf);
1196
1312
  container.addEventListener("click", onClick);
@@ -1235,6 +1351,7 @@ function createChartHost(container, config) {
1235
1351
  } catch (err) {
1236
1352
  throw explainRenderFailure(err, d3);
1237
1353
  }
1354
+ ensureCrossfilterHitTargets(container, doc);
1238
1355
  paintSelection();
1239
1356
  },
1240
1357
  setOptions(partial) {
@@ -1303,8 +1420,11 @@ export {
1303
1420
  HOST_CONTAINER_CLASS,
1304
1421
  SELECTION_ACTIVE_CLASS,
1305
1422
  MARK_SELECTED_CLASS,
1423
+ ACTIVE_TICK_CLASS,
1306
1424
  DIM_OPACITY_VAR,
1307
1425
  DIM_OPACITY_DEFAULT,
1426
+ chartOwnsTimeline,
1427
+ periodTickSuppressesFeedback,
1308
1428
  ANIM_PLAY_SPEED_DEFAULT,
1309
1429
  ANIM_PLAY_SPEED_MIN,
1310
1430
  ANIM_PLAY_SPEED_MAX,
@@ -1327,6 +1447,7 @@ export {
1327
1447
  L3,
1328
1448
  buildRenderPayload,
1329
1449
  createMarkResolver,
1450
+ ensureCrossfilterHitTargets,
1330
1451
  requiredD3Plugins,
1331
1452
  explainRenderFailure,
1332
1453
  stripEsmExports,
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
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
+ ACTIVE_TICK_CLASS,
3
4
  ANIM_LOOP_DELAY_DEFAULT,
4
5
  ANIM_LOOP_DELAY_MIN,
5
6
  ANIM_MAX_IDEAL_FRAMES_DEFAULT,
@@ -30,20 +31,23 @@ import {
30
31
  SELECTION_ACTIVE_CLASS,
31
32
  XFILTER_REFRESH_EVENT,
32
33
  buildRenderPayload,
34
+ chartOwnsTimeline,
33
35
  clearGeoCache,
34
36
  compileRenderFn,
35
37
  createChartHost,
36
38
  createMarkResolver,
39
+ ensureCrossfilterHitTargets,
37
40
  explainRenderFailure,
38
41
  geoAssetFor,
39
42
  geoFromCache,
40
43
  loadGeo,
44
+ periodTickSuppressesFeedback,
41
45
  registerGeo,
42
46
  registerGeoAsset,
43
47
  requiredD3Plugins,
44
48
  resolveOptions,
45
49
  stripEsmExports
46
- } from "./chunk-KT4RNDZ3.mjs";
50
+ } from "./chunk-XEVWH2RK.mjs";
47
51
  import "./chunk-A2GMXZP7.mjs";
48
52
 
49
53
  // src/trivial.ts
@@ -739,6 +743,7 @@ function registerCityTable(packed) {
739
743
  L3(packed);
740
744
  }
741
745
  export {
746
+ ACTIVE_TICK_CLASS,
742
747
  ANIM_LOOP_DELAY_DEFAULT,
743
748
  ANIM_LOOP_DELAY_MIN,
744
749
  ANIM_MAX_IDEAL_FRAMES_DEFAULT,
@@ -773,18 +778,21 @@ export {
773
778
  buildRenderPayload,
774
779
  buildReviewWire,
775
780
  captureSvgSnapshot,
781
+ chartOwnsTimeline,
776
782
  clearGeoCache,
777
783
  compileRenderFn,
778
784
  compileTrivialSource,
779
785
  computeSelectionCard,
780
786
  createChartHost,
781
787
  createMarkResolver,
788
+ ensureCrossfilterHitTargets,
782
789
  explainRenderFailure,
783
790
  geoAssetFor,
784
791
  geoFromCache,
785
792
  loadGeo,
786
793
  newQualifyGroupState,
787
794
  normaliseAggregation,
795
+ periodTickSuppressesFeedback,
788
796
  planTrivialChart,
789
797
  qualifyGroupHeadingFor,
790
798
  rasterizeSvgToPngDataUrl,
package/dist/react.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createChartHost,
5
5
  geoFromCache,
6
6
  loadGeo
7
- } from "./chunk-KT4RNDZ3.mjs";
7
+ } from "./chunk-XEVWH2RK.mjs";
8
8
  import "./chunk-A2GMXZP7.mjs";
9
9
 
10
10
  // src/react.tsx
@@ -19,8 +19,11 @@ export declare const CONTAINER_SLOT_UI_STATE = "__lchUiState";
19
19
  export declare const HOST_CONTAINER_CLASS = "bic-chart-host";
20
20
  export declare const SELECTION_ACTIVE_CLASS = "lch-has-selection";
21
21
  export declare const MARK_SELECTED_CLASS = "lch-mark-selected";
22
+ export declare const ACTIVE_TICK_CLASS = "lch-tick-active";
22
23
  export declare const DIM_OPACITY_VAR = "--lch-dim-opacity";
23
24
  export declare const DIM_OPACITY_DEFAULT = 0.25;
25
+ export declare function chartOwnsTimeline(container: any): boolean;
26
+ export declare function periodTickSuppressesFeedback(isAxisTick: boolean, ownsTimeline: boolean): boolean;
24
27
  export declare const ANIM_PLAY_SPEED_DEFAULT = 1000;
25
28
  export declare const ANIM_PLAY_SPEED_MIN = 250;
26
29
  export declare const ANIM_PLAY_SPEED_MAX = 5000;
@@ -0,0 +1,15 @@
1
+ export interface HitTargetReport {
2
+ /** tagged elements examined */
3
+ tagged: number;
4
+ /** transparent hit rects injected into inert <g> marks */
5
+ hitRects: number;
6
+ /** painted elements flipped off pointer-events:none */
7
+ peFlips: number;
8
+ /** of those, how many were UNFILLED and so got 'stroke' rather than 'all' */
9
+ peStrokeOnly: number;
10
+ }
11
+ /**
12
+ * Make every declared cross-filter mark inside `container` actually hittable.
13
+ * Safe to call repeatedly; returns what it changed (all zeros = nothing needed doing).
14
+ */
15
+ export declare function ensureCrossfilterHitTargets(container: any, doc?: any): HitTargetReport;
@@ -1,4 +1,4 @@
1
- export * from "./contract";
1
+ export { HOST_CONTRACT_VERSION, GEO_POINT_PRECISIONS, MARK_CLASS, LEGEND_MARK_CLASS, AXIS_FILTER_CLASS, ROW_IDX_ATTR, XFILTER_REFRESH_EVENT, CONTAINER_SLOT_ANIM_STOP, CONTAINER_SLOT_XF_CLEAR, CONTAINER_SLOT_INITIAL_XF_MARK, CONTAINER_SLOT_UI_STATE, HOST_CONTAINER_CLASS, SELECTION_ACTIVE_CLASS, MARK_SELECTED_CLASS, ACTIVE_TICK_CLASS, DIM_OPACITY_VAR, DIM_OPACITY_DEFAULT, chartOwnsTimeline, periodTickSuppressesFeedback, ANIM_PLAY_SPEED_DEFAULT, ANIM_PLAY_SPEED_MIN, ANIM_PLAY_SPEED_MAX, ANIM_LOOP_DELAY_DEFAULT, ANIM_LOOP_DELAY_MIN, ANIM_MAX_IDEAL_FRAMES_DEFAULT, ANIM_MAX_IDEAL_FRAMES_MIN, ANIM_MAX_IDEAL_FRAMES_MAX, COLOR_SCALE_SELF_CLAMP_PCT_DEFAULT, COLOR_SCALE_SELF_CLAMP_PCT_MIN, COLOR_SCALE_SELF_CLAMP_PCT_MAX, FLIP_MODE_DEFAULT, type GeoPointPrecision, type GeoMapKind, type TimelineStyle, type FlipMode, type ColorScaleScope, type RenderOptions, } from "./contract";
2
2
  export { resolveOptions, type ResolveOptionsInput } from "./defaults";
3
3
  export { loadGeo, geoFromCache, registerGeo, registerGeoAsset, geoAssetFor, clearGeoCache, type GeoAssetName, } from "./geoLazy";
4
4
  /** REPLACE the bundled city gazetteer with another packed table. Rarely needed — see the
@@ -13,3 +13,4 @@ export { shouldReview, buildReviewWire, bareBase64, actionFor, type ReviewGate,
13
13
  export { askApplyImprovements, type ReviewDialogOptions, type ReviewDialogText } from "./reviewDialog";
14
14
  export { qualifyGroupHeadingFor, newQualifyGroupState, type QualifyGroupRow, type QualifyGroupState, type QualifyGroupHeading, } from "./qualifyGroups";
15
15
  export { computeSelectionCard, normaliseAggregation, type SelectionCardModel, type SelectionCardLine, type SelectionCardOptions, } from "./selectionCard";
16
+ export { ensureCrossfilterHitTargets, type HitTargetReport } from "./hitTargets";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.38",
3
+ "version": "0.5.40",
4
4
  "description": "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 charts. The same contract the BIC Power BI visual implements, minus Power BI. React bindings at @bicharts/chart-host/react.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",