|
| 1 | +import { decodeLiDARPacket, depthToPointCloud } from './codec.mjs'; |
| 2 | + |
| 3 | +const canvas = document.querySelector('#view'); |
| 4 | +const ctx = canvas.getContext('2d'); |
| 5 | +const status = document.querySelector('#status'); |
| 6 | +const fpsEl = document.querySelector('#fps'); |
| 7 | +const pointsEl = document.querySelector('#points'); |
| 8 | +const seqEl = document.querySelector('#seq'); |
| 9 | +const latencyEl = document.querySelector('#latency'); |
| 10 | +const sensorEl = document.querySelector('#sensor'); |
| 11 | + |
| 12 | +let lastFrameAt = performance.now(); |
| 13 | +let yaw = 0.3; |
| 14 | +let pitch = -0.15; |
| 15 | +let scale = 120; |
| 16 | + |
| 17 | +function connect() { |
| 18 | + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| 19 | + const socket = new WebSocket(`${protocol}//${location.host}/ws/lidar`); |
| 20 | + |
| 21 | + socket.addEventListener('open', () => { |
| 22 | + status.textContent = 'LIVE'; |
| 23 | + status.dataset.state = 'live'; |
| 24 | + }); |
| 25 | + |
| 26 | + socket.addEventListener('close', () => { |
| 27 | + status.textContent = 'RECONNECTING'; |
| 28 | + status.dataset.state = 'warn'; |
| 29 | + setTimeout(connect, 1000); |
| 30 | + }); |
| 31 | + |
| 32 | + socket.addEventListener('message', (event) => { |
| 33 | + try { |
| 34 | + const raw = JSON.parse(event.data); |
| 35 | + const frame = decodeLiDARPacket(raw); |
| 36 | + const points = depthToPointCloud(frame, 1); |
| 37 | + render(points); |
| 38 | + |
| 39 | + const now = performance.now(); |
| 40 | + const delta = now - lastFrameAt; |
| 41 | + lastFrameAt = now; |
| 42 | + fpsEl.textContent = delta > 0 ? (1000 / delta).toFixed(1) : '0.0'; |
| 43 | + pointsEl.textContent = points.length.toLocaleString(); |
| 44 | + seqEl.textContent = frame.provenance.sequence; |
| 45 | + latencyEl.textContent = Math.max(0, Date.now() - Number(frame.provenance.timestampNs / 1_000_000)).toFixed(0); |
| 46 | + sensorEl.textContent = frame.provenance.sensor; |
| 47 | + } catch (error) { |
| 48 | + console.error(error); |
| 49 | + status.textContent = 'FRAME ERROR'; |
| 50 | + status.dataset.state = 'warn'; |
| 51 | + } |
| 52 | + }); |
| 53 | +} |
| 54 | + |
| 55 | +function resize() { |
| 56 | + const dpr = Math.min(devicePixelRatio || 1, 2); |
| 57 | + const rect = canvas.getBoundingClientRect(); |
| 58 | + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); |
| 59 | + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); |
| 60 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 61 | +} |
| 62 | + |
| 63 | +function render(points) { |
| 64 | + resize(); |
| 65 | + const w = canvas.clientWidth; |
| 66 | + const h = canvas.clientHeight; |
| 67 | + ctx.clearRect(0, 0, w, h); |
| 68 | + ctx.fillStyle = '#091018'; |
| 69 | + ctx.fillRect(0, 0, w, h); |
| 70 | + |
| 71 | + const cy = Math.cos(yaw); |
| 72 | + const sy = Math.sin(yaw); |
| 73 | + const cp = Math.cos(pitch); |
| 74 | + const sp = Math.sin(pitch); |
| 75 | + |
| 76 | + ctx.fillStyle = '#58e0d2'; |
| 77 | + for (let i = 0; i < points.length; i += 1) { |
| 78 | + let [x, y, z] = points[i]; |
| 79 | + const rx = x * cy - z * sy; |
| 80 | + const rz = x * sy + z * cy; |
| 81 | + const ry = y * cp - rz * sp; |
| 82 | + const rz2 = y * sp + rz * cp; |
| 83 | + const perspective = 1 / Math.max(0.35, 1.8 - rz2 * 0.12); |
| 84 | + const px = w / 2 + rx * scale * perspective; |
| 85 | + const py = h / 2 + ry * scale * perspective; |
| 86 | + if (px >= 0 && px < w && py >= 0 && py < h) ctx.fillRect(px, py, 1.4, 1.4); |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +canvas.addEventListener('pointermove', (event) => { |
| 91 | + if (!event.buttons) return; |
| 92 | + yaw += event.movementX * 0.006; |
| 93 | + pitch += event.movementY * 0.006; |
| 94 | +}); |
| 95 | + |
| 96 | +canvas.addEventListener('wheel', (event) => { |
| 97 | + event.preventDefault(); |
| 98 | + scale = Math.max(40, Math.min(400, scale - event.deltaY * 0.2)); |
| 99 | +}, { passive: false }); |
| 100 | + |
| 101 | +connect(); |
0 commit comments