Skip to content

Commit 0a748bb

Browse files
p4gsclaudehappy-otter
committed
Add 3D vendor network graph to nthpartyfinder hero section
Interactive Three.js visualization as hero background showing a force-directed supply chain network with layered depth (primary → Nth party). Uses InstancedMesh for GPU-efficient rendering, animated pulse particles on edges, auto-rotating camera, and concentric grid rings. Respects prefers-reduced-motion. Color-coded by party depth using GRC orange → blue → purple gradient. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent 90420bd commit 0a748bb

File tree

1 file changed

+360
-0
lines changed

1 file changed

+360
-0
lines changed

docs/projects/nthpartyfinder/index.html

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,25 @@
667667
@media (max-width: 520px) and (orientation: portrait) {
668668
.hero-title { font-size: clamp(4.2rem, 17vw, 7rem); letter-spacing: 3px; }
669669
}
670+
/* ── 3D GRAPH ── */
671+
#vendor-graph {
672+
position: absolute;
673+
top: 0;
674+
left: 0;
675+
width: 100%;
676+
height: 100%;
677+
z-index: 0;
678+
pointer-events: none;
679+
}
670680
</style>
681+
<script type="importmap">
682+
{
683+
"imports": {
684+
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
685+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
686+
}
687+
}
688+
</script>
671689
</head>
672690
<body>
673691

@@ -690,6 +708,7 @@
690708
<div class="haze haze-purple"></div>
691709

692710
<header class="hero">
711+
<canvas id="vendor-graph"></canvas>
693712
<div class="container">
694713
<h1 class="hero-title">
695714
<span class="lava-text"><span class="lava-letter" style="--i:0" data-letter="N">N</span><span class="lava-letter" style="--i:1" data-letter="t">t</span><span class="lava-letter" style="--i:2" data-letter="h">h</span><span class="lava-letter" style="--i:3" data-letter=" ">&nbsp;</span><span class="lava-letter" style="--i:4" data-letter="P">P</span><span class="lava-letter" style="--i:5" data-letter="a">a</span><span class="lava-letter" style="--i:6" data-letter="r">r</span><span class="lava-letter" style="--i:7" data-letter="t">t</span><span class="lava-letter" style="--i:8" data-letter="y">y</span></span><br>
@@ -897,5 +916,346 @@ <h2 class="section-title">Ready to Find Your Nth Party?</h2>
897916
</div>
898917
</footer>
899918

