@tangle-network/charts 0.1.13 → 0.2.0

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
@@ -27,7 +27,7 @@ Malformed rates, intervals, matrix counts, and breakdown counts return a refusal
27
27
  Unmeasured and flagged matrix attempts keep distinct marks and table counts, even without tier grades.
28
28
  An interval whisker is omitted when its bounds exclude the row's rate.
29
29
 
30
- It has no runtime dependencies and uses no DOM, so it runs in a Worker, a build step or a browser.
30
+ The static root has no runtime dependencies and uses no DOM, so it runs in a Worker, a build step or a browser. The separate opt-in `@tangle-network/charts/react` entrypoint requires React; it is never re-exported by the root.
31
31
 
32
32
  ## Use
33
33
 
@@ -134,3 +134,72 @@ Plot presentation uses labels of at least 14px and numeric axes of 15px.
134
134
  Dedicated phone SVGs retain their intended width inside a keyboard-accessible scroll region.
135
135
  Wide plots reserve side gutters so enlarged labels remain visible.
136
136
  These typography rules do not apply to report or chart presentation.
137
+
138
+ ## Optional React renderers
139
+
140
+ ```tsx
141
+ import { useState } from "react";
142
+ import { Sparkline, StackedBarChart } from "@tangle-network/charts/react";
143
+
144
+ export function RecordedUnits() {
145
+ const [selectedBucketId, onSelectionChange] = useState<string | null>(null);
146
+ return <>
147
+ <Sparkline label="Completion delta" values={[-2, 0, null, 3]} format={String} />
148
+ <StackedBarChart
149
+ label="Recorded units"
150
+ series={[{ id: "a", label: "Series A", color: "var(--chart-accent)" }]}
151
+ buckets={[
152
+ { id: "first", label: "First window", total: 3, segments: [{ seriesId: "a", value: 3 }] },
153
+ { id: "missing", label: "Second window", total: null, segments: [{ seriesId: "a", value: null }] },
154
+ ]}
155
+ maxValue={10}
156
+ formatValue={(value) => `${value} units`}
157
+ selectedBucketId={selectedBucketId}
158
+ onSelectionChange={onSelectionChange}
159
+ />
160
+ </>;
161
+ }
162
+ ```
163
+
164
+ Install React 18 or 19 in the consuming app. It is an **optional peer** so static-only consumers need neither React nor its types. There is no React DOM, chart framework or UI-package runtime dependency. The renderers need no generated stylesheet; `figure.css` remains exclusively the unchanged static-figure stylesheet. Supply application font/color styles and readable series colors. Apps with server/client component boundaries mark their interactive wrapper as a client component.
165
+
166
+ **Different scales, on purpose.** `Sparkline` ports Agent App's observed-extent scale; it does not implicitly include zero. Equal readings sit at mid-height, a single reading is a point, null/nonfinite samples retain their sample index and break the line, and finite negative readings are supported. Width/height props control its viewBox. Its accessible name describes the count, gaps, range and direction; the SVG description contains each sample's formatted value. It is not animated and has no selection interaction. The default `en-US` formatter retains Agent App's two-decimal behavior; pass `format={String}` or a domain-specific formatter when exact precision is important.
167
+
168
+ `StackedBarChart` always uses the caller's **explicit `[0, maxValue]` domain**. It accepts already-grouped buckets with unique IDs, labels, supplied totals and ordered segments referencing declared series IDs. It never fetches, sorts, groups, calculates billed amounts, chooses a currency, maps products/categories, or invents a maximum. Input bucket order is the sample axis, not an inferred date axis; supply every intended bucket, including gaps. Omitted segments are not synthesized as zero.
169
+
170
+ A null/nonfinite total or segment makes the whole stack a gap rather than a misleading complete bar. The known values remain in the readout and data table. Zero remains measured zero and draws no positive segment. As in the handoff, positive subpixel segments have a one-pixel visual minimum; their reported values and the total are not changed. Negative or out-of-domain finite values, duplicate/unknown identities, and inconsistent complete totals throw `RangeError` rather than clamp or silently aggregate. Nonnegative stacks do not model credits or diverging signed stacks. The existing static `Refusal` API is unchanged; it is not repurposed as React component state.
171
+
172
+ Selection is controlled by bucket ID. Hover, focus, click, Enter, Space and touch activation select rather than toggle; mouse leave, blur and Escape dismiss. A removed selected ID shows no stale readout, and reordering never selects another bucket by index. The stabilized SVG keeps full-height hit targets and highlights the actual stack. A named keyboard-scrollable region contains its fluid SVG with a 400px minimum. A cursor-adjacent tooltip repeats the selected values for mouse and pen users. It flips at viewport edges and uses a native manual popover to escape transformed or clipped panels. Browsers without popover support use a fixed-position fallback, which a transformed ancestor can constrain. The selected breakdown remains in flow and live-announced; every bucket also carries its formatted values in its accessible name, and a native “View data” disclosure contains the full table. No Tailwind build or hover-only tooltip is required to read the values.
173
+
174
+ **Provenance and extraction boundary.** Sparkline comes from `tangle-network/agent-app`, `src/web-react/sparkline.tsx` and its tests at `a8947683a050394d13848bb7e4cbbe7cf08d208e`. Stacked SVG rendering and select-never-toggle regressions come from merged ADC [PR #8746](https://github.com/tangle-network/agent-dev-container/pull/8746), `products/platform/web/src/client/components/UsageChartSvg.tsx` and `UsageChart.test.tsx` at `a1c3958fd73a3894d4515f9dc476dae9ace30ce0`. Platform's aggregation, currency/product/category functions, legend composition and portal-tooltip positioning are **not** copied. The persistent readout and native pointer popover avoid a new React DOM peer. This is not a new universal usage-chart API, a zero-baseline replacement for SpendCard's sparkline, or a consumer migration; Platform and Agent App are not edited.
175
+
176
+ ### React and package-boundary checks
177
+
178
+ ```sh
179
+ pnpm exec vitest run packages/charts/src/react/sparkline.test.tsx packages/charts/src/react/stacked-bar-chart.test.tsx
180
+ pnpm exec vitest run packages/charts/src/package-boundary.test.ts
181
+ pnpm exec vitest run packages/charts
182
+ pnpm --filter @tangle-network/charts exec tsc --noEmit
183
+ pnpm --filter @tangle-network/charts build
184
+ ```
185
+
186
+ The package-boundary test builds and packs Charts, installs the tarball in an OS-temporary consumer with a normal offline npm install (no peer-omission flags), asserts React/React DOM/React types cannot resolve, and executes the static root without DOM globals. It compares root exports, static SVG/tables, Refusals, report/chart/plot HTML and CSS with the untouched source implementation, then compiles a static-only consumer with `lib: ["ES2022"]`, `types: []` and `skipLibCheck: false`. Only afterward does it supply the workspace React peer/types to verify the packed React runtime and declarations. It runs under the existing root `pnpm test`; no CI workflow or shared UI smoke-script changes are needed.
187
+
188
+ The component tests cover gap/zero/one/equal/negative cases, controlled identity, keyboard/touch selection, nonvisual values and resize contracts. The jsdom resize check verifies fluid/minimum-width attributes and preserved selection, **not browser-computed layout**; The retained packed-browser fixture checks Chromium at 1100px and 390px, keyboard selection, emulated touch, light/dark Brand tokens, reduced motion, and top-layer positioning inside a transformed, paint-contained panel. It passes with React 18 and React 19; the React 18 consumer also compiles the packed declarations with React 18 types. Physical touch hardware and screen-reader audio remain unchecked.
189
+
190
+ ### Packed browser proof
191
+
192
+ Build Charts, then run the reusable fixture from the repository root.
193
+ Pass a tarball path to test a published artifact after downloading it with npm pack.
194
+
195
+ ```sh
196
+ pnpm --filter @tangle-network/charts build
197
+ PORT=46489 node scripts/charts-browser-smoke.mjs /absolute/path/to/charts.tgz
198
+ python3 scripts/charts-browser-proof.py http://127.0.0.1:46489 /tmp/charts-proof
199
+ ```
200
+
201
+ Set `CHARTS_REACT_VERSION=18` to install and check an isolated React 18 runtime and type consumer.
202
+ The default fixture uses the installed workspace React peer and bundles only the packed Charts export.
203
+ The fixture imports Brand token CSS and creates a transformed, paint-contained panel.
204
+ It needs Python Playwright and Chromium at `/snap/bin/chromium`.
205
+ Receipts and screenshots are in [the retained proof](../../docs/evidence/charts-react-189/README.md).
package/dist/index.js CHANGED
@@ -2345,6 +2345,10 @@ function timeSeries(rows, o) {
2345
2345
  }
2346
2346
  const xLabel = (x) => o.formatX ? o.formatX(x) : String(x);
2347
2347
  const yLabel = (y) => scalarLabel(y, o.y.unit, o.formatY);
2348
+ if (axisGutter(Array.from({ length: 5 }, (_, i) => o.y.domain[0] + i / 4 * (o.y.domain[1] - o.y.domain[0])), yLabel) >= 270) return {
2349
+ id,
2350
+ refused: "Formatted y-axis labels leave no room for the phone chart. Use a shorter formatter or unit."
2351
+ };
2348
2352
  const flat = rows.flatMap((row) => row.points.map((point) => ({
2349
2353
  row,
2350
2354
  point
@@ -2397,18 +2401,17 @@ function timeSeries(rows, o) {
2397
2401
  };
2398
2402
  }
2399
2403
  function plot(rows, o, width, xLabel, yLabel, facts) {
2400
- const left = 64, right = width - 24, top = 36, bottom = 260;
2404
+ const ticks = Array.from({ length: 5 }, (_, i) => o.y.domain[0] + i / 4 * (o.y.domain[1] - o.y.domain[0]));
2405
+ const left = axisGutter(ticks, yLabel);
2406
+ const right = width - 24, top = 36, bottom = 260;
2401
2407
  const px = (x) => left + (x - o.x.domain[0]) / (o.x.domain[1] - o.x.domain[0]) * (right - left);
2402
2408
  const py = (y) => bottom - (y - o.y.domain[0]) / (o.y.domain[1] - o.y.domain[0]) * 224;
2403
2409
  let marks = text(left, 18, o.y.label, { cls: "tgc-ink-muted" });
2404
- for (let i = 0; i <= 4; i++) {
2405
- const y = o.y.domain[0] + i / 4 * (o.y.domain[1] - o.y.domain[0]);
2406
- marks += line(left, py(y), right, py(y), i === 0 ? "tgc-axis" : "tgc-gridline") + text(56, py(y) + 4, yLabel(y), {
2407
- anchor: "end",
2408
- cls: "tgc-ink-muted tgc-num",
2409
- size: 11
2410
- });
2411
- }
2410
+ for (const [i, y] of ticks.entries()) marks += line(left, py(y), right, py(y), i === 0 ? "tgc-axis" : "tgc-gridline") + text(left - 8, py(y) + 4, yLabel(y), {
2411
+ anchor: "end",
2412
+ cls: "tgc-ink-muted tgc-num",
2413
+ size: 12
2414
+ });
2412
2415
  const xs = [...new Set(rows.flatMap((row) => row.points.map((point) => point.x)))].sort((a, b) => a - b);
2413
2416
  const tickStep = Math.max(1, Math.ceil(xs.length / (width < 720 ? 5 : 10)));
2414
2417
  for (const [i, x] of xs.entries()) if (i % tickStep === 0 || i === xs.length - 1) marks += text(px(x), 280, xLabel(x), {
@@ -2448,10 +2451,13 @@ function plot(rows, o, width, xLabel, yLabel, facts) {
2448
2451
  }
2449
2452
  flush();
2450
2453
  const label = wrap(row.label, right - left - 32, 12, "sans", 8);
2451
- marks += `<g style="stroke-dasharray:${dash}">${line(left, legendY - 4, 88, legendY - 4, "tgc-series-line")}</g>` + textBlock(label.lines, 96, legendY, 16, "class=\"tgc-ink\" font-size=\"12\"");
2454
+ marks += `<g style="stroke-dasharray:${dash}">${line(left, legendY - 4, left + 24, legendY - 4, "tgc-series-line")}</g>` + textBlock(label.lines, left + 32, legendY, 16, "class=\"tgc-ink\" font-size=\"12\"");
2452
2455
  legendY += label.lines.length * 16 + 8;
2453
2456
  }
2454
2457
  return `<svg class="tgc-svg tgc-trajectories" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${legendY + 12}" viewBox="0 0 ${width} ${legendY + 12}" role="group" aria-label="${esc(o.y.label)} by ${esc(o.x.label)}"><style>${rules}</style>${marks}${points}${tips}</svg>`;
2455
2458
  }
2459
+ function axisGutter(ticks, label) {
2460
+ return Math.max(64, ...ticks.map((value) => Math.ceil(textWidth(label(value), 12, "mono")) + 20));
2461
+ }
2456
2462
  //#endregion
2457
2463
  export { breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, isRefusal, metricBars, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, taskMatrix, timeSeries };
@@ -0,0 +1,84 @@
1
+ import { ReactElement } from "react";
2
+ //#region src/react/sparkline.d.ts
3
+ type SparklineDirection = "rising" | "falling" | "flat";
4
+ type SparklineValues = readonly (number | null)[];
5
+ interface SparklinePoint {
6
+ readonly x: number;
7
+ readonly y: number;
8
+ }
9
+ interface SparklineGeometry {
10
+ readonly readings: readonly number[];
11
+ readonly points: readonly SparklinePoint[];
12
+ /** One run per set of consecutive, finite samples. */
13
+ readonly segments: readonly (readonly SparklinePoint[])[];
14
+ readonly gaps: number;
15
+ readonly min: number;
16
+ readonly max: number;
17
+ readonly first: number;
18
+ readonly last: number;
19
+ readonly direction: SparklineDirection;
20
+ }
21
+ interface SparklineGeometryOptions {
22
+ width?: number;
23
+ height?: number;
24
+ inset?: number;
25
+ }
26
+ export declare const DEFAULT_SPARKLINE_WIDTH = 96;
27
+ export declare const DEFAULT_SPARKLINE_HEIGHT = 24;
28
+ export declare const DEFAULT_SPARKLINE_LABEL = "Trend";
29
+ export declare const DEFAULT_SPARKLINE_EMPTY_LABEL = "No history yet";
30
+ export declare const DEFAULT_SPARKLINE_UNAVAILABLE_LABEL = "No readings available";
31
+ export declare function formatSparklineValue(value: number): string;
32
+ export declare function sparklineReadings(values: SparklineValues): number[];
33
+ export declare function sparklineGeometry(values: SparklineValues, { width, height, inset }?: SparklineGeometryOptions): SparklineGeometry;
34
+ export declare function sparklinePointsAttribute(points: readonly SparklinePoint[]): string;
35
+ interface SparklineLabelOptions {
36
+ label?: string;
37
+ format?: (value: number) => string;
38
+ }
39
+ export declare function sparklineLabel(values: SparklineValues, { label, format }?: SparklineLabelOptions): string;
40
+ interface SparklineProps extends SparklineLabelOptions {
41
+ values: SparklineValues;
42
+ width?: number;
43
+ height?: number;
44
+ emptyLabel?: string;
45
+ unavailableLabel?: string;
46
+ className?: string;
47
+ }
48
+ export declare function Sparkline({ values, label, format, width, height, emptyLabel, unavailableLabel, className }: SparklineProps): ReactElement;
49
+ //#endregion
50
+ //#region src/react/stacked-bar-chart.d.ts
51
+ interface StackedBarSeries {
52
+ readonly id: string;
53
+ readonly label: string;
54
+ /** Caller-owned CSS color, e.g. a brand token. Never inferred from an ID. */
55
+ readonly color: string;
56
+ }
57
+ interface StackedBarSegment {
58
+ readonly seriesId: string;
59
+ readonly value: number | null;
60
+ }
61
+ interface StackedBarBucket {
62
+ readonly id: string;
63
+ readonly label: string;
64
+ /** Pre-aggregated by the caller. Null/nonfinite readings are gaps, not zero. */
65
+ readonly total: number | null;
66
+ /** Stack order is input order. Charts neither groups nor sorts. */
67
+ readonly segments: readonly StackedBarSegment[];
68
+ }
69
+ interface StackedBarChartProps {
70
+ label: string;
71
+ series: readonly StackedBarSeries[];
72
+ buckets: readonly StackedBarBucket[];
73
+ /** Explicit [0, maxValue] domain. Zero is valid only for zero-valued data. */
74
+ maxValue: number;
75
+ formatValue: (value: number) => string;
76
+ selectedBucketId: string | null;
77
+ onSelectionChange: (bucketId: string | null) => void;
78
+ emptyLabel?: string;
79
+ unavailableLabel?: string;
80
+ className?: string;
81
+ }
82
+ export declare function StackedBarChart(props: StackedBarChartProps): import("react").JSX.Element;
83
+ //#endregion
84
+ export type { SparklineDirection, SparklineGeometry, SparklineGeometryOptions, SparklineLabelOptions, SparklinePoint, SparklineProps, SparklineValues, StackedBarBucket, StackedBarChartProps, StackedBarSegment, StackedBarSeries };
@@ -0,0 +1,538 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment, useId, useRef, useState } from "react";
3
+ //#region src/react/sparkline.tsx
4
+ const DEFAULT_SPARKLINE_WIDTH = 96;
5
+ const DEFAULT_SPARKLINE_HEIGHT = 24;
6
+ const DEFAULT_SPARKLINE_LABEL = "Trend";
7
+ const DEFAULT_SPARKLINE_EMPTY_LABEL = "No history yet";
8
+ const DEFAULT_SPARKLINE_UNAVAILABLE_LABEL = "No readings available";
9
+ const DEFAULT_INSET = 2.5;
10
+ const STROKE_WIDTH = 1.5;
11
+ const DOT_RADIUS = 1.75;
12
+ const NUMBER_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
13
+ const hidden = {
14
+ position: "absolute",
15
+ width: 1,
16
+ height: 1,
17
+ padding: 0,
18
+ margin: -1,
19
+ overflow: "hidden",
20
+ clipPath: "inset(50%)",
21
+ whiteSpace: "nowrap",
22
+ border: 0
23
+ };
24
+ function formatSparklineValue(value) {
25
+ return NUMBER_FORMAT.format(value);
26
+ }
27
+ function isReading(value) {
28
+ return typeof value === "number" && Number.isFinite(value);
29
+ }
30
+ function sparklineReadings(values) {
31
+ return values.filter(isReading);
32
+ }
33
+ function round(value) {
34
+ return Math.round(value * 100) / 100;
35
+ }
36
+ function sparklineGeometry(values, { width = 96, height = 24, inset = DEFAULT_INSET } = {}) {
37
+ if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(inset) || width <= 0 || height <= 0 || inset < 0 || inset * 2 > Math.min(width, height)) throw new RangeError("Sparkline requires finite positive dimensions and an inset inside the viewBox");
38
+ const plotted = [];
39
+ for (let index = 0; index < values.length; index += 1) {
40
+ const value = values[index];
41
+ if (isReading(value)) plotted.push({
42
+ index,
43
+ value
44
+ });
45
+ }
46
+ const readings = plotted.map(({ value }) => value);
47
+ const gaps = values.length - readings.length;
48
+ if (readings.length === 0) return {
49
+ readings,
50
+ points: [],
51
+ segments: [],
52
+ gaps,
53
+ min: 0,
54
+ max: 0,
55
+ first: 0,
56
+ last: 0,
57
+ direction: "flat"
58
+ };
59
+ let min = readings[0];
60
+ let max = readings[0];
61
+ for (const value of readings) {
62
+ if (value < min) min = value;
63
+ if (value > max) max = value;
64
+ }
65
+ const first = readings[0];
66
+ const last = readings[readings.length - 1];
67
+ const span = max - min;
68
+ const points = plotted.map(({ index, value }) => {
69
+ const fraction = span === 0 ? .5 : Number.isFinite(span) ? (value - min) / span : (value / 2 - min / 2) / (max / 2 - min / 2);
70
+ return {
71
+ x: round(values.length <= 1 ? width / 2 : inset + (width - 2 * inset) * index / (values.length - 1)),
72
+ y: round(height - inset - (height - 2 * inset) * fraction)
73
+ };
74
+ });
75
+ const segments = [];
76
+ let run = [];
77
+ let previous = Number.NEGATIVE_INFINITY;
78
+ plotted.forEach(({ index }, position) => {
79
+ if (index !== previous + 1 && run.length > 0) {
80
+ segments.push(run);
81
+ run = [];
82
+ }
83
+ run.push(points[position]);
84
+ previous = index;
85
+ });
86
+ if (run.length > 0) segments.push(run);
87
+ return {
88
+ readings,
89
+ points,
90
+ segments,
91
+ gaps,
92
+ min,
93
+ max,
94
+ first,
95
+ last,
96
+ direction: last > first ? "rising" : last < first ? "falling" : "flat"
97
+ };
98
+ }
99
+ function sparklinePointsAttribute(points) {
100
+ return points.map(({ x, y }) => `${x},${y}`).join(" ");
101
+ }
102
+ function sparklineLabel(values, { label = DEFAULT_SPARKLINE_LABEL, format = formatSparklineValue } = {}) {
103
+ const { readings, gaps, min, max, first, last, direction } = sparklineGeometry(values);
104
+ const missing = gaps === 0 ? "" : `, ${gaps} not available`;
105
+ if (readings.length === 0) return gaps === 0 ? `${label}: no readings yet` : `${label}: no readings${missing}`;
106
+ if (readings.length === 1) return `${label}: one reading${missing}, ${format(first)}`;
107
+ if (max === min) return `${label}: ${readings.length} readings${missing}, unchanged at ${format(first)}`;
108
+ const movement = direction === "flat" ? "net unchanged" : direction;
109
+ return `${label}: ${readings.length} readings${missing}, range ${format(min)} to ${format(max)}, ${movement} from ${format(first)} to ${format(last)}`;
110
+ }
111
+ function Sparkline({ values, label = DEFAULT_SPARKLINE_LABEL, format = formatSparklineValue, width = 96, height = 24, emptyLabel = DEFAULT_SPARKLINE_EMPTY_LABEL, unavailableLabel = DEFAULT_SPARKLINE_UNAVAILABLE_LABEL, className }) {
112
+ const geometry = sparklineGeometry(values, {
113
+ width,
114
+ height
115
+ });
116
+ const accessibleName = sparklineLabel(values, {
117
+ label,
118
+ format
119
+ });
120
+ if (geometry.points.length === 0) return /* @__PURE__ */ jsxs("span", {
121
+ "data-sparkline": geometry.gaps > 0 ? "unavailable" : "empty",
122
+ className,
123
+ children: [/* @__PURE__ */ jsx("span", {
124
+ style: hidden,
125
+ children: accessibleName
126
+ }), /* @__PURE__ */ jsx("span", {
127
+ "aria-hidden": "true",
128
+ children: geometry.gaps > 0 ? unavailableLabel : emptyLabel
129
+ })]
130
+ });
131
+ const drawsLine = geometry.segments.some((segment) => segment.length > 1);
132
+ const end = geometry.points[geometry.points.length - 1];
133
+ return /* @__PURE__ */ jsxs("svg", {
134
+ role: "img",
135
+ "aria-label": accessibleName,
136
+ "data-scale": "extent",
137
+ "data-sparkline": drawsLine ? "line" : "point",
138
+ "data-direction": geometry.direction,
139
+ "data-gaps": geometry.gaps > 0 ? geometry.gaps : void 0,
140
+ width,
141
+ height,
142
+ viewBox: `0 0 ${width} ${height}`,
143
+ className,
144
+ focusable: "false",
145
+ children: [
146
+ /* @__PURE__ */ jsx("desc", { children: Array.from(values, (value, index) => `Sample ${index + 1}: ${isReading(value) ? format(value) : "not available"}`).join("; ") }),
147
+ geometry.segments.map((segment, index) => {
148
+ if (segment.length > 1) return /* @__PURE__ */ jsx("polyline", {
149
+ points: sparklinePointsAttribute(segment),
150
+ fill: "none",
151
+ stroke: "currentColor",
152
+ strokeWidth: STROKE_WIDTH,
153
+ strokeLinecap: "round",
154
+ strokeLinejoin: "round",
155
+ vectorEffect: "non-scaling-stroke"
156
+ }, `segment-${index}`);
157
+ const only = segment[0];
158
+ if (only.x === end.x && only.y === end.y) return null;
159
+ return /* @__PURE__ */ jsx("circle", {
160
+ cx: only.x,
161
+ cy: only.y,
162
+ r: DOT_RADIUS,
163
+ fill: "currentColor"
164
+ }, `segment-${index}`);
165
+ }),
166
+ /* @__PURE__ */ jsx("circle", {
167
+ cx: end.x,
168
+ cy: end.y,
169
+ r: DOT_RADIUS,
170
+ fill: "currentColor"
171
+ })
172
+ ]
173
+ });
174
+ }
175
+ //#endregion
176
+ //#region src/react/stacked-bar-chart.tsx
177
+ /**
178
+ * Renderer extracted from ADC PR #8746, a1c3958fd73a3894d4515f9dc476dae9ace30ce0:
179
+ * products/platform/web/src/client/components/UsageChartSvg.tsx.
180
+ * Keeps its viewBox, hit targets, actual-stack highlight and select-never-toggle
181
+ * interaction. Product/category mapping, aggregation, currency and the portal
182
+ * tooltip formatting remain caller-owned; no React DOM dependency is needed here.
183
+ */
184
+ function isValue(value) {
185
+ return value !== null && Number.isFinite(value);
186
+ }
187
+ function hasGap(bucket) {
188
+ return !isValue(bucket.total) || bucket.segments.some(({ value }) => !isValue(value));
189
+ }
190
+ function validate({ series, buckets, maxValue }) {
191
+ if (!Number.isFinite(maxValue) || maxValue < 0) throw new RangeError("StackedBarChart maxValue must be finite and nonnegative");
192
+ const seriesIds = /* @__PURE__ */ new Set();
193
+ for (const { id } of series) {
194
+ if (!id || seriesIds.has(id)) throw new RangeError("StackedBarChart requires unique, nonempty series IDs");
195
+ seriesIds.add(id);
196
+ }
197
+ const bucketIds = /* @__PURE__ */ new Set();
198
+ for (const bucket of buckets) {
199
+ if (!bucket.id || bucketIds.has(bucket.id)) throw new RangeError("StackedBarChart requires unique, nonempty bucket IDs");
200
+ bucketIds.add(bucket.id);
201
+ const segments = /* @__PURE__ */ new Set();
202
+ let sum = 0;
203
+ for (const { seriesId, value } of bucket.segments) {
204
+ if (!seriesIds.has(seriesId) || segments.has(seriesId)) throw new RangeError("StackedBarChart segments require distinct, declared series IDs");
205
+ segments.add(seriesId);
206
+ if (isValue(value)) {
207
+ if (value < 0 || value > maxValue) throw new RangeError("StackedBarChart values must lie in [0, maxValue]");
208
+ sum += value;
209
+ }
210
+ }
211
+ if (isValue(bucket.total) && (bucket.total < 0 || bucket.total > maxValue)) throw new RangeError("StackedBarChart totals must lie in [0, maxValue]");
212
+ if (!hasGap(bucket)) {
213
+ const total = bucket.total;
214
+ const tolerance = Number.EPSILON * 8 * Math.max(Math.abs(sum), Math.abs(total), Number.MIN_VALUE);
215
+ if (!Number.isFinite(sum) || Math.abs(sum - total) > tolerance) throw new RangeError("StackedBarChart total must match its supplied segments");
216
+ }
217
+ }
218
+ }
219
+ function StackedBarChart(props) {
220
+ const { label, series, buckets, maxValue, formatValue, selectedBucketId, onSelectionChange, emptyLabel = "No data yet", unavailableLabel = "Not available", className } = props;
221
+ const readoutId = useId();
222
+ const touchInteraction = useRef(false);
223
+ const [pointerBucketId, setPointerBucketId] = useState(null);
224
+ const pointerPosition = useRef({
225
+ x: 0,
226
+ y: 0
227
+ });
228
+ const tooltipRef = useRef(null);
229
+ const positionTooltip = (element) => {
230
+ const viewport = element.ownerDocument.defaultView;
231
+ if (!viewport) return;
232
+ const { width, height } = element.getBoundingClientRect();
233
+ const { x, y } = pointerPosition.current;
234
+ const left = x + 14 + width <= viewport.innerWidth - 8 ? x + 14 : x - width - 14;
235
+ const top = y + 14 + height <= viewport.innerHeight - 8 ? y + 14 : y - height - 14;
236
+ element.style.transform = `translate(${Math.max(8, Math.min(left, viewport.innerWidth - width - 8))}px, ${Math.max(8, Math.min(top, viewport.innerHeight - height - 8))}px)`;
237
+ };
238
+ const trackPointer = (event, bucketId) => {
239
+ pointerPosition.current = {
240
+ x: event.clientX,
241
+ y: event.clientY
242
+ };
243
+ if (pointerBucketId !== bucketId) setPointerBucketId(bucketId);
244
+ if (tooltipRef.current) positionTooltip(tooltipRef.current);
245
+ };
246
+ validate(props);
247
+ const bySeries = new Map(series.map((entry) => [entry.id, entry]));
248
+ const formatted = (value) => isValue(value) ? formatValue(value) : unavailableLabel;
249
+ const summary = (bucket) => `${bucket.label}: total ${formatted(bucket.total)}` + (hasGap(bucket) ? ` — ${unavailableLabel}` : "") + (bucket.segments.length ? ` — ${bucket.segments.map((segment) => `${bySeries.get(segment.seriesId).label} ${formatted(segment.value)}`).join(", ")}` : "");
250
+ const activeIndex = selectedBucketId === null ? -1 : buckets.findIndex(({ id }) => id === selectedBucketId);
251
+ const activeBucket = activeIndex >= 0 ? buckets[activeIndex] : null, marginTop = 12, marginRight = 16, marginLeft = 56;
252
+ const chartHeight = 220;
253
+ const innerHeight = 176;
254
+ const barGap = 6;
255
+ const barWidth = Math.max(8, Math.min(36, 648 / Math.max(1, buckets.length) - barGap));
256
+ const viewWidth = Math.max(buckets.length * (barWidth + barGap) + marginLeft + marginRight, 400);
257
+ const denseLabels = buckets.length > 14;
258
+ if (buckets.length === 0) return /* @__PURE__ */ jsxs("p", {
259
+ className,
260
+ children: [
261
+ label,
262
+ ": ",
263
+ emptyLabel
264
+ ]
265
+ });
266
+ return /* @__PURE__ */ jsxs("div", {
267
+ className,
268
+ "data-stacked-bar-chart": "",
269
+ "data-scale": "zero",
270
+ children: [
271
+ /* @__PURE__ */ jsx("section", {
272
+ tabIndex: 0,
273
+ "aria-label": `${label} chart`,
274
+ style: { overflowX: "auto" },
275
+ children: /* @__PURE__ */ jsxs("svg", {
276
+ "aria-label": label,
277
+ width: "100%",
278
+ viewBox: `0 0 ${viewWidth} ${chartHeight}`,
279
+ style: {
280
+ display: "block",
281
+ minWidth: 400,
282
+ maxWidth: "100%",
283
+ maxHeight: 300
284
+ },
285
+ preserveAspectRatio: "xMinYMin meet",
286
+ children: [[
287
+ 0,
288
+ .25,
289
+ .5,
290
+ .75,
291
+ 1
292
+ ].map((fraction) => {
293
+ const y = 188 - fraction * innerHeight;
294
+ return /* @__PURE__ */ jsxs("g", {
295
+ "aria-hidden": "true",
296
+ children: [/* @__PURE__ */ jsx("line", {
297
+ x1: marginLeft,
298
+ y1: y,
299
+ x2: viewWidth - marginRight,
300
+ y2: y,
301
+ stroke: "currentColor",
302
+ strokeDasharray: fraction === 0 ? "0" : "3 3",
303
+ opacity: fraction === 0 ? .4 : .2
304
+ }), /* @__PURE__ */ jsx("text", {
305
+ x: 48,
306
+ y,
307
+ dy: "0.32em",
308
+ textAnchor: "end",
309
+ fontSize: "11",
310
+ fill: "currentColor",
311
+ fontFamily: "inherit",
312
+ children: formatValue(maxValue * fraction)
313
+ })]
314
+ }, fraction);
315
+ }), buckets.map((bucket, index) => {
316
+ const x = marginLeft + index * (barWidth + barGap) + barGap / 2;
317
+ const isActive = index === activeIndex;
318
+ const gap = hasGap(bucket);
319
+ const stackHeight = !gap && maxValue > 0 ? bucket.total / maxValue * innerHeight : 0;
320
+ let yOffset = 0;
321
+ const select = () => onSelectionChange(bucket.id);
322
+ const clear = () => {
323
+ if (isActive) onSelectionChange(null);
324
+ };
325
+ return /* @__PURE__ */ jsxs("g", {
326
+ role: "button",
327
+ tabIndex: 0,
328
+ "data-bucket-id": bucket.id,
329
+ "data-gap": gap || void 0,
330
+ "aria-label": summary(bucket),
331
+ "aria-describedby": isActive ? readoutId : void 0,
332
+ onPointerEnter: (event) => {
333
+ touchInteraction.current = event.pointerType === "touch";
334
+ if (!touchInteraction.current) {
335
+ trackPointer(event, bucket.id);
336
+ select();
337
+ }
338
+ },
339
+ onPointerLeave: () => {
340
+ if (!touchInteraction.current) {
341
+ setPointerBucketId(null);
342
+ clear();
343
+ }
344
+ },
345
+ onPointerDown: (event) => {
346
+ touchInteraction.current = event.pointerType === "touch";
347
+ if (touchInteraction.current) setPointerBucketId(null);
348
+ },
349
+ onPointerMove: (event) => {
350
+ touchInteraction.current = event.pointerType === "touch";
351
+ if (!touchInteraction.current) {
352
+ trackPointer(event, bucket.id);
353
+ if (selectedBucketId !== bucket.id) select();
354
+ }
355
+ },
356
+ onMouseEnter: (event) => {
357
+ if (!touchInteraction.current) {
358
+ trackPointer(event, bucket.id);
359
+ select();
360
+ }
361
+ },
362
+ onMouseMove: (event) => {
363
+ if (!touchInteraction.current) {
364
+ trackPointer(event, bucket.id);
365
+ if (selectedBucketId !== bucket.id) select();
366
+ }
367
+ },
368
+ onMouseLeave: () => {
369
+ if (!touchInteraction.current) {
370
+ setPointerBucketId(null);
371
+ clear();
372
+ }
373
+ },
374
+ onFocus: select,
375
+ onBlur: clear,
376
+ onClick: select,
377
+ onKeyDown: (event) => {
378
+ setPointerBucketId(null);
379
+ if (event.key === "Enter" || event.key === " ") {
380
+ event.preventDefault();
381
+ select();
382
+ } else if (event.key === "Escape") {
383
+ event.preventDefault();
384
+ clear();
385
+ }
386
+ },
387
+ style: { cursor: "pointer" },
388
+ children: [
389
+ /* @__PURE__ */ jsx("rect", {
390
+ "data-hit-target": "",
391
+ x,
392
+ y: marginTop,
393
+ width: barWidth,
394
+ height: innerHeight,
395
+ fill: "transparent",
396
+ stroke: "none",
397
+ rx: 3
398
+ }),
399
+ !gap && bucket.segments.map((segment, segmentIndex) => {
400
+ const height = maxValue > 0 ? segment.value / maxValue * innerHeight : 0;
401
+ const y = 188 - yOffset - height;
402
+ yOffset += height;
403
+ return /* @__PURE__ */ jsx("rect", {
404
+ "data-series-id": segment.seriesId,
405
+ x,
406
+ y,
407
+ width: barWidth,
408
+ height: Math.max(height, height > 0 ? 1 : 0),
409
+ rx: segmentIndex === bucket.segments.length - 1 ? 3 : 0,
410
+ fill: bySeries.get(segment.seriesId).color,
411
+ opacity: isActive ? 1 : .92,
412
+ pointerEvents: "none"
413
+ }, segment.seriesId);
414
+ }),
415
+ gap && /* @__PURE__ */ jsx("text", {
416
+ x: x + barWidth / 2,
417
+ y: 184,
418
+ textAnchor: "middle",
419
+ fill: "currentColor",
420
+ "aria-hidden": "true",
421
+ pointerEvents: "none",
422
+ children: "—"
423
+ }),
424
+ isActive && /* @__PURE__ */ jsx("rect", {
425
+ "data-selection": "",
426
+ x,
427
+ y: 188 - Math.max(1, stackHeight),
428
+ width: barWidth,
429
+ height: Math.max(1, stackHeight),
430
+ fill: "none",
431
+ stroke: "currentColor",
432
+ strokeWidth: 1.5,
433
+ rx: 3,
434
+ pointerEvents: "none"
435
+ }),
436
+ /* @__PURE__ */ jsx("text", {
437
+ x: x + barWidth / 2,
438
+ y: 204,
439
+ textAnchor: denseLabels ? "end" : "middle",
440
+ fontSize: "10",
441
+ fill: "currentColor",
442
+ fontFamily: "inherit",
443
+ pointerEvents: "none",
444
+ "aria-hidden": "true",
445
+ transform: denseLabels ? `rotate(-40, ${x + barWidth / 2}, 204)` : void 0,
446
+ children: bucket.label
447
+ })
448
+ ]
449
+ }, bucket.id);
450
+ })]
451
+ })
452
+ }),
453
+ activeBucket && pointerBucketId === activeBucket.id && !touchInteraction.current && /* @__PURE__ */ jsxs("div", {
454
+ "data-chart-tooltip": "",
455
+ role: "tooltip",
456
+ "aria-hidden": "true",
457
+ popover: "manual",
458
+ ref: (element) => {
459
+ tooltipRef.current = element;
460
+ if (element) {
461
+ if (typeof element.showPopover === "function") element.showPopover();
462
+ positionTooltip(element);
463
+ }
464
+ },
465
+ style: {
466
+ position: "fixed",
467
+ inset: "auto",
468
+ margin: 0,
469
+ left: 0,
470
+ top: 0,
471
+ zIndex: 1e3,
472
+ pointerEvents: "none",
473
+ width: "max-content",
474
+ maxWidth: "min(280px, calc(100vw - 16px))",
475
+ padding: "10px 12px",
476
+ borderRadius: "var(--radius-md, 8px)",
477
+ background: "var(--md3-surface-container-highest, #3b3b3b)",
478
+ color: "var(--md3-on-surface, #e6e6e6)",
479
+ border: "1px solid var(--md3-outline-variant, #4c4c4c)",
480
+ fontFamily: "inherit",
481
+ fontSize: "0.75rem",
482
+ lineHeight: 1.5,
483
+ overflowWrap: "anywhere"
484
+ },
485
+ children: [
486
+ /* @__PURE__ */ jsx("strong", { children: activeBucket.label }),
487
+ /* @__PURE__ */ jsxs("div", { children: ["Total ", formatted(activeBucket.total)] }),
488
+ activeBucket.segments.map((segment) => /* @__PURE__ */ jsxs("div", { children: [
489
+ bySeries.get(segment.seriesId).label,
490
+ ": ",
491
+ formatted(segment.value)
492
+ ] }, segment.seriesId))
493
+ ]
494
+ }),
495
+ /* @__PURE__ */ jsx("div", {
496
+ id: readoutId,
497
+ role: "status",
498
+ "aria-live": "polite",
499
+ "aria-atomic": "true",
500
+ children: activeBucket ? summary(activeBucket) : "Hover, tap, or focus a bar to see its breakdown."
501
+ }),
502
+ /* @__PURE__ */ jsxs("details", { children: [/* @__PURE__ */ jsx("summary", { children: "View data" }), /* @__PURE__ */ jsxs("table", { children: [
503
+ /* @__PURE__ */ jsx("caption", { children: label }),
504
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
505
+ /* @__PURE__ */ jsx("th", {
506
+ scope: "col",
507
+ children: "Bucket"
508
+ }),
509
+ /* @__PURE__ */ jsx("th", {
510
+ scope: "col",
511
+ children: "Series"
512
+ }),
513
+ /* @__PURE__ */ jsx("th", {
514
+ scope: "col",
515
+ children: "Value"
516
+ })
517
+ ] }) }),
518
+ /* @__PURE__ */ jsx("tbody", { children: buckets.map((bucket) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("tr", { children: [
519
+ /* @__PURE__ */ jsx("th", {
520
+ scope: "row",
521
+ children: bucket.label
522
+ }),
523
+ /* @__PURE__ */ jsx("td", { children: "Total" }),
524
+ /* @__PURE__ */ jsx("td", { children: formatted(bucket.total) })
525
+ ] }), bucket.segments.map((segment) => /* @__PURE__ */ jsxs("tr", { children: [
526
+ /* @__PURE__ */ jsx("th", {
527
+ scope: "row",
528
+ children: bucket.label
529
+ }),
530
+ /* @__PURE__ */ jsx("td", { children: bySeries.get(segment.seriesId).label }),
531
+ /* @__PURE__ */ jsx("td", { children: formatted(segment.value) })
532
+ ] }, segment.seriesId))] }, bucket.id)) })
533
+ ] })] })
534
+ ]
535
+ });
536
+ }
537
+ //#endregion
538
+ export { DEFAULT_SPARKLINE_EMPTY_LABEL, DEFAULT_SPARKLINE_HEIGHT, DEFAULT_SPARKLINE_LABEL, DEFAULT_SPARKLINE_UNAVAILABLE_LABEL, DEFAULT_SPARKLINE_WIDTH, Sparkline, StackedBarChart, formatSparklineValue, sparklineGeometry, sparklineLabel, sparklinePointsAttribute, sparklineReadings };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/charts",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "Benchmark figures as static SVG and HTML tables: ranked rates, cost frontier, comparison intervals, task matrix and breakdown. Draws from producer data and refuses what it cannot draw honestly.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,20 @@
26
26
  "import": "./dist/index.js",
27
27
  "default": "./dist/index.js"
28
28
  },
29
- "./figure.css": "./dist/figure.css"
29
+ "./figure.css": "./dist/figure.css",
30
+ "./react": {
31
+ "types": "./dist/react/index.d.ts",
32
+ "import": "./dist/react/index.js",
33
+ "default": "./dist/react/index.js"
34
+ }
35
+ },
36
+ "peerDependencies": {
37
+ "react": "^18.0.0 || ^19.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "react": {
41
+ "optional": true
42
+ }
30
43
  },
31
44
  "devDependencies": {
32
45
  "tsdown": "^0.23.0",