diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 2aae9999..ec565686 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -2437,6 +2437,91 @@ font-variant-numeric: tabular-nums; } +.root :global(.future-v2-daylight) { + margin-top: 14px; + padding: 12px 12px 10px; + border-radius: 12px; + border: 1px solid rgba(245, 158, 11, 0.14); + background: linear-gradient( + 180deg, + rgba(245, 158, 11, 0.08) 0%, + rgba(34, 211, 238, 0.04) 100% + ); +} + +.root :global(.future-v2-daylight-head) { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.root :global(.future-v2-daylight-head span) { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.root :global(.future-v2-daylight-head strong) { + color: #fde68a; + font-size: 12px; + font-weight: 700; +} + +.root :global(.future-v2-daylight-bar) { + position: relative; + height: 12px; + border-radius: 999px; + background: + linear-gradient( + 90deg, + rgba(15, 23, 42, 0.85) 0%, + rgba(56, 189, 248, 0.18) 15%, + rgba(250, 204, 21, 0.24) 50%, + rgba(56, 189, 248, 0.16) 85%, + rgba(15, 23, 42, 0.85) 100% + ); + overflow: hidden; +} + +.root :global(.future-v2-daylight-bar::before) { + content: ""; + position: absolute; + inset: 0; + width: var(--daylight-progress, 0%); + background: linear-gradient( + 90deg, + rgba(34, 211, 238, 0.22) 0%, + rgba(250, 204, 21, 0.4) 70%, + rgba(251, 146, 60, 0.5) 100% + ); +} + +.root :global(.future-v2-daylight-bar::after) { + content: ""; + position: absolute; + top: 50%; + left: var(--daylight-progress, 0%); + width: 14px; + height: 14px; + border-radius: 50%; + background: radial-gradient(circle, #fff7cc 0%, #facc15 65%, #fb923c 100%); + box-shadow: + 0 0 0 3px rgba(250, 204, 21, 0.16), + 0 0 18px rgba(250, 204, 21, 0.22); + transform: translate(-50%, -50%); +} + +.root :global(.future-v2-daylight-times) { + margin-top: 8px; + display: flex; + justify-content: space-between; + color: var(--text-secondary); + font-size: 11px; +} + .root :global(.future-v2-mini-grid) { margin-top: 14px; display: grid; @@ -2457,6 +2542,17 @@ gap: 4px; } +.root :global(.future-v2-signal-item) { + gap: 8px; +} + +.root :global(.future-v2-signal-head) { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + .root :global(.future-v2-mini-item span) { color: var(--text-muted); font-size: 11px; @@ -2469,6 +2565,134 @@ font-weight: 700; } +.root :global(.future-v2-signal-tag) { + font-style: normal; + font-size: 10px; + font-weight: 700; + line-height: 1; + padding: 5px 8px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); +} + +.root :global(.future-v2-signal-tag.cyan) { + color: #67e8f9; + border-color: rgba(34, 211, 238, 0.22); + background: rgba(34, 211, 238, 0.08); +} + +.root :global(.future-v2-signal-tag.blue) { + color: #93c5fd; + border-color: rgba(96, 165, 250, 0.22); + background: rgba(96, 165, 250, 0.08); +} + +.root :global(.future-v2-signal-tag.amber) { + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.22); + background: rgba(251, 191, 36, 0.08); +} + +.root :global(.future-v2-signal-meter) { + position: relative; + height: 7px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); +} + +.root :global(.future-v2-signal-fill) { + height: 100%; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(203, 213, 225, 0.25) 0%, + rgba(255, 255, 255, 0.32) 100% + ); +} + +.root :global(.future-v2-signal-fill.cyan) { + background: linear-gradient(90deg, #155e75 0%, #22d3ee 100%); +} + +.root :global(.future-v2-signal-fill.blue) { + background: linear-gradient(90deg, #2563eb 0%, #60a5fa 100%); +} + +.root :global(.future-v2-signal-fill.amber) { + background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%); +} + +.root :global(.intraday-scene-shell) { + margin-top: 12px; + margin-bottom: 10px; + border-radius: 14px; + border: 1px solid rgba(99, 102, 241, 0.14); + background: + radial-gradient( + circle at 50% 18%, + rgba(34, 211, 238, 0.14) 0%, + rgba(255, 255, 255, 0.02) 48%, + rgba(255, 255, 255, 0.015) 100% + ); + overflow: hidden; +} + +.root :global(.intraday-scene-frame) { + height: 196px; +} + +.root :global(.intraday-scene-frame canvas) { + width: 100%; + height: 100%; + display: block; +} + +.root :global(.intraday-scene-legend) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + padding: 10px 12px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(2, 6, 23, 0.18); +} + +.root :global(.intraday-scene-chip) { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; +} + +.root :global(.intraday-scene-chip-dot) { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 5px; + box-shadow: 0 0 10px currentColor; + flex: 0 0 auto; +} + +.root :global(.intraday-scene-chip-copy) { + min-width: 0; + display: grid; + gap: 2px; +} + +.root :global(.intraday-scene-chip-copy strong) { + color: var(--text-primary); + font-size: 11px; + font-weight: 700; +} + +.root :global(.intraday-scene-chip-copy span) { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; +} + .root :global(.future-v2-card-title) { margin: 0; color: var(--text-primary); @@ -2743,6 +2967,54 @@ font-weight: 700; } +.root :global(.future-trend-meter) { + position: relative; + height: 8px; + margin-top: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); +} + +.root :global(.future-trend-meter.center) { + background: + linear-gradient( + 90deg, + rgba(56, 189, 248, 0.1) 0%, + rgba(255, 255, 255, 0.05) 48%, + rgba(255, 255, 255, 0.05) 52%, + rgba(245, 158, 11, 0.1) 100% + ); +} + +.root :global(.future-trend-meter-midline) { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: rgba(255, 255, 255, 0.18); + transform: translateX(-50%); + z-index: 1; +} + +.root :global(.future-trend-meter-fill) { + position: absolute; + top: 0; + left: 0; + bottom: 0; + border-radius: inherit; + background: linear-gradient(90deg, #334155 0%, #94a3b8 100%); +} + +.root :global(.future-trend-meter-fill.warm) { + background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%); +} + +.root :global(.future-trend-meter-fill.cold) { + background: linear-gradient(90deg, #0f766e 0%, #22d3ee 100%); +} + .root :global(.future-trend-note) { margin-top: 6px; color: var(--text-secondary); @@ -2838,6 +3110,11 @@ font-size: 18px; } + .root :global(.future-v2-daylight-head) { + flex-direction: column; + align-items: flex-start; + } + .root :global(.future-modal-grid), .root :global(.future-trend-grid) { grid-template-columns: 1fr; @@ -2881,6 +3158,11 @@ font-size: 20px; } + .root :global(.future-v2-signal-head) { + flex-direction: column; + align-items: flex-start; + } + .root :global(.future-v2-main-chart) { min-height: 280px; } @@ -2897,6 +3179,14 @@ padding: 10px; } + .root :global(.intraday-scene-frame) { + height: 174px; + } + + .root :global(.intraday-scene-legend) { + grid-template-columns: 1fr; + } + .root :global(.future-trend-value) { font-size: 16px; } diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 5fbd2e8f..86b29523 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -19,6 +19,7 @@ import { useChart } from "@/hooks/useChart"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; +import { IntradaySignalScene } from "@/components/dashboard/IntradaySignalScene"; import { ModelForecast, ProbabilityDistribution, @@ -181,6 +182,159 @@ function parseVisibilityText( return "--"; } +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function parseClockMinutes(value?: string | null) { + const text = String(value || "").trim(); + const match = text.match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null; + return hours * 60 + minutes; +} + +function parseVisibilityMiles(value?: string | null) { + const text = String(value || "").trim(); + if (!text || text === "--") return null; + const match = text.match(/(\d+(?:\.\d+)?)/); + if (!match) return null; + const numeric = Number(match[1]); + return Number.isFinite(numeric) ? numeric : null; +} + +function parseLeadingNumber(value?: string | number | null) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const text = String(value || "").trim(); + const match = text.match(/[-+]?\d+(?:\.\d+)?/); + if (!match) return null; + const numeric = Number(match[0]); + return Number.isFinite(numeric) ? numeric : null; +} + +function getCurrentMetricDescriptor( + kind: "humidity" | "dewpoint" | "wind" | "visibility", + value: string, + numeric: number | null, + aux?: number | null, +) { + if (kind === "humidity") { + const percent = numeric != null ? clamp(numeric, 0, 100) : null; + const hint = + percent == null + ? "--" + : percent >= 75 + ? "偏湿" + : percent >= 45 + ? "适中" + : "偏干"; + const tone = + percent == null ? "neutral" : percent >= 75 ? "cyan" : percent >= 45 ? "blue" : "amber"; + return { fill: percent, hint, tone, value }; + } + + if (kind === "dewpoint") { + const spread = aux != null && numeric != null ? aux - numeric : null; + const closeness = + spread != null ? clamp(100 - spread * 12, 0, 100) : null; + const hint = + spread == null + ? "--" + : spread <= 2 + ? "近饱和" + : spread <= 6 + ? "偏湿" + : "偏干"; + const tone = + spread == null ? "neutral" : spread <= 2 ? "cyan" : spread <= 6 ? "blue" : "amber"; + return { fill: closeness, hint, tone, value }; + } + + if (kind === "wind") { + const percent = numeric != null ? clamp((numeric / 25) * 100, 0, 100) : null; + const hint = + numeric == null + ? "--" + : numeric >= 18 + ? "偏强" + : numeric >= 8 + ? "中等" + : "较弱"; + const tone = + numeric == null ? "neutral" : numeric >= 18 ? "amber" : numeric >= 8 ? "blue" : "cyan"; + return { fill: percent, hint, tone, value }; + } + + const percent = numeric != null ? clamp((numeric / 10) * 100, 0, 100) : null; + const hint = + numeric == null + ? "--" + : numeric >= 6 + ? "通透" + : numeric >= 3 + ? "一般" + : "受限"; + const tone = + numeric == null ? "neutral" : numeric >= 6 ? "cyan" : numeric >= 3 ? "blue" : "amber"; + return { fill: percent, hint, tone, value }; +} + +function getTrendMetricVisual(metric: { + label?: string; + value?: string; + tone?: string; +}) { + const label = String(metric.label || "").toLowerCase(); + const value = String(metric.value || ""); + const numeric = parseLeadingNumber(value); + + if (numeric == null) return null; + + if (label.includes("降水") || label.includes("precip")) { + return { + mode: "fill" as const, + percent: clamp(numeric, 0, 100), + tone: "cold" as const, + }; + } + + if (label.includes("温度") || label.includes("temp")) { + return { + mode: "center" as const, + percent: clamp(50 + (numeric / 4) * 50, 0, 100), + tone: numeric >= 0 ? "warm" as const : "cold" as const, + }; + } + + if (label.includes("露点") || label.includes("dew")) { + return { + mode: "center" as const, + percent: clamp(50 + (numeric / 3) * 50, 0, 100), + tone: numeric >= 0 ? "warm" as const : "cold" as const, + }; + } + + if (label.includes("气压") || label.includes("pressure")) { + return { + mode: "center" as const, + percent: clamp(50 + (numeric / 4) * 50, 0, 100), + tone: numeric >= 0 ? "warm" as const : "cold" as const, + }; + } + + if (label.includes("云量") || label.includes("cloud")) { + return { + mode: "center" as const, + percent: clamp(50 + (numeric / 40) * 50, 0, 100), + tone: numeric >= 0 ? "cold" as const : "warm" as const, + }; + } + + return null; +} + function DailyTemperatureChart({ dateStr }: { dateStr: string }) { const store = useDashboardStore(); const { locale, t } = useI18n(); @@ -796,6 +950,55 @@ export function FutureForecastModal() { detail.current?.raw_metar, detail.current?.visibility_mi, ); + const humidityValue = + fallbackHumidity != null ? Math.round(fallbackHumidity) : null; + const windValue = + detail.current?.wind_speed_kt != null + ? Number(detail.current.wind_speed_kt) + : null; + const visibilityValue = parseVisibilityMiles(visibilityText); + const daylightProgress = (() => { + const now = parseClockMinutes(detail.current?.obs_time); + const sunrise = parseClockMinutes(detail.forecast?.sunrise); + const sunset = parseClockMinutes(detail.forecast?.sunset); + if (now == null || sunrise == null || sunset == null || sunset <= sunrise) { + return null; + } + const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100); + const phase = + now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中"; + return { + phase, + percent, + }; + })(); + const currentMetricVisuals = [ + { + key: "humidity", + label: locale === "en-US" ? "Humidity" : "湿度", + ...getCurrentMetricDescriptor("humidity", humidityText, humidityValue), + }, + { + key: "dewpoint", + label: locale === "en-US" ? "Dew Point" : "露点", + ...getCurrentMetricDescriptor( + "dewpoint", + dewpointText, + fallbackDewpoint != null ? Number(fallbackDewpoint) : null, + detail.current?.temp != null ? Number(detail.current.temp) : null, + ), + }, + { + key: "wind", + label: locale === "en-US" ? "Wind" : "风速", + ...getCurrentMetricDescriptor("wind", windText, windValue), + }, + { + key: "visibility", + label: locale === "en-US" ? "Visibility" : "能见度", + ...getCurrentMetricDescriptor("visibility", visibilityText, visibilityValue), + }, + ]; const ai = getAirportNarrative(detail, locale); const risk = detail.risk || {}; const settlementSourceCode = String( @@ -1001,6 +1204,34 @@ export function FutureForecastModal() {
@{detail.current?.obs_time || "--"}
+ {daylightProgress ? ( +
+
+ + {locale === "en-US" + ? "Solar Window" + : "昼夜进度"} + + + {locale === "en-US" + ? `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%` + : `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%`} + +
+
+
+ {detail.forecast?.sunrise || "--"} + {detail.forecast?.sunset || "--"} +
+
+ ) : null}
@@ -1040,25 +1271,43 @@ export function FutureForecastModal() {

{locale === "en-US" ? "Current Metrics" : "当前指标"}

+
-
- {locale === "en-US" ? "Humidity" : "湿度"} - {humidityText} -
-
- {locale === "en-US" ? "Dew Point" : "露点"} - {dewpointText} -
-
- {locale === "en-US" ? "Wind" : "风速"} - {windText} -
-
- - {locale === "en-US" ? "Visibility" : "能见度"} - - {visibilityText} -
+ {currentMetricVisuals.map((metric) => ( +
+
+ {metric.label} + + {metric.hint} + +
+ {metric.value} +
+
+
+
+ ))}
@@ -1322,6 +1571,31 @@ export function FutureForecastModal() { > {metric.value}
+ {getTrendMetricVisual(metric) ? ( +
+ {getTrendMetricVisual(metric)?.mode === "center" ? ( + + ) : null} +
+
+ ) : null}
{metric.note}
))} @@ -1350,6 +1624,31 @@ export function FutureForecastModal() { > {metric.value}
+ {getTrendMetricVisual(metric) ? ( +
+ {getTrendMetricVisual(metric)?.mode === "center" ? ( + + ) : null} +
+
+ ) : null}
{metric.note}
))} diff --git a/frontend/components/dashboard/IntradaySignalScene.tsx b/frontend/components/dashboard/IntradaySignalScene.tsx new file mode 100644 index 00000000..2ed89a2a --- /dev/null +++ b/frontend/components/dashboard/IntradaySignalScene.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; + +export interface IntradaySignalMetric { + key: string; + label: string; + value: string; + hint: string; + fill: number | null; + tone: string; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getToneColor(tone: string) { + if (tone === "cyan") return "#22d3ee"; + if (tone === "blue") return "#60a5fa"; + if (tone === "amber") return "#f59e0b"; + return "#94a3b8"; +} + +export function IntradaySignalScene({ + metrics, + score, +}: { + metrics: IntradaySignalMetric[]; + score: number; +}) { + const containerRef = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); + + useEffect(() => { + const host = containerRef.current; + if (!host) return; + + const renderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: true, + powerPreference: "low-power", + }); + renderer.setClearColor(0x000000, 0); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100); + camera.position.set(0, 2.8, 7.2); + camera.lookAt(0, 1.2, 0); + + const ambient = new THREE.AmbientLight(0xbfe8ff, 1.25); + const keyLight = new THREE.PointLight(0x67e8f9, 22, 18, 2); + keyLight.position.set(-3.8, 5.6, 4.8); + const warmLight = new THREE.PointLight(0xf59e0b, 12, 16, 2); + warmLight.position.set(4.2, 2.8, 4); + scene.add(ambient, keyLight, warmLight); + + const stage = new THREE.Group(); + scene.add(stage); + + const floor = new THREE.Mesh( + new THREE.CylinderGeometry(3.3, 3.8, 0.12, 48), + new THREE.MeshStandardMaterial({ + color: new THREE.Color(score >= 0 ? "#10263b" : "#2c1d12"), + emissive: new THREE.Color(score >= 0 ? "#0e7490" : "#b45309"), + emissiveIntensity: 0.18 + clamp(Math.abs(score) / 8, 0, 0.24), + metalness: 0.2, + roughness: 0.78, + }), + ); + floor.position.y = -0.12; + stage.add(floor); + + const ring = new THREE.Mesh( + new THREE.TorusGeometry(2.8, 0.03, 18, 100), + new THREE.MeshBasicMaterial({ + color: new THREE.Color(score >= 0 ? "#22d3ee" : "#f59e0b"), + transparent: true, + opacity: 0.5, + }), + ); + ring.rotation.x = Math.PI / 2; + ring.position.y = 0.03; + stage.add(ring); + + const barGeometry = new THREE.BoxGeometry(0.8, 1, 0.8); + const capGeometry = new THREE.SphereGeometry(0.16, 16, 16); + const bars: Array<{ + mesh: THREE.Mesh; + cap: THREE.Mesh; + glow: THREE.Mesh; + baseY: number; + targetHeight: number; + }> = []; + + const xPositions = [-1.8, -0.6, 0.6, 1.8]; + metrics.slice(0, 4).forEach((metric, index) => { + const height = 0.5 + ((metric.fill ?? 20) / 100) * 2.8; + const color = new THREE.Color(getToneColor(metric.tone)); + const material = new THREE.MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.22, + metalness: 0.14, + roughness: 0.38, + }); + const mesh = new THREE.Mesh(barGeometry, material); + mesh.position.set(xPositions[index] || 0, height / 2, 0); + mesh.scale.y = height; + stage.add(mesh); + + const cap = new THREE.Mesh( + capGeometry, + new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.95, + }), + ); + cap.position.set(mesh.position.x, height + 0.2, 0); + stage.add(cap); + + const glow = new THREE.Mesh( + new THREE.CylinderGeometry(0.46, 0.58, 0.08, 32), + new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.22, + }), + ); + glow.position.set(mesh.position.x, 0.06, 0); + stage.add(glow); + + bars.push({ mesh, cap, glow, baseY: cap.position.y, targetHeight: height }); + }); + + const resize = () => { + const width = Math.max(host.clientWidth, 1); + const height = Math.max(host.clientHeight, 1); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + }; + + resize(); + host.appendChild(renderer.domElement); + + const clock = new THREE.Clock(); + let frameId = 0; + + const renderFrame = () => { + frameId = window.requestAnimationFrame(renderFrame); + const elapsed = clock.getElapsedTime(); + stage.rotation.y = Math.sin(elapsed * 0.35) * 0.16; + ring.material.opacity = 0.38 + Math.sin(elapsed * 0.8) * 0.08; + + bars.forEach((bar, index) => { + const pulse = prefersReducedMotion + ? 0 + : Math.sin(elapsed * 1.5 + index * 0.8) * 0.08; + bar.cap.position.y = bar.baseY + pulse; + bar.glow.scale.x = 1 + Math.sin(elapsed * 1.2 + index) * 0.06; + bar.glow.scale.z = 1 + Math.sin(elapsed * 1.2 + index) * 0.06; + }); + + renderer.render(scene, camera); + }; + + const observer = new ResizeObserver(resize); + observer.observe(host); + frameId = window.requestAnimationFrame(renderFrame); + + return () => { + observer.disconnect(); + window.cancelAnimationFrame(frameId); + stage.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + if (Array.isArray(child.material)) { + child.material.forEach((material) => material.dispose()); + } else { + child.material.dispose(); + } + } + }); + renderer.dispose(); + if (renderer.domElement.parentNode === host) { + host.removeChild(renderer.domElement); + } + }; + }, [metrics, prefersReducedMotion, score]); + + return ( +
+ + ); +}