mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-10-16 06:15:43 -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:
19 files changed
+3357
-227
No files matched your search
+4
-4
@@ -6,6 +6,7 @@ dist/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.tanstack/
|
.tanstack/
|
||||||
.source/
|
.source/
|
||||||
|
.vercel
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -22,10 +23,9 @@ pnpm-debug.log*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Env
|
# Secrets
|
||||||
.env
|
.env*
|
||||||
.env.local
|
!.env.example
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -21,5 +21,7 @@
|
|||||||
},
|
},
|
||||||
"menuColor": "default-translucent",
|
"menuColor": "default-translucent",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle",
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@evilcharts": "https://evilcharts.com/r/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -34,12 +34,14 @@
|
|||||||
"fumadocs-mdx": "^15.0.9",
|
"fumadocs-mdx": "^15.0.9",
|
||||||
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
|
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
|
||||||
"lru-cache": "^11.5.0",
|
"lru-cache": "^11.5.0",
|
||||||
|
"motion": "^12.40.0",
|
||||||
"nitro": "^3.0.260522-beta",
|
"nitro": "^3.0.260522-beta",
|
||||||
"posthog-js": "^1.376.2",
|
"posthog-js": "^1.376.2",
|
||||||
"posthog-node": "^5.35.4",
|
"posthog-node": "^5.35.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-resizable-panels": "^4.11.2",
|
"react-resizable-panels": "^4.11.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"shadcn": "^4.8.0",
|
"shadcn": "^4.8.0",
|
||||||
"shiki": "^4.1.0",
|
"shiki": "^4.1.0",
|
||||||
"sonner": "^2.0.7",
|
"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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -28,8 +28,21 @@ type BarListProps = {
|
|||||||
* outside the bar for readability against the muted backdrop. Rows are
|
* outside the bar for readability against the muted backdrop. Rows are
|
||||||
* interactive when `href` is set: hover deepens the fill so the affordance is
|
* interactive when `href` is set: hover deepens the fill so the affordance is
|
||||||
* felt across the whole bar, not just the link text.
|
* 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) {
|
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) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className={cn("py-2 text-xs/relaxed text-muted-foreground/70", className)}>
|
<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;
|
const max = data.reduce((acc, entry) => Math.max(acc, entry.value), 0) || 1;
|
||||||
return (
|
return (
|
||||||
<ol className={cn("flex w-full flex-col gap-1", className)}>
|
<ol className={cn("flex w-full flex-col gap-1", className)}>
|
||||||
{data.map((entry) => {
|
{data.map((entry, idx) => {
|
||||||
const pct = (entry.value / max) * 100;
|
const pct = (entry.value / max) * 100;
|
||||||
return (
|
return (
|
||||||
<li key={entry.name} className="group/row relative h-7">
|
<li key={entry.name} className="group/row relative h-7 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-y-0 left-0 bg-muted transition-colors group-hover/row:bg-foreground/15"
|
className={cn(
|
||||||
style={{ width: `${pct}%` }}
|
"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="relative flex h-full items-center justify-between gap-3 px-2 text-xs">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{entry.leading}
|
{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);
|
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 { isTouchDevice } = usePointerCapability();
|
||||||
const contextValue = React.useMemo(() => ({ isTouchDevice }), [isTouchDevice]);
|
const contextValue = React.useMemo(() => ({ isTouchDevice }), [isTouchDevice]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipContext.Provider value={contextValue}>
|
<TooltipContext.Provider value={contextValue}>
|
||||||
{isTouchDevice ? (
|
<TooltipPrimitive.Provider delay={delay} {...props}>
|
||||||
<PopoverPrimitive.Root data-slot="tooltip" {...props} />
|
{children}
|
||||||
) : (
|
</TooltipPrimitive.Provider>
|
||||||
<TooltipPrimitive.Provider delay={0}>
|
|
||||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
||||||
</TooltipPrimitive.Provider>
|
|
||||||
)}
|
|
||||||
</TooltipContext.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({
|
function TooltipTrigger({
|
||||||
nativeButton,
|
nativeButton,
|
||||||
closeDelay,
|
closeDelay,
|
||||||
@@ -95,10 +101,10 @@ function useTooltipContext(componentName: string) {
|
|||||||
const ctx = React.use(TooltipContext);
|
const ctx = React.use(TooltipContext);
|
||||||
|
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error(`${componentName} must be used within <Tooltip>.`);
|
throw new Error(`${componentName} must be used within <TooltipProvider>.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx;
|
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 { RouteErrorBoundary, RouteNotFoundBoundary } from "@/components/route-boundaries";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { getDocsIndex } from "@/server/docs-index.functions";
|
import { getDocsIndex } from "@/server/docs-index.functions";
|
||||||
import { getRegistryIndex } from "@/server/registry-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">
|
<body className="min-h-svh bg-background font-sans text-foreground tabular-nums antialiased">
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
|
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
|
||||||
<a
|
<TooltipProvider>
|
||||||
href="#main"
|
<a
|
||||||
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"
|
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>
|
Skip to content
|
||||||
<NavigationProgress />
|
</a>
|
||||||
<div className="flex min-h-svh flex-col">
|
<NavigationProgress />
|
||||||
<Header />
|
<div className="flex min-h-svh flex-col">
|
||||||
<main id="main" className="flex-1">
|
<Header />
|
||||||
<Outlet />
|
<main id="main" className="flex-1">
|
||||||
</main>
|
<Outlet />
|
||||||
<Footer />
|
</main>
|
||||||
</div>
|
<Footer />
|
||||||
<Toaster />
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import type { CategoryId, ModuleSummary } from "@stanza/registry";
|
import type { CategoryId, ModuleSummary } from "@stanza/registry";
|
||||||
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
|
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
|
||||||
import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
|
import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
|
||||||
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { ModuleLogo } from "@/components/module-logo";
|
import { ModuleLogo } from "@/components/module-logo";
|
||||||
import { BarList } from "@/components/ui/bar-list";
|
import { BarList } from "@/components/ui/bar-list";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { SparkAreaChart } from "@/components/ui/spark-chart";
|
|
||||||
import { buildHead } from "@/lib/seo";
|
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")({
|
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: () =>
|
head: () =>
|
||||||
buildHead({
|
buildHead({
|
||||||
title: "Stats",
|
title: "Stats",
|
||||||
@@ -49,13 +47,33 @@ function moduleHref(category: CategoryId, id: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatsPage() {
|
function StatsPage() {
|
||||||
const stats = Route.useLoaderData();
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const { registry } = useLoaderData({ from: "__root__" });
|
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 =>
|
const findModule = (category: CategoryId, id: string): ModuleSummary | undefined =>
|
||||||
registry.modules.find((m: ModuleSummary) => m.category === category && m.id === id);
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
|
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
|
||||||
@@ -68,9 +86,11 @@ function StatsPage() {
|
|||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
|
{stats ? (
|
||||||
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
|
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
|
||||||
</p>
|
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mb-4 grid gap-4 sm:grid-cols-2">
|
<section className="mb-4 grid gap-4 sm:grid-cols-2">
|
||||||
@@ -82,7 +102,7 @@ function StatsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
|
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
|
||||||
{formatCount(stats.projectsScaffolded)}
|
{formatCount(stats?.projectsScaffolded ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -94,7 +114,7 @@ function StatsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
|
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
|
||||||
{formatCount(stats.modulesInstalled)}
|
{formatCount(stats?.modulesInstalled ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -107,17 +127,17 @@ function StatsPage() {
|
|||||||
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
CLI runs · last 30 days
|
CLI runs · last 30 days
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
{!isLoading ? (
|
||||||
{activitySum > 0 ? `${numberFormatter.format(activitySum)} total` : "no runs yet"}
|
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
</span>
|
{activitySum > 0 ? `${numberFormatter.format(activitySum)} total` : "no runs yet"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-1">
|
<CardContent className="pb-1">
|
||||||
<SparkAreaChart
|
<Suspense fallback={<div aria-hidden className="h-24 w-full" />}>
|
||||||
data={stats.activity30d.map((day) => ({ label: day.date, value: day.count }))}
|
<ActivityChart activity30d={stats?.activity30d ?? []} isLoading={isLoading} />
|
||||||
ariaLabel="CLI runs per day over the last 30 days"
|
</Suspense>
|
||||||
height={96}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
@@ -126,7 +146,7 @@ function StatsPage() {
|
|||||||
<h2 className="mb-4 text-lg font-semibold tracking-tight">Popular modules by category</h2>
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{KNOWN_CATEGORIES.map((category) => {
|
{KNOWN_CATEGORIES.map((category) => {
|
||||||
const entries = stats.perCategory[category] ?? [];
|
const entries = stats?.perCategory[category] ?? [];
|
||||||
const totalInCategory = entries.reduce((acc, entry) => acc + entry.count, 0);
|
const totalInCategory = entries.reduce((acc, entry) => acc + entry.count, 0);
|
||||||
return (
|
return (
|
||||||
<Card key={category}>
|
<Card key={category}>
|
||||||
@@ -144,6 +164,7 @@ function StatsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BarList
|
<BarList
|
||||||
|
emptyMessage={isLoading ? "Loading…" : "No data yet."}
|
||||||
data={entries.map((entry) => {
|
data={entries.map((entry) => {
|
||||||
const summary = findModule(category, entry.id);
|
const summary = findModule(category, entry.id);
|
||||||
const label = summary?.label ?? entry.id;
|
const label = summary?.label ?? entry.id;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default defineConfig({
|
|||||||
"@base-ui/react/tooltip",
|
"@base-ui/react/tooltip",
|
||||||
"@base-ui/react/merge-props",
|
"@base-ui/react/merge-props",
|
||||||
"@base-ui/react/use-render",
|
"@base-ui/react/use-render",
|
||||||
|
"recharts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Generated
+292
@@ -11,6 +11,7 @@ catalogs:
|
|||||||
version: 0.1.22
|
version: 0.1.22
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
|
es-toolkit: 1.46.1
|
||||||
vite: npm:@voidzero-dev/vite-plus-core@^0.1.22
|
vite: npm:@voidzero-dev/vite-plus-core@^0.1.22
|
||||||
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.22
|
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.22
|
||||||
|
|
||||||
@@ -154,6 +155,9 @@ importers:
|
|||||||
lru-cache:
|
lru-cache:
|
||||||
specifier: ^11.5.0
|
specifier: ^11.5.0
|
||||||
version: 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:
|
nitro:
|
||||||
specifier: ^3.0.260522-beta
|
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)
|
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:
|
react-resizable-panels:
|
||||||
specifier: ^4.11.2
|
specifier: ^4.11.2
|
||||||
version: 4.11.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
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:
|
shadcn:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.0(@types/node@25.9.1)(typescript@6.0.3)
|
version: 4.8.0(@types/node@25.9.1)(typescript@6.0.3)
|
||||||
@@ -1639,6 +1646,17 @@ packages:
|
|||||||
'@protobufjs/utf8@1.1.1':
|
'@protobufjs/utf8@1.1.1':
|
||||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
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':
|
'@rolldown/binding-android-arm64@1.0.2':
|
||||||
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
|
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1811,6 +1829,9 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
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':
|
'@tabler/icons-react@3.44.0':
|
||||||
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
|
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2253,6 +2274,33 @@ packages:
|
|||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
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':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
@@ -2309,6 +2357,9 @@ packages:
|
|||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
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':
|
'@types/validate-npm-package-name@4.0.2':
|
||||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||||
|
|
||||||
@@ -2809,6 +2860,50 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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:
|
data-uri-to-buffer@4.0.1:
|
||||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
@@ -2855,6 +2950,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1:
|
||||||
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@@ -3026,6 +3124,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.46.1:
|
||||||
|
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
|
||||||
|
|
||||||
esast-util-from-estree@2.0.0:
|
esast-util-from-estree@2.0.0:
|
||||||
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
|
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
|
||||||
|
|
||||||
@@ -3081,6 +3182,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4:
|
||||||
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
eventsource-parser@3.0.8:
|
eventsource-parser@3.0.8:
|
||||||
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
|
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -3483,6 +3587,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
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:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3493,6 +3603,10 @@ packages:
|
|||||||
inline-style-parser@0.2.7:
|
inline-style-parser@0.2.7:
|
||||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
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:
|
ip-address@10.2.0:
|
||||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
@@ -4421,6 +4535,18 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
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:
|
react-remove-scroll-bar@2.3.8:
|
||||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4473,6 +4599,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||||
engines: {node: '>= 4'}
|
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:
|
recma-build-jsx@1.0.0:
|
||||||
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
||||||
|
|
||||||
@@ -4487,6 +4621,14 @@ packages:
|
|||||||
recma-stringify@1.0.0:
|
recma-stringify@1.0.0:
|
||||||
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
|
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:
|
regex-recursion@6.0.2:
|
||||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||||
|
|
||||||
@@ -4528,6 +4670,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reselect@5.1.1:
|
||||||
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
reselect@5.2.0:
|
reselect@5.2.0:
|
||||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||||
|
|
||||||
@@ -5075,6 +5220,9 @@ packages:
|
|||||||
vfile@6.0.3:
|
vfile@6.0.3:
|
||||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
vite-plus@0.1.22:
|
vite-plus@0.1.22:
|
||||||
resolution: {integrity: sha512-fCCmEKjI+Hv74PdL/MKcrBkdYPHFNcqD5568KxwN0sa4SGxtcbs55i/577LxKs0w5zIjuLRZZ0zQPu9MO+9itg==}
|
resolution: {integrity: sha512-fCCmEKjI+Hv74PdL/MKcrBkdYPHFNcqD5568KxwN0sa4SGxtcbs55i/577LxKs0w5zIjuLRZZ0zQPu9MO+9itg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -6368,6 +6516,18 @@ snapshots:
|
|||||||
|
|
||||||
'@protobufjs/utf8@1.1.1': {}
|
'@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':
|
'@rolldown/binding-android-arm64@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6501,6 +6661,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@tabler/icons-react@3.44.0(react@19.2.6)':
|
'@tabler/icons-react@3.44.0(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tabler/icons': 3.44.0
|
'@tabler/icons': 3.44.0
|
||||||
@@ -7006,6 +7168,30 @@ snapshots:
|
|||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
assertion-error: 2.0.1
|
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':
|
'@types/debug@4.1.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@@ -7059,6 +7245,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2': {}
|
'@types/validate-npm-package-name@4.0.2': {}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.1': {}
|
'@ungap/structured-clone@1.3.1': {}
|
||||||
@@ -7472,6 +7660,44 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
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-uri-to-buffer@4.0.1: {}
|
||||||
|
|
||||||
data-urls@7.0.0(@noble/hashes@1.8.0):
|
data-urls@7.0.0(@noble/hashes@1.8.0):
|
||||||
@@ -7491,6 +7717,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.3.0:
|
decode-named-character-reference@1.3.0:
|
||||||
@@ -7622,6 +7850,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
es-toolkit@1.46.1: {}
|
||||||
|
|
||||||
esast-util-from-estree@2.0.0:
|
esast-util-from-estree@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree-jsx': 1.0.5
|
'@types/estree-jsx': 1.0.5
|
||||||
@@ -7712,6 +7942,8 @@ snapshots:
|
|||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
eventsource-parser@3.0.8: {}
|
eventsource-parser@3.0.8: {}
|
||||||
|
|
||||||
eventsource@3.0.7:
|
eventsource@3.0.7:
|
||||||
@@ -8199,6 +8431,10 @@ snapshots:
|
|||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
|
immer@10.2.0: {}
|
||||||
|
|
||||||
|
immer@11.1.8: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -8208,6 +8444,8 @@ snapshots:
|
|||||||
|
|
||||||
inline-style-parser@0.2.7: {}
|
inline-style-parser@0.2.7: {}
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
ip-address@10.2.0: {}
|
ip-address@10.2.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
@@ -9388,6 +9626,15 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
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):
|
react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
@@ -9439,6 +9686,26 @@ snapshots:
|
|||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
tslib: 2.8.1
|
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:
|
recma-build-jsx@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -9468,6 +9735,12 @@ snapshots:
|
|||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
vfile: 6.0.3
|
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:
|
regex-recursion@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
@@ -9546,6 +9819,8 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
reselect@5.2.0: {}
|
reselect@5.2.0: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -10075,6 +10350,23 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.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):
|
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:
|
dependencies:
|
||||||
'@oxc-project/types': 0.129.0
|
'@oxc-project/types': 0.129.0
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ onlyBuiltDependencies:
|
|||||||
- msw
|
- msw
|
||||||
- protobufjs
|
- protobufjs
|
||||||
overrides:
|
overrides:
|
||||||
|
es-toolkit: 1.46.1
|
||||||
vite: "catalog:"
|
vite: "catalog:"
|
||||||
vitest: "catalog:"
|
vitest: "catalog:"
|
||||||
peerDependencyRules:
|
peerDependencyRules:
|
||||||
|
|||||||
Reference in new issue
Block a user