- Remove unused variables and imports across components - Fix BabylonJS material property errors (hasAlpha → useAlphaFromDiffuseTexture) - Resolve TypeScript interface extension issues in PhaseViewer - Add null safety checks for potentially undefined properties - Ensure proper array initialization before operations - Clean up unused function declarations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
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<HTMLCanvasElement>(null)
|
|
const engineRef = useRef<Engine | null>(null)
|
|
const sceneRef = useRef<Scene | null>(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<string, HTTPRequest[]>()
|
|
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 (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={width}
|
|
height={height}
|
|
style={{ width: '100%', height: '100%', display: 'block' }}
|
|
/>
|
|
)
|
|
} |