Add intraday pace card to today's analysis
This commit is contained in:
@@ -3019,6 +3019,110 @@
|
|||||||
background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
|
background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-card) {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at top right,
|
||||||
|
rgba(34, 211, 238, 0.12) 0%,
|
||||||
|
rgba(15, 23, 42, 0) 42%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(8, 15, 28, 0.96) 0%,
|
||||||
|
rgba(255, 255, 255, 0.02) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-head) {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-kicker) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-delta) {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-delta.warm) {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-delta.cold) {
|
||||||
|
color: #67e8f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-delta.neutral) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-summary) {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-meter) {
|
||||||
|
position: relative;
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(34, 211, 238, 0.12) 0%,
|
||||||
|
rgba(255, 255, 255, 0.06) 46%,
|
||||||
|
rgba(255, 255, 255, 0.06) 54%,
|
||||||
|
rgba(251, 191, 36, 0.12) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-meter-midline) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-meter-fill) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: var(--pace-left, 46%);
|
||||||
|
width: var(--pace-width, 8%);
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-meter-fill.warm) {
|
||||||
|
background: linear-gradient(90deg, #b45309 0%, #fbbf24 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-meter-fill.cold) {
|
||||||
|
background: linear-gradient(90deg, #0f766e 0%, #67e8f9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.future-v2-pace-meter-fill.neutral) {
|
||||||
|
background: linear-gradient(90deg, #475569 0%, #cbd5e1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.intraday-scene-shell) {
|
.root :global(.intraday-scene-shell) {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getFutureModalView,
|
getFutureModalView,
|
||||||
getAirportNarrative,
|
getAirportNarrative,
|
||||||
|
getTodayPaceView,
|
||||||
parseAiAnalysis,
|
parseAiAnalysis,
|
||||||
getTemperatureChartData,
|
getTemperatureChartData,
|
||||||
getWeatherSummary,
|
getWeatherSummary,
|
||||||
@@ -703,6 +704,10 @@ export function FutureForecastModal() {
|
|||||||
"--score-position": scorePosition,
|
"--score-position": scorePosition,
|
||||||
} as CSSProperties & { "--score-position": string };
|
} as CSSProperties & { "--score-position": string };
|
||||||
const weatherSummary = getWeatherSummary(detail, locale);
|
const weatherSummary = getWeatherSummary(detail, locale);
|
||||||
|
const paceView = useMemo(
|
||||||
|
() => (isToday ? getTodayPaceView(detail, locale) : null),
|
||||||
|
[detail, isToday, locale],
|
||||||
|
);
|
||||||
const isNoaaSettlement =
|
const isNoaaSettlement =
|
||||||
detail.current?.settlement_source === "noaa" ||
|
detail.current?.settlement_source === "noaa" ||
|
||||||
detail.current?.settlement_source_label === "NOAA";
|
detail.current?.settlement_source_label === "NOAA";
|
||||||
@@ -1281,6 +1286,94 @@ export function FutureForecastModal() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{paceView ? (
|
||||||
|
<section className="future-v2-card future-v2-pace-card">
|
||||||
|
<h4 className="future-v2-card-title">
|
||||||
|
{locale === "en-US" ? "Current Pace" : "当前节奏 Pace"}
|
||||||
|
</h4>
|
||||||
|
<div className="future-v2-pace-head">
|
||||||
|
<span className="future-v2-pace-kicker">
|
||||||
|
{paceView.kicker}
|
||||||
|
</span>
|
||||||
|
<em
|
||||||
|
className={clsx(
|
||||||
|
"future-v2-signal-tag",
|
||||||
|
paceView.biasTone === "cold" && "cyan",
|
||||||
|
paceView.biasTone === "neutral" && "blue",
|
||||||
|
paceView.biasTone === "warm" && "amber",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{paceView.badge}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"future-v2-pace-delta",
|
||||||
|
paceView.biasTone === "cold" && "cold",
|
||||||
|
paceView.biasTone === "neutral" && "neutral",
|
||||||
|
paceView.biasTone === "warm" && "warm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{paceView.deltaText}
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-pace-summary">
|
||||||
|
{paceView.summary}
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-pace-meter">
|
||||||
|
<span className="future-v2-pace-meter-midline" />
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"future-v2-pace-meter-fill",
|
||||||
|
paceView.biasTone === "cold" && "cold",
|
||||||
|
paceView.biasTone === "neutral" && "neutral",
|
||||||
|
paceView.biasTone === "warm" && "warm",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--pace-left": `${paceView.meterLeft}%`,
|
||||||
|
"--pace-width": `${paceView.meterWidth}%`,
|
||||||
|
} as CSSProperties & {
|
||||||
|
"--pace-left": string;
|
||||||
|
"--pace-width": string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US" ? "Expected now" : "预期此刻"}
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
{paceView.expectedNow.toFixed(1)}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>{paceView.observedLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{paceView.observedNow.toFixed(1)}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>{paceView.paceAdjustedLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{paceView.paceAdjustedHigh != null
|
||||||
|
? `${paceView.paceAdjustedHigh.toFixed(1)}${detail.temp_symbol}`
|
||||||
|
: "--"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US" ? "Peak window" : "峰值窗口"}
|
||||||
|
</span>
|
||||||
|
<strong>{paceView.peakWindowText}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="future-v2-card">
|
<section className="future-v2-card">
|
||||||
<h4 className="future-v2-card-title">
|
<h4 className="future-v2-card-title">
|
||||||
{locale === "en-US" ? "Current Metrics" : "当前指标"}
|
{locale === "en-US" ? "Current Metrics" : "当前指标"}
|
||||||
|
|||||||
@@ -194,6 +194,224 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN")
|
|||||||
return { weatherIcon, weatherText };
|
return { weatherIcon, weatherText };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHm(value?: string | null) {
|
||||||
|
const match = String(value || "").match(/(\d{1,2}):(\d{2})/);
|
||||||
|
if (!match) return null;
|
||||||
|
const hour = Number.parseInt(match[1], 10);
|
||||||
|
const minute = Number.parseInt(match[2], 10);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(hour) ||
|
||||||
|
!Number.isFinite(minute) ||
|
||||||
|
hour < 0 ||
|
||||||
|
hour > 23 ||
|
||||||
|
minute < 0 ||
|
||||||
|
minute > 59
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmToMinutes(value?: string | null) {
|
||||||
|
const normalized = normalizeHm(value);
|
||||||
|
if (!normalized) return null;
|
||||||
|
const [hourText, minuteText] = normalized.split(":");
|
||||||
|
const hour = Number.parseInt(hourText || "", 10);
|
||||||
|
const minute = Number.parseInt(minuteText || "", 10);
|
||||||
|
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||||
|
return hour * 60 + minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateSeriesAtMinutes(
|
||||||
|
times: string[],
|
||||||
|
values: Array<number | null | undefined>,
|
||||||
|
currentMinutes: number,
|
||||||
|
) {
|
||||||
|
const points = times
|
||||||
|
.map((time, index) => {
|
||||||
|
const minute = hmToMinutes(time);
|
||||||
|
const value = values[index];
|
||||||
|
return minute != null && value != null && Number.isFinite(Number(value))
|
||||||
|
? { minute, value: Number(value) }
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((point): point is { minute: number; value: number } => point != null);
|
||||||
|
|
||||||
|
if (!points.length) return null;
|
||||||
|
|
||||||
|
const exact = points.find((point) => point.minute === currentMinutes);
|
||||||
|
if (exact) return exact.value;
|
||||||
|
|
||||||
|
let left: { minute: number; value: number } | null = null;
|
||||||
|
let right: { minute: number; value: number } | null = null;
|
||||||
|
|
||||||
|
for (const point of points) {
|
||||||
|
if (point.minute < currentMinutes) {
|
||||||
|
left = point;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (point.minute > currentMinutes) {
|
||||||
|
right = point;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left && right) {
|
||||||
|
const span = right.minute - left.minute;
|
||||||
|
if (span <= 0) return left.value;
|
||||||
|
const ratio = (currentMinutes - left.minute) / span;
|
||||||
|
return Number((left.value + (right.value - left.value) * ratio).toFixed(1));
|
||||||
|
}
|
||||||
|
if (left) return left.value;
|
||||||
|
if (right) return right.value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTodayPaceView(
|
||||||
|
detail: CityDetail,
|
||||||
|
locale: Locale = "zh-CN",
|
||||||
|
) {
|
||||||
|
const hourly = detail.hourly || {};
|
||||||
|
const times = hourly.times || [];
|
||||||
|
const temps = hourly.temps || [];
|
||||||
|
if (!times.length || !temps.length) return null;
|
||||||
|
|
||||||
|
const currentMinutes =
|
||||||
|
hmToMinutes(detail.local_time) ??
|
||||||
|
hmToMinutes(detail.airport_primary?.obs_time) ??
|
||||||
|
hmToMinutes(detail.airport_current?.obs_time) ??
|
||||||
|
hmToMinutes(detail.current?.obs_time);
|
||||||
|
if (currentMinutes == null) return null;
|
||||||
|
|
||||||
|
const omHigh = Number(detail.forecast?.today_high);
|
||||||
|
const debHigh = Number(detail.deb?.prediction);
|
||||||
|
const useDebOffset = Number.isFinite(omHigh) && Number.isFinite(debHigh);
|
||||||
|
const offset = useDebOffset ? debHigh - omHigh : 0;
|
||||||
|
const expectedSeries = temps.map((temp) =>
|
||||||
|
temp != null && Number.isFinite(Number(temp))
|
||||||
|
? Number((Number(temp) + offset).toFixed(1))
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const expectedNow = interpolateSeriesAtMinutes(times, expectedSeries, currentMinutes);
|
||||||
|
if (expectedNow == null) return null;
|
||||||
|
|
||||||
|
const observedNowCandidate = [
|
||||||
|
detail.airport_primary?.temp,
|
||||||
|
detail.airport_current?.temp,
|
||||||
|
detail.current?.temp,
|
||||||
|
]
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.find((value) => Number.isFinite(value));
|
||||||
|
if (observedNowCandidate == null) return null;
|
||||||
|
const observedNow = Number(observedNowCandidate);
|
||||||
|
|
||||||
|
const delta = Number((observedNow - expectedNow).toFixed(1));
|
||||||
|
const biasMagnitude = Math.abs(delta);
|
||||||
|
const biasTone =
|
||||||
|
delta >= 0.6 ? "warm" : delta <= -0.6 ? "cold" : "neutral";
|
||||||
|
const badge =
|
||||||
|
biasTone === "warm"
|
||||||
|
? isEnglish(locale)
|
||||||
|
? "Running hot"
|
||||||
|
: "跑得偏热"
|
||||||
|
: biasTone === "cold"
|
||||||
|
? isEnglish(locale)
|
||||||
|
? "Running cool"
|
||||||
|
: "跑得偏冷"
|
||||||
|
: isEnglish(locale)
|
||||||
|
? "On track"
|
||||||
|
: "基本跟踪";
|
||||||
|
const kicker = isEnglish(locale)
|
||||||
|
? `As of ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}`
|
||||||
|
: `截至 ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}`;
|
||||||
|
const deltaText =
|
||||||
|
delta === 0
|
||||||
|
? isEnglish(locale)
|
||||||
|
? "0.0°C vs expected"
|
||||||
|
: "0.0°C 相对预期"
|
||||||
|
: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`;
|
||||||
|
|
||||||
|
const topObservedCandidate = [
|
||||||
|
detail.airport_primary?.max_so_far,
|
||||||
|
detail.airport_current?.max_so_far,
|
||||||
|
detail.current?.max_so_far,
|
||||||
|
observedNow,
|
||||||
|
]
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.find((value) => Number.isFinite(value));
|
||||||
|
const topObserved = topObservedCandidate != null ? Number(topObservedCandidate) : null;
|
||||||
|
const projectedBase = Number.isFinite(debHigh)
|
||||||
|
? debHigh
|
||||||
|
: Number.isFinite(omHigh)
|
||||||
|
? omHigh
|
||||||
|
: null;
|
||||||
|
const paceAdjustedHigh =
|
||||||
|
projectedBase != null
|
||||||
|
? Number(
|
||||||
|
Math.max(projectedBase + delta, topObserved ?? projectedBase).toFixed(1),
|
||||||
|
)
|
||||||
|
: topObserved;
|
||||||
|
const paceAdjustedLabel = isEnglish(locale)
|
||||||
|
? "Pace-adjusted high"
|
||||||
|
: "节奏修正高点";
|
||||||
|
const peakWindowText =
|
||||||
|
Number.isFinite(Number(detail.peak?.first_h)) &&
|
||||||
|
Number.isFinite(Number(detail.peak?.last_h))
|
||||||
|
? `${String(Number(detail.peak?.first_h)).padStart(2, "0")}:00-${String(
|
||||||
|
Number(detail.peak?.last_h) + 1,
|
||||||
|
).padStart(2, "0")}:00`
|
||||||
|
: "--";
|
||||||
|
const observedLabel =
|
||||||
|
detail.airport_primary?.temp != null || detail.airport_current?.temp != null
|
||||||
|
? isEnglish(locale)
|
||||||
|
? "Airport obs"
|
||||||
|
: "机场实测"
|
||||||
|
: isEnglish(locale)
|
||||||
|
? "Current obs"
|
||||||
|
: "当前实测";
|
||||||
|
|
||||||
|
const paceSummary =
|
||||||
|
biasTone === "warm"
|
||||||
|
? isEnglish(locale)
|
||||||
|
? `The airport anchor is ${biasMagnitude.toFixed(1)}°C above the intraday curve. If that bias survives into the peak window, the day high is more likely to lean hotter than the current DEB path.`
|
||||||
|
: `机场主站当前比盘中曲线高 ${biasMagnitude.toFixed(1)}°C。若这段偏热节奏延续进峰值窗口,日高更容易落在当前 DEB 路径之上。`
|
||||||
|
: biasTone === "cold"
|
||||||
|
? isEnglish(locale)
|
||||||
|
? `The airport anchor is ${biasMagnitude.toFixed(1)}°C below the intraday curve. If that drag survives into the peak window, chasing higher buckets becomes harder.`
|
||||||
|
: `机场主站当前比盘中曲线低 ${biasMagnitude.toFixed(1)}°C。若这段偏冷节奏延续进峰值窗口,继续追更高温区间会更吃力。`
|
||||||
|
: isEnglish(locale)
|
||||||
|
? "The airport anchor is still tracking the intraday curve. Let later pace and peak-window structure decide."
|
||||||
|
: "机场主站当前仍基本贴着盘中曲线运行,后续主要看峰值窗口内的节奏有没有进一步偏离。";
|
||||||
|
|
||||||
|
const clamped = Math.min(Math.max(delta, -4), 4);
|
||||||
|
const meterLeft =
|
||||||
|
biasTone === "neutral"
|
||||||
|
? 46
|
||||||
|
: clamped >= 0
|
||||||
|
? 50
|
||||||
|
: 50 - (Math.abs(clamped) / 4) * 50;
|
||||||
|
const meterWidth =
|
||||||
|
biasTone === "neutral" ? 8 : Math.max((Math.abs(clamped) / 4) * 50, 8);
|
||||||
|
|
||||||
|
return {
|
||||||
|
badge,
|
||||||
|
biasTone,
|
||||||
|
delta,
|
||||||
|
deltaText,
|
||||||
|
expectedNow,
|
||||||
|
kicker,
|
||||||
|
meterLeft,
|
||||||
|
meterWidth,
|
||||||
|
observedLabel,
|
||||||
|
observedNow,
|
||||||
|
paceAdjustedHigh,
|
||||||
|
paceAdjustedLabel,
|
||||||
|
peakWindowText,
|
||||||
|
summary: paceSummary,
|
||||||
|
topObserved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||||
const current = detail.current || {};
|
const current = detail.current || {};
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user