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:
parent
2d121b8ee5
commit
8fe057e15b
@ -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'],
|
||||
|
||||
@ -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;
|
||||
|
||||
165
js/analysis.js
165
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 =>
|
||||
`<option value="${p.id}">${p.name}</option>`
|
||||
).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 = '<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) {
|
||||
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 `<div class="aoe-hp-bar">
|
||||
<div class="aoe-hp-remaining" style="width:${afterPct.toFixed(1)}%;background:${hpColor}"></div>
|
||||
@ -148,6 +227,12 @@
|
||||
</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 iconSrc = MITIG_ICONS[m.name];
|
||||
if (!iconSrc) return '';
|
||||
@ -155,6 +240,12 @@
|
||||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||
}).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;
|
||||
|
||||
return `
|
||||
@ -172,10 +263,64 @@
|
||||
${hpBar}
|
||||
</div>
|
||||
</div>
|
||||
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''}
|
||||
${(mitigIcons || missingIcons) ? `<div class="aoe-target-buffs">${mitigIcons}${missingIcons}</div>` : ''}
|
||||
</div>`;
|
||||
}).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 `
|
||||
<div class="aoe-event">
|
||||
<div class="aoe-time">${fmtTime(ev.timestamp, fightStart)}</div>
|
||||
@ -185,6 +330,7 @@
|
||||
<span class="aoe-total">— ${fmtDmg(ev.totalDamage)} total</span>
|
||||
</div>
|
||||
<div class="aoe-targets">${targets}</div>
|
||||
${refHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -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';
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,7 +11,12 @@
|
||||
<div id="analysis-content" style="display:none">
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user