nxtgauge-frontend-solid/src/components/OpportunityGraph.tsx

204 lines
7 KiB
TypeScript

import { createMemo, createSignal, onCleanup, onMount } from 'solid-js';
const PHASES = [
'Opportunities start scattered',
'Profiles and opportunities connect',
'Relevant matches are prioritized',
'Everything organizes into one system',
'Nxtgauge: one clear opportunity workspace',
] as const;
type NodeDef = {
id: string;
label: string;
kind: 'profile' | 'opportunity' | 'signal' | 'status';
p2: [number, number];
p4: [number, number];
};
const NODES: NodeDef[] = [
{ id: 'developer', label: 'Developer', kind: 'profile', p2: [12, 18], p4: [18, 28] },
{ id: 'tutor', label: 'Tutor', kind: 'profile', p2: [30, 20], p4: [18, 40] },
{ id: 'photographer', label: 'Photographer', kind: 'profile', p2: [19, 44], p4: [18, 52] },
{ id: 'company', label: 'Company', kind: 'profile', p2: [24, 72], p4: [18, 64] },
{ id: 'verified', label: 'Verified', kind: 'status', p2: [44, 18], p4: [40, 28] },
{ id: 'skills', label: 'Skills', kind: 'signal', p2: [38, 62], p4: [40, 40] },
{ id: 'portfolio', label: 'Portfolio', kind: 'signal', p2: [58, 12], p4: [40, 52] },
{ id: 'match', label: 'Match', kind: 'signal', p2: [56, 70], p4: [56, 40] },
{ id: 'lead', label: 'Lead', kind: 'signal', p2: [72, 24], p4: [56, 52] },
{ id: 'job', label: 'Job', kind: 'opportunity', p2: [84, 30], p4: [72, 30] },
{ id: 'project', label: 'Project', kind: 'opportunity', p2: [78, 58], p4: [72, 44] },
{ id: 'opening', label: 'Opening', kind: 'opportunity', p2: [90, 74], p4: [72, 58] },
{ id: 'response', label: 'Response', kind: 'status', p2: [92, 48], p4: [88, 36] },
{ id: 'update', label: 'Update', kind: 'status', p2: [74, 10], p4: [88, 52] },
];
const EDGES: Array<[string, string]> = [
['developer', 'skills'],
['tutor', 'skills'],
['photographer', 'skills'],
['company', 'verified'],
['developer', 'verified'],
['portfolio', 'verified'],
['verified', 'match'],
['skills', 'match'],
['lead', 'match'],
['match', 'job'],
['match', 'project'],
['match', 'opening'],
['portfolio', 'project'],
['job', 'response'],
['project', 'response'],
['opening', 'response'],
['response', 'update'],
];
const WORKSPACE_CARDS = [
{ label: 'Verified Profiles', value: 'Trust layer ready' },
{ label: 'Matched Opportunities', value: 'Priority queue' },
{ label: 'Responses', value: 'Tracked in one flow' },
{ label: 'Updates', value: 'Live status signals' },
];
type Props = {
reduceMotion: boolean;
};
export default function OpportunityGraph(props: Props) {
const [timelineMs, setTimelineMs] = createSignal(0);
const [paused, setPaused] = createSignal(false);
const LOOP_MS = 42000;
const EDGE_REVEAL_STEP_MS = 1500;
onMount(() => {
if (props.reduceMotion) {
setTimelineMs(LOOP_MS - 1000);
return;
}
const ticker = window.setInterval(() => {
if (paused()) return;
setTimelineMs((prev) => Math.min(prev + 100, LOOP_MS));
}, 100);
onCleanup(() => window.clearInterval(ticker));
});
const edgeStartMs = 16500;
const nodeEndMs = 14000;
const workspaceStartMs = 37200;
const edgeRevealCount = createMemo(() => {
if (timelineMs() < edgeStartMs) return 0;
return Math.min(EDGES.length, Math.floor((timelineMs() - edgeStartMs) / EDGE_REVEAL_STEP_MS) + 1);
});
const nodeRevealCount = createMemo(() => {
const nodeProgress = Math.max(0, Math.min(1, timelineMs() / nodeEndMs));
return Math.floor(nodeProgress * NODES.length);
});
const workspaceVisible = createMemo(() => timelineMs() >= workspaceStartMs);
const nodePos = createMemo(() => {
const map = new Map<string, [number, number]>();
for (const node of NODES) map.set(node.id, node.p2);
return map;
});
const copy = createMemo(() => {
const t = timelineMs();
if (t < 12000) return PHASES[0];
if (t < 23500) return PHASES[1];
if (t < 32500) return PHASES[2];
if (t < workspaceStartMs) return PHASES[3];
return PHASES[4];
});
const lineCoords = (pair: [string, string]) => {
const [from, to] = pair;
const a = nodePos().get(from);
const b = nodePos().get(to);
if (!a || !b) return null;
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.001) return null;
const trim = 1.6;
const ux = dx / dist;
const uy = dy / dist;
return {
x1: a[0] + ux * trim,
y1: a[1] + uy * trim,
x2: b[0] - ux * trim,
y2: b[1] - uy * trim,
len: Math.sqrt((b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])),
};
};
return (
<div
class="op-graph-wrap"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusIn={() => setPaused(true)}
onFocusOut={() => setPaused(false)}
aria-label="Nxtgauge opportunity graph preview"
role="region"
>
<div class={`op-graph ${workspaceVisible() ? 'op-graph-phase4' : ''}`}>
<div class="op-graph-canvas">
<svg class="op-graph-svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{EDGES.map((edge, index) => {
const c = lineCoords(edge);
if (!c) return null;
const visible = index < edgeRevealCount();
const drawing = visible && index === edgeRevealCount() - 1;
return (
<line
class={`op-graph-line ${visible ? 'op-graph-line-visible' : 'op-graph-line-hidden'} ${drawing ? 'op-graph-line-drawing' : ''}`}
style={{ '--op-line-len': `${Math.sqrt((c.x2 - c.x1) * (c.x2 - c.x1) + (c.y2 - c.y1) * (c.y2 - c.y1))}` }}
x1={c.x1}
y1={c.y1}
x2={c.x2}
y2={c.y2}
/>
);
})}
</svg>
{!workspaceVisible() && NODES.map((node, index) => {
if (index >= nodeRevealCount()) return null;
const xy = nodePos().get(node.id) || [0, 0];
return (
<span
class={`op-graph-node op-graph-node-chip op-graph-node-${node.kind} op-graph-node-on`}
style={{ left: `${xy[0]}%`, top: `${xy[1]}%` }}
>
{node.label}
</span>
);
})}
<div class="op-graph-workspace">
<div class="op-graph-workspace-tick">
<span class="op-graph-workspace-tick-icon"></span>
<span class="op-graph-workspace-tick-text">Everything from Nxtgauge</span>
</div>
<p class="op-graph-workspace-title">Opportunity Workspace</p>
{WORKSPACE_CARDS.map((card) => (
<span class="op-graph-workspace-row">
<strong>{card.label}</strong>
<small>{card.value}</small>
</span>
))}
</div>
</div>
<div class="op-graph-copy">
<p class="op-graph-copy-line">{copy()}</p>
{workspaceVisible() ? <p class="op-graph-copy-sub">From scattered signals to one clear decision system.</p> : null}
</div>
</div>
</div>
);
}