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";