"use client"; import type { ChartConfiguration } from "chart.js"; import clsx from "clsx"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { ForecastTable } from "@/components/dashboard/PanelSections"; import { useChart } from "@/hooks/useChart"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources"; import { CityDetail } from "@/lib/dashboard-types"; import { trackAppEvent } from "@/lib/app-analytics"; import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links"; import { getCityProfileStats, getRiskBadgeLabel, getTemperatureChartData, } from "@/lib/dashboard-utils"; function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) { const { locale, t } = useI18n(); const chartData = useMemo( () => getTemperatureChartData(detail, locale), [detail, locale], ); const canvasRef = useChart(() => { if (!chartData) { return { data: { datasets: [], labels: [] }, type: "line", } satisfies ChartConfiguration<"line">; } const forecastPoints = chartData.datasets.hasMgmHourly ? chartData.datasets.mgmHourlyPoints : chartData.datasets.debPast.map( (value, index) => value ?? chartData.datasets.debFuture[index], ); return { data: { datasets: [ { borderColor: chartData.datasets.hasMgmHourly ? "rgba(250, 204, 21, 0.92)" : "rgba(52, 211, 153, 0.86)", borderWidth: 1.8, data: forecastPoints, fill: false, label: chartData.datasets.hasMgmHourly ? locale === "en-US" ? "MGM Forecast" : "MGM 预测" : locale === "en-US" ? "DEB Forecast" : "DEB 预测", pointRadius: 0, spanGaps: true, tension: 0.28, }, { backgroundColor: "#22d3ee", borderColor: "#22d3ee", borderWidth: 0, data: chartData.datasets.metarPoints, fill: false, label: chartData.observationLabel || (locale === "en-US" ? "METAR Observation" : "METAR 实况"), pointHoverRadius: 6, pointRadius: 3.8, showLine: false, }, ], labels: chartData.times, }, options: { interaction: { intersect: false, mode: "index" }, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: "rgba(15, 23, 42, 0.95)", borderColor: "rgba(34, 211, 238, 0.25)", borderWidth: 1, }, }, responsive: true, scales: { x: { grid: { color: "rgba(255,255,255,0.03)" }, ticks: { callback: (_value, index) => typeof index === "number" && index % 4 === 0 ? chartData.times[index] : "", color: "#64748b", font: { size: 10 }, maxRotation: 0, }, }, y: { grid: { color: "rgba(255,255,255,0.03)" }, max: chartData.max, min: chartData.min, ticks: { callback: (value) => `${value}${detail.temp_symbol || "°C"}`, color: "#64748b", font: { size: 10 }, }, }, }, }, type: "line", } satisfies ChartConfiguration<"line">; }, [chartData, detail.temp_symbol, locale]); return (
{chartData?.legendText || t("detail.chartLegendEmpty")}
); } export function DetailPanel() { const store = useDashboardStore(); const { locale, t } = useI18n(); const router = useRouter(); const detail = store.selectedDetail; const selectedCityItem = useMemo( () => store.selectedCity ? store.cities.find((city) => city.name === store.selectedCity) || null : null, [store.cities, store.selectedCity], ); const selectedSummary = useMemo( () => store.selectedCity ? store.citySummariesByName[store.selectedCity] || null : null, [store.citySummariesByName, store.selectedCity], ); const isPro = store.proAccess.subscriptionActive; const isAuthenticated = store.proAccess.authenticated; const panelRef = useRef(null); const [heavyContentReady, setHeavyContentReady] = useState(false); const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen; const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen; const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem); const panelDisplayName = detail?.display_name || selectedSummary?.display_name || selectedCityItem?.display_name || store.selectedCity || "..."; const panelRiskLevel = detail?.risk?.level || selectedSummary?.risk?.level || selectedCityItem?.risk_level || "low"; const profileStats = useMemo( () => (detail ? getCityProfileStats(detail, locale) : []), [detail, locale], ); const officialLinks = useMemo( () => (detail ? getOfficialSourceLinks(detail) : []), [detail], ); const marketUrl = useMemo( () => getTodayPolymarketUrl(detail, locale), [detail, locale], ); const basicSettlementLabel = selectedSummary?.current?.settlement_source_label || selectedCityItem?.settlement_source_label || selectedCityItem?.settlement_source || (locale === "en-US" ? "Settlement source pending" : "结算口径待确认"); const basicAirportLabel = selectedCityItem?.airport || selectedSummary?.icao || (locale === "en-US" ? "Airport pending" : "机场待确认"); const heroSettlementLabel = detail?.current?.settlement_source_label || basicSettlementLabel; const heroAirportLabel = detail?.risk?.airport || basicAirportLabel; const blurActiveElement = () => { if (typeof document === "undefined") return; const active = document.activeElement; if (active instanceof HTMLElement) { active.blur(); } }; const handleFeatureAccess = (feature: "today" | "history") => { blurActiveElement(); if (!isPro) { trackAppEvent("paywall_feature_clicked", { entry: "detail_panel", feature, city: store.selectedCity, user_state: isAuthenticated ? "logged_in" : "guest", }); } if (isPro) { if (feature === "today") { void store.openTodayModal(); return; } void store.openHistory(); return; } if (isAuthenticated) { router.push("/account"); return; } if (feature === "today") { void store.openTodayModal(); return; } void store.openHistory(); }; useEffect(() => { const panel = panelRef.current; if (!panel) return; if (!isVisible) { panel.setAttribute("inert", ""); if (typeof document !== "undefined" && panel.contains(document.activeElement)) { const active = document.activeElement; if (active instanceof HTMLElement) { active.blur(); } } return; } panel.removeAttribute("inert"); }, [isVisible]); useEffect(() => { if (!isVisible || !detail) { setHeavyContentReady(false); return; } let canceled = false; let timeoutId: number | null = null; let idleId: number | null = null; const win = typeof window !== "undefined" ? (window as any) : null; const markReady = () => { if (!canceled) { setHeavyContentReady(true); } }; if (win && typeof win.requestIdleCallback === "function") { idleId = win.requestIdleCallback(markReady, { timeout: 180 }); } else if (typeof window !== "undefined") { timeoutId = window.setTimeout(markReady, 80); } else { setHeavyContentReady(true); } return () => { canceled = true; if (win && idleId != null && typeof win.cancelIdleCallback === "function") { win.cancelIdleCallback(idleId); } if (timeoutId != null && typeof window !== "undefined") { window.clearTimeout(timeoutId); } }; }, [detail, isVisible]); return ( ); }