Separate city-card rendering from forecast orchestration

AiPinnedForecastView still owns the city-card data assembly, but its header, decision band, and AI evidence rendering now live in focused presentational sections. This keeps the next decision-state refactor smaller while preserving the current UI and interaction behavior.

Constraint: Keep weather, market, and AI evidence calculations unchanged in this pass.
Rejected: Move business-state construction at the same time | mixing behavior extraction with view extraction would make regressions harder to isolate.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Tested: npm run build
Not-tested: Browser manual regression on mobile expand/collapse states.
This commit is contained in:
2569718930@qq.com
2026-04-28 09:26:46 +08:00
parent cf5502b6d6
commit bf8de6a09b
2 changed files with 454 additions and 278 deletions
@@ -0,0 +1,380 @@
"use client";
import clsx from "clsx";
import { ChevronDown, RefreshCw, X } from "lucide-react";
import type { MouseEvent } from "react";
import type {
MarketDecisionView,
WeatherDecisionView,
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
type StatusTone = "green" | "blue" | "amber" | "red" | "muted";
export type CityStatusTag = {
label: string;
tone: StatusTone;
};
export type DataFreshnessRow = {
label: string;
value: string;
tone: string;
};
export function CityCardHeader({
aiStatusLabel,
aiStatusTone,
collapseId,
collapsed,
currentTempText,
dataFreshnessRows,
debText,
detailLocalTime,
displayName,
expectedHighText,
freshnessSeparator,
isEn,
isRefreshing,
modelRange,
onRefresh,
onRemove,
onToggleCollapsed,
peakWindow,
removing,
rowLocalTime,
statusTags,
}: {
aiStatusLabel: string;
aiStatusTone: StatusTone;
collapseId: string;
collapsed: boolean;
currentTempText: string;
dataFreshnessRows: DataFreshnessRow[];
debText: string;
detailLocalTime?: string | null;
displayName: string;
expectedHighText: string;
freshnessSeparator: string;
isEn: boolean;
isRefreshing: boolean;
modelRange: string;
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
onToggleCollapsed: () => void;
peakWindow: string;
removing?: boolean;
rowLocalTime?: string | null;
statusTags: CityStatusTag[];
}) {
return (
<header className="scan-ai-city-hero">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "Deep analysis" : "城市深度分析"}
</span>
<h3>{displayName}</h3>
<div className="scan-ai-city-mobile-priority" aria-label={isEn ? "Key city decision metrics" : "城市决策重点"}>
<span>
<small>{isEn ? "Observed" : "当前温度"}</small>
<b>{currentTempText}</b>
</span>
<span>
<small>{isEn ? "Expected high" : "预测高点"}</small>
<b>{expectedHighText}</b>
</span>
<span>
<small>{isEn ? "Peak" : "峰值时间"}</small>
<b>{peakWindow}</b>
</span>
</div>
<div className="scan-ai-city-status-tags">
{statusTags.map((tag) => (
<span
key={tag.label}
className={clsx("scan-ai-city-status-tag", tag.tone)}
>
{tag.label}
</span>
))}
</div>
<div className="scan-ai-city-pills">
<span>{detailLocalTime || rowLocalTime || "--"}</span>
<span>DEB {debText}</span>
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
<span>{isEn ? "Peak" : "峰值"} {peakWindow}</span>
</div>
<div className="scan-ai-city-freshness" aria-label={isEn ? "Data freshness" : "数据新鲜度"}>
<strong>{isEn ? "Data freshness" : "数据新鲜度"}</strong>
{dataFreshnessRows.map((freshness) => (
<span key={freshness.label} className={freshness.tone}>
<b>{freshness.label}{freshnessSeparator}</b>
<em>{freshness.value}</em>
</span>
))}
<span className={aiStatusTone}>
<b>AI{freshnessSeparator}</b>
<em>{aiStatusLabel}</em>
</span>
</div>
</div>
<div className="scan-ai-city-hero-side">
<span>{isEn ? "Expected high" : "预计最高温"}</span>
<strong>{expectedHighText}</strong>
<div className="scan-ai-city-actions">
<button
type="button"
className="scan-ai-city-icon-button"
onClick={onRefresh}
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
title={
isEn
? "Refresh city data, chart and AI analysis"
: "刷新城市数据、温度走势图和 AI 分析"
}
disabled={isRefreshing}
>
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
</button>
<button
type="button"
className="scan-ai-city-icon-button danger"
onClick={onRemove}
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
title={isEn ? "Remove city" : "移除城市"}
disabled={removing}
>
<X size={15} />
</button>
<button
type="button"
className="scan-ai-city-collapse"
onClick={onToggleCollapsed}
aria-expanded={!collapsed}
aria-controls={collapseId}
>
<ChevronDown size={15} />
{collapsed ? (isEn ? "Expand" : "展开") : (isEn ? "Collapse" : "收起")}
</button>
</div>
</div>
</header>
);
}
export function WeatherDecisionBand({
currentTempText,
decisionView,
decisionWhyText,
isEn,
longText,
marketDecisionView,
marketLineText,
paceDeltaText,
peakWindow,
}: {
currentTempText: string;
decisionView: WeatherDecisionView;
decisionWhyText: string;
isEn: boolean;
longText: string;
marketDecisionView: MarketDecisionView;
marketLineText: string;
paceDeltaText: string;
peakWindow: string;
}) {
return (
<section className={clsx("scan-ai-decision-band", decisionView.tone)}>
<div className="scan-ai-decision-main">
<span>{decisionView.kicker}</span>
<strong>{decisionView.action}</strong>
<p className="scan-ai-decision-why">{decisionWhyText}</p>
<p className="scan-ai-decision-long">{longText}</p>
<div className="scan-ai-decision-reasons">
{decisionView.reasons.map((reason, index) => (
<small key={`${reason}-${index}`}>{reason}</small>
))}
</div>
<p className="scan-ai-decision-risk">{decisionView.risk}</p>
<div className={clsx("scan-ai-market-mobile-line", marketDecisionView.tone)}>
<span>{isEn ? "Market price" : "市场价格"}</span>
<b>{marketLineText}</b>
</div>
<div className={clsx("scan-ai-market-decision", marketDecisionView.tone)}>
<div>
<span>{isEn ? "Polymarket price layer" : "Polymarket 价格层"}</span>
<strong>{marketDecisionView.title}</strong>
<p>{marketDecisionView.reason}</p>
</div>
<div className="scan-ai-market-decision-stats">
<small>
{isEn ? "Bucket" : "温度桶"} <b>{marketDecisionView.bucketLabel}</b>
</small>
<small>
{isEn ? "YES buy" : "YES 买价"} <b>{marketDecisionView.priceText}</b>
</small>
<small>
{isEn ? "Model-market" : "模型-市场差"} <b>{marketDecisionView.edgeText}</b>
</small>
</div>
{marketDecisionView.marketUrl ? (
<a
className="scan-ai-market-link"
href={marketDecisionView.marketUrl}
target="_blank"
rel="noreferrer"
>
{isEn ? "Open market" : "打开市场"}
</a>
) : null}
</div>
</div>
<div className="scan-ai-decision-metrics">
<span>
{isEn ? "Expected high" : "预计高点"}
<b>{decisionView.expectedHigh}</b>
</span>
<span>
{isEn ? "Weather range" : "天气区间"}
<b>{decisionView.targetRange}</b>
</span>
<span>
{isEn ? "Confidence" : "信心"}
<b>{decisionView.confidence}</b>
</span>
<span>
{isEn ? "Observed" : "实测"}
<b>{currentTempText}</b>
</span>
<span>
{isEn ? "Path delta" : "路径偏差"} <b>{paceDeltaText}</b>
</span>
<span>
{isEn ? "Peak window" : "峰值窗口"} <b>{peakWindow}</b>
</span>
<span>
{isEn ? "Market implied" : "市场隐含"} <b>{marketDecisionView.impliedText}</b>
</span>
<span>
{isEn ? "Model prob" : "模型概率"} <b>{marketDecisionView.modelText}</b>
</span>
<span>
{isEn ? "Quote status" : "报价状态"}{" "}
<b>{marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")}</b>
</span>
</div>
</section>
);
}
export function AiEvidencePanel({
aiBullets,
aiCityForecast,
aiForecast,
aiReadCompleteText,
aiReadInProgressText,
aiRuleEvidenceMode,
aiRuleEvidenceText,
fallbackAiReason,
isCompactCard,
isEn,
isHkoObservation,
localModelSupportNote,
localizedFinalJudgment,
rawObservationText,
}: {
aiBullets: string[];
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
aiForecast: AiCityForecastState;
aiReadCompleteText: string;
aiReadInProgressText: string;
aiRuleEvidenceMode: boolean;
aiRuleEvidenceText: string;
fallbackAiReason: string;
isCompactCard: boolean;
isEn: boolean;
isHkoObservation: boolean;
localModelSupportNote: string;
localizedFinalJudgment: string;
rawObservationText: string;
}) {
return (
<details className="scan-ai-city-section scan-ai-city-ai-read" open={!isCompactCard}>
<summary className="scan-ai-city-section-title">
{isHkoObservation
? isEn
? "Evidence · AI HKO observation read"
: "证据 · AI 香港天文台观测解读"
: isEn
? "Evidence · AI airport read"
: "证据 · AI 机场报文解读"}
</summary>
<div className="scan-ai-city-section-body">
{aiForecast.status === "loading" ? (
<>
<p className="scan-ai-weather-summary">{aiReadInProgressText}</p>
{localizedFinalJudgment || aiForecast.streamText ? (
<p className="scan-ai-city-muted">
{localizedFinalJudgment || aiForecast.streamText}
</p>
) : null}
<p className="scan-ai-city-muted">
{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 解读返回后会自动合并。"}
</p>
</>
) : aiForecast.status === "ready" && aiCityForecast ? (
<>
<p className="scan-ai-weather-summary">
{aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText}
</p>
<ul className="scan-ai-weather-bullets">
{[localizedFinalJudgment, ...aiBullets]
.filter((line) => String(line || "").trim())
.map((line, index) => (
<li key={`${line}-${index}`}>{line}</li>
))}
</ul>
<p className="scan-ai-raw-metar">{rawObservationText}</p>
</>
) : aiForecast.status === "ready" ? (
<>
<p className="scan-ai-weather-summary">{aiRuleEvidenceText}</p>
<ul className="scan-ai-weather-bullets">
{fallbackAiReason ? <li>{fallbackAiReason}</li> : null}
<li>{localModelSupportNote}</li>
<li>{rawObservationText}</li>
</ul>
</>
) : aiForecast.status === "failed" ? (
<>
<p className="scan-ai-weather-summary">{aiRuleEvidenceText}</p>
<ul className="scan-ai-weather-bullets">
{aiForecast.error ? <li>{aiForecast.error}</li> : null}
<li>{localModelSupportNote}</li>
<li>{rawObservationText}</li>
</ul>
</>
) : (
<p>
{isEn
? isHkoObservation
? "Waiting for AI to read the latest HKO observation."
: "Waiting for AI to read the latest airport bulletin."
: isHkoObservation
? "等待 AI 解读最新香港天文台观测。"
: "等待 AI 解读最新机场报文。"}
</p>
)}
</div>
</details>
);
}
@@ -1,10 +1,14 @@
"use client";
import clsx from "clsx";
import { ChevronDown, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ModelForecast } from "@/components/dashboard/PanelSections";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import {
AiEvidencePanel,
CityCardHeader,
WeatherDecisionBand,
} from "@/components/dashboard/scan-terminal/AiPinnedCityCardSections";
import {
buildMarketDecisionView,
buildWeatherDecisionView,
@@ -394,6 +398,10 @@ function AiPinnedCityCard({
currentTempNumber != null
? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 })
: "--";
const debText =
debNumber != null
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
: "--";
const marketDecisionView = buildMarketDecisionView({
expectedHigh: decisionExpectedHighNumber,
isEn,
@@ -634,289 +642,77 @@ function AiPinnedCityCard({
return (
<article className={clsx("scan-ai-city-card", collapsed && "collapsed", removing && "removing")}>
<header className="scan-ai-city-hero">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "Deep analysis" : "城市深度分析"}
</span>
<h3>{displayName}</h3>
<div className="scan-ai-city-mobile-priority" aria-label={isEn ? "Key city decision metrics" : "城市决策重点"}>
<span>
<small>{isEn ? "Observed" : "当前温度"}</small>
<b>{currentTempText}</b>
</span>
<span>
<small>{isEn ? "Expected high" : "预测高点"}</small>
<b>{expectedHighText}</b>
</span>
<span>
<small>{isEn ? "Peak" : "峰值时间"}</small>
<b>{peakWindow}</b>
</span>
</div>
<div className="scan-ai-city-status-tags">
{statusTags.map((tag) => (
<span
key={tag.label}
className={clsx("scan-ai-city-status-tag", tag.tone)}
>
{tag.label}
</span>
))}
</div>
<div className="scan-ai-city-pills">
<span>{detail?.local_time || row?.local_time || "--"}</span>
<span>
DEB{" "}
{debNumber != null
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
: "--"}
</span>
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
<span>{isEn ? "Peak" : "峰值"} {peakWindow}</span>
</div>
<div className="scan-ai-city-freshness" aria-label={isEn ? "Data freshness" : "数据新鲜度"}>
<strong>{isEn ? "Data freshness" : "数据新鲜度"}</strong>
{dataFreshnessRows.map((freshness) => (
<span key={freshness.label} className={freshness.tone}>
<b>{freshness.label}{freshnessSeparator}</b>
<em>{freshness.value}</em>
</span>
))}
<span className={aiStatusTone}>
<b>AI{freshnessSeparator}</b>
<em>{aiStatusLabel}</em>
</span>
</div>
</div>
<div className="scan-ai-city-hero-side">
<span>{isEn ? "Expected high" : "预计最高温"}</span>
<strong>
{expectedHighText}
</strong>
<div className="scan-ai-city-actions">
<button
type="button"
className="scan-ai-city-icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (refreshingDetail) return;
setRefreshingDetail(true);
void onRefreshCityDetail(detailCityName)
.catch(() => {})
.finally(() => {
refreshAiForecast();
setRefreshingDetail(false);
});
}}
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
title={
isEn
? "Refresh city data, chart and AI analysis"
: "刷新城市数据、温度走势图和 AI 分析"
}
disabled={isRefreshing}
>
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
</button>
<button
type="button"
className="scan-ai-city-icon-button danger"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemove();
}}
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
title={isEn ? "Remove city" : "移除城市"}
disabled={removing}
>
<X size={15} />
</button>
<button
type="button"
className="scan-ai-city-collapse"
onClick={onToggleCollapsed}
aria-expanded={!collapsed}
aria-controls={collapseId}
>
<ChevronDown size={15} />
{collapsed ? (isEn ? "Expand" : "展开") : (isEn ? "Collapse" : "收起")}
</button>
</div>
</div>
</header>
<CityCardHeader
aiStatusLabel={aiStatusLabel}
aiStatusTone={aiStatusTone}
collapseId={collapseId}
collapsed={collapsed}
currentTempText={currentTempText}
dataFreshnessRows={dataFreshnessRows}
debText={debText}
detailLocalTime={detail?.local_time}
displayName={displayName}
expectedHighText={expectedHighText}
freshnessSeparator={freshnessSeparator}
isEn={isEn}
isRefreshing={isRefreshing}
modelRange={modelRange}
onRefresh={(event) => {
event.preventDefault();
event.stopPropagation();
if (refreshingDetail) return;
setRefreshingDetail(true);
void onRefreshCityDetail(detailCityName)
.catch(() => {})
.finally(() => {
refreshAiForecast();
setRefreshingDetail(false);
});
}}
onRemove={(event) => {
event.preventDefault();
event.stopPropagation();
onRemove();
}}
onToggleCollapsed={onToggleCollapsed}
peakWindow={peakWindow}
removing={removing}
rowLocalTime={row?.local_time}
statusTags={statusTags}
/>
{detail && !collapsed ? (
<div className="scan-ai-city-body" id={collapseId}>
<section className={clsx("scan-ai-decision-band", decisionView.tone)}>
<div className="scan-ai-decision-main">
<span>{decisionView.kicker}</span>
<strong>{decisionView.action}</strong>
<p className="scan-ai-decision-why">{decisionWhyText}</p>
<p className="scan-ai-decision-long">{localizedFinalJudgment || paceText}</p>
<div className="scan-ai-decision-reasons">
{decisionView.reasons.map((reason, index) => (
<small key={`${reason}-${index}`}>{reason}</small>
))}
</div>
<p className="scan-ai-decision-risk">{decisionView.risk}</p>
<div className={clsx("scan-ai-market-mobile-line", marketDecisionView.tone)}>
<span>{isEn ? "Market price" : "市场价格"}</span>
<b>{marketLineText}</b>
</div>
<div className={clsx("scan-ai-market-decision", marketDecisionView.tone)}>
<div>
<span>
{isEn ? "Polymarket price layer" : "Polymarket 价格层"}
</span>
<strong>{marketDecisionView.title}</strong>
<p>{marketDecisionView.reason}</p>
</div>
<div className="scan-ai-market-decision-stats">
<small>
{isEn ? "Bucket" : "温度桶"} <b>{marketDecisionView.bucketLabel}</b>
</small>
<small>
{isEn ? "YES buy" : "YES 买价"} <b>{marketDecisionView.priceText}</b>
</small>
<small>
{isEn ? "Model-market" : "模型-市场差"} <b>{marketDecisionView.edgeText}</b>
</small>
</div>
{marketDecisionView.marketUrl ? (
<a
className="scan-ai-market-link"
href={marketDecisionView.marketUrl}
target="_blank"
rel="noreferrer"
>
{isEn ? "Open market" : "打开市场"}
</a>
) : null}
</div>
</div>
<div className="scan-ai-decision-metrics">
<span>
{isEn ? "Expected high" : "预计高点"}
<b>{decisionView.expectedHigh}</b>
</span>
<span>
{isEn ? "Weather range" : "天气区间"}
<b>{decisionView.targetRange}</b>
</span>
<span>
{isEn ? "Confidence" : "信心"}
<b>{decisionView.confidence}</b>
</span>
<span>
{isEn ? "Observed" : "实测"}
<b>{currentTempText}</b>
</span>
<span>
{isEn ? "Path delta" : "路径偏差"} <b>{paceView?.deltaText || "--"}</b>
</span>
<span>
{isEn ? "Peak window" : "峰值窗口"} <b>{peakWindow}</b>
</span>
<span>
{isEn ? "Market implied" : "市场隐含"} <b>{marketDecisionView.impliedText}</b>
</span>
<span>
{isEn ? "Model prob" : "模型概率"} <b>{marketDecisionView.modelText}</b>
</span>
<span>
{isEn ? "Quote status" : "报价状态"} <b>{marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")}</b>
</span>
</div>
</section>
<WeatherDecisionBand
currentTempText={currentTempText}
decisionView={decisionView}
decisionWhyText={decisionWhyText}
isEn={isEn}
longText={localizedFinalJudgment || paceText}
marketDecisionView={marketDecisionView}
marketLineText={marketLineText}
paceDeltaText={paceView?.deltaText || "--"}
peakWindow={peakWindow}
/>
<div className="scan-ai-city-analysis-grid">
<AiCityTemperatureChart detail={detail} />
<details className="scan-ai-city-section scan-ai-city-ai-read" open={!isCompactCard}>
<summary className="scan-ai-city-section-title">
{isHkoObservation
? isEn
? "Evidence · AI HKO observation read"
: "证据 · AI 香港天文台观测解读"
: isEn
? "Evidence · AI airport read"
: "证据 · AI 机场报文解读"}
</summary>
<div className="scan-ai-city-section-body">
{aiForecast.status === "loading" ? (
<>
<p className="scan-ai-weather-summary">
{aiReadInProgressText}
</p>
{localizedFinalJudgment || aiForecast.streamText ? (
<p className="scan-ai-city-muted">
{localizedFinalJudgment || aiForecast.streamText}
</p>
) : null}
<p className="scan-ai-city-muted">
{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 解读返回后会自动合并。"}
</p>
</>
) : aiForecast.status === "ready" && aiCityForecast ? (
<>
<p className="scan-ai-weather-summary">
{aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText}
</p>
<ul className="scan-ai-weather-bullets">
{[
localizedFinalJudgment,
...aiBullets,
].filter((line) => String(line || "").trim()).map((line, index) => (
<li key={`${line}-${index}`}>{line}</li>
))}
</ul>
<p className="scan-ai-raw-metar">
{rawObservationText}
</p>
</>
) : aiForecast.status === "ready" ? (
<>
<p className="scan-ai-weather-summary">
{aiRuleEvidenceText}
</p>
<ul className="scan-ai-weather-bullets">
{fallbackAiReason ? <li>{fallbackAiReason}</li> : null}
<li>{localModelSupportNote}</li>
<li>{rawObservationText}</li>
</ul>
</>
) : aiForecast.status === "failed" ? (
<>
<p className="scan-ai-weather-summary">
{aiRuleEvidenceText}
</p>
<ul className="scan-ai-weather-bullets">
{aiForecast.error ? <li>{aiForecast.error}</li> : null}
<li>{localModelSupportNote}</li>
<li>{rawObservationText}</li>
</ul>
</>
) : (
<p>
{isEn
? isHkoObservation
? "Waiting for AI to read the latest HKO observation."
: "Waiting for AI to read the latest airport bulletin."
: isHkoObservation
? "等待 AI 解读最新香港天文台观测。"
: "等待 AI 解读最新机场报文。"}
</p>
)}
</div>
</details>
<AiEvidencePanel
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiForecast={aiForecast}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
fallbackAiReason={fallbackAiReason}
isCompactCard={isCompactCard}
isEn={isEn}
isHkoObservation={isHkoObservation}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
rawObservationText={rawObservationText}
/>
</div>
<section className="scan-ai-city-section models">