Add analysis features: HP bars, death highlight, mitigation via buffs field, Event Explorer

- HP bar per player in AoE timeline (3-segment: remaining/damage/missing)
- Death highlight: red border + background when hp === 0
- Mitigation tracking refactored to read buffs field directly from damage events
  instead of separate applybuff/removebuff window tracking (simpler, more accurate)
- Mitigations are now per-target instead of per-event
- Add Magick Barrier to tracked mitigation abilities
- Player filter text input above AoE timeline
- Player cards as toggle buttons; tanks hidden by default; sorted Healer→DPS→Tank
- Fix Temperance double-display (dedup by name instead of abilityId)
- Event Explorer in Report tab: DataType, raw event type, ability, player dropdowns,
  limit, time range; abilities and players loaded on fight select
- Fight percentage display fix (was divided by 100 twice)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-20 14:58:10 +02:00
parent d792d5b718
commit 399e5fad93
11 changed files with 604 additions and 165 deletions

113
api/abilities.php Normal file
View File

@ -0,0 +1,113 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
session_start_safe();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
if (empty($_SESSION['access_token'])) { echo json_encode(['reauth' => true]); exit; }
if (($_SESSION['token_expires'] ?? 0) <= time()) { echo json_encode(['reauth' => true]); exit; }
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
$fightId = (int)($_POST['fight_id'] ?? 0);
$startTime = (float)($_POST['start_time'] ?? 0);
$endTime = (float)($_POST['end_time'] ?? 0);
if (!$reportCode || !$fightId || !$endTime) { http_response_code(400); echo json_encode(['error' => 'Missing params']); exit; }
$token = $_SESSION['access_token'];
function ab_gql(string $query): array {
global $token;
$ch = curl_init(GRAPHQL_URI);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['query' => $query]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body, true) ?? [];
}
// Fetch ability names from masterData
$mdResult = ab_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
playerDetails(fightIDs: [$fightId])
masterData {
abilities { gameID name }
}
}
}
}
GQL);
$abilityNames = [];
foreach ($mdResult['data']['reportData']['report']['masterData']['abilities'] ?? [] as $ab) {
$abilityNames[(int)$ab['gameID']] = $ab['name'];
}
// Build sorted player list
$pdRaw = $mdResult['data']['reportData']['report']['playerDetails'] ?? null;
$pdParsed = is_string($pdRaw) ? json_decode($pdRaw, true) : $pdRaw;
$pdGroups = $pdParsed['data']['playerDetails'] ?? [];
$players = [];
$roleMap = ['tanks' => 'tank', 'healers' => 'healer', 'dps' => 'dps'];
foreach ($roleMap as $group => $role) {
foreach ($pdGroups[$group] ?? [] as $p) {
$players[] = ['name' => $p['name'], 'role' => $role];
}
}
usort($players, fn($a, $b) => strcmp($a['name'], $b['name']));
// Fetch DamageTaken events from NPC sources only (paginated)
$filterEsc = 'source.type = \\"NPC\\"';
$seenIds = [];
$nextPage = $startTime;
for ($page = 0; $page < 10; $page++) {
$evResult = ab_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
events(
fightIDs: [$fightId],
dataType: DamageTaken,
startTime: $nextPage,
endTime: $endTime,
filterExpression: "$filterEsc"
) {
data
nextPageTimestamp
}
}
}
}
GQL);
$ev = $evResult['data']['reportData']['report']['events'] ?? [];
foreach ($ev['data'] ?? [] as $event) {
$abId = (int)($event['abilityGameID'] ?? 0);
if ($abId > 7) $seenIds[$abId] = true;
}
$nextPage = $ev['nextPageTimestamp'] ?? null;
if ($nextPage === null || $nextPage >= $endTime) break;
}
// Build sorted ability list
$abilities = [];
foreach (array_keys($seenIds) as $id) {
$abilities[] = [
'id' => $id,
'name' => $abilityNames[$id] ?? ('Ability #' . $id),
];
}
usort($abilities, fn($a, $b) => strcmp($a['name'], $b['name']));
echo json_encode(['abilities' => $abilities, 'players' => $players]);

View File

