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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user