feat(frontend): add SeatMap component (tutor/student/student-self variants)

This commit is contained in:
2026-04-28 15:13:35 +02:00
parent 04155c182a
commit 7da7c1e1d0

View File

@@ -0,0 +1,169 @@
<script lang="ts">
interface SeatDef {
id: string;
x: number;
y: number;
table: string;
}
interface StudentRef {
id: number;
name: string;
initials: string;
}
const {
assignments = {},
students = [],
selectedStudent = null,
onSeatClick,
variant = 'tutor',
ownSeat = null,
scale = 1,
} = $props<{
assignments?: Record<string, number>;
students?: StudentRef[];
selectedStudent?: number | null;
onSeatClick?: (seat: SeatDef) => void;
variant?: 'tutor' | 'student' | 'student-self';
ownSeat?: string | null;
scale?: number;
}>();
const W = 760;
const H = 460;
const WALLS = { x: 12, y: 12, w: 736, h: 436 };
const WINDOW = { x: 12, y: 120, w: 6, h: 220 };
const DOOR = { x: 60, y: 448, w: 70, h: 6 };
const BEAMER = { x: 372, y: 24, w: 110, h: 8 };
const PODIUM = { x: 332, y: 60, w: 190, h: 38 };
const TABLES = [
{ id: 'T1', x: 90, y: 150, w: 200, h: 70, label: 'T1' },
{ id: 'T2', x: 470, y: 150, w: 200, h: 70, label: 'T2' },
{ id: 'T3', x: 90, y: 320, w: 200, h: 70, label: 'T3' },
{ id: 'T4', x: 470, y: 320, w: 200, h: 70, label: 'T4' },
];
function makeSeats(): SeatDef[] {
return TABLES.flatMap((t) => [
{ id: `${t.id}-1`, x: t.x + t.w * 0.28, y: t.y - 22, table: t.id },
{ id: `${t.id}-2`, x: t.x + t.w * 0.72, y: t.y - 22, table: t.id },
{ id: `${t.id}-3`, x: t.x + t.w * 0.28, y: t.y + t.h + 22, table: t.id },
{ id: `${t.id}-4`, x: t.x + t.w * 0.72, y: t.y + t.h + 22, table: t.id },
{ id: `${t.id}-5`, x: t.x + t.w + 26, y: t.y + t.h / 2, table: t.id },
]);
}
const SEATS = makeSeats();
function studentById(id: number): StudentRef | undefined {
return students.find((s: StudentRef) => s.id === id);
}
function seatStyle(seat: SeatDef): { bg: string; border: string; label: string; labelColor: string; shadow: string } {
const sid = assignments[seat.id];
const student = sid ? studentById(sid) : undefined;
const isSelected = selectedStudent != null && sid === selectedStudent;
const isOwn = ownSeat === seat.id;
if (variant === 'tutor') {
if (student) {
return {
bg: isSelected ? 'var(--ink)' : '#fbf7ee',
border: isSelected ? 'var(--ink)' : 'var(--ink-2)',
label: student.initials,
labelColor: isSelected ? '#f7eedc' : 'var(--ink)',
shadow: isSelected ? '0 0 0 3px rgba(241,211,106,0.6)' : 'none',
};
}
return { bg: '#f7f1e3', border: 'var(--ink-4)', label: '', labelColor: 'var(--ink-4)', shadow: 'none' };
}
if (variant === 'student') {
if (isOwn) {
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
}
if (sid) {
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
}
return { bg: '#fbf7ee', border: 'var(--ink-2)', label: '', labelColor: '', shadow: 'none' };
}
// student-self (read-only)
if (isOwn) {
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
}
if (sid) {
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
}
return { bg: '#fbf7ee', border: 'var(--ink-3)', label: '', labelColor: '', shadow: 'none' };
}
</script>
<div style="position:relative;width:{W * scale}px;height:{H * scale}px;background:#f7f1e3;border:1px solid var(--rule);border-radius:4px;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.02),0 1px 0 rgba(0,0,0,0.03)">
<!-- Inner graph-paper grid -->
<div style="position:absolute;inset:0;background-image:linear-gradient(to right,rgba(110,90,60,0.05) 1px,transparent 1px),linear-gradient(to bottom,rgba(110,90,60,0.05) 1px,transparent 1px);background-size:{24*scale}px {24*scale}px"></div>
<!-- Design-space inner container at 1× coords, CSS-scaled -->
<div style="position:absolute;inset:0;transform:scale({scale});transform-origin:top left;width:{W}px;height:{H}px">
<!-- Walls -->
<div style="position:absolute;left:{WALLS.x}px;top:{WALLS.y}px;width:{WALLS.w}px;height:{WALLS.h}px;border:2px solid var(--ink-2);border-radius:2px"></div>
<!-- Window -->
<div style="position:absolute;left:{WINDOW.x - 2}px;top:{WINDOW.y}px;width:{WINDOW.w + 4}px;height:{WINDOW.h}px;background:#dfeaf0;border-top:2px solid var(--ink-2);border-bottom:2px solid var(--ink-2)">
<div style="position:absolute;left:1px;top:50%;width:{WINDOW.w + 2}px;height:2px;background:var(--ink-2)"></div>
</div>
<div style="position:absolute;left:-8px;top:{WINDOW.y + WINDOW.h / 2 - 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);transform:rotate(-90deg);transform-origin:left top;letter-spacing:0.1em;text-transform:uppercase">Fenster</div>
<!-- Door gap + arc -->
<div style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 2}px;width:{DOOR.w}px;height:4px;background:#f7f1e3"></div>
<svg style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 36}px;pointer-events:none" width="{DOOR.w + 10}" height="40">
<path d="M 2 38 Q 2 2 {DOOR.w} 2" stroke="var(--ink-3)" stroke-width="1" stroke-dasharray="2 2" fill="none"/>
<line x1="2" y1="38" x2="2" y2="2" stroke="var(--ink-2)" stroke-width="1.5"/>
</svg>
<div style="position:absolute;left:{DOOR.x + 6}px;top:{DOOR.y + 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Tür</div>
<!-- Beamer -->
<div style="position:absolute;left:{BEAMER.x}px;top:{BEAMER.y}px;width:{BEAMER.w}px;height:{BEAMER.h}px;background:var(--ink-2);border-radius:1px"></div>
<div style="position:absolute;left:{BEAMER.x + BEAMER.w + 6}px;top:{BEAMER.y}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Beamer</div>
<!-- Podium -->
<div style="position:absolute;left:{PODIUM.x}px;top:{PODIUM.y}px;width:{PODIUM.w}px;height:{PODIUM.h}px;border:1.5px solid var(--ink-2);background:#efe6d2;border-radius:2px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:0.15em;text-transform:uppercase">
Pult · Tutor:in
</div>
<!-- Tables -->
{#each TABLES as t}
<div style="position:absolute;left:{t.x}px;top:{t.y}px;width:{t.w}px;height:{t.h}px;background:#e8dec5;border:1.5px solid var(--ink-2);border-radius:3px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:22px;color:rgba(31,27,22,0.35);font-style:italic">
{t.label}
</div>
{/each}
<!-- Seats -->
{#each SEATS as seat}
{@const s = seatStyle(seat)}
<button
style="position:absolute;left:{seat.x - 18}px;top:{seat.y - 18}px;width:36px;height:36px;border-radius:50%;background:{s.bg};border:1.5px solid {s.border};cursor:{onSeatClick && variant !== 'student-self' ? 'pointer' : 'default'};display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-weight:600;font-size:11px;color:{s.labelColor};padding:0;box-shadow:{s.shadow};transition:background 120ms,border-color 120ms"
title={assignments[seat.id] ? (studentById(assignments[seat.id])?.name ?? 'besetzt') : 'frei'}
disabled={variant === 'student-self'}
onclick={() => onSeatClick?.(seat)}
>
{s.label}
</button>
{/each}
<!-- Compass -->
<div style="position:absolute;right:18px;bottom:14px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.15em">
<div style="display:flex;flex-direction:column;align-items:center">
<span>N</span>
<svg width="14" height="22">
<path d="M7 2 L7 20 M3 6 L7 2 L11 6" stroke="var(--ink-3)" stroke-width="1" fill="none"/>
</svg>
</div>
</div>
</div>
</div>