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];
|
$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'],
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
165
js/analysis.js
165
js/analysis.js
@ -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';
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user