@bicharts/chart-host 0.5.73 → 0.5.74

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.
@@ -1972,6 +1972,267 @@ function fitRenderedChart(container, opts = {}) {
1972
1972
  return result;
1973
1973
  }
1974
1974
 
1975
+ // src/labelContrast.ts
1976
+ function parseRGBA(c) {
1977
+ if (typeof c !== "string") return null;
1978
+ const s = c.trim();
1979
+ const m2 = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i);
1980
+ if (m2) return [parseFloat(m2[1]), parseFloat(m2[2]), parseFloat(m2[3]), m2[4] !== void 0 ? parseFloat(m2[4]) : 1];
1981
+ let h2 = s.replace(/^#/, "");
1982
+ if (h2.length === 3) h2 = h2.split("").map((ch) => ch + ch).join("");
1983
+ if (h2.length === 6 && /^[0-9a-fA-F]{6}$/.test(h2)) {
1984
+ return [parseInt(h2.slice(0, 2), 16), parseInt(h2.slice(2, 4), 16), parseInt(h2.slice(4, 6), 16), 1];
1985
+ }
1986
+ return null;
1987
+ }
1988
+ var DARK_TEXT = "#111111";
1989
+ var LIGHT_TEXT = "#ffffff";
1990
+ var MIN_CONTRAST = 3;
1991
+ var WHITE_TEXT_BG_LUM = 0.5;
1992
+ var PILL_MIN_ALPHA = 0.05;
1993
+ var PILL_OPAQUE_ALPHA = 0.85;
1994
+ var PAGE_MATCH_TOLERANCE = 2;
1995
+ function isPillBackdropAlpha(a2) {
1996
+ return a2 >= PILL_MIN_ALPHA && a2 < PILL_OPAQUE_ALPHA;
1997
+ }
1998
+ var PILL_MIN_COVERAGE = 0.6;
1999
+ function pillBacksGlyph(overlapArea, glyphArea) {
2000
+ if (!(glyphArea > 0) || !isFinite(overlapArea) || overlapArea <= 0) return false;
2001
+ return overlapArea / glyphArea >= PILL_MIN_COVERAGE;
2002
+ }
2003
+ var BACKING_MAJORITY = 0.5;
2004
+ function backingHoldsGlyph(backedArea, glyphArea) {
2005
+ if (!(glyphArea > 0) || !isFinite(backedArea) || backedArea <= 0) return false;
2006
+ return backedArea / glyphArea >= BACKING_MAJORITY;
2007
+ }
2008
+ function cellSuppressesNormalize(cellClass) {
2009
+ return typeof cellClass === "string" && /(^|\s)lch-deck-panel(\s|$)/.test(cellClass);
2010
+ }
2011
+ var NAMED = {
2012
+ white: [255, 255, 255, 1],
2013
+ black: [0, 0, 0, 1],
2014
+ none: [0, 0, 0, 0],
2015
+ transparent: [0, 0, 0, 0]
2016
+ };
2017
+ function toRGBA(c) {
2018
+ if (typeof c !== "string") return null;
2019
+ const k2 = c.trim().toLowerCase();
2020
+ if (k2 in NAMED) return NAMED[k2];
2021
+ return parseRGBA(c);
2022
+ }
2023
+ function compositeOver(fg, bg) {
2024
+ const a2 = Math.max(0, Math.min(1, fg[3]));
2025
+ return [
2026
+ Math.round(fg[0] * a2 + bg[0] * (1 - a2)),
2027
+ Math.round(fg[1] * a2 + bg[1] * (1 - a2)),
2028
+ Math.round(fg[2] * a2 + bg[2] * (1 - a2))
2029
+ ];
2030
+ }
2031
+ function relativeLuminance(rgb) {
2032
+ const lin = rgb.map((v2) => {
2033
+ const s = v2 / 255;
2034
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
2035
+ });
2036
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
2037
+ }
2038
+ function contrastRatio(l1, l2) {
2039
+ const hi = Math.max(l1, l2);
2040
+ const lo = Math.min(l1, l2);
2041
+ return (hi + 0.05) / (lo + 0.05);
2042
+ }
2043
+ function decideLabelColor(textColor, fill, fillOpacity, pageBg, minContrast = MIN_CONTRAST, normalize = false) {
2044
+ const fillRGBA = toRGBA(fill);
2045
+ if (!fillRGBA) return { fix: false, color: "", reason: "fill-unparsed" };
2046
+ const pageRGBA = toRGBA(pageBg) || [255, 255, 255, 1];
2047
+ const pageRGB = [pageRGBA[0], pageRGBA[1], pageRGBA[2]];
2048
+ const effFill = [fillRGBA[0], fillRGBA[1], fillRGBA[2], fillRGBA[3] * clamp01(fillOpacity)];
2049
+ const bgRGB = compositeOver(effFill, pageRGB);
2050
+ const bgLum = relativeLuminance(bgRGB);
2051
+ const bgIsPage = Math.abs(bgRGB[0] - pageRGB[0]) <= PAGE_MATCH_TOLERANCE && Math.abs(bgRGB[1] - pageRGB[1]) <= PAGE_MATCH_TOLERANCE && Math.abs(bgRGB[2] - pageRGB[2]) <= PAGE_MATCH_TOLERANCE;
2052
+ const effNormalize = normalize && !bgIsPage;
2053
+ const textRGBA = toRGBA(textColor);
2054
+ if (!effNormalize && textRGBA) {
2055
+ const txtRGB = compositeOver(textRGBA, bgRGB);
2056
+ const cur = contrastRatio(relativeLuminance(txtRGB), bgLum);
2057
+ if (cur >= minContrast) return { fix: false, color: "", reason: "ok" };
2058
+ }
2059
+ const darkC = contrastRatio(relativeLuminance(hexToRGB(DARK_TEXT)), bgLum);
2060
+ const lightC = contrastRatio(relativeLuminance(hexToRGB(LIGHT_TEXT)), bgLum);
2061
+ let useLight = bgLum <= WHITE_TEXT_BG_LUM;
2062
+ if (useLight && lightC < minContrast && darkC >= minContrast) useLight = false;
2063
+ if (!useLight && darkC < minContrast && lightC >= minContrast) useLight = true;
2064
+ return useLight ? { fix: true, color: LIGHT_TEXT, reason: "fixed-light" } : { fix: true, color: DARK_TEXT, reason: "fixed-dark" };
2065
+ }
2066
+ var GLYPH_SAMPLE_N = 3;
2067
+ function glyphSampleGrid(box, n = GLYPH_SAMPLE_N) {
2068
+ const out = [];
2069
+ if (!box || !(box.width > 0) || !(box.height > 0) || !(n >= 1)) return out;
2070
+ const k2 = Math.floor(n);
2071
+ for (let i = 0; i < k2; i++) {
2072
+ for (let j2 = 0; j2 < k2; j2++) {
2073
+ out.push({
2074
+ x: box.left + box.width * (i + 0.5) / k2,
2075
+ y: box.top + box.height * (j2 + 0.5) / k2
2076
+ });
2077
+ }
2078
+ }
2079
+ return out;
2080
+ }
2081
+ function clamp01(v2) {
2082
+ if (typeof v2 !== "number" || !isFinite(v2)) return 1;
2083
+ return Math.max(0, Math.min(1, v2));
2084
+ }
2085
+ function hexToRGB(hex) {
2086
+ const p2 = toRGBA(hex);
2087
+ return [p2[0], p2[1], p2[2]];
2088
+ }
2089
+
2090
+ // src/labelContrastDom.ts
2091
+ var LABEL_CONTRAST_DONE_ATTR = "data-lch-contrast";
2092
+ var LABEL_CONTRAST_CAP = 2e3;
2093
+ var EMPTY3 = { rects: 0, scanned: 0, fixed: 0, pillsBoosted: 0, offFill: 0, pageMajority: 0 };
2094
+ function backedSamples(el, pts) {
2095
+ try {
2096
+ const ge = el;
2097
+ if (typeof ge.isPointInFill !== "function" || typeof ge.getScreenCTM !== "function") return null;
2098
+ const m2 = ge.getScreenCTM();
2099
+ const svg = ge.ownerSVGElement;
2100
+ if (!m2 || !svg || typeof svg.createSVGPoint !== "function") return null;
2101
+ if (pts.length === 0) return null;
2102
+ const inv = m2.inverse();
2103
+ const hit = [];
2104
+ for (let i = 0; i < pts.length; i++) {
2105
+ const p2 = svg.createSVGPoint();
2106
+ p2.x = pts[i].x;
2107
+ p2.y = pts[i].y;
2108
+ hit.push(ge.isPointInFill(p2.matrixTransform(inv)));
2109
+ }
2110
+ return hit;
2111
+ } catch {
2112
+ return null;
2113
+ }
2114
+ }
2115
+ function applyLabelContrast(container, opts = {}) {
2116
+ const report = { ...EMPTY3 };
2117
+ if (!container) {
2118
+ report.skipped = "no-container";
2119
+ return report;
2120
+ }
2121
+ const CAP = opts.cap ?? LABEL_CONTRAST_CAP;
2122
+ const pageBg = opts.pageBg || "#ffffff";
2123
+ try {
2124
+ const rects = [];
2125
+ let ord = 0;
2126
+ const pushShape = (el) => {
2127
+ const tag = el.tagName.toLowerCase();
2128
+ if (tag !== "rect" && tag !== "path") return;
2129
+ const r = el.getBoundingClientRect();
2130
+ if (r.width <= 0 || r.height <= 0) return;
2131
+ let fill = el.getAttribute("fill") || "";
2132
+ const win2 = container.ownerDocument?.defaultView;
2133
+ if ((!fill || fill === "none") && win2 && typeof win2.getComputedStyle === "function") {
2134
+ fill = win2.getComputedStyle(el).fill || "";
2135
+ }
2136
+ if (!fill || fill === "none") return;
2137
+ const opAttr = el.getAttribute("fill-opacity");
2138
+ const op = opAttr === null ? 1 : parseFloat(opAttr);
2139
+ rects.push({ r, fill, op: isFinite(op) ? op : 1, area: r.width * r.height, el, ord: ord++ });
2140
+ };
2141
+ const shapeEls = container.querySelectorAll("rect, path");
2142
+ if (shapeEls.length === 0) {
2143
+ report.skipped = "no-shapes";
2144
+ return report;
2145
+ }
2146
+ if (shapeEls.length > CAP) {
2147
+ report.skipped = "too-many-shapes";
2148
+ return report;
2149
+ }
2150
+ for (let i = 0; i < shapeEls.length; i++) pushShape(shapeEls[i]);
2151
+ if (rects.length === 0) {
2152
+ report.skipped = "no-shapes";
2153
+ return report;
2154
+ }
2155
+ report.rects = rects.length;
2156
+ const pageRGBA = toRGBA(pageBg) || [255, 255, 255, 1];
2157
+ const pageRGB = [pageRGBA[0], pageRGBA[1], pageRGBA[2]];
2158
+ const effAlpha = (mk) => {
2159
+ const c = toRGBA(mk.fill);
2160
+ return (c ? c[3] : 1) * mk.op;
2161
+ };
2162
+ const texts = container.querySelectorAll("text");
2163
+ if (texts.length > CAP) {
2164
+ report.skipped = "too-many-texts";
2165
+ return report;
2166
+ }
2167
+ const win = container.ownerDocument?.defaultView;
2168
+ for (let t = 0; t < texts.length; t++) {
2169
+ const tx = texts[t];
2170
+ if (tx.getAttribute(LABEL_CONTRAST_DONE_ATTR) === "1") continue;
2171
+ const tr = tx.getBoundingClientRect();
2172
+ if (tr.width <= 0 || tr.height <= 0) continue;
2173
+ const ovArea = (r) => Math.max(0, Math.min(r.right, tr.right) - Math.max(r.left, tr.left)) * Math.max(0, Math.min(r.bottom, tr.bottom) - Math.max(r.top, tr.top));
2174
+ const glyphArea = tr.width * tr.height;
2175
+ const boxed = rects.map((mk) => ({ mk, ov: ovArea(mk.r) })).filter((x2) => x2.ov > 0);
2176
+ const pts = glyphSampleGrid({ left: tr.left, top: tr.top, width: tr.width, height: tr.height });
2177
+ const painted = pts.map(() => false);
2178
+ let unmeasured = 0;
2179
+ const under = boxed.map((x2) => {
2180
+ const mask = backedSamples(x2.mk.el, pts);
2181
+ if (mask === null) {
2182
+ unmeasured++;
2183
+ return x2;
2184
+ }
2185
+ let inside = 0;
2186
+ const paints = effAlpha(x2.mk) >= PILL_MIN_ALPHA;
2187
+ for (let i = 0; i < mask.length; i++) {
2188
+ if (!mask[i]) continue;
2189
+ inside++;
2190
+ if (paints) painted[i] = true;
2191
+ }
2192
+ return { mk: x2.mk, ov: glyphArea * (inside / mask.length) };
2193
+ }).filter((x2) => x2.ov > 0);
2194
+ if (under.length === 0 && boxed.length > 0) report.offFill++;
2195
+ if (under.length === 0) continue;
2196
+ if (unmeasured === 0 && pts.length > 0) {
2197
+ let held = 0;
2198
+ for (let i = 0; i < painted.length; i++) if (painted[i]) held++;
2199
+ if (!backingHoldsGlyph(glyphArea * (held / pts.length), glyphArea)) {
2200
+ report.pageMajority++;
2201
+ continue;
2202
+ }
2203
+ }
2204
+ report.scanned++;
2205
+ const opaqueUnder = under.filter((x2) => effAlpha(x2.mk) >= PILL_OPAQUE_ALPHA);
2206
+ const cellEntry = (opaqueUnder.length ? opaqueUnder : under).reduce((a2, b2) => b2.ov > a2.ov || b2.ov === a2.ov && b2.mk.ord > a2.mk.ord ? b2 : a2);
2207
+ const cell = cellEntry.mk;
2208
+ const pill = under.find((x2) => x2.mk !== cell && isPillBackdropAlpha(effAlpha(x2.mk)) && x2.mk.ord > cell.ord && pillBacksGlyph(x2.ov, glyphArea))?.mk;
2209
+ const cellRGBA = toRGBA(cell.fill);
2210
+ let bgRGB = cellRGBA ? compositeOver([cellRGBA[0], cellRGBA[1], cellRGBA[2], cellRGBA[3] * cell.op], pageRGB) : pageRGB;
2211
+ if (pill) {
2212
+ const pr = toRGBA(pill.fill);
2213
+ if (pr) {
2214
+ bgRGB = compositeOver([pr[0], pr[1], pr[2], 0.9], bgRGB);
2215
+ pill.el.setAttribute("fill", `rgb(${pr[0]}, ${pr[1]}, ${pr[2]})`);
2216
+ pill.el.setAttribute("fill-opacity", "0.9");
2217
+ report.pillsBoosted++;
2218
+ }
2219
+ }
2220
+ const cur = tx.getAttribute("fill") || (win && typeof win.getComputedStyle === "function" ? win.getComputedStyle(tx).fill : "");
2221
+ const normalize = !cellSuppressesNormalize(cell.el.getAttribute("class"));
2222
+ const d = decideLabelColor(cur, `rgb(${bgRGB[0]}, ${bgRGB[1]}, ${bgRGB[2]})`, 1, pageBg, MIN_CONTRAST, normalize);
2223
+ if (d.fix) {
2224
+ tx.setAttribute("fill", d.color);
2225
+ report.fixed++;
2226
+ }
2227
+ if (pill || d.fix) tx.setAttribute(LABEL_CONTRAST_DONE_ATTR, "1");
2228
+ }
2229
+ return report;
2230
+ } catch {
2231
+ report.skipped = "error";
2232
+ return report;
2233
+ }
2234
+ }
2235
+
1975
2236
  // src/host.ts
