feat: add interactive microphone array builder
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
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";
|
||||
Reference in New Issue
Block a user