diff --git a/server/src/routes/org-chart-svg.ts b/server/src/routes/org-chart-svg.ts index cfc49f88..1f239516 100644 --- a/server/src/routes/org-chart-svg.ts +++ b/server/src/routes/org-chart-svg.ts @@ -50,58 +50,87 @@ interface StyleTheme { cardAccent: ((tag: string) => string) | null; } -// ── Role icons (shared across styles) ──────────────────────────── +// ── 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", iconColor: "#92400e", accentColor: "#f0883e", + 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", iconColor: "#1e40af", accentColor: "#58a6ff", + bg: "#dbeafe", roleLabel: "Technology", accentColor: "#58a6ff", iconColor: "#1e40af", iconPath: "M2 3l5 5-5 5M9 13h5", + // 💻 Laptop + emojiSvg: ``, }, cmo: { - bg: "#dcfce7", roleLabel: "Marketing", iconColor: "#166534", accentColor: "#3fb950", + 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", iconColor: "#92400e", accentColor: "#f0883e", + 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", iconColor: "#075985", accentColor: "#58a6ff", - 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", + 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", iconColor: "#6b21a8", accentColor: "#bc8cff", + bg: "#f3e8ff", roleLabel: "Engineering", accentColor: "#bc8cff", iconColor: "#6b21a8", iconPath: "M5 3L1 8l4 5M11 3l4 5-4 5", + // ⌨️ Keyboard + emojiSvg: ``, }, quality: { - bg: "#ffe4e6", roleLabel: "Quality", iconColor: "#9f1239", accentColor: "#f778ba", + 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", iconColor: "#9d174d", accentColor: "#79c0ff", + 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", iconColor: "#92400e", accentColor: "#f0883e", + 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", iconColor: "#075985", accentColor: "#58a6ff", - 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", + 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", iconColor: "#6b21a8", accentColor: "#bc8cff", + 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: ``, }, }; @@ -199,7 +228,7 @@ const THEMES: Record = { defs: () => "", bgExtras: () => "", renderCard: (ln: LayoutNode, theme: StyleTheme) => { - const { tag, roleLabel, iconPath, iconColor } = getRoleInfo(ln.node); + 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; @@ -208,15 +237,10 @@ const THEMES: Record = { const avatarCY = ln.y + 24; const nameY = ln.y + 52; const roleY = ln.y + 68; - const iconScale = 0.7; - const iconOffset = (34 * iconScale) / 2; 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()} `; @@ -262,7 +286,7 @@ const THEMES: Record = { `, bgExtras: (w, h) => ``, renderCard: (ln: LayoutNode, theme: StyleTheme) => { - const { tag, accentColor, iconPath, iconColor } = getRoleInfo(ln.node); + const { tag, accentColor, emojiSvg } = getRoleInfo(ln.node); const cx = ln.x + ln.width / 2; // Schematic uses monospace role labels @@ -277,16 +301,11 @@ const THEMES: Record = { const avatarCY = ln.y + 24; const nameY = ln.y + 52; const roleY = ln.y + 68; - const iconScale = 0.7; - const iconOffset = (34 * iconScale) / 2; return ` - - - - + ${renderEmojiAvatar(cx, avatarCY, 17, "rgba(48,54,61,0.3)", emojiSvg, theme.cardBorder)} ${escapeXml(ln.node.name)} ${escapeXml(roleText)} `; @@ -365,19 +384,24 @@ 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, iconColor, iconPath } = getRoleInfo(ln.node); + 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 iconScale = 0.7; - const iconOffset = (AVATAR_SIZE * iconScale) / 2; - const iconX = cx - iconOffset; - const iconY = avatarCY - iconOffset; - const filterId = `shadow-${ln.node.id}`; const shadowFilter = theme.cardShadow ? `filter="url(#${filterId})"` @@ -392,15 +416,12 @@ function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string { // 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 ? "" : `stroke="rgba(255,255,255,0.08)" stroke-width="1"`; + 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)} `;