monitor/html/js/office-render.js
fds2610-bot 811a7d9f69 PO bekommt festen Platz + laeuft bei niemandem zugeordneten Ticketwechseln zum Board
Der Product Owner ("PO") ist keine ueber die API registrierte Rolle, sondern
repraesentiert die Produktverantwortung des Systems selbst: eigener, fester
Platz ausserhalb des dynamischen 8er-Schreibtisch-Rasters, anwesend genau
dann, wenn die API erreichbar ist (statt ueber last_seen_at). Ausserdem laeuft
er zum Board, sobald sich dort ein Ticket-Status aendert, der noch keiner
anderen Rolle zugeordnet ist (z.B. Wechsel nach "bereit" durchs Label
"automate", wie es diesem Issue selbst passiert ist) - Wechsel nach
fertig/done bleiben weiterhin Sache der Rolle "user" (Tuerbesuche).

Schliesst #19
2026-07-29 17:13:59 +02:00

1183 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Zeichnet die Buero-Szene als Vanilla-SVG in den Container #office-scene und haelt sie mit
// echten Daten aktuell (Phase 3/4: Board/Monitor-Wand live, Phase 5: Agenten-Animation, siehe
// Visualization.md Abschnitt 9). Reine DOM-Glue-Logik - Layoutberechnung/Datenmodelle stecken
// testbar in office-scene.js.
//
// Architektur-Hinweis fuer die Animation (Phase 5): Board, Monitor-Wand und Schreibtisch-
// Moebel werden bei jedem Update komplett neu gezeichnet (kein Animationsbedarf dort), die
// Agenten-Avatare dagegen sind stabile DOM-Knoten pro Agent-ID, deren transform-Attribut nur
// aktualisiert wird - so kann CSS transition den Laufweg animieren, statt bei jedem Update
// hart zu springen.
import {
ROLES,
AGENT_LOCATIONS,
buildBoardColumnLayout,
buildDeskLayout,
buildSystemTiles,
buildEventLines,
aggregateTicketsByColumn,
groupTicketsByColumn,
buildTicketModalContent,
mapEventForDisplay,
buildEventModalContent,
buildSystemModalContent,
buildKpiTiles,
buildKpiModalContent,
mapAgentsToDesks,
normalizeAgentForDisplay,
resolveAgentVisualState,
isAgentPresent,
pickIdleActivity,
AnimationQueue,
detectDoorVisits,
resolveDoorVisitVisual,
advanceDoorVisitStage,
DOOR_VISIT_STAGES,
detectUnassignedTicketChanges,
advancePoVisitStage,
PO_VISIT_STAGES,
PO_AGENT_ID,
PO_DISPLAY_NAME,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
} from "./office-scene.js";
import {
DEMO_TICKET_COUNTS,
DEMO_AGENTS,
DEMO_SYSTEMS,
DEMO_EVENTS,
DEMO_KPI_PLACEHOLDER,
} from "./demo-data.js";
import { fetchTickets, fetchEvents, fetchSystems, fetchKpis, fetchAgents } from "./office-api.js";
import { startRealtimeUpdates } from "./office-realtime.js";
import { openModal } from "./office-modal.js";
const SVG_NS = "http://www.w3.org/2000/svg";
const VIEW_W = 1920;
const VIEW_H = 1080;
const WALL_BAND_H = 420;
const BOARD_FRAME = { x: 60, y: 40, w: 1120, h: 340 };
const MONITOR_WALL = { x: 1240, y: 40, w: 620, h: 340 };
const DOOR = { w: 40, h: 220, y: 700 };
const DESK = { w: 220, h: 110 };
const DESK_ROW_Y = [540, 840];
const DESK_COL_X = [232, 644, 1056, 1468];
const COFFEE_CORNER = { x: 960, y: 780 };
// Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters
// (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten
// Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten.
const PO_DESK = { x: 870, y: 380, w: 180 };
const NOTE_W = 96;
const NOTE_H = 50;
const NOTE_STEP_Y = 56;
// Feste "Aufenthaltsorte" fuer die Agenten-Avatare (Phase 5), abgeleitet aus den Rahmen der
// jeweiligen Wandelemente - der Avatar steht direkt davor.
const BOARD_FEET = { x: BOARD_FRAME.x + BOARD_FRAME.w / 2, y: BOARD_FRAME.y + BOARD_FRAME.h + 40 };
const MONITOR_FEET = { x: MONITOR_WALL.x + MONITOR_WALL.w / 2, y: MONITOR_WALL.y + MONITOR_WALL.h + 40 };
const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 };
// Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der
// Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()).
const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 };
// Ruheplatz des PO an seinem festen Platz (Issue #19), analog zu den Fuss-Positionen der
// regulaeren Schreibtische (deskSeat().feet).
const PO_DESK_FEET = { x: PO_DESK.x + PO_DESK.w / 2, y: PO_DESK.y + 40 + DESK.h + 30 };
const IDLE_MIN_DELAY_MS = 15000;
const IDLE_MAX_DELAY_MS = 40000;
const IDLE_ACTIVITY_DURATION_MS = 8000;
const DOOR_VISIT_STAGE_DURATION_MS = 2200;
function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) {
if (value !== null && value !== undefined) {
node.setAttribute(key, value);
}
}
for (const child of children) {
node.appendChild(child);
}
return node;
}
function text(content, attrs = {}) {
const node = el("text", attrs);
node.textContent = content;
return node;
}
function renderRoom() {
const g = el("g", { class: "office-room" });
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: VIEW_H, class: "floor" }));
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: WALL_BAND_H, class: "wall" }));
g.appendChild(el("rect", { x: 0, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
g.appendChild(el("rect", { x: VIEW_W - 40, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
g.appendChild(el("rect", { x: 0, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-left" }));
g.appendChild(
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-right" })
);
g.appendChild(renderCoffeeCorner());
return g;
}
// Kaffee-Ecke als fester Raum-Prop zwischen den Schreibtischreihen (Ziel fuer den
// "at_coffee_corner"-Zustand, Vision-Text: "gehen in eine Kaffee-Ecke").
function renderCoffeeCorner() {
const { x, y } = COFFEE_CORNER;
const g = el("g", { class: "coffee-corner" });
g.appendChild(el("rect", { x: x - 34, y: y - 20, width: 68, height: 40, rx: 8, class: "coffee-table" }));
g.appendChild(el("circle", { cx: x - 14, cy: y - 26, r: 8, class: "coffee-cup" }));
g.appendChild(el("circle", { cx: x + 14, cy: y - 26, r: 8, class: "coffee-cup" }));
g.appendChild(text("☕", { x, y: y - 34, class: "coffee-icon", "text-anchor": "middle" }));
return g;
}
function renderStackBadge(x, y, count) {
const g = el("g", { class: "note-stack" });
[6, 3, 0].forEach((offset, i) => {
g.appendChild(
el("rect", {
x: x - offset,
y: y - offset,
width: NOTE_W,
height: NOTE_H,
rx: 6,
class: `note note-stack-layer-${i}`,
})
);
});
const cx = x + NOTE_W / 2;
const cy = y + NOTE_H / 2;
g.appendChild(el("circle", { cx, cy, r: 20, class: "note-stack-badge" }));
g.appendChild(
text(String(count), { x: cx, y: cy + 7, class: "note-stack-count", "text-anchor": "middle" })
);
return g;
}
/**
* @param {Record<string, number>} ticketCounts
* @param {(columnKey: string) => void} onColumnClick
*/
function renderBoard(ticketCounts, onColumnClick) {
const columns = buildBoardColumnLayout(ticketCounts);
const g = el("g", { class: "board" });
g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
g.appendChild(
text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" })
);
const columnWidth = BOARD_FRAME.w / columns.length;
columns.forEach((column, index) => {
const columnX = BOARD_FRAME.x + index * columnWidth;
const colGroup = el("g", { class: `board-column board-column-${column.key} clickable` });
colGroup.appendChild(
el("rect", {
x: columnX,
y: BOARD_FRAME.y,
width: columnWidth,
height: BOARD_FRAME.h,
class: "board-column-hit",
})
);
colGroup.addEventListener("click", () => onColumnClick(column.key));
if (index > 0) {
colGroup.appendChild(
el("line", {
x1: columnX,
y1: BOARD_FRAME.y,
x2: columnX,
y2: BOARD_FRAME.y + BOARD_FRAME.h,
class: "board-column-divider",
})
);
}
colGroup.appendChild(
text(column.label, {
x: columnX + columnWidth / 2,
y: BOARD_FRAME.y + 26,
class: "board-column-label",
"text-anchor": "middle",
})
);
const noteX = columnX + (columnWidth - NOTE_W) / 2;
let noteY = BOARD_FRAME.y + 44;
for (let i = 0; i < column.visibleNotes; i++) {
colGroup.appendChild(
el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" })
);
noteY += NOTE_STEP_Y;
}
if (column.stackCount > 0) {
colGroup.appendChild(renderStackBadge(noteX, noteY, column.stackCount));
}
g.appendChild(colGroup);
});
return g;
}
function renderEventMonitor(frame, events) {
const lines = buildEventLines(events).map(mapEventForDisplay);
const g = el("g", { class: "monitor monitor-events" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("Events", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen",
})
);
lines.forEach((eventLine, index) => {
g.appendChild(
text(`${eventLine.time} ${eventLine.system}: ${eventLine.message}`, {
x: frame.x + 18,
y: frame.y + 38 + index * 26,
class: `monitor-event-line severity-${eventLine.severity}`,
})
);
});
return g;
}
function renderSystemMonitor(frame, systems) {
const tiles = buildSystemTiles(systems);
const g = el("g", { class: "monitor monitor-systems" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen",
})
);
const cols = 3;
const tileMargin = 8;
const gridX = frame.x + 18;
const gridY = frame.y + 18;
const tileSize = (frame.w - 36 - tileMargin * (cols - 1)) / cols;
tiles.forEach((system, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
const tileX = gridX + col * (tileSize + tileMargin);
const tileY = gridY + row * (tileSize + tileMargin);
const tileGroup = el("g", { class: `system-tile status-${system.status}` });
tileGroup.appendChild(
el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 6, class: "system-tile-bg" })
);
tileGroup.appendChild(
el("rect", { x: tileX + 10, y: tileY + 10, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
);
tileGroup.appendChild(
el("rect", { x: tileX + 10, y: tileY + 30, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
);
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 17, r: 3, class: "system-tile-led" }));
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 37, r: 3, class: "system-tile-led" }));
tileGroup.appendChild(
text(system.name, {
x: tileX + tileSize / 2,
y: tileY + tileSize - 8,
class: "system-tile-label",
"text-anchor": "middle",
})
);
g.appendChild(tileGroup);
});
return g;
}
function renderSparkline(points, width, height, x, y) {
const g = el("g", { class: "kpi-sparkline", transform: `translate(${x}, ${y})` });
g.appendChild(el("rect", { x: 0, y: 0, width, height, class: "kpi-sparkline-bg" }));
if (points) {
g.appendChild(el("polyline", { points, class: "kpi-sparkline-line" }));
}
return g;
}
function renderKpiMonitor(frame, kpis) {
const tiles = buildKpiTiles(kpis);
const g = el("g", { class: "monitor monitor-kpi" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("KPIs", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: `monitor-screen ${tiles.length === 0 ? "monitor-screen-placeholder" : ""}`,
})
);
if (tiles.length === 0) {
renderPlaceholderText(g, frame, DEMO_KPI_PLACEHOLDER);
return g;
}
const rowH = (frame.h - 30) / tiles.length;
tiles.forEach((tile, index) => {
const rowY = frame.y + 20 + index * rowH;
g.appendChild(
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
x: frame.x + 16,
y: rowY + 14,
class: "kpi-tile-label",
})
);
g.appendChild(renderSparkline(tile.sparklinePoints, frame.w - 32, rowH - 26, frame.x + 16, rowY + 20));
});
return g;
}
function renderPlaceholderText(g, frame, placeholder) {
const words = placeholder.split(" ");
const lineHeight = 22;
const maxCharsPerLine = 16;
const lines = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length > maxCharsPerLine && current) {
lines.push(current);
current = word;
} else {
current = candidate;
}
}
if (current) lines.push(current);
const startY = frame.y + frame.h / 2 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
g.appendChild(
text(line, {
x: frame.x + frame.w / 2,
y: startY + index * lineHeight,
class: "monitor-placeholder-text",
"text-anchor": "middle",
})
);
});
}
/**
* @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void}} handlers
*/
function renderMonitorWall(systems, events, kpis, handlers) {
const g = el("g", { class: "monitor-wall" });
const gap = 20;
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
const frames = [0, 1, 2].map((i) => ({
x: MONITOR_WALL.x + i * (monitorWidth + gap),
y: MONITOR_WALL.y,
w: monitorWidth,
h: MONITOR_WALL.h,
}));
const eventsGroup = renderEventMonitor(frames[0], events);
eventsGroup.classList.add("clickable");
eventsGroup.addEventListener("click", handlers.onEventsClick);
const systemsGroup = renderSystemMonitor(frames[1], systems);
systemsGroup.classList.add("clickable");
systemsGroup.addEventListener("click", handlers.onSystemsClick);
const kpiGroup = renderKpiMonitor(frames[2], kpis);
kpiGroup.classList.add("clickable");
kpiGroup.addEventListener("click", handlers.onKpiClick);
g.appendChild(eventsGroup);
g.appendChild(systemsGroup);
g.appendChild(kpiGroup);
return g;
}
function deskSeat(deskIndex) {
const col = deskIndex % DESK_COL_X.length;
const row = Math.floor(deskIndex / DESK_COL_X.length);
const x = DESK_COL_X[col];
const y = DESK_ROW_Y[row];
return {
origin: { x, y },
feet: { x: x + DESK.w / 2, y: y + 40 + DESK.h + 30 },
};
}
function deskScreenPoseClass(pose) {
switch (pose) {
case "working":
return "pose-working";
default:
return "pose-idle";
}
}
// Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
// zeichnen zu muessen. Parametrisiert ueber origin/width, damit sowohl die regulaeren
// Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe
// Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen.
function renderDeskAt(origin, agent, width = DESK.w) {
const role = agent ? ROLES[agent.role] : null;
const g = el("g", {
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
transform: `translate(${origin.x}, ${origin.y})`,
});
g.appendChild(el("rect", { x: 0, y: 40, width, height: DESK.h, rx: 6, class: "desk-table" }));
g.appendChild(
el("rect", {
x: 20,
y: 10,
width: 60,
height: 40,
rx: 4,
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`,
})
);
g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
if (role) {
if (role.deco === "star") {
g.appendChild(
el("path", {
d: `M${width - 50},20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z`,
class: "desk-deco desk-deco-star",
})
);
} else if (role.deco === "flower") {
const cx = width - 45;
const cy = 22;
g.appendChild(
el("g", { class: "desk-deco desk-deco-flower" }, [
el("circle", { cx: cx - 7, cy, r: 6 }),
el("circle", { cx: cx + 7, cy, r: 6 }),
el("circle", { cx, cy: cy - 7, r: 6 }),
el("circle", { cx, cy: cy + 7, r: 6 }),
el("circle", { cx, cy, r: 4, class: "desk-deco-flower-center" }),
])
);
} else if (role.deco === "check") {
g.appendChild(
el("path", { d: `M${width - 58},24 l8,8 16,-18`, class: "desk-deco desk-deco-check" })
);
}
g.appendChild(
text(agent.name, {
x: width / 2,
y: 40 + DESK.h + 74,
class: "desk-name",
"text-anchor": "middle",
})
);
} else {
g.appendChild(
text("frei", {
x: width / 2,
y: 40 + DESK.h + 74,
class: "desk-name desk-name-empty",
"text-anchor": "middle",
})
);
}
return g;
}
function renderDesk({ deskIndex, agent }) {
return renderDeskAt(deskSeat(deskIndex).origin, agent);
}
/**
* @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
*/
function renderDesks(desks, poAgent) {
const g = el("g", { class: "desks" });
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
poDeskGroup.classList.add("po-desk");
g.appendChild(poDeskGroup);
return g;
}
// ---------------------------------------------------------------------------------------
// Phase 5 - Agenten-Avatare (bewegliche Schicht, siehe Architektur-Hinweis oben)
// ---------------------------------------------------------------------------------------
const agentAvatarElements = new Map();
const idleCosmeticTimers = new Map();
function feetForLocation(location, homeFeet) {
switch (location) {
case AGENT_LOCATIONS.BOARD:
return BOARD_FEET;
case AGENT_LOCATIONS.MONITOR_EVENTS:
case AGENT_LOCATIONS.MONITOR_SYSTEMS:
case AGENT_LOCATIONS.MONITOR_KPI:
return MONITOR_FEET;
case AGENT_LOCATIONS.COFFEE_CORNER:
return COFFEE_FEET;
case AGENT_LOCATIONS.DOOR:
return DOOR_FEET;
case AGENT_LOCATIONS.DESK:
default:
return homeFeet;
}
}
function poseExtras(pose) {
const nodes = [];
if (pose === "walking") {
nodes.push(el("rect", { x: -10, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-left" }));
nodes.push(el("rect", { x: 2, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-right" }));
} else if (pose === "coffee") {
nodes.push(el("rect", { x: 16, y: -30, width: 10, height: 10, rx: 2, class: "person-prop-cup" }));
} else if (pose === "reading_newspaper") {
nodes.push(el("rect", { x: -16, y: -48, width: 32, height: 18, rx: 1, class: "person-prop-newspaper" }));
} else if (pose === "talking") {
nodes.push(el("circle", { cx: 20, cy: -40, r: 7, class: "person-prop-speech" }));
}
return nodes;
}
function createAgentAvatarElement() {
const g = el("g", { class: "agent-avatar" });
g.appendChild(el("circle", { cx: 0, cy: -34, r: 16, class: "person-head" }));
g.appendChild(el("rect", { x: -22, y: -20, width: 44, height: 34, rx: 12, class: "person-body" }));
g.appendChild(el("g", { class: "person-extras" }));
g.appendChild(text("", { x: 0, y: 46, class: "avatar-name", "text-anchor": "middle" }));
return g;
}
function updateAgentAvatarElement(g, agent, feet, showName) {
g.setAttribute("transform", `translate(${feet.x}, ${feet.y})`);
g.setAttribute("class", `agent-avatar pose-${agent.pose}`);
g.dataset.state = agent.state || "idle";
const role = ROLES[agent.role];
const body = g.querySelector(".person-body");
body.style.setProperty("--role-color", role ? role.accentColor : "#94a3b8");
const extras = g.querySelector(".person-extras");
extras.replaceChildren(...poseExtras(agent.pose));
const nameEl = g.querySelector(".avatar-name");
nameEl.textContent = showName ? agent.name : "";
}
/**
* Aktualisiert die Agenten-Avatar-Schicht per DOM-Diff (statt Neuzeichnen), damit CSS
* transition das transform-Attribut zwischen zwei Aufenthaltsorten weich animiert
* (Detailantworten F9: "Laufwege und Zustandswechsel realistisch animieren").
*
* @param {SVGGElement} layer
* @param {Array<{agent: object, homeFeet: {x:number,y:number}}>} placements
*/
function updateAgentsLayer(layer, placements) {
const seen = new Set();
placements.forEach(({ agent, homeFeet }, index) => {
const id = String(agent.id ?? `guest-${index}`);
seen.add(id);
let node = agentAvatarElements.get(id);
if (!node) {
node = createAgentAvatarElement();
agentAvatarElements.set(id, node);
layer.appendChild(node);
}
const feet = feetForLocation(agent.location, homeFeet);
const showName = agent.location !== AGENT_LOCATIONS.DESK;
updateAgentAvatarElement(node, agent, feet, showName);
});
for (const [id, node] of agentAvatarElements) {
if (!seen.has(id)) {
node.remove();
agentAvatarElements.delete(id);
clearIdleCosmeticTimer(id);
}
}
}
function clearIdleCosmeticTimer(agentId) {
const handle = idleCosmeticTimers.get(agentId);
if (handle) {
window.clearTimeout(handle.timeoutId);
idleCosmeticTimers.delete(agentId);
}
}
/**
* Rein kosmetische, zufaellige idle-Aktivitaeten (F8: "Nur idle Aktivitaeten ... steuert das
* System selber random" - wird bewusst NICHT an die API gemeldet, ein echter Zustandswechsel
* kommt ausschliesslich vom Agenten selbst). Greift nur, solange die API weiterhin "idle"
* meldet; sobald ein echter Zustand reinkommt, wird der Timer verworfen (siehe
* scheduleIdleCosmeticsForCurrentAgents()).
*/
function scheduleIdleCosmetic(agentId, node) {
if (idleCosmeticTimers.has(agentId)) {
return;
}
const delay = IDLE_MIN_DELAY_MS + Math.random() * (IDLE_MAX_DELAY_MS - IDLE_MIN_DELAY_MS);
const timeoutId = window.setTimeout(() => {
if (!agentAvatarElements.has(agentId) || node.dataset.state !== "idle") {
idleCosmeticTimers.delete(agentId);
return;
}
const activity = pickIdleActivity();
const visual = resolveAgentVisualState(activity);
const homeFeet = node.__homeFeet;
node.setAttribute("transform", `translate(${feetForLocation(visual.location, homeFeet).x}, ${feetForLocation(visual.location, homeFeet).y})`);
node.setAttribute("class", `agent-avatar pose-${visual.pose}`);
node.querySelector(".person-extras").replaceChildren(...poseExtras(visual.pose));
node.querySelector(".avatar-name").textContent = node.__agentName || "";
window.setTimeout(() => {
if (agentAvatarElements.has(agentId) && node.dataset.state === "idle") {
const restingPose = node.__idlePose || "idle";
node.setAttribute("transform", `translate(${homeFeet.x}, ${homeFeet.y})`);
node.setAttribute("class", `agent-avatar pose-${restingPose}`);
node.querySelector(".person-extras").replaceChildren(...poseExtras(restingPose));
node.querySelector(".avatar-name").textContent = "";
}
idleCosmeticTimers.delete(agentId);
}, IDLE_ACTIVITY_DURATION_MS);
}, delay);
idleCosmeticTimers.set(agentId, { timeoutId });
}
function scheduleIdleCosmeticsForCurrentAgents(placements) {
placements.forEach(({ agent, homeFeet }, index) => {
const id = String(agent.id ?? `guest-${index}`);
const node = agentAvatarElements.get(id);
if (!node) {
return;
}
node.__homeFeet = homeFeet;
node.__agentName = agent.name;
node.__idlePose = agent.pose || "idle";
if (agent.state === "idle" || !agent.state) {
scheduleIdleCosmetic(id, node);
} else {
clearIdleCosmeticTimer(id);
}
});
}
// ---------------------------------------------------------------------------------------
// Phase 6 - Tuerbesucher (F2: Rolle "user" kommt nur anlassbezogen durch die linke Tuer,
// siehe detectDoorVisits() in office-scene.js). Laeuft unabhaengig vom Daten-Refresh-Takt
// ueber eigene Timer, damit ein Besuch auch dann sauber zu Ende animiert, wenn zwischendurch
// ein neuer SSE-/Polling-Refresh hereinkommt (siehe repaintAgentsLayer()).
// ---------------------------------------------------------------------------------------
let currentAgentsLayer = null;
let currentRegularPlacements = [];
const doorVisitors = new Map();
let doorVisitCounter = 0;
function doorVisitVisual(visitor) {
if (visitor.atDoor) {
return { location: AGENT_LOCATIONS.DOOR, pose: "walking" };
}
return resolveDoorVisitVisual(visitor.stage);
}
function doorVisitorPlacements() {
return Array.from(doorVisitors.values()).map((visitor) => {
const visual = doorVisitVisual(visitor);
return {
agent: { id: visitor.id, name: "User", role: "user", pose: visual.pose, location: visual.location },
homeFeet: DOOR_FEET,
};
});
}
// Kurzes visuelles Feedback (Tuer "leuchtet" auf), waehrend mindestens ein Besucher am
// Tuerplatz steht - rein kosmetisch, siehe .door-active in office.css.
function updateDoorActiveClass() {
if (!currentAgentsLayer) return;
const svg = currentAgentsLayer.closest("svg");
const doorLeft = svg && svg.querySelector(".door-left");
if (!doorLeft) return;
const anyAtDoor = Array.from(doorVisitors.values()).some(
(visitor) => doorVisitVisual(visitor).location === AGENT_LOCATIONS.DOOR
);
doorLeft.classList.toggle("door-active", anyAtDoor);
}
function repaintAgentsLayer() {
if (!currentAgentsLayer) return;
updateAgentsLayer(currentAgentsLayer, [
...currentRegularPlacements,
...poPlacement(),
...doorVisitorPlacements(),
]);
updateDoorActiveClass();
}
// ---------------------------------------------------------------------------------------
// Product Owner (Issue #19): fester Platz + Avatar, der bei "niemandem zugeordneten"
// Ticket-Statuswechseln zum Board laeuft (siehe detectUnassignedTicketChanges() in
// office-scene.js). Anders als die Tuerbesucher (mehrere gleichzeitig moeglich, Phase 6) gibt
// es nur einen PO - poState ist daher ein einzelner Zustand statt einer Map.
// ---------------------------------------------------------------------------------------
let poIsLive = false;
let poState = "idle";
let poVisitTimeoutId = null;
function poPlacement() {
if (!poIsLive) {
return [];
}
const visual = resolveAgentVisualState(poState);
return [
{
agent: { id: PO_AGENT_ID, name: PO_DISPLAY_NAME, role: "product_owner", pose: visual.pose, location: visual.location },
homeFeet: PO_DESK_FEET,
},
];
}
function advancePoVisit() {
const nextStage = advancePoVisitStage(poState);
if (nextStage === null) {
poState = "idle";
repaintAgentsLayer();
return;
}
poState = nextStage;
repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
}
// Mehrere gleichzeitige unzugeordnete Aenderungen (z.B. mehrere Tickets erhalten kurz
// hintereinander das Label "automate") loesen bewusst nur einen Besuch aus, statt den PO fuer
// jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende
// animiert, statt unterbrochen zu werden.
function triggerPoVisit() {
if (!poIsLive || poState !== "idle") {
return;
}
poState = PO_VISIT_STAGES[0];
repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
}
function advanceDoorVisit(id) {
const visitor = doorVisitors.get(id);
if (!visitor) {
return;
}
if (visitor.atDoor) {
visitor.atDoor = false;
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
return;
}
const nextStage = advanceDoorVisitStage(visitor.stage);
if (nextStage === null) {
doorVisitors.delete(id);
repaintAgentsLayer();
return;
}
visitor.stage = nextStage;
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
/** @param {{type: string, ticketId: number}} visit */
function triggerDoorVisit(visit) {
doorVisitCounter += 1;
const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`;
doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true });
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
// ---------------------------------------------------------------------------------------
// Modal-Inhalte (Phase 3/4 - Board-/Monitor-Klick, siehe Visualization.md 4.5)
// ---------------------------------------------------------------------------------------
function renderTicketModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Tickets in dieser Spalte."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = "office-modal-item";
const heading = document.createElement("div");
heading.className = "office-modal-item-heading";
const titleEl = document.createElement("strong");
titleEl.textContent = item.title;
heading.appendChild(titleEl);
if (item.type) {
const badge = document.createElement("span");
badge.className = "office-modal-badge";
badge.textContent = item.type;
if (item.color) badge.style.backgroundColor = item.color;
heading.appendChild(badge);
}
li.appendChild(heading);
if (item.description) {
const desc = document.createElement("p");
desc.textContent = item.description;
li.appendChild(desc);
}
if (item.link) {
const a = document.createElement("a");
a.href = item.link;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = "Auf Codeberg öffnen";
li.appendChild(a);
}
list.appendChild(li);
}
body.appendChild(list);
}
function renderEventModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Events vorhanden."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = `office-modal-item severity-${item.severity}`;
li.textContent = `${item.time} · ${item.name}${item.systemId ? ` (${item.systemId})` : ""}: ${item.message}`;
list.appendChild(li);
}
body.appendChild(list);
}
function renderSystemModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Systeme gemeldet."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = `office-modal-item status-${item.status}`;
li.textContent = `${item.name} (${item.type}) – ${item.status === "ok" ? "OK" : "Fehler"}${item.detail ? `: ${item.detail}` : ""}`;
list.appendChild(li);
}
body.appendChild(list);
}
function renderKpiModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Noch keine KPIs gemeldet – kommt in einer späteren Phase."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = "office-modal-item";
const label = document.createElement("div");
label.textContent = `${item.label}: ${item.currentValue ?? "–"} ${item.unit}`;
li.appendChild(label);
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 320 80");
svg.setAttribute("class", "kpi-modal-sparkline");
svg.appendChild(el("polyline", { points: item.sparklinePoints, class: "kpi-sparkline-line" }));
li.appendChild(svg);
list.appendChild(li);
}
body.appendChild(list);
}
function emptyMessage(message) {
const p = document.createElement("p");
p.className = "office-modal-empty";
p.textContent = message;
return p;
}
// ---------------------------------------------------------------------------------------
// Datenbeschaffung + Orchestrierung
// ---------------------------------------------------------------------------------------
async function loadLiveData() {
const [tickets, events, systems, kpis, agents] = await Promise.all([
fetchTickets(),
fetchEvents(),
fetchSystems(),
fetchKpis(),
fetchAgents(),
]);
return { tickets, events, systems, kpis, agents, isLive: true };
}
const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
window.setInterval(() => animationQueue.processDue(), 50);
function flash(node) {
animationQueue.enqueue(() => {
if (!node.isConnected) return;
node.classList.add("just-updated");
window.setTimeout(() => node.classList.remove("just-updated"), 900);
});
}
export function renderOfficeScene(container, data = {}) {
const {
ticketCounts = DEMO_TICKET_COUNTS,
agents = DEMO_AGENTS,
systems = DEMO_SYSTEMS,
events = DEMO_EVENTS,
kpis = [],
tickets = [],
isLive = false,
} = data;
let svg = container.querySelector(".office-svg");
let firstRender = false;
if (!svg) {
firstRender = true;
svg = el("svg", {
viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
class: "office-svg",
role: "img",
"aria-label": "Buero-Visualisierung mit Kanban-Board, Monitor-Wand und Schreibtischen",
});
svg.appendChild(renderRoom());
svg.appendChild(el("g", { class: "agents-layer" }));
container.replaceChildren(svg);
agentAvatarElements.clear();
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
idleCosmeticTimers.clear();
doorVisitors.clear();
if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId);
poVisitTimeoutId = null;
poState = "idle";
}
// Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber
// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten (Issue #19).
poIsLive = isLive;
const groupedTickets = groupTicketsByColumn(tickets);
const boardGroup = renderBoard(ticketCounts, (columnKey) => {
openModal(
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
renderTicketModalBody
);
});
const existingBoard = svg.querySelector(".board");
if (existingBoard) {
existingBoard.replaceWith(boardGroup);
if (!firstRender) flash(boardGroup);
} else {
svg.insertBefore(boardGroup, svg.querySelector(".agents-layer"));
}
const wallGroup = renderMonitorWall(systems, events, kpis, {
onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody),
onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody),
onKpiClick: () => openModal(buildKpiModalContent(kpis), renderKpiModalBody),
});
const existingWall = svg.querySelector(".monitor-wall");
if (existingWall) {
existingWall.replaceWith(wallGroup);
if (!firstRender) flash(wallGroup);
} else {
svg.insertBefore(wallGroup, svg.querySelector(".agents-layer"));
}
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
const desksGroup = renderDesks(deskLayout, poDeskAgent);
const existingDesks = svg.querySelector(".desks");
if (existingDesks) {
existingDesks.replaceWith(desksGroup);
} else {
svg.insertBefore(desksGroup, svg.querySelector(".agents-layer"));
}
const placements = agentPlacements(agents, deskLayout);
currentAgentsLayer = svg.querySelector(".agents-layer");
currentRegularPlacements = placements;
repaintAgentsLayer();
scheduleIdleCosmeticsForCurrentAgents(placements);
}
// Demo-Agenten (Phase 2) tragen bereits {name, role, pose} ohne desk_index/state - diese
// Helfer machen buildDeskLayout()/renderAgentAvatar() fuer beide Formen (Demo- und
// Live-API-Agenten) nutzbar, ohne die Demo-Daten anfassen zu muessen.
function normalizedDeskAgents(agents) {
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
if (!hasDeskIndex) {
return agents;
}
return mapAgentsToDesks(agents).map((agent) => (agent ? normalizeAgentForDisplay(agent) : null));
}
function agentPlacements(agents, deskLayout) {
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
if (!hasDeskIndex) {
// Demo-Daten: Positionsindex in der Liste == Schreibtisch-Index (Phase 2-Verhalten).
return deskLayout
.filter((d) => d.agent)
.map((d) => ({ agent: { ...d.agent, location: AGENT_LOCATIONS.DESK, state: "idle" }, homeFeet: deskSeat(d.deskIndex).feet }));
}
const placements = [];
const seatedIds = new Set();
deskLayout.forEach((desk) => {
if (desk.agent) {
seatedIds.add(desk.agent.id);
placements.push({ agent: desk.agent, homeFeet: deskSeat(desk.deskIndex).feet });
}
});
// Agenten ohne Schreibtisch (Rolle "user" oder alle Plaetze belegt, F2) bekommen einen
// Ruheplatz vor der linken Tuer zugewiesen, gestaffelt nebeneinander.
let overflowIndex = 0;
for (const raw of agents) {
if (seatedIds.has(raw.id)) continue;
if (!isAgentPresent(raw)) continue;
const agent = normalizeAgentForDisplay(raw);
const homeFeet = {
x: 70 + (overflowIndex % 3) * 26,
y: DOOR.y + 40 + Math.floor(overflowIndex / 3) * 40,
};
placements.push({ agent, homeFeet });
overflowIndex += 1;
}
return placements;
}
// Der Demo-Fallback unten sieht auf den ersten Blick wie eine echte Szene aus (fremde
// Agenten-Namen, erfundene Ticketzahlen, aber leere Detail-Modals). Damit das auf einem
// Produktiv-Monitor nicht fuer echte Daten gehalten wird, blendet der Fallback zusaetzlich
// einen roten Banner ein - und wieder aus, sobald die API antwortet.
function setApiBanner(visible) {
const banner = document.getElementById("api-status-banner");
if (banner) {
banner.hidden = !visible;
}
}
// Rein kosmetischer Countdown im Header (Issue #12): zaehlt unabhaengig vom tatsaechlichen
// SSE-/Polling-Takt von MONITOR_SYNC_INTERVAL (aus config.js, siehe office.html) auf 0
// herunter und startet danach von vorn - loest selbst keinen Reload aus.
function startReloadCountdown() {
const countdownEl = document.getElementById("reload-countdown");
if (!countdownEl) {
return;
}
const intervalSeconds = resolveReloadIntervalSeconds(window.MONITOR_SYNC_INTERVAL);
let remaining = intervalSeconds;
countdownEl.textContent = `${remaining}s`;
window.setInterval(() => {
remaining = nextCountdownSeconds(remaining, intervalSeconds);
countdownEl.textContent = `${remaining}s`;
}, 1000);
}
async function boot() {
const container = document.getElementById("office-scene");
if (!container) {
return;
}
// Vorheriger Ticket-Snapshot fuer detectDoorVisits() (Phase 6, F2) - bleibt null im
// Demo-Fallback (kein "echter" Anlass ohne Live-API) und wird nur bei erreichbarer API
// gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen.
let previousTickets = null;
async function refresh() {
try {
const data = await loadLiveData();
if (previousTickets) {
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit);
if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) {
triggerPoVisit();
}
}
previousTickets = data.tickets;
renderOfficeScene(container, {
ticketCounts: aggregateTicketsByColumn(data.tickets),
tickets: data.tickets,
agents: data.agents,
systems: data.systems,
events: data.events,
kpis: data.kpis,
isLive: true,
});
setApiBanner(false);
} catch (err) {
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);
renderOfficeScene(container);
setApiBanner(true);
}
}
await refresh();
startRealtimeUpdates(refresh);
}
document.addEventListener("DOMContentLoaded", () => {
boot();
startReloadCountdown();
});