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,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,
};
}