init
This commit is contained in:
55
frontend/components/ui/button.tsx
Normal file
55
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
78
frontend/components/ui/card.tsx
Normal file
78
frontend/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
41
frontend/components/ui/collapsible.tsx
Normal file
41
frontend/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from './button'
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Collapsible({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-lg', className)}>
|
||||
<Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
variant="ghost"
|
||||
className="w-full justify-between p-4 h-auto font-semibold hover:bg-accent"
|
||||
>
|
||||
<span>{title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-5 w-5 transition-transform duration-200',
|
||||
isOpen && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{isOpen && <div className="p-4 pt-0 border-t">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
frontend/components/ui/compass-selector.tsx
Normal file
221
frontend/components/ui/compass-selector.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface CompassSelectorProps {
|
||||
value: number // 0-360 degrees
|
||||
onChange: (value: number) => void
|
||||
range?: number // Optional range to display as arc (±degrees)
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CompassSelector({ value, onChange, range, size = 200, className = '' }: CompassSelectorProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const compassRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging || !compassRef.current) return
|
||||
|
||||
const rect = compassRef.current.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
const dx = e.clientX - centerX
|
||||
const dy = e.clientY - centerY
|
||||
|
||||
// Calculate angle in degrees (0° = North/top)
|
||||
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
||||
if (angle < 0) angle += 360
|
||||
|
||||
// Round to nearest 5 degrees for easier selection
|
||||
angle = Math.round(angle / 5) * 5
|
||||
|
||||
onChange(angle % 360)
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
setIsDragging(true)
|
||||
// Trigger initial update
|
||||
const rect = compassRef.current!.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
const dx = e.clientX - centerX
|
||||
const dy = e.clientY - centerY
|
||||
|
||||
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
||||
if (angle < 0) angle += 360
|
||||
angle = Math.round(angle / 5) * 5
|
||||
|
||||
onChange(angle % 360)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
// Convert degrees to radians for calculations
|
||||
const angleRad = (value - 90) * (Math.PI / 180)
|
||||
const radius = size / 2 - 10
|
||||
const needleLength = radius * 0.7
|
||||
|
||||
// Calculate needle endpoint
|
||||
const needleX = size / 2 + Math.cos(angleRad) * needleLength
|
||||
const needleY = size / 2 + Math.sin(angleRad) * needleLength
|
||||
|
||||
// Helper to convert degrees to compass direction
|
||||
const getCompassDirection = (degrees: number): string => {
|
||||
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const index = Math.round(degrees / 22.5) % 16
|
||||
return directions[index]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center ${className}`}>
|
||||
<svg
|
||||
ref={compassRef}
|
||||
width={size}
|
||||
height={size}
|
||||
className="cursor-pointer select-none"
|
||||
onPointerDown={handlePointerDown}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
{/* Outer circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="hsl(var(--secondary))"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Acceptable range arc (green) */}
|
||||
{range !== undefined && (() => {
|
||||
const startAngle = value - range
|
||||
const endAngle = value + range
|
||||
|
||||
// Convert to radians for calculation (adjusting for SVG coordinate system)
|
||||
const startRad = (startAngle - 90) * (Math.PI / 180)
|
||||
const endRad = (endAngle - 90) * (Math.PI / 180)
|
||||
|
||||
// Calculate start and end points on the circle
|
||||
const x1 = size / 2 + Math.cos(startRad) * radius
|
||||
const y1 = size / 2 + Math.sin(startRad) * radius
|
||||
const x2 = size / 2 + Math.cos(endRad) * radius
|
||||
const y2 = size / 2 + Math.sin(endRad) * radius
|
||||
|
||||
// Determine if the arc should be large (>180°) or small
|
||||
const largeArcFlag = range * 2 > 180 ? 1 : 0
|
||||
|
||||
// Create SVG arc path
|
||||
const arcPath = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`
|
||||
|
||||
return (
|
||||
<path
|
||||
d={arcPath}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Cardinal direction markers */}
|
||||
{[
|
||||
{ angle: 0, label: 'N' },
|
||||
{ angle: 90, label: 'E' },
|
||||
{ angle: 180, label: 'S' },
|
||||
{ angle: 270, label: 'W' },
|
||||
].map(({ angle, label }) => {
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
const x = size / 2 + Math.cos(rad) * (radius - 20)
|
||||
const y = size / 2 + Math.sin(rad) * (radius - 20)
|
||||
|
||||
return (
|
||||
<text
|
||||
key={angle}
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-sm font-bold fill-foreground"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Degree tick marks every 30° */}
|
||||
{Array.from({ length: 12 }, (_, i) => {
|
||||
const angle = i * 30
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
const x1 = size / 2 + Math.cos(rad) * (radius - 5)
|
||||
const y1 = size / 2 + Math.sin(rad) * (radius - 5)
|
||||
const x2 = size / 2 + Math.cos(rad) * radius
|
||||
const y2 = size / 2 + Math.sin(rad) * radius
|
||||
|
||||
return (
|
||||
<line
|
||||
key={angle}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center dot */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r="4"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
|
||||
{/* Direction needle */}
|
||||
<line
|
||||
x1={size / 2}
|
||||
y1={size / 2}
|
||||
x2={needleX}
|
||||
y2={needleY}
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Needle tip */}
|
||||
<circle
|
||||
cx={needleX}
|
||||
cy={needleY}
|
||||
r="6"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Value display */}
|
||||
<div className="mt-2 text-center">
|
||||
<div className="text-lg font-bold">{value}° ({getCompassDirection(value)})</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/components/ui/slider.tsx
Normal file
36
frontend/components/ui/slider.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const values = props.value || props.defaultValue || [0]
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
{values.map((_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={index}
|
||||
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
})
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
163
frontend/components/weather/assessment-badge.tsx
Normal file
163
frontend/components/weather/assessment-badge.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Assessment, FlyableWindow, WeatherPoint } from '@/lib/types'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { useThresholdStore } from '@/store/threshold-store'
|
||||
|
||||
interface AssessmentBadgeProps {
|
||||
assessment: Assessment
|
||||
currentWeather: WeatherPoint
|
||||
bestWindow?: FlyableWindow
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Transform direction to offset from West (270°)
|
||||
function calculateOffset(direction: number): number {
|
||||
return ((direction - 270 + 180) % 360) - 180
|
||||
}
|
||||
|
||||
type Rating = 'Great' | 'Good' | 'Okay' | 'Bad'
|
||||
|
||||
interface RatingInfo {
|
||||
text: Rating
|
||||
color: string
|
||||
}
|
||||
|
||||
export function AssessmentBadge({ assessment, currentWeather, bestWindow, className }: AssessmentBadgeProps) {
|
||||
const { speedMin, speedMax, dirCenter, dirRange } = useThresholdStore()
|
||||
|
||||
// Evaluate wind speed
|
||||
const evaluateWindSpeed = (speed: number): RatingInfo => {
|
||||
if (speed < speedMin || speed > speedMax) {
|
||||
return { text: 'Bad', color: 'text-red-600 dark:text-red-400' }
|
||||
}
|
||||
const range = speedMax - speedMin
|
||||
const distanceFromMin = speed - speedMin
|
||||
const distanceFromMax = speedMax - speed
|
||||
const minDistance = Math.min(distanceFromMin, distanceFromMax)
|
||||
|
||||
if (minDistance < range * 0.15) {
|
||||
return { text: 'Okay', color: 'text-yellow-600 dark:text-yellow-400' }
|
||||
} else if (minDistance < range * 0.35) {
|
||||
return { text: 'Good', color: 'text-green-600 dark:text-green-400' }
|
||||
} else {
|
||||
return { text: 'Great', color: 'text-green-700 dark:text-green-300' }
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate wind direction
|
||||
const evaluateWindDirection = (direction: number): RatingInfo => {
|
||||
const offset = calculateOffset(direction)
|
||||
const centerOffset = calculateOffset(dirCenter)
|
||||
const minOffset = centerOffset - dirRange
|
||||
const maxOffset = centerOffset + dirRange
|
||||
|
||||
if (offset < minOffset || offset > maxOffset) {
|
||||
return { text: 'Bad', color: 'text-red-600 dark:text-red-400' }
|
||||
}
|
||||
|
||||
const distanceFromCenter = Math.abs(offset - centerOffset)
|
||||
|
||||
if (distanceFromCenter > dirRange * 0.7) {
|
||||
return { text: 'Okay', color: 'text-yellow-600 dark:text-yellow-400' }
|
||||
} else if (distanceFromCenter > dirRange * 0.4) {
|
||||
return { text: 'Good', color: 'text-green-600 dark:text-green-400' }
|
||||
} else {
|
||||
return { text: 'Great', color: 'text-green-700 dark:text-green-300' }
|
||||
}
|
||||
}
|
||||
|
||||
const speedRating = evaluateWindSpeed(currentWeather.wind_speed)
|
||||
const directionRating = evaluateWindDirection(currentWeather.wind_direction)
|
||||
|
||||
// Overall assessment is based on the worse of the two metrics
|
||||
const getOverallAssessment = (): boolean => {
|
||||
const ratingValues: Record<Rating, number> = {
|
||||
'Great': 4,
|
||||
'Good': 3,
|
||||
'Okay': 2,
|
||||
'Bad': 1
|
||||
}
|
||||
const worstRating = Math.min(
|
||||
ratingValues[speedRating.text],
|
||||
ratingValues[directionRating.text]
|
||||
)
|
||||
// Only GOOD if both metrics are at least "Good"
|
||||
return worstRating >= 3
|
||||
}
|
||||
|
||||
const isGood = getOverallAssessment()
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'border-2',
|
||||
isGood
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-950'
|
||||
: 'border-red-500 bg-red-50 dark:bg-red-950',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-full',
|
||||
isGood ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isGood ? (
|
||||
<Check className="h-8 w-8 text-white" strokeWidth={3} />
|
||||
) : (
|
||||
<X className="h-8 w-8 text-white" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-3xl font-bold',
|
||||
isGood ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{isGood ? 'GOOD' : 'BAD'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Flyability Assessment
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bestWindow && isGood && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Best window
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{format(parseISO(bestWindow.start), 'ha')} -{' '}
|
||||
{format(parseISO(bestWindow.end), 'ha')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bestWindow.duration_hours.toFixed(1)}h duration
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Individual metric ratings */}
|
||||
<div className="mt-4 pt-4 border-t space-y-2">
|
||||
<div className="text-lg font-semibold">
|
||||
Wind direction is <span className={directionRating.color}>{directionRating.text}</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
Wind speed is <span className={speedRating.color}>{speedRating.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
6
frontend/components/weather/index.ts
Normal file
6
frontend/components/weather/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AssessmentBadge } from './assessment-badge'
|
||||
export { WindSpeedChart } from './wind-speed-chart'
|
||||
export { WindDirectionChart } from './wind-direction-chart'
|
||||
export { ThresholdControls } from './threshold-controls'
|
||||
export { RefreshCountdown } from './refresh-countdown'
|
||||
export { StaleDataBanner } from './stale-data-banner'
|
||||
78
frontend/components/weather/refresh-countdown.tsx
Normal file
78
frontend/components/weather/refresh-countdown.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RefreshCountdownProps {
|
||||
onRefresh: () => void
|
||||
intervalMs?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RefreshCountdown({
|
||||
onRefresh,
|
||||
intervalMs = 5 * 60 * 1000, // 5 minutes default
|
||||
className,
|
||||
}: RefreshCountdownProps) {
|
||||
const [secondsRemaining, setSecondsRemaining] = useState(intervalMs / 1000)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setSecondsRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Auto refresh
|
||||
handleRefresh()
|
||||
return intervalMs / 1000
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [intervalMs])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
await onRefresh()
|
||||
setSecondsRemaining(intervalMs / 1000)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const progressPercentage = ((intervalMs / 1000 - secondsRemaining) / (intervalMs / 1000)) * 100
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-4', className)}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Next refresh</span>
|
||||
<span className="text-sm text-muted-foreground">{formatTime(secondsRemaining)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-1000 ease-linear"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="min-h-[44px] min-w-[44px]"
|
||||
aria-label="Refresh now"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
frontend/components/weather/stale-data-banner.tsx
Normal file
42
frontend/components/weather/stale-data-banner.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { differenceInMinutes, parseISO } from 'date-fns'
|
||||
|
||||
interface StaleDataBannerProps {
|
||||
lastUpdated: string
|
||||
thresholdMinutes?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StaleDataBanner({
|
||||
lastUpdated,
|
||||
thresholdMinutes = 10,
|
||||
className,
|
||||
}: StaleDataBannerProps) {
|
||||
const minutesOld = differenceInMinutes(new Date(), parseISO(lastUpdated))
|
||||
const isStale = minutesOld > thresholdMinutes
|
||||
|
||||
if (!isStale) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border border-yellow-500 bg-yellow-50 dark:bg-yellow-950 p-4',
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Data may be outdated
|
||||
</p>
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-300">
|
||||
Last updated {minutesOld} minutes ago. Live data may differ.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
frontend/components/weather/threshold-controls.tsx
Normal file
122
frontend/components/weather/threshold-controls.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { CompassSelector } from '@/components/ui/compass-selector'
|
||||
import { useThresholdStore } from '@/store/threshold-store'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ThresholdControlsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ThresholdControls({ className }: ThresholdControlsProps) {
|
||||
const {
|
||||
speedMin,
|
||||
speedMax,
|
||||
dirCenter,
|
||||
dirRange,
|
||||
setSpeedRange,
|
||||
setDirCenter,
|
||||
setDirRange,
|
||||
initFromURL,
|
||||
} = useThresholdStore()
|
||||
|
||||
// Initialize from URL on mount
|
||||
useEffect(() => {
|
||||
initFromURL()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Threshold Controls</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{/* Wind Speed Range */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<label className="text-sm font-medium">Wind Speed Range</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Slider
|
||||
value={[speedMin, speedMax]}
|
||||
onValueChange={(values) => setSpeedRange(values[0], values[1])}
|
||||
min={0}
|
||||
max={30}
|
||||
step={0.5}
|
||||
minStepsBetweenThumbs={1}
|
||||
className="min-h-[44px] py-4"
|
||||
aria-label="Wind speed range threshold"
|
||||
/>
|
||||
<div className="flex justify-between text-xs font-medium mt-1">
|
||||
<span style={{ position: 'absolute', left: `${(speedMin / 30) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||
{speedMin} mph
|
||||
</span>
|
||||
<span style={{ position: 'absolute', left: `${(speedMax / 30) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||
{speedMax} mph
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wind Direction Center */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="text-sm font-medium">Direction Center</label>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<CompassSelector
|
||||
value={dirCenter}
|
||||
onChange={setDirCenter}
|
||||
range={dirRange}
|
||||
size={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wind Direction Range */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium">Direction Range</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
±{dirRange}°
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[dirRange]}
|
||||
onValueChange={(values) => setDirRange(values[0])}
|
||||
min={5}
|
||||
max={90}
|
||||
step={5}
|
||||
className="min-h-[44px] py-4"
|
||||
aria-label="Wind direction range threshold"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Acceptable range: {(dirCenter - dirRange + 360) % 360}° to {(dirCenter + dirRange) % 360}°
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Thresholds are saved to URL and applied to charts automatically.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to convert degrees to compass direction
|
||||
function getCompassDirection(degrees: number): string {
|
||||
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const index = Math.round(degrees / 22.5) % 16
|
||||
return directions[index]
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
200
frontend/components/weather/wind-speed-chart.tsx
Normal file
200
frontend/components/weather/wind-speed-chart.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Legend,
|
||||
Area,
|
||||
ComposedChart,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import type { WeatherPoint } from '@/lib/types'
|
||||
import { useThresholdStore } from '@/store/threshold-store'
|
||||
|
||||
interface WindSpeedChartProps {
|
||||
data: WeatherPoint[]
|
||||
yesterdayData?: WeatherPoint[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
timestamp: string
|
||||
time: string
|
||||
speed?: number
|
||||
yesterdaySpeed?: number
|
||||
isInRange: boolean
|
||||
}
|
||||
|
||||
export function WindSpeedChart({ data, yesterdayData, className }: WindSpeedChartProps) {
|
||||
const { speedMin, speedMax } = useThresholdStore()
|
||||
|
||||
// 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 || []
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
return {
|
||||
timestamp: forecastPoint?.timestamp || '',
|
||||
time: slot.label,
|
||||
speed: forecastPoint?.wind_speed,
|
||||
yesterdaySpeed: yesterdayPoint?.wind_speed,
|
||||
isInRange: forecastPoint ?
|
||||
(forecastPoint.wind_speed >= speedMin && forecastPoint.wind_speed <= speedMax) :
|
||||
false
|
||||
}
|
||||
})
|
||||
|
||||
// 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.speed === undefined) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="font-medium">{data.time}</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Forecast:</span> {data.speed.toFixed(1)} mph
|
||||
</p>
|
||||
{data.yesterdaySpeed !== undefined && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Yesterday:</span> {data.yesterdaySpeed.toFixed(1)} mph
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Wind Speed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="speedRangeGood" 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>
|
||||
<linearGradient id="speedRangeBad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#ef4444" 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: 'Speed (mph)', angle: -90, position: 'insideLeft' }}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
|
||||
{/* Shaded area for acceptable speed range (green) */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={() => speedMax}
|
||||
fill="url(#speedRangeGood)"
|
||||
stroke="none"
|
||||
fillOpacity={0.3}
|
||||
activeDot={false}
|
||||
/>
|
||||
|
||||
{/* Threshold reference lines */}
|
||||
<ReferenceLine
|
||||
y={speedMin}
|
||||
stroke="#22c55e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: `Min: ${speedMin}`, fontSize: 11, fill: '#22c55e' }}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={speedMax}
|
||||
stroke="#22c55e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: `Max: ${speedMax}`, fontSize: 11, fill: '#22c55e' }}
|
||||
/>
|
||||
|
||||
{/* Yesterday's data (faded) */}
|
||||
{yesterdayData && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="yesterdaySpeed"
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={1}
|
||||
name="Yesterday"
|
||||
dot={false}
|
||||
activeDot={true}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Today's forecast - single continuous line */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="speed"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
name="Forecast"
|
||||
dot={false}
|
||||
activeDot={true}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user