Skip to content

Commit 09e7af4

Browse files
feat: React Three Fiber 3D hero background + 3D architecture diagrams
- Install three, @react-three/fiber, @react-three/drei, @types/three - Add usePerformanceTier hook (WebGL detect, hardwareConcurrency, prefers-reduced-motion) - Create Hero3D: animated grid floor, 8 floating infra nodes, connection lines, mouse parallax - Create Architecture3D: 6-layer glass RoundedBox containers, hover sphere nodes with Html tooltips, OrbitControls with auto-rotate, mobile flat fallback - Create Architecture3DSection: SSR-safe dynamic wrapper (ssr: false) with loading state - Hero.tsx: embed Hero3D as absolute-positioned background on high-perf devices - Case study [slug] page: switch DiagramSection → Architecture3DSection - Zero TypeScript errors, production build passes (20/20 pages)
1 parent 10c5f8c commit 09e7af4

9 files changed

Lines changed: 1191 additions & 11 deletions

File tree

package-lock.json

Lines changed: 629 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"lint": "eslint"
1010
},
1111
"dependencies": {
12+
"@react-three/drei": "^10.7.7",
13+
"@react-three/fiber": "^9.5.0",
1214
"@xyflow/react": "^12.10.1",
1315
"clsx": "^2.1.1",
1416
"framer-motion": "^12.34.3",
@@ -22,13 +24,15 @@
2224
"remark": "^15.0.1",
2325
"remark-html": "^16.0.1",
2426
"resend": "^6.9.2",
25-
"tailwind-merge": "^3.5.0"
27+
"tailwind-merge": "^3.5.0",
28+
"three": "^0.183.1"
2629
},
2730
"devDependencies": {
2831
"@tailwindcss/postcss": "^4",
2932
"@types/node": "^20",
3033
"@types/react": "^19",
3134
"@types/react-dom": "^19",
35+
"@types/three": "^0.183.1",
3236
"babel-plugin-react-compiler": "1.0.0",
3337
"eslint": "^9",
3438
"eslint-config-next": "16.1.6",

src/app/case-studies/[slug]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ArchitectureDiagram } from "@/components/sections/ArchitectureDiagram";
66
import { Badge } from "@/components/ui/Badge";
77
import { JsonLd } from "@/components/seo/JsonLd";
88
import { DiagramSection } from "@/components/ui/ArchitectureFlow/DiagramSection";
9+
import { Architecture3DSection } from "@/components/three/Architecture3DSection";
910
import { formatDate } from "@/lib/utils";
1011
import { architectureConfigs } from "@/config/architectureData";
1112
import { siteConfig } from "@/config/site";
@@ -187,7 +188,7 @@ export default async function CaseStudyPage({
187188
{/* Architecture diagram — React Flow if config exists, else simple step list */}
188189
{architectureConfigs[slug] ? (
189190
<div className="mb-12">
190-
<DiagramSection config={architectureConfigs[slug]} />
191+
<Architecture3DSection config={architectureConfigs[slug]} />
191192
</div>
192193
) : caseStudy.architecture.length > 0 ? (
193194
<div className="mb-12">

src/components/sections/Hero.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22

33
import { motion } from "framer-motion";
44
import { ArrowRight, CheckCircle } from "lucide-react";
5+
import dynamic from "next/dynamic";
56
import { Button } from "@/components/ui/Button";
67
import { GradientMesh } from "@/components/ui/GradientMesh";
78
import { MagneticButton } from "@/components/ui/MagneticButton";
89
import { siteConfig } from "@/config/site";
10+
import { usePerformanceTier } from "@/hooks/usePerformanceTier";
11+
12+
// Dynamic import — Three.js uses browser APIs; disabled for SSR
13+
const Hero3D = dynamic(
14+
() => import("@/components/three/Hero3D").then((m) => m.Hero3D),
15+
{ ssr: false }
16+
);
917

1018
const { freeReview, owner } = siteConfig;
1119

@@ -31,8 +39,20 @@ const proofPoints = [
3139
];
3240

3341
export function Hero() {
42+
const tier = usePerformanceTier();
43+
3444
return (
3545
<section className="relative overflow-hidden py-28 sm:py-36">
46+
{/* ── 3D animated background (high-perf devices only) ── */}
47+
{tier !== "none" && (
48+
<div
49+
aria-hidden="true"
50+
className="pointer-events-none absolute inset-0"
51+
style={{ opacity: 0.55 }}
52+
>
53+
<Hero3D />
54+
</div>
55+
)}
3656
{/* Animated gradient mesh — pure CSS, GPU-composited */}
3757
<GradientMesh />
3858

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"use client";
2+
3+
import { useRef, useState, useCallback } from "react";
4+
import { Canvas, useFrame, useThree } from "@react-three/fiber";
5+
import { RoundedBox, Html, OrbitControls, Text } from "@react-three/drei";
6+
import * as THREE from "three";
7+
import type { ArchitectureConfig, ArchNodeDef } from "@/types/architecture";
8+
import { usePerformanceTier } from "@/hooks/usePerformanceTier";
9+
10+
// ─── Layer colour map (keyed by LayerStyle) ──────────────────────────────────
11+
12+
import type { LayerStyle } from "@/types/architecture";
13+
14+
const LAYER_COLORS: Record<LayerStyle, string> = {
15+
edge: "#38bdf8",
16+
network: "#818cf8",
17+
compute: "#06b6d4",
18+
data: "#34d399",
19+
security: "#f472b6",
20+
cicd: "#fb923c",
21+
monitoring: "#a78bfa",
22+
iac: "#fb923c",
23+
artifacts: "#94a3b8",
24+
deploy: "#2dd4bf",
25+
};
26+
27+
function layerColor(style: LayerStyle | string): string {
28+
return LAYER_COLORS[style as LayerStyle] ?? "#818cf8";
29+
}
30+
31+
// ─── Single node sphere ───────────────────────────────────────────────────────
32+
33+
interface NodeSphereProps {
34+
position: [number, number, number];
35+
color: string;
36+
label: string;
37+
}
38+
39+
function NodeSphere({ position, color, label }: NodeSphereProps) {
40+
const [hovered, setHovered] = useState(false);
41+
const meshRef = useRef<THREE.Mesh>(null);
42+
43+
useFrame((_, delta) => {
44+
if (meshRef.current) {
45+
const target = hovered ? 1.3 : 1;
46+
const s = meshRef.current.scale.x;
47+
meshRef.current.scale.setScalar(s + (target - s) * delta * 6);
48+
}
49+
});
50+
51+
return (
52+
<mesh
53+
ref={meshRef}
54+
position={position}
55+
onPointerEnter={() => setHovered(true)}
56+
onPointerLeave={() => setHovered(false)}
57+
>
58+
<sphereGeometry args={[0.18, 16, 16]} />
59+
<meshStandardMaterial
60+
color={color}
61+
emissive={color}
62+
emissiveIntensity={hovered ? 1 : 0.4}
63+
roughness={0.15}
64+
metalness={0.7}
65+
/>
66+
{hovered && (
67+
<Html distanceFactor={8} center>
68+
<div
69+
style={{
70+
background: "rgba(10,10,20,0.92)",
71+
border: "1px solid rgba(129,140,248,0.4)",
72+
borderRadius: "6px",
73+
padding: "4px 10px",
74+
color: "#f1f5f9",
75+
fontSize: "11px",
76+
whiteSpace: "nowrap",
77+
pointerEvents: "none",
78+
}}
79+
>
80+
{label}
81+
</div>
82+
</Html>
83+
)}
84+
</mesh>
85+
);
86+
}
87+
88+
// ─── Layer container (glass box) ────────────────────────────────────────────
89+
90+
interface LayerBoxProps {
91+
position: [number, number, number];
92+
width: number;
93+
height: number;
94+
depth: number;
95+
color: string;
96+
label: string;
97+
nodes: ArchNodeDef[];
98+
nodeIds: Set<string>;
99+
}
100+
101+
function LayerBox({ position, width, height, depth, color, label, nodes, nodeIds }: LayerBoxProps) {
102+
const [hovered, setHovered] = useState(false);
103+
104+
// Filter nodes that belong to this layer's group
105+
// Filter nodes that belong to this layer group
106+
const layerNodes = nodes.filter((n) => nodeIds.has(n.id));
107+
108+
// Spread nodes evenly across the box
109+
const nodePositions = layerNodes.map((_, i): [number, number, number] => {
110+
const cols = Math.ceil(Math.sqrt(layerNodes.length));
111+
const row = Math.floor(i / cols);
112+
const col = i % cols;
113+
const spacingX = (width - 0.8) / Math.max(cols - 1, 1);
114+
const spacingZ = (depth - 0.8) / Math.max(Math.ceil(layerNodes.length / cols) - 1, 1);
115+
return [
116+
-width / 2 + 0.4 + col * spacingX,
117+
0,
118+
-depth / 2 + 0.4 + row * spacingZ,
119+
];
120+
});
121+
122+
return (
123+
<group position={position}>
124+
{/* Glass container */}
125+
<RoundedBox
126+
args={[width, height, depth]}
127+
radius={0.12}
128+
smoothness={4}
129+
onPointerEnter={() => setHovered(true)}
130+
onPointerLeave={() => setHovered(false)}
131+
>
132+
<meshStandardMaterial
133+
color={color}
134+
transparent
135+
opacity={hovered ? 0.14 : 0.07}
136+
roughness={0.05}
137+
metalness={0.95}
138+
side={THREE.DoubleSide}
139+
/>
140+
</RoundedBox>
141+
142+
{/* Layer label */}
143+
<Text
144+
position={[0, height / 2 + 0.2, 0]}
145+
fontSize={0.22}
146+
color={color}
147+
anchorX="center"
148+
anchorY="bottom"
149+
font={undefined}
150+
>
151+
{label}
152+
</Text>
153+
154+
{/* Node spheres */}
155+
{layerNodes.map((node, i) => (
156+
<NodeSphere
157+
key={node.id}
158+
position={nodePositions[i]}
159+
color={color}
160+
label={node.label ?? node.id}
161+
/>
162+
))}
163+
</group>
164+
);
165+
}
166+
167+
// ─── Scene ───────────────────────────────────────────────────────────────────
168+
169+
interface SceneProps {
170+
config: ArchitectureConfig;
171+
}
172+
173+
function Scene({ config }: SceneProps) {
174+
const groups = config.groups ?? [];
175+
const nodes: ArchNodeDef[] = config.nodes ?? [];
176+
177+
// Build set of node ids per group
178+
const nodesByGroup: Record<string, Set<string>> = {};
179+
for (const node of nodes) {
180+
const gid = node.groupId ?? "default";
181+
if (!nodesByGroup[gid]) nodesByGroup[gid] = new Set();
182+
nodesByGroup[gid].add(node.id);
183+
}
184+
185+
const layerCount = groups.length;
186+
const layerH = 0.9;
187+
const gap = 0.4;
188+
const totalH = layerCount * (layerH + gap);
189+
const startY = totalH / 2 - layerH / 2;
190+
191+
return (
192+
<>
193+
<ambientLight intensity={0.5} />
194+
<directionalLight position={[4, 8, 4]} intensity={0.7} />
195+
<pointLight position={[-4, 4, 4]} intensity={0.5} color="#818cf8" />
196+
197+
<OrbitControls
198+
enablePan={false}
199+
minDistance={5}
200+
maxDistance={22}
201+
minPolarAngle={Math.PI / 6}
202+
maxPolarAngle={Math.PI / 2}
203+
// Auto-rotate very slowly for enterprise feel
204+
autoRotate
205+
autoRotateSpeed={0.4}
206+
/>
207+
208+
{groups.map((group, i) => {
209+
const color = layerColor(group.layerStyle);
210+
const y = startY - i * (layerH + gap);
211+
const nIds = nodesByGroup[group.id] ?? new Set<string>();
212+
const nCount = nIds.size || 3;
213+
const boxW = Math.max(5, nCount * 1.4);
214+
215+
return (
216+
<LayerBox
217+
key={group.id}
218+
position={[0, y, 0]}
219+
width={boxW}
220+
height={layerH}
221+
depth={2.2}
222+
color={color}
223+
label={group.label ?? group.id}
224+
nodes={nodes}
225+
nodeIds={nIds}
226+
/>
227+
);
228+
})}
229+
</>
230+
);
231+
}
232+
233+
// ─── Mobile flat fallback ─────────────────────────────────────────────────────
234+
235+
function MobileFallback2D({ config }: { config: ArchitectureConfig }) {
236+
const groups = config.groups ?? [];
237+
return (
238+
<div className="w-full space-y-2 py-4">
239+
{groups.map((group) => {
240+
const color = layerColor(group.layerStyle);
241+
return (
242+
<div
243+
key={group.id}
244+
style={{
245+
border: `1px solid ${color}44`,
246+
background: `${color}0d`,
247+
borderRadius: "8px",
248+
padding: "10px 16px",
249+
color: color,
250+
fontSize: "13px",
251+
fontWeight: 600,
252+
}}
253+
>
254+
{group.label ?? group.id}
255+
</div>
256+
);
257+
})}
258+
</div>
259+
);
260+
}
261+
262+
// ─── Public export (Canvas wrapper) ──────────────────────────────────────────
263+
264+
export function Architecture3D({ config }: { config: ArchitectureConfig }) {
265+
const tier = usePerformanceTier();
266+
267+
if (tier === "none") {
268+
return <MobileFallback2D config={config} />;
269+
}
270+
271+
return (
272+
<div style={{ width: "100%", height: "520px" }}>
273+
<Canvas camera={{ position: [0, 3, 14], fov: 50 }} dpr={[1, 1.5]} gl={{ alpha: true }}>
274+
<Scene config={config} />
275+
</Canvas>
276+
</div>
277+
);
278+
}

0 commit comments

Comments
 (0)