Phase 23: formation pitch view, season zone bar, season history UI
- mapper.rs: add GET /division/history route; bump exact routes assertion to ≥56 - dashboard: formation pitch view in Squad tab with CSS pitch markings, player chips positioned by line, bench row, Grid/Pitch toggle button; season zone bar in Division tab (relegation/safe/promotion colour bands + current points marker); season history list from GET /division/history; division response now includes promotion_pts, relegation_pts, season_length for richer display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+223
-13
@@ -244,6 +244,40 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
.event-status.on { color: #3fb950; }
|
||||
.event-status.off { color: #8b949e; }
|
||||
|
||||
/* ── Formation Pitch ── */
|
||||
.pitch-wrap { position:relative; width:100%; max-width:380px; aspect-ratio:3/4.2; background:#16502e; border-radius:10px; overflow:hidden; border:1px solid #30363d; }
|
||||
.pitch-markings { position:absolute; inset:0; pointer-events:none; }
|
||||
.pitch-centerline { position:absolute; left:8%; right:8%; top:50%; height:1px; background:rgba(255,255,255,0.18); }
|
||||
.pitch-center-circle { position:absolute; left:50%; top:50%; width:18%; aspect-ratio:1; border-radius:50%; border:1px solid rgba(255,255,255,0.18); transform:translate(-50%,-50%); }
|
||||
.pitch-penalty-top { position:absolute; left:27%; right:27%; top:3%; height:18%; border:1px solid rgba(255,255,255,0.18); border-bottom:none; }
|
||||
.pitch-penalty-bot { position:absolute; left:27%; right:27%; bottom:3%; height:18%; border:1px solid rgba(255,255,255,0.18); border-top:none; }
|
||||
.pitch-border { position:absolute; inset:3%; border:1px solid rgba(255,255,255,0.18); }
|
||||
.pitch-player { position:absolute; transform:translate(-50%,-50%); text-align:center; z-index:1; }
|
||||
.pitch-chip { background:rgba(22,27,34,0.92); border:1px solid #30363d; border-radius:5px; padding:3px 5px; min-width:48px; }
|
||||
.pitch-chip-ovr { font-size:0.82rem; font-weight:800; color:#f5a623; line-height:1.1; }
|
||||
.pitch-chip-name { font-size:0.55rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:56px; color:#c9d1d9; }
|
||||
.pitch-chip-pos { font-size:0.5rem; color:#8b949e; text-transform:uppercase; }
|
||||
.pitch-chip.empty-chip { opacity:0.4; border-style:dashed; }
|
||||
.pitch-toggle { display:flex; gap:6px; margin-bottom:10px; }
|
||||
.pitch-toggle button { padding:4px 12px; font-size:0.78rem; border-radius:12px; border:1px solid #30363d; background:#21262d; color:#8b949e; cursor:pointer; }
|
||||
.pitch-toggle button.active { background:#f5a623; color:#000; border-color:#f5a623; }
|
||||
|
||||
/* ── Season Zone Bar ── */
|
||||
.season-zone-bar { position:relative; height:28px; border-radius:4px; overflow:hidden; margin:12px 0; background:#21262d; }
|
||||
.szb-relegate { position:absolute; left:0; height:100%; background:#7f1d1d; }
|
||||
.szb-safe { position:absolute; height:100%; background:#21262d; }
|
||||
.szb-promote { position:absolute; right:0; height:100%; background:#14532d; }
|
||||
.szb-marker { position:absolute; top:0; bottom:0; width:3px; background:#f5a623; transform:translateX(-50%); border-radius:2px; }
|
||||
.szb-label { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.7); }
|
||||
.szb-zone-label { position:absolute; font-size:0.6rem; color:rgba(255,255,255,0.5); top:50%; transform:translateY(-50%); }
|
||||
|
||||
/* ── Season History ── */
|
||||
.season-hist-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid #21262d; font-size:0.82rem; }
|
||||
.hist-result-badge { width:24px; height:24px; border-radius:4px; font-weight:800; font-size:0.7rem; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
|
||||
.hist-result-promoted { background:#14532d; color:#4ade80; }
|
||||
.hist-result-maintained { background:#374151; color:#d1d5db; }
|
||||
.hist-result-relegated { background:#7f1d1d; color:#f87171; }
|
||||
|
||||
/* ── Card Detail Modal ── */
|
||||
.cd-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.82); z-index:300; align-items:center; justify-content:center; }
|
||||
.cd-overlay.open { display:flex; }
|
||||
@@ -456,6 +490,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
<div>
|
||||
<h2>Season Progress</h2>
|
||||
<div id="season-progress"><span class="loading">Loading…</span></div>
|
||||
<div style="margin-top:24px">
|
||||
<h2>Season History</h2>
|
||||
<div id="season-history"><span class="loading">Loading…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -628,10 +666,15 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
</div>
|
||||
<!-- View mode -->
|
||||
<div id="squad-view-mode">
|
||||
<div id="squad-header"></div>
|
||||
<div class="pitch-toggle">
|
||||
<button id="sq-grid-btn" class="active" onclick="setSquadDisplayMode('grid')">Grid</button>
|
||||
<button id="sq-pitch-btn" onclick="setSquadDisplayMode('pitch')">Pitch</button>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<div id="squad-header"></div>
|
||||
<div id="squad-grid" class="squad-grid"><span class="loading">Loading…</span></div>
|
||||
<div id="squad-pitch" style="display:none"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Chemistry</h2>
|
||||
@@ -1161,34 +1204,93 @@ async function loadDivision() {
|
||||
try {
|
||||
const d = await api('GET', '/division');
|
||||
const rec = d.record || {};
|
||||
const ptsMax = 30; // 10 matches × 3 pts
|
||||
const ptsPct = Math.min(100, Math.round((d.season_points / ptsMax) * 100));
|
||||
const promoPct = d.pts_for_promotion > 0
|
||||
? Math.min(100, Math.round((d.season_points / d.pts_for_promotion) * 100)) : 100;
|
||||
const promoThresh = d.promotion_pts ?? 21;
|
||||
const relThresh = d.relegation_pts ?? 3;
|
||||
const seasonLen = d.season_length ?? 10;
|
||||
const ptsMax = seasonLen * 3;
|
||||
const pts = d.season_points ?? 0;
|
||||
const divLabel = d.division === 1 ? 'Top Flight' : d.division === 10 ? 'Amateur' : `Division ${d.division}`;
|
||||
|
||||
// Zone bar: total bar spans 0–ptsMax; rel zone 0–relThresh, promo zone promoThresh–ptsMax
|
||||
const relPct = Math.round((relThresh / ptsMax) * 100);
|
||||
const promoPct = Math.round((promoThresh / ptsMax) * 100);
|
||||
const safePct = promoPct - relPct;
|
||||
const markerPct = Math.min(100, Math.round((pts / ptsMax) * 100));
|
||||
|
||||
const zoneBar = `
|
||||
<div style="font-size:0.75rem;color:#8b949e;margin-bottom:4px;display:flex;justify-content:space-between">
|
||||
<span style="color:#f87171">Relegation zone ≤${relThresh}pts</span>
|
||||
<span>${pts} pts</span>
|
||||
<span style="color:#4ade80">Promotion ≥${promoThresh}pts</span>
|
||||
</div>
|
||||
<div class="season-zone-bar">
|
||||
<div class="szb-relegate" style="width:${relPct}%"></div>
|
||||
<div class="szb-safe" style="left:${relPct}%;width:${safePct}%"></div>
|
||||
<div class="szb-promote" style="width:${100-promoPct}%"></div>
|
||||
<div class="szb-marker" style="left:${markerPct}%"></div>
|
||||
<div class="szb-label">${pts} / ${ptsMax}</div>
|
||||
</div>
|
||||
<div style="font-size:0.68rem;color:#8b949e;display:flex;gap:12px;margin-top:2px">
|
||||
<span>● <span style="color:#f87171">Rel</span></span>
|
||||
<span>● <span style="color:#8b949e">Safe</span></span>
|
||||
<span>● <span style="color:#4ade80">Prom</span></span>
|
||||
<span style="margin-left:auto;color:#f5a623">▲ You</span>
|
||||
</div>`;
|
||||
|
||||
const zoneTxt = pts >= promoThresh && d.can_be_relegated !== false
|
||||
? '<span style="color:#4ade80;font-weight:700">Promotion zone ↑</span>'
|
||||
: pts <= relThresh && d.can_be_relegated
|
||||
? '<span style="color:#f87171;font-weight:700">Relegation zone ↓</span>'
|
||||
: '<span style="color:#8b949e">Safe zone</span>';
|
||||
|
||||
el('division-card').innerHTML = `
|
||||
<div class="division-num">${d.division}</div>
|
||||
<div class="division-label">Division · Season ${d.season_number}</div>
|
||||
<div class="division-label">${divLabel} · Season ${d.season_number}</div>
|
||||
<div class="record-row">
|
||||
<div class="record-cell w"><div class="val">${rec.wins ?? 0}</div><div class="lbl">W</div></div>
|
||||
<div class="record-cell d"><div class="val">${rec.draws ?? 0}</div><div class="lbl">D</div></div>
|
||||
<div class="record-cell l"><div class="val">${rec.losses ?? 0}</div><div class="lbl">L</div></div>
|
||||
</div>
|
||||
<div style="margin-top:12px;font-size:0.82rem">${zoneTxt}</div>
|
||||
`;
|
||||
el('season-progress').innerHTML = `
|
||||
<div class="info-row"><span class="lbl">Points</span><strong>${d.season_points}</strong></div>
|
||||
<div class="info-row"><span class="lbl">For promotion</span>${d.pts_for_promotion} pts needed</div>
|
||||
<div class="progress-track" style="margin:8px 0 16px">
|
||||
<div class="progress-fill${promoPct >= 100 ? ' done' : ''}" style="width:${promoPct}%"></div>
|
||||
</div>
|
||||
<div class="info-row"><span class="lbl">Matches played</span>${d.matches_played} / 10</div>
|
||||
<div class="info-row"><span class="lbl">Remaining</span>${d.matches_remaining}</div>
|
||||
${zoneBar}
|
||||
<div class="info-row" style="margin-top:14px"><span class="lbl">Matches played</span><strong>${d.matches_played} / ${seasonLen}</strong></div>
|
||||
<div class="info-row"><span class="lbl">Remaining</span>${d.matches_remaining} matches</div>
|
||||
<div class="info-row"><span class="lbl">Started</span>${d.started_at ? new Date(d.started_at).toLocaleDateString() : '—'}</div>
|
||||
${d.promotion_achievable
|
||||
? `<div style="margin-top:8px;font-size:0.78rem;color:#4ade80">Promotion still achievable!</div>`
|
||||
: d.can_be_relegated && pts > relThresh
|
||||
? `<div style="margin-top:8px;font-size:0.78rem;color:#8b949e">Promotion out of reach — finish strong.</div>`
|
||||
: ''}
|
||||
`;
|
||||
} catch (e) {
|
||||
el('division-card').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
||||
el('season-progress').innerHTML = '';
|
||||
}
|
||||
|
||||
try {
|
||||
const h = await api('GET', '/division/history');
|
||||
const history = h.history || [];
|
||||
if (!history.length) {
|
||||
el('season-history').innerHTML = '<span class="empty">No completed seasons yet.</span>';
|
||||
} else {
|
||||
el('season-history').innerHTML = history.map(s => {
|
||||
const badgeCls = `hist-result-${s.result}`;
|
||||
const letter = s.result === 'promoted' ? '↑' : s.result === 'relegated' ? '↓' : '=';
|
||||
const divChg = s.result !== 'maintained'
|
||||
? ` → Div ${s.new_division}` : '';
|
||||
return `<div class="season-hist-row">
|
||||
<div class="hist-result-badge ${badgeCls}">${letter}</div>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600">Season ${s.season_number} · Div ${s.division}${divChg}</div>
|
||||
<div style="font-size:0.72rem;color:#8b949e">${s.wins}W ${s.draws}D ${s.losses}L · ${s.season_points}pts · +${s.coins_awarded.toLocaleString()}c</div>
|
||||
</div>
|
||||
<div style="font-size:0.7rem;color:#8b949e">${new Date(s.ended_at).toLocaleDateString()}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (_) { el('season-history').innerHTML = '<span class="empty">History unavailable.</span>'; }
|
||||
}
|
||||
|
||||
async function claimRivalsReward() {
|
||||
@@ -1744,6 +1846,107 @@ async function saveSettings() {
|
||||
|
||||
// ── Squad ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Formation Pitch View ─────────────────────────────────────────────────────
|
||||
|
||||
let _squadPitchData = null; // cache last squad for pitch toggle
|
||||
|
||||
const POS_GROUP = {
|
||||
GK: 0,
|
||||
SW: 1, CB: 1, RB: 1, LB: 1, RWB: 1, LWB: 1, WB: 1,
|
||||
DM: 2, CDM: 2, CM: 2, RM: 2, LM: 2,
|
||||
AM: 3, CAM: 3, RAM: 3, LAM: 3, CF: 3,
|
||||
RW: 4, LW: 4, RF: 4, LF: 4, ST: 4, SS: 4, FW: 4,
|
||||
};
|
||||
|
||||
function pitchPosGroup(posLabel) {
|
||||
if (!posLabel) return 2;
|
||||
const up = posLabel.toUpperCase().trim();
|
||||
return POS_GROUP[up] ?? (up.startsWith('G') ? 0 : up.includes('B') || up === 'SW' ? 1 : up.startsWith('S') || up.startsWith('F') ? 4 : 2);
|
||||
}
|
||||
|
||||
function renderPitch(players, formation, playerChems) {
|
||||
const starters = players.filter(p => !p.is_on_bench);
|
||||
const bench = players.filter(p => p.is_on_bench);
|
||||
const frmParts = (formation || '4-4-2').split('-').map(Number);
|
||||
|
||||
// Build lines: line 0 = GK, then one line per formation group
|
||||
const lines = [[], ...frmParts.map(() => [])];
|
||||
starters.forEach(p => {
|
||||
const g = pitchPosGroup(p.position_label || p.card?.position);
|
||||
// Map role group (0=GK,1=DEF,2=MID,3=CAM,4=ATT) → line index
|
||||
// GK → 0; otherwise distribute across lines 1..frmParts.length
|
||||
if (g === 0) { lines[0].push(p); return; }
|
||||
// For 2-group formations map to nearest line; for 3+ map linearly
|
||||
const mapped = Math.min(Math.round((g / 4) * (frmParts.length - 1)) + 1, frmParts.length);
|
||||
lines[mapped].push(p);
|
||||
});
|
||||
|
||||
const totalLines = lines.length;
|
||||
const pitchMarkings = `
|
||||
<div class="pitch-markings">
|
||||
<div class="pitch-border"></div>
|
||||
<div class="pitch-centerline"></div>
|
||||
<div class="pitch-center-circle"></div>
|
||||
<div class="pitch-penalty-top"></div>
|
||||
<div class="pitch-penalty-bot"></div>
|
||||
</div>`;
|
||||
|
||||
const chips = [];
|
||||
lines.forEach((linePlayers, lineIdx) => {
|
||||
if (!linePlayers.length) return;
|
||||
// y: GK at 90%, top line at 10% from top
|
||||
const yPct = 90 - (lineIdx / (totalLines - 1)) * 80;
|
||||
linePlayers.forEach((p, i) => {
|
||||
const xPct = linePlayers.length === 1 ? 50 : 8 + (i / (linePlayers.length - 1)) * 84;
|
||||
const card = p.card;
|
||||
const ovr = card?.overall ?? '?';
|
||||
const name = card?.name ? card.name.split(' ').pop() : '?';
|
||||
const pos = p.position_label || card?.position || '?';
|
||||
const pChem = (playerChems || {})[p.owned_card_id] ?? 0;
|
||||
const chemDot = pChem >= 7 ? '#3fb950' : pChem >= 4 ? '#d29922' : '#f85149';
|
||||
chips.push(`<div class="pitch-player" style="left:${xPct}%;top:${yPct}%">
|
||||
<div class="pitch-chip${lineIdx===0?' gk-chip':''}">
|
||||
<div style="width:6px;height:6px;border-radius:50%;background:${chemDot};margin:0 auto 1px"></div>
|
||||
<div class="pitch-chip-ovr">${ovr}</div>
|
||||
<div class="pitch-chip-name">${name}</div>
|
||||
<div class="pitch-chip-pos">${pos}</div>
|
||||
</div>
|
||||
</div>`);
|
||||
});
|
||||
});
|
||||
|
||||
let benchHtml = '';
|
||||
if (bench.length) {
|
||||
benchHtml = `<div style="margin-top:10px;padding:8px;background:#21262d;border-radius:6px">
|
||||
<div style="font-size:0.68rem;color:#8b949e;margin-bottom:6px;text-transform:uppercase">Bench</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
${bench.map(p => {
|
||||
const card = p.card;
|
||||
return `<div style="background:#161b22;border:1px solid #30363d;border-radius:5px;padding:4px 8px;font-size:0.72rem;text-align:center">
|
||||
<div style="font-weight:700;color:#f5a623">${card?.overall ?? '?'}</div>
|
||||
<div style="color:#8b949e">${p.position_label || card?.position || '?'}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="pitch-wrap">${pitchMarkings}${chips.join('')}</div>${benchHtml}`;
|
||||
}
|
||||
|
||||
let _sqDisplayMode = 'grid';
|
||||
function setSquadDisplayMode(mode) {
|
||||
_sqDisplayMode = mode;
|
||||
el('sq-grid-btn').className = mode === 'grid' ? 'active' : '';
|
||||
el('sq-pitch-btn').className = mode === 'pitch' ? 'active' : '';
|
||||
el('squad-grid').style.display = mode === 'grid' ? '' : 'none';
|
||||
el('squad-pitch').style.display = mode === 'pitch' ? '' : 'none';
|
||||
if (mode === 'pitch' && _squadPitchData) {
|
||||
const {players, formation, chems} = _squadPitchData;
|
||||
el('squad-pitch').innerHTML = renderPitch(players, formation, chems);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSquad() {
|
||||
try {
|
||||
const data = await api('GET', '/squad');
|
||||
@@ -1757,8 +1960,15 @@ async function loadSquad() {
|
||||
<span class="formation-badge">${squad?.formation || '—'}</span>
|
||||
</div>`;
|
||||
|
||||
// Cache for pitch toggle
|
||||
_squadPitchData = { players, formation: squad?.formation || '4-4-2', chems: chem.players || {} };
|
||||
if (_sqDisplayMode === 'pitch') {
|
||||
el('squad-pitch').innerHTML = renderPitch(players, squad?.formation, chem.players || {});
|
||||
}
|
||||
|
||||
if (!players.length) {
|
||||
el('squad-grid').innerHTML = '<span class="empty">No squad saved yet. Use /squad POST from the game or API.</span>';
|
||||
el('squad-pitch').innerHTML = '<span class="empty">No squad saved yet.</span>';
|
||||
el('squad-chemistry').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
+8
-1
@@ -237,6 +237,13 @@ const EXACT: &[ExactRoute] = &[
|
||||
notes: "FUT notifications (plural form) → Core notifications",
|
||||
},
|
||||
// ── Squad list ────────────────────────────────────────────────────────────
|
||||
// ── Division / Season history ─────────────────────────────────────────────
|
||||
ExactRoute {
|
||||
ea_method: "GET", ea_path: "/ut/game/fut/division/history",
|
||||
core_method: "GET", core_path: "/division/history",
|
||||
notes: "FUT division history → Core season history",
|
||||
},
|
||||
// ── Squad list ────────────────────────────────────────────────────────────
|
||||
ExactRoute {
|
||||
ea_method: "GET", ea_path: "/ut/game/fut/squad/list",
|
||||
core_method: "GET", core_path: "/squads",
|
||||
@@ -697,7 +704,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_total_exact_routes_count() {
|
||||
assert!(EXACT.len() >= 55, "expected at least 55 exact mappings, got {}", EXACT.len());
|
||||
assert!(EXACT.len() >= 56, "expected at least 56 exact mappings, got {}", EXACT.len());
|
||||
}
|
||||
|
||||
// ── Phase 22 new mappings ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user