@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.
- package/dist/BarChart.d.ts +22 -0
- package/dist/BarChart.js +21 -0
- package/dist/DonutChart.d.ts +26 -0
- package/dist/DonutChart.js +19 -0
- package/dist/FunnelChart.d.ts +26 -0
- package/dist/FunnelChart.js +24 -0
- package/dist/GaugeChart.d.ts +37 -0
- package/dist/GaugeChart.js +44 -0
- package/dist/GradientBackground.d.ts +21 -0
- package/dist/GradientBackground.js +24 -0
- package/dist/HeatmapChart.d.ts +46 -0
- package/dist/HeatmapChart.js +53 -0
- package/dist/ParetoChart.d.ts +34 -0
- package/dist/ParetoChart.js +44 -0
- package/dist/RadarChart.d.ts +35 -0
- package/dist/RadarChart.js +55 -0
- package/dist/ScatterPlot.d.ts +37 -0
- package/dist/ScatterPlot.js +22 -0
- package/dist/Sparkline.d.ts +30 -0
- package/dist/Sparkline.js +21 -0
- package/dist/Spotlight.d.ts +17 -0
- package/dist/Spotlight.js +39 -0
- package/dist/StackedLineChart.d.ts +35 -0
- package/dist/StackedLineChart.js +38 -0
- package/dist/Ticker.js +13 -24
- package/dist/TiltCard.d.ts +17 -0
- package/dist/TiltCard.js +45 -0
- package/dist/WordCloud.d.ts +38 -0
- package/dist/WordCloud.js +36 -0
- package/dist/chartGeometry.d.ts +229 -0
- package/dist/chartGeometry.js +450 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/usePrefersReducedMotion.d.ts +6 -0
- package/dist/usePrefersReducedMotion.js +22 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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 {};
|
package/dist/TiltCard.js
ADDED
|
@@ -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[];
|