forked from xziino/ff14-mitigator
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:
parent
d792d5b718
commit
399e5fad93
113
api/abilities.php
Normal file
113
api/abilities.php
Normal 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]);
|
||||
157
api/analysis.php
157
api/analysis.php
@ -75,11 +75,28 @@ const MITIGATION_ABILITIES = [
|
||||
'Troubadour' => ['dr' => 15, 'buffType' => 'buff'],
|
||||
'Tactician' => ['dr' => 15, 'buffType' => 'buff'],
|
||||
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff'],
|
||||
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff'],
|
||||
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff'],
|
||||
'Feint' => ['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) ─────────────────────────
|
||||
$pdResult = fflogs_gql(<<<GQL
|
||||
{
|
||||
@ -132,102 +149,7 @@ foreach ($roleMap as $group => $role) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Buff / debuff events for mitigation tracking ────────────────────────
|
||||
$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) ─────────────────────────────────────
|
||||
// ── 2. Damage-taken events (paginated) ─────────────────────────────────────
|
||||
$allEvents = [];
|
||||
$nextPage = $startTime;
|
||||
|
||||
@ -239,6 +161,7 @@ for ($page = 0; $page < 10; $page++) {
|
||||
events(
|
||||
fightIDs: [$fightId],
|
||||
dataType: DamageTaken,
|
||||
includeResources: true,
|
||||
startTime: $nextPage,
|
||||
endTime: $endTime
|
||||
) {
|
||||
@ -259,18 +182,18 @@ for ($page = 0; $page < 10; $page++) {
|
||||
if ($nextPage === null || $nextPage >= $endTime) break;
|
||||
}
|
||||
|
||||
// ── 4. AoE detection ───────────────────────────────────────────────────────
|
||||
// ── 3. AoE detection ───────────────────────────────────────────────────────
|
||||
$buckets = [];
|
||||
foreach ($allEvents as $ev) {
|
||||
if (!empty($ev['tick'])) continue;
|
||||
if (($ev['type'] ?? '') !== 'calculateddamage') continue;
|
||||
if (($ev['type'] ?? '') !== 'damage') continue;
|
||||
|
||||
$abId = (int)($ev['abilityGameID'] ?? 0);
|
||||
$tgtId = (int)($ev['targetID'] ?? 0);
|
||||
if (!$abId || !$tgtId || $abId <= 7) continue;
|
||||
|
||||
$ts = (float)($ev['timestamp'] ?? 0);
|
||||
$bucket = (int)floor($ts / 300) * 300;
|
||||
$bucket = (int)floor($ts / 990) * 990;
|
||||
$key = $bucket . '_' . $abId;
|
||||
|
||||
if (!isset($buckets[$key])) {
|
||||
@ -283,7 +206,13 @@ foreach ($allEvents as $ev) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@ -301,36 +230,20 @@ foreach ($buckets as $group) {
|
||||
'type' => $p['type'] ?? '',
|
||||
'role' => $p['role'] ?? 'dps',
|
||||
'amount' => $tgt['amount'],
|
||||
'hp' => $tgt['hp'],
|
||||
'maxHp' => $tgt['maxHp'],
|
||||
'mitigations' => resolveMitigations($tgt['buffs'], $mitigIdMap),
|
||||
];
|
||||
}
|
||||
usort($targets, fn($a, $b) => $b['amount'] <=> $a['amount']);
|
||||
|
||||
// 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$roleOrder = ['healer' => 0, 'dps' => 1, 'tank' => 2];
|
||||
usort($targets, fn($a, $b) => ($roleOrder[$a['role']] ?? 1) <=> ($roleOrder[$b['role']] ?? 1));
|
||||
|
||||
$aoeEvents[] = [
|
||||
'timestamp' => $evTs,
|
||||
'timestamp' => $group['timestamp'],
|
||||
'abilityId' => $group['abilityId'],
|
||||
'abilityName' => $group['abilityName'],
|
||||
'targets' => $targets,
|
||||
'totalDamage' => array_sum(array_column($targets, 'amount')),
|
||||
'mitigations' => $activeMitig,
|
||||
];
|
||||
}
|
||||
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||
|
||||
127
api/debug-events.php
Normal file
127
api/debug-events.php
Normal 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);
|
||||
BIN
assets/icons/mitigation/magick-barrier.png
Normal file
BIN
assets/icons/mitigation/magick-barrier.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@ -1,28 +1,33 @@
|
||||
/* ── Player Grid ─────────────────────────────────────────────────────────── */
|
||||
.player-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.player-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 10px 12px;
|
||||
padding: 7px 9px;
|
||||
display: flex;
|
||||
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 {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-d);
|
||||
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-name {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--t1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.player-type {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: var(--t2);
|
||||
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-event {
|
||||
display: grid;
|
||||
@ -89,28 +109,67 @@
|
||||
|
||||
.aoe-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 2px 8px 2px 6px;
|
||||
padding: 3px 8px 4px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.aoe-target--dead {
|
||||
border-color: rgba(224, 92, 92, 0.6);
|
||||
background: rgba(224, 92, 92, 0.08);
|
||||
}
|
||||
|
||||
.aoe-target-job {
|
||||
font-family: var(--font-d);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.aoe-target-job.role-tank { color: var(--blue); }
|
||||
.aoe-target-job.role-healer { color: var(--green); }
|
||||
.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-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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@ -173,6 +173,30 @@ select option { background: var(--bg2); }
|
||||
.mitig-opt .opt-name { font-size: 12px; flex: 1; }
|
||||
.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 {
|
||||
background: var(--bg0);
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
||||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||||
'Feint': 'assets/icons/mitigation/feint.png',
|
||||
'Addle': 'assets/icons/mitigation/addle.png',
|
||||
@ -48,13 +49,20 @@
|
||||
return String(n);
|
||||
}
|
||||
|
||||
let hiddenPlayers = new Set();
|
||||
let lastEvents = [];
|
||||
let lastFightStart = 0;
|
||||
let playerFilter = '';
|
||||
|
||||
function renderPlayers(players) {
|
||||
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));
|
||||
|
||||
hiddenPlayers = new Set(players.filter(p => p.role === 'tank').map(p => p.id));
|
||||
|
||||
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>
|
||||
<div class="player-name">${p.name}</div>
|
||||
@ -64,7 +72,29 @@
|
||||
`).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) {
|
||||
lastEvents = events;
|
||||
lastFightStart = fightStart;
|
||||
|
||||
const el = document.getElementById('aoe-timeline');
|
||||
|
||||
if (!events.length) {
|
||||
@ -72,24 +102,48 @@
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = events.map(ev => {
|
||||
const mitigIcons = (ev.mitigations ?? []).map(m => {
|
||||
const rows = events.map(ev => {
|
||||
const visibleTargets = ev.targets.filter(t =>
|
||||
!hiddenPlayers.has(t.id) &&
|
||||
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
|
||||
);
|
||||
if (!visibleTargets.length) return '';
|
||||
|
||||
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} (${m.casterName})">`;
|
||||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||
}).join('');
|
||||
|
||||
const targets = ev.targets.map(t => `
|
||||
const dead = t.hp === 0 && t.maxHp > 0;
|
||||
|
||||
return `
|
||||
<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>
|
||||
<div class="aoe-target-body">
|
||||
<div class="aoe-target-row">
|
||||
<span class="aoe-target-name">${t.name}</span>
|
||||
<span class="aoe-target-dmg">${fmtDmg(t.amount)}</span>
|
||||
</div>
|
||||
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''}
|
||||
${hpBar}
|
||||
</div>
|
||||
`).join('');
|
||||
</div>
|
||||
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="aoe-event">
|
||||
@ -104,6 +158,8 @@
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = rows || '<div class="empty" style="padding:30px 0"><h3>Keine sichtbaren Targets</h3></div>';
|
||||
}
|
||||
|
||||
function setEmpty(msg) {
|
||||
|
||||
79
js/app.js
79
js/app.js
@ -7,6 +7,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const initialHint = document.getElementById('initial-hint');
|
||||
const fightSelectCard = document.getElementById('fight-select-card');
|
||||
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 = [];
|
||||
|
||||
@ -20,8 +24,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (fight.kill) return 'Kill';
|
||||
const pct = fight.fightPercentage;
|
||||
if (pct == null) return '?';
|
||||
// fightPercentage is 0–10000 (e.g. 5000 = 50.00%)
|
||||
return (pct / 100).toFixed(2) + '%';
|
||||
return pct.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function displayFight(fight) {
|
||||
@ -40,7 +43,78 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
window.App.fightEnd = fight.endTime;
|
||||
|
||||
displayFight(fight);
|
||||
explorerCard.style.display = 'block';
|
||||
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) => {
|
||||
@ -50,6 +124,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
outputCard.style.display = 'block';
|
||||
output.textContent = '// fetching...';
|
||||
fightSelectCard.style.display = 'none';
|
||||
explorerCard.style.display = 'none';
|
||||
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||
allFights = [];
|
||||
|
||||
|
||||
68
templates/event-explorer.php
Normal file
68
templates/event-explorer.php
Normal 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>
|
||||
@ -16,7 +16,10 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<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>
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<?php require __DIR__ . '/report-form.php'; ?>
|
||||
<?php require __DIR__ . '/fight-select.php'; ?>
|
||||
<?php require __DIR__ . '/event-explorer.php'; ?>
|
||||
<?php require __DIR__ . '/output-card.php'; ?>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user