feat: productize landing and chart education

This commit is contained in:
2569718930@qq.com
2026-06-06 19:17:24 +08:00
parent c2f9bc5994
commit 7a38e3588a
7 changed files with 722 additions and 45 deletions
@@ -1,7 +1,7 @@
"use client";
import clsx from "clsx";
import { Bug } from "lucide-react";
import { Bug, ChevronDown, ChevronUp } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
@@ -109,6 +109,236 @@ function getWundergroundDailyHigh(hourly: HourlyForecast) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
}
type AdvancedWeatherVariableItem = {
key: "wind_dir" | "wind_speed" | "dewpoint" | "humidity" | "pressure";
label: string;
value: string;
};
type SourceCadenceSummary = {
label: string;
cadence: string;
status: string | null;
};
function formatCompactNumber(value: number, decimals = 1) {
const rounded = Number(value.toFixed(decimals));
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(decimals);
}
function fallbackCadenceSeconds(sourceText: string) {
const value = sourceText.toLowerCase();
if (value.includes("amos")) return 60;
if (value.includes("amsc")) return 180;
if (value.includes("madis") || value.includes("hfmetar")) return 300;
if (value.includes("cowin")) return 60;
if (value.includes("hko")) return 600;
if (value.includes("cwa") || value.includes("jma") || value.includes("fmi") || value.includes("knmi")) return 600;
if (value.includes("mgm")) return 900;
if (value.includes("mss") || value.includes("singapore")) return 60;
return null;
}
function formatCadence(seconds: number) {
return `${Math.round(seconds)}s`;
}
function sourceStatusLabel(status: string | null | undefined, isEn: boolean) {
if (!status) return null;
const normalized = status.toLowerCase();
if (normalized === "fresh") return isEn ? "fresh" : "新鲜";
if (normalized === "expected_wait") return isEn ? "waiting for source" : "等待源头";
if (normalized === "delayed") return isEn ? "delayed" : "延迟";
if (normalized === "stale") return isEn ? "stale" : "过旧";
if (normalized === "offline") return isEn ? "offline" : "离线";
return status;
}
function buildSourceCadenceSummary(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
isEn: boolean,
): SourceCadenceSummary | null {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
const sourceParts = [
(primary as any)?.source,
primary?.source_code,
primary?.source_label,
primary?.station_code,
row?.metar_context?.source,
row?.metar_context?.station_label,
row?.airport,
]
.map((item) => String(item || "").trim())
.filter(Boolean);
const sourceText = sourceParts.join(" ");
const nativeCadence = validNumber(primary?.freshness?.native_update_interval_sec);
const cadenceSeconds = nativeCadence ?? fallbackCadenceSeconds(sourceText);
if (cadenceSeconds === null) return null;
const label =
primary?.source_label ||
primary?.source_code ||
(primary as any)?.source ||
row?.metar_context?.station_label ||
row?.airport ||
(isEn ? "Source" : "数据源");
return {
label,
cadence: formatCadence(cadenceSeconds),
status: sourceStatusLabel(primary?.freshness?.freshness_status, isEn),
};
}
function buildAdvancedWeatherVariableItems(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
isEn: boolean,
): AdvancedWeatherVariableItem[] {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
const current = hourly?.current || null;
const metarContext = row?.metar_context || null;
const tempSymbol = row?.temp_symbol || primary?.temp_symbol || "°C";
const windDir =
validNumber(primary?.wind_dir) ??
validNumber(current?.wind_dir) ??
validNumber(metarContext?.airport_wind_dir);
const windSpeed =
validNumber(primary?.wind_speed_kt) ??
validNumber(current?.wind_speed_kt) ??
validNumber(metarContext?.airport_wind_speed_kt);
const dewpoint =
validNumber(current?.dewpoint) ??
validNumber((current as any)?.dew_point) ??
validNumber((primary as any)?.dewpoint) ??
validNumber((primary as any)?.dew_point);
const humidity =
validNumber(primary?.humidity) ??
validNumber(current?.humidity) ??
validNumber(metarContext?.airport_humidity);
const pressure =
validNumber(primary?.pressure_hpa) ??
validNumber((current as any)?.pressure_hpa) ??
validNumber((current as any)?.pressure);
const items: AdvancedWeatherVariableItem[] = [];
if (windDir !== null) {
items.push({
key: "wind_dir",
label: isEn ? "Wind Dir" : "风向",
value: `${Math.round(windDir)}°`,
});
}
if (windSpeed !== null) {
items.push({
key: "wind_speed",
label: isEn ? "Wind Speed" : "风速",
value: `${formatCompactNumber(windSpeed)} kt`,
});
}
if (dewpoint !== null) {
items.push({
key: "dewpoint",
label: isEn ? "Dew Point" : "露点",
value: `${formatCompactNumber(dewpoint)}${tempSymbol}`,
});
}
if (humidity !== null) {
items.push({
key: "humidity",
label: isEn ? "Humidity" : "湿度",
value: `${formatCompactNumber(humidity)}%`,
});
}
if (pressure !== null) {
items.push({
key: "pressure",
label: isEn ? "Pressure" : "气压",
value: `${formatCompactNumber(pressure)} hPa`,
});
}
return items;
}
function SourceCadenceStrip({
isEn,
summary,
}: {
isEn: boolean;
summary: SourceCadenceSummary | null;
}) {
if (!summary) return null;
return (
<div className="shrink-0 border-b border-slate-200 bg-slate-50/70 px-4 py-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600">
<span className="font-bold text-slate-700">
{isEn ? "Source" : "数据源"}: {summary.label}
</span>
<span className="font-mono font-black text-slate-900">
{isEn ? "native cadence" : "源头频率"} {summary.cadence}
</span>
{summary.status && (
<span className="rounded border border-slate-200 bg-white px-1.5 py-0.5 font-semibold text-slate-500">
{summary.status}
</span>
)}
</div>
</div>
);
}
function AdvancedWeatherVariablesStrip({
isEn,
items,
}: {
isEn: boolean;
items: AdvancedWeatherVariableItem[];
}) {
const [expanded, setExpanded] = useState(false);
if (!items.length) return null;
return (
<div className="shrink-0 border-b border-slate-200 bg-white px-4 py-2">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="inline-flex min-h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600 shadow-sm transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-900"
>
<span>{isEn ? "Advanced Variables" : "高级气象变量"}</span>
<span className="font-mono text-slate-400">{items.length}</span>
{expanded ? <ChevronUp size={13} aria-hidden="true" /> : <ChevronDown size={13} aria-hidden="true" />}
</button>
{expanded && (
<div className="mt-2 grid gap-2 sm:grid-cols-5">
{items.map((item) => (
<div
key={item.key}
className="rounded-md border border-slate-100 bg-slate-50 px-3 py-2"
>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{item.label}
</div>
<div className="mt-1 font-mono text-sm font-black text-slate-800">
{item.value}
</div>
</div>
))}
</div>
)}
{expanded && (
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{isEn
? "Context only. These variables help explain capping and boundary-layer structure; they are not settlement-temperature curves."
: "仅作上下文。它们用于解释压温和边界层结构,不是结算温度曲线。"}
</p>
)}
</div>
);
}
function shouldFetchCityDetailForChart({
city,
documentHidden,
@@ -672,6 +902,14 @@ export function LiveTemperatureThresholdChart({
const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null;
const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧"));
const spreadLabelEn = spread === null ? "" : (spread <= 2.0 ? "Low" : (spread <= 4.0 ? "Medium" : "High"));
const sourceCadenceSummary = useMemo(
() => buildSourceCadenceSummary(row, chartHourly, isEn),
[chartHourly, isEn, row],
);
const advancedWeatherVariables = useMemo(
() => buildAdvancedWeatherVariableItems(row, chartHourly, isEn),
[chartHourly, isEn, row],
);
const formattedUpdateTime = useMemo(() => {
const nowUtc = Date.now();
@@ -991,6 +1229,18 @@ export function LiveTemperatureThresholdChart({
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} tempSymbol={row?.temp_symbol || "°C"} />
)}
{timeframe === "1D" && !compact && (
<SourceCadenceStrip isEn={isEn} summary={sourceCadenceSummary} />
)}
{timeframe === "1D" && !compact && (
<AdvancedWeatherVariablesStrip
key={city || "advanced-weather"}
isEn={isEn}
items={advancedWeatherVariables}
/>
)}
<TemperatureChartCanvas
isEn={isEn}
compact={compact}
@@ -1047,3 +1297,5 @@ export const __shouldPollLiveChartForTest = shouldPollLiveChart;
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
export const __selectCompactSecondaryTempForTest = selectCompactSecondaryTemp;
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
export const __buildAdvancedWeatherVariableItemsForTest = buildAdvancedWeatherVariableItems;
export const __buildSourceCadenceSummaryForTest = buildSourceCadenceSummary;
@@ -0,0 +1,66 @@
import * as Chart from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const buildItems = (Chart as any).__buildAdvancedWeatherVariableItemsForTest;
assert(typeof buildItems === "function", "advanced weather variable item builder should be exported for tests");
const buildCadence = (Chart as any).__buildSourceCadenceSummaryForTest;
assert(typeof buildCadence === "function", "source cadence summary builder should be exported for tests");
const items = buildItems(
{
metar_context: {
airport_wind_speed_kt: 7,
airport_wind_dir: 220,
airport_humidity: 68,
},
},
{
current: { dewpoint: 18.2 },
airportPrimary: {
wind_speed_kt: 9,
wind_dir: 240,
humidity: 64,
pressure_hpa: 1009.4,
source_label: "MADIS HFMETAR",
},
},
false,
);
assert(items.length === 5, `expected five advanced variable items, got ${items.length}`);
assert(items.some((item: any) => item.key === "wind_dir" && item.value === "240°"), "wind direction should prefer airport-primary detail data");
assert(items.some((item: any) => item.key === "wind_speed" && item.value === "9 kt"), "wind speed should include kt units");
assert(items.some((item: any) => item.key === "dewpoint" && item.value === "18.2°C"), "dew point should render from current conditions");
assert(items.some((item: any) => item.key === "humidity" && item.value === "64%"), "humidity should render as a percentage");
assert(items.some((item: any) => item.key === "pressure" && item.value === "1009.4 hPa"), "pressure should render as hPa");
const emptyItems = buildItems({}, {}, true);
assert(emptyItems.length === 0, "advanced variables should stay hidden when no source fields exist");
const backendCadence = buildCadence(
{},
{
airportPrimary: {
source_code: "custom_source",
source_label: "Custom Feed",
freshness: { native_update_interval_sec: 420, freshness_status: "fresh" },
},
},
true,
);
assert(backendCadence?.cadence === "420s", "source cadence should prefer backend native_update_interval_sec");
assert(backendCadence?.label.includes("Custom Feed"), "source cadence should include the source label");
const amscCadence = buildCadence({}, { airportPrimary: { source: "amsc_awos" } }, false);
assert(amscCadence?.cadence === "180s", "AMSC AWOS should fall back to 180s source cadence");
const amosCadence = buildCadence({}, { airportPrimary: { source_label: "AMOS runway" } }, false);
assert(amosCadence?.cadence === "60s", "AMOS should fall back to 60s source cadence");
const madisCadence = buildCadence({}, { airportPrimary: { source_code: "madis_hfmetar" } }, false);
assert(madisCadence?.cadence === "300s", "MADIS should fall back to 300s source cadence");
}
@@ -0,0 +1,57 @@
import { getDocsPage } from "@/content/docs/docs";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function pageText(slug: string, locale: "zh-CN" | "en-US") {
const page = getDocsPage(slug);
assert(page, `${slug} docs page should exist`);
return [
page.content[locale].title,
page.content[locale].description,
...page.content[locale].sections.flatMap((section) => [
section.title,
...section.blocks.flatMap((block) => {
if (block.type === "paragraph" || block.type === "callout") return [block.text];
if (block.type === "bullets" || block.type === "steps") return block.items;
if (block.type === "link") return [block.label, block.caption || ""];
if (block.type === "image") return [block.alt, block.caption || ""];
return [];
}),
]),
].join("\n");
}
export function runTests() {
const introZh = pageText("intro", "zh-CN");
assert(
introZh.includes("结算源优先") && introZh.includes("实时实测终端"),
"intro should position PolyWeather as a settlement-source-first live terminal",
);
const chartGuideZh = pageText("chart-guide", "zh-CN");
assert(chartGuideZh.includes("如何读 PolyWeather 图表"), "chart guide title should be present");
assert(chartGuideZh.includes("高级气象变量") && chartGuideZh.includes("默认隐藏"), "chart guide should explain advanced variables as hidden-by-default context");
assert(chartGuideZh.includes("不要把概率温度带当成实测曲线"), "chart guide should warn against reading probability as observation");
const realtimeSourcesZh = pageText("realtime-sources", "zh-CN");
assert(realtimeSourcesZh.includes("AMSC 180s"), "realtime sources should document the AMSC 180s cadence");
assert(realtimeSourcesZh.includes("AMOS 60s"), "realtime sources should document the AMOS 60s cadence");
assert(realtimeSourcesZh.includes("SSE patch"), "realtime sources should document the SSE patch path");
const settlementSourcesZh = pageText("settlement-sources", "zh-CN");
assert(settlementSourcesZh.includes("结算站点"), "settlement source guide should exist");
assert(settlementSourcesZh.includes("机场 METAR") && settlementSourcesZh.includes("官方结算站点"), "settlement source guide should distinguish airport and official station settlement");
assert(
getDocsPage("chart-guide")?.group === "getting-started" &&
getDocsPage("realtime-sources")?.group === "settlement" &&
getDocsPage("settlement-sources")?.group === "settlement",
"docs navigation should expose chart, realtime source, and settlement source guides in the right groups",
);
const chartGuideEn = pageText("chart-guide", "en-US");
assert(chartGuideEn.includes("How To Read PolyWeather Charts"), "English chart guide should exist");
assert(chartGuideEn.includes("hidden by default"), "English chart guide should describe hidden-by-default advanced variables");
}
@@ -2,6 +2,7 @@ import type {
AmosData,
AirportCurrentConditions,
CityDetail,
CurrentConditions,
ScanOpportunityRow,
ForecastDay,
DailyModelForecast,
@@ -944,6 +945,7 @@ type HourlyForecast = {
runwayPlateHistory?: Record<string, Array<Record<string, unknown>>>;
runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>;
amos?: AmosData | null;
current?: CurrentConditions | null;
airportCurrent?: AirportCurrentConditions | null;
airportPrimary?: AirportCurrentConditions | null;
wundergroundCurrent?: AirportCurrentConditions | null;
@@ -970,6 +972,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
runwayBandHistory: undefined,
amos: null,
current: null,
airportCurrent: null,
airportPrimary: null,
wundergroundCurrent: (row as any)?.wunderground_current || null,
@@ -1031,6 +1034,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
runwayBandHistory: (json as any)?.runway_band_history || undefined,
amos: json.amos || null,
current: json.current || null,
airportCurrent: json.airport_current || null,
airportPrimary: json.airport_primary || null,
wundergroundCurrent: (json as any).wunderground_current || (json as any)?.official?.wunderground_current || null,
@@ -12,25 +12,25 @@ import {
} from "@/components/landing/landingLocale";
const COVERAGE_EN = [
"Live airport observations",
"DEB blend forecast",
"Model-implied distribution",
"Intraday observation windows",
"Deviation checks and risk thresholds",
"Paid Telegram alerts",
"AMOS 60s runway sensors",
"AMSC 180s runway endpoints",
"MADIS 300s airport observations",
"CoWIN 60s + HKO 600s",
"SSE patch live terminal",
"Telegram reads latest cache",
];
const COVERAGE_ZH = [
"机场实况观测数据",
"DEB 智能融合预报",
"模型隐含分布预测",
"日内分段观测窗口",
"偏差校验与风控阈值",
"付费 Telegram 实时通知",
"AMOS 60s 跑道传感器",
"AMSC 180s 跑道端点",
"MADIS 300s 机场观测",
"CoWIN 60s + HKO 600s",
"SSE patch 实时终端",
"Telegram 读取最新缓存",
];
const PRO_FEATURES_EN = [
"METAR airport observations and runway-level reference data",
"Settlement-source-first airport, official-station, and runway observations",
"DEB blend forecast with model-spread context",
"Model-implied distribution and probability estimates",
"Intraday windows, deviation metrics, and settlement context",
@@ -39,7 +39,7 @@ const PRO_FEATURES_EN = [
];
const PRO_FEATURES_ZH = [
"METAR 机场实测与跑道级参考数据",
"结算源优先的机场、官方站与跑道实测",
"DEB 智能融合预报与模型分歧背景",
"模型隐含分布预测与概率估算",
"日内观测窗口、偏差度量与结算背景",
@@ -260,6 +260,12 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<a href="#coverage" className="hover:text-slate-950">
{isEn ? "Data" : "数据"}
</a>
<a href="#screenshots" className="hover:text-slate-950">
{isEn ? "Screens" : "截图"}
</a>
<Link href="/docs/chart-guide" className="hover:text-slate-950">
{isEn ? "Guide" : "读图"}
</Link>
<a href="#pricing" className="hover:text-slate-950">
{isEn ? "Pricing" : "定价"}
</a>
@@ -279,8 +285,8 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-slate-600 sm:text-xl">
{isEn
? "A calmer way to read airport weather, model forecasts, and intraday risk before the market moves."
: "用更轻松的方式阅读机场天气、模型预报和日内风险,在市场变化前完成判断。"}
? "A settlement-source-first terminal for temperature markets: live airport/runway observations, DEB, market buckets, and alerts in one workflow."
: "面向温度市场的结算源优先终端:机场/跑道实测、DEB、市场温度桶和提醒放在同一个工作流里。"}
</p>
<LandingHeroActions locale={locale} />
<p className="mt-4 text-sm text-slate-500">
@@ -338,8 +344,8 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
{isEn
? "Like a tidy workspace for weather decisions."
: "像整理好的工作区一样阅读天气决策。"}
? "Built around the station that actually matters."
: "围绕真正会影响结算的站点构建。"}
</h2>
</div>
@@ -354,6 +360,100 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
</article>
))}
</div>
<div className="mt-10 rounded-lg border border-slate-200 bg-[#fbfbfa] p-5">
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">
{isEn ? "Differentiation" : "差异化卖点"}
</p>
<h3 className="mt-3 text-2xl font-black tracking-tight text-slate-950">
{isEn ? "Settlement-source first, not generic weather." : "结算源优先,不做泛天气看板。"}
</h3>
<p className="mt-3 text-sm leading-7 text-slate-600">
{isEn
? "The product is built around the station, runway, and update cadence that affect settlement, then layers DEB, market buckets, and alerting on top."
: "产品围绕真正影响结算的站点、跑道和源头频率构建,再叠加 DEB、市场温度桶和提醒工作流。"}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(isEn
? [
"Runway-level China and Korea observation context",
"Hong Kong CoWIN + HKO dual-source reading",
"Source-native collector cadence with SSE patch delivery",
"Telegram alerts read cache instead of force-refreshing sources",
]
: [
"中国和韩国跑道级实测上下文",
"香港 CoWIN + HKO 双源读数",
"按源频率采集,并用 SSE patch 推送",
"Telegram 读取缓存,不强制刷新外部源",
]
).map((item) => (
<div key={item} className="rounded-md border border-slate-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-700">
{item}
</div>
))}
</div>
</div>
</div>
</div>
</section>
<section id="screenshots" className="border-b border-slate-200 bg-[#fbfbfa] px-4 py-20 sm:px-6">
<div className="mx-auto max-w-6xl">
<div className="max-w-2xl">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">
{isEn ? "Product Screenshots" : "产品截图"}
</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
{isEn ? "The terminal and alerts are the product." : "终端和提醒就是核心产品。"}
</h2>
<p className="mt-4 text-sm leading-7 text-slate-600">
{isEn
? "Public packaging should show the actual workflow: live chart reading in the browser and concise runway alerts in Telegram."
: "公开包装应该直接展示真实工作流:浏览器里读实时图表,Telegram 里接收简洁的跑道提醒。"}
</p>
</div>
<div className="mt-10 grid gap-5 lg:grid-cols-[1.4fr_0.8fr]">
<figure className="rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
<div className="aspect-[16/9] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
<img
src="/static/web.webp"
width="680"
height="340"
alt={isEn ? "Realtime terminal screenshot" : "实时终端截图"}
className="h-full w-full object-cover object-top"
decoding="async"
loading="lazy"
sizes="(min-width: 1024px) 760px, calc(100vw - 48px)"
/>
</div>
<figcaption className="px-2 py-3 text-xs font-semibold text-slate-500">
{isEn ? "Browser terminal: settlement observations, DEB, source cadence, and market context." : "浏览器终端:结算实测、DEB、源头频率和市场上下文。"}
</figcaption>
</figure>
<figure className="rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
<div className="aspect-[9/16] max-h-[520px] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
<img
src="/static/tel.png"
width="420"
height="640"
alt={isEn ? "Telegram runway alert screenshot" : "Telegram 跑道提醒截图"}
className="h-full w-full object-cover object-top"
decoding="async"
loading="lazy"
sizes="(min-width: 1024px) 340px, calc(100vw - 48px)"
/>
</div>
<figcaption className="px-2 py-3 text-xs font-semibold text-slate-500">
{isEn ? "Telegram alerts: concise runway and settlement-source updates for paid users." : "Telegram 提醒:为付费用户提供简洁的跑道与结算源更新。"}
</figcaption>
</figure>
</div>
</div>
</section>
@@ -364,8 +464,27 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
{isEn ? "Data Coverage" : "数据覆盖"}
</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
{isEn ? "Keep the signal clear and the page approachable." : "信号清楚,页面也可以亲和。"}
{isEn ? "Refresh cadence follows the source, not a fake timer." : "刷新频率跟随源头,不伪装成统一定时器。"}
</h2>
<p className="mt-4 text-sm leading-7 text-slate-600">
{isEn
? "The collector writes cache and event streams first; the website consumes snapshots plus SSE patches, while Telegram reads the latest cached state."
: "采集器先写缓存和事件流;网站消费完整快照 + SSE patch,Telegram 只读取最新缓存状态。"}
</p>
<div className="mt-6 flex flex-wrap gap-2">
<Link
href="/docs/chart-guide"
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Read chart guide" : "查看读图指南"}
</Link>
<Link
href="/docs/realtime-sources"
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Source cadence" : "数据源频率"}
</Link>
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm">
@@ -408,7 +527,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
</p>
</div>
<div className="mt-12 grid gap-4 md:grid-cols-3">
<div className="mt-12 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<div className="flex items-center gap-2 text-sm font-bold text-emerald-700">
<LandingIcon name="clock" />
@@ -440,20 +559,31 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
Pro
</div>
<h3 className="mt-5 text-2xl font-black text-slate-950">
{isEn ? "Pro Monthly" : "Pro 月付"}
Pro
</h3>
<p className="mt-3 text-sm leading-7 text-slate-600">
{isEn
? "Full Pro access for 30 days, including paid Telegram group eligibility."
: "完整 Pro 权限 30 天,包含付费 Telegram 群准入资格。"}
? "Full terminal access, chart guides, advanced context, and paid Telegram group eligibility."
: "完整终端权限、读图指南、高级上下文和付费 Telegram 群准入资格。"}
</p>
<div className="mt-7 flex items-baseline gap-2">
<span className="font-mono text-5xl font-black text-slate-950">29.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 30 </span>
<div className="mt-7 space-y-2">
<div className="flex items-baseline gap-2">
<span className="font-mono text-4xl font-black text-slate-950">29.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 30 </span>
</div>
<div className="flex items-baseline gap-2">
<span className="font-mono text-2xl font-black text-slate-950">79.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 90 </span>
</div>
</div>
<p className="mt-2 text-xs font-semibold text-slate-500">
{isEn ? "Referral first month: 20 USDC" : "使用邀请码首月 20 USDC"}
</p>
<div className="mt-4 rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold text-slate-600">
{isEn
? "Invite reward: referrer gets +3500 points when invitee subscribes."
: "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
</div>
<ul className="mt-7 space-y-3 border-t border-slate-200 pt-6">
{(isEn ? PRO_FEATURES_EN : PRO_FEATURES_ZH).map((feature) => (
<li key={feature} className="flex items-start gap-3">
@@ -478,30 +608,51 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<div className="flex items-center gap-2 text-sm font-bold text-amber-700">
<LandingIcon name="lineChart" />
{isEn ? "Quarterly" : "季度"}
API
</div>
<h3 className="mt-5 text-2xl font-black text-slate-950">
{isEn ? "Pro Quarterly" : "Pro 季度"}
{isEn ? "API" : "API"}
</h3>
<p className="mt-3 flex-1 text-sm leading-7 text-slate-600">
{isEn
? "90 days of Pro access for users with steady usage. Lower cost per month."
: "90 天 Pro 权限,适合稳定使用的个人和团队,折算月成本更低。"}
? "Not sold as a public product right now. The public site and Telegram workflow remain the supported product surface."
: "目前不作为公开产品售卖。当前支持的产品形态仍是网站终端和 Telegram 工作流。"}
</p>
<div className="mt-7 flex items-baseline gap-2">
<span className="font-mono text-5xl font-black text-slate-950">79.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 90 </span>
</div>
<div className="mt-5 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
{isEn
? "Invite reward: referrer gets +3500 points when invitee subscribes."
: "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
<div className="mt-7 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
{isEn ? "Not for sale. We will revisit API packaging only after endpoint docs, keys, limits, and support boundaries are ready." : "暂不售卖。只有接口文档、key、限额和支持边界准备好后,才重新评估 API 产品化。"}
</div>
<Link
href="/account"
href="/docs/realtime-sources"
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Choose quarterly" : "选择季度 Pro"}
{isEn ? "Read data guide" : "查看数据指南"}
<LandingIcon name="arrow" size={15} />
</Link>
</div>
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<div className="flex items-center gap-2 text-sm font-bold text-violet-700">
<LandingIcon name="shield" />
Team
</div>
<h3 className="mt-5 text-2xl font-black text-slate-950">
{isEn ? "Team" : "团队"}
</h3>
<p className="mt-3 flex-1 text-sm leading-7 text-slate-600">
{isEn
? "For private groups that need shared access, Telegram workflow support, and manual onboarding."
: "面向需要共享权限、Telegram 工作流支持和人工开通的私密团队。"}
</p>
<div className="mt-7 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
{isEn
? "Custom seat and group setup. Best for teams already using the terminal together."
: "自定义席位和群组配置,适合已经一起使用终端的团队。"}
</div>
<Link
href="/subscription-help"
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Talk to us" : "联系开通"}
<LandingIcon name="arrow" size={15} />
</Link>
</div>
@@ -57,6 +57,9 @@ export function runTests() {
"WebP preview must be materially smaller than the PNG LCP image",
);
assert(source.includes("/static/web.webp"), "landing page must load the lighter WebP product preview image");
assert(source.includes("/static/tel.png"), "landing page must include the Telegram alert screenshot");
assert(source.includes("#screenshots"), "landing navigation must expose the product screenshot section");
assert(source.includes("结算源优先") && source.includes("差异化卖点"), "landing page must explain the differentiated settlement-source positioning");
assert(!source.includes('src="/static/web.png"'), "landing hero must not use the heavy PNG as its primary LCP image");
assert(
source.includes('width="680"') &&
@@ -73,6 +76,10 @@ export function runTests() {
);
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
assert(source.includes("API") && source.includes("暂不售卖"), "landing page must describe API as not currently for sale");
assert(!source.includes("Request API") && !source.includes("申请 API"), "landing page must not invite users to buy or request API access");
assert(source.includes("Team") && source.includes("团队"), "landing page must describe the Team tier");
assert(source.includes("Trial") && source.includes("Pro") && source.includes("API") && source.includes("Team"), "landing pricing ladder must clearly name Trial / Pro / API / Team");
assert(source.includes("20 USDC") && source.includes("+3500 积分"), "landing page must describe referral discount and reward");
assert(!source.includes("AI 气象证据链解读"), "legacy AI evidence-chain wording must be removed");
assert(!source.includes("AI weather evidence"), "legacy AI evidence wording must be removed");
+144 -4
View File
@@ -47,8 +47,8 @@ export const DOCS_PAGES: DocsPage[] = [
id: "what-is-polyweather",
title: "PolyWeather 是什么",
blocks: [
{ type: "paragraph", text: "PolyWeather 不是通用天气 App。它面向天气衍生品和温度市场,重点回答三个问题:今天最高温大概会落在哪个区间、机场或官方结算站会不会被压温、市场有没有明显错定价。" },
{ type: "callout", tone: "info", title: "产品定位", text: "主站的核心价值不是报天气,而是把模型、机场主站实况、官方增强站网、机场预报和结算规则整合成交易可用的信息。" },
{ type: "paragraph", text: "PolyWeather 不是通用天气 App。它是结算源优先的温度市场实时实测终端,面向天气衍生品和温度市场,重点回答三个问题:今天最高温大概会落在哪个区间、机场或官方结算站会不会被压温、市场有没有明显错定价。" },
{ type: "callout", tone: "info", title: "产品定位", text: "主站的核心价值不是报天气,而是把模型、机场主站实况、官方增强站网、机场预报和结算规则整合成交易可用的信息。先确认结算源,再看 DEB、市场桶和概率。" },
],
},
{
@@ -75,8 +75,8 @@ export const DOCS_PAGES: DocsPage[] = [
id: "what-is-polyweather",
title: "What PolyWeather is",
blocks: [
{ type: "paragraph", text: "PolyWeather is not a generic weather app. It is built for weather derivatives and temperature markets, with one job: estimate the likely high-temperature bucket, explain whether the airport or official settlement site may get capped, and surface whether the market is mispricing that outcome." },
{ type: "callout", tone: "info", title: "Product focus", text: "The core value is not raw weather reporting. It is the conversion of models, airport-primary observations, official nearby networks, airport forecasts, and settlement rules into usable trading context." },
{ type: "paragraph", text: "PolyWeather is not a generic weather app. It is a settlement-source-first live terminal for temperature markets, built to estimate the likely high-temperature bucket, explain whether the airport or official settlement site may get capped, and surface whether the market is mispricing that outcome." },
{ type: "callout", tone: "info", title: "Product focus", text: "The core value is not raw weather reporting. It is the conversion of models, airport-primary observations, official nearby networks, airport forecasts, and settlement rules into usable trading context. Confirm the settlement source first, then read DEB, market buckets, and calibrated probability." },
],
},
{
@@ -97,6 +97,146 @@ export const DOCS_PAGES: DocsPage[] = [
},
},
},
{
slug: "chart-guide",
group: "getting-started",
content: {
"zh-CN": {
title: "如何读 PolyWeather 图表",
description: "这页解释终端温度图的图层、默认显示策略、高级气象变量和常见误读。",
sections: [
{
id: "read-order",
title: "先读什么",
blocks: [
{ type: "steps", items: ["先看结算源实测:机场主站、官方站或跑道端点是不是还在更新。", "再看 DEB Forecast:它是今日最高温路径,不是已经发生的实测。", "然后看结算跑道或官方增强站网:判断当前实测是否领先、滞后或被压温。", "最后看市场桶和校准概率:判断天气概率是否已经被价格充分反映。"] },
],
},
{
id: "layers",
title: "图层怎么理解",
blocks: [
{ type: "bullets", items: ["结算源或参考站:默认显示,代表当前最接近结算口径的实测曲线。", "跑道明细:AMSC / AMOS 城市默认展示结算跑道,并允许展开辅助跑道作为空间背景。", "METAR / 官方增强层:用于确认机场或官方站状态,不替代明确命名的结算站。", "DEB Forecast:模型融合路径,帮助判断未来峰值窗口。", "原始模型曲线:默认隐藏,用户需要解释模型分歧时再打开。"] },
],
},
{
id: "advanced-variables",
title: "高级气象变量",
blocks: [
{ type: "paragraph", text: "风速、风向、露点、湿度、气压这些变量对解释压温、海风、云雨和边界层结构很有用,但它们不是温度结算曲线。" },
{ type: "callout", tone: "info", title: "默认隐藏", text: "高级气象变量默认隐藏,只作为上下文辅助展示。这样详情面板不会继续变重,用户需要时再展开查看。" },
],
},
{
id: "common-misreads",
title: "常见误读",
blocks: [
{ type: "bullets", items: ["不要把概率温度带当成实测曲线。概率层只是解释概率中心和温度区间,不代表某个时刻的温度。", "不要把城区体感当成结算温度。机场市场先看机场主站或规则指定站。", "不要用固定 1 分钟预期要求所有城市。图表更新频率取决于源头原生频率。"] },
],
},
],
},
"en-US": {
title: "How To Read PolyWeather Charts",
description: "A practical guide to the terminal chart layers, default visibility, advanced weather variables, and common misreads.",
sections: [
{
id: "read-order",
title: "Read order",
blocks: [
{ type: "steps", items: ["Start with the settlement-source observation: airport primary, official station, or runway endpoint.", "Read DEB Forecast next. It is the expected high-temperature path, not an observation that already happened.", "Use runway or official network overlays to decide whether the live path is leading, lagging, or capped.", "Then compare market buckets and calibrated probability to see whether the weather path is already priced."] },
],
},
{
id: "layers",
title: "Chart layers",
blocks: [
{ type: "bullets", items: ["Settlement or reference station: visible by default and closest to the settlement rule.", "Runway details: AMSC / AMOS cities show the settlement runway by default, with auxiliary runways available as spatial context.", "METAR / official enhancement: confirmation context for the airport or official station, not a replacement for an explicitly named settlement site.", "DEB Forecast: blended forecast path for the peak window.", "Raw model curves: hidden by default and opened only when users need model-spread context."] },
],
},
{
id: "advanced-variables",
title: "Advanced weather variables",
blocks: [
{ type: "paragraph", text: "Wind speed, wind direction, dew point, humidity, and pressure help explain suppression, sea-breeze timing, cloud/rain risk, and boundary-layer structure, but they are not settlement-temperature curves." },
{ type: "callout", tone: "info", title: "Hidden by default", text: "Advanced variables stay hidden by default and are shown as context only. This keeps the detail panel readable while still making the extra variables available when needed." },
],
},
{
id: "common-misreads",
title: "Common misreads",
blocks: [
{ type: "bullets", items: ["Do not read probability bands as observation curves. They explain probability center and bucket context, not a timestamped temperature.", "Do not trade broad city feel as settlement temperature. Airport markets start with the airport primary or named settlement site.", "Do not expect every city to update every minute. Refresh cadence follows the source's native cadence."] },
],
},
],
},
},
},
{
slug: "realtime-sources",
group: "settlement",
content: {
"zh-CN": {
title: "实时数据频率",
description: "这页解释为什么不同城市刷新频率不同,以及网站、Telegram 和 SSE patch 之间的关系。",
sections: [
{
id: "source-cadence",
title: "按源频率采集",
blocks: [
{ type: "bullets", items: ["AMOS 60s:首尔、釜山跑道传感器。", "AMSC 180s:中国内地跑道端点观测城市。", "MADIS 300s:美国高频机场观测城市。", "CoWIN 60s:香港 6087 参考站。", "HKO 600s:香港天文台官方 10 分钟层。", "CWA / JMA / FMI / KNMI / MGM:按各自官方或可用频率采集。"] },
],
},
{
id: "pipeline",
title: "网站怎么更新",
blocks: [
{ type: "paragraph", text: "网站不是靠 Telegram 推送循环刷新图表。独立 observation collector 按源频率采集,写缓存、DB 或 Redis,再通过 SSE patch 把 city_observation_patch.v1 推给前端。" },
{ type: "callout", tone: "info", title: "SSE patch", text: "前端先读取完整 snapshot,之后只合并 SSE patch。只有可见图表长时间没有 patch 时,才做轻量兜底刷新。" },
],
},
{
id: "telegram",
title: "Telegram 和网站的关系",
blocks: [
{ type: "paragraph", text: "Telegram 只读最新缓存或 DB,不再主动强制刷新观测源。除非完全没有缓存,才允许非强制兜底分析。" },
{ type: "paragraph", text: "Web、collector、Telegram 共享 per-city/source singleflight 和冷却,避免同时打同一个外部源。" },
],
},
],
},
"en-US": {
title: "Realtime Source Cadence",
description: "Why cities update at different speeds, and how the website, Telegram, and SSE patches relate to each other.",
sections: [
{
id: "source-cadence",
title: "Source-native cadence",
blocks: [
{ type: "bullets", items: ["AMOS 60s: Seoul and Busan runway sensors.", "AMSC 180s: mainland China runway endpoint observations.", "MADIS 300s: US high-frequency airport observations.", "CoWIN 60s: Hong Kong 6087 reference station.", "HKO 600s: Hong Kong Observatory official 10-minute layer.", "CWA / JMA / FMI / KNMI / MGM: collected at their own official or available cadence."] },
],
},
{
id: "pipeline",
title: "How the site updates",
blocks: [
{ type: "paragraph", text: "The website does not rely on the Telegram push loop to refresh charts. The independent observation collector samples each source at its own cadence, writes cache, DB, or Redis, then sends city_observation_patch.v1 to the frontend through SSE patch." },
{ type: "callout", tone: "info", title: "SSE patch", text: "The frontend reads a full snapshot first, then merges SSE patches. Only visible charts use a lightweight fallback refresh if patches stop for too long." },
],
},
{
id: "telegram",
title: "How Telegram relates",
blocks: [
{ type: "paragraph", text: "Telegram reads the latest cache or DB state and no longer force-refreshes observation sources. It only falls back to a non-forced panel analysis when no cache exists at all." },
{ type: "paragraph", text: "Web, collector, and Telegram share per-city/source singleflight and cooldown gates so they do not hit the same external source at the same time." },
],
},
],
},
},
},
{
slug: "intraday-signal",
group: "analysis",