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