feat: add live multi-plane and 3d array views
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user