Hustler-Pixelwelt T7: Detailansicht eines Hustlers (Szene nach Status, Daten, Spur, Blättern, Fokus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:19:02 +00:00
1 parent 363419ed12
commit 3027f03dbe
3 files changed
+928

No files matched your search

@@ -0,0 +1,193 @@
// Detailansicht eines Hustlers im Hochhaus (Spec E10, E14): Szene mit großer Figur nach Status (Büro, Stecker oder Brücke), rechts Name,
// Status, Modell, Etage · Raum, Aufgabe, Gerade, Heute, Kosten, die Spur und unten ◂ Nummer ▸. Aufbau und Klassen wie Entwurf/markup.html der
// Zeichenfläche (Block „offen“), alle Klassen mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-detail.css.
// Die Felder liefert hustlerSicht.detailModell, die Positionen kommen aus DETAIL in hustlerWelt.js, die Spur aus useWorkerTrace (alle 15 s).
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, kein innerHTML.
//
// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf „Schließen“, Tab bleibt im Dialog (erster Knopf = Schließen, letzter = ▸),
// Esc schließt, ←/→ blättern in Roster-Reihenfolge, ein Klick auf den Hintergrund schließt erst 400 ms nach dem Öffnen (Geister-Klick nach
// dem Antippen der Figur). Beim Schließen geht der Fokus zurück auf das Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument
// steht), sonst auf die Figur des zuletzt gezeigten Hustlers ([data-hk-hustler="<id>"], die setzt die Welt).
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useWorkerTrace } from '../../../hooks/useWorkersFeed'
import { blaettern, detailModell } from '../../../lib/hustlerSicht'
import { DETAIL } from '../../../lib/hustlerWelt'
import { safeLink } from '../../../lib/workersView'
// So lange nach dem Öffnen schließt der Hintergrund nicht (der Antipp-Klick, der den Dialog öffnet, kommt sonst als Geister-Klick darauf an)
const HINTERGRUND_SPERRE_MS = 400
// Ids der Hustler sind einfache Kennungen; nur solche gehen in den Attribut-Selektor
const EINFACHE_ID = /^[A-Za-z0-9_-]+$/
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// Einziger Ort des Dialogs, an dem ein Ziel für einen Link entsteht (wie TraceText in WorkerCard): nur was safeLink durchlässt, wird ein Link
// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text.
function SpurText({ link, children }) {
const safe = isRecord(link) ? safeLink(link.href) : null
if (!safe) return <>{children}</>
if (safe.external) return <a href={safe.href} target="_blank" rel="noopener noreferrer">{children}</a>
return <Link to={safe.href}>{children}</Link>
}
// Stelle der Figur in der Szene: Brücke (Overseer), Stecker-Szene (nicht angeschlossen) oder Büro
function figurStelle(szene) {
if (szene.includes('hk-sz-bruecke')) return DETAIL.figur.bruecke
if (szene.includes('hk-sz-nicht')) return DETAIL.figur.nicht
return DETAIL.figur.buero
}
// Props: api (Dienst-API für die Spur), workerId (null = nichts anzeigen), workers (Hustler-Tabelle der Seite, Zustandseinträge nach workerId),
// onClose(), onWechsel(nextId) (Blättern; die Seite setzt die neue Id)
export default function HustlerDialog({ api, workerId, workers, onClose, onWechsel }) {
const { trace, loading, error } = useWorkerTrace(api, workerId)
const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle)
const letzterRef = useRef(null) // Knopf „Nächster Hustler“ (Ende der Fokusfalle)
const sectionRef = useRef(null)
const hintenRef = useRef(null)
const geoeffnetRef = useRef(0)
const openerRef = useRef(null)
const idRef = useRef(workerId)
const offen = Boolean(workerId)
if (workerId) idRef.current = workerId
// Öffnen: merken, wer den Fokus hatte, und den Fokus auf „Schließen“ setzen; Schließen (oder Abbau): Fokus zurückgeben
useEffect(() => {
if (!offen) return undefined
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
openerRef.current = aktiv && aktiv !== document.body ? aktiv : null
geoeffnetRef.current = Date.now()
const erster = ersterRef.current
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
return () => {
const opener = openerRef.current
openerRef.current = null
let ziel = opener && opener.isConnected ? opener : null
if (!ziel && typeof document !== 'undefined' && typeof document.querySelector === 'function' && EINFACHE_ID.test(String(idRef.current ?? ''))) {
ziel = document.querySelector(`[data-hk-hustler="${idRef.current}"]`)
}
if (ziel && typeof ziel.focus === 'function') ziel.focus({ preventScroll: true })
}
}, [offen])
if (!workerId) return null
const eintrag = isRecord(workers) && Object.hasOwn(workers, workerId) ? workers[workerId] : null
const m = detailModell(workerId, eintrag, trace, Date.now())
const stelle = figurStelle(m.szene)
const o = m.overlays
const wechsel = (schritt) => onWechsel(blaettern(workerId, schritt))
// Hintergrund: schließt erst nach der Sperrzeit
const hintergrund = () => {
if (Date.now() - geoeffnetRef.current > HINTERGRUND_SPERRE_MS) onClose()
}
// Tasten (am äußeren Rahmen, damit sie auch gelten, wenn der Fokus auf dem Hintergrund oder dem Dialog selbst liegt):
// Esc schließt, ←/→ blättern, Tab wandert im Kreis zwischen „Schließen“ und „Nächster Hustler“
const taste = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onClose()
return
}
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
if (e.altKey || e.ctrlKey || e.metaKey) return // Alt+← ist „Zurück“ im Browser
e.preventDefault()
e.stopPropagation()
wechsel(e.key === 'ArrowRight' ? 1 : -1)
return
}
if (e.key !== 'Tab') return
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
const erster = ersterRef.current
const letzter = letzterRef.current
if (!e.shiftKey && aktiv === letzter && erster) {
e.preventDefault()
erster.focus()
} else if (e.shiftKey && letzter && (aktiv === erster || aktiv === sectionRef.current || aktiv === hintenRef.current || aktiv === e.currentTarget)) {
e.preventDefault()
letzter.focus()
}
}
return (
<div className="hk-dialog-ueber" onKeyDown={taste}>
<button type="button" tabIndex={-1} className="hk-dialog-hinten" aria-label="Detailansicht schließen" ref={hintenRef} onClick={hintergrund} />
<section role="dialog" aria-modal="true" aria-label={`Hustler ${m.name}`} tabIndex={-1} className="hk-panel hk-dialog" ref={sectionRef}>
<div className={m.szene} aria-hidden="true">
<span className={o.gedimmt ? `${m.figur} hk-gedimmt` : m.figur} aria-hidden="true" style={{ left: stelle.left, top: stelle.top }}>
{o.alarm && <span className="hk-alarm hk-alarm-gross" aria-hidden="true">!</span>}
</span>
{o.monitor && <span className="hk-spr hk-monitor-gross" aria-hidden="true" style={{ left: DETAIL.monitor.left, top: DETAIL.monitor.top }} />}
{o.dampf && <span className="hk-spr hk-dampf" aria-hidden="true" style={{ left: DETAIL.dampf.left, top: DETAIL.dampf.top }} />}
{o.zzz && <span className="hk-spr hk-zzz-gross" aria-hidden="true" style={{ left: DETAIL.zzz.left, top: DETAIL.zzz.top }} />}
{o.wirbel && <span className="hk-spr hk-wirbel" aria-hidden="true" style={{ left: DETAIL.wirbel.left, top: DETAIL.wirbel.top }} />}
{o.schild && (
<span className="hk-dialog-schild" style={{ left: DETAIL.schild.left, top: DETAIL.schild.top, width: DETAIL.schild.w, height: DETAIL.schild.h }}>
noch nicht angeschlossen
</span>
)}
</div>
<div className="hk-dialog-daten">
<div className="hk-dialog-kopf">
<div>
<h2 className="hk-dialog-name">{m.name}</h2>
<div className="hk-dialog-status" style={{ '--hk-status': m.statusFarbe }}>
<span className="hk-dialog-punkt" aria-hidden="true" />
{m.statusText}
</div>
</div>
<button type="button" className="hk-knopf" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
<span className="hk-ic hk-schliessen" aria-hidden="true" />
</button>
</div>
<dl className="hk-dialog-dl">
<dt>Modell</dt>
<dd>{m.modell}</dd>
<dt>Etage</dt>
<dd>{m.etageRaum}</dd>
<dt>Aufgabe</dt>
<dd>{m.aufgabe}</dd>
<dt>Gerade</dt>
<dd>{m.gerade}</dd>
<dt>Heute</dt>
<dd>{m.heute}</dd>
<dt>Kosten</dt>
<dd>{`${m.kosten} · ${m.tokens} Tokens`}</dd>
</dl>
<div>
<h3 className="hk-dialog-spurtitel">Spur</h3>
{error && <p className="hk-dialog-fehler" role="alert">{error}</p>}
{loading && <p className="hk-dialog-hinweis" role="status">Spur wird geladen …</p>}
{!loading && m.spur.length === 0 && !error && <p className="hk-dialog-hinweis">Noch keine Einträge.</p>}
{m.spur.length > 0 && (
<ol className="hk-dialog-spurliste">
{m.spur.map((row, i) => (
<li key={`${row.id}-${i}`} className="hk-dialog-spurzeile">
<span className="hk-dialog-zeit">{row.time}</span>
<span className="hk-dialog-spurtext">
<SpurText link={row.link}>{row.text}</SpurText>
</span>
</li>
))}
</ol>
)}
</div>
<div className="hk-dialog-fuss">
<button type="button" className="hk-knopf" aria-label="Vorheriger Hustler" onClick={() => wechsel(-1)}>
<span className="hk-ic hk-pfeil-links" aria-hidden="true" />
</button>
<span className="hk-dialog-nummer">{m.nummer}</span>
<button type="button" className="hk-knopf" ref={letzterRef} aria-label="Nächster Hustler" onClick={() => wechsel(1)}>
<span className="hk-ic hk-pfeil-rechts" aria-hidden="true" />
</button>
</div>
</div>
</section>
</div>
)
}