feat: visualize array receive beam patterns
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -90,6 +90,122 @@
|
||||
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
|
||||
</section>
|
||||
|
||||
<details class="beam-pattern-card" id="beam-pattern-panel" open>
|
||||
<summary class="beam-pattern-heading">
|
||||
<div>
|
||||
<p class="eyebrow">배열 분석</p>
|
||||
<h2>수신 빔 패턴</h2>
|
||||
</div>
|
||||
<span id="beam-pattern-summary">1 kHz · XZ · +Z 조향</span>
|
||||
</summary>
|
||||
|
||||
<div class="beam-pattern-body">
|
||||
<div class="beam-pattern-controls">
|
||||
<p class="beam-pattern-model">
|
||||
Narrowband far-field conventional delay-and-sum
|
||||
<span>geometry-only</span>
|
||||
</p>
|
||||
|
||||
<div class="beam-pattern-fields">
|
||||
<label class="field">
|
||||
<span>아날로그 주파수 (Hz)</span>
|
||||
<input
|
||||
id="beam-pattern-frequency"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000000"
|
||||
step="100"
|
||||
value="1000"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>음속 (m/s)</span>
|
||||
<input
|
||||
id="beam-pattern-speed-of-sound"
|
||||
type="number"
|
||||
min="250"
|
||||
max="400"
|
||||
step="0.1"
|
||||
value="343"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>360° 절단면</span>
|
||||
<select id="beam-pattern-cut">
|
||||
<option value="xz">XZ · 0° +Z / 90° +X</option>
|
||||
<option value="yz">YZ · 0° +Z / 90° +Y</option>
|
||||
<option value="xy">XY · 0° +X / 90° +Y</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>조향각 (deg)</span>
|
||||
<input
|
||||
id="beam-pattern-steering-angle"
|
||||
type="number"
|
||||
min="-360"
|
||||
max="360"
|
||||
step="0.5"
|
||||
value="0"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>각도 샘플</span>
|
||||
<select id="beam-pattern-samples">
|
||||
<option value="auto">자동 · 위상 간격 기준</option>
|
||||
<option value="360">360 · 1°</option>
|
||||
<option value="720">720 · 0.5°</option>
|
||||
<option value="1440">1,440 · 0.25°</option>
|
||||
<option value="2880">2,880 · 0.125°</option>
|
||||
<option value="5760">5,760 · 0.0625°</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>표시 하한 (dB)</span>
|
||||
<select id="beam-pattern-floor">
|
||||
<option value="-30">−30 dB</option>
|
||||
<option value="-40">−40 dB</option>
|
||||
<option value="-60" selected>−60 dB</option>
|
||||
<option value="-80">−80 dB</option>
|
||||
<option value="-100">−100 dB</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="beam-pattern-metrics" aria-label="빔 패턴 수치">
|
||||
<article><span>파장</span><strong id="beam-pattern-wavelength">—</strong></article>
|
||||
<article><span>각도 해상도</span><strong id="beam-pattern-resolution">—</strong></article>
|
||||
<article><span>조향 응답</span><strong id="beam-pattern-steering-response">—</strong></article>
|
||||
<article><span>−3.0103 dB 폭</span><strong id="beam-pattern-hpbw">—</strong></article>
|
||||
</div>
|
||||
|
||||
<p class="beam-pattern-status" id="beam-pattern-status" role="status" aria-live="polite">
|
||||
현재 배열의 수신 array factor를 계산하고 있습니다.
|
||||
</p>
|
||||
<p class="beam-pattern-note" id="beam-pattern-model-note">
|
||||
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 단면입니다. 3D 전체 패턴,
|
||||
근거리, 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로
|
||||
Nyquist 초과 주파수도 접지 않고 원래 파장으로 계산합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<figure class="beam-pattern-figure">
|
||||
<svg
|
||||
id="beam-pattern-plot"
|
||||
viewBox="0 0 520 520"
|
||||
role="img"
|
||||
aria-label="수신 빔 패턴 polar plot"
|
||||
aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-peaks"
|
||||
></svg>
|
||||
<figcaption id="beam-pattern-peaks">
|
||||
국소 최대를 계산하고 있습니다.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="shortcut-help">
|
||||
<summary>빠른 편집 키</summary>
|
||||
<div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user