Move pace math out of the dashboard utility bulk
Pace-adjusted high calculations were embedded in dashboard-utils alongside chart, profile, and modal helpers. This moves the pure pace model and reusable HM time helpers into focused modules while keeping dashboard-utils re-export compatibility for older callers. Constraint: Preserve existing pace wording, thresholds, and calculation output. Rejected: Split all remaining dashboard-utils helpers at once | model/chart/modal helpers have wider call surfaces and should move in separate reversible passes. Confidence: high Scope-risk: narrow Reversibility: clean Tested: TypeScript diagnostics for pace-utils, time-utils, dashboard-utils, and FutureForecastModal Tested: npm run build Not-tested: Bundle analyzer size comparison.
This commit is contained in:
@@ -27,10 +27,10 @@ import {
|
||||
getFutureModalView,
|
||||
getModelView,
|
||||
getProbabilityView,
|
||||
getTodayPaceView,
|
||||
getTemperatureChartData,
|
||||
getWeatherSummary,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { dashboardClient } from "@/lib/dashboard-client";
|
||||
import {
|
||||
normalizeObservationSourceCode,
|
||||
|
||||
@@ -11,8 +11,8 @@ import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
import {
|
||||
getModelView,
|
||||
getProbabilityView,
|
||||
getTodayPaceView,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import {
|
||||
formatTemperatureValue,
|
||||
normalizeTemperatureLabel,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getTodayPaceView } from "@/lib/dashboard-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
||||
import { getPeakWindowLabel } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
useCityMarketScan,
|
||||
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getModelView, getTodayPaceView } from "@/lib/dashboard-utils";
|
||||
import { getModelView } from "@/lib/dashboard-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
||||
|
||||
function toFiniteDecisionNumber(value: unknown) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types";
|
||||
import { getTodayPaceView } from "@/lib/dashboard-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import {
|
||||
formatTemperatureValue,
|
||||
normalizeTemperatureLabel,
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
normalizeTemperatureLabel,
|
||||
normalizeTemperatureSymbol,
|
||||
} from "@/lib/temperature-utils";
|
||||
import {
|
||||
hmToMinutes,
|
||||
interpolateSeriesAtMinutes,
|
||||
normalizeHm,
|
||||
} from "@/lib/time-utils";
|
||||
|
||||
export { getTodayPaceView } from "@/lib/pace-utils";
|
||||
export {
|
||||
formatTemperatureValue,
|
||||
normalizeTemperatureLabel,
|
||||
@@ -240,34 +246,6 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN")
|
||||
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 findNearestTimeIndex(
|
||||
times: string[],
|
||||
targetTime?: string | null,
|
||||
@@ -478,196 +456,6 @@ function buildCurrentObservationFallback(
|
||||
];
|
||||
}
|
||||
|
||||
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") {
|
||||
const current = detail.current || {};
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import {
|
||||
hmToMinutes,
|
||||
interpolateSeriesAtMinutes,
|
||||
normalizeHm,
|
||||
} from "@/lib/time-utils";
|
||||
|
||||
function isEnglish(locale: Locale) {
|
||||
return locale === "en-US";
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export 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")}`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
Reference in New Issue
Block a user