Initial commit: FFLogs mitigation analyzer
Two-tab app: report viewer + analysis tab with AoE timeline, per-player mitigation icons (local XIVAPI PNGs), and fight-wide buff/debuff window tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158
CLAUDE.md
@ -1,23 +1,145 @@
|
|||||||
# ff14-auth — FFLogs Report Viewer
|
# ff14-mitigator — FFLogs Mitigation Analyzer
|
||||||
|
|
||||||
## Projekt
|
## Projekt
|
||||||
Einfache PHP/HTML/JS-Seite zum Verbinden mit FFLogs via OAuth2 PKCE und Abrufen von Report-Daten über die GraphQL-API. Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting.
|
PHP/HTML/JS-Tool zum Analysieren von FFXIV-Raidlogs via FFLogs OAuth2 PKCE + GraphQL API.
|
||||||
|
Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting.
|
||||||
|
|
||||||
|
Zwei Tabs:
|
||||||
|
- **Report-Tab**: Report-Code eingeben, Fight auswählen, Raw-JSON-Ausgabe
|
||||||
|
- **Analyse-Tab**: Spielerübersicht + AoE-Timeline mit Mitigation-Tracking
|
||||||
|
|
||||||
|
## Architektur & Konventionen
|
||||||
|
|
||||||
|
### Trennung von PHP, HTML und JS
|
||||||
|
- **PHP-Logik** gehört ausschließlich in `index.php` (und API/Auth-Endpunkte). Keine Geschäftslogik in Templates.
|
||||||
|
- **HTML** gehört in `templates/`. Jede logisch in sich geschlossene Komponente ist eine eigene Datei.
|
||||||
|
- **CSS** gehört in `css/`. Jede CSS-Datei hat einen klar abgegrenzten Scope (base, layout, components, analysis).
|
||||||
|
- **JavaScript** gehört in `js/`. Keine Inline-Scripts in Templates außer dem `<script src="...">` Tag in `page.php`.
|
||||||
|
|
||||||
|
### Template-System
|
||||||
|
`index.php` setzt alle Variablen und ruft dann `require templates/page.php` auf. Templates sind reine Ausgabe — sie lesen Variablen aus dem Scope, setzen aber keine.
|
||||||
|
|
||||||
|
### Geteilter JS-State
|
||||||
|
`window.App` in `app.js` hält den gemeinsamen State für alle Tabs:
|
||||||
|
```js
|
||||||
|
window.App = { reportCode, fightId, fightStart, fightEnd }
|
||||||
|
```
|
||||||
|
`window.analysisTab` (definiert in `analysis.js`) stellt Hooks bereit:
|
||||||
|
- `onFightSelected()` — wird von `app.js` aufgerufen wenn ein Fight gewählt wird
|
||||||
|
- `onTabOpen()` — wird von `tabs.js` aufgerufen wenn der Analyse-Tab geöffnet wird
|
||||||
|
- `reset()` — wird von `app.js` aufgerufen wenn ein neuer Report geladen wird
|
||||||
|
|
||||||
## Dateistruktur
|
## Dateistruktur
|
||||||
```
|
```
|
||||||
config.php — Konstanten (CLIENT_ID, URIs) + session_start_safe()
|
index.php — PHP-Logik: Auth-Check, Variablen, require page.php
|
||||||
index.php — Haupt-UI: Connect-Button / Report-Formular / Terminal-Output
|
config.php — Konstanten (CLIENT_ID, URIs) + session_start_safe()
|
||||||
auth/
|
templates/
|
||||||
start.php — PKCE generieren, Session speichern, Redirect zu FFLogs
|
page.php — HTML-Skeleton (head, body), routet zu login oder app
|
||||||
callback.php — Code gegen Token tauschen, Token in Session speichern
|
login.php — Login-Overlay (nicht authentifiziert / Token abgelaufen)
|
||||||
api/
|
topbar.php — Topbar mit Logo + Tab-Navigation + Token-Ablaufzeit
|
||||||
fight.php — POST-Endpunkt: GraphQL-Query → JSON
|
tab-report.php — Report-Tab: includes report-form, fight-select, output-card
|
||||||
|
tab-analysis.php — Analyse-Tab: Spieler-Grid + AoE-Timeline HTML
|
||||||
|
report-form.php — Report-Code-Eingabe Card
|
||||||
|
fight-select.php — Fight-Auswahl Dropdown Card
|
||||||
|
output-card.php — Terminal-Ausgabe Card + Initial-Hint
|
||||||
|
css/
|
||||||
|
base.css — CSS-Variablen, Reset, Basis-Styles, Feedback-Klassen
|
||||||
|
layout.css — App-Shell, Topbar, Tabs, Login-Overlay, Form-Helpers
|
||||||
|
components.css — Cards, Inputs, Buttons, Badges, Terminal
|
||||||
|
analysis.css — Spieler-Grid, AoE-Timeline, Mitigation-Icons
|
||||||
js/
|
js/
|
||||||
app.js — Formular, Dropdown, Fetch, Ausgabe
|
app.js — Formular, Fight-Dropdown, Fetch, window.App State
|
||||||
|
tabs.js — Tab-Switching, ruft window.analysisTab.onTabOpen() auf
|
||||||
|
analysis.js — Analyse-Tab: Daten laden, Spieler rendern, Timeline rendern
|
||||||
|
auth/
|
||||||
|
start.php — PKCE generieren, Session speichern, Redirect zu FFLogs
|
||||||
|
callback.php — Code gegen Token tauschen, Token in Session speichern
|
||||||
|
api/
|
||||||
|
fight.php — POST-Endpunkt: Fight-Liste via GraphQL → JSON
|
||||||
|
analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
|
||||||
|
assets/
|
||||||
|
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
|
||||||
debug/
|
debug/
|
||||||
schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen)
|
schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Design-System
|
||||||
|
CSS-Variablen in `css/base.css`:
|
||||||
|
- Hintergründe: `--bg0` (#08090d) bis `--bg3` (#1c2130), `--bgcard` (#121620)
|
||||||
|
- Akzentfarbe: `--gold` (#c8a84b)
|
||||||
|
- Text: `--t1` (hell) / `--t2` (gedimmt) / `--t3` (sehr gedimmt)
|
||||||
|
- Farben: `--blue`, `--green`, `--red`, `--orange`
|
||||||
|
- Fonts: `--font-d` Cinzel (Titel/Logo), `--font-b` Inter (Body)
|
||||||
|
- Border-Radius: `--r` (klein), `--rl` (groß)
|
||||||
|
|
||||||
|
## Analyse-Tab — Konzepte & Entscheidungen
|
||||||
|
|
||||||
|
### AoE-Erkennung
|
||||||
|
- Nur `calculateddamage` Events (post-Mitigation Snapshot) — **nicht** `damage` (Application), da sonst doppelte Events
|
||||||
|
- Gruppierung: 300ms-Zeitfenster × `abilityGameID` → Bucket
|
||||||
|
- AoE = Bucket mit ≥ 3 unterschiedlichen `targetID`s
|
||||||
|
- Auto-Attacks und Fähigkeiten mit `abilityGameID ≤ 7` werden gefiltert
|
||||||
|
- Tick-Schaden (`ev['tick'] = true`) wird ignoriert
|
||||||
|
|
||||||
|
### Spielernamen statt IDs
|
||||||
|
- `masterData.abilities` (gameID → Name) wird zusammen mit `playerDetails` in einem einzigen Query abgerufen
|
||||||
|
- `$mitigIdMap` (gameID → Mitigation-Meta) wird nur für Abilities gebaut, die tatsächlich im Report vorkommen
|
||||||
|
|
||||||
|
### Mitigation-Tracking
|
||||||
|
Getrackte party-wide Buffs + Boss-Debuffs (definiert in `MITIGATION_ABILITIES` in `api/analysis.php`):
|
||||||
|
|
||||||
|
| Ability | DR | Typ |
|
||||||
|
|---|---|---|
|
||||||
|
| Passage of Arms | 15% | buff |
|
||||||
|
| Divine Veil | Barrier | buff |
|
||||||
|
| Shake It Off | Barrier | buff |
|
||||||
|
| Dark Missionary | 10% | buff |
|
||||||
|
| Heart of Light | 10% | buff |
|
||||||
|
| Temperance | 10% | buff |
|
||||||
|
| Sacred Soil | 10% | buff |
|
||||||
|
| Expedient | 10% | buff |
|
||||||
|
| Fey Illumination | 5% | buff |
|
||||||
|
| Collective Unconscious | 10% | buff |
|
||||||
|
| Holos | 10% | buff |
|
||||||
|
| Kerachole | 10% | buff |
|
||||||
|
| Panhaima | Barrier | buff |
|
||||||
|
| Troubadour | 15% | buff |
|
||||||
|
| Tactician | 15% | buff |
|
||||||
|
| Shield Samba | 15% | buff |
|
||||||
|
| Reprisal | 10% | debuff |
|
||||||
|
| Feint | 10% | debuff |
|
||||||
|
| Addle | 10% | debuff |
|
||||||
|
|
||||||
|
**Fenster-Tracking:** `applybuff`/`applydebuff` öffnet Fenster (nur erstes pro `abilityId_sourceId`-Key, da party-wide Buffs einmal pro Partymitglied feuern). `removebuff`/`removedebuff` schließt das Fenster. Noch offene Fenster am Fight-Ende werden mit `endTime` geschlossen.
|
||||||
|
|
||||||
|
### Mitigation-Icons
|
||||||
|
- Icons lokal gespeichert in `assets/icons/mitigation/` als PNG
|
||||||
|
- Quelle: XIVAPI v2 (`https://v2.xivapi.com/api/asset?path=...&format=png`)
|
||||||
|
- Icon-Pfade per Action-Row-ID abgerufen: `https://v2.xivapi.com/api/sheet/Action/{id}?fields=Name,Icon`
|
||||||
|
- Dateinamen: kebab-case des Ability-Namens (z.B. `passage-of-arms.png`)
|
||||||
|
- Mapping in `analysis.js`: `MITIG_ICONS` Objekt (Ability-Name → lokaler Pfad)
|
||||||
|
- Darstellung: 16×16px Icon unter jedem Spieler-Target, kein Text, `title`-Tooltip mit Name + DR% + Caster
|
||||||
|
|
||||||
|
## Geplante Features
|
||||||
|
|
||||||
|
### HP-Balken pro Spieler (nächstes Feature)
|
||||||
|
Für jeden Spieler in der AoE-Timeline einen 3-Segment-Balken anzeigen, der den HP-Stand im Kontext des Treffers zeigt:
|
||||||
|
|
||||||
|
```
|
||||||
|
[████████████▓▓▓░░░░░░░░]
|
||||||
|
^verbleibend ^Schaden ^vorher schon fehlend
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Grün** (links): HP nach dem Treffer (als % von MaxHP)
|
||||||
|
- **Rot/Orange** (Mitte): erlittener Schaden (als % von MaxHP)
|
||||||
|
- **Dunkelgrau** (rechts): HP die bereits vor dem Treffer fehlten
|
||||||
|
- Grünton dynamisch: >50% grün, 25–50% gelb/amber, <25% rot
|
||||||
|
- Balken sitzt direkt unter Name+Schaden-Zeile in der Spieler-Box
|
||||||
|
|
||||||
|
**Datenbedarf:** FFLogs `calculateddamage` Events enthalten `hitPoints` (HP vor dem Treffer) und `maxHitPoints`. Diese müssen in `api/analysis.php` pro Target mitgegeben werden.
|
||||||
|
|
||||||
|
**Backend-Änderung:** In `$buckets[$key]['targets'][$tgtId]` zusätzlich `hp` und `maxHp` aus dem letzten Event speichern und in der Response durchreichen.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
- `config.php`: `CLIENT_ID`, `REDIRECT_URI`, `DEV_MODE` anpassen
|
- `config.php`: `CLIENT_ID`, `REDIRECT_URI`, `DEV_MODE` anpassen
|
||||||
- `DEV_MODE = true` deaktiviert SSL-Verifizierung (nur lokal, nie in Produktion)
|
- `DEV_MODE = true` deaktiviert SSL-Verifizierung (nur lokal, nie in Produktion)
|
||||||
@ -29,6 +151,19 @@ debug/
|
|||||||
- GraphQL Endpoint (user-scoped): `https://www.fflogs.com/api/v2/user`
|
- GraphQL Endpoint (user-scoped): `https://www.fflogs.com/api/v2/user`
|
||||||
- Token Endpoint: `https://www.fflogs.com/oauth/token`
|
- Token Endpoint: `https://www.fflogs.com/oauth/token`
|
||||||
- Kein Refresh Token für öffentliche Clients — abgelaufene Sessions starten PKCE neu
|
- Kein Refresh Token für öffentliche Clients — abgelaufene Sessions starten PKCE neu
|
||||||
|
- Event-Typen: `calculateddamage` (Snapshot nach Mitigation, den wir nutzen) vs. `damage` (Application, ignorieren)
|
||||||
|
|
||||||
|
## XIVAPI
|
||||||
|
- Basis-URL: `https://v2.xivapi.com`
|
||||||
|
- Action-Lookup per Row-ID: `/api/sheet/Action/{id}?fields=Name,Icon`
|
||||||
|
- Asset-Download: `/api/asset?path={tex_path}&format=png`
|
||||||
|
- Icons nicht hotlinken — lokal speichern (Community-Service, kein SLA)
|
||||||
|
- XIVAPI-Suche (`/api/search`) gibt bei manchen Abilities ClassJob-Daten statt Action-Daten zurück → direkt per Row-ID abrufen
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
- Remote: `https://git.epow0.org/xziino/ff14-mitigator`
|
||||||
|
- Platform: Gitea (git.epow0.org)
|
||||||
|
- Branch: `main`
|
||||||
|
|
||||||
## Lokale Entwicklung
|
## Lokale Entwicklung
|
||||||
```
|
```
|
||||||
@ -46,3 +181,4 @@ Vollständiges Schema: siehe `debug/schema.php` oder `fflogs-schema.json`
|
|||||||
- `DEV_MODE` auf `false` setzen
|
- `DEV_MODE` auf `false` setzen
|
||||||
- `REDIRECT_URI` auf produktive HTTPS-URL anpassen
|
- `REDIRECT_URI` auf produktive HTTPS-URL anpassen
|
||||||
- `debug/` Ordner nicht deployen
|
- `debug/` Ordner nicht deployen
|
||||||
|
- `assets/` Ordner deployen (enthält lokale Icons)
|
||||||
|
|||||||
342
api/analysis.php
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
<?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 required parameters']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $_SESSION['access_token'];
|
||||||
|
|
||||||
|
function fflogs_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);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($err) return ['_curl_error' => $err];
|
||||||
|
if ($code === 401) return ['_reauth' => true];
|
||||||
|
return json_decode($body, true) ?? ['_parse_error' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Party-wide mitigation + boss debuffs to track ─────────────────────────
|
||||||
|
// Barriers (dr = 0) are shown without a percentage.
|
||||||
|
const MITIGATION_ABILITIES = [
|
||||||
|
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff'],
|
||||||
|
'Divine Veil' => ['dr' => 0, 'buffType' => 'buff'],
|
||||||
|
'Shake It Off' => ['dr' => 0, 'buffType' => 'buff'],
|
||||||
|
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Temperance' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Expedient' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff'],
|
||||||
|
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Holos' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Kerachole' => ['dr' => 10, 'buffType' => 'buff'],
|
||||||
|
'Panhaima' => ['dr' => 0, 'buffType' => 'buff'],
|
||||||
|
'Troubadour' => ['dr' => 15, 'buffType' => 'buff'],
|
||||||
|
'Tactician' => ['dr' => 15, 'buffType' => 'buff'],
|
||||||
|
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff'],
|
||||||
|
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff'],
|
||||||
|
'Feint' => ['dr' => 10, 'buffType' => 'debuff'],
|
||||||
|
'Addle' => ['dr' => 10, 'buffType' => 'debuff'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 1. Player details + masterData (ability names) ─────────────────────────
|
||||||
|
$pdResult = fflogs_gql(<<<GQL
|
||||||
|
{
|
||||||
|
reportData {
|
||||||
|
report(code: "$reportCode") {
|
||||||
|
playerDetails(fightIDs: [$fightId])
|
||||||
|
masterData {
|
||||||
|
abilities {
|
||||||
|
gameID
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GQL);
|
||||||
|
|
||||||
|
if (isset($pdResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
|
||||||
|
if (isset($pdResult['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $pdResult['_curl_error']]); exit; }
|
||||||
|
|
||||||
|
// abilityGameID → display name
|
||||||
|
$abilityNames = [];
|
||||||
|
foreach ($pdResult['data']['reportData']['report']['masterData']['abilities'] ?? [] as $ab) {
|
||||||
|
$abilityNames[(int)$ab['gameID']] = $ab['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameID → mitigation meta (only for abilities present in this report)
|
||||||
|
$mitigIdMap = [];
|
||||||
|
foreach ($abilityNames as $gameId => $name) {
|
||||||
|
if (isset(MITIGATION_ABILITIES[$name])) {
|
||||||
|
$mitigIdMap[$gameId] = array_merge(['name' => $name], MITIGATION_ABILITIES[$name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// player actorID → player data
|
||||||
|
$pdRaw = $pdResult['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[(int)$p['id']] = [
|
||||||
|
'id' => (int)$p['id'],
|
||||||
|
'name' => $p['name'],
|
||||||
|
'type' => $p['type'] ?? '',
|
||||||
|
'role' => $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) ─────────────────────────────────────
|
||||||
|
$allEvents = [];
|
||||||
|
$nextPage = $startTime;
|
||||||
|
|
||||||
|
for ($page = 0; $page < 10; $page++) {
|
||||||
|
$evResult = fflogs_gql(<<<GQL
|
||||||
|
{
|
||||||
|
reportData {
|
||||||
|
report(code: "$reportCode") {
|
||||||
|
events(
|
||||||
|
fightIDs: [$fightId],
|
||||||
|
dataType: DamageTaken,
|
||||||
|
startTime: $nextPage,
|
||||||
|
endTime: $endTime
|
||||||
|
) {
|
||||||
|
data
|
||||||
|
nextPageTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GQL);
|
||||||
|
|
||||||
|
if (isset($evResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
|
||||||
|
if (isset($evResult['_curl_error'])) break;
|
||||||
|
|
||||||
|
$ev = $evResult['data']['reportData']['report']['events'] ?? [];
|
||||||
|
$allEvents = array_merge($allEvents, $ev['data'] ?? []);
|
||||||
|
$nextPage = $ev['nextPageTimestamp'] ?? null;
|
||||||
|
if ($nextPage === null || $nextPage >= $endTime) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. AoE detection ───────────────────────────────────────────────────────
|
||||||
|
$buckets = [];
|
||||||
|
foreach ($allEvents as $ev) {
|
||||||
|
if (!empty($ev['tick'])) continue;
|
||||||
|
if (($ev['type'] ?? '') !== 'calculateddamage') 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;
|
||||||
|
$key = $bucket . '_' . $abId;
|
||||||
|
|
||||||
|
if (!isset($buckets[$key])) {
|
||||||
|
$buckets[$key] = [
|
||||||
|
'timestamp' => (int)$ts,
|
||||||
|
'abilityId' => $abId,
|
||||||
|
'abilityName' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId),
|
||||||
|
'targets' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($buckets[$key]['targets'][$tgtId])) {
|
||||||
|
$buckets[$key]['targets'][$tgtId] = ['id' => $tgtId, 'amount' => 0];
|
||||||
|
}
|
||||||
|
$buckets[$key]['targets'][$tgtId]['amount'] += (int)($ev['amount'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$aoeEvents = [];
|
||||||
|
foreach ($buckets as $group) {
|
||||||
|
if (count($group['targets']) < 3) continue;
|
||||||
|
|
||||||
|
$targets = [];
|
||||||
|
foreach ($group['targets'] as $tgtId => $tgt) {
|
||||||
|
$p = $players[$tgtId] ?? null;
|
||||||
|
$targets[] = [
|
||||||
|
'id' => $tgtId,
|
||||||
|
'name' => $p['name'] ?? '?',
|
||||||
|
'type' => $p['type'] ?? '',
|
||||||
|
'role' => $p['role'] ?? 'dps',
|
||||||
|
'amount' => $tgt['amount'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$aoeEvents[] = [
|
||||||
|
'timestamp' => $evTs,
|
||||||
|
'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']);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'players' => array_values($players),
|
||||||
|
'aoe_events' => $aoeEvents,
|
||||||
|
'fight_start' => (int)$startTime,
|
||||||
|
]);
|
||||||
BIN
assets/icons/mitigation/addle.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/icons/mitigation/collective-unconscious.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/icons/mitigation/dark-missionary.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/icons/mitigation/divine-veil.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/icons/mitigation/expedient.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/mitigation/feint.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/icons/mitigation/fey-illumination.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/icons/mitigation/heart-of-light.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/icons/mitigation/holos.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/icons/mitigation/kerachole.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/icons/mitigation/panhaima.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/mitigation/passage-of-arms.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/icons/mitigation/reprisal.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icons/mitigation/sacred-soil.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/icons/mitigation/shake-it-off.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/icons/mitigation/shield-samba.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/icons/mitigation/tactician.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/mitigation/temperance.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/icons/mitigation/troubadour.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
127
css/analysis.css
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/* ── Player Grid ─────────────────────────────────────────────────────────── */
|
||||||
|
.player-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-job-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-d);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.player-job-icon.role-tank { background: rgba(74,158,255,.15); color: var(--blue); border: 1px solid rgba(74,158,255,.35); }
|
||||||
|
.player-job-icon.role-healer { background: rgba(76,175,125,.15); color: var(--green); border: 1px solid rgba(76,175,125,.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 {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--t1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.player-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t2);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AoE Timeline ────────────────────────────────────────────────────────── */
|
||||||
|
.aoe-event {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.aoe-event:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.aoe-time {
|
||||||
|
font-family: var(--font-d);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gold);
|
||||||
|
padding-top: 2px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aoe-ability {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--t1);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.aoe-total {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t2);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aoe-targets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aoe-target-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aoe-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 2px 8px 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aoe-target-job {
|
||||||
|
font-family: var(--font-d);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.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-name { color: var(--t1); }
|
||||||
|
.aoe-target-dmg { color: var(--t2); margin-left: 2px; }
|
||||||
|
|
||||||
|
.aoe-target-buffs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aoe-target-buff-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
148
css/base.css
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=Inter:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
|
/* ── CSS Custom Properties ─────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg0: #08090d;
|
||||||
|
--bg1: #0f1117;
|
||||||
|
--bg2: #161a23;
|
||||||
|
--bg3: #1c2130;
|
||||||
|
--bgcard: #121620;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
|
--borderem: rgba(255, 255, 255, 0.14);
|
||||||
|
|
||||||
|
/* Accent Colors */
|
||||||
|
--gold: #c8a84b;
|
||||||
|
--golddim: #7d6929;
|
||||||
|
--goldbg: rgba(200, 168, 75, 0.08);
|
||||||
|
|
||||||
|
--blue: #4a9eff;
|
||||||
|
--bluedim: rgba(74, 158, 255, 0.12);
|
||||||
|
|
||||||
|
--teal: #2ec4b6;
|
||||||
|
--red: #e05c5c;
|
||||||
|
--redbg: rgba(224, 92, 92, 0.10);
|
||||||
|
|
||||||
|
--green: #4caf7d;
|
||||||
|
--greenbg: rgba(76, 175, 125, 0.10);
|
||||||
|
|
||||||
|
--orange: #f5a623;
|
||||||
|
--orangebg: rgba(245, 166, 35, 0.10);
|
||||||
|
|
||||||
|
--purple: #9b72cf;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--t1: #eae6df;
|
||||||
|
--t2: #8a92a6;
|
||||||
|
--t3: #454d62;
|
||||||
|
|
||||||
|
/* Misc */
|
||||||
|
--r: 6px;
|
||||||
|
--rl: 10px;
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
--font-d: 'Cinzel', serif;
|
||||||
|
--font-b: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ──────────────────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ───────────────────────────────────────────────────────────────────── */
|
||||||
|
body {
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--t1);
|
||||||
|
font-family: var(--font-b);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--blue); }
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bg2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol li {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--t2);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utility ────────────────────────────────────────────────────────────────── */
|
||||||
|
.section-gap { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* ── Feedback ───────────────────────────────────────────────────────────────── */
|
||||||
|
.error {
|
||||||
|
background: var(--redbg);
|
||||||
|
border: 1px solid rgba(224, 92, 92, 0.3);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: var(--greenbg);
|
||||||
|
border: 1px solid rgba(76, 175, 125, 0.3);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 2px solid var(--borderem);
|
||||||
|
border-top-color: var(--gold);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
color: var(--t2);
|
||||||
|
}
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
opacity: 0.25;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.empty h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--t1);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
189
css/components.css
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/* ── Card ───────────────────────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--bgcard);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--rl);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.card + .card { margin-top: 16px; }
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-d);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card-title::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
height: 13px;
|
||||||
|
background: var(--gold);
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inputs ─────────────────────────────────────────────────────────────────── */
|
||||||
|
input, select, textarea {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--borderem);
|
||||||
|
border-radius: var(--r);
|
||||||
|
color: var(--t1);
|
||||||
|
font-family: var(--font-b);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: var(--blue); }
|
||||||
|
input::placeholder { color: var(--t3); }
|
||||||
|
select option { background: var(--bg2); }
|
||||||
|
|
||||||
|
/* ── Buttons ────────────────────────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: var(--r);
|
||||||
|
border: 1px solid var(--borderem);
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--t1);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-b);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--bg3); }
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.btn-gold {
|
||||||
|
background: var(--golddim);
|
||||||
|
border-color: var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
.btn-gold:hover { background: #4a3510; }
|
||||||
|
|
||||||
|
.btn-red {
|
||||||
|
border-color: var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.btn-red:hover { background: var(--redbg); }
|
||||||
|
|
||||||
|
.btn-sm { padding: 4px 11px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── Stats row ──────────────────────────────────────────────────────────────── */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 130px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.stat-value { font-size: 20px; font-weight: 600; }
|
||||||
|
.stat-sub { font-size: 11px; color: var(--t3); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── Badges ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.badge-tank { background: rgba(74,158,255,.14); border-color: rgba(74,158,255,.4); color: var(--blue); }
|
||||||
|
.badge-heal { background: rgba(76,175,125,.14); border-color: rgba(76,175,125,.4); color: var(--green); }
|
||||||
|
.badge-dps { background: rgba(224,92,92,.14); border-color: rgba(224,92,92,.4); color: var(--red); }
|
||||||
|
.badge-gold { background: var(--goldbg); border-color: rgba(200,168,75,.4); color: var(--gold); }
|
||||||
|
.badge-planned { opacity: 0.55; border-style: dashed; }
|
||||||
|
|
||||||
|
/* ── DR Bar ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.dr-bar-wrap {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.dr-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--green);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Damage grid (in detail panel) ─────────────────────────────────────────── */
|
||||||
|
.dmg-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dmg-box {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.dmg-box-label { font-size: 11px; color: var(--t2); margin-bottom: 3px; }
|
||||||
|
.dmg-box-val { font-size: 16px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Mitigation picker ──────────────────────────────────────────────────────── */
|
||||||
|
.mitig-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mitig-opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
background: var(--bg2);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.mitig-opt:hover { border-color: var(--borderem); }
|
||||||
|
.mitig-opt.sel { border-color: var(--gold); background: var(--goldbg); }
|
||||||
|
.mitig-opt.active-log { opacity: 0.5; cursor: default; }
|
||||||
|
.mitig-opt .opt-name { font-size: 12px; flex: 1; }
|
||||||
|
.mitig-opt .opt-dr { font-size: 11px; color: var(--t2); }
|
||||||
|
|
||||||
|
/* ── Terminal output ────────────────────────────────────────────────────────── */
|
||||||
|
.terminal {
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
color: #a3e635;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
170
css/layout.css
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
/* ── App Shell ──────────────────────────────────────────────────────────────── */
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Topbar ─────────────────────────────────────────────────────────────────── */
|
||||||
|
#topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 52px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: var(--font-d);
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.logo span {
|
||||||
|
font-family: var(--font-b);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t3);
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ───────────────────────────────────────────────────────────────────── */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t2);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--r);
|
||||||
|
font-family: var(--font-b);
|
||||||
|
transition: all 0.15s;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab:hover { background: var(--bg3); color: var(--t1); }
|
||||||
|
.tab.active { background: var(--bg3); color: var(--gold); border-bottom-color: var(--gold); }
|
||||||
|
|
||||||
|
/* ── Main content ───────────────────────────────────────────────────────────── */
|
||||||
|
#main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Two-column panel layout (timeline + detail) ────────────────────────────── */
|
||||||
|
.panel-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 360px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.panel-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form helpers ───────────────────────────────────────────────────────────── */
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.fg label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Topbar user badge ───────────────────────────────────────────────────────── */
|
||||||
|
.topbar-user {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login Overlay ───────────────────────────────────────────────────────────── */
|
||||||
|
#login-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bgcard);
|
||||||
|
border: 1px solid var(--borderem);
|
||||||
|
border-radius: var(--rl);
|
||||||
|
padding: 48px 40px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
font-family: var(--font-d);
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.login-logo span {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-b);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t3);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 4px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--t2);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 24px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 11px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-d);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t3);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
197
index.php
@ -17,200 +17,5 @@ $errorMessages = [
|
|||||||
$errorText = $error ? ($errorMessages[$error] ?? 'Unknown error: ' . htmlspecialchars($error)) : null;
|
$errorText = $error ? ($errorMessages[$error] ?? 'Unknown error: ' . htmlspecialchars($error)) : null;
|
||||||
$tokenDebug = $_SESSION['token_debug'] ?? null;
|
$tokenDebug = $_SESSION['token_debug'] ?? null;
|
||||||
unset($_SESSION['token_debug']);
|
unset($_SESSION['token_debug']);
|
||||||
?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>FFLogs Report Viewer</title>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
require __DIR__ . '/templates/page.php';
|
||||||
font-family: 'Courier New', Courier, monospace;
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #e0e0e0;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 860px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #a78bfa;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-box {
|
|
||||||
background: #3b0f0f;
|
|
||||||
border: 1px solid #7f1d1d;
|
|
||||||
color: #fca5a5;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box {
|
|
||||||
background: #1e2a1e;
|
|
||||||
border: 1px solid #365936;
|
|
||||||
color: #86efac;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.6rem 1.4rem;
|
|
||||||
background: #7c3aed;
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn:hover { background: #6d28d9; }
|
|
||||||
.btn-secondary {
|
|
||||||
background: #374151;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover { background: #4b5563; }
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"], select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 180px;
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
background: #111827;
|
|
||||||
border: 1px solid #374151;
|
|
||||||
color: #e0e0e0;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input[type="text"]:focus, select:focus { border-color: #7c3aed; }
|
|
||||||
input[type="text"]::placeholder { color: #4b5563; }
|
|
||||||
select option { background: #111827; }
|
|
||||||
#fight-select-row { display: none; }
|
|
||||||
|
|
||||||
.terminal {
|
|
||||||
background: #0d0d0d;
|
|
||||||
border: 1px solid #2d2d2d;
|
|
||||||
color: #a3e635;
|
|
||||||
padding: 1.25rem;
|
|
||||||
min-height: 260px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link {
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
.footer-link a { color: #6b7280; text-decoration: none; }
|
|
||||||
.footer-link a:hover { color: #9ca3af; }
|
|
||||||
|
|
||||||
.connect-area {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.connect-area h1 { margin-bottom: 0.5rem; }
|
|
||||||
.connect-area p {
|
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<?php if ($errorText): ?>
|
|
||||||
<div class="error-box"><?= $errorText ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($tokenDebug): ?>
|
|
||||||
<div class="error-box">
|
|
||||||
<strong>Debug — FFLogs Token Response:</strong><br>
|
|
||||||
HTTP <?= (int)$tokenDebug['http_status'] ?><?= $tokenDebug['curl_error'] ? ' | curl: ' . htmlspecialchars($tokenDebug['curl_error']) : '' ?><br>
|
|
||||||
<pre style="margin-top:0.5rem;white-space:pre-wrap;font-size:0.8rem;"><?= htmlspecialchars($tokenDebug['response_body'] ?? '(empty)') ?></pre>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (!$authenticated && !$tokenExpired): ?>
|
|
||||||
<div class="connect-area">
|
|
||||||
<h1>// FFLogs Report Viewer</h1>
|
|
||||||
<p>Connect your FFLogs account to fetch report data.</p>
|
|
||||||
<a class="btn" href="auth/start.php">Connect to FFLogs</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php elseif ($tokenExpired): ?>
|
|
||||||
<div class="connect-area">
|
|
||||||
<h1>// FFLogs Report Viewer</h1>
|
|
||||||
<div class="info-box">Session expired. Please reconnect.</div>
|
|
||||||
<a class="btn" href="auth/start.php">Reconnect to FFLogs</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php else: ?>
|
|
||||||
<h1>// FFLogs Report Viewer</h1>
|
|
||||||
|
|
||||||
<form id="report-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="report_code"
|
|
||||||
placeholder="Report Code (e.g. aBcDeFgH1234)"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<button class="btn" type="submit">Fetch</button>
|
|
||||||
<a class="btn btn-secondary" href="auth/start.php">Reconnect</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="form-row" id="fight-select-row">
|
|
||||||
<select id="fight-select">
|
|
||||||
<option value="">— Fight auswählen —</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<pre id="output" class="terminal">// paste a report code above and hit Fetch</pre>
|
|
||||||
|
|
||||||
<div class="footer-link">
|
|
||||||
Token valid until: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="js/app.js"></script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
156
js/analysis.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
(function () {
|
||||||
|
const MITIG_ICONS = {
|
||||||
|
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
||||||
|
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||||||
|
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
||||||
|
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
||||||
|
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
||||||
|
'Temperance': 'assets/icons/mitigation/temperance.png',
|
||||||
|
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
||||||
|
'Expedient': 'assets/icons/mitigation/expedient.png',
|
||||||
|
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
||||||
|
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
||||||
|
'Holos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
||||||
|
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||||||
|
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||||||
|
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||||||
|
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||||||
|
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||||||
|
'Feint': 'assets/icons/mitigation/feint.png',
|
||||||
|
'Addle': 'assets/icons/mitigation/addle.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOB_ABBR = {
|
||||||
|
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
||||||
|
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
||||||
|
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
||||||
|
'Reaper': 'RPR', 'Viper': 'VPR',
|
||||||
|
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
|
||||||
|
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
||||||
|
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
||||||
|
};
|
||||||
|
|
||||||
|
function abbr(type) {
|
||||||
|
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ms, start) {
|
||||||
|
const rel = ms - start;
|
||||||
|
const min = Math.floor(rel / 60000);
|
||||||
|
const sec = String(Math.floor((rel % 60000) / 1000)).padStart(2, '0');
|
||||||
|
return `${min}:${sec}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDmg(n) {
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
|
||||||
|
if (n >= 1_000) return Math.round(n / 1_000) + 'k';
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayers(players) {
|
||||||
|
const grid = document.getElementById('player-grid');
|
||||||
|
const order = { tank: 0, healer: 1, dps: 2 };
|
||||||
|
players.sort((a, b) => (order[a.role] ?? 2) - (order[b.role] ?? 2));
|
||||||
|
|
||||||
|
grid.innerHTML = players.map(p => `
|
||||||
|
<div class="player-card">
|
||||||
|
<div class="player-job-icon role-${p.role}">${abbr(p.type)}</div>
|
||||||
|
<div>
|
||||||
|
<div class="player-name">${p.name}</div>
|
||||||
|
<div class="player-type">${p.type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(events, fightStart) {
|
||||||
|
const el = document.getElementById('aoe-timeline');
|
||||||
|
|
||||||
|
if (!events.length) {
|
||||||
|
el.innerHTML = '<div class="empty" style="padding:30px 0"><h3>Keine AoE-Events gefunden</h3></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = events.map(ev => {
|
||||||
|
const mitigIcons = (ev.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})">`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const targets = ev.targets.map(t => `
|
||||||
|
<div class="aoe-target-wrap">
|
||||||
|
<div class="aoe-target">
|
||||||
|
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
|
||||||
|
<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>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="aoe-event">
|
||||||
|
<div class="aoe-time">${fmtTime(ev.timestamp, fightStart)}</div>
|
||||||
|
<div>
|
||||||
|
<div class="aoe-ability">
|
||||||
|
${ev.abilityName}
|
||||||
|
<span class="aoe-total">— ${fmtDmg(ev.totalDamage)} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="aoe-targets">${targets}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEmpty(msg) {
|
||||||
|
document.getElementById('analysis-loading').style.display = 'none';
|
||||||
|
document.getElementById('analysis-content').style.display = 'none';
|
||||||
|
document.getElementById('analysis-empty').style.display = 'block';
|
||||||
|
document.getElementById('analysis-empty-msg').textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastFightId = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const { reportCode, fightId, fightStart, fightEnd } = window.App ?? {};
|
||||||
|
if (!reportCode || !fightId) return;
|
||||||
|
if (lastFightId === fightId) return;
|
||||||
|
|
||||||
|
document.getElementById('analysis-loading').style.display = 'flex';
|
||||||
|
document.getElementById('analysis-empty').style.display = 'none';
|
||||||
|
document.getElementById('analysis-content').style.display = 'none';
|
||||||
|
|
||||||
|
let json;
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/analysis.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ report_code: reportCode, fight_id: fightId, start_time: fightStart, end_time: fightEnd }),
|
||||||
|
});
|
||||||
|
json = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
setEmpty('Netzwerkfehler: ' + err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
|
||||||
|
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
|
||||||
|
|
||||||
|
lastFightId = fightId;
|
||||||
|
renderPlayers(json.players ?? []);
|
||||||
|
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
|
||||||
|
|
||||||
|
document.getElementById('analysis-loading').style.display = 'none';
|
||||||
|
document.getElementById('analysis-content').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.analysisTab = {
|
||||||
|
onFightSelected: load,
|
||||||
|
onTabOpen: load,
|
||||||
|
reset() { lastFightId = null; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
57
js/app.js
@ -1,7 +1,11 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
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 fightSelectRow = document.getElementById('fight-select-row');
|
const outputCard = document.getElementById('output-card');
|
||||||
|
const initialHint = document.getElementById('initial-hint');
|
||||||
|
const fightSelectCard = document.getElementById('fight-select-card');
|
||||||
const fightSelect = document.getElementById('fight-select');
|
const fightSelect = document.getElementById('fight-select');
|
||||||
|
|
||||||
let allFights = [];
|
let allFights = [];
|
||||||
@ -21,32 +25,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayFight(fight) {
|
function displayFight(fight) {
|
||||||
output.textContent = JSON.stringify(fight, null, 2);
|
output.textContent = JSON.stringify(fight, null, 2);
|
||||||
|
outputCard.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
fightSelect.addEventListener('change', () => {
|
fightSelect.addEventListener('change', () => {
|
||||||
const id = parseInt(fightSelect.value, 10);
|
if (!fightSelect.value) return;
|
||||||
|
const id = parseInt(fightSelect.value, 10);
|
||||||
const fight = allFights.find(f => f.id === id);
|
const fight = allFights.find(f => f.id === id);
|
||||||
if (fight) displayFight(fight);
|
if (!fight) return;
|
||||||
|
|
||||||
|
window.App.fightId = id;
|
||||||
|
window.App.fightStart = fight.startTime;
|
||||||
|
window.App.fightEnd = fight.endTime;
|
||||||
|
|
||||||
|
displayFight(fight);
|
||||||
|
window.analysisTab?.onFightSelected?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
output.textContent = '// fetching...';
|
|
||||||
fightSelectRow.style.display = 'none';
|
initialHint.style.display = 'none';
|
||||||
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
outputCard.style.display = 'block';
|
||||||
|
output.textContent = '// fetching...';
|
||||||
|
fightSelectCard.style.display = 'none';
|
||||||
|
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||||
allFights = [];
|
allFights = [];
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const reportCode = form.elements['report_code'].value.trim();
|
||||||
report_code: form.elements['report_code'].value.trim(),
|
window.App.reportCode = reportCode;
|
||||||
});
|
window.App.fightId = null;
|
||||||
|
window.App.fightStart = 0;
|
||||||
|
window.App.fightEnd = 0;
|
||||||
|
window.analysisTab?.reset?.();
|
||||||
|
|
||||||
let response, json;
|
let response, json;
|
||||||
try {
|
try {
|
||||||
response = await fetch('api/fight.php', {
|
response = await fetch('api/fight.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params.toString(),
|
body: new URLSearchParams({ report_code: reportCode }),
|
||||||
});
|
});
|
||||||
json = await response.json();
|
json = await response.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -78,18 +97,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// populate dropdown
|
|
||||||
allFights.forEach(fight => {
|
allFights.forEach(fight => {
|
||||||
const duration = formatDuration(fight.endTime - fight.startTime);
|
const duration = formatDuration(fight.endTime - fight.startTime);
|
||||||
const hp = formatBossHp(fight);
|
const hp = formatBossHp(fight);
|
||||||
const label = `${fight.name} — ${duration} — ${hp}`;
|
const opt = document.createElement('option');
|
||||||
const opt = document.createElement('option');
|
opt.value = fight.id;
|
||||||
opt.value = fight.id;
|
opt.textContent = `${fight.name} — ${duration} — ${hp}`;
|
||||||
opt.textContent = label;
|
|
||||||
fightSelect.appendChild(opt);
|
fightSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
fightSelectRow.style.display = 'flex';
|
fightSelectCard.style.display = 'block';
|
||||||
output.textContent = '// Fight auswählen ↑';
|
output.textContent = '// Fight auswählen ↑';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
19
js/tabs.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const tabs = document.querySelectorAll('.tabs .tab');
|
||||||
|
const contents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
function showTab(name) {
|
||||||
|
contents.forEach(el => el.style.display = 'none');
|
||||||
|
tabs.forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
|
const content = document.getElementById('tab-' + name);
|
||||||
|
const btn = document.querySelector(`.tabs .tab[data-tab="${name}"]`);
|
||||||
|
|
||||||
|
if (content) content.style.display = 'block';
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
|
||||||
|
if (name === 'analysis') window.analysisTab?.onTabOpen?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));
|
||||||
|
});
|
||||||
6
templates/fight-select.php
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<div class="card section-gap" id="fight-select-card" style="display:none">
|
||||||
|
<div class="card-title">Fight auswählen</div>
|
||||||
|
<select id="fight-select">
|
||||||
|
<option value="">— Fight auswählen —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
33
templates/login.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<div id="login-overlay">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">REPORT VIEWER <span>Final Fantasy XIV</span></div>
|
||||||
|
|
||||||
|
<?php if ($errorText): ?>
|
||||||
|
<div class="error"><?= htmlspecialchars($errorText) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($tokenDebug): ?>
|
||||||
|
<div class="error" style="text-align:left">
|
||||||
|
<strong>Debug — FFLogs Token Response:</strong><br>
|
||||||
|
HTTP <?= (int)$tokenDebug['http_status'] ?><?= $tokenDebug['curl_error'] ? ' | curl: ' . htmlspecialchars($tokenDebug['curl_error']) : '' ?><br>
|
||||||
|
<pre style="margin-top:0.5rem;white-space:pre-wrap;font-size:0.8rem;"><?= htmlspecialchars($tokenDebug['response_body'] ?? '(empty)') ?></pre>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p class="login-desc">
|
||||||
|
<?php if ($tokenExpired): ?>
|
||||||
|
Session abgelaufen — bitte erneut mit FFLogs verbinden.
|
||||||
|
<?php else: ?>
|
||||||
|
Verbinde deinen FFLogs-Account, um Report-Daten abzurufen und zu analysieren.
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="auth/start.php" class="btn btn-gold btn-login">
|
||||||
|
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="login-hint">
|
||||||
|
Du wirst zu FFLogs weitergeleitet, um den Zugriff zu autorisieren.<br>
|
||||||
|
Deine Zugangsdaten werden niemals an diese App übermittelt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
9
templates/output-card.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<div class="card" id="output-card" style="display:none">
|
||||||
|
<div class="card-title">Ausgabe</div>
|
||||||
|
<pre id="output" class="terminal"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="initial-hint" class="empty">
|
||||||
|
<div class="empty-icon">⚔</div>
|
||||||
|
<h3>Report Code eingeben und Fetch klicken</h3>
|
||||||
|
</div>
|
||||||
39
templates/page.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>FFLogs Report Viewer</title>
|
||||||
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/analysis.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<?php if (!$authenticated): ?>
|
||||||
|
<?php require __DIR__ . '/login.php'; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div id="app">
|
||||||
|
<?php require __DIR__ . '/topbar.php'; ?>
|
||||||
|
<main id="main">
|
||||||
|
<?php if ($errorText): ?>
|
||||||
|
<div class="error section-gap"><?= htmlspecialchars($errorText) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div id="tab-report" class="tab-content">
|
||||||
|
<?php require __DIR__ . '/tab-report.php'; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-analysis" class="tab-content" style="display:none">
|
||||||
|
<?php require __DIR__ . '/tab-analysis.php'; ?>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
<script src="js/tabs.js"></script>
|
||||||
|
<script src="js/analysis.js"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
templates/report-form.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<div class="card section-gap">
|
||||||
|
<div class="card-title">Report laden</div>
|
||||||
|
<form id="report-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="fg">
|
||||||
|
<label>Report Code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="report_code"
|
||||||
|
placeholder="z.B. aBcDeFgH1234"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
|
||||||
|
<a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
23
templates/tab-analysis.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<div id="analysis-loading" class="loading" style="display:none">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Analysiere Fight-Daten...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="analysis-empty" class="empty">
|
||||||
|
<div class="empty-icon">📊</div>
|
||||||
|
<h3 id="analysis-empty-msg">Bitte zuerst einen Fight im Report-Tab auswählen</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="analysis-content" style="display:none">
|
||||||
|
|
||||||
|
<div class="card section-gap">
|
||||||
|
<div class="card-title">Spieler</div>
|
||||||
|
<div id="player-grid" class="player-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">AoE Timeline</div>
|
||||||
|
<div id="aoe-timeline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
3
templates/tab-report.php
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?php require __DIR__ . '/report-form.php'; ?>
|
||||||
|
<?php require __DIR__ . '/fight-select.php'; ?>
|
||||||
|
<?php require __DIR__ . '/output-card.php'; ?>
|
||||||
8
templates/topbar.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<header id="topbar">
|
||||||
|
<div class="logo">REPORT VIEWER <span>Final Fantasy XIV</span></div>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-tab="report">⚔ Report</button>
|
||||||
|
<button class="tab" data-tab="analysis">📊 Analyse</button>
|
||||||
|
</nav>
|
||||||
|
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
|
||||||
|
</header>
|
||||||