Phase 6 (F2): detectDoorVisits() erkennt die drei Anlaesse aus den Detailantworten
(neues per Codeberg-Sync angelegtes Backlog-Ticket, Ticket erreicht "fertig", Ticket
erreicht "done") durch Diff zweier Ticket-Snapshots. office-render.js laesst dafuer
einen "user"-Besucher durch die linke Tuer hereinkommen, zum Board laufen, kurz
interagieren und wieder hinausgehen - unabhaengig vom Daten-Refresh-Takt, damit ein
Besuch auch bei einem zwischenzeitlichen Refresh sauber zu Ende animiert.
Phase 7 (F16): html/mon.html, script.js und styles.css sind entfallen ("kann
entfallen" laut Detailantworten); check_fds.sh/mail_fds.py/status.txt speisen
weiterhin unveraendert den System-Monitor. Unit-Tests fuer die neue Tuer-Logik
ergaenzt (weiterhin 100% Line-Coverage, node --test). E2E-Tests bleiben bewusst
ausserhalb dieser Phase.
Ausserdem: update.sh (holt die neueste Version vom konfigurierten Git-Remote und
faehrt die Docker-Compose-Entwicklungsumgebung hoch) und register-agent.sh
(Hilfsskript zur Agenten-Registrierung per API) ergaenzt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1054 lines
34 KiB
JavaScript
1054 lines
34 KiB
JavaScript
// 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,
|
||
pickIdleActivity,
|
||
AnimationQueue,
|
||
detectDoorVisits,
|
||
resolveDoorVisitVisual,
|
||
advanceDoorVisitStage,
|
||
DOOR_VISIT_STAGES,
|
||
} 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 };
|
||
|
||
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 };
|
||
|
||
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.
|
||
function renderDesk({ deskIndex, agent }) {
|
||
const { origin, feet } = deskSeat(deskIndex);
|
||
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: DESK.w, 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: DESK.w / 2, cy: feet.y - origin.y, r: 22, class: "desk-chair" }));
|
||
|
||
if (role) {
|
||
if (role.deco === "star") {
|
||
g.appendChild(
|
||
el("path", {
|
||
d: "M170,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 = 175;
|
||
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: "M162,24 l8,8 16,-18", class: "desk-deco desk-deco-check" })
|
||
);
|
||
}
|
||
|
||
g.appendChild(
|
||
text(agent.name, {
|
||
x: DESK.w / 2,
|
||
y: 40 + DESK.h + 74,
|
||
class: "desk-name",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
} else {
|
||
g.appendChild(
|
||
text("frei", {
|
||
x: DESK.w / 2,
|
||
y: 40 + DESK.h + 74,
|
||
class: "desk-name desk-name-empty",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
}
|
||
|
||
return g;
|
||
}
|
||
|
||
function renderDesks(desks) {
|
||
const g = el("g", { class: "desks" });
|
||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||
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, ...doorVisitorPlacements()]);
|
||
updateDoorActiveClass();
|
||
}
|
||
|
||
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 = [],
|
||
} = 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();
|
||
}
|
||
|
||
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 desksGroup = renderDesks(deskLayout);
|
||
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;
|
||
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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
previousTickets = data.tickets;
|
||
renderOfficeScene(container, {
|
||
ticketCounts: aggregateTicketsByColumn(data.tickets),
|
||
tickets: data.tickets,
|
||
agents: data.agents,
|
||
systems: data.systems,
|
||
events: data.events,
|
||
kpis: data.kpis,
|
||
});
|
||
} catch (err) {
|
||
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);
|
||
renderOfficeScene(container);
|
||
}
|
||
}
|
||
|
||
await refresh();
|
||
startRealtimeUpdates(refresh);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
boot();
|
||
});
|