diff --git a/src/components/workers/WorkersBoard.jsx b/src/components/workers/WorkersBoard.jsx
new file mode 100644
index 0000000..e9f5afd
--- /dev/null
+++ b/src/components/workers/WorkersBoard.jsx
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import Button from '../ui/Button'
+import EmptyState from '../ui/EmptyState'
+import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed'
+import { indexWorkers, reduceEvents } from '../../lib/workersEvents'
+import { pauseLabel } from '../../lib/workersView'
+import WorkerCard from './WorkerCard'
+import WorkerList from './WorkerList'
+import WorkersKpis from './WorkersKpis'
+import WorkersSettingsDialog from './WorkersSettingsDialog'
+import WorkersStage from './WorkersStage'
+
+const RUNNING_TEXT = 'Läuft'
+const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.'
+const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.'
+const NO_REASON_TEXT = 'Grund nicht angegeben'
+
+// Grund der Notbremse für den Satz „Versand gestoppt: . …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“)
+function haltReasonOf(reason) {
+ const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : ''
+ return text || NO_REASON_TEXT
+}
+
+// Inhalt des Reiters „Hochhaus“: Kopfzeile mit Pausenschalter, Kennzahlen, Hochhaus mit Seitenspalte (Karte und Liste).
+// Ohne Auth-Bezug: isAdmin kommt von der Seite. stageRef ist optional (nur der Prüfstand reicht eine durch); ohne eins legt das Board
+// selbst eins an, damit pushEvents die Bühne immer erreicht.
+export default function WorkersBoard({ api, isAdmin, stageRef }) {
+ const ownRef = useRef(null)
+ const ref = stageRef ?? ownRef
+ const aliveRef = useRef(true)
+ const [workers, setWorkers] = useState({})
+ const [selectedId, setSelectedId] = useState(null)
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ const [pausing, setPausing] = useState(false)
+ const [pauseError, setPauseError] = useState('')
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => { aliveRef.current = false }
+ }, [])
+
+ // Neue Ereignisse: Tabelle fortschreiben und der Bühne vorspielen (die Bühne kennt den Rückstau selbst)
+ const onEvents = useCallback((events) => {
+ setWorkers((w) => reduceEvents(w, events))
+ if (ref.current) ref.current.pushEvents(events)
+ }, [ref])
+
+ const { state, error, loaded, refresh } = useWorkersFeed(api, { onEvents })
+ useEffect(() => {
+ if (state) setWorkers(indexWorkers(state))
+ }, [state])
+
+ const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId)
+
+ if (!loaded || !state) {
+ if (error) {
+ return (
+ {error}
}
+ action={}
+ />
+ )
+ }
+ return Wird geladen …
+ }
+
+ const paused = Boolean(state.paused)
+ const worker = selectedId && Object.hasOwn(workers, selectedId) ? workers[selectedId] : undefined
+
+ // Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert, danach wird der Zustand sofort neu geholt
+ const togglePause = async () => {
+ if (pausing) return
+ setPausing(true)
+ setPauseError('')
+ try {
+ await api.pause(!paused)
+ refresh()
+ } catch (err) {
+ if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT)
+ }
+ if (aliveRef.current) setPausing(false)
+ }
+
+ return (
+ <>
+
+ {paused ? PAUSED_TEXT : RUNNING_TEXT}
+
+
+ {isAdmin && }
+
+
+ {pauseError && {pauseError}
}
+ {state.sendingHalted === true && (
+
+ Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
+
+ )}
+ {error && Verbindung gestört: {error} Es wird weiter versucht.
}
+
+
+
+
+
+
+ setSelectedId(null)}
+ />
+
+
+
+
+ {isAdmin && settingsOpen && (
+ setSettingsOpen(false)} onSaved={() => refresh()} />
+ )}
+ >
+ )
+}
diff --git a/src/components/workers/WorkersStage.jsx b/src/components/workers/WorkersStage.jsx
new file mode 100644
index 0000000..eafc9f7
--- /dev/null
+++ b/src/components/workers/WorkersStage.jsx
@@ -0,0 +1,120 @@
+import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
+import Button from '../ui/Button'
+import { ZOOM_STEP, mountTower } from './engine/scene'
+
+const FONT = '600 12px "Pixelify Sans"'
+const REDUCED_MOTION = '(prefers-reduced-motion: reduce)'
+const COARSE_POINTER = '(pointer: coarse)'
+
+// matchMedia, wo es das gibt (sonst null)
+const media = (query) => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(query) : null)
+
+const NO_STATS = { frames: 0, avgDrawMs: 0, zoom: 1 }
+const NO_DEBUG = { pending: 0, agents: [], lift: 'idle', fx: 0 }
+
+// Bühne des Hochhauses: ein Canvas, den die Engine (mountTower) bespielt, und die Zoomknöpfe.
+// state = Zustand der Dienst-API (oder null), selectedId = markierte Figur, onSelect(id | null) kommt nur bei Klicks auf die Leinwand.
+// Handle über ref: { pushEvents(events), locate(id), stats(), debug() }; vor dem Einhängen der Engine sind alle Aufrufe leer.
+// Die Engine wird neu eingehängt, wenn sich „weniger Bewegung“ ändert (Standbild statt Schleife); engineKey zählt die Einhängungen,
+// damit Zustand und Markierung an jede neue Engine gehen (auch bei StrictMode: einhängen, abbauen, einhängen).
+// Im Standbild kostet jeder Aufruf ein Bild: darum kein setState vor der ersten Einhängung, kein select ohne Änderung und
+// kein resize, solange die Breite gleich bleibt.
+export default forwardRef(function WorkersStage({ state, selectedId, onSelect }, ref) {
+ const canvasRef = useRef(null)
+ const handleRef = useRef(null)
+ const onSelectRef = useRef(onSelect)
+ const shownRef = useRef(null) // Markierung, die die aktuelle Engine zeigt (eine neue Engine startet ohne)
+ const [reduceMotion, setReduceMotion] = useState(() => Boolean(media(REDUCED_MOTION)?.matches))
+ const [engineKey, setEngineKey] = useState(0)
+
+ useEffect(() => { onSelectRef.current = onSelect }, [onSelect])
+
+ useImperativeHandle(ref, () => ({
+ pushEvents: (events) => { if (handleRef.current) handleRef.current.pushEvents(events) },
+ locate: (id) => (handleRef.current ? handleRef.current.locate(id) : null),
+ stats: () => (handleRef.current ? handleRef.current.stats() : NO_STATS),
+ debug: () => (handleRef.current ? handleRef.current.debug() : NO_DEBUG),
+ }), [])
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return undefined
+ const handle = mountTower(canvas, {
+ onSelect: (id) => { if (onSelectRef.current) onSelectRef.current(id) },
+ reducedMotion: reduceMotion,
+ coarsePointer: Boolean(media(COARSE_POINTER)?.matches),
+ fontsReady: document.fonts && typeof document.fonts.load === 'function' ? document.fonts.load(FONT) : undefined,
+ dpr: window.devicePixelRatio || 1,
+ })
+ handleRef.current = handle
+ shownRef.current = null
+
+ // mountTower hat schon gemessen; erst eine andere Breite löst resize() aus (der erste Aufruf des Beobachters meldet nur den Ist-Wert)
+ let width = canvas.getBoundingClientRect().width
+ const observer = typeof ResizeObserver === 'function'
+ ? new ResizeObserver(() => {
+ const next = canvas.getBoundingClientRect().width
+ if (next === width) return
+ width = next
+ handle.resize()
+ })
+ : null
+ if (observer) observer.observe(canvas)
+
+ // Versteckter Tab: die Szene ruht. Beim Zurückkehren wird die Regie beruhigt (flush), damit nichts aus der Zeit davor nachläuft.
+ // Die Ereignisse, die inzwischen aufgelaufen sind, holt der Feed selbst und gibt sie danach über pushEvents herein.
+ const onVisibility = () => {
+ const hidden = document.hidden
+ handle.setPaused(hidden)
+ if (!hidden) handle.flush()
+ }
+ document.addEventListener('visibilitychange', onVisibility)
+ if (document.hidden) handle.setPaused(true)
+
+ const motion = media(REDUCED_MOTION)
+ const onMotion = (e) => setReduceMotion(Boolean(e.matches))
+ if (motion) motion.addEventListener('change', onMotion)
+
+ setEngineKey((k) => k + 1)
+
+ return () => {
+ document.removeEventListener('visibilitychange', onVisibility)
+ if (motion) motion.removeEventListener('change', onMotion)
+ if (observer) observer.disconnect()
+ handle.destroy()
+ if (handleRef.current === handle) handleRef.current = null
+ }
+ }, [reduceMotion])
+
+ // engineKey 0 = noch keine Engine eingehängt (der Zähler springt gleich nach dem Einhängen auf 1)
+ useEffect(() => {
+ if (engineKey > 0 && state && handleRef.current) handleRef.current.setState(state)
+ }, [state, engineKey])
+
+ useEffect(() => {
+ const handle = handleRef.current
+ const wanted = selectedId ?? null
+ if (!handle || wanted === shownRef.current) return
+ shownRef.current = wanted
+ handle.select(wanted)
+ }, [selectedId, engineKey])
+
+ const zoomBy = (factor) => { if (handleRef.current) handleRef.current.zoomBy(factor) }
+ const resetView = () => { if (handleRef.current) handleRef.current.resetView() }
+
+ return (
+
+
+
+
+
+
+
+
+ )
+})
diff --git a/tests/unit/workersComponents.test.mjs b/tests/unit/workersComponents.test.mjs
index 9585ea3..775fde5 100644
--- a/tests/unit/workersComponents.test.mjs
+++ b/tests/unit/workersComponents.test.mjs
@@ -35,3 +35,33 @@ test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Be
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
})
+test('WorkersStage: Aufräumen, Sichtbarkeit, Bewegungsreduktion', () => {
+ const s = read('WorkersStage.jsx')
+ assert.match(s, /\.destroy\(\)/); assert.match(s, /visibilitychange/); assert.match(s, /removeEventListener\('visibilitychange'/); assert.match(s, /prefers-reduced-motion: reduce/)
+ assert.match(s, /ResizeObserver/); assert.match(s, /disconnect\(\)/); assert.match(s, /fontsReady/); assert.match(s, /forwardRef/)
+ // Controller-Beschluss: Sichtbarkeit steuert die Szene (setPaused), beim Zurückkehren wird die Regie beruhigt (flush)
+ assert.match(s, /setPaused\(/); assert.match(s, /\.flush\(\)/); assert.match(s, /mountTower\(/); assert.match(s, /engineKey/)
+ assert.match(s, /className="wk-stage"/); assert.match(s, /className="wk-canvas"/); assert.match(s, /className="wk-zoom"/)
+ assert.ok(s.includes('Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste.'))
+ for (const t of ['+', '−', 'Ganz']) assert.ok(s.includes(`>${t}<`), t)
+ // jeder Knopf der Bühne ist ein type="button" (WOMS-Button hat keinen Standard-type, PF-08)
+ assert.equal((s.match(/