/** * Server-side SVG renderer for Paperclip org charts. * Supports 5 visual styles: monochrome, nebula, circuit, warmth, schematic. * Pure SVG output — no browser/Playwright needed. PNG via sharp. */ import sharp from "sharp"; export interface OrgNode { id: string; name: string; role: string; status: string; reports: OrgNode[]; } export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic"; export const ORG_CHART_STYLES: OrgChartStyle[] = ["monochrome", "nebula", "circuit", "warmth", "schematic"]; interface LayoutNode { node: OrgNode; x: number; y: number; width: number; height: number; children: LayoutNode[]; } // ── Style theme definitions ────────────────────────────────────── interface StyleTheme { bgColor: string; cardBg: string; cardBorder: string; cardRadius: number; cardShadow: string | null; lineColor: string; lineWidth: number; nameColor: string; roleColor: string; font: string; watermarkColor: string; /** Extra SVG defs (filters, patterns, gradients) */ defs: (svgW: number, svgH: number) => string; /** Extra background elements after the main bg rect */ bgExtras: (svgW: number, svgH: number) => string; /** Custom card renderer — if null, uses default avatar+name+role */ renderCard: ((ln: LayoutNode, theme: StyleTheme) => string) | null; /** Per-card accent (top bar, border glow, etc.) */ cardAccent: ((tag: string) => string) | null; } // ── Role config with Twemoji SVG inlines (viewBox 0 0 36 36) ───── // // Each `emojiSvg` contains the inner SVG paths from Twemoji (CC-BY 4.0). // These render as colorful emoji-style icons inside the avatar circle, // without needing a browser or emoji font. const ROLE_ICONS: Record = { ceo: { bg: "#fef3c7", roleLabel: "Chief Executive", accentColor: "#f0883e", iconColor: "#92400e", 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", // 👑 Crown emojiSvg: ``, }, cto: { bg: "#dbeafe", roleLabel: "Technology", accentColor: "#58a6ff", iconColor: "#1e40af", iconPath: "M2 3l5 5-5 5M9 13h5", // 💻 Laptop emojiSvg: ``, }, cmo: { bg: "#dcfce7", roleLabel: "Marketing", accentColor: "#3fb950", iconColor: "#166534", 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", // 🌐 Globe with meridians emojiSvg: ``, }, cfo: { bg: "#fef3c7", roleLabel: "Finance", accentColor: "#f0883e", 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", // 📊 Bar chart emojiSvg: ``, }, coo: { bg: "#e0f2fe", roleLabel: "Operations", accentColor: "#58a6ff", iconColor: "#075985", iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z", // ⚙️ Gear emojiSvg: ``, }, engineer: { bg: "#f3e8ff", roleLabel: "Engineering", accentColor: "#bc8cff", iconColor: "#6b21a8", iconPath: "M5 3L1 8l4 5M11 3l4 5-4 5", // ⌨️ Keyboard emojiSvg: ``, }, quality: { bg: "#ffe4e6", roleLabel: "Quality", accentColor: "#f778ba", iconColor: "#9f1239", 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", // 🔬 Microscope emojiSvg: ``, }, design: { bg: "#fce7f3", roleLabel: "Design", accentColor: "#79c0ff", iconColor: "#9d174d", iconPath: "M12 2l2 2-9 9H3v-2zM9.5 4.5l2 2", // 🪄 Magic wand emojiSvg: ``, }, finance: { bg: "#fef3c7", roleLabel: "Finance", accentColor: "#f0883e", 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", // 📊 Bar chart (same as CFO) emojiSvg: ``, }, operations: { bg: "#e0f2fe", roleLabel: "Operations", accentColor: "#58a6ff", iconColor: "#075985", iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z", // ⚙️ Gear (same as COO) emojiSvg: ``, }, default: { bg: "#f3e8ff", roleLabel: "Agent", accentColor: "#bc8cff", iconColor: "#6b21a8", 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", // 👤 Person silhouette emojiSvg: ``, }, }; 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 getRoleInfo(node: OrgNode) { const tag = guessRoleTag(node); return { tag, ...(ROLE_ICONS[tag] || ROLE_ICONS.default) }; } // ── Style themes ───────────────────────────────────────────────── const THEMES: Record = { // 01 — Monochrome (Vercel-inspired, dark minimal) monochrome: { bgColor: "#18181b", cardBg: "#18181b", cardBorder: "#27272a", cardRadius: 6, cardShadow: null, lineColor: "#3f3f46", lineWidth: 1.5, nameColor: "#fafafa", roleColor: "#71717a", font: "'Inter', system-ui, sans-serif", watermarkColor: "rgba(255,255,255,0.25)", defs: () => "", bgExtras: () => "", renderCard: null, cardAccent: null, }, // 02 — Nebula (glassmorphism on cosmic gradient) nebula: { bgColor: "#0f0c29", cardBg: "rgba(255,255,255,0.07)", cardBorder: "rgba(255,255,255,0.12)", cardRadius: 6, cardShadow: null, lineColor: "rgba(255,255,255,0.25)", lineWidth: 1.5, nameColor: "#ffffff", roleColor: "rgba(255,255,255,0.45)", font: "'Inter', system-ui, sans-serif", watermarkColor: "rgba(255,255,255,0.2)", defs: (_w, _h) => ` `, bgExtras: (w, h) => ` `, renderCard: null, cardAccent: null, }, // 03 — Circuit (Linear/Raycast — indigo traces, amethyst CEO) circuit: { bgColor: "#0c0c0e", cardBg: "rgba(99,102,241,0.04)", cardBorder: "rgba(99,102,241,0.18)", cardRadius: 5, cardShadow: null, lineColor: "rgba(99,102,241,0.35)", lineWidth: 1.5, nameColor: "#e4e4e7", roleColor: "#6366f1", font: "'Inter', system-ui, sans-serif", watermarkColor: "rgba(99,102,241,0.3)", defs: () => "", bgExtras: () => "", renderCard: (ln: LayoutNode, theme: StyleTheme) => { const { tag, roleLabel, emojiSvg } = getRoleInfo(ln.node); const cx = ln.x + ln.width / 2; const isCeo = tag === "ceo"; const borderColor = isCeo ? "rgba(168,85,247,0.35)" : theme.cardBorder; const bgColor = isCeo ? "rgba(168,85,247,0.06)" : theme.cardBg; const avatarCY = ln.y + 24; const nameY = ln.y + 66; const roleY = ln.y + 82; return ` ${renderEmojiAvatar(cx, avatarCY, 17, "rgba(99,102,241,0.08)", emojiSvg, "rgba(99,102,241,0.15)")} ${escapeXml(ln.node.name)} ${escapeXml(roleLabel).toUpperCase()} `; }, cardAccent: null, }, // 04 — Warmth (Airbnb — light, colored avatars, soft shadows) warmth: { bgColor: "#fafaf9", cardBg: "#ffffff", cardBorder: "#e7e5e4", cardRadius: 6, cardShadow: "rgba(0,0,0,0.05)", lineColor: "#d6d3d1", lineWidth: 2, nameColor: "#1c1917", roleColor: "#78716c", font: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", watermarkColor: "rgba(0,0,0,0.25)", defs: () => "", bgExtras: () => "", renderCard: null, cardAccent: null, }, // 05 — Schematic (Blueprint — grid bg, monospace, colored top-bars) schematic: { bgColor: "#0d1117", cardBg: "rgba(13,17,23,0.92)", cardBorder: "#30363d", cardRadius: 4, cardShadow: null, lineColor: "#30363d", lineWidth: 1.5, nameColor: "#c9d1d9", roleColor: "#8b949e", font: "'JetBrains Mono', 'SF Mono', monospace", watermarkColor: "rgba(139,148,158,0.3)", defs: (w, h) => ` `, bgExtras: (w, h) => ``, renderCard: (ln: LayoutNode, theme: StyleTheme) => { const { tag, accentColor, emojiSvg } = getRoleInfo(ln.node); const cx = ln.x + ln.width / 2; // Schematic uses monospace role labels const schemaRoles: Record = { ceo: "chief_executive", cto: "chief_technology", cmo: "chief_marketing", cfo: "chief_financial", coo: "chief_operating", engineer: "engineer", quality: "quality_assurance", design: "designer", finance: "finance", operations: "operations", default: "agent", }; const roleText = schemaRoles[tag] || schemaRoles.default; const avatarCY = ln.y + 24; const nameY = ln.y + 66; const roleY = ln.y + 82; return ` ${renderEmojiAvatar(cx, avatarCY, 17, "rgba(48,54,61,0.3)", emojiSvg, theme.cardBorder)} ${escapeXml(ln.node.name)} ${escapeXml(roleText)} `; }, cardAccent: null, }, }; // ── Layout constants ───────────────────────────────────────────── const CARD_H = 96; const CARD_MIN_W = 150; const CARD_PAD_X = 22; const AVATAR_SIZE = 34; const GAP_X = 24; const GAP_Y = 56; const PADDING = 48; const LOGO_PADDING = 16; // ── Text measurement ───────────────────────────────────────────── function measureText(text: string, fontSize: number): number { return text.length * fontSize * 0.58; } function cardWidth(node: OrgNode): number { const { roleLabel } = getRoleInfo(node); 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, """); } /** Render a colorful Twemoji inside a circle at (cx, cy) with given radius */ function renderEmojiAvatar(cx: number, cy: number, radius: number, bgFill: string, emojiSvg: string, bgStroke?: string): string { const emojiSize = radius * 1.3; // emoji fills most of the circle const emojiX = cx - emojiSize / 2; const emojiY = cy - emojiSize / 2; const stroke = bgStroke ? `stroke="${bgStroke}" stroke-width="1"` : ""; return ` ${emojiSvg}`; } function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string { const { roleLabel, bg, emojiSvg } = getRoleInfo(ln.node); const cx = ln.x + ln.width / 2; const avatarCY = ln.y + 24; const nameY = ln.y + 52; const roleY = ln.y + 68; const filterId = `shadow-${ln.node.id}`; const shadowFilter = theme.cardShadow ? `filter="url(#${filterId})"` : ""; const shadowDef = theme.cardShadow ? ` ` : ""; // For dark themes without avatars, use a subtle circle const isLight = theme.bgColor === "#fafaf9" || theme.bgColor === "#ffffff"; const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)"; const avatarStroke = isLight ? undefined : "rgba(255,255,255,0.08)"; return ` ${shadowDef} ${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)} ${escapeXml(ln.node.name)} ${escapeXml(roleLabel)} `; } function renderConnectors(ln: LayoutNode, theme: StyleTheme): 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; const lc = theme.lineColor; const lw = theme.lineWidth; let svg = ""; 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, theme); } return svg; } function renderCards(ln: LayoutNode, theme: StyleTheme): string { const render = theme.renderCard || defaultRenderCard; let svg = render(ln, theme); for (const child of ln.children) { svg += renderCards(child, theme); } 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: 24×24 icon + wordmark, vertically centered const PAPERCLIP_LOGO_SVG = ` Paperclip `; // ── Public API ─────────────────────────────────────────────────── export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): string { const theme = THEMES[style] || THEMES.warmth; 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 ` ${theme.defs(svgW, svgH)} ${theme.bgExtras(svgW, svgH)} ${PAPERCLIP_LOGO_SVG} ${renderConnectors(layout, theme)} ${renderCards(layout, theme)} `; } export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): Promise { const svg = renderOrgChartSvg(orgTree, style); // Render at 2x density for retina-quality output return sharp(Buffer.from(svg), { density: 144 }).png().toBuffer(); }