diff --git a/css/planner.css b/css/planner.css index dd7f516..5e51e20 100644 --- a/css/planner.css +++ b/css/planner.css @@ -4,6 +4,12 @@ grid-template-columns: 280px 1fr; gap: 16px; align-items: start; + min-width: 0; +} + +#plan-detail-panel, +#plan-content { + min-width: 0; } /* ── Plan Sidebar ────────────────────────────────────────────────────────────── */ @@ -654,3 +660,318 @@ } .folder-picker-option:hover { background: var(--bg2); color: var(--t1); } .folder-picker-option.active { color: var(--gold); } + +/* ── Planner Timeline ───────────────────────────────────────────────────────── */ +.timeline-hint { + font-size: 12px; + color: var(--t3); +} + +.timeline-empty, +.timeline-settings-empty { + font-size: 13px; + color: var(--t3); + padding: 12px 0; +} + +.timeline-scroll { + overflow-x: auto; + overflow-y: hidden; + border: 1px solid var(--border); + background: var(--bg1); + max-width: 100%; + width: 100%; +} + +.timeline-grid { + width: calc(180px + var(--timeline-width)); + display: grid; + grid-template-columns: 180px var(--timeline-width); +} + +.timeline-row, +.timeline-axis { + display: grid; + grid-template-columns: 180px var(--timeline-width); + min-height: 38px; + grid-column: 1 / -1; +} + +.timeline-row-label { + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + background: var(--bgcard); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + font-size: 12px; + color: var(--t2); + min-width: 0; +} + +.timeline-boss-row { + min-height: var(--boss-row-height, 52px); +} + +.timeline-boss-row .timeline-row-label { + color: var(--gold); + font-weight: 600; +} + +.timeline-track, +.timeline-axis-track { + position: relative; + min-height: inherit; + border-bottom: 1px solid var(--border); + background: + repeating-linear-gradient( + to right, + transparent 0, + transparent 79px, + rgba(255,255,255,0.07) 80px + ); +} + +.timeline-hit-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + transform: translateX(-50%); + background: rgba(200,168,75,.38); + pointer-events: none; + z-index: 1; +} + +.timeline-player-row .timeline-track { + background-color: rgba(255,255,255,0.015); +} + +.timeline-job { + width: 36px; + text-align: center; + border-radius: 3px; + padding: 2px 0; + font-size: 11px; + flex-shrink: 0; +} + +.timeline-player-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-boss-action { + position: absolute; + top: 8px; + transform: translateX(-50%); + max-width: 150px; + padding: 5px 8px; + border: 1px solid rgba(224,92,92,.35); + border-radius: var(--r); + background: rgba(224,92,92,.14); + color: var(--t1); + font-size: 12px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 3; +} + +.timeline-boss-action:hover { + border-color: rgba(224,92,92,.7); + background: rgba(224,92,92,.22); +} + +.timeline-mitigation { + position: absolute; + top: 6px; + width: var(--cd-width); + min-width: 28px; + height: 26px; + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px; + border: 1px solid var(--borderem); + border-radius: var(--r); + background: var(--bg2); + color: var(--t1); + font-size: 11px; + cursor: grab; + z-index: 4; + overflow: hidden; +} + +.timeline-mitigation-active { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: var(--active-width); + background: currentColor; + opacity: 0.22; + pointer-events: none; +} + +.timeline-mitigation:active { cursor: grabbing; } +.timeline-mitigation.selected { + outline: 2px solid var(--gold); + outline-offset: 1px; +} + +.timeline-mitigation--candidate { + border-style: dashed; + opacity: 0.78; +} + +.timeline-mitigation img { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; + position: relative; + z-index: 1; +} + +.timeline-mitigation span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + z-index: 1; +} + +.timeline-mitigation .timeline-mitigation-active { + z-index: 0; +} + +.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); } +.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); } +.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); } + +.timeline-axis { + min-height: 28px; +} + +.timeline-axis-track { + border-bottom: none; +} + +.timeline-tick { + position: absolute; + top: 6px; + transform: translateX(-50%); + font-size: 11px; + color: var(--t3); +} + +.timeline-settings-panel { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.timeline-settings-title { + font-size: 13px; + color: var(--t1); + font-weight: 600; + width: 100%; +} + +.timeline-settings-meta { + font-size: 12px; + color: var(--t3); + width: 100%; + margin-top: -6px; +} + +.timeline-settings-panel label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: var(--t3); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.timeline-setting-input { + width: 86px !important; + font-size: 13px !important; + padding: 5px 7px !important; +} + +.timeline-context-menu { + position: fixed; + z-index: 300; + min-width: 190px; + max-width: 280px; + padding: 5px; + border: 1px solid var(--borderem); + border-radius: var(--r); + background: var(--bgcard); + box-shadow: 0 10px 24px rgba(0,0,0,0.45); +} + +.timeline-menu-item { + width: 100%; + display: flex; + align-items: center; + gap: 7px; + padding: 7px 9px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--t2); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.timeline-menu-item:hover { + background: var(--bg2); + color: var(--t1); +} + +.timeline-menu-item.disabled, +.timeline-menu-item:disabled { + opacity: 0.38; + cursor: not-allowed; +} + +.timeline-menu-item img { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; +} + +.timeline-menu-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-menu-empty { + padding: 8px 10px; + color: var(--t3); + font-size: 12px; +} + +@media (max-width: 980px) { + .planner-layout { + grid-template-columns: 1fr; + } + + .plan-sidebar { + position: static; + } +} diff --git a/js/planner.js b/js/planner.js index d3a11a4..38d40d2 100644 --- a/js/planner.js +++ b/js/planner.js @@ -96,6 +96,10 @@ function copyPlan(id) { let activePlanId = null; let collapsedFolders = new Set(); +let selectedTimelineAssignment = null; +let actionMetaPromise = null; +let actionMetaById = {}; +let actionMetaByName = {}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -129,6 +133,48 @@ function plannerLanguage() { return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; } +async function ensureActionMetaLoaded() { + if (actionMetaPromise) return actionMetaPromise; + actionMetaPromise = (async () => { + let compact = {}; + let full = {}; + try { + const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' }); + if (res.ok) compact = await res.json(); + } catch { } + try { + const res = await fetch('assets/mitigation-actions.json', { cache: 'no-cache' }); + if (res.ok) full = await res.json(); + } catch { } + + const byId = {}; + const byName = {}; + for (const [id, action] of Object.entries(full ?? {})) { + const description = action.Description_en ?? ''; + const durations = [...description.matchAll(/Duration:\s*(\d+)s/gi)].map(m => parseInt(m[1], 10)); + const meta = { + id, + name: action.Name_en ?? '', + cast: (parseInt(compact?.[id]?.cast ?? action.Cast100ms ?? 0, 10) || 0) / 10, + recast: (parseInt(compact?.[id]?.recast ?? action.Recast100ms ?? 0, 10) || 0) / 10, + duration: durations.find(Number.isFinite) ?? 15, + }; + byId[id] = meta; + if (meta.name) byName[meta.name] = meta; + } + for (const [id, action] of Object.entries(compact ?? {})) { + byId[id] = { + ...(byId[id] ?? { id }), + cast: (parseInt(action.cast ?? 0, 10) || 0) / 10, + recast: (parseInt(action.recast ?? 0, 10) || 0) / 10, + }; + } + actionMetaById = byId; + actionMetaByName = byName; + })(); + return actionMetaPromise; +} + function sameMechanic(existing, incoming, source) { const fightStart = source?.fightStart ?? 0; const incomingRel = incoming.timestamp - fightStart; @@ -138,6 +184,12 @@ function sameMechanic(existing, incoming, source) { return Math.abs(existing.timestamp - incomingRel) < 1500; } +function visiblePlanMechanics(plan) { + return [...(plan?.mechanics ?? [])] + .filter(m => m?.id && String(m.name ?? '').trim() !== '' && Number.isFinite(Number(m.timestamp))) + .sort((a, b) => a.timestamp - b.timestamp); +} + // ── Rendering: Plan List ────────────────────────────────────────────────────── function planItemHtml(p) { @@ -313,6 +365,8 @@ function renderPlanDetail(plan) { noplan.style.display = 'none'; content.style.display = ''; + const visibleMechanics = visiblePlanMechanics(plan); + content.innerHTML = `
@@ -320,7 +374,7 @@ function renderPlanDetail(plan) { ${escHtml(plan.name)}
-
Erstellt ${fmtDate(plan.createdAt)} · ${plan.mechanics.length} Mechaniken
+
Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken
@@ -334,6 +388,19 @@ function renderPlanDetail(plan) { +
+
+
Zeitstrahl
+
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
+
+
+ ${renderTimelineHtml(plan)} +
+
+ ${renderTimelineSettingsHtml(plan)} +
+
+
Mechaniken
@@ -351,12 +418,15 @@ function renderPlanDetail(plan) { document.getElementById('name-import-open-btn')?.addEventListener('click', () => { showNameImportModal(plan.id); }); + initTimeline(plan.id); initMechanicClicks(plan.id); renderInfoPanel(plan); + ensureActionMetaLoaded().then(() => refreshTimeline(plan.id)); } function renderMechanicListHtml(plan) { - if (plan.mechanics.length === 0) { + const mechanics = visiblePlanMechanics(plan); + if (mechanics.length === 0) { return `
📋
@@ -370,7 +440,7 @@ function renderMechanicListHtml(plan) { const activeJobSet = new Set(plan.jobComposition.filter(j => j)); - return plan.mechanics.map(m => { + return mechanics.map(m => { const sorted = sortedAssignments(m.assignments); const assignHtml = sorted.length === 0 ? 'Keine Zuweisung' @@ -463,13 +533,488 @@ function initJobSlots(planId) { }); } +// ── Timeline ───────────────────────────────────────────────────────────────── + +function planDurationMs(plan) { + const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0); + const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000)); + return Math.max(sourceDuration, mechanicEnd, 60000); +} + +function timelineScale(plan) { + const duration = planDurationMs(plan); + const pxPerSecond = 8; + return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) }; +} + +function timelinePlayerRows(plan) { + const roster = plan.playerRoster ?? []; + return (plan.jobComposition ?? []).map((job, idx) => ({ + idx, + job, + name: roster[idx]?.name ?? '', + role: JOB_ROLE[job] ?? '', + })).filter(row => row.job); +} + +function selectedAssignmentMatches(mechanicId, assignment) { + return selectedTimelineAssignment + && selectedTimelineAssignment.mechanicId === mechanicId + && selectedTimelineAssignment.ability === assignment.ability + && selectedTimelineAssignment.job === (assignment.job ?? ''); +} + +function jobCanUseAbility(job, ability) { + return (JOB_ABILITIES[job] ?? []).some(a => a.name === ability); +} + +function timelineRowsForAssignment(rows, assignment) { + const assignedJob = assignment.job ?? ''; + if (assignedJob) return rows.filter(row => row.job === assignedJob); + return rows.filter(row => jobCanUseAbility(row.job, assignment.ability)); +} + +function assignmentStartMs(mechanic, assignment) { + return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp; +} + +function findNearestMechanic(plan, timestamp) { + const mechanics = visiblePlanMechanics(plan); + if (!mechanics.length) return null; + return mechanics.reduce((best, mechanic) => + Math.abs(mechanic.timestamp - timestamp) < Math.abs(best.timestamp - timestamp) ? mechanic : best + , mechanics[0]); +} + +function timelineTimestampFromEvent(plan, track, event) { + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left)); + return (x / rect.width) * planDurationMs(plan); +} + +function layoutBossActions(mechanics, duration) { + const lanes = []; + const minGapPct = 9; + return mechanics.map(mechanic => { + const left = (mechanic.timestamp / duration) * 100; + let lane = lanes.findIndex(lastLeft => left - lastLeft >= minGapPct); + if (lane === -1) { + lane = lanes.length; + lanes.push(left); + } else { + lanes[lane] = left; + } + return { mechanic, left, lane }; + }); +} + +function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null) { + for (const mechanic of visiblePlanMechanics(plan)) { + for (const assignment of mechanic.assignments ?? []) { + if (assignment.ability !== ability) continue; + if ((assignment.job ?? '') !== job && !(!(assignment.job ?? '') && jobCanUseAbility(job, ability))) continue; + if (ignore && ignore.mechanicId === mechanic.id && ignore.ability === ability && ignore.job === (assignment.job ?? '')) continue; + + const start = assignmentStartMs(mechanic, assignment); + const cooldownMs = Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000; + const end = start + cooldownMs; + if (timestamp >= start && timestamp < end) return true; + } + } + return false; +} + +function actionMetaForAssignment(assignment) { + const id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? ''); + return (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null; +} + +function assignmentCooldownSeconds(assignment) { + const own = Number(assignment?.cooldownSeconds); + if (Number.isFinite(own) && own >= 0) return own; + return actionMetaForAssignment(assignment)?.recast ?? 0; +} + +function assignmentDurationSeconds(assignment) { + const own = Number(assignment?.durationSeconds); + if (Number.isFinite(own) && own > 0) return own; + return actionMetaForAssignment(assignment)?.duration ?? (assignment?.buffType === 'shield' ? 20 : 15); +} + +function renderTimelineHtml(plan) { + const mechanics = visiblePlanMechanics(plan); + if (!mechanics.length) { + return '
Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.
'; + } + + const { duration, width } = timelineScale(plan); + const rows = timelinePlayerRows(plan); + const marks = []; + const tick = 10000; + for (let t = 0; t <= duration; t += tick) { + marks.push(`${fmtTimestamp(t)}`); + } + + const bossActions = layoutBossActions(mechanics, duration); + const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1)); + const hitLines = mechanics.map(m => ` + + `).join(''); + + const bossItems = bossActions.map(({ mechanic: m, left, lane }) => ` + + `).join(''); + + const playerRows = rows.map(row => { + const blocks = []; + for (const m of mechanics) { + for (const a of sortedAssignments(m.assignments ?? [])) { + if (!timelineRowsForAssignment(rows, a).some(target => target.job === row.job)) continue; + const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp; + const durationSec = assignmentDurationSeconds(a); + const cooldownSec = assignmentCooldownSeconds(a); + const left = Math.max(0, Math.min(100, (start / duration) * 100)); + const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100)); + const cdWidthPct = cooldownSec > 0 ? Math.max(widthPct, Math.min(100 - left, (cooldownSec * 1000 / duration) * 100)) : widthPct; + const activeWidthPct = Math.min(100, (widthPct / cdWidthPct) * 100); + const selected = selectedAssignmentMatches(m.id, a) ? ' selected' : ''; + const unresolved = a.job ? '' : ' timeline-mitigation--candidate'; + const cls = a.buffType === 'debuff' ? 'timeline-mitigation--debuff' + : a.buffType === 'shield' ? 'timeline-mitigation--shield' + : 'timeline-mitigation--buff'; + const icon = MITIG_ICONS[a.ability] ?? ''; + const ability = assignmentAbilityName(a, plan); + blocks.push(` + + `); + } + } + return ` +
+
+ ${escHtml(row.job)} + ${escHtml(row.name || `Slot ${row.idx + 1}`)} +
+
${hitLines}${blocks.join('')}
+
`; + }).join(''); + + return ` +
+
+
+
Boss
+
${hitLines}${bossItems}
+
+ ${playerRows} +
+
+
${marks.join('')}
+
+
+
`; +} + +function findTimelineAssignment(plan, selected = selectedTimelineAssignment) { + if (!plan || !selected) return null; + const mechanic = (plan.mechanics ?? []).find(m => m.id === selected.mechanicId); + if (!mechanic) return null; + const assignment = (mechanic.assignments ?? []).find(a => + a.ability === selected.ability && (a.job ?? '') === selected.job + ); + return assignment ? { mechanic, assignment } : null; +} + +function renderTimelineSettingsHtml(plan) { + const found = findTimelineAssignment(plan); + if (!found) { + return '
Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.
'; + } + + const { mechanic, assignment } = found; + const ability = assignmentAbilityName(assignment, plan); + const jobLabel = assignment.job || 'Nicht zugewiesen'; + return ` +
+
${escHtml(jobLabel)} · ${escHtml(ability)}
+
${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}
+ + + +
`; +} + +function refreshTimeline(planId) { + const plan = getPlan(planId); + if (!plan) return; + const timeline = document.getElementById('planner-timeline'); + const settings = document.getElementById('timeline-settings'); + if (timeline) timeline.innerHTML = renderTimelineHtml(plan); + if (settings) settings.innerHTML = renderTimelineSettingsHtml(plan); +} + +function setTimelineAssignmentField(planId, mechanicId, ability, job, field, value) { + const plan = getPlan(planId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === mechanicId); + const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); + if (!assignment) return; + + if (field === 'timestamp') assignment.timestamp = Math.max(0, Math.round(Number(value) * 1000)); + if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value))); + if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value))); + updatePlan(planId, { mechanics: plan.mechanics }); + refreshTimeline(planId); +} + +function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) { + const plan = getPlan(planId); + if (!plan || !jobCanUseAbility(nextJob, ability)) return; + const mechanic = plan.mechanics.find(m => m.id === mechanicId); + const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); + if (!assignment) return; + const timestamp = assignmentStartMs(mechanic, assignment); + if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job })) return; + assignment.job = nextJob; + selectedTimelineAssignment = { mechanicId, ability, job: nextJob }; + updatePlan(planId, { mechanics: plan.mechanics }); + refreshMechanicList(planId); +} + +function removeTimelineAssignment(planId, mechanicId, ability, job) { + 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 === ability && (a.job ?? '') === job)); + if (selectedTimelineAssignment?.mechanicId === mechanicId && selectedTimelineAssignment?.ability === ability && selectedTimelineAssignment?.job === job) { + selectedTimelineAssignment = null; + } + updatePlan(planId, { mechanics: plan.mechanics }); + refreshMechanicList(planId); +} + +function addTimelineAssignment(planId, mechanicId, ability, job, buffType, timestamp) { + const plan = getPlan(planId); + if (!plan || !jobCanUseAbility(job, ability)) return; + const mechanic = plan.mechanics.find(m => m.id === mechanicId); + if (!mechanic) return; + mechanic.assignments = mechanic.assignments ?? []; + mechanic.assignments.push({ + ability, + abilityName: plan.mitigationNames?.[ability], + actionId: actionMetaByName[ability]?.id ?? null, + job, + buffType, + timestamp: Math.max(0, Math.round(timestamp)), + }); + selectedTimelineAssignment = { mechanicId, ability, job }; + updatePlan(planId, { mechanics: plan.mechanics }); + refreshMechanicList(planId); +} + +function closeTimelineMenu() { + document.getElementById('timeline-context-menu')?.remove(); +} + +function showTimelineMenu(x, y, items) { + closeTimelineMenu(); + const menu = document.createElement('div'); + menu.id = 'timeline-context-menu'; + menu.className = 'timeline-context-menu'; + menu.innerHTML = items.length + ? items.map((item, idx) => ` + + `).join('') + : '
Keine verfügbare Fähigkeit
'; + document.body.appendChild(menu); + + const rect = menu.getBoundingClientRect(); + menu.style.left = Math.min(x, window.innerWidth - rect.width - 8) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - rect.height - 8) + 'px'; + + 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 closeOutside = ev => { + if (menu.contains(ev.target)) return; + closeTimelineMenu(); + document.removeEventListener('click', closeOutside, true); + document.removeEventListener('contextmenu', closeOutside, true); + }; + setTimeout(() => { + document.addEventListener('click', closeOutside, true); + document.addEventListener('contextmenu', closeOutside, true); + }, 0); +} + +function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) { + const plan = getPlan(planId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === mechanicId); + const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); + if (!assignment) return; + if (!jobCanUseAbility(rowJob, ability)) return; + assignment.timestamp = Math.max(0, Math.round(timestamp)); + assignment.job = rowJob; + selectedTimelineAssignment = { mechanicId, ability, job: rowJob }; + updatePlan(planId, { mechanics: plan.mechanics }); + refreshTimeline(planId); + refreshMechanicList(planId, false); +} + +function initTimeline(planId) { + const timeline = document.getElementById('planner-timeline'); + const settings = document.getElementById('timeline-settings'); + if (!timeline || !settings) return; + + timeline.addEventListener('click', e => { + closeTimelineMenu(); + const boss = e.target.closest('.timeline-boss-action'); + if (boss) { + showAbilityModal(planId, boss.dataset.mechanicId); + return; + } + const block = e.target.closest('.timeline-mitigation'); + if (block) { + selectedTimelineAssignment = { + mechanicId: block.dataset.mechanicId, + ability: block.dataset.ability, + job: block.dataset.job, + }; + refreshTimeline(planId); + const plan = getPlan(planId); + const rows = timelinePlayerRows(plan ?? {}); + const found = findTimelineAssignment(plan, selectedTimelineAssignment); + const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0; + const items = rows + .filter(row => jobCanUseAbility(row.job, block.dataset.ability)) + .map(row => ({ + label: `${row.job}${row.name ? ` · ${row.name}` : ''}`, + icon: MITIG_ICONS[block.dataset.ability] ?? '', + disabled: assignmentOverlapsJob(plan, row.job, block.dataset.ability, timestamp, { + mechanicId: block.dataset.mechanicId, + ability: block.dataset.ability, + job: block.dataset.job, + }), + onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job), + })); + showTimelineMenu(e.clientX, e.clientY, items); + return; + } + + const track = e.target.closest('.timeline-player-row .timeline-track'); + const row = e.target.closest('.timeline-player-row'); + if (!track || !row) return; + const plan = getPlan(planId); + if (!plan) return; + const timestamp = timelineTimestampFromEvent(plan, track, e); + const mechanic = findNearestMechanic(plan, timestamp); + if (!mechanic) return; + const items = (JOB_ABILITIES[row.dataset.job] ?? []) + .filter(ab => !assignmentOverlapsJob(plan, row.dataset.job, ab.name, timestamp)) + .map(ab => ({ + label: `${plan.mitigationNames?.[ab.name] ?? ab.name} · ${fmtTimestamp(timestamp)}`, + icon: MITIG_ICONS[ab.name] ?? '', + onClick: () => addTimelineAssignment(planId, mechanic.id, ab.name, row.dataset.job, ab.buffType, timestamp), + })); + showTimelineMenu(e.clientX, e.clientY, items); + }); + + timeline.addEventListener('contextmenu', e => { + const block = e.target.closest('.timeline-mitigation'); + if (!block) return; + e.preventDefault(); + closeTimelineMenu(); + removeTimelineAssignment(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job); + }); + + timeline.addEventListener('dragstart', e => { + const block = e.target.closest('.timeline-mitigation'); + if (!block) return; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', JSON.stringify({ + mechanicId: block.dataset.mechanicId, + ability: block.dataset.ability, + job: block.dataset.job, + })); + }); + + timeline.addEventListener('dragover', e => { + if (e.target.closest('.timeline-player-row .timeline-track')) e.preventDefault(); + }); + + timeline.addEventListener('drop', e => { + const track = e.target.closest('.timeline-player-row .timeline-track'); + const row = e.target.closest('.timeline-player-row'); + if (!track || !row) return; + e.preventDefault(); + let data; + try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } + catch { return; } + const plan = getPlan(planId); + if (!plan) return; + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + const timestamp = (x / rect.width) * planDurationMs(plan); + updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp); + }); + + settings.addEventListener('change', e => { + const input = e.target.closest('.timeline-setting-input'); + if (!input || !selectedTimelineAssignment) return; + setTimelineAssignmentField( + planId, + selectedTimelineAssignment.mechanicId, + selectedTimelineAssignment.ability, + selectedTimelineAssignment.job, + input.dataset.field, + input.value + ); + }); +} + // ── Mechanic list helpers ───────────────────────────────────────────────────── -function refreshMechanicList(planId) { +function refreshMechanicList(planId, includeTimeline = true) { const plan = getPlan(planId); if (!plan) return; const el = document.getElementById('mechanic-list'); if (el) el.innerHTML = renderMechanicListHtml(plan); + if (includeTimeline) refreshTimeline(planId); renderInfoPanel(plan); } @@ -493,7 +1038,7 @@ function renderInfoPanel(plan) { const noJobAbilities = new Set(); const missingJobPairs = new Set(); - for (const m of plan.mechanics) { + for (const m of visiblePlanMechanics(plan)) { for (const a of m.assignments) { if (!a.job) { noJobAbilities.add(a.ability); @@ -995,6 +1540,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga assignments.push({ ability: key, abilityName: mitigationDisplayName(m) || mitigationNames[key], + actionId: m.extraAbilityGameID ?? null, job: guessJob(key, players), buffType: m.buffType ?? '', }); @@ -1254,7 +1800,13 @@ function toggleAbilityAssignment(abilityName, job, buffType) { mechanic.assignments[idx].job = job; } } else { - mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], job, buffType }); + mechanic.assignments.push({ + ability: abilityName, + abilityName: plan.mitigationNames?.[abilityName], + actionId: actionMetaByName[abilityName]?.id ?? null, + job, + buffType, + }); } updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });