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