Spaces:
Running
Running
| <html lang="zh"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>世界杯 2026 · Gemma 4 赛果预测</title> | |
| <meta name="description" content="用 Gemma 4 预测世界杯 2026 每场比赛的比分与胜平负概率,Fast Gemma Challenge 展示项目。"> | |
| <meta property="og:title" content="⚽️ World Cup 2026 · Gemma 4 Predictions"> | |
| <meta property="og:description" content="AI-predicted scores and win/draw/lose probabilities for every group-stage match."> | |
| <meta property="og:type" content="website"> | |
| <meta property="og:url" content="https://lazyxcode-worldcup2026-gemma.static.hf.space"> | |
| <meta name="twitter:card" content="summary"> | |
| <script async src="https://www.googletagmanager.com/gtag/js?id=G-KLCFPG9NP7"></script> | |
| <script> | |
| window.dataLayer = window.dataLayer || []; | |
| function gtag(){dataLayer.push(arguments);} | |
| gtag('js', new Date()); | |
| gtag('config', 'G-KLCFPG9NP7'); | |
| </script> | |
| <style> | |
| :root { | |
| --bg: #f4f6f9; --card: #ffffff; --line: #e6eaf0; | |
| --text: #1f2937; --muted: #6b7280; | |
| --green: #16a34a; --green-soft: #dcfce7; | |
| --amber: #d97706; --amber-soft: #fef3c7; | |
| --red: #dc2626; --red-soft: #fee2e2; | |
| } | |
| * { box-sizing: border-box; margin: 0; } | |
| body { background: var(--bg); color: var(--text); font: 15px/1.55 -apple-system, "PingFang SC", "Segoe UI", sans-serif; } | |
| header { background: linear-gradient(135deg, #15803d, #16a34a 55%, #4ade80); color: #fff; padding: 44px 16px 56px; text-align: center; position: relative; } | |
| header h1 { font-size: 30px; letter-spacing: 1px; } | |
| header p { margin-top: 8px; opacity: .9; font-size: 13px; } | |
| .lang { position: absolute; top: 14px; right: 14px; background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.45); | |
| color: #fff; font: 600 13px/1 inherit; padding: 7px 14px; border-radius: 99px; cursor: pointer; } | |
| .lang:hover { background: rgba(255,255,255,.3); } | |
| .wrap { max-width: 920px; margin: -28px auto 60px; padding: 0 16px; } | |
| .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); gap: 16px; } | |
| @media (max-width: 480px) { | |
| header { padding: 36px 12px 44px; } | |
| header h1 { font-size: 22px; } | |
| .card { padding: 14px; border-radius: 12px; } | |
| .team { font-size: 13px; gap: 6px; } | |
| .card .team span { white-space: normal; line-height: 1.25; hyphens: auto; overflow-wrap: anywhere; } | |
| .card .team.away { text-align: right; } | |
| .teams img { width: 20px; } | |
| .score { font-size: 18px; padding: 2px 10px; } | |
| .meta { flex-wrap: wrap; gap: 4px; } | |
| } | |
| .card { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px 20px; | |
| box-shadow: 0 1px 3px rgba(16,24,40,.06); } | |
| .meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-bottom: 12px; } | |
| .badge { background: var(--green-soft); color: var(--green); font-weight: 600; padding: 1px 8px; border-radius: 99px; } | |
| .teams { display: flex; align-items: center; gap: 10px; } | |
| .team { flex: 1; display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 16px; min-width: 0; } | |
| .team span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .team.away { justify-content: flex-end; } | |
| .teams img { width: 26px; border-radius: 3px; box-shadow: 0 0 0 1px var(--line); flex-shrink: 0; } | |
| .score { font-size: 22px; font-weight: 800; color: var(--green); background: var(--green-soft); | |
| padding: 3px 14px; border-radius: 10px; white-space: nowrap; } | |
| .bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin: 14px 0 8px; background: var(--line); } | |
| .bar .w { background: var(--green); } .bar .d { background: #f59e0b; } .bar .l { background: #ef4444; } | |
| .probs { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); gap: 8px; } | |
| .probs b { font-size: 13px; } | |
| .probs .w b { color: var(--green); } .probs .d b { color: var(--amber); } .probs .l b { color: var(--red); } | |
| .reason { margin-top: 12px; font-size: 13px; color: var(--muted); background: var(--bg); | |
| border-radius: 10px; padding: 8px 12px; } | |
| .empty { text-align: center; color: var(--muted); padding: 80px 0; } | |
| .share { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin: 28px 0 8px; } | |
| .share button, .share a { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; | |
| border: 1px solid var(--line); background: var(--card); color: var(--text); text-decoration: none; | |
| font: 600 13px/1 inherit; padding: 9px 16px; border-radius: 99px; | |
| box-shadow: 0 1px 2px rgba(16,24,40,.05); transition: transform .1s; } | |
| .share button:hover, .share a:hover { transform: translateY(-1px); border-color: var(--green); } | |
| .toast { position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%); | |
| background: #1f2937; color: #fff; font-size: 13px; padding: 10px 18px; border-radius: 99px; | |
| opacity: 0; pointer-events: none; transition: opacity .25s; z-index: 9; } | |
| .toast.show { opacity: 1; } | |
| footer { text-align: center; font-size: 12px; color: var(--muted); padding-bottom: 40px; } | |
| footer a { color: var(--green); } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <button class="lang" id="langBtn"></button> | |
| <h1 id="title"></h1> | |
| <p id="sub"></p> | |
| </header> | |
| <div class="wrap"> | |
| <div class="grid" id="list"></div> | |
| <div class="empty" id="empty" hidden></div> | |
| <div class="share" id="share"></div> | |
| </div> | |
| <footer id="foot"></footer> | |
| <div class="toast" id="toast"></div> | |
| <script src="predictions.js"></script> | |
| <script> | |
| // ---- 多语言资源 ---- | |
| const I18N = { | |
| zh: { | |
| title: "⚽️ 世界杯 2026 赛果预测", | |
| sub: g => `Gemma 4 驱动 · 数据来源 worldcup26.ir${g ? ` · 生成于 ${g}` : ""}`, | |
| badge: p => `${p.group} 组 · 第 ${p.matchday} 轮`, | |
| gameNo: p => `第 ${p.game_id} 场 · ${p.date}`, | |
| home: "主胜", draw: "平局", away: "客胜", | |
| empty: "没有预测数据 —— 先运行 batch.py 生成 predictions.js", | |
| copied: "文案已复制,去粘贴分享吧 ✅", copyFail: "复制失败,请手动复制", | |
| copyBtn: "📋 复制文案", sysShare: "📤 分享…", xShare: "𝕏 分享", | |
| snap: "🖼 生成分享图", snapMaking: "正在生成分享图…", snapDone: "分享图已保存 ✅", | |
| imgSub: (n, md) => `小组赛第 ${md} 轮 · ${n} 场 AI 预测`, | |
| foot: `Fast Gemma Challenge 展示项目 · 预测仅供娱乐 · <a href="https://huggingface.co/spaces/gemma-challenge/gemma-dashboard" target="_blank">挑战赛排行榜</a>`, | |
| langBtn: "English", | |
| reason: p => p.reason_zh || p.reason, | |
| teamName: en => TEAM_ZH[en] || en, | |
| shareText: (n, p) => `⚽️ 我用 Gemma 4 把世界杯 2026 小组赛 ${n} 场比赛全预测了一遍:${p ? `揭幕战 AI 押 ${TEAM_ZH[p.home] || p.home} ${p.score} ${TEAM_ZH[p.away] || p.away}(胜率 ${Math.round(p.win_prob * 100)}%)!` : ""}来看看 AI 的眼光准不准 👉 `, | |
| }, | |
| en: { | |
| title: "⚽️ World Cup 2026 Predictions", | |
| sub: g => `Powered by Gemma 4 · Data from worldcup26.ir${g ? ` · Generated ${g}` : ""}`, | |
| badge: p => `Group ${p.group} · MD ${p.matchday}`, | |
| gameNo: p => `Match ${p.game_id} · ${p.date}`, | |
| home: "Home win", draw: "Draw", away: "Away win", | |
| empty: "No predictions yet — run batch.py to generate predictions.js", | |
| copied: "Copied! Go paste & share ✅", copyFail: "Copy failed, please copy manually", | |
| copyBtn: "📋 Copy text", sysShare: "📤 Share…", xShare: "𝕏 Post", | |
| snap: "🖼 Make image", snapMaking: "Generating image…", snapDone: "Image saved ✅", | |
| imgSub: (n, md) => `Group stage · Matchday ${md} · ${n} AI predictions`, | |
| foot: `A Fast Gemma Challenge showcase · For fun only · <a href="https://huggingface.co/spaces/gemma-challenge/gemma-dashboard" target="_blank">Leaderboard</a>`, | |
| langBtn: "中文", | |
| reason: p => p.reason, | |
| teamName: en => en, | |
| shareText: (n, p) => `⚽️ I asked Gemma 4 to predict all ${n} World Cup 2026 group-stage openers. ${p ? `It picks ${p.home} ${p.score} ${p.away} in the opener (${Math.round(p.win_prob * 100)}% win)! ` : ""}See the AI's calls 👉 `, | |
| }, | |
| }; | |
| const TEAM_ZH = { | |
| "Mexico": "墨西哥", "South Africa": "南非", "South Korea": "韩国", "Czech Republic": "捷克", | |
| "Canada": "加拿大", "Bosnia and Herzegovina": "波黑", "United States": "美国", "Paraguay": "巴拉圭", | |
| "Haiti": "海地", "Scotland": "苏格兰", "Australia": "澳大利亚", "Turkey": "土耳其", | |
| "Brazil": "巴西", "Morocco": "摩洛哥", "Qatar": "卡塔尔", "Switzerland": "瑞士", | |
| "Ivory Coast": "科特迪瓦", "Ecuador": "厄瓜多尔", "Germany": "德国", "Curaçao": "库拉索", | |
| "Netherlands": "荷兰", "Japan": "日本", "Sweden": "瑞典", "Tunisia": "突尼斯", | |
| "Iran": "伊朗", "New Zealand": "新西兰", "Spain": "西班牙", "Cape Verde": "佛得角", | |
| "Belgium": "比利时", "Egypt": "埃及", "Saudi Arabia": "沙特阿拉伯", "Uruguay": "乌拉圭", | |
| "France": "法国", "Senegal": "塞内加尔", "Iraq": "伊拉克", "Norway": "挪威", | |
| "Argentina": "阿根廷", "Algeria": "阿尔及利亚", "Austria": "奥地利", "Jordan": "约旦", | |
| "Portugal": "葡萄牙", "Democratic Republic of the Congo": "刚果(金)", "England": "英格兰", "Croatia": "克罗地亚", | |
| "Uzbekistan": "乌兹别克斯坦", "Colombia": "哥伦比亚", "Ghana": "加纳", "Panama": "巴拿马", | |
| }; | |
| const SITE = "https://lazyxcode-worldcup2026-gemma.static.hf.space"; | |
| const preds = window.PREDICTIONS || []; | |
| let lang = localStorage.getItem("wc-lang") || | |
| ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"); | |
| function render() { | |
| const t = I18N[lang]; | |
| document.documentElement.lang = lang; | |
| document.title = t.title.replace("⚽️ ", ""); | |
| document.getElementById("langBtn").textContent = t.langBtn; | |
| document.getElementById("title").textContent = t.title; | |
| document.getElementById("sub").textContent = t.sub(window.GENERATED_AT); | |
| document.getElementById("empty").textContent = t.empty; | |
| document.getElementById("empty").hidden = preds.length > 0; | |
| document.getElementById("foot").innerHTML = t.foot; | |
| const pct = x => Math.round(x * 100) + "%"; | |
| document.getElementById("list").innerHTML = preds.map(p => ` | |
| <div class="card"> | |
| <div class="meta"><span class="badge">${t.badge(p)}</span><span>${t.gameNo(p)}</span></div> | |
| <div class="teams"> | |
| <div class="team">${p.home_flag ? `<img src="${p.home_flag}" alt="">` : ""}<span>${t.teamName(p.home)}</span></div> | |
| <div class="score">${p.score}</div> | |
| <div class="team away"><span>${t.teamName(p.away)}</span>${p.away_flag ? `<img src="${p.away_flag}" alt="">` : ""}</div> | |
| </div> | |
| <div class="bar"> | |
| <div class="w" style="flex:${p.win_prob}"></div> | |
| <div class="d" style="flex:${p.draw_prob}"></div> | |
| <div class="l" style="flex:${p.lose_prob}"></div> | |
| </div> | |
| <div class="probs"> | |
| <span class="w"><b>${pct(p.win_prob)}</b> ${t.home}</span> | |
| <span class="d"><b>${pct(p.draw_prob)}</b> ${t.draw}</span> | |
| <span class="l"><b>${pct(p.lose_prob)}</b> ${t.away}</span> | |
| </div> | |
| <div class="reason">💡 ${t.reason(p)}</div> | |
| </div>`).join(""); | |
| const copy = t.shareText(preds.length, preds[0]) + SITE; | |
| const enc = encodeURIComponent; | |
| const links = [ | |
| [t.xShare, `https://twitter.com/intent/tweet?text=${enc(copy)}`], | |
| ["微博", `https://service.weibo.com/share/share.php?url=${enc(SITE)}&title=${enc(copy)}`], | |
| ["Telegram", `https://t.me/share/url?url=${enc(SITE)}&text=${enc(copy)}`], | |
| ["WhatsApp", `https://wa.me/?text=${enc(copy)}`], | |
| ]; | |
| document.getElementById("share").innerHTML = | |
| `<button id="imgBtn">${t.snap}</button>` + | |
| links.map(([n, u]) => `<a href="${u}" target="_blank" rel="noopener">${n}</a>`).join("") + | |
| `<button id="copyBtn">${t.copyBtn}</button>` + | |
| (navigator.share ? `<button id="sysShare">${t.sysShare}</button>` : ""); | |
| document.getElementById("imgBtn").onclick = shareImage; | |
| document.getElementById("copyBtn").onclick = async () => { | |
| try { await navigator.clipboard.writeText(copy); toast(t.copied); } | |
| catch { toast(t.copyFail); } | |
| }; | |
| const sys = document.getElementById("sysShare"); | |
| if (sys) sys.onclick = () => navigator.share({ title: t.title, text: copy, url: SITE }).catch(() => {}); | |
| } | |
| // ---- 分享卡片图片 (Canvas) ---- | |
| const loadImg = src => new Promise(res => { | |
| const i = new Image(); i.crossOrigin = "anonymous"; | |
| i.onload = () => res(i); i.onerror = () => res(null); i.src = src; | |
| }); | |
| function rr(ctx, x, y, w, h, r) { | |
| ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.closePath(); | |
| } | |
| const FONT = "-apple-system, 'PingFang SC', sans-serif"; | |
| function fitText(ctx, text, maxW, size, minSize) { | |
| let s = size; | |
| while (s > minSize) { | |
| ctx.font = `bold ${s}px ${FONT}`; | |
| if (ctx.measureText(text).width <= maxW) return text; | |
| s -= 2; | |
| } | |
| ctx.font = `bold ${minSize}px ${FONT}`; | |
| let txt = text; | |
| while (txt.length > 1 && ctx.measureText(txt + "…").width > maxW) txt = txt.slice(0, -1); | |
| return txt === text ? text : txt + "…"; | |
| } | |
| async function makeSummaryCanvas() { | |
| const t = I18N[lang]; | |
| const W = 1080, rowH = 84, gap = 14, top = 300; | |
| const H = top + preds.length * (rowH + gap) + 140; | |
| const c = document.createElement("canvas"); c.width = W; c.height = H; | |
| const ctx = c.getContext("2d"); | |
| // 背景 + 头部渐变 | |
| ctx.fillStyle = "#f4f6f9"; ctx.fillRect(0, 0, W, H); | |
| const grad = ctx.createLinearGradient(0, 0, W, 250); | |
| grad.addColorStop(0, "#15803d"); grad.addColorStop(.55, "#16a34a"); grad.addColorStop(1, "#4ade80"); | |
| ctx.fillStyle = grad; ctx.fillRect(0, 0, W, 250); | |
| ctx.fillStyle = "#fff"; ctx.textAlign = "center"; | |
| ctx.font = `bold 54px ${FONT}`; ctx.fillText(t.title, W / 2, 115); | |
| ctx.font = `30px ${FONT}`; ctx.globalAlpha = .92; | |
| ctx.fillText(t.imgSub(preds.length, preds[0]?.matchday || 1), W / 2, 180); ctx.globalAlpha = 1; | |
| // 预加载全部国旗 | |
| const flagUrls = [...new Set(preds.flatMap(p => [p.home_flag, p.away_flag]).filter(Boolean))]; | |
| const flagImgs = {}; | |
| await Promise.all(flagUrls.map(async u => { flagImgs[u] = await loadImg(u); })); | |
| const drawFlag = (u, x, y) => { | |
| const img = flagImgs[u]; | |
| if (img) ctx.drawImage(img, x, y, 48, 32); | |
| else { ctx.fillStyle = "#e6eaf0"; rr(ctx, x, y, 48, 32, 4); ctx.fill(); } | |
| ctx.strokeStyle = "rgba(0,0,0,.08)"; ctx.lineWidth = 1.5; | |
| rr(ctx, x, y, 48, 32, 3); ctx.stroke(); | |
| }; | |
| preds.forEach((p, i) => { | |
| const ry = top + i * (rowH + gap); | |
| const cy = ry + 40; // 文本/旗帜的垂直中心 | |
| ctx.fillStyle = "#fff"; rr(ctx, 60, ry, W - 120, rowH, 16); ctx.fill(); | |
| ctx.strokeStyle = "#e6eaf0"; ctx.lineWidth = 1.5; rr(ctx, 60, ry, W - 120, rowH, 16); ctx.stroke(); | |
| // 小组徽章 | |
| ctx.fillStyle = "#dcfce7"; rr(ctx, 84, cy - 20, 52, 40, 12); ctx.fill(); | |
| ctx.fillStyle = "#16a34a"; ctx.font = `bold 26px ${FONT}`; ctx.textAlign = "center"; | |
| ctx.fillText(p.group, 110, cy + 9); | |
| // 主队 (左) / 客队 (右) | |
| drawFlag(p.home_flag, 160, cy - 16); | |
| ctx.fillStyle = "#1f2937"; ctx.textAlign = "left"; | |
| ctx.fillText(fitText(ctx, t.teamName(p.home), 230, 28, 20), 222, cy + 9); | |
| drawFlag(p.away_flag, W - 208, cy - 16); | |
| ctx.fillStyle = "#1f2937"; ctx.textAlign = "right"; | |
| ctx.fillText(fitText(ctx, t.teamName(p.away), 230, 28, 20), W - 222, cy + 9); | |
| // 比分 | |
| ctx.fillStyle = "#dcfce7"; rr(ctx, W / 2 - 60, cy - 24, 120, 48, 12); ctx.fill(); | |
| ctx.fillStyle = "#16a34a"; ctx.font = `bold 30px ${FONT}`; ctx.textAlign = "center"; | |
| ctx.fillText(p.score, W / 2, cy + 10); | |
| // 概率细条 | |
| const bx = 160, bw = W - 368, by = ry + 64, bh = 6; | |
| ctx.save(); rr(ctx, bx, by, bw, bh, 3); ctx.clip(); | |
| const wW = bw * p.win_prob, dW = bw * p.draw_prob; | |
| ctx.fillStyle = "#16a34a"; ctx.fillRect(bx, by, wW, bh); | |
| ctx.fillStyle = "#f59e0b"; ctx.fillRect(bx + wW, by, dW, bh); | |
| ctx.fillStyle = "#ef4444"; ctx.fillRect(bx + wW + dW, by, bw - wW - dW, bh); | |
| ctx.restore(); | |
| }); | |
| // 页脚 | |
| ctx.textAlign = "center"; ctx.fillStyle = "#9ca3af"; ctx.font = `26px ${FONT}`; | |
| ctx.fillText(SITE.replace("https://", ""), W / 2, H - 80); | |
| ctx.fillStyle = "#16a34a"; ctx.font = `bold 26px ${FONT}`; | |
| ctx.fillText("⚡ Fast Gemma Challenge", W / 2, H - 38); | |
| return c; | |
| } | |
| async function shareImage() { | |
| const t = I18N[lang]; | |
| toast(t.snapMaking); | |
| const canvas = await makeSummaryCanvas(); | |
| const blob = await new Promise(res => canvas.toBlob(res, "image/png")); | |
| const file = new File([blob], `wc2026-md${preds[0]?.matchday || 1}-${lang}.png`, { type: "image/png" }); | |
| if (navigator.canShare && navigator.canShare({ files: [file] })) { | |
| try { await navigator.share({ files: [file], title: t.title, url: SITE }); return; } | |
| catch (e) { if (e.name === "AbortError") return; } | |
| } | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); | |
| URL.revokeObjectURL(a.href); | |
| toast(t.snapDone); | |
| } | |
| window.__makeSummaryCanvas = makeSummaryCanvas; // 调试用 | |
| const toast = msg => { | |
| const el = document.getElementById("toast"); | |
| el.textContent = msg; el.classList.add("show"); | |
| setTimeout(() => el.classList.remove("show"), 1800); | |
| }; | |
| document.getElementById("langBtn").onclick = () => { | |
| lang = lang === "zh" ? "en" : "zh"; | |
| localStorage.setItem("wc-lang", lang); | |
| render(); | |
| }; | |
| render(); | |
| </script> | |
| </body> | |
| </html> | |