feat: project cover art with AI generation, branding rename, RBAC fix, computation graph

- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 11:31:56 +01:00
parent 21af720f90
commit 093e13b88f
86 changed files with 5623 additions and 744 deletions
@@ -0,0 +1,540 @@
"use client";
import { useCallback, useMemo, useRef, useEffect, useState } from "react";
import { DOMAIN_COLORS, DOMAIN_LABELS, type Domain } from "~/components/analytics/computation-graph/domain-colors";
import type { PositionedNode } from "~/components/analytics/computation-graph/graph-data";
import type { ComputationGraphState } from "~/components/analytics/computation-graph/useComputationGraphData";
// ─── Layout constants ────────────────────────────────────────────────────────
const NODE_W = 220;
const NODE_H = 88;
const H_GAP = 50;
const V_GAP = 140;
const PADDING = 60;
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.12;
// ─── 2D DAG layout ──────────────────────────────────────────────────────────
interface PlacedNode extends PositionedNode {
px: number;
py: number;
}
function build2DLayout(nodes: PositionedNode[]): { placed: PlacedNode[]; width: number; height: number } {
if (nodes.length === 0) return { placed: [], width: 800, height: 400 };
const byLevel = new Map<number, PositionedNode[]>();
for (const n of nodes) {
const arr = byLevel.get(n.level) ?? [];
arr.push(n);
byLevel.set(n.level, arr);
}
const levels = [...byLevel.keys()].sort((a, b) => a - b);
const domainOrder: Domain[] = [
"EFFORT", "SAH", "ESTIMATE", "INPUT", "ALLOCATION", "COMMERCIAL",
"RULES", "EXPERIENCE", "CHARGEABILITY", "SPREAD", "BUDGET",
];
for (const [, arr] of byLevel) {
arr.sort((a, b) => {
const ai = domainOrder.indexOf(a.domain as Domain);
const bi = domainOrder.indexOf(b.domain as Domain);
return ai - bi;
});
}
let maxRowNodes = 0;
for (const arr of byLevel.values()) {
if (arr.length > maxRowNodes) maxRowNodes = arr.length;
}
const svgW = Math.max(800, maxRowNodes * (NODE_W + H_GAP) - H_GAP + PADDING * 2);
const svgH = levels.length * (NODE_H + V_GAP) - V_GAP + PADDING * 2;
const placed: PlacedNode[] = [];
for (let li = 0; li < levels.length; li++) {
const level = levels[li]!;
const row = byLevel.get(level)!;
const rowWidth = row.length * (NODE_W + H_GAP) - H_GAP;
const offsetX = (svgW - rowWidth) / 2;
for (let ni = 0; ni < row.length; ni++) {
placed.push({
...row[ni]!,
px: offsetX + ni * (NODE_W + H_GAP) + NODE_W / 2,
py: PADDING + li * (NODE_H + V_GAP) + NODE_H / 2,
});
}
}
return { placed, width: svgW, height: svgH };
}
// ─── Edge path ───────────────────────────────────────────────────────────────
function edgePath(src: PlacedNode, tgt: PlacedNode): string {
const sx = src.px;
const sy = src.py + NODE_H / 2;
const ex = tgt.px;
const ey = tgt.py - NODE_H / 2;
const midY = (sy + ey) / 2;
return `M ${sx} ${sy} C ${sx} ${midY}, ${ex} ${midY}, ${ex} ${ey}`;
}
// ─── Pre-computed edge label data ────────────────────────────────────────────
interface EdgeLabel {
x: number;
y: number;
anchor: "start" | "middle" | "end";
offsetX: number;
text: string;
pillW: number;
pillX: number;
}
function computeEdgeLabel(src: PlacedNode, tgt: PlacedNode, formula: string): EdgeLabel {
const labelX = (src.px + tgt.px) / 2;
const labelY = (src.py + NODE_H / 2 + tgt.py - NODE_H / 2) / 2;
const offsetX = src.px === tgt.px ? 14 : 0;
const anchor = src.px === tgt.px ? "start" : "middle";
const text = formula.length > 28 ? formula.slice(0, 26) + "..." : formula;
const pillW = text.length * 7 + 12;
const pillX = anchor === "middle" ? labelX - pillW / 2 + offsetX : labelX + offsetX - 4;
return { x: labelX, y: labelY, anchor, offsetX, text, pillW, pillX };
}
// ─── Component ───────────────────────────────────────────────────────────────
interface Props {
state: ComputationGraphState;
}
export default function ComputationGraph2D({ state }: Props) {
const { graphData, highlightedNodes, handleNodeClick } = state;
const containerRef = useRef<HTMLDivElement>(null);
const transformRef = useRef<HTMLDivElement>(null);
const [isDark, setIsDark] = useState(false);
// Pan & zoom via refs — zero React re-renders during interaction
const viewState = useRef({ zoom: 1, panX: 0, panY: 0 });
const zoomLabelRef = useRef<HTMLSpanElement>(null);
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const mousePosRef = useRef({ x: 0, y: 0 });
const tooltipRef = useRef<HTMLDivElement>(null);
const hoveredNodeRef = useRef<PositionedNode | null>(null);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
setIsDark(mq.matches || document.documentElement.classList.contains("dark"));
const handler = () => setIsDark(mq.matches || document.documentElement.classList.contains("dark"));
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const { placed, width, height } = useMemo(
() => build2DLayout(graphData.nodes),
[graphData.nodes],
);
const nodeMap = useMemo(() => {
const m = new Map<string, PlacedNode>();
for (const n of placed) m.set(n.id, n);
return m;
}, [placed]);
// ── Apply CSS transform to the wrapper div (GPU-composited, no SVG repaint) ──
const applyTransform = useCallback(() => {
const el = transformRef.current;
if (!el) return;
const { zoom, panX, panY } = viewState.current;
el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
}, []);
const updateZoomLabel = useCallback((zoom: number) => {
const el = zoomLabelRef.current;
if (el) el.textContent = `${Math.round(zoom * 100)}%`;
}, []);
// Update tooltip content + position via DOM (no React re-render)
const updateTooltip = useCallback((node: PositionedNode | null) => {
const el = tooltipRef.current;
if (!el) return;
if (!node) {
el.style.display = "none";
return;
}
el.style.display = "block";
el.style.top = `${mousePosRef.current.y + 16}px`;
el.style.left = `${mousePosRef.current.x + 16}px`;
const label = el.querySelector("[data-tt-label]");
const dot = el.querySelector("[data-tt-dot]") as HTMLElement | null;
const domain = el.querySelector("[data-tt-domain]");
const value = el.querySelector("[data-tt-value]");
const unit = el.querySelector("[data-tt-unit]");
const desc = el.querySelector("[data-tt-desc]");
const formula = el.querySelector("[data-tt-formula]");
if (label) label.textContent = node.label;
if (dot) dot.style.backgroundColor = node.color;
if (domain) domain.textContent = DOMAIN_LABELS[node.domain];
if (value) value.textContent = String(node.value);
if (unit) unit.textContent = node.unit;
if (desc) desc.textContent = node.description;
if (formula) {
formula.textContent = node.formula ?? "";
(formula as HTMLElement).style.display = node.formula ? "block" : "none";
}
}, []);
// Fit to view
const fitToView = useCallback(() => {
const el = containerRef.current;
if (!el || placed.length === 0) return;
const cw = el.clientWidth;
const ch = el.clientHeight;
const scaleX = cw / width;
const scaleY = ch / height;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.min(scaleX, scaleY) * 0.92));
viewState.current = {
zoom: newZoom,
panX: (cw - width * newZoom) / 2,
panY: (ch - height * newZoom) / 2,
};
applyTransform();
updateZoomLabel(newZoom);
}, [width, height, placed.length, applyTransform, updateZoomLabel]);
useEffect(() => {
if (placed.length > 0) fitToView();
}, [placed.length, fitToView]);
// Focus on a node
const focusNode = useCallback((node: PlacedNode) => {
const el = containerRef.current;
if (!el) return;
const cw = el.clientWidth;
const ch = el.clientHeight;
const targetZoom = 1.2;
viewState.current = {
zoom: targetZoom,
panX: cw / 2 - node.px * targetZoom,
panY: ch / 2 - node.py * targetZoom,
};
applyTransform();
updateZoomLabel(targetZoom);
}, [applyTransform, updateZoomLabel]);
// Wheel zoom — native event, direct DOM mutation
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const vs = viewState.current;
const direction = e.deltaY < 0 ? 1 : -1;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, vs.zoom * (1 + direction * ZOOM_STEP)));
const scale = newZoom / vs.zoom;
vs.panX = cx - scale * (cx - vs.panX);
vs.panY = cy - scale * (cy - vs.panY);
vs.zoom = newZoom;
applyTransform();
updateZoomLabel(newZoom);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [applyTransform, updateZoomLabel]);
// Pointer handlers — native events, direct DOM mutation, no React state
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onDown = (e: PointerEvent) => {
const tag = (e.target as Element).tagName;
const isBg = tag === "svg" || tag === "DIV" || !!(e.target as Element).closest?.("[data-bg]");
if (e.button === 1 || isBg) {
isPanning.current = true;
const vs = viewState.current;
panStart.current = { x: e.clientX, y: e.clientY, panX: vs.panX, panY: vs.panY };
(e.target as Element).setPointerCapture?.(e.pointerId);
e.preventDefault();
el.style.cursor = "grabbing";
}
};
const onMove = (e: PointerEvent) => {
mousePosRef.current.x = e.clientX;
mousePosRef.current.y = e.clientY;
if (hoveredNodeRef.current) {
const tip = tooltipRef.current;
if (tip) {
tip.style.top = `${e.clientY + 16}px`;
tip.style.left = `${e.clientX + 16}px`;
}
}
if (!isPanning.current) return;
viewState.current.panX = panStart.current.panX + (e.clientX - panStart.current.x);
viewState.current.panY = panStart.current.panY + (e.clientY - panStart.current.y);
applyTransform();
};
const onUp = () => {
isPanning.current = false;
el.style.cursor = "grab";
};
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp);
el.addEventListener("pointerleave", onUp);
return () => {
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointerleave", onUp);
};
}, [applyTransform, updateTooltip]);
// Zoom controls
const zoomBy = useCallback((direction: 1 | -1) => {
const el = containerRef.current;
if (!el) return;
const cw = el.clientWidth / 2;
const ch = el.clientHeight / 2;
const vs = viewState.current;
const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, vs.zoom * (1 + direction * ZOOM_STEP)));
const scale = next / vs.zoom;
vs.panX = cw - scale * (cw - vs.panX);
vs.panY = ch - scale * (ch - vs.panY);
vs.zoom = next;
applyTransform();
updateZoomLabel(next);
}, [applyTransform, updateZoomLabel]);
if (graphData.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
{state.viewMode === "resource" ? "Select a resource and month" : "Select a project"}
</div>
);
}
const cardBg = isDark ? "#1e293b" : "#ffffff";
const cardTextPrimary = isDark ? "#f1f5f9" : "#1e293b";
const cardTextSecondary = isDark ? "#94a3b8" : "#64748b";
const canvasBg = isDark ? "#0f172a" : "#f8fafc";
const gridLine = isDark ? "#1e293b" : "#e2e8f0";
const pillBg = isDark ? "#1e293b" : "#ffffff";
const pillStroke = isDark ? "#334155" : "#e2e8f0";
const pillText = isDark ? "#94a3b8" : "#475569";
return (
<div
ref={containerRef}
className="relative h-full w-full overflow-hidden"
style={{ backgroundColor: canvasBg, cursor: "grab" }}
>
{/* Wrapper div for CSS transform — GPU-composited, no SVG repaint */}
<div
ref={transformRef}
style={{
position: "absolute",
width: `${width}px`,
height: `${height}px`,
transformOrigin: "0 0",
willChange: "transform",
}}
>
<svg
width={width}
height={height}
style={{ position: "absolute", top: 0, left: 0 }}
>
<defs>
<marker id="arrow-default" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<path d="M 0 0 L 10 3.5 L 0 7 Z" fill={isDark ? "#475569" : "#94a3b8"} />
</marker>
<marker id="arrow-highlight" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<path d="M 0 0 L 10 3.5 L 0 7 Z" fill="#3b82f6" />
</marker>
</defs>
{/* Grid lines */}
{[...new Set(placed.map((n) => n.py))].map((y) => (
<line
key={`grid-${y}`}
x1="0" y1={y} x2={width} y2={y}
stroke={gridLine} strokeWidth="1" strokeDasharray="4 4" opacity="0.4"
/>
))}
{/* ── Links ── */}
{graphData.links.map((link) => {
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
const src = nodeMap.get(sourceId);
const tgt = nodeMap.get(targetId);
if (!src || !tgt) return null;
const isHighlighted = highlightedNodes
? highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)
: false;
const isNeutral = !highlightedNodes;
const isDimmed = highlightedNodes && !isHighlighted;
const showLabel = (isNeutral || isHighlighted) && link.formula;
return (
<g key={`${sourceId}-${targetId}`}>
<path
d={edgePath(src, tgt)}
fill="none"
stroke={isDimmed ? (isDark ? "#334155" : "#d1d5db") : src.color}
strokeWidth={isDimmed ? 1 : isHighlighted ? (link.weight ?? 1) * 2.5 : (link.weight ?? 1) * 1.5}
strokeOpacity={isDimmed ? 0.2 : isHighlighted ? 0.8 : 0.4}
markerEnd={isDimmed ? undefined : (isHighlighted ? "url(#arrow-highlight)" : "url(#arrow-default)")}
/>
{showLabel && (() => {
const lbl = computeEdgeLabel(src, tgt, link.formula);
return (
<>
<rect
x={lbl.pillX} y={lbl.y - 10}
width={lbl.pillW} height={20} rx="4"
fill={pillBg} stroke={pillStroke} strokeWidth="1" opacity="0.95"
/>
<text
x={lbl.x + lbl.offsetX} y={lbl.y + 1}
fontSize="12" fill={pillText}
textAnchor={lbl.anchor} dominantBaseline="central"
className="pointer-events-none select-none"
fontFamily="ui-monospace, monospace"
>
{lbl.text}
</text>
</>
);
})()}
</g>
);
})}
{/* ── Nodes ── */}
{placed.map((node) => {
const isActive = !highlightedNodes || highlightedNodes.has(node.id);
const isDimmed = highlightedNodes && !isActive;
const color = DOMAIN_COLORS[node.domain as Domain] ?? "#6b7280";
const valueStr = typeof node.value === "number"
? node.value.toFixed(1)
: String(node.value);
const displayValue = valueStr.length > 16 ? valueStr.slice(0, 14) + "..." : valueStr;
const displayLabel = node.label.length > 22 ? node.label.slice(0, 20) + "..." : node.label;
return (
<g
key={node.id}
transform={`translate(${node.px - NODE_W / 2}, ${node.py - NODE_H / 2})`}
onClick={(e) => {
e.stopPropagation();
if (e.detail === 2) {
focusNode(node);
} else {
handleNodeClick(node.id);
}
}}
onMouseEnter={() => { hoveredNodeRef.current = node; updateTooltip(node); }}
onMouseLeave={() => { hoveredNodeRef.current = null; updateTooltip(null); }}
className="cursor-pointer"
opacity={isDimmed ? 0.2 : 1}
>
<rect
width={NODE_W} height={NODE_H} rx="10"
fill={cardBg}
stroke={isDark ? "#334155" : "#e2e8f0"}
strokeWidth="1"
/>
<path
d={`M 10 0 H ${NODE_W - 10} Q ${NODE_W} 0 ${NODE_W} 10 V 4 H 0 V 10 Q 0 0 10 0 Z`}
fill={color}
/>
<text x="12" y="22" fontSize="11" fontWeight="500" fill={cardTextSecondary} fontFamily="system-ui, sans-serif">
{DOMAIN_LABELS[node.domain as Domain]}
</text>
<text x="12" y="44" fontSize="14" fontWeight="600" fill={cardTextPrimary} fontFamily="system-ui, sans-serif">
{displayLabel}
</text>
<text x="12" y="68" fontSize="18" fontWeight="700" fill={color} fontFamily="ui-monospace, monospace">
{displayValue}
</text>
<text x={NODE_W - 12} y="68" fontSize="12" fontWeight="400" fill={cardTextSecondary} textAnchor="end" fontFamily="system-ui, sans-serif">
{node.unit}
</text>
</g>
);
})}
</svg>
</div>
{/* Background click target for pan (covers viewport) */}
<div data-bg="true" className="absolute inset-0" style={{ zIndex: -1 }} />
{/* ── Zoom controls (bottom-right) ── */}
<div className="absolute bottom-4 right-4 flex flex-col gap-1">
<button
onClick={() => zoomBy(1)}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-bold text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
title="Zoom in"
>
+
</button>
<button
onClick={() => zoomBy(-1)}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-bold text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
title="Zoom out"
>
-
</button>
<button
onClick={fitToView}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
title="Fit to view"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="1" y="1" width="12" height="12" rx="2" />
<path d="M 4 7 L 1 7 M 10 7 L 13 7 M 7 4 L 7 1 M 7 10 L 7 13" />
</svg>
</button>
<span ref={zoomLabelRef} className="mt-1 text-center text-[10px] text-zinc-400">
100%
</span>
</div>
{/* Hover Tooltip — always rendered, shown/hidden via DOM */}
<div
ref={tooltipRef}
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-800 shadow-xl dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200"
style={{ display: "none", top: 0, left: 0 }}
>
<div className="flex items-center gap-2">
<span data-tt-dot className="inline-block h-2.5 w-2.5 rounded-full" />
<span data-tt-label className="font-semibold" />
<span data-tt-domain className="ml-auto text-xs text-zinc-500" />
</div>
<div className="mt-1 text-lg font-bold">
<span data-tt-value /> <span data-tt-unit className="text-sm font-normal text-zinc-400" />
</div>
<div data-tt-desc className="mt-1 text-xs text-zinc-500 dark:text-zinc-400" />
<div data-tt-formula className="mt-1 font-mono text-xs text-blue-500" />
</div>
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import { useCallback, useState } from "react";
import dynamic from "next/dynamic";
import { DOMAIN_LABELS } from "~/components/analytics/computation-graph/domain-colors";
import { createNodeSprite, createDimmedNodeSprite, getLinkColor } from "~/components/analytics/computation-graph/node-renderer";
import type { PositionedNode } from "~/components/analytics/computation-graph/graph-data";
import type { ComputationGraphState } from "~/components/analytics/computation-graph/useComputationGraphData";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph3D = dynamic(() => import("react-force-graph-3d"), { ssr: false }) as any;
interface Props {
state: ComputationGraphState;
}
export default function ComputationGraph3DView({ state }: Props) {
const { graphData, highlightedNodes, handleNodeClick, setHoveredNode } = state;
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onNodeClick = useCallback((node: any) => {
handleNodeClick((node as PositionedNode).id);
}, [handleNodeClick]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onNodeHover = useCallback((node: any) => {
setHoveredNode(node as PositionedNode | null);
}, [setHoveredNode]);
const nodeThreeObject = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(node: any) => {
const n = node as PositionedNode;
if (highlightedNodes && !highlightedNodes.has(n.id)) {
return createDimmedNodeSprite(n);
}
return createNodeSprite(n);
},
[highlightedNodes],
);
const linkColorFn = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(link: any) => {
if (!highlightedNodes) return getLinkColor(link, 0.4);
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
const targetId = typeof link.target === "object" ? link.target.id : link.target;
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
return getLinkColor(link, 0.9);
}
return getLinkColor(link, 0.08);
},
[highlightedNodes],
);
const linkWidthFn = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(link: any) => {
if (!highlightedNodes) return link.weight ?? 1;
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
const targetId = typeof link.target === "object" ? link.target.id : link.target;
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
return (link.weight ?? 1) * 2.5;
}
return 0.3;
},
[highlightedNodes],
);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
}, []);
if (graphData.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
{state.viewMode === "resource" ? "Select a resource and month" : "Select a project"}
</div>
);
}
return (
<div className="relative h-full w-full" onMouseMove={handleMouseMove}>
<ForceGraph3D
graphData={graphData}
nodeThreeObject={nodeThreeObject}
nodeThreeObjectExtend={false}
onNodeClick={onNodeClick}
onNodeHover={onNodeHover}
linkColor={linkColorFn}
linkWidth={linkWidthFn}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.8}
linkCurvature={0.1}
backgroundColor="#0f172a"
showNavInfo={false}
warmupTicks={50}
cooldownTicks={0}
/>
{/* Hover Tooltip */}
{state.hoveredNode && (
<div
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-3 text-sm text-zinc-200 shadow-xl"
style={{ top: mousePos.y + 16, left: mousePos.x + 16 }}
>
<div className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: state.hoveredNode.color }}
/>
<span className="font-semibold">{state.hoveredNode.label}</span>
<span className="ml-auto text-xs text-zinc-500">{DOMAIN_LABELS[state.hoveredNode.domain]}</span>
</div>
<div className="mt-1 text-lg font-bold">
{state.hoveredNode.value} <span className="text-sm font-normal text-zinc-400">{state.hoveredNode.unit}</span>
</div>
<div className="mt-1 text-xs text-zinc-400">{state.hoveredNode.description}</div>
{state.hoveredNode.formula && (
<div className="mt-1 font-mono text-xs text-blue-400">{state.hoveredNode.formula}</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import {
DOMAIN_COLORS,
DOMAIN_LABELS,
} from "~/components/analytics/computation-graph/domain-colors";
import { useComputationGraphData } from "~/components/analytics/computation-graph/useComputationGraphData";
import ComputationGraph2D from "~/components/analytics/ComputationGraph2D";
import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
type Dimension = "2d" | "3d";
export default function ComputationGraphClient() {
const state = useComputationGraphData();
const [dimension, setDimension] = useState<Dimension>("2d");
const {
viewMode, setViewMode,
resourceId, setResourceId,
month, setMonth,
projectId, setProjectId,
resources, projects,
isLoading,
activeDomains,
graphData,
highlightedNodes, setHighlightedNodes,
domainFilter, toggleDomain,
} = state;
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* ── Header Bar ── */}
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
{/* 2D / 3D Toggle */}
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
<button
onClick={() => setDimension("2d")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
dimension === "2d"
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-l-lg`}
>
2D
</button>
<button
onClick={() => setDimension("3d")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
dimension === "3d"
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-r-lg`}
>
3D
</button>
</div>
{/* View Mode Toggle */}
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
<button
onClick={() => setViewMode("resource")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === "resource"
? "bg-blue-600 text-white"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-l-lg`}
>
Resource View
</button>
<button
onClick={() => setViewMode("project")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === "project"
? "bg-blue-600 text-white"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-r-lg`}
>
Project View
</button>
</div>
{/* Selectors */}
{viewMode === "resource" ? (
<>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">Select Resource...</option>
{resources.map((r: { id: string; displayName: string; eid: string }) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
/>
</>
) : (
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">Select Project...</option>
{(Array.isArray(projects) ? projects : []).map((p: { id: string; name: string; shortCode?: string | null }) => (
<option key={p.id} value={p.id}>
{p.shortCode ? `${p.shortCode}` : ""}{p.name}
</option>
))}
</select>
)}
{/* Meta info */}
{graphData.nodes.length > 0 && (
<span className="ml-auto text-xs text-zinc-500">
{graphData.nodes.length} nodes, {graphData.links.length} links
</span>
)}
{/* Clear highlight */}
{highlightedNodes && (
<button
onClick={() => setHighlightedNodes(null)}
className="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300"
>
Clear highlight
</button>
)}
</div>
{/* ── Main Area ── */}
<div className="relative flex flex-1 overflow-hidden">
{/* Domain Filter Sidebar */}
<div className="flex w-48 flex-col gap-1 border-r border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900">
<span className="mb-1 text-xs font-semibold uppercase text-zinc-500">Domains</span>
{activeDomains.map((domain) => (
<button
key={domain}
onClick={() => toggleDomain(domain)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors ${
domainFilter.has(domain)
? "text-zinc-400 line-through"
: "text-zinc-700 dark:text-zinc-300"
} hover:bg-zinc-200 dark:hover:bg-zinc-800`}
>
<span
className="inline-block h-3 w-3 rounded-full"
style={{
backgroundColor: domainFilter.has(domain) ? "#9ca3af" : DOMAIN_COLORS[domain],
}}
/>
{DOMAIN_LABELS[domain]}
</button>
))}
</div>
{/* Graph View */}
<div className="flex-1 overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center text-zinc-500">
Loading computation graph...
</div>
) : dimension === "2d" ? (
<ComputationGraph2D state={state} />
) : (
<ComputationGraph3D state={state} />
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
// ─── Domain Types & Colors for the 3D Computation Graph ────────────────────
export type Domain =
| "INPUT"
| "SAH"
| "ALLOCATION"
| "RULES"
| "CHARGEABILITY"
| "BUDGET"
| "ESTIMATE"
| "COMMERCIAL"
| "EXPERIENCE"
| "EFFORT"
| "SPREAD";
export const DOMAIN_COLORS: Record<Domain, string> = {
INPUT: "#94a3b8",
SAH: "#3b82f6",
ALLOCATION: "#f97316",
RULES: "#8b5cf6",
CHARGEABILITY: "#22c55e",
BUDGET: "#ef4444",
ESTIMATE: "#06b6d4",
COMMERCIAL: "#f59e0b",
EXPERIENCE: "#ec4899",
EFFORT: "#14b8a6",
SPREAD: "#6366f1",
};
export const DOMAIN_LABELS: Record<Domain, string> = {
INPUT: "Inputs",
SAH: "SAH",
ALLOCATION: "Allocation",
RULES: "Rules Engine",
CHARGEABILITY: "Chargeability",
BUDGET: "Budget",
ESTIMATE: "Estimates",
COMMERCIAL: "Commercial",
EXPERIENCE: "Experience Mult.",
EFFORT: "Effort Rules",
SPREAD: "Monthly Spread",
};
// ─── Graph Node / Link Types ────────────────────────────────────────────────
export interface GraphNode {
id: string;
label: string;
value: number | string;
unit: string;
domain: Domain;
description: string;
formula?: string;
level: number; // 0=Input, 1=Intermediate, 2=Derived, 3=Output
// react-force-graph position hints
fx?: number;
fy?: number;
fz?: number;
}
export interface GraphLink {
source: string;
target: string;
formula: string;
weight: number; // 1-3 for line thickness
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
// ─── Resource View Mode ─────────────────────────────────────────────────────
export const RESOURCE_VIEW_DOMAINS: Domain[] = [
"INPUT",
"SAH",
"ALLOCATION",
"RULES",
"CHARGEABILITY",
"BUDGET",
];
export const PROJECT_VIEW_DOMAINS: Domain[] = [
"INPUT",
"ESTIMATE",
"COMMERCIAL",
"EXPERIENCE",
"EFFORT",
"SPREAD",
"BUDGET",
];
@@ -0,0 +1,125 @@
import type { GraphNode, GraphLink, Domain } from "./domain-colors";
import { DOMAIN_COLORS } from "./domain-colors";
// ─── Layout Constants ───────────────────────────────────────────────────────
const LEVEL_Y_SPACING = 120;
const DOMAIN_X_SPACING = 200;
const NODE_Z_JITTER = 40;
// Domain → X position offset for clustering
const DOMAIN_X_OFFSETS: Partial<Record<Domain, number>> = {
INPUT: 0,
SAH: -300,
ALLOCATION: -100,
RULES: 100,
CHARGEABILITY: 300,
BUDGET: 500,
ESTIMATE: -200,
COMMERCIAL: 0,
EXPERIENCE: 200,
EFFORT: -400,
SPREAD: 400,
};
// ─── Position Calculator ────────────────────────────────────────────────────
export interface PositionedNode extends GraphNode {
fx: number;
fy: number;
fz: number;
color: string;
}
export interface ForceGraphData {
nodes: PositionedNode[];
links: GraphLink[];
}
/**
* Assigns 3D positions to nodes based on their level (Y) and domain (X),
* with slight Z jitter to prevent overlap.
*/
export function buildForceGraphData(
nodes: GraphNode[],
links: GraphLink[],
): ForceGraphData {
// Group nodes by domain+level to spread within each cluster
const groups = new Map<string, GraphNode[]>();
for (const node of nodes) {
const key = `${node.domain}:${node.level}`;
const arr = groups.get(key) ?? [];
arr.push(node);
groups.set(key, arr);
}
const positionedNodes: PositionedNode[] = nodes.map((node) => {
const key = `${node.domain}:${node.level}`;
const siblings = groups.get(key) ?? [node];
const idx = siblings.indexOf(node);
const count = siblings.length;
// Center siblings around the domain X offset
const baseX = DOMAIN_X_OFFSETS[node.domain] ?? 0;
const spreadX = count > 1 ? (idx - (count - 1) / 2) * 80 : 0;
return {
...node,
fx: baseX + spreadX,
fy: node.level * LEVEL_Y_SPACING,
fz: (idx % 2 === 0 ? 1 : -1) * NODE_Z_JITTER * (Math.floor(idx / 2) + 1) * 0.5,
color: DOMAIN_COLORS[node.domain],
};
});
// Filter links to only reference existing node IDs
const nodeIds = new Set(positionedNodes.map((n) => n.id));
const validLinks = links.filter((link) => nodeIds.has(link.source) && nodeIds.has(link.target));
return { nodes: positionedNodes, links: validLinks };
}
// ─── Highlight Helpers ──────────────────────────────────────────────────────
/**
* Given a clicked node, returns the set of node IDs in its
* upstream (ancestors) and downstream (descendants) path.
*/
export function getConnectedNodeIds(
nodeId: string,
links: GraphLink[],
): Set<string> {
const connected = new Set<string>([nodeId]);
// BFS upstream (nodes that feed into this one)
const queue = [nodeId];
while (queue.length > 0) {
const current = queue.shift()!;
for (const link of links) {
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
if (targetId === current && !connected.has(sourceId)) {
connected.add(sourceId);
queue.push(sourceId);
}
}
}
// BFS downstream (nodes this one feeds into)
const queue2 = [nodeId];
const visited = new Set<string>([nodeId]);
while (queue2.length > 0) {
const current = queue2.shift()!;
for (const link of links) {
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
if (sourceId === current && !visited.has(targetId)) {
connected.add(targetId);
visited.add(targetId);
queue2.push(targetId);
}
}
}
return connected;
}
@@ -0,0 +1,120 @@
import * as THREE from "three";
import type { PositionedNode } from "./graph-data";
// ─── Canvas-based node sprites ──────────────────────────────────────────────
const spriteCache = new Map<string, THREE.Sprite>();
/**
* Creates a Three.js sprite for a graph node: colored circle with value label.
*/
export function createNodeSprite(node: PositionedNode): THREE.Sprite {
const cacheKey = `${node.id}:${node.value}:${node.color}`;
const cached = spriteCache.get(cacheKey);
if (cached) return cached.clone();
const canvas = document.createElement("canvas");
const size = 512;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 16;
// Outer glow ring
const gradient = ctx.createRadialGradient(cx, cy, radius * 0.8, cx, cy, radius);
gradient.addColorStop(0, node.color + "aa");
gradient.addColorStop(1, node.color + "00");
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// Dark background circle for contrast
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2);
ctx.fillStyle = "#0f172a";
ctx.fill();
// Colored border ring
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2);
ctx.strokeStyle = node.color;
ctx.lineWidth = 8;
ctx.stroke();
// Subtle inner fill
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.74, 0, Math.PI * 2);
ctx.fillStyle = node.color + "20";
ctx.fill();
// Label (top)
ctx.fillStyle = "#ffffff";
ctx.font = "bold 36px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "#000000";
ctx.shadowBlur = 6;
const label = node.label.length > 16 ? node.label.slice(0, 14) + "..." : node.label;
ctx.fillText(label, cx, cy - 28);
// Value (bottom) — brighter, larger
ctx.fillStyle = node.color;
ctx.font = "bold 44px system-ui, sans-serif";
ctx.shadowBlur = 4;
const valueStr = typeof node.value === "number" ? node.value.toFixed(1) : String(node.value);
const displayValue = valueStr.length > 12 ? valueStr.slice(0, 10) + "..." : valueStr;
ctx.fillText(displayValue, cx, cy + 24);
// Unit (small, below value)
if (node.unit) {
ctx.fillStyle = "#94a3b8";
ctx.font = "24px system-ui, sans-serif";
ctx.shadowBlur = 0;
ctx.fillText(node.unit, cx, cy + 60);
}
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(50, 50, 1);
spriteCache.set(cacheKey, sprite);
return sprite;
}
/**
* Creates a dimmed version of a node sprite (for non-highlighted nodes).
*/
export function createDimmedNodeSprite(node: PositionedNode): THREE.Sprite {
const sprite = createNodeSprite({ ...node, color: "#4b5563" });
sprite.material.opacity = 0.3;
return sprite;
}
// ─── Link particle rendering ───────────────────────────────────────────────
/**
* Returns a color for a link based on the source node's domain color.
*/
export function getLinkColor(
link: { source: string | { color?: string }; weight: number },
opacity = 0.4,
): string {
const sourceColor = typeof link.source === "object" && link.source.color
? link.source.color
: "#6b7280";
// Convert hex to rgba
const r = parseInt(sourceColor.slice(1, 3), 16);
const g = parseInt(sourceColor.slice(3, 5), 16);
const b = parseInt(sourceColor.slice(5, 7), 16);
return `rgba(${r},${g},${b},${opacity})`;
}
@@ -0,0 +1,155 @@
"use client";
import { useState, useMemo, useEffect, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import {
RESOURCE_VIEW_DOMAINS,
PROJECT_VIEW_DOMAINS,
type Domain,
type GraphNode,
} from "./domain-colors";
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
export type ViewMode = "resource" | "project";
export interface ComputationGraphState {
viewMode: ViewMode;
setViewMode: (m: ViewMode) => void;
resourceId: string;
setResourceId: (id: string) => void;
month: string;
setMonth: (m: string) => void;
projectId: string;
setProjectId: (id: string) => void;
resources: Array<{ id: string; displayName: string; eid: string }>;
projects: Array<{ id: string; name: string; shortCode?: string | null }>;
isLoading: boolean;
activeDomains: Domain[];
graphData: ForceGraphData;
highlightedNodes: Set<string> | null;
setHighlightedNodes: (s: Set<string> | null) => void;
hoveredNode: PositionedNode | null;
setHoveredNode: (n: PositionedNode | null) => void;
domainFilter: Set<Domain>;
toggleDomain: (domain: Domain) => void;
handleNodeClick: (nodeId: string) => void;
}
export function useComputationGraphData(): ComputationGraphState {
const [viewMode, setViewMode] = useState<ViewMode>("resource");
const [resourceId, setResourceId] = useState<string>("");
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [projectId, setProjectId] = useState<string>("");
const [highlightedNodes, setHighlightedNodes] = useState<Set<string> | null>(null);
const [hoveredNode, setHoveredNode] = useState<PositionedNode | null>(null);
const [domainFilter, setDomainFilter] = useState<Set<Domain>>(new Set());
// Load selectors
const { data: resourceData } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const resources = resourceData?.resources ?? [];
const { data: projectData } = trpc.project.list.useQuery(
{},
{ staleTime: 60_000 },
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projects: Array<{ id: string; name: string; shortCode?: string | null }> = (projectData as any)?.projects ?? (projectData as any) ?? [];
// Auto-select first resource/project
useEffect(() => {
if (!resourceId && resources.length > 0) {
setResourceId(resources[0]!.id);
}
}, [resources, resourceId]);
useEffect(() => {
if (!projectId && Array.isArray(projects) && projects.length > 0) {
setProjectId(projects[0]!.id);
}
}, [projects, projectId]);
// Fetch graph data
const { data: resourceGraphData, isLoading: resourceLoading } = trpc.computationGraph.getResourceData.useQuery(
{ resourceId, month },
{ enabled: viewMode === "resource" && !!resourceId, staleTime: 30_000 },
);
const { data: projectGraphData, isLoading: projectLoading } = trpc.computationGraph.getProjectData.useQuery(
{ projectId },
{ enabled: viewMode === "project" && !!projectId, staleTime: 30_000 },
);
const rawData = viewMode === "resource" ? resourceGraphData : projectGraphData;
const isLoading = viewMode === "resource" ? resourceLoading : projectLoading;
const activeDomains = viewMode === "resource" ? RESOURCE_VIEW_DOMAINS : PROJECT_VIEW_DOMAINS;
// Build graph data with positions
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] };
let filteredNodes = rawData.nodes as GraphNode[];
if (domainFilter.size > 0) {
filteredNodes = filteredNodes.filter((nd: GraphNode) => !domainFilter.has(nd.domain as Domain));
}
const filteredNodeIds = new Set(filteredNodes.map((nd: GraphNode) => nd.id));
const filteredLinks = rawData.links.filter(
(lk: { source: string; target: string }) =>
filteredNodeIds.has(lk.source) && filteredNodeIds.has(lk.target),
);
return buildForceGraphData(filteredNodes, filteredLinks);
}, [rawData, domainFilter]);
// Domain filter toggle
const toggleDomain = useCallback((domain: Domain) => {
setDomainFilter((prev) => {
const next = new Set(prev);
if (next.has(domain)) {
next.delete(domain);
} else {
next.add(domain);
}
return next;
});
}, []);
// Node click → highlight path
const handleNodeClick = useCallback(
(nodeId: string) => {
if (highlightedNodes?.has(nodeId)) {
setHighlightedNodes(null);
} else {
const connected = getConnectedNodeIds(nodeId, graphData.links);
setHighlightedNodes(connected);
}
},
[graphData.links, highlightedNodes],
);
return {
viewMode,
setViewMode,
resourceId,
setResourceId,
month,
setMonth,
projectId,
setProjectId,
resources,
projects,
isLoading,
activeDomains,
graphData,
highlightedNodes,
setHighlightedNodes,
hoveredNode,
setHoveredNode,
domainFilter,
toggleDomain,
handleNodeClick,
};
}