Files

327 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}