fix(startseite): durchgehendes Basel-Panorama, Kreise ohne Stühle

Vier Korrekturen aus der Nachprüfung.

**Naht in der linken Fensterfront.** Sie stammte von zwei getrennten
Fotoflächen — vorne und links. Eine Stossstelle lässt sich nicht wegblenden;
sie verschwindet nur, wenn es keine zwei Bilder mehr gibt. Die Kulisse ist
jetzt eine gekrümmte Fläche mit einer einzigen Panoramadatei: auf einem
durchgehenden Zylinder ist eine Naht geometrisch unmöglich.

Das Panorama entsteht einmalig aus den beiden Referenzfotos
(`scripts/build-basel-panorama.mjs`), jede Quelle genau einmal — kein Kacheln,
kein Spiegeln. Das gute Motiv vom Tischende bleibt in Ausschnitt und Farbe
unverändert und steht weiterhin in der Frontscheibe; die Panoramaaufnahme setzt
es nach links fort, helligkeitsangeglichen und über 320 Pixel überblendet. Aus
der Fortsetzung wird nur der Abschnitt östlich des Münsters verwendet — beide
Vorlagen zeigen es, nebeneinander stünde es zweimal im Bild. Himmel und Wasser
sind aus den Randzeilen gestreckt, damit oben und unten keine Bildgrenze bleibt.

**Stühle und Körper entfernt.** Sitzmöbel, Rümpfe, Schultern, Hälse, Arme und
Hinterköpfe sind ersatzlos weg. Die Agenten bestehen nur noch aus ihrem runden
Porträt mit weissem Ring.

**Positionen neu.** Ferdi und Bruno sind die direkten Nachbarn links und rechts
vorne, die übrigen gestaffelt dahinter, Thomas unverändert vis-à-vis. Die
Kreise liegen deutlich über der Blickachse: lagen sie auf Augenhöhe,
projizierten alle fünf auf fast dieselbe Bildhöhe und die hinteren
verschwanden hinter den vorderen — seitliches Auseinanderziehen half dagegen
nicht, weil ein tieferer Platz in der Perspektive zur Mitte wandert.

Dazu ein echter Fehler, der dabei auffiel: die grosszügigen Trefferquader der
vorderen Kreise fingen die Strahlen der hinteren ab, ein Klick auf Livia landete
bei Ferdi. Die Trefferfläche ist jetzt eine Kugel knapp um den sichtbaren Kreis.

**Bildschärfe.** Die Textur wird auf einen mittigen quadratischen Ausschnitt
begrenzt — ohne das zieht die Kreisgeometrie ein liegendes Foto in die Breite,
was Gesichter verzerrt und Schärfe kostet. Dazu anisotrope Filterung und
Mipmaps. Höher aufgelöste Originale der fünf Agenten waren nicht auffindbar:
die Porträts in `Agenten_Verwaltung_Fotos.html` liegen zwar in 256×256 vor,
gehören aber zu einer anderen Belegschaft — geprüft und verworfen, statt Gesichter
zu vertauschen.

**Ortsangabe** unten rechts entfernt. **Menü** liegt bündig an der rechten
Fensterkante: der Spalt kam vom Anker am eingerückten Menüsymbol, jetzt ist das
Klappmenü an eine Bildschirmstelle gebunden.

Geprüft: 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925). Im
Browser 1280 und 1600 px: sechs Beschriftungen ohne Überlappung, sechs Kreise
ohne Überlappung, alle sechs per Maus auf der richtigen Subpage, Menü bündig
ohne waagrechten Scrollbalken, keine Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-17 14:32:58 +02:00
1 parent bf453d2b69
commit 4349641ee9
7 files changed
+358 -158

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

