diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..7766bb5
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,9 @@
+{
+ "python-envs.pythonProjects": [
+ {
+ "path": ".",
+ "envManager": "ms-python.python:system",
+ "packageManager": "ms-python.python:pip"
+ }
+ ]
+}
diff --git a/css/planner.css b/css/planner.css
index f23059b..a5a5e24 100644
--- a/css/planner.css
+++ b/css/planner.css
@@ -993,6 +993,39 @@
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 {
width: 18px;
height: 18px;
@@ -1233,6 +1266,24 @@
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-btns {
@@ -1248,17 +1299,176 @@
flex-wrap: wrap;
}
-.cactbot-export-option {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: var(--t2);
- font-size: 12px;
- white-space: nowrap;
+.cactbot-export-modal-box {
+ max-width: 720px;
+ max-height: min(86vh, 780px);
+ overflow-y: auto;
+ padding: 0;
}
-.cactbot-export-option input {
- width: auto;
- margin: 0;
+
+.cactbot-modal-header {
+ 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 {
diff --git a/js/planner.js b/js/planner.js
index c0d3279..13562ad 100644
--- a/js/planner.js
+++ b/js/planner.js
@@ -165,6 +165,16 @@ function abilityEffectTooltip(ability, buffType, plan = null) {
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() {
if (actionMetaPromise) return actionMetaPromise;
actionMetaPromise = (async () => {
@@ -449,7 +459,7 @@ function renderPlanDetail(plan) {
-
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
+ Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Modifier+Scroll zum Zoomen · Klick für Zeiten
${renderTimelineHtml(plan)}
@@ -466,10 +476,6 @@ function renderPlanDetail(plan) {
-
@@ -580,7 +586,7 @@ function cactbotExportFilename(plan) {
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'mitigation-plan';
- return `${base}-mitigations.txt`;
+ return `${base}-mitigations`;
}
function cactbotAssignmentLabel(assignment, plan) {
@@ -588,16 +594,63 @@ function cactbotAssignmentLabel(assignment, plan) {
return assignment.job ? `${assignment.job} ${ability}` : ability;
}
-function cactbotFilterHints(plan) {
- const jobs = [...new Set((plan.jobComposition ?? []).filter(Boolean))].sort();
- if (!jobs.length) return [];
- return ['# Optional job filters - uncomment lines you do not want to see:', ...jobs.map(job => `# hideall ".*${job} .*"`)];
+function cactbotJsString(text) {
+ return String(text ?? '')
+ .replace(/\\/g, '\\\\')
+ .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();
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;
planned.forEach(assignment => {
@@ -613,19 +666,23 @@ function cactbotTimelineText(plan, splitEntries = false) {
const rows = [];
for (const [time, group] of byStart.entries()) {
const assignments = [...group.assignments.values()];
- if (splitEntries) {
+ if (settings.splitEntries) {
assignments.forEach(text => rows.push({ time, text }));
} else {
rows.push({ time, text: assignments.join(', ') });
}
}
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 = [
`# Exported from FF14 Mitigator`,
`# Plan: ${plan.name}`,
plan.source?.fightName ? `# Fight: ${plan.source.fightName}` : null,
`# Format: cactbot timeline entries`,
- ...cactbotFilterHints(plan),
].filter(Boolean);
return [
@@ -634,6 +691,40 @@ function cactbotTimelineText(plan, splitEntries = false) {
].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) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
@@ -646,17 +737,150 @@ function downloadTextFile(filename, text) {
URL.revokeObjectURL(url);
}
-function initCactbotExport(planId) {
- document.getElementById('cactbot-export-btn')?.addEventListener('click', () => {
- const plan = getPlan(planId);
- if (!plan) return;
- const splitEntries = !!document.getElementById('cactbot-export-split')?.checked;
- const text = cactbotTimelineText(plan, splitEntries);
- if (!text.split('\n').some(line => /^\d/.test(line))) {
+function cactbotExportModalHtml(plan) {
+ const slots = cactbotExportSlots(plan);
+ const roles = [...new Set(slots.map(slot => slot.role).filter(Boolean))]
+ .sort((a, b) => cactbotRoleOrder(a) - cactbotRoleOrder(b));
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Roles
+
+ ${roles.map(role => `
+
+ `).join('')}
+
+
+
+
+
Players
+
+ ${roles.map(role => `
+
+
${escHtml(cactbotRoleLabel(role))}
+ ${slots.filter(slot => slot.role === role).map(slot => `
+
+ `).join('')}
+
+ `).join('')}
+
+
+
+
+
+
+
+
+
`;
+}
+
+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.');
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 tolerance = 50;
- for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
- if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
+ const rows = timelinePlayerRows(plan);
+ 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
- // (verhindert dass DRK-Cooldowns auf GNB-Tankbuster erscheinen und umgekehrt)
- if (TIMELINE_PERSONAL_ABILITIES.has(entry.assignment.ability)
- && entry.mechanic.id !== targetMechanic.id) {
- continue;
- }
+ const key = `${entry.assignment.ability}::${entry.assignment.job || '*'}::${entry.start}`;
+ if (seen.has(key)) return;
+ seen.add(key);
result.push({
...entry.assignment,
sourceMechanicId: entry.mechanic.id,
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;
@@ -936,6 +1171,11 @@ function initTimelineOptions(planId) {
// ── 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) {
const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0);
const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000));
@@ -944,7 +1184,7 @@ function planDurationMs(plan) {
function timelineScale(plan) {
const duration = planDurationMs(plan);
- const pxPerSecond = 8;
+ const pxPerSecond = 8 * timelineZoom(plan);
return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) };
}
@@ -1369,7 +1609,7 @@ function renderTimelineHtml(plan) {
const playerRows = rows.map(row => {
const blocks = [];
- for (const entry of canonicalAssignmentActivations(plan, {
+ const rowEntries = canonicalAssignmentActivations(plan, {
dedupeKey: item => canonicalTimelineKey(item, row),
includeEntry: item => {
const assignment = item.assignment;
@@ -1377,7 +1617,41 @@ function renderTimelineHtml(plan) {
const assignedJob = assignment.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(`
+
+
+
+
+ `);
+ }
+
+ for (const entry of rowEntries) {
const item = entry;
const m = item.mechanic;
const a = item.assignment;
@@ -1395,6 +1669,7 @@ function renderTimelineHtml(plan) {
: 'timeline-mitigation--buff';
const icon = abilityIcon(a.ability);
const abilityLabel = assignmentAbilityName(a, plan);
+ const tooltip = timelineAssignmentTooltip(a, m, start, durationSec, cooldownSec, plan);
blocks.push(`