mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
feat: replace hand-rolled SparkAreaChart with evilcharts area chart on stats page
- Add `recharts` and `motion` deps; register `@evilcharts` registry in `components.json` - Vendor evilcharts primitives (`AreaChart`, `Chart`, `Dot`, `EvilBrush`, `Legend`, `Tooltip`, `Background`) under `components/evilcharts/` - Add `ActivityChart` wrapper in `components/stats/` that uses the evilcharts `AreaChart` for the daily-run sparkline on `/stats`; replaces the hand-rolled inline-SVG `SparkAreaChart` (deleted) - Update `stats.tsx` to render `ActivityChart` instead of the removed component - Tighten `.gitignore`: add `.vercel`, collapse `.env.local` / `.env.*.local` to `.env*` with a `!.env.example` carve-out
This commit is contained in:
+4
-4
@@ -6,6 +6,7 @@ dist/
|
||||
*.tsbuildinfo
|
||||
.tanstack/
|
||||
.source/
|
||||
.vercel
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
@@ -22,10 +23,9 @@ pnpm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
# Secrets
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Test artifacts
|
||||
coverage/
|
||||
|
||||
@@ -21,5 +21,7 @@
|
||||
},
|
||||
"menuColor": "default-translucent",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@evilcharts": "https://evilcharts.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,14 @@
|
||||
"fumadocs-mdx": "^15.0.9",
|
||||
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
|
||||
"lru-cache": "^11.5.0",
|
||||
"motion": "^12.40.0",
|
||||
"nitro": "^3.0.260522-beta",
|
||||
"posthog-js": "^1.376.2",
|
||||
"posthog-node": "^5.35.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.8.0",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { ZIndexLayer } from "recharts";
|
||||
|
||||
// ── Background Variant Types ─────────────────────────────────────────────────
|
||||
// To add a new variant:
|
||||
// 1. Add its name to the BackgroundVariant union type below
|
||||
// 2. Create a pattern component with PatternProps
|
||||
// 3. Register it in PATTERN_MAP
|
||||
|
||||
export type BackgroundVariant =
|
||||
| "dots"
|
||||
| "grid"
|
||||
| "cross-hatch"
|
||||
| "diagonal-lines"
|
||||
| "plus"
|
||||
| "falling-triangles"
|
||||
| "4-pointed-star"
|
||||
| "tiny-checkers"
|
||||
| "overlapping-circles"
|
||||
| "wiggle-lines"
|
||||
| "bubbles";
|
||||
|
||||
// ── Pattern Components ───────────────────────────────────────────────────────
|
||||
|
||||
type PatternProps = { id: string };
|
||||
|
||||
const DotsPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<circle className="text-border dark:text-border" cx="2" cy="2" r="1" fill="currentColor" />
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const GridPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
className="text-border dark:text-border"
|
||||
d="M 20 0 L 0 0 0 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const CrossHatchPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
className="text-border/60 dark:text-border/50"
|
||||
d="M 0 0 L 20 20 M 20 0 L 0 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const DiagonalLinesPattern = ({ id }: PatternProps) => (
|
||||
<pattern
|
||||
id={id}
|
||||
x="0"
|
||||
y="0"
|
||||
width="6"
|
||||
height="6"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<line
|
||||
className="text-border dark:text-border"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const PlusPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="16" height="16" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
className="text-border dark:text-border"
|
||||
d="M 8 4 L 8 12 M 4 8 L 12 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const FallingTrianglesPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="18" height="36" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
className="text-border dark:text-border"
|
||||
d="M2 6h12L8 18 2 6zm18 36h12l-6 12-6-12z"
|
||||
transform="scale(0.5)"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.4"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const FourPointedStarPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="16" height="16" patternUnits="userSpaceOnUse">
|
||||
<polygon
|
||||
className="text-border dark:text-border"
|
||||
fillRule="evenodd"
|
||||
points="5 3 8 4 5 5 4 8 3 5 0 4 3 3 4 0 5 3"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.4"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const TinyCheckersPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
className="text-border dark:text-border"
|
||||
fillRule="evenodd"
|
||||
d="M0 0h4v4H0V0zm4 4h4v4H4V4z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.2"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const OverlappingCirclesPattern = ({ id }: PatternProps) => (
|
||||
<pattern id={id} x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
className="text-border dark:text-border"
|
||||
fillRule="evenodd"
|
||||
d="M25 25c0-2.762 2.238-5 5-5s5 2.238 5 5-2.238 5-5 5c0 2.762-2.238 5-5 5s-5-2.238-5-5 2.238-5 5-5zM5 5c0-2.762 2.238-5 5-5s5 2.238 5 5-2.238 5-5 5c0 2.762-2.238 5-5 5S0 12.762 0 10s2.238-5 5-5zm5 4c2.209 0 4-1.791 4-4s-1.791-4-4-4-4 1.791-4 4 1.791 4 4 4zm20 20c2.209 0 4-1.791 4-4s-1.791-4-4-4-4 1.791-4 4 1.791 4 4 4z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.4"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const WiggleLinesPattern = ({ id }: PatternProps) => (
|
||||
<pattern
|
||||
id={id}
|
||||
x="0"
|
||||
y="0"
|
||||
width="52"
|
||||
height="26"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="scale(0.6)"
|
||||
>
|
||||
<path
|
||||
className="text-border dark:text-border"
|
||||
d="M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.4"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
const BubblesPattern = ({ id }: PatternProps) => (
|
||||
<pattern
|
||||
id={id}
|
||||
x="0"
|
||||
y="0"
|
||||
width="100"
|
||||
height="100"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="scale(0.6667)"
|
||||
>
|
||||
<path
|
||||
className="text-border dark:text-border"
|
||||
d="M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.4"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</pattern>
|
||||
);
|
||||
|
||||
// ── Pattern Registry ─────────────────────────────────────────────────────────
|
||||
// Map variant names to pattern components
|
||||
|
||||
const PATTERN_MAP: Record<BackgroundVariant, React.FC<PatternProps>> = {
|
||||
dots: DotsPattern,
|
||||
grid: GridPattern,
|
||||
plus: PlusPattern,
|
||||
bubbles: BubblesPattern,
|
||||
"cross-hatch": CrossHatchPattern,
|
||||
"diagonal-lines": DiagonalLinesPattern,
|
||||
"falling-triangles": FallingTrianglesPattern,
|
||||
"4-pointed-star": FourPointedStarPattern,
|
||||
"tiny-checkers": TinyCheckersPattern,
|
||||
"overlapping-circles": OverlappingCirclesPattern,
|
||||
"wiggle-lines": WiggleLinesPattern,
|
||||
};
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
// Usage: Place <ChartBackground variant="dots" /> inside any Recharts chart component.
|
||||
// ZIndexLayer with zIndex={-1} ensures the background renders behind all chart content.
|
||||
|
||||
interface ChartBackgroundProps {
|
||||
variant: BackgroundVariant;
|
||||
}
|
||||
|
||||
export function ChartBackground({ variant }: ChartBackgroundProps) {
|
||||
const baseId = useId().replace(/:/g, "");
|
||||
const patternId = `${baseId}-bg-${variant}`;
|
||||
const maskId = `${baseId}-bg-edge-fade`;
|
||||
const filterId = `${baseId}-bg-blur`;
|
||||
const PatternComponent = PATTERN_MAP[variant];
|
||||
|
||||
return (
|
||||
<ZIndexLayer zIndex={-1}>
|
||||
<defs>
|
||||
<PatternComponent id={patternId} />
|
||||
{/* Gaussian blur filter for soft edge fade */}
|
||||
<filter id={filterId}>
|
||||
<feGaussianBlur stdDeviation="25" />
|
||||
</filter>
|
||||
{/* Mask: a slightly inset white rect with blur creates smooth transparent edges */}
|
||||
<mask id={maskId} maskUnits="userSpaceOnUse">
|
||||
<rect x="8%" y="20%" width="85%" height="60%" fill="white" filter={`url(#${filterId})`} />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${patternId})`} mask={`url(#${maskId})`} />
|
||||
</ZIndexLayer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
type ThemeKey = keyof typeof THEMES;
|
||||
|
||||
// All Keys are optional at first
|
||||
type ThemeColorsBase = {
|
||||
[K in ThemeKey]?: string[];
|
||||
};
|
||||
|
||||
// Require at least one theme key
|
||||
type AtLeastOneThemeColor = {
|
||||
[K in ThemeKey]: Required<Pick<ThemeColorsBase, K>> & Partial<Omit<ThemeColorsBase, K>>;
|
||||
}[ThemeKey];
|
||||
|
||||
// THEMES is a const literal so its keys are exactly ThemeKey at runtime,
|
||||
// but Object.keys is typed as string[] regardless.
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
const VALID_THEME_KEYS = Object.keys(THEMES) as ThemeKey[];
|
||||
|
||||
// Validation for chart config colors at runtime
|
||||
function validateChartConfigColors(config: ChartConfig): void {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value.colors) {
|
||||
const hasValidThemeKey = VALID_THEME_KEYS.some(
|
||||
(themeKey) => value.colors?.[themeKey] !== undefined,
|
||||
);
|
||||
|
||||
if (!hasValidThemeKey) {
|
||||
throw new Error(
|
||||
`[EvilCharts] Invalid chart config for "${key}": colors object must have at least one theme key (${VALID_THEME_KEYS.join(", ")}). Received empty object or invalid keys.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
colors?: AtLeastOneThemeColor;
|
||||
}
|
||||
>;
|
||||
|
||||
interface ChartContextProps {
|
||||
config: ChartConfig;
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
export function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ChartContainerProps
|
||||
extends
|
||||
Omit<React.ComponentProps<"div">, "children">,
|
||||
Pick<
|
||||
React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>,
|
||||
| "initialDimension"
|
||||
| "aspect"
|
||||
| "debounce"
|
||||
| "minHeight"
|
||||
| "minWidth"
|
||||
| "maxHeight"
|
||||
| "height"
|
||||
| "width"
|
||||
| "onResize"
|
||||
| "children"
|
||||
> {
|
||||
config: ChartConfig;
|
||||
innerResponsiveContainerStyle?: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["style"];
|
||||
/** Optional content rendered below the chart (e.g. EvilBrush) */
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
config,
|
||||
initialDimension = { width: 320, height: 200 },
|
||||
className,
|
||||
children,
|
||||
footer,
|
||||
...props
|
||||
}: Readonly<ChartContainerProps>) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
// Validate chart config at runtime
|
||||
validateChartConfigColors(config);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"min-h-0 w-full flex-1",
|
||||
"relative flex flex-col justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
!footer && "aspect-video",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
className="min-h-0 w-full flex-1"
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
{footer}
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIndicator({ isLoading }: { isLoading: boolean }) {
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-2 rounded-md border bg-background px-2 py-0.5 text-sm text-primary">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-border border-t-primary" />
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Distribute colors evenly across slots, extra slots go to last color(s)
|
||||
// Example: 2 colors for 4 slots → [red, red, pink, pink]
|
||||
// Example: 3 colors for 4 slots → [red, pink, blue, blue]
|
||||
function distributeColors(colorsArray: string[], maxCount: number): string[] {
|
||||
const availableCount = colorsArray.length;
|
||||
if (availableCount >= maxCount) {
|
||||
return colorsArray.slice(0, maxCount);
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
const baseSlots = Math.floor(maxCount / availableCount);
|
||||
const extraSlots = maxCount % availableCount;
|
||||
|
||||
// First (availableCount - extraSlots) colors get baseSlots each
|
||||
// Last extraSlots colors get (baseSlots + 1) each
|
||||
for (let colorIdx = 0; colorIdx < availableCount; colorIdx++) {
|
||||
const color = colorsArray[colorIdx];
|
||||
if (color === undefined) continue;
|
||||
const isExtraColor = colorIdx >= availableCount - extraSlots;
|
||||
const slotsForThisColor = baseSlots + (isExtraColor ? 1 : 0);
|
||||
for (let j = 0; j < slotsForThisColor; j++) {
|
||||
result.push(color);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, entry]) => entry.colors);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generateCssVars = (theme: keyof typeof THEMES) =>
|
||||
colorConfig
|
||||
.flatMap(([key, itemConfig]) => {
|
||||
const colorsArray = itemConfig.colors?.[theme];
|
||||
if (!colorsArray || !Array.isArray(colorsArray) || colorsArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get max count across all themes for this key
|
||||
const maxCount = getColorsCount(itemConfig);
|
||||
|
||||
// Distribute colors evenly across all required slots
|
||||
const distributedColors = distributeColors(colorsArray, maxCount);
|
||||
|
||||
return distributedColors.map((color, index) => ` --color-${key}-${index}: ${color};`);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const css = Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) =>
|
||||
// `Object.entries` widens keys to `string`; THEMES is a const literal so
|
||||
// every `theme` here is a valid ThemeKey at runtime.
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
`${prefix} [data-chart=${id}] {\n${generateCssVars(theme as keyof typeof THEMES)}\n}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
||||
};
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
export function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Recharts' tooltip/legend item payloads are typed loosely; once we've guarded
|
||||
// them as non-null objects, we can safely index them by string key.
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
const outer = payload as Record<string, unknown>;
|
||||
const innerCandidate = outer.payload;
|
||||
const inner =
|
||||
typeof innerCandidate === "object" && innerCandidate !== null
|
||||
? // oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
(innerCandidate as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (typeof outer[key] === "string") {
|
||||
configLabelKey = outer[key];
|
||||
} else if (inner && typeof inner[key] === "string") {
|
||||
configLabelKey = inner[key];
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
// Format values to percent for expanded charts
|
||||
function axisValueToPercentFormatter(value: number) {
|
||||
return `${Math.round(value * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
// Pick the first string/number candidate as a series key, falling back to "value".
|
||||
// Recharts types dataKey as `string | number | (data) => unknown`, so the function
|
||||
// form needs to be filtered out before being used as an object index.
|
||||
function pickKey(...candidates: unknown[]): string {
|
||||
for (const c of candidates) {
|
||||
if (typeof c === "string") return c;
|
||||
if (typeof c === "number") return String(c);
|
||||
}
|
||||
return "value";
|
||||
}
|
||||
|
||||
// Get max colors count across all themes for a config entry
|
||||
function getColorsCount(config: ChartConfig[string]): number {
|
||||
if (!config.colors) return 1;
|
||||
const counts = VALID_THEME_KEYS.map((theme) => config.colors?.[theme]?.length ?? 0);
|
||||
return Math.max(...counts, 1);
|
||||
}
|
||||
|
||||
// Generate random loading data for skeleton/loading state
|
||||
// min/max represent percentage of the range (0-100), defaults to 20-80 for realistic look
|
||||
export const getLoadingData = (points: number = 10, min: number = 0, max: number = 70) => {
|
||||
const range = max - min;
|
||||
return Array.from({ length: points }, () => ({
|
||||
loading: Math.floor(Math.random() * range) + min,
|
||||
}));
|
||||
};
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartStyle,
|
||||
axisValueToPercentFormatter,
|
||||
LoadingIndicator,
|
||||
getColorsCount,
|
||||
pickKey,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type DotVariant = "default" | "border" | "colored-border";
|
||||
|
||||
type ChartDotProps = {
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
dataKey: string;
|
||||
chartId: string;
|
||||
className?: string;
|
||||
fillOpacity?: number;
|
||||
type?: DotVariant;
|
||||
/** Optional SVG <mask> id — lets the dot share an area's intro reveal wipe. */
|
||||
maskId?: string;
|
||||
};
|
||||
|
||||
const ChartDot = React.memo(function ChartDot({
|
||||
cx,
|
||||
cy,
|
||||
dataKey,
|
||||
chartId,
|
||||
className,
|
||||
fillOpacity = 1,
|
||||
type = "default",
|
||||
maskId,
|
||||
}: ChartDotProps) {
|
||||
const dotId = React.useId().replace(/:/g, "");
|
||||
const gradientUrl = `url(#${chartId}-colors-${dataKey})`;
|
||||
|
||||
if (cx === undefined || cy === undefined) return null;
|
||||
|
||||
switch (type) {
|
||||
case "border":
|
||||
return (
|
||||
<PrimaryBorderDot
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
dotId={dotId}
|
||||
fillOpacity={fillOpacity}
|
||||
gradientUrl={gradientUrl}
|
||||
className={className}
|
||||
maskId={maskId}
|
||||
/>
|
||||
);
|
||||
case "colored-border":
|
||||
return (
|
||||
<ColoredBorderDot
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
dotId={dotId}
|
||||
fillOpacity={fillOpacity}
|
||||
gradientUrl={gradientUrl}
|
||||
className={className}
|
||||
maskId={maskId}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DefaultDot
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
dotId={dotId}
|
||||
fillOpacity={fillOpacity}
|
||||
gradientUrl={gradientUrl}
|
||||
className={className}
|
||||
maskId={maskId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
type DotVariantProps = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
dotId: string;
|
||||
fillOpacity: number;
|
||||
gradientUrl: string;
|
||||
className?: string;
|
||||
maskId?: string;
|
||||
};
|
||||
|
||||
const DefaultDot = React.memo(
|
||||
({ cx, cy, dotId, fillOpacity, gradientUrl, className, maskId }: DotVariantProps) => {
|
||||
const r = 3;
|
||||
return (
|
||||
<g className={className} mask={maskId ? `url(#${maskId})` : undefined}>
|
||||
<defs>
|
||||
<clipPath id={`dot-clip-${dotId}`}>
|
||||
<circle cx={cx} cy={cy} r={r} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{/* Full-width gradient rectangle clipped to dot shape */}
|
||||
<rect
|
||||
x="0"
|
||||
y={cy - r}
|
||||
width="100%"
|
||||
height={r * 2}
|
||||
fill={gradientUrl}
|
||||
fillOpacity={fillOpacity}
|
||||
clipPath={`url(#dot-clip-${dotId})`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DefaultDot.displayName = "DefaultDot";
|
||||
|
||||
const PrimaryBorderDot = React.memo(
|
||||
({ cx, cy, dotId, fillOpacity, gradientUrl, className, maskId }: DotVariantProps) => {
|
||||
const r = 6;
|
||||
const strokeWidth = 5;
|
||||
return (
|
||||
<g className={cn(className, "text-background")} mask={maskId ? `url(#${maskId})` : undefined}>
|
||||
<defs>
|
||||
<clipPath id={`dot-clip-${dotId}`}>
|
||||
<circle cx={cx} cy={cy} r={r} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{/* Background stroke (border) */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="currentColor" />
|
||||
{/* Inner gradient circle clipped */}
|
||||
<rect
|
||||
x="0"
|
||||
y={cy - (r - strokeWidth / 2)}
|
||||
width="100%"
|
||||
height={(r - strokeWidth / 2) * 2}
|
||||
fill={gradientUrl}
|
||||
fillOpacity={fillOpacity}
|
||||
clipPath={`url(#dot-clip-inner-${dotId})`}
|
||||
/>
|
||||
<defs>
|
||||
<clipPath id={`dot-clip-inner-${dotId}`}>
|
||||
<circle cx={cx} cy={cy} r={r - strokeWidth / 2} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</g>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PrimaryBorderDot.displayName = "PrimaryBorderDot";
|
||||
|
||||
const ColoredBorderDot = React.memo(
|
||||
({ cx, cy, dotId, fillOpacity, gradientUrl, className, maskId }: DotVariantProps) => {
|
||||
const r = 3;
|
||||
const strokeWidth = 1;
|
||||
return (
|
||||
<g className={cn(className, "text-background")} mask={maskId ? `url(#${maskId})` : undefined}>
|
||||
<defs>
|
||||
<clipPath id={`dot-clip-${dotId}`}>
|
||||
<circle cx={cx} cy={cy} r={r + strokeWidth / 2} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{/* Gradient stroke (border) via clipped rect */}
|
||||
<rect
|
||||
x="0"
|
||||
y={cy - r - strokeWidth / 2}
|
||||
width="100%"
|
||||
height={(r + strokeWidth / 2) * 2}
|
||||
fill={gradientUrl}
|
||||
fillOpacity={fillOpacity}
|
||||
clipPath={`url(#dot-clip-${dotId})`}
|
||||
/>
|
||||
{/* Inner solid fill */}
|
||||
<circle cx={cx} cy={cy} r={r - strokeWidth / 2} fill="currentColor" />
|
||||
</g>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ColoredBorderDot.displayName = "ColoredBorderDot";
|
||||
|
||||
export { ChartDot };
|
||||
@@ -0,0 +1,689 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from "motion/react";
|
||||
import type { MotionValue } from "motion/react";
|
||||
import { useCallback, useEffect, type ComponentProps } from "react";
|
||||
import * as React from "react";
|
||||
import { ResponsiveContainer, AreaChart, Area, LineChart, Line, BarChart, Bar } from "recharts";
|
||||
|
||||
import { ChartStyle, getColorsCount, type ChartConfig } from "@/components/evilcharts/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type EvilBrushVariant = "line" | "area" | "bar";
|
||||
type CurveType = ComponentProps<typeof Area>["type"];
|
||||
|
||||
interface EvilBrushRange {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
interface EvilBrushProps {
|
||||
/** Full dataset – always rendered in the miniature chart */
|
||||
data: Record<string, unknown>[];
|
||||
/** Chart config with colour definitions */
|
||||
chartConfig: ChartConfig;
|
||||
/** Data keys to plot (default: all keys from chartConfig) */
|
||||
dataKeys?: string[];
|
||||
/** X-axis data key – used for handle labels */
|
||||
xDataKey?: string;
|
||||
/** Visual variant of the mini chart */
|
||||
variant?: EvilBrushVariant;
|
||||
/** Pixel height of the brush */
|
||||
height?: number;
|
||||
/** Extra className */
|
||||
className?: string;
|
||||
/** Whether areas/bars should be stacked in the mini chart */
|
||||
stacked?: boolean;
|
||||
/** Stroke variant for line / area strokes in the mini chart */
|
||||
strokeVariant?: "solid" | "dashed" | "animated-dashed";
|
||||
/** Whether to connect null data points in line / area variants */
|
||||
connectNulls?: boolean;
|
||||
/** Radius for bar corners in the bar variant */
|
||||
barRadius?: number;
|
||||
|
||||
// ── Controlled mode ──────────────────────────────────────────────────
|
||||
/** Controlled start index */
|
||||
startIndex?: number;
|
||||
/** Controlled end index */
|
||||
endIndex?: number;
|
||||
|
||||
// ── Uncontrolled mode ────────────────────────────────────────────────
|
||||
/** Initial start index (uncontrolled) */
|
||||
defaultStartIndex?: number;
|
||||
/** Initial end index (uncontrolled) */
|
||||
defaultEndIndex?: number;
|
||||
|
||||
/** Fired whenever the visible range changes */
|
||||
onChange?: (range: EvilBrushRange) => void;
|
||||
/** Format the handle label from the xDataKey value */
|
||||
formatLabel?: (value: unknown, index: number) => string;
|
||||
/** Curve type for line / area variants */
|
||||
curveType?: CurveType;
|
||||
/** Minimum number of data points that must remain selected */
|
||||
minSpan?: number;
|
||||
/** Whether to render labels on the handles */
|
||||
showLabels?: boolean;
|
||||
/** Skip rendering own ChartStyle (when inside a ChartContainer that already provides CSS vars) */
|
||||
skipStyle?: boolean;
|
||||
}
|
||||
|
||||
// ─── Spring config ──────────────────────────────────────────────────────────
|
||||
|
||||
const SPRING_CONFIG = { stiffness: 300, damping: 35, mass: 0.8 };
|
||||
|
||||
// ─── Pointer-capture drag hook ──────────────────────────────────────────────
|
||||
// Replaces raw addEventListener with the modern Pointer Events API.
|
||||
// setPointerCapture routes all pointer events to the originating element,
|
||||
// so we get mouse + touch + pen support with zero global listeners.
|
||||
|
||||
type DragType = "left" | "right" | "middle";
|
||||
|
||||
interface DragState {
|
||||
type: DragType;
|
||||
originX: number;
|
||||
originRange: EvilBrushRange;
|
||||
}
|
||||
|
||||
function useBrushDrag({
|
||||
range,
|
||||
totalPoints,
|
||||
containerRef,
|
||||
commit,
|
||||
}: {
|
||||
range: EvilBrushRange;
|
||||
totalPoints: number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
commit: (next: EvilBrushRange, mode?: DragType) => void;
|
||||
}) {
|
||||
const dragRef = React.useRef<DragState | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
const toIndexDelta = useCallback(
|
||||
(px: number) => {
|
||||
if (!containerRef.current || totalPoints <= 1) return 0;
|
||||
return Math.round(
|
||||
(px / containerRef.current.getBoundingClientRect().width) * (totalPoints - 1),
|
||||
);
|
||||
},
|
||||
[totalPoints, containerRef],
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent, type: DragType) => {
|
||||
e.preventDefault();
|
||||
if (e.target instanceof Element) e.target.setPointerCapture(e.pointerId);
|
||||
dragRef.current = { type, originX: e.clientX, originRange: { ...range } };
|
||||
setIsDragging(true);
|
||||
},
|
||||
[range],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
const delta = toIndexDelta(e.clientX - d.originX);
|
||||
const { type, originRange: o } = d;
|
||||
|
||||
if (type === "left") {
|
||||
commit({ startIndex: o.startIndex + delta, endIndex: o.endIndex }, "left");
|
||||
} else if (type === "right") {
|
||||
commit({ startIndex: o.startIndex, endIndex: o.endIndex + delta }, "right");
|
||||
} else {
|
||||
const span = o.endIndex - o.startIndex;
|
||||
let s = o.startIndex + delta;
|
||||
let e2 = s + span;
|
||||
if (s < 0) {
|
||||
s = 0;
|
||||
e2 = span;
|
||||
}
|
||||
if (e2 > totalPoints - 1) {
|
||||
e2 = totalPoints - 1;
|
||||
s = Math.max(0, e2 - span);
|
||||
}
|
||||
commit({ startIndex: s, endIndex: e2 }, "middle");
|
||||
}
|
||||
},
|
||||
[toIndexDelta, totalPoints, commit],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (e.target instanceof Element) e.target.releasePointerCapture(e.pointerId);
|
||||
dragRef.current = null;
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// Helper to bind all three pointer handlers for a given drag type
|
||||
const bind = useCallback(
|
||||
(type: DragType) => ({
|
||||
onPointerDown: (e: React.PointerEvent) => onPointerDown(e, type),
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}),
|
||||
[onPointerDown, onPointerMove, onPointerUp],
|
||||
);
|
||||
|
||||
return { isDragging, bind };
|
||||
}
|
||||
|
||||
// ─── EvilBrush ────────────────────────────────────────────────────────────
|
||||
|
||||
function EvilBrush({
|
||||
data,
|
||||
chartConfig,
|
||||
dataKeys,
|
||||
xDataKey,
|
||||
variant = "area",
|
||||
height = 56,
|
||||
className,
|
||||
stacked = false,
|
||||
strokeVariant = "solid",
|
||||
connectNulls = false,
|
||||
barRadius,
|
||||
startIndex: controlledStart,
|
||||
endIndex: controlledEnd,
|
||||
defaultStartIndex = 0,
|
||||
defaultEndIndex,
|
||||
onChange,
|
||||
formatLabel,
|
||||
curveType = "monotone",
|
||||
minSpan = 2,
|
||||
showLabels = true,
|
||||
skipStyle = false,
|
||||
}: EvilBrushProps) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const keys = React.useMemo(() => dataKeys ?? Object.keys(chartConfig), [dataKeys, chartConfig]);
|
||||
const totalPoints = data.length;
|
||||
const chartId = React.useId().replace(/:/g, "");
|
||||
|
||||
// ── Controlled vs uncontrolled ──────────────────────────────────────────
|
||||
|
||||
const isControlled = controlledStart !== undefined && controlledEnd !== undefined;
|
||||
|
||||
const [internalRange, setInternalRange] = React.useState<EvilBrushRange>(() => ({
|
||||
startIndex: Math.max(0, Math.min(defaultStartIndex, totalPoints - 1)),
|
||||
endIndex: Math.max(0, Math.min(defaultEndIndex ?? totalPoints - 1, totalPoints - 1)),
|
||||
}));
|
||||
|
||||
// Track the last committed range to avoid duplicate updates when small
|
||||
// mouse movements don't produce index changes (e.g., at boundaries)
|
||||
const lastCommittedRef = React.useRef<EvilBrushRange>(internalRange);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) {
|
||||
setInternalRange((prev) => {
|
||||
const adjusted = {
|
||||
startIndex: Math.min(prev.startIndex, Math.max(0, totalPoints - 1)),
|
||||
endIndex: Math.min(prev.endIndex, Math.max(0, totalPoints - 1)),
|
||||
};
|
||||
lastCommittedRef.current = adjusted;
|
||||
return adjusted;
|
||||
});
|
||||
}
|
||||
}, [totalPoints, isControlled]);
|
||||
|
||||
// ── Clamping & committing ───────────────────────────────────────────────
|
||||
|
||||
const clampRange = useCallback(
|
||||
(range: EvilBrushRange, mode?: DragType): EvilBrushRange => {
|
||||
let { startIndex, endIndex } = range;
|
||||
const maxIndex = Math.max(0, totalPoints - 1);
|
||||
|
||||
startIndex = Math.max(0, Math.min(startIndex, maxIndex));
|
||||
endIndex = Math.max(0, Math.min(endIndex, maxIndex));
|
||||
|
||||
if (mode === "left") {
|
||||
const maxStart = Math.max(0, endIndex - minSpan);
|
||||
startIndex = Math.min(startIndex, maxStart);
|
||||
return { startIndex, endIndex };
|
||||
}
|
||||
|
||||
if (mode === "right") {
|
||||
const minEnd = Math.min(maxIndex, startIndex + minSpan);
|
||||
endIndex = Math.max(endIndex, minEnd);
|
||||
return { startIndex, endIndex };
|
||||
}
|
||||
|
||||
if (endIndex - startIndex < minSpan) {
|
||||
endIndex = Math.min(startIndex + minSpan, maxIndex);
|
||||
if (endIndex - startIndex < minSpan) {
|
||||
startIndex = Math.max(0, endIndex - minSpan);
|
||||
}
|
||||
}
|
||||
return { startIndex, endIndex };
|
||||
},
|
||||
[totalPoints, minSpan],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: EvilBrushRange, mode?: DragType) => {
|
||||
const clamped = clampRange(next, mode);
|
||||
const last = lastCommittedRef.current;
|
||||
|
||||
// Only update if the range has actually changed — avoids unnecessary
|
||||
// re-renders when the brush is at a boundary and small mouse movements
|
||||
// don't produce index changes
|
||||
if (last.startIndex === clamped.startIndex && last.endIndex === clamped.endIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastCommittedRef.current = clamped;
|
||||
setInternalRange(clamped);
|
||||
// Defer the parent callback — chart re-render happens at lower priority,
|
||||
// React can skip intermediate frames during fast drags
|
||||
React.startTransition(() => {
|
||||
onChange?.(clamped);
|
||||
});
|
||||
},
|
||||
[clampRange, onChange],
|
||||
);
|
||||
|
||||
// ── Drag ────────────────────────────────────────────────────────────────
|
||||
|
||||
const { isDragging, bind } = useBrushDrag({
|
||||
range: internalRange,
|
||||
totalPoints,
|
||||
containerRef,
|
||||
commit,
|
||||
});
|
||||
|
||||
// Position always driven by internalRange (never lags behind controlled props)
|
||||
const range = internalRange;
|
||||
|
||||
// Sync internalRange with controlled props when not dragging
|
||||
useEffect(() => {
|
||||
if (isControlled && !isDragging) {
|
||||
const syncedRange = { startIndex: controlledStart, endIndex: controlledEnd };
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setInternalRange(syncedRange);
|
||||
lastCommittedRef.current = syncedRange;
|
||||
}
|
||||
}, [isControlled, controlledStart, controlledEnd, isDragging]);
|
||||
|
||||
// ── Computed positions (%) ──────────────────────────────────────────────
|
||||
|
||||
const leftPct = totalPoints > 1 ? (range.startIndex / (totalPoints - 1)) * 100 : 0;
|
||||
const rightPct = totalPoints > 1 ? (range.endIndex / (totalPoints - 1)) * 100 : 100;
|
||||
|
||||
// Drive all moving brush UI from the same springed edge values.
|
||||
const leftTarget = useMotionValue(leftPct);
|
||||
const rightTarget = useMotionValue(rightPct);
|
||||
if (leftTarget.get() !== leftPct) leftTarget.set(leftPct);
|
||||
if (rightTarget.get() !== rightPct) rightTarget.set(rightPct);
|
||||
|
||||
const leftSpring = useSpring(leftTarget, SPRING_CONFIG);
|
||||
const rightSpring = useSpring(rightTarget, SPRING_CONFIG);
|
||||
const leftPosition = useTransform(leftSpring, (v) => `${v}%`);
|
||||
const rightPosition = useTransform(rightSpring, (v) => `${v}%`);
|
||||
const leftOverlayWidth = useTransform(leftSpring, (v) => `${v}%`);
|
||||
const rightOverlayWidth = useTransform(rightSpring, (v) => `${Math.max(0, 100 - v)}%`);
|
||||
const selectedWidth = useMotionValue(`${Math.max(0, rightPct - leftPct)}%`);
|
||||
|
||||
const updateSelectedWidth = useCallback(() => {
|
||||
selectedWidth.set(`${Math.max(0, rightSpring.get() - leftSpring.get())}%`);
|
||||
}, [leftSpring, rightSpring, selectedWidth]);
|
||||
|
||||
useMotionValueEvent(leftSpring, "change", updateSelectedWidth);
|
||||
useMotionValueEvent(rightSpring, "change", updateSelectedWidth);
|
||||
|
||||
const getLabel = useCallback(
|
||||
(idx: number) => {
|
||||
if (!xDataKey) return String(idx);
|
||||
const v = data[idx]?.[xDataKey];
|
||||
if (formatLabel) return formatLabel(v, idx);
|
||||
return typeof v === "string" || typeof v === "number" ? String(v) : String(idx);
|
||||
},
|
||||
[data, xDataKey, formatLabel],
|
||||
);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (totalPoints === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-chart={skipStyle ? undefined : chartId}
|
||||
className={cn("group relative select-none", className)}
|
||||
style={{ height }}
|
||||
>
|
||||
{!skipStyle && <ChartStyle id={chartId} config={chartConfig} />}
|
||||
|
||||
{/* Mini chart – always shows all data */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
||||
<MiniChart
|
||||
data={data}
|
||||
keys={keys}
|
||||
chartConfig={chartConfig}
|
||||
variant={variant}
|
||||
curveType={curveType}
|
||||
chartId={chartId}
|
||||
stacked={stacked}
|
||||
strokeVariant={strokeVariant === "animated-dashed" ? "dashed" : strokeVariant}
|
||||
connectNulls={connectNulls}
|
||||
barRadius={barRadius}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dim overlay – left */}
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 rounded-l-md bg-background/70 backdrop-blur-[2px]"
|
||||
style={{ width: leftOverlayWidth }}
|
||||
/>
|
||||
{/* Dim overlay – right */}
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 rounded-r-md bg-background/70 backdrop-blur-[2px]"
|
||||
style={{ width: rightOverlayWidth }}
|
||||
/>
|
||||
|
||||
{/* Selected region – draggable to pan */}
|
||||
<motion.div
|
||||
className="absolute inset-y-0 cursor-grab touch-none rounded-sm border active:cursor-grabbing"
|
||||
style={{ left: leftPosition, width: selectedWidth }}
|
||||
{...bind("middle")}
|
||||
/>
|
||||
|
||||
{/* Left handle */}
|
||||
<BrushHandle
|
||||
side="left"
|
||||
position={leftPosition}
|
||||
label={showLabels ? getLabel(range.startIndex) : undefined}
|
||||
bind={bind("left")}
|
||||
/>
|
||||
|
||||
{/* Right handle */}
|
||||
<BrushHandle
|
||||
side="right"
|
||||
position={rightPosition}
|
||||
label={showLabels ? getLabel(range.endIndex) : undefined}
|
||||
bind={bind("right")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Brush Handle ───────────────────────────────────────────────────────────
|
||||
|
||||
function BrushHandle({
|
||||
side,
|
||||
position,
|
||||
label,
|
||||
bind,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
position: MotionValue<string>;
|
||||
label?: string;
|
||||
bind: {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: (e: React.PointerEvent) => void;
|
||||
};
|
||||
}) {
|
||||
const isLeft = side === "left";
|
||||
|
||||
return (
|
||||
<motion.div className="absolute inset-y-0 z-10" style={{ left: position }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group absolute inset-y-0 flex w-3 cursor-ew-resize touch-none items-center justify-center after:absolute after:inset-y-0 after:-left-4 after:w-11 after:content-['']",
|
||||
isLeft ? "" : "-translate-x-full",
|
||||
)}
|
||||
{...bind}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-4 w-1.5 items-center justify-center rounded-md bg-muted-foreground transition-colors group-hover:bg-foreground",
|
||||
isLeft ? "-left-[5.5px]" : "-right-[5.5px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-[2px]">
|
||||
<div className="h-[2px] w-[2px] rounded-full bg-background/70" />
|
||||
<div className="h-[2px] w-[2px] rounded-full bg-background/70" />
|
||||
<div className="h-[2px] w-[2px] rounded-full bg-background/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{label && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute -bottom-3 -translate-y-1/2 rounded-[3px] bg-foreground px-1 py-px text-[8px] leading-tight font-medium whitespace-nowrap text-background opacity-0 group-hover:opacity-100",
|
||||
isLeft ? "left-1.5" : "right-1.5",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mini Chart ─────────────────────────────────────────────────────────────
|
||||
|
||||
function MiniChart({
|
||||
data,
|
||||
keys,
|
||||
chartConfig,
|
||||
variant,
|
||||
curveType,
|
||||
chartId,
|
||||
stacked,
|
||||
strokeVariant = "solid",
|
||||
connectNulls = false,
|
||||
barRadius,
|
||||
}: {
|
||||
data: Record<string, unknown>[];
|
||||
keys: string[];
|
||||
chartConfig: ChartConfig;
|
||||
variant: EvilBrushVariant;
|
||||
curveType: CurveType;
|
||||
chartId: string;
|
||||
stacked: boolean;
|
||||
strokeVariant?: "solid" | "dashed" | "animated-dashed";
|
||||
connectNulls?: boolean;
|
||||
barRadius?: number;
|
||||
}) {
|
||||
const gradients = React.useMemo(
|
||||
() =>
|
||||
Object.entries(chartConfig)
|
||||
.filter(([key]) => keys.includes(key))
|
||||
.map(([dataKey, config]) => ({
|
||||
dataKey,
|
||||
colorsCount: getColorsCount(config),
|
||||
})),
|
||||
[chartConfig, keys],
|
||||
);
|
||||
|
||||
const dashArray =
|
||||
strokeVariant === "dashed" || strokeVariant === "animated-dashed" ? "4 4" : undefined;
|
||||
|
||||
const defsContent = (
|
||||
<>
|
||||
{/* Vertical fade gradient for area fill mask */}
|
||||
{variant === "area" && (
|
||||
<linearGradient id={`${chartId}-zm-vertical-fade`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="white" stopOpacity={0.15} />
|
||||
<stop offset="100%" stopColor="white" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
)}
|
||||
{gradients.map(({ dataKey, colorsCount }) => {
|
||||
const colorStops =
|
||||
colorsCount === 1 ? (
|
||||
<>
|
||||
<stop offset="0%" stopColor={`var(--color-${dataKey}-0)`} />
|
||||
<stop offset="100%" stopColor={`var(--color-${dataKey}-0)`} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: colorsCount }, (_, i) => (
|
||||
<stop
|
||||
key={i}
|
||||
offset={`${(i / (colorsCount - 1)) * 100}%`}
|
||||
stopColor={`var(--color-${dataKey}-${i}, var(--color-${dataKey}-0))`}
|
||||
/>
|
||||
))
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={dataKey}>
|
||||
{/* Vertical color gradient (stroke + bar fill) */}
|
||||
<linearGradient id={`${chartId}-zm-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
{colorStops}
|
||||
</linearGradient>
|
||||
|
||||
{/* Area fill: color gradient masked with vertical fade */}
|
||||
{variant === "area" && (
|
||||
<>
|
||||
<mask id={`${chartId}-zm-fill-mask-${dataKey}`}>
|
||||
<rect width="100%" height="100%" fill={`url(#${chartId}-zm-vertical-fade)`} />
|
||||
</mask>
|
||||
<pattern
|
||||
id={`${chartId}-zm-fill-${dataKey}`}
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill={`url(#${chartId}-zm-${dataKey})`}
|
||||
mask={`url(#${chartId}-zm-fill-mask-${dataKey})`}
|
||||
/>
|
||||
</pattern>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === "line") {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
|
||||
<defs>{defsContent}</defs>
|
||||
{keys.map((dk) => (
|
||||
<Line
|
||||
key={dk}
|
||||
type={curveType}
|
||||
dataKey={dk}
|
||||
stroke={`url(#${chartId}-zm-${dk})`}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
strokeDasharray={dashArray}
|
||||
connectNulls={connectNulls}
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "bar") {
|
||||
const r = barRadius ?? 3;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 2, right: 0, bottom: 0, left: 0 }}
|
||||
barGap={2}
|
||||
barSize={14}
|
||||
>
|
||||
<defs>{defsContent}</defs>
|
||||
{keys.map((dk) => (
|
||||
<Bar
|
||||
key={dk}
|
||||
dataKey={dk}
|
||||
fill={`url(#${chartId}-zm-${dk})`}
|
||||
fillOpacity={0.35}
|
||||
stackId={stacked ? "zm-stack" : undefined}
|
||||
isAnimationActive={false}
|
||||
radius={[r, r, r, r]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: area
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
|
||||
<defs>{defsContent}</defs>
|
||||
{keys.map((dk) => (
|
||||
<Area
|
||||
key={dk}
|
||||
type={curveType}
|
||||
dataKey={dk}
|
||||
stroke={`url(#${chartId}-zm-${dk})`}
|
||||
fill={`url(#${chartId}-zm-fill-${dk})`}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
strokeDasharray={dashArray}
|
||||
connectNulls={connectNulls}
|
||||
fillOpacity={1}
|
||||
stackId={stacked ? "zm-stack" : undefined}
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── useEvilBrush Hook ──────────────────────────────────────────────────────
|
||||
|
||||
function useEvilBrush<TData extends Record<string, unknown>>({
|
||||
data,
|
||||
defaultStartIndex = 0,
|
||||
defaultEndIndex,
|
||||
}: {
|
||||
data: TData[];
|
||||
defaultStartIndex?: number;
|
||||
defaultEndIndex?: number;
|
||||
}) {
|
||||
const [range, setRange] = React.useState<EvilBrushRange>({
|
||||
startIndex: defaultStartIndex,
|
||||
endIndex: defaultEndIndex ?? Math.max(0, data.length - 1),
|
||||
});
|
||||
|
||||
// Defer the range used for data slicing — the brush handles move at the
|
||||
|
||||
// immediate `range` cadence while the expensive chart re-render uses the
|
||||
// deferred value. React can skip intermediate slices during fast drags.
|
||||
const deferredRange = React.useDeferredValue(range);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRange({
|
||||
startIndex: 0,
|
||||
endIndex: Math.max(0, data.length - 1),
|
||||
});
|
||||
}, [data.length]);
|
||||
|
||||
const visibleData = React.useMemo(
|
||||
() => data.slice(deferredRange.startIndex, deferredRange.endIndex + 1),
|
||||
[data, deferredRange.startIndex, deferredRange.endIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
range,
|
||||
visibleData,
|
||||
brushProps: {
|
||||
startIndex: range.startIndex,
|
||||
endIndex: range.endIndex,
|
||||
onChange: setRange,
|
||||
} satisfies Pick<EvilBrushProps, "startIndex" | "endIndex" | "onChange">,
|
||||
};
|
||||
}
|
||||
|
||||
export { EvilBrush, useEvilBrush, type EvilBrushProps, type EvilBrushRange, type EvilBrushVariant };
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import {
|
||||
getPayloadConfigFromPayload,
|
||||
getColorsCount,
|
||||
pickKey,
|
||||
useChart,
|
||||
} from "@/components/evilcharts/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChartLegendVariant =
|
||||
| "square"
|
||||
| "circle"
|
||||
| "circle-outline"
|
||||
| "rounded-square"
|
||||
| "rounded-square-outline"
|
||||
| "vertical-bar"
|
||||
| "horizontal-bar";
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
nameKey,
|
||||
payload,
|
||||
verticalAlign,
|
||||
align = "right",
|
||||
selected,
|
||||
onSelectChange,
|
||||
isClickable,
|
||||
variant = "rounded-square",
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
selected?: string | null;
|
||||
isClickable?: boolean;
|
||||
onSelectChange?: (selected: string | null) => void;
|
||||
variant?: ChartLegendVariant;
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 select-none",
|
||||
align === "left" && "justify-start",
|
||||
align === "center" && "justify-center",
|
||||
align === "right" && "justify-end",
|
||||
verticalAlign === "top" ? "pb-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
// For pie charts, item.value contains the sector name (e.g., "chrome")
|
||||
// For radial charts, the name is in item.payload[nameKey]
|
||||
// For other charts, item.dataKey contains the series name (e.g., "desktop")
|
||||
const payloadName =
|
||||
nameKey && item.payload
|
||||
? // Recharts types `item.payload` as `any`; narrowing it here
|
||||
// to read a string-indexed value.
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
(item.payload as Record<string, unknown>)[nameKey]
|
||||
: undefined;
|
||||
const key = pickKey(payloadName, item.value, item.dataKey);
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const isSelected = selected === null || selected === key;
|
||||
|
||||
// Get colors count for this item to determine gradient vs solid
|
||||
const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 transition-opacity [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
!isSelected && "opacity-30",
|
||||
isClickable && "cursor-pointer",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isClickable) return;
|
||||
|
||||
onSelectChange?.(selected === key ? null : key);
|
||||
}}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<LegendIndicator variant={variant} dataKey={key} colorsCount={colorsCount} />
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legend indicator — each variant gets its own branch so future variants
|
||||
// can diverge freely in markup & style.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LegendIndicator({
|
||||
variant,
|
||||
dataKey,
|
||||
colorsCount,
|
||||
}: {
|
||||
variant: ChartLegendVariant;
|
||||
dataKey: string;
|
||||
colorsCount: number;
|
||||
}) {
|
||||
const fillStyle = getLegendFillStyle(dataKey, colorsCount);
|
||||
const outlineStyle = getLegendOutlineStyle(dataKey, colorsCount);
|
||||
|
||||
switch (variant) {
|
||||
case "square":
|
||||
return <div className="h-2 w-2 shrink-0" style={fillStyle} />;
|
||||
|
||||
case "circle":
|
||||
return <div className="h-2 w-2 shrink-0 rounded-full" style={fillStyle} />;
|
||||
|
||||
case "circle-outline":
|
||||
return <div className="h-2.5 w-2.5 shrink-0 rounded-full p-[1.5px]" style={outlineStyle} />;
|
||||
|
||||
case "vertical-bar":
|
||||
return <div className="h-3 w-1 shrink-0 rounded-[2px]" style={fillStyle} />;
|
||||
|
||||
case "horizontal-bar":
|
||||
return <div className="h-1 w-3 shrink-0 rounded-[2px]" style={fillStyle} />;
|
||||
|
||||
case "rounded-square-outline":
|
||||
return <div className="h-2.5 w-2.5 shrink-0 rounded-[3px] p-[1.5px]" style={outlineStyle} />;
|
||||
|
||||
case "rounded-square":
|
||||
default:
|
||||
return <div className="h-2 w-2 shrink-0 rounded-[2px]" style={fillStyle} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Solid fill / gradient background for filled variants. */
|
||||
function getLegendFillStyle(dataKey: string, colorsCount: number): React.CSSProperties {
|
||||
if (colorsCount <= 1) {
|
||||
return { backgroundColor: `var(--color-${dataKey}-0)` };
|
||||
}
|
||||
|
||||
const stops = Array.from({ length: colorsCount }, (_, i) => {
|
||||
const offset = (i / (colorsCount - 1)) * 100;
|
||||
return `var(--color-${dataKey}-${i}) ${offset}%`;
|
||||
}).join(", ");
|
||||
|
||||
return { background: `linear-gradient(to right, ${stops})` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline style for stroke variants.
|
||||
* Uses background + mask-composite to punch out the center, leaving only the
|
||||
* "border" visible. Works with both solid colors and gradients, and respects
|
||||
* border-radius — unlike plain `border-color`.
|
||||
*/
|
||||
function getLegendOutlineStyle(dataKey: string, colorsCount: number): React.CSSProperties {
|
||||
const maskStyle: React.CSSProperties = {
|
||||
WebkitMask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
||||
maskComposite: "exclude",
|
||||
};
|
||||
|
||||
if (colorsCount <= 1) {
|
||||
return {
|
||||
backgroundColor: `var(--color-${dataKey}-0)`,
|
||||
...maskStyle,
|
||||
};
|
||||
}
|
||||
|
||||
const stops = Array.from({ length: colorsCount }, (_, i) => {
|
||||
const offset = (i / (colorsCount - 1)) * 100;
|
||||
return `var(--color-${dataKey}-${i}) ${offset}%`;
|
||||
}).join(", ");
|
||||
|
||||
return {
|
||||
background: `linear-gradient(to right, ${stops})`,
|
||||
...maskStyle,
|
||||
};
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
export { ChartLegend, ChartLegendContent, type ChartLegendVariant };
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import {
|
||||
getPayloadConfigFromPayload,
|
||||
getColorsCount,
|
||||
pickKey,
|
||||
useChart,
|
||||
} from "@/components/evilcharts/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TooltipRoundness = "sm" | "md" | "lg" | "xl";
|
||||
type TooltipVariant = "default" | "frosted-glass";
|
||||
|
||||
const roundnessMap: Record<TooltipRoundness, string> = {
|
||||
sm: "rounded-sm",
|
||||
md: "rounded-md",
|
||||
lg: "rounded-lg",
|
||||
xl: "rounded-xl",
|
||||
};
|
||||
|
||||
const variantMap: Record<TooltipVariant, string> = {
|
||||
default: "bg-background",
|
||||
"frosted-glass": "bg-background/70 backdrop-blur-sm",
|
||||
};
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
nameKey,
|
||||
labelKey,
|
||||
selected,
|
||||
roundness = "lg",
|
||||
variant = "default",
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
selected?: string | null;
|
||||
roundness?: TooltipRoundness;
|
||||
variant?: TooltipVariant;
|
||||
} & Omit<RechartsPrimitive.DefaultTooltipContentProps, "accessibilityLayer">) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = pickKey(labelKey, item?.dataKey, item?.name);
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
// Empty tooltip - to prevent position getting 0.0 so it doesnt animate tooltip every time from 0.0 origin
|
||||
return <span className="p-4" />;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl",
|
||||
roundnessMap[roundness],
|
||||
variantMap[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
// For pie charts, item.name contains the sector name (e.g., "chrome")
|
||||
// For radial charts, the name is in item.payload[nameKey]
|
||||
// For other charts, item.name or item.dataKey contains the series name
|
||||
const payloadName =
|
||||
nameKey && item.payload
|
||||
? // Recharts types `item.payload` as `any`; narrowing it here
|
||||
// to read a string-indexed value.
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
(item.payload as Record<string, unknown>)[nameKey]
|
||||
: undefined;
|
||||
const key = pickKey(payloadName, item.name, item.dataKey);
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
// Get colors count for this item to determine gradient vs solid
|
||||
const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
selected != null && selected !== item.dataKey && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent!":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={getIndicatorColorStyle(key, colorsCount)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between gap-4 leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIndicatorColorStyle(dataKey: string, colorsCount: number): React.CSSProperties {
|
||||
if (colorsCount <= 1) {
|
||||
return { background: `var(--color-${dataKey}-0)` };
|
||||
}
|
||||
|
||||
// Multiple colors: create linear gradient with evenly distributed stops
|
||||
const stops = Array.from({ length: colorsCount }, (_, index) => {
|
||||
const offset = (index / (colorsCount - 1)) * 100;
|
||||
return `var(--color-${dataKey}-${index}) ${offset}%`;
|
||||
}).join(", ");
|
||||
|
||||
return { background: `linear-gradient(to right, ${stops})` };
|
||||
}
|
||||
|
||||
const ChartTooltip = ({
|
||||
animationDuration = 200,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip>) => (
|
||||
<RechartsPrimitive.Tooltip animationDuration={animationDuration} {...props} />
|
||||
);
|
||||
|
||||
export { ChartTooltip, ChartTooltipContent };
|
||||
export type { TooltipRoundness, TooltipVariant };
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ActiveDot,
|
||||
Area,
|
||||
EvilAreaChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
} from "@/components/evilcharts/charts/area-chart";
|
||||
import { type ChartConfig } from "@/components/evilcharts/ui/chart";
|
||||
|
||||
type ActivityPoint = { date: string; count: number };
|
||||
|
||||
type ActivityChartProps = {
|
||||
activity30d: ReadonlyArray<ActivityPoint>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const dayLabelFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
function formatDayLabel(isoDate: string): string {
|
||||
const date = new Date(`${isoDate}T00:00:00Z`);
|
||||
if (Number.isNaN(date.getTime())) return isoDate;
|
||||
return dayLabelFormatter.format(date);
|
||||
}
|
||||
|
||||
const activityConfig = {
|
||||
count: {
|
||||
label: "Runs",
|
||||
colors: {
|
||||
light: ["var(--color-chart-4)"],
|
||||
dark: ["var(--color-chart-1)"],
|
||||
},
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
/**
|
||||
* Pulled into its own module so the route can lazy-import it. Recharts
|
||||
* (~140kb gz) lives in this chunk only and never loads until the chart
|
||||
* actually renders — direct-nav to /stats paints the chrome and KPIs
|
||||
* without paying for it.
|
||||
*/
|
||||
export default function ActivityChart({ activity30d, isLoading }: ActivityChartProps) {
|
||||
const data = activity30d.map((day) => ({
|
||||
count: day.count,
|
||||
day: formatDayLabel(day.date),
|
||||
}));
|
||||
|
||||
return (
|
||||
<EvilAreaChart
|
||||
data={data}
|
||||
config={activityConfig}
|
||||
className="aspect-auto h-24 w-full"
|
||||
curveType="monotone"
|
||||
animationType="left-to-right"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<XAxis dataKey="day" hide />
|
||||
<Tooltip cursor={false} />
|
||||
<Area dataKey="count" variant="gradient" strokeVariant="solid">
|
||||
<ActiveDot variant="colored-border" />
|
||||
</Area>
|
||||
</EvilAreaChart>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -28,8 +28,21 @@ type BarListProps = {
|
||||
* outside the bar for readability against the muted backdrop. Rows are
|
||||
* interactive when `href` is set: hover deepens the fill so the affordance is
|
||||
* felt across the whole bar, not just the link text.
|
||||
*
|
||||
* Bars carry an EvilCharts-style diagonal hatch laid over a horizontal gradient,
|
||||
* and grow in from 0% on mount with a small per-row stagger so the leaderboard
|
||||
* settles into place instead of flashing fully-formed.
|
||||
*/
|
||||
export function BarList({ data, emptyMessage = "No data yet.", className }: BarListProps) {
|
||||
// Bars render at width 0 on first commit, then transition to their real
|
||||
// width once `mounted` flips after the initial paint. Without the deferred
|
||||
// flip, the transition would have nothing to interpolate from.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => setMounted(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<p className={cn("py-2 text-xs/relaxed text-muted-foreground/70", className)}>
|
||||
@@ -40,15 +53,26 @@ export function BarList({ data, emptyMessage = "No data yet.", className }: BarL
|
||||
const max = data.reduce((acc, entry) => Math.max(acc, entry.value), 0) || 1;
|
||||
return (
|
||||
<ol className={cn("flex w-full flex-col gap-1", className)}>
|
||||
{data.map((entry) => {
|
||||
{data.map((entry, idx) => {
|
||||
const pct = (entry.value / max) * 100;
|
||||
return (
|
||||
<li key={entry.name} className="group/row relative h-7">
|
||||
<li key={entry.name} className="group/row relative h-7 overflow-hidden">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-0 bg-muted transition-colors group-hover/row:bg-foreground/15"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 bg-gradient-to-r from-muted via-muted/70 to-muted/30 transition-[width] duration-700 ease-out",
|
||||
"group-hover/row:from-foreground/20 group-hover/row:via-foreground/12 group-hover/row:to-foreground/5",
|
||||
)}
|
||||
style={{
|
||||
width: mounted ? `${pct}%` : 0,
|
||||
transitionDelay: `${Math.min(idx * 40, 240)}ms`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[image:repeating-linear-gradient(135deg,currentColor_0_1px,transparent_1px_5px)] text-foreground opacity-[0.07] transition-opacity duration-300 group-hover/row:opacity-[0.14]"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex h-full items-center justify-between gap-3 px-2 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{entry.leading}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SparkPoint = {
|
||||
/** Tooltip label / accessible description (e.g. "2026-04-12"). */
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type SparkAreaChartProps = {
|
||||
data: ReadonlyArray<SparkPoint>;
|
||||
/** Aria summary, e.g. "CLI runs per day over the last 30 days". */
|
||||
ariaLabel: string;
|
||||
/** Render height in pixels. Width is responsive via viewBox. */
|
||||
height?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const VIEW_WIDTH = 600;
|
||||
|
||||
/**
|
||||
* Inspired by Tremor Raw's SparkAreaChart but rendered as a single inline SVG
|
||||
* with no charting deps. Monotone-cubic-smoothed line with a soft fill, a
|
||||
* baseline ruler, and a "you-are-here" dot on the latest point. Each datapoint
|
||||
* has an invisible 4px-wide tooltip target via `<title>` for native hover.
|
||||
*
|
||||
* Uses `--color-chart-1`/`--color-chart-2` so it tracks the existing greyscale
|
||||
* palette and dark-mode switch automatically.
|
||||
*/
|
||||
export function SparkAreaChart({ data, ariaLabel, height = 80, className }: SparkAreaChartProps) {
|
||||
if (data.length < 2) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center text-xs text-muted-foreground", className)}
|
||||
style={{ height }}
|
||||
>
|
||||
Not enough data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = data.reduce((acc, point) => Math.max(acc, point.value), 0);
|
||||
const safeMax = max === 0 ? 1 : max;
|
||||
const stepX = VIEW_WIDTH / (data.length - 1);
|
||||
const baselineY = height - 1;
|
||||
const points = data.map((point, i) => ({
|
||||
x: i * stepX,
|
||||
y: height - (point.value / safeMax) * (height - 4) - 2,
|
||||
point,
|
||||
}));
|
||||
|
||||
const linePath = monotonePath(points);
|
||||
const fillPath = `${linePath} L${VIEW_WIDTH.toFixed(2)},${baselineY.toFixed(2)} L0,${baselineY.toFixed(2)} Z`;
|
||||
const last = points[points.length - 1];
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full", className)} style={{ height }}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="block size-full overflow-visible"
|
||||
>
|
||||
<line
|
||||
x1={0}
|
||||
x2={VIEW_WIDTH}
|
||||
y1={baselineY}
|
||||
y2={baselineY}
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path d={fillPath} fill="var(--color-chart-1)" fillOpacity={0.35} />
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="var(--color-chart-2)"
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{points.map(({ x, point }) => (
|
||||
<rect key={point.label} x={x - 2} y={0} width={4} height={height} fill="transparent">
|
||||
<title>{`${point.label}: ${point.value}`}</title>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
{/*
|
||||
* The chart SVG uses `preserveAspectRatio="none"` so the curve stretches
|
||||
* to the container width. That stretches any inline <circle> into a
|
||||
* horizontal oval, so the last-point dot lives in the DOM instead and
|
||||
* stays perfectly round at any width.
|
||||
*/}
|
||||
{last ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute size-[9px] rounded-full border-[1.5px] border-foreground bg-background"
|
||||
style={{
|
||||
right: 0,
|
||||
top: `${(last.y / height) * 100}%`,
|
||||
transform: "translate(50%, -50%)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotone cubic interpolation (Fritsch–Carlson). Produces a smooth curve that
|
||||
* never overshoots between samples — important for data viz, where straight
|
||||
* Catmull-Rom can wiggle above peaks and misrepresent the dataset.
|
||||
*/
|
||||
function monotonePath(points: ReadonlyArray<{ x: number; y: number }>): string {
|
||||
const n = points.length;
|
||||
if (n === 0) return "";
|
||||
if (n === 1) {
|
||||
const p = points[0]!;
|
||||
return `M${p.x.toFixed(2)},${p.y.toFixed(2)}`;
|
||||
}
|
||||
|
||||
const slopes: number[] = [];
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const a = points[i]!;
|
||||
const b = points[i + 1]!;
|
||||
slopes.push((b.y - a.y) / (b.x - a.x));
|
||||
}
|
||||
|
||||
const tangents: number[] = Array.from({ length: n }, () => 0);
|
||||
tangents[0] = slopes[0]!;
|
||||
tangents[n - 1] = slopes[n - 2]!;
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
const prev = slopes[i - 1]!;
|
||||
const next = slopes[i]!;
|
||||
tangents[i] = prev * next <= 0 ? 0 : (prev + next) / 2;
|
||||
}
|
||||
// Enforce monotonicity: clamp tangents per Fritsch–Carlson.
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const s = slopes[i]!;
|
||||
if (s === 0) {
|
||||
tangents[i] = 0;
|
||||
tangents[i + 1] = 0;
|
||||
continue;
|
||||
}
|
||||
const a = tangents[i]! / s;
|
||||
const b = tangents[i + 1]! / s;
|
||||
const h = Math.hypot(a, b);
|
||||
if (h > 3) {
|
||||
const t = 3 / h;
|
||||
tangents[i] = t * a * s;
|
||||
tangents[i + 1] = t * b * s;
|
||||
}
|
||||
}
|
||||
|
||||
const first = points[0]!;
|
||||
let path = `M${first.x.toFixed(2)},${first.y.toFixed(2)}`;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const a = points[i]!;
|
||||
const b = points[i + 1]!;
|
||||
const dx = (b.x - a.x) / 3;
|
||||
const c1x = a.x + dx;
|
||||
const c1y = a.y + tangents[i]! * dx;
|
||||
const c2x = b.x - dx;
|
||||
const c2y = b.y - tangents[i + 1]! * dx;
|
||||
path += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${b.x.toFixed(2)},${b.y.toFixed(2)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -13,23 +13,29 @@ type TooltipContextValue = {
|
||||
|
||||
const TooltipContext = React.createContext<TooltipContextValue | null>(null);
|
||||
|
||||
function Tooltip({ ...props }: PopoverPrimitive.Root.Props & TooltipPrimitive.Root.Props) {
|
||||
function TooltipProvider({ delay = 0, children, ...props }: TooltipPrimitive.Provider.Props) {
|
||||
const { isTouchDevice } = usePointerCapability();
|
||||
const contextValue = React.useMemo(() => ({ isTouchDevice }), [isTouchDevice]);
|
||||
|
||||
return (
|
||||
<TooltipContext.Provider value={contextValue}>
|
||||
{isTouchDevice ? (
|
||||
<PopoverPrimitive.Root data-slot="tooltip" {...props} />
|
||||
) : (
|
||||
<TooltipPrimitive.Provider delay={0}>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipPrimitive.Provider>
|
||||
)}
|
||||
<TooltipPrimitive.Provider delay={delay} {...props}>
|
||||
{children}
|
||||
</TooltipPrimitive.Provider>
|
||||
</TooltipContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: PopoverPrimitive.Root.Props & TooltipPrimitive.Root.Props) {
|
||||
const { isTouchDevice } = useTooltipContext("Tooltip");
|
||||
|
||||
return isTouchDevice ? (
|
||||
<PopoverPrimitive.Root data-slot="tooltip" {...props} />
|
||||
) : (
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
nativeButton,
|
||||
closeDelay,
|
||||
@@ -95,10 +101,10 @@ function useTooltipContext(componentName: string) {
|
||||
const ctx = React.use(TooltipContext);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error(`${componentName} must be used within <Tooltip>.`);
|
||||
throw new Error(`${componentName} must be used within <TooltipProvider>.`);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent };
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PostHogProvider } from "@/components/posthog-provider";
|
||||
import { RouteErrorBoundary, RouteNotFoundBoundary } from "@/components/route-boundaries";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getDocsIndex } from "@/server/docs-index.functions";
|
||||
import { getRegistryIndex } from "@/server/registry-index.functions";
|
||||
|
||||
@@ -45,21 +46,23 @@ function RootComponent() {
|
||||
<body className="min-h-svh bg-background font-sans text-foreground tabular-nums antialiased">
|
||||
<PostHogProvider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:top-4 focus-visible:left-4 focus-visible:z-50 focus-visible:rounded-none focus-visible:border focus-visible:border-border focus-visible:bg-background focus-visible:px-3 focus-visible:py-2 focus-visible:text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<NavigationProgress />
|
||||
<div className="flex min-h-svh flex-col">
|
||||
<Header />
|
||||
<main id="main" className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<Toaster />
|
||||
<TooltipProvider>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:top-4 focus-visible:left-4 focus-visible:z-50 focus-visible:rounded-none focus-visible:border focus-visible:border-border focus-visible:bg-background focus-visible:px-3 focus-visible:py-2 focus-visible:text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<NavigationProgress />
|
||||
<div className="flex min-h-svh flex-col">
|
||||
<Header />
|
||||
<main id="main" className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
<TanStackDevtools
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import type { CategoryId, ModuleSummary } from "@stanza/registry";
|
||||
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
|
||||
import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { ModuleLogo } from "@/components/module-logo";
|
||||
import { BarList } from "@/components/ui/bar-list";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SparkAreaChart } from "@/components/ui/spark-chart";
|
||||
import { buildHead } from "@/lib/seo";
|
||||
import { getStats } from "@/server/stats.functions";
|
||||
import { getStats, type Stats } from "@/server/stats.functions";
|
||||
|
||||
const ActivityChart = lazy(() => import("@/components/stats/activity-chart"));
|
||||
|
||||
export const Route = createFileRoute("/stats")({
|
||||
loader: () => getStats(),
|
||||
// Stats are revalidated server-side by the cache TTL; client doesn't need
|
||||
// to refetch on every navigation.
|
||||
staleTime: 60_000,
|
||||
head: () =>
|
||||
buildHead({
|
||||
title: "Stats",
|
||||
@@ -49,13 +47,33 @@ function moduleHref(category: CategoryId, id: string): string {
|
||||
}
|
||||
|
||||
function StatsPage() {
|
||||
const stats = Route.useLoaderData();
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const { registry } = useLoaderData({ from: "__root__" });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const result = await getStats();
|
||||
if (!cancelled) setStats(result);
|
||||
} catch {
|
||||
// Swallow: the shell already shows the em-dash / "no data yet"
|
||||
// empty state, which is the right UX for a public dashboard that
|
||||
// happens to hit a transient PostHog blip.
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoading = stats === null;
|
||||
|
||||
const findModule = (category: CategoryId, id: string): ModuleSummary | undefined =>
|
||||
registry.modules.find((m: ModuleSummary) => m.category === category && m.id === id);
|
||||
|
||||
const activitySum = stats.activity30d.reduce((acc, day) => acc + day.count, 0);
|
||||
const activitySum = stats?.activity30d.reduce((acc, day) => acc + day.count, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
|
||||
@@ -68,9 +86,11 @@ function StatsPage() {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
|
||||
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
|
||||
</p>
|
||||
{stats ? (
|
||||
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
|
||||
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="mb-4 grid gap-4 sm:grid-cols-2">
|
||||
@@ -82,7 +102,7 @@ function StatsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
|
||||
{formatCount(stats.projectsScaffolded)}
|
||||
{formatCount(stats?.projectsScaffolded ?? 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -94,7 +114,7 @@ function StatsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
|
||||
{formatCount(stats.modulesInstalled)}
|
||||
{formatCount(stats?.modulesInstalled ?? 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -107,17 +127,17 @@ function StatsPage() {
|
||||
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||
CLI runs · last 30 days
|
||||
</CardTitle>
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{activitySum > 0 ? `${numberFormatter.format(activitySum)} total` : "no runs yet"}
|
||||
</span>
|
||||
{!isLoading ? (
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{activitySum > 0 ? `${numberFormatter.format(activitySum)} total` : "no runs yet"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-1">
|
||||
<SparkAreaChart
|
||||
data={stats.activity30d.map((day) => ({ label: day.date, value: day.count }))}
|
||||
ariaLabel="CLI runs per day over the last 30 days"
|
||||
height={96}
|
||||
/>
|
||||
<Suspense fallback={<div aria-hidden className="h-24 w-full" />}>
|
||||
<ActivityChart activity30d={stats?.activity30d ?? []} isLoading={isLoading} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
@@ -126,7 +146,7 @@ function StatsPage() {
|
||||
<h2 className="mb-4 text-lg font-semibold tracking-tight">Popular modules by category</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{KNOWN_CATEGORIES.map((category) => {
|
||||
const entries = stats.perCategory[category] ?? [];
|
||||
const entries = stats?.perCategory[category] ?? [];
|
||||
const totalInCategory = entries.reduce((acc, entry) => acc + entry.count, 0);
|
||||
return (
|
||||
<Card key={category}>
|
||||
@@ -144,6 +164,7 @@ function StatsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarList
|
||||
emptyMessage={isLoading ? "Loading…" : "No data yet."}
|
||||
data={entries.map((entry) => {
|
||||
const summary = findModule(category, entry.id);
|
||||
const label = summary?.label ?? entry.id;
|
||||
|
||||
@@ -66,6 +66,7 @@ export default defineConfig({
|
||||
"@base-ui/react/tooltip",
|
||||
"@base-ui/react/merge-props",
|
||||
"@base-ui/react/use-render",
|
||||
"recharts",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+292
@@ -11,6 +11,7 @@ catalogs:
|
||||
version: 0.1.22
|
||||
|
||||
overrides:
|
||||
es-toolkit: 1.46.1
|
||||
vite: npm:@voidzero-dev/vite-plus-core@^0.1.22
|
||||
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.22
|
||||
|
||||
@@ -154,6 +155,9 @@ importers:
|
||||
lru-cache:
|
||||
specifier: ^11.5.0
|
||||
version: 11.5.0
|
||||
motion:
|
||||
specifier: ^12.40.0
|
||||
version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
nitro:
|
||||
specifier: ^3.0.260522-beta
|
||||
version: 3.0.260522-beta(@vercel/functions@3.6.0)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(lru-cache@11.5.0)
|
||||
@@ -172,6 +176,9 @@ importers:
|
||||
react-resizable-panels:
|
||||
specifier: ^4.11.2
|
||||
version: 4.11.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
recharts:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1)
|
||||
shadcn:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.0(@types/node@25.9.1)(typescript@6.0.3)
|
||||
@@ -1639,6 +1646,17 @@ packages:
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0':
|
||||
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.2':
|
||||
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1811,6 +1829,9 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@tabler/icons-react@3.44.0':
|
||||
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
|
||||
peerDependencies:
|
||||
@@ -2253,6 +2274,33 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
@@ -2309,6 +2357,9 @@ packages:
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2':
|
||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||
|
||||
@@ -2809,6 +2860,50 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
data-uri-to-buffer@4.0.1:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -2855,6 +2950,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -3026,6 +3124,9 @@ packages:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.46.1:
|
||||
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
|
||||
|
||||
esast-util-from-estree@2.0.0:
|
||||
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
|
||||
|
||||
@@ -3081,6 +3182,9 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
eventsource-parser@3.0.8:
|
||||
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3483,6 +3587,12 @@ packages:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.8:
|
||||
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3493,6 +3603,10 @@ packages:
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -4421,6 +4535,18 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-redux@9.3.0:
|
||||
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-remove-scroll-bar@2.3.8:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4473,6 +4599,14 @@ packages:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
recharts@3.8.1:
|
||||
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
||||
|
||||
@@ -4487,6 +4621,14 @@ packages:
|
||||
recma-stringify@1.0.0:
|
||||
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
@@ -4528,6 +4670,9 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
reselect@5.2.0:
|
||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||
|
||||
@@ -5075,6 +5220,9 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-plus@0.1.22:
|
||||
resolution: {integrity: sha512-fCCmEKjI+Hv74PdL/MKcrBkdYPHFNcqD5568KxwN0sa4SGxtcbs55i/577LxKs0w5zIjuLRZZ0zQPu9MO+9itg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -6368,6 +6516,18 @@ snapshots:
|
||||
|
||||
'@protobufjs/utf8@1.1.1': {}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.8
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.2.0
|
||||
optionalDependencies:
|
||||
react: 19.2.6
|
||||
react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1)
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.2':
|
||||
optional: true
|
||||
|
||||
@@ -6501,6 +6661,8 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@tabler/icons-react@3.44.0(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tabler/icons': 3.44.0
|
||||
@@ -7006,6 +7168,30 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
@@ -7059,6 +7245,8 @@ snapshots:
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2': {}
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
@@ -7472,6 +7660,44 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
data-urls@7.0.0(@noble/hashes@1.8.0):
|
||||
@@ -7491,6 +7717,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
@@ -7622,6 +7850,8 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-toolkit@1.46.1: {}
|
||||
|
||||
esast-util-from-estree@2.0.0:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
@@ -7712,6 +7942,8 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
eventsource-parser@3.0.8: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
@@ -8199,6 +8431,10 @@ snapshots:
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.8: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -8208,6 +8444,8 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
@@ -9388,6 +9626,15 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.6
|
||||
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
redux: 5.0.1
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -9439,6 +9686,26 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
recharts@3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.46.1
|
||||
eventemitter3: 5.0.4
|
||||
immer: 10.2.0
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -9468,6 +9735,12 @@ snapshots:
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
@@ -9546,6 +9819,8 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
reselect@5.2.0: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@@ -10075,6 +10350,23 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-plus@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.129.0
|
||||
|
||||
@@ -15,6 +15,7 @@ onlyBuiltDependencies:
|
||||
- msw
|
||||
- protobufjs
|
||||
overrides:
|
||||
es-toolkit: 1.46.1
|
||||
vite: "catalog:"
|
||||
vitest: "catalog:"
|
||||
peerDependencyRules:
|
||||
|
||||
Reference in New Issue
Block a user