1976
2237
  function sessionViewStateProvider(container) {
1977
2238
  const holder = container;
@@ -2234,6 +2495,7 @@ function createChartHost(container, config) {
2234
2495
  container.classList?.add(HOST_CONTAINER_CLASS);
2235
2496
  ensureAffordanceStyles();
2236
2497
  const fitOpts = config.fit ?? true;
2498
+ const labelContrastOpts = config.labelContrast ?? true;
2237
2499
  const stopAnim = () => {
2238
2500
  const s = container[CONTAINER_SLOT_ANIM_STOP];
2239
2501
  if (typeof s === "function") {
@@ -2273,6 +2535,18 @@ function createChartHost(container, config) {
2273
2535
  } catch (err) {
2274
2536
  throw explainRenderFailure(err, d3);
2275
2537
  }
2538
+ if (labelContrastOpts !== false) {
2539
+ try {
2540
+ const lc = labelContrastOpts === true ? {} : { ...labelContrastOpts };
2541
+ if (!lc.pageBg) {
2542
+ const bg = resolved.backgroundColor ?? resolved.themeBg;
2543
+ if (typeof bg === "string" && bg) lc.pageBg = bg;
2544
+ }
2545
+ const r = applyLabelContrast(container, lc);
2546
+ config.onLabelContrast?.(r);
2547
+ } catch {
2548
+ }
2549
+ }
2276
2550
  ensureCrossfilterHitTargets(container, doc);
2277
2551
  paintSelection();
2278
2552
  try {
@@ -2432,6 +2706,29 @@ export {
2432
2706
  measureContainerBoxes,
2433
2707
  fitReadingFor,
2434
2708
  fitRenderedChart,
2709
+ DARK_TEXT,
2710
+ LIGHT_TEXT,
2711
+ MIN_CONTRAST,
2712
+ WHITE_TEXT_BG_LUM,
2713
+ PILL_MIN_ALPHA,
2714
+ PILL_OPAQUE_ALPHA,
2715
+ PAGE_MATCH_TOLERANCE,
2716
+ isPillBackdropAlpha,
2717
+ PILL_MIN_COVERAGE,
2718
+ pillBacksGlyph,
2719
+ BACKING_MAJORITY,
2720
+ backingHoldsGlyph,
2721
+ cellSuppressesNormalize,
2722
+ toRGBA,
2723
+ compositeOver,
2724
+ relativeLuminance,
2725
+ contrastRatio,
2726
+ decideLabelColor,
2727
+ GLYPH_SAMPLE_N,
2728
+ glyphSampleGrid,
2729
+ LABEL_CONTRAST_DONE_ATTR,
2730
+ LABEL_CONTRAST_CAP,
2731
+ applyLabelContrast,
2435
2732
  sessionViewStateProvider,
2436
2733
  noopViewStateProvider,
2437
2734
  requiredD3Plugins,
package/dist/index.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  ANIM_PLAY_SPEED_MIN,
12
12
  APPROXIMATE_POSITIONS_DEFAULT,
13
13
  AXIS_FILTER_CLASS,
14
+ BACKING_MAJORITY,
14
15
  COLOR_SCALE_SELF_CLAMP_PCT_DEFAULT,
15
16
  COLOR_SCALE_SELF_CLAMP_PCT_MAX,
16
17
  COLOR_SCALE_SELF_CLAMP_PCT_MIN,
@@ -18,21 +19,31 @@ import {
18
19
  CONTAINER_SLOT_INITIAL_XF_MARK,
19
20
  CONTAINER_SLOT_UI_STATE,
20
21
  CONTAINER_SLOT_XF_CLEAR,
22
+ DARK_TEXT,
21
23
  DIM_OPACITY_DEFAULT,
22
24
  DIM_OPACITY_VAR,
23
25
  FIT_CONTENT_SELECTOR,
24
26
  FLIP_MODE_DEFAULT,
25
27
  GEO_POINT_PRECISIONS,
28
+ GLYPH_SAMPLE_N,
26
29
  HOST_CONTAINER_CLASS,
27
30
  HOST_CONTRACT_VERSION,
28
31
  L3,
32
+ LABEL_CONTRAST_CAP,
33
+ LABEL_CONTRAST_DONE_ATTR,
29
34
  LEGEND_MARK_CLASS,
30
35
  LIFT_SELECTED_CLASS,
36
+ LIGHT_TEXT,
31
37
  MARK_CLASS,
32
38
  MARK_SELECTED_CLASS,
33
39
  MAX_FRAME_GROW_FACTOR,
40
+ MIN_CONTRAST,
34
41
  MIN_HIT_BAND_PX,
42
+ PAGE_MATCH_TOLERANCE,
35
43
  PHANTOM_FRACTION,
44
+ PILL_MIN_ALPHA,
45
+ PILL_MIN_COVERAGE,
46
+ PILL_OPAQUE_ALPHA,
36
47
  ROW_IDX_ATTR,
37
48
  Rt,
38
49
  SCROLL_SLACK_PX,
@@ -41,43 +52,55 @@ import {
41
52
  Se,
42
53
  Tt,
43
54
  VALUE_AXIS_BASELINE_DEFAULT,
55
+ WHITE_TEXT_BG_LUM,
44
56
  XFILTER_REFRESH_EVENT,
57
+ applyLabelContrast,
58
+ backingHoldsGlyph,
45
59
  blankRenderFlag,
46
60
  buildRenderPayload,
61
+ cellSuppressesNormalize,
47
62
  censusHitBands,
48
63
  censusMarks,
49
64
  chartOwnsTimeline,
50
65
  clearGeoCache,
51
66
  compileRenderFn,
67
+ compositeOver,
52
68
  contentExtentOf,
69
+ contrastRatio,
53
70
  createChartHost,
54
71
  createMarkResolver,
55
72
  ctmScaleOf,
73
+ decideLabelColor,
56
74
  ensureCrossfilterHitTargets,
57
75
  explainRenderFailure,
58
76
  fitReadingFor,
59
77
  fitRenderedChart,
60
78
  geoAssetFor,
61
79
  geoFromCache,
80
+ glyphSampleGrid,
62
81
  hitBandFlag,
63
82
  isBlankRender,
64
83
  isPhantomBox,
84
+ isPillBackdropAlpha,
65
85
  loadGeo,
66
86
  measureContainerBoxes,
67
87
  needsScroll,
68
88
  noopViewStateProvider,
69
89
  periodTickSuppressesFeedback,
90
+ pillBacksGlyph,
70
91
  planFrameGrow,
71
92
  registerGeo,
72
93
  registerGeoAsset,
94
+ relativeLuminance,
73
95
  requiredD3Plugins,
74
96
  resolveOptions,
75
97
  scrollFitFor,
76
98
  sessionViewStateProvider,
77
99
  stripEsmExports,
78
100
  svgInkReach,
101
+ toRGBA,
79
102
  ye
80
- } from "./chunk-D3DERYG3.mjs";
103
+ } from "./chunk-E6P2ZMGC.mjs";
81
104
  import "./chunk-A2GMXZP7.mjs";
82
105
  import "./chunk-GHODWOAL.mjs";
83
106
 
@@ -1010,6 +1033,7 @@ export {
1010
1033
  ANIM_PLAY_SPEED_MIN,
1011
1034
  APPROXIMATE_POSITIONS_DEFAULT,
1012
1035
  AXIS_FILTER_CLASS,
1036
+ BACKING_MAJORITY,
1013
1037
  CHOOSER_MIN_HEIGHT_PX,
1014
1038
  CHOOSER_MIN_WIDTH_PX,
1015
1039
  COLOR_SCALE_SELF_CLAMP_PCT_DEFAULT,
@@ -1019,6 +1043,7 @@ export {
1019
1043
  CONTAINER_SLOT_INITIAL_XF_MARK,
1020
1044
  CONTAINER_SLOT_UI_STATE,
1021
1045
  CONTAINER_SLOT_XF_CLEAR,
1046
+ DARK_TEXT,
1022
1047
  DIM_OPACITY_DEFAULT,
1023
1048
  DIM_OPACITY_VAR,
1024
1049
  FILTER_MIN_HEIGHT_PX,
@@ -1028,30 +1053,43 @@ export {
1028
1053
  FIT_CONTENT_SELECTOR,
1029
1054
  FLIP_MODE_DEFAULT,
1030
1055
  GEO_POINT_PRECISIONS,
1056
+ GLYPH_SAMPLE_N,
1031
1057
  HOST_CONTAINER_CLASS,
1032
1058
  HOST_CONTRACT_VERSION,
1033
1059
  INLINE_FILTER_MIN_ROWS,
1060
+ LABEL_CONTRAST_CAP,
1061
+ LABEL_CONTRAST_DONE_ATTR,
1034
1062
  LEGEND_MARK_CLASS,
1035
1063
  LIFT_SELECTED_CLASS,
1064
+ LIGHT_TEXT,
1036
1065
  MARK_CLASS,
1037
1066
  MARK_SELECTED_CLASS,
1038
1067
  MAX_FRAME_GROW_FACTOR,
1068
+ MIN_CONTRAST,
1039
1069
  MIN_HIT_BAND_PX,
1070
+ PAGE_MATCH_TOLERANCE,
1040
1071
  PHANTOM_FRACTION,
1072
+ PILL_MIN_ALPHA,
1073
+ PILL_MIN_COVERAGE,
1074
+ PILL_OPAQUE_ALPHA,
1041
1075
  ROW_IDX_ATTR,
1042
1076
  SCROLL_SLACK_PX,
1043
1077
  SEASONAL_MARKERS_DEFAULT,
1044
1078
  SELECTION_ACTIVE_CLASS,
1045
1079
  VALUE_AXIS_BASELINE_DEFAULT,
1080
+ WHITE_TEXT_BG_LUM,
1046
1081
  XFILTER_REFRESH_EVENT,
1047
1082
  actionFor,
1083
+ applyLabelContrast,
1048
1084
  askApplyImprovements,
1085
+ backingHoldsGlyph,
1049
1086
  bareBase64,
1050
1087
  blankRenderFlag,
1051
1088
  buildRenderPayload,
1052
1089
  buildReviewWire,
1053
1090
  canConfirmLaunch,
1054
1091
  captureSvgSnapshot,
1092
+ cellSuppressesNormalize,
1055
1093
  censusHitBands,
1056
1094
  censusMarks,
1057
1095
  chartOwnsTimeline,
@@ -1059,13 +1097,16 @@ export {
1059
1097
  clearGeoCache,
1060
1098
  compileRenderFn,
1061
1099
  compileTrivialSource,
1100
+ compositeOver,
1062
1101
  computeQualifyFilterView,
1063
1102
  computeSelectionCard,
1064
1103
  confirmLaunch,
1065
1104
  contentExtentOf,
1105
+ contrastRatio,
1066
1106
  createChartHost,
1067
1107
  createMarkResolver,
1068
1108
  ctmScaleOf,
1109
+ decideLabelColor,
1069
1110
  ensureCrossfilterHitTargets,
1070
1111
  explainRenderFailure,
1071
1112
  filterFitsChooser,
@@ -1074,11 +1115,13 @@ export {
1074
1115
  fitRenderedChart,
1075
1116
  geoAssetFor,
1076
1117
  geoFromCache,
1118
+ glyphSampleGrid,
1077
1119
  hasRefusalsToShow,
1078
1120
  hitBandFlag,
1079
1121
  inlineFilterGate,
1080
1122
  isBlankRender,
1081
1123
  isPhantomBox,
1124
+ isPillBackdropAlpha,
1082
1125
  launchFavorStyle,
1083
1126
  launchGenerates,
1084
1127
  listNeedsFilter,
@@ -1092,6 +1135,7 @@ export {
1092
1135
  normalizeFilterTerm,
1093
1136
  orderRefusalsForDisplay,
1094
1137
  periodTickSuppressesFeedback,
1138
+ pillBacksGlyph,
1095
1139
  planFrameGrow,
1096
1140
  planTrivialChart,
1097
1141
  qualifyAuto,
@@ -1109,6 +1153,7 @@ export {
1109
1153
  registerCityTable,
1110
1154
  registerGeo,
1111
1155
  registerGeoAsset,
1156
+ relativeLuminance,
1112
1157
  requiredD3Plugins,
1113
1158
  resolveOptions,
1114
1159
  scrollFitFor,
@@ -1119,5 +1164,6 @@ export {
1119
1164
  stripEsmExports,
1120
1165
  svgInkReach,
1121
1166
  svgNaturalSize,
1122
- svgToDataUrl
1167
+ svgToDataUrl,
1168
+ toRGBA
1123
1169
  };
package/dist/react.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createChartHost,
5
5
  geoFromCache,
6
6
  loadGeo
7
- } from "./chunk-D3DERYG3.mjs";
7
+ } from "./chunk-E6P2ZMGC.mjs";
8
8
  import "./chunk-A2GMXZP7.mjs";
9
9
  import "./chunk-GHODWOAL.mjs";
10
10
 
@@ -3,6 +3,7 @@ import { type ResolveOptionsInput } from "./defaults";
3
3
  import { type MarkCensus } from "./blankRender";
4
4
  import { type HitBandCensus } from "./hitBands";
5
5
  import { type FitRenderedChartOptions, type FitRenderedChartResult } from "./fitDom";
6
+ import { type LabelContrastOptions, type LabelContrastReport } from "./labelContrastDom";
6
7
  export type RenderFn = (container: HTMLElement, data: any, options: RenderOptions) => void;
7
8
  /**
8
9
  * THE SESSION PROVIDER — what every host got hard-coded before contract 1.6.0, now named.
@@ -115,6 +116,25 @@ export interface ChartHostConfig {
115
116
  fit?: boolean | FitRenderedChartOptions;
116
117
  /** The fit result after each render - what was grown, and how the chart measured. */
117
118
  onFit?: (result: FitRenderedChartResult) => void;
119
+ /**
120
+ * CAN THE LABELS ON THE MARKS BE READ? Runs after every render, BEFORE the hit-target heal
121
+ * (which injects transparent rects this pass must not mistake for backdrops).
122
+ *
123
+ * Generated code picks an in-mark label's colour from the mark's NOMINAL hue; the mark's
124
+ * ACTUAL rendered fill - a 0.18-opacity band over white, a translucent pill over a tile, an
125
+ * arc whose hole is canvas - is something else, and only a post-render read of the real DOM
126
+ * knows it. This resolves which shapes really back each label (geometry, not bounding
127
+ * boxes), composites the real stack over the page, and recolours to black or white only
128
+ * where the label would otherwise be unreadable or a tile's labels would read as mixed.
129
+ *
130
+ * DEFAULT ON, because the alternative is text nobody can read, and the pass only ever sets a
131
+ * text's fill. Pass `false` for a host that owns its own label colours, or an options object
132
+ * to name the page background it composites onto (`pageBg`) - a themed or high-contrast
133
+ * canvas is not white, and every readability answer is measured against it.
134
+ */
135
+ labelContrast?: boolean | LabelContrastOptions;
136
+ /** What the label-contrast pass did after each render - counts, so a host can log them. */
137
+ onLabelContrast?: (report: LabelContrastReport) => void;
118
138
  }
119
139
  export interface ChartHost {
120
140
  render(): void;
@@ -16,6 +16,8 @@ export { qualifyPick, qualifyAuto, qualifyCancel, launchGenerates, launchFavorSt
16
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, computeQualifyFilterView, qualifyFilterCountText, type QualifyFilterTier, type QualifyFilterResult, type QualifyFilterRead, type QualifyFilterGateReason, type QualifyFilterGateInput, type QualifyFilterRow, type QualifyFilterGroup, type QualifyFilterView, type QualifyFilterNoteKind, } from "./qualifyFilter";
17
17
  export { computeSelectionCard, normaliseAggregation, type SelectionCardModel, type SelectionCardLine, type SelectionCardOptions, } from "./selectionCard";
18
18
  export { ensureCrossfilterHitTargets, type HitTargetReport } from "./hitTargets";
19
+ export { applyLabelContrast, LABEL_CONTRAST_DONE_ATTR, LABEL_CONTRAST_CAP, type LabelContrastOptions, type LabelContrastReport, } from "./labelContrastDom";
20
+ export { decideLabelColor, toRGBA, compositeOver, relativeLuminance, contrastRatio, isPillBackdropAlpha, pillBacksGlyph, backingHoldsGlyph, cellSuppressesNormalize, glyphSampleGrid, DARK_TEXT, LIGHT_TEXT, MIN_CONTRAST, WHITE_TEXT_BG_LUM, PILL_MIN_ALPHA, PILL_OPAQUE_ALPHA, PILL_MIN_COVERAGE, PAGE_MATCH_TOLERANCE, BACKING_MAJORITY, GLYPH_SAMPLE_N, type LabelDecision, } from "./labelContrast";
19
21
  export { censusMarks, isBlankRender, blankRenderFlag, type MarkCensus, type BlankVerdictInput } from "./blankRender";
20
22
  export { censusHitBands, hitBandFlag, MIN_HIT_BAND_PX, type HitBandCensus } from "./hitBands";
21
23
  export { SCROLL_SLACK_PX, MAX_FRAME_GROW_FACTOR, PHANTOM_FRACTION, FIT_CONTENT_SELECTOR, needsScroll, contentExtentOf, scrollFitFor, planFrameGrow, isPhantomBox, type MeasuredBox, type ContentExtent, type ScrollFit, type FrameGrowPlan, } from "./fit";
@@ -0,0 +1,56 @@
1
+ export declare const DARK_TEXT = "#111111";
2
+ export declare const LIGHT_TEXT = "#ffffff";
3
+ export declare const MIN_CONTRAST = 3;
4
+ export declare const WHITE_TEXT_BG_LUM = 0.5;
5
+ export declare const PILL_MIN_ALPHA = 0.05;
6
+ export declare const PILL_OPAQUE_ALPHA = 0.85;
7
+ export declare const PAGE_MATCH_TOLERANCE = 2;
8
+ /** True when a shape's effective alpha lets it act as a label backdrop rather than the cell. */
9
+ export declare function isPillBackdropAlpha(a: number): boolean;
10
+ export declare const PILL_MIN_COVERAGE = 0.6;
11
+ /** True when a candidate backdrop covers enough of the glyph box to be the thing behind it. */
12
+ export declare function pillBacksGlyph(overlapArea: number, glyphArea: number): boolean;
13
+ export declare const BACKING_MAJORITY = 0.5;
14
+ /** True when the painted marks under a glyph hold enough of it to count as its background. */
15
+ export declare function backingHoldsGlyph(backedArea: number, glyphArea: number): boolean;
16
+ export declare function cellSuppressesNormalize(cellClass: string | null | undefined): boolean;
17
+ type RGB = [number, number, number];
18
+ type RGBA = [number, number, number, number];
19
+ /** Parse a colour string to RGBA, covering hex, rgb()/rgba() and a few keywords. */
20
+ export declare function toRGBA(c: string | null | undefined): RGBA | null;
21
+ /** Alpha-composite a foreground RGBA over an opaque RGB background. */
22
+ export declare function compositeOver(fg: RGBA, bg: RGB): RGB;
23
+ /** WCAG relative luminance of an opaque sRGB colour (0..1). */
24
+ export declare function relativeLuminance(rgb: RGB): number;
25
+ /** WCAG contrast ratio (1..21) between two luminances. */
26
+ export declare function contrastRatio(l1: number, l2: number): number;
27
+ export interface LabelDecision {
28
+ fix: boolean;
29
+ color: string;
30
+ reason: string;
31
+ }
32
+ /**
33
+ * Decide whether an in-mark label needs recolouring for legibility.
34
+ *
35
+ * @param textColor current text colour (any CSS form the SVG carries)
36
+ * @param fill the backing mark's fill colour
37
+ * @param fillOpacity the backing mark's fill-opacity (0..1)
38
+ * @param pageBg the page/visual background the mark sits on (opaque; '' → white)
39
+ * @param minContrast trip point; defaults to MIN_CONTRAST
40
+ */
41
+ export declare function decideLabelColor(textColor: string | null | undefined, fill: string | null | undefined, fillOpacity: number, pageBg: string | null | undefined, minContrast?: number, normalize?: boolean): LabelDecision;
42
+ export declare const GLYPH_SAMPLE_N = 3;
43
+ /**
44
+ * Evenly-spaced sample points over a glyph box, in the box's own coordinate space.
45
+ * `n` per side (n² points), each at the centre of its cell so no point lands on an edge.
46
+ */
47
+ export declare function glyphSampleGrid(box: {
48
+ left: number;
49
+ top: number;
50
+ width: number;
51
+ height: number;
52
+ }, n?: number): {
53
+ x: number;
54
+ y: number;
55
+ }[];
56
+ export {};
@@ -0,0 +1,33 @@
1
+ /** The attribute a recoloured (or pill-backed) label carries, so a second pass in the same
2
+ * render leaves it alone. Generated code that rebuilds its text nodes clears it by construction. */
3
+ export declare const LABEL_CONTRAST_DONE_ATTR = "data-lch-contrast";
4
+ /** How many shapes / texts the pass will consider before declining. Layout reads in a loop, so
5
+ * it is a real ceiling: a 5,000-cell heatmap is the size of thing this exists for, a 50,000-node
6
+ * scatter is not, and on the latter the pass costs more than the defect it would fix. */
7
+ export declare const LABEL_CONTRAST_CAP = 2000;
8
+ export interface LabelContrastOptions {
9
+ /** The opaque canvas the marks sit on. Default white; a host with a themed or high-contrast
10
+ * background passes it, or every "is this label readable" answer is measured against the
11
+ * wrong page. */
12
+ pageBg?: string;
13
+ /** Override the DOM ceiling. */
14
+ cap?: number;
15
+ }
16
+ export interface LabelContrastReport {
17
+ /** Filled shapes harvested as candidate backings. */
18
+ rects: number;
19
+ /** Labels that resolved a backing and were judged. */
20
+ scanned: number;
21
+ /** Labels recoloured. `fixed === scanned` is the module's own tell for a pass that is not
22
+ * measuring anything - see the incident notes in labelContrast.ts. */
23
+ fixed: number;
24
+ /** Translucent backdrops boosted to opaque so they actually back their label. */
25
+ pillsBoosted: number;
26
+ /** Labels whose candidates' BOXES contained them but whose FILLS did not - a ring's hole. */
27
+ offFill: number;
28
+ /** Labels mostly over the page with one end clipping a mark - handed back to the chart. */
29
+ pageMajority: number;
30
+ /** Why the pass did nothing, when it did nothing. */
31
+ skipped?: "no-container" | "no-shapes" | "too-many-shapes" | "too-many-texts" | "error";
32
+ }
33
+ export declare function applyLabelContrast(container: HTMLElement | null | undefined, opts?: LabelContrastOptions): LabelContrastReport;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.73",
3
+ "version": "0.5.74",
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",