+21 -3
View File
@@ -37,6 +37,9 @@ const ROLE_LABELS: Record<string, string> = {
*/
export function GlobalNavMenu() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
// Beim Öffnen festgehaltene Ankerstelle: oben unter der Kopfzeile, rechts
// exakt an der Fensterkante.
const [ankerStelle, setAnkerStelle] = useState({ top: 64, left: 0 })
const { currentUser, logout } = useSessionStore()
const navigate = useNavigate()
const showToast = useToastStore(s => s.showToast)
@@ -61,7 +64,12 @@ export function GlobalNavMenu() {
<Tooltip title="Menü">
<IconButton
size="small"
onClick={e => setAnchorEl(open ? null : e.currentTarget)}
onClick={e => {
if (open) { setAnchorEl(null); return }
const b = e.currentTarget.getBoundingClientRect()
setAnkerStelle({ top: b.bottom + 8, left: document.documentElement.clientWidth })
setAnchorEl(e.currentTarget)
}}
aria-label="Menü öffnen"
aria-haspopup="true"
aria-expanded={open}
@@ -75,12 +83,22 @@ export function GlobalNavMenu() {
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
/*
* An eine Bildschirmstelle statt an das Symbol gebunden: das Symbol liegt
* um den Innenabstand der Kopfzeile eingerückt, und ein daran
* ausgerichtetes Menü erbte genau diesen Abstand als Spalt zur rechten
* Fensterkante (§5.1). `marginThreshold={0}` hebt zusätzlich den
* Sicherheitsabstand auf, den MUI sonst zum Fensterrand hält.
* Gerechnet wird mit `clientWidth` statt `innerWidth`, also ohne den
* Scrollbalken — sonst entstünde ein waagrechter Scrollbalken.
*/
anchorReference="anchorPosition"
anchorPosition={ankerStelle}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
marginThreshold={0}
slotProps={{
paper: {
sx: {
mt: 1,
width: 320,
borderRadius: 2,
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
+1 -11
View File
@@ -8,8 +8,7 @@ import { MEETING_SEATS } from './meetingRoomSeats'
import { AGENT_PHOTOS } from '../team/agentPhotos'
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
import { WorkloadTone } from '../../domain/agentWorkload'
import { DS_ACCENT } from '../../lib/ds'
import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds'
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
/**
* Farben der Auslastung auf dunklem Grund. Hellere Stufen als die
@@ -328,15 +327,6 @@ export function MeetingRoom3D() {
))}
</Box>
<Typography
sx={{
position: 'absolute', right: 12, bottom: 8,
fontSize: '0.625rem', letterSpacing: 0.4,
color: DS_TEXT.inverted, opacity: 0.45, pointerEvents: 'none',
}}
>
Kleinbasel · Blick über den Rhein nach Grossbasel
</Typography>
</Box>
)
}
+16 -8
View File
@@ -51,18 +51,18 @@ export const ROOM_COLORS = {
export const ROOM = {
width: 7.4,
height: 3.05,
depth: 10.6,
depth: 9.2,
} as const
/** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */
export const CAMERA = {
/** Sitzende Augenhöhe über dem Boden. */
eyeHeight: 1.38,
eyeHeight: 1.19,
/** Knapp hinter der vorderen Tischkante. */
z: 1.9,
fov: 48,
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
lookAt: [0, 1.06, -6.2] as const,
lookAt: [0, 1.24, -5.2] as const,
/**
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
@@ -72,21 +72,29 @@ export const CAMERA = {
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
export const TABLE = {
width: 1.62,
width: 1.70,
/** Höhe der Plattenoberseite über dem Boden. */
top: 0.745,
thickness: 0.06,
/** Vordere Kante, knapp vor der Kamera. */
zNear: 0.15,
/** Hintere Kante, vor dem Platz von Thomas. */
zFar: -7.35,
zFar: -5.75,
} as const
/** Sitzende Personen: Kopfhöhe und Grössen. */
export const SEATED = {
/** Kopfmitte über dem Boden — deutlich über der Tischplatte. */
headY: 1.30,
headRadius: 0.155,
/**
* Höhe der Porträtkreise über dem Boden.
*
* Bewusst deutlich über der Blickachse der Kamera (1.19 m). Lagen die Kreise
* auf Augenhöhe, projizierten alle fünf auf fast dieselbe Bildhöhe und die
* hinteren verschwanden hinter den vorderen — unabhängig davon, wie weit man
* sie seitlich auseinanderzog. Erst der Höhenunterschied zur Blickachse
* trennt sie in der Tiefe (§3.2).
*/
headY: 1.52,
headRadius: 0.142,
/** Torso vom Stuhl bis zum Hals. */
torsoTop: 1.12,
torsoBottom: 0.42,
+101 -114
View File
@@ -2,8 +2,7 @@ import * as THREE from 'three'
import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
import { SeatSide } from './meetingRoomSeats'
import type { MeetingSeat } from './meetingRoomSeats'
import baselFront from '../../assets/staff/basel-front.jpg'
import baselLeft from '../../assets/staff/basel-left.jpg'
import baselPanorama from '../../assets/staff/basel-panorama.jpg'
/**
* Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
@@ -129,46 +128,63 @@ function addRoomShell(scene: THREE.Scene) {
}
/**
* Basler Fernkulisse (§4).
* Basler Fernkulisse (Runde 6, §2).
*
* Zwei grosse Flächen weit aussen vor den Glasfronten, texturiert mit den beiden
* Referenzfotos aus dem Auftrag. Sie stehen so weit weg, dass die Perspektive
* sie als Ferne liest und die Fensterpfosten davor sauber schneiden.
* **Eine** gekrümmte Fläche mit **einem** Panoramabild — nicht mehr zwei
* Flächen mit zwei Fotos. Genau deren Stossstelle war auf der linken
* Fensterseite als harte Kante sichtbar. Auf einem durchgehenden Zylinder gibt
* es diese Stelle nicht mehr: der Blick nach vorne und der Blick nach links
* lesen dasselbe Bild an benachbarten Stellen, eine Naht ist geometrisch
* unmöglich.
*
* Beide Fotos zeigen dieselbe Rheinseite von derselben Uferseite. Damit der
* Blick nach vorne und der Blick nach links zusammengehören, liegt die
* Wasserlinie beider Kulissen auf derselben Welthöhe — sonst wirkte es wie zwei
* zufällig zusammengesetzte Bilder.
* Das Panorama entsteht einmalig aus den beiden Referenzfotos des Auftrags;
* siehe `scripts/build-basel-panorama.mjs`. Das Motiv vom Tischende bleibt darin
* unverändert und wird nach links fortgeführt.
*
* Die Zahlen sind so gewählt, dass Bildmasstab und Wasserlinie denen der
* früheren Frontfläche entsprechen — das gut wirkende Motiv am Tischende
* erscheint dadurch unverändert gross (§2.2).
*/
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader) {
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) {
/** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */
const radius = 62
/**
* Abgedeckter Bogen. Er beginnt rechts neben der Frontscheibe und läuft über
* die Front nach links bis hinter die linke Glasfassade — mehr braucht es
* nicht, und weniger Bogen heisst mehr Bildpunkte je Grad.
*/
const arc = 2.62
/** Höhe passend zum Seitenverhältnis des Panoramas (1523 × 660). */
const height = (radius * arc) / (1523 / 660)
/** Wasserlinie im Panorama, als Anteil von oben. */
const waterFraction = 400 / 660
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
const waterY = -7.5
function backdrop(url: string, waterFraction: number, w: number, h: number) {
const tex = loader.load(url)
tex.colorSpace = THREE.SRGBColorSpace
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(w, h),
// Unbeleuchtet: das Foto trägt seine eigene Belichtung, jede Beleuchtung
// würde es nur verfälschen.
new THREE.MeshBasicMaterial({ map: tex }),
)
// Mittelpunkt so setzen, dass die Wasserlinie des Fotos auf `waterY` liegt.
mesh.position.y = waterY + h * (waterFraction - 0.5)
return mesh
}
const tex = loader.load(baselPanorama)
tex.colorSpace = THREE.SRGBColorSpace
tex.anisotropy = maxAnisotropy
// Kein Wiederholen: der Bogen deckt genau die Bildbreite ab, und eine
// Wiederholung wäre als Doppelung sichtbar (§2.2).
tex.wrapS = THREE.ClampToEdgeWrapping
tex.wrapT = THREE.ClampToEdgeWrapping
// Vorne: Blick über den Rhein auf Altstadt und Münster. Wasserlinie bei ~60 %.
const front = backdrop(baselFront, 0.6, 70, 47)
front.position.z = -58
scene.add(front)
// Links: Rheinverlauf mit Altstadtsilhouette. Panoramaformat, Wasserlinie ~64 %.
const left = backdrop(baselLeft, 0.64, 118, 38)
left.rotation.y = Math.PI / 2
left.position.x = -56
left.position.z = -26
scene.add(left)
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, height, 96, 1, true, 0, arc),
// Unbeleuchtet und von innen sichtbar: das Foto trägt seine eigene
// Belichtung, jede Beleuchtung würde sie nur verfälschen.
new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, fog: false }),
)
// Mittelpunkt so setzen, dass die Wasserlinie des Panoramas auf `waterY` liegt.
mesh.position.y = waterY - height * (0.5 - waterFraction)
/**
* Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen.
* Der Abzug von 0.68 setzt damit u ≈ 0.26 in die Blickachse — dort steht im
* Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom
* Tischende, die linke Fassade seine Fortsetzung nach links.
*/
mesh.rotation.y = Math.PI - 0.68
scene.add(mesh)
}
/** Bodentiefe Glasfassade mit Pfosten — links und gegenüber (§3). */
@@ -264,122 +280,92 @@ function addTable(scene: THREE.Scene) {
}
/**
* Ein sitzender Agent (§6).
* Ein Agent am Tisch (§3).
*
* Kopf ist eine Kreisfläche mit dem bestehenden Porträt als Textur — die
* Kreisgeometrie beschneidet das quadratische Foto rund, genau wie die Avatare
* im übrigen Programm. Darunter ein Torso in Anzugfarbe und eine Stuhllehne.
* Nur das runde Porträt mit einem weissen Ring — keine Stühle, keine Körper,
* keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind
* ersatzlos entfallen (§3.1).
*
* Der Kopf ist um `yaw` zum Tisch gedreht: die Agenten schauen ins Geschehen
* (§2), bleiben dabei aber lesbar. Voll zur Tischmitte gedreht sähe man von
* einem frontalen Porträtfoto nur die Kante.
* Das Porträt hängt in einer Kreisfläche. Weil die Vorlagen unterschiedliche
* Seitenverhältnisse haben, wird die Textur auf einen mittigen quadratischen
* Ausschnitt begrenzt: ohne diese Beschneidung zieht die Kreisgeometrie ein
* liegendes Foto in die Breite, was Gesichter verzerrt und Schärfe kostet (§3.3).
*
* Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von
* einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch
* genügt, damit die Runde zusammengehört.
*/
function addSeatedAgent(
scene: THREE.Scene,
loader: THREE.TextureLoader,
seat: MeetingSeat,
photoUrl: string | undefined,
suitColor: number,
maxAnisotropy: number,
): SeatHandle {
const group = new THREE.Group()
group.position.set(seat.x, 0, seat.z)
// Blickrichtung: zum Kopfende des Betrachters hin, aber zur Tischmitte gedreht.
const yaw =
seat.side === SeatSide.HEAD ? 0
: seat.side === SeatSide.LEFT ? 0.62
: -0.62
: seat.side === SeatSide.LEFT ? 0.42
: -0.42
// ── Torso ──
const torsoHeight = SEATED.torsoTop - SEATED.torsoBottom
const torso = new THREE.Mesh(
new THREE.CylinderGeometry(SEATED.torsoRadiusTop, SEATED.torsoRadiusBottom, torsoHeight, 20, 1),
new THREE.MeshLambertMaterial({ color: suitColor }),
)
torso.position.y = SEATED.torsoBottom + torsoHeight / 2
group.add(torso)
// Schultern: eine flach gedrückte Kugel schliesst den Torso oben ab
const shoulders = new THREE.Mesh(
new THREE.SphereGeometry(SEATED.shoulderWidth / 2, 20, 12),
new THREE.MeshLambertMaterial({ color: suitColor }),
)
shoulders.scale.set(1, 0.42, 0.66)
shoulders.position.y = SEATED.torsoTop - 0.02
group.add(shoulders)
// ── Kopf ──
const head = new THREE.Group()
head.position.y = SEATED.headY
head.rotation.y = yaw
const ring = new THREE.Mesh(
new THREE.CircleGeometry(SEATED.headRadius + 0.018, 40),
new THREE.CircleGeometry(SEATED.headRadius + 0.016, 48),
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
)
ring.position.z = -0.004
head.add(ring)
if (photoUrl) {
const tex = loader.load(photoUrl)
const tex = loader.load(photoUrl, t => {
// Mittigen quadratischen Ausschnitt wählen, sobald die Masse bekannt sind.
const { width, height } = t.image as { width: number; height: number }
if (width > height) {
t.repeat.set(height / width, 1)
t.offset.set((1 - height / width) / 2, 0)
} else if (height > width) {
t.repeat.set(1, width / height)
t.offset.set(0, (1 - width / height) / 2)
}
t.needsUpdate = true
})
tex.colorSpace = THREE.SRGBColorSpace
// Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden
// die Porträts an den Rändern matschig.
tex.anisotropy = maxAnisotropy
tex.generateMipmaps = true
tex.minFilter = THREE.LinearMipmapLinearFilter
tex.magFilter = THREE.LinearFilter
const face = new THREE.Mesh(
new THREE.CircleGeometry(SEATED.headRadius, 40),
new THREE.CircleGeometry(SEATED.headRadius, 48),
new THREE.MeshBasicMaterial({ map: tex }),
)
head.add(face)
}
// Hinterkopf, damit von der Seite kein Loch entsteht
const backOfHead = new THREE.Mesh(
new THREE.SphereGeometry(SEATED.headRadius * 0.94, 20, 14),
new THREE.MeshLambertMaterial({ color: 0x2a2724 }),
)
backOfHead.position.z = -SEATED.headRadius * 1.05
backOfHead.scale.set(1, 1, 0.62)
head.add(backOfHead)
// Hals
const neck = new THREE.Mesh(
new THREE.CylinderGeometry(0.055, 0.07, 0.16, 12),
new THREE.MeshLambertMaterial({ color: 0xc79a7c }),
)
neck.position.y = SEATED.headY - 0.17
group.add(neck)
group.add(head)
// Unterarme auf der Platte. Sie kosten zwei Quader und sind der Unterschied
// zwischen einem Rumpf hinter einer Kante und einer Person, die am Tisch sitzt.
const armMat = new THREE.MeshLambertMaterial({ color: suitColor })
for (const sign of [-1, 1]) {
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.075, 0.42), armMat)
arm.position.set(0, TABLE.top + 0.045, 0)
arm.rotation.y = yaw
arm.translateX(sign * 0.16)
// Zur Tischmitte hin nach vorne gelegt
arm.translateZ(seat.side === SeatSide.HEAD ? 0.3 : 0.3)
group.add(arm)
}
// ── Stuhllehne hinter dem Agenten ──
const chair = new THREE.Mesh(
new THREE.BoxGeometry(0.56, 0.62, 0.06),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.chair }),
)
chair.position.set(0, 0.86, 0)
chair.rotation.y = yaw
chair.translateZ(-0.28)
group.add(chair)
// ── Trefferkörper: gross genug für bequemes Klicken (§9) ──
const hitBox = new THREE.Mesh(
new THREE.BoxGeometry(0.94, 1.34, 0.82),
/*
* Eine Kugel knapp um den sichtbaren Kreis — nicht ein grosszügiger Quader.
* Ein weiter Quader am vorderen Platz fing die Strahlen der hinteren Plätze
* ab: der Raycaster nimmt den nächstgelegenen Treffer, und damit landete ein
* Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was
* man sieht.
*/
new THREE.SphereGeometry(SEATED.headRadius * 1.18, 12, 8),
// Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
// das Objekt aus der Strahlprüfung nehmen.
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
)
hitBox.position.y = 0.9
hitBox.position.y = SEATED.headY
group.add(hitBox)
scene.add(group)
@@ -415,7 +401,7 @@ export function createMeetingRoomScene(
scene.background = new THREE.Color(ROOM_COLORS.sky)
// Leichter Dunst nur in der Ferne: der Raum (0…12 m) bleibt klar, die Kulisse
// bei 40–65 m bekommt Tiefe. Ohne ihn klebt die Stadt an der Scheibe.
scene.fog = new THREE.Fog(ROOM_COLORS.sky, 30, 110)
scene.fog = new THREE.Fog(ROOM_COLORS.sky, 40, 140)
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300)
camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
@@ -432,14 +418,15 @@ export function createMeetingRoomScene(
scene.add(bounce)
const loader = new THREE.TextureLoader()
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy()
addBaselBackdrop(scene, loader)
addBaselBackdrop(scene, loader, maxAnisotropy)
addRoomShell(scene)
addGlassFacades(scene)
addTable(scene)
const handles = seats.map((seat, i) =>
addSeatedAgent(scene, loader, seat, photos[seat.id], ROOM_COLORS.suits[i % ROOM_COLORS.suits.length]),
const handles = seats.map(seat =>
addSeatedAgent(scene, loader, seat, photos[seat.id], maxAnisotropy),
)
const baseCam = camera.position.clone()
+29 -22
View File
@@ -42,33 +42,40 @@ export interface MeetingSeat {
}
/**
* Abstand der Plätze entlang des Tisches, in Metern. Bewusst grosszügig: bei
* engerer Staffelung überschneiden sich in der Perspektive die Köpfe der
* hinteren Plätze mit denen der vorderen (§5 «keine Personen überlappen»).
* Sitzplätze der fünf Agenten, in Metern (Runde 6, §3.2).
*
* Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links,
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dadurch
* entsteht hinter ihnen Platz, in dem die übrigen drei gestaffelt Raum haben,
* ohne sich zu überlagern.
*
* Vorher standen alle fünf zu weit hinten und drängten sich am Fluchtpunkt.
*/
const SEAT_Z = [-1.75, -3.55, -5.35]
const PLAETZE: Record<string, { side: SeatSide; x: number; z: number }> = {
ferdi: { side: SeatSide.LEFT, x: -1.14, z: -1.20 },
bruno: { side: SeatSide.RIGHT, x: 1.14, z: -1.20 },
livia: { side: SeatSide.LEFT, x: -1.42, z: -2.90 },
nora: { side: SeatSide.RIGHT, x: 1.42, z: -2.90 },
sina: { side: SeatSide.LEFT, x: -1.30, z: -4.75 },
}
/** Seitlicher Abstand von der Tischmitte — knapp aussen an der Tischkante. */
const SEAT_X = 1.32
/**
* Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt
* lautlos im Nichts.
*/
const FALLBACK = { side: SeatSide.RIGHT, x: 1.30, z: -4.75 }
/** Kopfende gegenüber: hinter der Tischkante, vor der Glasfront. */
const HEAD_Z = -8.15
/** Kopfende gegenüber. Unverändert: die Position wirkt gut (§3.2). */
const HEAD_Z = -6.75
export const MEETING_SEATS: MeetingSeat[] = [
...AGENT_WORKSPACES.map((agent, i) => {
const left = i % 2 === 0
return {
id: agent.id,
name: agent.name,
role: agent.role,
path: agent.path,
side: left ? SeatSide.LEFT : SeatSide.RIGHT,
x: left ? -SEAT_X : SEAT_X,
// Die Plätze rücken paarweise nach hinten: links und rechts versetzt,
// damit über die Tischmitte hinweg niemand hinter einem Kopf verschwindet.
z: SEAT_Z[Math.floor(i / 2)] - (left ? 0 : 0.9),
}
}),
...AGENT_WORKSPACES.map(agent => ({
id: agent.id,
name: agent.name,
role: agent.role,
path: agent.path,
...(PLAETZE[agent.id] ?? FALLBACK),
})),
{
id: AGENT_SUPERVISOR.id,
name: AGENT_SUPERVISOR.name,