localgreenchain/components/realtime/LiveChart.tsx
Claude 7098335ce7
Add real-time updates system with Socket.io
Implement Agent 6: Real-Time Updates feature for LocalGreenChain:

- Add Socket.io server with room-based subscriptions
- Create client-side hooks (useSocket, useLiveFeed, usePlantUpdates)
- Add SocketProvider context for application-wide state
- Implement UI components:
  - ConnectionStatus: Shows WebSocket connection state
  - LiveFeed: Real-time event feed display
  - NotificationToast: Toast notifications with auto-dismiss
  - LiveChart: Real-time data visualization
- Add event type definitions and formatting utilities
- Create socket API endpoint for WebSocket initialization
- Add socket stats endpoint for monitoring
- Extend tailwind with fadeIn/slideIn animations

Integrates with existing EventStream SSE system for fallback.
2025-11-23 03:51:51 +00:00

256 lines
7.3 KiB
TypeScript

/**
* Live Chart Component
*
* Displays real-time data as a simple line chart.
*/
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useSocket } from '../../lib/realtime/useSocket';
import type { TransparencyEventType } from '../../lib/realtime/types';
interface LiveChartProps {
eventTypes?: TransparencyEventType[];
dataKey?: string;
title?: string;
color?: string;
height?: number;
maxDataPoints?: number;
showGrid?: boolean;
className?: string;
}
/**
* Simple SVG line chart for real-time data
*/
export function LiveChart({
eventTypes = ['system.metric'],
dataKey = 'value',
title = 'Live Data',
color = '#3B82F6',
height = 120,
maxDataPoints = 30,
showGrid = true,
className,
}: LiveChartProps) {
const { events } = useSocket({
eventTypes,
maxEvents: maxDataPoints,
});
// Extract data points
const dataPoints = useMemo(() => {
return events
.filter((e) => e.data && typeof e.data[dataKey] === 'number')
.map((e) => ({
value: e.data[dataKey] as number,
timestamp: new Date(e.timestamp).getTime(),
}))
.reverse()
.slice(-maxDataPoints);
}, [events, dataKey, maxDataPoints]);
// Calculate chart dimensions
const chartWidth = 400;
const chartHeight = height - 40;
const padding = { top: 10, right: 10, bottom: 20, left: 40 };
const innerWidth = chartWidth - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
// Calculate scales
const { minValue, maxValue, points, pathD } = useMemo(() => {
if (dataPoints.length === 0) {
return { minValue: 0, maxValue: 100, points: [], pathD: '' };
}
const values = dataPoints.map((d) => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const pts = dataPoints.map((d, i) => ({
x: padding.left + (i / Math.max(1, dataPoints.length - 1)) * innerWidth,
y: padding.top + innerHeight - ((d.value - min) / range) * innerHeight,
}));
const d = pts.length > 0
? `M ${pts.map((p) => `${p.x},${p.y}`).join(' L ')}`
: '';
return { minValue: min, maxValue: max, points: pts, pathD: d };
}, [dataPoints, innerWidth, innerHeight, padding]);
// Latest value
const latestValue = dataPoints.length > 0 ? dataPoints[dataPoints.length - 1].value : null;
return (
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-gray-700">{title}</h4>
{latestValue !== null && (
<span className="text-lg font-bold" style={{ color }}>
{latestValue.toFixed(1)}
</span>
)}
</div>
{/* Chart */}
<svg
width="100%"
height={chartHeight}
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{/* Grid */}
{showGrid && (
<g className="text-gray-200">
{/* Horizontal grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => (
<line
key={`h-${ratio}`}
x1={padding.left}
y1={padding.top + innerHeight * ratio}
x2={padding.left + innerWidth}
y2={padding.top + innerHeight * ratio}
stroke="currentColor"
strokeDasharray="2,2"
/>
))}
{/* Vertical grid lines */}
{[0, 0.5, 1].map((ratio) => (
<line
key={`v-${ratio}`}
x1={padding.left + innerWidth * ratio}
y1={padding.top}
x2={padding.left + innerWidth * ratio}
y2={padding.top + innerHeight}
stroke="currentColor"
strokeDasharray="2,2"
/>
))}
</g>
)}
{/* Y-axis labels */}
<g className="text-gray-500 text-xs">
<text x={padding.left - 5} y={padding.top + 4} textAnchor="end">
{maxValue.toFixed(0)}
</text>
<text x={padding.left - 5} y={padding.top + innerHeight} textAnchor="end">
{minValue.toFixed(0)}
</text>
</g>
{/* Line path */}
{pathD && (
<>
{/* Gradient area */}
<defs>
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<path
d={`${pathD} L ${points[points.length - 1]?.x},${padding.top + innerHeight} L ${points[0]?.x},${padding.top + innerHeight} Z`}
fill="url(#areaGradient)"
/>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
{/* Data points */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={i === points.length - 1 ? 4 : 2}
fill={color}
/>
))}
{/* No data message */}
{dataPoints.length === 0 && (
<text
x={chartWidth / 2}
y={chartHeight / 2}
textAnchor="middle"
className="text-gray-400 text-sm"
>
Waiting for data...
</text>
)}
</svg>
</div>
);
}
/**
* Event count chart - shows event frequency over time
*/
export function EventCountChart({
className,
}: {
className?: string;
}) {
const { events } = useSocket({ maxEvents: 100 });
// Group events by minute
const countsByMinute = useMemo(() => {
const counts: Record<string, number> = {};
const now = Date.now();
// Initialize last 10 minutes
for (let i = 0; i < 10; i++) {
const minute = Math.floor((now - i * 60000) / 60000);
counts[minute] = 0;
}
// Count events
events.forEach((e) => {
const minute = Math.floor(new Date(e.timestamp).getTime() / 60000);
if (counts[minute] !== undefined) {
counts[minute]++;
}
});
return Object.entries(counts)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, count]) => count);
}, [events]);
const maxCount = Math.max(...countsByMinute, 1);
return (
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
<h4 className="text-sm font-medium text-gray-700 mb-2">Events per Minute</h4>
<div className="flex items-end gap-1 h-16">
{countsByMinute.map((count, i) => (
<div
key={i}
className="flex-1 bg-blue-500 rounded-t transition-all duration-300"
style={{ height: `${(count / maxCount) * 100}%` }}
title={`${count} events`}
/>
))}
</div>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>10m ago</span>
<span>Now</span>
</div>
</div>
);
}
export default LiveChart;