204 lines
7 KiB
TypeScript
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>
|
|
);
|
|
}
|