Files
micarrsim/apps/array-builder/main.js
T

1327 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";