Add pull comparison feature and consistent player sorting

- Reference fight dropdown in Spieler card for same-report pull comparison
- Ref row per AoE event: ref damage + delta, absorbed, mitigation icons
- Missing mitigations (active in ref but not current) shown with red border
- Delta moved below job abbreviation in ref targets to reduce card width
- Players and targets sorted alphabetically within role for consistent ordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-20 16:01:57 +02:00
parent 2d121b8ee5
commit 8fe057e15b
5 changed files with 215 additions and 9 deletions

View File

@ -269,7 +269,10 @@ foreach ($clusters as $group) {
]; ];
} }
$roleOrder = ['healer' => 0, 'dps' => 1, 'tank' => 2]; $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[] = [ $aoeEvents[] = [
'timestamp' => $group['timestamp'], 'timestamp' => $group['timestamp'],

View File

@ -187,6 +187,48 @@
background: rgba(224, 92, 92, 0.65); 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 { .aoe-target-buffs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -49,16 +49,29 @@
return String(n); 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 hiddenPlayers = new Set();
let lastEvents = []; let lastEvents = [];
let lastFightStart = 0; let lastFightStart = 0;
let playerFilter = ''; let playerFilter = '';
let phaseFilter = { startTime: 0, endTime: Infinity }; let phaseFilter = { startTime: 0, endTime: Infinity };
let refEvents = [];
let refFightStart = 0;
// ── Player grid ──────────────────────────────────────────────────────────
function renderPlayers(players) { function renderPlayers(players) {
const grid = document.getElementById('player-grid'); const grid = document.getElementById('player-grid');
const order = { healer: 0, dps: 1, tank: 2 }; 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)); hiddenPlayers = new Set(players.filter(p => p.role === 'tank').map(p => p.id));
@ -92,6 +105,8 @@
renderTimeline(lastEvents, lastFightStart); renderTimeline(lastEvents, lastFightStart);
}); });
// ── Phase select ─────────────────────────────────────────────────────────
const phaseSelect = document.getElementById('phase-select'); const phaseSelect = document.getElementById('phase-select');
phaseSelect.addEventListener('change', () => { phaseSelect.addEventListener('change', () => {
const phases = window.App?.phases ?? []; const phases = window.App?.phases ?? [];
@ -111,12 +126,63 @@
phaseSelect.innerHTML = phases.map(p => phaseSelect.innerHTML = phases.map(p =>
`<option value="${p.id}">${p.name}</option>` `<option value="${p.id}">${p.name}</option>`
).join(''); ).join('');
// Pre-select "Ganzer Fight"
phaseSelect.value = 0; phaseSelect.value = 0;
phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime }; phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime };
phaseSelect.style.display = ''; 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 = '<option value="">Kein Vergleich</option>';
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) { function renderTimeline(events, fightStart) {
lastEvents = events; lastEvents = events;
lastFightStart = fightStart; lastFightStart = fightStart;
@ -128,19 +194,32 @@
return; 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 => { const rows = events.map(ev => {
if (ev.timestamp < phaseFilter.startTime || ev.timestamp >= phaseFilter.endTime) return ''; 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 => const visibleTargets = ev.targets.filter(t =>
!hiddenPlayers.has(t.id) && !hiddenPlayers.has(t.id) &&
(!playerFilter || t.name.toLowerCase().includes(playerFilter)) (!playerFilter || t.name.toLowerCase().includes(playerFilter))
); );
if (!visibleTargets.length) return ''; if (!visibleTargets.length) return '';
// Current targets
const targets = visibleTargets.map(t => { const targets = visibleTargets.map(t => {
const hpBar = (t.maxHp > 0) ? (() => { const hpBar = (t.maxHp > 0) ? (() => {
const afterPct = t.hp / t.maxHp * 100; const afterPct = t.hp / t.maxHp * 100;
const damagePct = t.amount / t.maxHp * 100; const damagePct = t.amount / t.maxHp * 100;
const hpColor = afterPct > 50 ? 'var(--green)' : afterPct > 25 ? '#e8a020' : 'var(--red)'; const hpColor = afterPct > 50 ? 'var(--green)' : afterPct > 25 ? '#e8a020' : 'var(--red)';
return `<div class="aoe-hp-bar"> return `<div class="aoe-hp-bar">
<div class="aoe-hp-remaining" style="width:${afterPct.toFixed(1)}%;background:${hpColor}"></div> <div class="aoe-hp-remaining" style="width:${afterPct.toFixed(1)}%;background:${hpColor}"></div>
@ -148,6 +227,12 @@
</div>`; </div>`;
})() : ''; })() : '';
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 mitigIcons = (t.mitigations ?? []).map(m => {
const iconSrc = MITIG_ICONS[m.name]; const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return ''; if (!iconSrc) return '';
@ -155,6 +240,12 @@
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`; return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
}).join(''); }).join('');
const missingIcons = [...missingMitigNames].map(name => {
const iconSrc = MITIG_ICONS[name];
if (!iconSrc) return '';
return `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${name}" title="${name} fehlt (war im Referenz-Pull aktiv)">`;
}).join('');
const dead = t.hp === 0 && t.maxHp > 0; const dead = t.hp === 0 && t.maxHp > 0;
return ` return `
@ -172,10 +263,64 @@
${hpBar} ${hpBar}
</div> </div>
</div> </div>
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''} ${(mitigIcons || missingIcons) ? `<div class="aoe-target-buffs">${mitigIcons}${missingIcons}</div>` : ''}
</div>`; </div>`;
}).join(''); }).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
? `<span class="${diff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${diff > 0 ? '+' : ''}${fmtDmg(diff)}</span>`
: '';
const refMitigIcons = (t.mitigations ?? []).map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
}).join('');
return `
<div class="aoe-target-wrap">
<div class="aoe-ref-target${dead ? ' aoe-target--dead' : ''}">
<div class="aoe-target-left">
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
${deltaHtml}
</div>
<span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
</div>
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
</div>`;
}).join('');
const totalDiff = ev.totalDamage - refEv.totalDamage;
const totalDelta = totalDiff !== 0
? `<span class="${totalDiff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}</span>`
: '';
refHtml = `
<div class="aoe-ref-row">
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta}</span>
<div class="aoe-targets">${refCards}</div>
</div>`;
}
}
return ` return `
<div class="aoe-event"> <div class="aoe-event">
<div class="aoe-time">${fmtTime(ev.timestamp, fightStart)}</div> <div class="aoe-time">${fmtTime(ev.timestamp, fightStart)}</div>
@ -185,6 +330,7 @@
<span class="aoe-total"> ${fmtDmg(ev.totalDamage)} total</span> <span class="aoe-total"> ${fmtDmg(ev.totalDamage)} total</span>
</div> </div>
<div class="aoe-targets">${targets}</div> <div class="aoe-targets">${targets}</div>
${refHtml}
</div> </div>
</div> </div>
`; `;
@ -239,6 +385,13 @@
window.analysisTab = { window.analysisTab = {
onFightSelected: load, onFightSelected: load,
onTabOpen: load, onTabOpen: load,
reset() { lastFightId = null; }, onFightsLoaded: onFightsLoaded,
reset() {
lastFightId = null;
refEvents = [];
refFightStart = 0;
refFightSelect.value = '';
refFightSelect.style.display = 'none';
},
}; };
})(); })();

