add amkis whishes, cactbot timeline updates and biinos easy fix

This commit is contained in:
Akurosia Kamo 2026-06-08 17:00:08 +02:00
parent 0a47b126cf
commit 695c0fb99e
3 changed files with 596 additions and 49 deletions

9
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:system",
"packageManager": "ms-python.python:pip"
}
]
}

View File

@ -993,6 +993,39 @@
opacity: 0.78; opacity: 0.78;
} }
.timeline-inactive-gap {
position: absolute;
top: 10px;
height: 20px;
z-index: 2;
pointer-events: auto;
cursor: help;
}
.timeline-inactive-gap-fit,
.timeline-inactive-gap-late {
position: absolute;
top: 9px;
height: 0;
}
.timeline-inactive-gap-fit {
left: 0;
border-top: 2px dotted rgba(180,190,205,.42);
}
.timeline-inactive-gap-late {
border-top: 2px dotted rgba(224,92,92,.70);
}
.timeline-inactive-gap:hover .timeline-inactive-gap-fit {
border-top-color: var(--gold);
}
.timeline-inactive-gap:hover .timeline-inactive-gap-late {
border-top-color: var(--red);
}
.timeline-mitigation img { .timeline-mitigation img {
width: 18px; width: 18px;
height: 18px; height: 18px;
@ -1233,6 +1266,24 @@
font-size: 12px; font-size: 12px;
} }
.timeline-menu-time {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px 9px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border);
color: var(--t3);
font-size: 11px;
text-transform: uppercase;
}
.timeline-menu-time input {
width: 76px !important;
padding: 5px 7px !important;
font-size: 12px !important;
}
/* ── View Toggle ─────────────────────────────────────────────────────────── */ /* ── View Toggle ─────────────────────────────────────────────────────────── */
.view-toggle-btns { .view-toggle-btns {
@ -1248,17 +1299,176 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.cactbot-export-option { .cactbot-export-modal-box {
display: inline-flex; max-width: 720px;
align-items: center; max-height: min(86vh, 780px);
gap: 5px; overflow-y: auto;
color: var(--t2); padding: 0;
font-size: 12px;
white-space: nowrap;
} }
.cactbot-export-option input {
width: auto; .cactbot-modal-header {
margin: 0; display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 24px 28px 18px;
border-bottom: 1px solid var(--border);
}
.cactbot-modal-header .modal-title {
margin-bottom: 5px;
}
.cactbot-modal-subtitle {
color: var(--t3);
font-size: 13px;
}
.cactbot-export-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 18px 28px 0;
}
.cactbot-option-card,
.cactbot-check-card {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border);
border-radius: var(--r);
background: var(--bg2);
cursor: pointer;
transition: border-color .15s, background .15s;
}
.cactbot-option-card {
padding: 12px;
}
.cactbot-option-card:hover,
.cactbot-check-card:hover {
border-color: var(--borderem);
background: var(--bg3);
}
.cactbot-option-card input,
.cactbot-check-card input {
width: auto;
margin: 0;
flex-shrink: 0;
}
.cactbot-option-card span,
.cactbot-check-card span {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.cactbot-option-card strong,
.cactbot-check-card strong {
color: var(--t1);
font-size: 13px;
font-weight: 600;
}
.cactbot-option-card small,
.cactbot-check-card small {
color: var(--t3);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cactbot-field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px 28px 0;
}
.cactbot-field label,
.cactbot-section-title {
color: var(--t2);
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.cactbot-field input {
font-size: 13px;
}
.cactbot-check-grid,
.cactbot-role-grid,
.cactbot-player-columns {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 8px;
}
.cactbot-role-grid,
.cactbot-player-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.cactbot-player-columns {
gap: 10px;
align-items: start;
}
.cactbot-player-column {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.cactbot-player-column-title {
padding: 0 2px 2px;
color: var(--gold);
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.cactbot-check-card {
padding: 9px 10px;
}
.cactbot-check-card--role {
min-height: 40px;
}
.cactbot-section {
display: flex;
flex-direction: column;
gap: 9px;
padding: 18px 28px 0;
}
.cactbot-modal-actions {
position: sticky;
bottom: 0;
justify-content: flex-end;
padding: 18px 28px 24px;
margin-top: 8px;
border-top: 1px solid var(--border);
background: var(--bgcard);
}
@media (max-width: 680px) {
.cactbot-export-options,
.cactbot-role-grid,
.cactbot-player-columns {
grid-template-columns: 1fr;
}
} }
.view-toggle-btn { .view-toggle-btn {

View File

@ -165,6 +165,16 @@ function abilityEffectTooltip(ability, buffType, plan = null) {
return lines.join('\n'); return lines.join('\n');
} }
function timelineAssignmentTooltip(assignment, mechanic, start, durationSec, cooldownSec, plan = null) {
return [
abilityEffectTooltip(assignment.ability, assignment.buffType, plan),
assignment.job ? `Caster: ${assignment.job}` : 'Caster: not assigned',
`Active: ${durationSec}s (${fmtTimestamp(start)} - ${fmtTimestamp(start + durationSec * 1000)})`,
`Cooldown: ${cooldownSec}s`,
mechanic?.name ? `Mechanic: ${mechanic.name} (${fmtTimestamp(mechanic.timestamp)})` : null,
].filter(Boolean).join('\n');
}
async function ensureActionMetaLoaded() { async function ensureActionMetaLoaded() {
if (actionMetaPromise) return actionMetaPromise; if (actionMetaPromise) return actionMetaPromise;
actionMetaPromise = (async () => { actionMetaPromise = (async () => {
@ -449,7 +459,7 @@ function renderPlanDetail(plan) {
</label> </label>
</div> </div>
</div> </div>
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div> <div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Modifier+Scroll zum Zoomen · Klick für Zeiten</div>
<div id="planner-timeline"> <div id="planner-timeline">
${renderTimelineHtml(plan)} ${renderTimelineHtml(plan)}
</div> </div>
@ -466,10 +476,6 @@ function renderPlanDetail(plan) {
<button class="view-toggle-btn active" data-view="mechanics">Mechaniken</button> <button class="view-toggle-btn active" data-view="mechanics">Mechaniken</button>
<button class="view-toggle-btn" data-view="myspells"> Meine Spells</button> <button class="view-toggle-btn" data-view="myspells"> Meine Spells</button>
</div> </div>
<label class="cactbot-export-option">
<input type="checkbox" id="cactbot-export-split">
<span>Separate Entries</span>
</label>
<button id="cactbot-export-btn" class="btn btn-sm" title="Cactbot Timeline exportieren">Cactbot Export</button> <button id="cactbot-export-btn" class="btn btn-sm" title="Cactbot Timeline exportieren">Cactbot Export</button>
</div> </div>
</div> </div>
@ -580,7 +586,7 @@ function cactbotExportFilename(plan) {
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'mitigation-plan'; .replace(/^-+|-+$/g, '') || 'mitigation-plan';
return `${base}-mitigations.txt`; return `${base}-mitigations`;
} }
function cactbotAssignmentLabel(assignment, plan) { function cactbotAssignmentLabel(assignment, plan) {
@ -588,16 +594,63 @@ function cactbotAssignmentLabel(assignment, plan) {
return assignment.job ? `${assignment.job} ${ability}` : ability; return assignment.job ? `${assignment.job} ${ability}` : ability;
} }
function cactbotFilterHints(plan) { function cactbotJsString(text) {
const jobs = [...new Set((plan.jobComposition ?? []).filter(Boolean))].sort(); return String(text ?? '')
if (!jobs.length) return []; .replace(/\\/g, '\\\\')
return ['# Optional job filters - uncomment lines you do not want to see:', ...jobs.map(job => `# hideall ".*${job} .*"`)]; .replace(/'/g, "\\'")
.replace(/\r?\n/g, '\\n');
} }
function cactbotTimelineText(plan, splitEntries = false) { function cactbotTemplateLiteral(text) {
return String(text ?? '')
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${');
}
function cactbotRegexText(text) {
return String(text ?? '')
.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&')
.replace(/'/g, "\\'");
}
function cactbotExportSlots(plan) {
return (plan.jobComposition ?? [])
.map((job, idx) => ({
idx,
job,
role: JOB_ROLE[job] ?? '',
name: plan.playerRoster?.[idx]?.name ?? '',
}))
.filter(slot => slot.job);
}
function cactbotRoleLabel(role) {
return { healer: 'Healer', dps: 'DPS', tank: 'Tank' }[role] ?? role;
}
function cactbotRoleOrder(role) {
return { healer: 0, dps: 1, tank: 2 }[role] ?? 9;
}
function cactbotSelectedJobs(plan, settings) {
const selectedPlayers = new Set((settings.players ?? []).map(Number));
const slots = cactbotExportSlots(plan);
return new Set(slots
.filter(slot => selectedPlayers.has(slot.idx))
.map(slot => slot.job));
}
function cactbotAssignmentIncluded(assignment, selectedJobs) {
return !assignment.job || selectedJobs.has(assignment.job);
}
function cactbotTimelineRows(plan, settings) {
const selectedJobs = cactbotSelectedJobs(plan, settings);
const byStart = new Map(); const byStart = new Map();
for (const mechanic of visiblePlanMechanics(plan)) { for (const mechanic of visiblePlanMechanics(plan)) {
const planned = sortedAssignments(plannedAssignmentsForMechanic(plan, mechanic)); const planned = sortedAssignments(plannedAssignmentsForMechanic(plan, mechanic))
.filter(assignment => cactbotAssignmentIncluded(assignment, selectedJobs));
if (!planned.length) continue; if (!planned.length) continue;
planned.forEach(assignment => { planned.forEach(assignment => {
@ -613,19 +666,23 @@ function cactbotTimelineText(plan, splitEntries = false) {
const rows = []; const rows = [];
for (const [time, group] of byStart.entries()) { for (const [time, group] of byStart.entries()) {
const assignments = [...group.assignments.values()]; const assignments = [...group.assignments.values()];
if (splitEntries) { if (settings.splitEntries) {
assignments.forEach(text => rows.push({ time, text })); assignments.forEach(text => rows.push({ time, text }));
} else { } else {
rows.push({ time, text: assignments.join(', ') }); rows.push({ time, text: assignments.join(', ') });
} }
} }
rows.sort((a, b) => a.time - b.time || a.text.localeCompare(b.text)); rows.sort((a, b) => a.time - b.time || a.text.localeCompare(b.text));
return rows;
}
function cactbotTimelineText(plan, settings) {
const rows = cactbotTimelineRows(plan, settings);
const header = [ const header = [
`# Exported from FF14 Mitigator`, `# Exported from FF14 Mitigator`,
`# Plan: ${plan.name}`, `# Plan: ${plan.name}`,
plan.source?.fightName ? `# Fight: ${plan.source.fightName}` : null, plan.source?.fightName ? `# Fight: ${plan.source.fightName}` : null,
`# Format: cactbot timeline entries`, `# Format: cactbot timeline entries`,
...cactbotFilterHints(plan),
].filter(Boolean); ].filter(Boolean);
return [ return [
@ -634,6 +691,40 @@ function cactbotTimelineText(plan, splitEntries = false) {
].join('\n') + '\n'; ].join('\n') + '\n';
} }
function cactbotUserConfigText(plan, settings) {
const rows = cactbotTimelineRows(plan, settings);
const timeline = rows.map(row => ` ${cactbotTime(row.time)} "${cactbotEscape(row.text)}"`).join('\n');
const triggerRows = [...new Map(rows.map(row => [row.text, row])).values()];
const triggers = triggerRows.map((row, idx) => {
const idBase = `${plan.source?.fightName || plan.name}_${row.text}`
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 80) || `Mitigation_${idx + 1}`;
return ` {
id: '${cactbotJsString(idBase)}',
regex: /^${cactbotRegexText(row.text)}$/,
beforeSeconds: 1,
infoText: {
en: '${cactbotJsString(row.text)}',
},
}`;
}).join(',\n');
return `Options.Triggers.push(
{
zoneId: ${settings.zoneId || 'ZoneId.MatchAll'},
timeline: \`
${cactbotTemplateLiteral(timeline)}
\`,
timelineTriggers: [
${triggers}
],
triggers: [],
},
);
`;
}
function downloadTextFile(filename, text) { function downloadTextFile(filename, text) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -646,17 +737,150 @@ function downloadTextFile(filename, text) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function initCactbotExport(planId) { function cactbotExportModalHtml(plan) {
document.getElementById('cactbot-export-btn')?.addEventListener('click', () => { const slots = cactbotExportSlots(plan);
const plan = getPlan(planId); const roles = [...new Set(slots.map(slot => slot.role).filter(Boolean))]
if (!plan) return; .sort((a, b) => cactbotRoleOrder(a) - cactbotRoleOrder(b));
const splitEntries = !!document.getElementById('cactbot-export-split')?.checked; return `
const text = cactbotTimelineText(plan, splitEntries); <div id="cactbot-export-modal" class="modal-overlay" style="display:flex">
if (!text.split('\n').some(line => /^\d/.test(line))) { <div class="modal-box cactbot-export-modal-box">
<div class="cactbot-modal-header">
<div>
<div class="modal-title">Cactbot Export</div>
<div class="cactbot-modal-subtitle">${escHtml(plan.name)}</div>
</div>
<button id="cactbot-export-cancel" class="plan-btn" type="button" title="Schließen"></button>
</div>
<div class="cactbot-export-options">
<label class="cactbot-option-card">
<input type="checkbox" id="cactbot-just-timeline">
<span>
<strong>Just timeline</strong>
<small>Nur .txt Einträge statt UserConfig .js</small>
</span>
</label>
<label class="cactbot-option-card">
<input type="checkbox" id="cactbot-split-entries">
<span>
<strong>Separate entries</strong>
<small>Eine Timeline-Zeile pro Aktion</small>
</span>
</label>
</div>
<div class="cactbot-field">
<label for="cactbot-zone-id">ZoneId</label>
<input id="cactbot-zone-id" type="text" value="${escHtml(localStorage.getItem('ff14-cactbot-zone-id') || 'ZoneId.MatchAll')}">
</div>
<div class="cactbot-section">
<div class="cactbot-section-title">Roles</div>
<div class="cactbot-role-grid">
${roles.map(role => `
<label class="cactbot-check-card cactbot-check-card--role">
<input type="checkbox" class="cactbot-role-check" value="${escHtml(role)}" data-role="${escHtml(role)}" checked>
<span>${escHtml(cactbotRoleLabel(role))}</span>
</label>
`).join('')}
</div>
</div>
<div class="cactbot-section">
<div class="cactbot-section-title">Players</div>
<div class="cactbot-player-columns">
${roles.map(role => `
<div class="cactbot-player-column" data-role-column="${escHtml(role)}">
<div class="cactbot-player-column-title">${escHtml(cactbotRoleLabel(role))}</div>
${slots.filter(slot => slot.role === role).map(slot => `
<label class="cactbot-check-card">
<input type="checkbox" class="cactbot-player-check" value="${escHtml(slot.idx)}" data-role="${escHtml(role)}" checked>
<span>
<strong>${escHtml(slot.job)}</strong>
${slot.name ? `<small>${escHtml(slot.name)}</small>` : '<small>Slot ohne Namen</small>'}
</span>
</label>
`).join('')}
</div>
`).join('')}
</div>
</div>
<div class="modal-actions cactbot-modal-actions">
<button id="cactbot-export-confirm" class="btn btn-gold">Export</button>
<button id="cactbot-export-cancel-footer" class="btn">Abbrechen</button>
</div>
</div>
</div>`;
}
function closeCactbotExportModal() {
document.getElementById('cactbot-export-modal')?.remove();
}
function cactbotExportSettingsFromModal() {
const zoneId = document.getElementById('cactbot-zone-id')?.value.trim() || 'ZoneId.MatchAll';
localStorage.setItem('ff14-cactbot-zone-id', zoneId);
return {
justTimeline: !!document.getElementById('cactbot-just-timeline')?.checked,
splitEntries: !!document.getElementById('cactbot-split-entries')?.checked,
zoneId,
roles: [...document.querySelectorAll('.cactbot-role-check:checked')].map(input => input.value),
players: [...document.querySelectorAll('.cactbot-player-check:checked')].map(input => input.value),
};
}
function showCactbotExportModal(planId) {
closeCactbotExportModal();
const plan = getPlan(planId);
if (!plan) return;
document.body.insertAdjacentHTML('beforeend', cactbotExportModalHtml(plan));
const modal = document.getElementById('cactbot-export-modal');
document.getElementById('cactbot-export-cancel')?.addEventListener('click', closeCactbotExportModal);
document.getElementById('cactbot-export-cancel-footer')?.addEventListener('click', closeCactbotExportModal);
modal?.addEventListener('click', e => { if (e.target === modal) closeCactbotExportModal(); });
const playersForRole = role => [...modal.querySelectorAll('.cactbot-player-check')]
.filter(player => player.dataset.role === role);
const roleInputFor = role => [...modal.querySelectorAll('.cactbot-role-check')]
.find(input => input.dataset.role === role);
const syncRoleState = role => {
const players = playersForRole(role);
const roleInput = roleInputFor(role);
if (!roleInput || !players.length) return;
const checkedCount = players.filter(player => player.checked).length;
roleInput.checked = checkedCount === players.length;
roleInput.indeterminate = checkedCount > 0 && checkedCount < players.length;
};
modal?.querySelectorAll('.cactbot-role-check').forEach(input => {
input.addEventListener('change', () => {
playersForRole(input.dataset.role).forEach(player => { player.checked = input.checked; });
input.indeterminate = false;
});
});
modal?.querySelectorAll('.cactbot-player-check').forEach(input => {
input.addEventListener('change', () => {
syncRoleState(input.dataset.role);
});
});
modal?.querySelectorAll('.cactbot-role-check').forEach(input => syncRoleState(input.dataset.role));
document.getElementById('cactbot-export-confirm')?.addEventListener('click', () => {
const settings = cactbotExportSettingsFromModal();
const text = settings.justTimeline ? cactbotTimelineText(plan, settings) : cactbotUserConfigText(plan, settings);
if (!text.split('\n').some(line => /^\s*\d/.test(line))) {
alert('Keine Mitigations zum Exportieren gefunden.'); alert('Keine Mitigations zum Exportieren gefunden.');
return; return;
} }
downloadTextFile(cactbotExportFilename(plan), text); downloadTextFile(`${cactbotExportFilename(plan)}.${settings.justTimeline ? 'txt' : 'js'}`, text);
closeCactbotExportModal();
});
}
function initCactbotExport(planId) {
document.getElementById('cactbot-export-btn')?.addEventListener('click', () => {
showCactbotExportModal(planId);
}); });
} }
@ -766,21 +990,32 @@ function plannedAssignmentsForMechanic(plan, targetMechanic) {
const targetTime = Number(targetMechanic.timestamp); const targetTime = Number(targetMechanic.timestamp);
const tolerance = 50; const tolerance = 50;
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) { const rows = timelinePlayerRows(plan);
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue; const seen = new Set();
const includeActiveAssignment = entry => {
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) return;
// Persönliche Mitigation zeigt nur auf der Mechanik, der sie direkt zugewiesen ist const key = `${entry.assignment.ability}::${entry.assignment.job || '*'}::${entry.start}`;
// (verhindert dass DRK-Cooldowns auf GNB-Tankbuster erscheinen und umgekehrt) if (seen.has(key)) return;
if (TIMELINE_PERSONAL_ABILITIES.has(entry.assignment.ability) seen.add(key);
&& entry.mechanic.id !== targetMechanic.id) {
continue;
}
result.push({ result.push({
...entry.assignment, ...entry.assignment,
sourceMechanicId: entry.mechanic.id, sourceMechanicId: entry.mechanic.id,
sourceStart: entry.start, sourceStart: entry.start,
}); });
};
for (const row of rows) {
for (const entry of canonicalAssignmentActivations(plan, {
dedupeKey: item => canonicalTimelineKey(item, row),
includeEntry: item => {
const assignment = item.assignment;
if (assignment.ability !== row.ability) return false;
const assignedJob = assignment.job ?? '';
return !assignedJob || assignedJob === row.job;
},
})) includeActiveAssignment(entry);
} }
return result; return result;
@ -936,6 +1171,11 @@ function initTimelineOptions(planId) {
// ── Timeline ───────────────────────────────────────────────────────────────── // ── Timeline ─────────────────────────────────────────────────────────────────
function timelineZoom(plan) {
const zoom = Number(plan?.timelineZoom ?? 1);
return Math.max(0.5, Math.min(4, Number.isFinite(zoom) ? zoom : 1));
}
function planDurationMs(plan) { function planDurationMs(plan) {
const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0); const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0);
const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000)); const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000));
@ -944,7 +1184,7 @@ function planDurationMs(plan) {
function timelineScale(plan) { function timelineScale(plan) {
const duration = planDurationMs(plan); const duration = planDurationMs(plan);
const pxPerSecond = 8; const pxPerSecond = 8 * timelineZoom(plan);
return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) }; return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) };
} }
@ -1369,7 +1609,7 @@ function renderTimelineHtml(plan) {
const playerRows = rows.map(row => { const playerRows = rows.map(row => {
const blocks = []; const blocks = [];
for (const entry of canonicalAssignmentActivations(plan, { const rowEntries = canonicalAssignmentActivations(plan, {
dedupeKey: item => canonicalTimelineKey(item, row), dedupeKey: item => canonicalTimelineKey(item, row),
includeEntry: item => { includeEntry: item => {
const assignment = item.assignment; const assignment = item.assignment;
@ -1377,7 +1617,41 @@ function renderTimelineHtml(plan) {
const assignedJob = assignment.job ?? ''; const assignedJob = assignment.job ?? '';
return !assignedJob || assignedJob === row.job; return !assignedJob || assignedJob === row.job;
}, },
})) { }).sort((a, b) => a.start - b.start);
const gaps = [];
for (let i = 1; i < rowEntries.length; i++) {
const prev = rowEntries[i - 1];
const next = rowEntries[i];
const gapStart = Math.max(
prev.start + prev.durationSec * 1000,
prev.start + assignmentCooldownSeconds(prev.assignment) * 1000
);
const gapEnd = next.start;
if (gapEnd <= gapStart) continue;
const left = Math.max(0, Math.min(100, (gapStart / duration) * 100));
const widthPct = Math.max(0.2, Math.min(100 - left, ((gapEnd - gapStart) / duration) * 100));
const gapSeconds = cactbotTime(gapEnd - gapStart);
const requiredWindowMs = Math.max(
assignmentDurationSeconds(prev.assignment),
assignmentCooldownSeconds(prev.assignment)
) * 1000;
const latestExtraStart = gapEnd - requiredWindowMs;
const fitPct = Math.max(0, Math.min(100, ((latestExtraStart - gapStart) / (gapEnd - gapStart)) * 100));
const lateText = fitPct < 100
? `\nZu spät für einen weiteren Einsatz ab ${fmtTimestamp(Math.max(gapStart, latestExtraStart))}`
: '';
gaps.push(`
<span class="timeline-inactive-gap"
style="left:${left}%;width:${widthPct}%"
title="${escHtml(`${localizedAbilityName(row.ability, plan)} nicht aktiv: ${gapSeconds}s (${fmtTimestamp(gapStart)} - ${fmtTimestamp(gapEnd)})${lateText}`)}">
<span class="timeline-inactive-gap-fit" style="width:${fitPct}%"></span>
<span class="timeline-inactive-gap-late" style="left:${fitPct}%;width:${100 - fitPct}%"></span>
</span>
`);
}
for (const entry of rowEntries) {
const item = entry; const item = entry;
const m = item.mechanic; const m = item.mechanic;
const a = item.assignment; const a = item.assignment;
@ -1395,6 +1669,7 @@ function renderTimelineHtml(plan) {
: 'timeline-mitigation--buff'; : 'timeline-mitigation--buff';
const icon = abilityIcon(a.ability); const icon = abilityIcon(a.ability);
const abilityLabel = assignmentAbilityName(a, plan); const abilityLabel = assignmentAbilityName(a, plan);
const tooltip = timelineAssignmentTooltip(a, m, start, durationSec, cooldownSec, plan);
blocks.push(` blocks.push(`
<button class="timeline-mitigation ${cls}${selected}${unresolved}" <button class="timeline-mitigation ${cls}${selected}${unresolved}"
draggable="true" draggable="true"
@ -1402,7 +1677,7 @@ function renderTimelineHtml(plan) {
data-mechanic-id="${escHtml(m.id)}" data-mechanic-id="${escHtml(m.id)}"
data-ability="${escHtml(a.ability)}" data-ability="${escHtml(a.ability)}"
data-job="${escHtml(a.job ?? '')}" data-job="${escHtml(a.job ?? '')}"
title="${escHtml(abilityLabel)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}"> title="${escHtml(tooltip)}">
<span class="timeline-mitigation-active"></span> <span class="timeline-mitigation-active"></span>
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''} ${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<span>${escHtml(abilityLabel)}</span> <span>${escHtml(abilityLabel)}</span>
@ -1422,7 +1697,7 @@ function renderTimelineHtml(plan) {
<span class="timeline-row-ability-name">${escHtml(abilityDisplayName)}</span> <span class="timeline-row-ability-name">${escHtml(abilityDisplayName)}</span>
</span> </span>
</div> </div>
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div> <div class="timeline-track" style="width:${width}px">${hitLines}${gaps.join('')}${blocks.join('')}</div>
</div>`; </div>`;
}).join(''); }).join('');
@ -1467,7 +1742,7 @@ function renderTimelineSettingsHtml(plan) {
<div class="timeline-settings-meta">${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}</div> <div class="timeline-settings-meta">${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}</div>
<label> <label>
<span>Start</span> <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)}"> <input class="timeline-setting-input" data-field="timestamp" type="number" min="0" step="0.1" value="${(((assignment.timestamp ?? mechanic.timestamp) || 0) / 1000).toFixed(1).replace(/\.0$/, '')}">
</label> </label>
<label> <label>
<span>Dauer</span> <span>Dauer</span>
@ -1573,7 +1848,12 @@ function showTimelineMenu(x, y, items) {
menu.id = 'timeline-context-menu'; menu.id = 'timeline-context-menu';
menu.className = 'timeline-context-menu'; menu.className = 'timeline-context-menu';
menu.innerHTML = items.length menu.innerHTML = items.length
? items.map((item, idx) => ` ? items.map((item, idx) => item.type === 'timeInput' ? `
<label class="timeline-menu-time">
<span>${escHtml(item.label)}</span>
<input type="number" min="0" step="0.1" value="${escHtml(item.value)}" data-idx="${idx}">
</label>
` : `
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}${item.header ? ' timeline-menu-header' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}> <button class="timeline-menu-item${item.disabled ? ' disabled' : ''}${item.header ? ' timeline-menu-header' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}>
${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''} ${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''}
<span>${escHtml(item.label)}</span> <span>${escHtml(item.label)}</span>
@ -1594,6 +1874,13 @@ function showTimelineMenu(x, y, items) {
if (!item?.keepOpen) closeTimelineMenu(); if (!item?.keepOpen) closeTimelineMenu();
}); });
menu.addEventListener('change', e => {
const input = e.target.closest('.timeline-menu-time input');
if (!input) return;
const item = items[parseInt(input.dataset.idx, 10)];
item?.onChange?.(input.value);
});
const closeOutside = ev => { const closeOutside = ev => {
if (menu.contains(ev.target)) return; if (menu.contains(ev.target)) return;
closeTimelineMenu(); closeTimelineMenu();
@ -1932,6 +2219,35 @@ function initTimeline(planId) {
timelinePan = null; timelinePan = null;
}); });
timeline.addEventListener('wheel', e => {
if (!(e.ctrlKey || e.metaKey || e.altKey)) return;
const scroll = e.target.closest('.timeline-scroll');
if (!scroll) return;
const plan = getPlan(planId);
if (!plan) return;
e.preventDefault();
const oldTrack = scroll.querySelector('.timeline-track');
const oldWidth = oldTrack?.getBoundingClientRect().width || 1;
const rect = scroll.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const contentX = scroll.scrollLeft + cursorX;
const ratio = contentX / oldWidth;
const currentZoom = timelineZoom(plan);
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
const nextZoom = Math.max(0.5, Math.min(4, currentZoom * factor));
if (Math.abs(nextZoom - currentZoom) < 0.001) return;
updatePlan(planId, { timelineZoom: nextZoom });
refreshTimeline(planId);
const nextScroll = timeline.querySelector('.timeline-scroll');
const nextTrack = nextScroll?.querySelector('.timeline-track');
const nextWidth = nextTrack?.getBoundingClientRect().width || oldWidth;
if (nextScroll) {
nextScroll.scrollLeft = Math.max(0, ratio * nextWidth - cursorX);
}
}, { passive: false });
timeline.addEventListener('click', e => { timeline.addEventListener('click', e => {
if (suppressNextTimelineClick) { if (suppressNextTimelineClick) {
e.preventDefault(); e.preventDefault();
@ -1983,7 +2299,19 @@ function initTimeline(planId) {
const rows = timelinePlayerRows(plan ?? {}); const rows = timelinePlayerRows(plan ?? {});
const found = findTimelineAssignment(plan, selectedTimelineAssignment); const found = findTimelineAssignment(plan, selectedTimelineAssignment);
const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0; const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0;
const items = rows const items = [{
type: 'timeInput',
label: 'Start (s)',
value: (timestamp / 1000).toFixed(1).replace(/\.0$/, ''),
onChange: value => setTimelineAssignmentField(
planId,
block.dataset.mechanicId,
block.dataset.ability,
block.dataset.job,
'timestamp',
value
),
}, ...rows
.filter(row => jobCanUseAbility(row.job, block.dataset.ability)) .filter(row => jobCanUseAbility(row.job, block.dataset.ability))
.filter((row, idx, arr) => arr.findIndex(r => r.job === row.job) === idx) .filter((row, idx, arr) => arr.findIndex(r => r.job === row.job) === idx)
.map(row => ({ .map(row => ({
@ -1995,7 +2323,7 @@ function initTimeline(planId) {
job: block.dataset.job, job: block.dataset.job,
}, found?.assignment ?? null), }, found?.assignment ?? null),
onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job), onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
})); }))];
showTimelineMenu(e.clientX, e.clientY, items); showTimelineMenu(e.clientX, e.clientY, items);
return; return;
} }