monitor/html/js/office-render.js
fds2610-bot 37693a09a1 Phase 2 der Neuausrichtung: Statische Buero-Szene (SVG, Demo-Daten)
Setzt Phase 2 aus Visualization.md um: Vanilla-SVG-Buero-Szene (Waende, 2 Tueren,
8 Schreibtische, Kanban-Board-Rahmen mit 7 Spalten, Monitor-Wand mit Event-/System-/
KPI-Monitor) ohne Animation, gespeist mit statischen Demo-Daten im Format der
Phase-1-API-Responses. Board-Stapel-Logik (>5 Tickets, Done-Spalte als Dauerstapel)
folgt Detailantworten.md F2/F3.

Reine Layout-Logik (html/js/office-scene.js) ist ausgelagert und mit dem Node-
Testrunner abgedeckt (100% Zeilenabdeckung, tests/frontend/), das DOM-Rendering
(office-render.js) bleibt bewusst duenne Glue-Logik. Docker-Compose um web- (nginx,
serviert html/) und frontend-Service (node, "npm test") erweitert, analog zum
bestehenden api-Service und zum NC-Gantt-Vorbild: kein PHP/Node auf dem Host noetig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:19:22 +02:00

424 lines
12 KiB
JavaScript

// Zeichnet die statische Buero-Szene (Phase 2, siehe Visualization.md Abschnitt 9) als
// Vanilla-SVG in den Container #office-scene. Keine Animation, keine Klick-Interaktion
// (Board-/Monitor-Modals kommen erst mit den Live-Daten in Phase 3/4). Reine DOM-Glue-Logik -
// die eigentliche Layoutberechnung steckt testbar in office-scene.js.
import {
ROLES,
buildBoardColumnLayout,
buildDeskLayout,
buildSystemTiles,
buildEventLines,
} from "./office-scene.js";
import {
DEMO_TICKET_COUNTS,
DEMO_AGENTS,
DEMO_SYSTEMS,
DEMO_EVENTS,
DEMO_KPI_PLACEHOLDER,
} from "./demo-data.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 NOTE_W = 96;
const NOTE_H = 50;
const NOTE_STEP_Y = 56;
function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) {
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" }));
g.appendChild(
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" })
);
return g;
}
function renderStackBadge(x, y, count) {
const g = el("g", { class: "note-stack" });
// Mehrere leicht versetzte Zettel als "Stapel"-Andeutung, darueber die Anzahl (F3).
[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;
}
function renderBoard(ticketCounts) {
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}` });
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);
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" })
);
// Einfaches "Server"-Icon: zwei Balken mit je einer Status-Leuchte (F6: ein Icon-Typ
// fuer alle Systeme, unabhaengig von der Art).
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 renderKpiMonitor(frame, placeholder) {
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 monitor-screen-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",
})
);
});
return g;
}
function renderMonitorWall(systems, events) {
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,
}));
g.appendChild(renderEventMonitor(frames[0], events));
g.appendChild(renderSystemMonitor(frames[1], systems));
g.appendChild(renderKpiMonitor(frames[2], DEMO_KPI_PLACEHOLDER));
return g;
}
function personPose(pose) {
// Rein statische Pose-Auswahl (kein Animationszustand) - Zustandsmaschine/Animation
// folgt erst in Phase 5.
switch (pose) {
case "reading_newspaper":
return "pose-newspaper";
case "coffee":
return "pose-coffee";
case "working":
return "pose-working";
default:
return "pose-idle";
}
}
function renderDesk({ deskIndex, agent }) {
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];
const role = agent ? ROLES[agent.role] : null;
const g = el("g", {
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
transform: `translate(${x}, ${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 ? personPose(agent.pose) : ""}` })
);
g.appendChild(el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
if (role) {
// Deko-Icon je Rolle (F1): Stern (Entwickler:in), Bluete (Product Owner:in), Haken (QA).
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(
el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30 - 34, r: 16, class: "person-head" })
);
g.appendChild(
el("rect", {
x: DESK.w / 2 - 22,
y: 40 + DESK.h + 30 - 20,
width: 44,
height: 34,
rx: 12,
class: "person-body",
style: `--role-color: ${role.accentColor}`,
})
);
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(agents) {
const g = el("g", { class: "desks" });
buildDeskLayout(agents).forEach((desk) => g.appendChild(renderDesk(desk)));
return g;
}
export function renderOfficeScene(container, data = {}) {
const {
ticketCounts = DEMO_TICKET_COUNTS,
agents = DEMO_AGENTS,
systems = DEMO_SYSTEMS,
events = DEMO_EVENTS,
} = data;
const 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(renderBoard(ticketCounts));
svg.appendChild(renderMonitorWall(systems, events));
svg.appendChild(renderDesks(agents));
container.replaceChildren(svg);
}
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("office-scene");
if (container) {
renderOfficeScene(container);
}
});