Replace all 5 direct recharts usages with the shared, token-themed SVG
primitives, lazy-loaded for bundle savings:
- dashboard, usage, users/[id], ops/client-logs, extraction/entity-chart
now render AreaChart/BarChart/Donut from @bytelyst/charts.
- new src/components/charts: next/dynamic wrappers (own chunk, ssr:false)
that dynamic-import a local static re-export (primitives.tsx) — the chart
packages declare only an `import` export condition, so a direct
import('@bytelyst/charts') trips Next's resolver.
- new src/lib/chart-data.ts: pure, finite-safe data mappers (unit-tested).
- recharts removed from package.json + the admin-web lockfile importer entry
(now fully unused). Lockfile delta is importer-only (+charts/+data-viz as
workspace:*, -recharts); no monorepo re-normalization; --frozen-lockfile clean.
- vitest.config: inline @bytelyst/{charts,data-viz} + dedupe react so the
SSR no-NaN render tests use a single React copy.
Fidelity notes (charts are single-series/vertical; StackedBar is charts 0.2.x):
stacked severity chart -> single bars colored by dominant severity; pie charts
-> Donut; horizontal bars -> vertical.
Verify: typecheck+lint+build green (123 routes); vitest 18 files / 159 tests
(+19); format:check no new failures; e2e 11 passed / 80 failed (unchanged vs
UX-1 baseline — failures are environmental, no backend).
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
137 lines
3.8 KiB
TypeScript
137 lines
3.8 KiB
TypeScript
'use client';
|
|
|
|
import { BarChart, Donut } from '@/components/charts';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
|
|
interface ExtractionEntity {
|
|
extraction_class: string;
|
|
extraction_text: string;
|
|
attributes?: Record<string, string>;
|
|
}
|
|
|
|
interface EntityChartProps {
|
|
extractions: ExtractionEntity[];
|
|
title?: string;
|
|
}
|
|
|
|
const COLORS = [
|
|
'var(--chart-1)',
|
|
'var(--chart-2)',
|
|
'var(--chart-3)',
|
|
'var(--chart-4)',
|
|
'var(--chart-5)',
|
|
];
|
|
|
|
export function EntityBarChart({ extractions, title = 'Entities by Class' }: EntityChartProps) {
|
|
const classCounts = extractions.reduce(
|
|
(acc, e) => {
|
|
acc[e.extraction_class] = (acc[e.extraction_class] || 0) + 1;
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
);
|
|
|
|
const data = Object.entries(classCounts)
|
|
.map(([name, count]) => ({ name, count }))
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
if (data.length === 0) return null;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm">{title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<BarChart
|
|
data={data.map(d => ({ id: d.name, value: d.count, label: d.name }))}
|
|
width={640}
|
|
height={250}
|
|
className="h-auto w-full"
|
|
ariaLabel={title}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function EntityPieChart({ extractions, title = 'Class Distribution' }: EntityChartProps) {
|
|
const classCounts = extractions.reduce(
|
|
(acc, e) => {
|
|
acc[e.extraction_class] = (acc[e.extraction_class] || 0) + 1;
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
);
|
|
|
|
const data = Object.entries(classCounts)
|
|
.map(([name, value]) => ({ name, value }))
|
|
.sort((a, b) => b.value - a.value);
|
|
|
|
if (data.length === 0) return null;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm">{title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex justify-center">
|
|
<Donut
|
|
slices={data.map((d, idx) => ({
|
|
id: d.name,
|
|
label: d.name,
|
|
value: d.value,
|
|
color: COLORS[idx % COLORS.length],
|
|
}))}
|
|
size={220}
|
|
ariaLabel={title}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function EntityTimeline({ extractions }: { extractions: ExtractionEntity[] }) {
|
|
if (extractions.length === 0) return null;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm">Entity Timeline</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="relative pl-6 space-y-3">
|
|
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" />
|
|
{extractions.slice(0, 20).map((e, i) => (
|
|
<div key={i} className="relative flex items-start gap-3">
|
|
<div
|
|
className="absolute -left-4 mt-1.5 h-2.5 w-2.5 rounded-full"
|
|
style={{
|
|
backgroundColor:
|
|
COLORS[
|
|
Object.keys(
|
|
extractions.reduce(
|
|
(acc, ex) => {
|
|
acc[ex.extraction_class] = true;
|
|
return acc;
|
|
},
|
|
{} as Record<string, boolean>
|
|
)
|
|
).indexOf(e.extraction_class) % COLORS.length
|
|
],
|
|
}}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
{e.extraction_class}
|
|
</span>
|
|
<p className="text-sm text-foreground">{e.extraction_text}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|