forked from xziino/ff14-mitigator
add compare to livelog ability
This commit is contained in:
parent
f6078542ef
commit
bcbf14eb81
@ -18,6 +18,7 @@ $startTime = (float)($_POST['start_time'] ?? 0);
|
|||||||
$endTime = (float)($_POST['end_time'] ?? 0);
|
$endTime = (float)($_POST['end_time'] ?? 0);
|
||||||
$language = strtolower(trim($_POST['language'] ?? 'en'));
|
$language = strtolower(trim($_POST['language'] ?? 'en'));
|
||||||
$language = in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
|
$language = in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
|
||||||
|
$noCache = filter_var($_POST['no_cache'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||||
$translate = 'true';
|
$translate = 'true';
|
||||||
|
|
||||||
if (!$reportCode || !$fightId || !$endTime) {
|
if (!$reportCode || !$fightId || !$endTime) {
|
||||||
@ -27,7 +28,7 @@ if (!$reportCode || !$fightId || !$endTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
|
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
|
||||||
$cached = read_cached_log('analysis', $reportCode, $language, $cacheParts);
|
$cached = $noCache ? null : read_cached_log('analysis', $reportCode, $language, $cacheParts);
|
||||||
if ($cached !== null) {
|
if ($cached !== null) {
|
||||||
echo $cached;
|
echo $cached;
|
||||||
exit;
|
exit;
|
||||||
@ -196,8 +197,11 @@ function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourc
|
|||||||
'buffType' => $mitigIdMap[$id]['buffType'],
|
'buffType' => $mitigIdMap[$id]['buffType'],
|
||||||
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
|
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
|
||||||
];
|
];
|
||||||
$source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players);
|
$source = findBuffSourceEntry($buffSourceTimeline, $id, $ts, $players);
|
||||||
if ($source) $entry['sourcePlayerType'] = $source['type'];
|
if ($source) {
|
||||||
|
$entry['sourcePlayerType'] = $source['player']['type'];
|
||||||
|
$entry['appliedAt'] = $source['apply'];
|
||||||
|
}
|
||||||
$result[] = $entry;
|
$result[] = $entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,7 +209,7 @@ function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
|
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
|
||||||
function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
|
function findBuffSourceEntry(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
|
||||||
$best = null;
|
$best = null;
|
||||||
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
|
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
|
||||||
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
|
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
|
||||||
@ -213,7 +217,11 @@ function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, a
|
|||||||
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
|
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
|
||||||
}
|
}
|
||||||
if ($best === null || empty($best['sourceId'])) return null;
|
if ($best === null || empty($best['sourceId'])) return null;
|
||||||
return $players[$best['sourceId']] ?? null;
|
if (!isset($players[$best['sourceId']])) return null;
|
||||||
|
return [
|
||||||
|
'player' => $players[$best['sourceId']],
|
||||||
|
'apply' => $best['apply'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for shields consumed by a hit: the damage event's buffs field no
|
// Fallback for shields consumed by a hit: the damage event's buffs field no
|
||||||
@ -233,6 +241,7 @@ function shieldsActiveAt(array $shieldTimeline, int $targetId, float $ts, array
|
|||||||
'dr' => $m['dr'],
|
'dr' => $m['dr'],
|
||||||
'buffType' => $m['buffType'],
|
'buffType' => $m['buffType'],
|
||||||
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
|
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
|
||||||
|
'appliedAt' => $iv['apply'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -653,10 +662,37 @@ foreach ($clusters as $group) {
|
|||||||
usort($bossEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
usort($bossEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||||
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||||
|
|
||||||
|
$mitigationCasts = [];
|
||||||
|
$castSeen = [];
|
||||||
|
foreach ($buffSourceTimeline as $statusId => $entries) {
|
||||||
|
if (!isset($mitigIdMap[$statusId])) continue;
|
||||||
|
$meta = $mitigIdMap[$statusId];
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$sourceId = (int)($entry['sourceId'] ?? 0);
|
||||||
|
$apply = (float)($entry['apply'] ?? 0);
|
||||||
|
if ($sourceId <= 0 || $apply <= 0 || !isset($players[$sourceId])) continue;
|
||||||
|
$dedupeKey = $statusId . ':' . $sourceId . ':' . (int)round($apply / 100);
|
||||||
|
if (isset($castSeen[$dedupeKey])) continue;
|
||||||
|
$castSeen[$dedupeKey] = true;
|
||||||
|
$mitigationCasts[] = [
|
||||||
|
'key' => $meta['key'] ?? $meta['name'],
|
||||||
|
'name' => $meta['name'],
|
||||||
|
'buffType' => $meta['buffType'],
|
||||||
|
'sourcePlayerType' => $players[$sourceId]['type'] ?? '',
|
||||||
|
'sourceName' => $players[$sourceId]['name'] ?? '',
|
||||||
|
'timestamp' => $apply,
|
||||||
|
'remove' => $entry['remove'] ?? null,
|
||||||
|
'extraAbilityGameID' => $meta['extraAbilityGameID'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($mitigationCasts, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||||
|
|
||||||
$response = json_encode([
|
$response = json_encode([
|
||||||
'players' => array_values($players),
|
'players' => array_values($players),
|
||||||
'boss_events' => $bossEvents,
|
'boss_events' => $bossEvents,
|
||||||
'aoe_events' => $aoeEvents,
|
'aoe_events' => $aoeEvents,
|
||||||
|
'mitigation_casts' => $mitigationCasts,
|
||||||
'fight_start' => (int)$startTime,
|
'fight_start' => (int)$startTime,
|
||||||
'mitigation_names' => $mitigationNames,
|
'mitigation_names' => $mitigationNames,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
|
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
|
||||||
const CACHED_LOG_VERSION = 'v12';
|
const CACHED_LOG_VERSION = 'v14';
|
||||||
|
|
||||||
function cache_language(string $language): string {
|
function cache_language(string $language): string {
|
||||||
$language = strtolower(trim($language));
|
$language = strtolower(trim($language));
|
||||||
|
|||||||
@ -15,6 +15,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
||||||
$language = strtolower(trim($_POST['language'] ?? 'en'));
|
$language = strtolower(trim($_POST['language'] ?? 'en'));
|
||||||
$language = in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
|
$language = in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
|
||||||
|
$noCache = filter_var($_POST['no_cache'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
|
||||||
if (strlen($reportCode) < 1) {
|
if (strlen($reportCode) < 1) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@ -22,7 +23,7 @@ if (strlen($reportCode) < 1) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cached = read_cached_log('fight', $reportCode, $language);
|
$cached = $noCache ? null : read_cached_log('fight', $reportCode, $language);
|
||||||
if ($cached !== null) {
|
if ($cached !== null) {
|
||||||
echo $cached;
|
echo $cached;
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
437
css/planner.css
437
css/planner.css
@ -143,6 +143,14 @@
|
|||||||
color: var(--t3);
|
color: var(--t3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planner-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Job Slots ────────────────────────────────────────────────────────────────── */
|
/* ── Job Slots ────────────────────────────────────────────────────────────────── */
|
||||||
.job-slots-grid {
|
.job-slots-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -222,6 +230,435 @@
|
|||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Compare Modal ──────────────────────────────────────────────────────────── */
|
||||||
|
.planner-compare-modal-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(1680px, 98vw) !important;
|
||||||
|
height: 96vh !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: 96vh !important;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px 26px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bgcard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-header .modal-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-subtitle {
|
||||||
|
color: var(--t3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 18px 26px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 0.8fr) minmax(0, 1.2fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup-grid .modal-section {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup-grid .name-import-input-row {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup-grid .name-import-input-row .btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup-grid input,
|
||||||
|
.compare-setup-grid select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
padding: 14px 26px 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bgcard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-compare-result { margin-top: 16px; }
|
||||||
|
|
||||||
|
.compare-show-ok-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--t2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-show-ok-toggle input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-live-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) auto;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: rgba(14,18,27,.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-live-header strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--t1);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-live-header span {
|
||||||
|
color: var(--t3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 58px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat strong {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat small {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr);
|
||||||
|
min-height: 68px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: rgba(255,255,255,.018);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: rgba(255,255,255,.03);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-status strong {
|
||||||
|
color: var(--t1);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-status span {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-body {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--t1);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-title small {
|
||||||
|
color: var(--t3);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-warning {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-column-label {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--t3);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-status-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 68px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-status-line:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-status-line--no-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-status-label {
|
||||||
|
align-self: center;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: rgba(255,255,255,.025);
|
||||||
|
color: var(--t3);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-status-label--missing { color: var(--red); border-color: rgba(224,92,92,.45); }
|
||||||
|
.compare-status-label--late { color: #ffb07a; border-color: rgba(255,145,75,.50); }
|
||||||
|
.compare-status-label--early { color: #ffd18a; border-color: rgba(255,190,90,.45); }
|
||||||
|
.compare-status-label--extra { color: var(--blue); border-color: rgba(74,158,255,.45); }
|
||||||
|
.compare-status-label--unknown { color: var(--t3); border-color: rgba(180,190,205,.35); }
|
||||||
|
.compare-status-label--ok { color: #8fd99e; border-color: rgba(88,180,116,.45); }
|
||||||
|
|
||||||
|
.compare-mini-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 25px;
|
||||||
|
padding: 3px 7px 3px 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--t2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-mini-chip img {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-mini-chip strong {
|
||||||
|
color: var(--t1);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-mini-chip span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-mini-chip small {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat--ok,
|
||||||
|
.compare-mini-chip--ok,
|
||||||
|
.compare-row--ok .compare-row-status {
|
||||||
|
border-color: rgba(88,180,116,.45);
|
||||||
|
color: #8fd99e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat--early,
|
||||||
|
.compare-mini-chip--early,
|
||||||
|
.compare-row--early .compare-row-status {
|
||||||
|
border-color: rgba(255,190,90,.50);
|
||||||
|
color: #ffd18a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat--late,
|
||||||
|
.compare-mini-chip--late,
|
||||||
|
.compare-row--late .compare-row-status {
|
||||||
|
border-color: rgba(255,145,75,.55);
|
||||||
|
color: #ffb07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat--missing,
|
||||||
|
.compare-mini-chip--missing,
|
||||||
|
.compare-stat--unmatched,
|
||||||
|
.compare-row--missing .compare-row-status {
|
||||||
|
border-color: rgba(224,92,92,.55);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat--extra,
|
||||||
|
.compare-mini-chip--extra,
|
||||||
|
.compare-row--extra .compare-row-status {
|
||||||
|
border-color: rgba(74,158,255,.45);
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stat--unknown,
|
||||||
|
.compare-mini-chip--unknown,
|
||||||
|
.compare-row--unknown .compare-row-status {
|
||||||
|
border-color: rgba(180,190,205,.35);
|
||||||
|
color: var(--t3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-empty,
|
||||||
|
.compare-loading {
|
||||||
|
color: var(--t3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-empty--panel {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: var(--bg2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(224,92,92,.45);
|
||||||
|
border-radius: var(--r);
|
||||||
|
color: var(--red);
|
||||||
|
background: rgba(224,92,92,.08);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.planner-compare-modal-box {
|
||||||
|
width: 98vw;
|
||||||
|
height: 94vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-header,
|
||||||
|
.compare-modal-body,
|
||||||
|
.compare-modal-footer {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-modal-header-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup-grid,
|
||||||
|
.compare-live-header,
|
||||||
|
.compare-columns,
|
||||||
|
.compare-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-row-status {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */
|
/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */
|
||||||
.mechanic-card {
|
.mechanic-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
518
js/planner.js
518
js/planner.js
@ -431,7 +431,10 @@ function renderPlanDetail(plan) {
|
|||||||
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
|
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
|
||||||
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen">✎</button>
|
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen">✎</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken</div>
|
<div class="planner-header-actions">
|
||||||
|
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken</div>
|
||||||
|
<button id="planner-compare-open-btn" class="btn btn-sm">Compare Log</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -506,6 +509,9 @@ function renderPlanDetail(plan) {
|
|||||||
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
|
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
|
||||||
showNameImportModal(plan.id);
|
showNameImportModal(plan.id);
|
||||||
});
|
});
|
||||||
|
document.getElementById('planner-compare-open-btn')?.addEventListener('click', () => {
|
||||||
|
showCompareModal(plan.id);
|
||||||
|
});
|
||||||
initTimelineOptions(plan.id);
|
initTimelineOptions(plan.id);
|
||||||
initTimeline(plan.id);
|
initTimeline(plan.id);
|
||||||
initMechanicClicks(plan.id);
|
initMechanicClicks(plan.id);
|
||||||
@ -3280,6 +3286,515 @@ function initImportModal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Plan Compare ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const COMPARE_TIMING_TOLERANCE_MS = 3000;
|
||||||
|
let comparePlanId = null;
|
||||||
|
let compareReportCode = '';
|
||||||
|
let compareFights = [];
|
||||||
|
let compareLastResult = null;
|
||||||
|
let compareLastPlan = null;
|
||||||
|
|
||||||
|
function normalizeReportCodeInput(value) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
const match = text.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/i);
|
||||||
|
return (match ? match[1] : text).replace(/[^A-Za-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fightOptionText(fight) {
|
||||||
|
const ms = Math.max(0, (fight.endTime ?? 0) - (fight.startTime ?? 0));
|
||||||
|
const dur = `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`;
|
||||||
|
const hp = fight.kill ? 'Kill' : (fight.fightPercentage != null ? `${fight.fightPercentage.toFixed(2)}%` : '?');
|
||||||
|
return `${fight.name} — ${dur} — ${hp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectActualMitigations(event) {
|
||||||
|
const result = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const target of event?.targets ?? []) {
|
||||||
|
for (const mitigation of target.mitigations ?? []) {
|
||||||
|
const ability = mitigation.key ?? mitigation.name;
|
||||||
|
const sourceJob = JOB_FROM_TYPE[mitigation.sourcePlayerType] ?? '';
|
||||||
|
const key = `${ability}::${sourceJob || '*'}::${Math.round(Number(mitigation.appliedAt ?? 0))}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push({
|
||||||
|
ability,
|
||||||
|
name: mitigation.name ?? ability,
|
||||||
|
sourceJob,
|
||||||
|
appliedAt: Number.isFinite(Number(mitigation.appliedAt)) ? Number(mitigation.appliedAt) : null,
|
||||||
|
buffType: mitigation.buffType ?? abilityDefinition(ability)?.buffType ?? 'buff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectActualCasts(casts, actualFightStart) {
|
||||||
|
return (casts ?? []).map(cast => ({
|
||||||
|
ability: cast.key ?? cast.name,
|
||||||
|
name: cast.name ?? cast.key,
|
||||||
|
sourceJob: JOB_FROM_TYPE[cast.sourcePlayerType] ?? '',
|
||||||
|
sourceName: cast.sourceName ?? '',
|
||||||
|
timestamp: Number(cast.timestamp) || 0,
|
||||||
|
rel: (Number(cast.timestamp) || 0) - actualFightStart,
|
||||||
|
buffType: cast.buffType ?? abilityDefinition(cast.key ?? cast.name)?.buffType ?? 'buff',
|
||||||
|
})).filter(cast => cast.ability && cast.timestamp > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actualMatchesAssignment(actual, assignment) {
|
||||||
|
if ((actual.ability ?? actual.name) !== assignment.ability) return false;
|
||||||
|
if (!assignment.job || !actual.sourceJob) return true;
|
||||||
|
return actual.sourceJob === assignment.job;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCastForAssignment(actualCasts, consumedCasts, assignment, plannedStart, mechanicTime) {
|
||||||
|
const durationMs = assignmentDurationSeconds(assignment) * 1000;
|
||||||
|
const earlyWindow = Math.max(15000, durationMs + 5000);
|
||||||
|
const lateWindow = Math.max(10000, mechanicTime - plannedStart + 5000);
|
||||||
|
const candidates = actualCasts
|
||||||
|
.map((actual, idx) => ({ actual, idx, delta: actual.rel - plannedStart }))
|
||||||
|
.filter(item => !consumedCasts.has(item.idx))
|
||||||
|
.filter(item => actualMatchesAssignment(item.actual, assignment))
|
||||||
|
.filter(item => item.delta >= -earlyWindow && item.delta <= lateWindow)
|
||||||
|
.sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta));
|
||||||
|
const match = candidates[0] ?? null;
|
||||||
|
if (match) consumedCasts.add(match.idx);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActualMechanic(planMechanic, actualEvents, used, actualFightStart) {
|
||||||
|
const expected = Number(planMechanic.timestamp) || 0;
|
||||||
|
const abilityId = Number(planMechanic.abilityId ?? 0);
|
||||||
|
const name = String(planMechanic.name ?? '').trim().toLowerCase();
|
||||||
|
const scored = actualEvents
|
||||||
|
.map((event, idx) => ({ event, idx, rel: (Number(event.timestamp) || 0) - actualFightStart }))
|
||||||
|
.filter(item => !used.has(item.idx))
|
||||||
|
.map(item => {
|
||||||
|
const actualAbilityId = Number(item.event.abilityId ?? 0);
|
||||||
|
const actualName = String(item.event.abilityName ?? '').trim().toLowerCase();
|
||||||
|
const idMatch = abilityId > 0 && actualAbilityId > 0 && abilityId === actualAbilityId;
|
||||||
|
const nameMatch = name !== '' && actualName === name;
|
||||||
|
const timeDiff = Math.abs(item.rel - expected);
|
||||||
|
const closeMatch = timeDiff <= 3000;
|
||||||
|
const score = (idMatch ? 0 : nameMatch ? 1 : closeMatch ? 2 : 99) * 100000
|
||||||
|
+ timeDiff;
|
||||||
|
return { ...item, idMatch, nameMatch, closeMatch, timeDiff, score };
|
||||||
|
})
|
||||||
|
.filter(item => ((item.idMatch || item.nameMatch) && item.timeDiff <= 30000) || item.closeMatch)
|
||||||
|
.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
|
const match = scored[0] ?? null;
|
||||||
|
if (match) used.add(match.idx);
|
||||||
|
return match?.event ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePlanToAnalysis(plan, actualEvents, actualFightStart, actualCastEvents = []) {
|
||||||
|
const usedMechanics = new Set();
|
||||||
|
const actualCasts = collectActualCasts(actualCastEvents, actualFightStart);
|
||||||
|
const consumedCasts = new Set();
|
||||||
|
const rows = [];
|
||||||
|
const summary = { ok: 0, early: 0, late: 0, missing: 0, extra: 0, unmatched: 0, unknown: 0 };
|
||||||
|
|
||||||
|
for (const mechanic of visiblePlanMechanics(plan)) {
|
||||||
|
const actualEvent = findActualMechanic(mechanic, actualEvents, usedMechanics, actualFightStart);
|
||||||
|
const planned = sortedAssignments(plannedAssignmentsForMechanic(plan, mechanic));
|
||||||
|
if (!actualEvent) {
|
||||||
|
summary.unmatched += 1;
|
||||||
|
rows.push({ mechanic, status: 'unmatched', items: planned.map(assignment => ({ status: 'missing', assignment })), extras: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualMitigations = collectActualMitigations(actualEvent);
|
||||||
|
const consumedActual = new Set();
|
||||||
|
const items = planned.map(assignment => {
|
||||||
|
const plannedStart = Number(assignment.sourceStart ?? assignmentStartMs(mechanic, assignment)) || 0;
|
||||||
|
const castMatch = findCastForAssignment(actualCasts, consumedCasts, assignment, plannedStart, mechanic.timestamp);
|
||||||
|
if (!castMatch) {
|
||||||
|
summary.missing += 1;
|
||||||
|
return { status: 'missing', assignment };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualIdx = actualMitigations.findIndex((actual, idx) =>
|
||||||
|
!consumedActual.has(idx) && actualMatchesAssignment(actual, assignment)
|
||||||
|
);
|
||||||
|
if (actualIdx >= 0) consumedActual.add(actualIdx);
|
||||||
|
|
||||||
|
const actual = actualIdx >= 0 ? actualMitigations[actualIdx] : castMatch.actual;
|
||||||
|
const delta = castMatch.delta;
|
||||||
|
const status = delta < -COMPARE_TIMING_TOLERANCE_MS
|
||||||
|
? 'early'
|
||||||
|
: delta > COMPARE_TIMING_TOLERANCE_MS ? 'late' : 'ok';
|
||||||
|
summary[status] += 1;
|
||||||
|
return { status, assignment, actual, delta, coveredHit: actualIdx >= 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const extras = actualMitigations
|
||||||
|
.map((actual, idx) => ({ actual, idx }))
|
||||||
|
.filter(item => !consumedActual.has(item.idx));
|
||||||
|
summary.extra += extras.length;
|
||||||
|
rows.push({ mechanic, actualEvent, status: 'matched', items, extras });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { summary, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareStatusLabel(status) {
|
||||||
|
return {
|
||||||
|
ok: 'OK',
|
||||||
|
early: 'Früh',
|
||||||
|
late: 'Spät',
|
||||||
|
missing: 'Fehlt',
|
||||||
|
extra: 'Extra',
|
||||||
|
unmatched: 'Kein Hit',
|
||||||
|
unknown: 'Timing ?',
|
||||||
|
}[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDeltaText(delta) {
|
||||||
|
if (delta == null) return '';
|
||||||
|
const sec = Math.abs(delta / 1000).toFixed(1).replace(/\.0$/, '');
|
||||||
|
return delta < 0 ? `${sec}s früh` : `${sec}s spät`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRowIssueCount(row) {
|
||||||
|
if (row.status === 'unmatched') return 1;
|
||||||
|
return row.items.filter(item => item.status !== 'ok').length + row.extras.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRowVisible(row, showOk) {
|
||||||
|
return showOk || compareRowSeverity(row) !== 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRowSeverity(row) {
|
||||||
|
if (row.status === 'unmatched') return 'missing';
|
||||||
|
if (row.items.some(item => item.status === 'missing')) return 'missing';
|
||||||
|
if (row.items.some(item => item.status === 'late')) return 'late';
|
||||||
|
if (row.items.some(item => item.status === 'early')) return 'early';
|
||||||
|
if (row.extras.length) return 'extra';
|
||||||
|
if (row.items.some(item => item.status === 'unknown')) return 'unknown';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareStatusText(row) {
|
||||||
|
const severity = compareRowSeverity(row);
|
||||||
|
const count = compareRowIssueCount(row);
|
||||||
|
if (severity === 'ok') return 'OK';
|
||||||
|
if (severity === 'missing') return row.status === 'unmatched' ? 'Kein Hit' : `${count} Problem${count === 1 ? '' : 'e'}`;
|
||||||
|
if (severity === 'extra') return `${count} Extra`;
|
||||||
|
return `${count} Check${count === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareAssignmentChip(assignment, plan) {
|
||||||
|
const icon = abilityIcon(assignment.ability);
|
||||||
|
const name = assignmentAbilityName(assignment, plan);
|
||||||
|
return `
|
||||||
|
<span class="compare-mini-chip">
|
||||||
|
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
|
||||||
|
<strong>${escHtml(assignment.job || '?')}</strong>
|
||||||
|
<span>${escHtml(name)}</span>
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareActualChip(actual, plan, status = 'extra', delta = null, note = '') {
|
||||||
|
const icon = abilityIcon(actual.ability);
|
||||||
|
const name = localizedAbilityName(actual.ability, plan);
|
||||||
|
const deltaHtml = delta == null || status === 'ok' ? '' : `<small>${escHtml(compareDeltaText(delta))}</small>`;
|
||||||
|
const noteHtml = note ? `<small>${escHtml(note)}</small>` : '';
|
||||||
|
return `
|
||||||
|
<span class="compare-mini-chip compare-mini-chip--${escHtml(status)}">
|
||||||
|
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
|
||||||
|
<strong>${escHtml(actual.sourceJob || '?')}</strong>
|
||||||
|
<span>${escHtml(name)}</span>
|
||||||
|
${deltaHtml}
|
||||||
|
${noteHtml}
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareGroupedLines(entries, emptyHtml, options = {}) {
|
||||||
|
const order = ['missing', 'late', 'early', 'extra', 'unknown', 'ok'];
|
||||||
|
const groups = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const status = entry.status || 'unknown';
|
||||||
|
if (!groups.has(status)) groups.set(status, []);
|
||||||
|
groups.get(status).push(entry.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = order
|
||||||
|
.filter(status => groups.has(status))
|
||||||
|
.map(status => `
|
||||||
|
<div class="compare-status-line compare-status-line--${escHtml(status)}${options.hideLabels ? ' compare-status-line--no-label' : ''}">
|
||||||
|
${options.hideLabels ? '' : `<div class="compare-status-label compare-status-label--${escHtml(status)}">${escHtml(compareStatusLabel(status))}</div>`}
|
||||||
|
<div class="compare-chip-list">${groups.get(status).join('')}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
return html || emptyHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompareResults(result, plan, showOk = false) {
|
||||||
|
const stat = result.summary;
|
||||||
|
const issueCount = stat.early + stat.late + stat.missing + stat.extra + stat.unmatched + stat.unknown;
|
||||||
|
const visibleRows = result.rows.filter(row => compareRowVisible(row, showOk));
|
||||||
|
const summaryHtml = [
|
||||||
|
['missing', stat.missing + stat.unmatched, 'Fehlt'],
|
||||||
|
['late', stat.late, 'Spät'],
|
||||||
|
['early', stat.early, 'Früh'],
|
||||||
|
['extra', stat.extra, 'Extra'],
|
||||||
|
['unknown', stat.unknown, 'Timing ?'],
|
||||||
|
['ok', stat.ok, 'OK'],
|
||||||
|
].filter(([key, count]) => count > 0 && (showOk || key !== 'ok'))
|
||||||
|
.map(([key, count, label]) => `<span class="compare-stat compare-stat--${key}"><strong>${count}</strong><small>${label}</small></span>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const rowsHtml = visibleRows.map(row => {
|
||||||
|
const severity = compareRowSeverity(row);
|
||||||
|
const visibleItems = showOk ? row.items : row.items.filter(item => item.status !== 'ok');
|
||||||
|
const plannedEntries = visibleItems.map(item => ({
|
||||||
|
status: item.status,
|
||||||
|
html: compareAssignmentChip(item.assignment, plan),
|
||||||
|
}));
|
||||||
|
const plannedHtml = compareGroupedLines(
|
||||||
|
plannedEntries,
|
||||||
|
row.extras.length ? '<span class="compare-empty">OK ausgeblendet</span>' : '<span class="compare-empty">Nichts geplant</span>',
|
||||||
|
{ hideLabels: true }
|
||||||
|
);
|
||||||
|
const actualEntries = [
|
||||||
|
...visibleItems.map(item => {
|
||||||
|
if (!item.actual) return {
|
||||||
|
status: 'missing',
|
||||||
|
html: `
|
||||||
|
<span class="compare-mini-chip compare-mini-chip--missing">
|
||||||
|
<strong>${escHtml(item.assignment.job || '?')}</strong>
|
||||||
|
<span>${escHtml(assignmentAbilityName(item.assignment, plan))}</span>
|
||||||
|
</span>`,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
status: item.status,
|
||||||
|
html: compareActualChip(item.actual, plan, item.status, item.delta, item.coveredHit ? '' : 'nicht aktiv'),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...row.extras.map(({ actual }) => ({
|
||||||
|
status: 'extra',
|
||||||
|
html: compareActualChip(actual, plan, 'extra'),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const actualHtml = compareGroupedLines(actualEntries, '<span class="compare-empty">Keine erkannte Mitigation</span>');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="compare-row compare-row--${escHtml(severity)}">
|
||||||
|
<div class="compare-row-status">
|
||||||
|
<strong>${escHtml(compareStatusText(row))}</strong>
|
||||||
|
<span>${escHtml(fmtTimestamp(row.mechanic.timestamp))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row-body">
|
||||||
|
<div class="compare-row-title">
|
||||||
|
<strong>${escHtml(row.mechanic.name)}</strong>
|
||||||
|
${row.actualEvent?.abilityName ? `<small>Log: ${escHtml(row.actualEvent.abilityName)}</small>` : ''}
|
||||||
|
${row.status === 'unmatched' ? '<span class="compare-row-warning">Kein passender Hit im Log</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="compare-columns">
|
||||||
|
<div>
|
||||||
|
<div class="compare-column-label">Plan</div>
|
||||||
|
<div class="compare-chip-list">${plannedHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="compare-column-label">Log</div>
|
||||||
|
<div class="compare-chip-list">${actualHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="compare-live-header">
|
||||||
|
<div>
|
||||||
|
<strong>${issueCount ? `${issueCount} offene Checks` : 'Alles passt'}</strong>
|
||||||
|
<span>${showOk ? 'Alle Mechaniken sichtbar' : 'OK-Mechaniken ausgeblendet'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-summary">${summaryHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-list">${rowsHtml || '<div class="compare-empty compare-empty--panel">Keine Probleme gefunden.</div>'}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCompareModal(planId) {
|
||||||
|
comparePlanId = planId;
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
const modal = document.getElementById('planner-compare-modal');
|
||||||
|
const reportInput = document.getElementById('compare-report-input');
|
||||||
|
const fightSection = document.getElementById('compare-fight-section');
|
||||||
|
const fightSelect = document.getElementById('compare-fight-select');
|
||||||
|
const result = document.getElementById('compare-result');
|
||||||
|
const runBtn = document.getElementById('compare-run-btn');
|
||||||
|
const refreshBtn = document.getElementById('compare-refresh-btn');
|
||||||
|
const showOkWrap = document.getElementById('compare-show-ok-wrap');
|
||||||
|
const showOk = document.getElementById('compare-show-ok');
|
||||||
|
if (!modal || !plan) return;
|
||||||
|
|
||||||
|
compareReportCode = '';
|
||||||
|
compareFights = [];
|
||||||
|
compareLastResult = null;
|
||||||
|
compareLastPlan = null;
|
||||||
|
reportInput.value = plan.source?.reportCode ?? window.App?.reportCode ?? '';
|
||||||
|
fightSection.style.display = 'none';
|
||||||
|
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||||
|
result.style.display = 'none';
|
||||||
|
result.innerHTML = '';
|
||||||
|
runBtn.style.display = 'none';
|
||||||
|
refreshBtn.style.display = 'none';
|
||||||
|
showOkWrap.style.display = 'none';
|
||||||
|
showOk.checked = false;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
reportInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCompareModal() {
|
||||||
|
document.getElementById('planner-compare-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCompareModal() {
|
||||||
|
const modal = document.getElementById('planner-compare-modal');
|
||||||
|
const reportInput = document.getElementById('compare-report-input');
|
||||||
|
const loadBtn = document.getElementById('compare-load-btn');
|
||||||
|
const fightSection = document.getElementById('compare-fight-section');
|
||||||
|
const fightSelect = document.getElementById('compare-fight-select');
|
||||||
|
const result = document.getElementById('compare-result');
|
||||||
|
const runBtn = document.getElementById('compare-run-btn');
|
||||||
|
const refreshBtn = document.getElementById('compare-refresh-btn');
|
||||||
|
const showOkWrap = document.getElementById('compare-show-ok-wrap');
|
||||||
|
const showOk = document.getElementById('compare-show-ok');
|
||||||
|
const cancelBtn = document.getElementById('compare-cancel-btn');
|
||||||
|
if (!modal || !reportInput || !loadBtn || !fightSelect || !runBtn || !refreshBtn || !showOkWrap || !showOk || !cancelBtn) return;
|
||||||
|
|
||||||
|
const renderLastCompare = () => {
|
||||||
|
if (!compareLastResult || !compareLastPlan) return;
|
||||||
|
result.innerHTML = renderCompareResults(compareLastResult, compareLastPlan, showOk.checked);
|
||||||
|
result.style.display = '';
|
||||||
|
showOkWrap.style.display = '';
|
||||||
|
refreshBtn.style.display = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCompareFights = async (noCache = false) => {
|
||||||
|
const plan = getPlan(comparePlanId);
|
||||||
|
const code = normalizeReportCodeInput(reportInput.value);
|
||||||
|
if (!code) return false;
|
||||||
|
|
||||||
|
loadBtn.disabled = true;
|
||||||
|
if (!noCache) loadBtn.textContent = 'Lädt…';
|
||||||
|
if (!noCache) {
|
||||||
|
fightSection.style.display = 'none';
|
||||||
|
result.style.display = 'none';
|
||||||
|
runBtn.style.display = 'none';
|
||||||
|
refreshBtn.style.display = 'none';
|
||||||
|
showOkWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/fight.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ report_code: code, language: plannerLanguage(), no_cache: noCache ? '1' : '0' }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return false; }
|
||||||
|
if (json.error) throw new Error(json.error);
|
||||||
|
compareReportCode = code;
|
||||||
|
compareFights = json?.data?.reportData?.report?.fights ?? [];
|
||||||
|
compareFights.forEach(fight => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = fight.id;
|
||||||
|
opt.textContent = fightOptionText(fight);
|
||||||
|
fightSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
const sourceFightId = plan?.source?.reportCode === code ? Number(plan.source?.fightId ?? 0) : 0;
|
||||||
|
if (sourceFightId && compareFights.some(f => Number(f.id) === sourceFightId)) {
|
||||||
|
fightSelect.value = String(sourceFightId);
|
||||||
|
runBtn.style.display = '';
|
||||||
|
}
|
||||||
|
fightSection.style.display = '';
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
result.innerHTML = `<div class="compare-error">${escHtml(err.message || 'Fights konnten nicht geladen werden.')}</div>`;
|
||||||
|
result.style.display = '';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loadBtn.disabled = false;
|
||||||
|
loadBtn.textContent = 'Fights laden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCompare = async (noCache = false) => {
|
||||||
|
const plan = getPlan(comparePlanId);
|
||||||
|
const fightId = parseInt(fightSelect.value, 10);
|
||||||
|
const fight = compareFights.find(f => Number(f.id) === fightId);
|
||||||
|
if (!plan || !fight || !compareReportCode) return;
|
||||||
|
|
||||||
|
runBtn.disabled = true;
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
runBtn.textContent = noCache ? 'Refresh…' : 'Vergleicht…';
|
||||||
|
result.innerHTML = `<div class="compare-loading">${noCache ? 'Livelog wird aktualisiert…' : 'Log wird analysiert…'}</div>`;
|
||||||
|
result.style.display = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/analysis.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
report_code: compareReportCode,
|
||||||
|
fight_id: fight.id,
|
||||||
|
start_time: fight.startTime,
|
||||||
|
end_time: fight.endTime,
|
||||||
|
language: plannerLanguage(),
|
||||||
|
no_cache: noCache ? '1' : '0',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
|
||||||
|
if (json.error) throw new Error(json.error);
|
||||||
|
compareLastPlan = plan;
|
||||||
|
compareLastResult = comparePlanToAnalysis(plan, json.aoe_events ?? [], Number(fight.startTime) || 0, json.mitigation_casts ?? []);
|
||||||
|
renderLastCompare();
|
||||||
|
} catch (err) {
|
||||||
|
result.innerHTML = `<div class="compare-error">${escHtml(err.message || 'Vergleich fehlgeschlagen.')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
runBtn.disabled = false;
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
runBtn.textContent = 'Vergleichen';
|
||||||
|
};
|
||||||
|
|
||||||
|
reportInput.addEventListener('input', () => {
|
||||||
|
const code = normalizeReportCodeInput(reportInput.value);
|
||||||
|
if (code && code !== reportInput.value.trim()) reportInput.value = code;
|
||||||
|
});
|
||||||
|
|
||||||
|
loadBtn.addEventListener('click', () => loadCompareFights(false));
|
||||||
|
|
||||||
|
fightSelect.addEventListener('change', () => {
|
||||||
|
runBtn.style.display = fightSelect.value ? '' : 'none';
|
||||||
|
result.style.display = 'none';
|
||||||
|
refreshBtn.style.display = 'none';
|
||||||
|
showOkWrap.style.display = 'none';
|
||||||
|
compareLastResult = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
showOk.addEventListener('change', renderLastCompare);
|
||||||
|
runBtn.addEventListener('click', () => runCompare(false));
|
||||||
|
refreshBtn.addEventListener('click', async () => {
|
||||||
|
const selectedFightId = fightSelect.value;
|
||||||
|
const loaded = await loadCompareFights(true);
|
||||||
|
if (selectedFightId && compareFights.some(f => String(f.id) === String(selectedFightId))) {
|
||||||
|
fightSelect.value = selectedFightId;
|
||||||
|
}
|
||||||
|
if (loaded) await runCompare(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', hideCompareModal);
|
||||||
|
modal.addEventListener('click', e => { if (e.target === modal) hideCompareModal(); });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Name Import Modal ─────────────────────────────────────────────────────────
|
// ── Name Import Modal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let nameImportPlanId = null;
|
let nameImportPlanId = null;
|
||||||
@ -3498,6 +4013,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initImportModal();
|
initImportModal();
|
||||||
initAbilityModal();
|
initAbilityModal();
|
||||||
initNameImportModal();
|
initNameImportModal();
|
||||||
|
initCompareModal();
|
||||||
activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY);
|
activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY);
|
||||||
renderPlanList();
|
renderPlanList();
|
||||||
if (activePlanId && getPlan(activePlanId)) {
|
if (activePlanId && getPlan(activePlanId)) {
|
||||||
|
|||||||
@ -80,6 +80,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Planner Compare Modal -->
|
||||||
|
<div id="planner-compare-modal" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-box planner-compare-modal-box">
|
||||||
|
<div class="compare-modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="modal-title">Plan mit FFLogs vergleichen</div>
|
||||||
|
<div class="compare-modal-subtitle">Schnellcheck für live Logs: fehlende, frühe, späte und extra Mitigations.</div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-modal-header-actions">
|
||||||
|
<label class="compare-show-ok-toggle" id="compare-show-ok-wrap" style="display:none">
|
||||||
|
<input type="checkbox" id="compare-show-ok">
|
||||||
|
<span>OK anzeigen</span>
|
||||||
|
</label>
|
||||||
|
<button id="compare-run-btn" class="btn btn-gold" style="display:none">Vergleichen</button>
|
||||||
|
<button id="compare-refresh-btn" class="btn" style="display:none">Refresh live log</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compare-modal-body">
|
||||||
|
<div class="compare-setup-grid">
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-label">Report-Code</div>
|
||||||
|
<div class="name-import-input-row">
|
||||||
|
<input type="text" id="compare-report-input" placeholder="Report-Code oder URL…">
|
||||||
|
<button id="compare-load-btn" class="btn btn-sm">Fights laden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="compare-fight-section" style="display:none">
|
||||||
|
<div class="modal-label">Fight</div>
|
||||||
|
<select id="compare-fight-select">
|
||||||
|
<option value="">— Fight auswählen —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="compare-result" class="planner-compare-result" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions compare-modal-footer">
|
||||||
|
<button id="compare-cancel-btn" class="btn">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Name Import Modal (Planner) -->
|
<!-- Name Import Modal (Planner) -->
|
||||||
<div id="planner-name-import-modal" class="modal-overlay" style="display:none">
|
<div id="planner-name-import-modal" class="modal-overlay" style="display:none">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user