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

877 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { validateArrayDefinition } from "/packages/contracts/validation.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import { createArrayCanvas } from "./canvas-view.js";
import {
addMicrophone,
arrayGeometryIssues,
arrayStatistics,
centerMicrophones,
clampPositionToAperture,
createArrayDefinition,
fitApertureToMicrophones,
inferApertureShape,
moveMicrophone,
prepareArrayForEditing,
removeMicrophone,
replaceMicrophonesWithPreset,
safeArrayFileStem,
snapPosition,
updateMicrophone,
} from "./model.js";
const byId = (id) => {
const element = document.getElementById(id);
if (!element) throw new Error("필수 UI를 찾을 수 없습니다: #" + id);
return element;
};
const maybeById = (id) => document.getElementById(id);
const requiredIds = [
"array-builder-layout", "array-builder-controls", "array-builder-workspace",
"array-builder-split-handle", "array-input", "export-button", "array-id",
"array-name", "array-description", "aperture-width", "aperture-height",
"aperture-shape", "preset-type", "preset-count", "preset-columns",
"preset-rows", "preset-seed", "preset-minimum-spacing",
"generate-preset-button", "array-canvas", "select-tool-button",
"add-tool-button", "fit-view-button", "zoom-in-button", "zoom-out-button",
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
"remove-microphone-button", "validation-title", "validation-message",
];
const elements = Object.fromEntries(requiredIds.map((id) => [id, byId(id)]));
const optionalIds = [
"validation-dot", "save-state", "undo-button", "redo-button",
"microphone-count", "minimum-spacing", "array-centroid", "calibration-count",
"selection-status", "center-microphones-button", "fit-aperture-button",
"clamp-microphones-button", "move-microphone-up-button",
"move-microphone-down-button", "microphone-sensitivity", "microphone-phase",
"frequency-response-summary", "preset-axis", "preset-edge-inset",
"microphone-search", "microphone-selection-empty", "microphone-fields",
"workspace-summary",
];
for (const id of optionalIds) elements[id] = maybeById(id);
elements.validationCard = elements["validation-title"].closest(".validation-card");
const splitPane = createSplitPane({
container: elements["array-builder-layout"],
separator: elements["array-builder-split-handle"],
panel: elements["array-builder-controls"],
panelSide: "start",
storageKey: "micarrsim.array-builder.control-panel-width",
defaultSize: 420,
minSize: 360,
maxSize: 720,
minMainSize: 560,
enabledMediaQuery: "(min-width: 1101px)",
});
const state = {
arrayDefinition: createArrayDefinition(),
apertureShape: "rectangle",
selectedMicrophoneId: "mic-001",
tool: "select",
snapEnabled: true,
snapStepM: 0.005,
dirty: false,
validationRevision: 0,
valid: false,
history: [],
future: [],
dragSnapshot: null,
};
const MAX_HISTORY = 50;
const BUILTIN_ARRAY_IDS = new Set([
"rectangular-2x2", "rectangular-4x3", "rectangular-8x6",
"rectangular-16x12", "rectangular-24x16", "rectangular-16x12-compact",
"rectangular-16x12-large", "fermat-spiral-192",
]);
function finiteInput(element, label, options = {}) {
const value = element.valueAsNumber;
if (!Number.isFinite(value)) throw new Error(label + "에 유한한 숫자를 입력하세요.");
if (options.minimum !== undefined && value < options.minimum) {
throw new Error(label + "은(는) " + options.minimum + " 이상이어야 합니다.");
}
if (options.exclusiveMinimum !== undefined && value <= options.exclusiveMinimum) {
throw new Error(label + "은(는) " + options.exclusiveMinimum + "보다 커야 합니다.");
}
if (options.maximum !== undefined && value > options.maximum) {
throw new Error(label + "은(는) " + options.maximum + " 이하여야 합니다.");
}
return value;
}
function integerInput(element, label, minimum, maximum) {
const value = finiteInput(element, label, { minimum, maximum });
if (!Number.isInteger(value)) throw new Error(label + "은(는) 정수여야 합니다.");
return value;
}
function setNumber(element, value) {
if (!element) return;
element.value = Number.isFinite(value) ? String(value) : "";
}
function selectedMicrophone() {
return state.arrayDefinition.microphones.find(
(microphone) => microphone.id === state.selectedMicrophoneId,
) ?? null;
}
function captureSnapshot() {
return {
arrayDefinition: structuredClone(state.arrayDefinition),
apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId,
};
}
function snapshotSignature(snapshot) {
return JSON.stringify(snapshot);
}
function restoreSnapshot(snapshot, message) {
state.arrayDefinition = structuredClone(snapshot.arrayDefinition);
state.apertureShape = snapshot.apertureShape;
state.selectedMicrophoneId = snapshot.selectedMicrophoneId;
if (!selectedMicrophone()) {
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
}
state.dirty = true;
renderAll(message);
}
function recordSnapshot(before, label) {
if (snapshotSignature(before) === snapshotSignature(captureSnapshot())) return false;
state.history.push({ snapshot: before, label });
if (state.history.length > MAX_HISTORY) state.history.shift();
state.future.length = 0;
state.dirty = true;
return true;
}
function mutate(label, callback) {
const before = captureSnapshot();
callback();
if (recordSnapshot(before, label)) renderAll(label);
else renderAll();
}
function showActionError(error) {
const message = error instanceof Error ? error.message : String(error);
if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함";
elements["validation-title"].textContent = "작업을 적용하지 못했습니다";
elements["validation-message"].textContent = message;
if (elements.validationCard) elements.validationCard.dataset.severity = "error";
elements["array-builder-workspace"].dataset.actionError = message;
}
function safely(callback) {
try {
delete elements["array-builder-workspace"].dataset.actionError;
callback();
} catch (error) {
showActionError(error);
}
}
function announce(message) {
if (elements["save-state"]) {
elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨";
elements["save-state"].classList.toggle("is-dirty", state.dirty);
}
if (elements["selection-status"] && message) {
elements["selection-status"].textContent = message;
}
}
function setTool(tool, options = {}) {
state.tool = tool === "add" ? "add" : "select";
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
elements["select-tool-button"].setAttribute("aria-pressed", String(state.tool === "select"));
elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add"));
elements["array-canvas"].dataset.tool = state.tool;
canvasView.render(canvasState());
if (options.focus) canvasView.focus();
}
function canvasState() {
return {
arrayDefinition: state.arrayDefinition,
apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId,
tool: state.tool,
snapStepM: state.snapEnabled ? state.snapStepM : 0,
};
}
function normalizedPointerPosition(position) {
if (state.snapEnabled) {
return snapPosition(
position,
state.snapStepM,
state.arrayDefinition.aperture,
state.apertureShape,
);
}
return clampPositionToAperture(
position,
state.arrayDefinition.aperture,
state.apertureShape,
);
}
function coordinateKey(position) {
return position.map((value) => Number(value).toFixed(9)).join(",");
}
function availableManualPosition() {
const used = new Set(state.arrayDefinition.microphones.map(
(microphone) => coordinateKey(microphone.position),
));
const origin = [0, 0, 0];
if (!used.has(coordinateKey(origin))) return origin;
const step = Math.max(state.snapStepM, 0.001);
for (let ring = 1; ring <= 128; ring += 1) {
const samples = Math.max(8, ring * 8);
for (let index = 0; index < samples; index += 1) {
const angle = (index / samples) * Math.PI * 2;
const candidate = normalizedPointerPosition([
Math.cos(angle) * ring * step,
Math.sin(angle) * ring * step,
0,
]);
if (!used.has(coordinateKey(candidate))) return candidate;
}
}
throw new Error("빈 위치를 자동으로 찾지 못했습니다. 추가 도구로 canvas의 위치를 지정하세요.");
}
function selectMicrophone(id, options = {}) {
state.selectedMicrophoneId = state.arrayDefinition.microphones.some(
(microphone) => microphone.id === id,
) ? id : null;
renderMicrophoneList();
syncSelectedEditor();
canvasView.render(canvasState());
if (state.selectedMicrophoneId) announce(state.selectedMicrophoneId + " 선택");
if (options.focusCanvas) canvasView.focus();
}
const canvasView = createArrayCanvas(elements["array-canvas"], {
onSelect(id) {
selectMicrophone(id);
},
onAdd(position) {
safely(() => mutate("마이크 추가됨", () => {
const microphone = addMicrophone(
state.arrayDefinition,
normalizedPointerPosition(position),
);
state.selectedMicrophoneId = microphone.id;
}));
},
onDragStart(id) {
if (state.dragSnapshot) return;
state.selectedMicrophoneId = id;
state.dragSnapshot = captureSnapshot();
syncSelectedEditor();
},
onDrag(id, position) {
updateMicrophone(state.arrayDefinition, id, {
position: normalizedPointerPosition(position),
});
state.selectedMicrophoneId = id;
syncSelectedEditor();
canvasView.render(canvasState());
},
onDragEnd(id, position) {
if (!state.dragSnapshot) return;
if (position) {
updateMicrophone(state.arrayDefinition, id, {
position: normalizedPointerPosition(position),
});
}
const before = state.dragSnapshot;
state.dragSnapshot = null;
if (recordSnapshot(before, "마이크 위치 변경됨")) renderAll("마이크 위치 변경됨");
else renderAll();
},
onDelete() {
removeSelectedMicrophone();
},
onNudge(delta) {
const microphone = selectedMicrophone();
if (!microphone) return;
safely(() => mutate("마이크 위치 변경됨", () => {
const dx = Number.isFinite(delta?.dx) ? delta.dx : 0;
const dy = Number.isFinite(delta?.dy) ? delta.dy : 0;
updateMicrophone(state.arrayDefinition, microphone.id, {
position: normalizedPointerPosition([
microphone.position[0] + dx,
microphone.position[1] + dy,
microphone.position[2],
]),
});
}));
},
onToolChange(tool) {
setTool(tool);
},
onFit() {
canvasView.fit();
},
});
function renderDocumentEditor() {
elements["array-id"].value = state.arrayDefinition.id;
elements["array-name"].value = state.arrayDefinition.name;
elements["array-description"].value = state.arrayDefinition.description ?? "";
setNumber(elements["aperture-width"], state.arrayDefinition.aperture.width);
setNumber(elements["aperture-height"], state.arrayDefinition.aperture.height);
elements["aperture-shape"].value = state.apertureShape;
}
function formatCoordinate(value) {
const rounded = Math.abs(value) < 0.0000000005 ? 0 : value;
return Number(rounded).toFixed(4);
}
function renderStatistics() {
const statistics = arrayStatistics(state.arrayDefinition);
if (elements["workspace-summary"]) {
elements["workspace-summary"].textContent =
formatCoordinate(state.arrayDefinition.aperture.width) + " × " +
formatCoordinate(state.arrayDefinition.aperture.height) + " m · " +
String(statistics.count) + " channels";
}
if (elements["microphone-count"]) {
elements["microphone-count"].textContent = String(statistics.count) + " ch";
}
if (elements["minimum-spacing"]) {
elements["minimum-spacing"].textContent = statistics.minimumSpacing === null
? "—"
: (statistics.minimumSpacing * 1000).toFixed(2) + " mm";
}
if (elements["array-centroid"]) {
elements["array-centroid"].textContent =
statistics.centroid.map(formatCoordinate).join(", ") + " m";
}
if (elements["calibration-count"]) {
elements["calibration-count"].textContent =
String(statistics.calibrationCount) + " / " + String(statistics.count);
}
}
function renderMicrophoneList() {
const list = elements["microphone-list"];
const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? "";
list.replaceChildren();
state.arrayDefinition.microphones.forEach((microphone, index) => {
if (query && !microphone.id.toLowerCase().includes(query)) return;
const button = document.createElement("button");
button.type = "button";
button.className = "microphone-row";
button.dataset.microphoneId = microphone.id;
button.setAttribute("role", "option");
button.setAttribute(
"aria-selected",
String(microphone.id === state.selectedMicrophoneId),
);
button.setAttribute(
"aria-current",
String(microphone.id === state.selectedMicrophoneId),
);
button.classList.toggle("is-selected", microphone.id === state.selectedMicrophoneId);
const channel = document.createElement("span");
channel.className = "microphone-row__channel";
channel.textContent = String(index + 1);
const identity = document.createElement("span");
identity.className = "microphone-row__identity";
const strong = document.createElement("strong");
strong.textContent = microphone.id;
const coordinates = document.createElement("small");
coordinates.textContent =
"x " + formatCoordinate(microphone.position[0]) +
" · y " + formatCoordinate(microphone.position[1]) +
" · z " + formatCoordinate(microphone.position[2]);
identity.append(strong, coordinates);
if (
microphone.sensitivityDb !== undefined ||
microphone.phaseOffsetDeg !== undefined ||
microphone.frequencyResponse !== undefined
) {
const calibration = document.createElement("span");
calibration.className = "microphone-row__calibration";
calibration.textContent = "cal";
identity.append(calibration);
}
button.append(channel, identity);
button.addEventListener("click", () => selectMicrophone(microphone.id));
list.append(button);
});
if (!list.childElementCount) {
const empty = document.createElement("p");
empty.className = "microphone-list__empty";
empty.textContent = query ? "검색 결과가 없습니다." : "마이크가 없습니다.";
list.append(empty);
}
}
function syncSelectedEditor() {
const microphone = selectedMicrophone();
const inputs = [
elements["microphone-id"], elements["microphone-x"],
elements["microphone-y"], elements["microphone-z"],
elements["microphone-sensitivity"], elements["microphone-phase"],
].filter(Boolean);
for (const input of inputs) input.disabled = !microphone;
elements["microphone-selection-empty"]?.classList.toggle("is-hidden", Boolean(microphone));
elements["microphone-fields"]?.classList.toggle("is-disabled", !microphone);
elements["remove-microphone-button"].disabled =
!microphone || state.arrayDefinition.microphones.length <= 1;
if (!microphone) {
for (const input of inputs) input.value = "";
if (elements["frequency-response-summary"]) {
elements["frequency-response-summary"].textContent = "선택한 마이크가 없습니다.";
}
return;
}
elements["microphone-id"].value = microphone.id;
setNumber(elements["microphone-x"], microphone.position[0]);
setNumber(elements["microphone-y"], microphone.position[1]);
setNumber(elements["microphone-z"], microphone.position[2]);
if (elements["microphone-sensitivity"]) {
setNumber(elements["microphone-sensitivity"], microphone.sensitivityDb);
}
if (elements["microphone-phase"]) {
setNumber(elements["microphone-phase"], microphone.phaseOffsetDeg);
}
if (elements["frequency-response-summary"]) {
const count = microphone.frequencyResponse?.length ?? 0;
elements["frequency-response-summary"].textContent = count > 0
? String(count) + "개 주파수 응답점 보존 중"
: "주파수 응답점 없음";
}
const index = state.arrayDefinition.microphones.indexOf(microphone);
if (elements["move-microphone-up-button"]) {
elements["move-microphone-up-button"].disabled = index <= 0;
}
if (elements["move-microphone-down-button"]) {
elements["move-microphone-down-button"].disabled =
index < 0 || index >= state.arrayDefinition.microphones.length - 1;
}
}
function syncPresetControls() {
const type = elements["preset-type"].value;
const grid = type === "grid";
const random = type === "random";
const linear = type === "linear";
for (const element of [elements["preset-columns"], elements["preset-rows"]]) {
element.closest(".field")?.toggleAttribute("hidden", !grid);
}
elements["preset-count"].closest(".field")?.toggleAttribute("hidden", grid);
elements["preset-seed"].closest(".field")?.toggleAttribute("hidden", !random);
elements["preset-minimum-spacing"].closest(".field")?.toggleAttribute("hidden", !random);
elements["preset-axis"]?.closest(".field")?.toggleAttribute("hidden", !linear);
}
function syncHistoryButtons() {
if (elements["undo-button"]) {
elements["undo-button"].disabled = state.history.length === 0;
elements["undo-button"].title = state.history.length
? "실행 취소: " + state.history.at(-1).label
: "실행 취소할 작업이 없습니다.";
}
if (elements["redo-button"]) {
elements["redo-button"].disabled = state.future.length === 0;
elements["redo-button"].title = state.future.length
? "다시 실행: " + state.future.at(-1).label
: "다시 실행할 작업이 없습니다.";
}
}
async function validateCurrent() {
const revision = ++state.validationRevision;
const structuralIssues = await validateArrayDefinition(state.arrayDefinition);
if (revision !== state.validationRevision) return;
const geometryIssues = arrayGeometryIssues(state.arrayDefinition, {
shape: state.apertureShape,
});
const errors = [
...structuralIssues.map((issue) => ({ ...issue, severity: "error" })),
...geometryIssues.filter((issue) => issue.severity === "error"),
];
const warnings = geometryIssues.filter((issue) => issue.severity === "warning");
if (BUILTIN_ARRAY_IDS.has(state.arrayDefinition.id)) {
warnings.push({
severity: "warning",
path: "$.id",
message: "내장 배열과 같은 ID입니다. 사용자 배열을 구분하려면 다른 ID를 권장합니다.",
});
}
state.valid = errors.length === 0;
document.documentElement.dataset.arrayValid = String(state.valid);
elements["export-button"].disabled = !state.valid;
elements.validationCard?.setAttribute(
"data-severity",
errors.length ? "error" : warnings.length ? "warning" : "success",
);
elements.validationCard?.classList.toggle("is-invalid", errors.length > 0);
elements.validationCard?.classList.toggle("is-warning", errors.length === 0 && warnings.length > 0);
elements.validationCard?.classList.toggle("is-valid", errors.length === 0 && warnings.length === 0);
if (elements["validation-dot"]) {
elements["validation-dot"].dataset.severity =
errors.length ? "error" : warnings.length ? "warning" : "success";
}
if (errors.length) {
elements["validation-title"].textContent = String(errors.length) + "개 오류 · 저장 불가";
elements["validation-message"].textContent = errors
.slice(0, 3)
.map((issue) => issue.path + ": " + issue.message)
.join(" · ");
} else if (warnings.length) {
elements["validation-title"].textContent = "저장 가능 · " + String(warnings.length) + "개 확인";
elements["validation-message"].textContent = warnings
.slice(0, 3)
.map((issue) => issue.message)
.join(" · ");
} else {
elements["validation-title"].textContent = "Array JSON 저장 가능";
elements["validation-message"].textContent =
String(state.arrayDefinition.microphones.length) +
"개 채널과 표준 좌표 계약을 만족합니다.";
}
}
function scheduleValidation() {
const requestedRevision = ++state.validationRevision;
queueMicrotask(async () => {
if (requestedRevision !== state.validationRevision) return;
state.validationRevision -= 1;
await validateCurrent();
});
}
function renderAll(message) {
renderDocumentEditor();
renderStatistics();
renderMicrophoneList();
syncSelectedEditor();
syncPresetControls();
syncHistoryButtons();
setTool(state.tool);
canvasView.render(canvasState());
announce(message);
scheduleValidation();
}
function removeSelectedMicrophone() {
const microphone = selectedMicrophone();
if (!microphone) return;
safely(() => mutate("마이크 삭제됨", () => {
const index = state.arrayDefinition.microphones.indexOf(microphone);
if (!removeMicrophone(state.arrayDefinition, microphone.id)) {
throw new Error("Array에는 최소 한 개의 마이크가 필요합니다.");
}
state.selectedMicrophoneId =
state.arrayDefinition.microphones[Math.min(index, state.arrayDefinition.microphones.length - 1)]?.id ??
null;
}));
}
function applySelectedPosition() {
const microphone = selectedMicrophone();
if (!microphone) return;
const position = [
finiteInput(elements["microphone-x"], "x 좌표"),
finiteInput(elements["microphone-y"], "y 좌표"),
finiteInput(elements["microphone-z"], "z 좌표"),
];
mutate("마이크 좌표 변경됨", () => {
updateMicrophone(state.arrayDefinition, microphone.id, { position });
});
}
function presetOptions() {
return {
type: elements["preset-type"].value,
aperture: state.arrayDefinition.aperture,
shape: state.apertureShape,
count: integerInput(elements["preset-count"], "마이크 수", 1, 1024),
columns: integerInput(elements["preset-columns"], "Columns", 1, 1024),
rows: integerInput(elements["preset-rows"], "Rows", 1, 1024),
seed: integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295),
minimumSpacingM: finiteInput(
elements["preset-minimum-spacing"],
"최소 간격",
{ minimum: 0 },
),
edgeInsetM: elements["preset-edge-inset"]
? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 })
: 0,
axis: elements["preset-axis"]?.value ?? "x",
};
}
elements["array-id"].addEventListener("change", () => safely(() => {
const value = elements["array-id"].value.trim();
mutate("Array ID 변경됨", () => {
state.arrayDefinition.id = value;
});
}));
elements["array-name"].addEventListener("change", () => safely(() => {
mutate("배열 이름 변경됨", () => {
state.arrayDefinition.name = elements["array-name"].value.trim();
});
}));
elements["array-description"].addEventListener("change", () => safely(() => {
mutate("설명 변경됨", () => {
const value = elements["array-description"].value.trim();
if (value) state.arrayDefinition.description = value;
else delete state.arrayDefinition.description;
});
}));
for (const [id, property] of [
["aperture-width", "width"],
["aperture-height", "height"],
]) {
elements[id].addEventListener("change", () => safely(() => {
const value = finiteInput(elements[id], "Aperture " + property, {
exclusiveMinimum: 0,
maximum: 20,
});
mutate("Aperture 크기 변경됨", () => {
state.arrayDefinition.aperture[property] = value;
});
}));
}
elements["aperture-shape"].addEventListener("change", () => safely(() => {
mutate("생성 영역 형상 변경됨", () => {
state.apertureShape = elements["aperture-shape"].value === "ellipse"
? "ellipse"
: "rectangle";
});
}));
elements["preset-type"].addEventListener("change", syncPresetControls);
elements["generate-preset-button"].addEventListener("click", () => safely(() => {
const options = presetOptions();
const calibrationCount = arrayStatistics(state.arrayDefinition).calibrationCount;
mutate("Preset 배열 생성됨", () => {
replaceMicrophonesWithPreset(state.arrayDefinition, options);
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
});
announce(
calibrationCount > 0
? "Preset 생성됨 · 기존 calibration은 Undo로 복구할 수 있습니다."
: "Preset 배열 생성됨",
);
}));
elements["select-tool-button"].addEventListener("click", () => setTool("select", { focus: true }));
elements["add-tool-button"].addEventListener("click", () => setTool("add", { focus: true }));
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
elements["snap-enabled"].addEventListener("change", () => {
state.snapEnabled = elements["snap-enabled"].checked;
canvasView.render(canvasState());
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
});
elements["snap-step"].addEventListener("change", () => safely(() => {
const millimeters = finiteInput(elements["snap-step"], "Snap 간격", {
exclusiveMinimum: 0,
maximum: 10000,
});
state.snapStepM = millimeters / 1000;
canvasView.render(canvasState());
announce("Snap " + String(millimeters) + " mm");
}));
elements["add-microphone-button"].addEventListener("click", () => safely(() => {
mutate("마이크 추가됨", () => {
const microphone = addMicrophone(state.arrayDefinition, availableManualPosition());
state.selectedMicrophoneId = microphone.id;
});
}));
elements["remove-microphone-button"].addEventListener("click", removeSelectedMicrophone);
elements["microphone-id"].addEventListener("change", () => safely(() => {
const microphone = selectedMicrophone();
if (!microphone) return;
const previousId = microphone.id;
const nextId = elements["microphone-id"].value.trim();
mutate("마이크 ID 변경됨", () => {
updateMicrophone(state.arrayDefinition, previousId, { id: nextId });
state.selectedMicrophoneId = nextId;
});
}));
for (const id of ["microphone-x", "microphone-y", "microphone-z"]) {
elements[id].addEventListener("change", () => safely(applySelectedPosition));
}
if (elements["microphone-sensitivity"]) {
elements["microphone-sensitivity"].addEventListener("change", () => safely(() => {
const microphone = selectedMicrophone();
if (!microphone) return;
const value = elements["microphone-sensitivity"].value.trim() === ""
? null
: finiteInput(elements["microphone-sensitivity"], "Sensitivity", {
minimum: -120,
maximum: 120,
});
mutate("Sensitivity 변경됨", () => {
updateMicrophone(state.arrayDefinition, microphone.id, { sensitivityDb: value });
});
}));
}
if (elements["microphone-phase"]) {
elements["microphone-phase"].addEventListener("change", () => safely(() => {
const microphone = selectedMicrophone();
if (!microphone) return;
const value = elements["microphone-phase"].value.trim() === ""
? null
: finiteInput(elements["microphone-phase"], "Phase offset", {
minimum: -180,
maximum: 180,
});
mutate("Phase offset 변경됨", () => {
updateMicrophone(state.arrayDefinition, microphone.id, { phaseOffsetDeg: value });
});
}));
}
elements["move-microphone-up-button"]?.addEventListener("click", () => safely(() => {
const microphone = selectedMicrophone();
if (!microphone) return;
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, -1));
}));
elements["move-microphone-down-button"]?.addEventListener("click", () => safely(() => {
const microphone = selectedMicrophone();
if (!microphone) return;
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1));
}));
elements["microphone-search"]?.addEventListener("input", renderMicrophoneList);
elements["center-microphones-button"]?.addEventListener("click", () => safely(() => {
mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition));
}));
elements["fit-aperture-button"]?.addEventListener("click", () => safely(() => {
mutate("Aperture를 마이크 범위에 맞춤", () => fitApertureToMicrophones(
state.arrayDefinition,
{ shape: state.apertureShape, paddingM: 0.005 },
));
canvasView.fit();
}));
elements["clamp-microphones-button"]?.addEventListener("click", () => safely(() => {
mutate("마이크를 생성 경계 안으로 제한", () => {
for (const microphone of state.arrayDefinition.microphones) {
microphone.position = clampPositionToAperture(
microphone.position,
state.arrayDefinition.aperture,
state.apertureShape,
);
}
});
}));
function undo() {
const entry = state.history.pop();
if (!entry) return;
state.future.push({ snapshot: captureSnapshot(), label: entry.label });
restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label);
}
function redo() {
const entry = state.future.pop();
if (!entry) return;
state.history.push({ snapshot: captureSnapshot(), label: entry.label });
restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label);
}
elements["undo-button"]?.addEventListener("click", undo);
elements["redo-button"]?.addEventListener("click", redo);
window.addEventListener("keydown", (event) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable
) return;
if (event.key.toLowerCase() !== "z") return;
event.preventDefault();
if (event.shiftKey) redo();
else undo();
});
elements["array-input"].addEventListener("change", async () => {
const file = elements["array-input"].files?.[0];
if (!file) return;
try {
const value = await readJsonFile(file);
const issues = await validateArrayDefinition(value);
if (issues.length) {
const details = issues.slice(0, 4).map(
(issue) => issue.path + ": " + issue.message,
).join(" · ");
throw new Error("Array JSON 계약을 만족하지 않습니다. " + details);
}
state.arrayDefinition = prepareArrayForEditing(value);
state.apertureShape = inferApertureShape(value);
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
state.history.length = 0;
state.future.length = 0;
state.dirty = false;
canvasView.fit();
renderAll(file.name + " 불러옴");
} catch (error) {
showActionError(error);
} finally {
elements["array-input"].value = "";
}
});
elements["export-button"].addEventListener("click", async () => {
await validateCurrent();
if (!state.valid) {
showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요."));
return;
}
downloadJson(
state.arrayDefinition,
safeArrayFileStem(state.arrayDefinition) + ".array.json",
);
state.dirty = false;
announce("Array JSON 저장됨");
});
window.addEventListener("beforeunload", (event) => {
if (!state.dirty) return;
event.preventDefault();
event.returnValue = "";
});
window.addEventListener("unload", () => {
splitPane.destroy();
canvasView.destroy();
});
elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000);
renderAll("기본 4×3 배열");
canvasView.fit();
document.documentElement.dataset.appReady = "true";