View File

@ -1,5 +1,5 @@
document.addEventListener('DOMContentLoaded', () => { 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 form = document.getElementById('report-form');
const output = document.getElementById('output'); const output = document.getElementById('output');
@ -147,6 +147,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.App.fightStart = 0; window.App.fightStart = 0;
window.App.fightEnd = 0; window.App.fightEnd = 0;
window.App.phases = []; window.App.phases = [];
window.App.fights = [];
window.analysisTab?.reset?.(); window.analysisTab?.reset?.();
let response, json; let response, json;
@ -195,7 +196,9 @@ document.addEventListener('DOMContentLoaded', () => {
fightSelect.appendChild(opt); fightSelect.appendChild(opt);
}); });
window.App.fights = allFights;
fightSelectCard.style.display = 'block'; fightSelectCard.style.display = 'block';
output.textContent = '// Fight auswählen ↑'; output.textContent = '// Fight auswählen ↑';
window.analysisTab?.onFightsLoaded?.(allFights);
}); });
}); });

View File

@ -11,7 +11,12 @@
<div id="analysis-content" style="display:none"> <div id="analysis-content" style="display:none">
<div class="card section-gap"> <div class="card section-gap">
<div class="card-title">Spieler</div> <div class="card-title-row">
<div class="card-title">Spieler</div>
<select id="ref-fight-select" class="filter-input" style="display:none">
<option value="">Kein Vergleich</option>
</select>
</div>
<div id="player-grid" class="player-grid"></div> <div id="player-grid" class="player-grid"></div>
</div> </div>