diff --git a/frontend/components/dashboard/ScanTerminal.module.css b/frontend/components/dashboard/ScanTerminal.module.css index 05c94b19..92993633 100644 --- a/frontend/components/dashboard/ScanTerminal.module.css +++ b/frontend/components/dashboard/ScanTerminal.module.css @@ -3778,6 +3778,18 @@ height: 100% !important; } +.root :global(.scan-ai-city-chart-placeholder) { + display: grid; + height: 100%; + place-items: center; + border: 1px dashed rgba(77, 163, 255, 0.22); + border-radius: 14px; + background: rgba(11, 18, 32, 0.35); + color: #7f96b6; + font-size: 12px; + font-weight: 800; +} + .root :global(.scan-ai-city-chart-legend) { display: flex; gap: 14px; @@ -5818,6 +5830,12 @@ color: #64748b; } +.root :global(.scan-terminal.light .scan-ai-city-chart-placeholder) { + border-color: #cbd5e1; + background: #f8fafc; + color: #64748b; +} + .root :global(.scan-terminal.light .scan-ai-log-panel) { border-color: rgba(35, 72, 118, 0.12); background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9)); diff --git a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx index 4d1b9c09..c4b8ae38 100644 --- a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx +++ b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx @@ -1,5 +1,5 @@ import type { ChartConfiguration } from "chart.js"; -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { BarChart3 } from "lucide-react"; import type { CityDetail } from "@/lib/dashboard-types"; import { useChart } from "@/hooks/useChart"; @@ -63,11 +63,16 @@ function buildTemperatureChartSignature(detail: CityDetail) { export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { const { locale } = useI18n(); + const sectionRef = useRef(null); + const [shouldRenderChart, setShouldRenderChart] = useState(false); const cityKey = `${detail.name || detail.display_name || ""}:${detail.local_date || ""}`; - const chartSignature = useMemo(() => buildTemperatureChartSignature(detail), [detail]); + const chartSignature = useMemo( + () => (shouldRenderChart ? buildTemperatureChartSignature(detail) : ""), + [detail, shouldRenderChart], + ); const computedChartData = useMemo( - () => getTemperatureChartData(detail, locale), - [chartSignature, locale], + () => (shouldRenderChart ? getTemperatureChartData(detail, locale) : null), + [chartSignature, detail, locale, shouldRenderChart], ); const lastChartDataRef = useRef<{ cityKey: string; @@ -183,14 +188,40 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { } satisfies ChartConfiguration<"line">; }, [chartData, detail.temp_symbol, forecastLabel, observationLabel]); + useEffect(() => { + if (shouldRenderChart) return; + if (typeof window === "undefined" || !("IntersectionObserver" in window)) { + setShouldRenderChart(true); + return; + } + const target = sectionRef.current; + if (!target) return; + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + setShouldRenderChart(true); + observer.disconnect(); + }, + { rootMargin: "220px 0px" }, + ); + observer.observe(target); + return () => observer.disconnect(); + }, [shouldRenderChart]); + return ( -
+
{locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"}
- + {shouldRenderChart ? ( + + ) : ( +
+ {locale === "en-US" ? "Chart will render when visible" : "图表进入视口后再渲染"} +
+ )}
{chartData ? (
diff --git a/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx index 49f4673c..76aab13b 100644 --- a/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx +++ b/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import type { AiCityForecastPayload, AiCityForecastState, @@ -36,8 +37,18 @@ export function AiEvidencePanel({ localizedFinalJudgment: string; rawObservationText: string; }) { + const [isOpen, setIsOpen] = useState(() => !isCompactCard); + + useEffect(() => { + setIsOpen(!isCompactCard); + }, [isCompactCard]); + return ( -
+
setIsOpen(event.currentTarget.open)} + > {isHkoObservation ? isEn @@ -47,69 +58,71 @@ export function AiEvidencePanel({ ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"} -
- {aiForecast.status === "loading" ? ( - <> -

{aiReadInProgressText}

- {localizedFinalJudgment || aiForecast.streamText ? ( + {isOpen ? ( +
+ {aiForecast.status === "loading" ? ( + <> +

{aiReadInProgressText}

+ {localizedFinalJudgment || aiForecast.streamText ? ( +

+ {localizedFinalJudgment || aiForecast.streamText} +

+ ) : null}

- {localizedFinalJudgment || aiForecast.streamText} + {isEn + ? isHkoObservation + ? "Rule evidence is shown first; the full HKO AI read will merge automatically." + : "Rule evidence is shown first; the full airport AI read will merge automatically." + : isHkoObservation + ? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。" + : "先展示规则证据,完整机场 AI 解读返回后会自动合并。"}

- ) : null} -

+ + ) : aiForecast.status === "ready" && aiCityForecast ? ( + <> +

+ {aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText} +

+
    + {[localizedFinalJudgment, ...aiBullets] + .filter((line) => String(line || "").trim()) + .map((line, index) => ( +
  • {line}
  • + ))} +
+

{rawObservationText}

+ + ) : aiForecast.status === "ready" ? ( + <> +

{aiRuleEvidenceText}

+
    + {fallbackAiReason ?
  • {fallbackAiReason}
  • : null} +
  • {localModelSupportNote}
  • +
  • {rawObservationText}
  • +
+ + ) : aiForecast.status === "failed" ? ( + <> +

{aiRuleEvidenceText}

+
    + {aiForecast.error ?
  • {aiForecast.error}
  • : null} +
  • {localModelSupportNote}
  • +
  • {rawObservationText}
  • +
+ + ) : ( +

{isEn ? isHkoObservation - ? "Rule evidence is shown first; the full HKO AI read will merge automatically." - : "Rule evidence is shown first; the full airport AI read will merge automatically." + ? "Waiting for AI to read the latest HKO observation." + : "Waiting for AI to read the latest airport bulletin." : isHkoObservation - ? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。" - : "先展示规则证据,完整机场 AI 解读返回后会自动合并。"} + ? "等待 AI 解读最新香港天文台观测。" + : "等待 AI 解读最新机场报文。"}

- - ) : aiForecast.status === "ready" && aiCityForecast ? ( - <> -

- {aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText} -

-
    - {[localizedFinalJudgment, ...aiBullets] - .filter((line) => String(line || "").trim()) - .map((line, index) => ( -
  • {line}
  • - ))} -
-

{rawObservationText}

- - ) : aiForecast.status === "ready" ? ( - <> -

{aiRuleEvidenceText}

-
    - {fallbackAiReason ?
  • {fallbackAiReason}
  • : null} -
  • {localModelSupportNote}
  • -
  • {rawObservationText}
  • -
- - ) : aiForecast.status === "failed" ? ( - <> -

{aiRuleEvidenceText}

-
    - {aiForecast.error ?
  • {aiForecast.error}
  • : null} -
  • {localModelSupportNote}
  • -
  • {rawObservationText}
  • -
- - ) : ( -

- {isEn - ? isHkoObservation - ? "Waiting for AI to read the latest HKO observation." - : "Waiting for AI to read the latest airport bulletin." - : isHkoObservation - ? "等待 AI 解读最新香港天文台观测。" - : "等待 AI 解读最新机场报文。"} -

- )} -
+ )} +
+ ) : null}
); }