Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
/** Campaign constellation force-graph helpers for Emberwake War Room. */
|
||
|
||
import type { WarRoomCampaign } from './warRoom';
|
||
|
||
export interface ConstellationNode {
|
||
id: string;
|
||
campaign: string;
|
||
hits: number;
|
||
online: number;
|
||
conversionPct: number;
|
||
pins: string[];
|
||
radius: number;
|
||
color: string;
|
||
brightness: number;
|
||
pulsing: boolean;
|
||
x: number;
|
||
y: number;
|
||
vx: number;
|
||
vy: number;
|
||
}
|
||
|
||
export interface ConstellationEdge {
|
||
source: string;
|
||
target: string;
|
||
sharedPins: string[];
|
||
}
|
||
|
||
export interface ConstellationGraph {
|
||
nodes: ConstellationNode[];
|
||
edges: ConstellationEdge[];
|
||
}
|
||
|
||
const MIN_RADIUS = 10;
|
||
const MAX_RADIUS = 36;
|
||
|
||
/** Node radius scaled by hits (sqrt curve for readability). */
|
||
export function nodeRadius(hits: number, maxHits: number): number {
|
||
if (hits <= 0) return MIN_RADIUS;
|
||
if (maxHits <= 0) return MIN_RADIUS + 4;
|
||
const t = Math.sqrt(hits / maxHits);
|
||
return MIN_RADIUS + t * (MAX_RADIUS - MIN_RADIUS);
|
||
}
|
||
|
||
/** Brightness 0.35–1.0 from online agent count. */
|
||
export function nodeBrightness(online: number, maxOnline: number): number {
|
||
if (online <= 0) return 0.35;
|
||
if (maxOnline <= 0) return 1;
|
||
return 0.35 + 0.65 * (online / maxOnline);
|
||
}
|
||
|
||
/** Aether palette: cool cyan (low) → gold (mid) → rose (high conversion). */
|
||
export function conversionColor(pct: number): string {
|
||
const t = Math.max(0, Math.min(1, pct / 100));
|
||
if (t < 0.5) {
|
||
const u = t / 0.5;
|
||
const r = Math.round(61 + u * (201 - 61));
|
||
const g = Math.round(214 + u * (162 - 214));
|
||
const b = Math.round(198 + u * (39 - 198));
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
const u = (t - 0.5) / 0.5;
|
||
const r = Math.round(201 + u * (244 - 201));
|
||
const g = Math.round(162 + u * (63 - 162));
|
||
const b = Math.round(39 + u * (94 - 39));
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
/** Edges link campaigns that share at least one pin/build id. */
|
||
export function buildConstellationEdges(
|
||
campaigns: Pick<WarRoomCampaign, 'campaign' | 'pins'>[],
|
||
): ConstellationEdge[] {
|
||
const edges: ConstellationEdge[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
for (let i = 0; i < campaigns.length; i++) {
|
||
const pinsA = new Set((campaigns[i].pins ?? []).filter(Boolean));
|
||
if (!pinsA.size) continue;
|
||
|
||
for (let j = i + 1; j < campaigns.length; j++) {
|
||
const shared = (campaigns[j].pins ?? []).filter((p) => pinsA.has(p));
|
||
if (!shared.length) continue;
|
||
|
||
const a = campaigns[i].campaign;
|
||
const b = campaigns[j].campaign;
|
||
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
|
||
edges.push({ source: a, target: b, sharedPins: [...new Set(shared)] });
|
||
}
|
||
}
|
||
|
||
return edges;
|
||
}
|
||
|
||
/** Build graph nodes + pin-sharing edges from war-room campaigns. */
|
||
export function buildConstellationGraph(campaigns: WarRoomCampaign[]): ConstellationGraph {
|
||
const maxHits = Math.max(1, ...campaigns.map((c) => c.hits ?? 0));
|
||
const maxOnline = Math.max(1, ...campaigns.map((c) => c.online ?? 0));
|
||
|
||
const nodes: ConstellationNode[] = campaigns.map((c) => {
|
||
const hits = c.hits ?? 0;
|
||
const online = c.online ?? 0;
|
||
return {
|
||
id: c.campaign,
|
||
campaign: c.campaign,
|
||
hits,
|
||
online,
|
||
conversionPct: c.conversion_pct ?? 0,
|
||
pins: c.pins ?? [],
|
||
radius: nodeRadius(hits, maxHits),
|
||
color: conversionColor(c.conversion_pct ?? 0),
|
||
brightness: nodeBrightness(online, maxOnline),
|
||
pulsing: online > 0,
|
||
x: 0,
|
||
y: 0,
|
||
vx: 0,
|
||
vy: 0,
|
||
};
|
||
});
|
||
|
||
return { nodes, edges: buildConstellationEdges(campaigns) };
|
||
}
|
||
|
||
/** Scatter nodes in a circle for force-sim cold start. */
|
||
export function initNodePositions(nodes: ConstellationNode[], width: number, height: number): void {
|
||
const cx = width / 2;
|
||
const cy = height / 2;
|
||
const ring = Math.min(width, height) * 0.32;
|
||
|
||
nodes.forEach((n, i) => {
|
||
const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
|
||
n.x = cx + Math.cos(angle) * ring;
|
||
n.y = cy + Math.sin(angle) * ring;
|
||
n.vx = 0;
|
||
n.vy = 0;
|
||
});
|
||
}
|
||
|
||
const REPULSE = 4200;
|
||
const SPRING = 0.045;
|
||
const SPRING_LEN = 90;
|
||
const CENTER = 0.012;
|
||
const DAMPING = 0.82;
|
||
const PAD = 24;
|
||
|
||
/** One tick of lightweight force-directed layout (no D3). */
|
||
export function tickForceLayout(
|
||
nodes: ConstellationNode[],
|
||
edges: ConstellationEdge[],
|
||
width: number,
|
||
height: number,
|
||
alpha = 1,
|
||
): void {
|
||
const cx = width / 2;
|
||
const cy = height / 2;
|
||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
for (let j = i + 1; j < nodes.length; j++) {
|
||
const a = nodes[i];
|
||
const b = nodes[j];
|
||
let dx = b.x - a.x;
|
||
let dy = b.y - a.y;
|
||
let dist = Math.hypot(dx, dy) || 0.01;
|
||
const minDist = a.radius + b.radius + 12;
|
||
const force = (REPULSE * alpha) / (dist * dist);
|
||
if (dist < minDist) {
|
||
const push = ((minDist - dist) / dist) * 0.5;
|
||
dx *= push;
|
||
dy *= push;
|
||
dist = Math.hypot(dx, dy) || 0.01;
|
||
}
|
||
const fx = (dx / dist) * force;
|
||
const fy = (dy / dist) * force;
|
||
a.vx -= fx;
|
||
a.vy -= fy;
|
||
b.vx += fx;
|
||
b.vy += fy;
|
||
}
|
||
}
|
||
|
||
for (const e of edges) {
|
||
const a = nodeById.get(e.source);
|
||
const b = nodeById.get(e.target);
|
||
if (!a || !b) continue;
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const dist = Math.hypot(dx, dy) || 0.01;
|
||
const force = (dist - SPRING_LEN) * SPRING * alpha;
|
||
const fx = (dx / dist) * force;
|
||
const fy = (dy / dist) * force;
|
||
a.vx += fx;
|
||
a.vy += fy;
|
||
b.vx -= fx;
|
||
b.vy -= fy;
|
||
}
|
||
|
||
for (const n of nodes) {
|
||
n.vx += (cx - n.x) * CENTER * alpha;
|
||
n.vy += (cy - n.y) * CENTER * alpha;
|
||
n.vx *= DAMPING;
|
||
n.vy *= DAMPING;
|
||
n.x += n.vx;
|
||
n.y += n.vy;
|
||
|
||
const r = n.radius + PAD;
|
||
n.x = Math.max(r, Math.min(width - r, n.x));
|
||
n.y = Math.max(r, Math.min(height - r, n.y));
|
||
}
|
||
}
|
||
|
||
/** Run layout to near-equilibrium; returns same node references (mutated). */
|
||
export function settleForceLayout(
|
||
nodes: ConstellationNode[],
|
||
edges: ConstellationEdge[],
|
||
width: number,
|
||
height: number,
|
||
ticks = 120,
|
||
): ConstellationNode[] {
|
||
initNodePositions(nodes, width, height);
|
||
for (let t = ticks; t > 0; t--) {
|
||
tickForceLayout(nodes, edges, width, height, t / ticks);
|
||
}
|
||
return nodes;
|
||
}
|