';
}
}
async function fetchConfidenceTimeline(mid) {
try {
const res = await fetch('/api/match/' + mid + '/confidence-timeline');
if (!res.ok) return;
const data = await res.json();
if (!data.ok || !data.points || data.points.length === 0) return;
renderConfidenceCurve(data);
} catch (e) {
// silent — confidence curve is optional
}
}
function renderConfidenceCurve(data) {
const ui = {}; // English only
const container = document.getElementById('confidence-curve');
if (!container) return;
const points = data.points;
if (points.length === 0) return;
// Single point: show simple confidence card (no curve needed)
if (points.length === 1) {
const p = points[0];
const c = p.confidence || Math.max(p.home_prob, p.draw_prob, p.away_prob);
let html = '
';
html += '
';
html += '📈';
html += '' + (ui.confidenceTimeline || 'Confidence Timeline') + '';
html += '
';
html += '
';
html += '
' + (c * 100).toFixed(0) + '%
';
html += '
Model confidence — monitoring active
';
html += '
Curve appears when events impact predictions
';
html += '
';
container.innerHTML = html;
return;
}
// Multi-point: draw SVG curve
const confidences = points.map(p => {
const c = p.confidence || Math.max(p.home_prob, p.draw_prob, p.away_prob);
return c;
});
const times = points.map(p => {
const t = p.time || '';
return t.length >= 16 ? t.slice(5, 16) : t; // MM-DD HH:MM
});
const minC = Math.min(...confidences) * 0.9;
const maxC = Math.max(...confidences) * 1.05;
const range = maxC - minC || 1;
const W = 600, H = 140, padL = 50, padR = 30, padT = 15, padB = 30;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const xScale = (i) => padL + (i / (points.length - 1)) * plotW;
const yScale = (v) => padT + plotH - ((v - minC) / range) * plotH;
// Build line path
let lineD = '';
let fillD = '';
for (let i = 0; i < points.length; i++) {
const x = xScale(i);
const y = yScale(confidences[i]);
if (i === 0) {
lineD += 'M' + x.toFixed(1) + ',' + y.toFixed(1);
fillD += 'M' + x.toFixed(1) + ',' + (padT + plotH).toFixed(1);
fillD += ' L' + x.toFixed(1) + ',' + y.toFixed(1);
} else {
lineD += ' L' + x.toFixed(1) + ',' + y.toFixed(1);
fillD += ' L' + x.toFixed(1) + ',' + y.toFixed(1);
}
}
// Close fill path
const lastX = xScale(points.length - 1);
fillD += ' L' + lastX.toFixed(1) + ',' + (padT + plotH).toFixed(1) + ' Z';
// Calibration markers
let calMarkers = '';
if (data.calibration_flags) {
for (const f of data.calibration_flags) {
if (!f.has_divergence) continue;
const ft = f.time ? f.time.slice(5, 16) : '';
const idx = times.findIndex(t => t >= ft);
if (idx >= 0) {
const cx = xScale(idx);
calMarkers += '';
calMarkers += '';
calMarkers += '⚠';
}
}
}
// X-axis labels (first, last, and significant changes)
let xLabels = '';
if (points.length <= 6) {
for (let i = 0; i < points.length; i++) {
xLabels += '' + esc(times[i]) + '';
}
} else {
xLabels += '' + esc(times[0]) + '';
const mid = Math.floor(points.length / 2);
xLabels += '' + esc(times[mid]) + '';
xLabels += '' + esc(times[points.length - 1]) + '';
}
// Y-axis labels
const ySteps = 3;
let yLabels = '';
for (let i = 0; i <= ySteps; i++) {
const val = minC + (range * i / ySteps);
const y = yScale(val);
yLabels += '' + (val * 100).toFixed(0) + '%';
}
// Dots for each point
let dots = '';
for (let i = 0; i < points.length; i++) {
dots += '';
}
// Title with calibration badge
let titleExtra = '';
if (data.has_calibration) {
const divCount = data.calibration_flags.filter(f => f.has_divergence).length;
if (divCount > 0) {
titleExtra = ' ' + divCount + ' ' + (ui.signalCount || 'signals') + '';
}
}
let html = '
';
html += '
';
html += '📈';
html += '' + (ui.confidenceTimeline || 'Confidence Timeline') + '';
html += titleExtra;
html += '
';
html += '
';
html += '';
html += '
';
html += '
';
html += '
' + (ui.fmModelConfidence || 'FM Model Confidence') + '
';
if (data.has_calibration) {
html += '
Calibration Signal
';
}
html += '
';
html += '
';
container.innerHTML = html;
}
function renderNarrativeHTML(data) {
const ui = {}; // English only
const el = document.getElementById('sidebar');
if (!el) return;
const narrative = typeof data === 'string' ? data : data.narrative;
if (!narrative) { el.innerHTML = ''; return; }
const paragraphs = narrative.split('\n\n').filter(p => p.trim());
let html = '
';
html += '
';
html += '🧠';
html += '' + (ui.aiReport || 'AI Match Report') + '';
html += '
';
html += '
';
for (const p of paragraphs) {
html += '
' + esc(p.trim()) + '
';
}
html += '
';
if (data.grounded_on && data.grounded_on.length > 0) {
html += '
';
html += '' + (ui.groundedOn || 'Grounded on:') + '';
for (const g of data.grounded_on) {
html += '#' + g.id + ' ' + esc(g.type) + '';
}
html += '
';
}
html += '
';
el.innerHTML = html;
}
function renderNarrativeFromText(text) {
const ui = {}; // English only
if (!text) return;
const el = document.getElementById('sidebar');
if (!el) return;
const paragraphs = text.split('\n\n').filter(p => p.trim());
let html = '
';
html += '
';
html += '🧠';
html += '' + (ui.aiReport || 'AI Match Report') + '';
html += '
';
html += '
';
for (const p of paragraphs) {
html += '
' + esc(p.trim()) + '
';
}
html += '
';
el.innerHTML = html;
}
/* ── Fetch & Render ── */
async function load() {
// SSR preload: use server-injected data when available (no fetch needed)
const preload = window.__PRELOADED_DATA__;
const ui = (preload && preload.ui && preload.ui.detail) || {};
window._matchUI = ui;
// Localize initial loading text
const loadingEl = document.querySelector('#app .loading p');
if (loadingEl) loadingEl.textContent = ui.loading || loadingEl.textContent;
if (preload) {
try {
render({
ok: true,
match: preload.match,
prediction: preload.prediction,
state: preload.state,
timeline: preload.timeline,
sources: preload.sources,
snapshots: preload.snapshots,
signals: preload.signals,
context: preload.context,
}, preload.pulse);
// Render cached narrative if available
if (preload.narrative) {
renderNarrativeFromText(preload.narrative);
} else {
fetchNarrative(matchId); // fallback to live fetch
}
// Fetch confidence timeline (not in SSR preload)
fetchConfidenceTimeline(matchId);
return;
} catch (e) {
console.warn('Preload render failed, falling back to fetch:', e.message);
}
}
// Normal path: fetch from API
try {
const pageLang = ('en' // was document.documentElement.lang || navigator.language || 'en').startsWith('zh') ? 'zh-CN' : 'en';
const [stateRes, pulseRes] = await Promise.all([
fetch('/api/match-state/' + matchId + '?lang=' + pageLang),
fetch('/api/match-pulse/' + matchId),
]);
if (!stateRes.ok) {
const err = await stateRes.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(err.error || 'Match not found');
}
const data = await stateRes.json();
if (!data.ok) throw new Error(data.error || 'Failed to load match');
// Pulse is optional — if it fails, still render without it
let pulse = null;
if (pulseRes.ok) {
const pulseData = await pulseRes.json().catch(() => null);
if (pulseData?.ok) pulse = pulseData.pulse;
}
render(data, pulse);
// Narrative loads async — renders into placeholder when ready
fetchNarrative(matchId);
} catch (e) {
document.getElementById('app').innerHTML =
'
';
if (status === 'pending') {
html += '🟡 tentative';
} else if (status === 'auto') {
html += '✅ confirmed';
}
if (breakdown && breakdown.reason && breakdown.reason.length > 0) {
html += '
';
html += breakdown.reason.map(r => '· ' + esc(r) + '').join('');
html += '(tm=' + (breakdown.team_match || 0) + ' dp=' + (breakdown.date_proximity || 0) + ')';
html += '
';
}
if (isImpact && e.impact) {
html += '
';
if (e.impact.home_win) html += 'Home ' + impactDelta(e.impact.home_win) + '';
if (e.impact.draw) html += 'Draw ' + impactDelta(e.impact.draw) + '';
if (e.impact.away_win) html += 'Away ' + impactDelta(e.impact.away_win) + '';
html += '
';
if (e.impact.home_win) html += 'Home ' + impactDelta(e.impact.home_win) + '';
if (e.impact.draw) html += 'Draw ' + impactDelta(e.impact.draw) + '';
if (e.impact.away_win) html += 'Away ' + impactDelta(e.impact.away_win) + '';
html += '