mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Update dependencies and enhance chart animations
- Added `framer-motion` version 12.39.0 to `pnpm-lock.yaml` and `package.json` for improved animation capabilities. - Integrated motion properties into various chart components in `line-chart.tsx` and `metrics-page.tsx` to enhance user experience with smoother transitions. - Refactored sparkline component in `user-page-metric-card.tsx` to support motion effects, improving visual feedback during data updates. - Introduced utility functions for handling reduced motion preferences to ensure accessibility compliance.
This commit is contained in:
parent
3f03d947ce
commit
174f3a4c28
@ -86,6 +86,7 @@
|
||||
"jose": "^6.1.3",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"lodash": "^4.17.21",
|
||||
"motion": "^12.39.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"posthog-js": "^1.336.1",
|
||||
|
||||
@ -22,7 +22,7 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
import { UserAvatar } from '@stackframe/stack';
|
||||
import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts";
|
||||
|
||||
export type CustomDateRange = {
|
||||
@ -253,6 +253,7 @@ export function ActivityBarChart({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
@ -290,7 +291,7 @@ export function ActivityBarChart({
|
||||
dataKey="activity"
|
||||
fill="var(--color-activity)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
>
|
||||
{datapoints.map((entry, index) => {
|
||||
const isWeekendDay = isWeekend(parseChartDate(entry.date));
|
||||
@ -344,6 +345,7 @@ export function MiniActivityBarChart({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
@ -381,7 +383,7 @@ export function MiniActivityBarChart({
|
||||
dataKey="activity"
|
||||
fill="var(--color-activity)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
>
|
||||
{datapoints.map((entry, index) => {
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -450,6 +452,7 @@ export function MiniNamedBarChart({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
@ -487,7 +490,7 @@ export function MiniNamedBarChart({
|
||||
dataKey="activity"
|
||||
fill="var(--color-activity)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
>
|
||||
{datapoints.map((entry, index) => {
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -622,6 +625,7 @@ export function StackedBarChartDisplay({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map(p => p.new + p.retained + p.reactivated);
|
||||
@ -675,7 +679,7 @@ export function StackedBarChartDisplay({
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Bar dataKey="retained" stackId="split" fill="var(--color-retained)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="retained" stackId="split" fill="var(--color-retained)" radius={[0, 0, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -689,7 +693,7 @@ export function StackedBarChartDisplay({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="reactivated" stackId="split" fill="var(--color-reactivated)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="reactivated" stackId="split" fill="var(--color-reactivated)" radius={[0, 0, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -703,7 +707,7 @@ export function StackedBarChartDisplay({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="new" stackId="split" fill="var(--color-new)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="new" stackId="split" fill="var(--color-new)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -726,7 +730,7 @@ export function StackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -739,7 +743,7 @@ export function StackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -792,69 +796,50 @@ export type ComposedDataPoint = {
|
||||
_showRevenue?: boolean,
|
||||
};
|
||||
|
||||
const OVERVIEW_CHART_ANIMATION_MS = 260;
|
||||
const OVERVIEW_CHART_ANIMATION_MS = 520;
|
||||
|
||||
function interpolateNumber(from: number | undefined, to: number, progress: number): number {
|
||||
return (from ?? 0) + (to - (from ?? 0)) * progress;
|
||||
}
|
||||
type ChartMotionProps = {
|
||||
isAnimationActive: boolean,
|
||||
animationBegin: number,
|
||||
animationDuration: number,
|
||||
animationEasing: "ease-out",
|
||||
};
|
||||
|
||||
function easeOutCubic(progress: number): number {
|
||||
return 1 - Math.pow(1 - progress, 3);
|
||||
}
|
||||
const enabledChartMotion: ChartMotionProps = {
|
||||
isAnimationActive: true,
|
||||
animationBegin: 0,
|
||||
animationDuration: OVERVIEW_CHART_ANIMATION_MS,
|
||||
animationEasing: "ease-out",
|
||||
};
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
const disabledChartMotion: ChartMotionProps = {
|
||||
isAnimationActive: false,
|
||||
animationBegin: 0,
|
||||
animationDuration: 0,
|
||||
animationEasing: "ease-out",
|
||||
};
|
||||
|
||||
function useAnimatedComposedDatapoints(datapoints: ComposedDataPoint[]): ComposedDataPoint[] {
|
||||
const [animatedDatapoints, setAnimatedDatapoints] = useState(datapoints);
|
||||
const previousDatapointsRef = useRef(datapoints);
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion()) {
|
||||
previousDatapointsRef.current = datapoints;
|
||||
setAnimatedDatapoints(datapoints);
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousByDate = new Map(previousDatapointsRef.current.map((point) => [point.date, point]));
|
||||
const previousByIndex: Array<ComposedDataPoint | undefined> = previousDatapointsRef.current;
|
||||
const startedAt = performance.now();
|
||||
let frameId: number | null = null;
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
const renderFrame = (now: number) => {
|
||||
const linearProgress = Math.min(1, (now - startedAt) / OVERVIEW_CHART_ANIMATION_MS);
|
||||
const progress = easeOutCubic(linearProgress);
|
||||
setAnimatedDatapoints(datapoints.map((point, index) => {
|
||||
const previous = previousByDate.get(point.date) ?? previousByIndex[index];
|
||||
return {
|
||||
...point,
|
||||
page_views: interpolateNumber(previous?.page_views, point.page_views, progress),
|
||||
visitors: interpolateNumber(previous?.visitors, point.visitors, progress),
|
||||
dau: interpolateNumber(previous?.dau, point.dau, progress),
|
||||
new_cents: interpolateNumber(previous?.new_cents, point.new_cents, progress),
|
||||
refund_cents: interpolateNumber(previous?.refund_cents, point.refund_cents, progress),
|
||||
};
|
||||
}));
|
||||
updatePrefersReducedMotion();
|
||||
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
||||
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
||||
}, []);
|
||||
|
||||
if (linearProgress < 1) {
|
||||
frameId = requestAnimationFrame(renderFrame);
|
||||
return;
|
||||
}
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
||||
previousDatapointsRef.current = datapoints;
|
||||
setAnimatedDatapoints(datapoints);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(renderFrame);
|
||||
return () => {
|
||||
if (frameId != null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [datapoints]);
|
||||
|
||||
return animatedDatapoints;
|
||||
function useChartMotionProps(): ChartMotionProps {
|
||||
return usePrefersReducedMotion() ? disabledChartMotion : enabledChartMotion;
|
||||
}
|
||||
|
||||
export type VisitorsHoverDataPoint = {
|
||||
@ -900,12 +885,6 @@ const composedChartConfig: ChartConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const overviewChartAnimation = {
|
||||
isAnimationActive: true,
|
||||
animationDuration: OVERVIEW_CHART_ANIMATION_MS,
|
||||
animationEasing: "ease-out" as const,
|
||||
};
|
||||
|
||||
function ComposedTooltip({ active, payload }: TooltipProps<number, string>) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
@ -1016,13 +995,13 @@ export function ComposedAnalyticsChart({
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [hoveredX, setHoveredX] = useState<number | null>(null);
|
||||
const animatedDatapoints = useAnimatedComposedDatapoints(datapoints);
|
||||
const chartMotion = useChartMotionProps();
|
||||
const taggedDatapoints = useMemo(
|
||||
() => animatedDatapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })),
|
||||
[animatedDatapoints, showPageViews, showVisitors, showRevenue],
|
||||
() => datapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })),
|
||||
[datapoints, showPageViews, showVisitors, showRevenue],
|
||||
);
|
||||
const maxVisitors = Math.max(...animatedDatapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1);
|
||||
const maxRevenueCents = Math.max(...animatedDatapoints.map(d => showRevenue ? d.new_cents : 0), 1);
|
||||
const maxVisitors = Math.max(...datapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1);
|
||||
const maxRevenueCents = Math.max(...datapoints.map(d => showRevenue ? d.new_cents : 0), 1);
|
||||
const visitorTicks = niceAxisTicks(Math.ceil(maxVisitors * 1.1), 5);
|
||||
const revenueTicks = niceAxisTicks(Math.ceil(maxRevenueCents * 1.15), 5);
|
||||
const visitorsMax = visitorTicks[visitorTicks.length - 1] ?? maxVisitors;
|
||||
@ -1039,7 +1018,7 @@ export function ComposedAnalyticsChart({
|
||||
data={taggedDatapoints}
|
||||
margin={{ top: 10, right: 4, left: 4, bottom: 0 }}
|
||||
onMouseMove={(state) => {
|
||||
updateHoveredIndexFromChartState(state, animatedDatapoints.length, setHoveredIndex);
|
||||
updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex);
|
||||
setHoveredX(getActiveCoordinateX(state));
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
@ -1090,7 +1069,7 @@ export function ComposedAnalyticsChart({
|
||||
fill="var(--color-page_views)"
|
||||
fillOpacity={showPageViews ? (hoveredIndex == null ? 0.18 : 0.08) : 0}
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{showPageViews && hoveredIndex != null && hoveredX != null && (
|
||||
<Bar
|
||||
@ -1115,7 +1094,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeOpacity={showVisitors ? (hoveredIndex == null ? 1 : 0.22) : 0}
|
||||
dot={false}
|
||||
activeDot={showVisitors ? <HighlightedLineDot fill="var(--color-visitors)" /> : false}
|
||||
{...overviewChartAnimation}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{showVisitors && hoveredIndex != null && hoveredX != null && (
|
||||
<Line
|
||||
@ -1127,7 +1106,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeOpacity={1}
|
||||
dot={false}
|
||||
activeDot={<HighlightedLineDot fill="var(--color-visitors)" />}
|
||||
{...overviewChartAnimation}
|
||||
isAnimationActive={false}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ clipPath: `url(#visitors-highlight-clip-${id})` }}
|
||||
@ -1143,7 +1122,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeOpacity={hoveredIndex == null ? 0.95 : 0.24}
|
||||
dot={false}
|
||||
activeDot={<HighlightedLineDot fill="var(--color-dau)" />}
|
||||
{...overviewChartAnimation}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{hoveredIndex != null && hoveredX != null && (
|
||||
<Line
|
||||
@ -1155,7 +1134,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeOpacity={1}
|
||||
dot={false}
|
||||
activeDot={<HighlightedLineDot fill="var(--color-dau)" />}
|
||||
{...overviewChartAnimation}
|
||||
isAnimationActive={false}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ clipPath: `url(#dau-highlight-clip-${id})` }}
|
||||
@ -1172,7 +1151,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={showRevenue ? <HighlightedLineDot fill="var(--color-revenue)" /> : false}
|
||||
{...overviewChartAnimation}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{showRevenue && hoveredIndex != null && hoveredX != null && (
|
||||
<Line
|
||||
@ -1185,7 +1164,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={<HighlightedLineDot fill="var(--color-revenue)" />}
|
||||
{...overviewChartAnimation}
|
||||
isAnimationActive={false}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ clipPath: `url(#revenue-highlight-clip-${id})` }}
|
||||
@ -1213,7 +1192,7 @@ export function ComposedAnalyticsChart({
|
||||
tickMargin={compact ? 4 : 6}
|
||||
axisLine={false}
|
||||
padding={{ left: 8, right: 8 }}
|
||||
interval={animatedDatapoints.length <= 7 ? 0 : "equidistantPreserveStart"}
|
||||
interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }}
|
||||
tickFormatter={(value) => formatChartXAxisTick(value)}
|
||||
/>
|
||||
@ -2099,6 +2078,7 @@ export function CorrelationCard({
|
||||
const chartConfig: ChartConfig = Object.fromEntries(
|
||||
series.map(s => [s.key, { label: s.label, color: s.color }])
|
||||
);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
return (
|
||||
<ChartCard gradientColor={gradientColor} className={cn("h-full", className)}>
|
||||
@ -2164,7 +2144,7 @@ export function CorrelationCard({
|
||||
stroke={s.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
@ -2398,6 +2378,7 @@ export function EmailStackedBarChartDisplay({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map(p => p.ok + p.error + p.in_progress);
|
||||
@ -2460,7 +2441,7 @@ export function EmailStackedBarChartDisplay({
|
||||
};
|
||||
const colorVar = dataKey === "ok" ? "ok" : dataKey === "in_progress" ? "in_progress" : "error";
|
||||
return (
|
||||
<Bar key={dataKey} dataKey={dataKey} stackId="split" fill={`var(--color-${colorVar})`} radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar key={dataKey} dataKey={dataKey} stackId="split" fill={`var(--color-${colorVar})`} radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2488,7 +2469,7 @@ export function EmailStackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2501,7 +2482,7 @@ export function EmailStackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2624,6 +2605,7 @@ export function VisitorsHoverChart({
|
||||
compact?: boolean,
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map((p) => p.page_views);
|
||||
const avgValues = rollingAvg(totals, windowSize);
|
||||
@ -2669,7 +2651,7 @@ export function VisitorsHoverChart({
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Bar dataKey="page_views" stackId="visitors" fill="var(--color-page_views)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="page_views" stackId="visitors" fill="var(--color-page_views)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2692,7 +2674,7 @@ export function VisitorsHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2705,7 +2687,7 @@ export function VisitorsHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2834,6 +2816,7 @@ export function RevenueHoverChart({
|
||||
compact?: boolean,
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map((p) => p.new_cents + p.refund_cents);
|
||||
const avgValues = rollingAvg(totals, windowSize);
|
||||
@ -2884,7 +2867,7 @@ export function RevenueHoverChart({
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Bar dataKey="new_cents_square" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="new_cents_square" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2898,7 +2881,7 @@ export function RevenueHoverChart({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="new_cents_rounded" stackId="revenue" fill="var(--color-new_cents)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="new_cents_rounded" stackId="revenue" fill="var(--color-new_cents)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2912,7 +2895,7 @@ export function RevenueHoverChart({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="refund_cents" stackId="revenue" fill="var(--color-refund_cents)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="refund_cents" stackId="revenue" fill="var(--color-refund_cents)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2935,7 +2918,7 @@ export function RevenueHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2948,7 +2931,7 @@ export function RevenueHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
|
||||
@ -42,6 +42,7 @@ import useResizeObserver from '@react-hook/resize-observer';
|
||||
import { useUser } from "@stackframe/stack";
|
||||
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
import { LayoutGroup, motion, useReducedMotion, type Transition } from "motion/react";
|
||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||
import { type ElementType, type ReactNode, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnalyticsEventLimitBanner } from "../analytics/shared";
|
||||
@ -114,11 +115,98 @@ function formatPagesPerVisitor(value: number): string {
|
||||
}
|
||||
|
||||
const OVERVIEW_WIDGET_ANIMATION_MS = 260;
|
||||
const OVERVIEW_HEADER_COMPACT_SCROLL_TOP = 24;
|
||||
const OVERVIEW_HEADER_MORPH_MS = 520;
|
||||
const OVERVIEW_HEADER_TITLE_EXIT_MS = 150;
|
||||
const overviewHeaderLayoutTransition: Transition = {
|
||||
duration: OVERVIEW_HEADER_MORPH_MS / 1000,
|
||||
ease: [0.32, 0.72, 0, 1],
|
||||
};
|
||||
const reducedOverviewHeaderLayoutTransition: Transition = {
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
const scrollableOverflowValues = new Set(["auto", "scroll", "overlay"]);
|
||||
|
||||
function easeOutCubic(progress: number): number {
|
||||
return 1 - Math.pow(1 - progress, 3);
|
||||
}
|
||||
|
||||
function findScrollContainer(element: HTMLElement): HTMLElement | null {
|
||||
let current = element.parentElement;
|
||||
while (current != null) {
|
||||
const overflowY = window.getComputedStyle(current).overflowY;
|
||||
if (scrollableOverflowValues.has(overflowY) && current.scrollHeight > current.clientHeight) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useOverviewHeaderCompacted() {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [compacted, setCompacted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (sentinel == null) return;
|
||||
|
||||
const scrollContainer = findScrollContainer(sentinel);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
const nextCompacted = !entry.isIntersecting;
|
||||
setCompacted((current) => current === nextCompacted ? current : nextCompacted);
|
||||
}, {
|
||||
root: scrollContainer,
|
||||
rootMargin: `-${OVERVIEW_HEADER_COMPACT_SCROLL_TOP}px 0px 0px 0px`,
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
observer.observe(sentinel);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { compacted, sentinelRef };
|
||||
}
|
||||
|
||||
function useRenderWhileClosing(open: boolean, durationMs: number): boolean {
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => setShouldRender(false), durationMs);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [durationMs, open]);
|
||||
|
||||
return open || shouldRender;
|
||||
}
|
||||
|
||||
function useDelayedTrue(value: boolean, delayMs: number): boolean {
|
||||
const [delayedValue, setDelayedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setDelayedValue(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => setDelayedValue(true), delayMs);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [delayMs, value]);
|
||||
|
||||
return delayedValue;
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@ -554,6 +642,86 @@ function ViewToggle({ view, onChange }: { view: "overview" | "globe", onChange:
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewStickyHeader({ title, actions }: { title: string, actions: ReactNode }) {
|
||||
const { compacted, sentinelRef } = useOverviewHeaderCompacted();
|
||||
const renderTitle = useRenderWhileClosing(!compacted, OVERVIEW_HEADER_TITLE_EXIT_MS);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const delayedCompacted = useDelayedTrue(compacted, shouldReduceMotion ? 0 : OVERVIEW_HEADER_TITLE_EXIT_MS);
|
||||
const layoutCompacted = shouldReduceMotion ? compacted : delayedCompacted;
|
||||
const layoutTransition = shouldReduceMotion ? reducedOverviewHeaderLayoutTransition : overviewHeaderLayoutTransition;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={sentinelRef} aria-hidden className="-mb-[17px] h-px w-px" />
|
||||
<div
|
||||
className="sticky top-[4.25rem] z-30 mb-2 w-full pointer-events-none dark:top-[5.75rem]"
|
||||
>
|
||||
<LayoutGroup id="overview-sticky-header">
|
||||
<motion.div
|
||||
layout
|
||||
transition={layoutTransition}
|
||||
className={cn(
|
||||
"pointer-events-auto relative w-full max-w-full",
|
||||
layoutCompacted && "ml-auto w-fit",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
layout
|
||||
transition={layoutTransition}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-0 rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl will-change-transform transition-[background-color,border-color,box-shadow,opacity] duration-[520ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none dark:border-0 dark:bg-transparent dark:shadow-none dark:backdrop-blur-none",
|
||||
layoutCompacted && "rounded-xl border-black/[0.08] bg-white/[0.78] shadow-[0_14px_34px_rgba(15,23,42,0.14)] ring-1 ring-white/[0.55] dark:border-white/[0.08] dark:bg-background/[0.72] dark:shadow-[0_14px_34px_rgba(0,0,0,0.26)] dark:ring-white/[0.08] dark:backdrop-blur-xl",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-5 top-0 z-10 h-px bg-gradient-to-r from-transparent via-white/70 to-transparent opacity-0 transition-opacity duration-[520ms] motion-reduce:transition-none dark:via-white/20",
|
||||
layoutCompacted && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-4 dark:px-0 dark:py-0 dark:sm:px-0 dark:sm:py-0",
|
||||
layoutCompacted && "gap-0 sm:gap-0",
|
||||
layoutCompacted && "px-3 py-2 sm:px-4 sm:py-2.5 dark:px-4 dark:py-2.5 dark:sm:px-4 dark:sm:py-2.5",
|
||||
)}
|
||||
>
|
||||
{renderTitle && (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 transition-[opacity,transform,filter] duration-[150ms] ease-out motion-reduce:transition-none sm:flex-1",
|
||||
compacted && "pointer-events-none opacity-0 blur-[1px]",
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
type="h2"
|
||||
className="truncate text-xl font-semibold tracking-tight sm:text-2xl"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
layout
|
||||
transition={layoutTransition}
|
||||
className={cn(
|
||||
"relative z-10 min-w-0 max-w-full flex-shrink-0 overflow-x-auto will-change-transform [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
"transition-opacity duration-[520ms] motion-reduce:transition-none",
|
||||
layoutCompacted && "opacity-95",
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) {
|
||||
return (
|
||||
<div className="relative h-[calc(100vh-12rem)] min-h-[480px] w-full overflow-hidden rounded-2xl bg-white/90 shadow-sm ring-1 ring-black/[0.06] backdrop-blur-xl dark:rounded-none dark:bg-transparent dark:shadow-none dark:ring-0 dark:backdrop-blur-none">
|
||||
@ -703,13 +871,6 @@ function AnalyticsChartWidget({
|
||||
}) {
|
||||
const [selectedMode, setSelectedMode] = useState<AnalyticsChartMode>('default');
|
||||
const [previewMode, setPreviewMode] = useState<AnalyticsChartMode | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<AnalyticsChartMode>('default');
|
||||
const [fadingOut, setFadingOut] = useState(false);
|
||||
const [fadingIn, setFadingIn] = useState(false);
|
||||
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fadeInRaf1Ref = useRef<number | null>(null);
|
||||
const fadeInRaf2Ref = useRef<number | null>(null);
|
||||
const FADE_OUT_MS = 140;
|
||||
|
||||
const tablistInstanceId = useId();
|
||||
const tabpanelId = `${tablistInstanceId}-panel`;
|
||||
@ -718,46 +879,7 @@ function AnalyticsChartWidget({
|
||||
const revenueTabId = `${tablistInstanceId}-tab-revenue`;
|
||||
|
||||
const activeMode: AnalyticsChartMode = previewMode ?? selectedMode;
|
||||
|
||||
const switchToMode = (mode: AnalyticsChartMode) => {
|
||||
if (mode === displayMode) return;
|
||||
if (fadeTimerRef.current != null) {
|
||||
clearTimeout(fadeTimerRef.current);
|
||||
}
|
||||
setFadingOut(true);
|
||||
fadeTimerRef.current = setTimeout(() => {
|
||||
setDisplayMode(mode);
|
||||
setFadingOut(false);
|
||||
setFadingIn(true);
|
||||
fadeInRaf1Ref.current = requestAnimationFrame(() => {
|
||||
fadeInRaf2Ref.current = requestAnimationFrame(() => {
|
||||
setFadingIn(false);
|
||||
fadeInRaf2Ref.current = null;
|
||||
});
|
||||
fadeInRaf1Ref.current = null;
|
||||
});
|
||||
fadeTimerRef.current = null;
|
||||
}, FADE_OUT_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
switchToMode(activeMode);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- switchToMode closes over displayMode/fade state
|
||||
}, [activeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fadeTimerRef.current != null) {
|
||||
clearTimeout(fadeTimerRef.current);
|
||||
}
|
||||
if (fadeInRaf1Ref.current != null) {
|
||||
cancelAnimationFrame(fadeInRaf1Ref.current);
|
||||
}
|
||||
if (fadeInRaf2Ref.current != null) {
|
||||
cancelAnimationFrame(fadeInRaf2Ref.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const displayMode: AnalyticsChartMode = activeMode;
|
||||
|
||||
const handleHoverPreview = (mode: AnalyticsChartMetricMode) => {
|
||||
setPreviewMode(mode);
|
||||
@ -889,16 +1011,7 @@ function AnalyticsChartWidget({
|
||||
className="flex-1 min-h-0 relative"
|
||||
style={{ minHeight: chartViewportHeight }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col",
|
||||
fadingOut
|
||||
? "opacity-0 -translate-y-0.5 transition-[opacity,transform] duration-[140ms] ease-in"
|
||||
: fadingIn
|
||||
? "opacity-0 translate-y-0.5"
|
||||
: "opacity-100 translate-y-0 transition-[opacity,transform] duration-[260ms] ease-out",
|
||||
)}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{displayMode === 'default' && (
|
||||
composedData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@ -1284,31 +1397,31 @@ export default function MetricsPage(props: { toSetup: () => void }) {
|
||||
const markAnalyticsFiltersLoaded = useCallback(() => {
|
||||
setLoadedAnalyticsFilters(analyticsFilters);
|
||||
}, [analyticsFilters]);
|
||||
const headerTitle = `Welcome back, ${truncatedName}!`;
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{view === "overview" && (
|
||||
<>
|
||||
<FilterChipsBar filters={analyticsFilters} onClear={clearAnalyticsFilter} onClearAll={clearAllAnalyticsFilters} />
|
||||
<FilterMenu filters={analyticsFilters} onToggle={toggleAnalyticsFilter} />
|
||||
<TimeRangeToggle
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
customDateRange={customDateRange}
|
||||
onCustomDateRangeChange={setCustomDateRange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ViewToggle view={view} onChange={setView} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={`Welcome back, ${truncatedName}!`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{view === "overview" && (
|
||||
<>
|
||||
<FilterChipsBar filters={analyticsFilters} onClear={clearAnalyticsFilter} onClearAll={clearAllAnalyticsFilters} />
|
||||
<FilterMenu filters={analyticsFilters} onToggle={toggleAnalyticsFilter} />
|
||||
<TimeRangeToggle
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
customDateRange={customDateRange}
|
||||
onCustomDateRangeChange={setCustomDateRange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ViewToggle view={view} onChange={setView} />
|
||||
</div>
|
||||
}
|
||||
fillWidth
|
||||
fullBleed
|
||||
wrapHeaderInCard
|
||||
>
|
||||
<OverviewStickyHeader title={headerTitle} actions={headerActions} />
|
||||
{view === "overview" && <AnalyticsEventLimitBanner />}
|
||||
{view === "overview" && isUpdatingAnalyticsFilters && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { DesignAnalyticsCard, type AnalyticsCardGradient } from "@/components/design-components";
|
||||
import { SimpleTooltip } from "@/components/ui";
|
||||
|
||||
@ -50,27 +50,162 @@ const GRADIENT_STROKE: Record<AnalyticsCardGradient, string> = {
|
||||
slate: "rgb(100 116 139)",
|
||||
};
|
||||
|
||||
function Sparkline({ values, color }: { values: number[], color: string }) {
|
||||
const gradId = `metric-spark-${useId()}`;
|
||||
if (values.length < 2) return null;
|
||||
const w = 100;
|
||||
const h = 32;
|
||||
const SPARKLINE_WIDTH = 100;
|
||||
const SPARKLINE_HEIGHT = 32;
|
||||
const SPARKLINE_PLOT_HEIGHT = SPARKLINE_HEIGHT - 2;
|
||||
const SPARKLINE_BASELINE = SPARKLINE_HEIGHT - 1;
|
||||
const SPARKLINE_ANIMATION_MS = 520;
|
||||
const sparklineRestState = {
|
||||
transform: "translate(0px, 0px) scale(1, 1)",
|
||||
opacity: 1,
|
||||
transitionEnabled: true,
|
||||
};
|
||||
|
||||
type SparklineGeometry = {
|
||||
valuesKey: string,
|
||||
linePath: string,
|
||||
areaPath: string,
|
||||
min: number,
|
||||
range: number,
|
||||
pointCount: number,
|
||||
};
|
||||
|
||||
type SparklineMotionState = {
|
||||
transform: string,
|
||||
opacity: number,
|
||||
transitionEnabled: boolean,
|
||||
};
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
updatePrefersReducedMotion();
|
||||
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
||||
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function parseSparklineValues(valuesKey: string): number[] {
|
||||
if (valuesKey.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return valuesKey.split(",").map((value) => Number(value));
|
||||
}
|
||||
|
||||
function getSparklineGeometry(valuesKey: string): SparklineGeometry | null {
|
||||
const values = parseSparklineValues(valuesKey);
|
||||
if (values.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const flat = max === min;
|
||||
const range = flat ? 1 : max - min;
|
||||
const step = w / (values.length - 1);
|
||||
const step = SPARKLINE_WIDTH / (values.length - 1);
|
||||
const coords = values.map((v, i) => {
|
||||
const x = i * step;
|
||||
// Reserve 1px top/bottom so the stroke isn't clipped.
|
||||
const y = flat ? h / 2 : h - 1 - ((v - min) / range) * (h - 2);
|
||||
const y = flat ? SPARKLINE_HEIGHT / 2 : SPARKLINE_BASELINE - ((v - min) / range) * SPARKLINE_PLOT_HEIGHT;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
const linePath = `M${coords.join(" L")}`;
|
||||
const areaPath = `${linePath} L${w},${h} L0,${h} Z`;
|
||||
const areaPath = `${linePath} L${SPARKLINE_WIDTH},${SPARKLINE_HEIGHT} L0,${SPARKLINE_HEIGHT} Z`;
|
||||
|
||||
return {
|
||||
valuesKey,
|
||||
linePath,
|
||||
areaPath,
|
||||
min,
|
||||
range,
|
||||
pointCount: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialSparklineMotion(previous: SparklineGeometry, current: SparklineGeometry): SparklineMotionState {
|
||||
if (previous.pointCount !== current.pointCount) {
|
||||
return {
|
||||
transform: "translate(0px, 3px) scale(0.98, 0.94)",
|
||||
opacity: 0.72,
|
||||
transitionEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const scaleY = clampNumber(current.range / previous.range, 0.35, 2.4);
|
||||
const rawTranslateY = SPARKLINE_BASELINE
|
||||
- scaleY * SPARKLINE_BASELINE
|
||||
- ((current.min - previous.min) / previous.range) * SPARKLINE_PLOT_HEIGHT;
|
||||
const translateY = clampNumber(rawTranslateY, -SPARKLINE_HEIGHT, SPARKLINE_HEIGHT);
|
||||
|
||||
return {
|
||||
transform: `translate(0px, ${translateY.toFixed(2)}px) scale(1, ${scaleY.toFixed(4)})`,
|
||||
opacity: 0.88,
|
||||
transitionEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function useSparklineMotion(geometry: SparklineGeometry | null): SparklineMotionState {
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const previousGeometryRef = useRef<SparklineGeometry | null>(null);
|
||||
const [motionState, setMotionState] = useState<SparklineMotionState>(sparklineRestState);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (geometry == null) {
|
||||
previousGeometryRef.current = null;
|
||||
setMotionState(sparklineRestState);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousGeometry = previousGeometryRef.current;
|
||||
previousGeometryRef.current = geometry;
|
||||
|
||||
if (previousGeometry == null || previousGeometry.valuesKey === geometry.valuesKey || prefersReducedMotion) {
|
||||
setMotionState(sparklineRestState);
|
||||
return;
|
||||
}
|
||||
|
||||
setMotionState(getInitialSparklineMotion(previousGeometry, geometry));
|
||||
const frameId = requestAnimationFrame(() => setMotionState(sparklineRestState));
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [geometry, prefersReducedMotion]);
|
||||
|
||||
return motionState;
|
||||
}
|
||||
|
||||
function Sparkline({ values, color }: { values: number[], color: string }) {
|
||||
const gradId = `metric-spark-${useId()}`;
|
||||
const valuesKey = values.join(",");
|
||||
const geometry = useMemo(() => getSparklineGeometry(valuesKey), [valuesKey]);
|
||||
const motionState = useSparklineMotion(geometry);
|
||||
const motionStyle: CSSProperties = {
|
||||
transform: motionState.transform,
|
||||
transformBox: "view-box",
|
||||
transformOrigin: "left top",
|
||||
transition: motionState.transitionEnabled
|
||||
? `transform ${SPARKLINE_ANIMATION_MS}ms ease-out, opacity 180ms ease-out`
|
||||
: "none",
|
||||
opacity: motionState.opacity,
|
||||
};
|
||||
|
||||
if (geometry == null) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
viewBox={`0 0 ${SPARKLINE_WIDTH} ${SPARKLINE_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-8 w-full"
|
||||
aria-hidden
|
||||
@ -81,16 +216,18 @@ function Sparkline({ values, color }: { values: number[], color: string }) {
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill={`url(#${gradId})`} />
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<g style={motionStyle}>
|
||||
<path d={geometry.areaPath} fill={`url(#${gradId})`} />
|
||||
<path
|
||||
d={geometry.linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@ -559,6 +559,9 @@ importers:
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
motion:
|
||||
specifier: ^12.39.0
|
||||
version: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next:
|
||||
specifier: 16.1.7
|
||||
version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@ -13787,6 +13790,20 @@ packages:
|
||||
frame-ticker@1.0.3:
|
||||
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
|
||||
|
||||
framer-motion@12.39.0:
|
||||
resolution: {integrity: sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
freestyle-sandboxes@0.1.6:
|
||||
resolution: {integrity: sha512-zfyJy+DgmheFjCAPYMklo7rpzvuxNP46rB0a9WfNBEmitYGE23nlbjyTy8qdrmVuCVCoMIDQQzzJRkyuh0Szqg==}
|
||||
deprecated: This package has been deprecated. Please use freestyle instead.
|
||||
@ -15880,6 +15897,26 @@ packages:
|
||||
monaco-editor@0.52.2:
|
||||
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
|
||||
|
||||
motion-dom@12.39.0:
|
||||
resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==}
|
||||
|
||||
motion-utils@12.39.0:
|
||||
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
|
||||
|
||||
motion@12.39.0:
|
||||
resolution: {integrity: sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@ -35361,6 +35398,16 @@ snapshots:
|
||||
dependencies:
|
||||
simplesignal: 2.1.7
|
||||
|
||||
framer-motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
motion-dom: 12.39.0
|
||||
motion-utils: 12.39.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@emotion/is-prop-valid': 1.3.1
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
freestyle-sandboxes@0.1.6: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
@ -38089,6 +38136,21 @@ snapshots:
|
||||
|
||||
monaco-editor@0.52.2: {}
|
||||
|
||||
motion-dom@12.39.0:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
|
||||
motion-utils@12.39.0: {}
|
||||
|
||||
motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
framer-motion: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@emotion/is-prop-valid': 1.3.1
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user