@ -75,11 +75,28 @@ const MITIGATION_ABILITIES = [
'Troubadour' => ['dr' => 15, 'buffType' => 'buff'], 'Troubadour' => ['dr' => 15, 'buffType' => 'buff'],
'Tactician' => ['dr' => 15, 'buffType' => 'buff'], 'Tactician' => ['dr' => 15, 'buffType' => 'buff'],
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff'], 'Shield Samba' => ['dr' => 15, 'buffType' => 'buff'],
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff'],
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff'], 'Reprisal' => ['dr' => 10, 'buffType' => 'debuff'],
'Feint' => ['dr' => 10, 'buffType' => 'debuff'], 'Feint' => ['dr' => 10, 'buffType' => 'debuff'],
'Addle' => ['dr' => 10, 'buffType' => 'debuff'], 'Addle' => ['dr' => 10, 'buffType' => 'debuff'],
]; ];
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
if ($buffStr === '') return [];
$result = [];
foreach (explode('.', $buffStr) as $idStr) {
$id = (int)$idStr;
if (isset($mitigIdMap[$id])) {
$result[] = [
'name' => $mitigIdMap[$id]['name'],
'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'],
];
}
}
return $result;
}
// ── 1. Player details + masterData (ability names) ───────────────────────── // ── 1. Player details + masterData (ability names) ─────────────────────────
$pdResult = fflogs_gql(<<<GQL $pdResult = fflogs_gql(<<<GQL
{ {
@ -132,102 +149,7 @@ foreach ($roleMap as $group => $role) {
} }
} }
// ── 2. Buff / debuff events for mitigation tracking ──────────────────────── // ── 2. Damage-taken events (paginated) ─────────────────────────────────────
$mitigWindows = []; // [{name, dr, buffType, casterName, start, end}]
if (!empty($mitigIdMap)) {
$idsStr = implode(', ', array_keys($mitigIdMap));
$filterRaw = 'type in ("applybuff","removebuff","applydebuff","removedebuff","refreshbuff") and ability.id in (' . $idsStr . ')';
$filterEsc = str_replace('"', '\\"', $filterRaw);
$buffEvents = [];
$nextPage = $startTime;
for ($page = 0; $page < 5; $page++) {
$bResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
events(
fightIDs: [$fightId],
startTime: $nextPage,
endTime: $endTime,
filterExpression: "$filterEsc"
) {
data
nextPageTimestamp
}
}
}
}
GQL);
if (isset($bResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($bResult['_curl_error'])) break;
$bEv = $bResult['data']['reportData']['report']['events'] ?? [];
$buffEvents = array_merge($buffEvents, $bEv['data'] ?? []);
$nextPage = $bEv['nextPageTimestamp'] ?? null;
if ($nextPage === null || $nextPage >= $endTime) break;
}
// Build time windows per (abilityId, sourceId).
// Party-wide buffs fire one applybuff per party member — we only open the
// window on the FIRST applybuff for a given (ability, caster) pair.
$openWindows = []; // "$abId_$srcId" => [start, abilityId, sourceId]
foreach ($buffEvents as $ev) {
$type = $ev['type'] ?? '';
$abId = (int)($ev['abilityGameID'] ?? 0);
$srcId = (int)($ev['sourceID'] ?? 0);
$ts = (float)($ev['timestamp'] ?? 0);
if (!isset($mitigIdMap[$abId])) continue;
$key = $abId . '_' . $srcId;
if (in_array($type, ['applybuff', 'applydebuff'])) {
if (!isset($openWindows[$key])) {
$openWindows[$key] = ['start' => $ts, 'abilityId' => $abId, 'sourceId' => $srcId];
}
} elseif ($type === 'refreshbuff') {
// Buff was refreshed — keep window open (don't reset start)
if (!isset($openWindows[$key])) {
$openWindows[$key] = ['start' => $ts, 'abilityId' => $abId, 'sourceId' => $srcId];
}
} elseif (in_array($type, ['removebuff', 'removedebuff'])) {
if (isset($openWindows[$key])) {
$info = $mitigIdMap[$abId];
$mitigWindows[] = [
'name' => $info['name'],
'dr' => $info['dr'],
'buffType' => $info['buffType'],
'casterName' => $players[$srcId]['name'] ?? '?',
'abilityId' => $abId,
'start' => $openWindows[$key]['start'],
'end' => $ts,
];
unset($openWindows[$key]);
}
}
}
// Buffs still active at fight end
foreach ($openWindows as $win) {
$info = $mitigIdMap[$win['abilityId']];
$mitigWindows[] = [
'name' => $info['name'],
'dr' => $info['dr'],
'buffType' => $info['buffType'],
'casterName' => $players[$win['sourceId']]['name'] ?? '?',
'abilityId' => $win['abilityId'],
'start' => $win['start'],
'end' => $endTime,
];
}
}
// ── 3. Damage-taken events (paginated) ─────────────────────────────────────
$allEvents = []; $allEvents = [];
$nextPage = $startTime; $nextPage = $startTime;
@ -239,6 +161,7 @@ for ($page = 0; $page < 10; $page++) {
events( events(
fightIDs: [$fightId], fightIDs: [$fightId],
dataType: DamageTaken, dataType: DamageTaken,
includeResources: true,
startTime: $nextPage, startTime: $nextPage,
endTime: $endTime endTime: $endTime
) { ) {
@ -259,18 +182,18 @@ for ($page = 0; $page < 10; $page++) {
if ($nextPage === null || $nextPage >= $endTime) break; if ($nextPage === null || $nextPage >= $endTime) break;
} }
// ── 4. AoE detection ─────────────────────────────────────────────────────── // ── 3. AoE detection ───────────────────────────────────────────────────────
$buckets = []; $buckets = [];
foreach ($allEvents as $ev) { foreach ($allEvents as $ev) {
if (!empty($ev['tick'])) continue; if (!empty($ev['tick'])) continue;
if (($ev['type'] ?? '') !== 'calculateddamage') continue; if (($ev['type'] ?? '') !== 'damage') continue;
$abId = (int)($ev['abilityGameID'] ?? 0); $abId = (int)($ev['abilityGameID'] ?? 0);
$tgtId = (int)($ev['targetID'] ?? 0); $tgtId = (int)($ev['targetID'] ?? 0);
if (!$abId || !$tgtId || $abId <= 7) continue; if (!$abId || !$tgtId || $abId <= 7) continue;
$ts = (float)($ev['timestamp'] ?? 0); $ts = (float)($ev['timestamp'] ?? 0);
$bucket = (int)floor($ts / 300) * 300; $bucket = (int)floor($ts / 990) * 990;
$key = $bucket . '_' . $abId; $key = $bucket . '_' . $abId;
if (!isset($buckets[$key])) { if (!isset($buckets[$key])) {
@ -283,7 +206,13 @@ foreach ($allEvents as $ev) {
} }
if (!isset($buckets[$key]['targets'][$tgtId])) { if (!isset($buckets[$key]['targets'][$tgtId])) {
$buckets[$key]['targets'][$tgtId] = ['id' => $tgtId, 'amount' => 0]; $buckets[$key]['targets'][$tgtId] = [
'id' => $tgtId,
'amount' => 0,
'hp' => (int)($ev['targetResources']['hitPoints'] ?? 0),
'maxHp' => (int)($ev['targetResources']['maxHitPoints'] ?? 0),
'buffs' => $ev['buffs'] ?? '',
];
} }
$buckets[$key]['targets'][$tgtId]['amount'] += (int)($ev['amount'] ?? 0); $buckets[$key]['targets'][$tgtId]['amount'] += (int)($ev['amount'] ?? 0);
} }
@ -296,41 +225,25 @@ foreach ($buckets as $group) {
foreach ($group['targets'] as $tgtId => $tgt) { foreach ($group['targets'] as $tgtId => $tgt) {
$p = $players[$tgtId] ?? null; $p = $players[$tgtId] ?? null;
$targets[] = [ $targets[] = [
'id' => $tgtId, 'id' => $tgtId,
'name' => $p['name'] ?? '?', 'name' => $p['name'] ?? '?',
'type' => $p['type'] ?? '', 'type' => $p['type'] ?? '',
'role' => $p['role'] ?? 'dps', 'role' => $p['role'] ?? 'dps',
'amount' => $tgt['amount'], 'amount' => $tgt['amount'],
'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'],
'mitigations' => resolveMitigations($tgt['buffs'], $mitigIdMap),
]; ];
} }
usort($targets, fn($a, $b) => $b['amount'] <=> $a['amount']); $roleOrder = ['healer' => 0, 'dps' => 1, 'tank' => 2];
usort($targets, fn($a, $b) => ($roleOrder[$a['role']] ?? 1) <=> ($roleOrder[$b['role']] ?? 1));
// Collect active mitigations at this timestamp
$evTs = $group['timestamp'];
$activeMitig = [];
$seen = [];
foreach ($mitigWindows as $win) {
if ($win['start'] <= $evTs && $win['end'] > $evTs) {
$dedupeKey = $win['abilityId'] . '_' . $win['casterName'];
if (!isset($seen[$dedupeKey])) {
$seen[$dedupeKey] = true;
$activeMitig[] = [
'name' => $win['name'],
'dr' => $win['dr'],
'buffType' => $win['buffType'],
'casterName' => $win['casterName'],
];
}
}
}
$aoeEvents[] = [ $aoeEvents[] = [
'timestamp' => $evTs, 'timestamp' => $group['timestamp'],
'abilityId' => $group['abilityId'], 'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'], 'abilityName' => $group['abilityName'],
'targets' => $targets, 'targets' => $targets,
'totalDamage' => array_sum(array_column($targets, 'amount')), 'totalDamage' => array_sum(array_column($targets, 'amount')),
'mitigations' => $activeMitig,
]; ];
} }
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);

