feat: add live multi-plane and 3d array views

This commit is contained in:
2026-08-30 10:35:27 +09:00
parent dda72739a9
commit c289af366c
7 changed files with 1895 additions and 200 deletions
@@ -0,0 +1,429 @@
const MAX_PIXEL_RATIO = 3;
const MAX_SURFACE_VIEW_SAMPLES = 20_000;
const MAX_PITCH_RAD = Math.PI * 0.46;
const DEFAULT_YAW_RAD = -Math.PI / 5;
const DEFAULT_PITCH_RAD = Math.PI / 4.5;
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 perspective = 1 / Math.max(0.68, 1 - rotated[2] * 0.18);
const scale = Math.min(size.width, size.height) * 0.39;
return {
x: size.width / 2 + rotated[0] * scale * perspective,
y: size.height / 2 - rotated[1] * scale * perspective,
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) {
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 yaw = DEFAULT_YAW_RAD;
let pitch = DEFAULT_PITCH_RAD;
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 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();
yaw += (event.clientX - orbit.x) * 0.011;
pitch = clamp(
pitch - (event.clientY - orbit.y) * 0.011,
-MAX_PITCH_RAD,
MAX_PITCH_RAD,
);
orbit.x = event.clientX;
orbit.y = event.clientY;
schedule();
}
function onKeyDown(event) {
const step = event.shiftKey ? 15 : 5;
if (event.key === "ArrowLeft") yaw -= step * Math.PI / 180;
else if (event.key === "ArrowRight") yaw += step * Math.PI / 180;
else if (event.key === "ArrowUp") {
pitch = clamp(pitch + step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD);
} else if (event.key === "ArrowDown") {
pitch = clamp(pitch - step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD);
}
else if (event.key.toLowerCase() === "r") {
yaw = DEFAULT_YAW_RAD;
pitch = DEFAULT_PITCH_RAD;
} 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);
}
schedule();
return {
render,
renderError,
resetOrbit() {
yaw = DEFAULT_YAW_RAD;
pitch = DEFAULT_PITCH_RAD;
schedule();
},
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;
},
};
}