diff --git a/server/src/routes/org-chart-svg.ts b/server/src/routes/org-chart-svg.ts new file mode 100644 index 00000000..ec31df84 --- /dev/null +++ b/server/src/routes/org-chart-svg.ts @@ -0,0 +1,312 @@ +/** + * Server-side SVG renderer for Paperclip org charts. + * Renders the org tree in the "Warmth" style with Paperclip branding. + * Supports SVG output and PNG conversion via sharp. + */ +import sharp from "sharp"; + +export interface OrgNode { + id: string; + name: string; + role: string; + status: string; + reports: OrgNode[]; +} + +interface LayoutNode { + node: OrgNode; + x: number; + y: number; + width: number; + height: number; + children: LayoutNode[]; +} + +// ── Design tokens (Warmth style — matches index.html s-warm) ────── +const CARD_H = 88; +const CARD_MIN_W = 150; +const CARD_PAD_X = 22; +const CARD_RADIUS = 6; +const AVATAR_SIZE = 34; +const GAP_X = 24; +const GAP_Y = 56; +const LINE_COLOR = "#d6d3d1"; +const LINE_W = 2; +const BG_COLOR = "#fafaf9"; +const CARD_BG = "#ffffff"; +const CARD_BORDER = "#e7e5e4"; +const CARD_SHADOW_COLOR = "rgba(0,0,0,0.05)"; +const NAME_COLOR = "#1c1917"; +const ROLE_COLOR = "#78716c"; +const FONT = "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"; +const PADDING = 48; +const LOGO_PADDING = 16; + +// Role config: descriptive labels, avatar colors, and SVG icon paths (Pango-safe) +const ROLE_ICONS: Record = { + ceo: { + bg: "#fef3c7", roleLabel: "Chief Executive", iconColor: "#92400e", + // Star icon + iconPath: "M8 1l2.2 4.5L15 6.2l-3.5 3.4.8 4.9L8 12.2 3.7 14.5l.8-4.9L1 6.2l4.8-.7z", + }, + cto: { + bg: "#dbeafe", roleLabel: "Technology", iconColor: "#1e40af", + // Terminal/code icon + iconPath: "M2 3l5 5-5 5M9 13h5", + }, + cmo: { + bg: "#dcfce7", roleLabel: "Marketing", iconColor: "#166534", + // Globe icon + iconPath: "M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM1 8h14M8 1c-2 2-3 4.5-3 7s1 5 3 7c2-2 3-4.5 3-7s-1-5-3-7z", + }, + cfo: { + bg: "#fef3c7", roleLabel: "Finance", iconColor: "#92400e", + // Dollar sign icon + iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5", + }, + coo: { + bg: "#e0f2fe", roleLabel: "Operations", iconColor: "#075985", + // Settings/gear icon + iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM13 8a5 5 0 0 1-.1.9l1.5 1.2-1.5 2.5-1.7-.7a5 5 0 0 1-1.6.9L9.3 15H6.7l-.3-2.2a5 5 0 0 1-1.6-.9l-1.7.7L1.6 10l1.5-1.2A5 5 0 0 1 3 8c0-.3 0-.6.1-.9L1.6 6l1.5-2.5 1.7.7a5 5 0 0 1 1.6-.9L6.7 1h2.6l.3 2.2c.6.2 1.1.5 1.6.9l1.7-.7L14.4 6l-1.5 1.2c.1.2.1.5.1.8z", + }, + engineer: { + bg: "#f3e8ff", roleLabel: "Engineering", iconColor: "#6b21a8", + // Code brackets icon + iconPath: "M5 3L1 8l4 5M11 3l4 5-4 5", + }, + quality: { + bg: "#ffe4e6", roleLabel: "Quality", iconColor: "#9f1239", + // Checkmark/shield icon + iconPath: "M4 8l3 3 5-6M8 1L2 4v4c0 3.5 2.6 6.8 6 8 3.4-1.2 6-4.5 6-8V4z", + }, + design: { + bg: "#fce7f3", roleLabel: "Design", iconColor: "#9d174d", + // Pen/brush icon + iconPath: "M12 2l2 2-9 9H3v-2zM9.5 4.5l2 2", + }, + finance: { + bg: "#fef3c7", roleLabel: "Finance", iconColor: "#92400e", + iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5", + }, + operations: { + bg: "#e0f2fe", roleLabel: "Operations", iconColor: "#075985", + iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM13 8a5 5 0 0 1-.1.9l1.5 1.2-1.5 2.5-1.7-.7a5 5 0 0 1-1.6.9L9.3 15H6.7l-.3-2.2a5 5 0 0 1-1.6-.9l-1.7.7L1.6 10l1.5-1.2A5 5 0 0 1 3 8c0-.3 0-.6.1-.9L1.6 6l1.5-2.5 1.7.7a5 5 0 0 1 1.6-.9L6.7 1h2.6l.3 2.2c.6.2 1.1.5 1.6.9l1.7-.7L14.4 6l-1.5 1.2c.1.2.1.5.1.8z", + }, + default: { + bg: "#f3e8ff", roleLabel: "Agent", iconColor: "#6b21a8", + // User icon + iconPath: "M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2 14c0-3.3 2.7-4 6-4s6 .7 6 4", + }, +}; + +function guessRoleTag(node: OrgNode): string { + const name = node.name.toLowerCase(); + const role = node.role.toLowerCase(); + if (name === "ceo" || role.includes("chief executive")) return "ceo"; + if (name === "cto" || role.includes("chief technology") || role.includes("technology")) return "cto"; + if (name === "cmo" || role.includes("chief marketing") || role.includes("marketing")) return "cmo"; + if (name === "cfo" || role.includes("chief financial")) return "cfo"; + if (name === "coo" || role.includes("chief operating")) return "coo"; + if (role.includes("engineer") || role.includes("eng")) return "engineer"; + if (role.includes("quality") || role.includes("qa")) return "quality"; + if (role.includes("design")) return "design"; + if (role.includes("finance")) return "finance"; + if (role.includes("operations") || role.includes("ops")) return "operations"; + return "default"; +} + +function measureText(text: string, fontSize: number): number { + return text.length * fontSize * 0.58; +} + +function cardWidth(node: OrgNode): number { + const tag = guessRoleTag(node); + const roleLabel = ROLE_ICONS[tag]?.roleLabel ?? node.role; + const nameW = measureText(node.name, 14) + CARD_PAD_X * 2; + const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2; + return Math.max(CARD_MIN_W, Math.max(nameW, roleW)); +} + +// ── Tree layout (top-down, centered) ──────────────────────────── + +function subtreeWidth(node: OrgNode): number { + const cw = cardWidth(node); + if (!node.reports || node.reports.length === 0) return cw; + const childrenW = node.reports.reduce( + (sum, child, i) => sum + subtreeWidth(child) + (i > 0 ? GAP_X : 0), + 0, + ); + return Math.max(cw, childrenW); +} + +function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { + const w = cardWidth(node); + const sw = subtreeWidth(node); + const cardX = x + (sw - w) / 2; + + const layoutNode: LayoutNode = { + node, + x: cardX, + y, + width: w, + height: CARD_H, + children: [], + }; + + if (node.reports && node.reports.length > 0) { + let childX = x; + const childY = y + CARD_H + GAP_Y; + for (let i = 0; i < node.reports.length; i++) { + const child = node.reports[i]; + const childSW = subtreeWidth(child); + layoutNode.children.push(layoutTree(child, childX, childY)); + childX += childSW + GAP_X; + } + } + + return layoutNode; +} + +// ── SVG rendering ─────────────────────────────────────────────── + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function renderCard(ln: LayoutNode): string { + const tag = guessRoleTag(ln.node); + const role = ROLE_ICONS[tag] || ROLE_ICONS.default; + const cx = ln.x + ln.width / 2; + + // Vertical layout: avatar circle → name → role label + const avatarCY = ln.y + 24; + const nameY = ln.y + 52; + const roleY = ln.y + 68; + + // SVG icon inside the avatar circle, scaled to fit + const iconScale = 0.7; + const iconOffset = (AVATAR_SIZE * iconScale) / 2; + const iconX = cx - iconOffset; + const iconY = avatarCY - iconOffset; + + return ` + + + + + + + + + + + ${escapeXml(ln.node.name)} + ${escapeXml(role.roleLabel)} + `; +} + +function renderConnectors(ln: LayoutNode): string { + if (ln.children.length === 0) return ""; + + const parentCx = ln.x + ln.width / 2; + const parentBottom = ln.y + ln.height; + const midY = parentBottom + GAP_Y / 2; + + let svg = ""; + + // Vertical line from parent to midpoint + svg += ``; + + if (ln.children.length === 1) { + const childCx = ln.children[0].x + ln.children[0].width / 2; + svg += ``; + } else { + const leftCx = ln.children[0].x + ln.children[0].width / 2; + const rightCx = ln.children[ln.children.length - 1].x + ln.children[ln.children.length - 1].width / 2; + svg += ``; + + for (const child of ln.children) { + const childCx = child.x + child.width / 2; + svg += ``; + } + } + + for (const child of ln.children) { + svg += renderConnectors(child); + } + + return svg; +} + +function renderCards(ln: LayoutNode): string { + let svg = renderCard(ln); + for (const child of ln.children) { + svg += renderCards(child); + } + return svg; +} + +function treeBounds(ln: LayoutNode): { minX: number; minY: number; maxX: number; maxY: number } { + let minX = ln.x; + let minY = ln.y; + let maxX = ln.x + ln.width; + let maxY = ln.y + ln.height; + for (const child of ln.children) { + const cb = treeBounds(child); + minX = Math.min(minX, cb.minX); + minY = Math.min(minY, cb.minY); + maxX = Math.max(maxX, cb.maxX); + maxY = Math.max(maxY, cb.maxY); + } + return { minX, minY, maxX, maxY }; +} + +// Paperclip logo as inline SVG path +const PAPERCLIP_LOGO_SVG = ` + + Paperclip +`; + +export function renderOrgChartSvg(orgTree: OrgNode[]): string { + let root: OrgNode; + if (orgTree.length === 1) { + root = orgTree[0]; + } else { + root = { + id: "virtual-root", + name: "Organization", + role: "Root", + status: "active", + reports: orgTree, + }; + } + + const layout = layoutTree(root, PADDING, PADDING + 24); + const bounds = treeBounds(layout); + + const svgW = bounds.maxX + PADDING; + const svgH = bounds.maxY + PADDING; + + const logoX = svgW - 110 - LOGO_PADDING; + const logoY = LOGO_PADDING; + + return ` + + + ${PAPERCLIP_LOGO_SVG} + + ${renderConnectors(layout)} + ${renderCards(layout)} +`; +} + +export async function renderOrgChartPng(orgTree: OrgNode[]): Promise { + const svg = renderOrgChartSvg(orgTree); + return sharp(Buffer.from(svg)).png().toBuffer(); +}