127
api/debug-events.php Normal file
View File

@ -0,0 +1,127 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
session_start_safe();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
if (empty($_SESSION['access_token'])) { echo json_encode(['reauth' => true]); exit; }
if (($_SESSION['token_expires'] ?? 0) <= time()) { echo json_encode(['reauth' => true]); exit; }
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
$fightId = (int)($_POST['fight_id'] ?? 0);
$startTime = (float)($_POST['start_time'] ?? 0);
$endTime = (float)($_POST['end_time'] ?? 0);
$playerName = trim($_POST['player_name'] ?? '');
$eventType = trim($_POST['event_type'] ?? '');
$abilityId = (int)($_POST['ability_id'] ?? 0);
$limit = max(1, min(500, (int)($_POST['limit'] ?? 20)));
$startOffset = (float)($_POST['start_offset'] ?? 0) * 1000; // s → ms
$endOffset = isset($_POST['end_offset']) && $_POST['end_offset'] !== ''
? (float)$_POST['end_offset'] * 1000
: null;
$allowedTypes = ['DamageTaken', 'DamageDone', 'Healing', 'Casts', 'Buffs', 'Deaths'];
$dataType = in_array($_POST['data_type'] ?? '', $allowedTypes) ? $_POST['data_type'] : 'DamageTaken';
if (!$reportCode || !$fightId) { http_response_code(400); echo json_encode(['error' => 'Missing params']); exit; }
$queryStart = $startTime + $startOffset;
$queryEnd = $endOffset !== null ? $startTime + $endOffset : $endTime;
$queryEnd = min($queryEnd, $endTime);
$token = $_SESSION['access_token'];
function dbg_gql(string $query): array {
global $token;
$ch = curl_init(GRAPHQL_URI);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['query' => $query]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body, true) ?? [];
}
// Resolve player name → actor IDs (source + target)
$playerIds = [];
if ($playerName !== '') {
$pd = dbg_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
playerDetails(fightIDs: [$fightId])
}
}
}
GQL);
$pdRaw = $pd['data']['reportData']['report']['playerDetails'] ?? null;
$pdParsed = is_string($pdRaw) ? json_decode($pdRaw, true) : $pdRaw;
$pdGroups = $pdParsed['data']['playerDetails'] ?? [];
foreach (['tanks', 'healers', 'dps'] as $group) {
foreach ($pdGroups[$group] ?? [] as $p) {
if (stripos($p['name'], $playerName) !== false) {
$playerIds[] = (int)$p['id'];
}
}
}
}
// Fetch events
$includeResources = in_array($dataType, ['DamageTaken', 'DamageDone']) ? 'includeResources: true,' : '';
$result = dbg_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
events(
fightIDs: [$fightId],
dataType: $dataType,
$includeResources
startTime: $queryStart,
endTime: $queryEnd
) {
data
}
}
}
}
GQL);
$events = $result['data']['reportData']['report']['events']['data'] ?? [];
// Filter by raw event type, player (source OR target), then apply limit
$filtered = [];
foreach ($events as $ev) {
if ($eventType !== '' && ($ev['type'] ?? '') !== $eventType) continue;
if ($abilityId > 0 && (int)($ev['abilityGameID'] ?? 0) !== $abilityId) continue;
if (!empty($playerIds)) {
$srcId = (int)($ev['sourceID'] ?? -1);
$tgtId = (int)($ev['targetID'] ?? -1);
if (!in_array($srcId, $playerIds) && !in_array($tgtId, $playerIds)) continue;
}
$filtered[] = $ev;
if (count($filtered) >= $limit) break;
}
echo json_encode([
'data_type' => $dataType,
'event_type' => $eventType ?: null,
'ability_id' => $abilityId ?: null,
'player_name' => $playerName ?: null,
'player_ids' => $playerIds ?: null,
'time_range' => [
'from_ms' => (int)$queryStart,
'to_ms' => (int)$queryEnd,
],
'total_before_limit' => count($events),
'count' => count($filtered),
'events' => $filtered,
], JSON_PRETTY_PRINT);

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,28 +1,33 @@
/* ── Player Grid ─────────────────────────────────────────────────────────── */ /* ── Player Grid ─────────────────────────────────────────────────────────── */
.player-grid { .player-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px; gap: 6px;
} }
.player-card { .player-card {
background: var(--bg2); background: var(--bg2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r); border-radius: var(--r);
padding: 10px 12px; padding: 7px 9px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 7px;
cursor: pointer;
transition: opacity 0.15s, border-color 0.15s;
user-select: none;
} }
.player-card:hover { border-color: var(--borderem); }
.player-card.player-hidden { opacity: 0.35; border-style: dashed; }
.player-job-icon { .player-job-icon {
width: 34px; width: 28px;
height: 34px; height: 28px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 9px;
font-weight: 600; font-weight: 600;
font-family: var(--font-d); font-family: var(--font-d);
letter-spacing: 0.03em; letter-spacing: 0.03em;
@ -33,18 +38,33 @@
.player-job-icon.role-dps { background: rgba(224,92,92,.15); color: var(--red); border: 1px solid rgba(224,92,92,.35); } .player-job-icon.role-dps { background: rgba(224,92,92,.15); color: var(--red); border: 1px solid rgba(224,92,92,.35); }
.player-name { .player-name {
font-size: 13px; font-size: 12px;
color: var(--t1); color: var(--t1);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.player-type { .player-type {
font-size: 11px; font-size: 10px;
color: var(--t2); color: var(--t2);
margin-top: 1px; margin-top: 1px;
} }
.card-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.card-title-row .card-title { margin-bottom: 0; flex-shrink: 0; }
.filter-input {
width: 200px;
padding: 4px 9px;
font-size: 12px;
margin-left: auto;
}
/* ── AoE Timeline ────────────────────────────────────────────────────────── */ /* ── AoE Timeline ────────────────────────────────────────────────────────── */
.aoe-event { .aoe-event {
display: grid; display: grid;
@ -89,28 +109,67 @@
.aoe-target { .aoe-target {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 5px; gap: 5px;
background: var(--bg3); background: var(--bg3);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r); border-radius: var(--r);
padding: 2px 8px 2px 6px; padding: 3px 8px 4px 6px;
font-size: 11px; font-size: 11px;
} }
.aoe-target--dead {
border-color: rgba(224, 92, 92, 0.6);
background: rgba(224, 92, 92, 0.08);
}
.aoe-target-job { .aoe-target-job {
font-family: var(--font-d); font-family: var(--font-d);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
padding-top: 2px;
flex-shrink: 0;
} }
.aoe-target-job.role-tank { color: var(--blue); } .aoe-target-job.role-tank { color: var(--blue); }
.aoe-target-job.role-healer { color: var(--green); } .aoe-target-job.role-healer { color: var(--green); }
.aoe-target-job.role-dps { color: var(--red); } .aoe-target-job.role-dps { color: var(--red); }
.aoe-target-body {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.aoe-target-row {
display: flex;
align-items: center;
gap: 5px;
}
.aoe-target-name { color: var(--t1); } .aoe-target-name { color: var(--t1); }
.aoe-target-dmg { color: var(--t2); margin-left: 2px; } .aoe-target-dmg { color: var(--t2); margin-left: 2px; }
/* ── HP Bar ──────────────────────────────────────────────────────────────── */
.aoe-hp-bar {
height: 6px;
background: var(--bg0);
border-radius: 2px;
overflow: hidden;
display: flex;
}
.aoe-hp-remaining {
height: 100%;
}
.aoe-hp-damage {
height: 100%;
background: rgba(224, 92, 92, 0.65);
}
.aoe-target-buffs { .aoe-target-buffs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -173,6 +173,30 @@ select option { background: var(--bg2); }
.mitig-opt .opt-name { font-size: 12px; flex: 1; } .mitig-opt .opt-name { font-size: 12px; flex: 1; }
.mitig-opt .opt-dr { font-size: 11px; color: var(--t2); } .mitig-opt .opt-dr { font-size: 11px; color: var(--t2); }
/* ── Event Explorer ─────────────────────────────────────────────────────────── */
.explorer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-group label {
font-size: 11px;
color: var(--t2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input,
.form-group select { width: 100%; }
/* ── Terminal output ────────────────────────────────────────────────────────── */ /* ── Terminal output ────────────────────────────────────────────────────────── */
.terminal { .terminal {
background: var(--bg0); background: var(--bg0);

View File

@ -16,6 +16,7 @@
'Troubadour': 'assets/icons/mitigation/troubadour.png', 'Troubadour': 'assets/icons/mitigation/troubadour.png',
'Tactician': 'assets/icons/mitigation/tactician.png', 'Tactician': 'assets/icons/mitigation/tactician.png',
'Shield Samba': 'assets/icons/mitigation/shield-samba.png', 'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
'Reprisal': 'assets/icons/mitigation/reprisal.png', 'Reprisal': 'assets/icons/mitigation/reprisal.png',
'Feint': 'assets/icons/mitigation/feint.png', 'Feint': 'assets/icons/mitigation/feint.png',
'Addle': 'assets/icons/mitigation/addle.png', 'Addle': 'assets/icons/mitigation/addle.png',
@ -48,13 +49,20 @@
return String(n); return String(n);
} }
let hiddenPlayers = new Set();
let lastEvents = [];
let lastFightStart = 0;
let playerFilter = '';
function renderPlayers(players) { function renderPlayers(players) {
const grid = document.getElementById('player-grid'); const grid = document.getElementById('player-grid');
const order = { tank: 0, healer: 1, dps: 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) => (order[a.role] ?? 2) - (order[b.role] ?? 2));
hiddenPlayers = new Set(players.filter(p => p.role === 'tank').map(p => p.id));
grid.innerHTML = players.map(p => ` grid.innerHTML = players.map(p => `
<div class="player-card"> <div class="player-card ${hiddenPlayers.has(p.id) ? 'player-hidden' : ''}" data-player-id="${p.id}">
<div class="player-job-icon role-${p.role}">${abbr(p.type)}</div> <div class="player-job-icon role-${p.role}">${abbr(p.type)}</div>
<div> <div>
<div class="player-name">${p.name}</div> <div class="player-name">${p.name}</div>
@ -64,7 +72,29 @@
`).join(''); `).join('');
} }
document.getElementById('player-grid').addEventListener('click', e => {
const card = e.target.closest('.player-card');
if (!card) return;
const id = parseInt(card.dataset.playerId, 10);
if (hiddenPlayers.has(id)) {
hiddenPlayers.delete(id);
card.classList.remove('player-hidden');
} else {
hiddenPlayers.add(id);
card.classList.add('player-hidden');
}
renderTimeline(lastEvents, lastFightStart);
});
document.getElementById('player-filter').addEventListener('input', e => {
playerFilter = e.target.value.trim().toLowerCase();
renderTimeline(lastEvents, lastFightStart);
});
function renderTimeline(events, fightStart) { function renderTimeline(events, fightStart) {
lastEvents = events;
lastFightStart = fightStart;
const el = document.getElementById('aoe-timeline'); const el = document.getElementById('aoe-timeline');
if (!events.length) { if (!events.length) {
@ -72,24 +102,48 @@
return; return;
} }
el.innerHTML = events.map(ev => { const rows = events.map(ev => {
const mitigIcons = (ev.mitigations ?? []).map(m => { const visibleTargets = ev.targets.filter(t =>
const iconSrc = MITIG_ICONS[m.name]; !hiddenPlayers.has(t.id) &&
if (!iconSrc) return ''; (!playerFilter || t.name.toLowerCase().includes(playerFilter))
const dr = m.dr > 0 ? ` ${m.dr}%` : ''; );
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} (${m.casterName})">`; if (!visibleTargets.length) return '';
}).join('');
const targets = ev.targets.map(t => ` 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 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>
<div class="aoe-hp-damage" style="width:${damagePct.toFixed(1)}%"></div>
</div>`;
})() : '';
const mitigIcons = (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('');
const dead = t.hp === 0 && t.maxHp > 0;
return `
<div class="aoe-target-wrap"> <div class="aoe-target-wrap">
<div class="aoe-target"> <div class="aoe-target${dead ? ' aoe-target--dead' : ''}">
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span> <span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
<span class="aoe-target-name">${t.name}</span> <div class="aoe-target-body">
<span class="aoe-target-dmg">${fmtDmg(t.amount)}</span> <div class="aoe-target-row">
<span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${fmtDmg(t.amount)}</span>
</div>
${hpBar}
</div>
</div> </div>
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''} ${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''}
</div> </div>`;
`).join(''); }).join('');
return ` return `
<div class="aoe-event"> <div class="aoe-event">
@ -104,6 +158,8 @@
</div> </div>
`; `;
}).join(''); }).join('');
el.innerHTML = rows || '<div class="empty" style="padding:30px 0"><h3>Keine sichtbaren Targets</h3></div>';
} }
function setEmpty(msg) { function setEmpty(msg) {

View File

@ -1,12 +1,16 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0 }; window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0 };
const form = document.getElementById('report-form'); const form = document.getElementById('report-form');
const output = document.getElementById('output'); const output = document.getElementById('output');
const outputCard = document.getElementById('output-card'); const outputCard = document.getElementById('output-card');
const initialHint = document.getElementById('initial-hint'); const initialHint = document.getElementById('initial-hint');
const fightSelectCard = document.getElementById('fight-select-card'); const fightSelectCard = document.getElementById('fight-select-card');
const fightSelect = document.getElementById('fight-select'); const fightSelect = document.getElementById('fight-select');
const explorerCard = document.getElementById('event-explorer-card');
const exLoadBtn = document.getElementById('ex-load-btn');
const exAbilitySelect = document.getElementById('ex-ability');
const exPlayerSelect = document.getElementById('ex-player-name');
let allFights = []; let allFights = [];
@ -20,8 +24,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (fight.kill) return 'Kill'; if (fight.kill) return 'Kill';
const pct = fight.fightPercentage; const pct = fight.fightPercentage;
if (pct == null) return '?'; if (pct == null) return '?';
// fightPercentage is 010000 (e.g. 5000 = 50.00%) return pct.toFixed(2) + '%';
return (pct / 100).toFixed(2) + '%';
} }
function displayFight(fight) { function displayFight(fight) {
@ -40,7 +43,78 @@ document.addEventListener('DOMContentLoaded', () => {
window.App.fightEnd = fight.endTime; window.App.fightEnd = fight.endTime;
displayFight(fight); displayFight(fight);
explorerCard.style.display = 'block';
window.analysisTab?.onFightSelected?.(); window.analysisTab?.onFightSelected?.();
loadAbilities(id, fight.startTime, fight.endTime);
});
async function loadAbilities(fightId, startTime, endTime) {
exAbilitySelect.innerHTML = '<option value="">Lädt…</option>';
try {
const res = await fetch('api/abilities.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
report_code: window.App.reportCode,
fight_id: fightId,
start_time: startTime,
end_time: endTime,
}),
});
const json = await res.json();
exAbilitySelect.innerHTML = '<option value="">Alle</option>';
(json.abilities ?? []).forEach(ab => {
const opt = document.createElement('option');
opt.value = ab.id;
opt.textContent = ab.name;
exAbilitySelect.appendChild(opt);
});
exPlayerSelect.innerHTML = '<option value="">Alle</option>';
(json.players ?? []).forEach(p => {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = p.name;
exPlayerSelect.appendChild(opt);
});
} catch {
exAbilitySelect.innerHTML = '<option value="">Fehler beim Laden</option>';
}
}
exLoadBtn.addEventListener('click', async () => {
if (!window.App.fightId) return;
output.textContent = '// loading events...';
outputCard.style.display = 'block';
const params = {
report_code: window.App.reportCode,
fight_id: window.App.fightId,
start_time: window.App.fightStart,
end_time: window.App.fightEnd,
data_type: document.getElementById('ex-data-type').value,
ability_id: exAbilitySelect.value,
event_type: document.getElementById('ex-event-type').value.trim(),
player_name: document.getElementById('ex-player-name').value.trim(),
limit: document.getElementById('ex-limit').value,
start_offset: document.getElementById('ex-start-offset').value,
end_offset: document.getElementById('ex-end-offset').value,
};
try {
const res = await fetch('api/debug-events.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params),
});
const json = await res.json();
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
output.textContent = JSON.stringify(json, null, 2);
} catch (err) {
output.textContent = '// Fehler: ' + err.message;
}
}); });
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
@ -49,8 +123,9 @@ document.addEventListener('DOMContentLoaded', () => {
initialHint.style.display = 'none'; initialHint.style.display = 'none';
outputCard.style.display = 'block'; outputCard.style.display = 'block';
output.textContent = '// fetching...'; output.textContent = '// fetching...';
fightSelectCard.style.display = 'none'; fightSelectCard.style.display = 'none';
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>'; explorerCard.style.display = 'none';
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
allFights = []; allFights = [];
const reportCode = form.elements['report_code'].value.trim(); const reportCode = form.elements['report_code'].value.trim();

View File

@ -0,0 +1,68 @@
<div class="card section-gap" id="event-explorer-card" style="display:none">
<div class="card-title">Event Explorer</div>
<div class="explorer-grid">
<div class="form-group">
<label for="ex-ability">Ability</label>
<select id="ex-ability">
<option value="">Alle</option>
</select>
</div>
<div class="form-group">
<label for="ex-data-type">DataType</label>
<select id="ex-data-type">
<option value="DamageTaken">DamageTaken</option>
<option value="DamageDone">DamageDone</option>
<option value="Healing">Healing</option>
<option value="Casts">Casts</option>
<option value="Buffs">Buffs</option>
<option value="Deaths">Deaths</option>
</select>
</div>
<div class="form-group">
<label for="ex-event-type">Event-Typ (raw)</label>
<select id="ex-event-type">
<option value="">Alle</option>
<option value="damage" selected>damage</option>
<option value="calculateddamage">calculateddamage</option>
<option value="heal">heal</option>
<option value="calculatedheal">calculatedheal</option>
<option value="cast">cast</option>
<option value="begincast">begincast</option>
<option value="applybuff">applybuff</option>
<option value="removebuff">removebuff</option>
<option value="refreshbuff">refreshbuff</option>
<option value="applydebuff">applydebuff</option>
<option value="removedebuff">removedebuff</option>
<option value="death">death</option>
<option value="resurrect">resurrect</option>
</select>
</div>
<div class="form-group">
<label for="ex-player-name">Spieler</label>
<select id="ex-player-name">
<option value="">Alle</option>
</select>
</div>
<div class="form-group">
<label for="ex-limit">Limit</label>
<input type="number" id="ex-limit" value="20" min="1" max="500">
</div>
<div class="form-group">
<label for="ex-start-offset">Von (Sek.)</label>
<input type="number" id="ex-start-offset" placeholder="0" min="0">
</div>
<div class="form-group">
<label for="ex-end-offset">Bis (Sek.)</label>
<input type="number" id="ex-end-offset" placeholder="Ende" min="0">
</div>
</div>
<button id="ex-load-btn" class="btn">Laden</button>
</div>

View File

@ -16,7 +16,10 @@
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">AoE Timeline</div> <div class="card-title-row">
<div class="card-title">AoE Timeline</div>
<input type="text" id="player-filter" class="filter-input" placeholder="Spieler filtern…">
</div>
<div id="aoe-timeline"></div> <div id="aoe-timeline"></div>
</div> </div>

View File

@ -1,3 +1,4 @@
<?php require __DIR__ . '/report-form.php'; ?> <?php require __DIR__ . '/report-form.php'; ?>
<?php require __DIR__ . '/fight-select.php'; ?> <?php require __DIR__ . '/fight-select.php'; ?>
<?php require __DIR__ . '/event-explorer.php'; ?>
<?php require __DIR__ . '/output-card.php'; ?> <?php require __DIR__ . '/output-card.php'; ?>