@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,30 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ type SparklineProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
3
+ /** The series to plot. Ignored when `series` is given. */
4
+ data?: number[];
5
+ /**
6
+ * Several series on one shared y-domain — for comparing trends at a glance.
7
+ * Takes precedence over `data`.
8
+ */
9
+ series?: number[][];
10
+ /** `line` (default) draws a stroke; `area` fills under it. Single series only. */
11
+ variant?: 'line' | 'area';
12
+ /** Accessible name for the chart. Required — the SVG itself is hidden. */
13
+ label: string;
14
+ /** viewBox width in coordinate units. Defaults to 160. */
15
+ width?: number;
16
+ /** viewBox height in coordinate units. Defaults to 40. */
17
+ height?: number;
18
+ };
19
+ /**
20
+ * A compact, axis-free trend line — the sparkline used across dashboards and
21
+ * KPI cards. Pure SVG from `chartGeometry`, so it renders identically to the
22
+ * Angular `PaulSparkline`. Exposes `role="img"` with a caller-supplied label
23
+ * since the drawing carries no text of its own.
24
+ *
25
+ * With `series`, every line is scaled against ONE domain spanning all of them —
26
+ * independently scaled sparklines look comparable and aren't. `area` applies to
27
+ * the single-series form only; stacked fills at this size are mud.
28
+ */
29
+ export declare function Sparkline({ data, series, variant, label, width, height, className, ...props }: SparklineProps): import("react").JSX.Element;
30
+ export {};
@@ -0,0 +1,21 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { linePath, areaPath, multiLinePoints } from './chartGeometry';
4
+ /**
5
+ * A compact, axis-free trend line — the sparkline used across dashboards and
6
+ * KPI cards. Pure SVG from `chartGeometry`, so it renders identically to the
7
+ * Angular `PaulSparkline`. Exposes `role="img"` with a caller-supplied label
8
+ * since the drawing carries no text of its own.
9
+ *
10
+ * With `series`, every line is scaled against ONE domain spanning all of them —
11
+ * independently scaled sparklines look comparable and aren't. `area` applies to
12
+ * the single-series form only; stacked fills at this size are mud.
13
+ */
14
+ export function Sparkline({ data, series, variant = 'line', label, width = 160, height = 40, className, ...props }) {
15
+ const box = { width, height, padding: 2 };
16
+ const multi = series?.filter((s) => s.length > 0) ?? [];
17
+ const single = data ?? [];
18
+ const hasData = multi.length > 0 || single.length > 0;
19
+ const lines = multi.length > 0 ? multiLinePoints(multi, box) : [];
20
+ return (_jsx("div", { role: "img", "aria-label": label, className: cx('paul-chart', 'paul-chart--sparkline', className), ...props, children: hasData ? (_jsx("svg", { className: "paul-chart__svg", viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false", children: multi.length > 0 ? (lines.map((points, i) => (_jsx("path", { className: "paul-chart__line", d: points.map((p, j) => `${j === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '), fill: "none", stroke: `var(--paul-chart-${Math.min(i, 5) + 1})`, vectorEffect: "non-scaling-stroke" }, i)))) : (_jsxs(_Fragment, { children: [variant === 'area' && (_jsx("path", { className: "paul-chart__area", d: areaPath(single, box) })), _jsx("path", { className: "paul-chart__line", d: linePath(single, box), fill: "none", vectorEffect: "non-scaling-stroke" })] })) })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }));
21
+ }
@@ -0,0 +1,17 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ type SpotlightProps = HTMLAttributes<HTMLDivElement> & {
3
+ /** Diameter of the glow, in pixels. */
4
+ size?: number;
5
+ /** Glow colour. Any CSS colour; defaults to a soft brand-blue wash. */
6
+ color?: string;
7
+ children: ReactNode;
8
+ };
9
+ /**
10
+ * An interactive background: a soft radial glow that follows the cursor across
11
+ * the surface. The glow is decorative and clipped to the container, and the
12
+ * content sits in its own layer on top, so the component stays content-agnostic.
13
+ * Under prefers-reduced-motion the glow is pinned to the centre and stops
14
+ * tracking the pointer — visible, but with no cursor-driven movement.
15
+ */
16
+ export declare function Spotlight({ size, color, className, style, children, ...rest }: SpotlightProps): import("react").JSX.Element;
17
+ export {};
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, } from 'react';
3
+ import { cx } from './cx';
4
+ import { usePrefersReducedMotion } from './usePrefersReducedMotion';
5
+ /**
6
+ * An interactive background: a soft radial glow that follows the cursor across
7
+ * the surface. The glow is decorative and clipped to the container, and the
8
+ * content sits in its own layer on top, so the component stays content-agnostic.
9
+ * Under prefers-reduced-motion the glow is pinned to the centre and stops
10
+ * tracking the pointer — visible, but with no cursor-driven movement.
11
+ */
12
+ export function Spotlight({ size = 350, color, className, style, children, ...rest }) {
13
+ const reduced = usePrefersReducedMotion();
14
+ const rootRef = useRef(null);
15
+ const baseStyle = {
16
+ ['--paul-spotlight-size']: `${size}px`,
17
+ ...(color ? { ['--paul-spotlight-color']: color } : {}),
18
+ ...style,
19
+ };
20
+ // Static, centred glow — visible, but never chases the pointer.
21
+ if (reduced) {
22
+ return (_jsxs("div", { className: cx('spotlight', className), "data-active": "true", style: baseStyle, ...rest, children: [_jsx("div", { "aria-hidden": "true", className: "spotlight__glow" }), _jsx("div", { className: "spotlight__content", children: children })] }));
23
+ }
24
+ const handleMove = (e) => {
25
+ const el = rootRef.current;
26
+ if (!el)
27
+ return;
28
+ const rect = el.getBoundingClientRect();
29
+ el.style.setProperty('--paul-spotlight-x', `${e.clientX - rect.left}px`);
30
+ el.style.setProperty('--paul-spotlight-y', `${e.clientY - rect.top}px`);
31
+ el.dataset.active = 'true';
32
+ };
33
+ const handleLeave = () => {
34
+ const el = rootRef.current;
35
+ if (el)
36
+ delete el.dataset.active;
37
+ };
38
+ return (_jsxs("div", { ref: rootRef, className: cx('spotlight', className), style: baseStyle, onPointerMove: handleMove, onPointerLeave: handleLeave, ...rest, children: [_jsx("div", { "aria-hidden": "true", className: "spotlight__glow" }), _jsx("div", { className: "spotlight__content", children: children })] }));
39
+ }
@@ -0,0 +1,35 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type LineSeries = {
3
+ label: string;
4
+ values: number[];
5
+ };
6
+ type StackedLineChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
7
+ series: LineSeries[];
8
+ /** Accessible name for the chart. Required. */
9
+ label: string;
10
+ /** `lines` (default) overlays one line per series; `stacked` fills part-to-whole bands. */
11
+ variant?: 'lines' | 'stacked';
12
+ /** viewBox width in coordinate units. Defaults to 200. */
13
+ width?: number;
14
+ /** viewBox height in coordinate units. Defaults to 120. */
15
+ height?: number;
16
+ /**
17
+ * Render the swatch legend. Defaults to true, and a legend is the only thing
18
+ * naming a series here — set this to false with two or more series and
19
+ * identity becomes colour-only, which fails the same readers the palette was
20
+ * ordered for. One series never gets a legend regardless: the accessible
21
+ * name already says what the line is.
22
+ */
23
+ showLegend?: boolean;
24
+ };
25
+ /**
26
+ * Several series over one shared x — either overlaid as lines, or stacked as
27
+ * part-to-whole bands. Pure SVG from `chartGeometry`, matching the Angular
28
+ * `PaulStackedLineChart`.
29
+ *
30
+ * Both variants take their y-domain from `chartGeometry`, which computes it
31
+ * once across every series. That is the whole point of the primitive: series
32
+ * rescaled to their own extents look comparable and aren't.
33
+ */
34
+ export declare function StackedLineChart({ series, label, variant, width, height, showLegend, className, ...props }: StackedLineChartProps): import("react").JSX.Element;
35
+ export {};
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { multiLinePoints, stackedSeries } from './chartGeometry';
4
+ /**
5
+ * Series colour comes from the CATEGORICAL palette: series are identities, not
6
+ * magnitudes. Clamped at slot 6 rather than cycled — a seventh line reusing
7
+ * slot 1 would read as the first series, and two identical hues on one plot is
8
+ * worse than none. Past six, facet or roll the tail into "Other".
9
+ */
10
+ const seriesColor = (i) => `var(--paul-chart-${Math.min(i, 5) + 1})`;
11
+ const toPath = (points) => points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
12
+ /**
13
+ * Several series over one shared x — either overlaid as lines, or stacked as
14
+ * part-to-whole bands. Pure SVG from `chartGeometry`, matching the Angular
15
+ * `PaulStackedLineChart`.
16
+ *
17
+ * Both variants take their y-domain from `chartGeometry`, which computes it
18
+ * once across every series. That is the whole point of the primitive: series
19
+ * rescaled to their own extents look comparable and aren't.
20
+ */
21
+ export function StackedLineChart({ series, label, variant = 'lines', width = 200, height = 120, showLegend = true, className, ...props }) {
22
+ const box = { width, height, padding: 2 };
23
+ const values = series.map((s) => s.values);
24
+ const hasData = values.some((v) => v.length > 0);
25
+ const lines = variant === 'lines' ? multiLinePoints(values, box) : [];
26
+ const bands = variant === 'stacked' ? stackedSeries(values, box) : [];
27
+ // The last value is what a trend line is read for, so it is what the
28
+ // accessible name carries — the drawing itself is hidden.
29
+ const summary = series
30
+ .map((s) => `${s.label} ${s.values.length > 0 ? s.values[s.values.length - 1] : 0}`)
31
+ .join(', ');
32
+ const name = hasData ? `${label}: ${summary}` : label;
33
+ // Two or more series ALWAYS get a legend; one never does.
34
+ const legend = showLegend && hasData && series.length >= 2;
35
+ return (_jsxs("div", { className: cx('paul-chart', 'paul-chart--lines', className), ...props, children: [_jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: hasData ? (_jsx("svg", { className: "paul-chart__svg", viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false", children: variant === 'stacked'
36
+ ? bands.map((band) => (_jsx("path", { className: "paul-chart__band", d: band.path, fill: seriesColor(band.index) }, band.index)))
37
+ : lines.map((points, i) => (_jsx("path", { className: "paul-chart__series-line", d: toPath(points), fill: "none", stroke: seriesColor(i), vectorEffect: "non-scaling-stroke" }, series[i]?.label ?? i))) })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }), legend && (_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))) }))] }));
38
+ }
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from 'react';
2
+ type TickerProps = {
3
+ /** Accessible name for the strip. Scroll mode renders a labelled region. */
4
+ label: string;
5
+ /**
6
+ * `scroll` (default) is an accessible, real scroll container with an ambient
7
+ * auto-scroll — every item stays reachable. `marquee` is a decorative,
8
+ * aria-hidden CSS loop for pure flavour.
9
+ */
10
+ mode?: 'scroll' | 'marquee';
11
+ /** Which edge the strip sits on; picks the border side. */
12
+ edge?: 'top' | 'bottom';
13
+ /** Which way the ambient motion travels. */
14
+ direction?: 'left' | 'right';
15
+ /** Ambient auto-scroll speed for scroll mode, in px/sec. */
16
+ speed?: number;
17
+ className?: string;
18
+ children: ReactNode;
19
+ };
20
+ /**
21
+ * A horizontal ticker strip with two modes: an accessible, auto-scrolling
22
+ * container (`scroll`) and a decorative CSS marquee (`marquee`). Both loop
23
+ * seamlessly and both honour prefers-reduced-motion. Content is passed as
24
+ * children, so the strip stays content-agnostic.
25
+ */
26
+ export declare function Ticker(props: TickerProps): import("react").JSX.Element;
27
+ export {};
package/dist/Ticker.js ADDED
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { cx } from './cx';
4
+ import { usePrefersReducedMotion } from './usePrefersReducedMotion';
5
+ /** How long a touch keeps the strip frozen before the ambient scroll resumes. */
6
+ const TOUCH_RESUME_MS = 4000;
7
+ /**
8
+ * A horizontal ticker strip with two modes: an accessible, auto-scrolling
9
+ * container (`scroll`) and a decorative CSS marquee (`marquee`). Both loop
10
+ * seamlessly and both honour prefers-reduced-motion. Content is passed as
11
+ * children, so the strip stays content-agnostic.
12
+ */
13
+ export function Ticker(props) {
14
+ return props.mode === 'marquee' ? (_jsx(MarqueeTicker, { ...props })) : (_jsx(ScrollTicker, { ...props }));
15
+ }
16
+ function edgeClassFor(edge) {
17
+ return edge === 'top' ? 'ticker--top' : 'ticker--bottom';
18
+ }
19
+ /** Decorative marquee: aria-hidden, CSS-driven, content duplicated for the loop. */
20
+ function MarqueeTicker({ edge = 'top', direction = 'left', className, children, }) {
21
+ return (_jsx("div", { "aria-hidden": "true", "data-direction": direction, className: cx('ticker', 'ticker--marquee', edgeClassFor(edge), className), children: _jsxs("div", { className: "ticker__track", children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { className: "ticker__group", children: children })] }) }));
22
+ }
23
+ /** Accessible scroll container with an ambient JS auto-scroll loop. */
24
+ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, className, children, }) {
25
+ const reduced = usePrefersReducedMotion();
26
+ const scrollerRef = useRef(null);
27
+ const cloneRef = useRef(null);
28
+ const [paused, setPaused] = useState(false);
29
+ // Mirror `paused` into a ref so the animation-frame loop reads the latest
30
+ // value without the scroll effect re-subscribing on every toggle.
31
+ const pausedRef = useRef(false);
32
+ useEffect(() => {
33
+ pausedRef.current = paused;
34
+ }, [paused]);
35
+ // Fallback for browsers without `inert`.
36
+ //
37
+ // The clone carries `inert` in the markup, which drops its descendants from
38
+ // the tab order and the accessibility tree together, before first paint. This
39
+ // effect used to be the only mechanism, and it left a gap: an effect runs
40
+ // after the DOM exists, so between render and this call the duplicate held
41
+ // tabbable controls inside an aria-hidden container. Hiding something from
42
+ // assistive tech while leaving it reachable by keyboard is worse than not
43
+ // hiding it — the user lands on a control a screen reader insists is absent.
44
+ useEffect(() => {
45
+ if ('inert' in HTMLElement.prototype)
46
+ return;
47
+ const focusables = cloneRef.current?.querySelectorAll('a[href], button, input, select, textarea, [tabindex]');
48
+ focusables?.forEach((el) => {
49
+ el.tabIndex = -1;
50
+ });
51
+ });
52
+ // Ambient scroll: advance scrollLeft each frame and wrap by one copy width
53
+ // for a seamless loop. Pauses on hover/touch; user scrolling is left alone.
54
+ useEffect(() => {
55
+ if (reduced)
56
+ return;
57
+ const el = scrollerRef.current;
58
+ if (!el)
59
+ return;
60
+ const dir = direction === 'left' ? 1 : -1;
61
+ // Start the rightward strip one copy in, so it has somewhere to scroll back.
62
+ if (dir < 0)
63
+ el.scrollLeft = el.scrollWidth / 2;
64
+ let raf = 0;
65
+ let last = performance.now();
66
+ const step = (now) => {
67
+ const dt = now - last;
68
+ last = now;
69
+ const half = el.scrollWidth / 2;
70
+ if (!pausedRef.current && half > 0) {
71
+ let next = el.scrollLeft + (dir * speed * dt) / 1000;
72
+ if (next >= half)
73
+ next -= half;
74
+ else if (next < 0)
75
+ next += half;
76
+ el.scrollLeft = next;
77
+ }
78
+ raf = requestAnimationFrame(step);
79
+ };
80
+ raf = requestAnimationFrame(step);
81
+ return () => cancelAnimationFrame(raf);
82
+ }, [reduced, direction, speed]);
83
+ const resumeTimer = useRef(null);
84
+ const freezeForTouch = () => {
85
+ setPaused(true);
86
+ if (resumeTimer.current)
87
+ clearTimeout(resumeTimer.current);
88
+ resumeTimer.current = setTimeout(() => setPaused(false), TOUCH_RESUME_MS);
89
+ };
90
+ const classes = cx('ticker', edgeClassFor(edge), className);
91
+ // Reduced motion: a plain, single-copy scrollable row. No clone, no loop.
92
+ if (reduced) {
93
+ return (_jsx("section", { "aria-label": label, className: classes, children: _jsx("div", { className: "ticker__group", children: children }) }));
94
+ }
95
+ return (_jsx("section", { ref: scrollerRef, "aria-label": label, "data-direction": direction, className: classes, onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false), onTouchStart: freezeForTouch, children: _jsxs("div", { className: "ticker__track", "data-paused": paused || undefined, children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { ref: cloneRef, "aria-hidden": "true", inert: true, className: "ticker__group", children: children })] }) }));
96
+ }
@@ -0,0 +1,17 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ type TiltCardProps = HTMLAttributes<HTMLDivElement> & {
3
+ /** Maximum rotation, in degrees, at the edges of the card. */
4
+ maxTilt?: number;
5
+ /** Show the cursor-tracking glare highlight. Defaults to true. */
6
+ glare?: boolean;
7
+ children: ReactNode;
8
+ };
9
+ /**
10
+ * A surface that tilts in 3D toward the pointer, with an optional glare that
11
+ * tracks the cursor. The effect is purely decorative — the content layer stays
12
+ * readable — and it is pointer-driven only, so keyboard users are never left
13
+ * without access. Under prefers-reduced-motion it renders as a flat card with
14
+ * no tilt or glare. Content-agnostic: pass any children (a Card, an image…).
15
+ */
16
+ export declare function TiltCard({ maxTilt, glare, className, children, ...rest }: TiltCardProps): import("react").JSX.Element;
17
+ export {};
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from 'react';
3
+ import { cx } from './cx';
4
+ import { usePrefersReducedMotion } from './usePrefersReducedMotion';
5
+ /**
6
+ * A surface that tilts in 3D toward the pointer, with an optional glare that
7
+ * tracks the cursor. The effect is purely decorative — the content layer stays
8
+ * readable — and it is pointer-driven only, so keyboard users are never left
9
+ * without access. Under prefers-reduced-motion it renders as a flat card with
10
+ * no tilt or glare. Content-agnostic: pass any children (a Card, an image…).
11
+ */
12
+ export function TiltCard({ maxTilt = 12, glare = true, className, children, ...rest }) {
13
+ const reduced = usePrefersReducedMotion();
14
+ const innerRef = useRef(null);
15
+ // No tilt, no glare, no pointer handlers — just a flat, static card.
16
+ if (reduced) {
17
+ return (_jsx("div", { className: cx('tilt-card', className), ...rest, children: _jsx("div", { className: "tilt-card__inner", children: children }) }));
18
+ }
19
+ const handleMove = (e) => {
20
+ const rect = e.currentTarget.getBoundingClientRect();
21
+ if (rect.width === 0 || rect.height === 0)
22
+ return;
23
+ const px = (e.clientX - rect.left) / rect.width; // 0 (left) … 1 (right)
24
+ const py = (e.clientY - rect.top) / rect.height; // 0 (top) … 1 (bottom)
25
+ const rotateY = (px - 0.5) * 2 * maxTilt;
26
+ const rotateX = -(py - 0.5) * 2 * maxTilt;
27
+ const inner = innerRef.current;
28
+ if (!inner)
29
+ return;
30
+ inner.style.setProperty('--paul-tilt-x', `${rotateX.toFixed(2)}deg`);
31
+ inner.style.setProperty('--paul-tilt-y', `${rotateY.toFixed(2)}deg`);
32
+ inner.style.setProperty('--paul-glare-x', `${(px * 100).toFixed(1)}%`);
33
+ inner.style.setProperty('--paul-glare-y', `${(py * 100).toFixed(1)}%`);
34
+ inner.dataset.active = 'true';
35
+ };
36
+ const handleLeave = () => {
37
+ const inner = innerRef.current;
38
+ if (!inner)
39
+ return;
40
+ inner.style.setProperty('--paul-tilt-x', '0deg');
41
+ inner.style.setProperty('--paul-tilt-y', '0deg');
42
+ delete inner.dataset.active;
43
+ };
44
+ return (_jsx("div", { className: cx('tilt-card', className), onPointerMove: handleMove, onPointerLeave: handleLeave, ...rest, children: _jsxs("div", { ref: innerRef, className: "tilt-card__inner", children: [glare && _jsx("div", { "aria-hidden": "true", className: "tilt-card__glare" }), children] }) }));
45
+ }
@@ -0,0 +1,38 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ export type WordCloudDatum = {
3
+ text: string;
4
+ weight: number;
5
+ };
6
+ type WordCloudProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
7
+ terms: WordCloudDatum[];
8
+ /** Accessible name for the chart. Required. */
9
+ label: string;
10
+ /** Terms considered, heaviest first. Beyond this the tail is dropped. Defaults to 50. */
11
+ limit?: number;
12
+ /** Font size of the lightest term, in coordinate units. Defaults to 12. */
13
+ minFontSize?: number;
14
+ /** Font size of the heaviest term, in coordinate units. Defaults to 40. */
15
+ maxFontSize?: number;
16
+ /** viewBox width in coordinate units. Defaults to 400. */
17
+ width?: number;
18
+ /** viewBox height in coordinate units. Defaults to 240. */
19
+ height?: number;
20
+ };
21
+ /**
22
+ * A word cloud. Read this before you use it:
23
+ *
24
+ * Glyph AREA is not a comparable encoding. A long word reads as bigger than a
25
+ * short one at the same weight, so "internationalization" at weight 10 looks
26
+ * heavier than "go" at weight 30, and nobody can recover the numbers from the
27
+ * picture. `BarChart` shows the same data honestly. This component exists
28
+ * because a gallery wants one — when the numbers matter, reach for the bars.
29
+ *
30
+ * The aria-label carries the ranked term/weight list, which is the honest
31
+ * version of the same data, so a screen reader gets the better chart.
32
+ *
33
+ * Pure SVG from `chartGeometry`, matching the Angular `PaulWordCloud`. The
34
+ * layout has no randomness in it: the same input renders the same picture every
35
+ * time, so server and client agree and visual regression settles.
36
+ */
37
+ export declare function WordCloud({ terms, label, limit, minFontSize, maxFontSize, width, height, className, ...props }: WordCloudProps): import("react").JSX.Element;
38
+ export {};
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { wordCloudLayout } from './chartGeometry';
4
+ /**
5
+ * A word cloud. Read this before you use it:
6
+ *
7
+ * Glyph AREA is not a comparable encoding. A long word reads as bigger than a
8
+ * short one at the same weight, so "internationalization" at weight 10 looks
9
+ * heavier than "go" at weight 30, and nobody can recover the numbers from the
10
+ * picture. `BarChart` shows the same data honestly. This component exists
11
+ * because a gallery wants one — when the numbers matter, reach for the bars.
12
+ *
13
+ * The aria-label carries the ranked term/weight list, which is the honest
14
+ * version of the same data, so a screen reader gets the better chart.
15
+ *
16
+ * Pure SVG from `chartGeometry`, matching the Angular `PaulWordCloud`. The
17
+ * layout has no randomness in it: the same input renders the same picture every
18
+ * time, so server and client agree and visual regression settles.
19
+ */
20
+ export function WordCloud({ terms, label, limit = 50, minFontSize = 12, maxFontSize = 40, width = 400, height = 240, className, ...props }) {
21
+ const words = wordCloudLayout(terms, { width, height, padding: 4 }, { limit, minFontSize, maxFontSize });
22
+ // Ranked from the input rather than from the layout: a term the spiral had no
23
+ // room for still belongs in the accessible list. Same filter, sort and cap the
24
+ // geometry applies, so the two stay in step.
25
+ const ranked = [...terms]
26
+ .filter((t) => t.weight > 0)
27
+ .sort((a, b) => b.weight - a.weight)
28
+ .slice(0, limit);
29
+ const summary = ranked.map((t) => `${t.text} ${t.weight}`).join(', ');
30
+ const name = ranked.length > 0 ? `${label}: ${summary}` : label;
31
+ // Weight is already carried by font size. Ramping colour by weight too would
32
+ // encode the same number twice; the categorical slots just keep the words
33
+ // apart, cycling by rank.
34
+ const colorFor = (i) => `var(--paul-chart-${(i % 6) + 1})`;
35
+ return (_jsx("div", { className: cx('paul-chart', 'paul-chart--word-cloud', className), ...props, children: _jsx("div", { role: "img", "aria-label": name, className: "paul-chart__figure", children: words.length > 0 ? (_jsx("svg", { className: "paul-chart__svg", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true", focusable: "false", children: words.map((word) => (_jsx("text", { className: "paul-chart__word", x: word.x, y: word.y, fontSize: word.fontSize, fill: colorFor(word.index), textAnchor: "middle", dominantBaseline: "middle", children: word.text }, word.text))) })) : (_jsx("span", { className: "paul-chart__empty", "aria-hidden": "true", children: "No data" })) }) }));
36
+ }