|
667 | 667 | @media (max-width: 520px) and (orientation: portrait) { |
668 | 668 | .hero-title { font-size: clamp(4.2rem, 17vw, 7rem); letter-spacing: 3px; } |
669 | 669 | } |
| 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 | +} |
670 | 680 | </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> |
671 | 689 | </head> |
672 | 690 | <body> |
673 | 691 |
|
|
690 | 708 | <div class="haze haze-purple"></div> |
691 | 709 |
|
692 | 710 | <header class="hero"> |
| 711 | + <canvas id="vendor-graph"></canvas> |
693 | 712 | <div class="container"> |
694 | 713 | <h1 class="hero-title"> |
695 | 714 | <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=" "> </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> |
897 | 916 | </div> |
898 | 917 | </footer> |
899 | 918 |
|
| 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> |
900 | 1260 | </body> |
901 | 1261 | </html> |
0 commit comments