/** * 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 = `
${this.escapeHtml(block.title)}
${block.start} – ${block.end}
${block.responsible ? `
${this.escapeHtml(block.responsible)}
` : ''}
`; // 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; } };