diff --git a/api/analysis.php b/api/analysis.php index 67d7616..9aa9802 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -455,6 +455,7 @@ foreach ($byAbility as $abId => $events) { if ($current !== null) $clusters[] = $current; } +$bossEvents = []; $aoeEvents = []; foreach ($clusters as $group) { $targetCount = count($group['targets']); @@ -476,8 +477,6 @@ foreach ($clusters as $group) { } } - if ($targetCount < 3 && !$isHeavyTankbuster) continue; - $targets = []; foreach ($group['targets'] as $tgtId => $tgt) { $p = $players[$tgtId] ?? null; @@ -514,7 +513,7 @@ foreach ($clusters as $group) { return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']); }); - $aoeEvents[] = [ + $bossEvent = [ 'timestamp' => $group['timestamp'], 'abilityId' => $group['abilityId'], 'abilityName' => $group['abilityName'], @@ -522,11 +521,18 @@ foreach ($clusters as $group) { 'totalDamage' => array_sum(array_column($targets, 'amount')), 'isHeavyTankbuster' => $isHeavyTankbuster, ]; + $bossEvents[] = $bossEvent; + + if ($targetCount < 3 && !$isHeavyTankbuster) continue; + + $aoeEvents[] = $bossEvent; } +usort($bossEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); $response = json_encode([ 'players' => array_values($players), + 'boss_events' => $bossEvents, 'aoe_events' => $aoeEvents, 'fight_start' => (int)$startTime, 'mitigation_names' => $mitigationNames, diff --git a/api/cache.php b/api/cache.php index 8aeade9..e77fa93 100644 --- a/api/cache.php +++ b/api/cache.php @@ -2,7 +2,7 @@ declare(strict_types=1); const CACHED_LOG_DIR = __DIR__ . '/../cached_logs'; -const CACHED_LOG_VERSION = 'v2'; +const CACHED_LOG_VERSION = 'v3'; function cache_language(string $language): string { $language = strtolower(trim($language)); diff --git a/css/planner.css b/css/planner.css index 1dcb3ec..c590c8b 100644 --- a/css/planner.css +++ b/css/planner.css @@ -707,6 +707,39 @@ .timeline-hint { font-size: 12px; color: var(--t3); + margin-bottom: 8px; +} + +.timeline-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.timeline-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: var(--r); + background: rgba(255,255,255,.025); + color: var(--t2); + font-size: 12px; + cursor: pointer; + user-select: none; +} + +.timeline-toggle:hover { + border-color: var(--borderem); + color: var(--t1); +} + +.timeline-toggle input { + width: auto !important; + min-width: 0 !important; + margin: 0; } .timeline-empty, @@ -772,6 +805,20 @@ z-index: 9; } +.timeline-boss-label { + border-top: none; + border-left: none; + border-radius: 0; + cursor: pointer; + font: inherit; + text-align: left; + width: 100%; +} + +.timeline-boss-label:hover { + background: rgba(200,168,75,.08); +} + .timeline-track, .timeline-axis-track { position: relative; @@ -1092,6 +1139,8 @@ z-index: 300; min-width: 190px; max-width: 280px; + max-height: min(520px, calc(100vh - 24px)); + overflow-y: auto; padding: 5px; border: 1px solid var(--borderem); border-radius: var(--r); @@ -1125,6 +1174,23 @@ cursor: not-allowed; } +.timeline-menu-header:disabled { + opacity: 1; + padding-top: 9px; + padding-bottom: 4px; + color: var(--gold); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + cursor: default; +} + +.timeline-menu-header:hover { + background: transparent; + color: var(--gold); +} + .timeline-menu-item img { width: 18px; height: 18px; diff --git a/js/planner.js b/js/planner.js index 5e0f560..73e3734 100644 --- a/js/planner.js +++ b/js/planner.js @@ -41,6 +41,7 @@ function createPlan(name) { updatedAt: Date.now(), source: null, mitigationNames: {}, + timelineOptions: { includeShields: false, includePersonal: false }, folderId: null, jobComposition: Array(8).fill(''), mechanics: [] @@ -177,7 +178,8 @@ async function ensureActionMetaLoaded() { function sameMechanic(existing, incoming, source) { const fightStart = source?.fightStart ?? 0; - const incomingRel = incoming.timestamp - fightStart; + const incomingTs = Number(incoming.timestamp); + const incomingRel = incomingTs >= fightStart ? incomingTs - fightStart : incomingTs; if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) { return Math.abs(existing.timestamp - incomingRel) < 1500; } @@ -391,8 +393,18 @@ function renderPlanDetail(plan) {
Zeitstrahl
-
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
+
+ + +
+
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
${renderTimelineHtml(plan)}
@@ -418,6 +430,7 @@ function renderPlanDetail(plan) { document.getElementById('name-import-open-btn')?.addEventListener('click', () => { showNameImportModal(plan.id); }); + initTimelineOptions(plan.id); initTimeline(plan.id); initMechanicClicks(plan.id); renderInfoPanel(plan); @@ -585,6 +598,25 @@ function initJobSlots(planId) { }); } +function initTimelineOptions(planId) { + const shields = document.getElementById('timeline-include-shields'); + const personal = document.getElementById('timeline-include-personal'); + const update = () => { + const plan = getPlan(planId); + if (!plan) return; + updatePlan(planId, { + timelineOptions: { + ...(plan.timelineOptions ?? {}), + includeShields: !!shields?.checked, + includePersonal: !!personal?.checked, + }, + }); + refreshMechanicList(planId); + }; + shields?.addEventListener('change', update); + personal?.addEventListener('change', update); +} + // ── Timeline ───────────────────────────────────────────────────────────────── function planDurationMs(plan) { @@ -608,9 +640,39 @@ const JOB_GANTT_ORDER = { 'PLD': 40, 'WAR': 41, 'DRK': 42, 'GNB': 43, }; +const TIMELINE_PERSONAL_ABILITIES = new Set([ + 'Guardian', + 'Bloodwhetting', + 'Divine Benison', + 'Intersection', + 'the Spire', + 'Haima', + 'Eukrasian Diagnosis', + 'Differential Diagnosis', + 'Seraphic Veil', + 'Radiant Aegis', + 'Tempera Coat', +]); + +function timelineOptions(plan) { + return { + includeShields: !!plan?.timelineOptions?.includeShields, + includePersonal: !!plan?.timelineOptions?.includePersonal, + }; +} + +function timelineAbilityVisible(ability, options) { + const isPersonal = TIMELINE_PERSONAL_ABILITIES.has(ability.name); + const isShield = ability.buffType === 'shield'; + if (isPersonal && !options.includePersonal) return false; + if (isShield && !options.includeShields && ability.name !== 'Panhaima') return false; + return true; +} + function timelinePlayerRows(plan) { const roster = plan.playerRoster ?? []; const rows = []; + const options = timelineOptions(plan); const jobEntries = (plan.jobComposition ?? []) .map((job, idx) => ({ job, idx })) .filter(e => !!e.job) @@ -619,7 +681,7 @@ function timelinePlayerRows(plan) { const name = roster[idx]?.name ?? ''; const role = JOB_ROLE[job] ?? ''; const abilities = (JOB_ABILITIES[job] ?? []) - .filter(ab => ab.buffType !== 'shield' || ab.name === 'Panhaima'); + .filter(ab => timelineAbilityVisible(ab, options)); abilities.forEach((ab, abilityIdx) => { rows.push({ idx, job, ability: ab.name, buffType: ab.buffType, name, role, firstForJob: abilityIdx === 0 }); }); @@ -994,7 +1056,7 @@ function renderTimelineHtml(plan) {
-
Boss
+
${hitLines}${bossItems}
${playerRows} @@ -1120,7 +1182,13 @@ function addTimelineAssignment(planId, mechanicId, ability, job, buffType, times refreshMechanicList(planId); } +let timelineMenuCleanup = null; + function closeTimelineMenu() { + if (timelineMenuCleanup) { + timelineMenuCleanup(); + timelineMenuCleanup = null; + } document.getElementById('timeline-context-menu')?.remove(); } @@ -1131,7 +1199,7 @@ function showTimelineMenu(x, y, items) { menu.className = 'timeline-context-menu'; menu.innerHTML = items.length ? items.map((item, idx) => ` - @@ -1146,15 +1214,16 @@ function showTimelineMenu(x, y, items) { menu.addEventListener('click', e => { const btn = e.target.closest('.timeline-menu-item'); if (!btn || btn.disabled) return; - items[parseInt(btn.dataset.idx, 10)]?.onClick?.(); - closeTimelineMenu(); - document.removeEventListener('click', closeOutside, true); - document.removeEventListener('contextmenu', closeOutside, true); + const item = items[parseInt(btn.dataset.idx, 10)]; + item?.onClick?.(); + if (!item?.keepOpen) closeTimelineMenu(); }); const closeOutside = ev => { if (menu.contains(ev.target)) return; closeTimelineMenu(); + }; + timelineMenuCleanup = () => { document.removeEventListener('click', closeOutside, true); document.removeEventListener('contextmenu', closeOutside, true); }; @@ -1164,6 +1233,207 @@ function showTimelineMenu(x, y, items) { }, 0); } +function showTimelineSectionMenu(x, y, sections) { + let openSection = null; + const render = () => { + const items = []; + sections.forEach((section, idx) => { + const isOpen = openSection === idx; + items.push({ + label: `${isOpen ? '▾' : '▸'} ${section.label}`, + keepOpen: true, + onClick: () => { + openSection = isOpen ? null : idx; + render(); + }, + }); + if (isOpen) { + items.push(...section.items.map(item => ({ + ...item, + label: ` ${item.label}`, + }))); + } + }); + showTimelineMenu(x, y, items.length ? items : [{ label: 'Keine Eintraege', disabled: true }]); + }; + render(); +} + +function setBossMechanicType(planId, mechanicId, isTankbuster) { + const plan = getPlan(planId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === mechanicId); + if (!mechanic) return; + mechanic.isHeavyTankbuster = !!isTankbuster; + mechanic.mechanicTypeManual = true; + updatePlan(planId, { mechanics: plan.mechanics }); + refreshMechanicList(planId); +} + +function confirmRemoveMechanic(mechanic) { + const name = mechanic?.name ? `"${mechanic.name}"` : 'diesen Angriff'; + return window.confirm(`${name} wirklich aus dem Plan entfernen?`); +} + +function removeBossMechanic(planId, mechanicId, ask = true) { + const plan = getPlan(planId); + if (!plan) return; + const mechanic = (plan.mechanics ?? []).find(m => m.id === mechanicId); + if (ask && !confirmRemoveMechanic(mechanic)) return; + const mechanics = (plan.mechanics ?? []).filter(m => m.id !== mechanicId); + if (mechanics.length === (plan.mechanics ?? []).length) return; + if (selectedTimelineAssignment?.mechanicId === mechanicId) { + selectedTimelineAssignment = null; + } + updatePlan(planId, { mechanics }); + refreshMechanicList(planId); +} + +async function fetchPlanAnalysisSource(plan) { + const source = plan?.source ?? {}; + const language = plannerLanguage(); + if (!source.reportCode || !source.fightId || !Number.isFinite(Number(source.fightStart)) || !Number.isFinite(Number(source.fightEnd))) { + return { error: 'Keine Report-Quelle fuer diesen Plan gefunden' }; + } + + const res = await fetch('api/analysis.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + report_code: source.reportCode, + fight_id: source.fightId, + start_time: source.fightStart, + end_time: source.fightEnd, + language, + }), + }); + const json = await res.json(); + if (json.reauth) { + window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; + return null; + } + return json; +} + +function mechanicExistsInPlan(plan, event) { + return (plan.mechanics ?? []).some(mechanic => sameMechanic(mechanic, event, plan.source ?? {})); +} + +function addMechanicsFromEvents(planId, events, analysisJson) { + const plan = getPlan(planId); + if (!plan || !events.length) return; + const source = plan.source ?? {}; + const mechanics = aoeEventsToMechanics( + events, + source.fightStart ?? analysisJson?.fight_start ?? 0, + [], + analysisJson?.players ?? [], + false, + analysisJson?.mitigation_names ?? plan.mitigationNames ?? {} + ); + const merged = [...(plan.mechanics ?? [])]; + for (const mechanic of mechanics) { + if (!merged.some(existing => sameMechanic(existing, { + timestamp: mechanic.timestamp + (source.fightStart ?? 0), + abilityId: mechanic.abilityId, + abilityName: mechanic.name, + }, source))) { + merged.push(mechanic); + } + } + merged.sort((a, b) => a.timestamp - b.timestamp); + updatePlan(planId, { + mechanics: merged, + mitigationNames: { ...(plan.mitigationNames ?? {}), ...(analysisJson?.mitigation_names ?? {}) }, + }); + refreshMechanicList(planId); +} + +async function showMissingBossActionsMenu(planId, x, y) { + const plan = getPlan(planId); + if (!plan) return; + showTimelineMenu(x, y, [{ label: 'Boss-Angriffe werden geladen...', disabled: true }]); + let json = null; + try { + json = await fetchPlanAnalysisSource(plan); + } catch { + showTimelineMenu(x, y, [{ label: 'Boss-Angriffe konnten nicht geladen werden', disabled: true }]); + return; + } + if (!json) return; + if (json.error) { + showTimelineMenu(x, y, [{ label: json.error, disabled: true }]); + return; + } + + const genericAttackNames = new Set(['attack', 'attacke', 'auto attack', 'auto-attack', 'angriff', 'attaque', '攻撃']); + const isGenericAttack = event => { + const name = String(event?.abilityName ?? '') + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + const id = parseInt(event?.abilityId ?? 0, 10) || 0; + return id > 0 && id <= 7 || genericAttackNames.has(name); + }; + const missing = (json.boss_events ?? json.aoe_events ?? []) + .filter(event => !isGenericAttack(event)) + .filter(event => !mechanicExistsInPlan(plan, event)) + .sort((a, b) => a.timestamp - b.timestamp); + + const sourceStart = plan.source?.fightStart ?? json.fight_start ?? 0; + const byAttack = new Map(); + const idsByNameType = new Map(); + for (const event of missing) { + const nameKey = String(event.abilityName ?? '').trim().toLowerCase(); + const typeKey = event.isHeavyTankbuster ? 'tankbuster' : 'aoe'; + const idKey = String(event.abilityId ?? ''); + const nameTypeKey = `${nameKey}::${typeKey}`; + if (!idsByNameType.has(nameTypeKey)) idsByNameType.set(nameTypeKey, new Set()); + if (idKey) idsByNameType.get(nameTypeKey).add(idKey); + const key = `${idKey || nameKey}::${nameTypeKey}`; + if (!byAttack.has(key)) byAttack.set(key, []); + byAttack.get(key).push(event); + } + + const attackItems = []; + const timeItems = []; + if (byAttack.size) { + for (const events of [...byAttack.values()].sort((a, b) => a[0].abilityName.localeCompare(b[0].abilityName))) { + const first = events[0]; + const count = events.length > 1 ? ` (${events.length}x)` : ''; + const nameTypeKey = `${String(first.abilityName ?? '').trim().toLowerCase()}::${first.isHeavyTankbuster ? 'tankbuster' : 'aoe'}`; + const duplicateName = (idsByNameType.get(nameTypeKey)?.size ?? 0) > 1; + const idLabel = duplicateName && first.abilityId ? ` [${first.abilityId}]` : ''; + attackItems.push({ + label: `${first.abilityName}${idLabel}${count} · ${first.isHeavyTankbuster ? 'Tankbuster' : 'AoE'}`, + onClick: () => addMechanicsFromEvents(planId, events, json), + }); + } + + for (const event of missing) { + timeItems.push({ + label: `${fmtTimestamp(event.timestamp - sourceStart)} · ${event.abilityName} · ${event.isHeavyTankbuster ? 'Tankbuster' : 'AoE'}`, + onClick: () => addMechanicsFromEvents(planId, [event], json), + }); + } + } else { + showTimelineMenu(x, y, [{ label: 'Keine fehlenden Boss-Angriffe', disabled: true }]); + return; + } + + if (missing.length > 1) { + attackItems.unshift({ + label: `Alle ${missing.length} fehlenden hinzufügen`, + onClick: () => addMechanicsFromEvents(planId, missing, json), + }); + } + showTimelineSectionMenu(x, y, [ + { label: 'By Attack', items: attackItems }, + { label: 'By Time', items: timeItems }, + ]); +} + function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) { const plan = getPlan(planId); if (!plan) return; @@ -1247,7 +1517,7 @@ function initTimeline(planId) { timeline.addEventListener('pointerdown', e => { if (e.button !== 0) return; if (!e.target.closest('.timeline-scroll')) return; - if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-context-menu')) return; + if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-boss-label, .timeline-context-menu')) return; const scroll = e.target.closest('.timeline-scroll'); timelinePan = { @@ -1295,9 +1565,35 @@ function initTimeline(planId) { return; } closeTimelineMenu(); + const bossLabel = e.target.closest('.timeline-boss-label'); + if (bossLabel) { + showMissingBossActionsMenu(planId, e.clientX, e.clientY); + return; + } const boss = e.target.closest('.timeline-boss-action'); if (boss) { - showAbilityModal(planId, boss.dataset.mechanicId); + const plan = getPlan(planId); + const mechanic = plan?.mechanics?.find(m => m.id === boss.dataset.mechanicId); + showTimelineMenu(e.clientX, e.clientY, [ + { + label: 'Als AoE markieren', + disabled: mechanic && !mechanic.isHeavyTankbuster, + onClick: () => setBossMechanicType(planId, boss.dataset.mechanicId, false), + }, + { + label: 'Als Tankbuster markieren', + disabled: mechanic && !!mechanic.isHeavyTankbuster, + onClick: () => setBossMechanicType(planId, boss.dataset.mechanicId, true), + }, + { + label: 'Mitigation zuweisen', + onClick: () => showAbilityModal(planId, boss.dataset.mechanicId), + }, + { + label: 'Angriff entfernen', + onClick: () => removeBossMechanic(planId, boss.dataset.mechanicId), + }, + ]); return; } const block = e.target.closest('.timeline-mitigation'); @@ -1549,7 +1845,12 @@ function removeAssignment(planId, mechanicId, abilityName, job = null) { function deleteMechanic(planId, mechanicId) { const plan = getPlan(planId); if (!plan) return; - plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId); + const mechanic = (plan.mechanics ?? []).find(m => m.id === mechanicId); + if (!confirmRemoveMechanic(mechanic)) return; + plan.mechanics = (plan.mechanics ?? []).filter(m => m.id !== mechanicId); + if (selectedTimelineAssignment?.mechanicId === mechanicId) { + selectedTimelineAssignment = null; + } updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); renderPlanList(); @@ -1992,7 +2293,7 @@ async function refreshPlanLanguage(planId) { ...mechanic, name: match.abilityName ?? mechanic.name, abilityId: match.abilityId ?? mechanic.abilityId, - isHeavyTankbuster: !!match.isHeavyTankbuster, + isHeavyTankbuster: mechanic.mechanicTypeManual ? !!mechanic.isHeavyTankbuster : !!match.isHeavyTankbuster, assignments, }; });