diff --git a/static/trip.html b/static/trip.html index fbf1816..cb6f39a 100644 --- a/static/trip.html +++ b/static/trip.html @@ -73,6 +73,9 @@ .room-locked { --wa-color-surface-border: var(--wa-color-brand-50); } .room-label { font-weight: bold; font-size: 0.8rem; margin-bottom: 0.2rem; } .solver-score { font-size: 0.8rem; margin-top: 0.3rem; color: var(--wa-color-neutral-500); } + .swap-group { border-left: 2px solid var(--wa-color-brand-50); padding-left: 0.5rem; margin-bottom: 0.5rem; } + .swap-group-nav { display: flex; align-items: center; gap: 0.3rem; margin-bottom: 0.3rem; } + .swap-group-label { font-size: 0.8rem; color: var(--wa-color-neutral-500); min-width: 3rem; text-align: center; } .divider { border: none; border-top: 1px solid #909090; margin: 0.75rem 0; } .pref-rows { display: grid; grid-template-columns: auto 1fr; } .pref-row { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; align-items: center; gap: 0.5rem; padding: 0.3rem 0.4rem; } diff --git a/static/trip.js b/static/trip.js index 0b31b8b..7ba4a1c 100644 --- a/static/trip.js +++ b/static/trip.js @@ -423,97 +423,190 @@ document.getElementById('solve-btn').addEventListener('click', async () => { const container = document.getElementById('solver-results'); container.innerHTML = ''; - const renderSolution = (sol, parent, lockedRooms) => { - for (let i = 0; i < sol.rooms.length; i++) { - const key = sol.rooms[i].map(m => m.id).sort((a, b) => a - b).join(','); - const card = document.createElement('wa-card'); - const locked = lockedRooms.has(key); - card.className = 'room-card' + (locked ? ' room-locked' : ''); - if (locked) card.setAttribute('appearance', 'outlined'); - const label = document.createElement('div'); - label.className = 'room-label'; - label.textContent = 'Room ' + (i + 1); - card.appendChild(label); - const tags = document.createElement('div'); - tags.className = 'tags'; - const roomIDs = sol.rooms[i].map(m => m.id); - const violations = []; - for (const a of sol.rooms[i]) { - for (const b of sol.rooms[i]) { - if (a.id === b.id) continue; - const eff = lastOveralls[a.id]?.[b.id]; - if (eff && eff.kind === 'prefer_not') { - violations.push({ from: a.name, to: b.name }); - } + const renderRoomCard = (room, parent, roomNum, locked) => { + const card = document.createElement('wa-card'); + card.className = 'room-card' + (locked ? ' room-locked' : ''); + if (locked) card.setAttribute('appearance', 'outlined'); + const label = document.createElement('div'); + label.className = 'room-label'; + label.textContent = 'Room ' + roomNum; + card.appendChild(label); + const tags = document.createElement('div'); + tags.className = 'tags'; + const roomIDs = room.map(m => m.id); + const violations = []; + for (const a of room) { + for (const b of room) { + if (a.id === b.id) continue; + const eff = lastOveralls[a.id]?.[b.id]; + if (eff && eff.kind === 'prefer_not') { + violations.push({ from: a.name, to: b.name }); } } - for (const member of sol.rooms[i]) { - const tag = document.createElement('wa-tag'); - tag.size = 'small'; - tag.style.cursor = 'pointer'; - const hasViolation = violations.some(v => v.from === member.name || v.to === member.name); - const hasPrefers = Object.values(lastOveralls[member.id] || {}).some(e => e.kind === 'prefer'); - const gotPrefer = hasPrefers && roomIDs.some(rid => rid !== member.id && lastOveralls[member.id]?.[rid]?.kind === 'prefer'); - if (hasViolation) tag.variant = 'danger'; - else if (hasPrefers && !gotPrefer) tag.variant = 'warning'; - else tag.variant = 'brand'; - tag.textContent = member.name; - tag.addEventListener('click', () => { - const studentCard = document.querySelector('[data-student-id="' + member.id + '"]'); - if (!studentCard) return; - const cDet = [...studentCard.querySelectorAll('wa-details')].find(d => d.summary === 'Constraints'); - if (cDet) cDet.open = true; - studentCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - tags.appendChild(tag); - } - if (violations.length > 0) { - const warn = document.createElement('div'); - warn.style.fontSize = '0.75rem'; - warn.style.color = 'var(--wa-color-warning-50)'; - warn.textContent = violations.map(v => v.from + ' \u2192 ' + v.to).join(', '); - card.appendChild(tags); - card.appendChild(warn); - } else { - card.appendChild(tags); - } - parent.appendChild(card); } + for (const member of room) { + const tag = document.createElement('wa-tag'); + tag.size = 'small'; + tag.style.cursor = 'pointer'; + const hasViolation = violations.some(v => v.from === member.name || v.to === member.name); + const hasPrefers = Object.values(lastOveralls[member.id] || {}).some(e => e.kind === 'prefer'); + const gotPrefer = hasPrefers && roomIDs.some(rid => rid !== member.id && lastOveralls[member.id]?.[rid]?.kind === 'prefer'); + if (hasViolation) tag.variant = 'danger'; + else if (hasPrefers && !gotPrefer) tag.variant = 'warning'; + else tag.variant = 'brand'; + tag.textContent = member.name; + tag.addEventListener('click', () => { + const studentCard = document.querySelector('[data-student-id="' + member.id + '"]'); + if (!studentCard) return; + const cDet = [...studentCard.querySelectorAll('wa-details')].find(d => d.summary === 'Constraints'); + if (cDet) cDet.open = true; + studentCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + tags.appendChild(tag); + } + if (violations.length > 0) { + const warn = document.createElement('div'); + warn.style.fontSize = '0.75rem'; + warn.style.color = 'var(--wa-color-warning-50)'; + warn.textContent = violations.map(v => v.from + ' \u2192 ' + v.to).join(', '); + card.appendChild(tags); + card.appendChild(warn); + } else { + card.appendChild(tags); + } + parent.appendChild(card); }; const solutions = result.solutions; - let lockedRooms = new Set(); - if (solutions.length > 1) { - const roomKey = (room) => room.map(m => m.id).sort((a, b) => a - b).join(','); - const sets = solutions.map(sol => new Set(sol.rooms.map(roomKey))); - lockedRooms = new Set([...sets[0]].filter(k => sets.every(s => s.has(k)))); - } + const roomKey = (room) => room.map(m => m.id).sort((a, b) => a - b).join(','); + let swapGroups = []; + if (solutions.length === 1) { - renderSolution(solutions[0], container, lockedRooms); + let roomNum = 1; + for (const room of solutions[0].rooms) { + renderRoomCard(room, container, roomNum++, false); + } } else if (solutions.length > 1) { - const optionLabel = (i) => { - const a = 'A'.charCodeAt(0); - return i < 26 ? String.fromCharCode(a + i) : String.fromCharCode(a + Math.floor(i / 26) - 1) + String.fromCharCode(a + (i % 26)); + const sets = solutions.map(sol => new Set(sol.rooms.map(roomKey))); + const lockedKeys = new Set([...sets[0]].filter(k => sets.every(s => s.has(k)))); + + const lockedRoomsList = solutions[0].rooms.filter(r => lockedKeys.has(roomKey(r))); + + const uf = {}; + const ufFind = (x) => { + if (uf[x] === undefined) uf[x] = x; + if (uf[x] !== x) uf[x] = ufFind(uf[x]); + return uf[x]; }; - const tabGroup = document.createElement('wa-tab-group'); - for (let si = 0; si < solutions.length; si++) { - const tab = document.createElement('wa-tab'); - tab.slot = 'nav'; - tab.panel = 'sol-' + si; - tab.textContent = optionLabel(si); - tabGroup.appendChild(tab); + const ufUnion = (a, b) => { + const ra = ufFind(a), rb = ufFind(b); + if (ra !== rb) uf[ra] = rb; + }; + + for (const sol of solutions) { + for (const room of sol.rooms) { + if (lockedKeys.has(roomKey(room))) continue; + const ids = room.map(m => m.id); + for (let i = 1; i < ids.length; i++) { + ufUnion(ids[0], ids[i]); + } + } } - for (let si = 0; si < solutions.length; si++) { - const panel = document.createElement('wa-tab-panel'); - panel.name = 'sol-' + si; - renderSolution(solutions[si], panel, lockedRooms); - tabGroup.appendChild(panel); + + const components = {}; + for (const id of Object.keys(uf)) { + const root = ufFind(parseInt(id)); + if (!components[root]) components[root] = new Set(); + components[root].add(parseInt(id)); + } + + for (const studentIDs of Object.values(components)) { + const configs = []; + const configKeySet = new Set(); + for (const sol of solutions) { + const groupRooms = sol.rooms.filter(r => r.some(m => studentIDs.has(m.id))); + groupRooms.sort((a, b) => roomKey(a).localeCompare(roomKey(b))); + const ck = groupRooms.map(r => roomKey(r)).join('|'); + if (!configKeySet.has(ck)) { + configKeySet.add(ck); + configs.push(groupRooms); + } + } + swapGroups.push({ studentIDs, configs }); + } + swapGroups.sort((a, b) => Math.min(...a.studentIDs) - Math.min(...b.studentIDs)); + + let roomNum = 1; + for (const room of lockedRoomsList) { + renderRoomCard(room, container, roomNum++, true); + } + + for (const group of swapGroups) { + const section = document.createElement('div'); + section.className = 'swap-group'; + + let current = 0; + const roomsDiv = document.createElement('div'); + const baseRoomNum = roomNum; + + const render = () => { + roomsDiv.innerHTML = ''; + let rn = baseRoomNum; + for (const room of group.configs[current]) { + renderRoomCard(room, roomsDiv, rn++, false); + } + }; + + const nav = document.createElement('div'); + nav.className = 'swap-group-nav'; + const prevBtn = document.createElement('wa-button'); + prevBtn.size = 'small'; + prevBtn.variant = 'text'; + prevBtn.textContent = '\u25c0'; + const lbl = document.createElement('span'); + lbl.className = 'swap-group-label'; + lbl.textContent = '1 of ' + group.configs.length; + const nextBtn = document.createElement('wa-button'); + nextBtn.size = 'small'; + nextBtn.variant = 'text'; + nextBtn.textContent = '\u25b6'; + + prevBtn.addEventListener('click', () => { + current = (current - 1 + group.configs.length) % group.configs.length; + lbl.textContent = (current + 1) + ' of ' + group.configs.length; + render(); + }); + nextBtn.addEventListener('click', () => { + current = (current + 1) % group.configs.length; + lbl.textContent = (current + 1) + ' of ' + group.configs.length; + render(); + }); + + nav.appendChild(prevBtn); + nav.appendChild(lbl); + nav.appendChild(nextBtn); + section.appendChild(nav); + section.appendChild(roomsDiv); + container.appendChild(section); + + render(); + roomNum += group.configs[0].length; } - container.appendChild(tabGroup); } + const scoreDiv = document.createElement('div'); scoreDiv.className = 'solver-score'; - scoreDiv.textContent = 'Score: ' + (solutions[0]?.score ?? 0) + (solutions.length > 1 ? ' (' + solutions.length + ' options)' : ''); + let scoreText = 'Score: ' + (solutions[0]?.score ?? 0); + if (swapGroups.length > 0) { + const counts = swapGroups.map(g => g.configs.length); + const total = counts.reduce((a, b) => a * b, 1); + if (swapGroups.length === 1) { + scoreText += ' (' + total + ' options)'; + } else { + scoreText += ' (' + counts.join(' \u00d7 ') + ' = ' + total + ' combinations)'; + } + } + scoreDiv.textContent = scoreText; container.appendChild(scoreDiv); } catch (e) { const container = document.getElementById('solver-results');