From 2e5bdfd1b616fc6baecbc39ecd712586f7823ba0 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 11:10:12 +0800 Subject: [PATCH] Defer heavy city card rendering until users need it City decision cards rendered Chart.js canvases and AI evidence bodies before the user could see or expand those sections. This keeps the card shell visible while delaying chart data/canvas work until the section nears the viewport and skipping the AI evidence body while its details panel is collapsed. Constraint: Preserve existing decision-card layout and evidence copy. Rejected: Add list virtualization in the same pass | card-level render costs should be reduced before changing list mechanics. Confidence: high Scope-risk: narrow Reversibility: clean Tested: npm run build Not-tested: Runtime scroll benchmark on a large production city set. --- .../dashboard/ScanTerminal.module.css | 18 +++ .../scan-terminal/AiCityTemperatureChart.tsx | 43 +++++- .../scan-terminal/AiEvidencePanel.tsx | 129 ++++++++++-------- 3 files changed, 126 insertions(+), 64 deletions(-) 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}
); }