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 }; }