diff --git a/apps/array-builder/beam-pattern-view.js b/apps/array-builder/beam-pattern-view.js new file mode 100644 index 0000000..faf36a2 --- /dev/null +++ b/apps/array-builder/beam-pattern-view.js @@ -0,0 +1,326 @@ +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const VIEWBOX_SIZE = 640; +const CENTER = VIEWBOX_SIZE / 2; +const PLOT_RADIUS = 244; + +let viewSequence = 0; + +function setAttributes(element, attributes) { + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, String(value)); + } + return element; +} + +function isNumericSequence(value) { + return Array.isArray(value) || ArrayBuffer.isView(value); +} + +function formatDb(value) { + const rounded = Math.round(value * 10) / 10; + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} dB`; +} + +function normalizeCut(cut) { + const normalized = String(cut ?? "").trim().toUpperCase(); + if (normalized !== "XY" && normalized !== "XZ" && normalized !== "YZ") { + throw new TypeError("cut은 XY, XZ 또는 YZ여야 합니다."); + } + return normalized; +} + +function getAxisLabels(cut) { + if (cut === "XY") { + return { right: "+X", top: "+Y", left: "−X", bottom: "−Y" }; + } + if (cut === "XZ") { + return { right: "+Z", top: "+X", left: "−Z", bottom: "−X" }; + } + return { right: "+Z", top: "+Y", left: "−Z", bottom: "−Y" }; +} + +function polarPoint(angleDeg, radius) { + const radians = (angleDeg * Math.PI) / 180; + return { + x: CENTER + radius * Math.cos(radians), + y: CENTER - radius * Math.sin(radians), + }; +} + +function validatePattern(pattern) { + if (!pattern || typeof pattern !== "object") { + throw new TypeError("빔 패턴 결과가 없습니다."); + } + + const { displayDb, anglesDeg } = pattern; + if (!isNumericSequence(displayDb) || !isNumericSequence(anglesDeg)) { + throw new TypeError("displayDb와 anglesDeg는 수치 배열이어야 합니다."); + } + if (displayDb.length === 0 || displayDb.length !== anglesDeg.length) { + throw new RangeError("displayDb와 anglesDeg는 같은 길이의 비어 있지 않은 배열이어야 합니다."); + } + + const dbFloor = Number(pattern.dbFloor); + const steeringAngleDeg = Number(pattern.steeringAngleDeg); + if (!Number.isFinite(dbFloor) || dbFloor >= 0) { + throw new RangeError("dbFloor는 0보다 작은 유한한 값이어야 합니다."); + } + if (!Number.isFinite(steeringAngleDeg)) { + throw new RangeError("steeringAngleDeg는 유한한 값이어야 합니다."); + } + + for (let index = 0; index < displayDb.length; index += 1) { + if (!Number.isFinite(Number(displayDb[index])) || !Number.isFinite(Number(anglesDeg[index]))) { + throw new RangeError("빔 패턴 표본에는 유한한 수치만 사용할 수 있습니다."); + } + } + + return { + displayDb, + anglesDeg, + dbFloor, + steeringAngleDeg, + cut: normalizeCut(pattern.cut), + }; +} + +/** + * Core 수신 빔 패턴 결과를 log-dB polar SVG로 표시한다. + * 입력 표본은 재정렬, 평활화 또는 peak 정규화하지 않는다. + */ +export function createReceiveBeamPatternView(svg) { + if ( + !svg || + String(svg.localName).toLowerCase() !== "svg" || + typeof svg.replaceChildren !== "function" || + !svg.ownerDocument + ) { + throw new TypeError("createReceiveBeamPatternView에는 SVG 요소가 필요합니다."); + } + + const document = svg.ownerDocument; + const idPrefix = `receive-beam-pattern-${++viewSequence}`; + const titleId = `${idPrefix}-title`; + const descriptionId = `${idPrefix}-description`; + let destroyed = false; + + svg.setAttribute("viewBox", `0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-labelledby", `${titleId} ${descriptionId}`); + + function createElement(name, attributes = {}) { + return setAttributes(document.createElementNS(SVG_NAMESPACE, name), attributes); + } + + function createAccessibilityNodes(titleText, descriptionText) { + const title = createElement("title", { id: titleId }); + title.textContent = titleText; + const description = createElement("desc", { id: descriptionId }); + description.textContent = descriptionText; + return [title, description]; + } + + function createText(text, attributes = {}) { + const node = createElement("text", attributes); + node.textContent = text; + return node; + } + + function renderError(message) { + if (destroyed) return; + + const errorMessage = String(message || "빔 패턴을 표시할 수 없습니다."); + const [title, description] = createAccessibilityNodes( + "수신 빔 패턴을 표시할 수 없음", + errorMessage, + ); + const errorText = createText(errorMessage, { + x: CENTER, + y: CENTER, + class: "beam-pattern-error", + "text-anchor": "middle", + "dominant-baseline": "middle", + fill: "currentColor", + }); + svg.replaceChildren(title, description, errorText); + } + + function render(pattern) { + if (destroyed) return; + + let validated; + try { + validated = validatePattern(pattern); + } catch (error) { + renderError(error instanceof Error ? error.message : String(error)); + return; + } + + const { displayDb, anglesDeg, dbFloor, steeringAngleDeg, cut } = validated; + const [title, description] = createAccessibilityNodes( + `${cut} 수신 빔 패턴`, + `${cut} 평면, ${displayDb.length}개 원시 각도 표본, 표시 하한 ${formatDb(dbFloor)}, 조향각 ${steeringAngleDeg}도. 0도는 오른쪽, 양의 90도는 위쪽입니다.`, + ); + const plot = createElement("g", { class: "beam-pattern-plot" }); + const grid = createElement("g", { + class: "beam-pattern-grid", + fill: "none", + stroke: "currentColor", + "stroke-opacity": "0.24", + }); + + for (let index = 1; index <= 4; index += 1) { + const fraction = index / 5; + const radius = PLOT_RADIUS * fraction; + const level = dbFloor + (0 - dbFloor) * fraction; + grid.append( + createElement("circle", { + cx: CENTER, + cy: CENTER, + r: radius, + class: "beam-pattern-ring", + "vector-effect": "non-scaling-stroke", + }), + createText(formatDb(level), { + x: CENTER + 6, + y: CENTER - radius + 16, + class: "beam-pattern-ring-label", + fill: "currentColor", + "fill-opacity": "0.68", + stroke: "none", + "font-size": "13", + }), + ); + } + + grid.append( + createElement("circle", { + cx: CENTER, + cy: CENTER, + r: PLOT_RADIUS, + class: "beam-pattern-ring beam-pattern-ring-outer", + "vector-effect": "non-scaling-stroke", + }), + createElement("line", { + x1: CENTER - PLOT_RADIUS, + y1: CENTER, + x2: CENTER + PLOT_RADIUS, + y2: CENTER, + class: "beam-pattern-axis", + "vector-effect": "non-scaling-stroke", + }), + createElement("line", { + x1: CENTER, + y1: CENTER - PLOT_RADIUS, + x2: CENTER, + y2: CENTER + PLOT_RADIUS, + class: "beam-pattern-axis", + "vector-effect": "non-scaling-stroke", + }), + createText("0 dB", { + x: CENTER + 6, + y: CENTER - PLOT_RADIUS + 16, + class: "beam-pattern-ring-label beam-pattern-ring-label-outer", + fill: "currentColor", + "fill-opacity": "0.8", + stroke: "none", + "font-size": "13", + }), + ); + plot.append(grid); + + const labels = getAxisLabels(cut); + const axisLabels = createElement("g", { + class: "beam-pattern-axis-labels", + fill: "currentColor", + "font-size": "17", + "font-weight": "600", + }); + axisLabels.append( + createText(labels.right, { + x: CENTER + PLOT_RADIUS + 20, + y: CENTER, + "text-anchor": "start", + "dominant-baseline": "middle", + }), + createText(labels.top, { + x: CENTER, + y: CENTER - PLOT_RADIUS - 20, + "text-anchor": "middle", + "dominant-baseline": "auto", + }), + createText(labels.left, { + x: CENTER - PLOT_RADIUS - 20, + y: CENTER, + "text-anchor": "end", + "dominant-baseline": "middle", + }), + createText(labels.bottom, { + x: CENTER, + y: CENTER + PLOT_RADIUS + 28, + "text-anchor": "middle", + "dominant-baseline": "hanging", + }), + ); + plot.append(axisLabels); + + const steeringEnd = polarPoint(steeringAngleDeg, PLOT_RADIUS); + const steering = createElement("g", { class: "beam-pattern-steering" }); + steering.append( + createElement("line", { + x1: CENTER, + y1: CENTER, + x2: steeringEnd.x, + y2: steeringEnd.y, + class: "beam-pattern-steering-ray", + stroke: "currentColor", + "stroke-opacity": "0.72", + "stroke-dasharray": "8 6", + "vector-effect": "non-scaling-stroke", + }), + createElement("circle", { + cx: steeringEnd.x, + cy: steeringEnd.y, + r: 5, + class: "beam-pattern-steering-point", + fill: "currentColor", + }), + ); + plot.append(steering); + + const pathCommands = []; + for (let index = 0; index < displayDb.length; index += 1) { + const radius = PLOT_RADIUS * ((Number(displayDb[index]) - dbFloor) / (0 - dbFloor)); + const point = polarPoint(Number(anglesDeg[index]), radius); + pathCommands.push(`${index === 0 ? "M" : "L"}${point.x} ${point.y}`); + } + pathCommands.push("Z"); + + plot.append( + createElement("path", { + d: pathCommands.join(" "), + class: "beam-pattern-response", + fill: "currentColor", + "fill-opacity": "0.12", + stroke: "currentColor", + "stroke-width": "2.25", + "stroke-linejoin": "round", + "vector-effect": "non-scaling-stroke", + }), + ); + + svg.replaceChildren(title, description, plot); + } + + function destroy() { + if (destroyed) return; + destroyed = true; + svg.replaceChildren(); + if (svg.getAttribute("aria-labelledby") === `${titleId} ${descriptionId}`) { + svg.removeAttribute("aria-labelledby"); + } + } + + return { render, renderError, destroy }; +} diff --git a/apps/array-builder/index.html b/apps/array-builder/index.html index f7ac1ae..7158485 100644 --- a/apps/array-builder/index.html +++ b/apps/array-builder/index.html @@ -90,6 +90,122 @@
보정 채널0sensitivity / phase / response
+
+ +
+

배열 분석

+

수신 빔 패턴

+
+ 1 kHz · XZ · +Z 조향 +
+ +
+
+

+ Narrowband far-field conventional delay-and-sum + geometry-only +

+ +
+ + + + + + +
+ +
+
파장—
+
각도 해상도—
+
조향 응답—
+
−3.0103 dB 폭—
+
+ +

+ 현재 배열의 수신 array factor를 계산하고 있습니다. +

+

+ 이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 단면입니다. 3D 전체 패턴, + 근거리, 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로 + Nyquist 초과 주파수도 접지 않고 원래 파장으로 계산합니다. +

+
+ +
+ +
+ 국소 최대를 계산하고 있습니다. +
+
+
+
+
빠른 편집 키
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",