import { downloadJson, readJsonFile } from "/packages/contracts/io.js"; import { validateArrayDefinition } from "/packages/contracts/validation.js"; import { computeReceiveBeamPattern } from "/packages/acoustics-core/beam-pattern.js"; import { createSplitPane } from "/packages/ui/split-pane.js"; import { createArrayCanvas } from "./canvas-view.js"; import { createReceiveBeamPatternView } from "./beam-pattern-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", "beam-pattern-panel", "beam-pattern-frequency", "beam-pattern-speed-of-sound", "beam-pattern-cut", "beam-pattern-steering-angle", "beam-pattern-samples", "beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status", "beam-pattern-summary", "beam-pattern-wavelength", "beam-pattern-resolution", "beam-pattern-steering-response", "beam-pattern-hpbw", "beam-pattern-peaks", ]; 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", "canvas-instructions", "mobile-export-button", ]; for (const id of optionalIds) elements[id] = maybeById(id); elements.validationCard = elements["validation-title"].closest(".validation-card"); const beamPatternView = createReceiveBeamPatternView(elements["beam-pattern-plot"]); const 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, importRevision: 0, baselineSignature: "", beamPatternRenderFrame: 0, beamPatternDirty: true, beamPatternPreviewPending: false, }; const MAX_HISTORY = 50; const MAX_HISTORY_BYTES = 24 * 1024 * 1024; const frequencyResponseFingerprintCache = new WeakMap(); const BUILTIN_ARRAY_IDS = new Set([ "rectangular-2x2-450mm", "rectangular-4x3-450mm", "rectangular-8x6-450mm", "rectangular-16x12", "rectangular-24x16-450mm", "rectangular-16x12-compact", "rectangular-16x12-large", "fermat-spiral-192", ]); state.baselineSignature = editableDocumentSignature(state.arrayDefinition); 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 cloneArrayForHistory(arrayDefinition) { return { ...arrayDefinition, coordinateSystem: { ...arrayDefinition.coordinateSystem }, aperture: { ...arrayDefinition.aperture }, microphones: arrayDefinition.microphones.map((microphone) => ({ ...microphone, position: [...microphone.position], ...(microphone.frequencyResponse === undefined ? {} : { frequencyResponse: microphone.frequencyResponse }), })), }; } function captureSnapshot() { return { arrayDefinition: cloneArrayForHistory(state.arrayDefinition), apertureShape: state.apertureShape, selectedMicrophoneId: state.selectedMicrophoneId, }; } function editableSnapshotSignature(snapshot) { const arrayDefinition = snapshot.arrayDefinition; return JSON.stringify({ document: editableDocumentView(arrayDefinition), apertureShape: snapshot.apertureShape, selectedMicrophoneId: snapshot.selectedMicrophoneId, }); } function fingerprintFrequencyResponse(response) { if (!Array.isArray(response)) return null; const cached = frequencyResponseFingerprintCache.get(response); if (cached) return cached; const canonical = JSON.stringify(response.map((point) => [ point.frequencyHz, point.gainDb, point.phaseDeg ?? null, ])); let first = 0x811c9dc5; let second = 0x9e3779b9; for (let index = 0; index < canonical.length; index += 1) { const code = canonical.charCodeAt(index); first = Math.imul(first ^ code, 0x01000193) >>> 0; second = Math.imul(second ^ code, 0x85ebca6b) >>> 0; } const fingerprint = String(canonical.length) + ":" + first.toString(16).padStart(8, "0") + ":" + second.toString(16).padStart(8, "0"); frequencyResponseFingerprintCache.set(response, fingerprint); return fingerprint; } function editableDocumentView(arrayDefinition) { return { schemaVersion: arrayDefinition.schemaVersion, kind: arrayDefinition.kind, id: arrayDefinition.id, name: arrayDefinition.name, description: arrayDefinition.description, units: arrayDefinition.units, coordinateSystem: arrayDefinition.coordinateSystem, aperture: arrayDefinition.aperture, microphones: arrayDefinition.microphones.map((microphone) => [ microphone.id, ...microphone.position, microphone.sensitivityDb ?? null, microphone.phaseOffsetDeg ?? null, fingerprintFrequencyResponse(microphone.frequencyResponse), ]), }; } function editableDocumentSignature(arrayDefinition) { return JSON.stringify(editableDocumentView(arrayDefinition)); } function currentSnapshotView() { return { arrayDefinition: state.arrayDefinition, apertureShape: state.apertureShape, selectedMicrophoneId: state.selectedMicrophoneId, }; } function estimatedSnapshotBytes(snapshot) { const responsePointCount = snapshot.arrayDefinition.microphones.reduce( (count, microphone) => count + (microphone.frequencyResponse?.length ?? 0), 0, ); return editableSnapshotSignature(snapshot).length * 4 + responsePointCount * 160; } function historyEntry(snapshot, label) { return { snapshot, label, bytes: estimatedSnapshotBytes(snapshot) }; } function trimHistoryMemory() { while (state.history.length > MAX_HISTORY) state.history.shift(); let totalBytes = [...state.history, ...state.future].reduce( (sum, entry) => sum + (entry.bytes ?? estimatedSnapshotBytes(entry.snapshot)), 0, ); while (totalBytes > MAX_HISTORY_BYTES && state.history.length > 1) { totalBytes -= state.history.shift().bytes; } while (totalBytes > MAX_HISTORY_BYTES && state.future.length > 1) { totalBytes -= state.future.shift().bytes; } while ( totalBytes > MAX_HISTORY_BYTES && state.history.length + state.future.length > 1 ) { const removed = state.history.length ? state.history.shift() : state.future.shift(); totalBytes -= removed.bytes; } } function refreshDirtyState() { state.dirty = editableDocumentSignature(state.arrayDefinition) !== state.baselineSignature; } function restoreSnapshot(snapshot, message) { state.arrayDefinition = cloneArrayForHistory(snapshot.arrayDefinition); state.apertureShape = snapshot.apertureShape; state.selectedMicrophoneId = snapshot.selectedMicrophoneId; if (!selectedMicrophone()) { state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null; } refreshDirtyState(); renderAll(message); } function recordSnapshot(before, label) { if (editableSnapshotSignature(before) === editableSnapshotSignature(currentSnapshotView())) { return false; } state.history.push(historyEntry(before, label)); state.future.length = 0; trimHistoryMemory(); refreshDirtyState(); 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.validationCard.classList.add("is-invalid"); elements.validationCard.classList.remove("is-valid", "is-warning"); } elements["array-builder-workspace"].dataset.actionError = message; } function safely(callback) { try { delete elements["array-builder-workspace"].dataset.actionError; callback(); } catch (error) { renderDocumentEditor(); syncSelectedEditor(); setNumber(elements["snap-step"], state.snapStepM * 1000); 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); } } function announceSelection(message) { if (elements["selection-status"]) elements["selection-status"].textContent = message; } function dismissCanvasInstructions() { elements["canvas-instructions"]?.classList.add("is-hidden"); } 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.userInitiated) { dismissCanvasInstructions(); announceSelection(state.tool === "add" ? "추가 도구 · 빈 곳을 클릭하세요." : "선택 도구"); } 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()); const selectedButton = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")] .find((button) => button.dataset.microphoneId === state.selectedMicrophoneId); if (options.focusList) selectedButton?.focus(); if (options.scrollList !== false) { selectedButton?.scrollIntoView?.({ block: "nearest" }); } announceSelection( state.selectedMicrophoneId ? state.selectedMicrophoneId + " 선택" : "선택된 마이크 없음", ); if (options.focusCanvas) canvasView.focus(); } const canvasView = createArrayCanvas(elements["array-canvas"], { onSelect(id) { dismissCanvasInstructions(); selectMicrophone(id, { scrollList: window.matchMedia("(min-width: 761px)").matches, }); }, onAdd(position) { dismissCanvasInstructions(); safely(() => mutate("마이크 추가됨", () => { const microphone = addMicrophone( state.arrayDefinition, normalizedPointerPosition(position), ); state.selectedMicrophoneId = microphone.id; })); }, onDragStart(id) { dismissCanvasInstructions(); 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()); scheduleBeamPatternRender({ preview: true }); }, 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, { userInitiated: true }); }, onFit() { canvasView.fit(); }, }); const beamPatternInputIds = [ "beam-pattern-frequency", "beam-pattern-speed-of-sound", "beam-pattern-cut", "beam-pattern-steering-angle", "beam-pattern-samples", "beam-pattern-floor", ]; function beamPatternNumber(id, label, options) { const element = elements[id]; try { const value = finiteInput(element, label, options); element.removeAttribute("aria-invalid"); return value; } catch (error) { element.setAttribute("aria-invalid", "true"); throw error; } } function readBeamPatternOptions(preview) { for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid"); const frequencyHz = beamPatternNumber( "beam-pattern-frequency", "아날로그 주파수", { exclusiveMinimum: 0, maximum: 1_000_000 }, ); const speedOfSound = beamPatternNumber( "beam-pattern-speed-of-sound", "음속", { minimum: 250, maximum: 400 }, ); const steeringAngleDeg = beamPatternNumber( "beam-pattern-steering-angle", "조향각", { minimum: -360, maximum: 360 }, ); const cut = elements["beam-pattern-cut"].value; if (!["xy", "xz", "yz"].includes(cut)) { elements["beam-pattern-cut"].setAttribute("aria-invalid", "true"); throw new Error("절단면은 XY, XZ 또는 YZ여야 합니다."); } const sampleValue = elements["beam-pattern-samples"].value; const configuredSampleCount = sampleValue === "auto" ? "auto" : Number(sampleValue); if ( configuredSampleCount !== "auto" && (!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36) ) { elements["beam-pattern-samples"].setAttribute("aria-invalid", "true"); throw new Error("각도 샘플 설정이 올바르지 않습니다."); } const dbFloor = Number(elements["beam-pattern-floor"].value); if (!Number.isFinite(dbFloor) || dbFloor >= 0) { elements["beam-pattern-floor"].setAttribute("aria-invalid", "true"); throw new Error("표시 dB 하한이 올바르지 않습니다."); } return { array: state.arrayDefinition, frequencyHz, speedOfSound, cut, steeringAngleDeg, sampleCount: preview ? Math.min( configuredSampleCount === "auto" ? 720 : configuredSampleCount, 720, ) : configuredSampleCount, dbFloor, }; } function formatBeamFrequency(frequencyHz) { if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz"; if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz"; return frequencyHz.toFixed(1) + " Hz"; } function formatBeamWavelength(wavelengthM) { if (wavelengthM >= 1) return wavelengthM.toFixed(4) + " m"; return (wavelengthM * 1000).toFixed(wavelengthM >= 0.01 ? 2 : 3) + " mm"; } function formatBeamDecibels(value) { if (!Number.isFinite(value)) return "−∞ dB"; const normalized = Math.abs(value) < 0.0005 ? 0 : value; return normalized.toFixed(3) + " dB"; } function formatBeamHpbw(halfPower) { if (halfPower.status === "sampled-bracketed-bisection") { return halfPower.widthDeg.toFixed(2) + "° · sampled bracket"; } if (halfPower.status === "full-circle-above-half-power") { return "정의 불가 · 전 방향 ≥ −3.0103 dB"; } if (halfPower.status === "no-sampled-half-power-crossing") { return "미확정 · sampled crossing 없음"; } return "미확정 · 각도 해상도 부족"; } function describeBeamPatternPeaks(pattern) { const summary = pattern.metrics.sampledLocalMaxima; if (summary.status === "resolution-unresolved") { return "각도 해상도 기준이 충족되지 않아 peak를 분류하지 않습니다. Polar 선은 raw 표본만 표시합니다."; } if (summary.status === "flat-on-grid") { return "이 절단면의 모든 표본이 수치 허용오차 안에서 같습니다. 고유 peak를 선택하지 않습니다."; } const displayed = summary.peaks.slice(0, 8).map((peak) => peak.angleDeg.toFixed(2) + "° " + formatBeamDecibels(peak.responseDb)); const remaining = Math.max(0, summary.peaks.length - displayed.length); const prefix = summary.status === "tied-global-maxima-on-grid" ? String(summary.globalMaximumRegions.length) + "개 sampled 전역 최대 동률 · " : "sampled 전역 최대 1개 · "; return prefix + displayed.join(" · ") + (remaining ? " · 외 " + String(remaining) + "개" : "") + " · 자동 sidelobe/grating-lobe 분류는 하지 않습니다."; } function setBeamPatternStatus(message, severity = "success") { elements["beam-pattern-status"].textContent = message; elements["beam-pattern-status"].dataset.severity = severity; } function renderBeamPattern(preview = false) { const startedAt = performance.now(); try { const options = readBeamPatternOptions(preview); const pattern = computeReceiveBeamPattern(options); beamPatternView.render(pattern); state.beamPatternDirty = false; document.documentElement.dataset.beamPatternReady = "true"; document.documentElement.dataset.beamPatternPreview = String(preview); elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount); elements["beam-pattern-plot"].dataset.cut = pattern.cut; elements["beam-pattern-plot"].dataset.frequencyHz = String(pattern.frequencyHz); elements["beam-pattern-summary"].textContent = formatBeamFrequency(pattern.frequencyHz) + " · " + pattern.cut.toUpperCase() + " · " + pattern.steeringAngleDeg.toFixed(1) + "° 조향"; elements["beam-pattern-wavelength"].textContent = formatBeamWavelength(pattern.wavelengthM); elements["beam-pattern-resolution"].textContent = pattern.sampleCount.toLocaleString("ko-KR") + " · " + pattern.angularResolutionDeg.toFixed(4) + "°"; elements["beam-pattern-steering-response"].textContent = formatBeamDecibels(pattern.metrics.steeringDb); elements["beam-pattern-hpbw"].textContent = preview ? "드래그 종료 후 계산" : formatBeamHpbw(pattern.metrics.halfPowerBeamwidth); if (!preview) { elements["beam-pattern-peaks"].textContent = describeBeamPatternPeaks(pattern); const elapsedMs = performance.now() - startedAt; document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3); if (!pattern.angularSampling.metricResolutionSatisfied) { setBeamPatternStatus( "Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " + pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) + " rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." + (pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") + " · " + elapsedMs.toFixed(1) + " ms", "warning", ); } else if ( pattern.metrics.halfPowerBeamwidth.status === "no-sampled-half-power-crossing" ) { setBeamPatternStatus( "π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " + "전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms", "warning", ); } else { setBeamPatternStatus( "π/8 인접 위상 간격 기준 충족 · " + pattern.sampleCount.toLocaleString("ko-KR") + " angles · " + pattern.evaluationCount.toLocaleString("ko-KR") + " phasor evaluations · " + elapsedMs.toFixed(1) + " ms", "success", ); } } } catch (error) { state.beamPatternDirty = false; document.documentElement.dataset.beamPatternReady = "error"; document.documentElement.dataset.beamPatternPreview = "false"; const message = error instanceof Error ? error.message : String(error); beamPatternView.renderError(message); setBeamPatternStatus(message, "error"); elements["beam-pattern-hpbw"].textContent = "—"; elements["beam-pattern-peaks"].textContent = "빔 패턴 설정을 수정하면 다시 계산합니다."; } } function scheduleBeamPatternRender(options = {}) { const preview = options.preview === true; state.beamPatternDirty = true; if (!elements["beam-pattern-panel"].open) return; if (state.beamPatternRenderFrame) { state.beamPatternPreviewPending = preview; return; } state.beamPatternPreviewPending = preview; state.beamPatternRenderFrame = requestAnimationFrame(() => { const pendingPreview = state.beamPatternPreviewPending; state.beamPatternRenderFrame = 0; state.beamPatternPreviewPending = false; renderBeamPattern(pendingPreview); }); } 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) => { const matchesQuery = !query || microphone.id.toLowerCase().includes(query); if (!matchesQuery && microphone.id !== state.selectedMicrophoneId) return; const button = document.createElement("button"); button.type = "button"; button.className = "microphone-row"; button.dataset.microphoneId = microphone.id; button.dataset.searchPinned = String(!matchesQuery); button.tabIndex = microphone.id === state.selectedMicrophoneId ? 0 : -1; 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", () => { dismissCanvasInstructions(); selectMicrophone(microphone.id, { focusList: true }); }); list.append(button); }); if (!list.querySelector('[tabindex="0"]')) { const firstButton = list.querySelector("[data-microphone-id]"); if (firstButton) firstButton.tabIndex = 0; } 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; if (elements["mobile-export-button"]) { elements["mobile-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()); scheduleBeamPatternRender(); 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() { const type = elements["preset-type"].value; const options = { type, aperture: state.arrayDefinition.aperture, shape: state.apertureShape, edgeInsetM: elements["preset-edge-inset"] ? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 }) : 0, }; if (type === "grid") { options.columns = integerInput(elements["preset-columns"], "Columns", 1, 1024); options.rows = integerInput(elements["preset-rows"], "Rows", 1, 1024); } else { options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024); } if (type === "linear") options.axis = elements["preset-axis"]?.value ?? "x"; if (type === "random") { options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295); options.minimumSpacingM = finiteInput( elements["preset-minimum-spacing"], "최소 간격", { minimum: 0 }, ); } return options; } 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, userInitiated: true })); elements["add-tool-button"].addEventListener("click", () => setTool("add", { focus: true, userInitiated: 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 꺼짐"); }); for (const id of beamPatternInputIds) { const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change"; elements[id].addEventListener(eventName, () => scheduleBeamPatternRender()); } elements["beam-pattern-panel"].addEventListener("toggle", () => { if (elements["beam-pattern-panel"].open && state.beamPatternDirty) { scheduleBeamPatternRender(); } }); 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(() => { dismissCanvasInstructions(); 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["microphone-list"].addEventListener("keydown", (event) => { if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return; const buttons = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")]; if (!buttons.length) return; const focusedIndex = Math.max(0, buttons.indexOf(document.activeElement)); let targetIndex = focusedIndex; if (event.key === "ArrowUp") targetIndex = Math.max(0, focusedIndex - 1); else if (event.key === "ArrowDown") targetIndex = Math.min(buttons.length - 1, focusedIndex + 1); else if (event.key === "Home") targetIndex = 0; else targetIndex = buttons.length - 1; event.preventDefault(); selectMicrophone(buttons[targetIndex].dataset.microphoneId, { focusList: true }); }); 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(historyEntry(captureSnapshot(), entry.label)); trimHistoryMemory(); restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label); } function redo() { const entry = state.future.pop(); if (!entry) return; state.history.push(historyEntry(captureSnapshot(), entry.label)); trimHistoryMemory(); 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; const importRevision = ++state.importRevision; try { const value = await readJsonFile(file); const issues = await validateArrayDefinition(value); if (importRevision !== state.importRevision) return; if (issues.length) { const details = issues.slice(0, 4).map( (issue) => issue.path + ": " + issue.message, ).join(" · "); throw new Error("Array JSON 계약을 만족하지 않습니다. " + details); } const previousSnapshot = captureSnapshot(); state.arrayDefinition = prepareArrayForEditing(value); state.apertureShape = inferApertureShape(value); state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null; state.history = [historyEntry(previousSnapshot, "Array 불러오기")]; state.future.length = 0; trimHistoryMemory(); state.baselineSignature = editableDocumentSignature(state.arrayDefinition); state.dirty = false; canvasView.fit(); renderAll(file.name + " 불러옴 · 실행 취소 가능"); } catch (error) { if (importRevision === state.importRevision) showActionError(error); } finally { if (importRevision === state.importRevision) elements["array-input"].value = ""; } }); async function exportArray() { await validateCurrent(); if (!state.valid) { showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요.")); return; } downloadJson( state.arrayDefinition, safeArrayFileStem(state.arrayDefinition) + ".array.json", ); state.baselineSignature = editableDocumentSignature(state.arrayDefinition); refreshDirtyState(); announce("Array JSON 저장됨"); } elements["export-button"].addEventListener("click", exportArray); elements["mobile-export-button"]?.addEventListener("click", exportArray); window.addEventListener("beforeunload", (event) => { if (!state.dirty) return; event.preventDefault(); event.returnValue = ""; }); window.addEventListener("unload", () => { if (state.beamPatternRenderFrame) { cancelAnimationFrame(state.beamPatternRenderFrame); } beamPatternView.destroy(); 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";