feat: add live multi-plane and 3d array views
This commit is contained in:
+385
-106
@@ -1,8 +1,12 @@
|
||||
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 {
|
||||
computeReceiveBeamPattern,
|
||||
computeReceiveBeamPatternSphere,
|
||||
} from "/packages/acoustics-core/beam-pattern.js";
|
||||
import { createSplitPane } from "/packages/ui/split-pane.js";
|
||||
import { createArrayCanvas } from "./canvas-view.js";
|
||||
import { createReceiveBeamPatternSurfaceView } from "./beam-pattern-surface-view.js";
|
||||
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
|
||||
import {
|
||||
addMicrophone,
|
||||
@@ -36,14 +40,22 @@ const requiredIds = [
|
||||
"aperture-shape", "preset-type", "preset-count", "preset-columns",
|
||||
"preset-rows", "preset-seed", "preset-minimum-spacing",
|
||||
"generate-preset-button", "array-canvas", "select-tool-button",
|
||||
"add-tool-button", "fit-view-button", "zoom-in-button", "zoom-out-button",
|
||||
"add-tool-button", "array-view-2d-button", "array-view-3d-button",
|
||||
"canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
|
||||
"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-panel", "beam-pattern-frequency", "beam-pattern-frequency-value",
|
||||
"beam-pattern-speed-of-sound", "beam-pattern-steering-angle",
|
||||
"beam-pattern-steering-azimuth", "beam-pattern-steering-elevation",
|
||||
"beam-pattern-samples", "beam-pattern-surface-resolution",
|
||||
"beam-pattern-floor", "beam-pattern-view-cuts-button",
|
||||
"beam-pattern-view-3d-button", "beam-pattern-cuts-view",
|
||||
"beam-pattern-surface-view", "beam-pattern-surface",
|
||||
"beam-pattern-plot-xz", "beam-pattern-plot-yz", "beam-pattern-plot-xy",
|
||||
"beam-pattern-caption-xz", "beam-pattern-caption-yz",
|
||||
"beam-pattern-caption-xy", "beam-pattern-surface-caption",
|
||||
"beam-pattern-status",
|
||||
"beam-pattern-summary", "beam-pattern-wavelength",
|
||||
"beam-pattern-resolution", "beam-pattern-steering-response",
|
||||
"beam-pattern-hpbw", "beam-pattern-peaks",
|
||||
@@ -62,7 +74,14 @@ 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 BEAM_PATTERN_CUTS = Object.freeze(["xz", "yz", "xy"]);
|
||||
const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
|
||||
cut,
|
||||
createReceiveBeamPatternView(elements["beam-pattern-plot-" + cut]),
|
||||
]));
|
||||
const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
|
||||
elements["beam-pattern-surface"],
|
||||
);
|
||||
|
||||
const splitPane = createSplitPane({
|
||||
container: elements["array-builder-layout"],
|
||||
@@ -92,6 +111,8 @@ const state = {
|
||||
dragSnapshot: null,
|
||||
importRevision: 0,
|
||||
baselineSignature: "",
|
||||
arrayViewMode: "2d",
|
||||
beamPatternViewMode: "cuts",
|
||||
beamPatternRenderFrame: 0,
|
||||
beamPatternDirty: true,
|
||||
beamPatternPreviewPending: false,
|
||||
@@ -337,6 +358,7 @@ function dismissCanvasInstructions() {
|
||||
}
|
||||
|
||||
function setTool(tool, options = {}) {
|
||||
if (state.arrayViewMode === "3d" && tool === "add") return state.tool;
|
||||
state.tool = tool === "add" ? "add" : "select";
|
||||
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
|
||||
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
|
||||
@@ -351,12 +373,66 @@ function setTool(tool, options = {}) {
|
||||
if (options.focus) canvasView.focus();
|
||||
}
|
||||
|
||||
function setArrayViewMode(mode, options = {}) {
|
||||
const resolved = mode === "3d" ? "3d" : "2d";
|
||||
state.arrayViewMode = resolved;
|
||||
if (resolved === "3d") state.tool = "select";
|
||||
elements["array-view-2d-button"].classList.toggle("is-active", resolved === "2d");
|
||||
elements["array-view-3d-button"].classList.toggle("is-active", resolved === "3d");
|
||||
elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d"));
|
||||
elements["array-view-3d-button"].setAttribute("aria-pressed", String(resolved === "3d"));
|
||||
elements["add-tool-button"].disabled = resolved === "3d";
|
||||
elements["select-tool-button"].disabled = resolved === "3d";
|
||||
const instruction = elements["canvas-instructions"];
|
||||
if (instruction) {
|
||||
const title = instruction.querySelector("strong");
|
||||
const detail = instruction.querySelector("span");
|
||||
if (title) {
|
||||
title.textContent = resolved === "3d"
|
||||
? "드래그하여 배열을 회전하고 채널을 클릭해 선택하세요."
|
||||
: "마이크를 클릭하거나 드래그해 위치를 편집하세요.";
|
||||
}
|
||||
if (detail) {
|
||||
detail.textContent = resolved === "3d"
|
||||
? "3D는 검사 전용입니다. 방향키로 회전하고 R로 초기화하며, 좌표 수정은 2D 또는 x/y/z 입력에서 합니다."
|
||||
: "추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.";
|
||||
}
|
||||
instruction.classList.remove("is-hidden");
|
||||
}
|
||||
elements["canvas-front-badge"].textContent = resolved === "3d"
|
||||
? "+Z FRONT · 초록 축"
|
||||
: "+Z FRONT · 화면 바깥쪽";
|
||||
elements["array-canvas"].setAttribute(
|
||||
"aria-label",
|
||||
resolved === "3d"
|
||||
? "마이크 어레이 3D 검사 canvas, 드래그하여 회전"
|
||||
: "마이크 어레이 평면 편집 canvas",
|
||||
);
|
||||
elements["array-canvas"].setAttribute(
|
||||
"aria-keyshortcuts",
|
||||
resolved === "3d"
|
||||
? "ArrowLeft ArrowRight ArrowUp ArrowDown F R Escape"
|
||||
: "V A F Escape Delete ArrowLeft ArrowRight ArrowUp ArrowDown",
|
||||
);
|
||||
setTool(state.tool);
|
||||
canvasView.render(canvasState());
|
||||
if (options.focus) canvasView.focus();
|
||||
if (options.announce !== false) {
|
||||
announceSelection(
|
||||
resolved === "3d"
|
||||
? "3D 검사 · +Z가 FRONT입니다."
|
||||
: "2D 편집 · +Z FRONT는 화면 바깥 방향입니다.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function canvasState() {
|
||||
return {
|
||||
arrayDefinition: state.arrayDefinition,
|
||||
apertureShape: state.apertureShape,
|
||||
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||
tool: state.tool,
|
||||
viewMode: state.arrayViewMode,
|
||||
snapStepM: state.snapEnabled ? state.snapStepM : 0,
|
||||
};
|
||||
}
|
||||
@@ -496,13 +572,24 @@ 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-steering-azimuth",
|
||||
"beam-pattern-steering-elevation",
|
||||
"beam-pattern-samples",
|
||||
"beam-pattern-surface-resolution",
|
||||
"beam-pattern-floor",
|
||||
];
|
||||
const BEAM_FREQUENCY_LOG_STEPS_PER_DECADE = 1000;
|
||||
const BEAM_PATTERN_LIVE_EVALUATION_BUDGET = 2_400_000;
|
||||
const BEAM_PATTERN_FULL_EVALUATION_BUDGET = 16_000_000;
|
||||
|
||||
function beamPatternEvaluationBudget(preview, simultaneousViews = 1) {
|
||||
const total = preview
|
||||
? BEAM_PATTERN_LIVE_EVALUATION_BUDGET
|
||||
: BEAM_PATTERN_FULL_EVALUATION_BUDGET;
|
||||
return Math.floor(total / simultaneousViews);
|
||||
}
|
||||
|
||||
function beamPatternNumber(id, label, options) {
|
||||
const element = elements[id];
|
||||
@@ -516,39 +603,35 @@ function beamPatternNumber(id, label, options) {
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
function beamPatternFrequencyHz() {
|
||||
const slider = elements["beam-pattern-frequency"];
|
||||
const logarithmicPosition = finiteInput(slider, "아날로그 주파수 slider", {
|
||||
minimum: 0,
|
||||
maximum: 6000,
|
||||
});
|
||||
return Number(
|
||||
Math.pow(10, logarithmicPosition / BEAM_FREQUENCY_LOG_STEPS_PER_DECADE)
|
||||
.toPrecision(12),
|
||||
);
|
||||
}
|
||||
|
||||
function syncBeamPatternFrequency() {
|
||||
const frequencyHz = beamPatternFrequencyHz();
|
||||
const formatted = formatBeamFrequency(frequencyHz);
|
||||
elements["beam-pattern-frequency-value"].textContent = formatted;
|
||||
elements["beam-pattern-frequency"].setAttribute("aria-valuenow", String(frequencyHz));
|
||||
elements["beam-pattern-frequency"].setAttribute("aria-valuetext", formatted);
|
||||
return frequencyHz;
|
||||
}
|
||||
|
||||
function readBeamPatternCommonOptions() {
|
||||
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
|
||||
const frequencyHz = syncBeamPatternFrequency();
|
||||
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");
|
||||
@@ -558,18 +641,73 @@ function readBeamPatternOptions(preview) {
|
||||
array: state.arrayDefinition,
|
||||
frequencyHz,
|
||||
speedOfSound,
|
||||
cut,
|
||||
steeringAngleDeg,
|
||||
sampleCount: preview
|
||||
? Math.min(
|
||||
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
|
||||
720,
|
||||
)
|
||||
: configuredSampleCount,
|
||||
dbFloor,
|
||||
};
|
||||
}
|
||||
|
||||
function readBeamPatternCutOptions(preview) {
|
||||
const common = readBeamPatternCommonOptions();
|
||||
const steeringAngleDeg = beamPatternNumber(
|
||||
"beam-pattern-steering-angle",
|
||||
"단면별 조향각",
|
||||
{ minimum: -360, maximum: 360 },
|
||||
);
|
||||
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("각도 샘플 설정이 올바르지 않습니다.");
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
steeringAngleDeg,
|
||||
maximumEvaluations: beamPatternEvaluationBudget(
|
||||
preview,
|
||||
BEAM_PATTERN_CUTS.length,
|
||||
),
|
||||
sampleCount: preview
|
||||
? Math.min(configuredSampleCount === "auto" ? 720 : configuredSampleCount, 720)
|
||||
: configuredSampleCount,
|
||||
};
|
||||
}
|
||||
|
||||
function readBeamPatternSurfaceOptions(preview) {
|
||||
const common = readBeamPatternCommonOptions();
|
||||
const steeringAzimuthDeg = beamPatternNumber(
|
||||
"beam-pattern-steering-azimuth",
|
||||
"3D 조향 방위각",
|
||||
{ minimum: -360, maximum: 360 },
|
||||
);
|
||||
const steeringElevationDeg = beamPatternNumber(
|
||||
"beam-pattern-steering-elevation",
|
||||
"3D 조향 고도각",
|
||||
{ minimum: -90, maximum: 90 },
|
||||
);
|
||||
const configuredStepDeg = Number(elements["beam-pattern-surface-resolution"].value);
|
||||
if (
|
||||
!Number.isFinite(configuredStepDeg) ||
|
||||
configuredStepDeg < 1 ||
|
||||
configuredStepDeg > 15 ||
|
||||
360 % configuredStepDeg !== 0 ||
|
||||
180 % configuredStepDeg !== 0
|
||||
) {
|
||||
elements["beam-pattern-surface-resolution"].setAttribute("aria-invalid", "true");
|
||||
throw new Error("3D 구면 표본 간격이 올바르지 않습니다.");
|
||||
}
|
||||
const stepDeg = preview ? Math.max(6, configuredStepDeg) : configuredStepDeg;
|
||||
return {
|
||||
...common,
|
||||
steeringAzimuthDeg,
|
||||
steeringElevationDeg,
|
||||
maximumEvaluations: beamPatternEvaluationBudget(preview),
|
||||
azimuthSampleCount: Math.round(360 / stepDeg),
|
||||
elevationSampleCount: Math.round(180 / stepDeg) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -624,80 +762,198 @@ function setBeamPatternStatus(message, severity = "success") {
|
||||
elements["beam-pattern-status"].dataset.severity = severity;
|
||||
}
|
||||
|
||||
function renderBeamPatternCuts(preview, startedAt) {
|
||||
const options = readBeamPatternCutOptions(preview);
|
||||
const patterns = BEAM_PATTERN_CUTS.map((cut) =>
|
||||
computeReceiveBeamPattern({ ...options, cut }));
|
||||
for (const pattern of patterns) {
|
||||
const cut = pattern.cut;
|
||||
const plot = elements["beam-pattern-plot-" + cut];
|
||||
beamPatternViews[cut].render(pattern);
|
||||
plot.dataset.sampleCount = String(pattern.sampleCount);
|
||||
plot.dataset.cut = cut;
|
||||
plot.dataset.frequencyHz = String(pattern.frequencyHz);
|
||||
elements["beam-pattern-caption-" + cut].textContent =
|
||||
pattern.sampleCount.toLocaleString("ko-KR") + " samples · " +
|
||||
(preview
|
||||
? "live preview · 놓으면 HPBW 재계산"
|
||||
: "HPBW " + formatBeamHpbw(pattern.metrics.halfPowerBeamwidth));
|
||||
const diagnostic = elements["beam-pattern-peaks"].querySelector(
|
||||
'[data-cut="' + cut + '"] span',
|
||||
);
|
||||
if (diagnostic) {
|
||||
diagnostic.textContent = preview
|
||||
? "live raw samples · 놓으면 peak 재계산"
|
||||
: describeBeamPatternPeaks(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
const reference = patterns[0];
|
||||
elements["beam-pattern-summary"].textContent =
|
||||
formatBeamFrequency(reference.frequencyHz) + " · XZ/YZ/XY 동시 · " +
|
||||
reference.steeringAngleDeg.toFixed(1) + "° 독립 조향";
|
||||
elements["beam-pattern-wavelength"].textContent =
|
||||
formatBeamWavelength(reference.wavelengthM);
|
||||
elements["beam-pattern-resolution"].textContent = patterns
|
||||
.map((pattern) =>
|
||||
pattern.cut.toUpperCase() + " " +
|
||||
pattern.sampleCount.toLocaleString("ko-KR") + " / " +
|
||||
pattern.angularResolutionDeg.toFixed(3) + "°")
|
||||
.join(" · ");
|
||||
elements["beam-pattern-steering-response"].textContent = patterns
|
||||
.map((pattern) =>
|
||||
pattern.cut.toUpperCase() + " " +
|
||||
formatBeamDecibels(pattern.metrics.steeringDb))
|
||||
.join(" · ");
|
||||
elements["beam-pattern-hpbw"].textContent = preview
|
||||
? "slider/drag 종료 후 계산"
|
||||
: patterns
|
||||
.map((pattern) =>
|
||||
pattern.cut.toUpperCase() + " " +
|
||||
formatBeamHpbw(pattern.metrics.halfPowerBeamwidth))
|
||||
.join(" · ");
|
||||
|
||||
if (!preview) {
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
const unresolved = patterns.filter(
|
||||
(pattern) => !pattern.angularSampling.metricResolutionSatisfied,
|
||||
);
|
||||
const missingCrossing = patterns.filter(
|
||||
(pattern) =>
|
||||
pattern.metrics.halfPowerBeamwidth.status ===
|
||||
"no-sampled-half-power-crossing",
|
||||
);
|
||||
const evaluationCount = patterns.reduce(
|
||||
(sum, pattern) => sum + pattern.evaluationCount,
|
||||
0,
|
||||
);
|
||||
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
|
||||
if (unresolved.length) {
|
||||
setBeamPatternStatus(
|
||||
unresolved.map((pattern) => pattern.cut.toUpperCase()).join("/") +
|
||||
" raw polar 표본은 표시했지만 π/8 인접 위상 기준을 넘겨 HPBW·peak 해석을 보류합니다. · " +
|
||||
elapsedMs.toFixed(1) + " ms",
|
||||
"warning",
|
||||
);
|
||||
} else if (missingCrossing.length) {
|
||||
setBeamPatternStatus(
|
||||
missingCrossing.map((pattern) => pattern.cut.toUpperCase()).join("/") +
|
||||
"에서 sampled half-power crossing이 없어 전 방향 HPBW라고 단정하지 않습니다. · " +
|
||||
elapsedMs.toFixed(1) + " ms",
|
||||
"warning",
|
||||
);
|
||||
} else {
|
||||
setBeamPatternStatus(
|
||||
"세 평면 π/8 인접 위상 기준 충족 · " +
|
||||
evaluationCount.toLocaleString("ko-KR") +
|
||||
" microphone-angle evaluations · " + elapsedMs.toFixed(1) + " ms",
|
||||
"success",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBeamPatternSurface(preview, startedAt) {
|
||||
const options = readBeamPatternSurfaceOptions(preview);
|
||||
const surface = computeReceiveBeamPatternSphere(options);
|
||||
beamPatternSurfaceView.render(surface);
|
||||
elements["beam-pattern-summary"].textContent =
|
||||
formatBeamFrequency(surface.frequencyHz) + " · 3D · az " +
|
||||
surface.steeringAzimuthDeg.toFixed(1) + "° / el " +
|
||||
surface.steeringElevationDeg.toFixed(1) + "°";
|
||||
elements["beam-pattern-wavelength"].textContent =
|
||||
formatBeamWavelength(surface.wavelengthM);
|
||||
elements["beam-pattern-resolution"].textContent =
|
||||
surface.angularSampling.azimuthStepDeg.toFixed(3) + "° az · " +
|
||||
surface.angularSampling.elevationStepDeg.toFixed(3) + "° el · " +
|
||||
surface.sampleCount.toLocaleString("ko-KR") + " grid samples";
|
||||
elements["beam-pattern-steering-response"].textContent =
|
||||
"0.000 dB · analytic u=u₀";
|
||||
elements["beam-pattern-hpbw"].textContent =
|
||||
"3D 표면 · 평면 HPBW 미적용";
|
||||
elements["beam-pattern-surface-caption"].textContent =
|
||||
surface.azimuthSampleCount + "×" + surface.elevationSampleCount +
|
||||
" raw grid samples · dB-floor radial mapping · response interpolation 없음 · " +
|
||||
(preview ? "live preview" : "drag/방향키로 회전");
|
||||
if (!preview) {
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
|
||||
if (!surface.angularSampling.resolutionCriterionSatisfied) {
|
||||
setBeamPatternStatus(
|
||||
"Raw 3D 표면은 표시했습니다. 구면 cell 대각 위상 변화 " +
|
||||
surface.angularSampling.maximumCellDiagonalPhaseStepRadBound.toFixed(3) +
|
||||
" rad가 π/8 기준을 넘으므로 미세 lobe 해석은 미확정입니다. · " +
|
||||
elapsedMs.toFixed(1) + " ms",
|
||||
"warning",
|
||||
);
|
||||
} else {
|
||||
setBeamPatternStatus(
|
||||
"3D 구면 π/8 cell 대각 위상 기준 충족 · " +
|
||||
surface.evaluationCount.toLocaleString("ko-KR") +
|
||||
" microphone-direction evaluations · " + elapsedMs.toFixed(1) + " ms",
|
||||
"success",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBeamPattern(preview = false) {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const options = readBeamPatternOptions(preview);
|
||||
const pattern = computeReceiveBeamPattern(options);
|
||||
beamPatternView.render(pattern);
|
||||
if (state.beamPatternViewMode === "surface") {
|
||||
renderBeamPatternSurface(preview, startedAt);
|
||||
} else {
|
||||
renderBeamPatternCuts(preview, startedAt);
|
||||
}
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
document.documentElement.dataset.beamPatternView = state.beamPatternViewMode;
|
||||
} catch (error) {
|
||||
state.beamPatternDirty = false;
|
||||
document.documentElement.dataset.beamPatternReady = "error";
|
||||
document.documentElement.dataset.beamPatternPreview = "false";
|
||||
document.documentElement.dataset.beamPatternPreview = String(preview);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
beamPatternView.renderError(message);
|
||||
for (const view of Object.values(beamPatternViews)) view.renderError(message);
|
||||
beamPatternSurfaceView.renderError(message);
|
||||
setBeamPatternStatus(message, "error");
|
||||
elements["beam-pattern-hpbw"].textContent = "—";
|
||||
elements["beam-pattern-peaks"].textContent =
|
||||
"빔 패턴 설정을 수정하면 다시 계산합니다.";
|
||||
for (const diagnostic of elements["beam-pattern-peaks"].querySelectorAll("span")) {
|
||||
diagnostic.textContent = "설정을 수정하면 다시 계산합니다.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setBeamPatternViewMode(mode, options = {}) {
|
||||
const resolved = mode === "surface" ? "surface" : "cuts";
|
||||
state.beamPatternViewMode = resolved;
|
||||
elements["beam-pattern-view-cuts-button"].classList.toggle(
|
||||
"is-active",
|
||||
resolved === "cuts",
|
||||
);
|
||||
elements["beam-pattern-view-3d-button"].classList.toggle(
|
||||
"is-active",
|
||||
resolved === "surface",
|
||||
);
|
||||
elements["beam-pattern-view-cuts-button"].setAttribute(
|
||||
"aria-pressed",
|
||||
String(resolved === "cuts"),
|
||||
);
|
||||
elements["beam-pattern-view-3d-button"].setAttribute(
|
||||
"aria-pressed",
|
||||
String(resolved === "surface"),
|
||||
);
|
||||
for (const field of document.querySelectorAll("[data-beam-mode]")) {
|
||||
field.hidden = field.dataset.beamMode !== resolved;
|
||||
}
|
||||
elements["beam-pattern-cuts-view"].hidden = resolved !== "cuts";
|
||||
elements["beam-pattern-peaks"].hidden = resolved !== "cuts";
|
||||
elements["beam-pattern-surface-view"].hidden = resolved !== "surface";
|
||||
document.documentElement.dataset.beamPatternView = resolved;
|
||||
state.beamPatternDirty = true;
|
||||
if (options.render !== false) scheduleBeamPatternRender();
|
||||
}
|
||||
|
||||
function scheduleBeamPatternRender(options = {}) {
|
||||
const preview = options.preview === true;
|
||||
state.beamPatternDirty = true;
|
||||
@@ -1093,6 +1349,10 @@ elements["select-tool-button"].addEventListener("click", () =>
|
||||
setTool("select", { focus: true, userInitiated: true }));
|
||||
elements["add-tool-button"].addEventListener("click", () =>
|
||||
setTool("add", { focus: true, userInitiated: true }));
|
||||
elements["array-view-2d-button"].addEventListener("click", () =>
|
||||
setArrayViewMode("2d", { focus: true }));
|
||||
elements["array-view-3d-button"].addEventListener("click", () =>
|
||||
setArrayViewMode("3d", { focus: true }));
|
||||
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
|
||||
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
|
||||
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
|
||||
@@ -1102,10 +1362,25 @@ elements["snap-enabled"].addEventListener("change", () => {
|
||||
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
|
||||
});
|
||||
|
||||
elements["beam-pattern-frequency"].addEventListener("input", () => {
|
||||
syncBeamPatternFrequency();
|
||||
scheduleBeamPatternRender({ preview: true });
|
||||
});
|
||||
elements["beam-pattern-frequency"].addEventListener("change", () =>
|
||||
scheduleBeamPatternRender());
|
||||
for (const id of beamPatternInputIds) {
|
||||
const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change";
|
||||
elements[id].addEventListener(eventName, () => scheduleBeamPatternRender());
|
||||
if (elements[id] instanceof HTMLInputElement) {
|
||||
elements[id].addEventListener("input", () =>
|
||||
scheduleBeamPatternRender({ preview: true }));
|
||||
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
|
||||
} else {
|
||||
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
|
||||
}
|
||||
}
|
||||
elements["beam-pattern-view-cuts-button"].addEventListener("click", () =>
|
||||
setBeamPatternViewMode("cuts"));
|
||||
elements["beam-pattern-view-3d-button"].addEventListener("click", () =>
|
||||
setBeamPatternViewMode("surface"));
|
||||
elements["beam-pattern-panel"].addEventListener("toggle", () => {
|
||||
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
|
||||
scheduleBeamPatternRender();
|
||||
@@ -1314,13 +1589,17 @@ window.addEventListener("unload", () => {
|
||||
if (state.beamPatternRenderFrame) {
|
||||
cancelAnimationFrame(state.beamPatternRenderFrame);
|
||||
}
|
||||
beamPatternView.destroy();
|
||||
for (const view of Object.values(beamPatternViews)) view.destroy();
|
||||
beamPatternSurfaceView.destroy();
|
||||
splitPane.destroy();
|
||||
canvasView.destroy();
|
||||
});
|
||||
|
||||
elements["snap-enabled"].checked = state.snapEnabled;
|
||||
setNumber(elements["snap-step"], state.snapStepM * 1000);
|
||||
setArrayViewMode("2d", { announce: false });
|
||||
setBeamPatternViewMode("cuts", { render: false });
|
||||
syncBeamPatternFrequency();
|
||||
renderAll("기본 4×3 배열");
|
||||
canvasView.fit();
|
||||
document.documentElement.dataset.appReady = "true";
|
||||
|
||||
Reference in New Issue
Block a user