import { useEffect, useRef } from 'react' import { Engine, Scene, ArcRotateCamera, Vector3, HemisphericLight, MeshBuilder, StandardMaterial, Color3, DynamicTexture } from 'babylonjs' interface HTTPRequest { requestId: string url: string hostname: string method: string resourceType: string priority: string statusCode?: number mimeType?: string protocol?: string timing: { start: number startOffset?: number end?: number duration?: number queueTime?: number serverLatency?: number } encodedDataLength?: number contentLength?: number fromCache: boolean connectionReused: boolean } interface BabylonViewerProps { width?: number height?: number httpRequests?: HTTPRequest[] } export default function BabylonViewer({ width = 800, height = 600, httpRequests = [] }: BabylonViewerProps) { const canvasRef = useRef(null) const engineRef = useRef(null) const sceneRef = useRef(null) useEffect(() => { if (!canvasRef.current) return const canvas = canvasRef.current const engine = new Engine(canvas, true) engineRef.current = engine const scene = new Scene(engine) sceneRef.current = scene const camera = new ArcRotateCamera('camera1', -Math.PI / 2, Math.PI / 2.5, 25, Vector3.Zero(), scene) camera.attachControl(canvas, true) scene.activeCamera = camera const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene) light.intensity = 0.7 // Create ground plane for reference const ground = MeshBuilder.CreateGround('ground', { width: 50, height: 30 }, scene) const groundMaterial = new StandardMaterial('groundMaterial', scene) groundMaterial.diffuseColor = new Color3(0.2, 0.2, 0.2) groundMaterial.alpha = 0.3 ground.material = groundMaterial // Create central sphere at origin const centralSphere = MeshBuilder.CreateSphere('centralSphere', { diameter: 0.5 }, scene) centralSphere.position = Vector3.Zero() const sphereMaterial = new StandardMaterial('sphereMaterial', scene) sphereMaterial.diffuseColor = new Color3(0.3, 0.3, 0.8) // Blue sphere sphereMaterial.specularColor = new Color3(0.5, 0.5, 1) centralSphere.material = sphereMaterial // Visualize HTTP requests as 3D objects arranged radially by hostname if (httpRequests && httpRequests.length > 0) { // Group requests by hostname const requestsByHostname = new Map() httpRequests.forEach(request => { if (!requestsByHostname.has(request.hostname)) { requestsByHostname.set(request.hostname, []) } requestsByHostname.get(request.hostname)!.push(request) }) // Find min and max start times for normalization const startTimes = httpRequests.map(req => req.timing.startOffset || 0) const minStartTime = Math.min(...startTimes) const maxStartTime = Math.max(...startTimes) const timeRange = maxStartTime - minStartTime // Find min and max content-length values for height normalization const contentLengths = httpRequests .map(req => req.contentLength || 0) .filter(size => size > 0) // Only consider requests with content-length const minContentLength = contentLengths.length > 0 ? Math.min(...contentLengths) : 1 const maxContentLength = contentLengths.length > 0 ? Math.max(...contentLengths) : 1 const contentLengthRange = maxContentLength - minContentLength const hostnames = Array.from(requestsByHostname.keys()) const hostCount = hostnames.length const labelRadius = 12 // Distance from center for hostname labels const minDistance = 1 // Minimum distance from center (1 meter) const maxDistance = 10 // Maximum distance from center (10 meters) const minHeight = 0.1 // Minimum box height (0.1 meters) const maxHeight = 5 // Maximum box height (5 meters) const minDepth = 0.05 // Minimum box depth (0.05 meters) hostnames.forEach((hostname, hostIndex) => { // Calculate radial position for this hostname const angle = (hostIndex / hostCount) * 2 * Math.PI const labelX = labelRadius * Math.cos(angle) const labelZ = labelRadius * Math.sin(angle) // Create hostname label that always faces camera const labelTexture = new DynamicTexture(`hostLabel_${hostIndex}`, { width: 256, height: 64 }, scene) // Note: hasAlpha property handled by BabylonJS labelTexture.drawText( hostname, null, null, '16px Arial', 'white', 'rgba(0,0,0,0.7)', true ) const hostLabel = MeshBuilder.CreatePlane(`hostLabelPlane_${hostIndex}`, { size: 1.5 }, scene) hostLabel.position = new Vector3(labelX, 1.5, labelZ) // Rotate label to face origin on vertical axis only, then add 180 degree rotation const labelDirectionToOrigin = Vector3.Zero().subtract(hostLabel.position).normalize() const labelYRotation = Math.atan2(labelDirectionToOrigin.x, labelDirectionToOrigin.z) hostLabel.rotation.y = labelYRotation + Math.PI // Add 180 degrees (π radians) const labelMaterial = new StandardMaterial(`hostLabelMaterial_${hostIndex}`, scene) labelMaterial.diffuseTexture = labelTexture labelMaterial.useAlphaFromDiffuseTexture = true labelMaterial.backFaceCulling = false hostLabel.material = labelMaterial // Get requests for this hostname and sort by start time const hostnameRequests = requestsByHostname.get(hostname)! hostnameRequests.sort((a, b) => (a.timing.startOffset || 0) - (b.timing.startOffset || 0)) // Track placed boxes for this hostname to handle stacking const placedBoxes: Array<{ startDistance: number endDistance: number height: number yPosition: number }> = [] // Create boxes for each request from this hostname hostnameRequests.forEach((request, requestIndex) => { // Calculate start and end distances from center based on timing const startTime = request.timing.startOffset || 0 const duration = request.timing.duration || 1000 // Default 1ms if no duration const endTime = startTime + duration const normalizedStartTime = timeRange > 0 ? (startTime - minStartTime) / timeRange : 0 const normalizedEndTime = timeRange > 0 ? (endTime - minStartTime) / timeRange : 0 const startDistance = minDistance + (normalizedStartTime * (maxDistance - minDistance)) const endDistance = minDistance + (normalizedEndTime * (maxDistance - minDistance)) // Calculate box depth and center position const boxDepth = Math.max(minDepth, endDistance - startDistance) const centerDistance = startDistance + (boxDepth / 2) // Position box at center distance along the hostname's angle const boxX = centerDistance * Math.cos(angle) const boxZ = centerDistance * Math.sin(angle) // Calculate height based on content-length const contentLength = request.contentLength || 0 let boxHeight = minHeight if (contentLength > 0 && contentLengthRange > 0) { const normalizedSize = (contentLength - minContentLength) / contentLengthRange boxHeight = minHeight + (normalizedSize * (maxHeight - minHeight)) } // Find the highest Y position for overlapping boxes let stackHeight = 0 for (const placedBox of placedBoxes) { // Check if this request overlaps with a placed box if (startDistance < placedBox.endDistance && endDistance > placedBox.startDistance) { // This box overlaps, so we need to stack it above stackHeight = Math.max(stackHeight, placedBox.yPosition) } } // Calculate Y position (center of the box) - stack height is the bottom of where this box should be const yPosition = stackHeight + (boxHeight / 2) // Create box with duration-based depth and content-length based height const box = MeshBuilder.CreateBox(`request_${hostIndex}_${requestIndex}`, { width: 0.1, height: boxHeight, depth: boxDepth }, scene) box.position = new Vector3(boxX, yPosition, boxZ) // Add this box to the placed boxes list for future overlap checks // yPosition here represents the top of this box for future stacking placedBoxes.push({ startDistance, endDistance, height: boxHeight, yPosition: stackHeight + boxHeight }) // Rotate box to face the origin only on the vertical (Y) axis const directionToOrigin = Vector3.Zero().subtract(box.position).normalize() const yRotation = Math.atan2(directionToOrigin.x, directionToOrigin.z) box.rotation.y = yRotation // Calculate color based on vertical position for success requests // Higher boxes are lighter (closer to white), lower boxes are darker const verticalColorIntensity = Math.min(1.0, Math.max(0.3, (stackHeight + boxHeight) / 8.0)) // Color based on status code const material = new StandardMaterial(`material_${hostIndex}_${requestIndex}`, scene) if (request.statusCode) { if (request.statusCode >= 200 && request.statusCode < 300) { material.diffuseColor = new Color3(verticalColorIntensity, verticalColorIntensity, verticalColorIntensity) } else if (request.statusCode >= 300 && request.statusCode < 400) { material.diffuseColor = new Color3(1, 1, 0) // Yellow for redirects } else if (request.statusCode >= 400 && request.statusCode < 500) { material.diffuseColor = new Color3(1, 0.5, 0) // Orange for client errors } else if (request.statusCode >= 500) { material.diffuseColor = new Color3(1, 0, 0) // Red for server errors } } else { material.diffuseColor = new Color3(0.5, 0.5, 0.5) // Gray for unknown } // Add transparency for cached requests if (request.fromCache) { material.alpha = 0.7 } box.material = material }) // Create a line from center to hostname label position for visual connection const linePoints = [Vector3.Zero(), new Vector3(labelX * 0.8, 0, labelZ * 0.8)] MeshBuilder.CreateLines(`connectionLine_${hostIndex}`, { points: linePoints }, scene) const lineMaterial = new StandardMaterial(`lineMaterial_${hostIndex}`, scene) lineMaterial.diffuseColor = new Color3(0.4, 0.4, 0.4) lineMaterial.alpha = 0.5 }) } engine.runRenderLoop(() => { scene.render() }) const handleResize = () => { engine.resize() } window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) engine.dispose() } }, [httpRequests]) return ( ) }