@paul-portfolio/react 0.4.4 → 0.5.1

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
+ }
package/dist/Ticker.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ReactNode } from "react";
2
2
  type TickerProps = {
3
3
  /** Accessible name for the strip. Scroll mode renders a labelled region. */
4
4
  label: string;
@@ -7,11 +7,11 @@ type TickerProps = {
7
7
  * auto-scroll — every item stays reachable. `marquee` is a decorative,
8
8
  * aria-hidden CSS loop for pure flavour.
9
9
  */
10
- mode?: 'scroll' | 'marquee';
10
+ mode?: "scroll" | "marquee";
11
11
  /** Which edge the strip sits on; picks the border side. */
12
- edge?: 'top' | 'bottom';
12
+ edge?: "top" | "bottom";
13
13
  /** Which way the ambient motion travels. */
14
- direction?: 'left' | 'right';
14
+ direction?: "left" | "right";
15
15
  /** Ambient auto-scroll speed for scroll mode, in px/sec. */
16
16
  speed?: number;
17
17
  className?: string;
package/dist/Ticker.js CHANGED
@@ -1,26 +1,7 @@
1
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
- /**
5
- * Tracks prefers-reduced-motion. Defaults to false so the server and the first
6
- * client render agree (SSR-stable), then updates once mounted.
7
- */
8
- function usePrefersReducedMotion() {
9
- const [reduced, setReduced] = useState(false);
10
- useEffect(() => {
11
- if (typeof window === 'undefined' ||
12
- typeof window.matchMedia !== 'function') {
13
- return;
14
- }
15
- const query = window.matchMedia('(prefers-reduced-motion: reduce)');
16
- // Microtask defer keeps the effect from setting state synchronously.
17
- queueMicrotask(() => setReduced(query.matches));
18
- const onChange = (e) => setReduced(e.matches);
19
- query.addEventListener('change', onChange);
20
- return () => query.removeEventListener('change', onChange);
21
- }, []);
22
- return reduced;
23
- }
2
+ import { useEffect, useLayoutEffect, useRef, useState, } from "react";
3
+ import { cx } from "./cx";
4
+ import { usePrefersReducedMotion } from "./usePrefersReducedMotion";
24
5
  /** How long a touch keeps the strip frozen before the ambient scroll resumes. */
25
6
  const TOUCH_RESUME_MS = 4000;
26
7
  /**
@@ -30,17 +11,17 @@ const TOUCH_RESUME_MS = 4000;
30
11
  * children, so the strip stays content-agnostic.
31
12
  */
32
13
  export function Ticker(props) {
33
- return props.mode === 'marquee' ? (_jsx(MarqueeTicker, { ...props })) : (_jsx(ScrollTicker, { ...props }));
14
+ return props.mode === "marquee" ? (_jsx(MarqueeTicker, { ...props })) : (_jsx(ScrollTicker, { ...props }));
34
15
  }
35
16
  function edgeClassFor(edge) {
36
- return edge === 'top' ? 'ticker--top' : 'ticker--bottom';
17
+ return edge === "top" ? "ticker--top" : "ticker--bottom";
37
18
  }
38
19
  /** Decorative marquee: aria-hidden, CSS-driven, content duplicated for the loop. */
39
- function MarqueeTicker({ edge = 'top', direction = 'left', className, children, }) {
40
- 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 })] }) }));
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 })] }) }));
41
22
  }
42
23
  /** Accessible scroll container with an ambient JS auto-scroll loop. */
43
- function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, className, children, }) {
24
+ function ScrollTicker({ label, edge = "top", direction = "left", speed = 40, className, children, }) {
44
25
  const reduced = usePrefersReducedMotion();
45
26
  const scrollerRef = useRef(null);
46
27
  const cloneRef = useRef(null);
@@ -51,11 +32,25 @@ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, cla
51
32
  useEffect(() => {
52
33
  pausedRef.current = paused;
53
34
  }, [paused]);
54
- // The clone fills the trailing half of the loop and stays clickable for
55
- // pointer users, so drop its focusables out of the tab order by hand. Paired
56
- // with aria-hidden, screen readers and axe never see the duplicate.
57
- useEffect(() => {
58
- const focusables = cloneRef.current?.querySelectorAll('a[href], button, input, select, textarea, [tabindex]');
35
+ // Take the clone out of the tab order, but leave it clickable.
36
+ //
37
+ // The clone used to carry `inert`, which was the right instinct aimed one
38
+ // notch too broadly: `inert` removes a subtree from the accessibility tree
39
+ // AND from the pointer. Since the loop wraps at half the scroll width,
40
+ // roughly half of what is on screen at any moment is the clone, so half the
41
+ // strip silently ignored clicks.
42
+ //
43
+ // What is actually wanted is narrower: hidden from assistive tech
44
+ // (`aria-hidden` on the element), not tabbable (`tabIndex = -1` here), and
45
+ // still interactive with a pointer. Both copies render the same children, so
46
+ // clicking either runs the same handler and the reader cannot tell which one
47
+ // they hit — which is the point.
48
+ //
49
+ // A layout effect rather than a passive one: it runs before the browser
50
+ // paints, so there is no frame in which the duplicate holds tabbable controls
51
+ // inside an aria-hidden container, which axe rates serious.
52
+ useLayoutEffect(() => {
53
+ const focusables = cloneRef.current?.querySelectorAll("a[href], button, input, select, textarea, [tabindex]");
59
54
  focusables?.forEach((el) => {
60
55
  el.tabIndex = -1;
61
56
  });
@@ -68,7 +63,7 @@ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, cla
68
63
  const el = scrollerRef.current;
69
64
  if (!el)
70
65
  return;
71
- const dir = direction === 'left' ? 1 : -1;
66
+ const dir = direction === "left" ? 1 : -1;
72
67
  // Start the rightward strip one copy in, so it has somewhere to scroll back.
73
68
  if (dir < 0)
74
69
  el.scrollLeft = el.scrollWidth / 2;
@@ -98,7 +93,7 @@ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, cla
98
93
  clearTimeout(resumeTimer.current);
99
94
  resumeTimer.current = setTimeout(() => setPaused(false), TOUCH_RESUME_MS);
100
95
  };
101
- const classes = cx('ticker', edgeClassFor(edge), className);
96
+ const classes = cx("ticker", edgeClassFor(edge), className);
102
97
  // Reduced motion: a plain, single-copy scrollable row. No clone, no loop.
103
98
  if (reduced) {
104
99
  return (_jsx("section", { "aria-label": label, className: classes, children: _jsx("div", { className: "ticker__group", children: children }) }));
@@ -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
+ }