Compare commits

..
5 Commits
Author SHA1 Message Date
Benjamin SutterandClaude Opus 5 55274488d1 fix(startseite): Porträts scharf statt weich, besonders die hinteren
Sinas Bild wirkte unscharf. Die Datei ist es nicht — sie hat wie alle
anderen 512 Bildpunkte. Ihr Kreis erscheint aber nur rund 170 Bildpunkte
gross, weil sie am weitesten hinten sitzt. Bei so starker Verkleinerung
greift die Grafikkarte auf eine vorberechnete, viertel so grosse
Zwischenfassung zurück; das Ergebnis ist ein weiches Gesicht.

Drei Änderungen:

- Jedes Porträt wird beim Laden auf seine tatsächliche Anzeigegrösse
  gerechnet (Ferdi 384, Livia 275, Sina 229, Thomas 226 statt überall
  512). Die Grafikkarte zeigt damit die Vorlage nahezu Punkt für Punkt,
  die Zwischenfassung entfällt.
- Nach dem Verkleinern wird nachgeschärft, in der Stärke abhängig davon,
  wie weit verkleinert wurde. Ohne das verliert ein herunter gerechnetes
  Foto seine Zeichnung.
- Die Szene rechnet intern mindestens doppelt so fein wie der Bildschirm
  darstellt. Das kostet wenig, weil nicht laufend gezeichnet wird, und
  hilft auch Fensterprofilen und Kulisse.

Geladen wird dafür von Hand statt über den TextureLoader von three.js:
dessen Rückruf liefert die Bildmasse nicht zuverlässig, und ohne sie
lässt sich weder zuschneiden noch massgerecht verkleinern. Die Zählung
beim Ladeverwalter bleibt erhalten, damit die Szene nach dem letzten
Bild wie bisher einmal neu zeichnet. Der quadratische Zuschnitt sitzt
jetzt ebenfalls hier statt in den Texturkoordinaten.

422 Tests, Lint und Build sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:06:01 +02:00
Benjamin SutterandClaude Opus 5 403161283c fix(startseite): saubere Skyline links, deutlich grössere Agentenbilder
Runde 7.

§1 Hintergrund. Die Häuserzeile auf der linken Seite zeigte verwässerte
Doppelstrukturen. Drei Ursachen, alle behoben:

- Die Fortsetzung lief mit Massstab 2.45 und war damit gegenüber dem
  Hauptfoto um das 2.4-fache zu gross. Der Faktor ist jetzt aus den
  Fotos gemessen (Münsterhöhe über der Wasserlinie) und beträgt 1.06.
- Die 320 Pixel breite Überblendung mischte zwei verschiedene
  Häuserreihen zu halbdurchsichtigen Geisterhäusern. In der Häuserzeile
  wird jetzt praktisch hart getrennt, in Himmel und Wasser weiterhin
  weich — dort gibt es nichts zu vermischen.
- Die Umschaltlinie lag zwischen den Dächern, sodass die Gauben des
  Hauptfotos als Reihe schwebender Klötzchen im Himmel stehen blieben.
  Sie liegt jetzt über allen Dächern beider Aufnahmen.

Dazu: Helligkeit zeilenweise statt global angeglichen, Himmel der
Fortsetzung spaltenweise entlang der echten Dachkante entfernt, fehlende
Bildbereiche über die Zeilenfarbe statt über gestreckte Randzeilen
aufgefüllt, Häuserzeile nachgeschärft.

§2 Agentenbilder. Alle Kreise deutlich grösser, tiefenlogisch gestaffelt
und nur noch knapp über der Tischkante statt frei im Raum schwebend.
Gegenüber vorher: Ferdi und Bruno 2.11×, Livia und Nora 2.22×,
Sina 2.50×, Thomas 3.18×. Ferdi und Bruno rücken näher an die vordere
Tischkante. Radius und Höhe stehen jetzt je Platz in der Sitzordnung;
die Werte sind gemeinsam durchgerechnet, sodass sich im Bild kein
einziges Kreispaar überschneidet.

Nebenbei: waagrechter Bildwinkel nach oben begrenzt, damit flache
Fenster den Raum nicht zerren; Sockelleiste vor das Wandpaneel gesetzt
gegen die ausgefranste Kante.

Geprüft bei 1600×900, 1366×768 und 1280×620: sechs Beschriftungen ohne
Überlappung, kein Querbalken, Klick auf Ferdi führt auf seine Seite,
Menü bündig am rechten Rand. 422 Tests, Lint und Build sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:52:05 +02:00
Benjamin SutterandClaude Opus 5 9e825c04bf feat(agenten): Porträts in 512×512 aus den Originalen der Präsentation
Die Porträts lagen bisher nur als kleine, liegende Zuschnitte aus einem
HTML-Konzept vor — Ferdi etwa 215×160 Bildpunkte bei 6 KB. Im 3D-Meetingraum
waren sie damit sichtbar unscharf, und Hochskalieren hilft dagegen nicht.

Die Originale liegen in `PropertyOn_Engeli_v5.pptx` als quadratische Bilder mit
1200×1200 Bildpunkten. Sie zeigen dieselben fünf Personen: Kleidung, Frisur und
Ausschnitt stimmen mit den bisherigen Zuschnitten überein, und die Präsentation
nennt Name und Funktion direkt neben jedem Bild. Jede Zuordnung wurde einzeln
gegen den alten Zuschnitt geprüft — eine frühere Quelle trug dieselben Vornamen
für eine andere Belegschaft, und ein vertauschtes Gesicht wäre schlimmer als ein
unscharfes.

`scripts/build-agent-portraits.mjs` beschneidet den Folienrand, skaliert auf
512×512 und schreibt die Dateien. Das reicht für jede Darstellung im Programm —
im Raum sind die Kreise höchstens rund 100 Bildpunkte gross — und hält das
Bündel klein: rund 30 KB je Porträt statt 250 KB im Original.

Die Verarbeitung läuft ohne Bildbibliothek auf einem Canvas im vorhandenen
Chrome, wie schon beim Basel-Panorama. Keine neue Abhängigkeit.

Geprüft: 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925), Szene
im Browser ohne Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:27:20 +02:00
Benjamin SutterandClaude Opus 5 4349641ee9 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>
2026-08-17 14:32:58 +02:00
Benjamin SutterandClaude Opus 5 bf453d2b69 feat(startseite): echter 3D-Meetingraum, Navigation in die Topbar
Anpassungsrunde 6. Die Kartendarstellung aus Runde 5 ist ersetzt: die
Startseite zeigt oben einen räumlichen Meetingraum aus der Sicht des
Betrachters, der selbst am Kopfende des Tisches sitzt.

**3D statt Kulisse.** Zwei Versuche mit CSS-Flächen sind daran gescheitert,
dass gezeichnete Wände neben fotografierten Porträts erkennbar gezeichnet
bleiben. Diese Runde nutzt three.js — die einzige neue Abhängigkeit, und §19
lässt sie ausdrücklich zu, wenn eine schlanke 3D-Lösung die Anforderung trägt.
Keine Engine darüber hinaus: kein React-Three-Fiber, keine Physik, keine
Shader, keine Post-Processing-Kette, keine Orbit-Steuerung.

Der Raum: Boden, Decke, rechts eine geschlossene Wand mit Eichenpaneel und
einem schmalen Streifen im Markenblau, links und gegenüber bodentiefe
Glasfassaden mit Pfosten. Draussen tragen die beiden Referenzfotos aus dem
Auftrag die Basler Aussicht — vorne Rhein, Altstadt und Münster, links der
Rheinverlauf mit der Wettsteinbrücke. Die Wasserlinie beider Kulissen liegt auf
derselben Welthöhe, damit der Blick nach vorne und nach links zusammengehört
und nicht wie zwei zusammengesetzte Bilder wirkt (§4).

Am Tisch sitzen alle fünf bestehenden Agenten mit ihren bestehenden Porträts,
Namen, Funktionen und Zielseiten — alles aus `AGENT_WORKSPACES`, damit nichts
doppelt gepflegt wird (§24, Schritt 7). Neu ist Thomas, Personalverwalter,
vis-à-vis am gegenüberliegenden Kopfende; sein Klick führt in die bestehende
Ansicht «Meine Agenten».

Gerendert wird nur bei Anlass — Grösse, Maus, Hover. Ein Dauerloop wäre für
eine feste Szene verschwendete Rechenzeit (§21). Die Kamera folgt der Maus um
wenige Zentimeter, mehr nicht (§20).

Die Beschriftungen sind HTML über dem Bild, nicht Textur in der Szene: als
Textur wären sie bei Tiefe unlesbar. Ein Entzerrungsdurchgang nach der
Projektion hebt Plaketten so weit an, bis keine mehr überlappt — gerechnet und
nicht handverlesen, damit es auf jeder Fenstergrösse hält. Bei Hover kommt eine
Zeile mit der aktuellen Auslastung dazu; ruhend bleiben es Name und Funktion.

**Navigation.** Die linke Seitenleiste ist gelöscht, nicht ausgeblendet: samt
Breitenreservierung, Einklapplogik und mobilem Drawer. Der Inhalt beginnt
überall bei x=0. An der Stelle des früheren Profilsymbols steht neben der
Glocke ein Menüsymbol; das Klappmenü öffnet rechts an der Kopfzeile nach unten
und führt Startseite, Meine Agenten samt aller Agenten als eingerückte,
rechtsbündige Unterpunkte, und Profil mit Profil, Einstellungen, Produkttour
und Abmelden. Alle Aktionen und Routen sind die bestehenden.

Entfallen sind die Kartenkomponenten aus Runde 5 samt Präsenzschicht.

Geprüft (§25): 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925).
Im Browser 430–2560 px: keine alte Sidebar, kein leerer Streifen links, sechs
Beschriftungen ohne Überlappung, alle sechs Agenten per Maus und per Tastatur
erreichbar und auf der richtigen Subpage, Menü öffnet und schliesst per Klick
und Escape, alle Menürouten korrekt, alle bestehenden Seiten laden, kein
horizontaler Overflow, keine Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:00:00 +02:00
38 changed files with 2186 additions and 1409 deletions

No files matched your search

