1693 lines
63 KiB
JavaScript
1693 lines
63 KiB
JavaScript
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";
|