diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 27a2e8e5..fb0043c0 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -1,7 +1,7 @@ "use client"; import clsx from "clsx"; -import { Bug } from "lucide-react"; +import { Bug, ChevronDown, ChevronUp } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches"; @@ -109,6 +109,236 @@ function getWundergroundDailyHigh(hourly: HourlyForecast) { return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null; } +type AdvancedWeatherVariableItem = { + key: "wind_dir" | "wind_speed" | "dewpoint" | "humidity" | "pressure"; + label: string; + value: string; +}; + +type SourceCadenceSummary = { + label: string; + cadence: string; + status: string | null; +}; + +function formatCompactNumber(value: number, decimals = 1) { + const rounded = Number(value.toFixed(decimals)); + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(decimals); +} + +function fallbackCadenceSeconds(sourceText: string) { + const value = sourceText.toLowerCase(); + if (value.includes("amos")) return 60; + if (value.includes("amsc")) return 180; + if (value.includes("madis") || value.includes("hfmetar")) return 300; + if (value.includes("cowin")) return 60; + if (value.includes("hko")) return 600; + if (value.includes("cwa") || value.includes("jma") || value.includes("fmi") || value.includes("knmi")) return 600; + if (value.includes("mgm")) return 900; + if (value.includes("mss") || value.includes("singapore")) return 60; + return null; +} + +function formatCadence(seconds: number) { + return `${Math.round(seconds)}s`; +} + +function sourceStatusLabel(status: string | null | undefined, isEn: boolean) { + if (!status) return null; + const normalized = status.toLowerCase(); + if (normalized === "fresh") return isEn ? "fresh" : "新鲜"; + if (normalized === "expected_wait") return isEn ? "waiting for source" : "等待源头"; + if (normalized === "delayed") return isEn ? "delayed" : "延迟"; + if (normalized === "stale") return isEn ? "stale" : "过旧"; + if (normalized === "offline") return isEn ? "offline" : "离线"; + return status; +} + +function buildSourceCadenceSummary( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + isEn: boolean, +): SourceCadenceSummary | null { + const primary = hourly?.airportPrimary || hourly?.airportCurrent || null; + const sourceParts = [ + (primary as any)?.source, + primary?.source_code, + primary?.source_label, + primary?.station_code, + row?.metar_context?.source, + row?.metar_context?.station_label, + row?.airport, + ] + .map((item) => String(item || "").trim()) + .filter(Boolean); + const sourceText = sourceParts.join(" "); + const nativeCadence = validNumber(primary?.freshness?.native_update_interval_sec); + const cadenceSeconds = nativeCadence ?? fallbackCadenceSeconds(sourceText); + if (cadenceSeconds === null) return null; + + const label = + primary?.source_label || + primary?.source_code || + (primary as any)?.source || + row?.metar_context?.station_label || + row?.airport || + (isEn ? "Source" : "数据源"); + return { + label, + cadence: formatCadence(cadenceSeconds), + status: sourceStatusLabel(primary?.freshness?.freshness_status, isEn), + }; +} + +function buildAdvancedWeatherVariableItems( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + isEn: boolean, +): AdvancedWeatherVariableItem[] { + const primary = hourly?.airportPrimary || hourly?.airportCurrent || null; + const current = hourly?.current || null; + const metarContext = row?.metar_context || null; + const tempSymbol = row?.temp_symbol || primary?.temp_symbol || "°C"; + + const windDir = + validNumber(primary?.wind_dir) ?? + validNumber(current?.wind_dir) ?? + validNumber(metarContext?.airport_wind_dir); + const windSpeed = + validNumber(primary?.wind_speed_kt) ?? + validNumber(current?.wind_speed_kt) ?? + validNumber(metarContext?.airport_wind_speed_kt); + const dewpoint = + validNumber(current?.dewpoint) ?? + validNumber((current as any)?.dew_point) ?? + validNumber((primary as any)?.dewpoint) ?? + validNumber((primary as any)?.dew_point); + const humidity = + validNumber(primary?.humidity) ?? + validNumber(current?.humidity) ?? + validNumber(metarContext?.airport_humidity); + const pressure = + validNumber(primary?.pressure_hpa) ?? + validNumber((current as any)?.pressure_hpa) ?? + validNumber((current as any)?.pressure); + + const items: AdvancedWeatherVariableItem[] = []; + if (windDir !== null) { + items.push({ + key: "wind_dir", + label: isEn ? "Wind Dir" : "风向", + value: `${Math.round(windDir)}°`, + }); + } + if (windSpeed !== null) { + items.push({ + key: "wind_speed", + label: isEn ? "Wind Speed" : "风速", + value: `${formatCompactNumber(windSpeed)} kt`, + }); + } + if (dewpoint !== null) { + items.push({ + key: "dewpoint", + label: isEn ? "Dew Point" : "露点", + value: `${formatCompactNumber(dewpoint)}${tempSymbol}`, + }); + } + if (humidity !== null) { + items.push({ + key: "humidity", + label: isEn ? "Humidity" : "湿度", + value: `${formatCompactNumber(humidity)}%`, + }); + } + if (pressure !== null) { + items.push({ + key: "pressure", + label: isEn ? "Pressure" : "气压", + value: `${formatCompactNumber(pressure)} hPa`, + }); + } + return items; +} + +function SourceCadenceStrip({ + isEn, + summary, +}: { + isEn: boolean; + summary: SourceCadenceSummary | null; +}) { + if (!summary) return null; + + return ( +
+ {isEn + ? "Context only. These variables help explain capping and boundary-layer structure; they are not settlement-temperature curves." + : "仅作上下文。它们用于解释压温和边界层结构,不是结算温度曲线。"} +
+ )} +{isEn - ? "A calmer way to read airport weather, model forecasts, and intraday risk before the market moves." - : "用更轻松的方式阅读机场天气、模型预报和日内风险,在市场变化前完成判断。"} + ? "A settlement-source-first terminal for temperature markets: live airport/runway observations, DEB, market buckets, and alerts in one workflow." + : "面向温度市场的结算源优先终端:机场/跑道实测、DEB、市场温度桶和提醒放在同一个工作流里。"}
@@ -338,8 +344,8 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
+ {isEn ? "Differentiation" : "差异化卖点"} +
++ {isEn + ? "The product is built around the station, runway, and update cadence that affect settlement, then layers DEB, market buckets, and alerting on top." + : "产品围绕真正影响结算的站点、跑道和源头频率构建,再叠加 DEB、市场温度桶和提醒工作流。"} +
++ {isEn ? "Product Screenshots" : "产品截图"} +
++ {isEn + ? "Public packaging should show the actual workflow: live chart reading in the browser and concise runway alerts in Telegram." + : "公开包装应该直接展示真实工作流:浏览器里读实时图表,Telegram 里接收简洁的跑道提醒。"} +
+
+
+ + {isEn + ? "The collector writes cache and event streams first; the website consumes snapshots plus SSE patches, while Telegram reads the latest cached state." + : "采集器先写缓存和事件流;网站消费完整快照 + SSE patch,Telegram 只读取最新缓存状态。"} +
+{isEn - ? "Full Pro access for 30 days, including paid Telegram group eligibility." - : "完整 Pro 权限 30 天,包含付费 Telegram 群准入资格。"} + ? "Full terminal access, chart guides, advanced context, and paid Telegram group eligibility." + : "完整终端权限、读图指南、高级上下文和付费 Telegram 群准入资格。"}
-{isEn ? "Referral first month: 20 USDC" : "使用邀请码首月 20 USDC"}
+{isEn - ? "90 days of Pro access for users with steady usage. Lower cost per month." - : "90 天 Pro 权限,适合稳定使用的个人和团队,折算月成本更低。"} + ? "Not sold as a public product right now. The public site and Telegram workflow remain the supported product surface." + : "目前不作为公开产品售卖。当前支持的产品形态仍是网站终端和 Telegram 工作流。"}
-+ {isEn + ? "For private groups that need shared access, Telegram workflow support, and manual onboarding." + : "面向需要共享权限、Telegram 工作流支持和人工开通的私密团队。"} +
+