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>
424 lines
12 KiB
JavaScript
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);
|
|
}
|
|
});
|