+65
View File
@@ -22,6 +22,7 @@
"react-dom": "^19.2.6",
"react-leaflet": "^5.0.0",
"react-router": "^7.15.0",
"three": "^0.185.1",
"zod": "^3.25.17",
"zustand": "^5.0.5"
},
@@ -34,6 +35,7 @@
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.3.0",
@@ -516,6 +518,13 @@
"node": ">=20.19.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -1935,6 +1944,13 @@
}
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2060,6 +2076,35 @@
"@types/react": "*"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.4",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz",
"integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
@@ -3219,6 +3264,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4092,6 +4144,13 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -4805,6 +4864,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+2
View File
@@ -28,6 +28,7 @@
"react-dom": "^19.2.6",
"react-leaflet": "^5.0.0",
"react-router": "^7.15.0",
"three": "^0.185.1",
"zod": "^3.25.17",
"zustand": "^5.0.5"
},
@@ -40,6 +41,7 @@
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.3.0",
+131
View File
@@ -0,0 +1,131 @@
/**
* Erzeugt die Agentenporträts aus den hochauflösenden Originalen.
*
* Die Porträts im Repository stammten bis Runde 6 aus einem HTML-Konzept und
* lagen dort nur als kleine, liegende Zuschnitte vor — Ferdi etwa 215 × 160
* Bildpunkte bei 6 KB. Im 3D-Meetingraum sind sie damit sichtbar unscharf, und
* hochskalieren hilft nicht.
*
* Die Originale liegen in der Präsentation `PropertyOn_Engeli_v5.pptx` als
* quadratische Bilder mit 1200 × 1200 Bildpunkten. Sie zeigen dieselben fünf
* Personen — Kleidung, Frisur und Ausschnitt stimmen mit den bisherigen
* Zuschnitten überein, es werden also keine Gesichter getauscht.
*
* Das Skript beschneidet den Rand (die Bilder tragen oben und an den Seiten
* Reste des Folienrahmens), skaliert auf eine für den Browser sinnvolle Grösse
* und schreibt sie nach `src/assets/team/`.
*
* Einmalig auszuführen; die Ergebnisse liegen als Assets im Repository:
*
* node scripts/build-agent-portraits.mjs
*
* Es braucht keine Bildbibliothek — die Verarbeitung läuft auf einem Canvas im
* bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen.
*/
import { chromium } from 'playwright-core'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { inflateRawSync } from 'node:zlib'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const ziel = resolve(here, '../src/assets/team')
/** Quellpräsentation. Nur zum Erzeugen nötig, nicht Teil des Repositorys. */
const QUELLE = process.env.PORTRAIT_SOURCE
?? 'C:/Users/beni_/Downloads/PropertyOn_Engeli_v5.pptx'
/**
* Zuordnung Bild → Agent.
*
* Die Reihenfolge entspricht der Titelfolie der Präsentation: Ferdi, Bruno,
* Livia, Nora, Sina. Jede Zuordnung wurde einzeln gegen den bisherigen
* Zuschnitt geprüft — eine frühere Quelle trug dieselben Vornamen für eine
* andere Belegschaft, und ein vertauschtes Gesicht wäre schlimmer als ein
* unscharfes.
*/
const ZUORDNUNG = {
'image1.jpg': 'ferdi',
'image2.jpg': 'bruno',
'image3.jpg': 'livia',
'image4.jpg': 'nora',
'image5.jpg': 'sina',
}
/** Kantenbeschnitt je Seite, als Anteil der Bildbreite. */
const BESCHNITT = 0.03
/** Zielgrösse. Reicht für jede Darstellung im Programm, bleibt klein im Bündel. */
const GROESSE = 512
const CHROME = [
process.env.CHROME_PATH,
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
].filter(Boolean).find(p => existsSync(p))
if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.')
if (!existsSync(QUELLE)) throw new Error(`Quelldatei fehlt: ${QUELLE}`)
/** Bilder aus dem ZIP-Container der Präsentation lesen, ohne Fremdbibliothek. */
function medienAusPptx(pfad, gesucht) {
const b = readFileSync(pfad)
const gefunden = {}
let i = 0
while (i < b.length - 4) {
if (b.readUInt32LE(i) !== 0x04034b50) { i++; continue }
const method = b.readUInt16LE(i + 8)
const csize = b.readUInt32LE(i + 18)
const nameLen = b.readUInt16LE(i + 26)
const extraLen = b.readUInt16LE(i + 28)
const name = b.slice(i + 30, i + 30 + nameLen).toString()
const start = i + 30 + nameLen + extraLen
const datei = name.split('/').pop()
if (name.startsWith('ppt/media/') && gesucht.includes(datei)) {
try {
gefunden[datei] = method === 8
? inflateRawSync(b.slice(start, start + csize))
: b.slice(start, start + csize)
} catch { /* beschädigter Eintrag — überspringen */ }
}
i = start + csize
}
return gefunden
}
const roh = medienAusPptx(QUELLE, Object.keys(ZUORDNUNG))
const fehlend = Object.keys(ZUORDNUNG).filter(f => !roh[f])
if (fehlend.length) throw new Error(`Nicht gefunden: ${fehlend.join(', ')}`)
const browser = await chromium.launch({ executablePath: CHROME })
const page = await browser.newPage()
for (const [datei, agent] of Object.entries(ZUORDNUNG)) {
const dataUri = `data:image/jpeg;base64,${roh[datei].toString('base64')}`
const ergebnis = await page.evaluate(async ({ src, beschnitt, groesse }) => {
const img = await new Promise((ok, err) => {
const el = new Image()
el.onload = () => ok(el)
el.onerror = err
el.src = src
})
const rand = Math.round(Math.min(img.width, img.height) * beschnitt)
const sw = img.width - 2 * rand
const sh = img.height - 2 * rand
const c = document.createElement('canvas')
c.width = groesse
c.height = groesse
const g = c.getContext('2d')
g.imageSmoothingEnabled = true
g.imageSmoothingQuality = 'high'
g.drawImage(img, rand, rand, sw, sh, 0, 0, groesse, groesse)
return { dataUri: c.toDataURL('image/jpeg', 0.86), quelle: `${img.width}x${img.height}` }
}, { src: dataUri, beschnitt: BESCHNITT, groesse: GROESSE })
const aus = resolve(ziel, `${agent}.jpg`)
writeFileSync(aus, Buffer.from(ergebnis.dataUri.split(',')[1], 'base64'))
const kb = (readFileSync(aus).length / 1024).toFixed(0)
console.log(`${agent.padEnd(7)} ${ergebnis.quelle} → ${GROESSE}x${GROESSE}, ${kb} KB`)
}
await browser.close()
+496
View File
@@ -0,0 +1,496 @@
/**
* Erzeugt das Basler Panorama für den 3D-Meetingraum (Runde 6, §2).
*
* Vorher lagen zwei getrennte Fotoflächen vor den Glasfronten — vorne und
* links. An deren Stossstelle war auf der linken Fensterseite eine harte Kante
* sichtbar. Diese Naht lässt sich nicht wegblenden; sie verschwindet nur, wenn
* es gar keine zwei Bilder mehr gibt.
*
* Dieses Skript baut aus den beiden Referenzfotos des Auftrags eine einzige
* durchgehende Panoramadatei. Jede Quelle wird **genau einmal** verwendet —
* kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende
* («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe
* unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in
* Helligkeit angeglichen und über eine schmale Zone überblendet.
*
* Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben
* und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine
* harte Grenze, die im Raum als Kante sichtbar wäre.
*
* Einmalig auszuführen; das Ergebnis liegt als Asset im Repository:
*
* node scripts/build-basel-panorama.mjs
*
* Es braucht keine Bildbibliothek — die Komposition entsteht auf einem Canvas
* im bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen.
*/
import { chromium } from 'playwright-core'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const assets = resolve(here, '../src/assets/staff')
const SRC_FRONT = resolve(assets, 'basel-front.jpg')
const SRC_LEFT = resolve(assets, 'basel-left.jpg')
const OUT = resolve(assets, 'basel-panorama.jpg')
/**
* Zielmasse. Die Breite ergibt sich aus den beiden Quellen: jede einmal,
* abzüglich der Überblendung. Grösser wäre reines Hochskalieren.
*/
const H = 660
/** Wasserlinie im Ziel — beide Quellen werden darauf ausgerichtet. */
const WATER_Y = 400
/** Wasserlinie in den Quellen, als Anteil der Bildhöhe. */
const FRONT_WATER = 0.60
const LEFT_WATER = 0.64
/** Skalierung der beiden Quellen. */
const FRONT_SCALE = 1.3
/**
* Massstab der Fortsetzung (Runde 7, §1).
*
* Nicht frei gewählt, sondern aus den Fotos gemessen: das Münster steht in
* basel-front 106 Pixel über der Wasserlinie, in basel-left 130. Damit
* dieselben Häuser in beiden Bildhälften gleich gross erscheinen, muss die
* Fortsetzung mit 1.3 × 106/130 laufen. Vorher stand hier 2.45 — daher der
* Massstabssprung an der Nahtstelle, bei dem die linken Häuser doppelt so hoch
* wirkten wie die vorderen.
*/
const LEFT_SCALE = 1.06
/**
* Ausschnitt der Fortsetzung, in Pixeln der Quelle.
*
* Beide Referenzfotos zeigen das Münster. Nebeneinander gesetzt stünde es
* zweimal im Bild — eine Doppelstruktur, die §2.1 ausdrücklich ausschliesst.
* Aus der Panoramaaufnahme wird deshalb nur der Abschnitt östlich des Münsters
* verwendet: die Häuserzeilen am Rhein. Das Münster selbst kommt genau einmal
* vor, nämlich im Mittelmotiv vom Tischende.
*/
const LEFT_CROP = { sx: 330, sw: 370 }
/**
* Breite der Überblendung zwischen Mittelmotiv und Fortsetzung.
*
* Bewusst schmal (Runde 7, §1). Eine breite Überblendung mischt zwei
* verschiedene Häuserzeilen ineinander: beide bleiben halbdurchsichtig sichtbar
* und es entstehen «Geisterhäuser», die vor und über anderen Gebäuden stehen.
* Ein Überblenden funktioniert nur bei deckungsgleichem Inhalt — den gibt es
* hier nicht — schon 64 Pixel liessen ein blasses Dach über der echten Zeile
* stehen. Praktisch hart getrennt liest sich die Stelle als Übergang zwischen
* zwei Häuserblöcken am Fluss, und jedes Gebäude bleibt eindeutig.
*/
const FEATHER = 6
/** Überblendbreite in Himmel und Wasser — dort ist das Bild strukturlos. */
const FEATHER_WEICH = 300
/**
* Höhenband der Häuserzeile im fertigen Bild, in Pixeln.
*
* Innerhalb dieses Bands wird schmal überblendet und nachgeschärft, ausserhalb
* breit überblendet und nicht geschärft. Die Grenzen folgen aus der Wasserlinie
* bei 400 und den Dachfirsten darüber.
*
* Die obere Grenze liegt über **allen** Dächern beider Aufnahmen, auch über den
* Dachaufbauten. Lag sie tiefer, schnitt sie die Häuserzeile des Mittelmotivs
* waagrecht durch, und deren Gauben blieben als Reihe schwebender Klötzchen im
* Himmel stehen.
*/
const BAND = { oben: 220, obenEnde: 238, unten: 398, untenEnde: 420 }
/**
* Nachschärfung (Unschärfemaske) gegen die Weichheit des Hochskalierens.
*
* Die Fortsetzung stammt aus einem kleinen Panoramafoto und wird auf ein
* Mehrfaches vergrössert; ohne Nachschärfung verlieren die Dachkanten ihre
* Kontur. `staerke` ist der Anteil der zurückgegebenen Kantenzeichnung.
*/
const SCHAERFE = { staerke: 0.85, radius: 1.1 }
const CHROME = [
process.env.CHROME_PATH,
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
].filter(Boolean).find(p => existsSync(p))
if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.')
for (const f of [SRC_FRONT, SRC_LEFT]) {
if (!existsSync(f)) throw new Error(`Referenzbild fehlt: ${f}`)
}
const asDataUri = f => `data:image/jpeg;base64,${readFileSync(f).toString('base64')}`
const browser = await chromium.launch({ executablePath: CHROME })
const page = await browser.newPage()
const result = await page.evaluate(async (cfg) => {
const load = src => new Promise((ok, err) => {
const img = new Image()
img.onload = () => ok(img)
img.onerror = err
img.src = src
})
const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)])
const fW = Math.round(fImg.width * cfg.FRONT_SCALE)
const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE)
// Die beiden Bilder überlappen sich geometrisch über die volle weiche Breite.
// Nur so lässt sich in Himmel und Wasser breit überblenden — ausserhalb der
// Überlappung gäbe es nichts, worauf übergeblendet werden könnte, und es
// entstünde ein durchsichtiger und damit schwarzer Streifen.
const UEBER = cfg.FEATHER_WEICH
const W = lW + fW - UEBER
const H = cfg.H
/**
* Eine Quelle als Schicht, an der Wasserlinie ausgerichtet.
*
* Bewusst **ohne** senkrechtes Strecken über den Bildrand hinaus. Ein
* gestreckter Streifen Wasser zerfällt in Flecken und Striche. Was oben und
* unten fehlt, füllt weiter unten der Zeilenabgleich sauber auf.
*/
function schicht(img, crop, scale, waterFraction) {
const sx = crop ? crop.sx : 0
const sw = crop ? crop.sw : img.width
const w = Math.round(sw * scale)
const h = Math.round(img.height * scale)
const y = Math.round(cfg.WATER_Y - img.height * waterFraction * scale)
const c = document.createElement('canvas')
c.width = w
c.height = H
const cg = c.getContext('2d')
cg.imageSmoothingEnabled = true
cg.imageSmoothingQuality = 'high'
cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h)
return { canvas: c, w }
}
/**
* Helligkeit der Fortsetzung **zeilenweise** an das Mittelmotiv angleichen
* (Runde 7, §1).
*
* Ein einziger Faktor für das ganze Bild reicht nicht: die beiden Aufnahmen
* unterscheiden sich im Himmel anders als in der Häuserzeile und im Wasser.
* Der übrig bleibende Unterschied stand als waagrechte Tonkante quer durch den
* Himmel. Verglichen wird nur dort, wo beide Bilder tatsächlich übereinander
* liegen; jede Zeile bekommt ihren eigenen Faktor.
*/
function tonAngleichen(fort, vorne, abX) {
const vonX = abX + Math.round(UEBER * 0.2)
const bisX = abX + Math.round(UEBER * 0.8)
const fb = fort.canvas.getContext('2d').getImageData(0, 0, fort.w, H)
const vb = vorne.canvas.getContext('2d').getImageData(0, 0, vorne.w, H)
/*
* Ein gemeinsamer Faktor für alle drei Kanäle, kein eigener je Kanal.
* Kanalweise verglichen werden Inhalte, die einander nicht entsprechen —
* an derselben Stelle stehen im einen Bild Bäume, im anderen Hausfassaden.
* Die Verhältnisse laufen dann auseinander und färben die Fortsetzung.
*/
let letzterFaktor = 1
for (let y = 0; y < H; y++) {
let sf = 0, sv = 0, n = 0
for (let x = vonX; x < bisX; x++) {
const i = (y * fort.w + (x - abX)) * 4
const j = (y * vorne.w + x) * 4
if (fb.data[i + 3] < 250 || vb.data[j + 3] < 250) continue
sf += 0.2126 * fb.data[i] + 0.7152 * fb.data[i + 1] + 0.0722 * fb.data[i + 2]
sv += 0.2126 * vb.data[j] + 0.7152 * vb.data[j + 1] + 0.0722 * vb.data[j + 2]
n++
}
// Zeilen ohne Überlappung übernehmen den Faktor der letzten vergleichbaren.
if (n >= 20 && sf >= 1) letzterFaktor = Math.max(0.75, Math.min(1.35, sv / sf))
for (let x = 0; x < fort.w; x++) {
const i = (y * fort.w + x) * 4
for (let c = 0; c < 3; c++) {
const wert = fb.data[i + c] * letzterFaktor
fb.data[i + c] = wert > 255 ? 255 : wert
}
}
}
fort.canvas.getContext('2d').putImageData(fb, 0, 0)
}
const cv = document.createElement('canvas')
cv.width = W
cv.height = H
const g = cv.getContext('2d')
g.imageSmoothingEnabled = true
g.imageSmoothingQuality = 'high'
// 1) Beide Schichten aufbauen und die Fortsetzung zeilenweise im Ton
// angleichen, bevor irgendetwas zusammengesetzt wird.
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER)
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER)
tonAngleichen(fort, vorne, W - fort.w)
// 2) Fortsetzung rechts, darüber das Mittelmotiv mit höhenabhängig weicher
// rechter Flanke. Der Zylinder beginnt beim Mittelmotiv und läuft nach
// links in den Raum weiter; die Bildfolge muss dieser Richtung folgen.
/*
* Die Fortsetzung trägt **nur** die Häuserzeile bei (Runde 7, §1).
*
* Darüber und darunter wird sie ausgeblendet. Ihr Himmel ist milchig-weiss,
* der des Mittelmotivs kräftig blau; nebeneinander stand er als heller Block
* im Bild, und eine Helligkeitsangleichung holt die fehlende Farbsättigung
* nicht zurück. Ihr Wasser wiederum endet früh. Beides deckt weiter unten der
* Zeilenabgleich sauber ab — er verlängert Himmel und Wasser des
* Mittelmotivs über die volle Breite.
*/
const fk = fort.canvas.getContext('2d')
const fbild = fk.getImageData(0, 0, fort.w, H)
const fd = fbild.data
/*
* Oben: der Himmel wird **spaltenweise entlang der echten Dachkante**
* entfernt, nicht mit einer waagrechten Linie abgeschnitten. Eine gerade
* Linie liegt entweder über den Dächern — dann bleibt ein heller Streifen
* Fremdhimmel stehen — oder sie schneidet mitten durch die Dächer.
*
* Gesucht wird je Spalte die erste Zeile, die sich deutlich von der
* Himmelsfarbe ganz oben in derselben Spalte unterscheidet. Drei Zeilen in
* Folge müssen abweichen, damit eine Wolke die Kante nicht vortäuscht.
*/
const obenRand = new Array(fort.w).fill(0)
for (let x = 0; x < fort.w; x++) {
let y0 = 0
while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++
if (y0 >= H) { obenRand[x] = H; continue }
/*
* Verglichen wird mit der Zeile acht darüber, nicht mit der obersten Zeile
* der Spalte. Der Himmel verläuft von oben nach unten langsam von blau nach
* dunstig; gegen einen festen Bezugswert überschreitet allein dieser Verlauf
* irgendwann jede Schwelle, und die Kante läge zu hoch. Ein Dachrand
* dagegen ändert die Farbe auf wenigen Zeilen sprunghaft.
*/
let treffer = 0
let kante = H
for (let y = y0 + 8; y < H; y++) {
const i = (y * fort.w + x) * 4
const o = ((y - 8) * fort.w + x) * 4
const sprung = Math.abs(fd[i] - fd[o]) + Math.abs(fd[i + 1] - fd[o + 1]) + Math.abs(fd[i + 2] - fd[o + 2])
treffer = sprung > 60 ? treffer + 1 : 0
if (treffer >= 2) { kante = y - 9; break }
}
obenRand[x] = kante
}
/*
* Median statt Mittelwert über fünf Spalten. Ein Mittelwert zieht die Kante
* an jedem Dachabsatz zu weit nach oben, und darüber blieben kleine Fetzen
* fremden Himmels als Flecken stehen. Der Median folgt der Dachlinie und
* bügelt trotzdem einzelne Ausreisser aus.
*/
const geglaettet = obenRand.map((_, x) => {
const fenster = []
for (let k = -2; k <= 2; k++) if (obenRand[x + k] !== undefined) fenster.push(obenRand[x + k])
fenster.sort((a, b) => a - b)
return fenster[Math.floor(fenster.length / 2)]
})
/** Himmelsfarbe je Spalte, gemessen an der obersten Bildzeile der Schicht. */
const himmel = []
for (let x = 0; x < fort.w; x++) {
let y0 = 0
while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++
const i = (y0 * fort.w + x) * 4
himmel.push(y0 < H ? [fd[i], fd[i + 1], fd[i + 2]] : [0, 0, 0])
}
// Unten endet die Fortsetzung kurz unter der Wasserlinie; darunter übernimmt
// das Wasser des Mittelmotivs, das viel weiter hinunterreicht.
const untenAb = cfg.BAND.untenEnde + 10
const untenBis = cfg.BAND.untenEnde + 40
for (let x = 0; x < fort.w; x++) {
const kante = geglaettet[x]
const ref = himmel[x]
for (let y = 0; y < H; y++) {
const i = (y * fort.w + x) * 4
let obenA = Math.max(0, Math.min(1, (y - (kante - 2)) / 5))
/*
* Sicherheitsnetz dicht unter der Kante: was dort noch Himmelsfarbe hat,
* ist Himmel und verschwindet ebenfalls. Sonst bleiben an Dachabsätzen
* einzelne helle Flecken zwischen den Häusern stehen.
*/
if (obenA > 0 && y < kante + 14) {
const abstand = Math.abs(fd[i] - ref[0]) + Math.abs(fd[i + 1] - ref[1]) + Math.abs(fd[i + 2] - ref[2])
if (abstand < 70) obenA = 0
}
const untenA = y < untenAb ? 1 : Math.max(0, 1 - (y - untenAb) / (untenBis - untenAb))
fd[i + 3] = Math.round(fd[i + 3] * obenA * untenA)
}
}
fk.putImageData(fbild, 0, 0)
g.drawImage(fort.canvas, W - fort.w, 0)
const mid = document.createElement('canvas')
mid.width = vorne.w
mid.height = H
const mg = mid.getContext('2d')
mg.drawImage(vorne.canvas, 0, 0)
/*
* Überblendung mit höhenabhängiger Breite (Runde 7, §1).
*
* Über der Häuserzeile und unter der Wasserlinie ist das Bild strukturlos —
* dort wird über die volle Überlappung übergeblendet, und nur so verschwindet
* der Helligkeitssprung im Himmel, den die Angleichung sonst als senkrechte
* Kante stehen lässt. In der Häuserzeile selbst muss die Überblendung schmal
* sein: dort stehen zwei verschiedene Häuserreihen übereinander, und jede
* Breite mischt sie zu halbdurchsichtigen «Geisterhäusern».
*
* Beide Übergänge haben dieselbe Mitte, damit die Trennlinie über die ganze
* Bildhöhe an derselben Stelle liegt und nicht schräg verläuft.
*/
const mitte = vorne.w - UEBER / 2
const maske = document.createElement('canvas')
maske.width = vorne.w
maske.height = H
const kg = maske.getContext('2d')
for (let y = 0; y < H; y++) {
const imBand =
y < cfg.BAND.oben ? 0
: y < cfg.BAND.obenEnde ? (y - cfg.BAND.oben) / (cfg.BAND.obenEnde - cfg.BAND.oben)
: y < cfg.BAND.unten ? 1
: y < cfg.BAND.untenEnde ? 1 - (y - cfg.BAND.unten) / (cfg.BAND.untenEnde - cfg.BAND.unten)
: 0
/*
* Ausserhalb der Häuserzeile reicht das Mittelmotiv bis an seinen eigenen
* rechten Rand: dort trägt die Fortsetzung nichts mehr bei, weil sie oben
* und unten ausgeblendet ist. Innerhalb der Zeile wandert die Trennung in
* die Mitte der Überlappung und wird praktisch hart.
*/
const x0 = (vorne.w - 4) + (mitte - (vorne.w - 4)) * imBand
const b0 = 8 + (cfg.FEATHER - 8) * imBand
const grad = kg.createLinearGradient(x0 - b0 / 2, 0, x0 + b0 / 2, 0)
grad.addColorStop(0, 'rgba(0,0,0,1)')
grad.addColorStop(1, 'rgba(0,0,0,0)')
kg.fillStyle = grad
kg.fillRect(0, y, vorne.w, 1)
}
// Die Maske entsteht zuerst vollständig auf einer eigenen Fläche. Zeilenweise
// direkt mit `destination-in` zu arbeiten ginge nicht: dieser Modus wirkt auf
// die ganze Fläche und löschte bei jeder Zeile alle übrigen.
mg.globalCompositeOperation = 'destination-in'
mg.drawImage(maske, 0, 0)
g.drawImage(mid, 0, 0)
const bild = g.getImageData(0, 0, W, H)
const d = bild.data
/*
* 3) Freie Stellen zeilenweise auffüllen.
*
* Oben und unten reichen die Fotos nicht über die volle Höhe, und rechts endet
* die Fortsetzung früher als das Mittelmotiv. Diese Stellen sind Himmel oder
* Wasser: beides ist waagrecht nahezu einfarbig. Sie bekommen deshalb die
* Durchschnittsfarbe ihrer eigenen Zeile, zum Rand hin weich an die dort
* tatsächlich vorhandenen Bildpunkte angeglichen. Das ergibt eine ruhige
* Fortsetzung ohne Striche, Flecken oder Wiederholungen.
*/
// Erst je Zeile die Durchschnittsfarbe der echten Bildpunkte bestimmen.
const mittelwerte = new Array(H).fill(null)
const luecken = new Array(H).fill(0)
for (let y = 0; y < H; y++) {
const zeile = y * W * 4
let sr = 0, sg = 0, sb = 0, n = 0
for (let x = 0; x < W; x++) {
if (d[zeile + x * 4 + 3] > 250) { sr += d[zeile + x * 4]; sg += d[zeile + x * 4 + 1]; sb += d[zeile + x * 4 + 2]; n++ }
}
if (n > 0) mittelwerte[y] = [sr / n, sg / n, sb / n]
luecken[y] = W - n
}
// Zeilen ganz ohne Bildpunkte — ganz oben und ganz unten — übernehmen die
// Farbe der nächsten Zeile, die welche hat. Ein fester Ersatzwert stünde dort
// sonst als grauer Streifen quer über dem Himmel.
for (let y = 0; y < H; y++) if (!mittelwerte[y]) {
for (let k = 1; k < H; k++) {
if (y + k < H && mittelwerte[y + k]) { mittelwerte[y] = mittelwerte[y + k]; break }
if (y - k >= 0 && mittelwerte[y - k]) { mittelwerte[y] = mittelwerte[y - k]; break }
}
}
for (let y = 0; y < H; y++) {
if (luecken[y] === 0) continue
const zeile = y * W * 4
const mittel = mittelwerte[y]
// Farbe des nächstgelegenen echten Bildpunkts links und rechts merken
for (let x = 0; x < W; x++) {
const i = zeile + x * 4
if (d[i + 3] > 250) continue
/*
* Nur die unmittelbare Nachbarschaft zählt. Wurde weiter gesucht, trug
* die Füllung die Farbe des nächsten Dachs waagrecht in den Himmel
* hinein — als lange helle Schlieren über der Häuserzeile (Runde 7, §1).
* Ein paar Bildpunkte reichen, um die Kante weich zu machen; alles
* weitere bekommt die ruhige Durchschnittsfarbe der Zeile.
*/
let nachbar = null
for (let dx = 1; dx <= 12 && !nachbar; dx++) {
for (const xx of [x - dx, x + dx]) {
if (xx >= 0 && xx < W && d[zeile + xx * 4 + 3] > 250) {
nachbar = { c: [d[zeile + xx * 4], d[zeile + xx * 4 + 1], d[zeile + xx * 4 + 2]], dist: dx }
break
}
}
}
/*
* Der Nachbar zählt nur, wenn er der Zeile ähnlich sieht. Ein Dach oder
* ein Kamin am Rand der Lücke färbte die Füllung sonst ein und zog seine
* Farbe als helle Schliere waagrecht in den Himmel.
*/
const passend = nachbar && Math.abs(nachbar.c[0] - mittel[0]) + Math.abs(nachbar.c[1] - mittel[1]) + Math.abs(nachbar.c[2] - mittel[2]) < 90
const t = passend ? Math.min(1, nachbar.dist / 10) : 1
const quelle = passend ? nachbar.c : mittel
for (let c = 0; c < 3; c++) d[i + c] = quelle[c] * (1 - t) + mittel[c] * t
d[i + 3] = 255
}
}
g.putImageData(bild, 0, 0)
// 4) Nachschärfen (Unschärfemaske): das Bild minus seiner weichgezeichneten
// Fassung ergibt die Kantenzeichnung; sie wird verstärkt zurückgegeben.
// Das holt die Dachkanten der hochskalierten Häuserzeile zurück (§1).
const weich = document.createElement('canvas')
weich.width = W
weich.height = H
const wg = weich.getContext('2d')
wg.filter = `blur(${cfg.SCHAERFE.radius}px)`
wg.drawImage(cv, 0, 0)
const oBild = g.getImageData(0, 0, W, H)
const wBild = wg.getImageData(0, 0, W, H)
const o = oBild.data
const w = wBild.data
// Nur im Strukturband schärfen. Im Himmel brächte Schärfen nichts, im Wasser
// verstärkte es jede Unruhe der aufgefüllten Fläche.
for (let y = cfg.BAND.oben; y < cfg.BAND.untenEnde && y < H; y++) {
const rand = Math.min(y - cfg.BAND.oben, cfg.BAND.untenEnde - y, 24) / 24
const k = cfg.SCHAERFE.staerke * Math.max(0, rand)
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4
for (let c = 0; c < 3; c++) {
const wert = o[i + c] + k * (o[i + c] - w[i + c])
o[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert
}
}
}
g.putImageData(oBild, 0, 0)
return { dataUri: cv.toDataURL('image/jpeg', 0.9), W, H }
}, {
front: asDataUri(SRC_FRONT),
left: asDataUri(SRC_LEFT),
H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, FEATHER_WEICH, BAND, LEFT_CROP, SCHAERFE,
})
await browser.close()
writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64'))
const kb = (readFileSync(OUT).length / 1024).toFixed(0)
console.log(`Panorama erzeugt: ${OUT}`)
console.log(` ${result.W}x${result.H}, ${kb} KB`)
+10 -9
View File
@@ -1,14 +1,15 @@
# Bildherkunft
| Datei | Quelle | Lizenz |
| Datei | Inhalt | Herkunft |
|---|---|---|
| `buero.jpg` | Unsplash — Craig Lovelidge, «An empty conference room with desks and chairs» (The Mark, Rotterdam) | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig |
| `basel-front.jpg` | Blick über den Rhein auf Grossbasler Altstadt und Münster, links die Wettsteinbrücke | Referenzbild 1 aus dem Auftrag (Anpassungsrunde 5/6) |
| `basel-left.jpg` | Rheinperspektive mit Altstadt- und Münster-Silhouette, Panoramaformat | Referenzbild 2 aus dem Auftrag (Anpassungsrunde 5/6) |
Direktbezug: `https://images.unsplash.com/photo-1637665599155-abfcf9fc3f78`
Beide Bilder sind die dem Auftrag beigelegten Referenzfotos. Sie liegen im
3D-Meetingraum als Fernkulisse **aussen vor den Glasfassaden** — `basel-front`
hinter der gegenüberliegenden Front, `basel-left` entlang der linken Front.
Das Bild dient als abgedunkelte Umgebung hinter den Personenkarten der
Startseite. Es wird nie unbearbeitet gezeigt — der Verlauf in `roomBackdrop.ts`
nimmt ihm so viel Kontrast, dass die Porträts und die Schrift davor tragen.
**Austausch:** Datei ersetzen, Dateinamen beibehalten. Eine eigene Büroaufnahme
ist jederzeit vorzuziehen — sie zeigt den echten Arbeitsplatz der Belegschaft.
Weil beide Aufnahmen dieselbe Rheinseite von derselben Uferseite zeigen, ergeben
sie zusammen einen zusammenhängenden Stadtverlauf: die Wasserlinie beider
Kulissen liegt auf derselben Welthöhe, damit der Blick nach vorne und der Blick
nach links nicht wie zwei zusammengesetzte Fotos wirken (Runde 6, §4).
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

