@paul-portfolio/react 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.d.ts +27 -0
- package/dist/Ticker.js +96 -0
- 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 +17 -0
- package/dist/index.js +17 -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.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
type TickerProps = {
|
|
3
|
+
/** Accessible name for the strip. Scroll mode renders a labelled region. */
|
|
4
|
+
label: string;
|
|
5
|
+
/**
|
|
6
|
+
* `scroll` (default) is an accessible, real scroll container with an ambient
|
|
7
|
+
* auto-scroll — every item stays reachable. `marquee` is a decorative,
|
|
8
|
+
* aria-hidden CSS loop for pure flavour.
|
|
9
|
+
*/
|
|
10
|
+
mode?: 'scroll' | 'marquee';
|
|
11
|
+
/** Which edge the strip sits on; picks the border side. */
|
|
12
|
+
edge?: 'top' | 'bottom';
|
|
13
|
+
/** Which way the ambient motion travels. */
|
|
14
|
+
direction?: 'left' | 'right';
|
|
15
|
+
/** Ambient auto-scroll speed for scroll mode, in px/sec. */
|
|
16
|
+
speed?: number;
|
|
17
|
+
className?: string;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A horizontal ticker strip with two modes: an accessible, auto-scrolling
|
|
22
|
+
* container (`scroll`) and a decorative CSS marquee (`marquee`). Both loop
|
|
23
|
+
* seamlessly and both honour prefers-reduced-motion. Content is passed as
|
|
24
|
+
* children, so the strip stays content-agnostic.
|
|
25
|
+
*/
|
|
26
|
+
export declare function Ticker(props: TickerProps): import("react").JSX.Element;
|
|
27
|
+
export {};
|
package/dist/Ticker.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { cx } from './cx';
|
|
4
|
+
import { usePrefersReducedMotion } from './usePrefersReducedMotion';
|
|
5
|
+
/** How long a touch keeps the strip frozen before the ambient scroll resumes. */
|
|
6
|
+
const TOUCH_RESUME_MS = 4000;
|
|
7
|
+
/**
|
|
8
|
+
* A horizontal ticker strip with two modes: an accessible, auto-scrolling
|
|
9
|
+
* container (`scroll`) and a decorative CSS marquee (`marquee`). Both loop
|
|
10
|
+
* seamlessly and both honour prefers-reduced-motion. Content is passed as
|
|
11
|
+
* children, so the strip stays content-agnostic.
|
|
12
|
+
*/
|
|
13
|
+
export function Ticker(props) {
|
|
14
|
+
return props.mode === 'marquee' ? (_jsx(MarqueeTicker, { ...props })) : (_jsx(ScrollTicker, { ...props }));
|
|
15
|
+
}
|
|
16
|
+
function edgeClassFor(edge) {
|
|
17
|
+
return edge === 'top' ? 'ticker--top' : 'ticker--bottom';
|
|
18
|
+
}
|
|
19
|
+
/** Decorative marquee: aria-hidden, CSS-driven, content duplicated for the loop. */
|
|
20
|
+
function MarqueeTicker({ edge = 'top', direction = 'left', className, children, }) {
|
|
21
|
+
return (_jsx("div", { "aria-hidden": "true", "data-direction": direction, className: cx('ticker', 'ticker--marquee', edgeClassFor(edge), className), children: _jsxs("div", { className: "ticker__track", children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { className: "ticker__group", children: children })] }) }));
|
|
22
|
+
}
|
|
23
|
+
/** Accessible scroll container with an ambient JS auto-scroll loop. */
|
|
24
|
+
function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, className, children, }) {
|
|
25
|
+
const reduced = usePrefersReducedMotion();
|
|
26
|
+
const scrollerRef = useRef(null);
|
|
27
|
+
const cloneRef = useRef(null);
|
|
28
|
+
const [paused, setPaused] = useState(false);
|
|
29
|
+
// Mirror `paused` into a ref so the animation-frame loop reads the latest
|
|
30
|
+
// value without the scroll effect re-subscribing on every toggle.
|
|
31
|
+
const pausedRef = useRef(false);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
pausedRef.current = paused;
|
|
34
|
+
}, [paused]);
|
|
35
|
+
// Fallback for browsers without `inert`.
|
|
36
|
+
//
|
|
37
|
+
// The clone carries `inert` in the markup, which drops its descendants from
|
|
38
|
+
// the tab order and the accessibility tree together, before first paint. This
|
|
39
|
+
// effect used to be the only mechanism, and it left a gap: an effect runs
|
|
40
|
+
// after the DOM exists, so between render and this call the duplicate held
|
|
41
|
+
// tabbable controls inside an aria-hidden container. Hiding something from
|
|
42
|
+
// assistive tech while leaving it reachable by keyboard is worse than not
|
|
43
|
+
// hiding it — the user lands on a control a screen reader insists is absent.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if ('inert' in HTMLElement.prototype)
|
|
46
|
+
return;
|
|
47
|
+
const focusables = cloneRef.current?.querySelectorAll('a[href], button, input, select, textarea, [tabindex]');
|
|
48
|
+
focusables?.forEach((el) => {
|
|
49
|
+
el.tabIndex = -1;
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
// Ambient scroll: advance scrollLeft each frame and wrap by one copy width
|
|
53
|
+
// for a seamless loop. Pauses on hover/touch; user scrolling is left alone.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (reduced)
|
|
56
|
+
return;
|
|
57
|
+
const el = scrollerRef.current;
|
|
58
|
+
if (!el)
|
|
59
|
+
return;
|
|
60
|
+
const dir = direction === 'left' ? 1 : -1;
|
|
61
|
+
// Start the rightward strip one copy in, so it has somewhere to scroll back.
|
|
62
|
+
if (dir < 0)
|
|
63
|
+
el.scrollLeft = el.scrollWidth / 2;
|
|
64
|
+
let raf = 0;
|
|
65
|
+
let last = performance.now();
|
|
66
|
+
const step = (now) => {
|
|
67
|
+
const dt = now - last;
|
|
68
|
+
last = now;
|
|
69
|
+
const half = el.scrollWidth / 2;
|
|
70
|
+
if (!pausedRef.current && half > 0) {
|
|
71
|
+
let next = el.scrollLeft + (dir * speed * dt) / 1000;
|
|
72
|
+
if (next >= half)
|
|
73
|
+
next -= half;
|
|
74
|
+
else if (next < 0)
|
|
75
|
+
next += half;
|
|
76
|
+
el.scrollLeft = next;
|
|
77
|
+
}
|
|
78
|
+
raf = requestAnimationFrame(step);
|
|
79
|
+
};
|
|
80
|
+
raf = requestAnimationFrame(step);
|
|
81
|
+
return () => cancelAnimationFrame(raf);
|
|
82
|
+
}, [reduced, direction, speed]);
|
|
83
|
+
const resumeTimer = useRef(null);
|
|
84
|
+
const freezeForTouch = () => {
|
|
85
|
+
setPaused(true);
|
|
86
|
+
if (resumeTimer.current)
|
|
87
|
+
clearTimeout(resumeTimer.current);
|
|
88
|
+
resumeTimer.current = setTimeout(() => setPaused(false), TOUCH_RESUME_MS);
|
|
89
|
+
};
|
|
90
|
+
const classes = cx('ticker', edgeClassFor(edge), className);
|
|
91
|
+
// Reduced motion: a plain, single-copy scrollable row. No clone, no loop.
|
|
92
|
+
if (reduced) {
|
|
93
|
+
return (_jsx("section", { "aria-label": label, className: classes, children: _jsx("div", { className: "ticker__group", children: children }) }));
|
|
94
|
+
}
|
|
95
|
+
return (_jsx("section", { ref: scrollerRef, "aria-label": label, "data-direction": direction, className: classes, onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false), onTouchStart: freezeForTouch, children: _jsxs("div", { className: "ticker__track", "data-paused": paused || undefined, children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { ref: cloneRef, "aria-hidden": "true", inert: true, className: "ticker__group", children: children })] }) }));
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type HTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
type TiltCardProps = HTMLAttributes<HTMLDivElement> & {
|
|
3
|
+
/** Maximum rotation, in degrees, at the edges of the card. */
|
|
4
|
+
maxTilt?: number;
|
|
5
|
+
/** Show the cursor-tracking glare highlight. Defaults to true. */
|
|
6
|
+
glare?: boolean;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A surface that tilts in 3D toward the pointer, with an optional glare that
|
|
11
|
+
* tracks the cursor. The effect is purely decorative — the content layer stays
|
|
12
|
+
* readable — and it is pointer-driven only, so keyboard users are never left
|
|
13
|
+
* without access. Under prefers-reduced-motion it renders as a flat card with
|
|
14
|
+
* no tilt or glare. Content-agnostic: pass any children (a Card, an image…).
|
|
15
|
+
*/
|
|
16
|
+
export declare function TiltCard({ maxTilt, glare, className, children, ...rest }: TiltCardProps): import("react").JSX.Element;
|
|
17
|
+
export {};
|
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
|
+
}
|