forked from xziino/ff14-mitigator
Compare commits
12 Commits
ea00268227
...
7c6f443a53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c6f443a53 | ||
|
|
89b4849ed4 | ||
| cc36aa27c4 | |||
|
|
969484a1dc | ||
|
|
8f29619ef5 | ||
|
|
e2eed52d07 | ||
|
|
d73dd340c2 | ||
|
|
8f832e1a0a | ||
|
|
ed4b72654b | ||
|
|
4107779e2a | ||
|
|
19bd79c056 | ||
|
|
139ea8d3ac |
@ -129,6 +129,17 @@ const MITIGATION_ABILITIES = [
|
|||||||
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
|
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function localizedMitigationName(string $key, string $language, string $fallback): string {
|
||||||
|
$names = [
|
||||||
|
'de' => [
|
||||||
|
'Reprisal' => 'Reflexion',
|
||||||
|
'Feint' => 'Zermürben',
|
||||||
|
'Addle' => 'Stumpfsinn',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
return $names[$language][$key] ?? $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
|
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
|
||||||
if ($buffStr === '') return [];
|
if ($buffStr === '') return [];
|
||||||
$result = [];
|
$result = [];
|
||||||
@ -208,6 +219,7 @@ foreach ($abilityNames as $gameId => $name) {
|
|||||||
foreach (MITIGATION_ABILITIES as $name => $meta) {
|
foreach (MITIGATION_ABILITIES as $name => $meta) {
|
||||||
if (isset($meta['statusId']) && !isset($mitigIdMap[$meta['statusId']])) {
|
if (isset($meta['statusId']) && !isset($mitigIdMap[$meta['statusId']])) {
|
||||||
$displayName = $abilityNames[(int)$meta['statusId']] ?? $name;
|
$displayName = $abilityNames[(int)$meta['statusId']] ?? $name;
|
||||||
|
$displayName = localizedMitigationName($name, $language, $displayName);
|
||||||
$mitigIdMap[$meta['statusId']] = array_merge(['key' => $name, 'name' => $displayName], $meta);
|
$mitigIdMap[$meta['statusId']] = array_merge(['key' => $name, 'name' => $displayName], $meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,6 +345,7 @@ if (!empty($shieldStatusIds)) {
|
|||||||
// Group events by abilityId, then cluster by time proximity (≤ 1000ms from
|
// Group events by abilityId, then cluster by time proximity (≤ 1000ms from
|
||||||
// the first event in the cluster) to avoid fixed-window boundary splits.
|
// the first event in the cluster) to avoid fixed-window boundary splits.
|
||||||
const CLUSTER_WINDOW_MS = 1000;
|
const CLUSTER_WINDOW_MS = 1000;
|
||||||
|
const HEAVY_TANKBUSTER_MIN_HP_RATIO = 0.33;
|
||||||
|
|
||||||
$byAbility = []; // abilityId → [{ts, tgtId, amount, hp, maxHp, buffs, name}]
|
$byAbility = []; // abilityId → [{ts, tgtId, amount, hp, maxHp, buffs, name}]
|
||||||
foreach ($allEvents as $ev) {
|
foreach ($allEvents as $ev) {
|
||||||
@ -404,7 +417,26 @@ foreach ($byAbility as $abId => $events) {
|
|||||||
|
|
||||||
$aoeEvents = [];
|
$aoeEvents = [];
|
||||||
foreach ($clusters as $group) {
|
foreach ($clusters as $group) {
|
||||||
if (count($group['targets']) < 3) continue;
|
$targetCount = count($group['targets']);
|
||||||
|
$isHeavyTankbuster = false;
|
||||||
|
if ($targetCount < 3) {
|
||||||
|
foreach ($group['targets'] as $tgtId => $tgt) {
|
||||||
|
$p = $players[$tgtId] ?? null;
|
||||||
|
if (($p['role'] ?? null) !== 'tank') continue;
|
||||||
|
|
||||||
|
$tankMaxHp = (int)($tgt['maxHp'] ?? 0);
|
||||||
|
$rawDamage = max(
|
||||||
|
(int)($tgt['unmitigatedAmount'] ?? 0),
|
||||||
|
(int)($tgt['amount'] ?? 0) + (int)($tgt['absorbed'] ?? 0) + (int)($tgt['mitigated'] ?? 0)
|
||||||
|
);
|
||||||
|
if ($tankMaxHp > 0 && $rawDamage >= $tankMaxHp * HEAVY_TANKBUSTER_MIN_HP_RATIO) {
|
||||||
|
$isHeavyTankbuster = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
|
||||||
|
|
||||||
$targets = [];
|
$targets = [];
|
||||||
foreach ($group['targets'] as $tgtId => $tgt) {
|
foreach ($group['targets'] as $tgtId => $tgt) {
|
||||||
@ -443,11 +475,12 @@ foreach ($clusters as $group) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$aoeEvents[] = [
|
$aoeEvents[] = [
|
||||||
'timestamp' => $group['timestamp'],
|
'timestamp' => $group['timestamp'],
|
||||||
'abilityId' => $group['abilityId'],
|
'abilityId' => $group['abilityId'],
|
||||||
'abilityName' => $group['abilityName'],
|
'abilityName' => $group['abilityName'],
|
||||||
'targets' => $targets,
|
'targets' => $targets,
|
||||||
'totalDamage' => array_sum(array_column($targets, 'amount')),
|
'totalDamage' => array_sum(array_column($targets, 'amount')),
|
||||||
|
'isHeavyTankbuster' => $isHeavyTankbuster,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||||
|
|||||||
@ -2,10 +2,17 @@
|
|||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
session_start_safe();
|
session_start_safe();
|
||||||
|
|
||||||
|
function redirect_with_error(string $returnPath, string $error): void {
|
||||||
|
$separator = str_contains($returnPath, '?') ? '&' : '?';
|
||||||
|
header('Location: ' . $returnPath . $separator . 'error=' . rawurlencode($error));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnPath = safe_return_path($_SESSION['oauth_return'] ?? null);
|
||||||
|
|
||||||
// user denied access
|
// user denied access
|
||||||
if (isset($_GET['error'])) {
|
if (isset($_GET['error'])) {
|
||||||
header('Location: ../index.php?error=' . urlencode($_GET['error']));
|
redirect_with_error($returnPath, $_GET['error']);
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
@ -21,12 +28,11 @@ if (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (empty($_GET['code'])) {
|
if (empty($_GET['code'])) {
|
||||||
header('Location: ../index.php?error=missing_code');
|
redirect_with_error($returnPath, 'missing_code');
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$verifier = $_SESSION['pkce_verifier'];
|
$verifier = $_SESSION['pkce_verifier'];
|
||||||
unset($_SESSION['pkce_verifier'], $_SESSION['oauth_state']);
|
unset($_SESSION['pkce_verifier'], $_SESSION['oauth_state'], $_SESSION['oauth_return']);
|
||||||
|
|
||||||
$post = http_build_query([
|
$post = http_build_query([
|
||||||
'grant_type' => 'authorization_code',
|
'grant_type' => 'authorization_code',
|
||||||
@ -56,12 +62,11 @@ if ($curlError || $status !== 200 || empty($data['access_token'])) {
|
|||||||
'http_status' => $status,
|
'http_status' => $status,
|
||||||
'response_body' => $body,
|
'response_body' => $body,
|
||||||
];
|
];
|
||||||
header('Location: ../index.php?error=token_failed');
|
redirect_with_error($returnPath, 'token_failed');
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION['access_token'] = $data['access_token'];
|
$_SESSION['access_token'] = $data['access_token'];
|
||||||
$_SESSION['token_expires'] = time() + ($data['expires_in'] ?? 3600);
|
$_SESSION['token_expires'] = time() + ($data['expires_in'] ?? 3600);
|
||||||
|
|
||||||
header('Location: ../index.php');
|
header('Location: ' . $returnPath);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ $state = bin2hex(random_bytes(16));
|
|||||||
|
|
||||||
$_SESSION['pkce_verifier'] = $verifier;
|
$_SESSION['pkce_verifier'] = $verifier;
|
||||||
$_SESSION['oauth_state'] = $state;
|
$_SESSION['oauth_state'] = $state;
|
||||||
|
$_SESSION['oauth_return'] = safe_return_path($_GET['return'] ?? ($_SERVER['HTTP_REFERER'] ?? null));
|
||||||
|
|
||||||
$params = http_build_query([
|
$params = http_build_query([
|
||||||
'response_type' => 'code',
|
'response_type' => 'code',
|
||||||
|
|||||||
224
css/planner.css
224
css/planner.css
@ -137,27 +137,59 @@
|
|||||||
color: var(--t3);
|
color: var(--t3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Job Slots Placeholder ───────────────────────────────────────────────────── */
|
/* ── Job Slots ────────────────────────────────────────────────────────────────── */
|
||||||
.job-slots-placeholder {
|
.job-slots-grid {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
text-align: center;
|
gap: 8px;
|
||||||
color: var(--t3);
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg2);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
border-radius: var(--r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-slot {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-slot select {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-slot--tank select { border-left-color: var(--blue); }
|
||||||
|
.job-slot--healer select { border-left-color: var(--green); }
|
||||||
|
.job-slot--dps select { border-left-color: rgba(200,168,75,.5); }
|
||||||
|
|
||||||
/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */
|
/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */
|
||||||
.mechanic-card {
|
.mechanic-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 52px 1fr;
|
grid-template-columns: 52px 1fr auto;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 12px 0;
|
padding: 12px 6px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
border-radius: var(--r);
|
||||||
|
margin: 0 -6px;
|
||||||
}
|
}
|
||||||
.mechanic-card:last-child { border-bottom: none; }
|
.mechanic-card:last-child { border-bottom: none; }
|
||||||
|
.mechanic-card:hover { background: var(--bg2); }
|
||||||
|
|
||||||
|
.mechanic-delete-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.mechanic-card:hover .mechanic-delete-btn { opacity: 1; }
|
||||||
|
|
||||||
|
.mechanic-edit-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t3);
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.mechanic-card:hover .mechanic-edit-hint { opacity: 1; }
|
||||||
|
|
||||||
.mechanic-time {
|
.mechanic-time {
|
||||||
font-family: var(--font-d);
|
font-family: var(--font-d);
|
||||||
@ -204,13 +236,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-assign {
|
.badge-assign {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
background: var(--bg2);
|
background: var(--bg2);
|
||||||
border: 1px solid var(--borderem);
|
border: 1px solid var(--borderem);
|
||||||
border-radius: var(--r);
|
border-radius: var(--r);
|
||||||
padding: 2px 8px;
|
padding: 4px 10px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
color: var(--t2);
|
color: var(--t2);
|
||||||
}
|
}
|
||||||
|
.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
|
||||||
|
.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); }
|
||||||
|
.badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); }
|
||||||
|
|
||||||
|
.badge-assign--missing-job {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.badge-assign:hover .badge-remove { opacity: 0.6; }
|
||||||
|
.badge-remove:hover { opacity: 1 !important; }
|
||||||
|
|
||||||
.mechanic-notes {
|
.mechanic-notes {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -218,3 +283,136 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Import Modal ────────────────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
background: var(--bgcard);
|
||||||
|
border: 1px solid var(--borderem);
|
||||||
|
border-radius: var(--rl);
|
||||||
|
padding: 28px 32px;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-family: var(--font-d);
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-radio-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--t1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
/* Override global input styles that break radio button layout */
|
||||||
|
.modal-radio-label input[type="radio"] {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-subsection {
|
||||||
|
margin-left: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.modal-subsection input,
|
||||||
|
.modal-subsection select {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ability Assignment Modal ────────────────────────────────────────────────── */
|
||||||
|
.ability-modal-box {
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ability-job-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ability-job-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ability-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ability-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--borderem);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.ability-chip:hover { background: var(--bg3); color: var(--t1); }
|
||||||
|
|
||||||
|
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
|
||||||
|
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
|
||||||
|
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
|
||||||
|
|
||||||
|
.ability-chip--other-job { opacity: 0.45; }
|
||||||
|
|||||||
@ -77,6 +77,20 @@
|
|||||||
return `${min}:${sec}`;
|
return `${min}:${sec}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFightName(name) {
|
||||||
|
return String(name ?? '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFightName() {
|
||||||
|
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
|
||||||
|
return normalizeFightName(fight?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameFightName(fight) {
|
||||||
|
const name = currentFightName();
|
||||||
|
return name !== '' && normalizeFightName(fight?.name) === name;
|
||||||
|
}
|
||||||
|
|
||||||
let hiddenPlayers = new Set();
|
let hiddenPlayers = new Set();
|
||||||
let hiddenPlayerNames = new Set();
|
let hiddenPlayerNames = new Set();
|
||||||
let lastEvents = [];
|
let lastEvents = [];
|
||||||
@ -265,7 +279,7 @@
|
|||||||
let allSameReportFights = [];
|
let allSameReportFights = [];
|
||||||
|
|
||||||
function populateRefFightSelect() {
|
function populateRefFightSelect() {
|
||||||
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId);
|
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
|
||||||
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
||||||
visible.forEach(f => {
|
visible.forEach(f => {
|
||||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||||
@ -288,8 +302,22 @@
|
|||||||
const refExtPanel = document.getElementById('ref-ext-panel');
|
const refExtPanel = document.getElementById('ref-ext-panel');
|
||||||
const refReportInput = document.getElementById('ref-report-input');
|
const refReportInput = document.getElementById('ref-report-input');
|
||||||
const refReportLoad = document.getElementById('ref-report-load');
|
const refReportLoad = document.getElementById('ref-report-load');
|
||||||
|
const refFflogsLink = document.getElementById('ref-fflogs-report-link');
|
||||||
const refExtFightSelect = document.getElementById('ref-ext-fight-select');
|
const refExtFightSelect = document.getElementById('ref-ext-fight-select');
|
||||||
|
|
||||||
|
function updateRefFflogsLink(fightId = 0) {
|
||||||
|
if (!extReportCode) {
|
||||||
|
refFflogsLink.style.display = 'none';
|
||||||
|
refFflogsLink.href = '#';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refFflogsLink.href = window.App?.fflogsReportUrl
|
||||||
|
? window.App.fflogsReportUrl(extReportCode, fightId)
|
||||||
|
: `https://www.fflogs.com/reports/${encodeURIComponent(extReportCode)}${fightId ? `#fight=${fightId}` : ''}`;
|
||||||
|
refFflogsLink.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
refReportInput.addEventListener('input', () => {
|
refReportInput.addEventListener('input', () => {
|
||||||
const match = refReportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/);
|
const match = refReportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/);
|
||||||
if (match) refReportInput.value = match[1];
|
if (match) refReportInput.value = match[1];
|
||||||
@ -313,13 +341,14 @@
|
|||||||
body: new URLSearchParams({ report_code: code, language: window.App.language }),
|
body: new URLSearchParams({ report_code: code, language: window.App.language }),
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
|
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
|
||||||
|
|
||||||
const fights = json?.data?.reportData?.report?.fights ?? [];
|
const fights = json?.data?.reportData?.report?.fights ?? [];
|
||||||
extFights = fights;
|
extFights = fights;
|
||||||
extReportCode = code;
|
extReportCode = code;
|
||||||
|
updateRefFflogsLink();
|
||||||
|
|
||||||
const visibleExt = fights;
|
const visibleExt = fights.filter(isSameFightName);
|
||||||
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||||
visibleExt.forEach(f => {
|
visibleExt.forEach(f => {
|
||||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||||
@ -330,8 +359,9 @@
|
|||||||
});
|
});
|
||||||
refExtFightSelect.style.display = visibleExt.length ? '' : 'none';
|
refExtFightSelect.style.display = visibleExt.length ? '' : 'none';
|
||||||
refExtPanel.style.display = '';
|
refExtPanel.style.display = '';
|
||||||
if (preferredFightId) {
|
if (preferredFightId && visibleExt.some(f => f.id === preferredFightId)) {
|
||||||
refExtFightSelect.value = String(preferredFightId);
|
refExtFightSelect.value = String(preferredFightId);
|
||||||
|
updateRefFflogsLink(preferredFightId);
|
||||||
await loadExternalCompare(preferredFightId);
|
await loadExternalCompare(preferredFightId);
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
@ -392,7 +422,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
refExtFightSelect.addEventListener('change', async () => {
|
refExtFightSelect.addEventListener('change', async () => {
|
||||||
await loadExternalCompare(parseInt(refExtFightSelect.value, 10));
|
const refId = parseInt(refExtFightSelect.value, 10) || 0;
|
||||||
|
updateRefFflogsLink(refId);
|
||||||
|
await loadExternalCompare(refId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Timeline rendering ────────────────────────────────────────────────────
|
// ── Timeline rendering ────────────────────────────────────────────────────
|
||||||
@ -427,6 +459,7 @@
|
|||||||
!hiddenPlayers.has(t.id) &&
|
!hiddenPlayers.has(t.id) &&
|
||||||
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
|
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
|
||||||
);
|
);
|
||||||
|
if (ev.isHeavyTankbuster && !visibleTargets.some(t => t.role === 'tank')) return '';
|
||||||
if (!visibleTargets.length) return '';
|
if (!visibleTargets.length) return '';
|
||||||
|
|
||||||
// Collect boss debuffs (Reprisal/Feint/Addle) once at event level
|
// Collect boss debuffs (Reprisal/Feint/Addle) once at event level
|
||||||
@ -643,7 +676,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
|
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
|
||||||
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
|
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
|
||||||
|
|
||||||
lastFightId = fightId;
|
lastFightId = fightId;
|
||||||
@ -654,6 +687,9 @@
|
|||||||
|
|
||||||
document.getElementById('analysis-loading').style.display = 'none';
|
document.getElementById('analysis-loading').style.display = 'none';
|
||||||
document.getElementById('analysis-content').style.display = 'block';
|
document.getElementById('analysis-content').style.display = 'block';
|
||||||
|
|
||||||
|
const exportBtn = document.getElementById('export-to-planner-btn');
|
||||||
|
if (exportBtn) exportBtn.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.analysisTab = {
|
window.analysisTab = {
|
||||||
@ -672,6 +708,17 @@
|
|||||||
refFightSelect.dispatchEvent(new Event('change'));
|
refFightSelect.dispatchEvent(new Event('change'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
exportForPlanner() {
|
||||||
|
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
|
||||||
|
return {
|
||||||
|
aoeEvents: lastEvents,
|
||||||
|
fightStart: lastFightStart,
|
||||||
|
phases: window.App?.phases ?? [],
|
||||||
|
players: currentPlayers,
|
||||||
|
fightName: fight?.name ?? `Fight ${window.App?.fightId ?? '?'}`,
|
||||||
|
reportCode: window.App?.reportCode ?? '',
|
||||||
|
};
|
||||||
|
},
|
||||||
reset() {
|
reset() {
|
||||||
lastFightId = null;
|
lastFightId = null;
|
||||||
refEvents = [];
|
refEvents = [];
|
||||||
@ -684,7 +731,15 @@
|
|||||||
refFightSelect.style.display = 'none';
|
refFightSelect.style.display = 'none';
|
||||||
refExtFightSelect.value = '';
|
refExtFightSelect.value = '';
|
||||||
refExtFightSelect.style.display = 'none';
|
refExtFightSelect.style.display = 'none';
|
||||||
|
refFflogsLink.style.display = 'none';
|
||||||
|
refFflogsLink.href = '#';
|
||||||
refExtPanel.style.display = 'none';
|
refExtPanel.style.display = 'none';
|
||||||
|
const exportBtn = document.getElementById('export-to-planner-btn');
|
||||||
|
if (exportBtn) exportBtn.style.display = 'none';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
|
||||||
|
window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner());
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
40
js/app.js
40
js/app.js
@ -7,6 +7,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const initialHint = document.getElementById('initial-hint');
|
const initialHint = document.getElementById('initial-hint');
|
||||||
const fightSelectCard = document.getElementById('fight-select-card');
|
const fightSelectCard = document.getElementById('fight-select-card');
|
||||||
const fightSelect = document.getElementById('fight-select');
|
const fightSelect = document.getElementById('fight-select');
|
||||||
|
const fflogsReportLink = document.getElementById('fflogs-report-link');
|
||||||
const languageSelect = document.getElementById('language-select');
|
const languageSelect = document.getElementById('language-select');
|
||||||
const explorerCard = document.getElementById('event-explorer-card');
|
const explorerCard = document.getElementById('event-explorer-card');
|
||||||
const exLoadBtn = document.getElementById('ex-load-btn');
|
const exLoadBtn = document.getElementById('ex-load-btn');
|
||||||
@ -59,6 +60,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
window.App.setUrlState = setUrlState;
|
window.App.setUrlState = setUrlState;
|
||||||
|
|
||||||
|
function authStartUrl() {
|
||||||
|
return 'auth/start.php?return=' + encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
|
}
|
||||||
|
window.App.authStartUrl = authStartUrl;
|
||||||
|
|
||||||
|
function fflogsReportUrl(reportCode, fightId = 0) {
|
||||||
|
const code = String(reportCode || '').trim();
|
||||||
|
if (!code) return '#';
|
||||||
|
|
||||||
|
const host = {
|
||||||
|
de: 'de.fflogs.com',
|
||||||
|
fr: 'fr.fflogs.com',
|
||||||
|
jp: 'ja.fflogs.com',
|
||||||
|
}[window.App.language] ?? 'www.fflogs.com';
|
||||||
|
const fight = parseInt(fightId, 10) || 0;
|
||||||
|
return `https://${host}/reports/${encodeURIComponent(code)}${fight ? `#fight=${fight}` : ''}`;
|
||||||
|
}
|
||||||
|
window.App.fflogsReportUrl = fflogsReportUrl;
|
||||||
|
|
||||||
|
function updateFflogsReportLink() {
|
||||||
|
if (!window.App.reportCode) {
|
||||||
|
fflogsReportLink.style.display = 'none';
|
||||||
|
fflogsReportLink.href = '#';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fflogsReportLink.href = fflogsReportUrl(window.App.reportCode, window.App.fightId);
|
||||||
|
fflogsReportLink.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
const initialUrlState = getUrlState();
|
const initialUrlState = getUrlState();
|
||||||
const storedLanguage = localStorage.getItem('ff14-mitigator-language');
|
const storedLanguage = localStorage.getItem('ff14-mitigator-language');
|
||||||
const legacyTranslateLanguage = initialUrlState.translate === '1' ? 'de' : 'en';
|
const legacyTranslateLanguage = initialUrlState.translate === '1' ? 'de' : 'en';
|
||||||
@ -72,6 +103,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
window.App.language = normalizeLanguage(languageSelect.value);
|
window.App.language = normalizeLanguage(languageSelect.value);
|
||||||
localStorage.setItem('ff14-mitigator-language', window.App.language);
|
localStorage.setItem('ff14-mitigator-language', window.App.language);
|
||||||
setUrlState({ language: window.App.language });
|
setUrlState({ language: window.App.language });
|
||||||
|
window.dispatchEvent(new CustomEvent('ff14-language-change', { detail: { language: window.App.language } }));
|
||||||
if (window.App.reportCode) {
|
if (window.App.reportCode) {
|
||||||
loadReport(window.App.reportCode, window.App.fightId);
|
loadReport(window.App.reportCode, window.App.fightId);
|
||||||
}
|
}
|
||||||
@ -114,6 +146,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
window.App.fightStart = fight.startTime;
|
window.App.fightStart = fight.startTime;
|
||||||
window.App.fightEnd = fight.endTime;
|
window.App.fightEnd = fight.endTime;
|
||||||
window.App.phases = buildPhases(fight);
|
window.App.phases = buildPhases(fight);
|
||||||
|
updateFflogsReportLink();
|
||||||
|
|
||||||
displayFight(fight);
|
displayFight(fight);
|
||||||
explorerCard.style.display = 'block';
|
explorerCard.style.display = 'block';
|
||||||
@ -210,7 +243,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
body: new URLSearchParams(params),
|
body: new URLSearchParams(params),
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
|
if (json.reauth) { window.location.href = authStartUrl(); return; }
|
||||||
output.textContent = JSON.stringify(json, null, 2);
|
output.textContent = JSON.stringify(json, null, 2);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
output.textContent = '// Fehler: ' + err.message;
|
output.textContent = '// Fehler: ' + err.message;
|
||||||
@ -224,6 +257,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
fightSelectCard.style.display = 'none';
|
fightSelectCard.style.display = 'none';
|
||||||
explorerCard.style.display = 'none';
|
explorerCard.style.display = 'none';
|
||||||
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||||
|
fflogsReportLink.style.display = 'none';
|
||||||
|
fflogsReportLink.href = '#';
|
||||||
allFights = [];
|
allFights = [];
|
||||||
|
|
||||||
window.App.reportCode = reportCode;
|
window.App.reportCode = reportCode;
|
||||||
@ -258,7 +293,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
if (json.reauth) {
|
if (json.reauth) {
|
||||||
output.textContent = '// session expired — redirecting...';
|
output.textContent = '// session expired — redirecting...';
|
||||||
setTimeout(() => { window.location.href = 'auth/start.php'; }, 1500);
|
setTimeout(() => { window.location.href = authStartUrl(); }, 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,6 +326,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
window.App.fights = allFights;
|
window.App.fights = allFights;
|
||||||
fightSelectCard.style.display = 'block';
|
fightSelectCard.style.display = 'block';
|
||||||
|
updateFflogsReportLink();
|
||||||
window.analysisTab?.onFightsLoaded?.(allFights);
|
window.analysisTab?.onFightsLoaded?.(allFights);
|
||||||
|
|
||||||
if (preferredFightId && selectFight(preferredFightId, true)) return;
|
if (preferredFightId && selectFight(preferredFightId, true)) return;
|
||||||
|
|||||||
700
js/planner.js
700
js/planner.js
@ -89,6 +89,30 @@ function fmtNumber(n) {
|
|||||||
return Number(n).toLocaleString('de-DE');
|
return Number(n).toLocaleString('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentLanguage() {
|
||||||
|
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABILITY_DISPLAY_NAMES = {
|
||||||
|
de: {
|
||||||
|
'Addle': 'Stumpfsinn',
|
||||||
|
'Feint': 'Zermürben',
|
||||||
|
'Reprisal': 'Reflexion',
|
||||||
|
'Passage of Arms': 'Waffengang',
|
||||||
|
'Heart of Light': 'Herz des Lichts',
|
||||||
|
'Sacred Soil': 'Geweihte Erde',
|
||||||
|
'Tactician': 'Taktiker',
|
||||||
|
'Shake It Off': 'Abschütteln',
|
||||||
|
'Shield Samba': 'Schildsamba',
|
||||||
|
'Magick Barrier': 'Magiebarriere',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function localizedAbilityName(key, fallback = key) {
|
||||||
|
const lang = currentLanguage();
|
||||||
|
return ABILITY_DISPLAY_NAMES[lang]?.[key] ?? fallback ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rendering: Plan List ──────────────────────────────────────────────────────
|
// ── Rendering: Plan List ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function renderPlanList() {
|
function renderPlanList() {
|
||||||
@ -175,8 +199,8 @@ function renderPlanDetail(plan) {
|
|||||||
|
|
||||||
<div class="card section-gap">
|
<div class="card section-gap">
|
||||||
<div class="card-title">Jobaufstellung</div>
|
<div class="card-title">Jobaufstellung</div>
|
||||||
<div class="job-slots-placeholder">
|
<div class="job-slots-grid" id="job-slots-grid">
|
||||||
Jobaufstellung wird in einem späteren Schritt konfigurierbar
|
${renderJobSlotsHtml(plan)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -184,16 +208,20 @@ function renderPlanDetail(plan) {
|
|||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<div class="card-title">Mechaniken</div>
|
<div class="card-title">Mechaniken</div>
|
||||||
</div>
|
</div>
|
||||||
${renderMechanicList(plan)}
|
<div id="mechanic-list">
|
||||||
|
${renderMechanicListHtml(plan)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => {
|
document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => {
|
||||||
startRename(plan.id, plan.name);
|
startRename(plan.id, plan.name);
|
||||||
});
|
});
|
||||||
|
initJobSlots(plan.id);
|
||||||
|
initMechanicClicks(plan.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMechanicList(plan) {
|
function renderMechanicListHtml(plan) {
|
||||||
if (plan.mechanics.length === 0) {
|
if (plan.mechanics.length === 0) {
|
||||||
return `
|
return `
|
||||||
<div class="empty" style="padding:30px 0">
|
<div class="empty" style="padding:30px 0">
|
||||||
@ -206,28 +234,136 @@ function renderMechanicList(plan) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return plan.mechanics.map(m => `
|
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
|
||||||
<div class="mechanic-card">
|
|
||||||
<div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div>
|
return plan.mechanics.map(m => {
|
||||||
<div class="mechanic-body">
|
const sorted = sortedAssignments(m.assignments);
|
||||||
${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''}
|
const assignHtml = sorted.length === 0
|
||||||
<div class="mechanic-name">${escHtml(m.name)}</div>
|
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
||||||
${m.unmitigatedDamage
|
: sorted.map(a => {
|
||||||
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert</div>`
|
const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
|
||||||
: ''
|
: a.buffType === 'shield' ? 'badge-assign-shield'
|
||||||
}
|
: 'badge-assign-buff';
|
||||||
<div class="mechanic-assignments">
|
const isMissing = !!a.job && !activeJobSet.has(a.job);
|
||||||
${m.assignments.length === 0
|
const icon = MITIG_ICONS[a.ability] ?? '';
|
||||||
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
const ability = localizedAbilityName(a.ability, a.abilityName ?? a.ability);
|
||||||
: m.assignments.map(a =>
|
const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability);
|
||||||
`<span class="badge badge-assign">${escHtml(a.job)} · ${escHtml(a.ability)}</span>`
|
const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : '';
|
||||||
).join('')
|
return `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
|
||||||
|
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
|
||||||
|
${label}
|
||||||
|
<button class="badge-remove" data-mechanic-id="${escHtml(m.id)}" data-ability="${escHtml(a.ability)}" title="Entfernen">×</button>
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mechanic-card" data-mechanic-id="${escHtml(m.id)}">
|
||||||
|
<div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div>
|
||||||
|
<div class="mechanic-body">
|
||||||
|
${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''}
|
||||||
|
<div class="mechanic-name">${escHtml(m.name)}</div>
|
||||||
|
${m.unmitigatedDamage
|
||||||
|
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert</div>`
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
|
<div class="mechanic-assignments">${assignHtml}</div>
|
||||||
|
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
|
||||||
|
<div class="mechanic-edit-hint">Klicken zum Bearbeiten</div>
|
||||||
</div>
|
</div>
|
||||||
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
|
<button class="mechanic-delete-btn plan-btn plan-btn-danger" data-mechanic-id="${escHtml(m.id)}" title="Mechanik löschen">✕</button>
|
||||||
</div>
|
</div>`;
|
||||||
</div>
|
}).join('');
|
||||||
`).join('');
|
}
|
||||||
|
|
||||||
|
// ── Job Slots ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderJobSlotsHtml(plan) {
|
||||||
|
return Array.from({ length: 8 }, (_, i) => {
|
||||||
|
const job = plan.jobComposition[i] ?? '';
|
||||||
|
const role = JOB_ROLE[job] ?? '';
|
||||||
|
return `
|
||||||
|
<div class="job-slot${role ? ` job-slot--${role}` : ''}">
|
||||||
|
<select class="job-slot-select" data-idx="${i}">
|
||||||
|
<option value="">—</option>
|
||||||
|
${ALL_JOBS.map(g =>
|
||||||
|
`<optgroup label="${escHtml(g.group)}">${g.jobs.map(j =>
|
||||||
|
`<option value="${j}"${j === job ? ' selected' : ''}>${j}</option>`
|
||||||
|
).join('')}</optgroup>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initJobSlots(planId) {
|
||||||
|
const grid = document.getElementById('job-slots-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.addEventListener('change', e => {
|
||||||
|
const sel = e.target.closest('.job-slot-select');
|
||||||
|
if (!sel) return;
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) return;
|
||||||
|
const comp = [...plan.jobComposition];
|
||||||
|
comp[parseInt(sel.dataset.idx, 10)] = sel.value;
|
||||||
|
updatePlan(planId, { jobComposition: comp });
|
||||||
|
const slot = sel.closest('.job-slot');
|
||||||
|
if (slot) {
|
||||||
|
const role = JOB_ROLE[sel.value] ?? '';
|
||||||
|
slot.className = 'job-slot' + (role ? ` job-slot--${role}` : '');
|
||||||
|
}
|
||||||
|
refreshMechanicList(planId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mechanic list helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function refreshMechanicList(planId) {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) return;
|
||||||
|
const el = document.getElementById('mechanic-list');
|
||||||
|
if (el) el.innerHTML = renderMechanicListHtml(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAssignment(planId, mechanicId, abilityName) {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) return;
|
||||||
|
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||||
|
if (!mechanic) return;
|
||||||
|
mechanic.assignments = mechanic.assignments.filter(a => a.ability !== abilityName);
|
||||||
|
updatePlan(planId, { mechanics: plan.mechanics });
|
||||||
|
refreshMechanicList(planId);
|
||||||
|
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteMechanic(planId, mechanicId) {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) return;
|
||||||
|
plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId);
|
||||||
|
updatePlan(planId, { mechanics: plan.mechanics });
|
||||||
|
refreshMechanicList(planId);
|
||||||
|
renderPlanList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMechanicClicks(planId) {
|
||||||
|
const list = document.getElementById('mechanic-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.addEventListener('click', e => {
|
||||||
|
const removeBtn = e.target.closest('.badge-remove');
|
||||||
|
if (removeBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleteBtn = e.target.closest('.mechanic-delete-btn');
|
||||||
|
if (deleteBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteMechanic(planId, deleteBtn.dataset.mechanicId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = e.target.closest('.mechanic-card');
|
||||||
|
if (!card) return;
|
||||||
|
showAbilityModal(planId, card.dataset.mechanicId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rename ────────────────────────────────────────────────────────────────────
|
// ── Rename ────────────────────────────────────────────────────────────────────
|
||||||
@ -306,6 +442,506 @@ function initNewPlanForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ability → Job mapping ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ABILITY_JOB_MAP = {
|
||||||
|
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD', 'Guardian': 'PLD',
|
||||||
|
'Shake It Off': 'WAR', 'Bloodwhetting': 'WAR',
|
||||||
|
'Dark Missionary': 'DRK',
|
||||||
|
'Heart of Light': 'GNB',
|
||||||
|
'Temperance': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
|
||||||
|
'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH',
|
||||||
|
'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH',
|
||||||
|
'Collective Unconscious': 'AST', 'Neutral Sect': 'AST',
|
||||||
|
'Intersection': 'AST', 'the Spire': 'AST',
|
||||||
|
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
|
||||||
|
'Panhaima': 'SGE', 'Haima': 'SGE',
|
||||||
|
'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE',
|
||||||
|
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
|
||||||
|
'Troubadour': 'BRD',
|
||||||
|
'Tactician': 'MCH',
|
||||||
|
'Shield Samba': 'DNC', 'Improvised Finish': 'DNC',
|
||||||
|
'Radiant Aegis': 'SMN',
|
||||||
|
'Magick Barrier': 'RDM',
|
||||||
|
'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOB_FROM_TYPE = {
|
||||||
|
'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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TANK_JOBS = new Set(['PLD', 'WAR', 'DRK', 'GNB']);
|
||||||
|
const MELEE_JOBS = new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']);
|
||||||
|
const CASTER_JOBS = new Set(['BLM', 'SMN', 'RDM', 'PCT']);
|
||||||
|
|
||||||
|
const JOB_ROLE = {
|
||||||
|
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
|
||||||
|
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
|
||||||
|
'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps',
|
||||||
|
'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps',
|
||||||
|
'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_JOBS = [
|
||||||
|
{ group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] },
|
||||||
|
{ group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] },
|
||||||
|
{ group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] },
|
||||||
|
{ group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] },
|
||||||
|
{ group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const JOB_ABILITIES = {
|
||||||
|
'PLD': [
|
||||||
|
{ name: 'Passage of Arms', buffType: 'buff' },
|
||||||
|
{ name: 'Divine Veil', buffType: 'shield' },
|
||||||
|
{ name: 'Guardian', buffType: 'shield' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'WAR': [
|
||||||
|
{ name: 'Shake It Off', buffType: 'shield' },
|
||||||
|
{ name: 'Bloodwhetting', buffType: 'shield' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'DRK': [
|
||||||
|
{ name: 'Dark Missionary', buffType: 'buff' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'GNB': [
|
||||||
|
{ name: 'Heart of Light', buffType: 'buff' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'WHM': [
|
||||||
|
{ name: 'Temperance', buffType: 'buff' },
|
||||||
|
{ name: 'Divine Benison', buffType: 'shield' },
|
||||||
|
{ name: 'Divine Caress', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'SCH': [
|
||||||
|
{ name: 'Sacred Soil', buffType: 'buff' },
|
||||||
|
{ name: 'Expedient', buffType: 'buff' },
|
||||||
|
{ name: 'Fey Illumination', buffType: 'buff' },
|
||||||
|
{ name: 'Galvanize', buffType: 'shield' },
|
||||||
|
{ name: 'Seraphic Veil', buffType: 'shield' },
|
||||||
|
{ name: 'Catalyze', buffType: 'shield' },
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'AST': [
|
||||||
|
{ name: 'Collective Unconscious', buffType: 'buff' },
|
||||||
|
{ name: 'Neutral Sect', buffType: 'shield' },
|
||||||
|
{ name: 'Intersection', buffType: 'shield' },
|
||||||
|
{ name: 'the Spire', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'SGE': [
|
||||||
|
{ name: 'Kerachole', buffType: 'buff' },
|
||||||
|
{ name: 'Holos', buffType: 'buff' },
|
||||||
|
{ name: 'Holosakos', buffType: 'shield' },
|
||||||
|
{ name: 'Panhaima', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Diagnosis', buffType: 'shield' },
|
||||||
|
{ name: 'Differential Diagnosis', buffType: 'shield' },
|
||||||
|
{ name: 'Haima', buffType: 'shield' },
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
|
||||||
|
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
|
||||||
|
'DNC': [
|
||||||
|
{ name: 'Shield Samba', buffType: 'buff' },
|
||||||
|
{ name: 'Improvised Finish', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'MNK': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'DRG': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'NIN': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'SAM': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'RPR': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'VPR': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'BLM': [{ name: 'Addle', buffType: 'debuff' }],
|
||||||
|
'SMN': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Radiant Aegis', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'RDM': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Magick Barrier', buffType: 'buff' },
|
||||||
|
],
|
||||||
|
'PCT': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Tempera Coat', buffType: 'shield' },
|
||||||
|
{ name: 'Tempera Grassa', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const MITIG_ICONS = {
|
||||||
|
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.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',
|
||||||
|
'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',
|
||||||
|
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||||||
|
'Guardian': 'assets/icons/mitigation/guardian.png',
|
||||||
|
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
||||||
|
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
||||||
|
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
||||||
|
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
||||||
|
'Intersection': 'assets/icons/mitigation/intersection.png',
|
||||||
|
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
||||||
|
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
||||||
|
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||||||
|
'Holosakos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
||||||
|
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
||||||
|
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Haima': 'assets/icons/mitigation/haima.png',
|
||||||
|
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
||||||
|
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
||||||
|
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
||||||
|
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
||||||
|
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
||||||
|
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASSIGN_ORDER = { debuff: 0, buff: 1, shield: 2 };
|
||||||
|
|
||||||
|
function sortedAssignments(assignments) {
|
||||||
|
return [...assignments].sort((a, b) =>
|
||||||
|
(ASSIGN_ORDER[a.buffType] ?? 1) - (ASSIGN_ORDER[b.buffType] ?? 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessJob(abilityName, players) {
|
||||||
|
if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName];
|
||||||
|
const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? '');
|
||||||
|
if (abilityName === 'Reprisal') {
|
||||||
|
const tanks = jobs.filter(j => TANK_JOBS.has(j));
|
||||||
|
return tanks.length === 1 ? tanks[0] : '';
|
||||||
|
}
|
||||||
|
if (abilityName === 'Feint') {
|
||||||
|
const melees = jobs.filter(j => MELEE_JOBS.has(j));
|
||||||
|
return melees.length === 1 ? melees[0] : '';
|
||||||
|
}
|
||||||
|
if (abilityName === 'Addle') {
|
||||||
|
const casters = jobs.filter(j => CASTER_JOBS.has(j));
|
||||||
|
return casters.length === 1 ? casters[0] : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mitigationDisplayName(mitigation) {
|
||||||
|
return mitigation?.name ?? mitigation?.key ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mitigationKey(mitigation) {
|
||||||
|
return mitigation?.key ?? mitigation?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AoE Events → Plan Mechanics ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations) {
|
||||||
|
return aoeEvents.map(ev => {
|
||||||
|
const relTs = ev.timestamp - fightStart;
|
||||||
|
const phase = (phases ?? []).filter(p => p.id !== 0).find(p =>
|
||||||
|
ev.timestamp >= p.startTime && ev.timestamp < p.endTime
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonTankTargets = ev.targets.filter(t => t.role !== 'tank' && (t.unmitigatedAmount ?? 0) > 0);
|
||||||
|
const fallbackTargets = ev.targets.filter(t => (t.unmitigatedAmount ?? 0) > 0);
|
||||||
|
const relevantTargets = nonTankTargets.length > 0 ? nonTankTargets : fallbackTargets;
|
||||||
|
const avgUnmit = relevantTargets.length > 0
|
||||||
|
? Math.round(relevantTargets.reduce((s, t) => s + t.unmitigatedAmount, 0) / relevantTargets.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let assignments = [];
|
||||||
|
if (withMitigations) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const t of ev.targets) {
|
||||||
|
for (const m of (t.mitigations ?? [])) {
|
||||||
|
const key = mitigationKey(m);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
assignments.push({
|
||||||
|
ability: key,
|
||||||
|
abilityName: mitigationDisplayName(m),
|
||||||
|
job: guessJob(key, players),
|
||||||
|
buffType: m.buffType ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: ev.abilityName,
|
||||||
|
timestamp: relTs,
|
||||||
|
phase: phase?.name ?? '',
|
||||||
|
unmitigatedDamage: avgUnmit,
|
||||||
|
notes: '',
|
||||||
|
assignments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merge + Create plan from import ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractJobComp(players) {
|
||||||
|
const order = { tank: 0, healer: 1, dps: 2 };
|
||||||
|
const sorted = [...(players ?? [])]
|
||||||
|
.filter(p => JOB_FROM_TYPE[p.type])
|
||||||
|
.sort((a, b) => {
|
||||||
|
const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2);
|
||||||
|
return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8);
|
||||||
|
while (comp.length < 8) comp.push('');
|
||||||
|
return comp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doImport(data, withMitigations, whereMode, mergeId, newName) {
|
||||||
|
const { aoeEvents, fightStart, phases, players, fightName, reportCode } = data;
|
||||||
|
const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations);
|
||||||
|
|
||||||
|
if (whereMode === 'new') {
|
||||||
|
const plan = createPlan(newName || fightName || 'Importierter Plan');
|
||||||
|
return updatePlan(plan.id, {
|
||||||
|
mechanics,
|
||||||
|
source: { reportCode, fightName },
|
||||||
|
jobComposition: extractJobComp(players),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge into existing plan
|
||||||
|
const plan = getPlan(mergeId);
|
||||||
|
if (!plan) return null;
|
||||||
|
|
||||||
|
const merged = [...plan.mechanics];
|
||||||
|
for (const newM of mechanics) {
|
||||||
|
const exists = plan.mechanics.some(m =>
|
||||||
|
m.name === newM.name && Math.abs(m.timestamp - newM.timestamp) < 5000
|
||||||
|
);
|
||||||
|
if (!exists) merged.push(newM);
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
return updatePlan(mergeId, { mechanics: merged });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ability Assignment Modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let abilityModalPlanId = null;
|
||||||
|
let abilityModalMechanicId = null;
|
||||||
|
|
||||||
|
function showAbilityModal(planId, mechanicId) {
|
||||||
|
abilityModalPlanId = planId;
|
||||||
|
abilityModalMechanicId = mechanicId;
|
||||||
|
renderAbilityModalContent();
|
||||||
|
document.getElementById('planner-ability-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAbilityModal() {
|
||||||
|
document.getElementById('planner-ability-modal').style.display = 'none';
|
||||||
|
abilityModalPlanId = null;
|
||||||
|
abilityModalMechanicId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAbilityModalContent() {
|
||||||
|
const plan = getPlan(abilityModalPlanId);
|
||||||
|
if (!plan) return;
|
||||||
|
const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId);
|
||||||
|
if (!mechanic) return;
|
||||||
|
|
||||||
|
document.getElementById('ability-modal-title').textContent = mechanic.name;
|
||||||
|
|
||||||
|
const activeJobs = [...new Set(plan.jobComposition.filter(j => j))];
|
||||||
|
const content = document.getElementById('ability-modal-content');
|
||||||
|
|
||||||
|
if (activeJobs.length === 0) {
|
||||||
|
content.innerHTML = `
|
||||||
|
<p style="color:var(--t3);font-size:13px;padding:8px 0">
|
||||||
|
Bitte zuerst die Jobaufstellung konfigurieren.
|
||||||
|
</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = activeJobs.map(job => {
|
||||||
|
const abilities = JOB_ABILITIES[job] ?? [];
|
||||||
|
if (!abilities.length) return '';
|
||||||
|
const role = JOB_ROLE[job] ?? 'dps';
|
||||||
|
|
||||||
|
const chips = abilities.map(ab => {
|
||||||
|
const assigned = mechanic.assignments.find(a => a.ability === ab.name);
|
||||||
|
const isActive = !!assigned;
|
||||||
|
const byOtherJob = isActive && assigned.job !== job;
|
||||||
|
const cls = ab.buffType === 'debuff' ? 'badge-assign-debuff'
|
||||||
|
: ab.buffType === 'shield' ? 'badge-assign-shield'
|
||||||
|
: 'badge-assign-buff';
|
||||||
|
const activeClass = isActive ? ' ability-chip--active' : '';
|
||||||
|
const otherClass = byOtherJob ? ' ability-chip--other-job' : '';
|
||||||
|
const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : '';
|
||||||
|
const icon = MITIG_ICONS[ab.name] ?? '';
|
||||||
|
const label = localizedAbilityName(ab.name, ab.name);
|
||||||
|
return `<button class="ability-chip ${cls}${activeClass}${otherClass}"
|
||||||
|
data-ability="${escHtml(ab.name)}"
|
||||||
|
data-job="${escHtml(job)}"
|
||||||
|
data-buff-type="${escHtml(ab.buffType)}"
|
||||||
|
${title ? `title="${title}"` : ''}
|
||||||
|
>${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}${escHtml(label)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="ability-job-group">
|
||||||
|
<div class="ability-job-label">
|
||||||
|
<span class="aoe-target-job role-${role}">${escHtml(job)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ability-chips">${chips}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAbilityAssignment(abilityName, job, buffType) {
|
||||||
|
const plan = getPlan(abilityModalPlanId);
|
||||||
|
if (!plan) return;
|
||||||
|
const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId);
|
||||||
|
if (!mechanic) return;
|
||||||
|
|
||||||
|
const idx = mechanic.assignments.findIndex(a => a.ability === abilityName);
|
||||||
|
if (idx !== -1) {
|
||||||
|
if (mechanic.assignments[idx].job === job) {
|
||||||
|
mechanic.assignments.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
mechanic.assignments[idx].job = job;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mechanic.assignments.push({ ability: abilityName, abilityName: localizedAbilityName(abilityName, abilityName), job, buffType });
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });
|
||||||
|
refreshMechanicList(abilityModalPlanId);
|
||||||
|
renderAbilityModalContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAbilityModal() {
|
||||||
|
const overlay = document.getElementById('planner-ability-modal');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
document.getElementById('ability-modal-close')?.addEventListener('click', hideAbilityModal);
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) hideAbilityModal(); });
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && abilityModalPlanId) hideAbilityModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('ability-modal-content')?.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.ability-chip');
|
||||||
|
if (!btn) return;
|
||||||
|
toggleAbilityAssignment(btn.dataset.ability, btn.dataset.job, btn.dataset.buffType);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import Modal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let pendingImportData = null;
|
||||||
|
|
||||||
|
function showImportModal(data) {
|
||||||
|
pendingImportData = data;
|
||||||
|
|
||||||
|
// Pre-fill name
|
||||||
|
const nameInput = document.getElementById('import-plan-name');
|
||||||
|
if (nameInput) { nameInput.value = data.fightName || ''; }
|
||||||
|
|
||||||
|
// Populate merge dropdown
|
||||||
|
const planSelect = document.getElementById('import-plan-select');
|
||||||
|
if (planSelect) {
|
||||||
|
const plans = loadPlans();
|
||||||
|
planSelect.innerHTML = '<option value="">— Plan auswählen —</option>';
|
||||||
|
plans.forEach(p => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = p.name;
|
||||||
|
planSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
const mergeLabel = document.getElementById('import-merge-label');
|
||||||
|
if (mergeLabel) mergeLabel.style.opacity = plans.length === 0 ? '0.4' : '';
|
||||||
|
const mergeRadio = document.querySelector('input[name="import-where"][value="merge"]');
|
||||||
|
if (mergeRadio) mergeRadio.disabled = plans.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to defaults
|
||||||
|
document.querySelectorAll('input[name="import-what"]').forEach(r => { r.checked = r.value === 'with-mitigations'; });
|
||||||
|
document.querySelectorAll('input[name="import-where"]').forEach(r => { r.checked = r.value === 'new'; });
|
||||||
|
document.getElementById('import-new-section').style.display = '';
|
||||||
|
document.getElementById('import-merge-section').style.display = 'none';
|
||||||
|
|
||||||
|
document.getElementById('planner-import-modal').style.display = 'flex';
|
||||||
|
nameInput?.focus();
|
||||||
|
nameInput?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideImportModal() {
|
||||||
|
document.getElementById('planner-import-modal').style.display = 'none';
|
||||||
|
pendingImportData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initImportModal() {
|
||||||
|
document.querySelectorAll('input[name="import-where"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
document.getElementById('import-new-section').style.display = radio.value === 'new' ? '' : 'none';
|
||||||
|
document.getElementById('import-merge-section').style.display = radio.value === 'merge' ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('import-cancel-btn')?.addEventListener('click', hideImportModal);
|
||||||
|
|
||||||
|
document.getElementById('planner-import-modal')?.addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) hideImportModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && pendingImportData) hideImportModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('import-confirm-btn')?.addEventListener('click', () => {
|
||||||
|
if (!pendingImportData) return;
|
||||||
|
|
||||||
|
const withMitigations = document.querySelector('input[name="import-what"]:checked')?.value === 'with-mitigations';
|
||||||
|
const whereMode = document.querySelector('input[name="import-where"]:checked')?.value ?? 'new';
|
||||||
|
const newName = document.getElementById('import-plan-name')?.value.trim();
|
||||||
|
const mergeId = document.getElementById('import-plan-select')?.value;
|
||||||
|
|
||||||
|
if (whereMode === 'new' && !newName) {
|
||||||
|
document.getElementById('import-plan-name')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (whereMode === 'merge' && !mergeId) {
|
||||||
|
document.getElementById('import-plan-select')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = doImport(pendingImportData, withMitigations, whereMode, mergeId, newName);
|
||||||
|
if (!plan) return;
|
||||||
|
|
||||||
|
hideImportModal();
|
||||||
|
renderPlanList();
|
||||||
|
openPlan(plan.id);
|
||||||
|
window.showTab?.('planner');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── window.plannerTab (hooks for other tabs) ──────────────────────────────────
|
// ── window.plannerTab (hooks for other tabs) ──────────────────────────────────
|
||||||
|
|
||||||
window.plannerTab = {
|
window.plannerTab = {
|
||||||
@ -318,16 +954,26 @@ window.plannerTab = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showImportModal(data) {
|
||||||
|
showImportModal(data);
|
||||||
|
},
|
||||||
|
|
||||||
importFromAnalysis(aoeEvents, _refEvents, _options) {
|
importFromAnalysis(aoeEvents, _refEvents, _options) {
|
||||||
// Schritt 3 — noch nicht implementiert
|
console.log('[Planner] importFromAnalysis — use showImportModal instead', aoeEvents);
|
||||||
console.log('[Planner] importFromAnalysis — not yet implemented', aoeEvents);
|
},
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initNewPlanForm();
|
initNewPlanForm();
|
||||||
|
initImportModal();
|
||||||
|
initAbilityModal();
|
||||||
renderPlanList();
|
renderPlanList();
|
||||||
renderPlanDetail(null);
|
renderPlanDetail(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('ff14-language-change', () => {
|
||||||
|
if (!activePlanId) return;
|
||||||
|
renderPlanDetail(getPlan(activePlanId));
|
||||||
|
});
|
||||||
|
|||||||
@ -17,4 +17,5 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));
|
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));
|
||||||
|
window.showTab = showTab;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<div class="card section-gap" id="fight-select-card" style="display:none">
|
<div class="card section-gap" id="fight-select-card" style="display:none">
|
||||||
<div class="card-title">Fight auswählen</div>
|
<div class="card-title-row">
|
||||||
|
<div class="card-title">Fight auswählen</div>
|
||||||
|
<a id="fflogs-report-link" class="btn btn-sm" href="#" target="_blank" rel="noopener" style="display:none;text-decoration:none;margin-left:auto">FFLogs öffnen</a>
|
||||||
|
</div>
|
||||||
<select id="fight-select">
|
<select id="fight-select">
|
||||||
<option value="">— Fight auswählen —</option>
|
<option value="">— Fight auswählen —</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="auth/start.php" class="btn btn-gold btn-login">
|
<a href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" class="btn btn-gold btn-login">
|
||||||
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Planner Import Modal -->
|
||||||
|
<div id="planner-import-modal" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-title">In Planer exportieren</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-label">Was importieren?</div>
|
||||||
|
<label class="modal-radio-label">
|
||||||
|
<input type="radio" name="import-what" value="with-mitigations" checked>
|
||||||
|
<span>Mechaniken + Mitigation</span>
|
||||||
|
</label>
|
||||||
|
<label class="modal-radio-label">
|
||||||
|
<input type="radio" name="import-what" value="mechanics">
|
||||||
|
<span>Nur Mechaniken</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-label">Wohin?</div>
|
||||||
|
<label class="modal-radio-label">
|
||||||
|
<input type="radio" name="import-where" value="new" checked>
|
||||||
|
<span>Neuer Plan</span>
|
||||||
|
</label>
|
||||||
|
<div id="import-new-section" class="modal-subsection">
|
||||||
|
<input type="text" id="import-plan-name" placeholder="Plan-Name…">
|
||||||
|
</div>
|
||||||
|
<label class="modal-radio-label" id="import-merge-label">
|
||||||
|
<input type="radio" name="import-where" value="merge">
|
||||||
|
<span>In bestehenden Plan mergen</span>
|
||||||
|
</label>
|
||||||
|
<div id="import-merge-section" class="modal-subsection" style="display:none">
|
||||||
|
<select id="import-plan-select">
|
||||||
|
<option value="">— Plan auswählen —</option>
|
||||||
|
</select>
|
||||||
|
<div class="modal-hint">Neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="import-confirm-btn" class="btn btn-gold">Importieren</button>
|
||||||
|
<button id="import-cancel-btn" class="btn">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ability Assignment Modal -->
|
||||||
|
<div id="planner-ability-modal" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-box ability-modal-box">
|
||||||
|
<div class="modal-title" id="ability-modal-title">Mitigations</div>
|
||||||
|
<div id="ability-modal-content"></div>
|
||||||
|
<div class="modal-actions" style="margin-top:16px">
|
||||||
|
<button id="ability-modal-close" class="btn">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/tabs.js"></script>
|
<script src="js/tabs.js"></script>
|
||||||
<script src="js/analysis.js"></script>
|
<script src="js/analysis.js"></script>
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
|
<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>
|
<a class="btn" href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
<div id="ref-ext-panel" style="display:none">
|
<div id="ref-ext-panel" style="display:none">
|
||||||
<input type="text" id="ref-report-input" class="ref-report-input" placeholder="Report-Code">
|
<input type="text" id="ref-report-input" class="ref-report-input" placeholder="Report-Code">
|
||||||
<button id="ref-report-load" class="btn btn-sm">Laden</button>
|
<button id="ref-report-load" class="btn btn-sm">Laden</button>
|
||||||
|
<a id="ref-fflogs-report-link" class="btn btn-sm" href="#" target="_blank" rel="noopener" style="display:none;text-decoration:none">FFLogs öffnen</a>
|
||||||
<select id="ref-ext-fight-select" class="filter-input" style="display:none">
|
<select id="ref-ext-fight-select" class="filter-input" style="display:none">
|
||||||
<option value="">— Fight auswählen —</option>
|
<option value="">— Fight auswählen —</option>
|
||||||
</select>
|
</select>
|
||||||
@ -39,6 +40,7 @@
|
|||||||
<div class="card-title">AoE Timeline</div>
|
<div class="card-title">AoE Timeline</div>
|
||||||
<select id="phase-select" class="filter-input" style="display:none"></select>
|
<select id="phase-select" class="filter-input" style="display:none"></select>
|
||||||
<input type="text" id="player-filter" class="filter-input" placeholder="Spieler filtern…">
|
<input type="text" id="player-filter" class="filter-input" placeholder="Spieler filtern…">
|
||||||
|
<button id="export-to-planner-btn" class="btn btn-sm btn-gold" style="display:none">📋 Exportieren</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="aoe-timeline"></div>
|
<div id="aoe-timeline"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user