feat: add interactive microphone array builder

This commit is contained in:
2026-08-28 22:18:18 +09:00
parent acf68f4931
commit 0f9b4f8942
7 changed files with 3298 additions and 0 deletions
+707
View File
@@ -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;
},
};
}
+283
View File
@@ -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>
+876
View File
@@ -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
+290
View File
@@ -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();
}
});
+2
View File
@@ -20,9 +20,11 @@ try {
await import("/tests/unit/webgpu.test.js");
await import("/tests/unit/split-pane.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/webgpu.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/builder-ui.test.js");
await import("/tests/integration/camera-ui.test.js");
+100
View File
@@ -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");
});