From d2107cbcbef83f160c89c88c643cd73d76ebf966 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 8 Mar 2026 04:53:38 +0800 Subject: [PATCH] feat: Implement a new terminal dashboard featuring an analytics panel, sparklines, and foundational UI components and utilities. --- frontend/app/globals.css | 218 ++++++----- .../components/terminal/AnalyticsPanel.tsx | 254 +++++++++++++ frontend/components/terminal/Sidebar.tsx | 135 +++++++ frontend/components/terminal/Sparkline.tsx | 81 ++++ .../components/terminal/TerminalDashboard.tsx | 307 +++++++++++++++ frontend/components/ui/badge.tsx | 43 +++ frontend/components/ui/button.tsx | 57 +++ frontend/components/ui/card.tsx | 78 ++++ frontend/components/ui/input.tsx | 24 ++ frontend/components/ui/separator.tsx | 24 ++ frontend/components/ui/skeleton.tsx | 15 + frontend/lib/types.ts | 357 ++++++++++++++++++ frontend/lib/utils.ts | 49 +++ 13 files changed, 1542 insertions(+), 100 deletions(-) create mode 100644 frontend/components/terminal/AnalyticsPanel.tsx create mode 100644 frontend/components/terminal/Sidebar.tsx create mode 100644 frontend/components/terminal/Sparkline.tsx create mode 100644 frontend/components/terminal/TerminalDashboard.tsx create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/button.tsx create mode 100644 frontend/components/ui/card.tsx create mode 100644 frontend/components/ui/input.tsx create mode 100644 frontend/components/ui/separator.tsx create mode 100644 frontend/components/ui/skeleton.tsx create mode 100644 frontend/lib/types.ts create mode 100644 frontend/lib/utils.ts diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 50c243a8..52c8295d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -2,106 +2,124 @@ @tailwind components; @tailwind utilities; -:root { - --background: 223 53% 4%; - --foreground: 210 40% 98%; - --card: 223 46% 8%; - --card-foreground: 210 40% 98%; - --primary: 190 95% 56%; - --primary-foreground: 222 47% 8%; - --secondary: 224 30% 14%; - --secondary-foreground: 210 40% 98%; - --accent: 217 30% 18%; - --accent-foreground: 210 40% 98%; - --border: 221 38% 22%; -} - -* { - border-color: hsl(var(--border)); -} - -body { - font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif; - background: - radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%), - radial-gradient(circle at 90% 0%, rgba(59, 130, 246, 0.14), transparent 36%), - radial-gradient(circle at 80% 100%, rgba(8, 47, 73, 0.5), transparent 48%), - hsl(var(--background)); - color: hsl(var(--foreground)); -} - -.leaflet-container { - width: 100%; - height: 100%; - font-family: inherit; -} - -.leaflet-control-zoom a { - background: rgba(2, 6, 23, 0.82) !important; - border-color: rgba(71, 85, 105, 0.5) !important; - color: #e2e8f0 !important; -} - -.leaflet-control-zoom a:hover { - background: rgba(15, 23, 42, 0.95) !important; -} - -.map-pill { - min-width: 52px; - border-radius: 9999px; - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 5px 10px; - color: #f8fafc; - font-size: 11px; - font-weight: 700; - line-height: 1; - letter-spacing: 0.04em; - text-transform: uppercase; - backdrop-filter: blur(8px); - box-shadow: - 0 2px 14px rgba(2, 6, 23, 0.45), - inset 0 1px 0 rgba(255, 255, 255, 0.2); -} - -.map-pill.high { - background: linear-gradient(135deg, #ef4444, #b91c1c); -} - -.map-pill.medium { - background: linear-gradient(135deg, #f59e0b, #b45309); -} - -.map-pill.low { - background: linear-gradient(135deg, #10b981, #047857); -} - -.map-pill.active { - transform: translateY(-2px) scale(1.05); - box-shadow: - 0 8px 24px rgba(34, 211, 238, 0.4), - inset 0 1px 0 rgba(255, 255, 255, 0.35); -} - -.glass { - background: linear-gradient( - 180deg, - rgba(15, 23, 42, 0.9) 0%, - rgba(2, 6, 23, 0.75) 100% - ); - backdrop-filter: blur(10px); -} - -.fade-up { - animation: fadeUp 450ms ease-out; -} - -@keyframes fadeUp { - from { - opacity: 0; - transform: translateY(10px); +@layer base { + :root { + --background: 223 53% 4%; + --foreground: 210 40% 98%; + --card: 223 46% 8%; + --card-foreground: 210 40% 98%; + --primary: 190 95% 56%; + --primary-foreground: 222 47% 8%; + --secondary: 224 30% 14%; + --secondary-foreground: 210 40% 98%; + --accent: 217 30% 18%; + --accent-foreground: 210 40% 98%; + --border: 221 38% 22%; } - to { - opacity: 1; - transform: translateY(0); + + * { + @apply border-border; + } + + body { + font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif; + background: + radial-gradient( + circle at 10% -10%, + rgba(34, 211, 238, 0.2), + transparent 40% + ), + radial-gradient( + circle at 90% 0%, + rgba(59, 130, 246, 0.14), + transparent 36% + ), + radial-gradient( + circle at 80% 100%, + rgba(8, 47, 73, 0.5), + transparent 48% + ), + hsl(var(--background)); + color: hsl(var(--foreground)); + } +} + +@layer components { + .leaflet-container { + width: 100%; + height: 100%; + font-family: inherit; + } + + .leaflet-control-zoom a { + background: rgba(2, 6, 23, 0.82) !important; + border-color: rgba(71, 85, 105, 0.5) !important; + color: #e2e8f0 !important; + } + + .leaflet-control-zoom a:hover { + background: rgba(15, 23, 42, 0.95) !important; + } + + .map-pill { + min-width: 52px; + border-radius: 9999px; + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 5px 10px; + color: #f8fafc; + font-size: 11px; + font-weight: 700; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + backdrop-filter: blur(8px); + box-shadow: + 0 2px 14px rgba(2, 6, 23, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + } + + .map-pill.high { + background: linear-gradient(135deg, #ef4444, #b91c1c); + } + + .map-pill.medium { + background: linear-gradient(135deg, #f59e0b, #b45309); + } + + .map-pill.low { + background: linear-gradient(135deg, #10b981, #047857); + } + + .map-pill.active { + transform: translateY(-2px) scale(1.05); + box-shadow: + 0 8px 24px rgba(34, 211, 238, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.35); + } + + .glass { + background: linear-gradient( + 180deg, + rgba(15, 23, 42, 0.9) 0%, + rgba(2, 6, 23, 0.75) 100% + ); + backdrop-filter: blur(10px); + } + + .fade-up { + animation: fadeUp 450ms ease-out; + } +} + +@layer utilities { + @keyframes fadeUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } } diff --git a/frontend/components/terminal/AnalyticsPanel.tsx b/frontend/components/terminal/AnalyticsPanel.tsx new file mode 100644 index 00000000..1154586c --- /dev/null +++ b/frontend/components/terminal/AnalyticsPanel.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React from "react"; +import { + BarChart2, + Target, + ShieldAlert, + Zap, + Info, + Activity, + ArrowUpRight, +} from "lucide-react"; +import { Badge } from "../ui/badge"; +import { Sparkline } from "./Sparkline"; +import { CityDetail } from "../../lib/types"; + +interface AnalyticsPanelProps { + data: CityDetail; + t?: any; // Optional translations +} + +export function AnalyticsPanel({ + data, + t = {}, // Default empty for now, can be expanded via context or props +}: AnalyticsPanelProps) { + const { overview, market_scan, models, ai_analysis } = data; + + const modelEntries = Object.entries(models) + .filter(([_, v]) => v !== undefined && v !== null) + .map(([label, value]) => ({ label, value: value as number })) + .sort((a, b) => b.value - a.value); + + const signalColor = + market_scan.signal_label === "BUY YES" + ? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400" + : market_scan.signal_label === "BUY NO" + ? "border-rose-500/30 bg-rose-500/5 text-rose-400" + : "border-zinc-800 bg-zinc-900/50 text-zinc-400"; + + const signalIconColor = + market_scan.signal_label === "BUY YES" + ? "text-emerald-500" + : market_scan.signal_label === "BUY NO" + ? "text-rose-500" + : "text-zinc-500"; + + return ( +
+ {/* Header Stat Area */} +
+
+

+ {t.marketAnalysis || "MARKET ANALYSIS"} +

+ + {t.realTime || "LIVE UPLINK"} + +
+ +
+
+ + {t.liveMetar || "REAL-TIME METAR"} + +
+ + {overview.current_temp?.toFixed(1) || "--"} + + + {overview.temp_symbol} + +
+
+
+ + {t.ensembleDeb || "ENSEMBLE DEB"} + +
+ + {overview.deb_prediction?.toFixed(1) || "--"} + + + {overview.temp_symbol} + +
+
+
+ + {/* Trade Execution Signal */} +
+
+
+ + {t.signalStrategy || "SIGNAL STRATEGY"} + +
+ + + {market_scan.signal_label} + +
+
+
+ + {t.confidence || "CONFIDENCE"} + +
+ {market_scan.confidence} +
+
+
+
+ 0 + ? market_scan.sparkline + : [1, 1, 1] + } + width={360} + height={35} + color={ + market_scan.signal_label === "BUY YES" + ? "#10b981" + : market_scan.signal_label === "BUY NO" + ? "#f43f5e" + : "#52525b" + } + /> +
+
+
+ + {/* Probability & Market Pricing */} +
+
+
+ + + {t.yieldArbitrage || "YIELD ARBITRAGE"} + +
+
+
+
+ {t.probComparison || "PROB COMPARISON"} + + {t.predictionGap || "GAP"}:{" "} + {market_scan.edge_percent?.toFixed(1) || "0.0"}% + +
+ {/* Visual Probability Bar */} +
+
+
+
+
+ +
+
+ + {t.marketPriceLabel || "MIDPOINT"} + + + ${market_scan.market_price?.toFixed(2) || "--"} + +
+
+ + {t.modelProbLabel || "MODEL PROB"} + + + {((market_scan.model_probability || 0) * 100).toFixed(1)}% + +
+
+
+
+ +
+
+ + + {t.multiModelDivergence || "MULTI-MODEL DIVERGENCE"} + +
+
+ {modelEntries.map((m) => ( +
+
+ + {m.label} + + + {m.value.toFixed(1)}° + +
+
+
25 + ? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.5)]" + : m.value > 15 + ? "bg-amber-500" + : "bg-cyan-500 shadow-[0_0_8px_rgba(34,211,238,0.5)]" + }`} + style={{ width: `${(m.value / 40) * 100}%` }} + /> +
+
+ ))} +
+
+ + {ai_analysis && ( +
+
+ + + AI COGNITIVE ANALYSIS + +
+
+
+ +
+

+ {ai_analysis} +

+
+
+ )} +
+ + {/* Execute Scan Footer */} +
+ +
+
+ ); +} diff --git a/frontend/components/terminal/Sidebar.tsx b/frontend/components/terminal/Sidebar.tsx new file mode 100644 index 00000000..cf957758 --- /dev/null +++ b/frontend/components/terminal/Sidebar.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { Search, Globe, ChevronRight, Activity } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { CityInfo } from "@/lib/types"; +import { Sparkline } from "./Sparkline"; + +interface CitySidebarProps { + cities: CityInfo[]; + activeCity: string; + onSelectCity: (name: string) => void; + t: any; +} + +export function CitySidebar({ + cities, + activeCity, + onSelectCity, + t, +}: CitySidebarProps) { + return ( + + ); +} diff --git a/frontend/components/terminal/Sparkline.tsx b/frontend/components/terminal/Sparkline.tsx new file mode 100644 index 00000000..c74c1371 --- /dev/null +++ b/frontend/components/terminal/Sparkline.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React from "react"; + +interface SparklineProps { + data: number[]; + color?: string; + height?: number; + width?: number; + strokeWidth?: number; + className?: string; +} + +export function Sparkline({ + data, + color = "#22d3ee", + height = 40, + width = 120, + strokeWidth = 2, + className = "", +}: SparklineProps) { + if (!data || data.length < 2) return
; + + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + + const points = data + .map((val, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((val - min) / range) * height; + return `${x},${y}`; + }) + .join(" "); + + return ( +
+ + + + + + + + + {/* Fill Area */} + + + + + + + + +
+ ); +} diff --git a/frontend/components/terminal/TerminalDashboard.tsx b/frontend/components/terminal/TerminalDashboard.tsx new file mode 100644 index 00000000..80a6538f --- /dev/null +++ b/frontend/components/terminal/TerminalDashboard.tsx @@ -0,0 +1,307 @@ +"use client"; + +import React, { useState } from "react"; +import { CitySidebar } from "./Sidebar"; +import { AnalyticsPanel } from "./AnalyticsPanel"; +import { + RefreshCw, + LayoutGrid, + Maximize2, + Layers, + Languages, + Sun, + Moon, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +interface TerminalDashboardProps { + cities: any[]; + activeCity: string; + onSelectCity: (name: string) => void; + cityData: any; + marketData: any; + officialData: any; + isLoading: boolean; + refresh: () => void; + lang: any; + setLang: (lang: any) => void; + theme: string; + toggleTheme: () => void; + t: any; + children: React.ReactNode; // For the Map +} + +function NavLink({ + href, + label, + active = false, +}: { + href: string; + label: string; + active?: boolean; +}) { + return ( + + {label} + + ); +} + +export function TerminalDashboard({ + cities, + activeCity, + onSelectCity, + cityData, + marketData, + officialData, + isLoading, + refresh, + lang, + setLang, + theme, + toggleTheme, + t, + children, +}: TerminalDashboardProps) { + const currentTemp = cityData?.current?.temp ?? null; + const debPrediction = cityData?.deb?.prediction ?? null; + const marketPrice = marketData?.markets?.[0]?.price ?? 0.62; // Placeholder + + const modelEntries = Object.entries(cityData?.multi_model ?? {}) + .map(([label, value]) => ({ label, value: value as number })) + .filter((e) => typeof e.value === "number"); + + return ( +
+ {/* Top Global Ticker / Nav */} +
+
+ + + +
+ +
+
+ + {t.node || "Node"}: + + + CLOUD-ALPHA-01 + +
+ + +
+ +
+
+ + +
+ + + NODE: CLOUD-ALPHA-01 + + +
+
+ + {/* Main Container */} +
+ {/* Left: Component List */} + + + {/* Center: Interactive Map Area */} +
+ {/* Map UI Overlay */} +
+
+
+ + Current Focus + + + {activeCity} + +
+
+
+ +
+ + +
+ + {/* The Map */} +
+ {children} +
+ + {/* Bottom Map Info Bar */} +
+
+ + Projection: Mercator · Layer: Dynamic Thermal Ensemble + +
+
+ + STABLE VIBE + +
+
+
+ + {/* Right: Intelligence Panel */} + +
+ + {/* Footer Ticker */} +
+
+ + TICKER: + +
+ {cities.slice(0, 10).map((c) => ( +
+ + {c.display_name} + + + 24.5° + + LTAC +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 00000000..a96a0740 --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "../../lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.16em] transition-colors", + { + variants: { + variant: { + default: + "border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400", + secondary: + "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300", + success: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + danger: + "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400", + warning: + "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", + outline: "border-border bg-transparent text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends + React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 00000000..a1e86cdf --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,57 @@ +"use client"; + +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 gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/40 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_0_1px_rgba(34,211,238,0.2)]", + secondary: + "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800", + ghost: + "text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-100 dark:hover:bg-slate-900", + outline: + "border border-border bg-transparent hover:bg-slate-100 dark:hover:bg-slate-900", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-8 rounded-lg px-3 text-xs", + lg: "h-11 px-5", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx new file mode 100644 index 00000000..a56786fa --- /dev/null +++ b/frontend/components/ui/card.tsx @@ -0,0 +1,78 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx new file mode 100644 index 00000000..ae3d166e --- /dev/null +++ b/frontend/components/ui/input.tsx @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/frontend/components/ui/separator.tsx b/frontend/components/ui/separator.tsx new file mode 100644 index 00000000..8e82472a --- /dev/null +++ b/frontend/components/ui/separator.tsx @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", +}: React.HTMLAttributes & { + orientation?: "horizontal" | "vertical"; +}) { + return ( +
+ ); +} + +export { Separator }; diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx new file mode 100644 index 00000000..c1e47bb7 --- /dev/null +++ b/frontend/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts new file mode 100644 index 00000000..e2528d45 --- /dev/null +++ b/frontend/lib/types.ts @@ -0,0 +1,357 @@ +// --- Polymarket Market Types --- + +export interface MarketBook { + best_bid: number; + best_ask: number; + bid_levels: [number, number][]; // [price, size] + ask_levels: [number, number][]; // [price, size] +} + +export interface MarketToken { + outcome: string; + token_id: string; + implied_probability: number; + buy_price: number; + sell_price: number; + midpoint: number; + last_trade_price: number; + book?: MarketBook; +} + +export interface Trade { + id: string; + price: number; + size: number; + side: "buy" | "sell"; + timestamp: string; + timestamp_iso?: string | null; + outcome?: string | null; + asset?: string | null; + transaction_hash?: string | null; +} + +export interface MarketSnapshot { + id: string; + question: string; + title: string; + slug: string; + event_slug: string; + condition_id: string; + target_date: string; + active: boolean; + closed: boolean; + archived: boolean; + enable_order_book: boolean; + liquidity: number; + volume: number; + start_date: string; + end_date: string; + tokens: MarketToken[]; + recent_trades: Trade[]; +} + +export interface MarketPlatformData { + city: string; + target_date: string; + fetched_at: string; + market_count: number; + markets: MarketSnapshot[]; + websocket: { + market_url: string; + asset_ids: string[]; + condition_ids: string[]; + }; +} + +// --- Official Weather Types (Aviation) --- + +export interface METARObservation { + source: string; + icao: string; + station_name: string; + timestamp: string; + observation_time: string; + current: { + temp: number; + max_temp_so_far: number; + max_temp_time: string; + dewpoint: number; + wind_speed_kt: number; + wind_dir: number; + visibility_mi: string | number; + wx_desc: string | null; + altimeter: number; + clouds: any[]; + }; + recent_temps: number[]; + today_obs: any[]; + recent_obs: any[]; + unit: "celsius" | "fahrenheit"; +} + +export interface AviationWeatherData { + available: boolean; + source: string; + icao: string; + observation: METARObservation; +} + +// --- Official Weather Types (Weather.gov) --- + +export interface WeatherGovPeriod { + name: string; + start_time?: string; + end_time?: string; + is_daytime?: boolean; + temperature: number; + temperature_unit: string; + wind_speed?: string; + wind_direction?: string; + short_forecast?: string; + detailed_forecast?: string; +} + +export interface WeatherGovAlert { + id: string; + event: string; + severity: string; + certainty: string; + urgency: string; + headline: string; + onset: string; + expires: string; +} + +export interface WeatherGovData { + available: boolean; + source: string; + city: string; + grid: any; + nearest_station: { + station_identifier?: string; + name?: string; + timezone?: string; + elevation_m?: number | null; + } | null; + latest_observation: { + station_identifier?: string; + temperature_c?: number | null; + dewpoint_c?: number | null; + wind_direction_deg?: number | null; + wind_speed_kmh?: number | null; + text_description?: string | null; + timestamp?: string; + } | null; + stations: any[]; + forecast_periods: WeatherGovPeriod[]; + hourly_periods: WeatherGovPeriod[]; + active_alerts: WeatherGovAlert[]; +} + +export interface OfficialWeatherData { + city: string; + fetched_at: string; + aviation_weather?: AviationWeatherData; + weather_gov?: WeatherGovData; +} + +// --- City List Types --- + +export interface CityInfo { + name: string; + display_name: string; + lat: number; + lon: number; + risk_level: "low" | "medium" | "high"; + risk_emoji: string; + airport: string; + icao: string; + temp_unit: "celsius" | "fahrenheit"; + is_major: boolean; +} + +export interface CitiesResponse { + cities: CityInfo[]; +} + +// --- Full City Analysis Types --- + +export interface ModelComparison { + [key: string]: number | undefined; + "Open-Meteo"?: number; + ECMWF?: number; + GFS?: number; + ICON?: number; + GEM?: number; + JMA?: number; + MGM?: number; + NWS?: number; + Meteoblue?: number; +} + +export interface DEBAnalysis { + prediction: number | null; + weights_info?: string; +} + +export interface TrendInfo { + direction: string; + recent: Array<{ time: string; temp: number }>; + is_cooling: boolean; + is_dead_market: boolean; +} + +export interface PeakInfo { + hours: string[]; + first_h: number; + last_h: number; + status: string; +} + +export interface CityAnalysis { + name: string; + display_name: string; + lat: number; + lon: number; + temp_symbol: string; + local_time: string; + local_date: string; + risk: { + level: string; + emoji: string; + airport: string; + icao: string; + distance_km: number; + warning: string; + }; + current: { + temp: number | null; + max_so_far: number | null; + max_temp_time: string; + wu_settlement: number | null; + obs_time: string; + obs_age_min: number | null; + wind_speed_kt: number | null; + wind_dir: number | null; + humidity: number | null; + cloud_desc: string; + clouds_raw: Array<{ cover: string; base: number | null }>; + visibility_mi: number | null; + wx_desc: string | null; + }; + mgm?: { + temp?: number | null; + time?: string; + today_high?: number | null; + today_low?: number | null; + }; + forecast: { + today_high: number | null; + daily: any[]; + }; + multi_model: ModelComparison; + deb: DEBAnalysis; + probabilities: { + mu?: number | null; + distribution?: any[]; + }; + hourly?: any; + metar_recent_obs?: any[]; + trend?: TrendInfo; + peak?: PeakInfo; + ai_analysis: string; + updated_at: string; +} + +// --- Aggregated Detail Types --- + +export interface MarketScan { + available: boolean; + reason: string | null; + primary_market: any | null; + selected_date: string | null; + selected_condition_id: string | null; + selected_slug: string | null; + temperature_bucket: any | null; + model_probability: number | null; + market_price: number | null; + edge_percent: number | null; + signal_label: "BUY YES" | "BUY NO" | "MONITOR"; + confidence: "low" | "medium" | "high"; + yes_token: MarketToken | null; + no_token: MarketToken | null; + yes_buy: number | null; + yes_sell: number | null; + no_buy: number | null; + no_sell: number | null; + last_trade_price: number | null; + liquidity: number | null; + volume: number | null; + sparkline: number[]; + recent_trades: Trade[]; + websocket: any; +} + +export interface CityDetail { + city: string; + fetched_at: string; + overview: { + name: string; + display_name: string; + icao: string; + airport: string; + lat: number; + lon: number; + local_time: string; + local_date: string; + temp_symbol: string; + current_temp: number | null; + deb_prediction: number | null; + risk_level: string; + risk_warning: string; + updated_at: string; + }; + official: { + available: boolean; + metar: any; + weather_gov: WeatherGovData; + mgm: any; + mgm_nearby: any[]; + }; + timeseries: { + metar_recent_obs: any[]; + metar_today_obs: any[]; + hourly: any; + mgm_hourly: any[]; + forecast_daily: any[]; + }; + models: ModelComparison; + probabilities: { + mu: number | null; + distribution: any[]; + }; + market_scan: MarketScan; + risk: any; + ai_analysis: string; + errors: Record; +} + +export interface CitySummary { + name: string; + display_name: string; + icao: string; + local_time: string; + temp_symbol: string; + current: { + temp: number | null; + obs_time: string; + }; + deb: { + prediction: number | null; + }; + risk: { + level: string; + warning: string; + }; + updated_at: string; +} diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts new file mode 100644 index 00000000..04a3f035 --- /dev/null +++ b/frontend/lib/utils.ts @@ -0,0 +1,49 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Utility for merging tailwind classes safely. + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +/** + * Formats a number as a currency string. + */ +export function formatCurrency(value: number, decimals: number = 2) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); +} + +/** + * Formats a percentage from midpoint. + */ +export function formatPercent(value: number) { + return (value * 100).toFixed(1) + "%"; +} + +/** + * Converts Fahrenheit to Celsius. + */ +export function fToC(f: number) { + return ((f - 32) * 5) / 9; +} + +/** + * Formats a date/time string to HH:MM:SS. + */ +export function formatTime(date: Date | string) { + const d = typeof date === "string" ? new Date(date) : date; + if (isNaN(d.getTime())) return String(date); + return d.toLocaleTimeString("zh-CN", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}