093e13b88f
- 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>
156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
"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,
|
|
};
|
|
}
|