@paul-portfolio/react 0.4.4 → 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
+ }
package/dist/Ticker.js CHANGED
@@ -1,26 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
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
- }
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
  /**
@@ -51,10 +32,18 @@ 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.
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.
57
44
  useEffect(() => {
45
+ if ('inert' in HTMLElement.prototype)
46
+ return;
58
47
  const focusables = cloneRef.current?.querySelectorAll('a[href], button, input, select, textarea, [tabindex]');
59
48
  focusables?.forEach((el) => {
60
49
  el.tabIndex = -1;
@@ -103,5 +92,5 @@ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, cla
103
92
  if (reduced) {
104
93
  return (_jsx("section", { "aria-label": label, className: classes, children: _jsx("div", { className: "ticker__group", children: children }) }));
105
94
  }
106
- 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", className: "ticker__group", children: children })] }) }));
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 })] }) }));
107
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
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Pure, dependency-free chart geometry. Every function turns a plain array of
3
+ * numbers into SVG coordinates or path strings, so the React and Angular chart
4
+ * components can render identical output without pulling in a charting runtime.
5
+ *
6
+ * This module is deliberately framework-agnostic and side-effect-free. It is
7
+ * mirrored verbatim in @paul-portfolio/angular; the unit tests in each package
8
+ * guard the two copies against drifting.
9
+ */
10
+ export interface ChartBox {
11
+ width: number;
12
+ height: number;
13
+ /** Uniform inset, in px, between the drawing and the edges of the box. */
14
+ padding?: number;
15
+ }
16
+ export interface Point {
17
+ x: number;
18
+ y: number;
19
+ }
20
+ export interface Rect {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ value: number;
26
+ }
27
+ export interface BarOptions {
28
+ /** Fraction of each band left empty as a gap, 0..1. Defaults to 0.25. */
29
+ gap?: number;
30
+ }
31
+ export interface DonutOptions {
32
+ /** Outer diameter of the ring, in px. */
33
+ size: number;
34
+ /** Ring thickness (outer radius − inner radius), in px. */
35
+ thickness: number;
36
+ /** Angle, in degrees, where the first slice starts. 0 is straight up. */
37
+ startAngle?: number;
38
+ }
39
+ export interface DonutSegment {
40
+ path: string;
41
+ percent: number;
42
+ startAngle: number;
43
+ endAngle: number;
44
+ index: number;
45
+ }
46
+ /** Maps each value to a point, spread evenly across the inner width. */
47
+ export declare function linePoints(values: number[], box: ChartBox): Point[];
48
+ export declare function linePath(values: number[], box: ChartBox): string;
49
+ export declare function areaPath(values: number[], box: ChartBox): string;
50
+ /** Vertical bars: one rect per value, heights proportional to the value. */
51
+ export declare function barRects(values: number[], box: ChartBox, opts?: BarOptions): Rect[];
52
+ /** Horizontal bars: one rect per value, widths proportional to the value. */
53
+ export declare function barRectsHorizontal(values: number[], box: ChartBox, opts?: BarOptions): Rect[];
54
+ /** 0deg points straight up; angle increases clockwise. */
55
+ export declare function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number): Point;
56
+ /** SVG path for a ring wedge between two radii and two angles. */
57
+ export declare function arcPath(cx: number, cy: number, rOuter: number, rInner: number, startAngle: number, endAngle: number): string;
58
+ export declare function donutSegments(values: number[], opts: DonutOptions): DonutSegment[];
59
+ export interface FunnelStage {
60
+ /** Trapezoid path for the stage band. */
61
+ path: string;
62
+ /** Share of the FIRST stage, 0..100 — what "conversion" means in a funnel. */
63
+ percent: number;
64
+ /** Loss from the previous stage, 0..100. 0 for the first stage. */
65
+ dropOff: number;
66
+ value: number;
67
+ index: number;
68
+ }
69
+ /**
70
+ * Stacked trapezoids narrowing stage to stage. Widths are proportional to the
71
+ * value, centred, so the taper reads as the drop-off — which is the number a
72
+ * funnel is actually read for, hence `dropOff` on every stage.
73
+ */
74
+ export declare function funnelStages(values: number[], box: ChartBox, gap?: number): FunnelStage[];
75
+ export interface RadarAxis {
76
+ /** Outer end of the spoke. */
77
+ x: number;
78
+ y: number;
79
+ angle: number;
80
+ index: number;
81
+ }
82
+ export interface RadarGeometry {
83
+ cx: number;
84
+ cy: number;
85
+ radius: number;
86
+ axes: RadarAxis[];
87
+ /** Radii of the background rings, outermost last. */
88
+ rings: number[];
89
+ }
90
+ /**
91
+ * Spokes and rings for a radar chart. `radarPolygon` draws on the same centre
92
+ * and radius, so the series and the frame can't disagree.
93
+ */
94
+ export declare function radarAxes(count: number, box: ChartBox, ringCount?: number): RadarGeometry;
95
+ /**
96
+ * A closed point list for one series. `max` fixes the scale so several series —
97
+ * or several small multiples — are comparable; it defaults to the series max.
98
+ */
99
+ export declare function radarPolygon(values: number[], box: ChartBox, max?: number): Point[];
100
+ export interface ScatterDatum {
101
+ x: number;
102
+ y: number;
103
+ }
104
+ export interface ScatterPoint extends Point {
105
+ datum: ScatterDatum;
106
+ index: number;
107
+ }
108
+ export interface ScatterDomain {
109
+ xMin: number;
110
+ xMax: number;
111
+ yMin: number;
112
+ yMax: number;
113
+ }
114
+ /**
115
+ * Maps points into the box. Pass an explicit `domain` when several plots must
116
+ * share a scale — without it each plot silently rescales to its own extent and
117
+ * they stop being comparable.
118
+ */
119
+ export declare function scatterPoints(data: ScatterDatum[], box: ChartBox, domain?: ScatterDomain): ScatterPoint[];
120
+ export interface HeatmapCell extends Rect {
121
+ /** 0..1 against the matrix max — the input to the SEQUENTIAL ramp. */
122
+ intensity: number;
123
+ row: number;
124
+ col: number;
125
+ }
126
+ /**
127
+ * A grid of cells with a normalised intensity per cell. Intensity is deliberately
128
+ * 0..1 rather than a colour: the component maps it onto the sequential ramp, and
129
+ * a categorical palette here would double-encode the value as hue.
130
+ */
131
+ export declare function heatmapCells(matrix: number[][], box: ChartBox, gap?: number): HeatmapCell[];
132
+ export interface ParetoLayout {
133
+ /** Bars as PERCENT of total, so they share the cumulative line's scale. */
134
+ bars: Rect[];
135
+ /** Cumulative percentage points, in the same coordinate space as the bars. */
136
+ cumulative: Point[];
137
+ /** Percent value per index, sorted descending like the bars. */
138
+ percents: number[];
139
+ /** Index where the cumulative line first crosses 80% — the Pareto cut. */
140
+ cutIndex: number;
141
+ }
142
+ /**
143
+ * Bars plus a cumulative line on ONE axis.
144
+ *
145
+ * The textbook Pareto puts counts on the left and cumulative percent on the
146
+ * right. Two y-scales on one plot invent a relationship the data doesn't have —
147
+ * the alignment between them is arbitrary. Here the bars are percent-of-total
148
+ * and the line is cumulative percent, so both live on the same 0–100 scale and
149
+ * the crossing point means something.
150
+ *
151
+ * Values are sorted descending, as a Pareto requires. `cutIndex` is the first
152
+ * item at or past `opts.threshold`, so the reported cut and the rule a component
153
+ * draws can't disagree.
154
+ */
155
+ export interface ParetoOptions {
156
+ /** Fraction of each band left empty as a gap, 0..1. Defaults to 0.25. */
157
+ gap?: number;
158
+ /**
159
+ * The cumulative percentage the cut is measured against. Defaults to 80 —
160
+ * the "80/20" in Pareto — but a caller drawing a different rule needs
161
+ * `cutIndex` to agree with the line they drew, so it lives here rather than
162
+ * being re-derived in each component.
163
+ */
164
+ threshold?: number;
165
+ }
166
+ export declare function paretoLayout(values: number[], box: ChartBox, opts?: ParetoOptions): ParetoLayout;
167
+ export interface GaugeOptions {
168
+ size: number;
169
+ thickness: number;
170
+ min?: number;
171
+ max?: number;
172
+ /** Total sweep in degrees. Defaults to 270 — a dial, not a full ring. */
173
+ sweep?: number;
174
+ }
175
+ export interface GaugeGeometry {
176
+ track: string;
177
+ fill: string;
178
+ /** 0..100, clamped. */
179
+ percent: number;
180
+ /** Where the fill ends, in degrees. */
181
+ angle: number;
182
+ }
183
+ /**
184
+ * A single ratio against a limit. Values outside [min, max] clamp rather than
185
+ * overflowing the arc — a gauge reading 140% of its own dial is a bug, not data.
186
+ */
187
+ export declare function gaugeArc(value: number, opts: GaugeOptions): GaugeGeometry;
188
+ export interface WordCloudTerm {
189
+ text: string;
190
+ weight: number;
191
+ }
192
+ export interface PlacedWord extends WordCloudTerm {
193
+ x: number;
194
+ y: number;
195
+ fontSize: number;
196
+ index: number;
197
+ }
198
+ export interface WordCloudOptions {
199
+ minFontSize?: number;
200
+ maxFontSize?: number;
201
+ /** Terms considered, highest weight first. Beyond this the tail is dropped. */
202
+ limit?: number;
203
+ }
204
+ /**
205
+ * Deterministic spiral packing for a word cloud.
206
+ *
207
+ * Caveat, stated where it can't be missed: glyph AREA is not a comparable
208
+ * encoding, and a long word reads as bigger than a short one at the same
209
+ * weight. A ranked bar chart shows the same data honestly. This exists because
210
+ * a gallery wants one; reach for `BarChart` when the numbers matter.
211
+ *
212
+ * No randomness anywhere — the same input must produce the same picture, or the
213
+ * server and the client disagree and visual regression never settles.
214
+ */
215
+ export declare function wordCloudLayout(terms: WordCloudTerm[], box: ChartBox, opts?: WordCloudOptions): PlacedWord[];
216
+ export interface StackedBand {
217
+ path: string;
218
+ index: number;
219
+ }
220
+ /**
221
+ * One y-domain across every series, computed once. Series scaled independently
222
+ * look comparable and aren't.
223
+ */
224
+ export declare function multiLinePoints(series: number[][], box: ChartBox): Point[][];
225
+ /**
226
+ * Part-to-whole over time: each series is a band stacked on the ones before it,
227
+ * scaled so the tallest total fills the box.
228
+ */
229
+ export declare function stackedSeries(series: number[][], box: ChartBox): StackedBand[];