Files
micarrsim/apps/array-builder/beam-pattern-surface-view.js
T

471 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
},
};
}