Home
温哥华白帽 vs 圣何塞地震
AI裁决
主胜 — 77% probability ★★★★★
Predicted score: 1-1
Outlook stable
Confidence: 果断 · Updated: Sun, Mar 22, 2026 10:30 AM
AI预测
Predicted outcome: 主胜 (77% probability)
Home Win: 77%
Draw: 12%
Away Win: 11%
Most likely scores: 1-1 (12%), 1-0 (11%), 0-1 (11%)
Predicted scoreline: 1-1
模型置信度
Confidence Rating: 果断 (84%)
前两个结果之间界限清晰
This measures how certain the AI is about the ranking of outcomes (Home Win > Draw > Away Win), not the probability of any single outcome.
Match Pulse
➡️ Outlook stable (LOW CONFIDENCE)
No significant events detected
Prediction Timeline
How the AI's prediction evolved during the match — from kickoff to final whistle.
Prediction Stability: 稳定
预测保持稳定
Probability swing: 0%
Turning Point: N/A' — 模型全程保持置信度
模型全程保持置信度
In-Match Probability Shifts
— H: 77% / D: 12% / A: 11% [Kickoff]
Explainable AI Review
After the match, the AI explains why its prediction succeeded or failed.
Predicted: [object Object]
Actual: [object Object]
Prediction Correct? ❌ No
What Went Wrong — And Why
模型的预测落空——预期主胜(置信度77%),实际比分0-1。 主要因素:多项指标偏离模型假设。
Key Deviations
Where the match numbers diverged from model expectations.
Error Analysis
Primary Reason: 多项指标偏离模型假设
Error Categories: 无显著偏差
Model Performance
How the AI model has performed historically, so you can calibrate your trust in its predictions.
This match: ❌ Incorrect — Predicted [object Object], Actual [object Object]
Track record: The model is evaluated continuously. Visit the Tracking page for Brier scores, calibration curves, and accuracy by league.
How predictions are made: Our ensemble combines Gradient Boosting + Random Forest with Poisson-based score distributions, trained on historical match data, ELO ratings, and recent form.
深入细节
常见问题
Why is 主胜 the clear favorite?
The AI model assigns 77% probability to 主胜, indicating strong confidence based on historical data, ELO ratings, and recent form analysis.
How confident is the model in this ranking (果断, 84%)?
Model Confidence measures how certain the AI is about the order of outcomes, not any single probability. A clear gap between the top two outcomes gives the model high conviction in its ranking.
Why did the AI get this match wrong?
The primary reason was: 多项指标偏离模型假设. The Explainable AI Review above breaks down exactly which metrics (xG, possession, shots) diverged from what the model expected.
How does the AI prediction model work?
Our ensemble combines Gradient Boosting and Random Forest models trained on historical match data, ELO team ratings, recent form, and statistical metrics. Score distributions use Poisson-based simulations for the most likely scorelines.
Loading match intelligence…
_mainInit() {
(function() {
'use strict';
const ui = window._matchUI || {};
const matchId = (function() {
const m = location.pathname.match(/\/match\/(\d+)/);
return m ? parseInt(m[1]) : null;
})();
if (!matchId) {
document.getElementById('app').innerHTML =
'' + (ui.invalidMatch || 'Invalid Match') + ' ' + (ui.noMatchId || 'No match ID found in URL.') + '
';
return;
}
const STATE_ICONS = {
injury: '\uD83E\uDE79', weather: '\uD83C\uDF27', lineup: '\uD83D\uDCCB',
tactics: '\uD83E\uDDE0', transfer: '\uD83D\uDD04', referee: '\uD83C\uDFF3',
odds_move: '\uD83D\uDCCA', news: '\uD83D\uDCF0'
};
const STATE_LABELS = {
injury: ui.injury || 'Injury', weather: ui.weather || 'Weather', lineup: ui.lineup || 'Lineup',
tactics: ui.tactical || 'Tactical', transfer: ui.transfer || 'Transfer', referee: ui.referee || 'Referee',
odds_move: ui.oddsMove || 'Odds Move', news: ui.news || 'News'
};
const STATE_ORDER = ['injury', 'weather', 'lineup', 'tactics', 'transfer', 'referee', 'odds_move', 'news'];
const RESULT_NAMES = { H: ui.homeWin || 'Home Win', D: ui.draw || 'Draw', A: ui.awayWin || 'Away Win' };
const RESULT_ZH = { H: '主胜', D: '平局', A: '客胜' };
function fmtDate(iso) {
if (!iso) return '';
const loc = (document.documentElement.lang === 'zh-CN') ? 'zh-CN' : 'en-US';
const d = new Date(iso);
return d.toLocaleDateString(loc, { weekday:'short', month:'short', day:'numeric', year:'numeric' })
+ ' ' + d.toLocaleTimeString(loc, { hour:'2-digit', minute:'2-digit' });
}
function fmtTime(iso) {
if (!iso) return '';
const loc = (document.documentElement.lang === 'zh-CN') ? 'zh-CN' : 'en-US';
const d = new Date(iso);
return d.toLocaleTimeString(loc, { hour:'2-digit', minute:'2-digit' });
}
function impactClass(val) {
if (val > 0.01) return 'pos';
if (val < -0.01) return 'neg';
return 'neutral';
}
function impactDelta(val) {
if (Math.abs(val) < 0.001) return '0%';
const pct = (val * 100).toFixed(1);
return (val > 0 ? '+' : '') + pct + '%';
}
function impactLevel(impact) {
if (!impact) return 'low';
const maxVal = Math.max(
Math.abs(impact.home_win || 0),
Math.abs(impact.draw || 0),
Math.abs(impact.away_win || 0)
);
if (maxVal >= 0.03) return 'high';
if (maxVal >= 0.01) return 'medium';
return 'low';
}
/* ── Fetch & Render ── */
async function fetchNarrative(mid) {
const el = document.getElementById('sidebar');
if (!el) return;
// Show loading
el.innerHTML = '🧠 ' + (ui.aiMatchReport || 'AI Match Report') + '
' + (ui.generatingAnalysis || 'Generating grounded match analysis…') + '
';
try {
const res = await fetch('/api/match-narrative/' + mid);
const data = await res.json();
if (!data.ok || !data.narrative) {
el.innerHTML = '🧠 ' + (ui.aiMatchReport || 'AI Match Report') + '
' + (ui.unavailable || 'Unavailable') + '
';
return;
}
renderNarrativeHTML(data);
} catch (e) {
el.innerHTML = '🧠 ' + (ui.aiMatchReport || 'AI Match Report') + '
' + (ui.unavailable || 'Unavailable') + '
';
}
}
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 = window._matchUI || {};
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 += ' ';
// Grid lines
for (let i = 0; i <= ySteps; i++) {
const y = yScale(minC + (range * i / ySteps));
html += ' ';
}
html += calMarkers;
html += ' ';
html += ' ';
html += dots;
html += yLabels;
html += xLabels;
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 = window._matchUI || {};
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 = window._matchUI || {};
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 = (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 =
'';
}
}
function render(data, pulse) {
const ui = window._matchUI || {};
const { match, prediction, state, timeline, sources, snapshots } = data;
const resultKey = prediction?.predicted || 'D';
const predName = prediction?.predicted_name || RESULT_NAMES[resultKey];
const maxProb = prediction
? Math.max(prediction.home_win, prediction.draw, prediction.away_win)
: 0;
const topKey = prediction?.home_win === maxProb ? 'home' :
prediction?.away_win === maxProb ? 'away' : 'draw';
const homeWin = prediction?.home_win?.toFixed(0) || '--';
const drawPct = prediction?.draw?.toFixed(0) || '--';
const awayWin = prediction?.away_win?.toFixed(0) || '--';
let html = '';
/* ── Module 0: Match Pulse (Signal Compression) ── */
if (pulse) {
const tier = pulse.confidence_tier?.label || 'medium';
const dir = pulse.direction_label || {};
const hasDelta = pulse.deltas && (Math.abs(pulse.deltas.home) > 0.01 || Math.abs(pulse.deltas.draw) > 0.01 || Math.abs(pulse.deltas.away) > 0.01);
html += '';
html += '
';
html += '
';
html += '⚡ ';
html += '' + (ui.matchPulse || 'Match Pulse') + ' ';
html += '
';
html += '
' + tier + ' confidence ';
html += '
';
html += '
';
html += '' + (dir.emoji || '➡️') + ' ';
html += '' + esc(pulse.headline) + ' ';
html += '
';
html += '
' + esc(pulse.why || 'Monitoring for changes.') + '
';
if (hasDelta) {
html += '
';
const h = pulse.deltas.home, d = pulse.deltas.draw, a = pulse.deltas.away;
// Normalize: API may return fractions (0-1) or scaled percentages
const dScale = (Math.abs(h) + Math.abs(d) + Math.abs(a)) > 10 ? 0.01 : 1;
html += 'H ' + (h > 0 ? '+' : '') + (h * dScale).toFixed(1) + '% ';
html += 'D ' + (d > 0 ? '+' : '') + (d * dScale).toFixed(1) + '% ';
html += 'A ' + (a > 0 ? '+' : '') + (a * dScale).toFixed(1) + '% ';
html += '
';
}
html += '
';
if (pulse.attribution_confidence != null) {
html += 'Attribution: ' + (pulse.attribution_confidence * 100).toFixed(0) + '% ';
}
html += 'Snapshots: ' + (pulse.snapshot_count || 0) + ' ';
html += 'Events: ' + (pulse.event_count || 0) + ' ';
html += '
';
html += '
'; /* end pulse card */
}
/* ── Module 1: Prediction ── */
html += '';
html += '
';
html += '
' + esc(match.home_team) + ' VS ' + esc(match.away_team) + '
';
html += '
';
html += '' + fmtDate(match.date) + ' ';
html += '' + (ui.matchHash || 'Match #') + match.id + ' ';
html += '
';
html += '
';
html += '' + predName + ' ';
html += '
';
html += '
';
html += '
';
html += '
' + homeWin + '%
';
html += '
Home
';
html += '
';
html += '
' + drawPct + '%
';
html += '
Draw
';
html += '
';
html += '
' + awayWin + '%
';
html += '
Away
';
html += '
';
if (prediction?.predicted_score) {
html += '
';
html += (ui.predictedScore || 'Predicted Score: ') + '' + prediction.predicted_score + ' ';
if (prediction.confidence != null) {
var conf = prediction.confidence > 1 ? prediction.confidence : prediction.confidence * 100;
html += ' · ' + (ui.confidence || 'Confidence') + ': ' + conf.toFixed(1) + '%';
}
html += '
';
}
html += '
'; /* end prediction card */
/* ── Module 2: Match State ── */
html += '';
html += '
';
html += '\uD83D\uDCCA ';
html += '' + (ui.matchState || 'Match State') + ' ';
html += '' + (ui.liveIntelligence || 'Live Intelligence') + ' ';
html += '
';
let hasState = false;
for (const evtType of STATE_ORDER) {
const events = state[evtType] || [];
if (!events.length) continue;
hasState = true;
html += '
';
html += '
';
html += '' + (STATE_ICONS[evtType] || '\uD83D\uDCF0') + ' ';
html += '' + (STATE_LABELS[evtType] || evtType) + ' ';
html += '' + events.length + ' ';
html += '
';
for (const e of events) {
const level = impactLevel(e.impact);
const hasImpact = e.impact && (e.impact.home_win || e.impact.draw || e.impact.away_win);
html += '
';
html += '
';
html += '
' + esc(e.title) + '
';
if (e.summary) {
html += '
' + esc(e.summary.slice(0, 180)) + '
';
}
if (hasImpact) {
const parts = [];
if (e.impact.home_win) parts.push('H: ' + impactDelta(e.impact.home_win));
if (e.impact.draw) parts.push('D: ' + impactDelta(e.impact.draw));
if (e.impact.away_win) parts.push('A: ' + impactDelta(e.impact.away_win));
const impactClass = e.impact.home_win > 0 || e.impact.away_win > 0 ? 'pos' :
e.impact.home_win < 0 || e.impact.away_win < 0 ? 'neg' : 'neutral';
html += '
' + parts.join(' ') + ' ';
}
if (e.source && !/api.?football/i.test(e.source)) {
html += '
' + esc(e.source) + '
';
}
html += '
';
}
html += '
'; /* end state-group */
}
if (!hasState) {
html += '
' + (ui.noEvents || 'No event data yet — match state monitoring is active.') + '
';
}
html += '
'; /* end state card */
/* ── Module 3: Match Signals (SIGNAL relevance only) ── */
const { signals: sigs = [], context: ctxEvents = [] } = data;
const impactfulSignals = sigs.filter(e => e.impact && (e.impact.home_win || e.impact.draw || e.impact.away_win));
html += '';
html += '
';
html += '📡 ';
html += '' + (ui.matchSignals || 'Match Signals') + ' ';
html += '' + sigs.length + ' ' + (ui.signalCount || 'signals') + ' ';
html += '
';
if (sigs.length > 0) {
html += '
';
for (const e of sigs) {
const isImpact = impactfulSignals.includes(e);
const status = e.match_status;
const breakdown = e.match_breakdown ? (typeof e.match_breakdown === 'string' ? JSON.parse(e.match_breakdown) : e.match_breakdown) : null;
html += '
';
html += '
' + fmtTime(e.created_at) + ((e.source && !/api.?football/i.test(e.source)) ? ' · ' + esc(e.source) : '') + '
';
html += '
' + esc(e.title.slice(0, 80)) + '
';
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 += '
';
}
html += '
';
}
html += '
';
if (impactfulSignals.length > 0) {
html += '
';
html += '
⚡ ' + impactfulSignals.length + ' ' + (ui.signalsWithImpact || 'signals with prediction impact') + '
';
for (const e of impactfulSignals) {
html += '
';
html += '
' + esc(e.title.slice(0, 80)) + '
';
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 += '
';
}
html += '
';
}
} else {
html += '
' + (ui.noSignals || 'No match signals detected yet.') + '
';
}
html += '
'; /* end signals card */
/* ── Module 4: Tournament Context (folded) ── */
if (ctxEvents.length > 0) {
html += '';
html += '
';
html += '🌐 ';
html += '' + (ui.tournamentContext || 'Tournament Context') + ' ';
html += '' + ctxEvents.length + ' items ';
html += '▶ ';
html += '
';
html += '
';
for (const e of ctxEvents) {
html += '
';
html += '
';
html += '
' + esc(e.title?.slice(0, 100)) + '
';
if (e.source) html += '
' + esc(e.source) + '
';
html += '
';
}
html += '
';
}
/* ── Module 4: Prediction History ── */
html += '';
html += '
';
html += '\uD83D\uDCC8 ';
html += '' + (ui.predictionEvolution || 'Prediction Evolution') + ' ';
html += '' + (snapshots?.length || 0) + ' snapshots ';
html += '
';
if (snapshots && snapshots.length > 0) {
html += '
';
const reversed = [...snapshots].reverse(); // oldest first
for (let i = 0; i < reversed.length; i++) {
const s = reversed[i];
const prev = i > 0 ? reversed[i - 1] : null;
const contributors = s.contributors_json ? JSON.parse(s.contributors_json) : [];
const hRaw = s.home_win || 0, dRaw = s.draw || 0, aRaw = s.away_win || 0;
// Normalize: API may return fractions (0-1) or percentages (0-100)
const scale = (hRaw + dRaw + aRaw) < 3 ? 100 : 1;
const hV = hRaw * scale, dV = dRaw * scale, aV = aRaw * scale;
const total = Math.max(hV + dV + aV, 100);
const hW = (hV / total * 100).toFixed(0);
const dW = (dV / total * 100).toFixed(0);
const aW = (aV / total * 100).toFixed(0);
html += '
';
html += '
' + (s.created_at ? s.created_at.slice(11, 16) : '') + '
';
html += '
';
html += ' ';
html += ' ';
html += ' ';
html += '
';
html += '
';
html += 'H ' + hV.toFixed(1) + '% ';
html += 'D ' + dV.toFixed(1) + '% ';
html += 'A ' + aV.toFixed(1) + '% ';
if (prev) {
const hDiff = hV - (prev.home_win || 0) * (scale || 1);
const dDiff = dV - (prev.draw || 0) * (scale || 1);
const aDiff = aV - (prev.away_win || 0) * (scale || 1);
if (Math.abs(hDiff) > 0.1 || Math.abs(dDiff) > 0.1 || Math.abs(aDiff) > 0.1) {
html += '';
if (Math.abs(hDiff) > 0.1) html += 'H' + (hDiff > 0 ? '+' : '') + hDiff.toFixed(1) + '% ';
if (Math.abs(dDiff) > 0.1) html += 'D' + (dDiff > 0 ? '+' : '') + dDiff.toFixed(1) + '% ';
if (Math.abs(aDiff) > 0.1) html += 'A' + (aDiff > 0 ? '+' : '') + aDiff.toFixed(1) + '%';
html += ' ';
}
}
html += '
';
html += '
';
// Contributors
if (contributors.length > 0) {
html += '
';
html += '' + (ui.drivenBy || 'Driven by: ') + ' ';
for (let ci = 0; ci < contributors.length; ci++) {
const c = contributors[ci];
const stateIcon = STATE_ICONS[c.type] || '';
const confPct = c.confidence_weight ? ' · ' + (c.confidence_weight * 100).toFixed(0) + '% conf' : '';
html += '';
html += stateIcon + ' ' + esc(c.title.slice(0, 40)) + ' · ' + (c.weight * 100).toFixed(0) + '%';
if (c.confidence_weight) {
html += '(' + (c.confidence_weight * 100).toFixed(0) + '% conf) ';
}
html += ' ';
}
html += '
';
}
}
html += '
';
} else {
html += '
' + (ui.noSnapshots || 'No prediction history yet — snapshots are recorded when events impact predictions.') + '
';
}
html += '
'; /* end prediction history card */
/* ── Confidence Timeline placeholder (filled async via API) ── */
html += '
';
/* ── Module 5: Sources ── */
html += '';
html += '
';
html += '\uD83D\uDCD6 ';
html += '' + (ui.sources || 'Sources') + ' ';
html += '
';
if (sources.length > 0) {
html += '
';
for (const s of sources) {
html += (s.url
? '
' + esc(s.source) + ''
: '
' + esc(s.source) + '');
}
html += '
';
} else {
html += '
' + (ui.noSources || 'No sources available.') + '
';
}
html += '
'; /* end sources card */
document.getElementById('app').innerHTML = html;
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
load();
})();
// ── Feedback Widget + Scroll Tracking ──
(function(){
var matchId = (window.__PRELOADED_DATA__ && window.__PRELOADED_DATA__.match && window.__PRELOADED_DATA__.match.id) || null;
if (!matchId) return;
var ui = window._matchUI || {};
// Create feedback widget
var fb = document.createElement('div');
fb.id = 'feedback-widget';
fb.innerHTML = '' + (ui.feedbackQuestion || 'Was this explanation useful?') + ' 👍 👎
';
document.querySelector('.page')?.appendChild(fb);
var sent = false;
function sendFeedback(type) {
if (sent) return;
sent = true;
fetch('/api/feedback', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ match_id: matchId, feedback_type: type, page_url: location.href })
}).catch(function(){});
var btns = fb.querySelectorAll('.fb-btn');
btns.forEach(function(b){ b.disabled = true; b.style.opacity = '0.5'; });
fb.querySelector('.fb-q').textContent = type === 'useful' ? (ui.thanksUseful || 'Thanks for your feedback! 👍') : (ui.thanksImprove || "Thanks — we'll improve! 👎");
if (typeof gtag !== 'undefined') gtag('event', 'feedback', { event_category: 'engagement', event_label: type, match_id: matchId });
}
fb.querySelector('.fb-yes').onclick = function(){ sendFeedback('useful'); };
fb.querySelector('.fb-no').onclick = function(){ sendFeedback('not_useful'); };
// Scroll depth tracking via IntersectionObserver
var depths = {};
var sections = document.querySelectorAll('h2');
var observer = new IntersectionObserver(function(entries){
entries.forEach(function(e){
if (e.isIntersecting) {
var text = e.target.textContent.trim();
if (!depths[text]) {
depths[text] = true;
// Send to GA
if (typeof gtag !== 'undefined') gtag('event', 'scroll_depth', { event_category: 'engagement', event_label: text, match_id: matchId });
// Also send to our analytics endpoint
fetch('/api/scroll', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ match_id: matchId, section_label: text })
}).catch(function(){});
}
}
});
}, { threshold: 0.5 });
sections.forEach(function(s){ observer.observe(s); });
// Copy link / Share button
var share = document.createElement('div');
share.id = 'share-btn';
share.innerHTML = '🔗 ' + (ui.share || 'Share') + ' ';
share.querySelector('#share-link-btn').onclick = function(){
var btn = this;
navigator.clipboard.writeText(location.href).then(function(){
btn.textContent = (ui.copiedLink || '📋 Copied!');
setTimeout(function(){ btn.textContent = '🔗 ' + (ui.share || 'Share'); }, 2000);
});
};
fb.querySelector('.fb-inner').appendChild(share);
})();
} // end _mainInit
// Poll until _matchUI is ready, then run main init
(function poll() {
if (window._matchUIReady) { _mainInit(); }
else { setTimeout(poll, 15); }
})();