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');