feat: Implement a new terminal dashboard featuring an analytics panel, sparklines, and foundational UI components and utilities.
This commit is contained in:
+118
-100
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex relative h-full w-[400px] flex-col bg-zinc-950/20 backdrop-blur-sm border-l border-zinc-800/50 overflow-hidden">
|
||||
{/* Header Stat Area */}
|
||||
<div className="p-4 border-b border-zinc-800 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">
|
||||
{t.marketAnalysis || "MARKET ANALYSIS"}
|
||||
</h2>
|
||||
<Badge className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border-cyan-500/30 text-[9px] h-5">
|
||||
{t.realTime || "LIVE UPLINK"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
|
||||
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.liveMetar || "REAL-TIME METAR"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-black text-white">
|
||||
{overview.current_temp?.toFixed(1) || "--"}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-zinc-500">
|
||||
{overview.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
|
||||
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.ensembleDeb || "ENSEMBLE DEB"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-black text-cyan-400">
|
||||
{overview.deb_prediction?.toFixed(1) || "--"}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-cyan-500">
|
||||
{overview.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Execution Signal */}
|
||||
<div
|
||||
className={`rounded-xl border p-4 transition-all duration-500 ${signalColor}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.signalStrategy || "SIGNAL STRATEGY"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${market_scan.available ? "animate-pulse" : ""} ${signalIconColor}`}
|
||||
/>
|
||||
<span className="text-sm font-black text-white italic uppercase tracking-wider">
|
||||
{market_scan.signal_label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.confidence || "CONFIDENCE"}
|
||||
</span>
|
||||
<div className="mt-1 text-xs font-black text-white opacity-80 uppercase tracking-widest">
|
||||
{market_scan.confidence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 mt-2 flex items-end">
|
||||
<Sparkline
|
||||
data={
|
||||
market_scan.sparkline.length > 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"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Probability & Market Pricing */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 custom-scrollbar pb-20">
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Target className="h-3 w-3 text-violet-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
{t.yieldArbitrage || "YIELD ARBITRAGE"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] uppercase font-bold text-zinc-500">
|
||||
<span>{t.probComparison || "PROB COMPARISON"}</span>
|
||||
<span className="text-cyan-400">
|
||||
{t.predictionGap || "GAP"}:{" "}
|
||||
{market_scan.edge_percent?.toFixed(1) || "0.0"}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Visual Probability Bar */}
|
||||
<div className="relative h-4 w-full bg-zinc-900 rounded-sm border border-zinc-800 overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full bg-zinc-800 border-r border-zinc-700 transition-all duration-700"
|
||||
style={{ width: `${(market_scan.market_price || 0) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute h-full bg-violet-500/40 border-r border-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.5)] transition-all duration-1000"
|
||||
style={{
|
||||
width: `${(market_scan.model_probability || 0) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
|
||||
<span className="text-[9px] text-zinc-500 uppercase font-black">
|
||||
{t.marketPriceLabel || "MIDPOINT"}
|
||||
</span>
|
||||
<span className="font-mono text-zinc-100 font-bold text-sm">
|
||||
${market_scan.market_price?.toFixed(2) || "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
|
||||
<span className="text-[9px] text-violet-500 uppercase font-black">
|
||||
{t.modelProbLabel || "MODEL PROB"}
|
||||
</span>
|
||||
<span className="font-mono text-violet-400 font-bold text-sm">
|
||||
{((market_scan.model_probability || 0) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BarChart2 className="h-3 w-3 text-cyan-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
{t.multiModelDivergence || "MULTI-MODEL DIVERGENCE"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{modelEntries.map((m) => (
|
||||
<div key={m.label} className="group flex flex-col gap-1 px-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-zinc-500 group-hover:text-zinc-300 transition-colors uppercase">
|
||||
{m.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold text-white text-right">
|
||||
{m.value.toFixed(1)}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-1 w-full bg-zinc-800/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
m.value > 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}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{ai_analysis && (
|
||||
<section className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="h-3 w-3 text-amber-500" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
AI COGNITIVE ANALYSIS
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-2 opacity-20">
|
||||
<ShieldAlert className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-zinc-400 relative z-10 font-medium">
|
||||
{ai_analysis}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execute Scan Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-zinc-950/80 backdrop-blur-md border-t border-zinc-800/50">
|
||||
<button className="w-full rounded-lg bg-cyan-500 py-3 text-[11px] font-black uppercase tracking-widest text-black shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-all hover:bg-cyan-400 hover:scale-[0.98] active:scale-95 disabled:opacity-50 disabled:pointer-events-none">
|
||||
EXECUTE MARKET RE-SCAN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<aside className="flex h-full w-[300px] flex-col border-r border-terminal-border bg-terminal/95 transition-all">
|
||||
{/* Search Header */}
|
||||
<div className="p-4 border-b border-terminal-border">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 transition-colors group-focus-within:text-neon-cyan" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.lookupPlaceholder || "Search Terminal..."}
|
||||
className="w-full rounded-lg border border-terminal-border bg-slate-900/50 py-2 pl-9 pr-4 text-xs text-white placeholder:text-slate-600 focus:border-neon-cyan/50 focus:outline-none focus:ring-1 focus:ring-neon-cyan/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monitor List */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-4 custom-scrollbar">
|
||||
<div className="mb-4 flex items-center justify-between px-3">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
|
||||
{t.cityMonitor || "MONITORING NODES"}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 rounded-md border-terminal-border text-[10px] text-slate-400"
|
||||
>
|
||||
{cities.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{cities.map((city) => {
|
||||
const isActive =
|
||||
activeCity.toLowerCase() === city.name.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={city.name}
|
||||
onClick={() => onSelectCity(city.name)}
|
||||
className={`group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 transition-all ${
|
||||
isActive
|
||||
? "bg-neon-cyan/10 ring-1 ring-neon-cyan/30"
|
||||
: "hover:bg-slate-900/50"
|
||||
}`}
|
||||
>
|
||||
{/* Active Indicator Bar */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-4 w-1 -translate-y-1/2 rounded-r bg-neon-cyan shadow-[0_0_8px_rgba(34,211,238,0.8)]" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span
|
||||
className={`truncate text-[11px] font-bold uppercase tracking-wider ${isActive ? "text-neon-cyan" : "text-slate-200"}`}
|
||||
>
|
||||
{city.display_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-slate-500">
|
||||
{city.icao}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
city.risk_level === "high"
|
||||
? "bg-rose-500 shadow-[0_0_4px_rgba(244,63,94,0.6)]"
|
||||
: city.risk_level === "medium"
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate text-[10px] text-slate-500 font-medium">
|
||||
{city.airport.replace("Airport", "Intl")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 px-1">
|
||||
<Sparkline
|
||||
data={[10, 12, 11, 13, 12]}
|
||||
width={40}
|
||||
height={15}
|
||||
strokeWidth={1.5}
|
||||
color="#475569"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${isActive ? "translate-x-0.5 text-neon-cyan" : "text-slate-700 opacity-0 group-hover:opacity-100"}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Status */}
|
||||
<div className="border-t border-terminal-border bg-slate-950 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">
|
||||
UPLINK STATUS
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.8)]" />
|
||||
<span className="text-[10px] font-bold text-emerald-500">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-terminal-border bg-slate-900/50 p-2">
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-slate-400">
|
||||
<Activity className="h-3 w-3 text-neon-cyan" />
|
||||
<span>LATENCY: 12ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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 <div style={{ width, height }} />;
|
||||
|
||||
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 (
|
||||
<div className={className}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
filter="url(#glow)"
|
||||
style={{ opacity: 0.8 }}
|
||||
/>
|
||||
{/* Fill Area */}
|
||||
<path
|
||||
d={`M 0 ${height} L ${points} L ${width} ${height} Z`}
|
||||
fill={`url(#gradient-${color.replace("#", "")})`}
|
||||
style={{ opacity: 0.1 }}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`gradient-${color.replace("#", "")}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} />
|
||||
<stop offset="100%" stopColor="transparent" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<a
|
||||
href={href}
|
||||
className={`text-[10px] font-black uppercase tracking-widest transition-colors ${
|
||||
active
|
||||
? "text-neon-cyan shadow-[0_0_10px_rgba(34,211,238,0.3)]"
|
||||
: "text-slate-500 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen w-full flex-col overflow-hidden bg-terminal font-sans text-slate-200 antialiased">
|
||||
{/* Top Global Ticker / Nav */}
|
||||
<div className="flex h-14 items-center justify-between border-b border-terminal-border bg-slate-950/50 px-6 backdrop-blur-md">
|
||||
<div className="flex items-center gap-6">
|
||||
<NavLink href="#" label={t.marketScan} active />
|
||||
<NavLink href="#" label="FORENSICS" />
|
||||
<NavLink href="#" label="ARBITRAGE" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 rounded-full bg-slate-900 px-3 py-1 border border-terminal-border">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">
|
||||
{t.node || "Node"}:
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold text-neon-cyan tracking-widest">
|
||||
CLOUD-ALPHA-01
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-[10px] font-bold text-slate-400 hover:bg-white/5 hover:text-white transition-all border border-transparent hover:border-terminal-border"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="uppercase tracking-widest">{t.refresh}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border-r border-white/10 pr-3 mr-1 gap-1">
|
||||
<button
|
||||
onClick={() => setLang(lang === "en" ? "zh" : "en")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
|
||||
title={lang === "en" ? "Switch to Chinese" : "切换为英文"}
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 border-terminal-border bg-slate-900/50 text-[10px] font-mono text-slate-400"
|
||||
>
|
||||
NODE: CLOUD-ALPHA-01
|
||||
</Badge>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex h-8 items-center gap-2 rounded-md border border-terminal-border bg-slate-900/50 px-3 transition-all hover:bg-slate-800"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-slate-400 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-300">
|
||||
REFRESH
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Component List */}
|
||||
<CitySidebar
|
||||
cities={cities}
|
||||
activeCity={activeCity}
|
||||
onSelectCity={onSelectCity}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Center: Interactive Map Area */}
|
||||
<div className="relative flex-1 bg-slate-950 overflow-hidden flex flex-col">
|
||||
{/* Map UI Overlay */}
|
||||
<div className="absolute left-4 top-4 z-10 flex flex-col gap-2">
|
||||
<div className="rounded-lg border border-terminal-border bg-terminal/80 p-2 backdrop-blur-md shadow-2xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase">
|
||||
Current Focus
|
||||
</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-widest">
|
||||
{activeCity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-4 top-4 z-10 flex flex-col gap-2">
|
||||
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
|
||||
<Layers className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The Map */}
|
||||
<div className="flex-1 h-full min-h-0 relative bg-zinc-900/10">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Bottom Map Info Bar */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-10 flex items-center justify-between pointer-events-none">
|
||||
<div className="rounded-md border border-terminal-border bg-terminal/80 px-3 py-1.5 backdrop-blur-md pointer-events-auto">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
Projection: Mercator · Layer: Dynamic Thermal Ensemble
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 pointer-events-auto">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20">
|
||||
STABLE VIBE
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Intelligence Panel */}
|
||||
<AnalyticsPanel
|
||||
data={
|
||||
{
|
||||
city: activeCity,
|
||||
fetched_at: cityData?.updated_at || "",
|
||||
overview: {
|
||||
name: activeCity,
|
||||
display_name: cityData?.display_name || activeCity,
|
||||
icao: officialData?.aviation_weather?.icao || "",
|
||||
airport: cityData?.risk?.airport || "",
|
||||
lat: cityData?.lat || 0,
|
||||
lon: cityData?.lon || 0,
|
||||
local_time: cityData?.local_time || "",
|
||||
local_date: cityData?.local_date || "",
|
||||
temp_symbol: cityData?.temp_symbol || "°C",
|
||||
current_temp: currentTemp,
|
||||
deb_prediction: debPrediction,
|
||||
risk_level: cityData?.risk?.level || "low",
|
||||
risk_warning: cityData?.risk?.warning || "",
|
||||
updated_at: cityData?.updated_at || "",
|
||||
},
|
||||
official: {
|
||||
available: !!officialData?.aviation_weather?.available,
|
||||
metar: officialData?.aviation_weather?.observation,
|
||||
weather_gov: officialData?.weather_gov || {},
|
||||
mgm: cityData?.mgm || {},
|
||||
mgm_nearby: [],
|
||||
},
|
||||
timeseries: {
|
||||
metar_recent_obs: [],
|
||||
metar_today_obs: [],
|
||||
hourly: {},
|
||||
mgm_hourly: [],
|
||||
forecast_daily: cityData?.forecast?.multi_day || [],
|
||||
},
|
||||
models: cityData?.multi_model || {},
|
||||
probabilities: cityData?.probabilities || {
|
||||
mu: null,
|
||||
distribution: [],
|
||||
},
|
||||
market_scan: {
|
||||
available: !!marketData?.markets?.length,
|
||||
reason: null,
|
||||
primary_market: marketData?.markets?.[0] || null,
|
||||
selected_date: marketData?.target_date || null,
|
||||
selected_condition_id: null,
|
||||
selected_slug: null,
|
||||
temperature_bucket: null,
|
||||
model_probability: null,
|
||||
market_price: marketPrice,
|
||||
edge_percent: null,
|
||||
signal_label: "MONITOR",
|
||||
confidence: "low",
|
||||
yes_token: null,
|
||||
no_token: null,
|
||||
yes_buy: null,
|
||||
yes_sell: null,
|
||||
no_buy: null,
|
||||
no_sell: null,
|
||||
last_trade_price: null,
|
||||
liquidity: null,
|
||||
volume: null,
|
||||
sparkline: [],
|
||||
recent_trades: [],
|
||||
websocket: marketData?.websocket || {},
|
||||
},
|
||||
risk: cityData?.risk,
|
||||
ai_analysis: cityData?.ai_analysis || "",
|
||||
errors: {},
|
||||
} as any
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Ticker */}
|
||||
<footer className="h-8 border-t border-terminal-border bg-terminal-bg flex items-center px-4">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<span className="text-[10px] font-bold text-neon-cyan uppercase flex-shrink-0">
|
||||
TICKER:
|
||||
</span>
|
||||
<div className="flex items-center gap-6 animate-marquee whitespace-nowrap">
|
||||
{cities.slice(0, 10).map((c) => (
|
||||
<div key={c.name} className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-300">
|
||||
{c.display_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-emerald-500">
|
||||
24.5°
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-600">LTAC</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -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<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 };
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
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-2xl border border-border/80 bg-card/80 text-card-foreground shadow-xl backdrop-blur-xl",
|
||||
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-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-sm font-black uppercase tracking-[0.16em]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-5 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-5 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-border bg-background/70 px-4 py-2 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 bg-border/80",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-zinc-800/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user