919+
<script type="module">
920+
import * as THREE from 'three';
921+
922+
// ── CONFIG ──
923+
const NODE_COUNT = 120;
924+
const LAYERS = 5; // primary + 4 depth layers
925+
const COLORS = [
926+
new THREE.Color('#FFA84F'), // primary (your company) — orange
927+
new THREE.Color('#FF8C00'), // 3rd party — deeper orange
928+
new THREE.Color('#1B95C1'), // 4th party — blue
929+
new THREE.Color('#6AB8D4'), // 5th party — lighter blue
930+
new THREE.Color('#b388ff'), // Nth party — purple
931+
];
932+
const SIZES = [0.12, 0.07, 0.055, 0.04, 0.03];
933+
const EMISSIVE_INTENSITY = [1.5, 0.8, 0.6, 0.4, 0.25];
934+
const LAYER_COUNTS = [1, 12, 28, 38, 41]; // nodes per layer
935+
936+
// ── SETUP ──
937+
const canvas = document.getElementById('vendor-graph');
938+
const hero = canvas.parentElement;
939+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
940+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
941+
942+
const scene = new THREE.Scene();
943+
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
944+
camera.position.set(0, 2.5, 5.5);
945+
camera.lookAt(0, 0, 0);
946+
947+
// Lighting
948+
scene.add(new THREE.AmbientLight(0xffffff, 0.3));
949+
const pointLight = new THREE.PointLight(0xffffff, 0.6, 20);
950+
pointLight.position.set(2, 6, 4);
951+
scene.add(pointLight);
952+
953+
// ── GENERATE NODE POSITIONS & EDGES ──
954+
const nodes = [];
955+
const edges = [];
956+
const layerStartIndex = [];
957+
958+
// Place nodes in layered shells
959+
let nodeIndex = 0;
960+
for (let layer = 0; layer < LAYERS; layer++) {
961+
layerStartIndex.push(nodeIndex);
962+
const count = LAYER_COUNTS[layer];
963+
const radius = layer === 0 ? 0 : 0.8 + layer * 1.1;
964+
965+
for (let i = 0; i < count; i++) {
966+
if (layer === 0) {
967+
nodes.push({ pos: new THREE.Vector3(0, 0, 0), layer, vel: new THREE.Vector3() });
968+
} else {
969+
// Distribute on a sphere shell with some randomness
970+
const phi = Math.acos(1 - 2 * (i + 0.5) / count);
971+
const theta = Math.PI * (1 + Math.sqrt(5)) * i;
972+
const r = radius + (Math.random() - 0.5) * 0.5;
973+
const x = r * Math.sin(phi) * Math.cos(theta);
974+
const y = (r * Math.cos(phi)) * 0.5; // flatten Y
975+
const z = r * Math.sin(phi) * Math.sin(theta);
976+
nodes.push({ pos: new THREE.Vector3(x, y, z), layer, vel: new THREE.Vector3() });
977+
}
978+
nodeIndex++;
979+
}
980+
}
981+
982+
// Create edges: connect each node to 1-3 nodes in the previous layer
983+
for (let layer = 1; layer < LAYERS; layer++) {
984+
const start = layerStartIndex[layer];
985+
const end = start + LAYER_COUNTS[layer];
986+
const prevStart = layerStartIndex[layer - 1];
987+
const prevEnd = prevStart + LAYER_COUNTS[layer - 1];
988+
989+
for (let i = start; i < end; i++) {
990+
// Connect to closest node(s) in previous layer
991+
const connections = 1 + Math.floor(Math.random() * 2); // 1-2 connections
992+
const distances = [];
993+
for (let j = prevStart; j < prevEnd; j++) {
994+
distances.push({ idx: j, dist: nodes[i].pos.distanceTo(nodes[j].pos) });
995+
}
996+
distances.sort((a, b) => a.dist - b.dist);
997+
for (let c = 0; c < Math.min(connections, distances.length); c++) {
998+
edges.push([i, distances[c].idx]);
999+
}
1000+
}
1001+
1002+
// Add a few cross-layer edges for realism
1003+
if (layer >= 2) {
1004+
const crossCount = Math.floor(LAYER_COUNTS[layer] * 0.15);
1005+
for (let c = 0; c < crossCount; c++) {
1006+
const from = start + Math.floor(Math.random() * LAYER_COUNTS[layer]);
1007+
const twoBack = layerStartIndex[layer - 2];
1008+
const twoBackEnd = twoBack + LAYER_COUNTS[layer - 2];
1009+
const to = twoBack + Math.floor(Math.random() * (twoBackEnd - twoBack));
1010+
edges.push([from, to]);
1011+
}
1012+
}
1013+
}
1014+
1015+
// ── SIMPLE FORCE SIMULATION (run once) ──
1016+
const forceIterations = 80;
1017+
for (let iter = 0; iter < forceIterations; iter++) {
1018+
const alpha = 1 - iter / forceIterations;
1019+
// Repulsion between all nodes
1020+
for (let i = 0; i < nodes.length; i++) {
1021+
for (let j = i + 1; j < nodes.length; j++) {
1022+
const diff = new THREE.Vector3().subVectors(nodes[i].pos, nodes[j].pos);
1023+
const dist = Math.max(diff.length(), 0.1);
1024+
const force = (0.15 * alpha) / (dist * dist);
1025+
diff.normalize().multiplyScalar(force);
1026+
if (i !== 0) nodes[i].pos.add(diff);
1027+
if (j !== 0) nodes[j].pos.sub(diff);
1028+
}
1029+
}
1030+
// Edge spring attraction
1031+
for (const [a, b] of edges) {
1032+
const diff = new THREE.Vector3().subVectors(nodes[b].pos, nodes[a].pos);
1033+
const dist = diff.length();
1034+
const targetDist = 1.2 + Math.abs(nodes[a].layer - nodes[b].layer) * 0.8;
1035+
const force = (dist - targetDist) * 0.02 * alpha;
1036+
diff.normalize().multiplyScalar(force);
1037+
if (a !== 0) nodes[a].pos.add(diff);
1038+
if (b !== 0) nodes[b].pos.sub(diff);
1039+
}
1040+
// Layer constraint — keep nodes roughly at their shell radius
1041+
for (let i = 1; i < nodes.length; i++) {
1042+
const n = nodes[i];
1043+
const targetR = 0.8 + n.layer * 1.1;
1044+
const currentR = n.pos.length();
1045+
if (currentR > 0.01) {
1046+
n.pos.multiplyScalar(1 + (targetR / currentR - 1) * 0.3 * alpha);
1047+
}
1048+
}
1049+
// Pin primary node
1050+
nodes[0].pos.set(0, 0, 0);
1051+
}
1052+
1053+
// ── INSTANCED MESHES (one per layer) ──
1054+
const sphereGeo = new THREE.SphereGeometry(1, 24, 24);
1055+
const instancedMeshes = [];
1056+
const dummy = new THREE.Object3D();
1057+
1058+
for (let layer = 0; layer < LAYERS; layer++) {
1059+
const count = LAYER_COUNTS[layer];
1060+
const mat = new THREE.MeshStandardMaterial({
1061+
color: COLORS[layer],
1062+
emissive: COLORS[layer],
1063+
emissiveIntensity: EMISSIVE_INTENSITY[layer],
1064+
roughness: 0.4,
1065+
metalness: 0.2,
1066+
transparent: true,
1067+
opacity: layer === 0 ? 1.0 : 0.85,
1068+
});
1069+
const mesh = new THREE.InstancedMesh(sphereGeo, mat, count);
1070+
const start = layerStartIndex[layer];
1071+
for (let i = 0; i < count; i++) {
1072+
const n = nodes[start + i];
1073+
dummy.position.copy(n.pos);
1074+
const s = SIZES[layer];
1075+
dummy.scale.set(s, s, s);
1076+
dummy.updateMatrix();
1077+
mesh.setMatrixAt(i, dummy.matrix);
1078+
}
1079+
mesh.instanceMatrix.needsUpdate = true;
1080+
scene.add(mesh);
1081+
instancedMeshes.push(mesh);
1082+
}
1083+
1084+
// Outer glow spheres for primary node
1085+
const glowGeo = new THREE.SphereGeometry(1, 16, 16);
1086+
const glowMat = new THREE.MeshBasicMaterial({
1087+
color: COLORS[0],
1088+
transparent: true,
1089+
opacity: 0.08,
1090+
});
1091+
const glowSphere = new THREE.Mesh(glowGeo, glowMat);
1092+
glowSphere.scale.setScalar(0.35);
1093+
scene.add(glowSphere);
1094+
1095+
// ── EDGES ──
1096+
const edgePositions = [];
1097+
const edgeColors = [];
1098+
for (const [a, b] of edges) {
1099+
const pa = nodes[a].pos;
1100+
const pb = nodes[b].pos;
1101+
edgePositions.push(pa.x, pa.y, pa.z, pb.x, pb.y, pb.z);
1102+
// Color: blend between the two node layers
1103+
const ca = COLORS[nodes[a].layer];
1104+
const cb = COLORS[nodes[b].layer];
1105+
edgeColors.push(ca.r, ca.g, ca.b, cb.r, cb.g, cb.b);
1106+
}
1107+
const edgeGeo = new THREE.BufferGeometry();
1108+
edgeGeo.setAttribute('position', new THREE.Float32BufferAttribute(edgePositions, 3));
1109+
edgeGeo.setAttribute('color', new THREE.Float32BufferAttribute(edgeColors, 3));
1110+
const edgeMat = new THREE.LineBasicMaterial({
1111+
vertexColors: true,
1112+
transparent: true,
1113+
opacity: 0.15,
1114+
linewidth: 1,
1115+
});
1116+
const edgeMesh = new THREE.LineSegments(edgeGeo, edgeMat);
1117+
scene.add(edgeMesh);
1118+
1119+
// ── ANIMATED PULSE EDGES (subset) ──
1120+
const pulseEdgeCount = Math.min(edges.length, 40);
1121+
const pulseEdges = [];
1122+
for (let i = 0; i < pulseEdgeCount; i++) {
1123+
const edgeIdx = Math.floor(Math.random() * edges.length);
1124+
const [a, b] = edges[edgeIdx];
1125+
const pa = nodes[a].pos.clone();
1126+
const pb = nodes[b].pos.clone();
1127+
// Create a line of points for the pulse to travel along
1128+
const segments = 20;
1129+
const positions = new Float32Array(segments * 3);
1130+
const alphas = new Float32Array(segments);
1131+
for (let s = 0; s < segments; s++) {
1132+
const t = s / (segments - 1);
1133+
positions[s * 3] = pa.x + (pb.x - pa.x) * t;
1134+
positions[s * 3 + 1] = pa.y + (pb.y - pa.y) * t;
1135+
positions[s * 3 + 2] = pa.z + (pb.z - pa.z) * t;
1136+
alphas[s] = 0;
1137+
}
1138+
const geo = new THREE.BufferGeometry();
1139+
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
1140+
const color = COLORS[nodes[a].layer].clone().lerp(COLORS[nodes[b].layer], 0.5);
1141+
const mat = new THREE.PointsMaterial({
1142+
size: 0.015,
1143+
color: color,
1144+
transparent: true,
1145+
opacity: 0.8,
1146+
sizeAttenuation: true,
1147+
});
1148+
const points = new THREE.Points(geo, mat);
1149+
scene.add(points);
1150+
pulseEdges.push({
1151+
points,
1152+
segments,
1153+
speed: 0.3 + Math.random() * 0.5,
1154+
phase: Math.random() * Math.PI * 2,
1155+
pa, pb,
1156+
});
1157+
}
1158+
1159+
// ── CONCENTRIC GRID RINGS ──
1160+
const ringCount = 4;
1161+
for (let r = 1; r <= ringCount; r++) {
1162+
const radius = r * 1.3;
1163+
const ringPoints = [];
1164+
const ringSegments = 64;
1165+
for (let s = 0; s <= ringSegments; s++) {
1166+
const angle = (s / ringSegments) * Math.PI * 2;
1167+
ringPoints.push(new THREE.Vector3(
1168+
Math.cos(angle) * radius,
1169+
-0.5,
1170+
Math.sin(angle) * radius
1171+
));
1172+
}
1173+
const ringGeo = new THREE.BufferGeometry().setFromPoints(ringPoints);
1174+
const ringMat = new THREE.LineBasicMaterial({
1175+
color: 0xffffff,
1176+
transparent: true,
1177+
opacity: 0.03 + (ringCount - r) * 0.01,
1178+
});
1179+
scene.add(new THREE.Line(ringGeo, ringMat));
1180+
}
1181+
1182+
// ── RESIZE ──
1183+
function resize() {
1184+
const rect = hero.getBoundingClientRect();
1185+
const w = rect.width;
1186+
const h = rect.height;
1187+
renderer.setSize(w, h);
1188+
camera.aspect = w / h;
1189+
camera.updateProjectionMatrix();
1190+
}
1191+
resize();
1192+
window.addEventListener('resize', resize);
1193+
1194+
// ── REDUCED MOTION CHECK ──
1195+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1196+
1197+
// ── ANIMATION LOOP ──
1198+
const clock = new THREE.Clock();
1199+
function animate() {
1200+
requestAnimationFrame(animate);
1201+
const elapsed = clock.getElapsedTime();
1202+
const delta = Math.min(clock.getDelta(), 0.05);
1203+
1204+
if (!prefersReducedMotion) {
1205+
// Slow camera orbit
1206+
const orbitSpeed = 0.06;
1207+
const angle = elapsed * orbitSpeed;
1208+
camera.position.x = Math.sin(angle) * 5.5;
1209+
camera.position.z = Math.cos(angle) * 5.5;
1210+
camera.position.y = 2.0 + Math.sin(elapsed * 0.15) * 0.5;
1211+
camera.lookAt(0, 0, 0);
1212+
1213+
// Pulse primary glow
1214+
const glowScale = 0.35 + 0.08 * Math.sin(elapsed * 1.5);
1215+
glowSphere.scale.setScalar(glowScale);
1216+
glowSphere.material.opacity = 0.06 + 0.03 * Math.sin(elapsed * 2);
1217+
1218+
// Animate pulse particles
1219+
for (const pe of pulseEdges) {
1220+
const t = ((elapsed * pe.speed + pe.phase) % 1);
1221+
// Create a gaussian pulse traveling along the edge
1222+
const posAttr = pe.points.geometry.attributes.position;
1223+
for (let s = 0; s < pe.segments; s++) {
1224+
const st = s / (pe.segments - 1);
1225+
const dist = Math.abs(st - t);
1226+
const brightness = Math.exp(-(dist * dist) / 0.008);
1227+
// Scale point size based on brightness
1228+
posAttr.array[s * 3] = pe.pa.x + (pe.pb.x - pe.pa.x) * st;
1229+
posAttr.array[s * 3 + 1] = pe.pa.y + (pe.pb.y - pe.pa.y) * st;
1230+
posAttr.array[s * 3 + 2] = pe.pa.z + (pe.pb.z - pe.pa.z) * st;
1231+
}
1232+
pe.points.material.opacity = 0.6;
1233+
posAttr.needsUpdate = true;
1234+
}
1235+
1236+
// Gentle node float
1237+
for (let layer = 1; layer < LAYERS; layer++) {
1238+
const mesh = instancedMeshes[layer];
1239+
const start = layerStartIndex[layer];
1240+
for (let i = 0; i < LAYER_COUNTS[layer]; i++) {
1241+
const n = nodes[start + i];
1242+
dummy.position.set(
1243+
n.pos.x + Math.sin(elapsed * 0.4 + i * 1.3) * 0.02,
1244+
n.pos.y + Math.sin(elapsed * 0.5 + i * 0.7) * 0.03,
1245+
n.pos.z + Math.cos(elapsed * 0.3 + i * 1.1) * 0.02
1246+
);
1247+
const s = SIZES[layer];
1248+
dummy.scale.set(s, s, s);
1249+
dummy.updateMatrix();
1250+
mesh.setMatrixAt(i, dummy.matrix);
1251+
}
1252+
mesh.instanceMatrix.needsUpdate = true;
1253+
}
1254+
}
1255+
1256+
renderer.render(scene, camera);
1257+
}
1258+
animate();
1259+
</script>
9001260
</body>
9011261
</html>

0 commit comments

Comments
 (0)