+12
View File
@@ -0,0 +1,12 @@
# Bildherkunft — Porträts
Die Porträts der fünf Agenten stammen aus dem Konzeptdokument und sind
fachliche Vorgabe.
| Datei | Herkunft | Lizenz |
|---|---|---|
| `thomas.jpg` | Unsplash, Porträt eines Mannes in Business-Kleidung (`photo-1560250097-0b93528c311a`), quadratisch auf das Gesicht beschnitten | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig |
Thomas ist in Anpassungsrunde 6 neu dazugekommen und hatte kein Porträt aus dem
Konzeptdokument. Der Kopf eines bestehenden Mitarbeitenden wurde bewusst nicht
zweitverwendet — jeder Kopf gehört genau einer Person.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+20 -77
View File
@@ -1,46 +1,35 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router'
import { Box, Drawer, useTheme, useMediaQuery } from '@mui/material'
import { Box } from '@mui/material'
import { useLayoutStore } from '../../stores/layoutStore'
import { useSessionStore } from '../../stores/sessionStore'
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
import { RightContextPanel } from './RightContextPanel'
import { ToastProvider } from '../ui'
import { Sidebar } from './AppShellSidebar'
import { TopBar } from './AppShellTopBar'
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getWorkspaceFromPath } from './appShellConfig'
import { WorkspaceType } from '../../domain/enums'
// ---------------------------------------------------------------------------
// AppShell
// ---------------------------------------------------------------------------
import { WORKSPACE_CONFIG, getWorkspaceFromPath } from './appShellConfig'
/**
* Rahmen der Anwendung (Runde 6, §10).
*
* Die permanente Navigationsleiste links ist entfallen — samt Breitenreservierung,
* Einklapplogik und mobilem Drawer. Die Navigation liegt jetzt vollständig im
* Menü der Kopfzeile (§11–§14). Der Inhaltsbereich beginnt dadurch am linken
* Fensterrand; es bleibt kein leerer Streifen zurück, weil die Sidebar nicht
* ausgeblendet, sondern gelöscht ist.
*
* Geblieben sind die beiden Effekte, die nichts mit der Navigation zu tun haben:
* der Abgleich des Arbeitsbereichs mit der URL und die Umleitung, wenn ein
* Rollenwechsel den aktuellen Arbeitsbereich verbietet.
*/
export function AppShell() {
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
const [mobileOpen, setMobileOpen] = useState(false)
// Das mobile Menü ist nur offen, solange die Route dieselbe bleibt. Statt es
// nach dem Routenwechsel per Effekt zu schliessen, hängt der Zustand am Pfad:
// ein Wechsel erzeugt einen neuen Schlüssel und damit einen frischen Zustand.
const [openForPath, setOpenForPath] = useState(location.pathname)
const isMobileOpen = mobileOpen && openForPath === location.pathname
const openMobileMenu = () => { setOpenForPath(location.pathname); setMobileOpen(true) }
const closeMobileMenu = () => setMobileOpen(false)
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
// Sync active workspace with URL
// Arbeitsbereich am Pfad ausrichten
useEffect(() => {
const detected = getWorkspaceFromPath(location.pathname)
if (detected && detected !== activeWorkspace) {
@@ -48,8 +37,7 @@ export function AppShell() {
}
}, [location.pathname, activeWorkspace, setActiveWorkspace])
// When the user changes (role switch), redirect to their first allowed workspace
// if the current workspace is no longer permitted
// Nach einem Rollenwechsel auf den ersten erlaubten Arbeitsbereich umleiten
useEffect(() => {
if (!currentUser) return
const allowed = currentUser.allowedWorkspaces
@@ -64,60 +52,15 @@ export function AppShell() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser])
const handleWorkspaceClick = (workspace: WorkspaceType) => {
setActiveWorkspace(workspace)
navigate(WORKSPACE_CONFIG[workspace].firstPath)
}
const userName = currentUser?.name ?? 'User'
const orgName = currentUser?.organizationName ?? ''
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
const sidebarContent = (
<Sidebar
collapsed={isMobile ? false : sidebarCollapsed}
activeWorkspace={activeWorkspace}
allowedWorkspaces={allowedWorkspaces}
onWorkspaceClick={handleWorkspaceClick}
onToggle={isMobile ? closeMobileMenu : toggleSidebar}
onClose={isMobile ? closeMobileMenu : undefined}
userName={userName}
orgName={orgName}
/>
)
return (
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* Desktop: permanent sidebar */}
{!isMobile && sidebarContent}
{/* Mobile: temporary drawer */}
{isMobile && (
<Drawer
open={isMobileOpen}
onClose={closeMobileMenu}
variant="temporary"
ModalProps={{ keepMounted: true }}
slotProps={{ paper: { sx: { width: 264, bgcolor: 'transparent', boxShadow: 'none' } } }}
>
{sidebarContent}
</Drawer>
)}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<TopBar
pathname={location.pathname}
isMobile={isMobile}
onMenuClick={openMobileMenu}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<TopBar pathname={location.pathname} />
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
<Outlet />
</Box>
<RightContextPanel />
</Box>
<ToastProvider />
<WelcomeDialog />
</Box>
-400
View File
@@ -1,400 +0,0 @@
import { Box, Typography, Avatar, IconButton, Tooltip } from '@mui/material'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { NavLink, useLocation } from 'react-router'
import { WorkspaceType } from '../../domain/enums'
import { useCompareStore } from '../../stores/compareStore'
import { AgentAvatar } from '../team'
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig'
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
// ---------------------------------------------------------------------------
// Visual constants
// ---------------------------------------------------------------------------
export const SIDEBAR_BG = '#0f1923'
export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
export const TEXT_MUTED = '#94a3b8'
export const TEXT_WHITE = '#ffffff'
export const NAV_ACTIVE_BG = 'transparent'
export const NAV_ACTIVE_ACCENT = '#b8975a'
export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SidebarProps {
collapsed: boolean
activeWorkspace: WorkspaceType
allowedWorkspaces: WorkspaceType[]
onWorkspaceClick: (workspace: WorkspaceType) => void
onToggle: () => void
onClose?: () => void
userName: string
orgName: string
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function Sidebar({
collapsed,
activeWorkspace,
allowedWorkspaces,
onWorkspaceClick,
onToggle,
onClose,
userName,
orgName,
}: SidebarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace]
const compareCount = useCompareStore(s => s.compareItems.length)
const { pathname } = useLocation()
const width = collapsed ? 60 : 264
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
return (
<Box
component="nav"
sx={{
width,
minWidth: width,
flexShrink: 0,
height: '100vh',
backgroundColor: SIDEBAR_BG,
display: 'flex',
flexDirection: 'column',
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
{/* Logo area */}
<Box
sx={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
px: collapsed ? 0 : 2.5,
borderBottom: `1px solid ${DIVIDER_COLOR}`,
flexShrink: 0,
}}
>
{collapsed ? (
<Typography
variant="subtitle1"
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
>
PM
</Typography>
) : (
<Box>
<Typography
variant="subtitle1"
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
>
Property
</Typography>
<Typography
variant="caption"
sx={{
color: DS_BRAND.light,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
fontSize: '0.6rem',
}}
>
Match
</Typography>
</Box>
)}
</Box>
{/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */}
{visibleWorkspaces.length > 1 && <Box
sx={{
borderBottom: `1px solid ${DIVIDER_COLOR}`,
display: 'grid',
gridTemplateColumns: collapsed ? '1fr' : `repeat(${visibleWorkspaces.length}, 1fr)`,
flexShrink: 0,
}}
>
{visibleWorkspaces.map((ws, idx) => {
const wsConfig = WORKSPACE_CONFIG[ws]
const Icon = wsConfig.icon
const isActive = ws === activeWorkspace
const tabContent = (
<Box
onClick={() => { onWorkspaceClick(ws); onClose?.() }}
sx={{
display: 'flex',
flexDirection: collapsed ? 'row' : 'column',
alignItems: 'center',
justifyContent: 'center',
gap: collapsed ? 0 : 0.5,
py: collapsed ? 1 : 1.25,
cursor: 'pointer',
backgroundColor: isActive ? wsConfig.chipColor : 'transparent',
borderRight: !collapsed && idx < visibleWorkspaces.length - 1
? `1px solid ${DIVIDER_COLOR}`
: 'none',
transition: 'background-color 0.15s ease',
'&:hover': {
backgroundColor: isActive ? wsConfig.chipColor : NAV_HOVER_BG,
},
}}
>
<Icon size={15} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
{!collapsed && (
<Typography
variant="caption"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 700 : 400,
fontSize: '0.6875rem',
lineHeight: 1,
letterSpacing: 0.2,
}}
>
{wsConfig.label}
</Typography>
)}
</Box>
)
return collapsed ? (
<Tooltip key={ws} title={wsConfig.label} placement="right">
{tabContent}
</Tooltip>
) : (
<Box key={ws}>{tabContent}</Box>
)
})}
</Box>}
{/* Nav items */}
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
{config.navItems.map((item) => {
const Icon = item.icon
const navContent = (
<NavLink
to={item.path}
style={{ textDecoration: 'none', display: 'block' }}
onClick={() => onClose?.()}
>
{({ isActive }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
pl: collapsed ? 0 : isActive ? 1.25 : 1.5,
pr: collapsed ? 0 : 1.5,
py: 0.75,
mx: 0.5,
borderRadius: 1,
justifyContent: collapsed ? 'center' : 'flex-start',
borderLeft: collapsed ? 'none' : `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
backgroundColor: 'transparent',
'&:hover': { backgroundColor: NAV_HOVER_BG },
transition: 'border-color 0.15s ease',
cursor: 'pointer',
}}
>
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
{!collapsed && (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 600 : 400,
fontSize: '0.875rem',
letterSpacing: '-0.01em',
whiteSpace: 'nowrap',
}}
>
{item.label}
</Typography>
{item.path === '/demand/compare' && compareCount > 0 && (
<Box sx={{
minWidth: 18, height: 18, borderRadius: '9px',
bgcolor: DS_ACCENT.warning.main, color: 'white',
fontSize: '0.65rem', fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
px: 0.5, flexShrink: 0,
}}>
{compareCount}
</Box>
)}
</Box>
)}
</Box>
)}
</NavLink>
)
// Subreiter bleiben sichtbar, solange der Nutzer im Funktionsbereich ist.
// Seit Runde 4 liegen die Agentenseiten nicht mehr unterhalb des
// Basispfads — die Liste muss deshalb auch auf einer Agentenseite
// offen bleiben, sonst verschwände die Navigation beim Hinklicken.
// Eingeklappt entfallen sie — dort ist kein Platz für eine zweite Ebene.
const subItems = item.children ?? []
const inSection =
subItems.length > 0 &&
(pathname.startsWith(item.path) || subItems.some(c => pathname.startsWith(c.path)))
return collapsed ? (
<Tooltip key={item.path} title={item.label} placement="right">
<Box>{navContent}</Box>
</Tooltip>
) : (
<Box key={item.path}>
{navContent}
{inSection && (
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, mt: 0.25, mb: 0.5 }}>
{subItems.map((child) => (
<Box component="li" key={child.path}>
<NavLink
to={child.path}
style={{ textDecoration: 'none', display: 'block' }}
onClick={() => onClose?.()}
>
{({ isActive }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
pl: child.agentId ? 2.5 : 4.75,
pr: 1.5,
py: child.agentId ? 0.625 : 0.5,
mx: 0.5,
borderRadius: 1,
borderLeft: `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
'&:hover': { backgroundColor: NAV_HOVER_BG },
transition: 'border-color 0.15s ease',
cursor: 'pointer',
}}
>
{child.agentId && (
<AgentAvatar
agent={{ id: child.agentId, name: child.label, role: child.role ?? '' }}
size={26}
/>
)}
<Box sx={{ minWidth: 0 }}>
<Typography
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 600 : 400,
fontSize: '0.8125rem',
letterSpacing: '-0.01em',
whiteSpace: 'nowrap',
lineHeight: 1.3,
}}
>
{child.label}
</Typography>
{child.role && (
<Typography
variant="caption"
sx={{
display: 'block',
color: isActive ? TEXT_MUTED : '#64748b',
fontSize: '0.6875rem',
lineHeight: 1.3,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{child.role}
</Typography>
)}
</Box>
</Box>
)}
</NavLink>
</Box>
))}
</Box>
)}
</Box>
)
})}
</Box>
{/* Bottom section */}
<Box
sx={{
borderTop: `1px solid ${DIVIDER_COLOR}`,
px: collapsed ? 0 : 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
justifyContent: collapsed ? 'center' : 'space-between',
flexShrink: 0,
}}
>
{!collapsed && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Avatar
sx={{
width: 32,
height: 32,
fontSize: '0.75rem',
bgcolor: DS_NEUTRAL.gold,
flexShrink: 0,
}}
>
{getUserInitials(userName)}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="body2"
sx={{
color: TEXT_WHITE,
fontWeight: 500,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{userName}
</Typography>
<Typography
variant="caption"
sx={{
color: DS_SLATE[500],
fontSize: '0.7rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
}}
>
{orgName}
</Typography>
</Box>
</Box>
)}
<IconButton
onClick={onToggle}
size="small"
sx={{ color: DS_SLATE[500], flexShrink: 0 }}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</IconButton>
</Box>
</Box>
)
}
+15 -33
View File
@@ -1,39 +1,28 @@
import { Box, IconButton } from '@mui/material'
import { Menu } from 'lucide-react'
import { Box } from '@mui/material'
import { OrganizationContextBadge } from './OrganizationContextBadge'
import { NotificationButton } from './NotificationButton'
import { UserMenu } from './UserMenu'
import { GlobalNavMenu } from './GlobalNavMenu'
import { AgentChatStickyBar } from '../team'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TopBarProps {
pathname: string
onMenuClick?: () => void
isMobile?: boolean
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Kopfzeile der Anwendung.
*
* Sie trägt nur noch, was global gilt: Mandant, Benachrichtigungen und Konto.
* Das Chip «Verwaltung» und der wiederholte Seitenname sind entfallen — beides
* stand nochmals gross im Inhaltsbereich und kostete nur Höhe. Ohne Trennlinie
* geht die Zeile ohne sichtbaren Bruch in den Seiteninhalt über.
* Sie trägt seit Runde 6 die gesamte Navigation: Mandant, Benachrichtigungen
* und — an der Stelle des früheren Profilsymbols — ein Menüsymbol, das das
* globale Klappmenü öffnet (§11, §15). Das Profil ist vollständig in dieses
* Menü gewandert; in der Leiste selbst steht es nicht mehr.
*
* Seit Runde 5 steht in der freien Mitte der kompakte Agentenchat, sobald der
* ausführliche Chatkopf weggescrollt ist (§9). Er liegt im Balken selbst und
* nicht darüber — deshalb kann er Tabellen und Filter nicht verdecken. Der
* Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt, aber unter
* Menüs, Dialogen und Popovers, die MUI oberhalb davon einhängt (§10).
* In der freien Mitte erscheint der kompakte Agentenchat, sobald der
* ausführliche Chatkopf weggescrollt ist (Runde 5, §9). Er liegt im Balken
* selbst und nicht darüber — deshalb kann er Tabellen und Filter nicht
* verdecken. Der Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt,
* aber unter Menüs, Dialogen und Popovers, die MUI oberhalb davon einhängt.
*/
export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
export function TopBar(_props: TopBarProps) {
return (
<Box
component="header"
@@ -50,24 +39,17 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
px: { xs: 1.5, sm: 3 },
}}
>
{/* Links steht nur noch der Menüaufruf auf schmalen Schirmen. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{isMobile && (
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
<Menu size={20} />
</IconButton>
)}
</Box>
{/* Links bleibt frei — die Navigation liegt vollständig rechts. */}
<Box sx={{ flexShrink: 0 }} />
<AgentChatStickyBar />
{/* Right side */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexShrink: 0 }}>
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
<OrganizationContextBadge />
</Box>
<NotificationButton />
<UserMenu />
<GlobalNavMenu />
</Box>
</Box>
)
+236
View File
@@ -0,0 +1,236 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { Box, ButtonBase, Divider, IconButton, Popover, Tooltip, Typography } from '@mui/material'
import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AgentAvatar } from '../team'
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
const ROLE_LABELS: Record<string, string> = {
PROPERTY_MANAGER: 'Property Manager',
DEMAND_USER: 'Demand User',
}
/**
* Globale Navigation in der Kopfzeile (Runde 6, §11–§16).
*
* Sie ersetzt die entfernte Seitenleiste und das frühere Profilsymbol. Ein
* einziges Menüsymbol neben der Glocke öffnet ein Klappmenü, das rechts an der
* Kopfzeile ausgerichtet nach unten aufgeht.
*
* Aufbau: «Startseite», «Meine Agenten» mit allen Agenten als eingerückte,
* rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen,
* die vorher im Profilmenü lagen.
*
* Die Agentenliste kommt aus `AGENT_WORKSPACES` — derselben zentralen
* Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen,
* Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24,
* Schritt 7).
*
* `Popover` statt `Menu`: Ein `Menu` erwartet gleichrangige `MenuItem`-Kinder
* und übernimmt Tastaturnavigation über eine flache Liste. Hier gibt es zwei
* Ebenen mit unterschiedlicher Ausrichtung — das Popover lässt diese Struktur
* zu und bringt Klick-ausserhalb und Escape von sich aus mit.
*/
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)
const open = !!anchorEl
const close = () => setAnchorEl(null)
/** Menüpunkt und Ziel gehören zusammen: erst schliessen, dann navigieren. */
const go = (path: string) => {
close()
navigate(path)
}
const handleLogout = () => {
close()
logout()
navigate('/auth/login')
}
return (
<>
<Tooltip title="Menü">
<IconButton
size="small"
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}
sx={{ color: DS_TEXT.primary }}
>
<LayoutGrid size={20} />
</IconButton>
</Tooltip>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
/*
* 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: {
width: 320,
borderRadius: 2,
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
overflow: 'hidden',
},
},
}}
>
<MenuSection>
<MainItem label={HOME_NAV_LABEL} onClick={() => go(ROUTES.SUPPLY.PROPERTIES)} />
</MenuSection>
<Divider />
<MenuSection>
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
{/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */}
{AGENT_WORKSPACES.map(agent => (
<SubItem key={agent.id} onClick={() => go(agent.path)}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
{agent.name}
</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: DS_TEXT.secondary }}>
{agent.role}
</Typography>
<AgentAvatar agent={agent} size={26} />
</SubItem>
))}
</MenuSection>
<Divider />
<MenuSection>
<MainItem label="Profil" onClick={() => showProfileHint(showToast, close)} />
{currentUser && (
<Typography
sx={{
px: 2, pb: 0.75, textAlign: 'right',
fontSize: '0.6875rem', color: DS_TEXT.muted,
}}
>
{currentUser.name} · {ROLE_LABELS[currentUser.role] ?? currentUser.role}
{currentUser.organizationName ? ` · ${currentUser.organizationName}` : ''}
</Typography>
)}
<SubItem onClick={() => showProfileHint(showToast, close)}>
<ProfileLabel icon={<User size={15} />} label="Profil" />
</SubItem>
<SubItem onClick={() => { close(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
<ProfileLabel icon={<Settings size={15} />} label="Einstellungen" />
</SubItem>
<SubItem
onClick={() => {
// Bestehende Logik: die Tour zeigt sich erneut, sobald die Merkung
// entfernt ist. Nicht neu gebaut (§16).
close()
localStorage.removeItem('property_match_welcomed')
window.location.reload()
}}
>
<ProfileLabel icon={<HelpCircle size={15} />} label="Produkttour starten" />
</SubItem>
<SubItem onClick={handleLogout}>
<ProfileLabel icon={<LogOut size={15} />} label="Abmelden" />
</SubItem>
</MenuSection>
</Popover>
</>
)
}
function showProfileHint(showToast: (m: string, t: 'info') => void, close: () => void) {
close()
showToast('Profilseite ist in Kürze verfügbar.', 'info')
}
function MenuSection({ children }: { children: React.ReactNode }) {
return <Box sx={{ py: 1 }}>{children}</Box>
}
/** Hauptpunkt: linksbündig, kräftig — er trägt die Hierarchie. */
function MainItem({ label, onClick }: { label: string; onClick: () => void }) {
return (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
justifyContent: 'flex-start',
px: 2, py: 1,
'&:hover': { bgcolor: DS_SLATE[50] },
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
}}
>
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_BRAND.main }}>
{label}
</Typography>
</ButtonBase>
)
}
/**
* Unterpunkt: kleiner, eingerückt und am rechten Rand ausgerichtet (§14/§16).
* Die Rechtsausrichtung ist die Vorgabe — sie trennt die Ebenen auch ohne
* Einrückungslinien sichtbar voneinander.
*/
function SubItem({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 1,
pl: 4, pr: 2, py: 0.75,
textAlign: 'right',
'&:hover': { bgcolor: DS_SLATE[50] },
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
}}
>
{children}
</ButtonBase>
)
}
function ProfileLabel({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<>
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.primary }}>{label}</Typography>
<Box sx={{ display: 'flex', color: DS_TEXT.secondary }}>{icon}</Box>
</>
)
}
-103
View File
@@ -1,103 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
import { HelpCircle, LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { DS_BRAND } from '../../lib/ds'
const ROLE_LABELS: Record<string, string> = {
PROPERTY_MANAGER: 'Property Manager',
DEMAND_USER: 'Demand User',
}
function getUserInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
export function UserMenu() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { currentUser, logout } = useSessionStore()
const navigate = useNavigate()
const showToast = useToastStore((s) => s.showToast)
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
}
function handleClose() {
setAnchorEl(null)
}
function handleLogout() {
handleClose()
logout()
navigate('/auth/login')
}
const initials = getUserInitials(currentUser?.name ?? 'U')
return (
<>
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: DS_BRAND.main }}>
{initials}
</Avatar>
</IconButton>
<Menu
anchorEl={anchorEl}
open={!!anchorEl}
onClose={handleClose}
slotProps={{ paper: { sx: { width: 300, mt: 1 } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
{/* User info header */}
{currentUser && (
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{ROLE_LABELS[currentUser.role] ?? currentUser.role}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{currentUser.organizationName}
</Typography>
</Box>
)}
<Divider />
<MenuItem onClick={() => { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}>
<ListItemIcon><User size={16} /></ListItemIcon>
Profil
</MenuItem>
<MenuItem onClick={() => { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
<ListItemIcon><Settings size={16} /></ListItemIcon>
Einstellungen
</MenuItem>
<MenuItem onClick={() => { handleClose(); localStorage.removeItem('property_match_welcomed'); window.location.reload() }}>
<ListItemIcon><HelpCircle size={16} /></ListItemIcon>
Produkttour starten
</MenuItem>
{/* Der Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» ist entfernt
(Runde 5, §8). Die Flächensuche existiert in diesem Stand nicht mehr;
eine Umschaltung auf eine Rolle ohne erreichbare Seiten wäre eine
Sackgasse im Profilmenü gewesen. */}
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon><LogOut size={16} /></ListItemIcon>
Abmelden
</MenuItem>
</Menu>
</>
)
}
+1 -1
View File
@@ -2,6 +2,6 @@ export { AppShell } from './AppShell'
export { PageHeader } from './PageHeader'
export { RightContextPanel } from './RightContextPanel'
export { CompareTray } from './CompareTray'
export { UserMenu } from './UserMenu'
export { GlobalNavMenu } from './GlobalNavMenu'
export { NotificationButton } from './NotificationButton'
export { OrganizationContextBadge } from './OrganizationContextBadge'
-240
View File
@@ -1,240 +0,0 @@
import { memo } from 'react'
import { Box, ButtonBase, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react'
import { AgentAvatar } from '../team'
import type { AgentPresence } from '../../domain/agentPresence'
import { WorkloadTone } from '../../domain/agentWorkload'
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
/**
* Farben der Auslastung auf dunklem Grund. Bewusst hellere Stufen als die
* Standardakzente — die dunkleren Töne verschwinden vor der abgedunkelten
* Bürofläche.
*/
const TONE_COLOR: Record<WorkloadTone, string> = {
[WorkloadTone.CALM]: DS_ACCENT.success.emerald,
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.amber,
[WorkloadTone.URGENT]: DS_ACCENT.danger.bright,
}
export interface AgentStaffCardProps {
presence: AgentPresence
onOpen: (path: string) => void
}
/**
* Ein digitaler Mitarbeitender auf der Startseite (Runde 5, §3/§6).
*
* Die Karte ist bewusst wie ein Personalblatt aufgebaut und nicht wie eine
* Kachel: Porträt, Name, Funktion, Abteilung und Personalnummer, dann ein Satz
* in der ersten Person, darunter die zuletzt protokollierte Handlung mit
* Zeitangabe.
*
* Diese letzte Zeile ist der Unterschied zwischen «KI-Funktion» und
* «Mitarbeitender»: sie belegt, dass jemand gearbeitet hat, statt es zu
* behaupten. Eine Kennzahl allein kann jede Software anzeigen.
*
* Die ganze Karte ist ein Schalter. `ButtonBase` liefert Tastaturbedienung,
* Enter, Leertaste und Fokusring ohne eigenen Code.
*/
export const AgentStaffCard = memo(function AgentStaffCard({ presence, onOpen }: AgentStaffCardProps) {
const { workload } = presence
const toneColor = workload ? TONE_COLOR[workload.tone] : DS_NEUTRAL.stone
return (
<ButtonBase
focusRipple
onClick={() => onOpen(presence.path)}
aria-label={
workload
? `${presence.name}, ${presence.role} — ${workload.label}. Arbeitsbereich öffnen`
: `${presence.name}, ${presence.role} — Arbeitsbereich öffnen`
}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
textAlign: 'left',
// Ohne `minWidth: 0` kann eine Flexkarte nicht unter ihre Inhaltsbreite
// schrumpfen. Die fünf Karten passten dadurch nicht in eine Reihe,
// brachen um — und die zweite Reihe drückte Titel und Filterbalken aus
// dem sichtbaren Bereich.
minWidth: 0,
gap: 1.25,
p: { xs: 1.75, xl: 2 },
borderRadius: 3,
overflow: 'hidden',
// Keine eigene Höhe: im Flexlayout gibt die Zeile die Höhe vor, und alle
// Karten einer Zeile werden von selbst gleich hoch. Mit `height: 100%`
// beanspruchte jede Karte die volle Fläche und die zweite Zeile fiel
// aus dem sichtbaren Bereich.
// Glasfläche statt Vollton: das Büro dahinter bleibt spürbar, ohne die
// Schrift zu stören.
bgcolor: 'rgba(255,255,255,0.06)',
backdropFilter: 'blur(14px)',
border: '1px solid rgba(255,255,255,0.12)',
transition: 'background-color 0.18s ease, transform 0.18s ease, border-color 0.18s ease',
'&:hover, &.Mui-focusVisible': {
bgcolor: 'rgba(255,255,255,0.11)',
borderColor: 'rgba(255,255,255,0.26)',
transform: 'translateY(-3px)',
},
'&:hover .staff-go, &.Mui-focusVisible .staff-go': { opacity: 1, transform: 'translateX(0)' },
'&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 2 },
}}
>
{/* Kopfzeile: Porträt, Personalien, Dienstzustand */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ position: 'relative', flexShrink: 0 }}>
<AgentAvatar
agent={{ id: presence.agentId, name: presence.name, role: presence.role }}
size={56}
loading="eager"
ringColor="rgba(255,255,255,0.55)"
ringWidth={2}
/>
{/* Dienstpunkt am Porträt — die kürzeste Art zu sagen «ist da». */}
<Box
sx={{
position: 'absolute', right: -1, bottom: -1,
width: 14, height: 14, borderRadius: '50%',
bgcolor: presence.onDuty ? DS_ACCENT.success.emerald : DS_NEUTRAL.stone,
border: '2.5px solid rgba(12,18,28,0.95)',
}}
/>
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
sx={{ fontWeight: 700, fontSize: '1.0625rem', lineHeight: 1.2, color: DS_NEUTRAL.white, overflowWrap: 'break-word' }}
>
{presence.name}
</Typography>
<Typography
// Umbrechen statt abschneiden: «Besichtigungsassistent» passt in
// einer schmalen Karte nicht in eine Zeile, und eine halbe
// Funktionsbezeichnung ist keine.
sx={{ fontSize: '0.8125rem', lineHeight: 1.3, color: 'rgba(255,255,255,0.72)', overflowWrap: 'break-word' }}
>
{presence.role}
</Typography>
{(presence.department || presence.personnelNumber) && (
<Typography
sx={{
mt: 0.25,
fontSize: '0.6875rem',
lineHeight: 1.3,
color: 'rgba(255,255,255,0.42)',
overflowWrap: 'break-word',
// Umbrechen statt abschneiden: in einer schmalen Karte fiele
// sonst ausgerechnet die Personalnummer weg, und die macht aus
// einem Werkzeug einen Mitarbeitenden.
}}
>
{[presence.department, presence.personnelNumber].filter(Boolean).join(' · ')}
</Typography>
)}
</Box>
</Box>
{/* Kompetenz: was der Mitarbeitende selbst entscheiden darf. */}
{presence.autonomy && (
<Box
sx={{
alignSelf: 'flex-start',
px: 1,
py: 0.25,
borderRadius: 1,
bgcolor: 'rgba(255,255,255,0.09)',
border: '1px solid rgba(255,255,255,0.14)',
}}
>
<Typography
sx={{
fontSize: '0.625rem',
fontWeight: 700,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.66)',
}}
>
{presence.autonomy}
</Typography>
</Box>
)}
{/* Was er dazu sagt — in der ersten Person. */}
{workload && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Box
sx={{ width: 3, alignSelf: 'stretch', borderRadius: 2, bgcolor: toneColor, flexShrink: 0 }}
/>
<Typography
sx={{
fontSize: '0.8125rem',
lineHeight: 1.45,
color: 'rgba(255,255,255,0.92)',
overflowWrap: 'break-word',
}}
>
{workload.voice}
</Typography>
</Box>
)}
{/* Beleg: was zuletzt tatsächlich getan wurde. */}
<Box sx={{ mt: 'auto', pt: 1, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
{presence.lastAction ? (
<>
<Typography
sx={{
fontSize: '0.625rem',
fontWeight: 700,
letterSpacing: 0.8,
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.38)',
}}
>
Zuletzt · {presence.lastAction.when}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Typography
sx={{
flex: 1,
minWidth: 0,
fontSize: '0.75rem',
lineHeight: 1.4,
color: 'rgba(255,255,255,0.62)',
overflowWrap: 'break-word',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{presence.lastAction.title}
</Typography>
<Box
className="staff-go"
sx={{
color: DS_NEUTRAL.gold,
opacity: 0,
transform: 'translateX(-4px)',
transition: 'opacity 0.18s ease, transform 0.18s ease',
flexShrink: 0,
mt: 0.25,
}}
>
<ArrowRight size={16} />
</Box>
</Box>
</>
) : (
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.42)' }}>
Noch keine Tätigkeit protokolliert.
</Typography>
)}
</Box>
</ButtonBase>
)
})
-172
View File
@@ -1,172 +0,0 @@
import { useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router'
import { Box, Typography } from '@mui/material'
import { StaffBackdrop } from './StaffBackdrop'
import { AgentStaffCard } from './AgentStaffCard'
import { useAgentPresence } from '../../hooks/useAgentPresence'
import { WorkloadTone } from '../../domain/agentWorkload'
import { DS_NEUTRAL } from '../../lib/ds'
/**
* Mindesthöhe, damit die Belegschaft eine Fläche hat und nicht als schmaler
* Streifen über der Objektliste klebt.
*
* Eine feste oder gedeckelte Höhe hatte dieser Bereich früher — und genau das
* war der Fehler: passte der Inhalt nicht hinein, scrollte er in sich selbst,
* die Porträts wurden oben abgeschnitten und neben der Seite stand ein zweiter
* Scrollbalken. Die Höhe richtet sich deshalb nach dem Inhalt. Damit der Titel
* «Meine Objekte» beim Einstieg sichtbar bleibt (§2.3), stehen die fünf
* Mitarbeitenden auf grossen Schirmen in einer einzigen Reihe statt in zweien.
*/
const MIN_SCENE_PX = 360
/**
* Grösste Breite der Karten samt Anriss. Freilaufend würden bei drei Spalten
* auf einem 2560er Monitor Karten von über 700 Pixeln entstehen — flache
* Streifen, in denen zwei Textzeilen verloren stehen. Der Hintergrund bleibt
* randlos, nur der Inhalt wird gefasst und mittig gestellt.
*/
const MAX_CONTENT_PX = 1280
/**
* Die digitale Belegschaft auf der Startseite (Runde 5, §3).
*
* Die fünf Mitarbeitenden an ihrem Arbeitsplatz — mit Porträt, Funktion,
* Abteilung, Personalnummer, Dienstzustand, einem Satz in der ersten Person
* und der zuletzt protokollierten Handlung. Ein Klick auf eine Karte öffnet
* den jeweiligen Arbeitsbereich. Die Personalverwaltung selbst liegt im
* Menüpunkt «Meine Agenten» und braucht keine eigene Person am Bildschirm.
*
* Bewusst keine nachgebaute Raumperspektive mehr: eine gezeichnete Kulisse
* bleibt neben fotografierten Porträts erkennbar gezeichnet, und ein Raum
* allein macht niemanden menschlich. Was menschlich wirkt, ist dasselbe wie im
* echten Büro — dass jemand da ist, etwas zu sagen hat und nachweisbar
* gearbeitet hat.
*/
export function AgentStaffOverview() {
const navigate = useNavigate()
const { data: staff } = useAgentPresence()
const handleOpen = useCallback((path: string) => navigate(path), [navigate])
// Ein Durchgang über dieselbe Liste statt zweier Filterläufe (CLAUDE.md §10.4).
const { urgent, onDuty } = useMemo(() => {
let urgentCount = 0
let onDutyCount = 0
for (const person of staff) {
if (person.workload?.tone === WorkloadTone.URGENT) urgentCount += 1
if (person.onDuty) onDutyCount += 1
}
return { urgent: urgentCount, onDuty: onDutyCount }
}, [staff])
return (
<Box
component="section"
aria-label="Digitale Belegschaft"
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: MIN_SCENE_PX,
overflow: 'hidden',
}}
>
<StaffBackdrop />
<Box
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: MAX_CONTENT_PX,
mx: 'auto',
px: { xs: 2.5, lg: 4 },
py: { xs: 2.5, lg: 3 },
gap: { xs: 2, lg: 2.5 },
}}
>
<StaffHeadline urgent={urgent} onDuty={onDuty} total={staff.length} />
<Box
sx={{
/*
* Ab `lg` stehen alle fünf nebeneinander in einer Reihe. Zwei
* Reihen brauchten rund 250 Pixel mehr Höhe — auf einem Laptop war
* das genau der Betrag, der den Bereich zum Scrollen brachte und
* die Porträts der oberen Reihe abschnitt. Eine Reihe ist zugleich
* das ehrlichere Bild: fünf Kolleginnen und Kollegen nebeneinander,
* keine Hierarchie über zwei Ebenen.
*
* Darunter wird umbrochen, weil fünf Karten nebeneinander unter
* 1200 Pixeln unlesbar schmal würden.
*/
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'stretch',
gap: { xs: 1.5, lg: 2 },
'& > *': {
flex: {
xs: '1 1 100%',
sm: '0 1 calc((100% - 12px) / 2)',
md: '0 1 calc((100% - 24px) / 3)',
lg: '0 1 calc((100% - 64px) / 5)',
},
},
}}
>
{staff.map(person => (
<AgentStaffCard key={person.agentId} presence={person} onOpen={handleOpen} />
))}
</Box>
</Box>
</Box>
)
}
/**
* Anriss über der Belegschaft.
*
* Beantwortet die Frage, mit der jemand diese Seite öffnet: muss ich heute
* irgendwo eingreifen? Ohne den Satz müsste man jede Karte einzeln lesen und
* selbst zusammenzählen.
*/
function StaffHeadline({ urgent, onDuty, total }: { urgent: number; onDuty: number; total: number }) {
const summary =
urgent > 0
? `${urgent} ${urgent === 1 ? 'Mitarbeitender braucht' : 'Mitarbeitende brauchen'} heute eine Entscheidung von dir`
: 'Alle laufen — heute liegt nichts bei dir'
return (
<Box sx={{ flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: DS_NEUTRAL.gold }} />
<Typography
sx={{
fontSize: '0.6875rem',
fontWeight: 700,
letterSpacing: 1.4,
textTransform: 'uppercase',
color: DS_NEUTRAL.gold,
}}
>
{onDuty} von {total} im Dienst
</Typography>
</Box>
<Typography
component="h2"
sx={{
mt: 0.5,
fontWeight: 700,
fontSize: { xs: '1.25rem', lg: '1.6rem' },
lineHeight: 1.2,
color: DS_NEUTRAL.white,
}}
>
{summary}
</Typography>
</Box>
)
}
+332
View File
@@ -0,0 +1,332 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import * as THREE from 'three'
import { Box, Typography } from '@mui/material'
import { createMeetingRoomScene } from './meetingRoomScene'
import type { MeetingRoomScene } from './meetingRoomScene'
import { MEETING_SEATS } from './meetingRoomSeats'
import { AGENT_PHOTOS } from '../team/agentPhotos'
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
import { WorkloadTone } from '../../domain/agentWorkload'
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
/**
* Farben der Auslastung auf dunklem Grund. Hellere Stufen als die
* Standardakzente — die dunkleren verschwinden vor der Szene.
*/
const TONE_COLOR: Record<WorkloadTone, string> = {
[WorkloadTone.CALM]: DS_ACCENT.success.emerald,
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.amber,
[WorkloadTone.URGENT]: DS_ACCENT.danger.bright,
}
/**
* Höhe der Kopfzeile plus des Streifens, der unter dem Raum sichtbar bleiben
* muss: Titel «Meine Objekte» und der Beginn von «Filter & Übersicht»
* (Runde 6, §1.1).
*/
const TOP_BAR_PX = 64
const KEEP_VISIBLE_BELOW_PX = 124
/** Unter dieser Höhe verliert die Perspektive ihre Wirkung. */
const MIN_HEIGHT_PX = 380
/** Darüber wird der Raum zur Landschaft und die Objektliste rutscht zu weit weg. */
const MAX_HEIGHT_PX = 760
/**
* Virtueller 3D-Meetingraum auf der Startseite (Runde 6, §1–§9).
*
* Der Betrachter sitzt am Kopfende des Tisches und sieht seine Agenten vor sich.
* Die Kamera ist fest; als einzige Bewegung folgt sie der Maus um wenige
* Zentimeter, damit die Szene lebt, ohne die verbindliche Perspektive zu
* verlassen (§20).
*
* Die Beschriftungen sind HTML und nicht Teil der 3D-Szene. Als Textur im Raum
* wären sie bei Tiefe unlesbar; als DOM-Element bleiben sie scharf, lassen sich
* mit den Mitteln des Designsystems gestalten und sind für Screenreader
* vorhanden. Ihre Position kommt aus der Szene: jede Bildwiederholung projiziert
* den Ankerpunkt über dem Kopf auf den Bildschirm.
*
* Gerendert wird nur bei Anlass — Grösse, Maus, Hover. Ein Dauerloop wäre für
* eine statische Szene verschwendete Rechenzeit (§21).
*/
export function MeetingRoom3D() {
const navigate = useNavigate()
const canvasRef = useRef<HTMLCanvasElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const sceneRef = useRef<MeetingRoomScene | null>(null)
const labelRefs = useRef(new Map<string, HTMLDivElement | null>())
const hoveredRef = useRef<string | null>(null)
const [hovered, setHovered] = useState<string | null>(null)
// Beim Hover zeigt die Plakette zusätzlich, woran der Agent gerade sitzt.
// Ruhend bleibt sie bei Name und Funktion (§8).
const { data: workload } = useAgentWorkload()
/**
* Beschriftungen an die projizierten Kopfpositionen setzen.
*
* Danach folgt ein Entzerrungsdurchgang: Plätze weiter hinten rücken in der
* Perspektive auf wenige Bildpunkte zusammen, ihre Plaketten lägen dort
* übereinander. Wer mit einem bereits gesetzten Nachbarn kollidiert, wandert
* so weit nach oben, bis er frei steht.
*
* Bewusst nach der Projektion und nicht über handverlesene Höhen in der
* Szene: gerechnete Abstände halten auf jeder Fenstergrösse, fest gesetzte
* Werte gelten nur für die Breite, an der sie eingestellt wurden.
*/
const positionLabels = useCallback(() => {
const s = sceneRef.current
const wrap = wrapRef.current
if (!s || !wrap) return
const w = wrap.clientWidth
const h = wrap.clientHeight
const v = new THREE.Vector3()
type Placed = { el: HTMLDivElement; x: number; y: number; halfW: number; height: number }
const placed: Placed[] = []
// Von vorne nach hinten: die vorderen Plaketten behalten ihren Platz, die
// hinteren geben nach. Das entspricht der Tiefenordnung im Bild.
const ordered = [...s.seats].sort((a, b) => b.labelAnchor.z - a.labelAnchor.z)
for (const handle of ordered) {
const el = labelRefs.current.get(handle.seat.id)
if (!el) continue
v.copy(handle.labelAnchor).project(s.camera)
let y = (-v.y * 0.5 + 0.5) * h
// Weiter hinten sitzende Beschriftungen werden kleiner, bleiben aber über
// einer Untergrenze lesbar (§8 «bei räumlicher Tiefe ausreichend lesbar»).
const scale = Math.max(0.68, Math.min(1, 1 - (v.z - 0.955) * 8))
const halfW = (el.offsetWidth * scale) / 2
const height = el.offsetHeight * scale
// Innerhalb des Bildes halten: auf schmalen Fenstern liegen die äusseren
// Plätze so weit aussen, dass ihre Beschriftung sonst abgeschnitten wird.
const x = Math.min(Math.max((v.x * 0.5 + 0.5) * w, halfW + 6), w - halfW - 6)
for (const other of placed) {
const overlapsX = Math.abs(x - other.x) < halfW + other.halfW + 6
const overlapsY = Math.abs(y - other.y) < Math.max(height, other.height) + 4
if (overlapsX && overlapsY) {
y = other.y - Math.max(height, other.height) - 6
}
}
placed.push({ el, x, y, halfW, height })
el.style.transform =
`translate(-50%, -100%) translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${scale.toFixed(3)})`
}
}, [])
const draw = useCallback(() => {
sceneRef.current?.render()
positionLabels()
}, [positionLabels])
// ── Szene aufbauen ──
useEffect(() => {
const canvas = canvasRef.current
const wrap = wrapRef.current
if (!canvas || !wrap) return
const s = createMeetingRoomScene(canvas, MEETING_SEATS, AGENT_PHOTOS)
sceneRef.current = s
s.setSize(wrap.clientWidth, wrap.clientHeight)
s.render()
positionLabels()
// Texturen kommen asynchron; ohne ein zweites Zeichnen bliebe die Szene
// beim Stand des ersten Bildes stehen — ohne Fotos, ohne Kulisse.
THREE.DefaultLoadingManager.onLoad = () => {
s.render()
positionLabels()
}
const ro = new ResizeObserver(() => {
s.setSize(wrap.clientWidth, wrap.clientHeight)
s.render()
positionLabels()
})
ro.observe(wrap)
return () => {
THREE.DefaultLoadingManager.onLoad = () => {}
ro.disconnect()
s.dispose()
sceneRef.current = null
}
}, [positionLabels])
// ── Maus: Parallaxe und Trefferprüfung ──
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const s = sceneRef.current
const wrap = wrapRef.current
if (!s || !wrap) return
const rect = wrap.getBoundingClientRect()
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
s.applyParallax(nx, ny)
const ray = new THREE.Raycaster()
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
const id = hit ? (s.seats.find(h => h.hitBox === hit.object)?.seat.id ?? null) : null
if (id !== hoveredRef.current) {
hoveredRef.current = id
s.setHovered(id)
setHovered(id)
}
draw()
}, [draw])
const handlePointerLeave = useCallback(() => {
const s = sceneRef.current
if (!s) return
s.applyParallax(0, 0)
hoveredRef.current = null
s.setHovered(null)
setHovered(null)
draw()
}, [draw])
const handleClick = useCallback(() => {
const id = hoveredRef.current
if (!id) return
const seat = MEETING_SEATS.find(x => x.id === id)
if (seat) navigate(seat.path)
}, [navigate])
return (
<Box
component="section"
aria-label="Virtueller Meetingraum mit den digitalen Mitarbeitenden"
ref={wrapRef}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onClick={handleClick}
sx={{
position: 'relative',
width: '100%',
height: `clamp(${MIN_HEIGHT_PX}px, calc(100vh - ${TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX}px), ${MAX_HEIGHT_PX}px)`,
'@supports (height: 100dvh)': {
height: `clamp(${MIN_HEIGHT_PX}px, calc(100dvh - ${TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX}px), ${MAX_HEIGHT_PX}px)`,
},
overflow: 'hidden',
cursor: hovered ? 'pointer' : 'default',
bgcolor: DS_NEUTRAL.sidebar,
}}
>
<Box component="canvas" ref={canvasRef} sx={{ display: 'block', width: '100%', height: '100%' }} />
{/*
Beschriftungen über den Köpfen (§8). Sie liegen über dem Bild, nehmen
aber keine Klicks an — der Klick gehört der Szene darunter, damit der
Trefferkörper des Agenten entscheidet und nicht die Textbreite.
*/}
{MEETING_SEATS.map(seat => {
const on = hovered === seat.id
return (
<Box
key={seat.id}
ref={(el: HTMLDivElement | null) => { labelRefs.current.set(seat.id, el) }}
sx={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
textAlign: 'center',
whiteSpace: 'nowrap',
px: 1,
py: 0.375,
borderRadius: 1.5,
transformOrigin: 'bottom center',
transition: 'background-color 0.15s ease, box-shadow 0.15s ease',
// Kein starker Kasten, der den Raum verdeckt (§8) — aber ohne jede
// Unterlage verschwindet weisse Schrift vor der hellen Glasfront.
// Deshalb ein leichter Schleier mit Weichzeichnung, der bei Hover
// etwas dichter wird.
bgcolor: on ? 'rgba(11,18,29,0.74)' : 'rgba(11,18,29,0.34)',
backdropFilter: 'blur(3px)',
boxShadow: on ? '0 6px 20px rgba(0,0,0,0.35)' : 'none',
}}
>
<Typography
sx={{
fontWeight: 700,
fontSize: '0.875rem',
lineHeight: 1.2,
color: DS_NEUTRAL.white,
textShadow: '0 1px 6px rgba(0,0,0,0.75)',
}}
>
{seat.name}
</Typography>
<Typography
sx={{
fontSize: '0.6875rem',
lineHeight: 1.25,
color: on ? DS_NEUTRAL.gold : 'rgba(255,255,255,0.82)',
textShadow: '0 1px 6px rgba(0,0,0,0.75)',
}}
>
{seat.role}
</Typography>
{/*
Nur bei Hover: die aktuelle Auslastung. Ruhend bliebe die Plakette
sonst überdimensioniert (§8), im Hover ist die Hervorhebung
ausdrücklich erlaubt — und sie sagt vor dem Klick, was drüben liegt.
*/}
{on && workload[seat.id] && (
<Typography
sx={{
mt: 0.25,
fontSize: '0.6875rem',
fontWeight: 600,
lineHeight: 1.25,
color: TONE_COLOR[workload[seat.id].tone],
}}
>
{workload[seat.id].label}
</Typography>
)}
</Box>
)
})}
{/*
Tastaturweg zu denselben Zielen. Die Szene selbst ist ein Canvas und
damit für die Tastatur unerreichbar; ohne diese Schalter wären die
Agenten nur mit der Maus erreichbar.
*/}
<Box
sx={{
position: 'absolute', left: 0, top: 0, width: 1, height: 1,
overflow: 'hidden', clip: 'rect(0 0 0 0)',
}}
>
{MEETING_SEATS.map(seat => (
<button
key={seat.id}
type="button"
onClick={e => { e.stopPropagation(); navigate(seat.path) }}
onFocus={() => {
hoveredRef.current = seat.id
sceneRef.current?.setHovered(seat.id)
setHovered(seat.id)
draw()
}}
>
{`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`}
</button>
))}
</Box>
</Box>
)
}
-39
View File
@@ -1,39 +0,0 @@
import { memo } from 'react'
import { Box } from '@mui/material'
import { ROOM_BACKDROP } from './roomBackdrop'
import { DS_NEUTRAL } from '../../lib/ds'
/**
* Bildhintergrund der Belegschaftsansicht (Runde 5, §3).
*
* Bewusst eine einzige Bildfläche mit Verlauf — und nicht länger ein aus
* Flächen konstruierter Raum. Ein selbstgezeichneter Raum bleibt neben
* fotografierten Porträts immer erkennbar gezeichnet; die Bildwirkung muss
* deshalb aus dem Foto kommen, nicht aus Geometrie.
*
* Die Komponente selbst weiss nichts über das Motiv. Sie zeigt, was
* `ROOM_BACKDROP` liefert — die Aussicht heute, eine Raumaufnahme später.
*/
export const StaffBackdrop = memo(function StaffBackdrop() {
return (
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: DS_NEUTRAL.sidebar }}>
<Box
component="img"
src={ROOM_BACKDROP.src}
alt=""
aria-hidden="true"
sx={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: ROOM_BACKDROP.focus,
}}
/>
<Box sx={{ position: 'absolute', inset: 0, background: ROOM_BACKDROP.scrim }} />
</Box>
)
})
+4 -4
View File
@@ -1,5 +1,5 @@
// Digitale Belegschaft auf der Startseite (Runde 5, §3). Barrel-Export (CLAUDE.md §13).
// Virtueller 3D-Meetingraum der Startseite (Runde 6). Barrel-Export (CLAUDE.md §13).
export { AgentStaffOverview } from './AgentStaffOverview'
export { AgentStaffCard } from './AgentStaffCard'
export { ROOM_BACKDROP } from './roomBackdrop'
export { MeetingRoom3D } from './MeetingRoom3D'
export { MEETING_SEATS, SeatSide } from './meetingRoomSeats'
export type { MeetingSeat } from './meetingRoomSeats'
+102
View File
@@ -0,0 +1,102 @@
/**
* Farben und Maße des 3D-Meetingraums (Runde 6, §3/§5).
*
* Die Farben stehen als Zahlen und nicht als Hexzeichenketten, weil three.js
* Zahlen erwartet — `new Color(0x…)` ist der direkte Weg, jede Umwandlung
* dazwischen wäre nur Umweg. Sie liegen hier statt in der Szene, damit sich die
* Materialstimmung an einer Stelle ändern lässt (CLAUDE.md §8).
*
* Der Raum ist «modern, hochwertig, zurückhaltend, nicht futuristisch»: warmes
* Eichenholz, heller Putz, Anthrazitteppich, mattgraue Fensterprofile. Als
* einziger Markenbezug trägt die geschlossene Wand einen schmalen Streifen im
* Property-Match-Blau (§3 «darf subtil aufgenommen werden»).
*/
export const ROOM_COLORS = {
floor: 0x4b4741,
floorSheen: 0x5d5852,
ceiling: 0xf6f5f2,
ceilingRail: 0xeeebe5,
/** Geschlossene Wand rechts: heller Putz mit Eichenpaneel im unteren Drittel. */
wall: 0xe8e4dc,
wallPanel: 0x9c7b52,
wallSkirting: 0xd6d1c7,
/** Schmaler Markenstreifen auf der geschlossenen Wand. */
brandStripe: 0x152642,
/** Fensterprofile und Pfosten. */
mullion: 0x6b7079,
frame: 0x585d65,
/** Glasscheibe: sehr schwacher Blauschleier, sonst wäre sie unsichtbar. */
glass: 0xd8e8f0,
/** Tischplatte in Eiche, Kante etwas dunkler. */
tableTop: 0x8f7350,
tableEdge: 0x6f5637,
tableBase: 0x35322f,
/** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
headRing: 0xffffff,
/** Ring bei Hover: Gold aus dem Designsystem. */
headRingHover: 0xb8975a,
/** Himmel hinter der Kulisse, falls doch ein Rand sichtbar wird. */
sky: 0xbcd7e6,
} as const
/**
* Raumgeometrie in Metern. Der Betrachter sitzt am Kopfende bei z ≈ 0 und
* schaut in Richtung −z; die Fensterfront gegenüber liegt bei z = −ROOM.depth.
*/
export const ROOM = {
width: 7.4,
height: 3.05,
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.19,
/** Knapp hinter der vorderen Tischkante. */
z: 1.9,
fov: 48,
/**
* Obergrenze des waagrechten Bildwinkels in Grad. Greift nur bei sehr breiten
* und gleichzeitig flachen Fenstern; bei üblichen Seitenverhältnissen liegt
* der waagrechte Winkel darunter und die Ansicht bleibt unverändert.
*/
maxHFov: 84,
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
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).
*/
parallax: 0.075,
} as const
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
export const TABLE = {
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: -5.75,
} as const
/** Sitzende Personen: Höhe der Porträtkreise. */
export const SEATED = {
/**
* Luft zwischen Tischplatte und Unterkante des Porträtkreises (Runde 7, §2).
*
* Daraus ergibt sich die Kopfhöhe: `TABLE.top + radius + headGap`. Die Kreise
* schweben damit nur knapp über der Tischkante statt weit darüber. Vorher
* hingen sie fest auf 1.52 m — bei den jetzt deutlich grösseren Kreisen sähe
* das aus, als schwebten Köpfe frei im Raum.
*
* Getrennt gehalten werden die Plätze in der Tiefe nicht über die Höhe,
* sondern seitlich: die Werte in `meetingRoomSeats` sind so gewählt, dass sich
* im Bild keine zwei Kreise überschneiden.
*/
headGap: 0.05,
} as const
+624
View File
@@ -0,0 +1,624 @@
import * as THREE from 'three'
import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
import { SeatSide } from './meetingRoomSeats'
import type { MeetingSeat } from './meetingRoomSeats'
import baselPanorama from '../../assets/staff/basel-panorama.jpg'
/**
* Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
*
* Bewusst ohne React: hier entsteht nur der three.js-Graph. Die Komponente
* daneben kümmert sich um Einbettung, Grösse, Maus und Beschriftungen. Diese
* Trennung hält beide Teile lesbar — eine Datei mit Szene *und* Reactzustand
* wäre unübersichtlich und schwer zu verändern.
*
* Die Kamera sitzt auf Augenhöhe einer sitzenden Person am Kopfende des
* Tisches und ist fest: keine Orbit-Steuerung, keine freie Navigation (§20).
* Der Tisch läuft von ihr weg in den Raum, links und rechts sitzen die
* Agenten, am gegenüberliegenden Kopfende Thomas.
*
* Es wird **nicht** dauernd gerendert. Die Komponente ruft `render()` nur bei
* Grössenänderung, Mausbewegung und Zustandswechsel auf — ein Dauerloop wäre
* für eine statische Szene verschwendete Rechenzeit (§21).
*/
/** Ein anklickbarer Platz samt seiner Teile in der Szene. */
export interface SeatHandle {
seat: MeetingSeat
/** Unsichtbarer Trefferkörper für den Raycaster. */
hitBox: THREE.Mesh
/** Ring hinter dem Porträt — wird bei Hover golden. */
ring: THREE.Mesh
/** Gruppe aus Kopf, Ring und Körper; wird bei Hover leicht angehoben. */
group: THREE.Group
/** Weltposition für die schwebende Beschriftung (über dem Kopf). */
labelAnchor: THREE.Vector3
}
export interface MeetingRoomScene {
scene: THREE.Scene
camera: THREE.PerspectiveCamera
renderer: THREE.WebGLRenderer
seats: SeatHandle[]
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
applyParallax: (dx: number, dy: number) => void
setSize: (width: number, height: number) => void
render: () => void
setHovered: (id: string | null) => void
dispose: () => void
}
// ── Bausteine ────────────────────────────────────────────────────────────────
function addRoomShell(scene: THREE.Scene) {
const { width, height, depth } = ROOM
const halfW = width / 2
// Boden — ein leichter Verlauf über zwei Flächen wäre teuer; ein matter Ton
// mit heller Kante zur Fensterseite genügt für die Tiefenwirkung.
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(width, depth),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.floor }),
)
floor.rotation.x = -Math.PI / 2
floor.position.set(0, 0, -depth / 2)
scene.add(floor)
// Lichtstreifen auf dem Boden entlang der Glasfront
const sheen = new THREE.Mesh(
new THREE.PlaneGeometry(1.6, depth),
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.floorSheen, transparent: true, opacity: 0.5 }),
)
sheen.rotation.x = -Math.PI / 2
sheen.position.set(-halfW + 1.1, 0.004, -depth / 2)
scene.add(sheen)
const ceiling = new THREE.Mesh(
new THREE.PlaneGeometry(width, depth),
// Unbeleuchtet: eine Lambertfläche, die nach unten schaut, bekommt von
// Licht aus dem Raum nichts ab und säuft zu Grau ab.
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.ceiling }),
)
ceiling.rotation.x = Math.PI / 2
ceiling.position.set(0, height, -depth / 2)
scene.add(ceiling)
// Deckenschiene über dem Tisch — gibt der Decke Struktur ohne Dekoration
const rail = new THREE.Mesh(
new THREE.BoxGeometry(0.14, 0.035, depth * 0.66),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.ceilingRail }),
)
rail.position.set(0, height - 0.05, -depth / 2)
scene.add(rail)
// ── Rechte Wand: geschlossen (§3) ──
const wall = new THREE.Mesh(
new THREE.PlaneGeometry(depth, height),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wall }),
)
wall.rotation.y = -Math.PI / 2
wall.position.set(halfW, height / 2, -depth / 2)
scene.add(wall)
// Eichenpaneel im unteren Drittel
const panel = new THREE.Mesh(
new THREE.PlaneGeometry(depth, 0.95),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallPanel }),
)
panel.rotation.y = -Math.PI / 2
panel.position.set(halfW - 0.01, 0.475, -depth / 2)
scene.add(panel)
// Schmaler Markenstreifen — der einzige Markenbezug im Raum
const stripe = new THREE.Mesh(
new THREE.PlaneGeometry(depth, 0.03),
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.brandStripe }),
)
stripe.rotation.y = -Math.PI / 2
stripe.position.set(halfW - 0.02, 0.99, -depth / 2)
scene.add(stripe)
const skirting = new THREE.Mesh(
new THREE.PlaneGeometry(depth, 0.08),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
)
skirting.rotation.y = -Math.PI / 2
// Eine Idee vor dem Paneel: auf gleicher Höhe streiten sich die beiden
// Flächen um dieselben Bildpunkte, und die Kante franst sichtbar aus.
skirting.position.set(halfW - 0.015, 0.04, -depth / 2)
scene.add(skirting)
}
/**
* Basler Fernkulisse (Runde 6, §2).
*
* **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.
*
* 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, maxAnisotropy: number) {
/** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */
const radius = 62
/**
* Masse der Panoramadatei. Ändert sich das Bild, gehören die Zahlen
* nachgeführt — sonst zieht der Zylinder es in die Länge oder staucht es.
*/
const panorama = { breite: 1028, hoehe: 660 }
/**
* 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.
*
* Der Wert hält die Bildpunkte je Grad konstant: das Panorama ist seit
* Runde 7 schmaler, weil die Fortsetzung nicht mehr überskaliert wird, und
* der Bogen schrumpft im selben Verhältnis mit. Die Frontscheibe zeigt
* dadurch unverändert denselben Ausschnitt in derselben Grösse (§1: der
* Gesamteindruck bleibt, nur die Unsauberkeiten verschwinden).
*/
const arc = 1.514
/** Höhe passend zum Seitenverhältnis des Panoramas. */
const height = (radius * arc) / (panorama.breite / panorama.hoehe)
/** Wasserlinie im Panorama, als Anteil von oben. */
const waterFraction = 400 / panorama.hoehe
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
const waterY = -7.5
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
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.45 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). */
function addGlassFacades(scene: THREE.Scene) {
const { width, height, depth } = ROOM
const halfW = width / 2
const glassMat = new THREE.MeshBasicMaterial({
color: ROOM_COLORS.glass,
transparent: true,
opacity: 0.07,
side: THREE.DoubleSide,
depthWrite: false,
})
const frameMat = new THREE.MeshLambertMaterial({ color: ROOM_COLORS.mullion })
// Gegenüberliegende Front
const frontGlass = new THREE.Mesh(new THREE.PlaneGeometry(width, height), glassMat)
frontGlass.position.set(0, height / 2, -depth)
scene.add(frontGlass)
// Linke Front
const leftGlass = new THREE.Mesh(new THREE.PlaneGeometry(depth, height), glassMat)
leftGlass.rotation.y = Math.PI / 2
leftGlass.position.set(-halfW, height / 2, -depth / 2)
scene.add(leftGlass)
/** Senkrechter Pfosten. */
function post(x: number, y: number, z: number, rotY = 0) {
const m = new THREE.Mesh(new THREE.BoxGeometry(0.07, height, 0.09), frameMat)
m.position.set(x, y, z)
m.rotation.y = rotY
scene.add(m)
}
/** Waagrechter Riegel. */
function rail(len: number, x: number, y: number, z: number, rotY = 0) {
const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.07, 0.09), frameMat)
m.position.set(x, y, z)
m.rotation.y = rotY
scene.add(m)
}
// Pfosten der Frontscheibe — Thomas sitzt zwischen zwei Feldern, damit die
// Stadtsilhouette rund um ihn sichtbar bleibt (§3 «rund um ihn herum»).
for (const x of [-halfW, -1.5, 1.5, halfW]) post(x, height / 2, -depth)
rail(width, 0, height - 0.035, -depth)
rail(width, 0, 0.035, -depth)
rail(width, 0, 2.28, -depth)
// Pfosten der linken Front, alle 2 m
for (let z = 0; z >= -depth; z -= 2) post(-halfW, height / 2, z, Math.PI / 2)
rail(depth, -halfW, height - 0.035, -depth / 2, Math.PI / 2)
rail(depth, -halfW, 0.035, -depth / 2, Math.PI / 2)
rail(depth, -halfW, 2.28, -depth / 2, Math.PI / 2)
}
/** Meetingtisch samt Sockel und Kontaktschatten (§5). */
function addTable(scene: THREE.Scene) {
const length = TABLE.zNear - TABLE.zFar
const zMid = (TABLE.zNear + TABLE.zFar) / 2
const top = new THREE.Mesh(
new THREE.BoxGeometry(TABLE.width, TABLE.thickness, length),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableTop }),
)
top.position.set(0, TABLE.top, zMid)
scene.add(top)
// Kante minimal dunkler — lässt die Platte dicker und wertiger wirken
const edge = new THREE.Mesh(
new THREE.BoxGeometry(TABLE.width + 0.02, 0.018, length + 0.02),
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableEdge }),
)
edge.position.set(0, TABLE.top - TABLE.thickness / 2, zMid)
scene.add(edge)
// Zwei Sockelwangen statt vier Beine: verdeckt weniger und wirkt hochwertiger
const baseMat = new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableBase })
for (const z of [zMid - length * 0.28, zMid + length * 0.28]) {
const base = new THREE.Mesh(new THREE.BoxGeometry(TABLE.width * 0.55, TABLE.top - 0.05, 0.12), baseMat)
base.position.set(0, (TABLE.top - 0.05) / 2, z)
scene.add(base)
}
// Kontaktschatten auf dem Boden — ohne ihn schwebt der Tisch
const shadow = new THREE.Mesh(
new THREE.PlaneGeometry(TABLE.width + 1.0, length + 0.8),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.28 }),
)
shadow.rotation.x = -Math.PI / 2
shadow.position.set(0, 0.006, zMid)
scene.add(shadow)
}
/**
* Nachschärfen nach dem Verkleinern (Unschärfemaske).
*
* Beim Herunterrechnen eines Fotos verschwimmen Kanten — Wimpern, Brillenrand,
* einzelne Haare. Das Bild minus seiner weichgezeichneten Fassung ergibt genau
* diese Kantenzeichnung; verstärkt zurückgegeben holt sie die Zeichnung wieder
* heran. Jedes Bildbearbeitungsprogramm macht das nach einer Verkleinerung.
*
* Die Stärke richtet sich danach, wie weit verkleinert wurde: wer kaum
* verkleinert, braucht kaum Schärfung, sonst wirkt das Ergebnis überzeichnet.
*/
function schaerfeNach(g: CanvasRenderingContext2D, flaeche: HTMLCanvasElement, ziel: number, kante: number) {
const staerke = Math.min(0.6, Math.max(0, (kante / ziel - 1) * 0.5))
if (staerke < 0.05) return
const weich = document.createElement('canvas')
weich.width = ziel
weich.height = ziel
const wg = weich.getContext('2d')
if (!wg) return
wg.filter = 'blur(0.8px)'
wg.drawImage(flaeche, 0, 0)
const scharf = g.getImageData(0, 0, ziel, ziel)
const unscharf = wg.getImageData(0, 0, ziel, ziel)
const a = scharf.data
const b = unscharf.data
for (let i = 0; i < a.length; i += 4) {
for (let c = 0; c < 3; c++) {
const wert = a[i + c] + staerke * (a[i + c] - b[i + c])
a[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert
}
}
g.putImageData(scharf, 0, 0)
}
/**
* Porträt laden und dabei auf seine Anzeigegrösse bringen (Runde 7, Nachtrag).
*
* Die Vorlagen haben 512 Bildpunkte, die Kreise erscheinen je nach Platz aber
* nur 170 bis 330 gross. Bei so starker Verkleinerung greift die Grafikkarte
* nicht auf die Vorlage zurück, sondern auf eine vorberechnete, halb oder
* viertel so grosse Zwischenfassung. Das Ergebnis sind weiche Gesichter,
* am deutlichsten bei den weiter hinten sitzenden Agenten.
*
* Wird die Textur vorab auf ihre Anzeigegrösse gerechnet, entfällt dieser
* Zwischenschritt. Verkleinert wird einmalig auf einer Zeichenfläche, und die
* rechnet dabei über alle Quellpunkte — deutlich sorgfältiger als die schnelle
* Zwischenfassung der Grafikkarte.
*
* Zugleich entsteht hier der mittige quadratische Ausschnitt. Ohne ihn zöge die
* Kreisfläche eine liegende Vorlage in die Breite.
*
* Geladen wird bewusst von Hand statt über `TextureLoader`: dessen Rückruf
* liefert das fertige Bild nicht zuverlässig, und ohne Bildmasse lässt sich
* weder zuschneiden noch massgerecht verkleinern. Die Zählung beim
* Ladeverwalter bleibt erhalten, damit die Szene nach dem letzten Bild wie
* bisher einmal neu zeichnet.
*/
function ladePortraet(url: string, seat: MeetingSeat, leinwandHoehe: number, maxAnisotropy: number) {
const tex = new THREE.Texture()
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 verwalter = THREE.DefaultLoadingManager
verwalter.itemStart(url)
const bild = new Image()
bild.onload = () => {
const kante = Math.min(bild.width, bild.height)
// Sichtbarer Durchmesser des Kreises in Bildpunkten der Leinwand
const abstand = Math.hypot(seat.x, CAMERA.z - seat.z)
const durchmesser = (seat.radius * leinwandHoehe) / (abstand * Math.tan((CAMERA.fov / 2) * Math.PI / 180))
// Etwas Reserve für Fenster, die grösser sind als das aktuelle
const ziel = Math.round(Math.min(kante, Math.max(160, durchmesser * 1.35)))
const flaeche = document.createElement('canvas')
flaeche.width = ziel
flaeche.height = ziel
const g = flaeche.getContext('2d')
if (g) {
g.imageSmoothingEnabled = true
g.imageSmoothingQuality = 'high'
g.drawImage(bild, (bild.width - kante) / 2, (bild.height - kante) / 2, kante, kante, 0, 0, ziel, ziel)
schaerfeNach(g, flaeche, ziel, kante)
tex.image = flaeche
} else {
tex.image = bild
}
tex.needsUpdate = true
verwalter.itemEnd(url)
}
bild.onerror = () => {
verwalter.itemError(url)
verwalter.itemEnd(url)
}
bild.src = url
return tex
}
/**
* Ein Agent am Tisch (§3).
*
* 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).
*
* 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,
seat: MeetingSeat,
photoUrl: string | undefined,
maxAnisotropy: number,
leinwandHoehe: number,
): SeatHandle {
const group = new THREE.Group()
group.position.set(seat.x, 0, seat.z)
const yaw =
seat.side === SeatSide.HEAD ? 0
: seat.side === SeatSide.LEFT ? 0.42
: -0.42
// Radius und Höhe stammen vom Platz: näher sitzende Agenten haben einen
// kleineren Kreis, erscheinen im Bild aber grösser (Runde 7, §2).
const radius = seat.radius
const kopfHoehe = TABLE.top + radius + SEATED.headGap
const head = new THREE.Group()
head.position.y = kopfHoehe
head.rotation.y = yaw
const ring = new THREE.Mesh(
new THREE.CircleGeometry(radius * 1.06, 48),
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
)
ring.position.z = -0.006
head.add(ring)
if (photoUrl) {
const tex = ladePortraet(photoUrl, seat, leinwandHoehe, maxAnisotropy)
const face = new THREE.Mesh(
new THREE.CircleGeometry(radius, 96),
new THREE.MeshBasicMaterial({ map: tex }),
)
head.add(face)
}
group.add(head)
// ── Trefferkörper: gross genug für bequemes Klicken (§9) ──
const hitBox = new THREE.Mesh(
/*
* 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(radius * 1.1, 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 = kopfHoehe
group.add(hitBox)
scene.add(group)
return {
seat,
hitBox,
ring,
group,
// Der Anker steigt mit der Tiefe: weiter hinten sitzende Plaketten hängen
// höher. Ohne diese Staffelung laufen sie am Fluchtpunkt ineinander, weil
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
labelAnchor: new THREE.Vector3(
seat.x,
kopfHoehe + radius + 0.10 + Math.min(0.34, Math.max(0, (Math.abs(seat.z) - 0.9) * 0.06)),
seat.z,
),
}
}
// ── Aufbau ───────────────────────────────────────────────────────────────────
export function createMeetingRoomScene(
canvas: HTMLCanvasElement,
seats: MeetingSeat[],
photos: Record<string, string | undefined>,
): MeetingRoomScene {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
/*
* Mindestens doppelte Auflösung, unabhängig vom Bildschirm.
*
* Wird intern feiner gerechnet als der Bildschirm darstellt, gewinnen alle
* feinen Kanten — Fensterprofile, Kulisse und vor allem die Porträtkreise.
* Das kostet hier wenig: die Szene wird nicht laufend, sondern nur bei
* Grössenänderung, Mausbewegung und Zustandswechsel neu gezeichnet.
*/
renderer.setPixelRatio(Math.min(Math.max(window.devicePixelRatio, 2), 3))
renderer.outputColorSpace = THREE.SRGBColorSpace
const scene = new THREE.Scene()
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, 40, 140)
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300)
camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
camera.lookAt(new THREE.Vector3(...CAMERA.lookAt))
scene.add(new THREE.AmbientLight(0xffffff, 1.45))
// Tageslicht von der linken Glasfront (§4 «klares natürliches Tageslicht»)
const sun = new THREE.DirectionalLight(0xfff6e8, 1.5)
sun.position.set(-6, 5, -2)
scene.add(sun)
// Aufheller von der geschlossenen Wand, damit rechts nichts absäuft
const bounce = new THREE.DirectionalLight(0xdfe6ee, 0.5)
bounce.position.set(5, 2, 3)
scene.add(bounce)
const loader = new THREE.TextureLoader()
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy()
addBaselBackdrop(scene, loader, maxAnisotropy)
addRoomShell(scene)
addGlassFacades(scene)
addTable(scene)
/*
* Höhe der Leinwand in Bildpunkten, aus der die Porträts ihre Auflösung
* ableiten. Zum Zeitpunkt des Aufbaus steht sie noch nicht fest, weil die
* Grösse erst danach gesetzt wird. Der Ausgangswert deckt den üblichen
* Bereich ab; wächst das Fenster später deutlich, werden die Bilder etwas
* vergrössert dargestellt — weit weniger auffällig als eine zu grobe
* Zwischenfassung.
*/
const leinwandHoehe = Math.max(canvas.clientHeight, 700) * renderer.getPixelRatio()
const handles = seats.map(seat =>
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
)
const baseCam = camera.position.clone()
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
function render() {
renderer.render(scene, camera)
}
return {
scene,
camera,
renderer,
seats: handles,
applyParallax(dx, dy) {
camera.position.set(
baseCam.x + dx * CAMERA.parallax,
baseCam.y + dy * CAMERA.parallax * 0.6,
baseCam.z,
)
camera.lookAt(lookTarget)
},
setSize(width, height) {
renderer.setSize(width, height, false)
const aspect = width / Math.max(height, 1)
camera.aspect = aspect
// Schmale Fenster brauchen einen weiteren Bildwinkel: bei festem Winkel
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
// Kamera, bis der Raum wieder hineinpasst.
const weit = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26)
/*
* Nach oben begrenzt durch den waagrechten Bildwinkel. Bei sehr breiten,
* flachen Fenstern — etwa 1280 × 380 auf einem Notebook — griffe sonst ein
* waagrechter Winkel von über 110°: der Raum wirkt gezerrt, und der Blick
* reicht seitlich über den Rand des Panoramas hinaus.
*/
const maxTan = Math.tan((CAMERA.maxHFov / 2) * Math.PI / 180) / aspect
camera.fov = Math.min(weit, 2 * Math.atan(maxTan) * 180 / Math.PI)
camera.updateProjectionMatrix()
},
render,
setHovered(id) {
for (const h of handles) {
const on = h.seat.id === id
;(h.ring.material as THREE.MeshBasicMaterial).color.set(
on ? ROOM_COLORS.headRingHover : ROOM_COLORS.headRing,
)
// Leichtes Anheben statt Vergrössern: eine skalierte Person wirkt
// sofort falsch, ein Aufrichten liest sich als Zuwendung.
h.group.position.y = on ? 0.035 : 0
}
},
dispose() {
scene.traverse(obj => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose()
const mat = obj.material
const mats = Array.isArray(mat) ? mat : [mat]
for (const m of mats) {
const withMap = m as THREE.Material & { map?: THREE.Texture | null }
withMap.map?.dispose()
m.dispose()
}
}
})
renderer.dispose()
},
}
}
+106
View File
@@ -0,0 +1,106 @@
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
import { AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
/**
* Sitzordnung im 3D-Meetingraum (Runde 6, §5/§6/§7).
*
* Eine einzige Tabelle bestimmt, wer wo sitzt. Namen, Funktionen, Porträts und
* Zielseiten kommen dabei nicht von hier, sondern aus `AGENT_WORKSPACES` —
* derselben zentralen Konfiguration, die Menü und Seitenköpfe verwenden. Hier
* steht ausschliesslich die Geometrie (§24, Schritt 7: nichts doppelt pflegen).
*
* Koordinaten sind Meter im Weltsystem der Szene:
* - x: negativ = links vom Betrachter, positiv = rechts
* - z: 0 am Kopfende des Betrachters, negativ = in den Raum hinein
*
* Die Agenten wechseln die Tischseite in der Reihenfolge der Konfiguration
* (Ferdi links, Bruno rechts, Livia links, …). Das verteilt sie gleichmässig,
* hält die Abstände gross genug, dass sich niemand überlappt, und macht den
* Arbeitsablauf auch am Tisch ablesbar.
*/
/** Auf welcher Tischseite ein Platz liegt. */
export const SeatSide = {
LEFT: 'LEFT',
RIGHT: 'RIGHT',
/** Kopfende gegenüber dem Betrachter — nur Thomas. */
HEAD: 'HEAD',
} as const
export type SeatSide = typeof SeatSide[keyof typeof SeatSide]
export interface MeetingSeat {
/** Agenten-ID; identisch mit `AGENT_PHOTOS` und den Mockdaten. */
id: string
name: string
role: string
/** Zielseite des Klicks. */
path: string
side: SeatSide
/** Sitzposition in Metern. */
x: number
z: number
/** Radius des Porträtkreises in Metern. */
radius: number
}
/** Ein Platz, so wie er unten eingetragen wird. */
type Platz = { side: SeatSide; x: number; z: number; radius: number }
/**
* Sitzplätze der fünf Agenten, in Metern (Runde 6 §3.2, Runde 7 §2).
*
* Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links,
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dahinter
* staffeln sich die übrigen drei.
*
* Die Radien wachsen nach hinten, die **Bildgrösse** nimmt trotzdem mit der
* Tiefe ab: ein Kreis von 0.30 m in 5 m Entfernung erscheint kleiner als einer
* von 0.265 m in 3 m. Ohne diese Gegensteuerung wären die hinteren Porträts bei
* gleichem Radius kaum noch erkennbar (§2: «tiefenlogisch», aber «insgesamt klar
* besser sichtbar»).
*
* Alle Werte sind gemeinsam gewählt, nicht einzeln: mit den Kamerawerten aus
* `meetingRoomPalette` durchgerechnet ergeben sie gegenüber dem Stand vor dieser
* Runde die Faktoren 2.11× (Ferdi/Bruno), 2.22× (Livia/Nora), 2.50× (Sina) und
* 3.18× (Thomas) — und dabei **keine** einzige Überschneidung zweier Kreise im
* Bild. Wer einen Wert ändert, verschiebt beides; die Kreise überlappen dann
* schnell wieder.
*/
const PLAETZE: Record<string, Platz> = {
ferdi: { side: SeatSide.LEFT, x: -1.16, z: -0.88, radius: 0.268 },
bruno: { side: SeatSide.RIGHT, x: 1.16, z: -0.88, radius: 0.268 },
livia: { side: SeatSide.LEFT, x: -1.32, z: -3.35, radius: 0.345 },
nora: { side: SeatSide.RIGHT, x: 1.32, z: -3.35, radius: 0.345 },
sina: { side: SeatSide.LEFT, x: -0.95, z: -5.50, radius: 0.395 },
}
/**
* Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt
* lautlos im Nichts.
*/
const FALLBACK: Platz = { side: SeatSide.RIGHT, x: 0.95, z: -5.50, radius: 0.395 }
/**
* Kopfende gegenüber. Position unverändert, nur der Kreis wächst kräftig: von
* dort aus ist der Abstand am grössten, und Thomas war zuvor kaum auszumachen
* (§2). Grösser darf er nicht werden — sein Kreis überträfe im Bild sonst den
* von Sina, die näher sitzt, und die Tiefenwirkung kippte.
*/
const KOPFENDE: Platz = { side: SeatSide.HEAD, x: 0, z: -6.75, radius: 0.452 }
export const MEETING_SEATS: MeetingSeat[] = [
...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,
role: AGENT_SUPERVISOR.role,
path: AGENT_SUPERVISOR.path,
...KOPFENDE,
},
]
-32
View File
@@ -1,32 +0,0 @@
import buero from '../../assets/staff/buero.jpg'
/**
* Umgebung der Belegschaftsansicht auf der Startseite (Runde 5, §3).
*
* Genau eine Stelle bestimmt, welches Bild hinter den Personenkarten liegt.
* Ein Austausch ist damit ein Einzeiler; die Herkunft steht in `HERKUNFT.md`
* neben der Bilddatei.
*
* Das Bild ist Umgebung, nicht Motiv: es zeigt ein Büro, damit die digitalen
* Mitarbeitenden einen Arbeitsplatz haben statt eines leeren Hintergrunds. Der
* Verlauf darüber nimmt ihm den grössten Teil des Kontrasts — sonst
* konkurriert jeder Bürostuhl mit den Gesichtern und der Schrift davor.
*/
export interface RoomBackdrop {
src: string
/** Bildausschnitt bei `object-fit: cover`. */
focus: string
/** Verlauf über dem Bild: Lesbarkeit und Übergang zur Objektliste darunter. */
scrim: string
}
export const ROOM_BACKDROP: RoomBackdrop = {
src: buero,
focus: '50% 58%',
scrim: [
// Grundton: das Büro tritt weit zurück, die Karten treten hervor.
'linear-gradient(180deg, rgba(9,14,23,0.84) 0%, rgba(9,14,23,0.76) 52%, rgba(9,14,23,0.92) 100%)',
// Warmes Streiflicht von links, damit die Fläche nicht tot wirkt.
'radial-gradient(85% 120% at 10% 6%, rgba(184,151,90,0.18) 0%, rgba(184,151,90,0) 58%)',
].join(', '),
}
+6
View File
@@ -50,6 +50,11 @@ import gian from '../../assets/team/gian.jpg'
import ida from '../../assets/team/ida.jpg'
import jana from '../../assets/team/jana.jpg'
import zeno from '../../assets/team/zeno.jpg'
// Thomas ist in Runde 6 dazugekommen und hat kein Porträt aus dem
// Konzeptdokument. Statt den Kopf eines bestehenden Mitarbeitenden
// zweitzuverwenden — jeder Kopf gehört genau einer Person — trägt er ein eigenes
// Porträt. Herkunft und Lizenz stehen in `assets/team/HERKUNFT.md`.
import thomas from '../../assets/team/thomas.jpg'
export const AGENT_PHOTOS: Record<string, string | undefined> = {
bruno,
@@ -86,4 +91,5 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
ida,
jana,
zeno,
thomas,
}
-44
View File
@@ -1,44 +0,0 @@
import type { AgentWorkload } from './agentWorkload'
/**
* Property On — ein digitaler Mitarbeitender, so wie er auf der Startseite
* auftritt (Runde 5).
*
* Zusammengesetzt aus drei Quellen, weil Menschlichkeit aus dem Zusammenspiel
* entsteht und nicht aus einem einzelnen Feld:
*
* - **Personalien** — Name, Funktion, Abteilung, Personalnummer. Wer eine
* Personalnummer hat, ist Personal und kein Werkzeug.
* - **Auslastung** — woran er gerade sitzt, in seinen eigenen Worten.
* - **Letzte Handlung** — was er zuletzt getan hat, mit Zeitangabe. Das ist
* der Beleg, dass wirklich jemand arbeitet; ohne ihn bleibt jede Zusage
* eine Behauptung.
*/
export interface AgentPresence {
agentId: string
name: string
role: string
/** Abteilung laut Personalblatt; leer, solange kein Dossier vorliegt. */
department: string
/** Personalnummer laut Personalblatt; leer, solange kein Dossier vorliegt. */
personnelNumber: string
/** Zielseite des Klicks auf die Karte. */
path: string
/** true, solange der Mitarbeitende nicht pausiert ist. */
onDuty: boolean
/**
* Was der Mitarbeitende selbst entscheiden darf — «Autonom», «Freigabe
* erforderlich» oder «Nur Vorschlag». Die Stufe ist das stärkste Merkmal des
* Ansatzes: sie sagt, wie viel Verantwortung jemand trägt, und trennt
* Personal von einer Automatisierung.
*/
autonomy: string
workload: AgentWorkload
/** Zuletzt protokollierte Handlung — fehlt, wenn noch nichts vorliegt. */
lastAction?: {
/** Was getan wurde, als ganzer Satz aus dem Protokoll. */
title: string
/** Wann, in Alltagssprache: «vor 12 Min.», «gestern 16:40». */
when: string
}
}
-25
View File
@@ -1,25 +0,0 @@
import { useMemo } from 'react'
import { useTeamAgents } from './useTeamAgents'
import { useAgentProtocol } from './useAgentProtocol'
import { useAgentWorkload } from './useAgentWorkload'
import { buildAgentPresence } from '../services/agentPresenceService'
import type { AgentPresence } from '../domain/agentPresence'
/**
* Die Belegschaft, wie sie auf der Startseite auftritt (Runde 5).
*
* Der Hook sammelt Personalblätter, Protokoll und Auslastung ein; was daraus
* wird, entscheidet `agentPresenceService` (CLAUDE.md §4.2).
*/
export function useAgentPresence(): { data: AgentPresence[]; isLoading: boolean } {
const { data: agents = [], isLoading: agentsLoading } = useTeamAgents()
const { data: protocol = [], isLoading: protocolLoading } = useAgentProtocol()
const { data: workload, isLoading: workloadLoading } = useAgentWorkload()
const data = useMemo(
() => buildAgentPresence({ agents, protocol, workload }),
[agents, protocol, workload],
)
return { data, isLoading: agentsLoading || protocolLoading || workloadLoading }
}
+18
View File
@@ -33,3 +33,21 @@ export const AGENT_WORKSPACES: AgentWorkspace[] = [
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
return AGENT_WORKSPACES.find(a => a.id === id)
}
/**
* Thomas — Personalverwalter und Vorgesetzter der fünf Agenten (Runde 6, §7).
*
* Bewusst nicht Teil von `AGENT_WORKSPACES`: Thomas hat keine eigene
* Arbeitsseite, sondern steht für die Adminfläche selbst. Sein Ziel ist «Meine
* Agenten» mit den Bereichen Personalverwaltung, Kanäle & Systeme und
* Bearbeitungsverlauf. Stünde er in derselben Liste, erschiene er als sechster
* Eintrag im Menü und als sechster Mitarbeitender in der Belegschaft — beides
* wäre falsch (§13: «Thomas muss nicht zwingend nochmals als normaler
* Unteragent dargestellt werden»).
*/
export const AGENT_SUPERVISOR: AgentWorkspace = {
id: 'thomas',
name: 'Thomas',
role: 'Personalverwalter',
path: ROUTES.SUPPLY.TEAM,
}
+6 -5
View File
@@ -4,7 +4,7 @@ import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui
import { ChevronDown, ChevronUp } from 'lucide-react'
import { PageHeader } from '../../components/layout'
import { ViewToggle } from '../../components/shared'
import { AgentStaffOverview } from '../../components/staff'
import { MeetingRoom3D } from '../../components/staff'
import { useProperties } from '../../hooks/useProperties'
import { useActiveInquiries } from '../../hooks/useInquiries'
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
@@ -95,13 +95,14 @@ export default function Properties() {
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
return (
// Runde 5, §2.3: Die Startseite scrollt als Ganzes. Die Belegschaft steht
// oben und misst sich an der Viewport-Höhe; Titel und Filterbalken bleiben
// beim Einstieg gerade noch sichtbar, die Objektliste beginnt darunter.
// Runde 6, §1.1/§17: Die Startseite ist die Landing Page und scrollt als
// Ganzes. Oben der Meetingraum, darunter «Meine Objekte» mit dem
// unveränderten Objektbestand. Der Meetingraum bemisst seine Höhe so, dass
// Titel und Filterbalken beim Einstieg gerade noch sichtbar bleiben.
// Ein eigener Scrollbereich für die Tabelle wäre ein zweiter Scrollbalken
// im selben Bild und würde genau das verhindern.
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
<AgentStaffOverview />
<MeetingRoom3D />
<PageHeader
title={MY_PROPERTIES_LABEL}
@@ -1,130 +0,0 @@
/**
* Property On — Belegschaftsansicht der Startseite (Runde 5).
*
* Hier steht, was die digitalen Mitarbeitenden menschlich macht: dass sie eine
* Reihenfolge haben, Personalien tragen, einen Dienstzustand zeigen und
* belegen können, was sie zuletzt getan haben. Jede dieser Zusicherungen kann
* still brechen, ohne dass die Oberfläche kaputt aussieht — eine leere
* Personalnummer fällt niemandem auf.
*/
import { describe, it, expect } from 'vitest'
import { buildAgentPresence, formatRelativeTime } from '../agentPresenceService'
import type { AgentPresenceInput } from '../agentPresenceService'
import { AgentStatus } from '../../domain/teamAgent'
import { WorkloadTone } from '../../domain/agentWorkload'
import type { AgentWorkload } from '../../domain/agentWorkload'
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
const MINUTE = 60 * 1000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const ago = (ms: number) => new Date(NOW - ms).toISOString()
function workload(agentId: string): AgentWorkload {
return { agentId, count: 0, label: 'nichts offen', tone: WorkloadTone.CALM, voice: 'Alles ruhig.' }
}
const WORKLOAD = Object.fromEntries(
['ferdi', 'bruno', 'livia', 'nora', 'sina'].map(id => [id, workload(id)]),
)
function input(partial: Partial<AgentPresenceInput> = {}): AgentPresenceInput {
return { agents: [], protocol: [], workload: WORKLOAD, ...partial }
}
// Nur die Felder, die der Dienst liest.
const dossier = (id: string, extra: Record<string, unknown> = {}) =>
({
id,
department: 'Bewirtschaftung',
personnelNumber: 'PO-TEST-01',
status: AgentStatus.ACTIVE,
autonomyLevel: 'AUTONOMOUS',
...extra,
}) as AgentPresenceInput['agents'][number]
const entry = (agentId: string, msAgo: number, title: string) =>
({ agentId, timestamp: ago(msAgo), title }) as AgentPresenceInput['protocol'][number]
describe('Zeitangabe in Alltagssprache', () => {
it('nennt frische Ereignisse in Minuten', () => {
expect(formatRelativeTime(ago(12 * MINUTE), NOW)).toBe('vor 12 Min.')
})
it('wechselt nach einer Stunde auf Stunden, mit korrekter Einzahl', () => {
expect(formatRelativeTime(ago(1 * HOUR + MINUTE), NOW)).toBe('vor 1 Stunde')
expect(formatRelativeTime(ago(5 * HOUR), NOW)).toBe('vor 5 Stunden')
})
it('nennt ab gestern die Uhrzeit — «vor 19 Stunden» ordnet niemand einem Arbeitstag zu', () => {
expect(formatRelativeTime(ago(30 * HOUR), NOW)).toMatch(/^gestern \d{2}:\d{2}$/)
})
it('nennt weiter zurückliegende Ereignisse mit Datum', () => {
expect(formatRelativeTime(ago(9 * DAY), NOW)).toMatch(/^\d{2}\.\d{2}\. \d{2}:\d{2}$/)
})
it('bleibt bei unbrauchbarem Zeitstempel stumm statt «Invalid Date» zu zeigen', () => {
expect(formatRelativeTime('kein datum', NOW)).toBe('')
})
})
describe('Belegschaft der Startseite', () => {
it('führt die fünf Agenten in verbindlicher Reihenfolge', () => {
const staff = buildAgentPresence(input(), NOW)
expect(staff.map(p => p.agentId)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina'])
})
it('übernimmt Personalien aus dem Personalblatt', () => {
const staff = buildAgentPresence(
input({ agents: [dossier('ferdi', { department: 'Bewirtschaftung', personnelNumber: 'PO-ZD-02' })] }),
NOW,
)
const ferdi = staff.find(p => p.agentId === 'ferdi')!
expect(ferdi.department).toBe('Bewirtschaftung')
expect(ferdi.personnelNumber).toBe('PO-ZD-02')
expect(ferdi.autonomy).toBe('Autonom')
})
it('zeigt einen pausierten Mitarbeitenden als nicht im Dienst', () => {
const staff = buildAgentPresence(
input({ agents: [dossier('sina', { status: AgentStatus.PAUSED })] }),
NOW,
)
expect(staff.find(p => p.agentId === 'sina')!.onDuty).toBe(false)
// Wer kein Dossier hat, gilt als im Dienst — sonst stünde die halbe
// Belegschaft nur deshalb still, weil Stammdaten fehlen.
expect(staff.find(p => p.agentId === 'bruno')!.onDuty).toBe(true)
})
it('nimmt je Mitarbeitendem den jüngsten Protokolleintrag', () => {
const staff = buildAgentPresence(
input({
protocol: [
entry('livia', 5 * DAY, 'Alter Eintrag'),
entry('livia', 20 * MINUTE, 'Jüngster Eintrag'),
entry('livia', 2 * DAY, 'Mittlerer Eintrag'),
],
}),
NOW,
)
const livia = staff.find(p => p.agentId === 'livia')!
expect(livia.lastAction?.title).toBe('Jüngster Eintrag')
expect(livia.lastAction?.when).toBe('vor 20 Min.')
})
it('lässt die letzte Handlung offen, statt eine zu erfinden', () => {
const staff = buildAgentPresence(input(), NOW)
expect(staff.every(p => p.lastAction === undefined)).toBe(true)
})
it('ignoriert Protokolleinträge ohne Zuordnung — sie gehören keinem Mitarbeitenden', () => {
const staff = buildAgentPresence(
input({ protocol: [{ timestamp: ago(MINUTE), title: 'Verbindung entfernt' } as AgentPresenceInput['protocol'][number]] }),
NOW,
)
expect(staff.every(p => p.lastAction === undefined)).toBe(true)
})
})
-95
View File
@@ -1,95 +0,0 @@
import type { AgentPresence } from '../domain/agentPresence'
import type { AgentWorkload } from '../domain/agentWorkload'
import type { AgentProtocolEntry } from '../domain/agentProtocol'
import type { TeamAgent } from '../domain/teamAgent'
import { AgentStatus } from '../domain/teamAgent'
import { AGENT_WORKSPACES } from '../lib/agentWorkspaces'
import { AGENT_AUTONOMY_LABELS } from '../lib/constants'
/**
* Setzt die Belegschaftsansicht der Startseite zusammen (Runde 5).
*
* Reine Geschäftslogik ausserhalb von React (CLAUDE.md §4.2): welcher Eintrag
* als «letzte Handlung» gilt und wie eine Zeitangabe in Alltagssprache lautet,
* sind fachliche Festlegungen. Der Stichzeitpunkt ist ein Parameter — dieselbe
* Datenlage ergibt damit immer dasselbe Ergebnis und lässt sich prüfen.
*/
const MINUTE = 60 * 1000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/**
* Zeitangabe in Alltagssprache.
*
* Bewusst grob: «vor 12 Min.» ist für einen Menschen brauchbarer als
* «14:32:07». Ab gestern steht die Uhrzeit dabei, weil «vor 19 Stunden»
* niemand in einen Arbeitstag einordnen kann.
*/
export function formatRelativeTime(iso: string, now: number): string {
const t = new Date(iso).getTime()
if (!Number.isFinite(t)) return ''
const diff = now - t
if (diff < 0) return 'gerade eben'
if (diff < MINUTE) return 'gerade eben'
if (diff < HOUR) return `vor ${Math.floor(diff / MINUTE)} Min.`
if (diff < DAY) {
const hours = Math.floor(diff / HOUR)
return `vor ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
}
const date = new Date(t)
const time = date.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
if (diff < 2 * DAY) return `gestern ${time}`
return date.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ` ${time}`
}
export interface AgentPresenceInput {
agents: TeamAgent[]
protocol: AgentProtocolEntry[]
workload: Record<string, AgentWorkload>
}
/**
* @param now Stichzeitpunkt in Millisekunden — als Parameter, nicht aus der
* Systemuhr, damit das Ergebnis reproduzierbar bleibt.
*/
export function buildAgentPresence(
{ agents, protocol, workload }: AgentPresenceInput,
now: number = Date.now(),
): AgentPresence[] {
// Jüngster Protokolleintrag je Mitarbeitendem. Ein Durchgang über die Liste
// statt einer Suche je Agent — das Protokoll wächst, die Belegschaft nicht.
const latest = new Map<string, AgentProtocolEntry>()
for (const entry of protocol) {
if (!entry.agentId) continue
const known = latest.get(entry.agentId)
if (!known || entry.timestamp > known.timestamp) latest.set(entry.agentId, entry)
}
const byId = new Map(agents.map(a => [a.id, a]))
const roster = AGENT_WORKSPACES
return roster.map(member => {
const dossier = byId.get(member.id)
const action = latest.get(member.id)
return {
agentId: member.id,
name: member.name,
role: member.role,
department: dossier?.department ?? '',
personnelNumber: dossier?.personnelNumber ?? '',
path: member.path,
// Ohne Personalblatt gilt jemand als im Dienst: die Belegschaftsansicht
// soll niemanden fälschlich als pausiert zeigen, nur weil ein Dossier fehlt.
onDuty: dossier ? dossier.status === AgentStatus.ACTIVE : true,
autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : '',
workload: workload[member.id],
lastAction: action
? { title: action.title, when: formatRelativeTime(action.timestamp, now) }
: undefined,
}
})
}