@bicharts/chart-host 0.5.58 → 0.5.60
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/dist/{chunk-UUH6S5Q5.mjs → chunk-WG4NTR5H.mjs} +90 -0
- package/dist/index.mjs +79 -1
- package/dist/react.mjs +1 -1
- package/dist/types/hitBands.d.ts +47 -0
- package/dist/types/host.d.ts +13 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/qualifyFilter.d.ts +193 -0
- package/package.json +1 -1
|
@@ -1227,6 +1227,87 @@ function blankRenderFlag(lang, cause) {
|
|
|
1227
1227
|
return `blankrender:${clean(lang)}:${clean(cause)}`;
|
|
1228
1228
|
}
|
|
1229
1229
|
|
|
1230
|
+
// src/hitBands.ts
|
|
1231
|
+
var MIN_HIT_BAND_PX = 8;
|
|
1232
|
+
var ELEMENT_CAP2 = 5e3;
|
|
1233
|
+
var EMPTY2 = {
|
|
1234
|
+
openStrokes: 0,
|
|
1235
|
+
interactive: 0,
|
|
1236
|
+
hairline: 0,
|
|
1237
|
+
uncovered: 0,
|
|
1238
|
+
widestPx: 0
|
|
1239
|
+
};
|
|
1240
|
+
var OPEN_TAGS = /* @__PURE__ */ new Set(["path", "line", "polyline"]);
|
|
1241
|
+
function styleOf(el, doc) {
|
|
1242
|
+
try {
|
|
1243
|
+
const view = doc?.defaultView;
|
|
1244
|
+
return view?.getComputedStyle ? view.getComputedStyle(el) : null;
|
|
1245
|
+
} catch {
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
function attrOrComputed(el, name, cs) {
|
|
1250
|
+
const a2 = el.getAttribute?.(name);
|
|
1251
|
+
if (a2 !== null && a2 !== void 0 && String(a2).trim() !== "") return String(a2).trim();
|
|
1252
|
+
const c = cs?.[name === "stroke-width" ? "strokeWidth" : name];
|
|
1253
|
+
return c === null || c === void 0 ? "" : String(c).trim();
|
|
1254
|
+
}
|
|
1255
|
+
function isUnfilled(fill) {
|
|
1256
|
+
const f = fill.toLowerCase();
|
|
1257
|
+
return f === "none" || f === "transparent" || f === "rgba(0, 0, 0, 0)" || f === "rgba(0,0,0,0)";
|
|
1258
|
+
}
|
|
1259
|
+
function widthPx(raw) {
|
|
1260
|
+
const m2 = /^(-?\d+(?:\.\d+)?)\s*(px)?$/i.exec(raw);
|
|
1261
|
+
if (!m2) return NaN;
|
|
1262
|
+
const n = parseFloat(m2[1]);
|
|
1263
|
+
return isFinite(n) ? n : NaN;
|
|
1264
|
+
}
|
|
1265
|
+
function censusHitBands(container, doc) {
|
|
1266
|
+
if (!container || typeof container.querySelectorAll !== "function") return EMPTY2;
|
|
1267
|
+
try {
|
|
1268
|
+
const els = Array.from(
|
|
1269
|
+
container.querySelectorAll(`.${MARK_CLASS}, [${ROW_IDX_ATTR}]`)
|
|
1270
|
+
);
|
|
1271
|
+
if (els.length === 0 || els.length > ELEMENT_CAP2) return EMPTY2;
|
|
1272
|
+
const ownerDoc = doc ?? container.ownerDocument;
|
|
1273
|
+
const widestByGeom = /* @__PURE__ */ new Map();
|
|
1274
|
+
const rows = [];
|
|
1275
|
+
for (const el of els) {
|
|
1276
|
+
const tag = String(el.tagName || "").toLowerCase();
|
|
1277
|
+
if (!OPEN_TAGS.has(tag)) continue;
|
|
1278
|
+
const cs = styleOf(el, ownerDoc);
|
|
1279
|
+
const open = isUnfilled(attrOrComputed(el, "fill", cs));
|
|
1280
|
+
const w = widthPx(attrOrComputed(el, "stroke-width", cs) || "1");
|
|
1281
|
+
const geom = String(el.getAttribute?.("d") ?? "") || `${tag}#${String(el.getAttribute?.(ROW_IDX_ATTR) ?? "")}`;
|
|
1282
|
+
const inert = String(cs?.pointerEvents ?? el.style?.pointerEvents ?? "") === "none";
|
|
1283
|
+
rows.push({ el, geom, w, open, inert });
|
|
1284
|
+
if (isFinite(w)) {
|
|
1285
|
+
const prev = widestByGeom.get(geom);
|
|
1286
|
+
if (prev === void 0 || w > prev) widestByGeom.set(geom, w);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const out = { ...EMPTY2 };
|
|
1290
|
+
for (const r of rows) {
|
|
1291
|
+
if (!r.open) continue;
|
|
1292
|
+
out.openStrokes++;
|
|
1293
|
+
if (r.inert) continue;
|
|
1294
|
+
out.interactive++;
|
|
1295
|
+
if (isFinite(r.w) && r.w > out.widestPx) out.widestPx = Math.round(r.w);
|
|
1296
|
+
if (!(r.w < MIN_HIT_BAND_PX)) continue;
|
|
1297
|
+
out.hairline++;
|
|
1298
|
+
const best = widestByGeom.get(r.geom);
|
|
1299
|
+
if (best === void 0 || best < MIN_HIT_BAND_PX) out.uncovered++;
|
|
1300
|
+
}
|
|
1301
|
+
return out;
|
|
1302
|
+
} catch {
|
|
1303
|
+
return EMPTY2;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
function hitBandFlag(c) {
|
|
1307
|
+
if (c.interactive === 0) return "";
|
|
1308
|
+
return c.uncovered > 0 ? "hitband:d3:thin" : "hitband:d3:ok";
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1230
1311
|
// src/host.ts
|
|
1231
1312
|
function sessionViewStateProvider(container) {
|
|
1232
1313
|
const holder = container;
|
|
@@ -1550,6 +1631,12 @@ function createChartHost(container, config) {
|
|
|
1550
1631
|
}
|
|
1551
1632
|
} catch {
|
|
1552
1633
|
}
|
|
1634
|
+
if (config.onHitBandCensus) {
|
|
1635
|
+
try {
|
|
1636
|
+
config.onHitBandCensus(censusHitBands(container, doc));
|
|
1637
|
+
} catch {
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1553
1640
|
},
|
|
1554
1641
|
setOptions(partial) {
|
|
1555
1642
|
raw = { ...raw, ...partial };
|
|
@@ -1655,6 +1742,9 @@ export {
|
|
|
1655
1742
|
censusMarks,
|
|
1656
1743
|
isBlankRender,
|
|
1657
1744
|
blankRenderFlag,
|
|
1745
|
+
MIN_HIT_BAND_PX,
|
|
1746
|
+
censusHitBands,
|
|
1747
|
+
hitBandFlag,
|
|
1658
1748
|
sessionViewStateProvider,
|
|
1659
1749
|
noopViewStateProvider,
|
|
1660
1750
|
requiredD3Plugins,
|
package/dist/index.mjs
CHANGED
|
@@ -31,12 +31,14 @@ import {
|
|
|
31
31
|
LIFT_SELECTED_CLASS,
|
|
32
32
|
MARK_CLASS,
|
|
33
33
|
MARK_SELECTED_CLASS,
|
|
34
|
+
MIN_HIT_BAND_PX,
|
|
34
35
|
ROW_IDX_ATTR,
|
|
35
36
|
SELECTION_ACTIVE_CLASS,
|
|
36
37
|
VALUE_AXIS_BASELINE_DEFAULT,
|
|
37
38
|
XFILTER_REFRESH_EVENT,
|
|
38
39
|
blankRenderFlag,
|
|
39
40
|
buildRenderPayload,
|
|
41
|
+
censusHitBands,
|
|
40
42
|
censusMarks,
|
|
41
43
|
chartOwnsTimeline,
|
|
42
44
|
clearGeoCache,
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
explainRenderFailure,
|
|
48
50
|
geoAssetFor,
|
|
49
51
|
geoFromCache,
|
|
52
|
+
hitBandFlag,
|
|
50
53
|
isBlankRender,
|
|
51
54
|
loadGeo,
|
|
52
55
|
ne,
|
|
@@ -59,7 +62,7 @@ import {
|
|
|
59
62
|
sessionViewStateProvider,
|
|
60
63
|
stripEsmExports,
|
|
61
64
|
te
|
|
62
|
-
} from "./chunk-
|
|
65
|
+
} from "./chunk-WG4NTR5H.mjs";
|
|
63
66
|
import "./chunk-A2GMXZP7.mjs";
|
|
64
67
|
|
|
65
68
|
// src/trivial.ts
|
|
@@ -648,6 +651,65 @@ function qualifyFailureFallsOpen(viaGenerate) {
|
|
|
648
651
|
return viaGenerate === true;
|
|
649
652
|
}
|
|
650
653
|
|
|
654
|
+
// src/qualifyFilter.ts
|
|
655
|
+
function normalizeFilterTerm(raw) {
|
|
656
|
+
if (typeof raw !== "string" || raw === "") return "";
|
|
657
|
+
return raw.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
658
|
+
}
|
|
659
|
+
var FILTER_MIN_TERM_CHARS = 2;
|
|
660
|
+
function startsAWord(name, t) {
|
|
661
|
+
for (let i = name.indexOf(t); i >= 0; i = name.indexOf(t, i + 1)) {
|
|
662
|
+
if (i === 0) return true;
|
|
663
|
+
const prev = name.charCodeAt(i - 1);
|
|
664
|
+
const alnum = prev >= 97 && prev <= 122 || prev >= 48 && prev <= 57;
|
|
665
|
+
if (!alnum) return true;
|
|
666
|
+
}
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
function filterQualifyRows(rows, term, read) {
|
|
670
|
+
const all = Array.isArray(rows) ? rows.slice() : [];
|
|
671
|
+
const t = normalizeFilterTerm(term);
|
|
672
|
+
if (t.length < FILTER_MIN_TERM_CHARS) return { rows: all, tier: "all", term: "" };
|
|
673
|
+
const atWordStart = [];
|
|
674
|
+
const midWord = [];
|
|
675
|
+
const byDesc = [];
|
|
676
|
+
for (const row of all) {
|
|
677
|
+
const f = read(row);
|
|
678
|
+
const name = normalizeFilterTerm(f.name);
|
|
679
|
+
if (name === "") continue;
|
|
680
|
+
if (name.indexOf(t) >= 0) {
|
|
681
|
+
(startsAWord(name, t) ? atWordStart : midWord).push(row);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (normalizeFilterTerm(f.description).indexOf(t) >= 0) byDesc.push(row);
|
|
685
|
+
}
|
|
686
|
+
if (atWordStart.length > 0) return { rows: atWordStart, tier: "name", term: t };
|
|
687
|
+
if (midWord.length > 0) return { rows: midWord, tier: "namePart", term: t };
|
|
688
|
+
if (byDesc.length > 0) return { rows: byDesc, tier: "desc", term: t };
|
|
689
|
+
return { rows: [], tier: "none", term: t };
|
|
690
|
+
}
|
|
691
|
+
var readQualifyChartRow = (r) => ({ name: r.name, description: r.description });
|
|
692
|
+
var readQualifyRefusalRow = (r) => ({ name: r.name, description: r.reason });
|
|
693
|
+
var FILTER_ROW_PX = 32;
|
|
694
|
+
var FILTER_MIN_WIDTH_PX = 420;
|
|
695
|
+
var FILTER_MIN_HEIGHT_PX = 300;
|
|
696
|
+
function filterFitsChooser(width, height) {
|
|
697
|
+
return Number.isFinite(width) && Number.isFinite(height) && width >= FILTER_MIN_WIDTH_PX && height >= FILTER_MIN_HEIGHT_PX;
|
|
698
|
+
}
|
|
699
|
+
function listNeedsFilter(scrollHeight, clientHeight) {
|
|
700
|
+
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return false;
|
|
701
|
+
return scrollHeight > clientHeight - FILTER_ROW_PX;
|
|
702
|
+
}
|
|
703
|
+
function qualifyFilterGate(i) {
|
|
704
|
+
if (!filterFitsChooser(i.cardWidth, i.cardHeight)) return { show: false, why: "too-small" };
|
|
705
|
+
if (!listNeedsFilter(i.scrollHeight, i.clientHeight)) return { show: false, why: "no-overflow" };
|
|
706
|
+
return { show: true, why: "shown" };
|
|
707
|
+
}
|
|
708
|
+
function inlineFilterGate(rowCount) {
|
|
709
|
+
return rowCount >= INLINE_FILTER_MIN_ROWS ? { show: true, why: "shown" } : { show: false, why: "no-overflow" };
|
|
710
|
+
}
|
|
711
|
+
var INLINE_FILTER_MIN_ROWS = 8;
|
|
712
|
+
|
|
651
713
|
// src/selectionCard.ts
|
|
652
714
|
var SYNTHETIC_PREFIX = "__";
|
|
653
715
|
var NUMERIC_DATATYPE = /int|double|decimal|single|float|number|currency|money/i;
|
|
@@ -890,14 +952,20 @@ export {
|
|
|
890
952
|
CONTAINER_SLOT_XF_CLEAR,
|
|
891
953
|
DIM_OPACITY_DEFAULT,
|
|
892
954
|
DIM_OPACITY_VAR,
|
|
955
|
+
FILTER_MIN_HEIGHT_PX,
|
|
956
|
+
FILTER_MIN_TERM_CHARS,
|
|
957
|
+
FILTER_MIN_WIDTH_PX,
|
|
958
|
+
FILTER_ROW_PX,
|
|
893
959
|
FLIP_MODE_DEFAULT,
|
|
894
960
|
GEO_POINT_PRECISIONS,
|
|
895
961
|
HOST_CONTAINER_CLASS,
|
|
896
962
|
HOST_CONTRACT_VERSION,
|
|
963
|
+
INLINE_FILTER_MIN_ROWS,
|
|
897
964
|
LEGEND_MARK_CLASS,
|
|
898
965
|
LIFT_SELECTED_CLASS,
|
|
899
966
|
MARK_CLASS,
|
|
900
967
|
MARK_SELECTED_CLASS,
|
|
968
|
+
MIN_HIT_BAND_PX,
|
|
901
969
|
ROW_IDX_ATTR,
|
|
902
970
|
SELECTION_ACTIVE_CLASS,
|
|
903
971
|
VALUE_AXIS_BASELINE_DEFAULT,
|
|
@@ -910,6 +978,7 @@ export {
|
|
|
910
978
|
buildReviewWire,
|
|
911
979
|
canConfirmLaunch,
|
|
912
980
|
captureSvgSnapshot,
|
|
981
|
+
censusHitBands,
|
|
913
982
|
censusMarks,
|
|
914
983
|
chartOwnsTimeline,
|
|
915
984
|
chooserFitsViewport,
|
|
@@ -922,27 +991,36 @@ export {
|
|
|
922
991
|
createMarkResolver,
|
|
923
992
|
ensureCrossfilterHitTargets,
|
|
924
993
|
explainRenderFailure,
|
|
994
|
+
filterFitsChooser,
|
|
995
|
+
filterQualifyRows,
|
|
925
996
|
geoAssetFor,
|
|
926
997
|
geoFromCache,
|
|
927
998
|
hasRefusalsToShow,
|
|
999
|
+
hitBandFlag,
|
|
1000
|
+
inlineFilterGate,
|
|
928
1001
|
isBlankRender,
|
|
929
1002
|
launchFavorStyle,
|
|
930
1003
|
launchGenerates,
|
|
1004
|
+
listNeedsFilter,
|
|
931
1005
|
loadGeo,
|
|
932
1006
|
newQualifyGroupState,
|
|
933
1007
|
newQualifyRefusalGroupState,
|
|
934
1008
|
noopViewStateProvider,
|
|
935
1009
|
normaliseAggregation,
|
|
1010
|
+
normalizeFilterTerm,
|
|
936
1011
|
orderRefusalsForDisplay,
|
|
937
1012
|
periodTickSuppressesFeedback,
|
|
938
1013
|
planTrivialChart,
|
|
939
1014
|
qualifyAuto,
|
|
940
1015
|
qualifyCancel,
|
|
941
1016
|
qualifyFailureFallsOpen,
|
|
1017
|
+
qualifyFilterGate,
|
|
942
1018
|
qualifyGroupHeadingFor,
|
|
943
1019
|
qualifyPick,
|
|
944
1020
|
qualifyRefusalHeadingFor,
|
|
945
1021
|
rasterizeSvgToPngDataUrl,
|
|
1022
|
+
readQualifyChartRow,
|
|
1023
|
+
readQualifyRefusalRow,
|
|
946
1024
|
refusalIsSelectable,
|
|
947
1025
|
registerCityTable,
|
|
948
1026
|
registerGeo,
|
package/dist/react.mjs
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrowest stroke, in CSS pixels, that counts as a real click target.
|
|
3
|
+
*
|
|
4
|
+
* The generated-code recipe asks for 10-14px. Eight is the floor rather than the target: it is
|
|
5
|
+
* generous enough that a chart which made a deliberate effort is not counted as a defect, and it
|
|
6
|
+
* is the same number the server-side rule uses, so the two halves cannot drift into disagreeing
|
|
7
|
+
* about which strokes are thin.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MIN_HIT_BAND_PX = 8;
|
|
10
|
+
export interface HitBandCensus {
|
|
11
|
+
/** Tagged marks that are OPEN strokes (`fill` none/transparent with a painted stroke). */
|
|
12
|
+
openStrokes: number;
|
|
13
|
+
/** Of those, the ones the chart has not opted out of with `pointer-events:none`. */
|
|
14
|
+
interactive: number;
|
|
15
|
+
/** Of the interactive ones, those whose own band is under `MIN_HIT_BAND_PX`. */
|
|
16
|
+
hairline: number;
|
|
17
|
+
/** Of the hairlines, those with no wider companion sharing their geometry. THE DEFECT. */
|
|
18
|
+
uncovered: number;
|
|
19
|
+
/** Widest band, rounded, seen on any interactive open stroke — for tuning the floor. */
|
|
20
|
+
widestPx: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Count the declared marks that are open strokes, and how many of them are too thin to hit.
|
|
24
|
+
*
|
|
25
|
+
* A companion is any OTHER tagged element sharing this one's geometry (the same `d`, or the same
|
|
26
|
+
* row index when there is no `d`) whose band clears the floor. That is exactly the recipe's
|
|
27
|
+
* shape, and it means a chart which drew its hit band correctly reports `uncovered: 0` however it
|
|
28
|
+
* spelled the transparency — the width is what makes a band a band.
|
|
29
|
+
*
|
|
30
|
+
* A mark whose own visible stroke is already wide is its OWN hit band and is never counted as a
|
|
31
|
+
* hairline. A Sankey link is the case that matters: its thickness is its value, routinely 10-40px,
|
|
32
|
+
* and asking it for a companion would be asking for a second hit area on top of a good one.
|
|
33
|
+
*
|
|
34
|
+
* Never throws — telemetry must not be the reason a delivered render fails. An unreadable width
|
|
35
|
+
* counts as WIDE (`NaN` fails the `< MIN_HIT_BAND_PX` test), because a census that guesses
|
|
36
|
+
* "defect" when it cannot measure would report the environment rather than the chart.
|
|
37
|
+
*/
|
|
38
|
+
export declare function censusHitBands(container: any, doc?: any): HitBandCensus;
|
|
39
|
+
/**
|
|
40
|
+
* The always-on behaviour tag for this census, or "" when there is nothing to say.
|
|
41
|
+
*
|
|
42
|
+
* One tag per render in the `hitband:` namespace, so the rate is a `LIKE` away and the three
|
|
43
|
+
* states sum to every render that drew an open stroke — a rate needs a denominator, and
|
|
44
|
+
* `hitband:d3:ok` is it. Charts with no open-stroke marks at all emit nothing rather than a
|
|
45
|
+
* third value nobody will remember to exclude.
|
|
46
|
+
*/
|
|
47
|
+
export declare function hitBandFlag(c: HitBandCensus): string;
|
package/dist/types/host.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type RenderOptions, type ViewStateProvider } from "./contract";
|
|
2
2
|
import { type ResolveOptionsInput } from "./defaults";
|
|
3
3
|
import { type MarkCensus } from "./blankRender";
|
|
4
|
+
import { type HitBandCensus } from "./hitBands";
|
|
4
5
|
export type RenderFn = (container: HTMLElement, data: any, options: RenderOptions) => void;
|
|
5
6
|
/**
|
|
6
7
|
* THE SESSION PROVIDER — what every host got hard-coded before contract 1.6.0, now named.
|
|
@@ -77,6 +78,18 @@ export interface ChartHostConfig {
|
|
|
77
78
|
census: MarkCensus;
|
|
78
79
|
rows: number;
|
|
79
80
|
}) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Called after every render with the hit-band census — how many of this chart's marks are
|
|
83
|
+
* thin open strokes, and how many of those have no wider companion to catch a click
|
|
84
|
+
* (2026-09-03). Counts only; the host decides what, if anything, to record. It fires even
|
|
85
|
+
* when everything is fine, because a rate needs a denominator and `interactive` is it: a
|
|
86
|
+
* chart with no open-stroke marks reports zeroes and should be excluded, not counted as good.
|
|
87
|
+
*
|
|
88
|
+
* This is the half a server-side code check structurally cannot do. A regex reads
|
|
89
|
+
* `.attr('stroke-width', 2)` and nothing else; `.attr('stroke-width', d => scale(d.v))` is a
|
|
90
|
+
* number only the browser knows, and this reads the COMPUTED width off the rendered element.
|
|
91
|
+
*/
|
|
92
|
+
onHitBandCensus?: (census: HitBandCensus) => void;
|
|
80
93
|
/** This chart declares time keyframes: frame one is allowed to be empty, so no blank verdict
|
|
81
94
|
* is issued for it. */
|
|
82
95
|
animated?: boolean;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ 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, orderRefusalsForDisplay, refusalIsSelectable, hasRefusalsToShow, qualifyRefusalHeadingFor, newQualifyRefusalGroupState, type QualifyRefusalRow, type QualifyRefusalGroupState, type QualifyRefusalHeading, } from "./qualifyGroups";
|
|
15
15
|
export { qualifyPick, qualifyAuto, qualifyCancel, launchGenerates, launchFavorStyle, chooserFitsViewport, shouldOpenChooserOnGenerate, shouldOpenInlineChooserOnGenerate, canConfirmLaunch, confirmLaunch, qualifyFailureFallsOpen, CHOOSER_MIN_WIDTH_PX, CHOOSER_MIN_HEIGHT_PX, type QualifyLaunchOutcome, type ChooserGateInput, } from "./qualifyLaunch";
|
|
16
|
+
export { normalizeFilterTerm, filterQualifyRows, readQualifyChartRow, readQualifyRefusalRow, filterFitsChooser, listNeedsFilter, qualifyFilterGate, inlineFilterGate, FILTER_MIN_TERM_CHARS, FILTER_ROW_PX, FILTER_MIN_WIDTH_PX, FILTER_MIN_HEIGHT_PX, INLINE_FILTER_MIN_ROWS, type QualifyFilterTier, type QualifyFilterResult, type QualifyFilterRead, type QualifyFilterGateReason, type QualifyFilterGateInput, } from "./qualifyFilter";
|
|
16
17
|
export { computeSelectionCard, normaliseAggregation, type SelectionCardModel, type SelectionCardLine, type SelectionCardOptions, } from "./selectionCard";
|
|
17
18
|
export { ensureCrossfilterHitTargets, type HitTargetReport } from "./hitTargets";
|
|
18
19
|
export { censusMarks, isBlankRender, blankRenderFlag, type MarkCensus, type BlankVerdictInput } from "./blankRender";
|
|
20
|
+
export { censusHitBands, hitBandFlag, MIN_HIT_BAND_PX, type HitBandCensus } from "./hitBands";
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comparison form of a term or a field.
|
|
3
|
+
*
|
|
4
|
+
* Accent folding is not decoration. The catalogue is English, but the reader's keyboard need not
|
|
5
|
+
* be, and a "Sankey" typed through a dead key has to match the same row a plain one does. NFD
|
|
6
|
+
* splits a composed character into base + combining marks and the range strip removes the marks,
|
|
7
|
+
* which is the whole of it - no transliteration, no stemming, nothing that could make two
|
|
8
|
+
* different chart names collide.
|
|
9
|
+
*
|
|
10
|
+
* Whitespace is collapsed rather than stripped: "bar chart" and "bar chart" are the same query,
|
|
11
|
+
* but "barchart" is deliberately NOT one. A reader who omits the space is asking for something we
|
|
12
|
+
* do not have a name for, and silently succeeding there would make the failures inexplicable.
|
|
13
|
+
*/
|
|
14
|
+
export declare function normalizeFilterTerm(raw: string | null | undefined): string;
|
|
15
|
+
/**
|
|
16
|
+
* THE SHORTEST TERM THAT FILTERS. One character matches most of a 108-row catalogue, so the
|
|
17
|
+
* list would flicker through a near-identity pass and teach the reader nothing; below this the
|
|
18
|
+
* list is returned untouched and the host hides its counter.
|
|
19
|
+
*/
|
|
20
|
+
export declare const FILTER_MIN_TERM_CHARS = 2;
|
|
21
|
+
/**
|
|
22
|
+
* WHICH TIER ANSWERED - the host needs this, not just the rows.
|
|
23
|
+
*
|
|
24
|
+
* "all" - no term (or too short). The rows are the input, unfiltered.
|
|
25
|
+
* "name" - the term starts a WORD in at least one name. Every lower tier is suppressed.
|
|
26
|
+
* "namePart" - it appears mid-word in a name, and nowhere at a word start.
|
|
27
|
+
* "desc" - no name matched at all, so the description fallback answered. THE HOST SAYS SO.
|
|
28
|
+
* "none" - nothing matched anywhere.
|
|
29
|
+
*
|
|
30
|
+
* A HOST ONLY HAS TO ANNOUNCE "desc". The first two are both name matches and need no
|
|
31
|
+
* explanation; the third is the one a reader would otherwise read as broken matching.
|
|
32
|
+
*/
|
|
33
|
+
export type QualifyFilterTier = "all" | "name" | "namePart" | "desc" | "none";
|
|
34
|
+
export interface QualifyFilterResult<T> {
|
|
35
|
+
rows: T[];
|
|
36
|
+
tier: QualifyFilterTier;
|
|
37
|
+
/** The normalized term actually applied. Empty when the tier is "all". */
|
|
38
|
+
term: string;
|
|
39
|
+
}
|
|
40
|
+
/** How to read a row's two searchable fields. Kept as a callback because the fitting rows and
|
|
41
|
+
* the refused rows carry different field names and must not need two copies of this logic. */
|
|
42
|
+
export type QualifyFilterRead<T> = (row: T) => {
|
|
43
|
+
name?: string | null;
|
|
44
|
+
description?: string | null;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Filter `rows` by `term`, PRESERVING INPUT ORDER EXACTLY in every branch.
|
|
48
|
+
*
|
|
49
|
+
* THREE TIERS, AND THE FIRST NON-EMPTY ONE WINS OUTRIGHT. That single rule is what makes this
|
|
50
|
+
* feel like a name filter instead of a search box:
|
|
51
|
+
*
|
|
52
|
+
* 1. the term starts a WORD in the name - "gan" -> Gantt chart, and NOT Organization chart
|
|
53
|
+
* 2. it appears mid-word in the name - "eth" -> Choropleth, which tier 1 cannot reach
|
|
54
|
+
* 3. it appears in the description - "hierarchy" -> Organization chart
|
|
55
|
+
*
|
|
56
|
+
* Typing "time" returns `Time series plot` and NOT the dozen types whose descriptions happen to
|
|
57
|
+
* say "over time", which is what a flat name-or-description match produces and which reads,
|
|
58
|
+
* correctly, as broken.
|
|
59
|
+
*
|
|
60
|
+
* The description tier exists so a reader who types a word we carry in no NAME still finds
|
|
61
|
+
* something rather than an empty list, and it fires only when both name tiers are empty. When it
|
|
62
|
+
* fires the host has to say so ("No name matches - showing types whose description mentions
|
|
63
|
+
* 'stacked'"): an unannounced fallback is indistinguishable from bad matching. The two NAME tiers
|
|
64
|
+
* need no announcement - both are the reader's own word, found where they expected it.
|
|
65
|
+
*
|
|
66
|
+
* A row with no name is dropped from every tier. It cannot be labelled, so it cannot be chosen,
|
|
67
|
+
* and the hosts already drop it at render time.
|
|
68
|
+
*/
|
|
69
|
+
export declare function filterQualifyRows<T>(rows: readonly T[] | null | undefined, term: string | null | undefined, read: QualifyFilterRead<T>): QualifyFilterResult<T>;
|
|
70
|
+
/** Reads a fitting row (`charts[]`): `name` / `description`. */
|
|
71
|
+
export declare const readQualifyChartRow: QualifyFilterRead<{
|
|
72
|
+
name?: string | null;
|
|
73
|
+
description?: string | null;
|
|
74
|
+
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Reads a REFUSED row (`refused[]`). Its second field is the gate's SENTENCE rather than a
|
|
77
|
+
* description, and searching it is right for the same reason the tier exists: the sentence names
|
|
78
|
+
* the reader's own columns, so "region" finding every type refused over Region is a useful
|
|
79
|
+
* answer to "why isn't my chart here" - which is the only question that section is open to ask.
|
|
80
|
+
*/
|
|
81
|
+
export declare const readQualifyRefusalRow: QualifyFilterRead<{
|
|
82
|
+
name?: string | null;
|
|
83
|
+
reason?: string | null;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* THE HEIGHT THE FILTER ROW COSTS THE LIST - the row plus its 6px bottom margin.
|
|
87
|
+
*
|
|
88
|
+
* Measured, not assumed: 32px, and constant across all 195 tile sizes in the sweep below (the
|
|
89
|
+
* row is a flex line around a 0.85em input, so it does not reflow with the card).
|
|
90
|
+
*/
|
|
91
|
+
export declare const FILTER_ROW_PX = 32;
|
|
92
|
+
/**
|
|
93
|
+
* THE SMALLEST CARD THE FILTER BOX IS USABLE IN - measured 2026-09-03, and NOT arithmetic off
|
|
94
|
+
* `CHOOSER_MIN_*`.
|
|
95
|
+
*
|
|
96
|
+
* The chooser's own floor (400 x 240) was measured with three pinned elements. The filter row is
|
|
97
|
+
* a FOURTH, in a card that clips, so the old envelope does not survive the addition and could not
|
|
98
|
+
* be adjusted by adding 32px to it - the coupling between the two dimensions moves too.
|
|
99
|
+
*
|
|
100
|
+
* Same harness as the 2026-09-01 chooser sweep, re-run over 195 sizes with the row present. The
|
|
101
|
+
* run reproduces the chooser's published coupling exactly when the row is hidden (320-340 wide
|
|
102
|
+
* needs 280 tall, 360-380 needs 260, 400+ needs 220), which is what says the harness is measuring
|
|
103
|
+
* the same card and not a different one.
|
|
104
|
+
*
|
|
105
|
+
* ONE CRITERION IS TIGHTER, and it is the reason this is a separate floor rather than a bigger
|
|
106
|
+
* version of the old one: the chooser sweep asked for TWO visible list rows, this asks for
|
|
107
|
+
* THREE. A filter that leaves two rows visible did not earn the height it cost.
|
|
108
|
+
*
|
|
109
|
+
* Measured envelope with the row present:
|
|
110
|
+
*
|
|
111
|
+
* 320-340 wide -> 380 tall 400 wide -> 300 tall 420+ wide -> 280 tall
|
|
112
|
+
*
|
|
113
|
+
* 420 x 300 is that envelope at its cheapest width, with one sweep step (20px) of margin on the
|
|
114
|
+
* height for the reason `CHOOSER_MIN_*` states: the measurement used one font stack and one
|
|
115
|
+
* string, every host localizes the title, and a longer translation wraps sooner than the sample.
|
|
116
|
+
*
|
|
117
|
+
* ERRING HIGH IS THE CHEAP DIRECTION, exactly as it is for the chooser. Below this floor the
|
|
118
|
+
* reader gets the chooser they have today, unchanged; above it wrongly, they get a clipped
|
|
119
|
+
* control in a card that cannot scroll to reveal it.
|
|
120
|
+
*/
|
|
121
|
+
export declare const FILTER_MIN_WIDTH_PX = 420;
|
|
122
|
+
export declare const FILTER_MIN_HEIGHT_PX = 300;
|
|
123
|
+
/**
|
|
124
|
+
* Condition A: can this card afford the box at all?
|
|
125
|
+
*
|
|
126
|
+
* MEASURE THE REAL SURFACE - the card as drawn, never a viewport the host reports outward. The
|
|
127
|
+
* chooser's gate says the same thing for the same reason: where an author can state a viewport
|
|
128
|
+
* for generation purposes, that stated size describes a tile that does not exist yet.
|
|
129
|
+
*/
|
|
130
|
+
export declare function filterFitsChooser(width: number, height: number): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Condition B: is the list long enough to be worth filtering?
|
|
133
|
+
*
|
|
134
|
+
* A filter box over four rows is clutter that spends pinned height for nothing, so the box
|
|
135
|
+
* appears only when the list actually scrolls.
|
|
136
|
+
*
|
|
137
|
+
* THE `- FILTER_ROW_PX` IS LOAD-BEARING, and leaving it out is how the naive version ships a
|
|
138
|
+
* flicker loop: inserting the box shrinks the list, which can turn a list that did not overflow
|
|
139
|
+
* into one that does, which would remove the box, which restores the overflow. Asking whether the
|
|
140
|
+
* content exceeds the height the list WOULD have is a single stable pass, because `scrollHeight`
|
|
141
|
+
* is content height and does not change when the container shrinks.
|
|
142
|
+
*/
|
|
143
|
+
export declare function listNeedsFilter(scrollHeight: number, clientHeight: number): boolean;
|
|
144
|
+
/** Why the box is or is not on screen - logged once per open, so the floor can be judged against
|
|
145
|
+
* real tiles rather than only against the sweep. */
|
|
146
|
+
export type QualifyFilterGateReason = "shown" | "too-small" | "no-overflow";
|
|
147
|
+
export interface QualifyFilterGateInput {
|
|
148
|
+
/** Real drawn width of the CARD, in CSS pixels. */
|
|
149
|
+
cardWidth: number;
|
|
150
|
+
/** Real drawn height of the card. */
|
|
151
|
+
cardHeight: number;
|
|
152
|
+
/** The list's content height and its current viewport height. */
|
|
153
|
+
scrollHeight: number;
|
|
154
|
+
clientHeight: number;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Both conditions, and the reason - which the host logs whichever way it comes out.
|
|
158
|
+
*
|
|
159
|
+
* DECIDED ONCE PER OPEN. Neither host re-asks this while the dialog is up: not when the filter
|
|
160
|
+
* hides rows and the list stops overflowing (a control that vanishes mid-typing is worse than one
|
|
161
|
+
* that is briefly unnecessary), and not on resize (a dialog whose controls appear and disappear
|
|
162
|
+
* under a window drag reads as broken).
|
|
163
|
+
*/
|
|
164
|
+
export declare function qualifyFilterGate(i: QualifyFilterGateInput): {
|
|
165
|
+
show: boolean;
|
|
166
|
+
why: QualifyFilterGateReason;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* The same question for a host whose chooser is an INLINE, SCROLLING PANEL - and it has no size
|
|
170
|
+
* clause, for the reason `shouldOpenInlineChooserOnGenerate` already sets out at length.
|
|
171
|
+
*
|
|
172
|
+
* THE ASYMMETRY IS THE POINT AND IT IS MEASURED. The modal lives in a card that CLIPS, so past a
|
|
173
|
+
* certain smallness its controls are simply not on screen. A panel that flows inside a scrolling
|
|
174
|
+
* pane, with a wrapping footer and a list carrying its own max-height, cannot reach that state -
|
|
175
|
+
* swept across 63 pane sizes down to 200x120, every one stayed usable. A size clause here would
|
|
176
|
+
* protect nobody and would switch the feature off in a default Excel task pane, which is narrower
|
|
177
|
+
* than the modal's floor.
|
|
178
|
+
*
|
|
179
|
+
* The OVERFLOW half still applies: a five-row answer does not want a filter box in any host.
|
|
180
|
+
*/
|
|
181
|
+
export declare function inlineFilterGate(rowCount: number): {
|
|
182
|
+
show: boolean;
|
|
183
|
+
why: QualifyFilterGateReason;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* The inline panel's stand-in for "the list scrolls".
|
|
187
|
+
*
|
|
188
|
+
* A COUNT RATHER THAN A MEASUREMENT, because the panel's list carries a fixed `max-height: 220px`
|
|
189
|
+
* and rows are one line each - so the count IS the overflow question there, where in the modal
|
|
190
|
+
* the card's height is the variable and the count is not. Eight rows at ~24px is the point the
|
|
191
|
+
* 220px list starts scrolling; below it there is nothing to scroll past.
|
|
192
|
+
*/
|
|
193
|
+
export declare const INLINE_FILTER_MIN_ROWS = 8;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bicharts/chart-host",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.60",
|
|
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",
|