빠른 편집 키
diff --git a/apps/array-builder/main.js b/apps/array-builder/main.js
index 9b6e76e..1f6c548 100644
--- a/apps/array-builder/main.js
+++ b/apps/array-builder/main.js
@@ -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();
});
diff --git a/apps/array-builder/styles.css b/apps/array-builder/styles.css
index d4e2185..e3acd06 100644
--- a/apps/array-builder/styles.css
+++ b/apps/array-builder/styles.css
@@ -571,6 +571,220 @@ details > summary::-webkit-details-marker {
}
.inline-details > summary,
+.beam-pattern-card {
+ container: beam-pattern / inline-size;
+ margin-top: 10px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgb(255 255 255 / 2%);
+}
+
+.beam-pattern-heading {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 16px;
+ cursor: pointer;
+}
+
+.beam-pattern-heading::after {
+ color: var(--mint);
+ content: "+";
+ font-size: 1rem;
+}
+
+.beam-pattern-card[open] > .beam-pattern-heading::after {
+ content: "−";
+}
+
+.beam-pattern-heading > div {
+ min-width: 0;
+}
+
+.beam-pattern-heading .eyebrow {
+ margin: 0 0 3px;
+ font-size: 0.61rem;
+}
+
+.beam-pattern-heading h2 {
+ margin: 0;
+ font-size: 0.94rem;
+}
+
+.beam-pattern-heading > span {
+ min-width: 0;
+ margin-left: auto;
+ color: var(--muted);
+ font: 650 0.65rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+.beam-pattern-body {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: minmax(270px, 0.82fr) minmax(340px, 1.18fr);
+ gap: 18px;
+ padding: 16px;
+ border-top: 1px solid var(--line);
+}
+
+.beam-pattern-controls,
+.beam-pattern-figure {
+ min-width: 0;
+}
+
+.beam-pattern-model {
+ display: flex;
+ min-width: 0;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 7px;
+ margin: 0 0 12px;
+ color: #cfe2db;
+ font: 680 0.64rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ overflow-wrap: anywhere;
+}
+
+.beam-pattern-model span {
+ padding: 3px 6px;
+ border: 1px solid rgb(88 199 232 / 28%);
+ border-radius: 999px;
+ color: var(--cyan);
+ background: rgb(88 199 232 / 7%);
+ font-size: 0.55rem;
+}
+
+.beam-pattern-fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 9px;
+}
+
+.beam-pattern-metrics {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+ margin-top: 11px;
+}
+
+.beam-pattern-metrics article {
+ min-width: 0;
+ padding: 9px 10px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: #0b1714;
+}
+
+.beam-pattern-metrics span,
+.beam-pattern-metrics strong {
+ display: block;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.beam-pattern-metrics span {
+ margin-bottom: 4px;
+ color: var(--muted);
+ font-size: 0.57rem;
+}
+
+.beam-pattern-metrics strong {
+ font: 720 0.68rem ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.beam-pattern-status {
+ margin: 11px 0 0;
+ padding: 9px 10px;
+ border: 1px solid rgb(101 242 191 / 22%);
+ border-radius: 9px;
+ color: #cfe2db;
+ background: rgb(101 242 191 / 4%);
+ font-size: 0.64rem;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.beam-pattern-status[data-severity="warning"] {
+ border-color: rgb(255 197 110 / 28%);
+ color: #ffe0a8;
+ background: rgb(255 197 110 / 5%);
+}
+
+.beam-pattern-status[data-severity="error"] {
+ border-color: rgb(255 123 115 / 34%);
+ color: #ffc1bd;
+ background: rgb(255 123 115 / 6%);
+}
+
+.beam-pattern-note {
+ margin: 9px 0 0;
+ color: var(--muted);
+ font-size: 0.59rem;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.beam-pattern-figure {
+ display: grid;
+ align-content: start;
+ justify-items: center;
+ gap: 9px;
+ margin: 0;
+}
+
+#beam-pattern-plot {
+ display: block;
+ width: min(100%, 580px);
+ height: auto;
+ aspect-ratio: 1;
+ overflow: visible;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ color: var(--mint);
+ background: #07110f;
+}
+
+.beam-pattern-figure figcaption {
+ width: min(100%, 580px);
+ min-height: 2.8em;
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.61rem;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.beam-pattern-ring,
+.beam-pattern-axis {
+ stroke: rgb(145 170 162 / 28%);
+ stroke-width: 1;
+}
+
+.beam-pattern-ring-label {
+ fill: #76948a;
+}
+
+.beam-pattern-axis-labels {
+ color: #b9cec7;
+}
+
+.beam-pattern-steering {
+ color: var(--amber);
+}
+
+.beam-pattern-response {
+ color: var(--mint);
+}
+
+.beam-pattern-error {
+ color: #ffc1bd;
+ font: 650 13px system-ui, sans-serif;
+}
+
.shortcut-help > summary {
position: relative;
padding: 10px 32px 10px 11px;
@@ -873,6 +1087,27 @@ body[data-array-tool="add"] #array-canvas,
display: none !important;
}
+@container beam-pattern (max-width: 760px) {
+ .beam-pattern-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@container beam-pattern (max-width: 460px) {
+ .beam-pattern-heading {
+ align-items: flex-start;
+ }
+
+ .beam-pattern-heading > span {
+ display: none;
+ }
+
+ .beam-pattern-fields,
+ .beam-pattern-metrics {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
@media (max-width: 1280px) {
.array-workflow li {
font-size: 0;
diff --git a/tests/integration/array-builder-ui.test.js b/tests/integration/array-builder-ui.test.js
index af8e804..9301f50 100644
--- a/tests/integration/array-builder-ui.test.js
+++ b/tests/integration/array-builder-ui.test.js
@@ -98,6 +98,95 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
() => documentObject.querySelector("#array-canvas").width > 0,
"Array canvas 렌더",
);
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternReady === "true",
+ "기본 수신 빔 패턴 렌더",
+ );
+ const beamPlot = documentObject.querySelector("#beam-pattern-plot");
+ const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
+ ?.getAttribute("d");
+ assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
+ assertEqual(
+ beamPlot.querySelectorAll(".beam-pattern-ring").length,
+ 5,
+ "polar plot has four inner dB rings and the 0 dB ring",
+ );
+ assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
+ assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
+ assert(
+ Number(beamPlot.dataset.sampleCount) >= 720,
+ "auto angular sampling uses at least 720 directions",
+ );
+ assert(
+ documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
+ "Nyquist 초과",
+ ),
+ "UI explains that the geometry-only pattern does not fold analog frequency",
+ );
+
+ const initialSaveState = documentObject.querySelector("#save-state").textContent;
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector("#beam-pattern-frequency"),
+ 49_125,
+ "input",
+ );
+ await waitFor(
+ () => beamPlot.dataset.frequencyHz === "49125" &&
+ Number(beamPlot.dataset.sampleCount) > 720,
+ "above-Nyquist-style analog pattern with auto angular sampling",
+ );
+ assert(
+ documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
+ "high analog frequency uses its short physical wavelength",
+ );
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector("#beam-pattern-samples"),
+ 360,
+ );
+ await waitFor(
+ () => beamPlot.dataset.sampleCount === "360" &&
+ documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
+ "해상도 부족",
+ ),
+ "coarse angular grid exposes unresolved metrics",
+ );
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector("#beam-pattern-frequency"),
+ "",
+ "input",
+ );
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternReady === "error",
+ "invalid beam preview setting",
+ );
+ assert(
+ !documentObject.querySelector("#export-button").disabled,
+ "invalid preview never blocks valid Array export",
+ );
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector("#beam-pattern-samples"),
+ "auto",
+ );
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector("#beam-pattern-frequency"),
+ 1_000,
+ "input",
+ );
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternReady === "true" &&
+ beamPlot.dataset.frequencyHz === "1000",
+ "beam preview recovery",
+ );
+ assertEqual(
+ documentObject.querySelector("#save-state").textContent,
+ initialSaveState,
+ "beam preview settings are outside Array dirty state",
+ );
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
assertEqual(
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
@@ -143,7 +232,12 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
dispatchDragPointer("pointerdown", 0, 0, 1);
dispatchDragPointer("pointermove", 36, -24, 1);
dispatchDragPointer("pointerup", 36, -24, 0);
- await nextPaint(windowObject);
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
+ beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
+ initialBeamPath,
+ "beam pattern follows edited microphone geometry",
+ );
const draggedPosition = selectedCoordinates(documentObject);
assert(
draggedPosition[0] !== initialFirstPosition[0] &&
@@ -444,6 +538,7 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
assert(!Object.hasOwn(exported, "shape"), "editor shape is not exported");
assert(!Object.hasOwn(exported, "preset"), "editor preset is not exported");
assert(!Object.hasOwn(exported, "seed"), "editor seed is not exported");
+ assert(!Object.hasOwn(exported, "beamPattern"), "beam preview is not exported");
const calibrated = exported.microphones.find(
(microphone) => microphone.id === "reference-channel",
);
@@ -694,6 +789,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.documentElement.clientWidth + 1,
"Array Builder has no horizontal overflow at 390px",
);
+ const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
+ .getBoundingClientRect();
+ const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
+ .getBoundingClientRect();
+ assert(
+ compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
+ compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
+ "compact polar plot stays inside its analysis card",
+ );
+ assert(
+ Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
+ "compact polar plot remains square",
+ );
+ const compactBeamFields = [
+ ...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
+ ].map((field) => field.getBoundingClientRect());
+ assert(
+ compactBeamFields.every(
+ (rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
+ ),
+ "compact beam controls stack without clipping",
+ );
assert(
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"Array canvas remains usable at 390px",