feat: Implement PolyWeather web frontend with dark theme, map, and city list UI.

This commit is contained in:
2569718930@qq.com
2026-03-06 11:26:36 +08:00
parent 05a5f005fe
commit a00ef8f81e
6 changed files with 824 additions and 107 deletions
+255 -2
View File
@@ -255,6 +255,85 @@ async function fetchCityDetail(cityName, force = false) {
return await res.json();
}
async function fetchCityMarket(cityName, targetDate, force = false) {
const urlName = cityName.replace(/\s/g, "-");
const params = new URLSearchParams({
force_refresh: String(force),
});
if (targetDate) {
params.set("target_date", targetDate);
}
const res = await fetch(`/api/polymarket/${encodeURIComponent(urlName)}?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
}
function hasMarketSnapshot(data, targetDate) {
return Boolean(
data?.polymarket &&
data.polymarket.target_date === targetDate &&
!data.polymarket.loading &&
!data.polymarket.fetch_error &&
Array.isArray(data.polymarket.markets),
);
}
async function hydrateCityMarketData(data, force = false) {
if (!data?.name) return null;
const targetDate = data.local_date || null;
if (!force && data.polymarket?.loading && data.polymarket.target_date === targetDate) {
return data.polymarket;
}
if (!force && hasMarketSnapshot(data, targetDate)) {
return data.polymarket;
}
const existingMarkets = Array.isArray(data.polymarket?.markets)
? data.polymarket.markets
: [];
data.polymarket = {
...(data.polymarket || {}),
target_date: targetDate,
loading: true,
fetch_error: null,
markets: existingMarkets,
};
if (selectedCity === data.name) {
renderMarketPrices(data);
}
try {
const snapshot = await fetchCityMarket(data.name, targetDate, force);
data.polymarket = {
...snapshot,
loading: false,
fetch_error: null,
};
} catch (e) {
console.error(`Failed to load market for ${data.name}:`, e);
data.polymarket = {
...(data.polymarket || {}),
target_date: targetDate,
loading: false,
fetch_error: e.message || "Unknown error",
markets: existingMarkets,
};
}
cityDataCache[data.name] = data;
saveCache();
if (selectedCity === data.name) {
renderMarketPrices(data);
}
return data.polymarket;
}
// ──────────────────────────────────────────────────────────
// Nearby Map Stations Rendering
// ──────────────────────────────────────────────────────────
@@ -344,8 +423,10 @@ async function loadCityDetail(cityName, force = false) {
setSelectedMarker(cityName);
if (!force && cityDataCache[cityName]) {
renderPanel(cityDataCache[cityName]);
renderNearbyStations(cityDataCache[cityName]);
const cachedData = cityDataCache[cityName];
renderPanel(cachedData);
renderNearbyStations(cachedData);
hydrateCityMarketData(cachedData, false);
return;
}
@@ -356,6 +437,7 @@ async function loadCityDetail(cityName, force = false) {
cityDataCache[cityName] = data;
saveCache();
renderPanel(data);
hydrateCityMarketData(data, force);
// Render nearby stations and zoom camera (cinematic or bounds)
renderNearbyStations(data);
@@ -412,6 +494,8 @@ function renderPanel(data) {
renderChart(data);
// Probabilities
renderProbabilities(data);
// Market prices
renderMarketPrices(data);
// Multi-model & Forecast synchronization
if (!selectedForecastDate) {
selectedForecastDate = data.local_date;
@@ -903,6 +987,175 @@ function renderProbabilities(data) {
});
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => {
return (
{
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[ch] || ch
);
});
}
function formatCents(price) {
const n = Number(price);
if (!Number.isFinite(n)) return "--";
const cents = Math.round(n * 1000) / 10;
return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
}
function formatCompactUsd(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
if (n >= 1000) {
const compact = n >= 10000 ? (n / 1000).toFixed(0) : (n / 1000).toFixed(1);
return `$${compact}k`;
}
return `$${Math.round(n)}`;
}
function formatMarketThreshold(market, fallbackUnit) {
const threshold = Number(market?.threshold);
if (!Number.isFinite(threshold)) {
return market?.question || "Market";
}
const isInteger = Math.abs(threshold - Math.round(threshold)) < 0.001;
const value = isInteger ? threshold.toFixed(0) : threshold.toFixed(1);
const unitRaw = String(market?.threshold_unit || fallbackUnit || "C").toUpperCase();
const unit = unitRaw === "F" ? "°F" : "°C";
if (market?.contract_type === "exceed") {
return `${value}${unit}+`;
}
return `${value}${unit}`;
}
function findOutcome(market, outcomeName) {
const target = String(outcomeName || "").toLowerCase();
return (market?.outcomes || []).find(
(outcome) => String(outcome?.name || "").toLowerCase() === target,
);
}
function renderMarketPrices(data) {
const summary = document.getElementById("marketSummary");
const container = document.getElementById("marketBook");
const snapshot = data.polymarket || {};
const markets = Array.isArray(snapshot.markets) ? [...snapshot.markets] : [];
const targetDate = snapshot.target_date || data.local_date || "--";
if (snapshot.loading && markets.length === 0) {
summary.innerHTML =
'<span class="market-muted">姝e湪鍔犺浇褰撳ぉ Polymarket 鐩樺彛...</span>';
container.innerHTML = "";
return;
}
if (snapshot.fetch_error && markets.length === 0) {
summary.innerHTML = `<span class="market-error">鐩樺彛鍔犺浇澶辫触: ${escapeHtml(snapshot.fetch_error)}</span>`;
container.innerHTML = "";
return;
}
if (markets.length === 0) {
summary.innerHTML =
'<span class="market-muted">褰撳ぉ鏆傛棤鍙敤鐨?Polymarket 甯傚満浠锋牸</span>';
container.innerHTML = "";
return;
}
markets.sort((a, b) => {
const left = Number(a?.threshold);
const right = Number(b?.threshold);
const safeLeft = Number.isFinite(left) ? left : Number.MAX_SAFE_INTEGER;
const safeRight = Number.isFinite(right) ? right : Number.MAX_SAFE_INTEGER;
return safeLeft - safeRight;
});
const primaryUrl = markets.find((market) => market?.url)?.url;
const updatedAtTs = snapshot.updated_at ? Date.parse(snapshot.updated_at) : NaN;
const updatedAt = Number.isFinite(updatedAtTs)
? new Date(updatedAtTs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: null;
summary.innerHTML = `
<div class="market-summary-main">
<span>${escapeHtml(targetDate)} · ${markets.length} 涓悎绾?/span>
<span>涔板叆 = best ask</span>
<span>鍗栧嚭 = best bid</span>
${updatedAt ? `<span>鏇存柊 ${escapeHtml(updatedAt)}</span>` : ""}
</div>
${primaryUrl ? `<a href="${escapeHtml(primaryUrl)}" target="_blank" rel="noreferrer">鎵撳紑 Polymarket</a>` : ""}
`;
container.innerHTML = markets
.map((market) => {
const yes = findOutcome(market, "yes") || {};
const no = findOutcome(market, "no") || {};
const label = formatMarketThreshold(
market,
String(data.temp_symbol || "").includes("F") ? "F" : "C",
);
const spread = Number(yes.spread);
const meta = [
`Volume ${formatCompactUsd(market.volume)}`,
`Liquidity ${formatCompactUsd(market.liquidity)}`,
Number.isFinite(spread) ? `Yes spread ${formatCents(spread)}` : null,
].filter(Boolean);
return `
<div class="market-row">
<div class="market-contract">
<div class="market-threshold">${escapeHtml(label)}</div>
<div class="market-contract-meta">${meta.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}</div>
<div class="market-question">${escapeHtml(market.question || "")}</div>
</div>
<div class="market-side yes">
<div class="market-side-header">
<span class="market-side-label yes">YES</span>
<span class="market-last">Last ${formatCents(yes.last_trade_price)}</span>
</div>
<div class="market-side-prices">
<div class="market-price-chip">
<div class="market-price-label">涔板叆</div>
<div class="market-price-value">${formatCents(yes.buy_price)}</div>
</div>
<div class="market-price-chip">
<div class="market-price-label">鍗栧嚭</div>
<div class="market-price-value">${formatCents(yes.sell_price)}</div>
</div>
</div>
</div>
<div class="market-side no">
<div class="market-side-header">
<span class="market-side-label no">NO</span>
<span class="market-last">Last ${formatCents(no.last_trade_price)}</span>
</div>
<div class="market-side-prices">
<div class="market-price-chip">
<div class="market-price-label">涔板叆</div>
<div class="market-price-value">${formatCents(no.buy_price)}</div>
</div>
<div class="market-price-chip">
<div class="market-price-label">鍗栧嚭</div>
<div class="market-price-value">${formatCents(no.sell_price)}</div>
</div>
</div>
</div>
</div>
`;
})
.join("");
}
function renderModels(data) {
const container = document.getElementById("modelBars");
const targetDate = selectedForecastDate || data.local_date;
+9 -1
View File
@@ -108,6 +108,14 @@
</div>
</section>
<section class="market-section">
<h3>Polymarket Prices</h3>
<div id="marketSummary" class="market-summary"></div>
<div id="marketBook" class="market-book">
<!-- Dynamically populated -->
</div>
</section>
<!-- ── Multi-Model Comparison ── -->
<section class="models-section">
<h3>🔬 多模型预报</h3>
@@ -214,4 +222,4 @@
<script src="/static/app.js"></script>
</body>
</html>
</html>
+152
View File
@@ -668,6 +668,155 @@ body {
background: rgba(99, 102, 241, 0.15);
}
/* ── Market Section ── */
.market-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
font-size: 11px;
color: var(--text-muted);
}
.market-summary-main {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.market-summary a {
color: var(--accent-cyan);
text-decoration: none;
font-weight: 600;
}
.market-summary a:hover {
text-decoration: underline;
}
.market-book {
display: flex;
flex-direction: column;
gap: 10px;
}
.market-row {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.025);
}
.market-contract {
min-width: 0;
}
.market-threshold {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.market-contract-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 11px;
color: var(--text-muted);
}
.market-question {
margin-top: 8px;
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
word-break: break-word;
}
.market-side {
border-radius: 10px;
padding: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
}
.market-side.yes {
border-color: rgba(34, 197, 94, 0.18);
background: rgba(34, 197, 94, 0.05);
}
.market-side.no {
border-color: rgba(248, 113, 113, 0.18);
background: rgba(248, 113, 113, 0.05);
}
.market-side-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.market-side-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
}
.market-side-label.yes {
color: #4ade80;
}
.market-side-label.no {
color: #fda4af;
}
.market-last {
font-size: 10px;
color: var(--text-muted);
}
.market-side-prices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.market-price-chip {
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: rgba(15, 23, 42, 0.45);
padding: 8px;
}
.market-price-label {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 2px;
}
.market-price-value {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.market-muted {
color: var(--text-muted);
}
.market-error {
color: var(--accent-red);
}
/* ── Model Bars ── */
.model-bars {
display: flex;
@@ -1088,6 +1237,9 @@ body {
:root {
--panel-width: 100%;
}
.market-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {