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 TOUCH_GESTURE_THRESHOLD_PX = 6;
|
||||
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"]);
|
||||
|
||||
@@ -21,8 +22,10 @@ function finiteNumber(value, fallback = 0) {
|
||||
|
||||
function validPosition(position) {
|
||||
return Array.isArray(position) &&
|
||||
position.length >= 3 &&
|
||||
Number.isFinite(Number(position[0])) &&
|
||||
Number.isFinite(Number(position[1]));
|
||||
Number.isFinite(Number(position[1])) &&
|
||||
Number.isFinite(Number(position[2]));
|
||||
}
|
||||
|
||||
function apertureDimensions(arrayDefinition) {
|
||||
@@ -198,6 +201,89 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
|
||||
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. */
|
||||
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
|
||||
const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
|
||||
@@ -444,9 +530,233 @@ function drawArray(context, size, model, zoom, drag) {
|
||||
context.textBaseline = "top";
|
||||
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
|
||||
}
|
||||
@@ -471,6 +781,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
let destroyed = false;
|
||||
let zoom = 1;
|
||||
let tool = "select";
|
||||
let viewMode = "2d";
|
||||
let orbit = { ...DEFAULT_ORBIT };
|
||||
let orbitDrag = null;
|
||||
let drag = null;
|
||||
let pendingAdd = null;
|
||||
|
||||
@@ -479,15 +792,23 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
if (canvas.dataset) {
|
||||
canvas.dataset.tool = tool;
|
||||
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() {
|
||||
frameId = 0;
|
||||
if (destroyed) return;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -499,20 +820,30 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
if (viewport) return viewport;
|
||||
if (!model?.arrayDefinition) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
function render(nextModel) {
|
||||
if (destroyed) return;
|
||||
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);
|
||||
if (viewMode === "3d") tool = "select";
|
||||
viewport = null;
|
||||
schedule();
|
||||
}
|
||||
|
||||
function setTool(nextTool) {
|
||||
const resolved = normalizedTool(nextTool);
|
||||
const resolved = viewMode === "3d" ? "select" : normalizedTool(nextTool);
|
||||
if (resolved === tool) return tool;
|
||||
if (resolved !== "add") pendingAdd = null;
|
||||
tool = resolved;
|
||||
@@ -524,6 +855,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
|
||||
function fit() {
|
||||
zoom = 1;
|
||||
if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT };
|
||||
viewport = null;
|
||||
schedule();
|
||||
return zoom;
|
||||
@@ -586,8 +918,29 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
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) {
|
||||
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 currentViewport = ensureViewport();
|
||||
if (!touch || !currentViewport) return;
|
||||
@@ -601,8 +954,27 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
|
||||
if (drag || pendingAdd) return;
|
||||
if (drag || pendingAdd || orbitDrag) return;
|
||||
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") {
|
||||
const position = constrainedEventPosition(event, 0);
|
||||
if (position) {
|
||||
@@ -649,6 +1021,18 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
}
|
||||
|
||||
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 (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
|
||||
return;
|
||||
@@ -705,6 +1089,39 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
|
||||
function onKeyDown(event) {
|
||||
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;
|
||||
if (lowerKey === "a") {
|
||||
event.preventDefault();
|
||||
@@ -742,16 +1159,22 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
}
|
||||
|
||||
function onPointerUp(event) {
|
||||
if (finishOrbit(event)) return;
|
||||
if (finishPendingAdd(event, false)) return;
|
||||
finishDrag(event, false);
|
||||
}
|
||||
|
||||
function onPointerCancel(event) {
|
||||
if (finishOrbit(event)) return;
|
||||
if (finishPendingAdd(event, true)) return;
|
||||
finishDrag(event, true);
|
||||
}
|
||||
|
||||
function onLostPointerCapture(event) {
|
||||
if (orbitDrag?.pointerId === event.pointerId) {
|
||||
finishOrbit(event);
|
||||
return;
|
||||
}
|
||||
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
|
||||
}
|
||||
|
||||
@@ -798,6 +1221,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
canvas.removeEventListener("keydown", onKeyDown);
|
||||
model = null;
|
||||
viewport = null;
|
||||
orbitDrag = null;
|
||||
drag = null;
|
||||
pendingAdd = null;
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow">마이크 배치</p>
|
||||
<h1>Array 평면 편집</h1>
|
||||
<h1>Array 2D 편집 · 3D 검사</h1>
|
||||
</div>
|
||||
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
|
||||
</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" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
|
||||
</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="실행 취소와 다시 실행">
|
||||
<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>
|
||||
@@ -80,6 +84,7 @@
|
||||
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
|
||||
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
|
||||
</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>
|
||||
|
||||
@@ -96,7 +101,7 @@
|
||||
<p class="eyebrow">배열 분석</p>
|
||||
<h2>수신 빔 패턴</h2>
|
||||
</div>
|
||||
<span id="beam-pattern-summary">1 kHz · XZ · +Z 조향</span>
|
||||
<span id="beam-pattern-summary">1 kHz · XZ/YZ/XY 동시</span>
|
||||
</summary>
|
||||
|
||||
<div class="beam-pattern-body">
|
||||
@@ -106,18 +111,30 @@
|
||||
<span>geometry-only</span>
|
||||
</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">
|
||||
<label class="field">
|
||||
<span>아날로그 주파수 (Hz)</span>
|
||||
<input
|
||||
id="beam-pattern-frequency"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000000"
|
||||
step="100"
|
||||
value="1000"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
<label class="field beam-frequency-field">
|
||||
<span>아날로그 주파수 · 로그 슬라이더</span>
|
||||
<div class="beam-frequency-control">
|
||||
<input
|
||||
id="beam-pattern-frequency"
|
||||
type="range"
|
||||
min="0"
|
||||
max="6000"
|
||||
step="1"
|
||||
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 class="field">
|
||||
<span>음속 (m/s)</span>
|
||||
@@ -131,16 +148,8 @@
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>360° 절단면</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>
|
||||
<label class="field" data-beam-mode="cuts">
|
||||
<span>단면별 조향각 (deg)</span>
|
||||
<input
|
||||
id="beam-pattern-steering-angle"
|
||||
type="number"
|
||||
@@ -150,8 +159,9 @@
|
||||
value="0"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
<small>각 XZ/YZ/XY 평면 안에서 독립 조향</small>
|
||||
</label>
|
||||
<label class="field">
|
||||
<label class="field" data-beam-mode="cuts">
|
||||
<span>각도 샘플</span>
|
||||
<select id="beam-pattern-samples">
|
||||
<option value="auto">자동 · 위상 간격 기준</option>
|
||||
@@ -162,6 +172,24 @@
|
||||
<option value="5760">5,760 · 0.0625°</option>
|
||||
</select>
|
||||
</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">
|
||||
<span>표시 하한 (dB)</span>
|
||||
<select id="beam-pattern-floor">
|
||||
@@ -185,24 +213,44 @@
|
||||
현재 배열의 수신 array factor를 계산하고 있습니다.
|
||||
</p>
|
||||
<p class="beam-pattern-note" id="beam-pattern-model-note">
|
||||
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 단면입니다. 3D 전체 패턴,
|
||||
근거리, 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로
|
||||
Nyquist 초과 주파수도 접지 않고 원래 파장으로 계산합니다.
|
||||
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 응답입니다. 세 평면은 각각
|
||||
독립 조향한 great-circle 단면이며 3D 모드는 실제 구면 방향을 계산합니다. 근거리,
|
||||
방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로 Nyquist 초과
|
||||
주파수도 접지 않고 원래 파장으로 계산합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<figure class="beam-pattern-figure">
|
||||
<svg
|
||||
id="beam-pattern-plot"
|
||||
viewBox="0 0 520 520"
|
||||
role="img"
|
||||
aria-label="수신 빔 패턴 polar plot"
|
||||
aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-peaks"
|
||||
></svg>
|
||||
<figcaption id="beam-pattern-peaks">
|
||||
국소 최대를 계산하고 있습니다.
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div class="beam-pattern-visuals">
|
||||
<div class="beam-pattern-cut-grid" id="beam-pattern-cuts-view">
|
||||
<figure class="beam-pattern-figure" data-cut="xz">
|
||||
<div class="beam-pattern-figure-heading"><strong>XZ</strong><span>0° +Z · 90° +X</span></div>
|
||||
<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>
|
||||
<figcaption id="beam-pattern-caption-xz">계산 중…</figcaption>
|
||||
</figure>
|
||||
<figure class="beam-pattern-figure" data-cut="yz">
|
||||
<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 id="beam-pattern-caption-yz">계산 중…</figcaption>
|
||||
</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>
|
||||
</details>
|
||||
|
||||
|
||||
+385
-106
@@ -1,8 +1,12 @@
|
||||
import { downloadJson, readJsonFile } from "/packages/contracts/io.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 { createArrayCanvas } from "./canvas-view.js";
|
||||
import { createReceiveBeamPatternSurfaceView } from "./beam-pattern-surface-view.js";
|
||||
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
|
||||
import {
|
||||
addMicrophone,
|
||||
@@ -36,14 +40,22 @@ const requiredIds = [
|
||||
"aperture-shape", "preset-type", "preset-count", "preset-columns",
|
||||
"preset-rows", "preset-seed", "preset-minimum-spacing",
|
||||
"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",
|
||||
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
|
||||
"remove-microphone-button", "validation-title", "validation-message",
|
||||
"beam-pattern-panel", "beam-pattern-frequency",
|
||||
"beam-pattern-speed-of-sound", "beam-pattern-cut",
|
||||
"beam-pattern-steering-angle", "beam-pattern-samples",
|
||||
"beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status",
|
||||
"beam-pattern-panel", "beam-pattern-frequency", "beam-pattern-frequency-value",
|
||||
"beam-pattern-speed-of-sound", "beam-pattern-steering-angle",
|
||||
"beam-pattern-steering-azimuth", "beam-pattern-steering-elevation",
|
||||
"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-resolution", "beam-pattern-steering-response",
|
||||
"beam-pattern-hpbw", "beam-pattern-peaks",
|
||||
@@ -62,7 +74,14 @@ const optionalIds = [
|
||||
];
|
||||
for (const id of optionalIds) elements[id] = maybeById(id);
|
||||
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({
|
||||
container: elements["array-builder-layout"],
|
||||
@@ -92,6 +111,8 @@ const state = {
|
||||
dragSnapshot: null,
|
||||
importRevision: 0,
|
||||
baselineSignature: "",
|
||||
arrayViewMode: "2d",
|
||||
beamPatternViewMode: "cuts",
|
||||
beamPatternRenderFrame: 0,
|
||||
beamPatternDirty: true,
|
||||
beamPatternPreviewPending: false,
|
||||
@@ -337,6 +358,7 @@ function dismissCanvasInstructions() {
|
||||
}
|
||||
|
||||
function setTool(tool, options = {}) {
|
||||
if (state.arrayViewMode === "3d" && tool === "add") return state.tool;
|
||||
state.tool = tool === "add" ? "add" : "select";
|
||||
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
|
||||
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
|
||||
@@ -351,12 +373,66 @@ function setTool(tool, options = {}) {
|
||||
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() {
|
||||
return {
|
||||
arrayDefinition: state.arrayDefinition,
|
||||
apertureShape: state.apertureShape,
|
||||
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||
tool: state.tool,
|
||||
viewMode: state.arrayViewMode,
|
||||
snapStepM: state.snapEnabled ? state.snapStepM : 0,
|
||||
};
|
||||
}
|
||||
@@ -496,13 +572,24 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
||||
});
|
||||
|
||||
const beamPatternInputIds = [
|
||||
"beam-pattern-frequency",
|
||||
"beam-pattern-speed-of-sound",
|
||||
"beam-pattern-cut",
|
||||
"beam-pattern-steering-angle",
|
||||
"beam-pattern-steering-azimuth",
|
||||
"beam-pattern-steering-elevation",
|
||||
"beam-pattern-samples",
|
||||
"beam-pattern-surface-resolution",
|
||||
"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) {
|
||||
const element = elements[id];
|
||||
@@ -516,39 +603,35 @@ function beamPatternNumber(id, label, options) {
|
||||
}
|
||||
}
|
||||
|
||||
function readBeamPatternOptions(preview) {
|
||||
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
|
||||
const frequencyHz = beamPatternNumber(
|
||||
"beam-pattern-frequency",
|
||||
"아날로그 주파수",
|
||||
{ exclusiveMinimum: 0, maximum: 1_000_000 },
|
||||
function beamPatternFrequencyHz() {
|
||||
const slider = elements["beam-pattern-frequency"];
|
||||
const logarithmicPosition = finiteInput(slider, "아날로그 주파수 slider", {
|
||||
minimum: 0,
|
||||
maximum: 6000,
|
||||
});
|
||||
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(
|
||||
"beam-pattern-speed-of-sound",
|
||||
"음속",
|
||||
{ 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);
|
||||
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
|
||||
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
|
||||
@@ -558,18 +641,73 @@ function readBeamPatternOptions(preview) {
|
||||
array: state.arrayDefinition,
|
||||
frequencyHz,
|
||||
speedOfSound,
|
||||
cut,
|
||||
steeringAngleDeg,
|
||||
sampleCount: preview
|
||||
? Math.min(
|
||||
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
|
||||
720,
|
||||
)
|
||||
: configuredSampleCount,
|
||||
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) {
|
||||
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const options = readBeamPatternOptions(preview);
|
||||
const pattern = computeReceiveBeamPattern(options);
|
||||
beamPatternView.render(pattern);
|
||||
if (state.beamPatternViewMode === "surface") {
|
||||
renderBeamPatternSurface(preview, startedAt);
|
||||
} else {
|
||||
renderBeamPatternCuts(preview, startedAt);
|
||||
}
|
||||
state.beamPatternDirty = false;
|
||||
document.documentElement.dataset.beamPatternReady = "true";
|
||||
document.documentElement.dataset.beamPatternPreview = String(preview);
|
||||
elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount);
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
document.documentElement.dataset.beamPatternView = state.beamPatternViewMode;
|
||||
} catch (error) {
|
||||
state.beamPatternDirty = false;
|
||||
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);
|
||||
beamPatternView.renderError(message);
|
||||
for (const view of Object.values(beamPatternViews)) view.renderError(message);
|
||||
beamPatternSurfaceView.renderError(message);
|
||||
setBeamPatternStatus(message, "error");
|
||||
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 = {}) {
|
||||
const preview = options.preview === true;
|
||||
state.beamPatternDirty = true;
|
||||
@@ -1093,6 +1349,10 @@ elements["select-tool-button"].addEventListener("click", () =>
|
||||
setTool("select", { focus: true, userInitiated: true }));
|
||||
elements["add-tool-button"].addEventListener("click", () =>
|
||||
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["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
|
||||
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
|
||||
@@ -1102,10 +1362,25 @@ elements["snap-enabled"].addEventListener("change", () => {
|
||||
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) {
|
||||
const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change";
|
||||
elements[id].addEventListener(eventName, () => scheduleBeamPatternRender());
|
||||
if (elements[id] instanceof HTMLInputElement) {
|
||||
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", () => {
|
||||
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
|
||||
scheduleBeamPatternRender();
|
||||
@@ -1314,13 +1589,17 @@ window.addEventListener("unload", () => {
|
||||
if (state.beamPatternRenderFrame) {
|
||||
cancelAnimationFrame(state.beamPatternRenderFrame);
|
||||
}
|
||||
beamPatternView.destroy();
|
||||
for (const view of Object.values(beamPatternViews)) view.destroy();
|
||||
beamPatternSurfaceView.destroy();
|
||||
splitPane.destroy();
|
||||
canvasView.destroy();
|
||||
});
|
||||
|
||||
elements["snap-enabled"].checked = state.snapEnabled;
|
||||
setNumber(elements["snap-step"], state.snapStepM * 1000);
|
||||
setArrayViewMode("2d", { announce: false });
|
||||
setBeamPatternViewMode("cuts", { render: false });
|
||||
syncBeamPatternFrequency();
|
||||
renderAll("기본 4×3 배열");
|
||||
canvasView.fit();
|
||||
document.documentElement.dataset.appReady = "true";
|
||||
|
||||
+214
-11
@@ -633,7 +633,9 @@ details > summary::-webkit-details-marker {
|
||||
}
|
||||
|
||||
.beam-pattern-controls,
|
||||
.beam-pattern-figure {
|
||||
.beam-pattern-visuals,
|
||||
.beam-pattern-figure,
|
||||
.beam-pattern-surface-figure {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -658,12 +660,59 @@ details > summary::-webkit-details-marker {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -728,36 +777,139 @@ details > summary::-webkit-details-marker {
|
||||
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;
|
||||
align-content: start;
|
||||
justify-items: center;
|
||||
gap: 9px;
|
||||
gap: 7px;
|
||||
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;
|
||||
width: min(100%, 580px);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
overflow: visible;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgb(145 170 162 / 22%);
|
||||
border-radius: 11px;
|
||||
color: var(--mint);
|
||||
background: #07110f;
|
||||
}
|
||||
|
||||
.beam-pattern-figure figcaption {
|
||||
width: min(100%, 580px);
|
||||
min-height: 2.8em;
|
||||
min-height: 3.8em;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.61rem;
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.45;
|
||||
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-axis {
|
||||
stroke: rgb(145 170 162 / 28%);
|
||||
@@ -976,6 +1128,15 @@ body[data-array-tool="add"] #array-canvas,
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
@@ -1002,6 +1163,24 @@ body[data-array-tool="add"] #array-canvas,
|
||||
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 {
|
||||
position: absolute;
|
||||
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) {
|
||||
.beam-pattern-heading {
|
||||
align-items: flex-start;
|
||||
@@ -1106,6 +1296,19 @@ body[data-array-tool="add"] #array-canvas,
|
||||
.beam-pattern-metrics {
|
||||
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) {
|
||||
|
||||
@@ -32,6 +32,10 @@ function dispatchValue(windowObject, element, value, eventName = "change") {
|
||||
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||||
}
|
||||
|
||||
function logarithmicFrequencyPosition(frequencyHz) {
|
||||
return Math.round(Math.log10(frequencyHz) * 1000);
|
||||
}
|
||||
|
||||
function dispatchFiles(windowObject, input, files) {
|
||||
const transfer = new windowObject.DataTransfer();
|
||||
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",
|
||||
"기본 수신 빔 패턴 렌더",
|
||||
);
|
||||
const beamPlot = documentObject.querySelector("#beam-pattern-plot");
|
||||
const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
|
||||
?.getAttribute("d");
|
||||
assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
|
||||
assertEqual(
|
||||
beamPlot.querySelectorAll(".beam-pattern-ring").length,
|
||||
5,
|
||||
"polar plot has four inner dB rings and the 0 dB ring",
|
||||
);
|
||||
assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
|
||||
assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
|
||||
const beamCuts = ["xz", "yz", "xy"];
|
||||
const beamPlots = Object.fromEntries(beamCuts.map((cut) => [
|
||||
cut,
|
||||
documentObject.querySelector("#beam-pattern-plot-" + cut),
|
||||
]));
|
||||
const initialBeamPaths = Object.fromEntries(beamCuts.map((cut) => [
|
||||
cut,
|
||||
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d"),
|
||||
]));
|
||||
for (const cut of beamCuts) {
|
||||
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(
|
||||
Number(beamPlot.dataset.sampleCount) >= 720,
|
||||
"auto angular sampling uses at least 720 directions",
|
||||
!documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
|
||||
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(
|
||||
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 frequencySlider = documentObject.querySelector("#beam-pattern-frequency");
|
||||
dispatchValue(
|
||||
windowObject,
|
||||
documentObject.querySelector("#beam-pattern-frequency"),
|
||||
49_125,
|
||||
frequencySlider,
|
||||
logarithmicFrequencyPosition(49_125),
|
||||
"input",
|
||||
);
|
||||
await waitFor(
|
||||
() => beamPlot.dataset.frequencyHz === "49125" &&
|
||||
Number(beamPlot.dataset.sampleCount) > 720,
|
||||
"above-Nyquist-style analog pattern with auto angular sampling",
|
||||
() => documentObject.documentElement.dataset.beamPatternPreview === "true" &&
|
||||
beamCuts.every((cut) =>
|
||||
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(
|
||||
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
|
||||
@@ -146,15 +194,15 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
360,
|
||||
);
|
||||
await waitFor(
|
||||
() => beamPlot.dataset.sampleCount === "360" &&
|
||||
() => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") &&
|
||||
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
|
||||
"해상도 부족",
|
||||
),
|
||||
"coarse angular grid exposes unresolved metrics",
|
||||
"coarse angular grid exposes unresolved metrics for the simultaneous cuts",
|
||||
);
|
||||
dispatchValue(
|
||||
windowObject,
|
||||
documentObject.querySelector("#beam-pattern-frequency"),
|
||||
documentObject.querySelector("#beam-pattern-speed-of-sound"),
|
||||
"",
|
||||
"input",
|
||||
);
|
||||
@@ -168,18 +216,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
);
|
||||
dispatchValue(
|
||||
windowObject,
|
||||
documentObject.querySelector("#beam-pattern-samples"),
|
||||
"auto",
|
||||
documentObject.querySelector("#beam-pattern-speed-of-sound"),
|
||||
343,
|
||||
"input",
|
||||
);
|
||||
dispatchValue(
|
||||
windowObject,
|
||||
documentObject.querySelector("#beam-pattern-frequency"),
|
||||
1_000,
|
||||
frequencySlider,
|
||||
logarithmicFrequencyPosition(1_000),
|
||||
"input",
|
||||
);
|
||||
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||||
dispatchValue(
|
||||
windowObject,
|
||||
documentObject.querySelector("#beam-pattern-samples"),
|
||||
"auto",
|
||||
);
|
||||
await waitFor(
|
||||
() => 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",
|
||||
);
|
||||
assertEqual(
|
||||
@@ -187,6 +245,87 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
initialSaveState,
|
||||
"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(
|
||||
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");
|
||||
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 initialCanvasRect = canvas.getBoundingClientRect();
|
||||
const initialViewport = fitArrayViewport(
|
||||
@@ -234,9 +483,10 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
dispatchDragPointer("pointerup", 36, -24, 0);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
|
||||
beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
|
||||
initialBeamPath,
|
||||
"beam pattern follows edited microphone geometry",
|
||||
beamCuts.every((cut) =>
|
||||
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d") !==
|
||||
initialBeamPaths[cut]),
|
||||
"all beam-pattern cuts follow edited microphone geometry",
|
||||
);
|
||||
const draggedPosition = selectedCoordinates(documentObject);
|
||||
assert(
|
||||
@@ -789,28 +1039,63 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
"Array Builder has no horizontal overflow at 390px",
|
||||
);
|
||||
const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
|
||||
.getBoundingClientRect();
|
||||
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
|
||||
.getBoundingClientRect();
|
||||
const compactBeamPlotRects = beamCuts.map((cut) =>
|
||||
beamPlots[cut].getBoundingClientRect());
|
||||
assert(
|
||||
compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
|
||||
compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
|
||||
"compact polar plot stays inside its analysis card",
|
||||
compactBeamPlotRects.every((rect) =>
|
||||
rect.left >= compactBeamCardRect.left - 1 &&
|
||||
rect.right <= compactBeamCardRect.right + 1),
|
||||
"all compact polar plots stay inside their analysis card",
|
||||
);
|
||||
assert(
|
||||
Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
|
||||
"compact polar plot remains square",
|
||||
compactBeamPlotRects.every((rect) => Math.abs(rect.width - rect.height) <= 2),
|
||||
"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 = [
|
||||
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
|
||||
].map((field) => field.getBoundingClientRect());
|
||||
].filter((field) => !field.hidden).map((field) => field.getBoundingClientRect());
|
||||
assert(
|
||||
compactBeamFields.every(
|
||||
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
|
||||
),
|
||||
"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(
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||
"Array canvas remains usable at 390px",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
hitTestMicrophone,
|
||||
keyboardNudgeForKey,
|
||||
niceGridStep,
|
||||
projectArrayPosition3d,
|
||||
} from "/apps/array-builder/canvas-view.js";
|
||||
|
||||
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("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