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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const MIN_ZOOM = 0.25;
|
|||||||
const MAX_ZOOM = 16;
|
const MAX_ZOOM = 16;
|
||||||
const TOUCH_GESTURE_THRESHOLD_PX = 6;
|
const TOUCH_GESTURE_THRESHOLD_PX = 6;
|
||||||
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
|
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
|
||||||
|
const DEFAULT_ORBIT = Object.freeze({ yaw: -Math.PI / 5, pitch: Math.PI / 4.5 });
|
||||||
|
|
||||||
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
|
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
|
||||||
|
|
||||||
@@ -21,8 +22,10 @@ function finiteNumber(value, fallback = 0) {
|
|||||||
|
|
||||||
function validPosition(position) {
|
function validPosition(position) {
|
||||||
return Array.isArray(position) &&
|
return Array.isArray(position) &&
|
||||||
|
position.length >= 3 &&
|
||||||
Number.isFinite(Number(position[0])) &&
|
Number.isFinite(Number(position[0])) &&
|
||||||
Number.isFinite(Number(position[1]));
|
Number.isFinite(Number(position[1])) &&
|
||||||
|
Number.isFinite(Number(position[2]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function apertureDimensions(arrayDefinition) {
|
function apertureDimensions(arrayDefinition) {
|
||||||
@@ -198,6 +201,89 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rotateArrayPosition3d(position, yaw, pitch) {
|
||||||
|
const x = finiteNumber(position?.[0]);
|
||||||
|
const y = finiteNumber(position?.[1]);
|
||||||
|
const z = finiteNumber(position?.[2]);
|
||||||
|
const cosYaw = Math.cos(yaw);
|
||||||
|
const sinYaw = Math.sin(yaw);
|
||||||
|
const cosPitch = Math.cos(pitch);
|
||||||
|
const sinPitch = Math.sin(pitch);
|
||||||
|
const horizontal = cosYaw * x - sinYaw * y;
|
||||||
|
const away = sinYaw * x + cosYaw * y;
|
||||||
|
return {
|
||||||
|
horizontal,
|
||||||
|
vertical: z * cosPitch + away * sinPitch,
|
||||||
|
depth: z * sinPitch - away * cosPitch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project an array [x,y,z] position into a fitted 3D inspection viewport. */
|
||||||
|
export function projectArrayPosition3d(position, viewport) {
|
||||||
|
const rotated = rotateArrayPosition3d(position, viewport.yaw, viewport.pitch);
|
||||||
|
return {
|
||||||
|
x: viewport.centerPixelX + (rotated.horizontal - viewport.centerHorizontal) * viewport.scale,
|
||||||
|
y: viewport.centerPixelY - (rotated.vertical - viewport.centerVertical) * viewport.scale,
|
||||||
|
depth: rotated.depth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitArrayViewport3d(width, height, model, zoom, orbit) {
|
||||||
|
const canvasWidth = Math.max(1, finiteNumber(width, 1));
|
||||||
|
const canvasHeight = Math.max(1, finiteNumber(height, 1));
|
||||||
|
const margins = resolvedMargins(canvasWidth, canvasHeight, {
|
||||||
|
left: 32,
|
||||||
|
right: 32,
|
||||||
|
top: 45,
|
||||||
|
bottom: 38,
|
||||||
|
});
|
||||||
|
const plot = {
|
||||||
|
x: margins.left,
|
||||||
|
y: margins.top,
|
||||||
|
width: Math.max(1, canvasWidth - margins.left - margins.right),
|
||||||
|
height: Math.max(1, canvasHeight - margins.top - margins.bottom),
|
||||||
|
};
|
||||||
|
const aperture = apertureDimensions(model.arrayDefinition);
|
||||||
|
const referenceSpan = Math.max(aperture.width, aperture.height, 0.000001);
|
||||||
|
const points = [
|
||||||
|
[-aperture.width / 2, -aperture.height / 2, 0],
|
||||||
|
[aperture.width / 2, -aperture.height / 2, 0],
|
||||||
|
[aperture.width / 2, aperture.height / 2, 0],
|
||||||
|
[-aperture.width / 2, aperture.height / 2, 0],
|
||||||
|
[0, 0, referenceSpan * 0.72],
|
||||||
|
];
|
||||||
|
for (const microphone of model.arrayDefinition.microphones ?? []) {
|
||||||
|
if (validPosition(microphone?.position)) points.push(microphone.position);
|
||||||
|
}
|
||||||
|
const rotated = points.map((position) => rotateArrayPosition3d(position, orbit.yaw, orbit.pitch));
|
||||||
|
const horizontalValues = rotated.map((point) => point.horizontal);
|
||||||
|
const verticalValues = rotated.map((point) => point.vertical);
|
||||||
|
const horizontalMin = Math.min(...horizontalValues);
|
||||||
|
const horizontalMax = Math.max(...horizontalValues);
|
||||||
|
const verticalMin = Math.min(...verticalValues);
|
||||||
|
const verticalMax = Math.max(...verticalValues);
|
||||||
|
const horizontalSpan = Math.max(0.000001, horizontalMax - horizontalMin);
|
||||||
|
const verticalSpan = Math.max(0.000001, verticalMax - verticalMin);
|
||||||
|
const scale = Math.max(
|
||||||
|
Number.MIN_VALUE,
|
||||||
|
Math.min(plot.width / (horizontalSpan * 1.22), plot.height / (verticalSpan * 1.22)) * zoom,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
plot,
|
||||||
|
scale,
|
||||||
|
zoom,
|
||||||
|
yaw: orbit.yaw,
|
||||||
|
pitch: orbit.pitch,
|
||||||
|
centerHorizontal: (horizontalMin + horizontalMax) / 2,
|
||||||
|
centerVertical: (verticalMin + verticalMax) / 2,
|
||||||
|
centerPixelX: plot.x + plot.width / 2,
|
||||||
|
centerPixelY: plot.y + plot.height / 2,
|
||||||
|
referenceSpan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Pure keyboard-to-array delta mapping used by the canvas controller. */
|
/** Pure keyboard-to-array delta mapping used by the canvas controller. */
|
||||||
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
|
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
|
||||||
const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
|
const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
|
||||||
@@ -444,9 +530,233 @@ function drawArray(context, size, model, zoom, drag) {
|
|||||||
context.textBaseline = "top";
|
context.textBaseline = "top";
|
||||||
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
|
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
|
||||||
context.textAlign = "left";
|
context.textAlign = "left";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
context.fillStyle = "rgba(101,242,191,.88)";
|
||||||
|
context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 5, 0, Math.PI * 2);
|
||||||
|
context.stroke();
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 1.8, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.fillText("+Z FRONT", viewport.plot.x + 20, viewport.plot.y + 5);
|
||||||
|
context.textAlign = "left";
|
||||||
return viewport;
|
return viewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function traceProjectedPositions(context, positions, viewport, close = false) {
|
||||||
|
context.beginPath();
|
||||||
|
positions.forEach((position, index) => {
|
||||||
|
const point = projectArrayPosition3d(position, viewport);
|
||||||
|
if (index === 0) context.moveTo(point.x, point.y);
|
||||||
|
else context.lineTo(point.x, point.y);
|
||||||
|
});
|
||||||
|
if (close) context.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function aperturePerimeter3d(model, sampleCount = 72) {
|
||||||
|
const aperture = apertureDimensions(model.arrayDefinition);
|
||||||
|
if (model.apertureShape !== "ellipse") {
|
||||||
|
return [
|
||||||
|
[-aperture.width / 2, -aperture.height / 2, 0],
|
||||||
|
[aperture.width / 2, -aperture.height / 2, 0],
|
||||||
|
[aperture.width / 2, aperture.height / 2, 0],
|
||||||
|
[-aperture.width / 2, aperture.height / 2, 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return Array.from({ length: sampleCount }, (_, index) => {
|
||||||
|
const angle = index / sampleCount * Math.PI * 2;
|
||||||
|
return [
|
||||||
|
Math.cos(angle) * aperture.width / 2,
|
||||||
|
Math.sin(angle) * aperture.height / 2,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawProjectedLine(context, start, end, viewport) {
|
||||||
|
const a = projectArrayPosition3d(start, viewport);
|
||||||
|
const b = projectArrayPosition3d(end, viewport);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(a.x, a.y);
|
||||||
|
context.lineTo(b.x, b.y);
|
||||||
|
context.stroke();
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArrayPlane3d(context, model, viewport) {
|
||||||
|
const perimeter = aperturePerimeter3d(model);
|
||||||
|
traceProjectedPositions(context, perimeter, viewport, true);
|
||||||
|
context.fillStyle = "rgba(101,242,191,.055)";
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = "rgba(174,229,210,.72)";
|
||||||
|
context.lineWidth = 1.5;
|
||||||
|
context.setLineDash([7, 5]);
|
||||||
|
context.stroke();
|
||||||
|
context.setLineDash([]);
|
||||||
|
|
||||||
|
const aperture = apertureDimensions(model.arrayDefinition);
|
||||||
|
context.strokeStyle = "rgba(166,196,186,.14)";
|
||||||
|
context.lineWidth = 1;
|
||||||
|
for (const fraction of [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75]) {
|
||||||
|
if (model.apertureShape === "ellipse") {
|
||||||
|
const bound = Math.sqrt(Math.max(0, 1 - fraction * fraction));
|
||||||
|
drawProjectedLine(
|
||||||
|
context,
|
||||||
|
[fraction * aperture.width / 2, -bound * aperture.height / 2, 0],
|
||||||
|
[fraction * aperture.width / 2, bound * aperture.height / 2, 0],
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
drawProjectedLine(
|
||||||
|
context,
|
||||||
|
[-bound * aperture.width / 2, fraction * aperture.height / 2, 0],
|
||||||
|
[bound * aperture.width / 2, fraction * aperture.height / 2, 0],
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
drawProjectedLine(
|
||||||
|
context,
|
||||||
|
[fraction * aperture.width / 2, -aperture.height / 2, 0],
|
||||||
|
[fraction * aperture.width / 2, aperture.height / 2, 0],
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
drawProjectedLine(
|
||||||
|
context,
|
||||||
|
[-aperture.width / 2, fraction * aperture.height / 2, 0],
|
||||||
|
[aperture.width / 2, fraction * aperture.height / 2, 0],
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAxis3d(context, viewport, endpoint, color, label) {
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.lineWidth = 2;
|
||||||
|
const point = drawProjectedLine(context, [0, 0, 0], endpoint, viewport);
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(point.x, point.y, 3, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.font = "800 10px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||||
|
context.textAlign = "left";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText(label, point.x + 6, point.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAxes3d(context, model, viewport) {
|
||||||
|
const aperture = apertureDimensions(model.arrayDefinition);
|
||||||
|
drawAxis3d(context, viewport, [aperture.width * 0.58, 0, 0], "#ff9e75", "+X");
|
||||||
|
drawAxis3d(context, viewport, [0, aperture.height * 0.58, 0], "#76b9ff", "+Y");
|
||||||
|
drawAxis3d(
|
||||||
|
context,
|
||||||
|
viewport,
|
||||||
|
[0, 0, viewport.referenceSpan * 0.72],
|
||||||
|
"#65f2bf",
|
||||||
|
"+Z FRONT",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedMicrophones(model, viewport) {
|
||||||
|
return (model.arrayDefinition.microphones ?? [])
|
||||||
|
.filter((microphone) => validPosition(microphone?.position))
|
||||||
|
.map((microphone, index) => ({
|
||||||
|
microphone,
|
||||||
|
index,
|
||||||
|
point: projectArrayPosition3d(microphone.position, viewport),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.point.depth - right.point.depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMicrophones3d(context, model, viewport) {
|
||||||
|
const microphones = projectedMicrophones(model, viewport);
|
||||||
|
const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7);
|
||||||
|
let selected = null;
|
||||||
|
for (const item of microphones) {
|
||||||
|
const { microphone, point } = item;
|
||||||
|
const isSelected = microphone.id === model.selectedMicrophoneId;
|
||||||
|
const inside = positionInsideAperture(
|
||||||
|
microphone.position,
|
||||||
|
model.arrayDefinition.aperture,
|
||||||
|
model.apertureShape,
|
||||||
|
);
|
||||||
|
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
|
||||||
|
context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)";
|
||||||
|
context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)";
|
||||||
|
context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.shadowBlur = 0;
|
||||||
|
context.strokeStyle = "rgba(239,255,249,.7)";
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.stroke();
|
||||||
|
if (isSelected) selected = { microphone, point, radius };
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
context.strokeStyle = "rgba(255,255,255,.94)";
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(selected.point.x, selected.point.y, selected.radius + 6, 0, Math.PI * 2);
|
||||||
|
context.stroke();
|
||||||
|
drawSelectedLabel(context, selected.microphone, selected.point, viewport);
|
||||||
|
}
|
||||||
|
return microphones;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArray3d(context, size, model, zoom, orbit) {
|
||||||
|
context.fillStyle = BACKGROUND;
|
||||||
|
context.fillRect(0, 0, size.width, size.height);
|
||||||
|
if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) {
|
||||||
|
drawEmpty(context, size.width, size.height);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const viewport = fitArrayViewport3d(size.width, size.height, model, zoom, orbit);
|
||||||
|
context.fillStyle = PLOT_BACKGROUND;
|
||||||
|
context.fillRect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
|
||||||
|
context.clip();
|
||||||
|
drawArrayPlane3d(context, model, viewport);
|
||||||
|
drawAxes3d(context, model, viewport);
|
||||||
|
viewport.projectedMicrophones = drawMicrophones3d(context, model, viewport);
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
context.strokeStyle = "rgba(174,211,199,.28)";
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.strokeRect(
|
||||||
|
viewport.plot.x + 0.5,
|
||||||
|
viewport.plot.y + 0.5,
|
||||||
|
Math.max(0, viewport.plot.width - 1),
|
||||||
|
Math.max(0, viewport.plot.height - 1),
|
||||||
|
);
|
||||||
|
context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
context.fillStyle = "rgba(205,225,218,.72)";
|
||||||
|
context.textAlign = "left";
|
||||||
|
context.fillText("3D INSPECT - drag to orbit", viewport.plot.x, 8);
|
||||||
|
context.textAlign = "right";
|
||||||
|
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
|
||||||
|
context.textAlign = "left";
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestProjectedMicrophone(point, model, viewport, radiusPx = 12) {
|
||||||
|
let best = null;
|
||||||
|
for (const item of viewport?.projectedMicrophones ?? projectedMicrophones(model, viewport)) {
|
||||||
|
const distancePx = Math.hypot(item.point.x - point.x, item.point.y - point.y);
|
||||||
|
const isCloser = !best || distancePx < best.distancePx - 1e-9;
|
||||||
|
const isNearerTie = best &&
|
||||||
|
Math.abs(distancePx - best.distancePx) <= 1e-9 &&
|
||||||
|
item.point.depth > best.point.depth;
|
||||||
|
if (distancePx <= radiusPx && (isCloser || isNearerTie)) {
|
||||||
|
best = { ...item, id: item.microphone.id, distancePx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedTool(tool) {
|
function normalizedTool(tool) {
|
||||||
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
|
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
|
||||||
}
|
}
|
||||||
@@ -471,6 +781,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let zoom = 1;
|
let zoom = 1;
|
||||||
let tool = "select";
|
let tool = "select";
|
||||||
|
let viewMode = "2d";
|
||||||
|
let orbit = { ...DEFAULT_ORBIT };
|
||||||
|
let orbitDrag = null;
|
||||||
let drag = null;
|
let drag = null;
|
||||||
let pendingAdd = null;
|
let pendingAdd = null;
|
||||||
|
|
||||||
@@ -479,15 +792,23 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
if (canvas.dataset) {
|
if (canvas.dataset) {
|
||||||
canvas.dataset.tool = tool;
|
canvas.dataset.tool = tool;
|
||||||
canvas.dataset.zoom = String(zoom);
|
canvas.dataset.zoom = String(zoom);
|
||||||
|
canvas.dataset.viewMode = viewMode;
|
||||||
|
canvas.dataset.orbiting = String(Boolean(orbitDrag));
|
||||||
|
}
|
||||||
|
if (canvas.style) {
|
||||||
|
canvas.style.cursor = viewMode === "3d"
|
||||||
|
? orbitDrag ? "grabbing" : "grab"
|
||||||
|
: tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
|
||||||
}
|
}
|
||||||
if (canvas.style) canvas.style.cursor = tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCurrent() {
|
function drawCurrent() {
|
||||||
frameId = 0;
|
frameId = 0;
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
const size = prepareContext(canvas, context, view);
|
const size = prepareContext(canvas, context, view);
|
||||||
viewport = drawArray(context, size, model, zoom, drag);
|
viewport = viewMode === "3d"
|
||||||
|
? drawArray3d(context, size, model, zoom, orbit)
|
||||||
|
: drawArray(context, size, model, zoom, drag);
|
||||||
updateCanvasAffordance();
|
updateCanvasAffordance();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,20 +820,30 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
if (viewport) return viewport;
|
if (viewport) return viewport;
|
||||||
if (!model?.arrayDefinition) return null;
|
if (!model?.arrayDefinition) return null;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
viewport = fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
|
viewport = viewMode === "3d"
|
||||||
|
? fitArrayViewport3d(rect.width, rect.height, model, zoom, orbit)
|
||||||
|
: fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
|
||||||
return viewport;
|
return viewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(nextModel) {
|
function render(nextModel) {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
model = nextModel;
|
model = nextModel;
|
||||||
|
const nextViewMode = nextModel?.viewMode === "3d" ? "3d" : "2d";
|
||||||
|
if (viewMode !== nextViewMode) {
|
||||||
|
viewMode = nextViewMode;
|
||||||
|
drag = null;
|
||||||
|
pendingAdd = null;
|
||||||
|
orbitDrag = null;
|
||||||
|
}
|
||||||
tool = normalizedTool(nextModel?.tool ?? tool);
|
tool = normalizedTool(nextModel?.tool ?? tool);
|
||||||
|
if (viewMode === "3d") tool = "select";
|
||||||
viewport = null;
|
viewport = null;
|
||||||
schedule();
|
schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTool(nextTool) {
|
function setTool(nextTool) {
|
||||||
const resolved = normalizedTool(nextTool);
|
const resolved = viewMode === "3d" ? "select" : normalizedTool(nextTool);
|
||||||
if (resolved === tool) return tool;
|
if (resolved === tool) return tool;
|
||||||
if (resolved !== "add") pendingAdd = null;
|
if (resolved !== "add") pendingAdd = null;
|
||||||
tool = resolved;
|
tool = resolved;
|
||||||
@@ -524,6 +855,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
|
|
||||||
function fit() {
|
function fit() {
|
||||||
zoom = 1;
|
zoom = 1;
|
||||||
|
if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT };
|
||||||
viewport = null;
|
viewport = null;
|
||||||
schedule();
|
schedule();
|
||||||
return zoom;
|
return zoom;
|
||||||
@@ -586,8 +918,29 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
TOUCH_GESTURE_THRESHOLD_PX;
|
TOUCH_GESTURE_THRESHOLD_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishOrbit(event) {
|
||||||
|
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pointerId = event?.pointerId ?? orbitDrag.pointerId;
|
||||||
|
orbitDrag = null;
|
||||||
|
try {
|
||||||
|
if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId);
|
||||||
|
} catch {
|
||||||
|
// Capture can be released by the browser before cancellation arrives.
|
||||||
|
}
|
||||||
|
updateCanvasAffordance();
|
||||||
|
schedule();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function onTouchStart(event) {
|
function onTouchStart(event) {
|
||||||
if (destroyed || tool !== "select" || !model?.arrayDefinition) return;
|
if (destroyed || !model?.arrayDefinition) return;
|
||||||
|
if (viewMode === "3d") {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tool !== "select") return;
|
||||||
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
|
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
|
||||||
const currentViewport = ensureViewport();
|
const currentViewport = ensureViewport();
|
||||||
if (!touch || !currentViewport) return;
|
if (!touch || !currentViewport) return;
|
||||||
@@ -601,8 +954,27 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
|
|
||||||
function onPointerDown(event) {
|
function onPointerDown(event) {
|
||||||
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
|
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
|
||||||
if (drag || pendingAdd) return;
|
if (drag || pendingAdd || orbitDrag) return;
|
||||||
focus();
|
focus();
|
||||||
|
if (viewMode === "3d") {
|
||||||
|
event.preventDefault();
|
||||||
|
const currentViewport = ensureViewport();
|
||||||
|
if (!currentViewport) return;
|
||||||
|
const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport);
|
||||||
|
callbacks.onSelect?.(hit?.id ?? null);
|
||||||
|
orbitDrag = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
lastClient: clientPoint(event),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Synthetic pointers and interrupted input do not always own capture.
|
||||||
|
}
|
||||||
|
updateCanvasAffordance();
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (tool === "add") {
|
if (tool === "add") {
|
||||||
const position = constrainedEventPosition(event, 0);
|
const position = constrainedEventPosition(event, 0);
|
||||||
if (position) {
|
if (position) {
|
||||||
@@ -649,6 +1021,18 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(event) {
|
function onPointerMove(event) {
|
||||||
|
if (orbitDrag?.pointerId === event.pointerId) {
|
||||||
|
const current = clientPoint(event);
|
||||||
|
const dx = current.x - orbitDrag.lastClient.x;
|
||||||
|
const dy = current.y - orbitDrag.lastClient.y;
|
||||||
|
orbitDrag.lastClient = current;
|
||||||
|
orbit.yaw += dx * 0.01;
|
||||||
|
orbit.pitch = clamp(orbit.pitch - dy * 0.01, -Math.PI * 0.46, Math.PI * 0.46);
|
||||||
|
viewport = null;
|
||||||
|
event.preventDefault();
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pendingAdd?.pointerId === event.pointerId) {
|
if (pendingAdd?.pointerId === event.pointerId) {
|
||||||
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
|
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
|
||||||
return;
|
return;
|
||||||
@@ -705,6 +1089,39 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
|
|
||||||
function onKeyDown(event) {
|
function onKeyDown(event) {
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||||
|
if (viewMode === "3d") {
|
||||||
|
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
|
if (key === "f" || key === "r") {
|
||||||
|
event.preventDefault();
|
||||||
|
fit();
|
||||||
|
callbacks.onFit?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
finishOrbit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orbitStep = event.shiftKey ? 0.18 : 0.06;
|
||||||
|
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep;
|
||||||
|
viewport = null;
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
orbit.pitch = clamp(
|
||||||
|
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
|
||||||
|
-Math.PI * 0.46,
|
||||||
|
Math.PI * 0.46,
|
||||||
|
);
|
||||||
|
viewport = null;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
if (lowerKey === "a") {
|
if (lowerKey === "a") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -742,16 +1159,22 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(event) {
|
function onPointerUp(event) {
|
||||||
|
if (finishOrbit(event)) return;
|
||||||
if (finishPendingAdd(event, false)) return;
|
if (finishPendingAdd(event, false)) return;
|
||||||
finishDrag(event, false);
|
finishDrag(event, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerCancel(event) {
|
function onPointerCancel(event) {
|
||||||
|
if (finishOrbit(event)) return;
|
||||||
if (finishPendingAdd(event, true)) return;
|
if (finishPendingAdd(event, true)) return;
|
||||||
finishDrag(event, true);
|
finishDrag(event, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLostPointerCapture(event) {
|
function onLostPointerCapture(event) {
|
||||||
|
if (orbitDrag?.pointerId === event.pointerId) {
|
||||||
|
finishOrbit(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
|
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,6 +1221,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
canvas.removeEventListener("keydown", onKeyDown);
|
canvas.removeEventListener("keydown", onKeyDown);
|
||||||
model = null;
|
model = null;
|
||||||
viewport = null;
|
viewport = null;
|
||||||
|
orbitDrag = null;
|
||||||
drag = null;
|
drag = null;
|
||||||
pendingAdd = null;
|
pendingAdd = null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<div class="workspace-heading">
|
<div class="workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">마이크 배치</p>
|
<p class="eyebrow">마이크 배치</p>
|
||||||
<h1>Array 평면 편집</h1>
|
<h1>Array 2D 편집 · 3D 검사</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
|
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +54,10 @@
|
|||||||
<button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button>
|
<button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button>
|
||||||
<button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
|
<button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar-group array-view-toggle" role="group" aria-label="배열 보기 방식">
|
||||||
|
<button class="tool-button is-active" id="array-view-2d-button" type="button" aria-pressed="true">2D 편집</button>
|
||||||
|
<button class="tool-button" id="array-view-3d-button" type="button" aria-pressed="false">3D 검사</button>
|
||||||
|
</div>
|
||||||
<div class="toolbar-group" aria-label="실행 취소와 다시 실행">
|
<div class="toolbar-group" aria-label="실행 취소와 다시 실행">
|
||||||
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
|
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
|
||||||
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
|
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
|
||||||
@@ -80,6 +84,7 @@
|
|||||||
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
|
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
|
||||||
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
|
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="canvas-front-badge" id="canvas-front-badge">+Z FRONT · 화면 바깥쪽</div>
|
||||||
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
|
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,7 +101,7 @@
|
|||||||
<p class="eyebrow">배열 분석</p>
|
<p class="eyebrow">배열 분석</p>
|
||||||
<h2>수신 빔 패턴</h2>
|
<h2>수신 빔 패턴</h2>
|
||||||
</div>
|
</div>
|
||||||
<span id="beam-pattern-summary">1 kHz · XZ · +Z 조향</span>
|
<span id="beam-pattern-summary">1 kHz · XZ/YZ/XY 동시</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="beam-pattern-body">
|
<div class="beam-pattern-body">
|
||||||
@@ -106,18 +111,30 @@
|
|||||||
<span>geometry-only</span>
|
<span>geometry-only</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="beam-pattern-view-switch" role="group" aria-label="빔 패턴 보기 방식">
|
||||||
|
<button class="tool-button is-active" id="beam-pattern-view-cuts-button" type="button" aria-pressed="true">세 평면</button>
|
||||||
|
<button class="tool-button" id="beam-pattern-view-3d-button" type="button" aria-pressed="false">3D 표면</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="beam-pattern-fields">
|
<div class="beam-pattern-fields">
|
||||||
<label class="field">
|
<label class="field beam-frequency-field">
|
||||||
<span>아날로그 주파수 (Hz)</span>
|
<span>아날로그 주파수 · 로그 슬라이더</span>
|
||||||
<input
|
<div class="beam-frequency-control">
|
||||||
id="beam-pattern-frequency"
|
<input
|
||||||
type="number"
|
id="beam-pattern-frequency"
|
||||||
min="1"
|
type="range"
|
||||||
max="1000000"
|
min="0"
|
||||||
step="100"
|
max="6000"
|
||||||
value="1000"
|
step="1"
|
||||||
inputmode="decimal"
|
value="3000"
|
||||||
/>
|
aria-valuemin="1"
|
||||||
|
aria-valuemax="1000000"
|
||||||
|
aria-valuenow="1000"
|
||||||
|
aria-valuetext="1.000 kHz"
|
||||||
|
/>
|
||||||
|
<output id="beam-pattern-frequency-value" for="beam-pattern-frequency">1.000 kHz</output>
|
||||||
|
</div>
|
||||||
|
<small>1 Hz–1 MHz · 이동 중 live preview</small>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>음속 (m/s)</span>
|
<span>음속 (m/s)</span>
|
||||||
@@ -131,16 +148,8 @@
|
|||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field" data-beam-mode="cuts">
|
||||||
<span>360° 절단면</span>
|
<span>단면별 조향각 (deg)</span>
|
||||||
<select id="beam-pattern-cut">
|
|
||||||
<option value="xz">XZ · 0° +Z / 90° +X</option>
|
|
||||||
<option value="yz">YZ · 0° +Z / 90° +Y</option>
|
|
||||||
<option value="xy">XY · 0° +X / 90° +Y</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>조향각 (deg)</span>
|
|
||||||
<input
|
<input
|
||||||
id="beam-pattern-steering-angle"
|
id="beam-pattern-steering-angle"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -150,8 +159,9 @@
|
|||||||
value="0"
|
value="0"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
/>
|
/>
|
||||||
|
<small>각 XZ/YZ/XY 평면 안에서 독립 조향</small>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field" data-beam-mode="cuts">
|
||||||
<span>각도 샘플</span>
|
<span>각도 샘플</span>
|
||||||
<select id="beam-pattern-samples">
|
<select id="beam-pattern-samples">
|
||||||
<option value="auto">자동 · 위상 간격 기준</option>
|
<option value="auto">자동 · 위상 간격 기준</option>
|
||||||
@@ -162,6 +172,24 @@
|
|||||||
<option value="5760">5,760 · 0.0625°</option>
|
<option value="5760">5,760 · 0.0625°</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="field" data-beam-mode="surface" hidden>
|
||||||
|
<span>3D 조향 방위각 (deg)</span>
|
||||||
|
<input id="beam-pattern-steering-azimuth" type="number" min="-360" max="360" step="1" value="0" inputmode="decimal" />
|
||||||
|
<small>XY에서 +X→+Y</small>
|
||||||
|
</label>
|
||||||
|
<label class="field" data-beam-mode="surface" hidden>
|
||||||
|
<span>3D 조향 고도각 (deg)</span>
|
||||||
|
<input id="beam-pattern-steering-elevation" type="number" min="-90" max="90" step="1" value="90" inputmode="decimal" />
|
||||||
|
<small>+90°가 +Z FRONT</small>
|
||||||
|
</label>
|
||||||
|
<label class="field" data-beam-mode="surface" hidden>
|
||||||
|
<span>3D 구면 표본</span>
|
||||||
|
<select id="beam-pattern-surface-resolution">
|
||||||
|
<option value="5">5° · 72×37</option>
|
||||||
|
<option value="3" selected>3° · 120×61</option>
|
||||||
|
<option value="2.5">2.5° · 144×73</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>표시 하한 (dB)</span>
|
<span>표시 하한 (dB)</span>
|
||||||
<select id="beam-pattern-floor">
|
<select id="beam-pattern-floor">
|
||||||
@@ -185,24 +213,44 @@
|
|||||||
현재 배열의 수신 array factor를 계산하고 있습니다.
|
현재 배열의 수신 array factor를 계산하고 있습니다.
|
||||||
</p>
|
</p>
|
||||||
<p class="beam-pattern-note" id="beam-pattern-model-note">
|
<p class="beam-pattern-note" id="beam-pattern-model-note">
|
||||||
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 단면입니다. 3D 전체 패턴,
|
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 응답입니다. 세 평면은 각각
|
||||||
근거리, 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로
|
독립 조향한 great-circle 단면이며 3D 모드는 실제 구면 방향을 계산합니다. 근거리,
|
||||||
Nyquist 초과 주파수도 접지 않고 원래 파장으로 계산합니다.
|
방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로 Nyquist 초과
|
||||||
|
주파수도 접지 않고 원래 파장으로 계산합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<figure class="beam-pattern-figure">
|
<div class="beam-pattern-visuals">
|
||||||
<svg
|
<div class="beam-pattern-cut-grid" id="beam-pattern-cuts-view">
|
||||||
id="beam-pattern-plot"
|
<figure class="beam-pattern-figure" data-cut="xz">
|
||||||
viewBox="0 0 520 520"
|
<div class="beam-pattern-figure-heading"><strong>XZ</strong><span>0° +Z · 90° +X</span></div>
|
||||||
role="img"
|
<svg id="beam-pattern-plot-xz" viewBox="0 0 520 520" role="img" aria-label="XZ 수신 빔 패턴 polar plot" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-caption-xz"></svg>
|
||||||
aria-label="수신 빔 패턴 polar plot"
|
<figcaption id="beam-pattern-caption-xz">계산 중…</figcaption>
|
||||||
aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-peaks"
|
</figure>
|
||||||
></svg>
|
<figure class="beam-pattern-figure" data-cut="yz">
|
||||||
<figcaption id="beam-pattern-peaks">
|
<div class="beam-pattern-figure-heading"><strong>YZ</strong><span>0° +Z · 90° +Y</span></div>
|
||||||
국소 최대를 계산하고 있습니다.
|
<svg id="beam-pattern-plot-yz" viewBox="0 0 520 520" role="img" aria-label="YZ 수신 빔 패턴 polar plot" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-caption-yz"></svg>
|
||||||
</figcaption>
|
<figcaption id="beam-pattern-caption-yz">계산 중…</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
<figure class="beam-pattern-figure" data-cut="xy">
|
||||||
|
<div class="beam-pattern-figure-heading"><strong>XY</strong><span>0° +X · 90° +Y</span></div>
|
||||||
|
<svg id="beam-pattern-plot-xy" viewBox="0 0 520 520" role="img" aria-label="XY 수신 빔 패턴 polar plot" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-caption-xy"></svg>
|
||||||
|
<figcaption id="beam-pattern-caption-xy">계산 중…</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure class="beam-pattern-surface-figure" id="beam-pattern-surface-view" hidden>
|
||||||
|
<div class="beam-pattern-figure-heading"><strong>3D 구면 응답</strong><span>drag하여 회전 · +Z FRONT</span></div>
|
||||||
|
<canvas id="beam-pattern-surface" tabindex="0" role="img" aria-label="3D 수신 빔 패턴 표면" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-surface-caption"></canvas>
|
||||||
|
<figcaption id="beam-pattern-surface-caption">3D 표면을 준비하고 있습니다.</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="beam-pattern-diagnostics" id="beam-pattern-peaks" aria-label="평면별 sampled peak 진단">
|
||||||
|
<p data-cut="xz"><strong>XZ</strong><span>계산 중…</span></p>
|
||||||
|
<p data-cut="yz"><strong>YZ</strong><span>계산 중…</span></p>
|
||||||
|
<p data-cut="xy"><strong>XY</strong><span>계산 중…</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+385
-106
@@ -1,8 +1,12 @@
|
|||||||
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
||||||
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||||||
import { computeReceiveBeamPattern } from "/packages/acoustics-core/beam-pattern.js";
|
import {
|
||||||
|
computeReceiveBeamPattern,
|
||||||
|
computeReceiveBeamPatternSphere,
|
||||||
|
} from "/packages/acoustics-core/beam-pattern.js";
|
||||||
import { createSplitPane } from "/packages/ui/split-pane.js";
|
import { createSplitPane } from "/packages/ui/split-pane.js";
|
||||||
import { createArrayCanvas } from "./canvas-view.js";
|
import { createArrayCanvas } from "./canvas-view.js";
|
||||||
|
import { createReceiveBeamPatternSurfaceView } from "./beam-pattern-surface-view.js";
|
||||||
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
|
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
|
||||||
import {
|
import {
|
||||||
addMicrophone,
|
addMicrophone,
|
||||||
@@ -36,14 +40,22 @@ const requiredIds = [
|
|||||||
"aperture-shape", "preset-type", "preset-count", "preset-columns",
|
"aperture-shape", "preset-type", "preset-count", "preset-columns",
|
||||||
"preset-rows", "preset-seed", "preset-minimum-spacing",
|
"preset-rows", "preset-seed", "preset-minimum-spacing",
|
||||||
"generate-preset-button", "array-canvas", "select-tool-button",
|
"generate-preset-button", "array-canvas", "select-tool-button",
|
||||||
"add-tool-button", "fit-view-button", "zoom-in-button", "zoom-out-button",
|
"add-tool-button", "array-view-2d-button", "array-view-3d-button",
|
||||||
|
"canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
|
||||||
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
|
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
|
||||||
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
|
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
|
||||||
"remove-microphone-button", "validation-title", "validation-message",
|
"remove-microphone-button", "validation-title", "validation-message",
|
||||||
"beam-pattern-panel", "beam-pattern-frequency",
|
"beam-pattern-panel", "beam-pattern-frequency", "beam-pattern-frequency-value",
|
||||||
"beam-pattern-speed-of-sound", "beam-pattern-cut",
|
"beam-pattern-speed-of-sound", "beam-pattern-steering-angle",
|
||||||
"beam-pattern-steering-angle", "beam-pattern-samples",
|
"beam-pattern-steering-azimuth", "beam-pattern-steering-elevation",
|
||||||
"beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status",
|
"beam-pattern-samples", "beam-pattern-surface-resolution",
|
||||||
|
"beam-pattern-floor", "beam-pattern-view-cuts-button",
|
||||||
|
"beam-pattern-view-3d-button", "beam-pattern-cuts-view",
|
||||||
|
"beam-pattern-surface-view", "beam-pattern-surface",
|
||||||
|
"beam-pattern-plot-xz", "beam-pattern-plot-yz", "beam-pattern-plot-xy",
|
||||||
|
"beam-pattern-caption-xz", "beam-pattern-caption-yz",
|
||||||
|
"beam-pattern-caption-xy", "beam-pattern-surface-caption",
|
||||||
|
"beam-pattern-status",
|
||||||
"beam-pattern-summary", "beam-pattern-wavelength",
|
"beam-pattern-summary", "beam-pattern-wavelength",
|
||||||
"beam-pattern-resolution", "beam-pattern-steering-response",
|
"beam-pattern-resolution", "beam-pattern-steering-response",
|
||||||
"beam-pattern-hpbw", "beam-pattern-peaks",
|
"beam-pattern-hpbw", "beam-pattern-peaks",
|
||||||
@@ -62,7 +74,14 @@ const optionalIds = [
|
|||||||
];
|
];
|
||||||
for (const id of optionalIds) elements[id] = maybeById(id);
|
for (const id of optionalIds) elements[id] = maybeById(id);
|
||||||
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
||||||
const beamPatternView = createReceiveBeamPatternView(elements["beam-pattern-plot"]);
|
const BEAM_PATTERN_CUTS = Object.freeze(["xz", "yz", "xy"]);
|
||||||
|
const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
|
||||||
|
cut,
|
||||||
|
createReceiveBeamPatternView(elements["beam-pattern-plot-" + cut]),
|
||||||
|
]));
|
||||||
|
const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
|
||||||
|
elements["beam-pattern-surface"],
|
||||||
|
);
|
||||||
|
|
||||||
const splitPane = createSplitPane({
|
const splitPane = createSplitPane({
|
||||||
container: elements["array-builder-layout"],
|
container: elements["array-builder-layout"],
|
||||||
@@ -92,6 +111,8 @@ const state = {
|
|||||||
dragSnapshot: null,
|
dragSnapshot: null,
|
||||||
importRevision: 0,
|
importRevision: 0,
|
||||||
baselineSignature: "",
|
baselineSignature: "",
|
||||||
|
arrayViewMode: "2d",
|
||||||
|
beamPatternViewMode: "cuts",
|
||||||
beamPatternRenderFrame: 0,
|
beamPatternRenderFrame: 0,
|
||||||
beamPatternDirty: true,
|
beamPatternDirty: true,
|
||||||
beamPatternPreviewPending: false,
|
beamPatternPreviewPending: false,
|
||||||
@@ -337,6 +358,7 @@ function dismissCanvasInstructions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTool(tool, options = {}) {
|
function setTool(tool, options = {}) {
|
||||||
|
if (state.arrayViewMode === "3d" && tool === "add") return state.tool;
|
||||||
state.tool = tool === "add" ? "add" : "select";
|
state.tool = tool === "add" ? "add" : "select";
|
||||||
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
|
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
|
||||||
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
|
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
|
||||||
@@ -351,12 +373,66 @@ function setTool(tool, options = {}) {
|
|||||||
if (options.focus) canvasView.focus();
|
if (options.focus) canvasView.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setArrayViewMode(mode, options = {}) {
|
||||||
|
const resolved = mode === "3d" ? "3d" : "2d";
|
||||||
|
state.arrayViewMode = resolved;
|
||||||
|
if (resolved === "3d") state.tool = "select";
|
||||||
|
elements["array-view-2d-button"].classList.toggle("is-active", resolved === "2d");
|
||||||
|
elements["array-view-3d-button"].classList.toggle("is-active", resolved === "3d");
|
||||||
|
elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d"));
|
||||||
|
elements["array-view-3d-button"].setAttribute("aria-pressed", String(resolved === "3d"));
|
||||||
|
elements["add-tool-button"].disabled = resolved === "3d";
|
||||||
|
elements["select-tool-button"].disabled = resolved === "3d";
|
||||||
|
const instruction = elements["canvas-instructions"];
|
||||||
|
if (instruction) {
|
||||||
|
const title = instruction.querySelector("strong");
|
||||||
|
const detail = instruction.querySelector("span");
|
||||||
|
if (title) {
|
||||||
|
title.textContent = resolved === "3d"
|
||||||
|
? "드래그하여 배열을 회전하고 채널을 클릭해 선택하세요."
|
||||||
|
: "마이크를 클릭하거나 드래그해 위치를 편집하세요.";
|
||||||
|
}
|
||||||
|
if (detail) {
|
||||||
|
detail.textContent = resolved === "3d"
|
||||||
|
? "3D는 검사 전용입니다. 방향키로 회전하고 R로 초기화하며, 좌표 수정은 2D 또는 x/y/z 입력에서 합니다."
|
||||||
|
: "추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.";
|
||||||
|
}
|
||||||
|
instruction.classList.remove("is-hidden");
|
||||||
|
}
|
||||||
|
elements["canvas-front-badge"].textContent = resolved === "3d"
|
||||||
|
? "+Z FRONT · 초록 축"
|
||||||
|
: "+Z FRONT · 화면 바깥쪽";
|
||||||
|
elements["array-canvas"].setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
resolved === "3d"
|
||||||
|
? "마이크 어레이 3D 검사 canvas, 드래그하여 회전"
|
||||||
|
: "마이크 어레이 평면 편집 canvas",
|
||||||
|
);
|
||||||
|
elements["array-canvas"].setAttribute(
|
||||||
|
"aria-keyshortcuts",
|
||||||
|
resolved === "3d"
|
||||||
|
? "ArrowLeft ArrowRight ArrowUp ArrowDown F R Escape"
|
||||||
|
: "V A F Escape Delete ArrowLeft ArrowRight ArrowUp ArrowDown",
|
||||||
|
);
|
||||||
|
setTool(state.tool);
|
||||||
|
canvasView.render(canvasState());
|
||||||
|
if (options.focus) canvasView.focus();
|
||||||
|
if (options.announce !== false) {
|
||||||
|
announceSelection(
|
||||||
|
resolved === "3d"
|
||||||
|
? "3D 검사 · +Z가 FRONT입니다."
|
||||||
|
: "2D 편집 · +Z FRONT는 화면 바깥 방향입니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function canvasState() {
|
function canvasState() {
|
||||||
return {
|
return {
|
||||||
arrayDefinition: state.arrayDefinition,
|
arrayDefinition: state.arrayDefinition,
|
||||||
apertureShape: state.apertureShape,
|
apertureShape: state.apertureShape,
|
||||||
selectedMicrophoneId: state.selectedMicrophoneId,
|
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||||
tool: state.tool,
|
tool: state.tool,
|
||||||
|
viewMode: state.arrayViewMode,
|
||||||
snapStepM: state.snapEnabled ? state.snapStepM : 0,
|
snapStepM: state.snapEnabled ? state.snapStepM : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -496,13 +572,24 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const beamPatternInputIds = [
|
const beamPatternInputIds = [
|
||||||
"beam-pattern-frequency",
|
|
||||||
"beam-pattern-speed-of-sound",
|
"beam-pattern-speed-of-sound",
|
||||||
"beam-pattern-cut",
|
|
||||||
"beam-pattern-steering-angle",
|
"beam-pattern-steering-angle",
|
||||||
|
"beam-pattern-steering-azimuth",
|
||||||
|
"beam-pattern-steering-elevation",
|
||||||
"beam-pattern-samples",
|
"beam-pattern-samples",
|
||||||
|
"beam-pattern-surface-resolution",
|
||||||
"beam-pattern-floor",
|
"beam-pattern-floor",
|
||||||
];
|
];
|
||||||
|
const BEAM_FREQUENCY_LOG_STEPS_PER_DECADE = 1000;
|
||||||
|
const BEAM_PATTERN_LIVE_EVALUATION_BUDGET = 2_400_000;
|
||||||
|
const BEAM_PATTERN_FULL_EVALUATION_BUDGET = 16_000_000;
|
||||||
|
|
||||||
|
function beamPatternEvaluationBudget(preview, simultaneousViews = 1) {
|
||||||
|
const total = preview
|
||||||
|
? BEAM_PATTERN_LIVE_EVALUATION_BUDGET
|
||||||
|
: BEAM_PATTERN_FULL_EVALUATION_BUDGET;
|
||||||
|
return Math.floor(total / simultaneousViews);
|
||||||
|
}
|
||||||
|
|
||||||
function beamPatternNumber(id, label, options) {
|
function beamPatternNumber(id, label, options) {
|
||||||
const element = elements[id];
|
const element = elements[id];
|
||||||
@@ -516,39 +603,35 @@ function beamPatternNumber(id, label, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBeamPatternOptions(preview) {
|
function beamPatternFrequencyHz() {
|
||||||
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
|
const slider = elements["beam-pattern-frequency"];
|
||||||
const frequencyHz = beamPatternNumber(
|
const logarithmicPosition = finiteInput(slider, "아날로그 주파수 slider", {
|
||||||
"beam-pattern-frequency",
|
minimum: 0,
|
||||||
"아날로그 주파수",
|
maximum: 6000,
|
||||||
{ exclusiveMinimum: 0, maximum: 1_000_000 },
|
});
|
||||||
|
return Number(
|
||||||
|
Math.pow(10, logarithmicPosition / BEAM_FREQUENCY_LOG_STEPS_PER_DECADE)
|
||||||
|
.toPrecision(12),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBeamPatternFrequency() {
|
||||||
|
const frequencyHz = beamPatternFrequencyHz();
|
||||||
|
const formatted = formatBeamFrequency(frequencyHz);
|
||||||
|
elements["beam-pattern-frequency-value"].textContent = formatted;
|
||||||
|
elements["beam-pattern-frequency"].setAttribute("aria-valuenow", String(frequencyHz));
|
||||||
|
elements["beam-pattern-frequency"].setAttribute("aria-valuetext", formatted);
|
||||||
|
return frequencyHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBeamPatternCommonOptions() {
|
||||||
|
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
|
||||||
|
const frequencyHz = syncBeamPatternFrequency();
|
||||||
const speedOfSound = beamPatternNumber(
|
const speedOfSound = beamPatternNumber(
|
||||||
"beam-pattern-speed-of-sound",
|
"beam-pattern-speed-of-sound",
|
||||||
"음속",
|
"음속",
|
||||||
{ minimum: 250, maximum: 400 },
|
{ minimum: 250, maximum: 400 },
|
||||||
);
|
);
|
||||||
const steeringAngleDeg = beamPatternNumber(
|
|
||||||
"beam-pattern-steering-angle",
|
|
||||||
"조향각",
|
|
||||||
{ minimum: -360, maximum: 360 },
|
|
||||||
);
|
|
||||||
const cut = elements["beam-pattern-cut"].value;
|
|
||||||
if (!["xy", "xz", "yz"].includes(cut)) {
|
|
||||||
elements["beam-pattern-cut"].setAttribute("aria-invalid", "true");
|
|
||||||
throw new Error("절단면은 XY, XZ 또는 YZ여야 합니다.");
|
|
||||||
}
|
|
||||||
const sampleValue = elements["beam-pattern-samples"].value;
|
|
||||||
const configuredSampleCount = sampleValue === "auto"
|
|
||||||
? "auto"
|
|
||||||
: Number(sampleValue);
|
|
||||||
if (
|
|
||||||
configuredSampleCount !== "auto" &&
|
|
||||||
(!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
|
|
||||||
) {
|
|
||||||
elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
|
|
||||||
throw new Error("각도 샘플 설정이 올바르지 않습니다.");
|
|
||||||
}
|
|
||||||
const dbFloor = Number(elements["beam-pattern-floor"].value);
|
const dbFloor = Number(elements["beam-pattern-floor"].value);
|
||||||
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
|
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
|
||||||
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
|
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
|
||||||
@@ -558,18 +641,73 @@ function readBeamPatternOptions(preview) {
|
|||||||
array: state.arrayDefinition,
|
array: state.arrayDefinition,
|
||||||
frequencyHz,
|
frequencyHz,
|
||||||
speedOfSound,
|
speedOfSound,
|
||||||
cut,
|
|
||||||
steeringAngleDeg,
|
|
||||||
sampleCount: preview
|
|
||||||
? Math.min(
|
|
||||||
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
|
|
||||||
720,
|
|
||||||
)
|
|
||||||
: configuredSampleCount,
|
|
||||||
dbFloor,
|
dbFloor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBeamPatternCutOptions(preview) {
|
||||||
|
const common = readBeamPatternCommonOptions();
|
||||||
|
const steeringAngleDeg = beamPatternNumber(
|
||||||
|
"beam-pattern-steering-angle",
|
||||||
|
"단면별 조향각",
|
||||||
|
{ minimum: -360, maximum: 360 },
|
||||||
|
);
|
||||||
|
const sampleValue = elements["beam-pattern-samples"].value;
|
||||||
|
const configuredSampleCount = sampleValue === "auto" ? "auto" : Number(sampleValue);
|
||||||
|
if (
|
||||||
|
configuredSampleCount !== "auto" &&
|
||||||
|
(!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
|
||||||
|
) {
|
||||||
|
elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
|
||||||
|
throw new Error("각도 샘플 설정이 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
steeringAngleDeg,
|
||||||
|
maximumEvaluations: beamPatternEvaluationBudget(
|
||||||
|
preview,
|
||||||
|
BEAM_PATTERN_CUTS.length,
|
||||||
|
),
|
||||||
|
sampleCount: preview
|
||||||
|
? Math.min(configuredSampleCount === "auto" ? 720 : configuredSampleCount, 720)
|
||||||
|
: configuredSampleCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBeamPatternSurfaceOptions(preview) {
|
||||||
|
const common = readBeamPatternCommonOptions();
|
||||||
|
const steeringAzimuthDeg = beamPatternNumber(
|
||||||
|
"beam-pattern-steering-azimuth",
|
||||||
|
"3D 조향 방위각",
|
||||||
|
{ minimum: -360, maximum: 360 },
|
||||||
|
);
|
||||||
|
const steeringElevationDeg = beamPatternNumber(
|
||||||
|
"beam-pattern-steering-elevation",
|
||||||
|
"3D 조향 고도각",
|
||||||
|
{ minimum: -90, maximum: 90 },
|
||||||
|
);
|
||||||
|
const configuredStepDeg = Number(elements["beam-pattern-surface-resolution"].value);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(configuredStepDeg) ||
|
||||||
|
configuredStepDeg < 1 ||
|
||||||
|
configuredStepDeg > 15 ||
|
||||||
|
360 % configuredStepDeg !== 0 ||
|
||||||
|
180 % configuredStepDeg !== 0
|
||||||
|
) {
|
||||||
|
elements["beam-pattern-surface-resolution"].setAttribute("aria-invalid", "true");
|
||||||
|
throw new Error("3D 구면 표본 간격이 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
const stepDeg = preview ? Math.max(6, configuredStepDeg) : configuredStepDeg;
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
steeringAzimuthDeg,
|
||||||
|
steeringElevationDeg,
|
||||||
|
maximumEvaluations: beamPatternEvaluationBudget(preview),
|
||||||
|
azimuthSampleCount: Math.round(360 / stepDeg),
|
||||||
|
elevationSampleCount: Math.round(180 / stepDeg) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatBeamFrequency(frequencyHz) {
|
function formatBeamFrequency(frequencyHz) {
|
||||||
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
|
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
|
||||||
if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz";
|
if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz";
|
||||||
@@ -624,80 +762,198 @@ function setBeamPatternStatus(message, severity = "success") {
|
|||||||
elements["beam-pattern-status"].dataset.severity = severity;
|
elements["beam-pattern-status"].dataset.severity = severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBeamPatternCuts(preview, startedAt) {
|
||||||
|
const options = readBeamPatternCutOptions(preview);
|
||||||
|
const patterns = BEAM_PATTERN_CUTS.map((cut) =>
|
||||||
|
computeReceiveBeamPattern({ ...options, cut }));
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const cut = pattern.cut;
|
||||||
|
const plot = elements["beam-pattern-plot-" + cut];
|
||||||
|
beamPatternViews[cut].render(pattern);
|
||||||
|
plot.dataset.sampleCount = String(pattern.sampleCount);
|
||||||
|
plot.dataset.cut = cut;
|
||||||
|
plot.dataset.frequencyHz = String(pattern.frequencyHz);
|
||||||
|
elements["beam-pattern-caption-" + cut].textContent =
|
||||||
|
pattern.sampleCount.toLocaleString("ko-KR") + " samples · " +
|
||||||
|
(preview
|
||||||
|
? "live preview · 놓으면 HPBW 재계산"
|
||||||
|
: "HPBW " + formatBeamHpbw(pattern.metrics.halfPowerBeamwidth));
|
||||||
|
const diagnostic = elements["beam-pattern-peaks"].querySelector(
|
||||||
|
'[data-cut="' + cut + '"] span',
|
||||||
|
);
|
||||||
|
if (diagnostic) {
|
||||||
|
diagnostic.textContent = preview
|
||||||
|
? "live raw samples · 놓으면 peak 재계산"
|
||||||
|
: describeBeamPatternPeaks(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = patterns[0];
|
||||||
|
elements["beam-pattern-summary"].textContent =
|
||||||
|
formatBeamFrequency(reference.frequencyHz) + " · XZ/YZ/XY 동시 · " +
|
||||||
|
reference.steeringAngleDeg.toFixed(1) + "° 독립 조향";
|
||||||
|
elements["beam-pattern-wavelength"].textContent =
|
||||||
|
formatBeamWavelength(reference.wavelengthM);
|
||||||
|
elements["beam-pattern-resolution"].textContent = patterns
|
||||||
|
.map((pattern) =>
|
||||||
|
pattern.cut.toUpperCase() + " " +
|
||||||
|
pattern.sampleCount.toLocaleString("ko-KR") + " / " +
|
||||||
|
pattern.angularResolutionDeg.toFixed(3) + "°")
|
||||||
|
.join(" · ");
|
||||||
|
elements["beam-pattern-steering-response"].textContent = patterns
|
||||||
|
.map((pattern) =>
|
||||||
|
pattern.cut.toUpperCase() + " " +
|
||||||
|
formatBeamDecibels(pattern.metrics.steeringDb))
|
||||||
|
.join(" · ");
|
||||||
|
elements["beam-pattern-hpbw"].textContent = preview
|
||||||
|
? "slider/drag 종료 후 계산"
|
||||||
|
: patterns
|
||||||
|
.map((pattern) =>
|
||||||
|
pattern.cut.toUpperCase() + " " +
|
||||||
|
formatBeamHpbw(pattern.metrics.halfPowerBeamwidth))
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
const elapsedMs = performance.now() - startedAt;
|
||||||
|
const unresolved = patterns.filter(
|
||||||
|
(pattern) => !pattern.angularSampling.metricResolutionSatisfied,
|
||||||
|
);
|
||||||
|
const missingCrossing = patterns.filter(
|
||||||
|
(pattern) =>
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status ===
|
||||||
|
"no-sampled-half-power-crossing",
|
||||||
|
);
|
||||||
|
const evaluationCount = patterns.reduce(
|
||||||
|
(sum, pattern) => sum + pattern.evaluationCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
|
||||||
|
if (unresolved.length) {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
unresolved.map((pattern) => pattern.cut.toUpperCase()).join("/") +
|
||||||
|
" raw polar 표본은 표시했지만 π/8 인접 위상 기준을 넘겨 HPBW·peak 해석을 보류합니다. · " +
|
||||||
|
elapsedMs.toFixed(1) + " ms",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
} else if (missingCrossing.length) {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
missingCrossing.map((pattern) => pattern.cut.toUpperCase()).join("/") +
|
||||||
|
"에서 sampled half-power crossing이 없어 전 방향 HPBW라고 단정하지 않습니다. · " +
|
||||||
|
elapsedMs.toFixed(1) + " ms",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
"세 평면 π/8 인접 위상 기준 충족 · " +
|
||||||
|
evaluationCount.toLocaleString("ko-KR") +
|
||||||
|
" microphone-angle evaluations · " + elapsedMs.toFixed(1) + " ms",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBeamPatternSurface(preview, startedAt) {
|
||||||
|
const options = readBeamPatternSurfaceOptions(preview);
|
||||||
|
const surface = computeReceiveBeamPatternSphere(options);
|
||||||
|
beamPatternSurfaceView.render(surface);
|
||||||
|
elements["beam-pattern-summary"].textContent =
|
||||||
|
formatBeamFrequency(surface.frequencyHz) + " · 3D · az " +
|
||||||
|
surface.steeringAzimuthDeg.toFixed(1) + "° / el " +
|
||||||
|
surface.steeringElevationDeg.toFixed(1) + "°";
|
||||||
|
elements["beam-pattern-wavelength"].textContent =
|
||||||
|
formatBeamWavelength(surface.wavelengthM);
|
||||||
|
elements["beam-pattern-resolution"].textContent =
|
||||||
|
surface.angularSampling.azimuthStepDeg.toFixed(3) + "° az · " +
|
||||||
|
surface.angularSampling.elevationStepDeg.toFixed(3) + "° el · " +
|
||||||
|
surface.sampleCount.toLocaleString("ko-KR") + " grid samples";
|
||||||
|
elements["beam-pattern-steering-response"].textContent =
|
||||||
|
"0.000 dB · analytic u=u₀";
|
||||||
|
elements["beam-pattern-hpbw"].textContent =
|
||||||
|
"3D 표면 · 평면 HPBW 미적용";
|
||||||
|
elements["beam-pattern-surface-caption"].textContent =
|
||||||
|
surface.azimuthSampleCount + "×" + surface.elevationSampleCount +
|
||||||
|
" raw grid samples · dB-floor radial mapping · response interpolation 없음 · " +
|
||||||
|
(preview ? "live preview" : "drag/방향키로 회전");
|
||||||
|
if (!preview) {
|
||||||
|
const elapsedMs = performance.now() - startedAt;
|
||||||
|
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
|
||||||
|
if (!surface.angularSampling.resolutionCriterionSatisfied) {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
"Raw 3D 표면은 표시했습니다. 구면 cell 대각 위상 변화 " +
|
||||||
|
surface.angularSampling.maximumCellDiagonalPhaseStepRadBound.toFixed(3) +
|
||||||
|
" rad가 π/8 기준을 넘으므로 미세 lobe 해석은 미확정입니다. · " +
|
||||||
|
elapsedMs.toFixed(1) + " ms",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
"3D 구면 π/8 cell 대각 위상 기준 충족 · " +
|
||||||
|
surface.evaluationCount.toLocaleString("ko-KR") +
|
||||||
|
" microphone-direction evaluations · " + elapsedMs.toFixed(1) + " ms",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderBeamPattern(preview = false) {
|
function renderBeamPattern(preview = false) {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
const options = readBeamPatternOptions(preview);
|
if (state.beamPatternViewMode === "surface") {
|
||||||
const pattern = computeReceiveBeamPattern(options);
|
renderBeamPatternSurface(preview, startedAt);
|
||||||
beamPatternView.render(pattern);
|
} else {
|
||||||
|
renderBeamPatternCuts(preview, startedAt);
|
||||||
|
}
|
||||||
state.beamPatternDirty = false;
|
state.beamPatternDirty = false;
|
||||||
document.documentElement.dataset.beamPatternReady = "true";
|
document.documentElement.dataset.beamPatternReady = "true";
|
||||||
document.documentElement.dataset.beamPatternPreview = String(preview);
|
document.documentElement.dataset.beamPatternPreview = String(preview);
|
||||||
elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount);
|
document.documentElement.dataset.beamPatternView = state.beamPatternViewMode;
|
||||||
elements["beam-pattern-plot"].dataset.cut = pattern.cut;
|
|
||||||
elements["beam-pattern-plot"].dataset.frequencyHz = String(pattern.frequencyHz);
|
|
||||||
elements["beam-pattern-summary"].textContent =
|
|
||||||
formatBeamFrequency(pattern.frequencyHz) + " · " +
|
|
||||||
pattern.cut.toUpperCase() + " · " +
|
|
||||||
pattern.steeringAngleDeg.toFixed(1) + "° 조향";
|
|
||||||
elements["beam-pattern-wavelength"].textContent =
|
|
||||||
formatBeamWavelength(pattern.wavelengthM);
|
|
||||||
elements["beam-pattern-resolution"].textContent =
|
|
||||||
pattern.sampleCount.toLocaleString("ko-KR") + " · " +
|
|
||||||
pattern.angularResolutionDeg.toFixed(4) + "°";
|
|
||||||
elements["beam-pattern-steering-response"].textContent =
|
|
||||||
formatBeamDecibels(pattern.metrics.steeringDb);
|
|
||||||
elements["beam-pattern-hpbw"].textContent = preview
|
|
||||||
? "드래그 종료 후 계산"
|
|
||||||
: formatBeamHpbw(pattern.metrics.halfPowerBeamwidth);
|
|
||||||
|
|
||||||
if (!preview) {
|
|
||||||
elements["beam-pattern-peaks"].textContent =
|
|
||||||
describeBeamPatternPeaks(pattern);
|
|
||||||
const elapsedMs = performance.now() - startedAt;
|
|
||||||
document.documentElement.dataset.beamPatternComputeMs =
|
|
||||||
elapsedMs.toFixed(3);
|
|
||||||
if (!pattern.angularSampling.metricResolutionSatisfied) {
|
|
||||||
setBeamPatternStatus(
|
|
||||||
"Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " +
|
|
||||||
pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) +
|
|
||||||
" rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." +
|
|
||||||
(pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") +
|
|
||||||
" · " + elapsedMs.toFixed(1) + " ms",
|
|
||||||
"warning",
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
pattern.metrics.halfPowerBeamwidth.status ===
|
|
||||||
"no-sampled-half-power-crossing"
|
|
||||||
) {
|
|
||||||
setBeamPatternStatus(
|
|
||||||
"π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " +
|
|
||||||
"전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms",
|
|
||||||
"warning",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setBeamPatternStatus(
|
|
||||||
"π/8 인접 위상 간격 기준 충족 · " +
|
|
||||||
pattern.sampleCount.toLocaleString("ko-KR") + " angles · " +
|
|
||||||
pattern.evaluationCount.toLocaleString("ko-KR") +
|
|
||||||
" phasor evaluations · " + elapsedMs.toFixed(1) + " ms",
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.beamPatternDirty = false;
|
state.beamPatternDirty = false;
|
||||||
document.documentElement.dataset.beamPatternReady = "error";
|
document.documentElement.dataset.beamPatternReady = "error";
|
||||||
document.documentElement.dataset.beamPatternPreview = "false";
|
document.documentElement.dataset.beamPatternPreview = String(preview);
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
beamPatternView.renderError(message);
|
for (const view of Object.values(beamPatternViews)) view.renderError(message);
|
||||||
|
beamPatternSurfaceView.renderError(message);
|
||||||
setBeamPatternStatus(message, "error");
|
setBeamPatternStatus(message, "error");
|
||||||
elements["beam-pattern-hpbw"].textContent = "—";
|
elements["beam-pattern-hpbw"].textContent = "—";
|
||||||
elements["beam-pattern-peaks"].textContent =
|
for (const diagnostic of elements["beam-pattern-peaks"].querySelectorAll("span")) {
|
||||||
"빔 패턴 설정을 수정하면 다시 계산합니다.";
|
diagnostic.textContent = "설정을 수정하면 다시 계산합니다.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setBeamPatternViewMode(mode, options = {}) {
|
||||||
|
const resolved = mode === "surface" ? "surface" : "cuts";
|
||||||
|
state.beamPatternViewMode = resolved;
|
||||||
|
elements["beam-pattern-view-cuts-button"].classList.toggle(
|
||||||
|
"is-active",
|
||||||
|
resolved === "cuts",
|
||||||
|
);
|
||||||
|
elements["beam-pattern-view-3d-button"].classList.toggle(
|
||||||
|
"is-active",
|
||||||
|
resolved === "surface",
|
||||||
|
);
|
||||||
|
elements["beam-pattern-view-cuts-button"].setAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
String(resolved === "cuts"),
|
||||||
|
);
|
||||||
|
elements["beam-pattern-view-3d-button"].setAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
String(resolved === "surface"),
|
||||||
|
);
|
||||||
|
for (const field of document.querySelectorAll("[data-beam-mode]")) {
|
||||||
|
field.hidden = field.dataset.beamMode !== resolved;
|
||||||
|
}
|
||||||
|
elements["beam-pattern-cuts-view"].hidden = resolved !== "cuts";
|
||||||
|
elements["beam-pattern-peaks"].hidden = resolved !== "cuts";
|
||||||
|
elements["beam-pattern-surface-view"].hidden = resolved !== "surface";
|
||||||
|
document.documentElement.dataset.beamPatternView = resolved;
|
||||||
|
state.beamPatternDirty = true;
|
||||||
|
if (options.render !== false) scheduleBeamPatternRender();
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleBeamPatternRender(options = {}) {
|
function scheduleBeamPatternRender(options = {}) {
|
||||||
const preview = options.preview === true;
|
const preview = options.preview === true;
|
||||||
state.beamPatternDirty = true;
|
state.beamPatternDirty = true;
|
||||||
@@ -1093,6 +1349,10 @@ elements["select-tool-button"].addEventListener("click", () =>
|
|||||||
setTool("select", { focus: true, userInitiated: true }));
|
setTool("select", { focus: true, userInitiated: true }));
|
||||||
elements["add-tool-button"].addEventListener("click", () =>
|
elements["add-tool-button"].addEventListener("click", () =>
|
||||||
setTool("add", { focus: true, userInitiated: true }));
|
setTool("add", { focus: true, userInitiated: true }));
|
||||||
|
elements["array-view-2d-button"].addEventListener("click", () =>
|
||||||
|
setArrayViewMode("2d", { focus: true }));
|
||||||
|
elements["array-view-3d-button"].addEventListener("click", () =>
|
||||||
|
setArrayViewMode("3d", { focus: true }));
|
||||||
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
|
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
|
||||||
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
|
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
|
||||||
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
|
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
|
||||||
@@ -1102,10 +1362,25 @@ elements["snap-enabled"].addEventListener("change", () => {
|
|||||||
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
|
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
elements["beam-pattern-frequency"].addEventListener("input", () => {
|
||||||
|
syncBeamPatternFrequency();
|
||||||
|
scheduleBeamPatternRender({ preview: true });
|
||||||
|
});
|
||||||
|
elements["beam-pattern-frequency"].addEventListener("change", () =>
|
||||||
|
scheduleBeamPatternRender());
|
||||||
for (const id of beamPatternInputIds) {
|
for (const id of beamPatternInputIds) {
|
||||||
const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change";
|
if (elements[id] instanceof HTMLInputElement) {
|
||||||
elements[id].addEventListener(eventName, () => scheduleBeamPatternRender());
|
elements[id].addEventListener("input", () =>
|
||||||
|
scheduleBeamPatternRender({ preview: true }));
|
||||||
|
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
|
||||||
|
} else {
|
||||||
|
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
elements["beam-pattern-view-cuts-button"].addEventListener("click", () =>
|
||||||
|
setBeamPatternViewMode("cuts"));
|
||||||
|
elements["beam-pattern-view-3d-button"].addEventListener("click", () =>
|
||||||
|
setBeamPatternViewMode("surface"));
|
||||||
elements["beam-pattern-panel"].addEventListener("toggle", () => {
|
elements["beam-pattern-panel"].addEventListener("toggle", () => {
|
||||||
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
|
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
|
||||||
scheduleBeamPatternRender();
|
scheduleBeamPatternRender();
|
||||||
@@ -1314,13 +1589,17 @@ window.addEventListener("unload", () => {
|
|||||||
if (state.beamPatternRenderFrame) {
|
if (state.beamPatternRenderFrame) {
|
||||||
cancelAnimationFrame(state.beamPatternRenderFrame);
|
cancelAnimationFrame(state.beamPatternRenderFrame);
|
||||||
}
|
}
|
||||||
beamPatternView.destroy();
|
for (const view of Object.values(beamPatternViews)) view.destroy();
|
||||||
|
beamPatternSurfaceView.destroy();
|
||||||
splitPane.destroy();
|
splitPane.destroy();
|
||||||
canvasView.destroy();
|
canvasView.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
elements["snap-enabled"].checked = state.snapEnabled;
|
elements["snap-enabled"].checked = state.snapEnabled;
|
||||||
setNumber(elements["snap-step"], state.snapStepM * 1000);
|
setNumber(elements["snap-step"], state.snapStepM * 1000);
|
||||||
|
setArrayViewMode("2d", { announce: false });
|
||||||
|
setBeamPatternViewMode("cuts", { render: false });
|
||||||
|
syncBeamPatternFrequency();
|
||||||
renderAll("기본 4×3 배열");
|
renderAll("기본 4×3 배열");
|
||||||
canvasView.fit();
|
canvasView.fit();
|
||||||
document.documentElement.dataset.appReady = "true";
|
document.documentElement.dataset.appReady = "true";
|
||||||
|
|||||||
+214
-11
@@ -633,7 +633,9 @@ details > summary::-webkit-details-marker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.beam-pattern-controls,
|
.beam-pattern-controls,
|
||||||
.beam-pattern-figure {
|
.beam-pattern-visuals,
|
||||||
|
.beam-pattern-figure,
|
||||||
|
.beam-pattern-surface-figure {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,12 +660,59 @@ details > summary::-webkit-details-marker {
|
|||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beam-pattern-view-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 11px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #07110f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-view-switch .tool-button {
|
||||||
|
min-width: 82px;
|
||||||
|
}
|
||||||
|
|
||||||
.beam-pattern-fields {
|
.beam-pattern-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beam-frequency-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-frequency-field > small,
|
||||||
|
.beam-pattern-fields .field > small {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-frequency-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(92px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-frequency {
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
accent-color: var(--mint);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-frequency-value {
|
||||||
|
color: var(--mint);
|
||||||
|
font: 760 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.beam-pattern-metrics {
|
.beam-pattern-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -728,36 +777,139 @@ details > summary::-webkit-details-marker {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beam-pattern-figure {
|
.beam-pattern-visuals {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
align-content: start;
|
||||||
|
gap: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-cut-grid {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-figure,
|
||||||
|
.beam-pattern-surface-figure {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
justify-items: center;
|
gap: 7px;
|
||||||
gap: 9px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#beam-pattern-plot {
|
.beam-pattern-figure-heading {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-figure-heading strong {
|
||||||
|
color: #edf9f4;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-figure-heading span {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 620 0.53rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-plot-xz,
|
||||||
|
#beam-pattern-plot-yz,
|
||||||
|
#beam-pattern-plot-xy {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(100%, 580px);
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid rgb(145 170 162 / 22%);
|
||||||
border-radius: 14px;
|
border-radius: 11px;
|
||||||
color: var(--mint);
|
color: var(--mint);
|
||||||
background: #07110f;
|
background: #07110f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beam-pattern-figure figcaption {
|
.beam-pattern-figure figcaption {
|
||||||
width: min(100%, 580px);
|
min-height: 3.8em;
|
||||||
min-height: 2.8em;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.61rem;
|
font-size: 0.56rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beam-pattern-surface-figure {
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-surface {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border: 1px solid rgb(145 170 162 / 24%);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #07110f;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-surface[data-orbiting="true"] {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-surface:focus-visible {
|
||||||
|
outline: 2px solid var(--mint);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-surface-figure figcaption {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-diagnostics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-diagnostics p {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgb(255 255 255 / 1.5%);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-diagnostics strong,
|
||||||
|
.beam-pattern-diagnostics span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-diagnostics strong {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: #cfe2db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-beam-mode][hidden],
|
||||||
|
#beam-pattern-cuts-view[hidden],
|
||||||
|
#beam-pattern-surface-view[hidden],
|
||||||
|
#beam-pattern-peaks[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.beam-pattern-ring,
|
.beam-pattern-ring,
|
||||||
.beam-pattern-axis {
|
.beam-pattern-axis {
|
||||||
stroke: rgb(145 170 162 / 28%);
|
stroke: rgb(145 170 162 / 28%);
|
||||||
@@ -976,6 +1128,15 @@ body[data-array-tool="add"] #array-canvas,
|
|||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#array-canvas[data-view-mode="3d"] {
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#array-canvas[data-view-mode="3d"][data-orbiting="true"] {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-instructions {
|
.canvas-instructions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24px;
|
top: 24px;
|
||||||
@@ -1002,6 +1163,24 @@ body[data-array-tool="add"] #array-canvas,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-front-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
max-width: calc(100% - 190px);
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid rgb(88 199 232 / 30%);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--cyan);
|
||||||
|
background: rgb(8 17 15 / 84%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
font: 720 0.62rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.selection-status {
|
.selection-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
@@ -1093,6 +1272,17 @@ body[data-array-tool="add"] #array-canvas,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container beam-pattern (max-width: 620px) {
|
||||||
|
.beam-pattern-cut-grid,
|
||||||
|
.beam-pattern-diagnostics {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-figure figcaption {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@container beam-pattern (max-width: 460px) {
|
@container beam-pattern (max-width: 460px) {
|
||||||
.beam-pattern-heading {
|
.beam-pattern-heading {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1106,6 +1296,19 @@ body[data-array-tool="add"] #array-canvas,
|
|||||||
.beam-pattern-metrics {
|
.beam-pattern-metrics {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beam-frequency-field {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-frequency-control {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-frequency-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ function dispatchValue(windowObject, element, value, eventName = "change") {
|
|||||||
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logarithmicFrequencyPosition(frequencyHz) {
|
||||||
|
return Math.round(Math.log10(frequencyHz) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchFiles(windowObject, input, files) {
|
function dispatchFiles(windowObject, input, files) {
|
||||||
const transfer = new windowObject.DataTransfer();
|
const transfer = new windowObject.DataTransfer();
|
||||||
for (const file of files) transfer.items.add(file);
|
for (const file of files) transfer.items.add(file);
|
||||||
@@ -102,20 +106,46 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
() => documentObject.documentElement.dataset.beamPatternReady === "true",
|
() => documentObject.documentElement.dataset.beamPatternReady === "true",
|
||||||
"기본 수신 빔 패턴 렌더",
|
"기본 수신 빔 패턴 렌더",
|
||||||
);
|
);
|
||||||
const beamPlot = documentObject.querySelector("#beam-pattern-plot");
|
const beamCuts = ["xz", "yz", "xy"];
|
||||||
const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
|
const beamPlots = Object.fromEntries(beamCuts.map((cut) => [
|
||||||
?.getAttribute("d");
|
cut,
|
||||||
assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
|
documentObject.querySelector("#beam-pattern-plot-" + cut),
|
||||||
assertEqual(
|
]));
|
||||||
beamPlot.querySelectorAll(".beam-pattern-ring").length,
|
const initialBeamPaths = Object.fromEntries(beamCuts.map((cut) => [
|
||||||
5,
|
cut,
|
||||||
"polar plot has four inner dB rings and the 0 dB ring",
|
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d"),
|
||||||
);
|
]));
|
||||||
assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
|
for (const cut of beamCuts) {
|
||||||
assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
|
const beamPlot = beamPlots[cut];
|
||||||
|
assert(
|
||||||
|
initialBeamPaths[cut]?.startsWith("M"),
|
||||||
|
cut.toUpperCase() + " polar response path is rendered",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
beamPlot.querySelectorAll(".beam-pattern-ring").length,
|
||||||
|
5,
|
||||||
|
cut.toUpperCase() + " polar plot has four inner dB rings and the 0 dB ring",
|
||||||
|
);
|
||||||
|
assertEqual(beamPlot.dataset.cut, cut, cut.toUpperCase() + " cut provenance");
|
||||||
|
assertEqual(
|
||||||
|
beamPlot.dataset.frequencyHz,
|
||||||
|
"1000",
|
||||||
|
cut.toUpperCase() + " default analog frequency",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Number(beamPlot.dataset.sampleCount) >= 720,
|
||||||
|
cut.toUpperCase() + " auto angular sampling uses at least 720 directions",
|
||||||
|
);
|
||||||
|
}
|
||||||
assert(
|
assert(
|
||||||
Number(beamPlot.dataset.sampleCount) >= 720,
|
!documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
|
||||||
"auto angular sampling uses at least 720 directions",
|
documentObject.querySelector("#beam-pattern-surface-view").hidden,
|
||||||
|
"three polar cuts are the simultaneous default view",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelectorAll("#beam-pattern-cuts-view .beam-pattern-figure").length,
|
||||||
|
3,
|
||||||
|
"XZ, YZ, and XY figures are present together",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
|
documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
|
||||||
@@ -125,16 +155,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
);
|
);
|
||||||
|
|
||||||
const initialSaveState = documentObject.querySelector("#save-state").textContent;
|
const initialSaveState = documentObject.querySelector("#save-state").textContent;
|
||||||
|
const frequencySlider = documentObject.querySelector("#beam-pattern-frequency");
|
||||||
dispatchValue(
|
dispatchValue(
|
||||||
windowObject,
|
windowObject,
|
||||||
documentObject.querySelector("#beam-pattern-frequency"),
|
frequencySlider,
|
||||||
49_125,
|
logarithmicFrequencyPosition(49_125),
|
||||||
"input",
|
"input",
|
||||||
);
|
);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => beamPlot.dataset.frequencyHz === "49125" &&
|
() => documentObject.documentElement.dataset.beamPatternPreview === "true" &&
|
||||||
Number(beamPlot.dataset.sampleCount) > 720,
|
beamCuts.every((cut) =>
|
||||||
"above-Nyquist-style analog pattern with auto angular sampling",
|
Number(beamPlots[cut].dataset.frequencyHz) > 48_000 &&
|
||||||
|
beamPlots[cut].dataset.sampleCount === "720"),
|
||||||
|
"logarithmic slider input renders a bounded live preview in every cut",
|
||||||
|
);
|
||||||
|
const previewFrequencyHz = Number(frequencySlider.getAttribute("aria-valuenow"));
|
||||||
|
assert(
|
||||||
|
previewFrequencyHz > 48_000 && previewFrequencyHz < 50_000,
|
||||||
|
"log slider position maps to the intended above-Nyquist-style analog frequency",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#beam-pattern-frequency-value").textContent.includes("kHz"),
|
||||||
|
"log slider exposes its physical frequency value",
|
||||||
|
);
|
||||||
|
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
|
||||||
|
beamCuts.every((cut) => Number(beamPlots[cut].dataset.sampleCount) > 720),
|
||||||
|
"logarithmic slider change renders full auto resolution in every cut",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
|
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
|
||||||
@@ -146,15 +194,15 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
360,
|
360,
|
||||||
);
|
);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => beamPlot.dataset.sampleCount === "360" &&
|
() => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") &&
|
||||||
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
|
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
|
||||||
"해상도 부족",
|
"해상도 부족",
|
||||||
),
|
),
|
||||||
"coarse angular grid exposes unresolved metrics",
|
"coarse angular grid exposes unresolved metrics for the simultaneous cuts",
|
||||||
);
|
);
|
||||||
dispatchValue(
|
dispatchValue(
|
||||||
windowObject,
|
windowObject,
|
||||||
documentObject.querySelector("#beam-pattern-frequency"),
|
documentObject.querySelector("#beam-pattern-speed-of-sound"),
|
||||||
"",
|
"",
|
||||||
"input",
|
"input",
|
||||||
);
|
);
|
||||||
@@ -168,18 +216,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
);
|
);
|
||||||
dispatchValue(
|
dispatchValue(
|
||||||
windowObject,
|
windowObject,
|
||||||
documentObject.querySelector("#beam-pattern-samples"),
|
documentObject.querySelector("#beam-pattern-speed-of-sound"),
|
||||||
"auto",
|
343,
|
||||||
|
"input",
|
||||||
);
|
);
|
||||||
dispatchValue(
|
dispatchValue(
|
||||||
windowObject,
|
windowObject,
|
||||||
documentObject.querySelector("#beam-pattern-frequency"),
|
frequencySlider,
|
||||||
1_000,
|
logarithmicFrequencyPosition(1_000),
|
||||||
"input",
|
"input",
|
||||||
);
|
);
|
||||||
|
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||||||
|
dispatchValue(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#beam-pattern-samples"),
|
||||||
|
"auto",
|
||||||
|
);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
|
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
|
||||||
beamPlot.dataset.frequencyHz === "1000",
|
documentObject.documentElement.dataset.beamPatternPreview === "false" &&
|
||||||
|
beamCuts.every((cut) =>
|
||||||
|
beamPlots[cut].dataset.frequencyHz === "1000" &&
|
||||||
|
Number(beamPlots[cut].dataset.sampleCount) >= 720),
|
||||||
"beam preview recovery",
|
"beam preview recovery",
|
||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
@@ -187,6 +245,87 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
initialSaveState,
|
initialSaveState,
|
||||||
"beam preview settings are outside Array dirty state",
|
"beam preview settings are outside Array dirty state",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
documentObject.querySelector("#beam-pattern-view-3d-button").click();
|
||||||
|
const beamSurface = documentObject.querySelector("#beam-pattern-surface");
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternView === "surface" &&
|
||||||
|
documentObject.documentElement.dataset.beamPatternReady === "true" &&
|
||||||
|
documentObject.documentElement.dataset.beamPatternPreview === "false" &&
|
||||||
|
!documentObject.querySelector("#beam-pattern-surface-view").hidden &&
|
||||||
|
documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
|
||||||
|
Number(beamSurface.dataset.sampleCount) > 0,
|
||||||
|
"3D beam surface render",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/^\d+x\d+$/.test(beamSurface.dataset.gridShape),
|
||||||
|
"3D beam canvas records its raw sphere grid",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#beam-pattern-view-3d-button").getAttribute("aria-pressed"),
|
||||||
|
"true",
|
||||||
|
"3D beam view toggle is pressed",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
|
||||||
|
"raw grid samples",
|
||||||
|
),
|
||||||
|
"3D beam caption identifies raw sampled directions",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
|
||||||
|
"dB-floor radial mapping",
|
||||||
|
),
|
||||||
|
"3D beam caption discloses the display-only radial mapping",
|
||||||
|
);
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
const surfaceBeforeOrbit = beamSurface.toDataURL();
|
||||||
|
const beamSurfaceRect = beamSurface.getBoundingClientRect();
|
||||||
|
const beamOrbitPointerId = 71;
|
||||||
|
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: beamOrbitPointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
button: 0,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.5,
|
||||||
|
clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.5,
|
||||||
|
}));
|
||||||
|
assertEqual(beamSurface.dataset.orbiting, "true", "3D beam drag begins orbiting");
|
||||||
|
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointermove", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: beamOrbitPointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
button: -1,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.7,
|
||||||
|
clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.36,
|
||||||
|
}));
|
||||||
|
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerup", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: beamOrbitPointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
button: 0,
|
||||||
|
buttons: 0,
|
||||||
|
}));
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
assert(!beamSurface.dataset.orbiting, "3D beam drag finishes orbiting");
|
||||||
|
assert(
|
||||||
|
beamSurface.toDataURL() !== surfaceBeforeOrbit,
|
||||||
|
"3D beam surface bitmap changes after orbit rotation",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#beam-pattern-view-cuts-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternView === "cuts" &&
|
||||||
|
documentObject.documentElement.dataset.beamPatternReady === "true" &&
|
||||||
|
!documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
|
||||||
|
documentObject.querySelector("#beam-pattern-surface-view").hidden,
|
||||||
|
"return to simultaneous polar cuts",
|
||||||
|
);
|
||||||
|
|
||||||
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
|
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
|
||||||
assertEqual(
|
assertEqual(
|
||||||
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
|
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
|
||||||
@@ -203,6 +342,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
);
|
);
|
||||||
|
|
||||||
const canvas = documentObject.querySelector("#array-canvas");
|
const canvas = documentObject.querySelector("#array-canvas");
|
||||||
|
assertEqual(canvas.dataset.viewMode, "2d", "array canvas starts in editable 2D mode");
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#canvas-front-badge").textContent.includes(
|
||||||
|
"+Z FRONT · 화면 바깥쪽",
|
||||||
|
),
|
||||||
|
"2D canvas states the +Z front direction",
|
||||||
|
);
|
||||||
|
const coordinatesBeforeArray3d = selectedCoordinates(documentObject);
|
||||||
|
const microphoneCountBeforeArray3d = microphoneCount(documentObject);
|
||||||
|
documentObject.querySelector("#array-view-3d-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() => canvas.dataset.viewMode === "3d" &&
|
||||||
|
canvas.getAttribute("aria-label").includes("3D 검사"),
|
||||||
|
"array 3D inspection mode",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#add-tool-button").disabled &&
|
||||||
|
documentObject.querySelector("#select-tool-button").disabled,
|
||||||
|
"array canvas edit tools are disabled in 3D inspection",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#canvas-front-badge").textContent.includes(
|
||||||
|
"+Z FRONT · 초록 축",
|
||||||
|
),
|
||||||
|
"3D canvas identifies the +Z front axis",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"),
|
||||||
|
"3D array guidance explains that coordinate editing is disabled",
|
||||||
|
);
|
||||||
|
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
key: "a",
|
||||||
|
}));
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
assertEqual(canvas.dataset.tool, "select", "A shortcut cannot enable add in 3D inspection");
|
||||||
|
assertEqual(
|
||||||
|
microphoneCount(documentObject),
|
||||||
|
microphoneCountBeforeArray3d,
|
||||||
|
"3D inspection cannot add a microphone",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
selectedCoordinates(documentObject).join(","),
|
||||||
|
coordinatesBeforeArray3d.join(","),
|
||||||
|
"3D inspection leaves microphone coordinates unchanged",
|
||||||
|
);
|
||||||
|
const array3dBeforeOrbit = canvas.toDataURL();
|
||||||
|
const array3dRect = canvas.getBoundingClientRect();
|
||||||
|
const arrayOrbitPointerId = 72;
|
||||||
|
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: arrayOrbitPointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
button: 0,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: array3dRect.left + array3dRect.width * 0.5,
|
||||||
|
clientY: array3dRect.top + array3dRect.height * 0.5,
|
||||||
|
}));
|
||||||
|
assertEqual(canvas.dataset.orbiting, "true", "array 3D drag begins orbiting");
|
||||||
|
canvas.dispatchEvent(new windowObject.PointerEvent("pointermove", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: arrayOrbitPointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
button: -1,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: array3dRect.left + array3dRect.width * 0.68,
|
||||||
|
clientY: array3dRect.top + array3dRect.height * 0.4,
|
||||||
|
}));
|
||||||
|
canvas.dispatchEvent(new windowObject.PointerEvent("pointerup", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: arrayOrbitPointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
button: 0,
|
||||||
|
buttons: 0,
|
||||||
|
}));
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
assertEqual(canvas.dataset.orbiting, "false", "array 3D drag finishes orbiting");
|
||||||
|
assert(
|
||||||
|
canvas.toDataURL() !== array3dBeforeOrbit,
|
||||||
|
"array 3D canvas bitmap changes after orbit rotation",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#array-view-2d-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() => canvas.dataset.viewMode === "2d" &&
|
||||||
|
canvas.getAttribute("aria-label").includes("평면 편집"),
|
||||||
|
"return to array 2D editing",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!documentObject.querySelector("#add-tool-button").disabled &&
|
||||||
|
!documentObject.querySelector("#select-tool-button").disabled,
|
||||||
|
"array canvas edit tools are restored in 2D",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#canvas-front-badge").textContent.includes(
|
||||||
|
"+Z FRONT · 화면 바깥쪽",
|
||||||
|
),
|
||||||
|
"returning to 2D restores its +Z front cue",
|
||||||
|
);
|
||||||
|
documentObject.querySelector(
|
||||||
|
'#microphone-list [data-microphone-id="mic-001"]',
|
||||||
|
).click();
|
||||||
|
assertEqual(
|
||||||
|
selectedCoordinates(documentObject).join(","),
|
||||||
|
coordinatesBeforeArray3d.join(","),
|
||||||
|
"2D edit resumes from the original microphone coordinates",
|
||||||
|
);
|
||||||
const defaultArray = createArrayDefinition();
|
const defaultArray = createArrayDefinition();
|
||||||
const initialCanvasRect = canvas.getBoundingClientRect();
|
const initialCanvasRect = canvas.getBoundingClientRect();
|
||||||
const initialViewport = fitArrayViewport(
|
const initialViewport = fitArrayViewport(
|
||||||
@@ -234,9 +483,10 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
dispatchDragPointer("pointerup", 36, -24, 0);
|
dispatchDragPointer("pointerup", 36, -24, 0);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
|
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
|
||||||
beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
|
beamCuts.every((cut) =>
|
||||||
initialBeamPath,
|
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d") !==
|
||||||
"beam pattern follows edited microphone geometry",
|
initialBeamPaths[cut]),
|
||||||
|
"all beam-pattern cuts follow edited microphone geometry",
|
||||||
);
|
);
|
||||||
const draggedPosition = selectedCoordinates(documentObject);
|
const draggedPosition = selectedCoordinates(documentObject);
|
||||||
assert(
|
assert(
|
||||||
@@ -789,28 +1039,63 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
documentObject.documentElement.clientWidth + 1,
|
documentObject.documentElement.clientWidth + 1,
|
||||||
"Array Builder has no horizontal overflow at 390px",
|
"Array Builder has no horizontal overflow at 390px",
|
||||||
);
|
);
|
||||||
const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
|
|
||||||
.getBoundingClientRect();
|
|
||||||
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
|
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
|
||||||
.getBoundingClientRect();
|
.getBoundingClientRect();
|
||||||
|
const compactBeamPlotRects = beamCuts.map((cut) =>
|
||||||
|
beamPlots[cut].getBoundingClientRect());
|
||||||
assert(
|
assert(
|
||||||
compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
|
compactBeamPlotRects.every((rect) =>
|
||||||
compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
|
rect.left >= compactBeamCardRect.left - 1 &&
|
||||||
"compact polar plot stays inside its analysis card",
|
rect.right <= compactBeamCardRect.right + 1),
|
||||||
|
"all compact polar plots stay inside their analysis card",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
|
compactBeamPlotRects.every((rect) => Math.abs(rect.width - rect.height) <= 2),
|
||||||
"compact polar plot remains square",
|
"all compact polar plots remain square",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
compactBeamPlotRects.every(
|
||||||
|
(rect, index) => index === 0 || rect.top > compactBeamPlotRects[index - 1].bottom,
|
||||||
|
),
|
||||||
|
"compact XZ, YZ, and XY plots stack without overlap",
|
||||||
);
|
);
|
||||||
const compactBeamFields = [
|
const compactBeamFields = [
|
||||||
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
|
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
|
||||||
].map((field) => field.getBoundingClientRect());
|
].filter((field) => !field.hidden).map((field) => field.getBoundingClientRect());
|
||||||
assert(
|
assert(
|
||||||
compactBeamFields.every(
|
compactBeamFields.every(
|
||||||
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
|
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
|
||||||
),
|
),
|
||||||
"compact beam controls stack without clipping",
|
"compact beam controls stack without clipping",
|
||||||
);
|
);
|
||||||
|
documentObject.querySelector("#beam-pattern-view-3d-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternView === "surface" &&
|
||||||
|
documentObject.documentElement.dataset.beamPatternReady === "true" &&
|
||||||
|
!documentObject.querySelector("#beam-pattern-surface-view").hidden &&
|
||||||
|
Number(beamSurface.dataset.sampleCount) > 0,
|
||||||
|
"compact 3D beam surface",
|
||||||
|
);
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
const compactBeamSurfaceRect = beamSurface.getBoundingClientRect();
|
||||||
|
const compactBeamSurfaceCardRect = documentObject.querySelector("#beam-pattern-panel")
|
||||||
|
.getBoundingClientRect();
|
||||||
|
assert(
|
||||||
|
compactBeamSurfaceRect.left >= compactBeamSurfaceCardRect.left - 1 &&
|
||||||
|
compactBeamSurfaceRect.right <= compactBeamSurfaceCardRect.right + 1,
|
||||||
|
"compact 3D beam surface stays inside its analysis card",
|
||||||
|
);
|
||||||
|
assertNear(
|
||||||
|
compactBeamSurfaceRect.width / compactBeamSurfaceRect.height,
|
||||||
|
16 / 10,
|
||||||
|
0.03,
|
||||||
|
"compact 3D beam surface preserves its aspect ratio",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.documentElement.scrollWidth <=
|
||||||
|
documentObject.documentElement.clientWidth + 1,
|
||||||
|
"3D beam mode has no horizontal overflow at 390px",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||||
"Array canvas remains usable at 390px",
|
"Array canvas remains usable at 390px",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
hitTestMicrophone,
|
hitTestMicrophone,
|
||||||
keyboardNudgeForKey,
|
keyboardNudgeForKey,
|
||||||
niceGridStep,
|
niceGridStep,
|
||||||
|
projectArrayPosition3d,
|
||||||
} from "/apps/array-builder/canvas-view.js";
|
} from "/apps/array-builder/canvas-view.js";
|
||||||
|
|
||||||
function assertNear(actual, expected, tolerance, label) {
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
@@ -139,3 +140,29 @@ test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규
|
|||||||
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
|
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
|
||||||
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
|
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다", () => {
|
||||||
|
const frontView = {
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
centerPixelX: 200,
|
||||||
|
centerPixelY: 120,
|
||||||
|
centerHorizontal: 0,
|
||||||
|
centerVertical: 0,
|
||||||
|
scale: 100,
|
||||||
|
};
|
||||||
|
const positiveX = projectArrayPosition3d([1, 0, 0], frontView);
|
||||||
|
const positiveZ = projectArrayPosition3d([0, 0, 1], frontView);
|
||||||
|
const positiveY = projectArrayPosition3d([0, 1, 0], frontView);
|
||||||
|
assertNear(positiveX.x, 300, 1e-12, "+X projects right");
|
||||||
|
assertNear(positiveX.y, 120, 1e-12, "+X keeps screen height");
|
||||||
|
assertNear(positiveZ.x, 200, 1e-12, "+Z stays centered horizontally");
|
||||||
|
assertNear(positiveZ.y, 20, 1e-12, "+Z FRONT projects upward");
|
||||||
|
assertNear(positiveY.depth, -1, 1e-12, "+Y supplies front-view depth");
|
||||||
|
|
||||||
|
const edgeView = { ...frontView, pitch: Math.PI / 2 };
|
||||||
|
const edgeY = projectArrayPosition3d([0, 1, 0], edgeView);
|
||||||
|
const edgeZ = projectArrayPosition3d([0, 0, 1], edgeView);
|
||||||
|
assertNear(edgeY.y, 20, 1e-12, "orbiting exposes +Y vertically");
|
||||||
|
assertNear(edgeZ.depth, 1, 1e-12, "orbiting moves +Z onto the depth axis");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user