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 + + + + +
+
+ + micarrsim + Array Builder + + + + +
+ + Camera 열기 + +
+
+ +
+ + + + +
+
+
+

마이크 배치

+

Array 평면 편집

+
+

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"); +});