<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Seating Chart Maker</title><style> @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap'); *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { --bg: #F7F5F0; --surface: #FFFFFF; --surface2: #F0EDE6; --border: #E2DDD5; --border2: #C8C2B8; --text: #1A1714; --text2: #6B635A; --text3: #9E9690; --accent: #2D6A4F; --accent-light: #D8EFE4; --accent2: #B5451B; --accent2-light: #FDEEE8; --warn: #8B5E00; --warn-light: #FEF3DC; --radius: 10px; --radius-sm: 6px; --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06); } body { font-family: 'DM Sans', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; } /* ── Layout ── */ .app { display: grid; grid-template-rows: 56px 1fr; height: 100vh; overflow: hidden; } .topbar { background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; padding: 0 20px; box-shadow: var(--shadow); z-index: 10; } .topbar-logo { font-weight: 600; font-size: 15px; letter-spacing: -0.3px; } .topbar-logo span { color: var(--accent); } .topbar-spacer { flex: 1; } .topbar select { font-family: inherit; font-size: 13px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 5px 10px; color: var(--text); cursor: pointer; max-width: 200px; } .main { display: grid; grid-template-columns: 300px 1fr 280px; overflow: hidden; } /* ── Panels ── */ .panel { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; } .panel:last-child { border-right: none; border-left: 1px solid var(--border); } .panel-header { padding: 14px 16px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } .panel-title { font-size: 11px; font-weight: 600; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text2); } .panel-body { flex: 1; overflow-y: auto; padding: 12px; } .panel-body::-webkit-scrollbar { width: 4px; } .panel-body::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; } /* ── Canvas ── */ .canvas-area { background: var(--bg); position: relative; overflow: auto; display: flex; align-items: flex-start; justify-content: center; padding: 24px; } #chart-canvas { background: var(--surface); border-radius: 12px; border: 1px solid var(--border); box-shadow: var(--shadow-md); position: relative; min-width: 600px; min-height: 500px; } /* ── Buttons ── */ .btn { display: inline-flex; align-items: center; gap: 6px; font-family: inherit; font-size: 13px; font-weight: 500; padding: 6px 12px; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: all 0.15s; white-space: nowrap; } .btn-primary { background: var(--accent); color: #fff; } .btn-primary:hover { background: #235A41; } .btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } .btn-secondary:hover { background: var(--border); } .btn-danger { background: var(--accent2-light); color: var(--accent2); } .btn-danger:hover { background: #FAD9CE; } .btn-sm { padding: 4px 8px; font-size: 12px; } .btn-icon { padding: 5px 7px; } /* ── Input ── */ input[type=text], input[type=number] { font-family: inherit; font-size: 13px; background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 10px; width: 100%; transition: border-color 0.15s; outline: none; } input[type=text]:focus, input[type=number]:focus { border-color: var(--accent); background: var(--surface); } /* ── Students ── */ .student-add { display: flex; gap: 6px; margin-bottom: 10px; } .student-add input { flex: 1; } .student-list { display: flex; flex-direction: column; gap: 4px; } .student-chip { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: var(--radius-sm); background: var(--surface2); border: 1px solid transparent; cursor: grab; transition: all 0.12s; user-select: none; } .student-chip:hover { border-color: var(--border2); background: var(--border); } .student-chip.seated { opacity: 0.4; } .student-avatar { width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; flex-shrink: 0; font-family: 'DM Mono', monospace; } .student-name { flex: 1; font-size: 13px; font-weight: 500; } .student-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; } .student-chip:hover .student-actions { opacity: 1; } /* ── Seats on Canvas ── */ .desk { position: absolute; width: 80px; height: 70px; background: var(--surface); border: 2px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; box-shadow: var(--shadow); z-index: 1; user-select: none; } .desk:hover { border-color: var(--border2); box-shadow: var(--shadow-md); z-index: 2; } .desk.occupied { border-color: var(--accent); background: var(--accent-light); } .desk.occupied:hover { border-color: #235A41; } .desk.conflict { border-color: var(--accent2) !important; background: var(--accent2-light) !important; } .desk.selected { border-color: #2563EB !important; box-shadow: 0 0 0 3px rgba(37,99,235,0.2) !important; z-index: 3; } .desk.drag-over { border-color: var(--accent) !important; border-style: dashed !important; } .desk.empty-drag-over { background: var(--accent-light); } .desk-label { font-size: 10px; font-family: 'DM Mono', monospace; color: var(--text3); font-weight: 500; } .desk-student { font-size: 11px; font-weight: 600; color: var(--text); text-align: center; line-height: 1.2; max-width: 72px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .desk-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; flex-shrink: 0; font-family: 'DM Mono', monospace; } .desk-icon { font-size: 20px; color: var(--border2); } /* ── Canvas toolbar ── */ .canvas-toolbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; } .canvas-wrap { display: flex; flex-direction: column; flex: 1; overflow: hidden; } /* ── Right Panel: Constraints ── */ .constraint-section { margin-bottom: 16px; } .constraint-label { font-size: 11px; font-weight: 600; color: var(--text2); letter-spacing: 0.5px; text-transform: uppercase; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; } .badge { display: inline-block; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 20px; } .badge-green { background: var(--accent-light); color: var(--accent); } .badge-red { background: var(--accent2-light); color: var(--accent2); } .constraint-pair { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); margin-bottom: 4px; font-size: 12px; font-weight: 500; } .constraint-pair.keep { background: var(--accent-light); color: var(--accent); } .constraint-pair.separate { background: var(--accent2-light); color: var(--accent2); } .constraint-pair span { flex: 1; } .constraint-pair button { background: none; border: none; cursor: pointer; opacity: 0.5; font-size: 14px; padding: 0 2px; } .constraint-pair button:hover { opacity: 1; } .constraint-adder { display: flex; flex-direction: column; gap: 6px; } .constraint-adder select { font-family: inherit; font-size: 12px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 5px 8px; color: var(--text); width: 100%; outline: none; } .constraint-adder select:focus { border-color: var(--accent); } .constraint-adder-row { display: flex; gap: 6px; } .constraint-adder-row select { flex: 1; } /* ── Modal ── */ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center; z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.2s; } .modal-overlay.open { opacity: 1; pointer-events: all; } .modal { background: var(--surface); border-radius: 14px; padding: 24px; width: 380px; max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,0.2); transform: translateY(8px); transition: transform 0.2s; } .modal-overlay.open .modal { transform: translateY(0); } .modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; } .modal-row { margin-bottom: 12px; } .modal-row label { font-size: 12px; font-weight: 500; color: var(--text2); display: block; margin-bottom: 4px; } .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; } /* ── Classes manager ── */ .class-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; } .class-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius-sm); background: var(--surface2); cursor: pointer; transition: background 0.12s; } .class-item:hover { background: var(--border); } .class-item.active { background: var(--accent-light); } .class-item-name { flex: 1; font-size: 13px; font-weight: 500; } .class-item-count { font-size: 11px; color: var(--text3); font-family: 'DM Mono', monospace; } /* ── Toast ── */ .toast { position: fixed; bottom: 20px; right: 20px; background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: var(--radius); font-size: 13px; font-weight: 500; box-shadow: var(--shadow-md); transform: translateY(60px); opacity: 0; transition: all 0.25s; z-index: 200; pointer-events: none; } .toast.show { transform: translateY(0); opacity: 1; } /* ── Empty state ── */ .empty { text-align: center; padding: 40px 20px; color: var(--text3); } .empty-icon { font-size: 32px; margin-bottom: 8px; } .empty p { font-size: 13px; } /* ── Conflict indicator ── */ .conflict-line { position: absolute; pointer-events: none; border-top: 2px dashed #B5451B; z-index: 0; transform-origin: left center; } /* ── Stats bar ── */ .stats-bar { display: flex; gap: 12px; padding: 8px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text3); flex-shrink: 0; background: var(--surface); } .stats-bar b { color: var(--text); } .divider { height: 1px; background: var(--border); margin: 10px 0; } /* ── Grid lines hint ── */ .canvas-hint { position: absolute; inset: 0; pointer-events: none; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; } .canvas-hint p { font-size: 13px; color: var(--text3); } .canvas-hint .hint-icon { font-size: 28px; } .row-group { position: absolute; left: 0; right: 0; height: 1px; background: transparent; border-top: 1px dashed var(--border); pointer-events: none; } /* auto-layout grid preview */ #chart-canvas { transition: none; } </style></head><body> <div class="app"> <!-- TOP BAR --> <header class="topbar"> <div class="topbar-logo">Seat<span>Plan</span></div> <select id="class-switcher" onchange="switchClass(this.value)"> <option value="">— select a class —</option> </select> <button class="btn btn-secondary btn-sm" onclick="openClassModal()">+ New class</button> <div class="topbar-spacer"></div> <button class="btn btn-secondary btn-sm" onclick="autoArrange()">Auto-arrange</button> <button class="btn btn-primary btn-sm" onclick="saveCurrentClass()">Save</button> </header> <div class="main"> <!-- LEFT: Students --> <aside class="panel"> <div class="panel-header"> <span class="panel-title">Students</span> <span id="student-count" style="font-size:11px;color:var(--text3);font-family:'DM Mono',monospace;">0</span> </div> <div class="panel-body"> <div class="student-add"> <input type="text" id="student-input" placeholder="Student name…" onkeydown="if(event.key==='Enter')addStudent()"> <button class="btn btn-primary btn-sm" onclick="addStudent()">Add</button> </div> <div class="student-list" id="student-list"></div> <div id="student-empty" class="empty" style="display:none"> <div class="empty-icon">✏️</div> <p>Add students to get started</p> </div> </div> <div class="stats-bar"> <span><b id="stat-seated">0</b> seated</span> <span><b id="stat-unseated">0</b> unseated</span> </div> </aside> <!-- CENTER: Canvas --> <div class="canvas-wrap"> <div class="canvas-toolbar"> <button class="btn btn-secondary btn-sm" onclick="addRow()">+ Row</button> <button class="btn btn-secondary btn-sm" onclick="addDesk()">+ Desk</button> <div style="flex:1"></div> <label style="font-size:12px;color:var(--text2)">Grid</label> <input type="number" id="grid-cols" value="5" min="1" max="12" style="width:52px" onchange="rebuildGrid()"> <span style="font-size:12px;color:var(--text2)">×</span> <input type="number" id="grid-rows" value="4" min="1" max="10" style="width:52px" onchange="rebuildGrid()"> <button class="btn btn-secondary btn-sm" onclick="rebuildGrid()">Apply</button> <button class="btn btn-danger btn-sm" onclick="clearSeats()">Clear seats</button> </div> <div class="canvas-area" id="canvas-area"> <div id="chart-canvas"> <div class="canvas-hint" id="canvas-hint"> <div class="hint-icon">🪑</div> <p>Create or load a class to begin</p> </div> </div> </div> </div> <!-- RIGHT: Constraints --> <aside class="panel" style="border-left:1px solid var(--border)"> <div class="panel-header"> <span class="panel-title">Constraints</span> </div> <div class="panel-body"> <!-- Keep Together --> <div class="constraint-section"> <div class="constraint-label"> Keep together <span class="badge badge-green" id="keep-count">0</span> </div> <div id="keep-list"></div> </div> <div class="divider"></div> <!-- Separate --> <div class="constraint-section"> <div class="constraint-label"> Keep apart <span class="badge badge-red" id="sep-count">0</span> </div> <div id="sep-list"></div> </div> <div class="divider"></div> <!-- Add constraint --> <div class="constraint-adder"> <div style="font-size:11px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;color:var(--text2);margin-bottom:6px">Add rule</div> <div class="constraint-adder-row"> <select id="c-s1"><option value="">Student A</option></select> <select id="c-s2"><option value="">Student B</option></select> </div> <div class="constraint-adder-row"> <button class="btn btn-secondary" style="flex:1;font-size:12px" onclick="addConstraint('keep')">✓ Keep together</button> <button class="btn btn-danger" style="flex:1;font-size:12px" onclick="addConstraint('sep')">✗ Keep apart</button> </div> </div> <div class="divider"></div> <!-- Violations --> <div id="violations-area" style="display:none"> <div class="constraint-label" style="color:var(--accent2)"> ⚠ Violations </div> <div id="violations-list" style="font-size:12px;color:var(--accent2)"></div> </div> </div> </aside> </div></div> <!-- New Class Modal --><div class="modal-overlay" id="class-modal"> <div class="modal"> <h3>New class</h3> <div class="modal-row"> <label>Class name</label> <input type="text" id="new-class-name" placeholder="e.g. Period 3 Math" onkeydown="if(event.key==='Enter')createClass()"> </div> <div class="modal-row"> <label>Rows</label> <input type="number" id="new-rows" value="4" min="1" max="10"> </div> <div class="modal-row"> <label>Columns</label> <input type="number" id="new-cols" value="5" min="1" max="12"> </div> <div class="modal-actions"> <button class="btn btn-secondary" onclick="closeModal('class-modal')">Cancel</button> <button class="btn btn-primary" onclick="createClass()">Create class</button> </div> </div></div> <!-- Student rename modal --><div class="modal-overlay" id="rename-modal"> <div class="modal"> <h3>Rename student</h3> <div class="modal-row"> <label>Name</label> <input type="text" id="rename-input" onkeydown="if(event.key==='Enter')confirmRename()"> </div> <div class="modal-actions"> <button class="btn btn-secondary" onclick="closeModal('rename-modal')">Cancel</button> <button class="btn btn-primary" onclick="confirmRename()">Save</button> </div> </div></div> <div class="toast" id="toast"></div> <script>// ── State ──────────────────────────────────────────────────────────────const STORAGE_KEY = 'seatplan_v2'; let state = { classes: {}, // { id: { name, students:[], seats:[], constraints:[] } } activeClass: null}; let renameTarget = null;let dragStudentId = null; // ── Init ───────────────────────────────────────────────────────────────function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) state = JSON.parse(raw); } catch(e) {} renderClassSwitcher(); if (state.activeClass && state.classes[state.activeClass]) { loadClass(state.activeClass); }} function persistState() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state));} // ── Classes ────────────────────────────────────────────────────────────function renderClassSwitcher() { const sel = document.getElementById('class-switcher'); const cur = sel.value; sel.innerHTML = '<option value="">— select a class —</option>'; Object.entries(state.classes).forEach(([id, cls]) => { const opt = document.createElement('option'); opt.value = id; opt.textContent = cls.name; if (id === state.activeClass) opt.selected = true; sel.appendChild(opt); });} function openClassModal() { document.getElementById('new-class-name').value = ''; document.getElementById('new-rows').value = 4; document.getElementById('new-cols').value = 5; openModal('class-modal'); setTimeout(() => document.getElementById('new-class-name').focus(), 100);} function createClass() { const name = document.getElementById('new-class-name').value.trim(); if (!name) { toast('Please enter a class name'); return; } const rows = parseInt(document.getElementById('new-rows').value) || 4; const cols = parseInt(document.getElementById('new-cols').value) || 5; const id = 'cls_' + Date.now(); const seats = generateGrid(rows, cols); state.classes[id] = { name, students: [], seats, constraints: [] }; state.activeClass = id; persistState(); renderClassSwitcher(); loadClass(id); closeModal('class-modal'); toast(`Class "${name}" created`);} function switchClass(id) { if (!id) return; state.activeClass = id; persistState(); loadClass(id);} function loadClass(id) { const cls = state.classes[id]; if (!cls) return; state.activeClass = id; document.getElementById('class-switcher').value = id; const rows = getGridDimensions(cls.seats).rows; const cols = getGridDimensions(cls.seats).cols; document.getElementById('grid-rows').value = rows; document.getElementById('grid-cols').value = cols; renderStudents(); renderCanvas(); renderConstraints(); checkViolations();} function getClsData() { return state.classes[state.activeClass];} function saveCurrentClass() { persistState(); toast('Saved!');} // ── Grid ───────────────────────────────────────────────────────────────function generateGrid(rows, cols) { const seats = []; const padding = 20; const dw = 90, dh = 80, gapX = 16, gapY = 14; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { seats.push({ id: `seat_${r}_${c}`, row: r, col: c, x: padding + c * (dw + gapX), y: padding + r * (dh + gapY), studentId: null }); } } return seats;} function getGridDimensions(seats) { if (!seats || !seats.length) return { rows: 4, cols: 5 }; const rows = Math.max(...seats.map(s => s.row)) + 1; const cols = Math.max(...seats.map(s => s.col)) + 1; return { rows, cols };} function rebuildGrid() { const cls = getClsData(); if (!cls) { toast('Please select or create a class first'); return; } const rows = parseInt(document.getElementById('grid-rows').value) || 4; const cols = parseInt(document.getElementById('grid-cols').value) || 5; // Preserve assignments const oldAssign = {}; cls.seats.forEach(s => { if (s.studentId) oldAssign[`${s.row}_${s.col}`] = s.studentId; }); cls.seats = generateGrid(rows, cols); cls.seats.forEach(s => { const key = `${s.row}_${s.col}`; if (oldAssign[key]) s.studentId = oldAssign[key]; }); persistState(); renderCanvas(); renderStudents(); checkViolations();} // ── Students ───────────────────────────────────────────────────────────const COLORS = ['#2D6A4F','#1D4ED8','#7C3AED','#B45309','#0F766E','#BE185D','#9F1239','#065F46'];function studentColor(name) { let hash = 0; for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); return COLORS[Math.abs(hash) % COLORS.length];}function initials(name) { return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);} function addStudent() { const cls = getClsData(); if (!cls) { toast('Create or select a class first'); return; } const input = document.getElementById('student-input'); const name = input.value.trim(); if (!name) return; if (cls.students.find(s => s.name.toLowerCase() === name.toLowerCase())) { toast('Student already exists'); return; } cls.students.push({ id: 'stu_' + Date.now() + Math.random(), name }); input.value = ''; persistState(); renderStudents(); renderConstraintSelects(); checkViolations();} function removeStudent(id) { const cls = getClsData(); cls.students = cls.students.filter(s => s.id !== id); cls.seats.forEach(seat => { if (seat.studentId === id) seat.studentId = null; }); cls.constraints = cls.constraints.filter(c => c.a !== id && c.b !== id); persistState(); renderStudents(); renderCanvas(); renderConstraints(); renderConstraintSelects(); checkViolations();} function openRename(id) { const cls = getClsData(); const stu = cls.students.find(s => s.id === id); if (!stu) return; renameTarget = id; document.getElementById('rename-input').value = stu.name; openModal('rename-modal'); setTimeout(() => document.getElementById('rename-input').focus(), 100);} function confirmRename() { const cls = getClsData(); const stu = cls.students.find(s => s.id === renameTarget); const newName = document.getElementById('rename-input').value.trim(); if (!newName || !stu) return; stu.name = newName; persistState(); renderStudents(); renderCanvas(); renderConstraints(); renderConstraintSelects(); closeModal('rename-modal'); toast('Renamed!');} function renderStudents() { const cls = getClsData(); const list = document.getElementById('student-list'); const empty = document.getElementById('student-empty'); if (!cls || !cls.students.length) { list.innerHTML = ''; empty.style.display = 'block'; document.getElementById('student-count').textContent = '0'; updateStats(); return; } empty.style.display = 'none'; document.getElementById('student-count').textContent = cls.students.length; const seatedIds = new Set(cls.seats.filter(s => s.studentId).map(s => s.studentId)); list.innerHTML = ''; cls.students.forEach(stu => { const seated = seatedIds.has(stu.id); const color = studentColor(stu.name); const chip = document.createElement('div'); chip.className = 'student-chip' + (seated ? ' seated' : ''); chip.draggable = !seated; chip.dataset.id = stu.id; chip.innerHTML = ` <div class="student-avatar" style="background:${color}22;color:${color}">${initials(stu.name)}</div> <span class="student-name">${esc(stu.name)}</span> <div class="student-actions"> <button class="btn btn-secondary btn-sm btn-icon" title="Rename" onclick="event.stopPropagation();openRename('${stu.id}')">✎</button> <button class="btn btn-danger btn-sm btn-icon" title="Remove" onclick="event.stopPropagation();removeStudent('${stu.id}')">✕</button> </div>`; chip.addEventListener('dragstart', e => { dragStudentId = stu.id; e.dataTransfer.effectAllowed = 'move'; }); chip.addEventListener('dragend', () => { dragStudentId = null; }); list.appendChild(chip); }); updateStats();} function updateStats() { const cls = getClsData(); if (!cls) { document.getElementById('stat-seated').textContent = 0; document.getElementById('stat-unseated').textContent = 0; return; } const seated = cls.seats.filter(s => s.studentId).length; document.getElementById('stat-seated').textContent = seated; document.getElementById('stat-unseated').textContent = cls.students.length - seated;} // ── Canvas ─────────────────────────────────────────────────────────────function renderCanvas() { const canvas = document.getElementById('chart-canvas'); const hint = document.getElementById('canvas-hint'); const cls = getClsData(); // Clear desks (not hint) canvas.querySelectorAll('.desk').forEach(d => d.remove()); if (!cls || !cls.seats.length) { hint.style.display = 'flex'; canvas.style.width = '600px'; canvas.style.height = '500px'; return; } hint.style.display = 'none'; const dw = 90, dh = 80, gapX = 16, gapY = 14, pad = 20; const { rows, cols } = getGridDimensions(cls.seats); const w = pad * 2 + cols * (dw + gapX) - gapX; const h = pad * 2 + rows * (dh + gapY) - gapY; canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; const violations = getViolationSet(); cls.seats.forEach(seat => { const stu = seat.studentId ? cls.students.find(s => s.id === seat.studentId) : null; const div = document.createElement('div'); div.className = 'desk' + (stu ? ' occupied' : ''); if (stu && violations.has(stu.id)) div.classList.add('conflict'); div.style.left = seat.x + 'px'; div.style.top = seat.y + 'px'; div.style.width = dw + 'px'; div.style.height = dh + 'px'; div.dataset.seatId = seat.id; if (stu) { const color = studentColor(stu.name); div.innerHTML = ` <div class="desk-avatar" style="background:${color}22;color:${color}">${initials(stu.name)}</div> <span class="desk-student">${esc(stu.name)}</span> <span class="desk-label">${seat.row + 1},${seat.col + 1}</span>`; } else { div.innerHTML = `<div class="desk-icon">+</div><span class="desk-label">${seat.row + 1},${seat.col + 1}</span>`; } // Click to unassign div.addEventListener('click', () => { if (seat.studentId) { seat.studentId = null; persistState(); renderCanvas(); renderStudents(); checkViolations(); } }); // Drop zone div.addEventListener('dragover', e => { e.preventDefault(); div.classList.add('drag-over'); if (!seat.studentId) div.classList.add('empty-drag-over'); }); div.addEventListener('dragleave', () => { div.classList.remove('drag-over','empty-drag-over'); }); div.addEventListener('drop', e => { e.preventDefault(); div.classList.remove('drag-over','empty-drag-over'); if (!dragStudentId) return; // Remove student from any existing seat cls.seats.forEach(s => { if (s.studentId === dragStudentId) s.studentId = null; }); // If seat has someone, swap const prev = seat.studentId; seat.studentId = dragStudentId; // Put evicted student somewhere else (first empty seat) if (prev && prev !== dragStudentId) { const empty = cls.seats.find(s => !s.studentId); if (empty) empty.studentId = prev; } dragStudentId = null; persistState(); renderCanvas(); renderStudents(); checkViolations(); }); canvas.appendChild(div); });} function clearSeats() { const cls = getClsData(); if (!cls) return; cls.seats.forEach(s => s.studentId = null); persistState(); renderCanvas(); renderStudents(); checkViolations(); toast('Seats cleared');} // ── Add Row / Desk ─────────────────────────────────────────────────────function addRow() { const cls = getClsData(); if (!cls) { toast('Create a class first'); return; } const { rows, cols } = getGridDimensions(cls.seats); const dw = 90, dh = 80, gapX = 16, gapY = 14, pad = 20; for (let c = 0; c < cols; c++) { cls.seats.push({ id: `seat_${rows}_${c}_${Date.now()}`, row: rows, col: c, x: pad + c * (dw + gapX), y: pad + rows * (dh + gapY), studentId: null }); } document.getElementById('grid-rows').value = rows + 1; persistState(); renderCanvas();} function addDesk() { const cls = getClsData(); if (!cls) { toast('Create a class first'); return; } const { rows, cols } = getGridDimensions(cls.seats); const dw = 90, dh = 80, gapX = 16, gapY = 14, pad = 20; for (let r = 0; r < rows; r++) { cls.seats.push({ id: `seat_${r}_${cols}_${Date.now()}`, row: r, col: cols, x: pad + cols * (dw + gapX), y: pad + r * (dh + gapY), studentId: null }); } document.getElementById('grid-cols').value = cols + 1; persistState(); renderCanvas();} // ── Auto-arrange ───────────────────────────────────────────────────────function autoArrange() { const cls = getClsData(); if (!cls || !cls.students.length) { toast('No students to arrange'); return; } cls.seats.forEach(s => s.studentId = null); // Simple greedy approach respecting constraints const students = [...cls.students]; shuffle(students); students.forEach(stu => { const available = cls.seats.filter(s => !s.studentId); if (!available.length) return; // Score each seat: fewer violations = better const scores = available.map(seat => { let score = 0; const neighbors = getNeighbors(seat, cls.seats); const neighborStudentIds = neighbors.map(n => n.studentId).filter(Boolean); cls.constraints.forEach(c => { if (c.a !== stu.id && c.b !== stu.id) return; const other = c.a === stu.id ? c.b : c.a; const otherSeat = cls.seats.find(s => s.studentId === other); if (!otherSeat) return; const isNeighbor = neighborStudentIds.includes(other); if (c.type === 'keep') score += isNeighbor ? -10 : 5; if (c.type === 'sep') score += isNeighbor ? 20 : -2; }); return { seat, score }; }); scores.sort((a, b) => a.score - b.score); scores[0].seat.studentId = stu.id; }); persistState(); renderCanvas(); renderStudents(); checkViolations(); toast('Arranged!');} function getNeighbors(seat, seats) { return seats.filter(s => s.id !== seat.id && Math.abs(s.row - seat.row) <= 1 && Math.abs(s.col - seat.col) <= 1 );} function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; }} // ── Constraints ────────────────────────────────────────────────────────function renderConstraintSelects() { const cls = getClsData(); ['c-s1','c-s2'].forEach(id => { const sel = document.getElementById(id); const cur = sel.value; sel.innerHTML = `<option value="">${id === 'c-s1' ? 'Student A' : 'Student B'}</option>`; if (cls) cls.students.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.name; if (s.id === cur) opt.selected = true; sel.appendChild(opt); }); });} function addConstraint(type) { const cls = getClsData(); const a = document.getElementById('c-s1').value; const b = document.getElementById('c-s2').value; if (!a || !b || a === b) { toast('Select two different students'); return; } // Avoid duplicate const dup = cls.constraints.find(c => (c.a === a && c.b === b) || (c.a === b && c.b === a) ); if (dup) { dup.type = type; } else { cls.constraints.push({ a, b, type }); } persistState(); renderConstraints(); checkViolations(); toast(type === 'keep' ? 'Will keep together' : 'Will keep apart');} function removeConstraint(idx) { const cls = getClsData(); cls.constraints.splice(idx, 1); persistState(); renderConstraints(); checkViolations();} function renderConstraints() { const cls = getClsData(); const keepList = document.getElementById('keep-list'); const sepList = document.getElementById('sep-list'); keepList.innerHTML = ''; sepList.innerHTML = ''; if (!cls) { document.getElementById('keep-count').textContent = 0; document.getElementById('sep-count').textContent = 0; return; } const keeps = cls.constraints.filter(c => c.type === 'keep'); const seps = cls.constraints.filter(c => c.type === 'sep'); document.getElementById('keep-count').textContent = keeps.length; document.getElementById('sep-count').textContent = seps.length; cls.constraints.forEach((c, idx) => { const sA = cls.students.find(s => s.id === c.a); const sB = cls.students.find(s => s.id === c.b); if (!sA || !sB) return; const el = document.createElement('div'); el.className = 'constraint-pair ' + (c.type === 'keep' ? 'keep' : 'separate'); el.innerHTML = `<span>${esc(sA.name)} & ${esc(sB.name)}</span><button onclick="removeConstraint(${idx})">✕</button>`; (c.type === 'keep' ? keepList : sepList).appendChild(el); }); renderConstraintSelects();} // ── Violations ─────────────────────────────────────────────────────────function getViolationSet() { const cls = getClsData(); const violating = new Set(); if (!cls) return violating; cls.constraints.forEach(c => { const seatA = cls.seats.find(s => s.studentId === c.a); const seatB = cls.seats.find(s => s.studentId === c.b); if (!seatA || !seatB) return; const isNeighbor = Math.abs(seatA.row - seatB.row) <= 1 && Math.abs(seatA.col - seatB.col) <= 1; if (c.type === 'sep' && isNeighbor) { violating.add(c.a); violating.add(c.b); } if (c.type === 'keep' && !isNeighbor) { violating.add(c.a); violating.add(c.b); } }); return violating;} function checkViolations() { const cls = getClsData(); const area = document.getElementById('violations-area'); const list = document.getElementById('violations-list'); if (!cls) { area.style.display = 'none'; return; } const msgs = []; cls.constraints.forEach(c => { const seatA = cls.seats.find(s => s.studentId === c.a); const seatB = cls.seats.find(s => s.studentId === c.b); if (!seatA || !seatB) return; const sA = cls.students.find(s => s.id === c.a); const sB = cls.students.find(s => s.id === c.b); const isNeighbor = Math.abs(seatA.row - seatB.row) <= 1 && Math.abs(seatA.col - seatB.col) <= 1; if (c.type === 'sep' && isNeighbor) msgs.push(`${sA.name} & ${sB.name} are seated together`); if (c.type === 'keep' && !isNeighbor) msgs.push(`${sA.name} & ${sB.name} are not near each other`); }); if (msgs.length) { area.style.display = 'block'; list.innerHTML = msgs.map(m => `<div style="margin-bottom:4px">⚠ ${esc(m)}</div>`).join(''); } else { area.style.display = 'none'; list.innerHTML = ''; }} // ── Utilities ──────────────────────────────────────────────────────────function esc(str) { return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');} function openModal(id) { document.getElementById(id).classList.add('open'); }function closeModal(id) { document.getElementById(id).classList.remove('open'); } let toastTimer;function toast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 2200);} // close modals on overlay clickdocument.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', e => { if (e.target === overlay) overlay.classList.remove('open'); });}); // Canvas drop support for no-classdocument.getElementById('chart-canvas').addEventListener('dragover', e => e.preventDefault()); // ── Boot ───────────────────────────────────────────────────────────────loadState();</script></body></html