refactor(analytics): replace ActivityChart stacked bars with interactive SVG chart featuring tooltips and grid lines
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { HourlyBucket } from "../../../shared/api/client";
|
||||
import {
|
||||
Card,
|
||||
@@ -6,20 +7,31 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
interface ActivityChartProps {
|
||||
hourly: HourlyBucket[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
if (loading && !hourly?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
const COLORS = {
|
||||
clean: { fill: "#38bdf8", label: "Clean" },
|
||||
warned: { fill: "#facc15", label: "Warned" },
|
||||
flagged: { fill: "#f472b6", label: "Flagged" },
|
||||
error: { fill: "#fb923c", label: "Error" },
|
||||
} as const;
|
||||
|
||||
if (!hourly?.length) {
|
||||
return <EmptyBox text="Belum ada data untuk periode ini." />;
|
||||
}
|
||||
type BarKey = keyof typeof COLORS;
|
||||
|
||||
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
hour: string;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [hoveredBar, setHoveredBar] = useState<string | null>(null);
|
||||
|
||||
if (loading && !hourly?.length) return <LoadingBox />;
|
||||
if (!hourly?.length) return <EmptyBox text="Belum ada data untuk periode ini." />;
|
||||
|
||||
const data = hourly.map((b) => {
|
||||
const utcHour = parseInt(b.hour.slice(11, 13), 10);
|
||||
@@ -34,63 +46,189 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const maxTotal = Math.max(...data.map((d) => d.total), 1);
|
||||
// Only show every Nth label to avoid crowding
|
||||
const labelInterval = data.length > 16 ? 2 : 1;
|
||||
|
||||
const bars: Array<{ key: BarKey; color: string; label: string }> = [
|
||||
{ key: "clean", color: COLORS.clean.fill, label: COLORS.clean.label },
|
||||
{ key: "warned", color: COLORS.warned.fill, label: COLORS.warned.label },
|
||||
{ key: "flagged", color: COLORS.flagged.fill, label: COLORS.flagged.label },
|
||||
{ key: "error", color: COLORS.error.fill, label: COLORS.error.label },
|
||||
];
|
||||
|
||||
const CHART_HEIGHT = 200;
|
||||
const BAR_GROUP_WIDTH = 28;
|
||||
const BAR_WIDTH = 5;
|
||||
const GAP = 2;
|
||||
|
||||
return (
|
||||
<Card className="col-span-1 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
Aktivitas per Jam
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Distribusi pesan per jam berdasarkan status moderasi.
|
||||
</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
Aktivitas per Jam
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Distribusi pesan per jam — arahkan kursor ke bar untuk detail.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span>Clean</span>
|
||||
<span>Warned</span>
|
||||
<span>Flagged</span>
|
||||
<span>Error</span>
|
||||
</div>
|
||||
<div className="max-h-55 space-y-2 overflow-auto pr-1">
|
||||
{data.map((bucket) => {
|
||||
const total = Math.max(bucket.total, 1);
|
||||
const clean = bucket.clean / total;
|
||||
const warned = bucket.warned / total;
|
||||
const flagged = bucket.flagged / total;
|
||||
const error = bucket.error / total;
|
||||
return (
|
||||
<div
|
||||
key={bucket.hour}
|
||||
className="grid gap-1 rounded-xl border border-sky-100 bg-white p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{bucket.hour}
|
||||
</span>
|
||||
<span>{bucket.total} pesan</span>
|
||||
</div>
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-sky-50">
|
||||
<div
|
||||
className="bg-gradient-to-r from-primary to-teal-300"
|
||||
style={{ width: `${clean * 100}%` }}
|
||||
{/* Legend */}
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-[11px]">
|
||||
{bars.map((b) => (
|
||||
<span key={b.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: b.color }}
|
||||
/>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="relative overflow-x-auto">
|
||||
<div className="min-w-[560px]">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(data.length * BAR_GROUP_WIDTH + 40, 200)} ${CHART_HEIGHT + 40}`}
|
||||
className="w-full"
|
||||
style={{ height: CHART_HEIGHT + 40 }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = CHART_HEIGHT - ratio * (CHART_HEIGHT - 20) - 20;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={30}
|
||||
y1={y}
|
||||
x2={data.length * BAR_GROUP_WIDTH + 10}
|
||||
y2={y}
|
||||
stroke="hsl(var(--muted))"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<div
|
||||
className="bg-yellow-400/60"
|
||||
style={{ width: `${warned * 100}%` }}
|
||||
<text
|
||||
x={28}
|
||||
y={y + 3}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{Math.round(ratio * maxTotal)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const x = i * BAR_GROUP_WIDTH + 32;
|
||||
let accumulated = 0;
|
||||
|
||||
return (
|
||||
<g key={d.hour}>
|
||||
{/* Hover target (invisible wider rect) */}
|
||||
<rect
|
||||
x={x - 4}
|
||||
y={0}
|
||||
width={BAR_GROUP_WIDTH}
|
||||
height={CHART_HEIGHT}
|
||||
fill="transparent"
|
||||
className="cursor-crosshair"
|
||||
onMouseEnter={() => {
|
||||
setTooltip({ hour: d.hour, total: d.total });
|
||||
setHoveredBar(d.hour);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTooltip(null);
|
||||
setHoveredBar(null);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="bg-accent/70"
|
||||
style={{ width: `${flagged * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-orange-300/70"
|
||||
style={{ width: `${error * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stacked bars */}
|
||||
{bars.map((bar) => {
|
||||
const val = d[bar.key];
|
||||
const barH = (val / maxTotal) * (CHART_HEIGHT - 20);
|
||||
const y = CHART_HEIGHT - accumulated - barH - 20;
|
||||
accumulated += barH;
|
||||
return val > 0 ? (
|
||||
<rect
|
||||
key={bar.key}
|
||||
x={x + GAP}
|
||||
y={y}
|
||||
width={BAR_WIDTH}
|
||||
height={Math.max(barH, 1)}
|
||||
fill={bar.color}
|
||||
rx={1.5}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
hoveredBar === d.hour
|
||||
? "opacity-100"
|
||||
: hoveredBar
|
||||
? "opacity-40"
|
||||
: "opacity-90",
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
{i % labelInterval === 0 && (
|
||||
<text
|
||||
x={x + BAR_WIDTH / 2 + GAP}
|
||||
y={CHART_HEIGHT - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{d.hour}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
|
||||
style={{
|
||||
left: `${data.findIndex((d) => d.hour === tooltip.hour) * BAR_GROUP_WIDTH + 36}px`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-semibold text-foreground">
|
||||
{tooltip.hour}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{bars.map((b) => {
|
||||
const d = data.find((d) => d.hour === tooltip.hour);
|
||||
const val = d?.[b.key] ?? 0;
|
||||
return val > 0 ? (
|
||||
<div key={b.key} className="flex items-center gap-2 text-muted-foreground">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ backgroundColor: b.color }}
|
||||
/>
|
||||
<span>{b.label}</span>
|
||||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
<div className="mt-1 flex items-center gap-2 border-t border-muted pt-1 text-muted-foreground">
|
||||
<span>Total</span>
|
||||
<span className="ml-auto font-bold tabular-nums text-foreground">
|
||||
{tooltip.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -101,7 +239,7 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-primary border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user