Hustler-Pixelwelt T4: reine Ableitungen (Figur, Detail, Kopfzeile, Kamera)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8912ee363
commit
43b4dcbe9f
2 files changed
+1177
No files matched your search
@@ -0,0 +1,311 @@
|
||||
// Reine Ableitungen der Hustler-Pixelwelt (Spec E5-E7, E10): Figur nach Status, Detailansicht, Kopfzeile, Kamera.
|
||||
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Klassen heißen genau wie in
|
||||
// src/styles/hustler/hochhaus.css (alle mit hk- davor). Keine Beispieldaten: die Aufgaben-Texte sind UI-Text.
|
||||
|
||||
import { FLOOR_TITLES, MODEL_IDS, ROLES, ROOM_TITLES, SEARCH_ROOMS, hatForWorker, roleOf } from './workersRoster.js'
|
||||
import { counterLines, formatCount, formatEur, formatUsd, kpiTiles, tokenTotal, traceRows } from './workersView.js'
|
||||
import { ETAGEN_SPALTE, HAUS, WELT } from './hustlerWelt.js'
|
||||
|
||||
const DASH = '–'
|
||||
const OVERSEER_ID = 'otto'
|
||||
|
||||
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
const isNum = (n) => typeof n === 'number' && Number.isFinite(n)
|
||||
const text = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Namen und Modelle
|
||||
|
||||
/** Hut-Namen der Sprites je Hut des Rosters (hatForWorker: haiku/sonnet/opus/overseer). */
|
||||
export const HUT = Object.freeze({ haiku: 'muetze', sonnet: 'kappe', opus: 'krone', overseer: 'overseer' })
|
||||
|
||||
const MODELL_KURZ = Object.freeze({ opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' })
|
||||
const MODELL_NAME = Object.freeze({ opus: 'Claude Opus 5.5', sonnet: 'Claude Sonnet 5.5', haiku: 'Claude Haiku 5.5' })
|
||||
|
||||
/** Anzeige-Name: otto heißt in der Oberfläche „Overseer“, sonst Roster-Name, sonst die Id selbst. */
|
||||
export function anzeigeName(workerId) {
|
||||
if (workerId === OVERSEER_ID) return 'Overseer'
|
||||
const r = roleOf(workerId)
|
||||
if (r) return r.name
|
||||
return typeof workerId === 'string' ? workerId : ''
|
||||
}
|
||||
|
||||
/** 'Opus' | 'Sonnet' | 'Haiku' nach dem Modell der Rolle; unbekannte Kennung '–'. */
|
||||
export function modellKurz(workerId) {
|
||||
const r = roleOf(workerId)
|
||||
return r && Object.hasOwn(MODELL_KURZ, r.model) ? MODELL_KURZ[r.model] : DASH
|
||||
}
|
||||
|
||||
/** 'Claude Opus 5.5' usw. aus Kurzname ('opus') oder voller Modell-Id ('claude-opus-5-5'); sonst '–'. */
|
||||
export function modellName(model) {
|
||||
if (typeof model !== 'string') return DASH
|
||||
const m = model.trim().toLowerCase()
|
||||
if (Object.hasOwn(MODELL_NAME, m)) return MODELL_NAME[m]
|
||||
const kurz = Object.keys(MODEL_IDS).find((k) => MODEL_IDS[k] === m)
|
||||
return kurz ? MODELL_NAME[kurz] : DASH
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Status
|
||||
|
||||
const STATUS_BEKANNT = new Set(['arbeitet', 'wartet', 'leerlauf', 'fehler'])
|
||||
|
||||
/** Anzeige-Status des Zustandseintrags: nicht angeschlossen -> 'grau', sonst der Dienst-Status, alles andere 'leerlauf'. */
|
||||
export function statusVon(eintrag) {
|
||||
if (!isRecord(eintrag)) return 'leerlauf'
|
||||
if (eintrag.connected === false) return 'grau'
|
||||
return typeof eintrag.status === 'string' && STATUS_BEKANNT.has(eintrag.status) ? eintrag.status : 'leerlauf'
|
||||
}
|
||||
|
||||
/** Anzeige je Status: text (Detailansicht), aria (Figuren-Label), farbe (Punkt neben dem Status). */
|
||||
export const STATUS_ANZEIGE = Object.freeze({
|
||||
arbeitet: Object.freeze({ text: 'Arbeitet', aria: 'arbeitet', farbe: '#4ade5a' }),
|
||||
wartet: Object.freeze({ text: 'Wartet auf Auftrag', aria: 'wartet auf Auftrag', farbe: '#ffd25a' }),
|
||||
leerlauf: Object.freeze({ text: 'Leerlauf', aria: 'Leerlauf', farbe: '#9aa6d6' }),
|
||||
grau: Object.freeze({ text: 'Noch nicht angeschlossen', aria: 'noch nicht angeschlossen', farbe: '#a4a2ad' }),
|
||||
fehler: Object.freeze({ text: 'Fehler', aria: 'Fehler', farbe: '#ff5a5a' }),
|
||||
})
|
||||
|
||||
/** Pose des Sprites je Status (grau und Fehler stehen). */
|
||||
export const POSE_NACH_STATUS = Object.freeze({ arbeitet: 'arbeiten', wartet: 'warten', leerlauf: 'schlafen', grau: 'nicht', fehler: 'nicht' })
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Figur im Haus
|
||||
|
||||
/**
|
||||
* Alles, was die Figur im Haus braucht (Spec E5): Sprite-Klasse, Zusatz-Symbole, graue Raum-Ebene, Label.
|
||||
* `eintrag` = Zustandseintrag { status, task, lastTs, connected, counters } aus indexWorkers; paused = alles steht still.
|
||||
*/
|
||||
export function figurZustand(workerId, eintrag, { paused = false } = {}) {
|
||||
const status = statusVon(eintrag)
|
||||
const ovs = workerId === OVERSEER_ID
|
||||
const hut = HUT[hatForWorker(workerId)]
|
||||
const pose = ovs ? 'overseer' : POSE_NACH_STATUS[status]
|
||||
const rolle = roleOf(workerId)
|
||||
const raum = rolle ? rolle.room : null
|
||||
const modell = modellKurz(workerId)
|
||||
return {
|
||||
status,
|
||||
hut,
|
||||
pose,
|
||||
klasse: ovs ? 'hk-spr hk-ovs-klein' : `hk-spr hk-fig-${hut}-${pose}`,
|
||||
punkte: status === 'wartet',
|
||||
zzz: status === 'leerlauf',
|
||||
bildschirm: status === 'arbeitet' && !ovs,
|
||||
radar: workerId === 'rita' && status === 'arbeitet',
|
||||
alarm: status === 'fehler',
|
||||
raum,
|
||||
raumGrau: status === 'grau' && raum !== null && SEARCH_ROOMS.includes(raum),
|
||||
gedimmt: ovs && status !== 'arbeitet',
|
||||
steht: paused === true,
|
||||
label: [anzeigeName(workerId), modell === DASH ? '' : modell, STATUS_ANZEIGE[status].aria].filter((t) => t !== '').join(', '),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Detailansicht
|
||||
|
||||
/** Aufgabe je Hustler (UI-Text der Zeichenfläche, wörtlich). */
|
||||
export const AUFGABEN = Object.freeze({
|
||||
otto: 'Plant die Nacht, verteilt die Aufträge, achtet aufs Budget und schreibt den Morgenbericht.',
|
||||
rita: 'Sucht im offenen Netz über Suchmaschinen nach frischen „Suche Webdesigner“-Posts.',
|
||||
kim: 'Liest die Suchauftrags-Mails von Kleinanzeigen und ähnlichen Portalen.',
|
||||
pia: 'Liest Projekt-Alerts und RSS der Portale (freelancermap, freelance.de und weitere).',
|
||||
finn: 'Durchsucht Foren und Frage-Seiten, nur wo es erlaubt ist.',
|
||||
ali: 'Branchen-Scout für Ärzte und Praxen ohne oder mit veralteter Website.',
|
||||
hanna: 'Branchen-Scout für Handwerksbetriebe ohne oder mit veralteter Website.',
|
||||
sven: 'Branchen-Scout für Selbstständige ohne oder mit veralteter Website.',
|
||||
lea: 'Branchen-Scout für Läden und Gastro ohne oder mit veralteter Website.',
|
||||
bea: 'Bewertet Anfragen und Kandidaten und verteilt Top 3 und Rest.',
|
||||
max: 'Schreibt Mails und ist der einzige Absender: Erstmail, Nachfassen und Antworten.',
|
||||
clara: 'Schreibt nur Antworten auf Anfragen (Plattform ins Pult, Mail an Max).',
|
||||
nora: 'Liest und sortiert alle eingehenden Antworten.',
|
||||
theo: 'Schreibt die Nachrichten rund um den Kauf (als Entwurf).',
|
||||
bob: 'Baut Vorschau-Websites, nur nach einem Ja.',
|
||||
bianca: 'Baut Vorschau-Websites, nur nach einem Ja.',
|
||||
ben: 'Baut Vorschau-Websites, nur nach einem Ja.',
|
||||
paul: 'Prüft jede Vorschau am Desktop und am Handy und gibt sie frei oder schickt sie zurück.',
|
||||
arne: 'Führt das Nie-doppelt-Register und die Bot-Lead-Liste.',
|
||||
})
|
||||
|
||||
function aufgabeVon(workerId, trace, rolle) {
|
||||
if (typeof workerId === 'string' && Object.hasOwn(AUFGABEN, workerId)) return AUFGABEN[workerId]
|
||||
const info = isRecord(trace) && isRecord(trace.worker) ? trace.worker : null
|
||||
return text(info && info.role) || text(rolle && rolle.role) || DASH
|
||||
}
|
||||
|
||||
function heuteVon(trace, eintrag) {
|
||||
const today = isRecord(trace) && isRecord(trace.today) ? trace.today : null
|
||||
const zaehler = today && isRecord(today.counters) ? today.counters : isRecord(eintrag) ? eintrag.counters : null
|
||||
const zeilen = counterLines(zaehler).map((c) => `${c.value} ${c.label}`)
|
||||
return zeilen.length > 0 ? zeilen.join(' · ') : DASH
|
||||
}
|
||||
|
||||
/**
|
||||
* Felder der Detailansicht (Spec E10). trace = Antwort von api.trace (oder null/kaputt), now = Uhr für die Spur.
|
||||
* Overseer steht auf der Brücke, ein nicht angeschlossener Hustler in der Stecker-Szene, alle anderen im Büro.
|
||||
*/
|
||||
export function detailModell(workerId, eintrag, trace, now) {
|
||||
const status = statusVon(eintrag)
|
||||
const anzeige = STATUS_ANZEIGE[status]
|
||||
const ovs = workerId === OVERSEER_ID
|
||||
const rolle = roleOf(workerId)
|
||||
const info = isRecord(trace) && isRecord(trace.worker) ? trace.worker : null
|
||||
const modelId = text(info && info.modelId) !== '' ? info.modelId : rolle ? rolle.model : null
|
||||
const today = isRecord(trace) && isRecord(trace.today) ? trace.today : null
|
||||
const hut = HUT[hatForWorker(workerId)]
|
||||
const index = ROLES.findIndex((r) => r.workerId === workerId)
|
||||
return {
|
||||
id: workerId,
|
||||
name: anzeigeName(workerId),
|
||||
status,
|
||||
statusText: anzeige.text,
|
||||
statusFarbe: anzeige.farbe,
|
||||
modell: modellName(modelId),
|
||||
etageRaum: rolle ? `${FLOOR_TITLES[rolle.floor]} · ${ROOM_TITLES[rolle.room]}` : DASH,
|
||||
aufgabe: aufgabeVon(workerId, trace, rolle),
|
||||
gerade: text(isRecord(eintrag) ? eintrag.task : null) || DASH,
|
||||
heute: heuteVon(trace, eintrag),
|
||||
kosten: formatUsd(today ? today.costUsd : null),
|
||||
tokens: formatCount(today ? tokenTotal(today.tokens) : null),
|
||||
spur: traceRows(isRecord(trace) ? trace.events : null, now),
|
||||
nummer: `${index >= 0 ? index + 1 : DASH} / ${ROLES.length}`,
|
||||
szene: ovs ? 'hk-sz hk-sz-bruecke' : status === 'grau' ? 'hk-sz hk-sz-nicht' : 'hk-sz hk-sz-buero',
|
||||
figur: ovs ? 'hk-spr hk-gr-overseer' : `hk-spr hk-gr-${hut}-${POSE_NACH_STATUS[status]}`,
|
||||
overlays: {
|
||||
monitor: status === 'arbeitet' && !ovs,
|
||||
dampf: status === 'wartet',
|
||||
zzz: status === 'leerlauf',
|
||||
wirbel: ovs,
|
||||
schild: status === 'grau',
|
||||
alarm: status === 'fehler',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Nächster (schritt > 0) oder vorheriger Hustler in Roster-Reihenfolge mit Umlauf; unbekannte Kennung -> der erste. */
|
||||
export function blaettern(workerId, schritt) {
|
||||
const n = ROLES.length
|
||||
const i = ROLES.findIndex((r) => r.workerId === workerId)
|
||||
if (i < 0) return ROLES[0].workerId
|
||||
const d = isNum(schritt) ? Math.trunc(schritt) : 0
|
||||
return ROLES[(((i + d) % n) + n) % n].workerId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Kopfzeile (HUD)
|
||||
|
||||
const API_TITEL = 'API-Kosten heute'
|
||||
|
||||
const HUD_ZAHLEN = Object.freeze([
|
||||
Object.freeze({ key: 'abos', icon: 'hk-ic hk-stern', titel: 'Neue Abos (Monat)', farbe: '#ffd25a', feld: 'newAbosMonth' }),
|
||||
Object.freeze({ key: 'deals', icon: 'hk-ic hk-pokal', titel: 'Deals (Monat)', farbe: '#ff9f43', feld: 'dealsMonth' }),
|
||||
Object.freeze({ key: 'threads', icon: 'hk-ic hk-blase', titel: 'Gespräche offen', farbe: '#4fd1e8', feld: 'openThreads' }),
|
||||
Object.freeze({ key: 'pult', icon: 'hk-ic hk-glocke', titel: 'Wartet auf dich', farbe: '#ff6fae', feld: 'pultOpen' }),
|
||||
Object.freeze({ key: 'botleads', icon: 'hk-ic hk-robo', titel: 'Bot-Leads gesamt', farbe: '#a78bfa', feld: 'botLeads' }),
|
||||
])
|
||||
|
||||
/**
|
||||
* Werte der Kopfzeile aus state.kpis: grüne und rote Münze, fünf Icons mit Zahl. Fehlende oder kaputte Werte
|
||||
* ergeben '–', nie NaN. Das Minus der Kosten ist ein ASCII-Bindestrich (die Pixelschrift hat kein U+2212).
|
||||
*/
|
||||
export function hudWerte(kpis) {
|
||||
const k = isRecord(kpis) ? kpis : {}
|
||||
const stufe = kpiTiles(k).find((t) => t.key === 'api').tone
|
||||
const einnahmen = isNum(k.mrrNewEur) && k.mrrNewEur >= 0 ? `+${formatEur(k.mrrNewEur)}/m` : DASH
|
||||
const heute = isNum(k.apiTodayUsd) && k.apiTodayUsd >= 0 ? k.apiTodayUsd : null
|
||||
const kosten = heute === null ? DASH : heute === 0 ? formatUsd(0) : `-${formatUsd(heute)}`
|
||||
const grenze = heute !== null && isNum(k.dailyBudgetUsd) ? ` von ${formatUsd(k.dailyBudgetUsd)}` : ''
|
||||
return {
|
||||
einnahmen: { text: einnahmen, titel: 'Eingenommen pro Monat', sr: 'Eingenommen:' },
|
||||
api: { text: kosten, titel: `${API_TITEL}: ${heute === null ? DASH : formatUsd(heute)}${grenze}`, sr: `${API_TITEL}:`, stufe },
|
||||
zahlen: HUD_ZAHLEN.map((z) => ({ key: z.key, icon: z.icon, titel: z.titel, text: formatCount(k[z.feld]), farbe: z.farbe })),
|
||||
}
|
||||
}
|
||||
|
||||
function zaehlerSchild(n, einzahl, mehrzahl) {
|
||||
if (!isNum(n) || n < 0) return null
|
||||
return `${formatCount(n)} ${n === 1 ? einzahl : mehrzahl}`
|
||||
}
|
||||
|
||||
/** Schilder im Haus: Briefkasten-Zähler (bei 0 weg), Tresor- und Keller-Schild; fehlende Werte ergeben null. */
|
||||
export function schilder(kpis) {
|
||||
const k = isRecord(kpis) ? kpis : {}
|
||||
return {
|
||||
briefkasten: isNum(k.pultOpen) && k.pultOpen > 0 ? formatCount(k.pultOpen) : null,
|
||||
tresor: zaehlerSchild(k.dealsMonth, 'Deal', 'Deals'),
|
||||
keller: zaehlerSchild(k.botLeads, 'Bot-Lead', 'Bot-Leads'),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Kamera (Formeln aus logik.js der Zeichenfläche). g = Größe des Sichtfensters { w, h }; Ergebnis { s, x, y }:
|
||||
// Welt-Pixel mal s plus Verschiebung x/y. Ungültige Eingaben (0, NaN) fallen auf die Entwurfsgröße zurück.
|
||||
|
||||
/** Größte Vergrößerung. */
|
||||
export const ZOOM_MAX = 5
|
||||
|
||||
/** Größe des Sichtfensters, wenn noch keine gemessen ist (wie im Entwurf). */
|
||||
export const SICHT_STANDARD = Object.freeze({ w: 1280, h: 720 })
|
||||
|
||||
const RAND_SEITE = 48 // „ganzes Haus“: so viel Luft links und rechts zusammen
|
||||
const RAND_UNTEN = 16 // „ganzes Haus“: Luft unter dem Haus
|
||||
const ETAGE_SEITE = 96 // Etage heranzoomen: Luft links und rechts zusammen
|
||||
const ETAGE_BREITE = ETAGEN_SPALTE.w + 48 // Breite, die eine Etage im Bild füllen soll (Spalte plus Rand, 560)
|
||||
const ETAGE_ZOOM_MAX = 3
|
||||
const ETAGE_HOEHE = 3.4 // Etagenhöhe mal dieser Faktor passt in die Höhe unter der Kopfzeile
|
||||
const ETAGE_HOEHE_RAND = 40
|
||||
const OBEN_FREI = 0.35 // so viel der Höhe darf oben über der Welt frei bleiben (Himmel)
|
||||
|
||||
function sicht(g) {
|
||||
return isRecord(g) && isNum(g.w) && g.w > 0 && isNum(g.h) && g.h > 0 ? g : SICHT_STANDARD
|
||||
}
|
||||
|
||||
/** Kleinster Zoom: die Welt füllt das Fenster ganz („cover“). */
|
||||
export function kameraMinS(g) {
|
||||
const v = sicht(g)
|
||||
return Math.max(v.w / WELT.w, v.h / WELT.h)
|
||||
}
|
||||
|
||||
/** Kamera in die Grenzen zwingen: Zoom zwischen „cover“ und 5, links/rechts/unten kein leerer Rand, oben höchstens 35 %. */
|
||||
export function kameraGrenzen(kamera, g) {
|
||||
const v = sicht(g)
|
||||
const k = isRecord(kamera) ? kamera : {}
|
||||
const min = kameraMinS(v)
|
||||
const s = Math.min(ZOOM_MAX, Math.max(min, isNum(k.s) ? k.s : min))
|
||||
const x = Math.min(0, Math.max(v.w - WELT.w * s, isNum(k.x) ? k.x : 0))
|
||||
const y = Math.min(v.h * OBEN_FREI, Math.max(v.h - WELT.h * s, isNum(k.y) ? k.y : 0))
|
||||
return { s, x, y }
|
||||
}
|
||||
|
||||
/** „Ganzes Haus“: das Haus mittig unter der Kopfzeile, so groß wie es passt (nie kleiner als „cover“). */
|
||||
export function kameraPassend(g) {
|
||||
const v = sicht(g)
|
||||
const s = Math.max(kameraMinS(v), Math.min((v.w - RAND_SEITE) / HAUS.w, (v.h - WELT.hud - RAND_UNTEN) / HAUS.h))
|
||||
const x = (v.w - HAUS.w * s) / 2 - HAUS.x * s
|
||||
const y = WELT.hud + (v.h - WELT.hud - RAND_UNTEN - HAUS.h * s) / 2 - HAUS.y * s
|
||||
return kameraGrenzen({ s, x, y }, v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf neuen Zoom `neuS` um den Punkt (mx, my) im Fenster: der Weltpunkt unter dem Zeiger bleibt dort (soweit die
|
||||
* Grenzen es erlauben). Ohne gültige bisherige Kamera (s <= 0, NaN) bleibt nur „ganzes Haus“.
|
||||
*/
|
||||
export function kameraZoomAuf(alt, mx, my, neuS, g) {
|
||||
const v = sicht(g)
|
||||
if (!isRecord(alt) || !isNum(alt.s) || alt.s <= 0 || !isNum(alt.x) || !isNum(alt.y)) return kameraPassend(v)
|
||||
const px = isNum(mx) ? mx : v.w / 2
|
||||
const py = isNum(my) ? my : v.h / 2
|
||||
const n = kameraGrenzen({ s: neuS, x: 0, y: 0 }, v)
|
||||
const f = n.s / alt.s
|
||||
return kameraGrenzen({ s: n.s, x: px - (px - alt.x) * f, y: py - (py - alt.y) * f }, v)
|
||||
}
|
||||
|
||||
/** Eine Etage (Klickbereich oben..unten in Weltpixeln) mittig heranzoomen, höchstens 3-fach. */
|
||||
export function kameraEtage(oben, unten, g) {
|
||||
const v = sicht(g)
|
||||
const hoehe = isNum(oben) && isNum(unten) && unten > oben ? unten - oben : 1
|
||||
const mitte = isNum(oben) && isNum(unten) ? (oben + unten) / 2 : 0
|
||||
const s = Math.max(kameraMinS(v), Math.min(ETAGE_ZOOM_MAX, (v.w - ETAGE_SEITE) / ETAGE_BREITE, (v.h - WELT.hud - ETAGE_HOEHE_RAND) / (hoehe * ETAGE_HOEHE)))
|
||||
return kameraGrenzen({ s, x: v.w / 2 - (ETAGEN_SPALTE.x + ETAGEN_SPALTE.w / 2) * s, y: WELT.hud / 2 + v.h / 2 - mitte * s }, v)
|
||||
}
|
||||
Reference in new issue
Block a user