init
This commit is contained in:
243
frontend/components/weather/wind-direction-chart.tsx
Normal file
243
frontend/components/weather/wind-direction-chart.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Legend,
|
||||
Area,
|
||||
ComposedChart,
|
||||
} from 'recharts'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import type { WeatherPoint } from '@/lib/types'
|
||||
import { useThresholdStore } from '@/store/threshold-store'
|
||||
|
||||
interface WindDirectionChartProps {
|
||||
data: WeatherPoint[]
|
||||
yesterdayData?: WeatherPoint[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
timestamp: string
|
||||
time: string
|
||||
offset?: number
|
||||
yesterdayOffset?: number
|
||||
direction?: number
|
||||
isInRange: boolean
|
||||
}
|
||||
|
||||
// Transform direction to offset from West (270°)
|
||||
// offset = ((direction - 270 + 180) % 360) - 180
|
||||
// This maps: 180° (S) = -90°, 270° (W) = 0°, 360° (N) = 90°
|
||||
function calculateOffset(direction: number): number {
|
||||
return ((direction - 270 + 180) % 360) - 180
|
||||
}
|
||||
|
||||
export function WindDirectionChart({ data, yesterdayData, className }: WindDirectionChartProps) {
|
||||
const { dirCenter, dirRange } = useThresholdStore()
|
||||
|
||||
// Calculate acceptable bounds
|
||||
const centerOffset = calculateOffset(dirCenter)
|
||||
const minOffset = centerOffset - dirRange
|
||||
const maxOffset = centerOffset + dirRange
|
||||
|
||||
// Filter data for TODAY's 8am-10pm window only
|
||||
const filterTimeWindow = (points: WeatherPoint[]) => {
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0)
|
||||
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0)
|
||||
|
||||
return points.filter((point) => {
|
||||
const timestamp = parseISO(point.timestamp)
|
||||
return timestamp >= todayStart && timestamp < todayEnd
|
||||
})
|
||||
}
|
||||
|
||||
// Generate static time slots for 8am-10pm (14 hours)
|
||||
const generateTimeSlots = (): { hour: number; label: string }[] => {
|
||||
const slots = []
|
||||
for (let hour = 8; hour < 22; hour++) {
|
||||
slots.push({
|
||||
hour,
|
||||
label: format(new Date().setHours(hour, 0, 0, 0), 'ha')
|
||||
})
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
const filteredData = filterTimeWindow(data)
|
||||
// Don't filter yesterday's data - show all available historical data
|
||||
const filteredYesterday = yesterdayData || []
|
||||
|
||||
// Helper to clamp values to chart display range
|
||||
const clampToChartRange = (value: number | undefined): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
return Math.max(-60, Math.min(60, value))
|
||||
}
|
||||
|
||||
// Generate static time slots and map data to them
|
||||
const timeSlots = generateTimeSlots()
|
||||
const chartData: ChartDataPoint[] = timeSlots.map(slot => {
|
||||
// Find forecast data for this hour
|
||||
const forecastPoint = filteredData.find(point =>
|
||||
parseISO(point.timestamp).getHours() === slot.hour
|
||||
)
|
||||
|
||||
// Find yesterday's data for this hour
|
||||
const yesterdayPoint = filteredYesterday.find(yp =>
|
||||
parseISO(yp.timestamp).getHours() === slot.hour
|
||||
)
|
||||
|
||||
const rawOffset = forecastPoint ? calculateOffset(forecastPoint.wind_direction) : undefined
|
||||
const offset = clampToChartRange(rawOffset)
|
||||
const isInRange = rawOffset !== undefined ? (rawOffset >= minOffset && rawOffset <= maxOffset) : false
|
||||
|
||||
return {
|
||||
timestamp: forecastPoint?.timestamp || '',
|
||||
time: slot.label,
|
||||
offset,
|
||||
yesterdayOffset: clampToChartRange(yesterdayPoint ? calculateOffset(yesterdayPoint.wind_direction) : undefined),
|
||||
direction: forecastPoint?.wind_direction,
|
||||
isInRange,
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to convert offset back to compass direction for display
|
||||
const offsetToCompass = (offset: number): string => {
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
const deg = (((offset + 270) % 360) + 360) % 360
|
||||
const index = Math.round(deg / 45) % 8
|
||||
return directions[index]
|
||||
}
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (!active || !payload || !payload.length) return null
|
||||
|
||||
const data = payload[0].payload
|
||||
|
||||
// Don't show tooltip if there's no forecast data for this time slot
|
||||
if (data.offset === undefined || data.direction === undefined) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="font-medium">{format(parseISO(data.timestamp), 'EEE ha')}</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Direction:</span> {data.direction.toFixed(0)}° ({offsetToCompass(data.offset)})
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Offset from West:</span> {data.offset.toFixed(1)}°
|
||||
</p>
|
||||
{data.yesterdayOffset !== undefined && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Yesterday:</span> {data.yesterdayOffset.toFixed(1)}°
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs mt-1">
|
||||
Range: {minOffset.toFixed(0)}° to {maxOffset.toFixed(0)}°
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Wind Direction (Offset from West)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="directionRange" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={0}
|
||||
angle={0}
|
||||
height={40}
|
||||
/>
|
||||
<YAxis
|
||||
label={{ value: 'Offset (°)', angle: -90, position: 'insideLeft' }}
|
||||
tick={{ fontSize: 12 }}
|
||||
domain={[() => -60, () => 60]}
|
||||
ticks={[-60, -30, 0, 30, 60]}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
|
||||
{/* Reference area for acceptable range */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={() => maxOffset}
|
||||
fill="url(#directionRange)"
|
||||
stroke="none"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
|
||||
{/* Threshold reference lines */}
|
||||
<ReferenceLine
|
||||
y={minOffset}
|
||||
stroke="#22c55e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: `Min: ${minOffset.toFixed(0)}°`, fontSize: 11, fill: '#22c55e' }}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={maxOffset}
|
||||
stroke="#22c55e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: `Max: ${maxOffset.toFixed(0)}°`, fontSize: 11, fill: '#22c55e' }}
|
||||
/>
|
||||
|
||||
{/* Perfect West reference */}
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="#6b7280"
|
||||
strokeDasharray="1 3"
|
||||
label={{ value: 'W (270°)', fontSize: 10, fill: '#6b7280' }}
|
||||
/>
|
||||
|
||||
{/* Yesterday's data (faded) */}
|
||||
{yesterdayData && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="yesterdayOffset"
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
name="Yesterday"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Today's data */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="offset"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="Direction Offset"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<div className="mt-4 text-xs text-muted-foreground text-center">
|
||||
0° = West (270°) | Negative = South | Positive = North
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user