Files
scenar-creator/app/static/js/canvas.js
Daneel 25fd578543
Some checks failed
Build & Push Docker / build (push) Has been cancelled
feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
- Remove all Excel code (import, export, template, pandas, openpyxl)
- New canvas-based schedule editor with drag & drop (interact.js)
- Modern 3-panel UI: sidebar, canvas, documentation tab
- New data model: Block with id/date/start/end, ProgramType with id/name/color
- Clean API: GET /api/health, POST /api/validate, GET /api/sample, POST /api/generate-pdf
- Rewritten PDF generator using ScenarioDocument directly (no DataFrame)
- Professional PDF output: dark header, colored blocks, merged cells, legend, footer
- Sample JSON: "Zimní výjezd oddílu" with 11 blocks, 3 program types
- 30 tests passing (API, core models, PDF generation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 17:02:51 +01:00

268 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Canvas editor for Scenar Creator v3.
* Renders schedule blocks on a time-grid canvas with drag & drop via interact.js.
*/
const Canvas = {
GRID_MINUTES: 15,
PX_PER_SLOT: 30, // 30px per 15 min
START_HOUR: 7,
END_HOUR: 23,
get pxPerMinute() {
return this.PX_PER_SLOT / this.GRID_MINUTES;
},
get totalSlots() {
return ((this.END_HOUR - this.START_HOUR) * 60) / this.GRID_MINUTES;
},
get totalHeight() {
return this.totalSlots * this.PX_PER_SLOT;
},
minutesToPx(minutes) {
return (minutes - this.START_HOUR * 60) * this.pxPerMinute;
},
pxToMinutes(px) {
return Math.round(px / this.pxPerMinute + this.START_HOUR * 60);
},
snapMinutes(minutes) {
return Math.round(minutes / this.GRID_MINUTES) * this.GRID_MINUTES;
},
formatTime(totalMinutes) {
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
},
parseTime(str) {
const [h, m] = str.split(':').map(Number);
return h * 60 + m;
},
isLightColor(hex) {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
},
renderTimeAxis() {
const axis = document.getElementById('timeAxis');
axis.innerHTML = '';
axis.style.height = this.totalHeight + 'px';
for (let slot = 0; slot <= this.totalSlots; slot++) {
const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES;
const isHour = minutes % 60 === 0;
const isHalf = minutes % 30 === 0;
if (isHour || isHalf) {
const label = document.createElement('div');
label.className = 'time-label';
label.style.top = (slot * this.PX_PER_SLOT) + 'px';
label.textContent = this.formatTime(minutes);
if (!isHour) {
label.style.fontSize = '9px';
label.style.opacity = '0.6';
}
axis.appendChild(label);
}
}
},
renderDayColumns(dates) {
const header = document.getElementById('canvasHeader');
const columns = document.getElementById('dayColumns');
header.innerHTML = '';
columns.innerHTML = '';
if (dates.length === 0) dates = [new Date().toISOString().split('T')[0]];
dates.forEach(dateStr => {
// Header
const dh = document.createElement('div');
dh.className = 'day-header';
dh.textContent = dateStr;
header.appendChild(dh);
// Column
const col = document.createElement('div');
col.className = 'day-column';
col.dataset.date = dateStr;
col.style.height = this.totalHeight + 'px';
// Grid lines
for (let slot = 0; slot <= this.totalSlots; slot++) {
const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES;
const line = document.createElement('div');
line.className = 'grid-line ' + (minutes % 60 === 0 ? 'hour' : 'half');
line.style.top = (slot * this.PX_PER_SLOT) + 'px';
col.appendChild(line);
}
// Click area for creating blocks
const clickArea = document.createElement('div');
clickArea.className = 'day-column-click-area';
clickArea.addEventListener('click', (e) => {
if (e.target !== clickArea) return;
const rect = col.getBoundingClientRect();
const y = e.clientY - rect.top;
const minutes = this.snapMinutes(this.pxToMinutes(y));
const endMinutes = Math.min(minutes + 60, this.END_HOUR * 60);
App.createBlock(dateStr, this.formatTime(minutes), this.formatTime(endMinutes));
});
col.appendChild(clickArea);
columns.appendChild(col);
});
},
renderBlocks(blocks, programTypes) {
// Remove existing blocks
document.querySelectorAll('.schedule-block').forEach(el => el.remove());
const typeMap = {};
programTypes.forEach(pt => { typeMap[pt.id] = pt; });
blocks.forEach(block => {
const col = document.querySelector(`.day-column[data-date="${block.date}"]`);
if (!col) return;
const pt = typeMap[block.type_id] || { color: '#94a3b8', name: '?' };
const startMin = this.parseTime(block.start);
const endMin = this.parseTime(block.end);
const top = this.minutesToPx(startMin);
const height = (endMin - startMin) * this.pxPerMinute;
const isLight = this.isLightColor(pt.color);
const el = document.createElement('div');
el.className = 'schedule-block' + (isLight ? ' light-bg' : '');
el.dataset.blockId = block.id;
el.style.top = top + 'px';
el.style.height = Math.max(height, 20) + 'px';
el.style.backgroundColor = pt.color;
el.innerHTML = `
<div class="block-color-bar" style="background:${this.darkenColor(pt.color)}"></div>
<div class="block-title">${this.escapeHtml(block.title)}</div>
<div class="block-time">${block.start} ${block.end}</div>
${block.responsible ? `<div class="block-responsible">${this.escapeHtml(block.responsible)}</div>` : ''}
<div class="resize-handle"></div>
`;
// Click to edit
el.addEventListener('click', (e) => {
if (e.target.classList.contains('resize-handle')) return;
App.editBlock(block.id);
});
col.appendChild(el);
});
this.initInteract();
},
initInteract() {
if (typeof interact === 'undefined') return;
interact('.schedule-block').draggable({
inertia: false,
modifiers: [
interact.modifiers.snap({
targets: [interact.snappers.grid({
x: 1, y: this.PX_PER_SLOT
})],
range: Infinity,
relativePoint: { x: 0, y: 0 }
}),
interact.modifiers.restrict({
restriction: 'parent',
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
})
],
listeners: {
start: (event) => {
event.target.style.zIndex = 50;
event.target.style.opacity = '0.9';
},
move: (event) => {
const target = event.target;
const y = (parseFloat(target.dataset.dragY) || 0) + event.dy;
target.dataset.dragY = y;
const currentTop = parseFloat(target.style.top) || 0;
const newTop = currentTop + event.dy;
target.style.top = newTop + 'px';
},
end: (event) => {
const target = event.target;
target.style.zIndex = '';
target.style.opacity = '';
target.dataset.dragY = 0;
const blockId = target.dataset.blockId;
const newTop = parseFloat(target.style.top);
const height = parseFloat(target.style.height);
const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop));
const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop + height));
App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin));
}
}
}).resizable({
edges: { bottom: '.resize-handle' },
modifiers: [
interact.modifiers.snap({
targets: [interact.snappers.grid({
x: 1, y: this.PX_PER_SLOT
})],
range: Infinity,
relativePoint: { x: 0, y: 0 },
offset: 'self'
}),
interact.modifiers.restrictSize({
min: { width: 0, height: this.PX_PER_SLOT }
})
],
listeners: {
move: (event) => {
const target = event.target;
target.style.height = event.rect.height + 'px';
},
end: (event) => {
const target = event.target;
const blockId = target.dataset.blockId;
const top = parseFloat(target.style.top);
const height = parseFloat(target.style.height);
const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(top));
const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(top + height));
App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin));
}
}
});
},
darkenColor(hex) {
const h = hex.replace('#', '');
const r = Math.max(0, parseInt(h.substring(0, 2), 16) - 30);
const g = Math.max(0, parseInt(h.substring(2, 4), 16) - 30);
const b = Math.max(0, parseInt(h.substring(4, 6), 16) - 30);
return `rgb(${r},${g},${b})`;
},
escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
};