import { orbitForViewDirection } from "./orbit-view.js"; const MAX_PIXEL_RATIO = 3; const MAX_SURFACE_VIEW_SAMPLES = 20_000; const MAX_PITCH_RAD = Math.PI / 2; function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); } function numericSequence(value) { return Array.isArray(value) || ArrayBuffer.isView(value); } function formatFrequency(frequencyHz) { if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz"; if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz"; return frequencyHz.toFixed(1) + " Hz"; } function surfaceColor(level, alpha = 0.72) { const normalized = clamp(level, 0, 1); const hue = 198 - normalized * 48; const saturation = 58 + normalized * 24; const lightness = 18 + normalized * 48; return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; } function validateSurface(surface) { if (!surface || typeof surface !== "object") { throw new TypeError("3D 빔 패턴 결과가 없습니다."); } const azimuthAxisDeg = surface.azimuthAxisDeg; const elevationAxisDeg = surface.elevationAxisDeg; const displayDb = surface.displayDb; if ( !numericSequence(azimuthAxisDeg) || !numericSequence(elevationAxisDeg) || !numericSequence(displayDb) ) { throw new TypeError("3D 빔 패턴 축과 displayDb는 수치 배열이어야 합니다."); } const rows = Number(surface.gridShape?.[0]); const columns = Number(surface.gridShape?.[1]); if ( !Number.isInteger(rows) || !Number.isInteger(columns) || rows !== elevationAxisDeg.length || columns !== azimuthAxisDeg.length || rows < 2 || columns < 3 || displayDb.length !== rows * columns ) { throw new RangeError("3D 빔 패턴 grid shape과 표본 수가 일치하지 않습니다."); } if (rows * columns > MAX_SURFACE_VIEW_SAMPLES) { throw new RangeError( "3D 표시 표본은 " + MAX_SURFACE_VIEW_SAMPLES.toLocaleString("en-US") + "개를 넘을 수 없습니다.", ); } const dbFloor = Number(surface.dbFloor); if (!Number.isFinite(dbFloor) || dbFloor >= 0) { throw new RangeError("3D 빔 패턴 dbFloor는 0보다 작은 유한한 값이어야 합니다."); } for (const value of displayDb) { if (!Number.isFinite(Number(value))) { throw new RangeError("3D 빔 패턴 displayDb에는 유한한 값만 사용할 수 있습니다."); } } return { azimuthAxisDeg, elevationAxisDeg, displayDb, rows, columns, dbFloor }; } function prepareCanvas(canvas, context, view) { const rect = canvas.getBoundingClientRect(); const width = Math.max(1, rect.width || canvas.clientWidth || 720); const height = Math.max(1, rect.height || canvas.clientHeight || width * 0.625); const ratio = Math.min(MAX_PIXEL_RATIO, Math.max(1, Number(view.devicePixelRatio) || 1)); const pixelWidth = Math.max(1, Math.round(width * ratio)); const pixelHeight = Math.max(1, Math.round(height * ratio)); if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; } context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); return { width, height }; } function unitDirection(azimuthDeg, elevationDeg) { const azimuth = Number(azimuthDeg) * Math.PI / 180; const elevation = Number(elevationDeg) * Math.PI / 180; const horizontal = Math.cos(elevation); return [ horizontal * Math.cos(azimuth), horizontal * Math.sin(azimuth), Math.sin(elevation), ]; } function rotatedPoint(point, yaw, pitch) { const cosYaw = Math.cos(yaw); const sinYaw = Math.sin(yaw); const xYaw = cosYaw * point[0] - sinYaw * point[1]; const yYaw = sinYaw * point[0] + cosYaw * point[1]; const cosPitch = Math.cos(pitch); const sinPitch = Math.sin(pitch); return [ xYaw, point[2] * cosPitch + yYaw * sinPitch, point[2] * sinPitch - yYaw * cosPitch, ]; } function projectedPoint(point, yaw, pitch, size) { const rotated = rotatedPoint(point, yaw, pitch); const scale = Math.min(size.width, size.height) * 0.39; return { x: size.width / 2 + rotated[0] * scale, y: size.height / 2 - rotated[1] * scale, depth: rotated[2], }; } function polygonArea(points) { let area = 0; for (let index = 0; index < points.length; index += 1) { const current = points[index]; const next = points[(index + 1) % points.length]; area += current.x * next.y - next.x * current.y; } return Math.abs(area) / 2; } export function createReceiveBeamPatternSurfaceView(canvas, callbacks = {}) { if (!canvas?.getContext) { throw new TypeError("3D 빔 패턴 canvas가 필요합니다."); } const context = canvas.getContext("2d", { alpha: false }); if (!context) throw new Error("3D 빔 패턴 2D drawing context를 만들 수 없습니다."); const view = canvas.ownerDocument?.defaultView ?? globalThis; const requestFrame = view.requestAnimationFrame?.bind(view) ?? ((callback) => view.setTimeout(callback, 16)); const cancelFrame = view.cancelAnimationFrame?.bind(view) ?? view.clearTimeout?.bind(view); let surface = null; let validation = null; let errorMessage = ""; let viewDirection = "iso"; let { yaw, pitch } = orbitForViewDirection(viewDirection); let orbit = null; let frameId = 0; let destroyed = false; function schedule() { if (!destroyed && !frameId) frameId = requestFrame(draw); } function drawMessage(size, message) { context.fillStyle = "#07110f"; context.fillRect(0, 0, size.width, size.height); context.fillStyle = "#ffc1bd"; context.font = "650 13px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(message, size.width / 2, size.height / 2); } function drawAxis(size, vector, color, label) { const start = projectedPoint([0, 0, 0], yaw, pitch, size); const end = projectedPoint(vector.map((value) => value * 1.18), yaw, pitch, size); context.strokeStyle = color; context.fillStyle = color; context.lineWidth = 1.6; context.beginPath(); context.moveTo(start.x, start.y); context.lineTo(end.x, end.y); context.stroke(); context.font = "750 11px ui-monospace, SFMono-Regular, Consolas, monospace"; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(label, end.x, end.y - 10); } function draw() { frameId = 0; if (destroyed) return; const size = prepareCanvas(canvas, context, view); context.fillStyle = "#07110f"; context.fillRect(0, 0, size.width, size.height); if (errorMessage) { drawMessage(size, errorMessage); return; } if (!surface || !validation) { drawMessage(size, "3D 빔 패턴을 계산하고 있습니다."); return; } const { azimuthAxisDeg, elevationAxisDeg, displayDb, rows, columns, dbFloor } = validation; const vertices = new Array(rows * columns); for (let row = 0; row < rows; row += 1) { for (let column = 0; column < columns; column += 1) { const index = row * columns + column; const level = clamp((Number(displayDb[index]) - dbFloor) / -dbFloor, 0, 1); const direction = unitDirection(azimuthAxisDeg[column], elevationAxisDeg[row]); const point = direction.map((value) => value * level); vertices[index] = { ...projectedPoint(point, yaw, pitch, size), level }; } } const faces = []; for (let row = 0; row < rows - 1; row += 1) { for (let column = 0; column < columns; column += 1) { const nextColumn = (column + 1) % columns; const points = [ vertices[row * columns + column], vertices[row * columns + nextColumn], vertices[(row + 1) * columns + nextColumn], vertices[(row + 1) * columns + column], ]; if (polygonArea(points) < 0.08) continue; faces.push({ points, depth: points.reduce((sum, point) => sum + point.depth, 0) / points.length, level: points.reduce((sum, point) => sum + point.level, 0) / points.length, }); } } faces.sort((left, right) => left.depth - right.depth); for (const face of faces) { context.beginPath(); context.moveTo(face.points[0].x, face.points[0].y); for (let index = 1; index < face.points.length; index += 1) { context.lineTo(face.points[index].x, face.points[index].y); } context.closePath(); context.fillStyle = surfaceColor(face.level); context.fill(); context.strokeStyle = "rgba(218,245,236,.075)"; context.lineWidth = 0.55; context.stroke(); } const steeringDirection = Array.isArray(surface.steeringDirection) ? surface.steeringDirection : unitDirection(surface.steeringAzimuthDeg ?? 0, surface.steeringElevationDeg ?? 90); const origin = projectedPoint([0, 0, 0], yaw, pitch, size); const steeringEnd = projectedPoint( steeringDirection.map((value) => Number(value) * 1.12), yaw, pitch, size, ); context.save(); context.setLineDash([7, 5]); context.strokeStyle = "#ffc56e"; context.lineWidth = 2; context.beginPath(); context.moveTo(origin.x, origin.y); context.lineTo(steeringEnd.x, steeringEnd.y); context.stroke(); context.restore(); drawAxis(size, [1, 0, 0], "#ff8f85", "+X"); drawAxis(size, [0, 1, 0], "#58c7e8", "+Y"); drawAxis(size, [0, 0, 1], "#65f2bf", "+Z FRONT"); context.fillStyle = "rgba(7,17,15,.82)"; context.fillRect(12, 12, 190, 42); context.fillStyle = "#dceee8"; context.font = "700 12px ui-monospace, SFMono-Regular, Consolas, monospace"; context.textAlign = "left"; context.textBaseline = "top"; context.fillText(formatFrequency(Number(surface.frequencyHz)), 20, 20); context.fillStyle = "#8eaaa0"; context.font = "600 10px ui-monospace, SFMono-Regular, Consolas, monospace"; context.fillText(columns + "×" + rows + " raw sphere samples", 20, 38); const legendX = 16; const legendY = size.height - 26; const legendWidth = Math.min(170, size.width * 0.3); for (let index = 0; index < 32; index += 1) { const level = index / 31; context.fillStyle = surfaceColor(level, 1); context.fillRect(legendX + legendWidth * level, legendY, legendWidth / 31 + 1, 7); } context.fillStyle = "#9bb4ab"; context.font = "600 9px ui-monospace, SFMono-Regular, Consolas, monospace"; context.textBaseline = "bottom"; context.fillText(String(dbFloor) + " dB", legendX, legendY - 2); context.textAlign = "right"; context.fillText("0 dB", legendX + legendWidth, legendY - 2); } function render(nextSurface) { if (destroyed) return; try { validation = validateSurface(nextSurface); surface = nextSurface; errorMessage = ""; canvas.dataset.sampleCount = String(validation.rows * validation.columns); canvas.dataset.frequencyHz = String(nextSurface.frequencyHz); canvas.dataset.gridShape = validation.columns + "x" + validation.rows; } catch (error) { surface = null; validation = null; delete canvas.dataset.sampleCount; delete canvas.dataset.frequencyHz; delete canvas.dataset.gridShape; errorMessage = error instanceof Error ? error.message : String(error); } schedule(); } function renderError(message) { surface = null; validation = null; delete canvas.dataset.sampleCount; delete canvas.dataset.frequencyHz; delete canvas.dataset.gridShape; errorMessage = String(message || "3D 빔 패턴을 표시할 수 없습니다."); schedule(); } function updateViewDirection(nextDirection) { if (viewDirection === nextDirection) return; viewDirection = nextDirection; canvas.dataset.viewAxis = viewDirection; callbacks.onViewDirectionChange?.(viewDirection); } function setViewDirection(nextDirection) { const nextOrbit = orbitForViewDirection(nextDirection); yaw = nextOrbit.yaw; pitch = nextOrbit.pitch; updateViewDirection(nextDirection); schedule(); return viewDirection; } function markFreeOrbit() { updateViewDirection("custom"); } function finishOrbit(event) { if (!orbit || (event?.pointerId !== undefined && event.pointerId !== orbit.pointerId)) return; const pointerId = orbit.pointerId; orbit = null; delete canvas.dataset.orbiting; try { if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId); } catch { // Pointer capture may already be released. } } function onPointerDown(event) { if (event.button !== 0 || orbit) return; event.preventDefault(); try { canvas.focus({ preventScroll: true }); } catch { canvas.focus?.(); } orbit = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; canvas.dataset.orbiting = "true"; try { canvas.setPointerCapture?.(event.pointerId); } catch { // Synthetic pointers may not own capture. } } function onPointerMove(event) { if (!orbit || orbit.pointerId !== event.pointerId) return; event.preventDefault(); const deltaX = event.clientX - orbit.x; const deltaY = event.clientY - orbit.y; const nextYaw = yaw + deltaX * 0.011; const nextPitch = clamp( pitch - deltaY * 0.011, -MAX_PITCH_RAD, MAX_PITCH_RAD, ); if (nextYaw !== yaw || nextPitch !== pitch) markFreeOrbit(); yaw = nextYaw; pitch = nextPitch; orbit.x = event.clientX; orbit.y = event.clientY; schedule(); } function onKeyDown(event) { const step = event.shiftKey ? 15 : 5; if (event.key === "ArrowLeft") { markFreeOrbit(); yaw -= step * Math.PI / 180; } else if (event.key === "ArrowRight") { markFreeOrbit(); yaw += step * Math.PI / 180; } else if (event.key === "ArrowUp") { const nextPitch = clamp( pitch + step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD, ); if (nextPitch !== pitch) markFreeOrbit(); pitch = nextPitch; } else if (event.key === "ArrowDown") { const nextPitch = clamp( pitch - step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD, ); if (nextPitch !== pitch) markFreeOrbit(); pitch = nextPitch; } else if (event.key.toLowerCase() === "r") { setViewDirection("iso"); } else return; event.preventDefault(); schedule(); } canvas.setAttribute("aria-keyshortcuts", "ArrowLeft ArrowRight ArrowUp ArrowDown R"); canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointermove", onPointerMove); canvas.addEventListener("pointerup", finishOrbit); canvas.addEventListener("pointercancel", finishOrbit); canvas.addEventListener("lostpointercapture", finishOrbit); canvas.addEventListener("keydown", onKeyDown); let resizeObserver = null; const onResize = () => schedule(); if (typeof view.ResizeObserver === "function") { resizeObserver = new view.ResizeObserver(onResize); resizeObserver.observe(canvas); } else { view.addEventListener?.("resize", onResize); } canvas.dataset.viewAxis = viewDirection; schedule(); return { render, renderError, setViewDirection, resetOrbit() { return setViewDirection("iso"); }, destroy() { if (destroyed) return; destroyed = true; if (frameId) cancelFrame?.(frameId); resizeObserver?.disconnect(); view.removeEventListener?.("resize", onResize); canvas.removeEventListener("pointerdown", onPointerDown); canvas.removeEventListener("pointermove", onPointerMove); canvas.removeEventListener("pointerup", finishOrbit); canvas.removeEventListener("pointercancel", finishOrbit); canvas.removeEventListener("lostpointercapture", finishOrbit); canvas.removeEventListener("keydown", onKeyDown); surface = null; validation = null; orbit = null; }, }; }