|
202 | 202 | { key: 'sunday', label: 'Sunday', abbr: 'Su', order: 6 } |
203 | 203 | ]; |
204 | 204 |
|
| 205 | + type PositionedMeeting = { |
| 206 | + course: Course; |
| 207 | + meeting: MeetingTime; |
| 208 | + startOffset: number; |
| 209 | + width: number; |
| 210 | + startTotal: number; |
| 211 | + endTotal: number; |
| 212 | + bgColor: string; |
| 213 | + textColor: string; |
| 214 | + stackIndex: number; |
| 215 | + overlapCount: number; |
| 216 | + }; |
| 217 | +
|
| 218 | + const stackGapPct = 2; |
| 219 | +
|
| 220 | + let stackedMeetings = $derived.by(() => { |
| 221 | + if (!processedData) return { byDay: {}, maxStacksByDay: {} }; |
| 222 | + const byDay: Record<string, PositionedMeeting[]> = {}; |
| 223 | + const maxStacksByDay: Record<string, number> = {}; |
| 224 | + for (const { key } of dayOrder) { |
| 225 | + byDay[key] = []; |
| 226 | + maxStacksByDay[key] = 1; |
| 227 | + } |
| 228 | + for (const course of processedData) { |
| 229 | + const isLab = course.schedule_type.toLowerCase() === 'laboratory'; |
| 230 | + const bgColorBase = isLab ? labColor : lectureColor; |
| 231 | + for (const meeting of course.meeting_times) { |
| 232 | + for (const { key } of dayOrder) { |
| 233 | + if (!meeting[key as keyof MeetingTime]) continue; |
| 234 | + const startHour = parseInt(meeting.begin_time.split(':')[0]); |
| 235 | + const startMin = parseInt(meeting.begin_time.split(':')[1]); |
| 236 | + const endHour = parseInt(meeting.end_time.split(':')[0]); |
| 237 | + const endMin = parseInt(meeting.end_time.split(':')[1]); |
| 238 | + const startTotal = startHour * 60 + startMin; |
| 239 | + const endTotal = endHour * 60 + endMin; |
| 240 | + const startOffset = ((startHour - 8) * 60 + startMin) / 60 * 8; |
| 241 | + const width = (endTotal - startTotal) / 60 * 8; |
| 242 | + const bgColor = meeting.color ?? bgColorBase; |
| 243 | + const textColor = getTextColor(bgColor); |
| 244 | + byDay[key].push({ course, meeting, startOffset, width, startTotal, endTotal, bgColor, textColor, stackIndex: 0, overlapCount: 1 }); |
| 245 | + } |
| 246 | + } |
| 247 | + } |
| 248 | + for (const { key } of dayOrder) { |
| 249 | + const arr = byDay[key]; |
| 250 | + arr.sort((a, b) => (a.startTotal === b.startTotal ? a.endTotal - b.endTotal : a.startTotal - b.startTotal)); |
| 251 | + const stackEnds: number[] = []; |
| 252 | + const active: PositionedMeeting[] = []; |
| 253 | + for (const item of arr) { |
| 254 | + for (let i = active.length - 1; i >= 0; i--) { |
| 255 | + if (item.startTotal >= active[i].endTotal) { |
| 256 | + active.splice(i, 1); |
| 257 | + } |
| 258 | + } |
| 259 | + const currentOverlap = active.length + 1; |
| 260 | + for (const a of active) { |
| 261 | + a.overlapCount = Math.max(a.overlapCount, currentOverlap); |
| 262 | + } |
| 263 | + item.overlapCount = currentOverlap; |
| 264 | + let stack = stackEnds.findIndex((end) => item.startTotal >= end); |
| 265 | + if (stack === -1) { |
| 266 | + stack = stackEnds.length; |
| 267 | + stackEnds.push(item.endTotal); |
| 268 | + } else { |
| 269 | + stackEnds[stack] = item.endTotal; |
| 270 | + } |
| 271 | + item.stackIndex = stack; |
| 272 | + active.push(item); |
| 273 | + } |
| 274 | + maxStacksByDay[key] = Math.max(...arr.map((m) => m.overlapCount), 1); |
| 275 | + } |
| 276 | + return { byDay, maxStacksByDay }; |
| 277 | + }); |
| 278 | +
|
205 | 279 | function getLatestEndHour(courses: Course[]): number { |
206 | 280 | let latestHour = 8; |
207 | 281 |
|
|
957 | 1031 | </div> |
958 | 1032 |
|
959 | 1033 | {#each dayOrder.slice(0, 5) as day} |
| 1034 | + {@const dayEvents = stackedMeetings.byDay?.[day.key] ?? []} |
960 | 1035 | <div class="flex flex-row flex-1 min-h-[120px] border-b border-outline-variant relative"> |
961 | 1036 | <div class="w-24 border-r border-outline-variant flex items-center justify-center bg-surface-container-low left-0 z-5"> |
962 | 1037 | <span class="font-medium text-sm">{day.label}</span> |
|
967 | 1042 | <div class="w-32 border-r border-outline-variant"></div> |
968 | 1043 | {/each} |
969 | 1044 |
|
970 | | - {#each processedData as course} |
971 | | - {#each course.meeting_times as meeting} |
972 | | - {#if meeting[day.key as keyof MeetingTime]} |
973 | | - {@const startHour = parseInt(meeting.begin_time.split(':')[0])} |
974 | | - {@const startMin = parseInt(meeting.begin_time.split(':')[1])} |
975 | | - {@const endHour = parseInt(meeting.end_time.split(':')[0])} |
976 | | - {@const endMin = parseInt(meeting.end_time.split(':')[1])} |
977 | | - {@const startOffset = ((startHour - 8) * 60 + startMin) / 60 * 8} |
978 | | - {@const width = ((endHour * 60 + endMin) - (startHour * 60 + startMin)) / 60 * 8} |
979 | | - {@const isLab = course.schedule_type.toLowerCase() === 'laboratory'} |
980 | | - {@const bgColorBase = isLab ? labColor : lectureColor} |
981 | | - {@const bgColor = meeting.color ?? bgColorBase} |
982 | | - {@const textColor = getTextColor(bgColor)} |
983 | | - |
984 | | - |
985 | | - <button |
986 | | - class="absolute top-1 bottom-1 rounded px-2 py-1 text-xs overflow-hidden cursor-pointer hover:shadow-md transition-shadow border-t-2" |
987 | | - style="background-color: {bgColor}; color: {textColor}; left: {startOffset}rem; width: {width}rem; border-color: {bgColor};" |
988 | | - onclick={() => {activeCourse = course; activeMeeting = meeting; activeDay = day; getEventPerfs(meeting.id)}} |
989 | | - > |
990 | | - <div class="font-medium truncate">{meeting.title_overrides?.[day.key] ?? course.title}</div> |
991 | | - <div class="opacity-80">{convertTo12Hour(meeting.begin_time)} - {convertTo12Hour(meeting.end_time)}</div> |
992 | | - <div class="opacity-70 text-[10px]">{meeting.location.building.abbreviation} {meeting.location.room}</div> |
993 | | - </button> |
994 | | - {/if} |
995 | | - {/each} |
| 1045 | + {#each dayEvents as item (item.meeting.id)} |
| 1046 | + {@const overlapCount = Math.max(item.overlapCount ?? 1, 1)} |
| 1047 | + {@const heightPct = Math.max((100 - (overlapCount + 1) * stackGapPct) / overlapCount, 0)} |
| 1048 | + {@const topPct = stackGapPct + item.stackIndex * (heightPct + stackGapPct)} |
| 1049 | + <button |
| 1050 | + class="absolute rounded px-2 py-1 text-xs overflow-hidden cursor-pointer hover:shadow-md transition-shadow border-t-2" |
| 1051 | + style={`background-color:${item.bgColor}; color:${item.textColor}; left:${item.startOffset}rem; width:${item.width}rem; top:${topPct}%; height:${heightPct}%; border-color:${item.bgColor};`} |
| 1052 | + onclick={() => {activeCourse = item.course; activeMeeting = item.meeting; activeDay = day; getEventPerfs(item.meeting.id)}} |
| 1053 | + > |
| 1054 | + <div class="font-medium truncate">{item.meeting.title_overrides?.[day.key] ?? item.course.title}</div> |
| 1055 | + <div class="opacity-80">{convertTo12Hour(item.meeting.begin_time)} - {convertTo12Hour(item.meeting.end_time)}</div> |
| 1056 | + <div class="opacity-70 text-[10px]">{item.meeting.location.building.abbreviation} {item.meeting.location.room}</div> |
| 1057 | + </button> |
996 | 1058 | {/each} |
997 | 1059 | </div> |
998 | 1060 | </div> |
|
0 commit comments