Files
micarrsim/apps/acoustic-camera/visualization.js
T
2026-08-28 12:11:10 +09:00

682 lines
25 KiB
JavaScript
Raw 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 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);
}