feat: visualize array receive beam patterns

This commit is contained in:
2026-08-29 14:45:47 +09:00
parent 041e0c3665
commit b68cd87e3c
5 changed files with 1044 additions and 1 deletions
+249
View File
@@ -1,7 +1,9 @@
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { validateArrayDefinition } from "/packages/contracts/validation.js";
import { computeReceiveBeamPattern } from "/packages/acoustics-core/beam-pattern.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import { createArrayCanvas } from "./canvas-view.js";
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
import {
addMicrophone,
arrayGeometryIssues,
@@ -38,6 +40,13 @@ const requiredIds = [
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
"remove-microphone-button", "validation-title", "validation-message",
"beam-pattern-panel", "beam-pattern-frequency",
"beam-pattern-speed-of-sound", "beam-pattern-cut",
"beam-pattern-steering-angle", "beam-pattern-samples",
"beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status",
"beam-pattern-summary", "beam-pattern-wavelength",
"beam-pattern-resolution", "beam-pattern-steering-response",
"beam-pattern-hpbw", "beam-pattern-peaks",
];
const elements = Object.fromEntries(requiredIds.map((id) => [id, byId(id)]));
@@ -53,6 +62,7 @@ const optionalIds = [
];
for (const id of optionalIds) elements[id] = maybeById(id);
elements.validationCard = elements["validation-title"].closest(".validation-card");
const beamPatternView = createReceiveBeamPatternView(elements["beam-pattern-plot"]);
const splitPane = createSplitPane({
container: elements["array-builder-layout"],
@@ -82,6 +92,9 @@ const state = {
dragSnapshot: null,
importRevision: 0,
baselineSignature: "",
beamPatternRenderFrame: 0,
beamPatternDirty: true,
beamPatternPreviewPending: false,
};
const MAX_HISTORY = 50;
@@ -442,6 +455,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
state.selectedMicrophoneId = id;
syncSelectedEditor();
canvasView.render(canvasState());
scheduleBeamPatternRender({ preview: true });
},
onDragEnd(id, position) {
if (!state.dragSnapshot) return;
@@ -481,6 +495,226 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
},
});
const beamPatternInputIds = [
"beam-pattern-frequency",
"beam-pattern-speed-of-sound",
"beam-pattern-cut",
"beam-pattern-steering-angle",
"beam-pattern-samples",
"beam-pattern-floor",
];
function beamPatternNumber(id, label, options) {
const element = elements[id];
try {
const value = finiteInput(element, label, options);
element.removeAttribute("aria-invalid");
return value;
} catch (error) {
element.setAttribute("aria-invalid", "true");
throw error;
}
}
function readBeamPatternOptions(preview) {
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
const frequencyHz = beamPatternNumber(
"beam-pattern-frequency",
"아날로그 주파수",
{ exclusiveMinimum: 0, maximum: 1_000_000 },
);
const speedOfSound = beamPatternNumber(
"beam-pattern-speed-of-sound",
"음속",
{ minimum: 250, maximum: 400 },
);
const steeringAngleDeg = beamPatternNumber(
"beam-pattern-steering-angle",
"조향각",
{ minimum: -360, maximum: 360 },
);
const cut = elements["beam-pattern-cut"].value;
if (!["xy", "xz", "yz"].includes(cut)) {
elements["beam-pattern-cut"].setAttribute("aria-invalid", "true");
throw new Error("절단면은 XY, XZ 또는 YZ여야 합니다.");
}
const sampleValue = elements["beam-pattern-samples"].value;
const configuredSampleCount = sampleValue === "auto"
? "auto"
: Number(sampleValue);
if (
configuredSampleCount !== "auto" &&
(!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
) {
elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
throw new Error("각도 샘플 설정이 올바르지 않습니다.");
}
const dbFloor = Number(elements["beam-pattern-floor"].value);
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
throw new Error("표시 dB 하한이 올바르지 않습니다.");
}
return {
array: state.arrayDefinition,
frequencyHz,
speedOfSound,
cut,
steeringAngleDeg,
sampleCount: preview
? Math.min(
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
720,
)
: configuredSampleCount,
dbFloor,
};
}
function formatBeamFrequency(frequencyHz) {
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz";
return frequencyHz.toFixed(1) + " Hz";
}
function formatBeamWavelength(wavelengthM) {
if (wavelengthM >= 1) return wavelengthM.toFixed(4) + " m";
return (wavelengthM * 1000).toFixed(wavelengthM >= 0.01 ? 2 : 3) + " mm";
}
function formatBeamDecibels(value) {
if (!Number.isFinite(value)) return "−∞ dB";
const normalized = Math.abs(value) < 0.0005 ? 0 : value;
return normalized.toFixed(3) + " dB";
}
function formatBeamHpbw(halfPower) {
if (halfPower.status === "sampled-bracketed-bisection") {
return halfPower.widthDeg.toFixed(2) + "° · sampled bracket";
}
if (halfPower.status === "full-circle-above-half-power") {
return "정의 불가 · 전 방향 ≥ −3.0103 dB";
}
if (halfPower.status === "no-sampled-half-power-crossing") {
return "미확정 · sampled crossing 없음";
}
return "미확정 · 각도 해상도 부족";
}
function describeBeamPatternPeaks(pattern) {
const summary = pattern.metrics.sampledLocalMaxima;
if (summary.status === "resolution-unresolved") {
return "각도 해상도 기준이 충족되지 않아 peak를 분류하지 않습니다. Polar 선은 raw 표본만 표시합니다.";
}
if (summary.status === "flat-on-grid") {
return "이 절단면의 모든 표본이 수치 허용오차 안에서 같습니다. 고유 peak를 선택하지 않습니다.";
}
const displayed = summary.peaks.slice(0, 8).map((peak) =>
peak.angleDeg.toFixed(2) + "° " + formatBeamDecibels(peak.responseDb));
const remaining = Math.max(0, summary.peaks.length - displayed.length);
const prefix = summary.status === "tied-global-maxima-on-grid"
? String(summary.globalMaximumRegions.length) + "개 sampled 전역 최대 동률 · "
: "sampled 전역 최대 1개 · ";
return prefix + displayed.join(" · ") +
(remaining ? " · 외 " + String(remaining) + "개" : "") +
" · 자동 sidelobe/grating-lobe 분류는 하지 않습니다.";
}
function setBeamPatternStatus(message, severity = "success") {
elements["beam-pattern-status"].textContent = message;
elements["beam-pattern-status"].dataset.severity = severity;
}
function renderBeamPattern(preview = false) {
const startedAt = performance.now();
try {
const options = readBeamPatternOptions(preview);
const pattern = computeReceiveBeamPattern(options);
beamPatternView.render(pattern);
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "true";
document.documentElement.dataset.beamPatternPreview = String(preview);
elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount);
elements["beam-pattern-plot"].dataset.cut = pattern.cut;
elements["beam-pattern-plot"].dataset.frequencyHz = String(pattern.frequencyHz);
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(pattern.frequencyHz) + " · " +
pattern.cut.toUpperCase() + " · " +
pattern.steeringAngleDeg.toFixed(1) + "° 조향";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(pattern.wavelengthM);
elements["beam-pattern-resolution"].textContent =
pattern.sampleCount.toLocaleString("ko-KR") + " · " +
pattern.angularResolutionDeg.toFixed(4) + "°";
elements["beam-pattern-steering-response"].textContent =
formatBeamDecibels(pattern.metrics.steeringDb);
elements["beam-pattern-hpbw"].textContent = preview
? "드래그 종료 후 계산"
: formatBeamHpbw(pattern.metrics.halfPowerBeamwidth);
if (!preview) {
elements["beam-pattern-peaks"].textContent =
describeBeamPatternPeaks(pattern);
const elapsedMs = performance.now() - startedAt;
document.documentElement.dataset.beamPatternComputeMs =
elapsedMs.toFixed(3);
if (!pattern.angularSampling.metricResolutionSatisfied) {
setBeamPatternStatus(
"Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " +
pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) +
" rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." +
(pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") +
" · " + elapsedMs.toFixed(1) + " ms",
"warning",
);
} else if (
pattern.metrics.halfPowerBeamwidth.status ===
"no-sampled-half-power-crossing"
) {
setBeamPatternStatus(
"π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " +
"전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"π/8 인접 위상 간격 기준 충족 · " +
pattern.sampleCount.toLocaleString("ko-KR") + " angles · " +
pattern.evaluationCount.toLocaleString("ko-KR") +
" phasor evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
} catch (error) {
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "error";
document.documentElement.dataset.beamPatternPreview = "false";
const message = error instanceof Error ? error.message : String(error);
beamPatternView.renderError(message);
setBeamPatternStatus(message, "error");
elements["beam-pattern-hpbw"].textContent = "—";
elements["beam-pattern-peaks"].textContent =
"빔 패턴 설정을 수정하면 다시 계산합니다.";
}
}
function scheduleBeamPatternRender(options = {}) {
const preview = options.preview === true;
state.beamPatternDirty = true;
if (!elements["beam-pattern-panel"].open) return;
if (state.beamPatternRenderFrame) {
state.beamPatternPreviewPending = preview;
return;
}
state.beamPatternPreviewPending = preview;
state.beamPatternRenderFrame = requestAnimationFrame(() => {
const pendingPreview = state.beamPatternPreviewPending;
state.beamPatternRenderFrame = 0;
state.beamPatternPreviewPending = false;
renderBeamPattern(pendingPreview);
});
}
function renderDocumentEditor() {
elements["array-id"].value = state.arrayDefinition.id;
elements["array-name"].value = state.arrayDefinition.name;
@@ -738,6 +972,7 @@ function renderAll(message) {
syncHistoryButtons();
setTool(state.tool);
canvasView.render(canvasState());
scheduleBeamPatternRender();
announce(message);
scheduleValidation();
}
@@ -866,6 +1101,16 @@ elements["snap-enabled"].addEventListener("change", () => {
canvasView.render(canvasState());
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
});
for (const id of beamPatternInputIds) {
const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change";
elements[id].addEventListener(eventName, () => scheduleBeamPatternRender());
}
elements["beam-pattern-panel"].addEventListener("toggle", () => {
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
scheduleBeamPatternRender();
}
});
elements["snap-step"].addEventListener("change", () => safely(() => {
const millimeters = finiteInput(elements["snap-step"], "Snap 간격", {
exclusiveMinimum: 0,
@@ -1066,6 +1311,10 @@ window.addEventListener("beforeunload", (event) => {
event.returnValue = "";
});
window.addEventListener("unload", () => {
if (state.beamPatternRenderFrame) {
cancelAnimationFrame(state.beamPatternRenderFrame);
}
beamPatternView.destroy();
splitPane.destroy();
canvasView.destroy();
});