feat: complete acoustic camera workflow

This commit is contained in:
Codex
2026-08-28 12:11:10 +09:00
parent d5f7530620
commit d04a4d9f65
13 changed files with 3632 additions and 16 deletions
+681
View File
@@ -0,0 +1,681 @@
const DEFAULT_BACKGROUND = "#07110f";
const DEFAULT_DYNAMIC_RANGE_DB = 48;
const MAX_PIXEL_RATIO = 3;
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function finiteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function validScan(scan) {
return Boolean(
scan &&
finiteNumber(scan.xMin) &&
finiteNumber(scan.xMax) &&
finiteNumber(scan.yMin) &&
finiteNumber(scan.yMax) &&
scan.xMin < scan.xMax &&
scan.yMin < scan.yMax &&
Number.isInteger(scan.columns) &&
Number.isInteger(scan.rows) &&
scan.columns > 0 &&
scan.rows > 0,
);
}
function parseHexColor(value) {
const match = /^#([0-9a-f]{6})$/i.exec(value ?? "");
if (!match) return [101, 242, 191];
const packed = Number.parseInt(match[1], 16);
return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff];
}
function resolveCanvasSize(canvas) {
const rect = canvas.getBoundingClientRect?.();
const width = Math.max(1, rect?.width || canvas.clientWidth || 1);
const height = Math.max(1, rect?.height || canvas.clientHeight || 1);
const pixelRatio = clamp(globalThis.devicePixelRatio || 1, 1, MAX_PIXEL_RATIO);
const backingWidth = Math.max(1, Math.round(width * pixelRatio));
const backingHeight = Math.max(1, Math.round(height * pixelRatio));
if (canvas.width !== backingWidth || canvas.height !== backingHeight) {
canvas.width = backingWidth;
canvas.height = backingHeight;
}
return { width, height, pixelRatio };
}
function prepareContext(canvas, context) {
const size = resolveCanvasSize(canvas);
context.setTransform(size.pixelRatio, 0, 0, size.pixelRatio, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
context.imageSmoothingEnabled = true;
context.clearRect(0, 0, size.width, size.height);
return size;
}
function createScheduledCanvas(canvas, draw) {
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("2D canvas context를 만들 수 없습니다.");
let model = null;
let frameId = 0;
let destroyed = false;
const requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ??
((callback) => globalThis.setTimeout(callback, 16));
const cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? globalThis.clearTimeout?.bind(globalThis);
function drawCurrent() {
frameId = 0;
if (destroyed || model === null) return;
const size = prepareContext(canvas, context);
draw(context, size, model);
}
function schedule() {
if (!destroyed && !frameId) frameId = requestFrame(drawCurrent);
}
function render(nextModel) {
if (destroyed) return;
model = nextModel;
schedule();
}
function renderNow(nextModel) {
if (destroyed) return;
if (arguments.length > 0) model = nextModel;
if (frameId) {
cancelFrame?.(frameId);
frameId = 0;
}
drawCurrent();
}
let resizeObserver = null;
const onWindowResize = () => schedule();
if (typeof globalThis.ResizeObserver === "function") {
resizeObserver = new globalThis.ResizeObserver(schedule);
resizeObserver.observe(canvas);
} else {
globalThis.addEventListener?.("resize", onWindowResize);
}
return {
render,
renderNow,
destroy() {
destroyed = true;
if (frameId) cancelFrame?.(frameId);
frameId = 0;
resizeObserver?.disconnect();
globalThis.removeEventListener?.("resize", onWindowResize);
model = null;
},
};
}
function drawEmpty(context, width, height, message) {
context.fillStyle = DEFAULT_BACKGROUND;
context.fillRect(0, 0, width, height);
context.fillStyle = "rgba(205, 225, 218, .66)";
context.font = "600 13px Inter, system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(message, width / 2, height / 2);
context.textAlign = "left";
context.textBaseline = "alphabetic";
}
function fitPlot(width, height, xSpan, ySpan, margins) {
const availableWidth = Math.max(1, width - margins.left - margins.right);
const availableHeight = Math.max(1, height - margins.top - margins.bottom);
const dataAspect = xSpan / ySpan;
const availableAspect = availableWidth / availableHeight;
let plotWidth = availableWidth;
let plotHeight = availableHeight;
if (dataAspect > availableAspect) plotHeight = plotWidth / dataAspect;
else plotWidth = plotHeight * dataAspect;
return {
x: margins.left + (availableWidth - plotWidth) / 2,
y: margins.top + (availableHeight - plotHeight) / 2,
width: plotWidth,
height: plotHeight,
};
}
export function scanPositionToCanvas(position, scan, plot) {
return {
x: plot.x + ((position[0] - scan.xMin) / (scan.xMax - scan.xMin)) * plot.width,
y: plot.y + ((scan.yMax - position[1]) / (scan.yMax - scan.yMin)) * plot.height,
};
}
function niceStep(span, targetLines = 6) {
const raw = span / targetLines;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const normalized = raw / magnitude;
const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
return multiplier * magnitude;
}
function drawScanGrid(context, scan, plot) {
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.fillStyle = "#0b1714";
context.fillRect(plot.x, plot.y, plot.width, plot.height);
const xStep = niceStep(scan.xMax - scan.xMin);
const yStep = niceStep(scan.yMax - scan.yMin);
context.lineWidth = 1;
for (let x = Math.ceil(scan.xMin / xStep) * xStep; x <= scan.xMax + xStep * 1e-6; x += xStep) {
const pixel = scanPositionToCanvas([x, scan.yMin], scan, plot).x;
context.strokeStyle = Math.abs(x) < xStep * 1e-6 ? "rgba(101,242,191,.28)" : "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(pixel, plot.y);
context.lineTo(pixel, plot.y + plot.height);
context.stroke();
}
for (let y = Math.ceil(scan.yMin / yStep) * yStep; y <= scan.yMax + yStep * 1e-6; y += yStep) {
const pixel = scanPositionToCanvas([scan.xMin, y], scan, plot).y;
context.strokeStyle = Math.abs(y) < yStep * 1e-6 ? "rgba(101,242,191,.28)" : "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(plot.x, pixel);
context.lineTo(plot.x + plot.width, pixel);
context.stroke();
}
context.restore();
context.strokeStyle = "rgba(174, 211, 199, .32)";
context.lineWidth = 1;
context.strokeRect(plot.x + 0.5, plot.y + 0.5, Math.max(0, plot.width - 1), Math.max(0, plot.height - 1));
context.fillStyle = "rgba(205, 225, 218, .65)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textBaseline = "top";
context.textAlign = "left";
context.fillText(`${scan.xMin.toFixed(2)} m`, plot.x, plot.y + plot.height + 8);
context.textAlign = "right";
context.fillText(`${scan.xMax.toFixed(2)} m`, plot.x + plot.width, plot.y + plot.height + 8);
context.save();
context.translate(Math.max(8, plot.x - 12), plot.y + plot.height / 2);
context.rotate(-Math.PI / 2);
context.textAlign = "center";
context.fillText(`y ${scan.yMin.toFixed(2)}…${scan.yMax.toFixed(2)} m`, 0, 0);
context.restore();
context.textAlign = "left";
context.textBaseline = "alphabetic";
}
function drawableImage(image) {
if (!image) return false;
if ("complete" in image && !image.complete) return false;
if ("naturalWidth" in image && image.naturalWidth === 0) return false;
return true;
}
function validPlane(plane) {
return Boolean(
plane &&
finiteNumber(plane.xMin) &&
finiteNumber(plane.xMax) &&
finiteNumber(plane.yMin) &&
finiteNumber(plane.yMax) &&
plane.xMin < plane.xMax &&
plane.yMin < plane.yMax,
);
}
function drawSceneImage(context, image, imagePlane, scan, plot) {
if (!drawableImage(image)) return;
try {
const plane = validPlane(imagePlane) ? imagePlane : scan;
const topLeft = scanPositionToCanvas([plane.xMin, plane.yMax], scan, plot);
const bottomRight = scanPositionToCanvas([plane.xMax, plane.yMin], scan, plot);
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.globalAlpha = 0.72;
context.drawImage(
image,
topLeft.x,
topLeft.y,
bottomRight.x - topLeft.x,
bottomRight.y - topLeft.y,
);
context.fillStyle = "rgba(2, 10, 8, .17)";
context.fillRect(
topLeft.x,
topLeft.y,
bottomRight.x - topLeft.x,
bottomRight.y - topLeft.y,
);
context.restore();
} catch {
// A not-yet-decoded or detached CanvasImageSource is simply omitted.
}
}
function resolveBandMap(band, scan) {
const map = band?.displayMap ?? band?.rawMap;
if (!map) return null;
const values = map.values ?? map;
if (!values || typeof values.length !== "number") return null;
const rows = map.rows ?? scan.rows;
const columns = map.columns ?? scan.columns;
if (rows !== scan.rows || columns !== scan.columns || values.length !== rows * columns) return null;
return {
values,
rows,
columns,
scale: map.scale ?? band.displayScale ?? "power",
};
}
function describeMap(map) {
let peak = -Infinity;
if (map.scale === "db") {
for (let index = 0; index < map.values.length; index += 1) {
const value = Number(map.values[index]);
if (Number.isFinite(value)) peak = Math.max(peak, value);
}
return Number.isFinite(peak) ? { peak, usable: true } : { peak: 0, usable: false };
}
peak = 0;
for (let index = 0; index < map.values.length; index += 1) {
const value = Number(map.values[index]);
if (Number.isFinite(value) && value > peak) peak = value;
}
return { peak, usable: peak > 0 };
}
export function mapValueToDynamicRange(value, scale, peak, dynamicRangeDb) {
if (!Number.isFinite(value) || !Number.isFinite(peak)) return 0;
const range = clamp(Number(dynamicRangeDb) || DEFAULT_DYNAMIC_RANGE_DB, 1, 240);
let relativeDb;
if (scale === "db") relativeDb = value - peak;
else {
if (!(value > 0) || !(peak > 0)) return 0;
relativeDb = 10 * Math.log10(value / peak);
}
return clamp(1 + relativeDb / range, 0, 1);
}
function createHeatmapCanvas(canvas, scan, bands, dynamicRangeDb) {
const ownerDocument = canvas.ownerDocument ?? globalThis.document;
if (!ownerDocument?.createElement) return null;
const heatCanvas = ownerDocument.createElement("canvas");
heatCanvas.width = scan.columns;
heatCanvas.height = scan.rows;
const heatContext = heatCanvas.getContext("2d");
if (!heatContext) return null;
const imageData = heatContext.createImageData(scan.columns, scan.rows);
const resolvedBands = bands
.filter((band) => band && band.visible !== false)
.map((band) => ({ band, map: resolveBandMap(band, scan), color: parseHexColor(band.color) }))
.filter(({ map }) => map)
.map((entry) => ({ ...entry, description: describeMap(entry.map) }))
.filter(({ description }) => description.usable);
for (let index = 0; index < scan.rows * scan.columns; index += 1) {
let alphaSum = 0;
let red = 0;
let green = 0;
let blue = 0;
for (const { band, map, color, description } of resolvedBands) {
const intensity = mapValueToDynamicRange(
Number(map.values[index]),
map.scale,
description.peak,
dynamicRangeDb,
) * clamp(Number(band.opacity ?? 1), 0, 1);
if (!(intensity > 0)) continue;
alphaSum += intensity;
red += color[0] * intensity;
green += color[1] * intensity;
blue += color[2] * intensity;
}
const alpha = Math.min(1, alphaSum);
if (!(alpha > 0)) continue;
const pixelOffset = index * 4;
// ImageData stores unassociated RGB. Dividing the additive premultiplied
// contributions by the final alpha preserves additive color mixing.
imageData.data[pixelOffset] = Math.round(clamp(red / alpha, 0, 255));
imageData.data[pixelOffset + 1] = Math.round(clamp(green / alpha, 0, 255));
imageData.data[pixelOffset + 2] = Math.round(clamp(blue / alpha, 0, 255));
imageData.data[pixelOffset + 3] = Math.round(alpha * 255);
}
heatContext.putImageData(imageData, 0, 0);
return { canvas: heatCanvas, activeBands: resolvedBands.map(({ band }) => band) };
}
function drawTruthMarkers(context, sources, scan, plot) {
const validSources = Array.isArray(sources) ? sources : [];
validSources.forEach((source, index) => {
const position = source?.position;
if (!Array.isArray(position) || !finiteNumber(position[0]) || !finiteNumber(position[1])) return;
const point = scanPositionToCanvas(position, scan, plot);
if (
point.x < plot.x - 16 || point.x > plot.x + plot.width + 16 ||
point.y < plot.y - 16 || point.y > plot.y + plot.height + 16
) return;
context.save();
context.strokeStyle = "rgba(255,255,255,.95)";
context.fillStyle = "rgba(5,14,12,.82)";
context.lineWidth = 2;
context.beginPath();
context.arc(point.x, point.y, 7, 0, Math.PI * 2);
context.fill();
context.stroke();
context.beginPath();
context.moveTo(point.x - 11, point.y);
context.lineTo(point.x + 11, point.y);
context.moveTo(point.x, point.y - 11);
context.lineTo(point.x, point.y + 11);
context.stroke();
const label = source.name || source.id || `S${index + 1}`;
context.font = "700 11px Inter, system-ui, sans-serif";
const labelWidth = context.measureText(label).width + 12;
const labelX = clamp(point.x + 12, plot.x + 4, plot.x + plot.width - labelWidth - 4);
const labelY = clamp(point.y - 20, plot.y + 4, plot.y + plot.height - 21);
context.fillStyle = "rgba(5,14,12,.82)";
context.fillRect(labelX, labelY, labelWidth, 19);
context.fillStyle = "#f4fbf8";
context.fillText(label, labelX + 6, labelY + 13);
context.restore();
});
}
function evaluationForBand(evaluations, bandId) {
if (!evaluations) return null;
if (typeof evaluations.get === "function") return evaluations.get(bandId) ?? null;
if (Array.isArray(evaluations)) {
return evaluations.find((entry) => entry?.bandId === bandId || entry?.id === bandId) ?? null;
}
return evaluations[bandId] ?? null;
}
function peaksForBand(band, evaluations) {
const evaluation = band.evaluation ?? evaluationForBand(evaluations, band.id);
const peaks =
band.peaks ??
evaluation?.peaks ??
evaluation?.detections ??
(evaluation?.globalPeak ? [evaluation.globalPeak] : null) ??
(band.peak ? [band.peak] : []);
return Array.isArray(peaks) ? peaks : [];
}
function drawBandPeakMarkers(context, bands, evaluations, scan, plot) {
for (const band of bands) {
if (!band || band.visible === false) continue;
const color = /^#[0-9a-f]{6}$/i.test(band.color ?? "") ? band.color : "#65f2bf";
for (const peak of peaksForBand(band, evaluations).slice(0, 128)) {
const position = peak?.position;
if (!position || !finiteNumber(position[0]) || !finiteNumber(position[1])) continue;
const point = scanPositionToCanvas(position, scan, plot);
if (
point.x < plot.x || point.x > plot.x + plot.width ||
point.y < plot.y || point.y > plot.y + plot.height
) continue;
context.save();
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 1.75;
context.shadowColor = color;
context.shadowBlur = 5;
context.beginPath();
context.arc(point.x, point.y, 5, 0, Math.PI * 2);
context.stroke();
context.shadowBlur = 0;
context.globalAlpha = 0.9;
context.beginPath();
context.arc(point.x, point.y, 1.4, 0, Math.PI * 2);
context.fill();
context.restore();
}
}
}
function drawBandLegend(context, bands, plot) {
const visibleBands = bands.filter((band) => band && band.visible !== false).slice(0, 8);
if (visibleBands.length === 0) return;
context.save();
context.font = "700 11px Inter, system-ui, sans-serif";
const rows = visibleBands.map((band) => ({
color: band.color ?? "#65f2bf",
label: band.label || band.name || band.id || "band",
}));
const width = Math.min(
plot.width - 12,
Math.max(...rows.map(({ label }) => context.measureText(label).width)) + 34,
);
const height = rows.length * 19 + 10;
const x = plot.x + plot.width - width - 6;
const y = plot.y + 6;
context.fillStyle = "rgba(3, 11, 9, .72)";
context.fillRect(x, y, width, height);
rows.forEach((row, index) => {
const rowY = y + 10 + index * 19;
context.fillStyle = row.color;
context.fillRect(x + 8, rowY + 1, 9, 9);
context.fillStyle = "rgba(239,249,245,.88)";
context.fillText(row.label, x + 23, rowY + 10, Math.max(1, width - 29));
});
context.restore();
}
function normalizeSceneModel(model) {
const value = model ?? {};
return {
scan: value.scan ?? value.experiment?.scan,
bands: value.bands ?? value.result?.bands ?? [],
sources: value.sources ?? value.scene?.sources ?? [],
image: value.image ?? value.imageElement ?? null,
imagePlane: value.imagePlane ?? value.scene?.image?.plane ?? null,
evaluations: value.evaluations ?? null,
dynamicRangeDb:
value.dynamicRangeDb ?? value.experiment?.beamforming?.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB,
opacity: value.opacity ?? 0.78,
showTruth: value.showTruth ?? true,
};
}
function drawScene(context, size, sourceModel, canvas) {
const model = normalizeSceneModel(sourceModel);
if (!validScan(model.scan)) {
drawEmpty(context, size.width, size.height, "scan 영역을 불러오면 acoustic map이 표시됩니다.");
return;
}
context.fillStyle = DEFAULT_BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
const scan = model.scan;
const plot = fitPlot(
size.width,
size.height,
scan.xMax - scan.xMin,
scan.yMax - scan.yMin,
{ left: 42, right: 16, top: 16, bottom: 34 },
);
drawScanGrid(context, scan, plot);
drawSceneImage(context, model.image, model.imagePlane, scan, plot);
const bands = Array.isArray(model.bands) ? model.bands : [];
const heatmap = createHeatmapCanvas(canvas, scan, bands, model.dynamicRangeDb);
if (heatmap) {
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
const requestedOpacity = Number(model.opacity);
context.globalAlpha = finiteNumber(requestedOpacity) ? clamp(requestedOpacity, 0, 1) : 0.78;
context.imageSmoothingEnabled = true;
// Map storage is row-major from yMax to yMin, which is the Canvas top-to-bottom order.
context.drawImage(heatmap.canvas, plot.x, plot.y, plot.width, plot.height);
context.restore();
drawBandPeakMarkers(context, bands, model.evaluations, scan, plot);
drawBandLegend(context, heatmap.activeBands, plot);
}
if (model.showTruth) drawTruthMarkers(context, model.sources, scan, plot);
}
/**
* Create the scene-plane renderer.
*
* `render()` coalesces repeated updates into one animation frame. Use
* `renderNow()` only for deterministic capture/tests. Accepted model fields:
* `{ experiment|scan, result|bands, scene|sources, image, imagePlane,
* evaluations, dynamicRangeDb, opacity, showTruth }`.
*/
export function createSceneVisualization(canvas) {
if (!canvas) throw new TypeError("scene canvas가 필요합니다.");
return createScheduledCanvas(canvas, (context, size, model) => drawScene(context, size, model, canvas));
}
function resolveArrayDefinition(model) {
if (Array.isArray(model?.microphones)) return model;
return model?.arrayDefinition ?? null;
}
function microphoneExtent(microphones, aperture) {
const positions = microphones
.map((microphone) => microphone?.position)
.filter((position) => Array.isArray(position) && finiteNumber(position[0]) && finiteNumber(position[1]));
if (positions.length === 0) return null;
let xMin = Math.min(...positions.map((position) => position[0]));
let xMax = Math.max(...positions.map((position) => position[0]));
let yMin = Math.min(...positions.map((position) => position[1]));
let yMax = Math.max(...positions.map((position) => position[1]));
if (finiteNumber(aperture?.width) && aperture.width > 0) {
xMin = Math.min(xMin, -aperture.width / 2);
xMax = Math.max(xMax, aperture.width / 2);
}
if (finiteNumber(aperture?.height) && aperture.height > 0) {
yMin = Math.min(yMin, -aperture.height / 2);
yMax = Math.max(yMax, aperture.height / 2);
}
const characteristicSize = Math.max(xMax - xMin, yMax - yMin, 0.01);
if (xMin === xMax) {
xMin -= characteristicSize * 0.5;
xMax += characteristicSize * 0.5;
}
if (yMin === yMax) {
yMin -= characteristicSize * 0.5;
yMax += characteristicSize * 0.5;
}
return { xMin, xMax, yMin, yMax, positions };
}
function arrayPositionToCanvas(position, extent, plot) {
return {
x: plot.x + ((position[0] - extent.xMin) / (extent.xMax - extent.xMin)) * plot.width,
y: plot.y + ((extent.yMax - position[1]) / (extent.yMax - extent.yMin)) * plot.height,
};
}
function drawArray(context, size, sourceModel) {
const arrayDefinition = resolveArrayDefinition(sourceModel);
const microphones = Array.isArray(arrayDefinition?.microphones) ? arrayDefinition.microphones : [];
const extent = microphoneExtent(microphones, arrayDefinition?.aperture);
if (!extent) {
drawEmpty(context, size.width, size.height, "array geometry를 불러오면 microphone 배치가 표시됩니다.");
return;
}
context.fillStyle = DEFAULT_BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
const xSpan = extent.xMax - extent.xMin;
const ySpan = extent.yMax - extent.yMin;
const padding = Math.max(xSpan, ySpan) * 0.08;
const view = {
...extent,
xMin: extent.xMin - padding,
xMax: extent.xMax + padding,
yMin: extent.yMin - padding,
yMax: extent.yMax + padding,
};
const plot = fitPlot(
size.width,
size.height,
view.xMax - view.xMin,
view.yMax - view.yMin,
{ left: 24, right: 24, top: 52, bottom: 30 },
);
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.fillStyle = "#0b1714";
context.fillRect(plot.x, plot.y, plot.width, plot.height);
if (view.xMin <= 0 && view.xMax >= 0) {
const x = arrayPositionToCanvas([0, 0], view, plot).x;
context.strokeStyle = "rgba(101,242,191,.16)";
context.beginPath();
context.moveTo(x, plot.y);
context.lineTo(x, plot.y + plot.height);
context.stroke();
}
if (view.yMin <= 0 && view.yMax >= 0) {
const y = arrayPositionToCanvas([0, 0], view, plot).y;
context.strokeStyle = "rgba(101,242,191,.16)";
context.beginPath();
context.moveTo(plot.x, y);
context.lineTo(plot.x + plot.width, y);
context.stroke();
}
const aperture = arrayDefinition.aperture;
if (finiteNumber(aperture?.width) && aperture.width > 0 && finiteNumber(aperture?.height) && aperture.height > 0) {
const topLeft = arrayPositionToCanvas([-aperture.width / 2, aperture.height / 2], view, plot);
const bottomRight = arrayPositionToCanvas([aperture.width / 2, -aperture.height / 2], view, plot);
context.setLineDash([5, 5]);
context.strokeStyle = "rgba(205,225,218,.28)";
context.lineWidth = 1;
context.strokeRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
context.setLineDash([]);
}
const radius = clamp(Math.min(plot.width, plot.height) / Math.max(40, Math.sqrt(microphones.length) * 8), 2, 5);
microphones.forEach((microphone) => {
const position = microphone?.position;
if (!Array.isArray(position) || !finiteNumber(position[0]) || !finiteNumber(position[1])) return;
const point = arrayPositionToCanvas(position, view, plot);
context.fillStyle = "rgba(101,242,191,.92)";
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(235,255,248,.5)";
context.lineWidth = 0.75;
context.stroke();
});
context.restore();
context.strokeStyle = "rgba(174,211,199,.28)";
context.strokeRect(plot.x + 0.5, plot.y + 0.5, Math.max(0, plot.width - 1), Math.max(0, plot.height - 1));
context.fillStyle = "rgba(239,249,245,.94)";
context.font = "700 13px Inter, system-ui, sans-serif";
context.fillText(arrayDefinition.name || arrayDefinition.id || "Microphone array", 24, 23);
context.fillStyle = "rgba(205,225,218,.66)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
const micXMin = Math.min(...extent.positions.map((position) => position[0]));
const micXMax = Math.max(...extent.positions.map((position) => position[0]));
const micYMin = Math.min(...extent.positions.map((position) => position[1]));
const micYMax = Math.max(...extent.positions.map((position) => position[1]));
context.fillText(
`${extent.positions.length} mic · ${(micXMax - micXMin).toFixed(3)} × ${(micYMax - micYMin).toFixed(3)} m`,
24,
41,
);
}
/** Create the microphone-array renderer. Accepts an array definition directly or `{ arrayDefinition }`. */
export function createArrayVisualization(canvas) {
if (!canvas) throw new TypeError("array canvas가 필요합니다.");
return createScheduledCanvas(canvas, drawArray);
}