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 };
|
||||
}
|
||||
Reference in New Issue
Block a user