init
This commit is contained in:
2
frontend/.env.local.example
Normal file
2
frontend/.env.local.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Copy this file to .env.local and fill in values
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
|
||||
64
frontend/Dockerfile
Normal file
64
frontend/Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
||||
# Dependencies stage
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Set production environment
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV production
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set permissions for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Copy standalone output
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
# Development stage
|
||||
FROM node:20-alpine AS dev
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV development
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
59
frontend/app/globals.css
Normal file
59
frontend/app/globals.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
25
frontend/app/layout.tsx
Normal file
25
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Paragliding Weather Forecaster',
|
||||
description: 'Real-time weather analysis for paragliding conditions',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
161
frontend/app/page.tsx
Normal file
161
frontend/app/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useCurrentWeather, useForecast, useHistorical } from '@/hooks/use-weather'
|
||||
import { AssessmentBadge } from '@/components/weather/assessment-badge'
|
||||
import { WindSpeedChart } from '@/components/weather/wind-speed-chart'
|
||||
import { WindDirectionChart } from '@/components/weather/wind-direction-chart'
|
||||
import { ThresholdControls } from '@/components/weather/threshold-controls'
|
||||
import { RefreshCountdown } from '@/components/weather/refresh-countdown'
|
||||
import { StaleDataBanner } from '@/components/weather/stale-data-banner'
|
||||
import { Collapsible } from '@/components/ui/collapsible'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { subDays, format } from 'date-fns'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch current weather and forecast
|
||||
const {
|
||||
data: currentWeather,
|
||||
isLoading: isLoadingCurrent,
|
||||
error: currentError,
|
||||
} = useCurrentWeather()
|
||||
|
||||
const {
|
||||
data: forecast,
|
||||
isLoading: isLoadingForecast,
|
||||
error: forecastError,
|
||||
} = useForecast()
|
||||
|
||||
// Fetch yesterday's data for comparison
|
||||
const yesterday = format(subDays(new Date(), 1), 'yyyy-MM-dd')
|
||||
const {
|
||||
data: historicalData,
|
||||
isLoading: isLoadingHistorical,
|
||||
} = useHistorical(yesterday)
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['weather'] })
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoadingCurrent || isLoadingForecast) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="h-12 w-12 animate-spin mx-auto text-primary" />
|
||||
<p className="text-muted-foreground">Loading weather data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (currentError || forecastError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-red-500 bg-red-50 dark:bg-red-950 p-6">
|
||||
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-1">
|
||||
Failed to load weather data
|
||||
</h2>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
{(currentError as Error)?.message || (forecastError as Error)?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="mt-3 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No data state
|
||||
if (!currentWeather || !forecast) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No weather data available</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get best flyable window
|
||||
const bestWindow = forecast.flyable_windows && forecast.flyable_windows.length > 0
|
||||
? forecast.flyable_windows[0]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-6 md:py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl md:text-4xl font-bold">
|
||||
#ShouldIFly TPG?
|
||||
</h1>
|
||||
{currentWeather.location.name && (
|
||||
<p className="text-muted-foreground">
|
||||
{currentWeather.location.name} ({currentWeather.location.lat.toFixed(2)}, {currentWeather.location.lon.toFixed(2)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stale Data Warning */}
|
||||
<StaleDataBanner lastUpdated={currentWeather.last_updated} />
|
||||
|
||||
{/* Assessment Badge */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div></div>
|
||||
<AssessmentBadge
|
||||
assessment={currentWeather.assessment}
|
||||
currentWeather={currentWeather.current}
|
||||
bestWindow={bestWindow}
|
||||
/>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid gap-6 lg:grid-cols-1">
|
||||
<WindSpeedChart
|
||||
data={forecast.forecast}
|
||||
yesterdayData={historicalData?.data}
|
||||
/>
|
||||
<WindDirectionChart
|
||||
data={forecast.forecast}
|
||||
yesterdayData={historicalData?.data}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Threshold Controls - Collapsible */}
|
||||
<Collapsible title="Threshold Controls" defaultOpen={false}>
|
||||
<ThresholdControls />
|
||||
</Collapsible>
|
||||
|
||||
{/* Refresh Countdown */}
|
||||
<RefreshCountdown onRefresh={handleRefresh} />
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-xs text-muted-foreground pt-8 border-t">
|
||||
<p>
|
||||
Data updates every 5 minutes. Forecast generated at{' '}
|
||||
{format(new Date(forecast.generated_at), 'PPpp')}
|
||||
</p>
|
||||
{isLoadingHistorical && (
|
||||
<p className="mt-1">Loading historical comparison data...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
frontend/app/providers.tsx
Normal file
22
frontend/app/providers.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
17
frontend/components.json
Normal file
17
frontend/components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
37
frontend/hooks/use-weather.ts
Normal file
37
frontend/hooks/use-weather.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCurrentWeather, getForecast, getHistorical } from '@/lib/api'
|
||||
import type { CurrentWeatherResponse, ForecastResponse, HistoricalResponse } from '@/lib/types'
|
||||
|
||||
const STALE_TIME = 5 * 60 * 1000 // 5 minutes
|
||||
const REFETCH_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export function useCurrentWeather(lat?: number, lon?: number) {
|
||||
return useQuery<CurrentWeatherResponse>({
|
||||
queryKey: ['weather', 'current', lat, lon],
|
||||
queryFn: () => getCurrentWeather(lat, lon),
|
||||
staleTime: STALE_TIME,
|
||||
refetchInterval: REFETCH_INTERVAL,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForecast(lat?: number, lon?: number) {
|
||||
return useQuery<ForecastResponse>({
|
||||
queryKey: ['weather', 'forecast', lat, lon],
|
||||
queryFn: () => getForecast(lat, lon),
|
||||
staleTime: STALE_TIME,
|
||||
refetchInterval: REFETCH_INTERVAL,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useHistorical(date: string, lat?: number, lon?: number) {
|
||||
return useQuery<HistoricalResponse>({
|
||||
queryKey: ['weather', 'historical', date, lat, lon],
|
||||
queryFn: () => getHistorical(date, lat, lon),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: !!date,
|
||||
})
|
||||
}
|
||||
207
frontend/lib/api.ts
Normal file
207
frontend/lib/api.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
CurrentWeatherResponse,
|
||||
ForecastResponse,
|
||||
HistoricalResponse,
|
||||
AssessmentResponse,
|
||||
Thresholds,
|
||||
APIError,
|
||||
} from './types'
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1'
|
||||
|
||||
class APIClient {
|
||||
private baseURL: string
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: APIError = await response.json().catch(() => ({
|
||||
error: 'An error occurred',
|
||||
detail: response.statusText,
|
||||
}))
|
||||
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
// Unwrap {success, data} response if present
|
||||
return (json.data || json) as T
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('An unexpected error occurred')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current weather conditions and flyability assessment
|
||||
*/
|
||||
async getCurrentWeather(
|
||||
lat?: number,
|
||||
lon?: number
|
||||
): Promise<CurrentWeatherResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (lat !== undefined) params.append('lat', lat.toString())
|
||||
if (lon !== undefined) params.append('lon', lon.toString())
|
||||
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
const data = await this.request<any>(`/weather/current${query}`)
|
||||
|
||||
// Transform backend response to frontend format
|
||||
return {
|
||||
location: data.location || { lat: 0, lon: 0 },
|
||||
current: {
|
||||
timestamp: data.current?.time || data.current?.timestamp,
|
||||
wind_speed: data.current?.windSpeed || data.current?.wind_speed || 0,
|
||||
wind_direction: data.current?.windDirection || data.current?.wind_direction || 0,
|
||||
wind_gust: data.current?.windGust || data.current?.wind_gust || 0,
|
||||
temperature: data.current?.temperature || 0,
|
||||
cloud_cover: data.current?.cloudCover || data.current?.cloud_cover || 0,
|
||||
precipitation: data.current?.precipitation || 0,
|
||||
visibility: data.current?.visibility || 0,
|
||||
pressure: data.current?.pressure || 0,
|
||||
humidity: data.current?.humidity || 0,
|
||||
},
|
||||
assessment: {
|
||||
is_flyable: data.assessment?.FlyableNow || data.assessment?.is_flyable || false,
|
||||
reasons: data.assessment?.Reason ? [data.assessment.Reason] : data.assessment?.reasons || [],
|
||||
score: data.assessment?.score || 0,
|
||||
},
|
||||
last_updated: data.timestamp || data.last_updated || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weather forecast for the next 7 days
|
||||
*/
|
||||
async getForecast(
|
||||
lat?: number,
|
||||
lon?: number
|
||||
): Promise<ForecastResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (lat !== undefined) params.append('lat', lat.toString())
|
||||
if (lon !== undefined) params.append('lon', lon.toString())
|
||||
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
const data = await this.request<any>(`/weather/forecast${query}`)
|
||||
|
||||
// Transform backend response to frontend format
|
||||
return {
|
||||
location: data.location || { lat: 0, lon: 0 },
|
||||
forecast: (data.forecast || []).map((point: any) => ({
|
||||
timestamp: point.Time || point.timestamp,
|
||||
wind_speed: point.WindSpeedMPH || point.wind_speed || 0,
|
||||
wind_direction: point.WindDirection || point.wind_direction || 0,
|
||||
wind_gust: point.WindGustMPH || point.wind_gust || 0,
|
||||
temperature: point.temperature || 0,
|
||||
cloud_cover: point.cloud_cover || 0,
|
||||
precipitation: point.precipitation || 0,
|
||||
visibility: point.visibility || 0,
|
||||
pressure: point.pressure || 0,
|
||||
humidity: point.humidity || 0,
|
||||
})),
|
||||
flyable_windows: (data.flyableWindows || data.flyable_windows || []).map((win: any) => ({
|
||||
start: win.start,
|
||||
end: win.end,
|
||||
duration_hours: win.durationHours || win.duration_hours || 0,
|
||||
avg_conditions: {
|
||||
wind_speed: win.avgConditions?.windSpeed || win.avg_conditions?.wind_speed || 0,
|
||||
wind_gust: win.avgConditions?.windGust || win.avg_conditions?.wind_gust || 0,
|
||||
temperature: win.avgConditions?.temperature || win.avg_conditions?.temperature || 0,
|
||||
cloud_cover: win.avgConditions?.cloudCover || win.avg_conditions?.cloud_cover || 0,
|
||||
},
|
||||
})),
|
||||
generated_at: data.generated || data.generated_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical weather data for a specific date
|
||||
* @param date - Date in YYYY-MM-DD format
|
||||
*/
|
||||
async getHistorical(
|
||||
date: string,
|
||||
lat?: number,
|
||||
lon?: number
|
||||
): Promise<HistoricalResponse> {
|
||||
const params = new URLSearchParams({ date })
|
||||
if (lat !== undefined) params.append('lat', lat.toString())
|
||||
if (lon !== undefined) params.append('lon', lon.toString())
|
||||
|
||||
const data = await this.request<any>(`/weather/historical?${params.toString()}`)
|
||||
|
||||
// Transform backend response to frontend format
|
||||
return {
|
||||
location: data.location || { lat: 0, lon: 0 },
|
||||
date: data.date || date,
|
||||
data: (data.data || []).map((point: any) => ({
|
||||
timestamp: point.Time || point.timestamp,
|
||||
wind_speed: point.WindSpeedMPH || point.wind_speed || 0,
|
||||
wind_direction: point.WindDirection || point.wind_direction || 0,
|
||||
wind_gust: point.WindGustMPH || point.wind_gust || 0,
|
||||
temperature: point.temperature || 0,
|
||||
cloud_cover: point.cloud_cover || 0,
|
||||
precipitation: point.precipitation || 0,
|
||||
visibility: point.visibility || 0,
|
||||
pressure: point.pressure || 0,
|
||||
humidity: point.humidity || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess current conditions with custom thresholds
|
||||
*/
|
||||
async assessWithThresholds(
|
||||
thresholds: Thresholds,
|
||||
lat?: number,
|
||||
lon?: number
|
||||
): Promise<AssessmentResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (lat !== undefined) params.append('lat', lat.toString())
|
||||
if (lon !== undefined) params.append('lon', lon.toString())
|
||||
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
|
||||
return this.request<AssessmentResponse>(`/weather/assess${query}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ thresholds }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiClient = new APIClient(API_BASE_URL)
|
||||
|
||||
// Export individual functions for convenience
|
||||
export const getCurrentWeather = (lat?: number, lon?: number) =>
|
||||
apiClient.getCurrentWeather(lat, lon)
|
||||
|
||||
export const getForecast = (lat?: number, lon?: number) =>
|
||||
apiClient.getForecast(lat, lon)
|
||||
|
||||
export const getHistorical = (date: string, lat?: number, lon?: number) =>
|
||||
apiClient.getHistorical(date, lat, lon)
|
||||
|
||||
export const assessWithThresholds = (
|
||||
thresholds: Thresholds,
|
||||
lat?: number,
|
||||
lon?: number
|
||||
) => apiClient.assessWithThresholds(thresholds, lat, lon)
|
||||
92
frontend/lib/types.ts
Normal file
92
frontend/lib/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Core weather data types matching backend models
|
||||
|
||||
export interface WeatherPoint {
|
||||
timestamp: string
|
||||
temperature: number
|
||||
wind_speed: number
|
||||
wind_gust: number
|
||||
wind_direction: number
|
||||
cloud_cover: number
|
||||
precipitation: number
|
||||
visibility: number
|
||||
pressure: number
|
||||
humidity: number
|
||||
}
|
||||
|
||||
export interface Thresholds {
|
||||
max_wind_speed: number
|
||||
max_wind_gust: number
|
||||
max_precipitation: number
|
||||
min_visibility: number
|
||||
max_cloud_cover: number
|
||||
min_temperature?: number
|
||||
max_temperature?: number
|
||||
}
|
||||
|
||||
export interface Assessment {
|
||||
is_flyable: boolean
|
||||
reasons: string[]
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface FlyableWindow {
|
||||
start: string
|
||||
end: string
|
||||
duration_hours: number
|
||||
avg_conditions: {
|
||||
wind_speed: number
|
||||
wind_gust: number
|
||||
temperature: number
|
||||
cloud_cover: number
|
||||
}
|
||||
}
|
||||
|
||||
// API Response types
|
||||
|
||||
export interface CurrentWeatherResponse {
|
||||
location: {
|
||||
lat: number
|
||||
lon: number
|
||||
name?: string
|
||||
}
|
||||
current: WeatherPoint
|
||||
assessment: Assessment
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
export interface ForecastResponse {
|
||||
location: {
|
||||
lat: number
|
||||
lon: number
|
||||
name?: string
|
||||
}
|
||||
forecast: WeatherPoint[]
|
||||
flyable_windows: FlyableWindow[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface HistoricalResponse {
|
||||
location: {
|
||||
lat: number
|
||||
lon: number
|
||||
name?: string
|
||||
}
|
||||
date: string
|
||||
data: WeatherPoint[]
|
||||
}
|
||||
|
||||
export interface AssessmentRequest {
|
||||
thresholds: Thresholds
|
||||
}
|
||||
|
||||
export interface AssessmentResponse {
|
||||
current: WeatherPoint
|
||||
assessment: Assessment
|
||||
thresholds_used: Thresholds
|
||||
}
|
||||
|
||||
// Error response type
|
||||
export interface APIError {
|
||||
error: string
|
||||
detail?: string
|
||||
}
|
||||
6
frontend/lib/utils.ts
Normal file
6
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
8
frontend/next.config.js
Normal file
8
frontend/next.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
8520
frontend/package-lock.json
generated
Normal file
8520
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "paragliding-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.395.0",
|
||||
"next": "14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.12.7",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^20.14.2",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
81
frontend/store/threshold-store.ts
Normal file
81
frontend/store/threshold-store.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface ThresholdState {
|
||||
speedMin: number
|
||||
speedMax: number
|
||||
dirCenter: number
|
||||
dirRange: number
|
||||
setSpeedMin: (value: number) => void
|
||||
setSpeedMax: (value: number) => void
|
||||
setDirCenter: (value: number) => void
|
||||
setDirRange: (value: number) => void
|
||||
setSpeedRange: (min: number, max: number) => void
|
||||
initFromURL: () => void
|
||||
}
|
||||
|
||||
// Helper to update URL params
|
||||
const updateURLParams = (params: Record<string, string>) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value)
|
||||
})
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
// Helper to read from URL params
|
||||
const getURLParam = (key: string, defaultValue: number): number => {
|
||||
if (typeof window === 'undefined') return defaultValue
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const value = params.get(key)
|
||||
return value ? parseFloat(value) : defaultValue
|
||||
}
|
||||
|
||||
export const useThresholdStore = create<ThresholdState>((set) => ({
|
||||
// Default values
|
||||
speedMin: 7,
|
||||
speedMax: 14,
|
||||
dirCenter: 270,
|
||||
dirRange: 15,
|
||||
|
||||
setSpeedMin: (value) => {
|
||||
set({ speedMin: value })
|
||||
updateURLParams({ speedMin: value.toString() })
|
||||
},
|
||||
|
||||
setSpeedMax: (value) => {
|
||||
set({ speedMax: value })
|
||||
updateURLParams({ speedMax: value.toString() })
|
||||
},
|
||||
|
||||
setDirCenter: (value) => {
|
||||
set({ dirCenter: value })
|
||||
updateURLParams({ dirCenter: value.toString() })
|
||||
},
|
||||
|
||||
setDirRange: (value) => {
|
||||
set({ dirRange: value })
|
||||
updateURLParams({ dirRange: value.toString() })
|
||||
},
|
||||
|
||||
setSpeedRange: (min, max) => {
|
||||
set({ speedMin: min, speedMax: max })
|
||||
updateURLParams({
|
||||
speedMin: min.toString(),
|
||||
speedMax: max.toString()
|
||||
})
|
||||
},
|
||||
|
||||
initFromURL: () => {
|
||||
set({
|
||||
speedMin: getURLParam('speedMin', 7),
|
||||
speedMax: getURLParam('speedMax', 14),
|
||||
dirCenter: getURLParam('dirCenter', 270),
|
||||
dirRange: getURLParam('dirRange', 15),
|
||||
})
|
||||
},
|
||||
}))
|
||||
75
frontend/tailwind.config.js
Normal file
75
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
}
|
||||
28
frontend/tsconfig.json
Normal file
28
frontend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user