Files

1693 lines
63 KiB
JavaScript
Raw Permalink 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,
computeReceiveBeamPatternSphere,
} from "/packages/acoustics-core/beam-pattern.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import { createArrayCanvas } from "./canvas-view.js";
import { createReceiveBeamPatternSurfaceView } from "./beam-pattern-surface-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-turns", "preset-seed", "preset-minimum-spacing",
"preset-hint",
"generate-preset-button", "array-canvas", "select-tool-button",
"add-tool-button", "array-view-2d-button", "array-view-3d-button",
"array-axis-view-control", "array-axis-view",
"canvas-front-badge", "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-frequency-value",
"beam-pattern-speed-of-sound", "beam-pattern-steering-angle",
"beam-pattern-steering-azimuth", "beam-pattern-steering-elevation",
"beam-pattern-samples", "beam-pattern-surface-resolution",
"beam-pattern-floor", "beam-pattern-view-cuts-button",
"beam-pattern-view-3d-button", "beam-pattern-cuts-view",
"beam-pattern-surface-view", "beam-pattern-surface", "beam-pattern-axis-view",
"beam-pattern-plot-xz", "beam-pattern-plot-yz", "beam-pattern-plot-xy",
"beam-pattern-caption-xz", "beam-pattern-caption-yz",
"beam-pattern-caption-xy", "beam-pattern-surface-caption",
"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 BEAM_PATTERN_CUTS = Object.freeze(["xz", "yz", "xy"]);
const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
cut,
createReceiveBeamPatternView(elements["beam-pattern-plot-" + cut]),
]));
const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
elements["beam-pattern-surface"],
{
onViewDirectionChange(direction) {
syncBeamViewDirection(direction);
},
},
);
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: "",
arrayViewMode: "2d",
beamPatternViewMode: "cuts",
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;
}
const VIEW_DIRECTION_LABELS = Object.freeze({
iso: "등각",
"+x": "+X에서",
"-x": "−X에서",
"+y": "+Y에서",
"-y": "−Y에서",
"+z": "+Z FRONT에서",
"-z": "−Z BACK에서",
custom: "자유 회전",
});
function resolvedViewDirection(direction) {
return Object.hasOwn(VIEW_DIRECTION_LABELS, direction) ? direction : "iso";
}
function viewDirectionDescription(direction) {
const resolved = resolvedViewDirection(direction);
return resolved === "custom"
? VIEW_DIRECTION_LABELS.custom
: VIEW_DIRECTION_LABELS[resolved] + " 원점 보기";
}
function syncBeamViewDirection(direction) {
const resolved = resolvedViewDirection(direction);
elements["beam-pattern-axis-view"].value = resolved;
document.documentElement.dataset.beamPatternViewAxis = resolved;
elements["beam-pattern-surface"].setAttribute(
"aria-label",
"3D 수신 빔 패턴 표면 · " + viewDirectionDescription(resolved),
);
}
function syncArrayViewDirection(direction) {
const resolved = resolvedViewDirection(direction);
elements["array-axis-view"].value = resolved;
elements["array-canvas"].dataset.viewAxis = resolved;
document.documentElement.dataset.arrayViewAxis = resolved;
if (state.arrayViewMode === "3d") {
elements["canvas-front-badge"].textContent = resolved === "custom"
? "+Z FRONT · 자유 회전"
: "시점 " + VIEW_DIRECTION_LABELS[resolved] + " 원점 보기 · +Z FRONT";
elements["array-canvas"].setAttribute(
"aria-label",
"마이크 어레이 3D 검사 canvas · " + viewDirectionDescription(resolved),
);
}
}
function dismissCanvasInstructions() {
elements["canvas-instructions"]?.classList.add("is-hidden");
}
function setTool(tool, options = {}) {
if (state.arrayViewMode === "3d" && tool === "add") return state.tool;
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 setArrayViewMode(mode, options = {}) {
const resolved = mode === "3d" ? "3d" : "2d";
state.arrayViewMode = resolved;
if (resolved === "3d") state.tool = "select";
elements["array-view-2d-button"].classList.toggle("is-active", resolved === "2d");
elements["array-view-3d-button"].classList.toggle("is-active", resolved === "3d");
elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d"));
elements["array-view-3d-button"].setAttribute("aria-pressed", String(resolved === "3d"));
elements["array-axis-view-control"].hidden = resolved !== "3d";
elements["add-tool-button"].disabled = resolved === "3d";
elements["select-tool-button"].disabled = resolved === "3d";
const instruction = elements["canvas-instructions"];
if (instruction) {
const title = instruction.querySelector("strong");
const detail = instruction.querySelector("span");
if (title) {
title.textContent = resolved === "3d"
? "드래그하여 배열을 회전하고 채널을 클릭해 선택하세요."
: "마이크를 클릭하거나 드래그해 위치를 편집하세요.";
}
if (detail) {
detail.textContent = resolved === "3d"
? "3D는 검사 전용입니다. 방향키로 회전하고 R로 초기화하며, 좌표 수정은 2D 또는 x/y/z 입력에서 합니다."
: "추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.";
}
instruction.classList.remove("is-hidden");
}
elements["canvas-front-badge"].textContent = resolved === "3d"
? "+Z FRONT · 초록 축"
: "+Z FRONT · 화면 바깥쪽";
elements["array-canvas"].setAttribute(
"aria-label",
resolved === "3d"
? "마이크 어레이 3D 검사 canvas, 드래그하여 회전"
: "마이크 어레이 평면 편집 canvas",
);
elements["array-canvas"].setAttribute(
"aria-keyshortcuts",
resolved === "3d"
? "ArrowLeft ArrowRight ArrowUp ArrowDown F R Escape"
: "V A F Escape Delete ArrowLeft ArrowRight ArrowUp ArrowDown",
);
if (resolved === "3d") syncArrayViewDirection(elements["array-axis-view"].value);
setTool(state.tool);
canvasView.render(canvasState());
if (options.focus) canvasView.focus();
if (options.announce !== false) {
announceSelection(
resolved === "3d"
? "3D 검사 · +Z가 FRONT입니다."
: "2D 편집 · +Z FRONT는 화면 바깥 방향입니다.",
);
}
}
function canvasState() {
return {
arrayDefinition: state.arrayDefinition,
apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId,
tool: state.tool,
viewMode: state.arrayViewMode,
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 });
},
onViewDirectionChange(direction) {
syncArrayViewDirection(direction);
},
onFit() {
canvasView.fit();
},
});
const beamPatternInputIds = [
"beam-pattern-speed-of-sound",
"beam-pattern-steering-angle",
"beam-pattern-steering-azimuth",
"beam-pattern-steering-elevation",
"beam-pattern-samples",
"beam-pattern-surface-resolution",
"beam-pattern-floor",
];
const BEAM_FREQUENCY_LOG_STEPS_PER_DECADE = 1000;
const BEAM_PATTERN_LIVE_EVALUATION_BUDGET = 2_400_000;
const BEAM_PATTERN_FULL_EVALUATION_BUDGET = 16_000_000;
function beamPatternEvaluationBudget(preview, simultaneousViews = 1) {
const total = preview
? BEAM_PATTERN_LIVE_EVALUATION_BUDGET
: BEAM_PATTERN_FULL_EVALUATION_BUDGET;
return Math.floor(total / simultaneousViews);
}
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 beamPatternFrequencyHz() {
const slider = elements["beam-pattern-frequency"];
const logarithmicPosition = finiteInput(slider, "아날로그 주파수 slider", {
minimum: 0,
maximum: 6000,
});
return Number(
Math.pow(10, logarithmicPosition / BEAM_FREQUENCY_LOG_STEPS_PER_DECADE)
.toPrecision(12),
);
}
function syncBeamPatternFrequency() {
const frequencyHz = beamPatternFrequencyHz();
const formatted = formatBeamFrequency(frequencyHz);
elements["beam-pattern-frequency-value"].textContent = formatted;
elements["beam-pattern-frequency"].setAttribute("aria-valuenow", String(frequencyHz));
elements["beam-pattern-frequency"].setAttribute("aria-valuetext", formatted);
return frequencyHz;
}
function readBeamPatternCommonOptions() {
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
const frequencyHz = syncBeamPatternFrequency();
const speedOfSound = beamPatternNumber(
"beam-pattern-speed-of-sound",
"음속",
{ minimum: 250, maximum: 400 },
);
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,
dbFloor,
};
}
function readBeamPatternCutOptions(preview) {
const common = readBeamPatternCommonOptions();
const steeringAngleDeg = beamPatternNumber(
"beam-pattern-steering-angle",
"단면별 조향각",
{ minimum: -360, maximum: 360 },
);
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("각도 샘플 설정이 올바르지 않습니다.");
}
return {
...common,
steeringAngleDeg,
maximumEvaluations: beamPatternEvaluationBudget(
preview,
BEAM_PATTERN_CUTS.length,
),
sampleCount: preview
? Math.min(configuredSampleCount === "auto" ? 720 : configuredSampleCount, 720)
: configuredSampleCount,
};
}
function readBeamPatternSurfaceOptions(preview) {
const common = readBeamPatternCommonOptions();
const steeringAzimuthDeg = beamPatternNumber(
"beam-pattern-steering-azimuth",
"3D 조향 방위각",
{ minimum: -360, maximum: 360 },
);
const steeringElevationDeg = beamPatternNumber(
"beam-pattern-steering-elevation",
"3D 조향 고도각",
{ minimum: -90, maximum: 90 },
);
const configuredStepDeg = Number(elements["beam-pattern-surface-resolution"].value);
if (
!Number.isFinite(configuredStepDeg) ||
configuredStepDeg < 1 ||
configuredStepDeg > 15 ||
360 % configuredStepDeg !== 0 ||
180 % configuredStepDeg !== 0
) {
elements["beam-pattern-surface-resolution"].setAttribute("aria-invalid", "true");
throw new Error("3D 구면 표본 간격이 올바르지 않습니다.");
}
const stepDeg = preview ? Math.max(6, configuredStepDeg) : configuredStepDeg;
return {
...common,
steeringAzimuthDeg,
steeringElevationDeg,
maximumEvaluations: beamPatternEvaluationBudget(preview),
azimuthSampleCount: Math.round(360 / stepDeg),
elevationSampleCount: Math.round(180 / stepDeg) + 1,
};
}
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 renderBeamPatternCuts(preview, startedAt) {
const options = readBeamPatternCutOptions(preview);
const patterns = BEAM_PATTERN_CUTS.map((cut) =>
computeReceiveBeamPattern({ ...options, cut }));
for (const pattern of patterns) {
const cut = pattern.cut;
const plot = elements["beam-pattern-plot-" + cut];
beamPatternViews[cut].render(pattern);
plot.dataset.sampleCount = String(pattern.sampleCount);
plot.dataset.cut = cut;
plot.dataset.frequencyHz = String(pattern.frequencyHz);
elements["beam-pattern-caption-" + cut].textContent =
pattern.sampleCount.toLocaleString("ko-KR") + " samples · " +
(preview
? "live preview · 놓으면 HPBW 재계산"
: "HPBW " + formatBeamHpbw(pattern.metrics.halfPowerBeamwidth));
const diagnostic = elements["beam-pattern-peaks"].querySelector(
'[data-cut="' + cut + '"] span',
);
if (diagnostic) {
diagnostic.textContent = preview
? "live raw samples · 놓으면 peak 재계산"
: describeBeamPatternPeaks(pattern);
}
}
const reference = patterns[0];
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(reference.frequencyHz) + " · XZ/YZ/XY 동시 · " +
reference.steeringAngleDeg.toFixed(1) + "° 독립 조향";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(reference.wavelengthM);
elements["beam-pattern-resolution"].textContent = patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
pattern.sampleCount.toLocaleString("ko-KR") + " / " +
pattern.angularResolutionDeg.toFixed(3) + "°")
.join(" · ");
elements["beam-pattern-steering-response"].textContent = patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
formatBeamDecibels(pattern.metrics.steeringDb))
.join(" · ");
elements["beam-pattern-hpbw"].textContent = preview
? "slider/drag 종료 후 계산"
: patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
formatBeamHpbw(pattern.metrics.halfPowerBeamwidth))
.join(" · ");
if (!preview) {
const elapsedMs = performance.now() - startedAt;
const unresolved = patterns.filter(
(pattern) => !pattern.angularSampling.metricResolutionSatisfied,
);
const missingCrossing = patterns.filter(
(pattern) =>
pattern.metrics.halfPowerBeamwidth.status ===
"no-sampled-half-power-crossing",
);
const evaluationCount = patterns.reduce(
(sum, pattern) => sum + pattern.evaluationCount,
0,
);
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
if (unresolved.length) {
setBeamPatternStatus(
unresolved.map((pattern) => pattern.cut.toUpperCase()).join("/") +
" raw polar 표본은 표시했지만 π/8 인접 위상 기준을 넘겨 HPBW·peak 해석을 보류합니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else if (missingCrossing.length) {
setBeamPatternStatus(
missingCrossing.map((pattern) => pattern.cut.toUpperCase()).join("/") +
"에서 sampled half-power crossing이 없어 전 방향 HPBW라고 단정하지 않습니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"세 평면 π/8 인접 위상 기준 충족 · " +
evaluationCount.toLocaleString("ko-KR") +
" microphone-angle evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
}
function renderBeamPatternSurface(preview, startedAt) {
const options = readBeamPatternSurfaceOptions(preview);
const surface = computeReceiveBeamPatternSphere(options);
beamPatternSurfaceView.render(surface);
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(surface.frequencyHz) + " · 3D · az " +
surface.steeringAzimuthDeg.toFixed(1) + "° / el " +
surface.steeringElevationDeg.toFixed(1) + "°";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(surface.wavelengthM);
elements["beam-pattern-resolution"].textContent =
surface.angularSampling.azimuthStepDeg.toFixed(3) + "° az · " +
surface.angularSampling.elevationStepDeg.toFixed(3) + "° el · " +
surface.sampleCount.toLocaleString("ko-KR") + " grid samples";
elements["beam-pattern-steering-response"].textContent =
"0.000 dB · analytic u=u₀";
elements["beam-pattern-hpbw"].textContent =
"3D 표면 · 평면 HPBW 미적용";
elements["beam-pattern-surface-caption"].textContent =
surface.azimuthSampleCount + "×" + surface.elevationSampleCount +
" raw grid samples · dB-floor radial mapping · response interpolation 없음 · " +
(preview ? "live preview" : "drag/방향키로 회전");
if (!preview) {
const elapsedMs = performance.now() - startedAt;
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
if (!surface.angularSampling.resolutionCriterionSatisfied) {
setBeamPatternStatus(
"Raw 3D 표면은 표시했습니다. 구면 cell 대각 위상 변화 " +
surface.angularSampling.maximumCellDiagonalPhaseStepRadBound.toFixed(3) +
" rad가 π/8 기준을 넘으므로 미세 lobe 해석은 미확정입니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"3D 구면 π/8 cell 대각 위상 기준 충족 · " +
surface.evaluationCount.toLocaleString("ko-KR") +
" microphone-direction evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
}
function renderBeamPattern(preview = false) {
const startedAt = performance.now();
try {
if (state.beamPatternViewMode === "surface") {
renderBeamPatternSurface(preview, startedAt);
} else {
renderBeamPatternCuts(preview, startedAt);
}
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "true";
document.documentElement.dataset.beamPatternPreview = String(preview);
document.documentElement.dataset.beamPatternView = state.beamPatternViewMode;
} catch (error) {
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "error";
document.documentElement.dataset.beamPatternPreview = String(preview);
const message = error instanceof Error ? error.message : String(error);
for (const view of Object.values(beamPatternViews)) view.renderError(message);
beamPatternSurfaceView.renderError(message);
setBeamPatternStatus(message, "error");
elements["beam-pattern-hpbw"].textContent = "—";
for (const diagnostic of elements["beam-pattern-peaks"].querySelectorAll("span")) {
diagnostic.textContent = "설정을 수정하면 다시 계산합니다.";
}
}
}
function setBeamPatternViewMode(mode, options = {}) {
const resolved = mode === "surface" ? "surface" : "cuts";
state.beamPatternViewMode = resolved;
elements["beam-pattern-view-cuts-button"].classList.toggle(
"is-active",
resolved === "cuts",
);
elements["beam-pattern-view-3d-button"].classList.toggle(
"is-active",
resolved === "surface",
);
elements["beam-pattern-view-cuts-button"].setAttribute(
"aria-pressed",
String(resolved === "cuts"),
);
elements["beam-pattern-view-3d-button"].setAttribute(
"aria-pressed",
String(resolved === "surface"),
);
for (const field of document.querySelectorAll("[data-beam-mode]")) {
field.hidden = field.dataset.beamMode !== resolved;
}
elements["beam-pattern-cuts-view"].hidden = resolved !== "cuts";
elements["beam-pattern-peaks"].hidden = resolved !== "cuts";
elements["beam-pattern-surface-view"].hidden = resolved !== "surface";
document.documentElement.dataset.beamPatternView = resolved;
state.beamPatternDirty = true;
if (options.render !== false) scheduleBeamPatternRender();
}
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";
const archimedean = type === "archimedean";
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);
elements["preset-turns"].closest(".field")?.toggleAttribute("hidden", !archimedean);
const hints = {
grid: "격자는 아래에서 위로, 각 행은 왼쪽에서 오른쪽 순서로 채널을 만듭니다.",
linear: "선택한 x/y 축 위에 마이크를 같은 간격으로 놓습니다.",
ring: "짧은 유효 반지름에 맞춘 실제 내접원 위에 같은 각도 간격으로 놓습니다.",
archimedean: "r∝θ인 한 줄 나선입니다. 회전 수를 늘리면 같은 반경 안에서 더 많이 감깁니다.",
spiral: "golden angle과 r∝√n을 쓰는 면적 균등 Fermat 배열입니다. 점만 보면 해바라기형으로 보입니다.",
random: "seed로 재현되는 균일 랜덤 좌표를 요청한 최소 간격으로 생성합니다.",
};
elements["preset-hint"].textContent = hints[type] ?? hints.grid;
}
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 === "archimedean") {
options.turns = finiteInput(elements["preset-turns"], "나선 회전 수", {
minimum: 0.25,
maximum: 32,
});
}
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["array-view-2d-button"].addEventListener("click", () =>
setArrayViewMode("2d", { focus: true }));
elements["array-view-3d-button"].addEventListener("click", () =>
setArrayViewMode("3d", { focus: true }));
elements["array-axis-view"].addEventListener("change", () => {
const direction = elements["array-axis-view"].value;
canvasView.setViewDirection(direction);
announceSelection("3D 시점 · " + viewDirectionDescription(direction));
});
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["beam-pattern-frequency"].addEventListener("input", () => {
syncBeamPatternFrequency();
scheduleBeamPatternRender({ preview: true });
});
elements["beam-pattern-frequency"].addEventListener("change", () =>
scheduleBeamPatternRender());
for (const id of beamPatternInputIds) {
if (elements[id] instanceof HTMLInputElement) {
elements[id].addEventListener("input", () =>
scheduleBeamPatternRender({ preview: true }));
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
} else {
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
}
}
elements["beam-pattern-view-cuts-button"].addEventListener("click", () =>
setBeamPatternViewMode("cuts"));
elements["beam-pattern-view-3d-button"].addEventListener("click", () =>
setBeamPatternViewMode("surface"));
elements["beam-pattern-axis-view"].addEventListener("change", () => {
beamPatternSurfaceView.setViewDirection(elements["beam-pattern-axis-view"].value);
});
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);
}
for (const view of Object.values(beamPatternViews)) view.destroy();
beamPatternSurfaceView.destroy();
splitPane.destroy();
canvasView.destroy();
});
elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000);
syncArrayViewDirection("iso");
syncBeamViewDirection("iso");
setArrayViewMode("2d", { announce: false });
setBeamPatternViewMode("cuts", { render: false });
syncBeamPatternFrequency();
renderAll("기본 4×3 배열");
canvasView.fit();
document.documentElement.dataset.appReady = "true";