diff --git a/api/analysis.php b/api/analysis.php index ea01940..0a9d12a 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -269,7 +269,10 @@ foreach ($clusters as $group) { ]; } $roleOrder = ['healer' => 0, 'dps' => 1, 'tank' => 2]; - usort($targets, fn($a, $b) => ($roleOrder[$a['role']] ?? 1) <=> ($roleOrder[$b['role']] ?? 1)); + usort($targets, function($a, $b) use ($roleOrder) { + $roleCmp = ($roleOrder[$a['role']] ?? 1) <=> ($roleOrder[$b['role']] ?? 1); + return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']); + }); $aoeEvents[] = [ 'timestamp' => $group['timestamp'], diff --git a/css/analysis.css b/css/analysis.css index ca83375..eeea9e6 100644 --- a/css/analysis.css +++ b/css/analysis.css @@ -187,6 +187,48 @@ background: rgba(224, 92, 92, 0.65); } +/* ── Reference row ───────────────────────────────────────────────────────── */ +.aoe-ref-row { + margin-top: 6px; + padding-top: 6px; + border-top: 1px dashed var(--border); + display: flex; + flex-direction: column; + gap: 4px; +} + +.aoe-ref-label { + font-size: 10px; + color: var(--t3); + letter-spacing: 0.05em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 6px; +} + +.aoe-ref-target { + display: flex; + align-items: center; + gap: 5px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--r); + padding: 2px 8px 2px 6px; + font-size: 11px; + opacity: 0.75; +} + +.aoe-delta-worse { font-size: 10px; color: var(--red); } +.aoe-delta-better { font-size: 10px; color: var(--green); } + +.aoe-buff-missing { + opacity: 0.5; + border: 1px solid var(--red); + border-radius: 2px; +} + +/* ── Target buffs ────────────────────────────────────────────────────────── */ .aoe-target-buffs { display: flex; flex-wrap: wrap; diff --git a/js/analysis.js b/js/analysis.js index a809892..dbcb53f 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -49,16 +49,29 @@ return String(n); } + function fmtDur(ms) { + const min = Math.floor(ms / 60000); + const sec = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'); + return `${min}:${sec}`; + } + let hiddenPlayers = new Set(); let lastEvents = []; let lastFightStart = 0; let playerFilter = ''; let phaseFilter = { startTime: 0, endTime: Infinity }; + let refEvents = []; + let refFightStart = 0; + + // ── Player grid ────────────────────────────────────────────────────────── function renderPlayers(players) { const grid = document.getElementById('player-grid'); const order = { healer: 0, dps: 1, tank: 2 }; - players.sort((a, b) => (order[a.role] ?? 2) - (order[b.role] ?? 2)); + players.sort((a, b) => { + const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2); + return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); + }); hiddenPlayers = new Set(players.filter(p => p.role === 'tank').map(p => p.id)); @@ -92,6 +105,8 @@ renderTimeline(lastEvents, lastFightStart); }); + // ── Phase select ───────────────────────────────────────────────────────── + const phaseSelect = document.getElementById('phase-select'); phaseSelect.addEventListener('change', () => { const phases = window.App?.phases ?? []; @@ -111,12 +126,63 @@ phaseSelect.innerHTML = phases.map(p => `` ).join(''); - // Pre-select "Ganzer Fight" phaseSelect.value = 0; phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime }; phaseSelect.style.display = ''; } + // ── Reference fight select ──────────────────────────────────────────────── + + const refFightSelect = document.getElementById('ref-fight-select'); + + refFightSelect.addEventListener('change', async () => { + const refId = parseInt(refFightSelect.value, 10); + if (!refId) { + refEvents = []; + refFightStart = 0; + renderTimeline(lastEvents, lastFightStart); + return; + } + + const fight = (window.App?.fights ?? []).find(f => f.id === refId); + if (!fight) return; + + refFightSelect.disabled = true; + try { + const res = await fetch('api/analysis.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + report_code: window.App.reportCode, + fight_id: refId, + start_time: fight.startTime, + end_time: fight.endTime, + }), + }); + const json = await res.json(); + if (!json.error && !json.reauth) { + refEvents = json.aoe_events ?? []; + refFightStart = json.fight_start ?? fight.startTime; + } + } catch { } + refFightSelect.disabled = false; + renderTimeline(lastEvents, lastFightStart); + }); + + function onFightsLoaded(fights) { + refFightSelect.innerHTML = ''; + fights.forEach(f => { + const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); + const opt = document.createElement('option'); + opt.value = f.id; + opt.textContent = `${f.name} — ${fmtDur(f.endTime - f.startTime)} — ${hp}`; + refFightSelect.appendChild(opt); + }); + refFightSelect.style.display = ''; + } + + // ── Timeline rendering ──────────────────────────────────────────────────── + function renderTimeline(events, fightStart) { lastEvents = events; lastFightStart = fightStart; @@ -128,19 +194,32 @@ return; } + // Build reference index: abilityName → [events in order] + const refIndex = {}; + for (const ev of refEvents) { + (refIndex[ev.abilityName] = refIndex[ev.abilityName] ?? []).push(ev); + } + const abilityOccurrence = {}; + const rows = events.map(ev => { if (ev.timestamp < phaseFilter.startTime || ev.timestamp >= phaseFilter.endTime) return ''; + // Track occurrence for ref matching + const occ = abilityOccurrence[ev.abilityName] ?? 0; + abilityOccurrence[ev.abilityName] = occ + 1; + const refEv = refEvents.length ? (refIndex[ev.abilityName]?.[occ] ?? null) : null; + const visibleTargets = ev.targets.filter(t => !hiddenPlayers.has(t.id) && (!playerFilter || t.name.toLowerCase().includes(playerFilter)) ); if (!visibleTargets.length) return ''; + // Current targets const targets = visibleTargets.map(t => { const hpBar = (t.maxHp > 0) ? (() => { - const afterPct = t.hp / t.maxHp * 100; - const damagePct = t.amount / t.maxHp * 100; + const afterPct = t.hp / t.maxHp * 100; + const damagePct = t.amount / t.maxHp * 100; const hpColor = afterPct > 50 ? 'var(--green)' : afterPct > 25 ? '#e8a020' : 'var(--red)'; return `
@@ -148,6 +227,12 @@
`; })() : ''; + const currentMitigNames = new Set((t.mitigations ?? []).map(m => m.name)); + const refTarget = refEv?.targets?.find(rt => rt.name === t.name); + const missingMitigNames = refTarget + ? new Set((refTarget.mitigations ?? []).filter(m => !currentMitigNames.has(m.name)).map(m => m.name)) + : new Set(); + const mitigIcons = (t.mitigations ?? []).map(m => { const iconSrc = MITIG_ICONS[m.name]; if (!iconSrc) return ''; @@ -155,6 +240,12 @@ return `${m.name}`; }).join(''); + const missingIcons = [...missingMitigNames].map(name => { + const iconSrc = MITIG_ICONS[name]; + if (!iconSrc) return ''; + return `${name}`; + }).join(''); + const dead = t.hp === 0 && t.maxHp > 0; return ` @@ -172,10 +263,64 @@ ${hpBar} - ${mitigIcons ? `
${mitigIcons}
` : ''} + ${(mitigIcons || missingIcons) ? `
${mitigIcons}${missingIcons}
` : ''} `; }).join(''); + // Reference row + let refHtml = ''; + if (refEv) { + const refVisible = refEv.targets.filter(t => + !hiddenPlayers.has(t.id) && + (!playerFilter || t.name.toLowerCase().includes(playerFilter)) + ); + if (refVisible.length) { + const currentByName = {}; + ev.targets.forEach(t => { currentByName[t.name] = t; }); + + const refCards = refVisible.map(t => { + const curr = currentByName[t.name]; + const diff = curr ? curr.amount - t.amount : 0; + const dead = t.hp === 0 && t.maxHp > 0; + + const deltaHtml = diff !== 0 + ? `${diff > 0 ? '+' : ''}${fmtDmg(diff)}` + : ''; + + const refMitigIcons = (t.mitigations ?? []).map(m => { + const iconSrc = MITIG_ICONS[m.name]; + if (!iconSrc) return ''; + const dr = m.dr > 0 ? ` −${m.dr}%` : ''; + return `${m.name}`; + }).join(''); + + return ` +
+
+
+ ${abbr(t.type)} + ${deltaHtml} +
+ ${t.name} + ${fmtDmg(t.amount)}${t.absorbed > 0 ? ` +${fmtDmg(t.absorbed)}` : ''} +
+ ${refMitigIcons ? `
${refMitigIcons}
` : ''} +
`; + }).join(''); + + const totalDiff = ev.totalDamage - refEv.totalDamage; + const totalDelta = totalDiff !== 0 + ? `${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}` + : ''; + + refHtml = ` +
+ REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} +
${refCards}
+
`; + } + } + return `
${fmtTime(ev.timestamp, fightStart)}
@@ -185,6 +330,7 @@ — ${fmtDmg(ev.totalDamage)} total
${targets}
+ ${refHtml} `; @@ -239,6 +385,13 @@ window.analysisTab = { onFightSelected: load, onTabOpen: load, - reset() { lastFightId = null; }, + onFightsLoaded: onFightsLoaded, + reset() { + lastFightId = null; + refEvents = []; + refFightStart = 0; + refFightSelect.value = ''; + refFightSelect.style.display = 'none'; + }, }; })(); diff --git a/js/app.js b/js/app.js index 8400c06..56963ad 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,5 @@ document.addEventListener('DOMContentLoaded', () => { - window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0, phases: [] }; + window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0, phases: [], fights: [] }; const form = document.getElementById('report-form'); const output = document.getElementById('output'); @@ -147,6 +147,7 @@ document.addEventListener('DOMContentLoaded', () => { window.App.fightStart = 0; window.App.fightEnd = 0; window.App.phases = []; + window.App.fights = []; window.analysisTab?.reset?.(); let response, json; @@ -195,7 +196,9 @@ document.addEventListener('DOMContentLoaded', () => { fightSelect.appendChild(opt); }); + window.App.fights = allFights; fightSelectCard.style.display = 'block'; output.textContent = '// Fight auswählen ↑'; + window.analysisTab?.onFightsLoaded?.(allFights); }); }); diff --git a/templates/tab-analysis.php b/templates/tab-analysis.php index 3d0636a..aa732da 100644 --- a/templates/tab-analysis.php +++ b/templates/tab-analysis.php @@ -11,7 +11,12 @@