diff --git a/apps/array-builder/canvas-view.js b/apps/array-builder/canvas-view.js
new file mode 100644
index 0000000..b72d361
--- /dev/null
+++ b/apps/array-builder/canvas-view.js
@@ -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;
+ },
+ };
+}
diff --git a/apps/array-builder/index.html b/apps/array-builder/index.html
new file mode 100644
index 0000000..6edd8f5
--- /dev/null
+++ b/apps/array-builder/index.html
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+ Array Builder · micarrsim
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0.45 × 0.45 m · 12 channels
+
+
+
+
+
+
+
+ 마이크를 클릭하거나 드래그해 위치를 편집하세요.
+ 추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.
+
+
선택된 마이크 없음
+
+
+
+ 채널12microphones
+ 최소 간격—nearest pair
+ 무게중심—x, y, z
+ 보정 채널0sensitivity / phase / response
+
+
+
+ 빠른 편집 키
+
+
V 선택 · A 추가 · Delete 선택 삭제
+
← ↑ ↓ → 위치 미세 조정 · Shift를 함께 누르면 10배 이동
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/array-builder/main.js b/apps/array-builder/main.js
new file mode 100644
index 0000000..b6e221f
--- /dev/null
+++ b/apps/array-builder/main.js
@@ -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";
diff --git a/apps/array-builder/styles.css b/apps/array-builder/styles.css
new file mode 100644
index 0000000..1db6633
--- /dev/null
+++ b/apps/array-builder/styles.css
@@ -0,0 +1,1040 @@
+.array-builder-shell {
+ min-width: 320px;
+ overflow-x: clip;
+}
+
+.array-builder-header {
+ position: sticky;
+ z-index: 30;
+ top: 0;
+ min-height: 59px;
+ padding-block: 9px;
+ gap: 16px;
+ background: rgb(8 17 15 / 94%);
+}
+
+.header-actions,
+.runtime-line,
+.section-title,
+.section-title > div,
+.toolbar-group,
+.snap-controls {
+ display: flex;
+ min-width: 0;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.header-actions {
+ justify-content: flex-end;
+}
+
+.button,
+.tool-button,
+.icon-button,
+.mini-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ min-height: 36px;
+ padding: 8px 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ color: var(--text);
+ background: var(--surface-raised);
+ cursor: pointer;
+ font-size: 0.76rem;
+ font-weight: 720;
+ text-decoration: none;
+ transition:
+ border-color 140ms ease,
+ background 140ms ease,
+ transform 140ms ease;
+}
+
+.button:hover:not(:disabled),
+.button:focus-visible,
+.tool-button:hover:not(:disabled),
+.tool-button:focus-visible,
+.icon-button:hover:not(:disabled),
+.icon-button:focus-visible,
+.mini-button:hover:not(:disabled),
+.mini-button:focus-visible {
+ border-color: var(--mint);
+ outline: none;
+}
+
+.button:hover:not(:disabled),
+.tool-button:hover:not(:disabled),
+.icon-button:hover:not(:disabled),
+.mini-button:hover:not(:disabled) {
+ transform: translateY(-1px);
+}
+
+.button:disabled,
+.tool-button:disabled,
+.icon-button:disabled,
+.mini-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.42;
+ transform: none;
+}
+
+.button--primary {
+ border-color: rgb(101 242 191 / 55%);
+ color: #06130f;
+ background: var(--mint);
+}
+
+.button--ghost {
+ background: rgb(255 255 255 / 3%);
+}
+
+.button--danger {
+ border-color: rgb(255 123 115 / 36%);
+ color: var(--danger);
+ background: rgb(255 123 115 / 7%);
+}
+
+.button--full {
+ width: 100%;
+ margin-top: 12px;
+}
+
+.file-button {
+ position: relative;
+}
+
+.file-button input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+}
+
+.array-workflow {
+ min-width: 0;
+ margin-left: auto;
+}
+
+.array-workflow ol {
+ display: flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.array-workflow li {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 0.67rem;
+ font-weight: 680;
+ white-space: nowrap;
+}
+
+.array-workflow li + li::before {
+ width: 16px;
+ height: 1px;
+ margin: 0 6px;
+ background: var(--line);
+ content: "";
+}
+
+.array-workflow li span {
+ display: grid;
+ width: 20px;
+ height: 20px;
+ border: 1px solid rgb(101 242 191 / 34%);
+ border-radius: 50%;
+ place-items: center;
+ color: var(--mint);
+ font: 750 0.6rem ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.array-builder-layout {
+ --control-panel-width: 420px;
+
+ display: grid;
+ grid-template-columns: var(--control-panel-width) 12px minmax(0, 1fr);
+ width: min(1880px, 100%);
+ min-height: calc(100vh - 59px);
+ margin: 0 auto;
+}
+
+.array-controls {
+ position: sticky;
+ top: 59px;
+ min-width: 0;
+ align-self: start;
+ overflow-y: auto;
+ max-height: calc(100vh - 59px);
+ padding: 14px;
+ border-right: 1px solid var(--line);
+ background: rgb(12 24 21 / 88%);
+ backdrop-filter: blur(18px);
+ container-name: array-controls;
+ container-type: inline-size;
+ scrollbar-width: thin;
+}
+
+.array-builder-layout > .split-handle {
+ position: sticky;
+ top: 59px;
+ height: calc(100vh - 59px);
+ align-self: start;
+}
+
+.runtime-line {
+ justify-content: space-between;
+ margin-bottom: 11px;
+}
+
+.save-state {
+ min-width: 0;
+ color: var(--muted);
+ font-size: 0.68rem;
+ overflow-wrap: anywhere;
+}
+
+.save-state.is-dirty {
+ color: var(--amber);
+}
+
+.validation-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 13px;
+ background: #0b1714;
+}
+
+.validation-card > div {
+ min-width: 0;
+}
+
+.validation-dot {
+ width: 9px;
+ height: 9px;
+ flex: 0 0 auto;
+ margin-top: 4px;
+ border-radius: 50%;
+ background: var(--muted);
+ box-shadow: 0 0 0 4px rgb(145 170 162 / 8%);
+}
+
+.validation-card strong,
+.validation-card p {
+ overflow-wrap: anywhere;
+}
+
+.validation-card strong {
+ font-size: 0.75rem;
+}
+
+.validation-card p {
+ margin: 3px 0 0;
+ color: var(--muted);
+ font-size: 0.65rem;
+ line-height: 1.5;
+}
+
+.validation-card.is-valid .validation-dot {
+ background: var(--mint);
+ box-shadow: 0 0 0 4px rgb(101 242 191 / 10%);
+}
+
+.validation-card.is-warning .validation-dot {
+ background: var(--amber);
+ box-shadow: 0 0 0 4px rgb(255 197 110 / 10%);
+}
+
+.validation-card.is-invalid .validation-dot {
+ background: var(--danger);
+ box-shadow: 0 0 0 4px rgb(255 123 115 / 10%);
+}
+
+.control-section {
+ margin-top: 11px;
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 15px;
+ background: rgb(255 255 255 / 2%);
+}
+
+.control-section--primary {
+ border-color: rgb(101 242 191 / 26%);
+ background: rgb(101 242 191 / 3%);
+}
+
+.section-title {
+ margin-bottom: 13px;
+}
+
+.section-title--split {
+ justify-content: space-between;
+}
+
+.section-title > span,
+.section-title > div > span {
+ color: var(--mint);
+ font: 750 0.65rem ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.section-title h2 {
+ margin: 0;
+ font-size: 0.86rem;
+}
+
+.mini-button {
+ min-height: 30px;
+ padding: 5px 8px;
+ color: var(--mint);
+ background: rgb(101 242 191 / 5%);
+ font-size: 0.66rem;
+}
+
+.field {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
+}
+
+.field > span,
+.compact-field > span {
+ color: var(--muted);
+ font-size: 0.65rem;
+ font-weight: 650;
+}
+
+.field > span small {
+ color: #6f8981;
+ font-size: 0.58rem;
+ font-weight: 500;
+}
+
+.field input,
+.field select,
+.field textarea,
+.compact-field input {
+ width: 100%;
+ min-width: 0;
+ min-height: 36px;
+ padding: 7px 8px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ color: var(--text);
+ background: #0b1714;
+ font-size: 0.72rem;
+}
+
+.field textarea {
+ min-height: 64px;
+ resize: vertical;
+ line-height: 1.45;
+}
+
+.field input:focus,
+.field select:focus,
+.field textarea:focus,
+.compact-field input:focus {
+ border-color: var(--mint);
+ outline: 2px solid rgb(101 242 191 / 10%);
+}
+
+.field input:disabled,
+.field select:disabled,
+.field textarea:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+.field--wide + .field--wide,
+.field-grid + .field--wide,
+.field--wide + .field-grid,
+.search-field + .microphone-list {
+ margin-top: 10px;
+}
+
+.field-grid,
+.preset-grid,
+.button-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 9px;
+}
+
+.field-grid--three {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.field-grid + .field-grid,
+.preset-grid,
+.button-grid {
+ margin-top: 10px;
+}
+
+.preset-grid__wide,
+.button-grid__wide {
+ grid-column: 1 / -1;
+}
+
+.section-note {
+ margin: 10px 0 0;
+ color: var(--muted);
+ font-size: 0.65rem;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.microphone-list {
+ min-height: 142px;
+ max-height: min(31vh, 310px);
+ overflow-y: auto;
+ padding: 7px;
+ border: 1px solid var(--line);
+ border-radius: 11px;
+ background: #08130f;
+ scrollbar-width: thin;
+}
+
+.microphone-list > .list-empty {
+ display: grid;
+ min-height: 124px;
+ margin: 0;
+ padding: 14px;
+ place-items: center;
+ color: var(--muted);
+ font-size: 0.68rem;
+ text-align: center;
+}
+
+.microphone-list [data-microphone-id] {
+ display: grid;
+ grid-template-columns: minmax(82px, 0.7fr) minmax(130px, 1.3fr);
+ width: 100%;
+ min-width: 0;
+ min-height: 38px;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 9px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: var(--muted);
+ background: transparent;
+ cursor: pointer;
+ font-size: 0.65rem;
+ text-align: left;
+}
+
+.microphone-list [data-microphone-id] + [data-microphone-id] {
+ margin-top: 3px;
+}
+
+.microphone-list [data-microphone-id]:hover,
+.microphone-list [data-microphone-id]:focus-visible,
+.microphone-list [data-microphone-id].is-selected,
+.microphone-list [data-microphone-id][aria-selected="true"] {
+ border-color: rgb(101 242 191 / 40%);
+ color: var(--text);
+ background: rgb(101 242 191 / 7%);
+ outline: none;
+}
+
+.microphone-list [data-microphone-id] > * {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.microphone-row__channel {
+ display: grid;
+ width: 25px;
+ height: 25px;
+ place-items: center;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ color: var(--mint);
+ font: 750 0.6rem ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.microphone-row__identity {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 2px 7px;
+}
+
+.microphone-row__identity strong,
+.microphone-row__identity small {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.microphone-row__identity strong {
+ grid-column: 1;
+ color: var(--text);
+ font-size: 0.68rem;
+}
+
+.microphone-row__identity small {
+ grid-column: 1 / -1;
+ color: var(--muted);
+ font: 600 0.58rem ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.microphone-row__calibration {
+ grid-column: 2;
+ grid-row: 1;
+ padding: 2px 5px;
+ border-radius: 999px;
+ color: var(--cyan);
+ background: rgb(88 199 232 / 9%);
+ font: 700 0.53rem ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.microphone-list__empty {
+ display: grid;
+ min-height: 124px;
+ margin: 0;
+ padding: 14px;
+ place-items: center;
+ color: var(--muted);
+ font-size: 0.68rem;
+ text-align: center;
+}
+
+.selected-microphone-editor {
+ margin-top: 11px;
+ padding-top: 11px;
+ border-top: 1px solid rgb(145 170 162 / 14%);
+}
+
+.selection-empty {
+ margin: 0 0 9px;
+ color: var(--muted);
+ font-size: 0.67rem;
+ line-height: 1.5;
+}
+
+details > summary {
+ list-style: none;
+}
+
+details > summary::-webkit-details-marker {
+ display: none;
+}
+
+.inline-details,
+.shortcut-help {
+ margin-top: 10px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgb(255 255 255 / 1.5%);
+}
+
+.inline-details > summary,
+.shortcut-help > summary {
+ position: relative;
+ padding: 10px 32px 10px 11px;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 0.66rem;
+ font-weight: 690;
+}
+
+.inline-details > summary::after,
+.shortcut-help > summary::after {
+ position: absolute;
+ top: 50%;
+ right: 11px;
+ color: var(--mint);
+ content: "+";
+ transform: translateY(-50%);
+}
+
+.inline-details[open] > summary::after,
+.shortcut-help[open] > summary::after {
+ content: "−";
+}
+
+.calibration-fields {
+ padding: 10px;
+ border-top: 1px solid var(--line);
+}
+
+.inline-details .section-note {
+ margin: 0;
+ padding: 0 10px 10px;
+}
+
+.array-workspace {
+ min-width: 0;
+ padding: 18px clamp(18px, 2.5vw, 38px) 38px;
+}
+
+.workspace-heading {
+ display: flex;
+ min-width: 0;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 13px;
+}
+
+.workspace-heading .eyebrow {
+ margin-bottom: 5px;
+ font-size: 0.65rem;
+}
+
+.workspace-heading h1 {
+ margin: 0;
+ font-size: clamp(1.45rem, 2.6vw, 2.25rem);
+ letter-spacing: -0.045em;
+}
+
+.workspace-summary {
+ min-width: 0;
+ margin: 0;
+ color: var(--muted);
+ font: 650 0.7rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+.workspace-toolbar {
+ display: flex;
+ min-width: 0;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 9px;
+ padding: 9px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgb(255 255 255 / 2%);
+}
+
+.toolbar-group {
+ flex: 0 1 auto;
+ padding-right: 8px;
+ border-right: 1px solid var(--line);
+}
+
+.tool-button,
+.icon-button {
+ min-height: 32px;
+ padding: 6px 9px;
+ border-radius: 8px;
+ color: var(--muted);
+ background: #0b1714;
+ font-size: 0.68rem;
+}
+
+.tool-button.is-active,
+.tool-button[aria-pressed="true"] {
+ border-color: rgb(101 242 191 / 54%);
+ color: var(--mint);
+ background: rgb(101 242 191 / 8%);
+}
+
+.icon-button {
+ width: 32px;
+ padding-inline: 0;
+ color: var(--text);
+ font-size: 1rem;
+}
+
+kbd {
+ padding: 1px 4px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ color: var(--muted);
+ background: rgb(255 255 255 / 3%);
+ font: 650 0.58rem ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.snap-controls {
+ margin-left: auto;
+}
+
+.check-field {
+ display: flex;
+ min-height: 32px;
+ align-items: center;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 0.67rem;
+ font-weight: 650;
+}
+
+.check-field input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--mint);
+}
+
+.compact-field {
+ display: grid;
+ grid-template-columns: auto 62px auto;
+ align-items: center;
+ gap: 5px;
+}
+
+.compact-field input {
+ min-height: 32px;
+ padding: 5px 7px;
+}
+
+.compact-field small {
+ color: var(--muted);
+ font-size: 0.6rem;
+}
+
+.array-canvas-card {
+ position: relative;
+ overflow: hidden;
+ min-height: 520px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #07110f;
+ box-shadow: var(--shadow);
+}
+
+#array-canvas {
+ display: block;
+ width: 100%;
+ height: clamp(520px, 68vh, 840px);
+ cursor: default;
+ touch-action: none;
+}
+
+#array-canvas:focus-visible {
+ outline: 2px solid var(--mint);
+ outline-offset: -3px;
+}
+
+body[data-array-tool="add"] #array-canvas,
+.array-builder-shell[data-array-tool="add"] #array-canvas {
+ cursor: crosshair;
+}
+
+.canvas-instructions {
+ position: absolute;
+ top: 24px;
+ left: 50%;
+ display: grid;
+ width: min(430px, calc(100% - 40px));
+ gap: 5px;
+ color: var(--muted);
+ text-align: center;
+ pointer-events: none;
+ transform: translateX(-50%);
+}
+
+.canvas-instructions strong {
+ color: #cfe2db;
+ font-size: 0.78rem;
+}
+
+.canvas-instructions span {
+ font-size: 0.65rem;
+}
+
+.canvas-instructions.is-hidden {
+ display: none;
+}
+
+.selection-status {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ max-width: calc(100% - 24px);
+ overflow: hidden;
+ padding: 7px 10px;
+ border: 1px solid rgb(255 255 255 / 10%);
+ border-radius: 999px;
+ color: #d7e8e2;
+ background: rgb(8 17 15 / 82%);
+ backdrop-filter: blur(10px);
+ font: 650 0.65rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: none;
+}
+
+.array-statistics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 9px;
+ margin-top: 10px;
+}
+
+.metric-card {
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgb(255 255 255 / 2%);
+}
+
+.metric-card span,
+.metric-card strong,
+.metric-card small {
+ display: block;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.metric-card span {
+ margin-bottom: 5px;
+ color: var(--muted);
+ font-size: 0.62rem;
+}
+
+.metric-card strong {
+ font-size: 0.94rem;
+ letter-spacing: -0.025em;
+}
+
+.metric-card small {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 0.56rem;
+ line-height: 1.35;
+}
+
+.shortcut-help > summary {
+ padding: 10px 34px 10px 12px;
+}
+
+.shortcut-help > div {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ padding: 10px;
+ border-top: 1px solid var(--line);
+}
+
+.shortcut-help p {
+ margin: 0;
+ padding: 9px;
+ border-radius: 8px;
+ color: var(--muted);
+ background: #0b1714;
+ font-size: 0.64rem;
+ line-height: 1.6;
+}
+
+.is-hidden {
+ display: none !important;
+}
+
+@media (max-width: 1280px) {
+ .array-workflow li {
+ font-size: 0;
+ gap: 0;
+ }
+
+ .array-workflow li + li::before {
+ width: 11px;
+ margin-inline: 4px;
+ }
+
+ .array-statistics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 1100px) {
+ .array-builder-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .array-builder-layout > .split-handle {
+ display: none;
+ }
+
+ .array-controls,
+ .array-workspace {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .array-controls {
+ position: static;
+ overflow: visible;
+ max-height: none;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .microphone-list {
+ max-height: 360px;
+ }
+}
+
+@media (max-width: 760px) {
+ .array-builder-header {
+ position: static;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .array-workflow {
+ order: 3;
+ width: 100%;
+ margin: 0;
+ }
+
+ .array-workflow ol {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .array-workflow li {
+ display: grid;
+ grid-template-columns: 20px minmax(0, 1fr);
+ gap: 5px;
+ font-size: 0.62rem;
+ white-space: normal;
+ }
+
+ .array-workflow li + li::before {
+ display: none;
+ }
+
+ .workspace-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .workspace-summary {
+ text-align: left;
+ }
+
+ .snap-controls {
+ width: 100%;
+ margin-left: 0;
+ justify-content: space-between;
+ }
+
+ .array-canvas-card {
+ min-height: 430px;
+ }
+
+ #array-canvas {
+ height: 500px;
+ }
+
+ .shortcut-help > div {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 520px) {
+ .array-builder-header {
+ align-items: stretch;
+ flex-direction: column;
+ padding-inline: 14px;
+ }
+
+ .array-builder-header .app-brand {
+ align-self: flex-start;
+ }
+
+ .header-actions {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ width: 100%;
+ gap: 6px;
+ }
+
+ .header-actions .button {
+ width: 100%;
+ padding-inline: 6px;
+ font-size: 0.68rem;
+ text-align: center;
+ }
+
+ .array-controls,
+ .array-workspace {
+ padding-inline: 14px;
+ }
+
+ .array-workspace {
+ padding-top: 15px;
+ }
+
+ .workspace-toolbar {
+ align-items: stretch;
+ }
+
+ .toolbar-group {
+ min-width: 0;
+ flex: 1 1 auto;
+ padding-right: 0;
+ border-right: 0;
+ }
+
+ .toolbar-group .tool-button {
+ flex: 1 1 auto;
+ }
+
+ .zoom-tools {
+ width: 100%;
+ }
+
+ .zoom-tools .tool-button {
+ flex: 1 1 auto;
+ }
+
+ .array-canvas-card {
+ min-height: 360px;
+ }
+
+ #array-canvas {
+ height: 430px;
+ }
+
+ .array-statistics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .canvas-instructions {
+ top: 18px;
+ }
+}
+
+@container array-controls (max-width: 380px) {
+ .field-grid--three {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .microphone-list [data-microphone-id] {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@container array-controls (max-width: 330px) {
+ .preset-grid,
+ .button-grid,
+ .calibration-fields {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .preset-grid__wide,
+ .button-grid__wide {
+ grid-column: auto;
+ }
+}
diff --git a/tests/integration/array-builder-ui.test.js b/tests/integration/array-builder-ui.test.js
new file mode 100644
index 0000000..dbd546b
--- /dev/null
+++ b/tests/integration/array-builder-ui.test.js
@@ -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();
+ }
+});
diff --git a/tests/test-runner.js b/tests/test-runner.js
index 44058fe..6e210a9 100644
--- a/tests/test-runner.js
+++ b/tests/test-runner.js
@@ -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");
diff --git a/tests/unit/array-builder-canvas.test.js b/tests/unit/array-builder-canvas.test.js
new file mode 100644
index 0000000..d882135
--- /dev/null
+++ b/tests/unit/array-builder-canvas.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");
+});