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