@paul-portfolio/react 0.4.3 → 0.5.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.
@@ -0,0 +1,22 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ type BarChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
3
+ /** One bar per value. */
4
+ data: number[];
5
+ /** Optional category labels, used to build the accessible summary. */
6
+ labels?: string[];
7
+ /** Bar direction. Defaults to vertical. */
8
+ orientation?: 'vertical' | 'horizontal';
9
+ /** Per-bar fill colors. Falls back to the token palette when omitted. */
10
+ colors?: string[];
11
+ /** Accessible name for the chart. Required. */
12
+ label: string;
13
+ width?: number;
14
+ height?: number;
15
+ };
16
+ /**
17
+ * A categorical bar chart, vertical or horizontal. Pure SVG from
18
+ * `chartGeometry`, matching the Angular `PaulBarChart`. Colour is never the
19
+ * only signal — the values are summarised in the `role="img"` label.
20
+ */
21
+ export declare function BarChart({ data, labels, orientation, colors, label, width, height, className, ...props }: BarChartProps): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { barRects, barRectsHorizontal } from './chartGeometry';
4
+ function summarise(data, labels) {
5
+ return data
6
+ .map((v, i) => (labels?.[i] ? `${labels[i]} ${v}` : `${v}`))
7
+ .join(', ');
8
+ }
9
+ /**
10
+ * A categorical bar chart, vertical or horizontal. Pure SVG from
11
+ * `chartGeometry`, matching the Angular `PaulBarChart`. Colour is never the
12
+ * only signal — the values are summarised in the `role="img"` label.
13
+ */
14
+ export function BarChart({ data, labels, orientation = 'vertical', colors, label, width = 160, height = 100, className, ...props }) {
15
+ const horizontal = orientation === 'horizontal';
16
+ const box = { width, height, padding: 2 };
17
+ const rects = horizontal ? barRectsHorizontal(data, box) : barRects(data, box);
18
+ const name = data.length > 0 ? `${label}: ${summarise(data, labels)}` : label;
19
+ const fillFor = (i) => colors?.[i] ?? `var(--paul-chart-${(i % 6) + 1})`;
20
+ return (_jsx("div", { role: "img", "aria-label": name, className: cx('paul-chart', 'paul-chart--bar', className), ...props, children: _jsx("svg", { className: cx('paul-chart__svg', horizontal && 'paul-chart__svg--horizontal'), viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false", children: rects.map((r, i) => (_jsx("rect", { className: "paul-chart__bar", x: r.x, y: r.y, width: r.width, height: r.height, rx: 2, fill: fillFor(i) }, i))) }) }));
21
+ }
@@ -0,0 +1,26 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type DonutDatum = {
3
+ label: string;
4
+ value: number;
5
+ /** Slice colour. Falls back to the token palette when omitted. */
6
+ color?: string;
7
+ };
8
+ type DonutChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
9
+ data: DonutDatum[];
10
+ /** Accessible name for the chart. Required. */
11
+ label: string;
12
+ /** Show the swatch + value legend beside the ring. Defaults to true. */
13
+ legend?: boolean;
14
+ /** Outer diameter, in coordinate units. Defaults to 120. */
15
+ size?: number;
16
+ /** Ring thickness, in coordinate units. Defaults to 28. */
17
+ thickness?: number;
18
+ };
19
+ /**
20
+ * A donut (ring) chart with an optional legend — the shape behind fleet-health
21
+ * and revenue-mix breakdowns. Pure SVG from `chartGeometry`, matching the
22
+ * Angular `PaulDonutChart`. The ring is `role="img"`; the legend sits outside
23
+ * it so its rows stay in the accessibility tree.
24
+ */
25
+ export declare function DonutChart({ data, label, legend, size, thickness, className, ...props }: DonutChartProps): import("react").JSX.Element;
26
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { donutSegments } from './chartGeometry';
4
+ /**
5
+ * A donut (ring) chart with an optional legend — the shape behind fleet-health
6
+ * and revenue-mix breakdowns. Pure SVG from `chartGeometry`, matching the
7
+ * Angular `PaulDonutChart`. The ring is `role="img"`; the legend sits outside
8
+ * it so its rows stay in the accessibility tree.
9
+ */
10
+ export function DonutChart({ data, label, legend = true, size = 120, thickness = 28, className, ...props }) {
11
+ const values = data.map((d) => d.value);
12
+ const segments = donutSegments(values, { size, thickness });
13
+ const summary = data.map((d) => `${d.label} ${d.value}`).join(', ');
14
+ const name = data.length > 0 ? `${label}: ${summary}` : label;
15
+ const colorFor = (i) => data[i]?.color ?? `var(--paul-chart-${(i % 6) + 1})`;
16
+ return (_jsxs("div", { className: cx('paul-chart', 'paul-chart--donut', className), ...props, children: [_jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: _jsx("svg", { className: "paul-chart__svg", viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", focusable: "false", children: segments
17
+ .filter((seg) => seg.percent > 0)
18
+ .map((seg) => (_jsx("path", { className: "paul-chart__slice", d: seg.path, fill: colorFor(seg.index) }, seg.index))) }) }), legend && data.length > 0 && (_jsx("ul", { className: "paul-chart__legend", children: data.map((d, i) => (_jsxs("li", { className: "paul-chart__legend-item", children: [_jsx("span", { className: "paul-chart__swatch", "aria-hidden": "true", style: { backgroundColor: colorFor(i) } }), _jsx("span", { className: "paul-chart__legend-label", children: d.label }), _jsx("span", { className: "paul-chart__legend-value", children: d.value })] }, d.label))) }))] }));
19
+ }
@@ -0,0 +1,26 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type FunnelDatum = {
3
+ label: string;
4
+ value: number;
5
+ };
6
+ type FunnelChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
7
+ data: FunnelDatum[];
8
+ /** Accessible name for the chart. Required. */
9
+ label: string;
10
+ /** Show the per-stage drop-off beside each row. Defaults to true. */
11
+ showDropOff?: boolean;
12
+ /** viewBox width in coordinate units. Defaults to 200. */
13
+ width?: number;
14
+ /** viewBox height in coordinate units. Defaults to 140. */
15
+ height?: number;
16
+ };
17
+ /**
18
+ * Stage-to-stage conversion, drawn as narrowing bands. Pure SVG from
19
+ * `chartGeometry`, matching the Angular `PaulFunnelChart`.
20
+ *
21
+ * The drop-off is what a funnel is read for, so it is rendered as text rather
22
+ * than left to the taper — the widths carry the shape, the labels carry the
23
+ * number.
24
+ */
25
+ export declare function FunnelChart({ data, label, showDropOff, width, height, className, ...props }: FunnelChartProps): import("react").JSX.Element;
26
+ export {};
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { funnelStages } from './chartGeometry';
4
+ /** Stage colour comes from the SEQUENTIAL ramp: funnel stages are ordered. */
5
+ const stageColor = (i, total) => {
6
+ const step = total <= 1 ? 3 : Math.round(1 + (i / (total - 1)) * 3);
7
+ return `var(--paul-chart-seq-${step + 1})`;
8
+ };
9
+ /**
10
+ * Stage-to-stage conversion, drawn as narrowing bands. Pure SVG from
11
+ * `chartGeometry`, matching the Angular `PaulFunnelChart`.
12
+ *
13
+ * The drop-off is what a funnel is read for, so it is rendered as text rather
14
+ * than left to the taper — the widths carry the shape, the labels carry the
15
+ * number.
16
+ */
17
+ export function FunnelChart({ data, label, showDropOff = true, width = 200, height = 140, className, ...props }) {
18
+ const stages = funnelStages(data.map((d) => d.value), { width, height, padding: 2 });
19
+ const summary = data
20
+ .map((d, i) => `${d.label} ${d.value} (${stages[i]?.percent ?? 0}%)`)
21
+ .join(', ');
22
+ const name = data.length > 0 ? `${label}: ${summary}` : label;
23
+ return (_jsxs("div", { className: cx('paul-chart', 'paul-chart--funnel', className), ...props, children: [_jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: data.length > 0 ? (_jsx("svg", { className: "paul-chart__svg", viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false", children: stages.map((stage) => (_jsx("path", { className: "paul-chart__stage", d: stage.path, fill: stageColor(stage.index, stages.length) }, stage.index))) })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }), data.length > 0 && (_jsx("ol", { className: "paul-chart__legend", children: data.map((d, i) => (_jsxs("li", { className: "paul-chart__legend-item", children: [_jsx("span", { className: "paul-chart__swatch", "aria-hidden": "true", style: { backgroundColor: stageColor(i, data.length) } }), _jsx("span", { className: "paul-chart__legend-label", children: d.label }), _jsx("span", { className: "paul-chart__legend-value", children: d.value }), showDropOff && i > 0 && (_jsxs("span", { className: "paul-chart__delta", children: ["\u2212", stages[i]?.dropOff ?? 0, "%"] }))] }, d.label))) }))] }));
24
+ }
@@ -0,0 +1,37 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type GaugeTone = 'default' | 'good' | 'warning' | 'critical';
3
+ type GaugeChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
4
+ /** The reading. Clamped into [min, max] for the arc; shown verbatim as text. */
5
+ value: number;
6
+ /** Accessible name for the chart. Required. */
7
+ label: string;
8
+ /** Bottom of the range. Defaults to 0. */
9
+ min?: number;
10
+ /** Top of the range. Defaults to 100. */
11
+ max?: number;
12
+ /** Suffix beside the hero number, e.g. "%" or "GB". */
13
+ unit?: string;
14
+ /** Outer diameter, in coordinate units. Defaults to 120. */
15
+ size?: number;
16
+ /** Arc thickness, in coordinate units. Defaults to 20. */
17
+ thickness?: number;
18
+ /** Total sweep in degrees. Defaults to 270 — a dial, not a full ring. */
19
+ sweep?: number;
20
+ /** Status colour for the fill. Defaults to the neutral series colour. */
21
+ tone?: GaugeTone;
22
+ };
23
+ /**
24
+ * One ratio against a limit, drawn as a radial dial. Pure SVG from
25
+ * `chartGeometry`, matching the Angular `PaulGaugeChart`.
26
+ *
27
+ * The number is the point, so it is a hero figure inside the arc rather than a
28
+ * label hanging off it — the arc gives the reading a position in its range, the
29
+ * text gives it precision. Text wears the text tokens and never the series
30
+ * colour; the track is recessive and the fill carries the tone. No legend: a
31
+ * single value has nothing to key.
32
+ *
33
+ * When `tone` is anything but `default` the tone name is rendered as text next
34
+ * to the value, because status must never be carried by colour alone.
35
+ */
36
+ export declare function GaugeChart({ value, label, min, max, unit, size, thickness, sweep, tone, className, ...props }: GaugeChartProps): import("react").JSX.Element;
37
+ export {};
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { gaugeArc } from './chartGeometry';
4
+ /**
5
+ * Status is never colour alone: a tone other than `default` also renders its
6
+ * name as visible text beside the value, so the reading survives greyscale,
7
+ * a colour-vision deficiency, and a printout.
8
+ */
9
+ const TONE_LABEL = {
10
+ default: '',
11
+ good: 'Good',
12
+ warning: 'Warning',
13
+ critical: 'Critical',
14
+ };
15
+ /** Tones borrow the SEMANTIC status colours, not the categorical series ramp. */
16
+ const TONE_COLOR = {
17
+ default: 'var(--paul-chart-1)',
18
+ good: 'var(--paul-color-success-600)',
19
+ warning: 'var(--paul-color-warning-600)',
20
+ critical: 'var(--paul-color-error-600)',
21
+ };
22
+ /**
23
+ * One ratio against a limit, drawn as a radial dial. Pure SVG from
24
+ * `chartGeometry`, matching the Angular `PaulGaugeChart`.
25
+ *
26
+ * The number is the point, so it is a hero figure inside the arc rather than a
27
+ * label hanging off it — the arc gives the reading a position in its range, the
28
+ * text gives it precision. Text wears the text tokens and never the series
29
+ * colour; the track is recessive and the fill carries the tone. No legend: a
30
+ * single value has nothing to key.
31
+ *
32
+ * When `tone` is anything but `default` the tone name is rendered as text next
33
+ * to the value, because status must never be carried by colour alone.
34
+ */
35
+ export function GaugeChart({ value, label, min = 0, max = 100, unit, size = 120, thickness = 20, sweep = 270, tone = 'default', className, ...props }) {
36
+ const geo = gaugeArc(value, { size, thickness, min, max, sweep });
37
+ const toneLabel = TONE_LABEL[tone];
38
+ // Name the whole range, not just its top: a gauge running 20–60 that says
39
+ // "of 60" invites the reader to compute 41.5/60 when the arc means
40
+ // (41.5-20)/(60-20). Only mention `min` when it isn't the assumed zero.
41
+ const range = min === 0 ? `${max}` : `${min} to ${max}`;
42
+ const name = `${label}: ${value} of ${range} (${geo.percent}%)${toneLabel ? `, ${toneLabel}` : ''}`;
43
+ return (_jsxs("div", { className: cx('paul-chart', 'paul-chart--gauge', className), ...props, children: [_jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: _jsxs("svg", { className: "paul-chart__svg", viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", focusable: "false", children: [_jsx("path", { className: "paul-chart__gauge-track", d: geo.track }), geo.fill !== '' && (_jsx("path", { className: "paul-chart__gauge-fill", d: geo.fill, fill: TONE_COLOR[tone] }))] }) }), _jsxs("div", { className: "paul-chart__readout", children: [_jsxs("p", { className: "paul-chart__value", children: [value, unit && _jsx("span", { className: "paul-chart__unit", children: unit })] }), toneLabel && _jsx("p", { className: "paul-chart__tone", children: toneLabel }), _jsxs("p", { className: "paul-chart__caption", children: ["of ", range] })] })] }));
44
+ }
@@ -0,0 +1,21 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ type GradientBackgroundProps = HTMLAttributes<HTMLDivElement> & {
3
+ /** Gradient stops, in order. Defaults to the design-token brand palette. */
4
+ colors?: string[];
5
+ /** Gradient angle in degrees. */
6
+ angle?: number;
7
+ /** Ambient flow speed. */
8
+ speed?: 'slow' | 'normal' | 'fast';
9
+ /** Run the ambient flow animation. Defaults to true. */
10
+ animate?: boolean;
11
+ children?: ReactNode;
12
+ };
13
+ /**
14
+ * A decorative surface painted with a flowing multi-stop gradient. Content is
15
+ * passed as children and renders on top, so the strip stays content-agnostic.
16
+ * The ambient flow is pure CSS and is gated behind prefers-reduced-motion, so
17
+ * it goes static for users who ask for reduced motion — no JS needed. When
18
+ * `colors` is omitted it falls back to the token brand palette.
19
+ */
20
+ export declare function GradientBackground({ colors, angle, speed, animate, className, style, children, ...rest }: GradientBackgroundProps): import("react").JSX.Element;
21
+ export {};
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ const DURATION = {
4
+ slow: '18s',
5
+ normal: '12s',
6
+ fast: '6s',
7
+ };
8
+ /**
9
+ * A decorative surface painted with a flowing multi-stop gradient. Content is
10
+ * passed as children and renders on top, so the strip stays content-agnostic.
11
+ * The ambient flow is pure CSS and is gated behind prefers-reduced-motion, so
12
+ * it goes static for users who ask for reduced motion — no JS needed. When
13
+ * `colors` is omitted it falls back to the token brand palette.
14
+ */
15
+ export function GradientBackground({ colors, angle = 120, speed = 'normal', animate = true, className, style, children, ...rest }) {
16
+ const gradientStyle = {
17
+ ...(colors && colors.length > 0
18
+ ? { ['--paul-gradient-image']: `linear-gradient(${angle}deg, ${colors.join(', ')})` }
19
+ : {}),
20
+ ['--paul-gradient-duration']: DURATION[speed],
21
+ ...style,
22
+ };
23
+ return (_jsx("div", { className: cx('gradient-bg', className), "data-animate": animate ? 'true' : undefined, style: gradientStyle, ...rest, children: children }));
24
+ }
@@ -0,0 +1,46 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type HeatmapRow = {
3
+ /** Rendered down the left gutter. */
4
+ label: string;
5
+ /** One value per column. */
6
+ values: number[];
7
+ };
8
+ type HeatmapChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
9
+ /**
10
+ * One entry per row, each carrying its own label.
11
+ *
12
+ * A row and its label travel together on purpose: as separate `matrix` and
13
+ * `rowLabels` arrays a length mismatch is representable, and it degrades
14
+ * quietly — the label for the missing row just vanishes and every other row
15
+ * still looks right.
16
+ */
17
+ rows: HeatmapRow[];
18
+ /** One label per column, rendered across the top gutter. */
19
+ colLabels: string[];
20
+ /** Accessible name for the chart. Required. */
21
+ label: string;
22
+ /**
23
+ * Print each cell's value inside the cell. Defaults to true.
24
+ *
25
+ * Direct labelling is only honest on a small grid — past roughly 8×8 the text
26
+ * is smaller than the cell can carry and the numbers turn into texture. Turn
27
+ * it off for large cohorts and let the ramp plus the scale legend do the work.
28
+ */
29
+ showValues?: boolean;
30
+ /** viewBox width in coordinate units. Defaults to 220. */
31
+ width?: number;
32
+ /** viewBox height in coordinate units. Defaults to 140. */
33
+ height?: number;
34
+ };
35
+ /**
36
+ * A cohort-retention grid: one cell per matrix value, shaded by magnitude. Pure
37
+ * SVG from `chartGeometry`, matching the Angular `PaulHeatmapChart`.
38
+ *
39
+ * The scale legend is not optional. A sequential ramp with no key tells a reader
40
+ * that one cell is bigger than another but never by how much, so the five steps
41
+ * are rendered with the matrix min and max as end labels. The `role="img"`
42
+ * summary names every row and its values, so the grid is readable with the
43
+ * colour thrown away entirely.
44
+ */
45
+ export declare function HeatmapChart({ rows, colLabels, label, showValues, width, height, className, ...props }: HeatmapChartProps): import("react").JSX.Element;
46
+ export {};
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { heatmapCells } from './chartGeometry';
4
+ /** Gutter reserved for the row labels, in coordinate units. */
5
+ const ROW_GUTTER = 40;
6
+ /** Gutter reserved for the column labels, in coordinate units. */
7
+ const COL_GUTTER = 14;
8
+ /**
9
+ * Cell colour comes from the SEQUENTIAL ramp: a heatmap encodes MAGNITUDE, so
10
+ * intensity maps onto one hue light-to-dark. The categorical --paul-chart-N
11
+ * palette would encode the value as identity, which is a different claim.
12
+ */
13
+ const cellColor = (intensity) => `var(--paul-chart-seq-${Math.round(intensity * 4) + 1})`;
14
+ /** The top two ramp steps are dark enough that ink-on-cell has to flip. */
15
+ const valueColor = (intensity) => Math.round(intensity * 4) + 1 >= 4 ? 'var(--paul-color-surface)' : 'var(--paul-color-foreground)';
16
+ /**
17
+ * A cohort-retention grid: one cell per matrix value, shaded by magnitude. Pure
18
+ * SVG from `chartGeometry`, matching the Angular `PaulHeatmapChart`.
19
+ *
20
+ * The scale legend is not optional. A sequential ramp with no key tells a reader
21
+ * that one cell is bigger than another but never by how much, so the five steps
22
+ * are rendered with the matrix min and max as end labels. The `role="img"`
23
+ * summary names every row and its values, so the grid is readable with the
24
+ * colour thrown away entirely.
25
+ */
26
+ export function HeatmapChart({ rows, colLabels, label, showValues = true, width = 220, height = 140, className, ...props }) {
27
+ const matrix = rows.map((row) => row.values);
28
+ const cells = heatmapCells(matrix, {
29
+ width: width - ROW_GUTTER,
30
+ height: height - COL_GUTTER,
31
+ });
32
+ const values = matrix.flat();
33
+ const min = values.length > 0 ? Math.min(...values) : 0;
34
+ const max = values.length > 0 ? Math.max(...values) : 0;
35
+ const summary = rows.map((row) => `${row.label} ${row.values.join(', ')}`).join('; ');
36
+ const name = cells.length > 0 ? `${label}: ${summary}` : label;
37
+ // Column and row label positions ride on the cells themselves, so the text
38
+ // never drifts out of alignment with the grid it annotates.
39
+ const firstInCol = (c) => cells.find((cell) => cell.col === c);
40
+ const firstInRow = (r) => cells.find((cell) => cell.row === r);
41
+ return (_jsxs("div", { className: cx('paul-chart', 'paul-chart--heatmap', className), ...props, children: [_jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: cells.length > 0 ? (_jsxs("svg", { className: "paul-chart__svg", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true", focusable: "false", children: [colLabels.map((text, c) => {
42
+ const cell = firstInCol(c);
43
+ if (!cell)
44
+ return null;
45
+ return (_jsx("text", { className: "paul-chart__col-label", x: ROW_GUTTER + cell.x + cell.width / 2, y: COL_GUTTER - 5, textAnchor: "middle", children: text }, `col-${text}`));
46
+ }), rows.map((row, r) => {
47
+ const cell = firstInRow(r);
48
+ if (!cell)
49
+ return null;
50
+ return (_jsx("text", { className: "paul-chart__row-label", x: ROW_GUTTER - 5, y: COL_GUTTER + cell.y + cell.height / 2, textAnchor: "end", dominantBaseline: "middle", children: row.label }, `row-${row.label}`));
51
+ }), _jsxs("g", { transform: `translate(${ROW_GUTTER} ${COL_GUTTER})`, children: [cells.map((cell) => (_jsx("rect", { className: "paul-chart__cell", x: cell.x, y: cell.y, width: cell.width, height: cell.height, fill: cellColor(cell.intensity) }, `${cell.row}-${cell.col}`))), showValues &&
52
+ cells.map((cell) => (_jsx("text", { className: "paul-chart__cell-value", x: cell.x + cell.width / 2, y: cell.y + cell.height / 2, textAnchor: "middle", dominantBaseline: "middle", fill: valueColor(cell.intensity), children: cell.value }, `v-${cell.row}-${cell.col}`)))] })] })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }), cells.length > 0 && (_jsxs("div", { className: "paul-chart__scale", children: [_jsx("span", { className: "paul-chart__scale-end", children: min }), [0, 1, 2, 3, 4].map((step) => (_jsx("span", { className: "paul-chart__scale-step", "aria-hidden": "true", style: { backgroundColor: `var(--paul-chart-seq-${step + 1})` } }, step))), _jsx("span", { className: "paul-chart__scale-end", children: max })] }))] }));
53
+ }
@@ -0,0 +1,34 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type ParetoDatum = {
3
+ label: string;
4
+ value: number;
5
+ };
6
+ type ParetoChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
7
+ data: ParetoDatum[];
8
+ /** Accessible name for the chart. Required. */
9
+ label: string;
10
+ /** Cumulative percent to draw the reference line at. Defaults to 80. */
11
+ threshold?: number;
12
+ /** viewBox width in coordinate units. Defaults to 200. */
13
+ width?: number;
14
+ /** viewBox height in coordinate units. Defaults to 140. */
15
+ height?: number;
16
+ };
17
+ /**
18
+ * Sorted bars plus a cumulative line, on ONE y-axis.
19
+ *
20
+ * The textbook Pareto puts raw counts on the left axis and cumulative percent
21
+ * on the right. Two y-scales on one plot invent a relationship the data doesn't
22
+ * have — where the line appears to cross the bars is a function of how the two
23
+ * scales were aligned, and that alignment is arbitrary. So there is one axis
24
+ * here: the bars are percent-of-total and the line is cumulative percent, both
25
+ * on 0–100, and the crossing point means something. Nothing is labelled with a
26
+ * raw count as if it had a scale of its own.
27
+ *
28
+ * One series, one colour. Ramping the bars by value would encode magnitude
29
+ * twice — the height already says it, and the categories are nominal.
30
+ *
31
+ * Pure SVG from `chartGeometry`, matching the Angular `PaulParetoChart`.
32
+ */
33
+ export declare function ParetoChart({ data, label, threshold, width, height, className, ...props }: ParetoChartProps): import("react").JSX.Element;
34
+ export {};
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { paretoLayout } from './chartGeometry';
4
+ /** Height reserved under the plot for the category labels. */
5
+ const LABEL_BAND = 16;
6
+ const PADDING = 2;
7
+ /**
8
+ * Sorted bars plus a cumulative line, on ONE y-axis.
9
+ *
10
+ * The textbook Pareto puts raw counts on the left axis and cumulative percent
11
+ * on the right. Two y-scales on one plot invent a relationship the data doesn't
12
+ * have — where the line appears to cross the bars is a function of how the two
13
+ * scales were aligned, and that alignment is arbitrary. So there is one axis
14
+ * here: the bars are percent-of-total and the line is cumulative percent, both
15
+ * on 0–100, and the crossing point means something. Nothing is labelled with a
16
+ * raw count as if it had a scale of its own.
17
+ *
18
+ * One series, one colour. Ramping the bars by value would encode magnitude
19
+ * twice — the height already says it, and the categories are nominal.
20
+ *
21
+ * Pure SVG from `chartGeometry`, matching the Angular `PaulParetoChart`.
22
+ */
23
+ export function ParetoChart({ data, label, threshold = 80, width = 200, height = 140, className, ...props }) {
24
+ // `paretoLayout` filters out non-positive values and sorts descending, so the
25
+ // incoming order is NOT the bar order. Pair labels to values and put them
26
+ // through the identical filter + sort, or every bar gets the wrong name.
27
+ // `Array.prototype.sort` is stable, so ties keep their original order in both.
28
+ const ranked = data.filter((d) => d.value > 0).sort((a, b) => b.value - a.value);
29
+ const plotHeight = Math.max(0, height - LABEL_BAND);
30
+ const box = { width, height: plotHeight, padding: PADDING };
31
+ // The geometry takes the threshold too, so the cut it reports and the rule
32
+ // drawn below are the same number by construction.
33
+ const { bars, cumulative, percents, cutIndex: cut } = paretoLayout(data.map((d) => d.value), box, { threshold });
34
+ // Same mapping the geometry uses for the cumulative points, so the reference
35
+ // line and the line it references are on one scale by construction.
36
+ const inner = { bottom: plotHeight - PADDING, height: Math.max(0, plotHeight - PADDING * 2) };
37
+ const thresholdY = Math.round((inner.bottom - threshold * (inner.height / 100)) * 1000) / 1000;
38
+ const summary = ranked.map((d, i) => `${d.label} ${percents[i] ?? 0}%`).join(', ');
39
+ const crossing = cut >= 0
40
+ ? `${threshold}% of the total is reached by the first ${cut + 1} of ${ranked.length}.`
41
+ : `The cumulative total never reaches ${threshold}%.`;
42
+ const name = ranked.length > 0 ? `${label}: ${summary}. ${crossing}` : label;
43
+ return (_jsx("div", { className: cx('paul-chart', 'paul-chart--pareto', className), ...props, children: _jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: ranked.length > 0 ? (_jsxs("svg", { className: "paul-chart__svg", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true", focusable: "false", children: [bars.map((bar, i) => (_jsx("rect", { className: "paul-chart__bar", x: bar.x, y: bar.y, width: bar.width, height: bar.height, rx: 1, fill: "var(--paul-chart-1)" }, ranked[i]?.label ?? i))), _jsx("line", { className: "paul-chart__threshold", x1: PADDING, x2: width - PADDING, y1: thresholdY, y2: thresholdY, stroke: "var(--paul-color-border)", strokeDasharray: "4 3" }), _jsx("polyline", { className: "paul-chart__cumulative", points: cumulative.map((p) => `${p.x},${p.y}`).join(' '), fill: "none", stroke: "var(--paul-chart-4)", strokeWidth: 2 }), cut >= 0 && cumulative[cut] && (_jsx("circle", { className: "paul-chart__cut", cx: cumulative[cut].x, cy: cumulative[cut].y, r: 3, fill: "var(--paul-chart-4)" })), bars.map((bar, i) => (_jsx("text", { className: "paul-chart__category", x: bar.x + bar.width / 2, y: height - 5, textAnchor: "middle", children: ranked[i]?.label ?? '' }, `label-${ranked[i]?.label ?? i}`)))] })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }) }));
44
+ }
@@ -0,0 +1,35 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type RadarSeries = {
3
+ label: string;
4
+ /** One value per axis, in axis order. Missing entries read as 0. */
5
+ values: number[];
6
+ };
7
+ type RadarChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
8
+ data: RadarSeries[];
9
+ /** Axis names, in order. The polygon has one vertex per entry. */
10
+ axes: string[];
11
+ /** Accessible name for the chart. Required. */
12
+ label: string;
13
+ /** Shared ceiling for every series. Defaults to the max across all series. */
14
+ max?: number;
15
+ /** Width and height of the square viewBox, in coordinate units. Defaults to 160. */
16
+ size?: number;
17
+ /** Show the series legend. Only consulted when there are ≥2 series. Defaults to true. */
18
+ showLegend?: boolean;
19
+ };
20
+ /**
21
+ * Several measures compared on one frame — the shape behind skill profiles and
22
+ * capability scorecards. Pure SVG from `chartGeometry`, matching the Angular
23
+ * `PaulRadarChart`.
24
+ *
25
+ * Two rules make the picture honest. Every series is scaled against ONE ceiling
26
+ * (`max`, defaulting to the max across all series), so the polygons are
27
+ * comparable rather than each filling the frame; and only the first three
28
+ * series are drawn — anything past that is dropped, because overlapping
29
+ * translucent polygons stop resolving into distinct shapes.
30
+ *
31
+ * The frame — spokes and rings — is recessive ink, never a series colour, so
32
+ * the data reads on top of it instead of competing with it.
33
+ */
34
+ export declare function RadarChart({ data, axes, label, max, size, showLegend, className, ...props }: RadarChartProps): import("react").JSX.Element;
35
+ export {};
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { radarAxes, radarPolygon } from './chartGeometry';
4
+ /** At most three series — a fourth overlapping polygon stops being readable. */
5
+ const MAX_SERIES = 3;
6
+ function warnTruncated(count, label) {
7
+ // Declared locally rather than pulling @types/node into a browser package.
8
+ if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production')
9
+ return;
10
+ // eslint-disable-next-line no-console
11
+ console.warn(`RadarChart ("${label}"): ${count} series given, ${MAX_SERIES} drawn. ` +
12
+ 'Overlapping polygons stop being readable past three — facet into small ' +
13
+ 'multiples, or fold the tail into one series, rather than relying on this cap.');
14
+ }
15
+ /** Series colour comes from the CATEGORICAL palette: series are identities. */
16
+ const seriesColor = (i) => `var(--paul-chart-${i + 1})`;
17
+ /**
18
+ * Several measures compared on one frame — the shape behind skill profiles and
19
+ * capability scorecards. Pure SVG from `chartGeometry`, matching the Angular
20
+ * `PaulRadarChart`.
21
+ *
22
+ * Two rules make the picture honest. Every series is scaled against ONE ceiling
23
+ * (`max`, defaulting to the max across all series), so the polygons are
24
+ * comparable rather than each filling the frame; and only the first three
25
+ * series are drawn — anything past that is dropped, because overlapping
26
+ * translucent polygons stop resolving into distinct shapes.
27
+ *
28
+ * The frame — spokes and rings — is recessive ink, never a series colour, so
29
+ * the data reads on top of it instead of competing with it.
30
+ */
31
+ export function RadarChart({ data, axes, label, max, size = 160, showLegend = true, className, ...props }) {
32
+ const series = data.slice(0, MAX_SERIES);
33
+ if (data.length > MAX_SERIES)
34
+ warnTruncated(data.length, label);
35
+ const box = { width: size, height: size, padding: 18 };
36
+ const empty = series.length === 0 || axes.length === 0;
37
+ const geo = radarAxes(Math.max(axes.length, 1), box);
38
+ /** Pad or trim each series to the axis count so the polygon always closes. */
39
+ const valuesFor = (s) => axes.map((_, i) => s.values[i] ?? 0);
40
+ const ceiling = max ?? Math.max(0, ...series.flatMap((s) => valuesFor(s)));
41
+ const polygons = series.map((s, i) => ({
42
+ label: s.label,
43
+ color: seriesColor(i),
44
+ points: radarPolygon(valuesFor(s), box, ceiling)
45
+ .map((p) => `${p.x},${p.y}`)
46
+ .join(' '),
47
+ }));
48
+ const summary = series
49
+ .map((s) => `${s.label} ${axes.map((a, i) => `${a} ${s.values[i] ?? 0}`).join(', ')}`)
50
+ .join('; ');
51
+ const name = empty ? label : `${label}: ${summary}`;
52
+ return (_jsxs("div", { className: cx('paul-chart', 'paul-chart--radar', className), ...props, children: [_jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: !empty ? (_jsxs("svg", { className: "paul-chart__svg", viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", focusable: "false", children: [geo.rings.map((r) => (_jsx("circle", { className: "paul-chart__ring", cx: geo.cx, cy: geo.cy, r: r }, r))), geo.axes.map((axis) => (_jsx("line", { className: "paul-chart__axis", x1: geo.cx, y1: geo.cy, x2: axis.x, y2: axis.y }, axis.index))), polygons.map((poly) => (_jsx("polygon", { className: "paul-chart__radar-area", points: poly.points, fill: poly.color, stroke: poly.color }, poly.label))), geo.axes.map((axis) => (_jsx("text", { className: "paul-chart__axis-label",
53
+ // Nudged past the spoke end so the name clears the outer ring.
54
+ x: axis.x + (axis.x - geo.cx) * 0.12, y: axis.y + (axis.y - geo.cy) * 0.12, textAnchor: axis.x > geo.cx + 1 ? 'start' : axis.x < geo.cx - 1 ? 'end' : 'middle', dominantBaseline: "middle", children: axes[axis.index] }, axis.index)))] })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }), showLegend && series.length >= 2 && (_jsx("ul", { className: "paul-chart__legend", children: series.map((s, i) => (_jsxs("li", { className: "paul-chart__legend-item", children: [_jsx("span", { className: "paul-chart__swatch", "aria-hidden": "true", style: { backgroundColor: seriesColor(i) } }), _jsx("span", { className: "paul-chart__legend-label", children: s.label })] }, s.label))) }))] }));
55
+ }
@@ -0,0 +1,37 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ import { type ScatterDomain } from './chartGeometry';
3
+ export type ScatterSeries = {
4
+ label: string;
5
+ points: {
6
+ x: number;
7
+ y: number;
8
+ }[];
9
+ };
10
+ type ScatterPlotProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
11
+ series: ScatterSeries[];
12
+ /** Accessible name for the chart. Required. */
13
+ label: string;
14
+ /**
15
+ * Fixes the scale. Pass it when several plots have to be read against each
16
+ * other — without it every plot rescales to its own extent and two charts
17
+ * side by side stop being comparable.
18
+ */
19
+ domain?: ScatterDomain;
20
+ /** viewBox width in coordinate units. Defaults to 200. */
21
+ width?: number;
22
+ /** viewBox height in coordinate units. Defaults to 140. */
23
+ height?: number;
24
+ /** Mark radius in coordinate units. Defaults to 4 — an 8px target. */
25
+ radius?: number;
26
+ };
27
+ /**
28
+ * Two measures against each other, one mark per observation. Pure SVG from
29
+ * `chartGeometry`, matching the Angular `PaulScatterPlot`.
30
+ *
31
+ * Every mark carries a surface-coloured ring (in CSS, not inline) so points
32
+ * that land on top of each other still read as two points rather than one blob.
33
+ * The whole plot is one `role="img"`; a per-point accessibility tree would be
34
+ * noise, so the summary counts observations per series instead.
35
+ */
36
+ export declare function ScatterPlot({ series, label, domain, width, height, radius, className, ...props }: ScatterPlotProps): import("react").JSX.Element;
37
+ export {};
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { scatterPoints } from './chartGeometry';
4
+ /** Series colour comes from the CATEGORICAL palette: series are identities. */
5
+ const seriesColor = (i) => `var(--paul-chart-${(i % 6) + 1})`;
6
+ /**
7
+ * Two measures against each other, one mark per observation. Pure SVG from
8
+ * `chartGeometry`, matching the Angular `PaulScatterPlot`.
9
+ *
10
+ * Every mark carries a surface-coloured ring (in CSS, not inline) so points
11
+ * that land on top of each other still read as two points rather than one blob.
12
+ * The whole plot is one `role="img"`; a per-point accessibility tree would be
13
+ * noise, so the summary counts observations per series instead.
14
+ */
15
+ export function ScatterPlot({ series, label, domain, width = 200, height = 140, radius = 4, className, ...props }) {
16
+ // Inset by the radius so marks on the extremes sit inside the box.
17
+ const box = { width, height, padding: radius };
18
+ const total = series.reduce((n, s) => n + s.points.length, 0);
19
+ const summary = series.map((s) => `${s.label} ${s.points.length} points`).join(', ');
20
+ const name = total > 0 ? `${label}: ${summary}` : label;
21
+ return (_jsxs("div", { className: cx('paul-chart', 'paul-chart--scatter', className), ...props, children: [_jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: total > 0 ? (_jsx("svg", { className: "paul-chart__svg", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true", focusable: "false", children: series.map((s, i) => (_jsx("g", { className: "paul-chart__series", children: scatterPoints(s.points, box, domain).map((point) => (_jsx("circle", { className: "paul-chart__mark", cx: point.x, cy: point.y, r: radius, fill: seriesColor(i) }, point.index))) }, s.label))) })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }), series.length > 1 && total > 0 && (_jsx("ul", { className: "paul-chart__legend", children: series.map((s, i) => (_jsxs("li", { className: "paul-chart__legend-item", children: [_jsx("span", { className: "paul-chart__swatch", "aria-hidden": "true", style: { backgroundColor: seriesColor(i) } }), _jsx("span", { className: "paul-chart__legend-label", children: s.label })] }, s.label))) }))] }));
22
+ }