Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55274488d1 | ||
|
|
403161283c | ||
|
|
9e825c04bf | ||
|
|
4349641ee9 | ||
|
|
bf453d2b69 |
No files matched your search
@@ -22,6 +22,7 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router": "^7.15.0",
|
"react-router": "^7.15.0",
|
||||||
|
"three": "^0.185.1",
|
||||||
"zod": "^3.25.17",
|
"zod": "^3.25.17",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
@@ -516,6 +518,13 @@
|
|||||||
"node": ">=20.19.0"
|
"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": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -2060,6 +2076,35 @@
|
|||||||
"@types/react": "*"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.3",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -4092,6 +4144,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "CC0-1.0"
|
"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": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -4805,6 +4864,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router": "^7.15.0",
|
"react-router": "^7.15.0",
|
||||||
|
"three": "^0.185.1",
|
||||||
"zod": "^3.25.17",
|
"zod": "^3.25.17",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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`)
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
# Bildherkunft
|
# 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
|
Weil beide Aufnahmen dieselbe Rheinseite von derselben Uferseite zeigen, ergeben
|
||||||
Startseite. Es wird nie unbearbeitet gezeigt — der Verlauf in `roomBackdrop.ts`
|
sie zusammen einen zusammenhängenden Stadtverlauf: die Wasserlinie beider
|
||||||
nimmt ihm so viel Kontrast, dass die Porträts und die Schrift davor tragen.
|
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).
|
||||||
**Austausch:** Datei ersetzen, Dateinamen beibehalten. Eine eigene Büroaufnahme
|
|
||||||
ist jederzeit vorzuziehen — sie zeigt den echten Arbeitsplatz der Belegschaft.
|
|
||||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 477 KiB |
@@ -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.
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -1,46 +1,35 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router'
|
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 { useLayoutStore } from '../../stores/layoutStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
|
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
|
||||||
import { RightContextPanel } from './RightContextPanel'
|
import { RightContextPanel } from './RightContextPanel'
|
||||||
import { ToastProvider } from '../ui'
|
import { ToastProvider } from '../ui'
|
||||||
import { Sidebar } from './AppShellSidebar'
|
|
||||||
import { TopBar } from './AppShellTopBar'
|
import { TopBar } from './AppShellTopBar'
|
||||||
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getWorkspaceFromPath } from './appShellConfig'
|
import { WORKSPACE_CONFIG, getWorkspaceFromPath } from './appShellConfig'
|
||||||
import { WorkspaceType } from '../../domain/enums'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AppShell
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function AppShell() {
|
||||||
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
|
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
|
||||||
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
|
|
||||||
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
||||||
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
|
|
||||||
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
|
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
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
|
// Arbeitsbereich am Pfad ausrichten
|
||||||
// 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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detected = getWorkspaceFromPath(location.pathname)
|
const detected = getWorkspaceFromPath(location.pathname)
|
||||||
if (detected && detected !== activeWorkspace) {
|
if (detected && detected !== activeWorkspace) {
|
||||||
@@ -48,8 +37,7 @@ export function AppShell() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname, activeWorkspace, setActiveWorkspace])
|
}, [location.pathname, activeWorkspace, setActiveWorkspace])
|
||||||
|
|
||||||
// When the user changes (role switch), redirect to their first allowed workspace
|
// Nach einem Rollenwechsel auf den ersten erlaubten Arbeitsbereich umleiten
|
||||||
// if the current workspace is no longer permitted
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) return
|
if (!currentUser) return
|
||||||
const allowed = currentUser.allowedWorkspaces
|
const allowed = currentUser.allowedWorkspaces
|
||||||
@@ -64,60 +52,15 @@ export function AppShell() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentUser])
|
}, [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 (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
||||||
{/* Desktop: permanent sidebar */}
|
<TopBar pathname={location.pathname} />
|
||||||
{!isMobile && sidebarContent}
|
|
||||||
|
|
||||||
{/* Mobile: temporary drawer */}
|
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
{isMobile && (
|
<Outlet />
|
||||||
<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 component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
|
||||||
<Outlet />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<RightContextPanel />
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<RightContextPanel />
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WelcomeDialog />
|
<WelcomeDialog />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,28 @@
|
|||||||
import { Box, IconButton } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { Menu } from 'lucide-react'
|
|
||||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
import { NotificationButton } from './NotificationButton'
|
import { NotificationButton } from './NotificationButton'
|
||||||
import { UserMenu } from './UserMenu'
|
import { GlobalNavMenu } from './GlobalNavMenu'
|
||||||
import { AgentChatStickyBar } from '../team'
|
import { AgentChatStickyBar } from '../team'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface TopBarProps {
|
export interface TopBarProps {
|
||||||
pathname: string
|
pathname: string
|
||||||
onMenuClick?: () => void
|
|
||||||
isMobile?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kopfzeile der Anwendung.
|
* Kopfzeile der Anwendung.
|
||||||
*
|
*
|
||||||
* Sie trägt nur noch, was global gilt: Mandant, Benachrichtigungen und Konto.
|
* Sie trägt seit Runde 6 die gesamte Navigation: Mandant, Benachrichtigungen
|
||||||
* Das Chip «Verwaltung» und der wiederholte Seitenname sind entfallen — beides
|
* und — an der Stelle des früheren Profilsymbols — ein Menüsymbol, das das
|
||||||
* stand nochmals gross im Inhaltsbereich und kostete nur Höhe. Ohne Trennlinie
|
* globale Klappmenü öffnet (§11, §15). Das Profil ist vollständig in dieses
|
||||||
* geht die Zeile ohne sichtbaren Bruch in den Seiteninhalt über.
|
* Menü gewandert; in der Leiste selbst steht es nicht mehr.
|
||||||
*
|
*
|
||||||
* Seit Runde 5 steht in der freien Mitte der kompakte Agentenchat, sobald der
|
* In der freien Mitte erscheint der kompakte Agentenchat, sobald der
|
||||||
* ausführliche Chatkopf weggescrollt ist (§9). Er liegt im Balken selbst und
|
* ausführliche Chatkopf weggescrollt ist (Runde 5, §9). Er liegt im Balken
|
||||||
* nicht darüber — deshalb kann er Tabellen und Filter nicht verdecken. Der
|
* selbst und nicht darüber — deshalb kann er Tabellen und Filter nicht
|
||||||
* Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt, aber unter
|
* verdecken. Der Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt,
|
||||||
* Menüs, Dialogen und Popovers, die MUI oberhalb davon einhängt (§10).
|
* 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="header"
|
component="header"
|
||||||
@@ -50,24 +39,17 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
|||||||
px: { xs: 1.5, sm: 3 },
|
px: { xs: 1.5, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Links steht nur noch der Menüaufruf auf schmalen Schirmen. */}
|
{/* Links bleibt frei — die Navigation liegt vollständig rechts. */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
<Box sx={{ flexShrink: 0 }} />
|
||||||
{isMobile && (
|
|
||||||
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
|
||||||
<Menu size={20} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<AgentChatStickyBar />
|
<AgentChatStickyBar />
|
||||||
|
|
||||||
{/* Right side */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexShrink: 0 }}>
|
||||||
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||||
<OrganizationContextBadge />
|
<OrganizationContextBadge />
|
||||||
</Box>
|
</Box>
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
<UserMenu />
|
<GlobalNavMenu />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,6 @@ export { AppShell } from './AppShell'
|
|||||||
export { PageHeader } from './PageHeader'
|
export { PageHeader } from './PageHeader'
|
||||||
export { RightContextPanel } from './RightContextPanel'
|
export { RightContextPanel } from './RightContextPanel'
|
||||||
export { CompareTray } from './CompareTray'
|
export { CompareTray } from './CompareTray'
|
||||||
export { UserMenu } from './UserMenu'
|
export { GlobalNavMenu } from './GlobalNavMenu'
|
||||||
export { NotificationButton } from './NotificationButton'
|
export { NotificationButton } from './NotificationButton'
|
||||||
export { OrganizationContextBadge } from './OrganizationContextBadge'
|
export { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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 { MeetingRoom3D } from './MeetingRoom3D'
|
||||||
export { AgentStaffCard } from './AgentStaffCard'
|
export { MEETING_SEATS, SeatSide } from './meetingRoomSeats'
|
||||||
export { ROOM_BACKDROP } from './roomBackdrop'
|
export type { MeetingSeat } from './meetingRoomSeats'
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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(', '),
|
|
||||||
}
|
|
||||||
@@ -50,6 +50,11 @@ import gian from '../../assets/team/gian.jpg'
|
|||||||
import ida from '../../assets/team/ida.jpg'
|
import ida from '../../assets/team/ida.jpg'
|
||||||
import jana from '../../assets/team/jana.jpg'
|
import jana from '../../assets/team/jana.jpg'
|
||||||
import zeno from '../../assets/team/zeno.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> = {
|
export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||||
bruno,
|
bruno,
|
||||||
@@ -86,4 +91,5 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
|||||||
ida,
|
ida,
|
||||||
jana,
|
jana,
|
||||||
zeno,
|
zeno,
|
||||||
|
thomas,
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -33,3 +33,21 @@ export const AGENT_WORKSPACES: AgentWorkspace[] = [
|
|||||||
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
|
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
|
||||||
return AGENT_WORKSPACES.find(a => a.id === id)
|
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,
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui
|
|||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { PageHeader } from '../../components/layout'
|
import { PageHeader } from '../../components/layout'
|
||||||
import { ViewToggle } from '../../components/shared'
|
import { ViewToggle } from '../../components/shared'
|
||||||
import { AgentStaffOverview } from '../../components/staff'
|
import { MeetingRoom3D } from '../../components/staff'
|
||||||
import { useProperties } from '../../hooks/useProperties'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||||
@@ -95,13 +95,14 @@ export default function Properties() {
|
|||||||
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Runde 5, §2.3: Die Startseite scrollt als Ganzes. Die Belegschaft steht
|
// Runde 6, §1.1/§17: Die Startseite ist die Landing Page und scrollt als
|
||||||
// oben und misst sich an der Viewport-Höhe; Titel und Filterbalken bleiben
|
// Ganzes. Oben der Meetingraum, darunter «Meine Objekte» mit dem
|
||||||
// beim Einstieg gerade noch sichtbar, die Objektliste beginnt darunter.
|
// 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
|
// Ein eigener Scrollbereich für die Tabelle wäre ein zweiter Scrollbalken
|
||||||
// im selben Bild und würde genau das verhindern.
|
// im selben Bild und würde genau das verhindern.
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
||||||
<AgentStaffOverview />
|
<MeetingRoom3D />
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={MY_PROPERTIES_LABEL}
|
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||