init
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user