feat: complete acoustic camera workflow
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
|
||||
import { sha256File } from "/packages/contracts/io.js";
|
||||
|
||||
const MAX_FILE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_BOUND_FILE_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
function basename(path) {
|
||||
return String(path).split(/[\\/]/).pop();
|
||||
}
|
||||
|
||||
function kindForFile(file) {
|
||||
if (file.type.startsWith("image/") || /\.(?:png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name)) return "image";
|
||||
if (file.type.startsWith("audio/") || /\.(?:wav|flac|mp3|ogg|m4a|aac)$/i.test(file.name)) return "audio";
|
||||
return null;
|
||||
}
|
||||
|
||||
function referencedAssetIds(scene) {
|
||||
const ids = new Set();
|
||||
if (scene.image?.assetId) ids.add(scene.image.assetId);
|
||||
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
|
||||
if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function audioAssetIds(scene) {
|
||||
const ids = new Set();
|
||||
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
|
||||
if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function loadImageElement(file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
image.addEventListener("load", resolve, { once: true });
|
||||
image.addEventListener("error", () => reject(new Error(`${file.name} 이미지를 decode하지 못했습니다.`)), {
|
||||
once: true,
|
||||
});
|
||||
image.src = url;
|
||||
});
|
||||
return { image, url };
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForDecode(decodePromise, signal) {
|
||||
if (!signal) return decodePromise;
|
||||
if (signal.aborted) return Promise.reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
decodePromise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createCameraAssetRuntime(onChange = () => {}) {
|
||||
const files = new Map();
|
||||
const errors = new Map();
|
||||
let imageElement = null;
|
||||
let imageUrl = null;
|
||||
let generation = 0;
|
||||
|
||||
function revokeImage() {
|
||||
if (imageUrl) URL.revokeObjectURL(imageUrl);
|
||||
imageUrl = null;
|
||||
imageElement = null;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
generation += 1;
|
||||
files.clear();
|
||||
errors.clear();
|
||||
revokeImage();
|
||||
onChange();
|
||||
}
|
||||
|
||||
function totalBoundBytes(exceptIds = new Set()) {
|
||||
let total = 0;
|
||||
const countedFiles = new Set();
|
||||
for (const [assetId, file] of files) {
|
||||
if (!exceptIds.has(assetId) && !countedFiles.has(file)) {
|
||||
total += file.size;
|
||||
countedFiles.add(file);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function refreshImage(scene, expectedGeneration) {
|
||||
const imageAssetId = scene.image?.assetId;
|
||||
if (!imageAssetId || !files.has(imageAssetId)) {
|
||||
revokeImage();
|
||||
return;
|
||||
}
|
||||
const file = files.get(imageAssetId);
|
||||
const loaded = await loadImageElement(file);
|
||||
if (generation !== expectedGeneration) {
|
||||
URL.revokeObjectURL(loaded.url);
|
||||
return;
|
||||
}
|
||||
revokeImage();
|
||||
imageElement = loaded.image;
|
||||
imageUrl = loaded.url;
|
||||
}
|
||||
|
||||
async function bindFiles(selectedFiles, scene) {
|
||||
const currentGeneration = ++generation;
|
||||
const descriptors = scene.assets ?? [];
|
||||
const bound = [];
|
||||
const rejected = [];
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
throw new Error(`${file.name} 크기가 128 MiB 파일 한도를 넘습니다.`);
|
||||
}
|
||||
const kind = kindForFile(file);
|
||||
if (!kind) throw new Error(`${file.name}의 image/audio 종류를 판별할 수 없습니다.`);
|
||||
const hash = await sha256File(file);
|
||||
if (generation !== currentGeneration) return { bound, rejected, stale: true };
|
||||
const candidates = descriptors.filter((descriptor) => descriptor.kind === kind);
|
||||
let matches = candidates.filter((descriptor) => descriptor.sha256 === hash);
|
||||
if (matches.length === 0) {
|
||||
matches = candidates.filter(
|
||||
(descriptor) => !descriptor.sha256 && basename(descriptor.fileName) === file.name,
|
||||
);
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
const conflict = candidates.some(
|
||||
(descriptor) => basename(descriptor.fileName) === file.name && descriptor.sha256 !== hash,
|
||||
);
|
||||
throw new Error(
|
||||
conflict
|
||||
? `${file.name}의 SHA-256이 manifest와 다릅니다.`
|
||||
: `${file.name}에 대응하는 ${kind} asset descriptor가 없습니다.`,
|
||||
);
|
||||
}
|
||||
const replacingIds = new Set(matches.map((descriptor) => descriptor.id));
|
||||
const nextTotal = totalBoundBytes(replacingIds) + file.size;
|
||||
if (nextTotal > MAX_BOUND_FILE_BYTES) {
|
||||
throw new Error(`연결 파일 합계가 512 MiB 한도를 넘습니다.`);
|
||||
}
|
||||
for (const descriptor of matches) {
|
||||
descriptor.sha256 ??= hash;
|
||||
files.set(descriptor.id, file);
|
||||
errors.delete(descriptor.id);
|
||||
bound.push(descriptor.id);
|
||||
}
|
||||
} catch (error) {
|
||||
rejected.push({ fileName: file.name, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
try {
|
||||
await refreshImage(scene, currentGeneration);
|
||||
} catch (error) {
|
||||
if (scene.image?.assetId) errors.set(scene.image.assetId, error.message);
|
||||
rejected.push({ fileName: files.get(scene.image?.assetId)?.name ?? "image", message: error.message });
|
||||
}
|
||||
if (generation === currentGeneration) onChange();
|
||||
return { bound: [...new Set(bound)], rejected, stale: generation !== currentGeneration };
|
||||
}
|
||||
|
||||
function statuses(scene) {
|
||||
const referenced = referencedAssetIds(scene);
|
||||
return (scene.assets ?? [])
|
||||
.filter((descriptor) => referenced.has(descriptor.id))
|
||||
.map((descriptor) => ({
|
||||
descriptor,
|
||||
file: files.get(descriptor.id) ?? null,
|
||||
error: errors.get(descriptor.id) ?? null,
|
||||
ready: files.has(descriptor.id) && !errors.has(descriptor.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function missingAssetIds(scene) {
|
||||
return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id);
|
||||
}
|
||||
|
||||
async function decodeAudioAssets(scene, signal, onProgress = () => {}) {
|
||||
const ids = [...audioAssetIds(scene)];
|
||||
const entries = [...scene.sources, ...(scene.ambient ?? [])];
|
||||
const decoded = new Map();
|
||||
const decodedByFile = new Map();
|
||||
let retainedBytes = 0;
|
||||
for (let index = 0; index < ids.length; index += 1) {
|
||||
const assetId = ids[index];
|
||||
const file = files.get(assetId);
|
||||
if (!file) throw new Error(`오디오 원본 asset을 연결하세요: ${assetId}`);
|
||||
onProgress({ completed: index, total: ids.length, assetId, fileName: file.name });
|
||||
let audio = decodedByFile.get(file);
|
||||
if (!audio) {
|
||||
const remaining = MAX_DECODED_AUDIO_BYTES - retainedBytes;
|
||||
audio = await waitForDecode(
|
||||
decodeAudioFile(file, scene.sampleRate, remaining, signal),
|
||||
signal,
|
||||
);
|
||||
decodedByFile.set(file, audio);
|
||||
retainedBytes += audio.channels.reduce((sum, channel) => sum + channel.byteLength, 0);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.signal?.type !== "asset" || entry.signal.assetId !== assetId) continue;
|
||||
const channel = entry.signal.channel ?? 0;
|
||||
if (!audio.channels[channel]) {
|
||||
throw new Error(
|
||||
`${assetId}은 ${audio.channels.length}채널 오디오이므로 요청한 channel ${channel}을 사용할 수 없습니다.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
decoded.set(assetId, audio);
|
||||
}
|
||||
onProgress({ completed: ids.length, total: ids.length });
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function transferListForAudio(assetBuffers) {
|
||||
const transfer = [];
|
||||
const seen = new Set();
|
||||
for (const audio of assetBuffers.values()) {
|
||||
for (const channel of audio.channels) {
|
||||
if (!seen.has(channel.buffer)) {
|
||||
seen.add(channel.buffer);
|
||||
transfer.push(channel.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
return transfer;
|
||||
}
|
||||
|
||||
return {
|
||||
bindFiles,
|
||||
decodeAudioAssets,
|
||||
get imageElement() {
|
||||
return imageElement;
|
||||
},
|
||||
missingAssetIds,
|
||||
reset,
|
||||
statuses,
|
||||
transferListForAudio,
|
||||
dispose() {
|
||||
generation += 1;
|
||||
revokeImage();
|
||||
files.clear();
|
||||
errors.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
+119
-16
@@ -3,30 +3,133 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="micarrsim WebGPU acoustic camera simulator" />
|
||||
<meta name="description" content="192채널 WebGPU acoustic camera simulator" />
|
||||
<title>Acoustic Camera · micarrsim</title>
|
||||
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
|
||||
<link rel="stylesheet" href="/apps/acoustic-camera/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<a class="app-brand" href="/">
|
||||
<strong>micarrsim</strong>
|
||||
<span>Acoustic Camera</span>
|
||||
</a>
|
||||
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
|
||||
<div class="app-shell camera-shell">
|
||||
<header class="app-header camera-header">
|
||||
<a class="app-brand" href="/"><strong>micarrsim</strong><span>Acoustic Camera</span></a>
|
||||
<div class="header-status">
|
||||
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
|
||||
<span class="run-status" id="run-status">기본 fixture 준비 중…</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="button button--ghost is-hidden" id="cancel-button" type="button">계산 취소</button>
|
||||
<button class="button button--ghost" id="export-result-button" type="button" disabled>Result JSON</button>
|
||||
<button class="button button--primary" id="run-button" type="button" disabled>▶ 기본 예제 실행</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<section class="empty-state">
|
||||
<p class="eyebrow">Beamforming laboratory</p>
|
||||
<h1>배열 성능 비교하기</h1>
|
||||
<p>
|
||||
이 화면에서 scene과 192채널 배열을 불러와 자유음장 전달 신호를 만들고 주파수 대역별
|
||||
DAS acoustic image를 ground truth와 비교하게 됩니다.
|
||||
</p>
|
||||
|
||||
<main class="camera-layout">
|
||||
<section class="scene-workspace" aria-label="Acoustic image workspace">
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Ground truth + acoustic overlay</p>
|
||||
<h1 id="workspace-title">음향 장면</h1>
|
||||
<p class="workspace-meta" id="scene-meta">장면을 불러오고 있습니다.</p>
|
||||
</div>
|
||||
<div class="workspace-tools" aria-label="입력 파일">
|
||||
<label class="button button--ghost file-button">Scene<input id="scene-input" type="file" accept="application/json,.json" /></label>
|
||||
<label class="button button--ghost file-button">Array<input id="array-input" type="file" accept="application/json,.json" /></label>
|
||||
<label class="button button--ghost file-button">Experiment<input id="experiment-input" type="file" accept="application/json,.json" /></label>
|
||||
<label class="button button--ghost file-button">Assets<input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scene-canvas-card">
|
||||
<canvas id="scene-canvas" aria-label="Ground truth와 주파수별 acoustic image"></canvas>
|
||||
<div class="canvas-message" id="canvas-message"><strong>기본 synthetic scene이 준비됩니다.</strong><span>실행하면 주파수별 acoustic image가 이 평면에 겹쳐집니다.</span></div>
|
||||
<div class="canvas-badge" id="canvas-badge">결과 없음</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay-toolbar">
|
||||
<div class="band-legend" id="band-legend" aria-label="표시 주파수 대역"></div>
|
||||
<label class="range-field"><span>Overlay</span><input id="overlay-opacity" type="range" min="0" max="1" step="0.05" value="0.72" /></label>
|
||||
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
|
||||
</div>
|
||||
|
||||
<section class="result-section" aria-live="polite">
|
||||
<div class="section-heading"><div><p class="eyebrow">Evaluation</p><h2>실행 결과</h2></div><span class="result-chip" id="result-badge">아직 실행하지 않음</span></div>
|
||||
<div class="empty-result" id="empty-result">시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
|
||||
<div class="metric-grid is-hidden" id="metric-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
|
||||
<p class="section-copy">Array JSON을 바꿔 다시 실행하면 같은 scene의 결과를 이 세션에서 나란히 비교합니다.</p>
|
||||
<div class="history-list" id="history-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="control-panel" aria-label="Simulation parameters">
|
||||
<section class="array-card">
|
||||
<div class="array-heading"><div><p class="eyebrow">Microphone geometry</p><h2 id="array-title">배열</h2></div><strong id="microphone-count">— ch</strong></div>
|
||||
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
|
||||
<p id="array-meta">배열을 불러오고 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="asset-card">
|
||||
<div class="section-title"><span>INPUT</span><h2>장면과 assets</h2></div>
|
||||
<div class="source-summary" id="source-summary"></div>
|
||||
<div class="asset-list" id="asset-list"></div>
|
||||
<a class="builder-link" href="/apps/dataset-builder/">음원 위치·ambient 편집은 Dataset Builder에서 →</a>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>01</span><h2>실험</h2></div>
|
||||
<label class="field field--wide"><span>이름</span><input id="experiment-name" type="text" maxlength="120" /></label>
|
||||
<label class="field field--wide"><span>Experiment ID</span><input id="experiment-id" type="text" maxlength="120" /></label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
|
||||
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
|
||||
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
|
||||
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>02</span><h2>Scan grid</h2></div>
|
||||
<label class="field field--wide"><span>Preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>Columns</span><input id="scan-columns" type="number" min="8" max="512" step="1" /></label>
|
||||
<label class="field"><span>Rows</span><input id="scan-rows" type="number" min="8" max="512" step="1" /></label>
|
||||
<label class="field"><span>x min (m)</span><input id="scan-x-min" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>x max (m)</span><input id="scan-x-max" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>y min (m)</span><input id="scan-y-min" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>y max (m)</span><input id="scan-y-max" type="number" step="0.05" /></label>
|
||||
</div>
|
||||
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>03</span><h2>STFT와 표시</h2></div>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>FFT size</span><select id="fft-size"><option>128</option><option>256</option><option selected>512</option><option>1024</option><option>2048</option><option>4096</option></select></label>
|
||||
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
|
||||
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
|
||||
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
|
||||
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title section-title--spread"><div><span>04</span><h2>주파수 대역</h2></div><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
|
||||
<div class="band-editor-list" id="band-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="output-card"><button class="button button--ghost button--full" id="export-experiment-button" type="button">Experiment JSON 저장</button></section>
|
||||
|
||||
<section class="validation-card" aria-live="polite">
|
||||
<div class="validation-dot" id="validation-dot"></div>
|
||||
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">입력을 준비하고 있습니다.</p></div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/packages/ui/runtime-status.js"></script>
|
||||
<script type="module" src="/apps/acoustic-camera/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
||||
|
||||
const RESULT_METRIC_KEYS = ["peakToSidelobeDb", "energyInsideTruthRatio", "precision", "recall"];
|
||||
|
||||
function sortForCanonicalJson(value) {
|
||||
if (Array.isArray(value)) return value.map(sortForCanonicalJson);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.filter((key) => value[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => [key, sortForCanonicalJson(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function canonicalStringify(value) {
|
||||
return JSON.stringify(sortForCanonicalJson(value));
|
||||
}
|
||||
|
||||
export async function hashSimulationInputs({ arrayDefinition, scene, experiment }) {
|
||||
const canonical = canonicalStringify({ array: arrayDefinition, experiment, scene });
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
|
||||
const frameCount = getFrameCount(
|
||||
Math.ceil(scene.duration * scene.sampleRate),
|
||||
experiment.stft.fftSize,
|
||||
experiment.stft.hopSize,
|
||||
);
|
||||
const bandBins = experiment.frequencyBands.map((band) =>
|
||||
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
|
||||
);
|
||||
const selectedBinCount = new Set(bandBins.flat()).size;
|
||||
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
||||
const microphoneCount = arrayDefinition.microphones.length;
|
||||
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount;
|
||||
return {
|
||||
pointCount,
|
||||
microphoneCount,
|
||||
frameCount,
|
||||
selectedBinCount,
|
||||
bandBins,
|
||||
complexMacCount,
|
||||
scalingVs50: pointCount / 2500,
|
||||
distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function shiftSceneDistance(scene, experiment, nextDistance) {
|
||||
const previousDistance = experiment.scan.z;
|
||||
const delta = nextDistance - previousDistance;
|
||||
experiment.scan.z = nextDistance;
|
||||
for (const source of scene.sources) source.position[2] += delta;
|
||||
if (scene.image?.plane) scene.image.plane.z += delta;
|
||||
}
|
||||
|
||||
function finiteMetricSubset(metrics = {}) {
|
||||
return Object.fromEntries(
|
||||
RESULT_METRIC_KEYS
|
||||
.filter((key) => Number.isFinite(metrics[key]))
|
||||
.map((key) => [key, metrics[key]]),
|
||||
);
|
||||
}
|
||||
|
||||
function peakForResult(peak) {
|
||||
const result = {
|
||||
position: peak.position.map(Number),
|
||||
value: Number(peak.value),
|
||||
};
|
||||
if (peak.sourceId) result.sourceId = peak.sourceId;
|
||||
if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM;
|
||||
return result;
|
||||
}
|
||||
|
||||
function environmentForResult({ arrayDefinition, experiment, run }) {
|
||||
const environment = {
|
||||
userAgent: navigator.userAgent,
|
||||
requestedBackend: experiment.beamforming.backend,
|
||||
microphoneCount: arrayDefinition.microphones.length,
|
||||
gridPoints: experiment.scan.rows * experiment.scan.columns,
|
||||
};
|
||||
if (run.fallback?.code) environment.fallbackCode = run.fallback.code;
|
||||
if (run.fallback?.message) environment.fallbackMessage = run.fallback.message;
|
||||
const adapter = run.gpu?.adapterInfo;
|
||||
if (adapter?.vendor) environment.gpuVendor = adapter.vendor;
|
||||
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
|
||||
if (adapter?.device) environment.gpuDevice = adapter.device;
|
||||
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
|
||||
return environment;
|
||||
}
|
||||
|
||||
export function createResultDocument(options) {
|
||||
const {
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment,
|
||||
run,
|
||||
evaluations,
|
||||
inputHash,
|
||||
createdAt = new Date().toISOString(),
|
||||
} = options;
|
||||
return {
|
||||
schemaVersion: "1.0.0",
|
||||
kind: "micarrsim.result",
|
||||
experimentId: experiment.id,
|
||||
arrayId: arrayDefinition.id,
|
||||
sceneId: scene.id,
|
||||
createdAt,
|
||||
backend: run.backend,
|
||||
inputHash,
|
||||
signalStats: {
|
||||
preNormalizationPeak: run.signalStats.preNormalizationPeak,
|
||||
appliedScale: run.signalStats.appliedScale,
|
||||
clippedSampleCount: run.signalStats.clippedSampleCount,
|
||||
},
|
||||
timingMs: {
|
||||
total: run.timingMs.total,
|
||||
signalGeneration: run.timingMs.signalGeneration,
|
||||
beamforming: run.timingMs.beamforming,
|
||||
},
|
||||
environment: environmentForResult({ arrayDefinition, experiment, run }),
|
||||
bands: run.bands.map((band) => {
|
||||
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
|
||||
return {
|
||||
id: band.id,
|
||||
color: band.color,
|
||||
rawMap: {
|
||||
rows: experiment.scan.rows,
|
||||
columns: experiment.scan.columns,
|
||||
scale: "power",
|
||||
values: Array.from(band.rawMap),
|
||||
},
|
||||
displayMap: {
|
||||
rows: experiment.scan.rows,
|
||||
columns: experiment.scan.columns,
|
||||
scale: band.displayScale,
|
||||
values: Array.from(band.displayMap),
|
||||
},
|
||||
peaks: evaluation.peaks.map(peakForResult),
|
||||
metrics: finiteMetricSubset(evaluation.metrics),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHistorySummary({ arrayDefinition, scene, experiment, run, evaluations, inputHash }) {
|
||||
const firstBand = run.bands[0];
|
||||
const evaluation = evaluations.get(firstBand.id);
|
||||
const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0];
|
||||
return {
|
||||
id: `${Date.now()}-${inputHash.slice(0, 8)}`,
|
||||
arrayId: arrayDefinition.id,
|
||||
arrayName: arrayDefinition.name,
|
||||
sceneId: scene.id,
|
||||
experimentId: experiment.id,
|
||||
backend: run.backend,
|
||||
pointCount: experiment.scan.rows * experiment.scan.columns,
|
||||
totalMs: run.timingMs.total,
|
||||
localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null,
|
||||
peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null,
|
||||
inputHash,
|
||||
};
|
||||
}
|
||||
|
||||
export function safeFileStem(value, fallback = "micarrsim") {
|
||||
return String(value || fallback)
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || fallback;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
|
||||
|
||||
function post(type, jobId, payload = {}, transfer = []) {
|
||||
self.postMessage({ type, jobId, ...payload }, transfer);
|
||||
}
|
||||
|
||||
function serializeError(error) {
|
||||
return {
|
||||
name: error?.name ?? "Error",
|
||||
code: error?.code ?? "",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error?.stack ?? "",
|
||||
causes: error instanceof AggregateError
|
||||
? error.errors.map((cause) => ({ name: cause?.name, code: cause?.code, message: cause?.message }))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
self.addEventListener("message", async (event) => {
|
||||
if (event.data?.type !== "run") return;
|
||||
const { jobId, scene, arrayDefinition, experiment, assetBuffers } = event.data;
|
||||
const totalStartedAt = performance.now();
|
||||
try {
|
||||
post("progress", jobId, { stage: "signal", message: "192채널 자유음장 신호를 합성하고 있습니다…" });
|
||||
const signalStartedAt = performance.now();
|
||||
const synthesis = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
});
|
||||
const signalGeneration = performance.now() - signalStartedAt;
|
||||
post("progress", jobId, {
|
||||
stage: "signal-complete",
|
||||
message: `마이크 신호 합성 완료 · ${signalGeneration.toFixed(0)} ms`,
|
||||
});
|
||||
|
||||
const beamformingStartedAt = performance.now();
|
||||
const das = await computeDasWithBackend({
|
||||
channels: synthesis.channels,
|
||||
sampleRate: synthesis.sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment,
|
||||
onFallback: ({ error }) =>
|
||||
post("fallback", jobId, {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
}),
|
||||
onProgress: (progress) => post("progress", jobId, { stage: progress.stage, progress }),
|
||||
});
|
||||
const beamforming = performance.now() - beamformingStartedAt;
|
||||
const transfer = [];
|
||||
for (const band of das.bands) {
|
||||
transfer.push(band.rawMap.buffer, band.displayMap.buffer);
|
||||
}
|
||||
post(
|
||||
"result",
|
||||
jobId,
|
||||
{
|
||||
run: {
|
||||
...das,
|
||||
signalStats: {
|
||||
preNormalizationPeak: synthesis.preNormalizationPeak,
|
||||
appliedScale: synthesis.appliedScale,
|
||||
clippedSampleCount: synthesis.clippedSampleCount,
|
||||
peak: synthesis.peak,
|
||||
},
|
||||
timingMs: {
|
||||
total: performance.now() - totalStartedAt,
|
||||
signalGeneration,
|
||||
beamforming,
|
||||
},
|
||||
},
|
||||
},
|
||||
transfer,
|
||||
);
|
||||
} catch (error) {
|
||||
post("error", jobId, { error: serializeError(error) });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
.camera-shell { min-width: 320px; }
|
||||
.camera-header { position: sticky; z-index: 30; top: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; }
|
||||
.header-status, .header-actions, .workspace-tools, .overlay-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; min-width: 0; }
|
||||
.header-status { justify-content: center; }
|
||||
.run-status { overflow: hidden; max-width: 420px; color: var(--muted); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 9px 14px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface-raised); cursor: pointer; font-size: .8rem; font-weight: 720; transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; }
|
||||
.button:hover, .button:focus-visible, .mini-button:hover, .mini-button:focus-visible { border-color: var(--mint); outline: none; transform: translateY(-1px); }
|
||||
.button:disabled { cursor: wait; opacity: .48; transform: none; }
|
||||
.button--primary { border-color: rgb(101 242 191 / 55%); color: #06130f; background: var(--mint); }
|
||||
.button--ghost { background: rgb(255 255 255 / 3%); }
|
||||
.button--full { width: 100%; }
|
||||
.file-button input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
|
||||
.camera-layout { display: grid; grid-template-columns: minmax(600px, 1fr) minmax(350px, 410px); min-height: calc(100vh - 65px); }
|
||||
.scene-workspace { min-width: 0; padding: clamp(22px, 3vw, 42px); }
|
||||
.workspace-heading, .section-heading, .array-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
|
||||
.workspace-heading { margin-bottom: 20px; }
|
||||
.workspace-heading .eyebrow, .section-heading .eyebrow, .array-heading .eyebrow { margin-bottom: 7px; }
|
||||
.workspace-heading h1 { margin-bottom: 4px; font-size: clamp(1.9rem, 4vw, 3.2rem); letter-spacing: -.055em; }
|
||||
.workspace-meta, .section-copy { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.55; }
|
||||
.workspace-tools { justify-content: flex-end; }
|
||||
.scene-canvas-card { position: relative; overflow: hidden; min-height: 520px; border: 1px solid var(--line); border-radius: 22px; background: #07110f; box-shadow: var(--shadow); }
|
||||
#scene-canvas { display: block; width: 100%; height: clamp(520px, 64vh, 760px); }
|
||||
.canvas-message { position: absolute; top: 32px; left: 50%; display: grid; width: min(440px, calc(100% - 48px)); gap: 6px; color: var(--muted); text-align: center; pointer-events: none; transform: translateX(-50%); }
|
||||
.canvas-message strong { color: #cfe2db; font-size: .9rem; }
|
||||
.canvas-message span { font-size: .74rem; }
|
||||
.canvas-message.is-hidden { display: none; }
|
||||
.canvas-badge { position: absolute; right: 14px; bottom: 14px; max-width: calc(100% - 28px); overflow: hidden; padding: 7px 10px; border: 1px solid rgb(255 255 255 / 10%); border-radius: 999px; color: #d7e8e2; background: rgb(8 17 15 / 80%); backdrop-filter: blur(10px); font: 650 .7rem ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; }
|
||||
.overlay-toolbar { justify-content: space-between; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; background: rgb(255 255 255 / 2%); }
|
||||
.band-legend { display: flex; flex: 1 1 360px; flex-wrap: wrap; gap: 7px; }
|
||||
.legend-toggle { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0b1714; cursor: pointer; font-size: .68rem; }
|
||||
.legend-toggle input { accent-color: var(--band-color, var(--mint)); }
|
||||
.legend-swatch { width: 9px; height: 9px; border-radius: 50%; background: var(--band-color, var(--mint)); }
|
||||
.range-field, .check-field { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .7rem; font-weight: 650; }
|
||||
.range-field input { width: 110px; accent-color: var(--mint); }
|
||||
.check-field input { width: 17px; height: 17px; accent-color: var(--mint); }
|
||||
.result-section, .history-section { margin-top: 28px; padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: rgb(255 255 255 / 2%); }
|
||||
.section-heading h2 { margin: 0; font-size: 1.05rem; }
|
||||
.result-chip { padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .68rem; }
|
||||
.empty-result { margin-top: 14px; padding: 18px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); font-size: .78rem; line-height: 1.6; text-align: center; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; margin-top: 14px; }
|
||||
.metric-card { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 13px; background: #0b1714; }
|
||||
.metric-card span { display: block; margin-bottom: 7px; color: var(--muted); font-size: .66rem; }
|
||||
.metric-card strong { display: block; overflow: hidden; font-size: 1.15rem; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; }
|
||||
.text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; }
|
||||
.history-list { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; }
|
||||
.history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; }
|
||||
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.history-card strong { font-size: .78rem; }
|
||||
.history-card span { color: var(--muted); font-size: .67rem; }
|
||||
.control-panel { position: sticky; top: 65px; align-self: start; overflow-y: auto; max-height: calc(100vh - 65px); padding: 16px; border-left: 1px solid var(--line); background: rgb(12 24 21 / 90%); backdrop-filter: blur(18px); }
|
||||
.array-card, .asset-card, .control-section, .output-card, .validation-card { padding: 16px; border: 1px solid var(--line); border-radius: 16px; background: rgb(255 255 255 / 2%); }
|
||||
.array-card + section, .control-panel section + section { margin-top: 11px; }
|
||||
.array-heading { align-items: flex-start; }
|
||||
.array-heading h2 { margin: 0; font-size: .95rem; }
|
||||
.array-heading > strong { color: var(--mint); font: 750 .78rem ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
#array-canvas { display: block; width: 100%; height: 265px; margin-top: 10px; border: 1px solid var(--line); border-radius: 12px; background: #07110f; }
|
||||
#array-meta { margin: 9px 0 0; color: var(--muted); font: 600 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.section-title { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; }
|
||||
.section-title > span, .section-title > div > span { color: var(--mint); font: 750 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.section-title h2 { margin: 0; font-size: .88rem; }
|
||||
.section-title--spread { justify-content: space-between; }
|
||||
.section-title--spread > div { display: flex; align-items: center; gap: 9px; }
|
||||
.source-summary { display: grid; gap: 7px; }
|
||||
.source-line { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .69rem; }
|
||||
.source-line strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.asset-list { display: grid; gap: 7px; margin-top: 10px; }
|
||||
.asset-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); font-size: .66rem; }
|
||||
.asset-status strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.asset-status.is-ready span { color: var(--mint); }
|
||||
.asset-status.is-error span { color: var(--danger); }
|
||||
.asset-empty { color: var(--muted); font-size: .68rem; }
|
||||
.builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; }
|
||||
.field { display: grid; min-width: 0; gap: 6px; }
|
||||
.field > span { color: var(--muted); font-size: .67rem; font-weight: 650; }
|
||||
.field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
|
||||
.field input:focus, .field select:focus, .band-editor input:focus { border-color: var(--mint); outline: 2px solid rgb(101 242 191 / 11%); }
|
||||
.field--wide + .field--wide, .field--wide + .field-grid { margin-top: 9px; }
|
||||
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 9px; }
|
||||
.workload-summary { margin: 11px 0 0; padding: 10px; border-radius: 9px; color: var(--muted); background: #0b1714; font: 600 .66rem/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.workload-summary.is-warning { color: var(--amber); }
|
||||
.band-editor-list { display: grid; gap: 9px; }
|
||||
.band-editor { display: grid; grid-template-columns: minmax(70px, 1fr) minmax(62px, .8fr) minmax(62px, .8fr) 34px; gap: 6px; padding: 9px; border: 1px solid var(--line); border-radius: 11px; background: #0b1714; }
|
||||
.band-editor input { min-width: 0; font-size: .68rem; }
|
||||
.band-editor input[type="color"] { padding: 3px; }
|
||||
.band-editor__remove { border: 0; border-radius: 8px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
|
||||
.mini-button { border-color: var(--line); }
|
||||
.validation-card { display: flex; align-items: flex-start; gap: 11px; }
|
||||
.validation-dot { width: 10px; height: 10px; flex: 0 0 auto; margin-top: 4px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 5px rgb(145 170 162 / 8%); }
|
||||
.validation-card strong { font-size: .8rem; }
|
||||
.validation-card p { margin: 4px 0 0; color: var(--muted); font-size: .69rem; line-height: 1.5; }
|
||||
.validation-card.is-valid .validation-dot { background: var(--mint); box-shadow: 0 0 0 5px rgb(101 242 191 / 10%); }
|
||||
.validation-card.is-invalid .validation-dot { background: var(--danger); box-shadow: 0 0 0 5px rgb(255 123 115 / 10%); }
|
||||
.validation-card.is-warning .validation-dot { background: var(--amber); box-shadow: 0 0 0 5px rgb(255 197 110 / 10%); }
|
||||
.is-hidden { display: none !important; }
|
||||
@media (max-width: 1120px) {
|
||||
.camera-header { grid-template-columns: auto 1fr; }
|
||||
.header-status { justify-content: flex-end; }
|
||||
.header-actions { grid-column: 1 / -1; justify-content: flex-end; }
|
||||
.camera-layout { grid-template-columns: 1fr; }
|
||||
.control-panel { position: static; max-height: none; border-top: 1px solid var(--line); border-left: 0; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.camera-header, .workspace-heading { align-items: stretch; }
|
||||
.camera-header { position: static; display: flex; flex-direction: column; }
|
||||
.header-status, .header-actions, .workspace-tools { justify-content: flex-start; width: 100%; }
|
||||
.header-actions .button, .workspace-tools .button { flex: 1 1 calc(50% - 5px); }
|
||||
.scene-workspace { padding: 18px; }
|
||||
.workspace-heading { flex-direction: column; }
|
||||
.scene-canvas-card { min-height: 380px; }
|
||||
#scene-canvas { height: 420px; }
|
||||
.history-card { grid-template-columns: 1fr 1fr; }
|
||||
.history-card strong { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.field-grid, .band-editor { grid-template-columns: 1fr; }
|
||||
.band-editor__remove { min-height: 34px; }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user