forked from xziino/ff14-mitigator
add timeline feature
This commit is contained in:
parent
27b9b0785e
commit
61fecbc576
321
css/planner.css
321
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;
|
||||
}
|
||||
}
|
||||
|
||||
564
js/planner.js
564
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 = `
|
||||
<div class="card section-gap">
|
||||
<div class="plan-detail-header">
|
||||
@ -320,7 +374,7 @@ function renderPlanDetail(plan) {
|
||||
<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>
|
||||
</div>
|
||||
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${plan.mechanics.length} Mechaniken</div>
|
||||
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -334,6 +388,19 @@ function renderPlanDetail(plan) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section-gap">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">Zeitstrahl</div>
|
||||
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
|
||||
</div>
|
||||
<div id="planner-timeline">
|
||||
${renderTimelineHtml(plan)}
|
||||
</div>
|
||||
<div id="timeline-settings">
|
||||
${renderTimelineSettingsHtml(plan)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">Mechaniken</div>
|
||||
@ -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 `
|
||||
<div class="empty" style="padding:30px 0">
|
||||
<div class="empty-icon" style="font-size:26px">📋</div>
|
||||
@ -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
|
||||
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
||||
@ -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 '<div class="timeline-empty">Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.</div>';
|
||||
}
|
||||
|
||||
const { duration, width } = timelineScale(plan);
|
||||
const rows = timelinePlayerRows(plan);
|
||||
const marks = [];
|
||||
const tick = 10000;
|
||||
for (let t = 0; t <= duration; t += tick) {
|
||||
marks.push(`<span class="timeline-tick" style="left:${(t / duration) * 100}%">${fmtTimestamp(t)}</span>`);
|
||||
}
|
||||
|
||||
const bossActions = layoutBossActions(mechanics, duration);
|
||||
const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1));
|
||||
const hitLines = mechanics.map(m => `
|
||||
<span class="timeline-hit-line" style="left:${(m.timestamp / duration) * 100}%"></span>
|
||||
`).join('');
|
||||
|
||||
const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
|
||||
<button class="timeline-boss-action"
|
||||
style="left:${left}%;top:${8 + lane * 30}px"
|
||||
data-mechanic-id="${escHtml(m.id)}"
|
||||
title="${escHtml(fmtTimestamp(m.timestamp))} · ${escHtml(m.name)}">
|
||||
${escHtml(m.name)}
|
||||
</button>
|
||||
`).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(`
|
||||
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
|
||||
draggable="true"
|
||||
style="left:${left}%;--cd-width:${cdWidthPct}%;--active-width:${activeWidthPct}%"
|
||||
data-mechanic-id="${escHtml(m.id)}"
|
||||
data-ability="${escHtml(a.ability)}"
|
||||
data-job="${escHtml(a.job ?? '')}"
|
||||
title="${escHtml(ability)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
|
||||
<span class="timeline-mitigation-active"></span>
|
||||
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
|
||||
<span>${escHtml(ability)}</span>
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
}
|
||||
return `
|
||||
<div class="timeline-row timeline-player-row" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}">
|
||||
<div class="timeline-row-label">
|
||||
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span>
|
||||
<span class="timeline-player-name">${escHtml(row.name || `Slot ${row.idx + 1}`)}</span>
|
||||
</div>
|
||||
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="timeline-scroll">
|
||||
<div class="timeline-grid" style="--timeline-width:${width}px">
|
||||
<div class="timeline-row timeline-boss-row" style="--boss-row-height:${Math.max(52, 18 + laneCount * 30)}px">
|
||||
<div class="timeline-row-label">Boss</div>
|
||||
<div class="timeline-track" style="width:${width}px">${hitLines}${bossItems}</div>
|
||||
</div>
|
||||
${playerRows}
|
||||
<div class="timeline-axis">
|
||||
<div></div>
|
||||
<div class="timeline-axis-track" style="width:${width}px">${marks.join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 '<div class="timeline-settings-empty">Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.</div>';
|
||||
}
|
||||
|
||||
const { mechanic, assignment } = found;
|
||||
const ability = assignmentAbilityName(assignment, plan);
|
||||
const jobLabel = assignment.job || 'Nicht zugewiesen';
|
||||
return `
|
||||
<div class="timeline-settings-panel">
|
||||
<div class="timeline-settings-title">${escHtml(jobLabel)} · ${escHtml(ability)}</div>
|
||||
<div class="timeline-settings-meta">${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}</div>
|
||||
<label>
|
||||
<span>Start</span>
|
||||
<input class="timeline-setting-input" data-field="timestamp" type="number" min="0" step="1" value="${Math.round(((assignment.timestamp ?? mechanic.timestamp) || 0) / 1000)}">
|
||||
</label>
|
||||
<label>
|
||||
<span>Dauer</span>
|
||||
<input class="timeline-setting-input" data-field="durationSeconds" type="number" min="1" step="1" value="${assignmentDurationSeconds(assignment)}">
|
||||
</label>
|
||||
<label>
|
||||
<span>Cooldown</span>
|
||||
<input class="timeline-setting-input" data-field="cooldownSeconds" type="number" min="0" step="1" value="${assignmentCooldownSeconds(assignment)}">
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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) => `
|
||||
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}>
|
||||
${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''}
|
||||
<span>${escHtml(item.label)}</span>
|
||||
</button>
|
||||
`).join('')
|
||||
: '<div class="timeline-menu-empty">Keine verfügbare Fähigkeit</div>';
|
||||
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 });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user