feat: add interactive microphone array builder
This commit is contained in:
@@ -0,0 +1,707 @@
|
|||||||
|
import { clampPositionToAperture, positionInsideAperture } from "./model.js";
|
||||||
|
|
||||||
|
const BACKGROUND = "#07110f";
|
||||||
|
const PLOT_BACKGROUND = "#0b1714";
|
||||||
|
const MAX_PIXEL_RATIO = 3;
|
||||||
|
const MIN_ZOOM = 0.25;
|
||||||
|
const MAX_ZOOM = 16;
|
||||||
|
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
|
||||||
|
|
||||||
|
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
|
||||||
|
|
||||||
|
function clamp(value, minimum, maximum) {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value, fallback = 0) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPosition(position) {
|
||||||
|
return Array.isArray(position) &&
|
||||||
|
Number.isFinite(Number(position[0])) &&
|
||||||
|
Number.isFinite(Number(position[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function apertureDimensions(arrayDefinition) {
|
||||||
|
return {
|
||||||
|
width: Math.max(0.000001, finiteNumber(arrayDefinition?.aperture?.width, 0.1)),
|
||||||
|
height: Math.max(0.000001, finiteNumber(arrayDefinition?.aperture?.height, 0.1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedMargins(width, height, requested = {}) {
|
||||||
|
const margins = { ...DEFAULT_MARGINS, ...requested };
|
||||||
|
const horizontalLimit = Math.max(0, width - 1);
|
||||||
|
const verticalLimit = Math.max(0, height - 1);
|
||||||
|
const horizontalScale = Math.min(1, horizontalLimit / Math.max(1, margins.left + margins.right));
|
||||||
|
const verticalScale = Math.min(1, verticalLimit / Math.max(1, margins.top + margins.bottom));
|
||||||
|
return {
|
||||||
|
left: margins.left * horizontalScale,
|
||||||
|
right: margins.right * horizontalScale,
|
||||||
|
top: margins.top * verticalScale,
|
||||||
|
bottom: margins.bottom * verticalScale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a 1/2/5 × 10^n grid step near the requested line density. */
|
||||||
|
export function niceGridStep(span, targetLines = 8) {
|
||||||
|
const safeSpan = Math.max(1e-12, Math.abs(finiteNumber(span, 1)));
|
||||||
|
const safeTarget = Math.max(1, finiteNumber(targetLines, 8));
|
||||||
|
const raw = safeSpan / safeTarget;
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
||||||
|
const normalized = raw / magnitude;
|
||||||
|
const multiplier = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||||
|
return multiplier * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit an array into a CSS-pixel viewport while keeping x/y at exactly the same scale.
|
||||||
|
* The aperture and any microphones outside it are both included by the fitted view.
|
||||||
|
*/
|
||||||
|
export function fitArrayViewport(width, height, arrayDefinition, options = {}) {
|
||||||
|
const canvasWidth = Math.max(1, finiteNumber(width, 1));
|
||||||
|
const canvasHeight = Math.max(1, finiteNumber(height, 1));
|
||||||
|
const margins = resolvedMargins(canvasWidth, canvasHeight, options.margins);
|
||||||
|
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(arrayDefinition);
|
||||||
|
let contentXMin = -aperture.width / 2;
|
||||||
|
let contentXMax = aperture.width / 2;
|
||||||
|
let contentYMin = -aperture.height / 2;
|
||||||
|
let contentYMax = aperture.height / 2;
|
||||||
|
for (const microphone of arrayDefinition?.microphones ?? []) {
|
||||||
|
if (!validPosition(microphone?.position)) continue;
|
||||||
|
contentXMin = Math.min(contentXMin, Number(microphone.position[0]));
|
||||||
|
contentXMax = Math.max(contentXMax, Number(microphone.position[0]));
|
||||||
|
contentYMin = Math.min(contentYMin, Number(microphone.position[1]));
|
||||||
|
contentYMax = Math.max(contentYMax, Number(microphone.position[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingRatio = clamp(finiteNumber(options.paddingRatio, 0.12), 0, 1);
|
||||||
|
const contentWidth = Math.max(0.000001, contentXMax - contentXMin);
|
||||||
|
const contentHeight = Math.max(0.000001, contentYMax - contentYMin);
|
||||||
|
const paddedWidth = contentWidth * (1 + paddingRatio * 2);
|
||||||
|
const paddedHeight = contentHeight * (1 + paddingRatio * 2);
|
||||||
|
const zoom = clamp(finiteNumber(options.zoom, 1), MIN_ZOOM, MAX_ZOOM);
|
||||||
|
const scale = Math.max(
|
||||||
|
Number.MIN_VALUE,
|
||||||
|
Math.min(plot.width / paddedWidth, plot.height / paddedHeight) * zoom,
|
||||||
|
);
|
||||||
|
const centerX = (contentXMin + contentXMax) / 2;
|
||||||
|
const centerY = (contentYMin + contentYMax) / 2;
|
||||||
|
const visibleWidth = plot.width / scale;
|
||||||
|
const visibleHeight = plot.height / scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
plot,
|
||||||
|
scale,
|
||||||
|
metersPerPixel: 1 / scale,
|
||||||
|
zoom,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
xMin: centerX - visibleWidth / 2,
|
||||||
|
xMax: centerX + visibleWidth / 2,
|
||||||
|
yMin: centerY - visibleHeight / 2,
|
||||||
|
yMax: centerY + visibleHeight / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an array [x,y,z] position to CSS pixels. */
|
||||||
|
export function arrayPositionToCanvas(position, viewport) {
|
||||||
|
return {
|
||||||
|
x: viewport.plot.x + (Number(position[0]) - viewport.xMin) * viewport.scale,
|
||||||
|
y: viewport.plot.y + (viewport.yMax - Number(position[1])) * viewport.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert CSS pixels to an array [x,y,z] position. `clamp` limits input to the plot. */
|
||||||
|
export function canvasPointToArray(point, viewport, options = {}) {
|
||||||
|
const plot = viewport.plot;
|
||||||
|
const pixelX = options.clamp
|
||||||
|
? clamp(finiteNumber(point?.x), plot.x, plot.x + plot.width)
|
||||||
|
: finiteNumber(point?.x);
|
||||||
|
const pixelY = options.clamp
|
||||||
|
? clamp(finiteNumber(point?.y), plot.y, plot.y + plot.height)
|
||||||
|
: finiteNumber(point?.y);
|
||||||
|
return [
|
||||||
|
viewport.xMin + (pixelX - plot.x) / viewport.scale,
|
||||||
|
viewport.yMax - (pixelY - plot.y) / viewport.scale,
|
||||||
|
finiteNumber(options.z, 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a PointerEvent-like object into canvas-local CSS pixels without touching the DOM. */
|
||||||
|
export function eventToCanvasPoint(event, boundingRect, options = {}) {
|
||||||
|
const width = Math.max(0, finiteNumber(boundingRect?.width));
|
||||||
|
const height = Math.max(0, finiteNumber(boundingRect?.height));
|
||||||
|
let x = finiteNumber(event?.clientX) - finiteNumber(boundingRect?.left);
|
||||||
|
let y = finiteNumber(event?.clientY) - finiteNumber(boundingRect?.top);
|
||||||
|
if (options.clamp) {
|
||||||
|
x = clamp(x, 0, width);
|
||||||
|
y = clamp(y, 0, height);
|
||||||
|
}
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a PointerEvent-like object to array coordinates; optionally clamp to the visible plot. */
|
||||||
|
export function eventToArrayPosition(event, boundingRect, viewport, options = {}) {
|
||||||
|
return canvasPointToArray(
|
||||||
|
eventToCanvasPoint(event, boundingRect, { clamp: Boolean(options.clampToCanvas) }),
|
||||||
|
viewport,
|
||||||
|
{ clamp: Boolean(options.clamp), z: options.z },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the closest microphone within a CSS-pixel radius. */
|
||||||
|
export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
|
||||||
|
const maximumDistance = Math.max(0, finiteNumber(radiusPx, 12));
|
||||||
|
let best = null;
|
||||||
|
for (let index = 0; index < (microphones?.length ?? 0); index += 1) {
|
||||||
|
const microphone = microphones[index];
|
||||||
|
if (!validPosition(microphone?.position)) continue;
|
||||||
|
const pixel = arrayPositionToCanvas(microphone.position, viewport);
|
||||||
|
const distancePx = Math.hypot(pixel.x - point.x, pixel.y - point.y);
|
||||||
|
if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) {
|
||||||
|
best = { id: microphone.id, index, microphone, point: pixel, distancePx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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));
|
||||||
|
const step = (requestedStep > 0 ? requestedStep : 0.001) * (shiftKey ? 10 : 1);
|
||||||
|
if (key === "ArrowLeft") return { dx: -step, dy: 0, dz: 0 };
|
||||||
|
if (key === "ArrowRight") return { dx: step, dy: 0, dz: 0 };
|
||||||
|
if (key === "ArrowUp") return { dx: 0, dy: step, dz: 0 };
|
||||||
|
if (key === "ArrowDown") return { dx: 0, dy: -step, dz: 0 };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCanvasSize(canvas, view) {
|
||||||
|
const rect = canvas.getBoundingClientRect?.();
|
||||||
|
const width = Math.max(1, rect?.width || canvas.clientWidth || 1);
|
||||||
|
const height = Math.max(1, rect?.height || canvas.clientHeight || 1);
|
||||||
|
const pixelRatio = clamp(finiteNumber(view?.devicePixelRatio, 1), 1, MAX_PIXEL_RATIO);
|
||||||
|
const backingWidth = Math.max(1, Math.round(width * pixelRatio));
|
||||||
|
const backingHeight = Math.max(1, Math.round(height * pixelRatio));
|
||||||
|
if (canvas.width !== backingWidth || canvas.height !== backingHeight) {
|
||||||
|
canvas.width = backingWidth;
|
||||||
|
canvas.height = backingHeight;
|
||||||
|
}
|
||||||
|
return { width, height, pixelRatio };
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareContext(canvas, context, view) {
|
||||||
|
const size = resolveCanvasSize(canvas, view);
|
||||||
|
context.setTransform(size.pixelRatio, 0, 0, size.pixelRatio, 0, 0);
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
context.imageSmoothingEnabled = true;
|
||||||
|
context.clearRect(0, 0, size.width, size.height);
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEmpty(context, width, height) {
|
||||||
|
context.fillStyle = BACKGROUND;
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
context.fillStyle = "rgba(205,225,218,.66)";
|
||||||
|
context.font = "600 13px Inter, system-ui, sans-serif";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText("마이크 어레이를 준비하고 있습니다.", width / 2, height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeters(value, step) {
|
||||||
|
const digits = clamp(Math.max(0, -Math.floor(Math.log10(step))) + 1, 0, 6);
|
||||||
|
const normalized = Math.abs(value) < step * 1e-7 ? 0 : value;
|
||||||
|
return normalized.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridValues(minimum, maximum, step) {
|
||||||
|
const first = Math.ceil((minimum - step * 1e-8) / step) * step;
|
||||||
|
const count = Math.min(200, Math.max(0, Math.floor((maximum - first) / step + 1 + 1e-8)));
|
||||||
|
return Array.from({ length: count }, (_, index) => first + index * step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(context, viewport) {
|
||||||
|
const { plot } = viewport;
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(plot.x, plot.y, plot.width, plot.height);
|
||||||
|
context.clip();
|
||||||
|
context.fillStyle = PLOT_BACKGROUND;
|
||||||
|
context.fillRect(plot.x, plot.y, plot.width, plot.height);
|
||||||
|
|
||||||
|
const step = niceGridStep(Math.max(viewport.xMax - viewport.xMin, viewport.yMax - viewport.yMin));
|
||||||
|
context.lineWidth = 1;
|
||||||
|
for (const x of gridValues(viewport.xMin, viewport.xMax, step)) {
|
||||||
|
const pixel = arrayPositionToCanvas([x, 0, 0], viewport).x;
|
||||||
|
context.strokeStyle = Math.abs(x) < step * 1e-7
|
||||||
|
? "rgba(101,242,191,.34)"
|
||||||
|
: "rgba(166,196,186,.11)";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(pixel, plot.y);
|
||||||
|
context.lineTo(pixel, plot.y + plot.height);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
for (const y of gridValues(viewport.yMin, viewport.yMax, step)) {
|
||||||
|
const pixel = arrayPositionToCanvas([0, y, 0], viewport).y;
|
||||||
|
context.strokeStyle = Math.abs(y) < step * 1e-7
|
||||||
|
? "rgba(101,242,191,.34)"
|
||||||
|
: "rgba(166,196,186,.11)";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(plot.x, pixel);
|
||||||
|
context.lineTo(plot.x + plot.width, pixel);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
context.fillStyle = "rgba(205,225,218,.62)";
|
||||||
|
context.font = "600 10px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
context.textAlign = "center";
|
||||||
|
for (const x of gridValues(viewport.xMin, viewport.xMax, step)) {
|
||||||
|
const pixel = arrayPositionToCanvas([x, 0, 0], viewport).x;
|
||||||
|
if (pixel < plot.x + 14 || pixel > plot.x + plot.width - 14) continue;
|
||||||
|
context.fillText(formatMeters(x, step), pixel, plot.y + plot.height + 8);
|
||||||
|
}
|
||||||
|
context.textAlign = "right";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
for (const y of gridValues(viewport.yMin, viewport.yMax, step)) {
|
||||||
|
const pixel = arrayPositionToCanvas([0, y, 0], viewport).y;
|
||||||
|
if (pixel < plot.y + 8 || pixel > plot.y + plot.height - 8) continue;
|
||||||
|
context.fillText(formatMeters(y, step), plot.x - 8, pixel);
|
||||||
|
}
|
||||||
|
context.textAlign = "right";
|
||||||
|
context.textBaseline = "alphabetic";
|
||||||
|
context.fillText("x → m", plot.x + plot.width, plot.y + plot.height + 31);
|
||||||
|
context.save();
|
||||||
|
context.translate(Math.max(12, plot.x - 37), plot.y);
|
||||||
|
context.rotate(-Math.PI / 2);
|
||||||
|
context.textAlign = "right";
|
||||||
|
context.fillText("y ↑ m", 0, 0);
|
||||||
|
context.restore();
|
||||||
|
context.textAlign = "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
function traceAperture(context, arrayDefinition, apertureShape, viewport) {
|
||||||
|
const aperture = apertureDimensions(arrayDefinition);
|
||||||
|
const center = arrayPositionToCanvas([0, 0, 0], viewport);
|
||||||
|
const radiusX = aperture.width * viewport.scale / 2;
|
||||||
|
const radiusY = aperture.height * viewport.scale / 2;
|
||||||
|
context.beginPath();
|
||||||
|
if (apertureShape === "ellipse") {
|
||||||
|
context.ellipse(center.x, center.y, radiusX, radiusY, 0, 0, Math.PI * 2);
|
||||||
|
} else {
|
||||||
|
context.rect(center.x - radiusX, center.y - radiusY, radiusX * 2, radiusY * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAperture(context, arrayDefinition, apertureShape, viewport) {
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
|
||||||
|
context.clip();
|
||||||
|
traceAperture(context, arrayDefinition, apertureShape, viewport);
|
||||||
|
context.fillStyle = "rgba(101,242,191,.035)";
|
||||||
|
context.fill();
|
||||||
|
context.setLineDash([7, 5]);
|
||||||
|
context.lineWidth = 1.5;
|
||||||
|
context.strokeStyle = "rgba(174,229,210,.54)";
|
||||||
|
context.stroke();
|
||||||
|
context.setLineDash([]);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSelectedLabel(context, microphone, point, viewport) {
|
||||||
|
const microphoneId = microphone.id.length > 24
|
||||||
|
? microphone.id.slice(0, 23) + "…"
|
||||||
|
: microphone.id;
|
||||||
|
const label = microphoneId + " [" +
|
||||||
|
microphone.position.map((value) => Number(value).toFixed(4)).join(", ") + "] m";
|
||||||
|
context.font = "700 11px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||||
|
const width = context.measureText(label).width + 16;
|
||||||
|
const x = clamp(point.x + 15, viewport.plot.x + 5, viewport.plot.x + viewport.plot.width - width - 5);
|
||||||
|
const y = clamp(point.y - 31, viewport.plot.y + 5, viewport.plot.y + viewport.plot.height - 28);
|
||||||
|
context.fillStyle = "rgba(4,13,10,.94)";
|
||||||
|
context.fillRect(x, y, width, 25);
|
||||||
|
context.strokeStyle = "rgba(101,242,191,.5)";
|
||||||
|
context.strokeRect(x + 0.5, y + 0.5, width - 1, 24);
|
||||||
|
context.fillStyle = "#eef8f4";
|
||||||
|
context.textAlign = "left";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText(label, x + 8, y + 12.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMicrophones(context, model, viewport, drag) {
|
||||||
|
const arrayDefinition = model.arrayDefinition;
|
||||||
|
const microphones = arrayDefinition.microphones ?? [];
|
||||||
|
const selectedId = drag?.id ?? model.selectedMicrophoneId;
|
||||||
|
const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7);
|
||||||
|
let selected = null;
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
|
||||||
|
context.clip();
|
||||||
|
microphones.forEach((microphone, index) => {
|
||||||
|
if (!validPosition(microphone?.position)) return;
|
||||||
|
const position = drag?.id === microphone.id ? drag.last : microphone.position;
|
||||||
|
const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
|
||||||
|
const point = arrayPositionToCanvas(position, viewport);
|
||||||
|
const isSelected = microphone.id === selectedId;
|
||||||
|
const inside = positionInsideAperture(position, 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 : 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 (microphones.length <= 24 && !isSelected) {
|
||||||
|
context.fillStyle = "rgba(239,249,245,.76)";
|
||||||
|
context.font = "700 9px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "bottom";
|
||||||
|
context.fillText(String(index + 1), point.x, point.y - radius - 3);
|
||||||
|
}
|
||||||
|
if (isSelected) selected = { microphone: drawMicrophone, 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();
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
if (selected) drawSelectedLabel(context, selected.microphone, selected.point, viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArray(context, size, model, zoom, drag) {
|
||||||
|
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 = fitArrayViewport(size.width, size.height, model.arrayDefinition, { zoom });
|
||||||
|
drawGrid(context, viewport);
|
||||||
|
drawAperture(context, model.arrayDefinition, model.apertureShape, viewport);
|
||||||
|
drawMicrophones(context, model, viewport, drag);
|
||||||
|
|
||||||
|
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.fillStyle = "rgba(205,225,218,.7)";
|
||||||
|
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||||
|
context.textAlign = "right";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
|
||||||
|
context.textAlign = "left";
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedTool(tool) {
|
||||||
|
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the editable microphone-array canvas.
|
||||||
|
*
|
||||||
|
* Callbacks: onSelect(id), onAdd(position), onDragStart(id), onDrag(id, position),
|
||||||
|
* onDragEnd(id, position), onDelete(), onNudge({dx,dy,dz}), onToolChange(tool), onFit().
|
||||||
|
*/
|
||||||
|
export function createArrayCanvas(canvas, callbacks = {}) {
|
||||||
|
if (!canvas) throw new TypeError("array canvas가 필요합니다.");
|
||||||
|
const context = canvas.getContext?.("2d", { alpha: false });
|
||||||
|
if (!context) throw new Error("2D canvas 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 model = null;
|
||||||
|
let viewport = null;
|
||||||
|
let frameId = 0;
|
||||||
|
let destroyed = false;
|
||||||
|
let zoom = 1;
|
||||||
|
let tool = "select";
|
||||||
|
let drag = null;
|
||||||
|
|
||||||
|
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
|
||||||
|
if (canvas.style) canvas.style.touchAction = "none";
|
||||||
|
|
||||||
|
function updateCanvasAffordance() {
|
||||||
|
if (canvas.dataset) {
|
||||||
|
canvas.dataset.tool = tool;
|
||||||
|
canvas.dataset.zoom = String(zoom);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
updateCanvasAffordance();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (!destroyed && !frameId) frameId = requestFrame(drawCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureViewport() {
|
||||||
|
if (viewport) return viewport;
|
||||||
|
if (!model?.arrayDefinition) return null;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
viewport = fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(nextModel) {
|
||||||
|
if (destroyed) return;
|
||||||
|
model = nextModel;
|
||||||
|
tool = normalizedTool(nextModel?.tool ?? tool);
|
||||||
|
viewport = null;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTool(nextTool) {
|
||||||
|
const resolved = normalizedTool(nextTool);
|
||||||
|
if (resolved === tool) return tool;
|
||||||
|
tool = resolved;
|
||||||
|
updateCanvasAffordance();
|
||||||
|
callbacks.onToolChange?.(tool);
|
||||||
|
schedule();
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
zoom = 1;
|
||||||
|
viewport = null;
|
||||||
|
schedule();
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomBy(factor) {
|
||||||
|
const multiplier = Number(factor);
|
||||||
|
if (!Number.isFinite(multiplier) || multiplier <= 0) {
|
||||||
|
throw new RangeError("zoom factor는 0보다 큰 유한한 숫자여야 합니다.");
|
||||||
|
}
|
||||||
|
zoom = clamp(zoom * multiplier, MIN_ZOOM, MAX_ZOOM);
|
||||||
|
viewport = null;
|
||||||
|
schedule();
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
try {
|
||||||
|
canvas.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
canvas.focus?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function microphoneById(id) {
|
||||||
|
return model?.arrayDefinition?.microphones?.find((microphone) => microphone.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainedEventPosition(event, z = 0) {
|
||||||
|
const currentViewport = ensureViewport();
|
||||||
|
if (!currentViewport || !model?.arrayDefinition?.aperture) return null;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const raw = eventToArrayPosition(event, rect, currentViewport, { clamp: true, z });
|
||||||
|
const step = finiteNumber(model.snapStepM, 0);
|
||||||
|
if (step > 0) {
|
||||||
|
raw[0] = Math.round(raw[0] / step) * step;
|
||||||
|
raw[1] = Math.round(raw[1] / step) * step;
|
||||||
|
}
|
||||||
|
return clampPositionToAperture(
|
||||||
|
raw,
|
||||||
|
model.arrayDefinition.aperture,
|
||||||
|
model.apertureShape,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerPoint(event) {
|
||||||
|
return eventToCanvasPoint(event, canvas.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event) {
|
||||||
|
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
|
||||||
|
focus();
|
||||||
|
if (tool === "add") {
|
||||||
|
const position = constrainedEventPosition(event, 0);
|
||||||
|
if (position) {
|
||||||
|
event.preventDefault();
|
||||||
|
callbacks.onAdd?.(position);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentViewport = ensureViewport();
|
||||||
|
if (!currentViewport) return;
|
||||||
|
const hit = hitTestMicrophone(
|
||||||
|
pointerPoint(event),
|
||||||
|
model.arrayDefinition.microphones,
|
||||||
|
currentViewport,
|
||||||
|
);
|
||||||
|
callbacks.onSelect?.(hit?.id ?? null);
|
||||||
|
if (!hit) return;
|
||||||
|
event.preventDefault();
|
||||||
|
drag = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
id: hit.id,
|
||||||
|
start: [...hit.microphone.position],
|
||||||
|
last: [...hit.microphone.position],
|
||||||
|
};
|
||||||
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
|
callbacks.onDragStart?.(hit.id);
|
||||||
|
updateCanvasAffordance();
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event) {
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
const microphone = microphoneById(drag.id);
|
||||||
|
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
|
||||||
|
if (!position) return;
|
||||||
|
event.preventDefault();
|
||||||
|
drag.last = position;
|
||||||
|
callbacks.onDrag?.(drag.id, [...position]);
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDrag(event, cancel = false) {
|
||||||
|
if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return;
|
||||||
|
const completed = drag;
|
||||||
|
drag = null;
|
||||||
|
const position = cancel ? completed.start : completed.last;
|
||||||
|
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
|
||||||
|
callbacks.onDragEnd?.(completed.id, [...position]);
|
||||||
|
const pointerId = event?.pointerId ?? completed.pointerId;
|
||||||
|
if (canvas.hasPointerCapture?.(pointerId)) {
|
||||||
|
canvas.releasePointerCapture?.(pointerId);
|
||||||
|
}
|
||||||
|
updateCanvasAffordance();
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event) {
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||||
|
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
|
if (lowerKey === "a") {
|
||||||
|
event.preventDefault();
|
||||||
|
setTool("add");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lowerKey === "v") {
|
||||||
|
event.preventDefault();
|
||||||
|
setTool("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lowerKey === "f") {
|
||||||
|
event.preventDefault();
|
||||||
|
fit();
|
||||||
|
callbacks.onFit?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (drag) finishDrag(undefined, true);
|
||||||
|
setTool("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((event.key === "Delete" || event.key === "Backspace") && model?.selectedMicrophoneId) {
|
||||||
|
event.preventDefault();
|
||||||
|
callbacks.onDelete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!model?.selectedMicrophoneId) return;
|
||||||
|
const defaultStep = finiteNumber(model.snapStepM, 0) > 0 ? Number(model.snapStepM) : 0.001;
|
||||||
|
const delta = keyboardNudgeForKey(event.key, defaultStep, event.shiftKey);
|
||||||
|
if (!delta) return;
|
||||||
|
event.preventDefault();
|
||||||
|
callbacks.onNudge?.(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event) {
|
||||||
|
finishDrag(event, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel(event) {
|
||||||
|
finishDrag(event, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLostPointerCapture(event) {
|
||||||
|
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener("pointerdown", onPointerDown);
|
||||||
|
canvas.addEventListener("pointermove", onPointerMove);
|
||||||
|
canvas.addEventListener("pointerup", onPointerUp);
|
||||||
|
canvas.addEventListener("pointercancel", onPointerCancel);
|
||||||
|
canvas.addEventListener("lostpointercapture", onLostPointerCapture);
|
||||||
|
canvas.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
let resizeObserver = null;
|
||||||
|
const onWindowResize = () => {
|
||||||
|
viewport = null;
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
if (typeof view.ResizeObserver === "function") {
|
||||||
|
resizeObserver = new view.ResizeObserver(onWindowResize);
|
||||||
|
resizeObserver.observe(canvas);
|
||||||
|
} else {
|
||||||
|
view.addEventListener?.("resize", onWindowResize);
|
||||||
|
}
|
||||||
|
updateCanvasAffordance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
render,
|
||||||
|
setTool,
|
||||||
|
fit,
|
||||||
|
zoomBy,
|
||||||
|
focus,
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
if (frameId) cancelFrame?.(frameId);
|
||||||
|
frameId = 0;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
view.removeEventListener?.("resize", onWindowResize);
|
||||||
|
canvas.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
canvas.removeEventListener("pointermove", onPointerMove);
|
||||||
|
canvas.removeEventListener("pointerup", onPointerUp);
|
||||||
|
canvas.removeEventListener("pointercancel", onPointerCancel);
|
||||||
|
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
|
||||||
|
canvas.removeEventListener("keydown", onKeyDown);
|
||||||
|
model = null;
|
||||||
|
viewport = null;
|
||||||
|
drag = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="마이크 위치를 직접 편집하고 격자·선형·원형·나선·랜덤 배열을 생성하는 micarrsim Array Builder"
|
||||||
|
/>
|
||||||
|
<title>Array Builder · micarrsim</title>
|
||||||
|
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
|
||||||
|
<link rel="stylesheet" href="/apps/array-builder/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell array-builder-shell">
|
||||||
|
<header class="app-header array-builder-header">
|
||||||
|
<a class="app-brand" href="/">
|
||||||
|
<strong>micarrsim</strong>
|
||||||
|
<span>Array Builder</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="array-workflow" aria-label="마이크 어레이 제작 순서">
|
||||||
|
<ol>
|
||||||
|
<li><span>1</span>크기·형상</li>
|
||||||
|
<li><span>2</span>생성·편집</li>
|
||||||
|
<li><span>3</span>검증</li>
|
||||||
|
<li><span>4</span>저장</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<label class="button button--ghost file-button">
|
||||||
|
JSON 열기
|
||||||
|
<input id="array-input" type="file" accept="application/json,.json" />
|
||||||
|
</label>
|
||||||
|
<a class="button button--ghost" href="/apps/acoustic-camera/">Camera 열기</a>
|
||||||
|
<button class="button button--primary" id="export-button" type="button">Array JSON 저장</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="array-builder-layout" id="array-builder-layout" data-split-layout>
|
||||||
|
<aside
|
||||||
|
class="array-controls"
|
||||||
|
id="array-builder-controls"
|
||||||
|
data-split-panel
|
||||||
|
aria-label="마이크 어레이 설정"
|
||||||
|
>
|
||||||
|
<div class="runtime-line">
|
||||||
|
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
|
||||||
|
<span class="save-state" id="save-state" role="status">기본 배열</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="validation-card" aria-live="polite" aria-atomic="true">
|
||||||
|
<span class="validation-dot" id="validation-dot" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<strong id="validation-title">검증 대기</strong>
|
||||||
|
<p id="validation-message">마이크 배열을 준비하고 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="control-section">
|
||||||
|
<div class="section-title"><span>01</span><h2>배열 정보</h2></div>
|
||||||
|
<label class="field field--wide">
|
||||||
|
<span>이름</span>
|
||||||
|
<input id="array-name" type="text" maxlength="120" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label class="field field--wide">
|
||||||
|
<span>Array ID</span>
|
||||||
|
<input
|
||||||
|
id="array-id"
|
||||||
|
type="text"
|
||||||
|
maxlength="120"
|
||||||
|
pattern="[a-z0-9][a-z0-9._-]*"
|
||||||
|
title="영문 소문자나 숫자로 시작하고 소문자, 숫자, 점, 밑줄, 하이픈만 사용할 수 있습니다."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field field--wide">
|
||||||
|
<span>설명 <small>선택 사항</small></span>
|
||||||
|
<textarea id="array-description" rows="2" maxlength="1000"></textarea>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="control-section control-section--primary">
|
||||||
|
<div class="section-title"><span>02</span><h2>크기와 형상</h2></div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>너비 (m)</span>
|
||||||
|
<input id="aperture-width" type="number" min="0.001" max="20" step="0.01" value="0.45" inputmode="decimal" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>높이 (m)</span>
|
||||||
|
<input id="aperture-height" type="number" min="0.001" max="20" step="0.01" value="0.45" inputmode="decimal" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="field field--wide">
|
||||||
|
<span>생성 경계</span>
|
||||||
|
<select id="aperture-shape">
|
||||||
|
<option value="rectangle">사각형</option>
|
||||||
|
<option value="ellipse">타원</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="section-note">크기를 바꿔도 기존 마이크 좌표는 자동으로 늘어나거나 줄어들지 않습니다.</p>
|
||||||
|
<div class="button-grid button-grid--two">
|
||||||
|
<button class="button button--ghost" id="center-microphones-button" type="button">좌표 중앙 정렬</button>
|
||||||
|
<button class="button button--ghost" id="fit-aperture-button" type="button">좌표에 크기 맞춤</button>
|
||||||
|
<button class="button button--ghost button-grid__wide" id="clamp-microphones-button" type="button">경계 밖 마이크를 안으로 이동</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="control-section">
|
||||||
|
<div class="section-title"><span>03</span><h2>프리셋 생성</h2></div>
|
||||||
|
<label class="field field--wide">
|
||||||
|
<span>배치 방식</span>
|
||||||
|
<select id="preset-type">
|
||||||
|
<option value="grid">격자</option>
|
||||||
|
<option value="linear">선형</option>
|
||||||
|
<option value="ring">원형</option>
|
||||||
|
<option value="spiral">나선</option>
|
||||||
|
<option value="random">균일 랜덤</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="preset-grid">
|
||||||
|
<label class="field" id="preset-count-field">
|
||||||
|
<span>마이크 수</span>
|
||||||
|
<input id="preset-count" type="number" min="1" max="1024" step="1" value="16" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="field" id="preset-axis-field">
|
||||||
|
<span>선형 축</span>
|
||||||
|
<select id="preset-axis">
|
||||||
|
<option value="x">가로 (x)</option>
|
||||||
|
<option value="y">세로 (y)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field" id="preset-columns-field">
|
||||||
|
<span>열</span>
|
||||||
|
<input id="preset-columns" type="number" min="1" max="1024" step="1" value="4" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="field" id="preset-rows-field">
|
||||||
|
<span>행</span>
|
||||||
|
<input id="preset-rows" type="number" min="1" max="1024" step="1" value="3" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="field" id="preset-seed-field">
|
||||||
|
<span>Random seed</span>
|
||||||
|
<input id="preset-seed" type="number" min="0" max="4294967295" step="1" value="1" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="field" id="preset-minimum-spacing-field">
|
||||||
|
<span>최소 간격 (m)</span>
|
||||||
|
<input id="preset-minimum-spacing" type="number" min="0" max="20" step="0.001" value="0.015" inputmode="decimal" />
|
||||||
|
</label>
|
||||||
|
<label class="field preset-grid__wide" id="preset-edge-inset-field">
|
||||||
|
<span>경계 여백 (m)</span>
|
||||||
|
<input id="preset-edge-inset" type="number" min="0" max="10" step="0.001" value="0.01" inputmode="decimal" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="section-note" id="preset-hint">격자는 아래에서 위로, 각 행은 왼쪽에서 오른쪽 순서로 채널을 만듭니다.</p>
|
||||||
|
<button class="button button--primary button--full" id="generate-preset-button" type="button">현재 배열을 프리셋으로 교체</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="control-section microphone-section">
|
||||||
|
<div class="section-title section-title--split">
|
||||||
|
<div><span>04</span><h2>마이크 편집</h2></div>
|
||||||
|
<button class="mini-button" id="add-microphone-button" type="button">+ 마이크</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field field--wide search-field">
|
||||||
|
<span>목록 검색</span>
|
||||||
|
<input id="microphone-search" type="search" placeholder="ID로 찾기" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div class="microphone-list" id="microphone-list" role="listbox" aria-label="마이크 채널 목록">
|
||||||
|
<p class="list-empty">마이크 목록을 준비하고 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selected-microphone-editor">
|
||||||
|
<p class="selection-empty" id="microphone-selection-empty">canvas나 목록에서 마이크를 선택하세요.</p>
|
||||||
|
<div id="microphone-fields">
|
||||||
|
<label class="field field--wide">
|
||||||
|
<span>마이크 ID</span>
|
||||||
|
<input id="microphone-id" type="text" maxlength="64" autocomplete="off" disabled />
|
||||||
|
</label>
|
||||||
|
<div class="field-grid field-grid--three">
|
||||||
|
<label class="field"><span>x (m)</span><input id="microphone-x" type="number" step="0.001" inputmode="decimal" disabled /></label>
|
||||||
|
<label class="field"><span>y (m)</span><input id="microphone-y" type="number" step="0.001" inputmode="decimal" disabled /></label>
|
||||||
|
<label class="field"><span>z (m)</span><input id="microphone-z" type="number" step="0.001" inputmode="decimal" disabled /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="inline-details">
|
||||||
|
<summary>보정값</summary>
|
||||||
|
<div class="field-grid calibration-fields">
|
||||||
|
<label class="field"><span>Sensitivity (dB)</span><input id="microphone-sensitivity" type="number" min="-120" max="120" step="0.1" inputmode="decimal" disabled /></label>
|
||||||
|
<label class="field"><span>Phase (deg)</span><input id="microphone-phase" type="number" min="-180" max="180" step="0.1" inputmode="decimal" disabled /></label>
|
||||||
|
</div>
|
||||||
|
<p class="section-note" id="frequency-response-summary">주파수 응답 없음</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="button-grid reorder-actions">
|
||||||
|
<button class="button button--ghost" id="move-microphone-up-button" type="button" disabled>채널 앞으로</button>
|
||||||
|
<button class="button button--ghost" id="move-microphone-down-button" type="button" disabled>채널 뒤로</button>
|
||||||
|
<button class="button button--danger button-grid__wide" id="remove-microphone-button" type="button" disabled>선택 마이크 삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="split-handle"
|
||||||
|
id="array-builder-split-handle"
|
||||||
|
role="separator"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="배열 설정 패널 너비 조절"
|
||||||
|
aria-controls="array-builder-controls"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
data-split-resizer
|
||||||
|
data-storage-key="micarrsim.array-builder.control-panel-width"
|
||||||
|
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<section class="array-workspace" id="array-builder-workspace" aria-label="마이크 배열 편집 화면">
|
||||||
|
<div class="workspace-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">마이크 배치</p>
|
||||||
|
<h1>Array 평면 편집</h1>
|
||||||
|
</div>
|
||||||
|
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workspace-toolbar" role="toolbar" aria-label="배열 편집 도구">
|
||||||
|
<div class="toolbar-group" aria-label="편집 도구">
|
||||||
|
<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" 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>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group zoom-tools" aria-label="화면 확대와 맞춤">
|
||||||
|
<button class="icon-button" id="zoom-out-button" type="button" aria-label="축소" title="축소">−</button>
|
||||||
|
<button class="tool-button" id="fit-view-button" type="button">화면 맞춤</button>
|
||||||
|
<button class="icon-button" id="zoom-in-button" type="button" aria-label="확대" title="확대">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="snap-controls">
|
||||||
|
<label class="check-field"><input id="snap-enabled" type="checkbox" checked /><span>격자 스냅</span></label>
|
||||||
|
<label class="compact-field"><span>간격</span><input id="snap-step" type="number" min="0.1" max="1000" step="0.1" value="5" inputmode="decimal" /><small>mm</small></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="array-canvas-card">
|
||||||
|
<canvas
|
||||||
|
id="array-canvas"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="마이크 어레이 평면 편집 canvas"
|
||||||
|
aria-describedby="canvas-instructions"
|
||||||
|
></canvas>
|
||||||
|
<div class="canvas-instructions" id="canvas-instructions">
|
||||||
|
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
|
||||||
|
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
|
||||||
|
</div>
|
||||||
|
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="array-statistics" aria-label="배열 요약">
|
||||||
|
<article class="metric-card"><span>채널</span><strong id="microphone-count">12</strong><small>microphones</small></article>
|
||||||
|
<article class="metric-card"><span>최소 간격</span><strong id="minimum-spacing">—</strong><small>nearest pair</small></article>
|
||||||
|
<article class="metric-card"><span>무게중심</span><strong id="array-centroid">—</strong><small>x, y, z</small></article>
|
||||||
|
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="shortcut-help">
|
||||||
|
<summary>빠른 편집 키</summary>
|
||||||
|
<div>
|
||||||
|
<p><kbd>V</kbd> 선택 · <kbd>A</kbd> 추가 · <kbd>Delete</kbd> 선택 삭제</p>
|
||||||
|
<p><kbd>← ↑ ↓ →</kbd> 위치 미세 조정 · <kbd>Shift</kbd>를 함께 누르면 10배 이동</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/packages/ui/runtime-status.js"></script>
|
||||||
|
<script type="module" src="/apps/array-builder/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,876 @@
|
|||||||
|
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
||||||
|
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||||||
|
import { createSplitPane } from "/packages/ui/split-pane.js";
|
||||||
|
import { createArrayCanvas } from "./canvas-view.js";
|
||||||
|
import {
|
||||||
|
addMicrophone,
|
||||||
|
arrayGeometryIssues,
|
||||||
|
arrayStatistics,
|
||||||
|
centerMicrophones,
|
||||||
|
clampPositionToAperture,
|
||||||
|
createArrayDefinition,
|
||||||
|
fitApertureToMicrophones,
|
||||||
|
inferApertureShape,
|
||||||
|
moveMicrophone,
|
||||||
|
prepareArrayForEditing,
|
||||||
|
removeMicrophone,
|
||||||
|
replaceMicrophonesWithPreset,
|
||||||
|
safeArrayFileStem,
|
||||||
|
snapPosition,
|
||||||
|
updateMicrophone,
|
||||||
|
} from "./model.js";
|
||||||
|
|
||||||
|
const byId = (id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) throw new Error("필수 UI를 찾을 수 없습니다: #" + id);
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeById = (id) => document.getElementById(id);
|
||||||
|
const requiredIds = [
|
||||||
|
"array-builder-layout", "array-builder-controls", "array-builder-workspace",
|
||||||
|
"array-builder-split-handle", "array-input", "export-button", "array-id",
|
||||||
|
"array-name", "array-description", "aperture-width", "aperture-height",
|
||||||
|
"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",
|
||||||
|
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
|
||||||
|
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
|
||||||
|
"remove-microphone-button", "validation-title", "validation-message",
|
||||||
|
];
|
||||||
|
const elements = Object.fromEntries(requiredIds.map((id) => [id, byId(id)]));
|
||||||
|
|
||||||
|
const optionalIds = [
|
||||||
|
"validation-dot", "save-state", "undo-button", "redo-button",
|
||||||
|
"microphone-count", "minimum-spacing", "array-centroid", "calibration-count",
|
||||||
|
"selection-status", "center-microphones-button", "fit-aperture-button",
|
||||||
|
"clamp-microphones-button", "move-microphone-up-button",
|
||||||
|
"move-microphone-down-button", "microphone-sensitivity", "microphone-phase",
|
||||||
|
"frequency-response-summary", "preset-axis", "preset-edge-inset",
|
||||||
|
"microphone-search", "microphone-selection-empty", "microphone-fields",
|
||||||
|
"workspace-summary",
|
||||||
|
];
|
||||||
|
for (const id of optionalIds) elements[id] = maybeById(id);
|
||||||
|
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
||||||
|
|
||||||
|
const splitPane = createSplitPane({
|
||||||
|
container: elements["array-builder-layout"],
|
||||||
|
separator: elements["array-builder-split-handle"],
|
||||||
|
panel: elements["array-builder-controls"],
|
||||||
|
panelSide: "start",
|
||||||
|
storageKey: "micarrsim.array-builder.control-panel-width",
|
||||||
|
defaultSize: 420,
|
||||||
|
minSize: 360,
|
||||||
|
maxSize: 720,
|
||||||
|
minMainSize: 560,
|
||||||
|
enabledMediaQuery: "(min-width: 1101px)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
arrayDefinition: createArrayDefinition(),
|
||||||
|
apertureShape: "rectangle",
|
||||||
|
selectedMicrophoneId: "mic-001",
|
||||||
|
tool: "select",
|
||||||
|
snapEnabled: true,
|
||||||
|
snapStepM: 0.005,
|
||||||
|
dirty: false,
|
||||||
|
validationRevision: 0,
|
||||||
|
valid: false,
|
||||||
|
history: [],
|
||||||
|
future: [],
|
||||||
|
dragSnapshot: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
const BUILTIN_ARRAY_IDS = new Set([
|
||||||
|
"rectangular-2x2", "rectangular-4x3", "rectangular-8x6",
|
||||||
|
"rectangular-16x12", "rectangular-24x16", "rectangular-16x12-compact",
|
||||||
|
"rectangular-16x12-large", "fermat-spiral-192",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function finiteInput(element, label, options = {}) {
|
||||||
|
const value = element.valueAsNumber;
|
||||||
|
if (!Number.isFinite(value)) throw new Error(label + "에 유한한 숫자를 입력하세요.");
|
||||||
|
if (options.minimum !== undefined && value < options.minimum) {
|
||||||
|
throw new Error(label + "은(는) " + options.minimum + " 이상이어야 합니다.");
|
||||||
|
}
|
||||||
|
if (options.exclusiveMinimum !== undefined && value <= options.exclusiveMinimum) {
|
||||||
|
throw new Error(label + "은(는) " + options.exclusiveMinimum + "보다 커야 합니다.");
|
||||||
|
}
|
||||||
|
if (options.maximum !== undefined && value > options.maximum) {
|
||||||
|
throw new Error(label + "은(는) " + options.maximum + " 이하여야 합니다.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerInput(element, label, minimum, maximum) {
|
||||||
|
const value = finiteInput(element, label, { minimum, maximum });
|
||||||
|
if (!Number.isInteger(value)) throw new Error(label + "은(는) 정수여야 합니다.");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNumber(element, value) {
|
||||||
|
if (!element) return;
|
||||||
|
element.value = Number.isFinite(value) ? String(value) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedMicrophone() {
|
||||||
|
return state.arrayDefinition.microphones.find(
|
||||||
|
(microphone) => microphone.id === state.selectedMicrophoneId,
|
||||||
|
) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureSnapshot() {
|
||||||
|
return {
|
||||||
|
arrayDefinition: structuredClone(state.arrayDefinition),
|
||||||
|
apertureShape: state.apertureShape,
|
||||||
|
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotSignature(snapshot) {
|
||||||
|
return JSON.stringify(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSnapshot(snapshot, message) {
|
||||||
|
state.arrayDefinition = structuredClone(snapshot.arrayDefinition);
|
||||||
|
state.apertureShape = snapshot.apertureShape;
|
||||||
|
state.selectedMicrophoneId = snapshot.selectedMicrophoneId;
|
||||||
|
if (!selectedMicrophone()) {
|
||||||
|
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
renderAll(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSnapshot(before, label) {
|
||||||
|
if (snapshotSignature(before) === snapshotSignature(captureSnapshot())) return false;
|
||||||
|
state.history.push({ snapshot: before, label });
|
||||||
|
if (state.history.length > MAX_HISTORY) state.history.shift();
|
||||||
|
state.future.length = 0;
|
||||||
|
state.dirty = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutate(label, callback) {
|
||||||
|
const before = captureSnapshot();
|
||||||
|
callback();
|
||||||
|
if (recordSnapshot(before, label)) renderAll(label);
|
||||||
|
else renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showActionError(error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함";
|
||||||
|
elements["validation-title"].textContent = "작업을 적용하지 못했습니다";
|
||||||
|
elements["validation-message"].textContent = message;
|
||||||
|
if (elements.validationCard) elements.validationCard.dataset.severity = "error";
|
||||||
|
elements["array-builder-workspace"].dataset.actionError = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safely(callback) {
|
||||||
|
try {
|
||||||
|
delete elements["array-builder-workspace"].dataset.actionError;
|
||||||
|
callback();
|
||||||
|
} catch (error) {
|
||||||
|
showActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function announce(message) {
|
||||||
|
if (elements["save-state"]) {
|
||||||
|
elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨";
|
||||||
|
elements["save-state"].classList.toggle("is-dirty", state.dirty);
|
||||||
|
}
|
||||||
|
if (elements["selection-status"] && message) {
|
||||||
|
elements["selection-status"].textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTool(tool, options = {}) {
|
||||||
|
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");
|
||||||
|
elements["select-tool-button"].setAttribute("aria-pressed", String(state.tool === "select"));
|
||||||
|
elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add"));
|
||||||
|
elements["array-canvas"].dataset.tool = state.tool;
|
||||||
|
canvasView.render(canvasState());
|
||||||
|
if (options.focus) canvasView.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasState() {
|
||||||
|
return {
|
||||||
|
arrayDefinition: state.arrayDefinition,
|
||||||
|
apertureShape: state.apertureShape,
|
||||||
|
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||||
|
tool: state.tool,
|
||||||
|
snapStepM: state.snapEnabled ? state.snapStepM : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedPointerPosition(position) {
|
||||||
|
if (state.snapEnabled) {
|
||||||
|
return snapPosition(
|
||||||
|
position,
|
||||||
|
state.snapStepM,
|
||||||
|
state.arrayDefinition.aperture,
|
||||||
|
state.apertureShape,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return clampPositionToAperture(
|
||||||
|
position,
|
||||||
|
state.arrayDefinition.aperture,
|
||||||
|
state.apertureShape,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinateKey(position) {
|
||||||
|
return position.map((value) => Number(value).toFixed(9)).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableManualPosition() {
|
||||||
|
const used = new Set(state.arrayDefinition.microphones.map(
|
||||||
|
(microphone) => coordinateKey(microphone.position),
|
||||||
|
));
|
||||||
|
const origin = [0, 0, 0];
|
||||||
|
if (!used.has(coordinateKey(origin))) return origin;
|
||||||
|
const step = Math.max(state.snapStepM, 0.001);
|
||||||
|
for (let ring = 1; ring <= 128; ring += 1) {
|
||||||
|
const samples = Math.max(8, ring * 8);
|
||||||
|
for (let index = 0; index < samples; index += 1) {
|
||||||
|
const angle = (index / samples) * Math.PI * 2;
|
||||||
|
const candidate = normalizedPointerPosition([
|
||||||
|
Math.cos(angle) * ring * step,
|
||||||
|
Math.sin(angle) * ring * step,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
if (!used.has(coordinateKey(candidate))) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("빈 위치를 자동으로 찾지 못했습니다. 추가 도구로 canvas의 위치를 지정하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMicrophone(id, options = {}) {
|
||||||
|
state.selectedMicrophoneId = state.arrayDefinition.microphones.some(
|
||||||
|
(microphone) => microphone.id === id,
|
||||||
|
) ? id : null;
|
||||||
|
renderMicrophoneList();
|
||||||
|
syncSelectedEditor();
|
||||||
|
canvasView.render(canvasState());
|
||||||
|
if (state.selectedMicrophoneId) announce(state.selectedMicrophoneId + " 선택");
|
||||||
|
if (options.focusCanvas) canvasView.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasView = createArrayCanvas(elements["array-canvas"], {
|
||||||
|
onSelect(id) {
|
||||||
|
selectMicrophone(id);
|
||||||
|
},
|
||||||
|
onAdd(position) {
|
||||||
|
safely(() => mutate("마이크 추가됨", () => {
|
||||||
|
const microphone = addMicrophone(
|
||||||
|
state.arrayDefinition,
|
||||||
|
normalizedPointerPosition(position),
|
||||||
|
);
|
||||||
|
state.selectedMicrophoneId = microphone.id;
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onDragStart(id) {
|
||||||
|
if (state.dragSnapshot) return;
|
||||||
|
state.selectedMicrophoneId = id;
|
||||||
|
state.dragSnapshot = captureSnapshot();
|
||||||
|
syncSelectedEditor();
|
||||||
|
},
|
||||||
|
onDrag(id, position) {
|
||||||
|
updateMicrophone(state.arrayDefinition, id, {
|
||||||
|
position: normalizedPointerPosition(position),
|
||||||
|
});
|
||||||
|
state.selectedMicrophoneId = id;
|
||||||
|
syncSelectedEditor();
|
||||||
|
canvasView.render(canvasState());
|
||||||
|
},
|
||||||
|
onDragEnd(id, position) {
|
||||||
|
if (!state.dragSnapshot) return;
|
||||||
|
if (position) {
|
||||||
|
updateMicrophone(state.arrayDefinition, id, {
|
||||||
|
position: normalizedPointerPosition(position),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const before = state.dragSnapshot;
|
||||||
|
state.dragSnapshot = null;
|
||||||
|
if (recordSnapshot(before, "마이크 위치 변경됨")) renderAll("마이크 위치 변경됨");
|
||||||
|
else renderAll();
|
||||||
|
},
|
||||||
|
onDelete() {
|
||||||
|
removeSelectedMicrophone();
|
||||||
|
},
|
||||||
|
onNudge(delta) {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
safely(() => mutate("마이크 위치 변경됨", () => {
|
||||||
|
const dx = Number.isFinite(delta?.dx) ? delta.dx : 0;
|
||||||
|
const dy = Number.isFinite(delta?.dy) ? delta.dy : 0;
|
||||||
|
updateMicrophone(state.arrayDefinition, microphone.id, {
|
||||||
|
position: normalizedPointerPosition([
|
||||||
|
microphone.position[0] + dx,
|
||||||
|
microphone.position[1] + dy,
|
||||||
|
microphone.position[2],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onToolChange(tool) {
|
||||||
|
setTool(tool);
|
||||||
|
},
|
||||||
|
onFit() {
|
||||||
|
canvasView.fit();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDocumentEditor() {
|
||||||
|
elements["array-id"].value = state.arrayDefinition.id;
|
||||||
|
elements["array-name"].value = state.arrayDefinition.name;
|
||||||
|
elements["array-description"].value = state.arrayDefinition.description ?? "";
|
||||||
|
setNumber(elements["aperture-width"], state.arrayDefinition.aperture.width);
|
||||||
|
setNumber(elements["aperture-height"], state.arrayDefinition.aperture.height);
|
||||||
|
elements["aperture-shape"].value = state.apertureShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value) {
|
||||||
|
const rounded = Math.abs(value) < 0.0000000005 ? 0 : value;
|
||||||
|
return Number(rounded).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatistics() {
|
||||||
|
const statistics = arrayStatistics(state.arrayDefinition);
|
||||||
|
if (elements["workspace-summary"]) {
|
||||||
|
elements["workspace-summary"].textContent =
|
||||||
|
formatCoordinate(state.arrayDefinition.aperture.width) + " × " +
|
||||||
|
formatCoordinate(state.arrayDefinition.aperture.height) + " m · " +
|
||||||
|
String(statistics.count) + " channels";
|
||||||
|
}
|
||||||
|
if (elements["microphone-count"]) {
|
||||||
|
elements["microphone-count"].textContent = String(statistics.count) + " ch";
|
||||||
|
}
|
||||||
|
if (elements["minimum-spacing"]) {
|
||||||
|
elements["minimum-spacing"].textContent = statistics.minimumSpacing === null
|
||||||
|
? "—"
|
||||||
|
: (statistics.minimumSpacing * 1000).toFixed(2) + " mm";
|
||||||
|
}
|
||||||
|
if (elements["array-centroid"]) {
|
||||||
|
elements["array-centroid"].textContent =
|
||||||
|
statistics.centroid.map(formatCoordinate).join(", ") + " m";
|
||||||
|
}
|
||||||
|
if (elements["calibration-count"]) {
|
||||||
|
elements["calibration-count"].textContent =
|
||||||
|
String(statistics.calibrationCount) + " / " + String(statistics.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMicrophoneList() {
|
||||||
|
const list = elements["microphone-list"];
|
||||||
|
const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? "";
|
||||||
|
list.replaceChildren();
|
||||||
|
state.arrayDefinition.microphones.forEach((microphone, index) => {
|
||||||
|
if (query && !microphone.id.toLowerCase().includes(query)) return;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "microphone-row";
|
||||||
|
button.dataset.microphoneId = microphone.id;
|
||||||
|
button.setAttribute("role", "option");
|
||||||
|
button.setAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
String(microphone.id === state.selectedMicrophoneId),
|
||||||
|
);
|
||||||
|
button.setAttribute(
|
||||||
|
"aria-current",
|
||||||
|
String(microphone.id === state.selectedMicrophoneId),
|
||||||
|
);
|
||||||
|
button.classList.toggle("is-selected", microphone.id === state.selectedMicrophoneId);
|
||||||
|
|
||||||
|
const channel = document.createElement("span");
|
||||||
|
channel.className = "microphone-row__channel";
|
||||||
|
channel.textContent = String(index + 1);
|
||||||
|
const identity = document.createElement("span");
|
||||||
|
identity.className = "microphone-row__identity";
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = microphone.id;
|
||||||
|
const coordinates = document.createElement("small");
|
||||||
|
coordinates.textContent =
|
||||||
|
"x " + formatCoordinate(microphone.position[0]) +
|
||||||
|
" · y " + formatCoordinate(microphone.position[1]) +
|
||||||
|
" · z " + formatCoordinate(microphone.position[2]);
|
||||||
|
identity.append(strong, coordinates);
|
||||||
|
if (
|
||||||
|
microphone.sensitivityDb !== undefined ||
|
||||||
|
microphone.phaseOffsetDeg !== undefined ||
|
||||||
|
microphone.frequencyResponse !== undefined
|
||||||
|
) {
|
||||||
|
const calibration = document.createElement("span");
|
||||||
|
calibration.className = "microphone-row__calibration";
|
||||||
|
calibration.textContent = "cal";
|
||||||
|
identity.append(calibration);
|
||||||
|
}
|
||||||
|
button.append(channel, identity);
|
||||||
|
button.addEventListener("click", () => selectMicrophone(microphone.id));
|
||||||
|
list.append(button);
|
||||||
|
});
|
||||||
|
if (!list.childElementCount) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "microphone-list__empty";
|
||||||
|
empty.textContent = query ? "검색 결과가 없습니다." : "마이크가 없습니다.";
|
||||||
|
list.append(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectedEditor() {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
const inputs = [
|
||||||
|
elements["microphone-id"], elements["microphone-x"],
|
||||||
|
elements["microphone-y"], elements["microphone-z"],
|
||||||
|
elements["microphone-sensitivity"], elements["microphone-phase"],
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const input of inputs) input.disabled = !microphone;
|
||||||
|
elements["microphone-selection-empty"]?.classList.toggle("is-hidden", Boolean(microphone));
|
||||||
|
elements["microphone-fields"]?.classList.toggle("is-disabled", !microphone);
|
||||||
|
elements["remove-microphone-button"].disabled =
|
||||||
|
!microphone || state.arrayDefinition.microphones.length <= 1;
|
||||||
|
|
||||||
|
if (!microphone) {
|
||||||
|
for (const input of inputs) input.value = "";
|
||||||
|
if (elements["frequency-response-summary"]) {
|
||||||
|
elements["frequency-response-summary"].textContent = "선택한 마이크가 없습니다.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements["microphone-id"].value = microphone.id;
|
||||||
|
setNumber(elements["microphone-x"], microphone.position[0]);
|
||||||
|
setNumber(elements["microphone-y"], microphone.position[1]);
|
||||||
|
setNumber(elements["microphone-z"], microphone.position[2]);
|
||||||
|
if (elements["microphone-sensitivity"]) {
|
||||||
|
setNumber(elements["microphone-sensitivity"], microphone.sensitivityDb);
|
||||||
|
}
|
||||||
|
if (elements["microphone-phase"]) {
|
||||||
|
setNumber(elements["microphone-phase"], microphone.phaseOffsetDeg);
|
||||||
|
}
|
||||||
|
if (elements["frequency-response-summary"]) {
|
||||||
|
const count = microphone.frequencyResponse?.length ?? 0;
|
||||||
|
elements["frequency-response-summary"].textContent = count > 0
|
||||||
|
? String(count) + "개 주파수 응답점 보존 중"
|
||||||
|
: "주파수 응답점 없음";
|
||||||
|
}
|
||||||
|
const index = state.arrayDefinition.microphones.indexOf(microphone);
|
||||||
|
if (elements["move-microphone-up-button"]) {
|
||||||
|
elements["move-microphone-up-button"].disabled = index <= 0;
|
||||||
|
}
|
||||||
|
if (elements["move-microphone-down-button"]) {
|
||||||
|
elements["move-microphone-down-button"].disabled =
|
||||||
|
index < 0 || index >= state.arrayDefinition.microphones.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPresetControls() {
|
||||||
|
const type = elements["preset-type"].value;
|
||||||
|
const grid = type === "grid";
|
||||||
|
const random = type === "random";
|
||||||
|
const linear = type === "linear";
|
||||||
|
for (const element of [elements["preset-columns"], elements["preset-rows"]]) {
|
||||||
|
element.closest(".field")?.toggleAttribute("hidden", !grid);
|
||||||
|
}
|
||||||
|
elements["preset-count"].closest(".field")?.toggleAttribute("hidden", grid);
|
||||||
|
elements["preset-seed"].closest(".field")?.toggleAttribute("hidden", !random);
|
||||||
|
elements["preset-minimum-spacing"].closest(".field")?.toggleAttribute("hidden", !random);
|
||||||
|
elements["preset-axis"]?.closest(".field")?.toggleAttribute("hidden", !linear);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHistoryButtons() {
|
||||||
|
if (elements["undo-button"]) {
|
||||||
|
elements["undo-button"].disabled = state.history.length === 0;
|
||||||
|
elements["undo-button"].title = state.history.length
|
||||||
|
? "실행 취소: " + state.history.at(-1).label
|
||||||
|
: "실행 취소할 작업이 없습니다.";
|
||||||
|
}
|
||||||
|
if (elements["redo-button"]) {
|
||||||
|
elements["redo-button"].disabled = state.future.length === 0;
|
||||||
|
elements["redo-button"].title = state.future.length
|
||||||
|
? "다시 실행: " + state.future.at(-1).label
|
||||||
|
: "다시 실행할 작업이 없습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateCurrent() {
|
||||||
|
const revision = ++state.validationRevision;
|
||||||
|
const structuralIssues = await validateArrayDefinition(state.arrayDefinition);
|
||||||
|
if (revision !== state.validationRevision) return;
|
||||||
|
const geometryIssues = arrayGeometryIssues(state.arrayDefinition, {
|
||||||
|
shape: state.apertureShape,
|
||||||
|
});
|
||||||
|
const errors = [
|
||||||
|
...structuralIssues.map((issue) => ({ ...issue, severity: "error" })),
|
||||||
|
...geometryIssues.filter((issue) => issue.severity === "error"),
|
||||||
|
];
|
||||||
|
const warnings = geometryIssues.filter((issue) => issue.severity === "warning");
|
||||||
|
if (BUILTIN_ARRAY_IDS.has(state.arrayDefinition.id)) {
|
||||||
|
warnings.push({
|
||||||
|
severity: "warning",
|
||||||
|
path: "$.id",
|
||||||
|
message: "내장 배열과 같은 ID입니다. 사용자 배열을 구분하려면 다른 ID를 권장합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.valid = errors.length === 0;
|
||||||
|
document.documentElement.dataset.arrayValid = String(state.valid);
|
||||||
|
elements["export-button"].disabled = !state.valid;
|
||||||
|
elements.validationCard?.setAttribute(
|
||||||
|
"data-severity",
|
||||||
|
errors.length ? "error" : warnings.length ? "warning" : "success",
|
||||||
|
);
|
||||||
|
elements.validationCard?.classList.toggle("is-invalid", errors.length > 0);
|
||||||
|
elements.validationCard?.classList.toggle("is-warning", errors.length === 0 && warnings.length > 0);
|
||||||
|
elements.validationCard?.classList.toggle("is-valid", errors.length === 0 && warnings.length === 0);
|
||||||
|
if (elements["validation-dot"]) {
|
||||||
|
elements["validation-dot"].dataset.severity =
|
||||||
|
errors.length ? "error" : warnings.length ? "warning" : "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
elements["validation-title"].textContent = String(errors.length) + "개 오류 · 저장 불가";
|
||||||
|
elements["validation-message"].textContent = errors
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((issue) => issue.path + ": " + issue.message)
|
||||||
|
.join(" · ");
|
||||||
|
} else if (warnings.length) {
|
||||||
|
elements["validation-title"].textContent = "저장 가능 · " + String(warnings.length) + "개 확인";
|
||||||
|
elements["validation-message"].textContent = warnings
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((issue) => issue.message)
|
||||||
|
.join(" · ");
|
||||||
|
} else {
|
||||||
|
elements["validation-title"].textContent = "Array JSON 저장 가능";
|
||||||
|
elements["validation-message"].textContent =
|
||||||
|
String(state.arrayDefinition.microphones.length) +
|
||||||
|
"개 채널과 표준 좌표 계약을 만족합니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleValidation() {
|
||||||
|
const requestedRevision = ++state.validationRevision;
|
||||||
|
queueMicrotask(async () => {
|
||||||
|
if (requestedRevision !== state.validationRevision) return;
|
||||||
|
state.validationRevision -= 1;
|
||||||
|
await validateCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll(message) {
|
||||||
|
renderDocumentEditor();
|
||||||
|
renderStatistics();
|
||||||
|
renderMicrophoneList();
|
||||||
|
syncSelectedEditor();
|
||||||
|
syncPresetControls();
|
||||||
|
syncHistoryButtons();
|
||||||
|
setTool(state.tool);
|
||||||
|
canvasView.render(canvasState());
|
||||||
|
announce(message);
|
||||||
|
scheduleValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedMicrophone() {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
safely(() => mutate("마이크 삭제됨", () => {
|
||||||
|
const index = state.arrayDefinition.microphones.indexOf(microphone);
|
||||||
|
if (!removeMicrophone(state.arrayDefinition, microphone.id)) {
|
||||||
|
throw new Error("Array에는 최소 한 개의 마이크가 필요합니다.");
|
||||||
|
}
|
||||||
|
state.selectedMicrophoneId =
|
||||||
|
state.arrayDefinition.microphones[Math.min(index, state.arrayDefinition.microphones.length - 1)]?.id ??
|
||||||
|
null;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelectedPosition() {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
const position = [
|
||||||
|
finiteInput(elements["microphone-x"], "x 좌표"),
|
||||||
|
finiteInput(elements["microphone-y"], "y 좌표"),
|
||||||
|
finiteInput(elements["microphone-z"], "z 좌표"),
|
||||||
|
];
|
||||||
|
mutate("마이크 좌표 변경됨", () => {
|
||||||
|
updateMicrophone(state.arrayDefinition, microphone.id, { position });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetOptions() {
|
||||||
|
return {
|
||||||
|
type: elements["preset-type"].value,
|
||||||
|
aperture: state.arrayDefinition.aperture,
|
||||||
|
shape: state.apertureShape,
|
||||||
|
count: integerInput(elements["preset-count"], "마이크 수", 1, 1024),
|
||||||
|
columns: integerInput(elements["preset-columns"], "Columns", 1, 1024),
|
||||||
|
rows: integerInput(elements["preset-rows"], "Rows", 1, 1024),
|
||||||
|
seed: integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295),
|
||||||
|
minimumSpacingM: finiteInput(
|
||||||
|
elements["preset-minimum-spacing"],
|
||||||
|
"최소 간격",
|
||||||
|
{ minimum: 0 },
|
||||||
|
),
|
||||||
|
edgeInsetM: elements["preset-edge-inset"]
|
||||||
|
? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 })
|
||||||
|
: 0,
|
||||||
|
axis: elements["preset-axis"]?.value ?? "x",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
elements["array-id"].addEventListener("change", () => safely(() => {
|
||||||
|
const value = elements["array-id"].value.trim();
|
||||||
|
mutate("Array ID 변경됨", () => {
|
||||||
|
state.arrayDefinition.id = value;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
elements["array-name"].addEventListener("change", () => safely(() => {
|
||||||
|
mutate("배열 이름 변경됨", () => {
|
||||||
|
state.arrayDefinition.name = elements["array-name"].value.trim();
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
elements["array-description"].addEventListener("change", () => safely(() => {
|
||||||
|
mutate("설명 변경됨", () => {
|
||||||
|
const value = elements["array-description"].value.trim();
|
||||||
|
if (value) state.arrayDefinition.description = value;
|
||||||
|
else delete state.arrayDefinition.description;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const [id, property] of [
|
||||||
|
["aperture-width", "width"],
|
||||||
|
["aperture-height", "height"],
|
||||||
|
]) {
|
||||||
|
elements[id].addEventListener("change", () => safely(() => {
|
||||||
|
const value = finiteInput(elements[id], "Aperture " + property, {
|
||||||
|
exclusiveMinimum: 0,
|
||||||
|
maximum: 20,
|
||||||
|
});
|
||||||
|
mutate("Aperture 크기 변경됨", () => {
|
||||||
|
state.arrayDefinition.aperture[property] = value;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
elements["aperture-shape"].addEventListener("change", () => safely(() => {
|
||||||
|
mutate("생성 영역 형상 변경됨", () => {
|
||||||
|
state.apertureShape = elements["aperture-shape"].value === "ellipse"
|
||||||
|
? "ellipse"
|
||||||
|
: "rectangle";
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
elements["preset-type"].addEventListener("change", syncPresetControls);
|
||||||
|
elements["generate-preset-button"].addEventListener("click", () => safely(() => {
|
||||||
|
const options = presetOptions();
|
||||||
|
const calibrationCount = arrayStatistics(state.arrayDefinition).calibrationCount;
|
||||||
|
mutate("Preset 배열 생성됨", () => {
|
||||||
|
replaceMicrophonesWithPreset(state.arrayDefinition, options);
|
||||||
|
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
||||||
|
});
|
||||||
|
announce(
|
||||||
|
calibrationCount > 0
|
||||||
|
? "Preset 생성됨 · 기존 calibration은 Undo로 복구할 수 있습니다."
|
||||||
|
: "Preset 배열 생성됨",
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
elements["select-tool-button"].addEventListener("click", () => setTool("select", { focus: true }));
|
||||||
|
elements["add-tool-button"].addEventListener("click", () => setTool("add", { 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));
|
||||||
|
elements["snap-enabled"].addEventListener("change", () => {
|
||||||
|
state.snapEnabled = elements["snap-enabled"].checked;
|
||||||
|
canvasView.render(canvasState());
|
||||||
|
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
|
||||||
|
});
|
||||||
|
elements["snap-step"].addEventListener("change", () => safely(() => {
|
||||||
|
const millimeters = finiteInput(elements["snap-step"], "Snap 간격", {
|
||||||
|
exclusiveMinimum: 0,
|
||||||
|
maximum: 10000,
|
||||||
|
});
|
||||||
|
state.snapStepM = millimeters / 1000;
|
||||||
|
canvasView.render(canvasState());
|
||||||
|
announce("Snap " + String(millimeters) + " mm");
|
||||||
|
}));
|
||||||
|
|
||||||
|
elements["add-microphone-button"].addEventListener("click", () => safely(() => {
|
||||||
|
mutate("마이크 추가됨", () => {
|
||||||
|
const microphone = addMicrophone(state.arrayDefinition, availableManualPosition());
|
||||||
|
state.selectedMicrophoneId = microphone.id;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
elements["remove-microphone-button"].addEventListener("click", removeSelectedMicrophone);
|
||||||
|
elements["microphone-id"].addEventListener("change", () => safely(() => {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
const previousId = microphone.id;
|
||||||
|
const nextId = elements["microphone-id"].value.trim();
|
||||||
|
mutate("마이크 ID 변경됨", () => {
|
||||||
|
updateMicrophone(state.arrayDefinition, previousId, { id: nextId });
|
||||||
|
state.selectedMicrophoneId = nextId;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
for (const id of ["microphone-x", "microphone-y", "microphone-z"]) {
|
||||||
|
elements[id].addEventListener("change", () => safely(applySelectedPosition));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements["microphone-sensitivity"]) {
|
||||||
|
elements["microphone-sensitivity"].addEventListener("change", () => safely(() => {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
const value = elements["microphone-sensitivity"].value.trim() === ""
|
||||||
|
? null
|
||||||
|
: finiteInput(elements["microphone-sensitivity"], "Sensitivity", {
|
||||||
|
minimum: -120,
|
||||||
|
maximum: 120,
|
||||||
|
});
|
||||||
|
mutate("Sensitivity 변경됨", () => {
|
||||||
|
updateMicrophone(state.arrayDefinition, microphone.id, { sensitivityDb: value });
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (elements["microphone-phase"]) {
|
||||||
|
elements["microphone-phase"].addEventListener("change", () => safely(() => {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
const value = elements["microphone-phase"].value.trim() === ""
|
||||||
|
? null
|
||||||
|
: finiteInput(elements["microphone-phase"], "Phase offset", {
|
||||||
|
minimum: -180,
|
||||||
|
maximum: 180,
|
||||||
|
});
|
||||||
|
mutate("Phase offset 변경됨", () => {
|
||||||
|
updateMicrophone(state.arrayDefinition, microphone.id, { phaseOffsetDeg: value });
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
elements["move-microphone-up-button"]?.addEventListener("click", () => safely(() => {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, -1));
|
||||||
|
}));
|
||||||
|
elements["move-microphone-down-button"]?.addEventListener("click", () => safely(() => {
|
||||||
|
const microphone = selectedMicrophone();
|
||||||
|
if (!microphone) return;
|
||||||
|
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1));
|
||||||
|
}));
|
||||||
|
elements["microphone-search"]?.addEventListener("input", renderMicrophoneList);
|
||||||
|
|
||||||
|
elements["center-microphones-button"]?.addEventListener("click", () => safely(() => {
|
||||||
|
mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition));
|
||||||
|
}));
|
||||||
|
elements["fit-aperture-button"]?.addEventListener("click", () => safely(() => {
|
||||||
|
mutate("Aperture를 마이크 범위에 맞춤", () => fitApertureToMicrophones(
|
||||||
|
state.arrayDefinition,
|
||||||
|
{ shape: state.apertureShape, paddingM: 0.005 },
|
||||||
|
));
|
||||||
|
canvasView.fit();
|
||||||
|
}));
|
||||||
|
elements["clamp-microphones-button"]?.addEventListener("click", () => safely(() => {
|
||||||
|
mutate("마이크를 생성 경계 안으로 제한", () => {
|
||||||
|
for (const microphone of state.arrayDefinition.microphones) {
|
||||||
|
microphone.position = clampPositionToAperture(
|
||||||
|
microphone.position,
|
||||||
|
state.arrayDefinition.aperture,
|
||||||
|
state.apertureShape,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
const entry = state.history.pop();
|
||||||
|
if (!entry) return;
|
||||||
|
state.future.push({ snapshot: captureSnapshot(), label: entry.label });
|
||||||
|
restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
const entry = state.future.pop();
|
||||||
|
if (!entry) return;
|
||||||
|
state.history.push({ snapshot: captureSnapshot(), label: entry.label });
|
||||||
|
restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
elements["undo-button"]?.addEventListener("click", undo);
|
||||||
|
elements["redo-button"]?.addEventListener("click", redo);
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
target?.isContentEditable
|
||||||
|
) return;
|
||||||
|
if (event.key.toLowerCase() !== "z") return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.shiftKey) redo();
|
||||||
|
else undo();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements["array-input"].addEventListener("change", async () => {
|
||||||
|
const file = elements["array-input"].files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const value = await readJsonFile(file);
|
||||||
|
const issues = await validateArrayDefinition(value);
|
||||||
|
if (issues.length) {
|
||||||
|
const details = issues.slice(0, 4).map(
|
||||||
|
(issue) => issue.path + ": " + issue.message,
|
||||||
|
).join(" · ");
|
||||||
|
throw new Error("Array JSON 계약을 만족하지 않습니다. " + details);
|
||||||
|
}
|
||||||
|
state.arrayDefinition = prepareArrayForEditing(value);
|
||||||
|
state.apertureShape = inferApertureShape(value);
|
||||||
|
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
||||||
|
state.history.length = 0;
|
||||||
|
state.future.length = 0;
|
||||||
|
state.dirty = false;
|
||||||
|
canvasView.fit();
|
||||||
|
renderAll(file.name + " 불러옴");
|
||||||
|
} catch (error) {
|
||||||
|
showActionError(error);
|
||||||
|
} finally {
|
||||||
|
elements["array-input"].value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements["export-button"].addEventListener("click", async () => {
|
||||||
|
await validateCurrent();
|
||||||
|
if (!state.valid) {
|
||||||
|
showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadJson(
|
||||||
|
state.arrayDefinition,
|
||||||
|
safeArrayFileStem(state.arrayDefinition) + ".array.json",
|
||||||
|
);
|
||||||
|
state.dirty = false;
|
||||||
|
announce("Array JSON 저장됨");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", (event) => {
|
||||||
|
if (!state.dirty) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
});
|
||||||
|
window.addEventListener("unload", () => {
|
||||||
|
splitPane.destroy();
|
||||||
|
canvasView.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements["snap-enabled"].checked = state.snapEnabled;
|
||||||
|
setNumber(elements["snap-step"], state.snapStepM * 1000);
|
||||||
|
renderAll("기본 4×3 배열");
|
||||||
|
canvasView.fit();
|
||||||
|
document.documentElement.dataset.appReady = "true";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
|||||||
|
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
label + ": expected " + expected + ", received " + actual,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(predicate, label, timeoutMs = 20000) {
|
||||||
|
const deadline = performance.now() + timeoutMs;
|
||||||
|
while (performance.now() < deadline) {
|
||||||
|
const value = predicate();
|
||||||
|
if (value) return value;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
}
|
||||||
|
throw new Error(label + " 대기 시간이 초과되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextPaint(windowObject) {
|
||||||
|
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
|
||||||
|
windowObject.requestAnimationFrame(resolve)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchValue(windowObject, element, value, eventName = "change") {
|
||||||
|
element.value = String(value);
|
||||||
|
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFiles(windowObject, input, files) {
|
||||||
|
const transfer = new windowObject.DataTransfer();
|
||||||
|
for (const file of files) transfer.items.add(file);
|
||||||
|
input.files = transfer.files;
|
||||||
|
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function microphoneButtons(documentObject) {
|
||||||
|
return [...documentObject.querySelectorAll("#microphone-list [data-microphone-id]")];
|
||||||
|
}
|
||||||
|
|
||||||
|
function microphoneCount(documentObject) {
|
||||||
|
return microphoneButtons(documentObject).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCoordinates(documentObject) {
|
||||||
|
return ["microphone-x", "microphone-y", "microphone-z"].map(
|
||||||
|
(id) => Number(documentObject.getElementById(id).value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 완주한다", async () => {
|
||||||
|
const frame = document.createElement("iframe");
|
||||||
|
frame.src = "/apps/array-builder/?integration-test=1";
|
||||||
|
frame.style.cssText =
|
||||||
|
"position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||||||
|
document.body.append(frame);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
frame.addEventListener("load", resolve, { once: true });
|
||||||
|
frame.addEventListener(
|
||||||
|
"error",
|
||||||
|
() => reject(new Error("Array Builder iframe load failed")),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const windowObject = frame.contentWindow;
|
||||||
|
const documentObject = frame.contentDocument;
|
||||||
|
const downloads = [];
|
||||||
|
const originalCreate = windowObject.URL.createObjectURL.bind(windowObject.URL);
|
||||||
|
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
||||||
|
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
||||||
|
windowObject.URL.createObjectURL = (blob) => {
|
||||||
|
const url = "blob:array-builder-test-" + String(downloads.length + 1);
|
||||||
|
downloads.push({ blob, url, fileName: null });
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
windowObject.URL.revokeObjectURL = () => {};
|
||||||
|
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
||||||
|
const download = downloads.find((item) => item.url === this.href);
|
||||||
|
if (download) download.fileName = this.download;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.appReady === "true",
|
||||||
|
"Array Builder 초기화",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.arrayValid === "true",
|
||||||
|
"기본 Array 검증",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.querySelector("#array-canvas").width > 0,
|
||||||
|
"Array canvas 렌더",
|
||||||
|
);
|
||||||
|
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
|
||||||
|
"mic-001",
|
||||||
|
"physical channel order starts at mic-001",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
|
||||||
|
"canvas dominates the desktop workspace",
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2);
|
||||||
|
documentObject.querySelector("#generate-preset-button").click();
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
|
||||||
|
assertEqual(
|
||||||
|
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
|
||||||
|
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
|
||||||
|
"grid keeps row-major physical channel IDs",
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse");
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring");
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 8);
|
||||||
|
documentObject.querySelector("#generate-preset-button").click();
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 8, "ellipse ring generation");
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.arrayValid === "true",
|
||||||
|
"ellipse ring validation",
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "random");
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 12);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-seed"), 4242);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-minimum-spacing"), 0.01);
|
||||||
|
documentObject.querySelector("#generate-preset-button").click();
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 12, "seed random generation");
|
||||||
|
const firstRandom = documentObject.querySelector("#microphone-list").textContent;
|
||||||
|
documentObject.querySelector("#generate-preset-button").click();
|
||||||
|
const repeatedRandom = documentObject.querySelector("#microphone-list").textContent;
|
||||||
|
assertEqual(repeatedRandom, firstRandom, "same seed repeats the visible layout");
|
||||||
|
|
||||||
|
documentObject.querySelector("#undo-button").click();
|
||||||
|
assertEqual(microphoneCount(documentObject), 8, "preset replacement is undoable");
|
||||||
|
documentObject.querySelector("#redo-button").click();
|
||||||
|
assertEqual(microphoneCount(documentObject), 12, "preset replacement is redoable");
|
||||||
|
|
||||||
|
documentObject.querySelector("#add-microphone-button").click();
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 13, "manual microphone add");
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#microphone-id").value,
|
||||||
|
"mic-013",
|
||||||
|
"manual add selects a stable next ID",
|
||||||
|
);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-id"), "reference-channel");
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), 0.01);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), -0.015);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), 0.002);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-sensitivity"), -2.5);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-phase"), 3.5);
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#microphone-id").value,
|
||||||
|
"reference-channel",
|
||||||
|
"manual ID edit",
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforeNudge = selectedCoordinates(documentObject);
|
||||||
|
const canvas = documentObject.querySelector("#array-canvas");
|
||||||
|
canvas.focus();
|
||||||
|
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
key: "ArrowRight",
|
||||||
|
}));
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
const afterNudge = selectedCoordinates(documentObject);
|
||||||
|
assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step");
|
||||||
|
assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y");
|
||||||
|
|
||||||
|
documentObject.querySelector("#add-tool-button").click();
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: 31,
|
||||||
|
pointerType: "mouse",
|
||||||
|
isPrimary: true,
|
||||||
|
button: 0,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: canvasRect.left + canvasRect.width * 0.7,
|
||||||
|
clientY: canvasRect.top + canvasRect.height * 0.35,
|
||||||
|
}));
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 14, "canvas microphone add");
|
||||||
|
documentObject.querySelector("#select-tool-button").click();
|
||||||
|
documentObject.querySelector("#remove-microphone-button").click();
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 13, "manual delete");
|
||||||
|
|
||||||
|
const firstButton = microphoneButtons(documentObject)[0];
|
||||||
|
const secondButton = microphoneButtons(documentObject)[1];
|
||||||
|
firstButton.click();
|
||||||
|
const firstPosition = selectedCoordinates(documentObject);
|
||||||
|
secondButton.click();
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), firstPosition[0]);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), firstPosition[1]);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), firstPosition[2]);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.arrayValid === "false",
|
||||||
|
"duplicate position rejection",
|
||||||
|
);
|
||||||
|
assert(documentObject.querySelector("#export-button").disabled, "invalid duplicate disables export");
|
||||||
|
documentObject.querySelector("#undo-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.arrayValid === "true",
|
||||||
|
"duplicate undo recovery",
|
||||||
|
);
|
||||||
|
|
||||||
|
documentObject.querySelector("#export-button").click();
|
||||||
|
await waitFor(() => downloads.some((item) => item.fileName), "Array JSON export");
|
||||||
|
const exportedDownload = downloads.find((item) => item.fileName);
|
||||||
|
assert(
|
||||||
|
exportedDownload.fileName.endsWith(".array.json"),
|
||||||
|
"export uses .array.json filename",
|
||||||
|
);
|
||||||
|
const exported = JSON.parse(await exportedDownload.blob.text());
|
||||||
|
assertEqual(exported.kind, "micarrsim.array", "standard array kind");
|
||||||
|
assertEqual((await validateArrayDefinition(exported)).length, 0, "exported Array contract");
|
||||||
|
assert(!Object.hasOwn(exported, "shape"), "editor shape is not exported");
|
||||||
|
assert(!Object.hasOwn(exported, "preset"), "editor preset is not exported");
|
||||||
|
assert(!Object.hasOwn(exported, "seed"), "editor seed is not exported");
|
||||||
|
const calibrated = exported.microphones.find(
|
||||||
|
(microphone) => microphone.id === "reference-channel",
|
||||||
|
);
|
||||||
|
assert(calibrated, "edited channel is exported");
|
||||||
|
assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved");
|
||||||
|
assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved");
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name");
|
||||||
|
const exportedFile = new windowObject.File(
|
||||||
|
[JSON.stringify(exported)],
|
||||||
|
"round-trip.array.json",
|
||||||
|
{ type: "application/json" },
|
||||||
|
);
|
||||||
|
dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [exportedFile]);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.querySelector("#array-name").value === exported.name,
|
||||||
|
"Array round trip import",
|
||||||
|
);
|
||||||
|
microphoneButtons(documentObject).find(
|
||||||
|
(button) => button.dataset.microphoneId === "reference-channel",
|
||||||
|
).click();
|
||||||
|
assertEqual(
|
||||||
|
Number(documentObject.querySelector("#microphone-sensitivity").value),
|
||||||
|
-2.5,
|
||||||
|
"import keeps sensitivity",
|
||||||
|
);
|
||||||
|
|
||||||
|
const countBeforeInvalidImport = microphoneCount(documentObject);
|
||||||
|
const invalidFile = new windowObject.File(
|
||||||
|
[JSON.stringify({ kind: "not-an-array" })],
|
||||||
|
"invalid.json",
|
||||||
|
{ type: "application/json" },
|
||||||
|
);
|
||||||
|
dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [invalidFile]);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.querySelector("#validation-title").textContent.includes("적용하지 못"),
|
||||||
|
"invalid import feedback",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
microphoneCount(documentObject),
|
||||||
|
countBeforeInvalidImport,
|
||||||
|
"invalid import preserves current array",
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.style.width = "390px";
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
|
||||||
|
"compact Array Builder layout",
|
||||||
|
);
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
assert(
|
||||||
|
documentObject.documentElement.scrollWidth <=
|
||||||
|
documentObject.documentElement.clientWidth + 1,
|
||||||
|
"Array Builder has no horizontal overflow at 390px",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||||
|
"Array canvas remains usable at 390px",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
windowObject.URL.createObjectURL = originalCreate;
|
||||||
|
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||||
|
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||||
|
frame.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -20,9 +20,11 @@ try {
|
|||||||
await import("/tests/unit/webgpu.test.js");
|
await import("/tests/unit/webgpu.test.js");
|
||||||
await import("/tests/unit/split-pane.test.js");
|
await import("/tests/unit/split-pane.test.js");
|
||||||
await import("/tests/unit/array-builder.test.js");
|
await import("/tests/unit/array-builder.test.js");
|
||||||
|
await import("/tests/unit/array-builder-canvas.test.js");
|
||||||
await import("/tests/integration/cpu-baseline.test.js");
|
await import("/tests/integration/cpu-baseline.test.js");
|
||||||
await import("/tests/integration/webgpu.test.js");
|
await import("/tests/integration/webgpu.test.js");
|
||||||
await import("/tests/integration/server.test.js");
|
await import("/tests/integration/server.test.js");
|
||||||
|
await import("/tests/integration/array-builder-ui.test.js");
|
||||||
await import("/tests/integration/split-layout-ui.test.js");
|
await import("/tests/integration/split-layout-ui.test.js");
|
||||||
await import("/tests/integration/builder-ui.test.js");
|
await import("/tests/integration/builder-ui.test.js");
|
||||||
await import("/tests/integration/camera-ui.test.js");
|
await import("/tests/integration/camera-ui.test.js");
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
arrayPositionToCanvas,
|
||||||
|
canvasPointToArray,
|
||||||
|
eventToArrayPosition,
|
||||||
|
eventToCanvasPoint,
|
||||||
|
fitArrayViewport,
|
||||||
|
hitTestMicrophone,
|
||||||
|
keyboardNudgeForKey,
|
||||||
|
niceGridStep,
|
||||||
|
} from "/apps/array-builder/canvas-view.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
return {
|
||||||
|
aperture: { width: 0.4, height: 0.2 },
|
||||||
|
microphones: [
|
||||||
|
{ id: "mic-001", position: [-0.1, -0.05, 0] },
|
||||||
|
{ id: "mic-002", position: [0.1, 0.05, 0.01] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Array canvas fit은 x/y에 동일 축척을 사용하고 좌표를 왕복한다", () => {
|
||||||
|
const viewport = fitArrayViewport(800, 420, fixture());
|
||||||
|
const point = [0.075, -0.025, 0.012];
|
||||||
|
const pixel = arrayPositionToCanvas(point, viewport);
|
||||||
|
const roundTrip = canvasPointToArray(pixel, viewport, { z: point[2] });
|
||||||
|
assertNear(roundTrip[0], point[0], 1e-12, "x round trip");
|
||||||
|
assertNear(roundTrip[1], point[1], 1e-12, "y round trip");
|
||||||
|
assertNear(roundTrip[2], point[2], 1e-12, "z preserved");
|
||||||
|
const oneCentimeterX = arrayPositionToCanvas([0.01, 0, 0], viewport).x -
|
||||||
|
arrayPositionToCanvas([0, 0, 0], viewport).x;
|
||||||
|
const oneCentimeterY = arrayPositionToCanvas([0, 0.01, 0], viewport).y -
|
||||||
|
arrayPositionToCanvas([0, 0, 0], viewport).y;
|
||||||
|
assertNear(Math.abs(oneCentimeterX), Math.abs(oneCentimeterY), 1e-12, "equal x/y scale");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Array canvas 좌표는 화면 위쪽을 양의 y로 해석한다", () => {
|
||||||
|
const viewport = fitArrayViewport(500, 500, fixture());
|
||||||
|
const upper = canvasPointToArray(
|
||||||
|
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y },
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
const lower = canvasPointToArray(
|
||||||
|
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y + viewport.plot.height },
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
assert(upper[1] > lower[1], "canvas top maps to larger array y");
|
||||||
|
assertNear(upper[1], viewport.yMax, 1e-12, "top is yMax");
|
||||||
|
assertNear(lower[1], viewport.yMin, 1e-12, "bottom is yMin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PointerEvent-like 좌표 변환은 DOM 없이 canvas와 plot 경계에 clamp할 수 있다", () => {
|
||||||
|
const viewport = fitArrayViewport(640, 360, fixture());
|
||||||
|
const rect = { left: 100, top: 50, width: 640, height: 360 };
|
||||||
|
const canvasPoint = eventToCanvasPoint({ clientX: 50, clientY: 500 }, rect, { clamp: true });
|
||||||
|
assertEqual(canvasPoint.x, 0, "client x clamps to canvas left");
|
||||||
|
assertEqual(canvasPoint.y, 360, "client y clamps to canvas bottom");
|
||||||
|
const position = eventToArrayPosition(
|
||||||
|
{ clientX: -500, clientY: 900 },
|
||||||
|
rect,
|
||||||
|
viewport,
|
||||||
|
{ clamp: true, clampToCanvas: true, z: 0.02 },
|
||||||
|
);
|
||||||
|
assertNear(position[0], viewport.xMin, 1e-12, "event x clamps to plot xMin");
|
||||||
|
assertNear(position[1], viewport.yMin, 1e-12, "event y clamps to plot yMin");
|
||||||
|
assertEqual(position[2], 0.02, "event conversion carries z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채널 index를 돌려준다", () => {
|
||||||
|
const arrayDefinition = fixture();
|
||||||
|
const viewport = fitArrayViewport(800, 420, arrayDefinition);
|
||||||
|
const micPixel = arrayPositionToCanvas(arrayDefinition.microphones[1].position, viewport);
|
||||||
|
const hit = hitTestMicrophone(
|
||||||
|
{ x: micPixel.x + 3, y: micPixel.y - 2 },
|
||||||
|
arrayDefinition.microphones,
|
||||||
|
viewport,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
assertEqual(hit.id, "mic-002", "closest microphone id");
|
||||||
|
assertEqual(hit.index, 1, "physical channel index");
|
||||||
|
assertNear(hit.distancePx, Math.hypot(3, 2), 1e-12, "pixel distance");
|
||||||
|
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
|
||||||
|
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
|
||||||
|
for (const step of candidates) {
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(step));
|
||||||
|
const normalized = Math.round(step / magnitude);
|
||||||
|
assert([1, 2, 5, 10].includes(normalized), "grid step uses 1/2/5 decade");
|
||||||
|
}
|
||||||
|
assertEqual(keyboardNudgeForKey("ArrowLeft", 0.002).dx, -0.002, "left nudge");
|
||||||
|
assertEqual(keyboardNudgeForKey("ArrowUp", 0.002).dy, 0.002, "up is positive y");
|
||||||
|
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
|
||||||
|
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user