feat: replace Recharts dashboard chart with visx area chart
Integrated 21st.dev AreaChart component with animated crosshair, spring-based tooltip, and date ticker. Uses brand orange for the line/fill with dark-only CSS variables.
This commit is contained in:
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||||
import { useTheme } from '@ciphera-net/ui'
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis, ReferenceLine } from 'recharts'
|
import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip, type TooltipRow } from '@/components/ui/area-chart'
|
||||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
|
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
||||||
import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
|
import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
|
||||||
@@ -103,40 +102,11 @@ const METRIC_CONFIGS: {
|
|||||||
{ key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) },
|
{ key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) },
|
||||||
]
|
]
|
||||||
|
|
||||||
const chartConfig = {
|
const CHART_COLORS: Record<MetricType, string> = {
|
||||||
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
|
visitors: '#FD5E0F',
|
||||||
pageviews: { label: 'Total Pageviews', color: '#FD5E0F' },
|
pageviews: '#FD5E0F',
|
||||||
bounce_rate: { label: 'Bounce Rate', color: '#FD5E0F' },
|
bounce_rate: '#FD5E0F',
|
||||||
avg_duration: { label: 'Visit Duration', color: '#FD5E0F' },
|
avg_duration: '#FD5E0F',
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
// ─── Custom Tooltip ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean
|
|
||||||
payload?: Array<{ dataKey: string; value: number; color: string }>
|
|
||||||
label?: string
|
|
||||||
metric: MetricType
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomTooltip({ active, payload, metric }: TooltipProps) {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const entry = payload[0]
|
|
||||||
const config = METRIC_CONFIGS.find((m) => m.key === metric)
|
|
||||||
|
|
||||||
if (config) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[120px]">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="size-1.5 rounded-full" style={{ backgroundColor: entry.color }}></div>
|
|
||||||
<span className="text-neutral-500 dark:text-neutral-400">{config.label}:</span>
|
|
||||||
<span className="font-semibold text-neutral-900 dark:text-white">{config.format(entry.value)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chart Component ─────────────────────────────────────────────────
|
// ─── Chart Component ─────────────────────────────────────────────────
|
||||||
@@ -227,6 +197,7 @@ export default function Chart({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
date: formattedDate,
|
date: formattedDate,
|
||||||
|
dateObj: new Date(item.date),
|
||||||
originalDate: item.date,
|
originalDate: item.date,
|
||||||
pageviews: item.pageviews,
|
pageviews: item.pageviews,
|
||||||
visitors: item.visitors,
|
visitors: item.visitors,
|
||||||
@@ -450,103 +421,41 @@ export default function Chart({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full" onContextMenu={handleChartContextMenu}>
|
<div className="w-full" onContextMenu={handleChartContextMenu}>
|
||||||
<ChartContainer
|
<VisxAreaChart
|
||||||
config={chartConfig}
|
data={chartData as Record<string, unknown>[]}
|
||||||
className="h-96 w-full overflow-visible [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
|
xDataKey="dateObj"
|
||||||
|
aspectRatio="2.5 / 1"
|
||||||
|
margin={{ top: 20, right: 20, bottom: 40, left: 50 }}
|
||||||
>
|
>
|
||||||
<ComposedChart
|
<VisxGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
|
||||||
data={chartData}
|
<VisxArea
|
||||||
margin={{ top: 20, right: 20, left: 5, bottom: 20 }}
|
dataKey={metric}
|
||||||
style={{ overflow: 'visible' }}
|
fill={CHART_COLORS[metric]}
|
||||||
>
|
fillOpacity={0.15}
|
||||||
<defs>
|
stroke={CHART_COLORS[metric]}
|
||||||
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
|
strokeWidth={2}
|
||||||
<stop offset="0%" stopColor={chartConfig[metric]?.color} stopOpacity={0.15} />
|
gradientToOpacity={0}
|
||||||
<stop offset="100%" stopColor={chartConfig[metric]?.color} stopOpacity={0.01} />
|
/>
|
||||||
</linearGradient>
|
<VisxXAxis numTicks={6} />
|
||||||
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
|
<VisxYAxis
|
||||||
<feDropShadow
|
numTicks={6}
|
||||||
dx="4"
|
formatValue={(v) => {
|
||||||
dy="6"
|
const config = METRIC_CONFIGS.find((m) => m.key === metric)
|
||||||
stdDeviation="25"
|
return config ? config.format(v) : v.toString()
|
||||||
floodColor={`${chartConfig[metric]?.color}60`}
|
}}
|
||||||
/>
|
/>
|
||||||
</filter>
|
<VisxChartTooltip
|
||||||
<filter id="dotShadow" x="-50%" y="-50%" width="200%" height="200%">
|
rows={(point) => {
|
||||||
<feDropShadow dx="2" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.5)" />
|
const config = METRIC_CONFIGS.find((m) => m.key === metric)
|
||||||
</filter>
|
const value = point[metric] as number
|
||||||
</defs>
|
return [{
|
||||||
|
color: CHART_COLORS[metric],
|
||||||
<CartesianGrid
|
label: config?.label || metric,
|
||||||
horizontal={true}
|
value: config ? config.format(value) : value,
|
||||||
vertical={false}
|
}]
|
||||||
stroke="var(--chart-grid)"
|
}}
|
||||||
strokeOpacity={0.7}
|
/>
|
||||||
/>
|
</VisxAreaChart>
|
||||||
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
|
|
||||||
tickMargin={10}
|
|
||||||
minTickGap={32}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<YAxis
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
|
|
||||||
tickMargin={10}
|
|
||||||
tickCount={6}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const config = METRIC_CONFIGS.find((m) => m.key === metric)
|
|
||||||
return config ? config.format(value) : value.toString()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
|
|
||||||
|
|
||||||
|
|
||||||
{/* Annotation reference lines */}
|
|
||||||
{visibleAnnotationMarkers.map((marker) => {
|
|
||||||
const primaryCategory = marker.annotations[0].category
|
|
||||||
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
|
|
||||||
return (
|
|
||||||
<ReferenceLine
|
|
||||||
key={`ann-${marker.x}`}
|
|
||||||
x={marker.x}
|
|
||||||
stroke={color}
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeOpacity={0.6}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey={metric}
|
|
||||||
fill="url(#areaFill)"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={metric}
|
|
||||||
stroke={chartConfig[metric]?.color}
|
|
||||||
strokeWidth={2}
|
|
||||||
filter="url(#lineShadow)"
|
|
||||||
dot={false}
|
|
||||||
activeDot={{
|
|
||||||
r: 6,
|
|
||||||
fill: chartConfig[metric]?.color,
|
|
||||||
stroke: 'white',
|
|
||||||
strokeWidth: 2,
|
|
||||||
filter: 'url(#dotShadow)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
2292
components/ui/area-chart.tsx
Normal file
2292
components/ui/area-chart.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1017
package-lock.json
generated
1017
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,18 @@
|
|||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
|
"@visx/curve": "^3.12.0",
|
||||||
|
"@visx/event": "^3.12.0",
|
||||||
|
"@visx/grid": "^3.12.0",
|
||||||
|
"@visx/responsive": "^3.12.0",
|
||||||
|
"@visx/scale": "^3.12.0",
|
||||||
|
"@visx/shape": "^3.12.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"d3-array": "^3.2.4",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
@@ -41,6 +48,7 @@
|
|||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-use-measure": "^2.1.7",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"svg-dotted-map": "^2.0.1",
|
"svg-dotted-map": "^2.0.1",
|
||||||
@@ -58,6 +66,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/d3-array": "^3.2.2",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
@@ -18,6 +18,18 @@
|
|||||||
--chart-grid: #262626;
|
--chart-grid: #262626;
|
||||||
--chart-axis: #737373;
|
--chart-axis: #737373;
|
||||||
|
|
||||||
|
/* * visx area chart tokens (dark-only) */
|
||||||
|
--chart-background: #0a0a0a;
|
||||||
|
--chart-foreground: #404040;
|
||||||
|
--chart-foreground-muted: #a3a3a3;
|
||||||
|
--chart-line-primary: #FD5E0F;
|
||||||
|
--chart-line-secondary: #737373;
|
||||||
|
--chart-crosshair: #404040;
|
||||||
|
--chart-label: #a3a3a3;
|
||||||
|
--chart-marker-background: #262626;
|
||||||
|
--chart-marker-border: #404040;
|
||||||
|
--chart-marker-foreground: #fafafa;
|
||||||
|
|
||||||
/* * shadcn-compatible semantic tokens (dark-only) */
|
/* * shadcn-compatible semantic tokens (dark-only) */
|
||||||
--background: 10 10 10;
|
--background: 10 10 10;
|
||||||
--foreground: 250 250 250;
|
--foreground: 250 250 250;
|
||||||
|
|||||||
Reference in New Issue
Block a user