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>
121 lines
3.7 KiB
TypeScript
121 lines
3.7 KiB
TypeScript
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})`;
|
|
}
|