feat(frontend): add SeatMap component (tutor/student/student-self variants)
This commit is contained in:
169
frontend/src/lib/components/SeatMap.svelte
Normal file
169
frontend/src/lib/components/SeatMap.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user