fix: harden array builder editing workflows
This commit is contained in:
@@ -59,6 +59,11 @@ Array JSON을 저장하세요.
|
|||||||
- **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다.
|
- **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다.
|
||||||
|
|
||||||
확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다.
|
확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다.
|
||||||
|
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
|
||||||
|
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
|
||||||
|
스크롤하려는 동작으로 채널이 생기지 않습니다.
|
||||||
|
`JSON 열기`로 다른 배열을 불러온 직후에도 한 번 실행 취소하면 불러오기 전 문서를 복구할 수
|
||||||
|
있습니다. 다시 실행하면 불러온 배열로 돌아갑니다.
|
||||||
|
|
||||||
## 보정값 보존
|
## 보정값 보존
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const PLOT_BACKGROUND = "#0b1714";
|
|||||||
const MAX_PIXEL_RATIO = 3;
|
const MAX_PIXEL_RATIO = 3;
|
||||||
const MIN_ZOOM = 0.25;
|
const MIN_ZOOM = 0.25;
|
||||||
const MAX_ZOOM = 16;
|
const MAX_ZOOM = 16;
|
||||||
|
const TOUCH_GESTURE_THRESHOLD_PX = 6;
|
||||||
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
|
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
|
||||||
|
|
||||||
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
|
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
|
||||||
@@ -97,6 +98,26 @@ export function fitArrayViewport(width, height, arrayDefinition, options = {}) {
|
|||||||
const centerY = (contentYMin + contentYMax) / 2;
|
const centerY = (contentYMin + contentYMax) / 2;
|
||||||
const visibleWidth = plot.width / scale;
|
const visibleWidth = plot.width / scale;
|
||||||
const visibleHeight = plot.height / scale;
|
const visibleHeight = plot.height / scale;
|
||||||
|
const fittedValues = [
|
||||||
|
scale,
|
||||||
|
1 / scale,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
visibleWidth,
|
||||||
|
visibleHeight,
|
||||||
|
centerX - visibleWidth / 2,
|
||||||
|
centerX + visibleWidth / 2,
|
||||||
|
centerY - visibleHeight / 2,
|
||||||
|
centerY + visibleHeight / 2,
|
||||||
|
];
|
||||||
|
if (fittedValues.some((value) => !Number.isFinite(value))) {
|
||||||
|
return fitArrayViewport(
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
{ aperture, microphones: [] },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: canvasWidth,
|
width: canvasWidth,
|
||||||
@@ -168,6 +189,7 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
|
|||||||
const microphone = microphones[index];
|
const microphone = microphones[index];
|
||||||
if (!validPosition(microphone?.position)) continue;
|
if (!validPosition(microphone?.position)) continue;
|
||||||
const pixel = arrayPositionToCanvas(microphone.position, viewport);
|
const pixel = arrayPositionToCanvas(microphone.position, viewport);
|
||||||
|
if (!Number.isFinite(pixel.x) || !Number.isFinite(pixel.y)) continue;
|
||||||
const distancePx = Math.hypot(pixel.x - point.x, pixel.y - point.y);
|
const distancePx = Math.hypot(pixel.x - point.x, pixel.y - point.y);
|
||||||
if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) {
|
if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) {
|
||||||
best = { id: microphone.id, index, microphone, point: pixel, distancePx };
|
best = { id: microphone.id, index, microphone, point: pixel, distancePx };
|
||||||
@@ -359,13 +381,14 @@ function drawMicrophones(context, model, viewport, drag) {
|
|||||||
const position = drag?.id === microphone.id ? drag.last : microphone.position;
|
const position = drag?.id === microphone.id ? drag.last : microphone.position;
|
||||||
const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
|
const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
|
||||||
const point = arrayPositionToCanvas(position, viewport);
|
const point = arrayPositionToCanvas(position, viewport);
|
||||||
|
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
||||||
const isSelected = microphone.id === selectedId;
|
const isSelected = microphone.id === selectedId;
|
||||||
const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape);
|
const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape);
|
||||||
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
|
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
|
||||||
|
|
||||||
context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)";
|
context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)";
|
||||||
context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)";
|
context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)";
|
||||||
context.shadowBlur = isSelected ? 16 : 6;
|
context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
||||||
context.fill();
|
context.fill();
|
||||||
@@ -449,10 +472,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
let zoom = 1;
|
let zoom = 1;
|
||||||
let tool = "select";
|
let tool = "select";
|
||||||
let drag = null;
|
let drag = null;
|
||||||
|
let pendingAdd = null;
|
||||||
|
|
||||||
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
|
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
|
||||||
if (canvas.style) canvas.style.touchAction = "none";
|
|
||||||
|
|
||||||
function updateCanvasAffordance() {
|
function updateCanvasAffordance() {
|
||||||
if (canvas.dataset) {
|
if (canvas.dataset) {
|
||||||
canvas.dataset.tool = tool;
|
canvas.dataset.tool = tool;
|
||||||
@@ -492,6 +514,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
function setTool(nextTool) {
|
function setTool(nextTool) {
|
||||||
const resolved = normalizedTool(nextTool);
|
const resolved = normalizedTool(nextTool);
|
||||||
if (resolved === tool) return tool;
|
if (resolved === tool) return tool;
|
||||||
|
if (resolved !== "add") pendingAdd = null;
|
||||||
tool = resolved;
|
tool = resolved;
|
||||||
updateCanvasAffordance();
|
updateCanvasAffordance();
|
||||||
callbacks.onToolChange?.(tool);
|
callbacks.onToolChange?.(tool);
|
||||||
@@ -550,15 +573,49 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
return eventToCanvasPoint(event, canvas.getBoundingClientRect());
|
return eventToCanvasPoint(event, canvas.getBoundingClientRect());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clientPoint(event) {
|
||||||
|
return {
|
||||||
|
x: finiteNumber(event?.clientX, 0),
|
||||||
|
y: finiteNumber(event?.clientY, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exceedsTouchThreshold(start, event) {
|
||||||
|
const current = clientPoint(event);
|
||||||
|
return Math.hypot(current.x - start.x, current.y - start.y) >
|
||||||
|
TOUCH_GESTURE_THRESHOLD_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(event) {
|
||||||
|
if (destroyed || tool !== "select" || !model?.arrayDefinition) return;
|
||||||
|
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
|
||||||
|
const currentViewport = ensureViewport();
|
||||||
|
if (!touch || !currentViewport) return;
|
||||||
|
const hit = hitTestMicrophone(
|
||||||
|
pointerPoint(touch),
|
||||||
|
model.arrayDefinition.microphones,
|
||||||
|
currentViewport,
|
||||||
|
);
|
||||||
|
if (hit) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function onPointerDown(event) {
|
function onPointerDown(event) {
|
||||||
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
|
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
|
||||||
|
if (drag || pendingAdd) return;
|
||||||
focus();
|
focus();
|
||||||
if (tool === "add") {
|
if (tool === "add") {
|
||||||
const position = constrainedEventPosition(event, 0);
|
const position = constrainedEventPosition(event, 0);
|
||||||
if (position) {
|
if (position) {
|
||||||
|
if (event.pointerType === "touch") {
|
||||||
|
pendingAdd = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startClient: clientPoint(event),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
callbacks.onAdd?.(position);
|
callbacks.onAdd?.(position);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,15 +634,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
id: hit.id,
|
id: hit.id,
|
||||||
start: [...hit.microphone.position],
|
start: [...hit.microphone.position],
|
||||||
last: [...hit.microphone.position],
|
last: [...hit.microphone.position],
|
||||||
|
startClient: clientPoint(event),
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
pending: event.pointerType === "touch",
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
canvas.setPointerCapture?.(event.pointerId);
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
callbacks.onDragStart?.(hit.id);
|
} catch {
|
||||||
|
// Synthetic pointers and interrupted input do not always own capture.
|
||||||
|
}
|
||||||
|
if (!drag.pending) callbacks.onDragStart?.(hit.id);
|
||||||
updateCanvasAffordance();
|
updateCanvasAffordance();
|
||||||
schedule();
|
schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(event) {
|
function onPointerMove(event) {
|
||||||
|
if (pendingAdd?.pointerId === event.pointerId) {
|
||||||
|
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
if (drag.pending) {
|
||||||
|
if (!exceedsTouchThreshold(drag.startClient, event)) return;
|
||||||
|
drag.pending = false;
|
||||||
|
callbacks.onDragStart?.(drag.id);
|
||||||
|
}
|
||||||
const microphone = microphoneById(drag.id);
|
const microphone = microphoneById(drag.id);
|
||||||
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
|
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
@@ -599,17 +672,37 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return;
|
if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return;
|
||||||
const completed = drag;
|
const completed = drag;
|
||||||
drag = null;
|
drag = null;
|
||||||
|
if (!completed.pending) {
|
||||||
const position = cancel ? completed.start : completed.last;
|
const position = cancel ? completed.start : completed.last;
|
||||||
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
|
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
|
||||||
callbacks.onDragEnd?.(completed.id, [...position]);
|
callbacks.onDragEnd?.(completed.id, [...position]);
|
||||||
|
}
|
||||||
const pointerId = event?.pointerId ?? completed.pointerId;
|
const pointerId = event?.pointerId ?? completed.pointerId;
|
||||||
|
try {
|
||||||
if (canvas.hasPointerCapture?.(pointerId)) {
|
if (canvas.hasPointerCapture?.(pointerId)) {
|
||||||
canvas.releasePointerCapture?.(pointerId);
|
canvas.releasePointerCapture?.(pointerId);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// The browser may have released capture before pointercancel/lostcapture.
|
||||||
|
}
|
||||||
updateCanvasAffordance();
|
updateCanvasAffordance();
|
||||||
schedule();
|
schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishPendingAdd(event, cancel = false) {
|
||||||
|
if (!pendingAdd || event?.pointerId !== pendingAdd.pointerId) return false;
|
||||||
|
const completed = pendingAdd;
|
||||||
|
pendingAdd = null;
|
||||||
|
if (!cancel && tool === "add" && !exceedsTouchThreshold(completed.startClient, event)) {
|
||||||
|
const position = constrainedEventPosition(event, 0);
|
||||||
|
if (position) {
|
||||||
|
event.preventDefault();
|
||||||
|
callbacks.onAdd?.(position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function onKeyDown(event) {
|
function onKeyDown(event) {
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||||
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
@@ -649,10 +742,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(event) {
|
function onPointerUp(event) {
|
||||||
|
if (finishPendingAdd(event, false)) return;
|
||||||
finishDrag(event, false);
|
finishDrag(event, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerCancel(event) {
|
function onPointerCancel(event) {
|
||||||
|
if (finishPendingAdd(event, true)) return;
|
||||||
finishDrag(event, true);
|
finishDrag(event, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +760,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
canvas.addEventListener("pointerup", onPointerUp);
|
canvas.addEventListener("pointerup", onPointerUp);
|
||||||
canvas.addEventListener("pointercancel", onPointerCancel);
|
canvas.addEventListener("pointercancel", onPointerCancel);
|
||||||
canvas.addEventListener("lostpointercapture", onLostPointerCapture);
|
canvas.addEventListener("lostpointercapture", onLostPointerCapture);
|
||||||
|
canvas.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||||
canvas.addEventListener("keydown", onKeyDown);
|
canvas.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
let resizeObserver = null;
|
let resizeObserver = null;
|
||||||
@@ -698,10 +794,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
|||||||
canvas.removeEventListener("pointerup", onPointerUp);
|
canvas.removeEventListener("pointerup", onPointerUp);
|
||||||
canvas.removeEventListener("pointercancel", onPointerCancel);
|
canvas.removeEventListener("pointercancel", onPointerCancel);
|
||||||
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
|
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
|
||||||
|
canvas.removeEventListener("touchstart", onTouchStart);
|
||||||
canvas.removeEventListener("keydown", onKeyDown);
|
canvas.removeEventListener("keydown", onKeyDown);
|
||||||
model = null;
|
model = null;
|
||||||
viewport = null;
|
viewport = null;
|
||||||
drag = null;
|
drag = null;
|
||||||
|
pendingAdd = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<link rel="stylesheet" href="/apps/array-builder/styles.css" />
|
<link rel="stylesheet" href="/apps/array-builder/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a class="skip-link" href="#array-canvas">배열 편집 화면으로 건너뛰기</a>
|
||||||
<div class="app-shell array-builder-shell">
|
<div class="app-shell array-builder-shell">
|
||||||
<header class="app-header array-builder-header">
|
<header class="app-header array-builder-header">
|
||||||
<a class="app-brand" href="/">
|
<a class="app-brand" href="/">
|
||||||
@@ -39,6 +40,65 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="array-builder-layout" id="array-builder-layout" data-split-layout>
|
<main class="array-builder-layout" id="array-builder-layout" data-split-layout>
|
||||||
|
<section class="array-workspace" id="array-builder-workspace" aria-label="마이크 배열 편집 화면">
|
||||||
|
<div class="workspace-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">마이크 배치</p>
|
||||||
|
<h1>Array 평면 편집</h1>
|
||||||
|
</div>
|
||||||
|
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workspace-toolbar" role="toolbar" aria-label="배열 편집 도구">
|
||||||
|
<div class="toolbar-group" aria-label="편집 도구">
|
||||||
|
<button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button>
|
||||||
|
<button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group" aria-label="실행 취소와 다시 실행">
|
||||||
|
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
|
||||||
|
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group zoom-tools" aria-label="화면 확대와 맞춤">
|
||||||
|
<button class="icon-button" id="zoom-out-button" type="button" aria-label="축소" title="축소">−</button>
|
||||||
|
<button class="tool-button" id="fit-view-button" type="button">화면 맞춤</button>
|
||||||
|
<button class="icon-button" id="zoom-in-button" type="button" aria-label="확대" title="확대">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="snap-controls">
|
||||||
|
<label class="check-field"><input id="snap-enabled" type="checkbox" checked /><span>격자 스냅</span></label>
|
||||||
|
<label class="compact-field"><span>간격</span><input id="snap-step" type="number" min="0.1" max="1000" step="0.1" value="5" inputmode="decimal" /><small>mm</small></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="array-canvas-card">
|
||||||
|
<canvas
|
||||||
|
id="array-canvas"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="마이크 어레이 평면 편집 canvas"
|
||||||
|
aria-describedby="canvas-instructions"
|
||||||
|
></canvas>
|
||||||
|
<div class="canvas-instructions" id="canvas-instructions">
|
||||||
|
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
|
||||||
|
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
|
||||||
|
</div>
|
||||||
|
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="array-statistics" aria-label="배열 요약">
|
||||||
|
<article class="metric-card"><span>채널</span><strong id="microphone-count">12</strong><small>microphones</small></article>
|
||||||
|
<article class="metric-card"><span>최소 간격</span><strong id="minimum-spacing">—</strong><small>nearest pair</small></article>
|
||||||
|
<article class="metric-card"><span>무게중심</span><strong id="array-centroid">—</strong><small>x, y, z</small></article>
|
||||||
|
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="shortcut-help">
|
||||||
|
<summary>빠른 편집 키</summary>
|
||||||
|
<div>
|
||||||
|
<p><kbd>V</kbd> 선택 · <kbd>A</kbd> 추가 · <kbd>Delete</kbd> 선택 삭제</p>
|
||||||
|
<p><kbd>← ↑ ↓ →</kbd> 위치 미세 조정 · <kbd>Shift</kbd>를 함께 누르면 10배 이동</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
class="array-controls"
|
class="array-controls"
|
||||||
id="array-builder-controls"
|
id="array-builder-controls"
|
||||||
@@ -58,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="control-section">
|
<section class="control-section array-metadata-section">
|
||||||
<div class="section-title"><span>01</span><h2>배열 정보</h2></div>
|
<div class="section-title"><span>01</span><h2>배열 정보</h2></div>
|
||||||
<label class="field field--wide">
|
<label class="field field--wide">
|
||||||
<span>이름</span>
|
<span>이름</span>
|
||||||
@@ -81,7 +141,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="control-section control-section--primary">
|
<section class="control-section control-section--primary aperture-section">
|
||||||
<div class="section-title"><span>02</span><h2>크기와 형상</h2></div>
|
<div class="section-title"><span>02</span><h2>크기와 형상</h2></div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -100,7 +160,7 @@
|
|||||||
<option value="ellipse">타원</option>
|
<option value="ellipse">타원</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p class="section-note">크기를 바꿔도 기존 마이크 좌표는 자동으로 늘어나거나 줄어들지 않습니다.</p>
|
<p class="section-note">크기를 바꿔도 기존 좌표는 자동으로 늘어나거나 줄어들지 않습니다. 생성 경계 형상은 편집용이며 JSON에는 최종 좌표와 너비·높이만 저장됩니다.</p>
|
||||||
<div class="button-grid button-grid--two">
|
<div class="button-grid button-grid--two">
|
||||||
<button class="button button--ghost" id="center-microphones-button" type="button">좌표 중앙 정렬</button>
|
<button class="button button--ghost" id="center-microphones-button" type="button">좌표 중앙 정렬</button>
|
||||||
<button class="button button--ghost" id="fit-aperture-button" type="button">좌표에 크기 맞춤</button>
|
<button class="button button--ghost" id="fit-aperture-button" type="button">좌표에 크기 맞춤</button>
|
||||||
@@ -108,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="control-section">
|
<section class="control-section preset-section">
|
||||||
<div class="section-title"><span>03</span><h2>프리셋 생성</h2></div>
|
<div class="section-title"><span>03</span><h2>프리셋 생성</h2></div>
|
||||||
<label class="field field--wide">
|
<label class="field field--wide">
|
||||||
<span>배치 방식</span>
|
<span>배치 방식</span>
|
||||||
@@ -201,6 +261,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="mobile-save-actions">
|
||||||
|
<button class="button button--primary button--full" id="mobile-export-button" type="button">Array JSON 저장</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -216,64 +280,6 @@
|
|||||||
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
|
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<section class="array-workspace" id="array-builder-workspace" aria-label="마이크 배열 편집 화면">
|
|
||||||
<div class="workspace-heading">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">마이크 배치</p>
|
|
||||||
<h1>Array 평면 편집</h1>
|
|
||||||
</div>
|
|
||||||
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workspace-toolbar" role="toolbar" aria-label="배열 편집 도구">
|
|
||||||
<div class="toolbar-group" aria-label="편집 도구">
|
|
||||||
<button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button>
|
|
||||||
<button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
|
|
||||||
</div>
|
|
||||||
<div class="toolbar-group" aria-label="실행 취소와 다시 실행">
|
|
||||||
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
|
|
||||||
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
|
|
||||||
</div>
|
|
||||||
<div class="toolbar-group zoom-tools" aria-label="화면 확대와 맞춤">
|
|
||||||
<button class="icon-button" id="zoom-out-button" type="button" aria-label="축소" title="축소">−</button>
|
|
||||||
<button class="tool-button" id="fit-view-button" type="button">화면 맞춤</button>
|
|
||||||
<button class="icon-button" id="zoom-in-button" type="button" aria-label="확대" title="확대">+</button>
|
|
||||||
</div>
|
|
||||||
<div class="snap-controls">
|
|
||||||
<label class="check-field"><input id="snap-enabled" type="checkbox" checked /><span>격자 스냅</span></label>
|
|
||||||
<label class="compact-field"><span>간격</span><input id="snap-step" type="number" min="0.1" max="1000" step="0.1" value="5" inputmode="decimal" /><small>mm</small></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="array-canvas-card">
|
|
||||||
<canvas
|
|
||||||
id="array-canvas"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="마이크 어레이 평면 편집 canvas"
|
|
||||||
aria-describedby="canvas-instructions"
|
|
||||||
></canvas>
|
|
||||||
<div class="canvas-instructions" id="canvas-instructions">
|
|
||||||
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
|
|
||||||
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
|
|
||||||
</div>
|
|
||||||
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="array-statistics" aria-label="배열 요약">
|
|
||||||
<article class="metric-card"><span>채널</span><strong id="microphone-count">12</strong><small>microphones</small></article>
|
|
||||||
<article class="metric-card"><span>최소 간격</span><strong id="minimum-spacing">—</strong><small>nearest pair</small></article>
|
|
||||||
<article class="metric-card"><span>무게중심</span><strong id="array-centroid">—</strong><small>x, y, z</small></article>
|
|
||||||
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details class="shortcut-help">
|
|
||||||
<summary>빠른 편집 키</summary>
|
|
||||||
<div>
|
|
||||||
<p><kbd>V</kbd> 선택 · <kbd>A</kbd> 추가 · <kbd>Delete</kbd> 선택 삭제</p>
|
|
||||||
<p><kbd>← ↑ ↓ →</kbd> 위치 미세 조정 · <kbd>Shift</kbd>를 함께 누르면 10배 이동</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+244
-43
@@ -49,7 +49,7 @@ const optionalIds = [
|
|||||||
"move-microphone-down-button", "microphone-sensitivity", "microphone-phase",
|
"move-microphone-down-button", "microphone-sensitivity", "microphone-phase",
|
||||||
"frequency-response-summary", "preset-axis", "preset-edge-inset",
|
"frequency-response-summary", "preset-axis", "preset-edge-inset",
|
||||||
"microphone-search", "microphone-selection-empty", "microphone-fields",
|
"microphone-search", "microphone-selection-empty", "microphone-fields",
|
||||||
"workspace-summary",
|
"workspace-summary", "canvas-instructions", "mobile-export-button",
|
||||||
];
|
];
|
||||||
for (const id of optionalIds) elements[id] = maybeById(id);
|
for (const id of optionalIds) elements[id] = maybeById(id);
|
||||||
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
||||||
@@ -80,14 +80,20 @@ const state = {
|
|||||||
history: [],
|
history: [],
|
||||||
future: [],
|
future: [],
|
||||||
dragSnapshot: null,
|
dragSnapshot: null,
|
||||||
|
importRevision: 0,
|
||||||
|
baselineSignature: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
const MAX_HISTORY = 50;
|
||||||
|
const MAX_HISTORY_BYTES = 24 * 1024 * 1024;
|
||||||
|
const frequencyResponseFingerprintCache = new WeakMap();
|
||||||
const BUILTIN_ARRAY_IDS = new Set([
|
const BUILTIN_ARRAY_IDS = new Set([
|
||||||
"rectangular-2x2", "rectangular-4x3", "rectangular-8x6",
|
"rectangular-2x2-450mm", "rectangular-4x3-450mm",
|
||||||
"rectangular-16x12", "rectangular-24x16", "rectangular-16x12-compact",
|
"rectangular-8x6-450mm", "rectangular-16x12",
|
||||||
|
"rectangular-24x16-450mm", "rectangular-16x12-compact",
|
||||||
"rectangular-16x12-large", "fermat-spiral-192",
|
"rectangular-16x12-large", "fermat-spiral-192",
|
||||||
]);
|
]);
|
||||||
|
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
|
||||||
|
|
||||||
function finiteInput(element, label, options = {}) {
|
function finiteInput(element, label, options = {}) {
|
||||||
const value = element.valueAsNumber;
|
const value = element.valueAsNumber;
|
||||||
@@ -121,35 +127,152 @@ function selectedMicrophone() {
|
|||||||
) ?? null;
|
) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneArrayForHistory(arrayDefinition) {
|
||||||
|
return {
|
||||||
|
...arrayDefinition,
|
||||||
|
coordinateSystem: { ...arrayDefinition.coordinateSystem },
|
||||||
|
aperture: { ...arrayDefinition.aperture },
|
||||||
|
microphones: arrayDefinition.microphones.map((microphone) => ({
|
||||||
|
...microphone,
|
||||||
|
position: [...microphone.position],
|
||||||
|
...(microphone.frequencyResponse === undefined
|
||||||
|
? {}
|
||||||
|
: { frequencyResponse: microphone.frequencyResponse }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function captureSnapshot() {
|
function captureSnapshot() {
|
||||||
return {
|
return {
|
||||||
arrayDefinition: structuredClone(state.arrayDefinition),
|
arrayDefinition: cloneArrayForHistory(state.arrayDefinition),
|
||||||
apertureShape: state.apertureShape,
|
apertureShape: state.apertureShape,
|
||||||
selectedMicrophoneId: state.selectedMicrophoneId,
|
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotSignature(snapshot) {
|
function editableSnapshotSignature(snapshot) {
|
||||||
return JSON.stringify(snapshot);
|
const arrayDefinition = snapshot.arrayDefinition;
|
||||||
|
return JSON.stringify({
|
||||||
|
document: editableDocumentView(arrayDefinition),
|
||||||
|
apertureShape: snapshot.apertureShape,
|
||||||
|
selectedMicrophoneId: snapshot.selectedMicrophoneId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintFrequencyResponse(response) {
|
||||||
|
if (!Array.isArray(response)) return null;
|
||||||
|
const cached = frequencyResponseFingerprintCache.get(response);
|
||||||
|
if (cached) return cached;
|
||||||
|
const canonical = JSON.stringify(response.map((point) => [
|
||||||
|
point.frequencyHz,
|
||||||
|
point.gainDb,
|
||||||
|
point.phaseDeg ?? null,
|
||||||
|
]));
|
||||||
|
let first = 0x811c9dc5;
|
||||||
|
let second = 0x9e3779b9;
|
||||||
|
for (let index = 0; index < canonical.length; index += 1) {
|
||||||
|
const code = canonical.charCodeAt(index);
|
||||||
|
first = Math.imul(first ^ code, 0x01000193) >>> 0;
|
||||||
|
second = Math.imul(second ^ code, 0x85ebca6b) >>> 0;
|
||||||
|
}
|
||||||
|
const fingerprint =
|
||||||
|
String(canonical.length) + ":" +
|
||||||
|
first.toString(16).padStart(8, "0") + ":" +
|
||||||
|
second.toString(16).padStart(8, "0");
|
||||||
|
frequencyResponseFingerprintCache.set(response, fingerprint);
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableDocumentView(arrayDefinition) {
|
||||||
|
return {
|
||||||
|
schemaVersion: arrayDefinition.schemaVersion,
|
||||||
|
kind: arrayDefinition.kind,
|
||||||
|
id: arrayDefinition.id,
|
||||||
|
name: arrayDefinition.name,
|
||||||
|
description: arrayDefinition.description,
|
||||||
|
units: arrayDefinition.units,
|
||||||
|
coordinateSystem: arrayDefinition.coordinateSystem,
|
||||||
|
aperture: arrayDefinition.aperture,
|
||||||
|
microphones: arrayDefinition.microphones.map((microphone) => [
|
||||||
|
microphone.id,
|
||||||
|
...microphone.position,
|
||||||
|
microphone.sensitivityDb ?? null,
|
||||||
|
microphone.phaseOffsetDeg ?? null,
|
||||||
|
fingerprintFrequencyResponse(microphone.frequencyResponse),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableDocumentSignature(arrayDefinition) {
|
||||||
|
return JSON.stringify(editableDocumentView(arrayDefinition));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSnapshotView() {
|
||||||
|
return {
|
||||||
|
arrayDefinition: state.arrayDefinition,
|
||||||
|
apertureShape: state.apertureShape,
|
||||||
|
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatedSnapshotBytes(snapshot) {
|
||||||
|
const responsePointCount = snapshot.arrayDefinition.microphones.reduce(
|
||||||
|
(count, microphone) => count + (microphone.frequencyResponse?.length ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return editableSnapshotSignature(snapshot).length * 4 + responsePointCount * 160;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyEntry(snapshot, label) {
|
||||||
|
return { snapshot, label, bytes: estimatedSnapshotBytes(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimHistoryMemory() {
|
||||||
|
while (state.history.length > MAX_HISTORY) state.history.shift();
|
||||||
|
let totalBytes = [...state.history, ...state.future].reduce(
|
||||||
|
(sum, entry) => sum + (entry.bytes ?? estimatedSnapshotBytes(entry.snapshot)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
while (totalBytes > MAX_HISTORY_BYTES && state.history.length > 1) {
|
||||||
|
totalBytes -= state.history.shift().bytes;
|
||||||
|
}
|
||||||
|
while (totalBytes > MAX_HISTORY_BYTES && state.future.length > 1) {
|
||||||
|
totalBytes -= state.future.shift().bytes;
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
totalBytes > MAX_HISTORY_BYTES &&
|
||||||
|
state.history.length + state.future.length > 1
|
||||||
|
) {
|
||||||
|
const removed = state.history.length
|
||||||
|
? state.history.shift()
|
||||||
|
: state.future.shift();
|
||||||
|
totalBytes -= removed.bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDirtyState() {
|
||||||
|
state.dirty = editableDocumentSignature(state.arrayDefinition) !== state.baselineSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreSnapshot(snapshot, message) {
|
function restoreSnapshot(snapshot, message) {
|
||||||
state.arrayDefinition = structuredClone(snapshot.arrayDefinition);
|
state.arrayDefinition = cloneArrayForHistory(snapshot.arrayDefinition);
|
||||||
state.apertureShape = snapshot.apertureShape;
|
state.apertureShape = snapshot.apertureShape;
|
||||||
state.selectedMicrophoneId = snapshot.selectedMicrophoneId;
|
state.selectedMicrophoneId = snapshot.selectedMicrophoneId;
|
||||||
if (!selectedMicrophone()) {
|
if (!selectedMicrophone()) {
|
||||||
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
state.dirty = true;
|
refreshDirtyState();
|
||||||
renderAll(message);
|
renderAll(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordSnapshot(before, label) {
|
function recordSnapshot(before, label) {
|
||||||
if (snapshotSignature(before) === snapshotSignature(captureSnapshot())) return false;
|
if (editableSnapshotSignature(before) === editableSnapshotSignature(currentSnapshotView())) {
|
||||||
state.history.push({ snapshot: before, label });
|
return false;
|
||||||
if (state.history.length > MAX_HISTORY) state.history.shift();
|
}
|
||||||
|
state.history.push(historyEntry(before, label));
|
||||||
state.future.length = 0;
|
state.future.length = 0;
|
||||||
state.dirty = true;
|
trimHistoryMemory();
|
||||||
|
refreshDirtyState();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +288,11 @@ function showActionError(error) {
|
|||||||
if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함";
|
if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함";
|
||||||
elements["validation-title"].textContent = "작업을 적용하지 못했습니다";
|
elements["validation-title"].textContent = "작업을 적용하지 못했습니다";
|
||||||
elements["validation-message"].textContent = message;
|
elements["validation-message"].textContent = message;
|
||||||
if (elements.validationCard) elements.validationCard.dataset.severity = "error";
|
if (elements.validationCard) {
|
||||||
|
elements.validationCard.dataset.severity = "error";
|
||||||
|
elements.validationCard.classList.add("is-invalid");
|
||||||
|
elements.validationCard.classList.remove("is-valid", "is-warning");
|
||||||
|
}
|
||||||
elements["array-builder-workspace"].dataset.actionError = message;
|
elements["array-builder-workspace"].dataset.actionError = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +301,9 @@ function safely(callback) {
|
|||||||
delete elements["array-builder-workspace"].dataset.actionError;
|
delete elements["array-builder-workspace"].dataset.actionError;
|
||||||
callback();
|
callback();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
renderDocumentEditor();
|
||||||
|
syncSelectedEditor();
|
||||||
|
setNumber(elements["snap-step"], state.snapStepM * 1000);
|
||||||
showActionError(error);
|
showActionError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,9 +313,14 @@ function announce(message) {
|
|||||||
elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨";
|
elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨";
|
||||||
elements["save-state"].classList.toggle("is-dirty", state.dirty);
|
elements["save-state"].classList.toggle("is-dirty", state.dirty);
|
||||||
}
|
}
|
||||||
if (elements["selection-status"] && message) {
|
|
||||||
elements["selection-status"].textContent = message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function announceSelection(message) {
|
||||||
|
if (elements["selection-status"]) elements["selection-status"].textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissCanvasInstructions() {
|
||||||
|
elements["canvas-instructions"]?.classList.add("is-hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTool(tool, options = {}) {
|
function setTool(tool, options = {}) {
|
||||||
@@ -196,6 +331,10 @@ function setTool(tool, options = {}) {
|
|||||||
elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add"));
|
elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add"));
|
||||||
elements["array-canvas"].dataset.tool = state.tool;
|
elements["array-canvas"].dataset.tool = state.tool;
|
||||||
canvasView.render(canvasState());
|
canvasView.render(canvasState());
|
||||||
|
if (options.userInitiated) {
|
||||||
|
dismissCanvasInstructions();
|
||||||
|
announceSelection(state.tool === "add" ? "추가 도구 · 빈 곳을 클릭하세요." : "선택 도구");
|
||||||
|
}
|
||||||
if (options.focus) canvasView.focus();
|
if (options.focus) canvasView.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,15 +397,29 @@ function selectMicrophone(id, options = {}) {
|
|||||||
renderMicrophoneList();
|
renderMicrophoneList();
|
||||||
syncSelectedEditor();
|
syncSelectedEditor();
|
||||||
canvasView.render(canvasState());
|
canvasView.render(canvasState());
|
||||||
if (state.selectedMicrophoneId) announce(state.selectedMicrophoneId + " 선택");
|
const selectedButton = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")]
|
||||||
|
.find((button) => button.dataset.microphoneId === state.selectedMicrophoneId);
|
||||||
|
if (options.focusList) selectedButton?.focus();
|
||||||
|
if (options.scrollList !== false) {
|
||||||
|
selectedButton?.scrollIntoView?.({ block: "nearest" });
|
||||||
|
}
|
||||||
|
announceSelection(
|
||||||
|
state.selectedMicrophoneId
|
||||||
|
? state.selectedMicrophoneId + " 선택"
|
||||||
|
: "선택된 마이크 없음",
|
||||||
|
);
|
||||||
if (options.focusCanvas) canvasView.focus();
|
if (options.focusCanvas) canvasView.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvasView = createArrayCanvas(elements["array-canvas"], {
|
const canvasView = createArrayCanvas(elements["array-canvas"], {
|
||||||
onSelect(id) {
|
onSelect(id) {
|
||||||
selectMicrophone(id);
|
dismissCanvasInstructions();
|
||||||
|
selectMicrophone(id, {
|
||||||
|
scrollList: window.matchMedia("(min-width: 761px)").matches,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onAdd(position) {
|
onAdd(position) {
|
||||||
|
dismissCanvasInstructions();
|
||||||
safely(() => mutate("마이크 추가됨", () => {
|
safely(() => mutate("마이크 추가됨", () => {
|
||||||
const microphone = addMicrophone(
|
const microphone = addMicrophone(
|
||||||
state.arrayDefinition,
|
state.arrayDefinition,
|
||||||
@@ -276,6 +429,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
onDragStart(id) {
|
onDragStart(id) {
|
||||||
|
dismissCanvasInstructions();
|
||||||
if (state.dragSnapshot) return;
|
if (state.dragSnapshot) return;
|
||||||
state.selectedMicrophoneId = id;
|
state.selectedMicrophoneId = id;
|
||||||
state.dragSnapshot = captureSnapshot();
|
state.dragSnapshot = captureSnapshot();
|
||||||
@@ -320,7 +474,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
onToolChange(tool) {
|
onToolChange(tool) {
|
||||||
setTool(tool);
|
setTool(tool, { userInitiated: true });
|
||||||
},
|
},
|
||||||
onFit() {
|
onFit() {
|
||||||
canvasView.fit();
|
canvasView.fit();
|
||||||
@@ -372,11 +526,14 @@ function renderMicrophoneList() {
|
|||||||
const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? "";
|
const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? "";
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
state.arrayDefinition.microphones.forEach((microphone, index) => {
|
state.arrayDefinition.microphones.forEach((microphone, index) => {
|
||||||
if (query && !microphone.id.toLowerCase().includes(query)) return;
|
const matchesQuery = !query || microphone.id.toLowerCase().includes(query);
|
||||||
|
if (!matchesQuery && microphone.id !== state.selectedMicrophoneId) return;
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "microphone-row";
|
button.className = "microphone-row";
|
||||||
button.dataset.microphoneId = microphone.id;
|
button.dataset.microphoneId = microphone.id;
|
||||||
|
button.dataset.searchPinned = String(!matchesQuery);
|
||||||
|
button.tabIndex = microphone.id === state.selectedMicrophoneId ? 0 : -1;
|
||||||
button.setAttribute("role", "option");
|
button.setAttribute("role", "option");
|
||||||
button.setAttribute(
|
button.setAttribute(
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
@@ -412,9 +569,16 @@ function renderMicrophoneList() {
|
|||||||
identity.append(calibration);
|
identity.append(calibration);
|
||||||
}
|
}
|
||||||
button.append(channel, identity);
|
button.append(channel, identity);
|
||||||
button.addEventListener("click", () => selectMicrophone(microphone.id));
|
button.addEventListener("click", () => {
|
||||||
|
dismissCanvasInstructions();
|
||||||
|
selectMicrophone(microphone.id, { focusList: true });
|
||||||
|
});
|
||||||
list.append(button);
|
list.append(button);
|
||||||
});
|
});
|
||||||
|
if (!list.querySelector('[tabindex="0"]')) {
|
||||||
|
const firstButton = list.querySelector("[data-microphone-id]");
|
||||||
|
if (firstButton) firstButton.tabIndex = 0;
|
||||||
|
}
|
||||||
if (!list.childElementCount) {
|
if (!list.childElementCount) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "microphone-list__empty";
|
empty.className = "microphone-list__empty";
|
||||||
@@ -521,6 +685,9 @@ async function validateCurrent() {
|
|||||||
state.valid = errors.length === 0;
|
state.valid = errors.length === 0;
|
||||||
document.documentElement.dataset.arrayValid = String(state.valid);
|
document.documentElement.dataset.arrayValid = String(state.valid);
|
||||||
elements["export-button"].disabled = !state.valid;
|
elements["export-button"].disabled = !state.valid;
|
||||||
|
if (elements["mobile-export-button"]) {
|
||||||
|
elements["mobile-export-button"].disabled = !state.valid;
|
||||||
|
}
|
||||||
elements.validationCard?.setAttribute(
|
elements.validationCard?.setAttribute(
|
||||||
"data-severity",
|
"data-severity",
|
||||||
errors.length ? "error" : warnings.length ? "warning" : "success",
|
errors.length ? "error" : warnings.length ? "warning" : "success",
|
||||||
@@ -603,24 +770,31 @@ function applySelectedPosition() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function presetOptions() {
|
function presetOptions() {
|
||||||
return {
|
const type = elements["preset-type"].value;
|
||||||
type: elements["preset-type"].value,
|
const options = {
|
||||||
|
type,
|
||||||
aperture: state.arrayDefinition.aperture,
|
aperture: state.arrayDefinition.aperture,
|
||||||
shape: state.apertureShape,
|
shape: state.apertureShape,
|
||||||
count: integerInput(elements["preset-count"], "마이크 수", 1, 1024),
|
|
||||||
columns: integerInput(elements["preset-columns"], "Columns", 1, 1024),
|
|
||||||
rows: integerInput(elements["preset-rows"], "Rows", 1, 1024),
|
|
||||||
seed: integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295),
|
|
||||||
minimumSpacingM: finiteInput(
|
|
||||||
elements["preset-minimum-spacing"],
|
|
||||||
"최소 간격",
|
|
||||||
{ minimum: 0 },
|
|
||||||
),
|
|
||||||
edgeInsetM: elements["preset-edge-inset"]
|
edgeInsetM: elements["preset-edge-inset"]
|
||||||
? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 })
|
? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 })
|
||||||
: 0,
|
: 0,
|
||||||
axis: elements["preset-axis"]?.value ?? "x",
|
|
||||||
};
|
};
|
||||||
|
if (type === "grid") {
|
||||||
|
options.columns = integerInput(elements["preset-columns"], "Columns", 1, 1024);
|
||||||
|
options.rows = integerInput(elements["preset-rows"], "Rows", 1, 1024);
|
||||||
|
} else {
|
||||||
|
options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024);
|
||||||
|
}
|
||||||
|
if (type === "linear") options.axis = elements["preset-axis"]?.value ?? "x";
|
||||||
|
if (type === "random") {
|
||||||
|
options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295);
|
||||||
|
options.minimumSpacingM = finiteInput(
|
||||||
|
elements["preset-minimum-spacing"],
|
||||||
|
"최소 간격",
|
||||||
|
{ minimum: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements["array-id"].addEventListener("change", () => safely(() => {
|
elements["array-id"].addEventListener("change", () => safely(() => {
|
||||||
@@ -680,8 +854,10 @@ elements["generate-preset-button"].addEventListener("click", () => safely(() =>
|
|||||||
);
|
);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
elements["select-tool-button"].addEventListener("click", () => setTool("select", { focus: true }));
|
elements["select-tool-button"].addEventListener("click", () =>
|
||||||
elements["add-tool-button"].addEventListener("click", () => setTool("add", { focus: true }));
|
setTool("select", { focus: true, userInitiated: true }));
|
||||||
|
elements["add-tool-button"].addEventListener("click", () =>
|
||||||
|
setTool("add", { focus: true, userInitiated: true }));
|
||||||
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
|
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
|
||||||
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
|
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
|
||||||
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
|
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
|
||||||
@@ -701,6 +877,7 @@ elements["snap-step"].addEventListener("change", () => safely(() => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
elements["add-microphone-button"].addEventListener("click", () => safely(() => {
|
elements["add-microphone-button"].addEventListener("click", () => safely(() => {
|
||||||
|
dismissCanvasInstructions();
|
||||||
mutate("마이크 추가됨", () => {
|
mutate("마이크 추가됨", () => {
|
||||||
const microphone = addMicrophone(state.arrayDefinition, availableManualPosition());
|
const microphone = addMicrophone(state.arrayDefinition, availableManualPosition());
|
||||||
state.selectedMicrophoneId = microphone.id;
|
state.selectedMicrophoneId = microphone.id;
|
||||||
@@ -763,6 +940,19 @@ elements["move-microphone-down-button"]?.addEventListener("click", () => safely(
|
|||||||
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1));
|
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1));
|
||||||
}));
|
}));
|
||||||
elements["microphone-search"]?.addEventListener("input", renderMicrophoneList);
|
elements["microphone-search"]?.addEventListener("input", renderMicrophoneList);
|
||||||
|
elements["microphone-list"].addEventListener("keydown", (event) => {
|
||||||
|
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
|
||||||
|
const buttons = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")];
|
||||||
|
if (!buttons.length) return;
|
||||||
|
const focusedIndex = Math.max(0, buttons.indexOf(document.activeElement));
|
||||||
|
let targetIndex = focusedIndex;
|
||||||
|
if (event.key === "ArrowUp") targetIndex = Math.max(0, focusedIndex - 1);
|
||||||
|
else if (event.key === "ArrowDown") targetIndex = Math.min(buttons.length - 1, focusedIndex + 1);
|
||||||
|
else if (event.key === "Home") targetIndex = 0;
|
||||||
|
else targetIndex = buttons.length - 1;
|
||||||
|
event.preventDefault();
|
||||||
|
selectMicrophone(buttons[targetIndex].dataset.microphoneId, { focusList: true });
|
||||||
|
});
|
||||||
|
|
||||||
elements["center-microphones-button"]?.addEventListener("click", () => safely(() => {
|
elements["center-microphones-button"]?.addEventListener("click", () => safely(() => {
|
||||||
mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition));
|
mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition));
|
||||||
@@ -789,14 +979,16 @@ elements["clamp-microphones-button"]?.addEventListener("click", () => safely(()
|
|||||||
function undo() {
|
function undo() {
|
||||||
const entry = state.history.pop();
|
const entry = state.history.pop();
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
state.future.push({ snapshot: captureSnapshot(), label: entry.label });
|
state.future.push(historyEntry(captureSnapshot(), entry.label));
|
||||||
|
trimHistoryMemory();
|
||||||
restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label);
|
restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
function redo() {
|
function redo() {
|
||||||
const entry = state.future.pop();
|
const entry = state.future.pop();
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
state.history.push({ snapshot: captureSnapshot(), label: entry.label });
|
state.history.push(historyEntry(captureSnapshot(), entry.label));
|
||||||
|
trimHistoryMemory();
|
||||||
restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label);
|
restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,31 +1013,36 @@ window.addEventListener("keydown", (event) => {
|
|||||||
elements["array-input"].addEventListener("change", async () => {
|
elements["array-input"].addEventListener("change", async () => {
|
||||||
const file = elements["array-input"].files?.[0];
|
const file = elements["array-input"].files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
const importRevision = ++state.importRevision;
|
||||||
try {
|
try {
|
||||||
const value = await readJsonFile(file);
|
const value = await readJsonFile(file);
|
||||||
const issues = await validateArrayDefinition(value);
|
const issues = await validateArrayDefinition(value);
|
||||||
|
if (importRevision !== state.importRevision) return;
|
||||||
if (issues.length) {
|
if (issues.length) {
|
||||||
const details = issues.slice(0, 4).map(
|
const details = issues.slice(0, 4).map(
|
||||||
(issue) => issue.path + ": " + issue.message,
|
(issue) => issue.path + ": " + issue.message,
|
||||||
).join(" · ");
|
).join(" · ");
|
||||||
throw new Error("Array JSON 계약을 만족하지 않습니다. " + details);
|
throw new Error("Array JSON 계약을 만족하지 않습니다. " + details);
|
||||||
}
|
}
|
||||||
|
const previousSnapshot = captureSnapshot();
|
||||||
state.arrayDefinition = prepareArrayForEditing(value);
|
state.arrayDefinition = prepareArrayForEditing(value);
|
||||||
state.apertureShape = inferApertureShape(value);
|
state.apertureShape = inferApertureShape(value);
|
||||||
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
||||||
state.history.length = 0;
|
state.history = [historyEntry(previousSnapshot, "Array 불러오기")];
|
||||||
state.future.length = 0;
|
state.future.length = 0;
|
||||||
|
trimHistoryMemory();
|
||||||
|
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
canvasView.fit();
|
canvasView.fit();
|
||||||
renderAll(file.name + " 불러옴");
|
renderAll(file.name + " 불러옴 · 실행 취소 가능");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showActionError(error);
|
if (importRevision === state.importRevision) showActionError(error);
|
||||||
} finally {
|
} finally {
|
||||||
elements["array-input"].value = "";
|
if (importRevision === state.importRevision) elements["array-input"].value = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elements["export-button"].addEventListener("click", async () => {
|
async function exportArray() {
|
||||||
await validateCurrent();
|
await validateCurrent();
|
||||||
if (!state.valid) {
|
if (!state.valid) {
|
||||||
showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요."));
|
showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요."));
|
||||||
@@ -855,9 +1052,13 @@ elements["export-button"].addEventListener("click", async () => {
|
|||||||
state.arrayDefinition,
|
state.arrayDefinition,
|
||||||
safeArrayFileStem(state.arrayDefinition) + ".array.json",
|
safeArrayFileStem(state.arrayDefinition) + ".array.json",
|
||||||
);
|
);
|
||||||
state.dirty = false;
|
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
|
||||||
|
refreshDirtyState();
|
||||||
announce("Array JSON 저장됨");
|
announce("Array JSON 저장됨");
|
||||||
});
|
}
|
||||||
|
|
||||||
|
elements["export-button"].addEventListener("click", exportArray);
|
||||||
|
elements["mobile-export-button"]?.addEventListener("click", exportArray);
|
||||||
|
|
||||||
window.addEventListener("beforeunload", (event) => {
|
window.addEventListener("beforeunload", (event) => {
|
||||||
if (!state.dirty) return;
|
if (!state.dirty) return;
|
||||||
|
|||||||
+71
-29
@@ -63,7 +63,10 @@ function microphoneFromPosition(position, index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pointKey(position) {
|
function pointKey(position) {
|
||||||
return position.map((value) => rounded(value).toFixed(COORDINATE_DIGITS)).join(",");
|
return position.map((value) => {
|
||||||
|
const number = finiteNumber(value, "좌표");
|
||||||
|
return Object.is(number, -0) ? "0" : String(number);
|
||||||
|
}).join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
function linearValue(index, count, minimum, maximum) {
|
function linearValue(index, count, minimum, maximum) {
|
||||||
@@ -218,16 +221,43 @@ function randomPositions(options, bounds, shape) {
|
|||||||
const random = createSeededRandom(options.seed ?? 1);
|
const random = createSeededRandom(options.seed ?? 1);
|
||||||
const positions = [];
|
const positions = [];
|
||||||
const keys = new Set();
|
const keys = new Set();
|
||||||
const maximumAttempts = Math.max(2000, count * 500);
|
const cellSize = Math.max(minimumSpacing, 10 ** -COORDINATE_DIGITS);
|
||||||
|
const spatialCells = minimumSpacingSquared > 0 ? new Map() : null;
|
||||||
|
const cellCoordinates = (position) => [
|
||||||
|
Math.floor(position[0] / cellSize),
|
||||||
|
Math.floor(position[1] / cellSize),
|
||||||
|
];
|
||||||
|
const cellKey = (x, y) => String(x) + "," + String(y);
|
||||||
|
const violatesMinimumSpacing = (candidate) => {
|
||||||
|
if (!spatialCells) return false;
|
||||||
|
const [cellX, cellY] = cellCoordinates(candidate);
|
||||||
|
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
||||||
|
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||||
|
const neighbors = spatialCells.get(cellKey(cellX + offsetX, cellY + offsetY));
|
||||||
|
if (neighbors?.some(
|
||||||
|
(position) => squaredDistance(position, candidate) < minimumSpacingSquared,
|
||||||
|
)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const indexPosition = (position) => {
|
||||||
|
if (!spatialCells) return;
|
||||||
|
const [cellX, cellY] = cellCoordinates(position);
|
||||||
|
const key = cellKey(cellX, cellY);
|
||||||
|
const cell = spatialCells.get(key);
|
||||||
|
if (cell) cell.push(position);
|
||||||
|
else spatialCells.set(key, [position]);
|
||||||
|
};
|
||||||
|
const maximumAttempts = Math.max(2000, count * 150);
|
||||||
for (let attempt = 0; attempt < maximumAttempts && positions.length < count; attempt += 1) {
|
for (let attempt = 0; attempt < maximumAttempts && positions.length < count; attempt += 1) {
|
||||||
const candidate = normalizedPosition(randomCandidate(random, bounds, shape));
|
const candidate = normalizedPosition(randomCandidate(random, bounds, shape));
|
||||||
const key = pointKey(candidate);
|
const key = pointKey(candidate);
|
||||||
if (keys.has(key)) continue;
|
if (keys.has(key)) continue;
|
||||||
if (positions.some((position) => squaredDistance(position, candidate) < minimumSpacingSquared)) {
|
if (violatesMinimumSpacing(candidate)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
keys.add(key);
|
keys.add(key);
|
||||||
positions.push(candidate);
|
positions.push(candidate);
|
||||||
|
indexPosition(candidate);
|
||||||
}
|
}
|
||||||
if (positions.length !== count) {
|
if (positions.length !== count) {
|
||||||
throw new RangeError(
|
throw new RangeError(
|
||||||
@@ -302,35 +332,36 @@ export function prepareArrayForEditing(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function inferApertureShape(arrayDefinition) {
|
export function inferApertureShape(arrayDefinition) {
|
||||||
const description = String(arrayDefinition?.description ?? "");
|
const shapeWords = [
|
||||||
if (/circle|circular|ellipse|elliptic|ring|spiral/i.test(description)) return "ellipse";
|
arrayDefinition?.id,
|
||||||
const aperture = arrayDefinition?.aperture;
|
arrayDefinition?.name,
|
||||||
const microphones = arrayDefinition?.microphones ?? [];
|
arrayDefinition?.description,
|
||||||
if (!aperture || microphones.length < 3) return "rectangle";
|
].filter(Boolean).join(" ");
|
||||||
let maximumNormalizedRadius = 0;
|
return /(^|[^a-z])(circle|circular|ellipse|elliptic|ring|spiral)([^a-z]|$)/i.test(shapeWords)
|
||||||
for (const microphone of microphones) {
|
? "ellipse"
|
||||||
const position = microphone?.position;
|
: "rectangle";
|
||||||
if (!Array.isArray(position)) return "rectangle";
|
|
||||||
const radius = Math.hypot(
|
|
||||||
position[0] / (aperture.width / 2),
|
|
||||||
position[1] / (aperture.height / 2),
|
|
||||||
);
|
|
||||||
if (radius > 1.000000001) return "rectangle";
|
|
||||||
maximumNormalizedRadius = Math.max(maximumNormalizedRadius, radius);
|
|
||||||
}
|
|
||||||
return maximumNormalizedRadius >= 0.72 ? "ellipse" : "rectangle";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextMicrophoneId(arrayDefinition) {
|
export function nextMicrophoneId(arrayDefinition) {
|
||||||
const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id));
|
const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id));
|
||||||
let maximumSuffix = 0;
|
let maximumSuffix = 0n;
|
||||||
for (const id of used) {
|
for (const id of used) {
|
||||||
const match = /^mic-(\d+)$/.exec(id);
|
const match = /^mic-(\d+)$/.exec(id);
|
||||||
if (match) maximumSuffix = Math.max(maximumSuffix, Number(match[1]));
|
if (!match) continue;
|
||||||
|
const suffix = BigInt(match[1]);
|
||||||
|
if (suffix > maximumSuffix) maximumSuffix = suffix;
|
||||||
}
|
}
|
||||||
let candidate = maximumSuffix + 1;
|
let candidate = maximumSuffix + 1n;
|
||||||
while (used.has(microphoneId(candidate))) candidate += 1;
|
for (let attempt = 0; attempt <= used.size; attempt += 1) {
|
||||||
return microphoneId(candidate);
|
const id = "mic-" + candidate.toString().padStart(3, "0");
|
||||||
|
if (id.length <= 64 && !used.has(id)) return id;
|
||||||
|
candidate += 1n;
|
||||||
|
}
|
||||||
|
for (let suffix = 1; suffix <= MAX_MICROPHONES + 1; suffix += 1) {
|
||||||
|
const id = microphoneId(suffix);
|
||||||
|
if (!used.has(id)) return id;
|
||||||
|
}
|
||||||
|
throw new Error("사용 가능한 마이크 ID를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addMicrophone(arrayDefinition, position, properties = {}) {
|
export function addMicrophone(arrayDefinition, position, properties = {}) {
|
||||||
@@ -514,8 +545,19 @@ export function arrayStatistics(arrayDefinition) {
|
|||||||
if (microphones.length === 0) {
|
if (microphones.length === 0) {
|
||||||
return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 };
|
return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 };
|
||||||
}
|
}
|
||||||
const centroid = [0, 1, 2].map((axis) =>
|
const centroid = [0, 1, 2].map((axis) => {
|
||||||
microphones.reduce((sum, microphone) => sum + microphone.position[axis], 0) / microphones.length);
|
const scale = microphones.reduce(
|
||||||
|
(maximum, microphone) => Math.max(maximum, Math.abs(microphone.position[axis])),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (scale === 0) return 0;
|
||||||
|
const normalizedSum = microphones.reduce(
|
||||||
|
(sum, microphone) => sum + microphone.position[axis] / scale,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const normalizedMean = Math.max(-1, Math.min(1, normalizedSum / microphones.length));
|
||||||
|
return normalizedMean * scale;
|
||||||
|
});
|
||||||
let minimumSpacingSquared = Infinity;
|
let minimumSpacingSquared = Infinity;
|
||||||
for (let left = 0; left < microphones.length; left += 1) {
|
for (let left = 0; left < microphones.length; left += 1) {
|
||||||
for (let right = left + 1; right < microphones.length; right += 1) {
|
for (let right = left + 1; right < microphones.length; right += 1) {
|
||||||
|
|||||||
@@ -3,6 +3,25 @@
|
|||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #06130f;
|
||||||
|
background: var(--mint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.array-builder-header {
|
.array-builder-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
@@ -116,6 +135,11 @@
|
|||||||
clip-path: inset(50%);
|
clip-path: inset(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-button:focus-within {
|
||||||
|
border-color: var(--mint);
|
||||||
|
outline: 2px solid rgb(101 242 191 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
.array-workflow {
|
.array-workflow {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -169,6 +193,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.array-controls {
|
.array-controls {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 59px;
|
top: 59px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -185,6 +211,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.array-builder-layout > .split-handle {
|
.array-builder-layout > .split-handle {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 59px;
|
top: 59px;
|
||||||
height: calc(100vh - 59px);
|
height: calc(100vh - 59px);
|
||||||
@@ -577,7 +605,13 @@ details > summary::-webkit-details-marker {
|
|||||||
padding: 0 10px 10px;
|
padding: 0 10px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-save-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.array-workspace {
|
.array-workspace {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 18px clamp(18px, 2.5vw, 38px) 38px;
|
padding: 18px clamp(18px, 2.5vw, 38px) 38px;
|
||||||
}
|
}
|
||||||
@@ -857,41 +891,60 @@ body[data-array-tool="add"] #array-canvas,
|
|||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.array-builder-layout {
|
.array-builder-layout {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(340px, 38%) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.array-builder-layout > .split-handle {
|
.array-builder-layout > .split-handle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.array-controls {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-workspace {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.array-controls,
|
.array-controls,
|
||||||
.array-workspace {
|
.array-workspace {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.array-controls {
|
|
||||||
position: static;
|
|
||||||
overflow: visible;
|
|
||||||
max-height: none;
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.microphone-list {
|
.microphone-list {
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.array-builder-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.array-builder-header {
|
.array-builder-header {
|
||||||
position: static;
|
position: static;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.array-workspace {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-controls {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
position: static;
|
||||||
|
overflow: visible;
|
||||||
|
max-height: none;
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.array-workflow {
|
.array-workflow {
|
||||||
order: 3;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -935,11 +988,17 @@ body[data-array-tool="add"] #array-canvas,
|
|||||||
|
|
||||||
#array-canvas {
|
#array-canvas {
|
||||||
height: 500px;
|
height: 500px;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-help > div {
|
.shortcut-help > div {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-save-actions {
|
||||||
|
display: block;
|
||||||
|
padding: 2px 0 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에
|
|||||||
주파수는 0–1,000,000 Hz에서 중복 없이 증가해야 하며, dB와 wrapped phase를 구간 선형 보간하고
|
주파수는 0–1,000,000 Hz에서 중복 없이 증가해야 하며, dB와 wrapped phase를 구간 선형 보간하고
|
||||||
측정 범위 밖에서는 가까운 끝점을 사용한다.
|
측정 범위 밖에서는 가까운 끝점을 사용한다.
|
||||||
|
|
||||||
|
Array Builder의 사각형/타원 생성 경계, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백,
|
||||||
|
snap과 zoom은 편집기 상태이며 `micarrsim.array` 계약에 포함되지 않는다. 저장 파일에는
|
||||||
|
`aperture.width/height`와 최종 `microphones` 좌표·순서만 들어간다. 따라서 다른 도구는
|
||||||
|
형상 이름을 추론하지 않고 실제 좌표를 배열 geometry의 기준으로 사용해야 한다.
|
||||||
|
|
||||||
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와
|
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와
|
||||||
raw map 계약은 다음과 같다.
|
raw map 계약은 다음과 같다.
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -37,13 +37,15 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
|
|||||||
때만 동일 스키마를 쓰는 별도 CLI를 추가한다. 이 방식은 초기 복잡도를 줄이면서 확장 경로를
|
때만 동일 스키마를 쓰는 별도 CLI를 추가한다. 이 방식은 초기 복잡도를 줄이면서 확장 경로를
|
||||||
남긴다.
|
남긴다.
|
||||||
|
|
||||||
두 앱의 책임은 다음과 같이 분리한다.
|
세 앱의 책임은 다음과 같이 분리한다.
|
||||||
|
|
||||||
|
- `array-builder`: aperture 크기와 사각/타원 생성 경계, 격자·선형·둘레·Fermat·seed random
|
||||||
|
프리셋, 마이크 직접 편집·채널 순서·보정 보존과 표준 Array JSON 내보내기
|
||||||
- `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정,
|
- `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정,
|
||||||
seed 고정, 합성 오디오와 ground truth manifest 내보내기
|
seed 고정, 합성 오디오와 ground truth manifest 내보내기
|
||||||
- `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성,
|
- `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성,
|
||||||
DAS 실행, overlay·수치 평가·실험 결과 내보내기
|
DAS 실행, overlay·수치 평가·실험 결과 내보내기
|
||||||
- `acoustics-core`: 두 앱이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
|
- `acoustics-core`: 앱들이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
|
||||||
STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer
|
STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer
|
||||||
- `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark
|
- `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
|
|||||||
JSON Schema를 먼저 고정하고 모든 길이는 m, 시간은 s, 주파수는 Hz, 좌표는 우수 좌표계로
|
JSON Schema를 먼저 고정하고 모든 길이는 m, 시간은 s, 주파수는 Hz, 좌표는 우수 좌표계로
|
||||||
명시한다.
|
명시한다.
|
||||||
|
|
||||||
- `array.schema.json`: schema version, 배열 폭·높이, 192개 마이크의 id와 `[x,y,z]`, 선택적
|
- `array.schema.json`: schema version, 배열 폭·높이, 1–1,024개 마이크의 id와 `[x,y,z]`, 선택적
|
||||||
감도·위상·주파수 응답 보정값
|
감도·위상·주파수 응답 보정값
|
||||||
- `scene.schema.json`: 배경 이미지 크기와 실제 평면 변환, 음원별 위치·오디오 참조·시작/종료·
|
- `scene.schema.json`: 배경 이미지 크기와 실제 평면 변환, 음원별 위치·오디오 참조·시작/종료·
|
||||||
gain, ambient 참조, sample rate, seed, 선택적 자유음장·shoebox 환경
|
gain, ambient 참조, sample rate, seed, 선택적 자유음장·shoebox 환경
|
||||||
@@ -145,7 +147,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
|||||||
- 좌표계·단위·오디오 normalization 결정 기록
|
- 좌표계·단위·오디오 normalization 결정 기록
|
||||||
- synthetic single-tone scene과 192채널 기준 배열 작성
|
- synthetic single-tone scene과 192채널 기준 배열 작성
|
||||||
|
|
||||||
완료 조건: 예제 JSON이 schema validation을 통과하고 두 앱이 동일 manifest를 읽는다.
|
완료 조건: 예제 JSON이 schema validation을 통과하고 앱들이 동일 계약을 읽는다.
|
||||||
|
|
||||||
### 단계 1 — CPU 기준 모델 · 완료
|
### 단계 1 — CPU 기준 모델 · 완료
|
||||||
|
|
||||||
@@ -164,6 +166,15 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
|||||||
|
|
||||||
완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다.
|
완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다.
|
||||||
|
|
||||||
|
### 단계 2A — Array Builder · 완료
|
||||||
|
|
||||||
|
- aperture 크기와 사각/타원 생성 경계, 5종 결정론적 프리셋 구현
|
||||||
|
- canvas 추가·선택·drag, 정밀 좌표·ID·채널 순서 편집과 undo/redo
|
||||||
|
- 가져온 sensitivity·phase·frequency response 보존과 표준 Array JSON 왕복
|
||||||
|
- 좌표 중복 오류, 경계 초과 경고, clamp·중앙 정렬·aperture 맞춤
|
||||||
|
|
||||||
|
완료 조건: 만든 Array가 계약 검증을 통과하고 Acoustic Camera에서 그대로 불러와진다.
|
||||||
|
|
||||||
### 단계 3 — WebGPU DAS · 완료
|
### 단계 3 — WebGPU DAS · 완료
|
||||||
|
|
||||||
- CPU와 동일한 buffer layout 및 WGSL kernel 구현
|
- CPU와 동일한 buffer layout 및 WGSL kernel 구현
|
||||||
@@ -212,7 +223,8 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
|
|||||||
|
|
||||||
## 10. 구현 결과와 다음 백로그
|
## 10. 구현 결과와 다음 백로그
|
||||||
|
|
||||||
- [x] 설치 없는 ES module workspace와 두 앱 shell
|
- [x] 설치 없는 ES module workspace와 세 앱 shell
|
||||||
|
- [x] Array Builder 프리셋·직접 편집·검증·Camera 전달
|
||||||
- [x] array/scene/experiment/result schema와 synthetic fixture
|
- [x] array/scene/experiment/result schema와 synthetic fixture
|
||||||
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
|
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
|
||||||
- [x] CPU DAS golden implementation
|
- [x] CPU DAS golden implementation
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
|
|||||||
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
|
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
|
||||||
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
|
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
|
||||||
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
|
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
|
||||||
|
- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo,
|
||||||
|
중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout
|
||||||
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와
|
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와
|
||||||
DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약; 이동 예제 로드, 8×8 CPU·2 Hz live map의
|
DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약; 이동 예제 로드, 8×8 CPU·2 Hz live map의
|
||||||
Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지
|
Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지
|
||||||
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
|
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
|
||||||
Result JSON을 저장하는 end-to-end 흐름
|
Result JSON을 저장하는 end-to-end 흐름
|
||||||
|
- `split-layout-ui.test.js`: 세 앱 설정 패널의 drag·keyboard 폭 조절, 저장·reset과
|
||||||
|
좁은 화면 적층
|
||||||
|
|
||||||
WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로
|
WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로
|
||||||
CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은
|
CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||||||
|
import {
|
||||||
|
arrayPositionToCanvas,
|
||||||
|
fitArrayViewport,
|
||||||
|
} from "/apps/array-builder/canvas-view.js";
|
||||||
|
import { createArrayDefinition } from "/apps/array-builder/model.js";
|
||||||
|
|
||||||
function assertNear(actual, expected, tolerance, label) {
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
assert(
|
assert(
|
||||||
@@ -103,16 +108,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
|
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
|
||||||
"canvas dominates the desktop workspace",
|
"canvas dominates the desktop workspace",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
!documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
|
||||||
|
"canvas guidance starts visible",
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvas = documentObject.querySelector("#array-canvas");
|
||||||
|
const defaultArray = createArrayDefinition();
|
||||||
|
const initialCanvasRect = canvas.getBoundingClientRect();
|
||||||
|
const initialViewport = fitArrayViewport(
|
||||||
|
initialCanvasRect.width,
|
||||||
|
initialCanvasRect.height,
|
||||||
|
defaultArray,
|
||||||
|
);
|
||||||
|
const firstMicrophonePixel = arrayPositionToCanvas(
|
||||||
|
defaultArray.microphones[0].position,
|
||||||
|
initialViewport,
|
||||||
|
);
|
||||||
|
const initialFirstPosition = selectedCoordinates(documentObject);
|
||||||
|
const dragPointerId = 21;
|
||||||
|
const dispatchDragPointer = (type, offsetX, offsetY, buttons) => {
|
||||||
|
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId: dragPointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
isPrimary: true,
|
||||||
|
button: type === "pointermove" ? -1 : 0,
|
||||||
|
buttons,
|
||||||
|
clientX: initialCanvasRect.left + firstMicrophonePixel.x + offsetX,
|
||||||
|
clientY: initialCanvasRect.top + firstMicrophonePixel.y + offsetY,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
dispatchDragPointer("pointerdown", 0, 0, 1);
|
||||||
|
dispatchDragPointer("pointermove", 36, -24, 1);
|
||||||
|
dispatchDragPointer("pointerup", 36, -24, 0);
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
const draggedPosition = selectedCoordinates(documentObject);
|
||||||
|
assert(
|
||||||
|
draggedPosition[0] !== initialFirstPosition[0] &&
|
||||||
|
draggedPosition[1] !== initialFirstPosition[1],
|
||||||
|
"pointer drag changes both array coordinates",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
|
||||||
|
"first canvas edit dismisses the guidance overlay",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#undo-button").click();
|
||||||
|
assertEqual(
|
||||||
|
selectedCoordinates(documentObject).join(","),
|
||||||
|
initialFirstPosition.join(","),
|
||||||
|
"one undo restores the whole drag",
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatchDragPointer("pointerdown", 0, 0, 1);
|
||||||
|
dispatchDragPointer("pointermove", 28, -18, 1);
|
||||||
|
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
key: "Escape",
|
||||||
|
}));
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
assertEqual(
|
||||||
|
selectedCoordinates(documentObject).join(","),
|
||||||
|
initialFirstPosition.join(","),
|
||||||
|
"Escape cancels an in-progress drag",
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialApertureWidth = documentObject.querySelector("#aperture-width").value;
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#aperture-width"), 0);
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#aperture-width").value,
|
||||||
|
initialApertureWidth,
|
||||||
|
"rejected aperture input is restored to the active value",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector(".validation-card").classList.contains("is-invalid"),
|
||||||
|
"rejected action has an error visual state",
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), "");
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3);
|
dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3);
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2);
|
dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2);
|
||||||
documentObject.querySelector("#generate-preset-button").click();
|
documentObject.querySelector("#generate-preset-button").click();
|
||||||
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
|
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#preset-count").value,
|
||||||
|
"",
|
||||||
|
"hidden count does not block grid generation",
|
||||||
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
|
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
|
||||||
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
|
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
|
||||||
"grid keeps row-major physical channel IDs",
|
"grid keeps row-major physical channel IDs",
|
||||||
);
|
);
|
||||||
|
assertEqual(
|
||||||
|
microphoneButtons(documentObject).filter((button) => button.tabIndex === 0).length,
|
||||||
|
1,
|
||||||
|
"microphone list has one roving Tab stop",
|
||||||
|
);
|
||||||
|
microphoneButtons(documentObject)[0].focus();
|
||||||
|
microphoneButtons(documentObject)[0].dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
key: "ArrowDown",
|
||||||
|
}));
|
||||||
|
assertEqual(
|
||||||
|
documentObject.activeElement.dataset.microphoneId,
|
||||||
|
"mic-002",
|
||||||
|
"ArrowDown selects and focuses the next channel",
|
||||||
|
);
|
||||||
|
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse");
|
dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse");
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring");
|
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring");
|
||||||
@@ -158,9 +263,14 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
"reference-channel",
|
"reference-channel",
|
||||||
"manual ID edit",
|
"manual ID edit",
|
||||||
);
|
);
|
||||||
|
documentObject.querySelector("#move-microphone-up-button").click();
|
||||||
|
assertEqual(
|
||||||
|
microphoneButtons(documentObject).at(-2).dataset.microphoneId,
|
||||||
|
"reference-channel",
|
||||||
|
"channel forward button changes physical order",
|
||||||
|
);
|
||||||
|
|
||||||
const beforeNudge = selectedCoordinates(documentObject);
|
const beforeNudge = selectedCoordinates(documentObject);
|
||||||
const canvas = documentObject.querySelector("#array-canvas");
|
|
||||||
canvas.focus();
|
canvas.focus();
|
||||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
@@ -172,6 +282,80 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step");
|
assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step");
|
||||||
assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y");
|
assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y");
|
||||||
|
|
||||||
|
const touchCanvasRect = canvas.getBoundingClientRect();
|
||||||
|
const touchViewport = fitArrayViewport(
|
||||||
|
touchCanvasRect.width,
|
||||||
|
touchCanvasRect.height,
|
||||||
|
{
|
||||||
|
aperture: {
|
||||||
|
width: Number(documentObject.querySelector("#aperture-width").value),
|
||||||
|
height: Number(documentObject.querySelector("#aperture-height").value),
|
||||||
|
},
|
||||||
|
microphones: [{ id: "selected", position: afterNudge }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const selectedTouchPixel = arrayPositionToCanvas(afterNudge, touchViewport);
|
||||||
|
const dispatchTouchDragPointer = (type, pointerId, offsetX, offsetY, buttons) =>
|
||||||
|
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId,
|
||||||
|
pointerType: "touch",
|
||||||
|
isPrimary: true,
|
||||||
|
button: type === "pointermove" ? -1 : 0,
|
||||||
|
buttons,
|
||||||
|
clientX: touchCanvasRect.left + selectedTouchPixel.x + offsetX,
|
||||||
|
clientY: touchCanvasRect.top + selectedTouchPixel.y + offsetY,
|
||||||
|
}));
|
||||||
|
|
||||||
|
dispatchTouchDragPointer("pointerdown", 22, 0, 0, 1);
|
||||||
|
const microphoneTouchStart = new windowObject.Event("touchstart", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(microphoneTouchStart, "changedTouches", {
|
||||||
|
value: [{
|
||||||
|
clientX: touchCanvasRect.left + selectedTouchPixel.x,
|
||||||
|
clientY: touchCanvasRect.top + selectedTouchPixel.y,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
canvas.dispatchEvent(microphoneTouchStart);
|
||||||
|
assert(microphoneTouchStart.defaultPrevented, "touch on a microphone reserves the drag gesture");
|
||||||
|
dispatchTouchDragPointer("pointermove", 22, 3, 2, 1);
|
||||||
|
dispatchTouchDragPointer("pointerup", 22, 3, 2, 0);
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
assertEqual(
|
||||||
|
selectedCoordinates(documentObject).join(","),
|
||||||
|
afterNudge.join(","),
|
||||||
|
"touch jitter below the gesture threshold does not move a microphone",
|
||||||
|
);
|
||||||
|
|
||||||
|
const blankTouchStart = new windowObject.Event("touchstart", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(blankTouchStart, "changedTouches", {
|
||||||
|
value: [{ clientX: touchCanvasRect.left + 3, clientY: touchCanvasRect.top + 3 }],
|
||||||
|
});
|
||||||
|
canvas.dispatchEvent(blankTouchStart);
|
||||||
|
assert(!blankTouchStart.defaultPrevented, "blank canvas touch remains available to page scroll");
|
||||||
|
|
||||||
|
dispatchTouchDragPointer("pointerdown", 23, 0, 0, 1);
|
||||||
|
dispatchTouchDragPointer("pointermove", 23, 24, -18, 1);
|
||||||
|
dispatchTouchDragPointer("pointerup", 23, 24, -18, 0);
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
const touchDraggedPosition = selectedCoordinates(documentObject);
|
||||||
|
assert(
|
||||||
|
touchDraggedPosition[0] !== afterNudge[0] && touchDraggedPosition[1] !== afterNudge[1],
|
||||||
|
"touch movement beyond the threshold drags both coordinates",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#undo-button").click();
|
||||||
|
assertEqual(
|
||||||
|
selectedCoordinates(documentObject).join(","),
|
||||||
|
afterNudge.join(","),
|
||||||
|
"one undo restores the whole touch drag",
|
||||||
|
);
|
||||||
|
|
||||||
documentObject.querySelector("#add-tool-button").click();
|
documentObject.querySelector("#add-tool-button").click();
|
||||||
const canvasRect = canvas.getBoundingClientRect();
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
||||||
@@ -190,6 +374,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
documentObject.querySelector("#remove-microphone-button").click();
|
documentObject.querySelector("#remove-microphone-button").click();
|
||||||
await waitFor(() => microphoneCount(documentObject) === 13, "manual delete");
|
await waitFor(() => microphoneCount(documentObject) === 13, "manual delete");
|
||||||
|
|
||||||
|
documentObject.querySelector("#add-tool-button").click();
|
||||||
|
const dispatchTouchAddPointer = (type, pointerId, offsetX, offsetY, buttons) =>
|
||||||
|
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId,
|
||||||
|
pointerType: "touch",
|
||||||
|
isPrimary: true,
|
||||||
|
button: type === "pointermove" ? -1 : 0,
|
||||||
|
buttons,
|
||||||
|
clientX: canvasRect.left + canvasRect.width * 0.7 + offsetX,
|
||||||
|
clientY: canvasRect.top + canvasRect.height * 0.35 + offsetY,
|
||||||
|
}));
|
||||||
|
assert(
|
||||||
|
dispatchTouchAddPointer("pointerdown", 32, 0, 0, 1),
|
||||||
|
"touch add pointerdown remains available to page scroll",
|
||||||
|
);
|
||||||
|
assertEqual(microphoneCount(documentObject), 13, "touch add waits for pointerup");
|
||||||
|
assert(
|
||||||
|
dispatchTouchAddPointer("pointermove", 32, 0, 12, 1),
|
||||||
|
"touch add scroll movement is not canceled",
|
||||||
|
);
|
||||||
|
dispatchTouchAddPointer("pointerup", 32, 0, 12, 0);
|
||||||
|
await nextPaint(windowObject);
|
||||||
|
assertEqual(microphoneCount(documentObject), 13, "touch scroll intent does not add a microphone");
|
||||||
|
|
||||||
|
dispatchTouchAddPointer("pointerdown", 33, 0, 0, 1);
|
||||||
|
assertEqual(microphoneCount(documentObject), 13, "touch tap still waits while pressed");
|
||||||
|
dispatchTouchAddPointer("pointermove", 33, 3, 2, 1);
|
||||||
|
assert(
|
||||||
|
!dispatchTouchAddPointer("pointerup", 33, 3, 2, 0),
|
||||||
|
"completed touch add tap prevents its synthetic click",
|
||||||
|
);
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 14, "touch canvas microphone add");
|
||||||
|
documentObject.querySelector("#select-tool-button").click();
|
||||||
|
documentObject.querySelector("#remove-microphone-button").click();
|
||||||
|
await waitFor(() => microphoneCount(documentObject) === 13, "touch-added microphone delete");
|
||||||
|
|
||||||
const firstButton = microphoneButtons(documentObject)[0];
|
const firstButton = microphoneButtons(documentObject)[0];
|
||||||
const secondButton = microphoneButtons(documentObject)[1];
|
const secondButton = microphoneButtons(documentObject)[1];
|
||||||
firstButton.click();
|
firstButton.click();
|
||||||
@@ -228,6 +450,20 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
assert(calibrated, "edited channel is exported");
|
assert(calibrated, "edited channel is exported");
|
||||||
assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved");
|
assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved");
|
||||||
assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved");
|
assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved");
|
||||||
|
assertEqual(
|
||||||
|
exported.microphones.indexOf(calibrated),
|
||||||
|
exported.microphones.length - 2,
|
||||||
|
"export keeps the edited physical channel order",
|
||||||
|
);
|
||||||
|
calibrated.frequencyResponse = [
|
||||||
|
{ frequencyHz: 100, gainDb: -2, phaseDeg: -4 },
|
||||||
|
{ frequencyHz: 1000, gainDb: 0.5, phaseDeg: 3 },
|
||||||
|
];
|
||||||
|
assertEqual(
|
||||||
|
(await validateArrayDefinition(exported)).length,
|
||||||
|
0,
|
||||||
|
"response-enhanced round trip fixture contract",
|
||||||
|
);
|
||||||
|
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name");
|
dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name");
|
||||||
const exportedFile = new windowObject.File(
|
const exportedFile = new windowObject.File(
|
||||||
@@ -240,6 +476,27 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
() => documentObject.querySelector("#array-name").value === exported.name,
|
() => documentObject.querySelector("#array-name").value === exported.name,
|
||||||
"Array round trip import",
|
"Array round trip import",
|
||||||
);
|
);
|
||||||
|
assert(!documentObject.querySelector("#undo-button").disabled, "valid import is undoable");
|
||||||
|
documentObject.querySelector("#undo-button").click();
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#array-name").value,
|
||||||
|
"Temporary name",
|
||||||
|
"import undo restores the previous document",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||||
|
"document before imported baseline is dirty",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#redo-button").click();
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#array-name").value,
|
||||||
|
exported.name,
|
||||||
|
"import redo restores the loaded document",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||||
|
"loaded baseline is clean after redo",
|
||||||
|
);
|
||||||
microphoneButtons(documentObject).find(
|
microphoneButtons(documentObject).find(
|
||||||
(button) => button.dataset.microphoneId === "reference-channel",
|
(button) => button.dataset.microphoneId === "reference-channel",
|
||||||
).click();
|
).click();
|
||||||
@@ -248,6 +505,62 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
-2.5,
|
-2.5,
|
||||||
"import keeps sensitivity",
|
"import keeps sensitivity",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#frequency-response-summary").textContent.includes("2개"),
|
||||||
|
"import exposes preserved frequency-response points",
|
||||||
|
);
|
||||||
|
const importedReferenceX = Number(documentObject.querySelector("#microphone-x").value);
|
||||||
|
dispatchValue(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#microphone-x"),
|
||||||
|
importedReferenceX + 0.001,
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#export-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() => downloads.filter((item) => item.fileName).length === 2,
|
||||||
|
"edited response Array export",
|
||||||
|
);
|
||||||
|
const editedExport = JSON.parse(
|
||||||
|
await downloads.filter((item) => item.fileName).at(-1).blob.text(),
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(
|
||||||
|
editedExport.microphones.find(
|
||||||
|
(microphone) => microphone.id === "reference-channel",
|
||||||
|
).frequencyResponse,
|
||||||
|
),
|
||||||
|
JSON.stringify(calibrated.frequencyResponse),
|
||||||
|
"position edit preserves every frequency-response value",
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseVariant = structuredClone(editedExport);
|
||||||
|
responseVariant.microphones.find(
|
||||||
|
(microphone) => microphone.id === "reference-channel",
|
||||||
|
).frequencyResponse[0].gainDb -= 1;
|
||||||
|
const responseVariantFile = new windowObject.File(
|
||||||
|
[JSON.stringify(responseVariant)],
|
||||||
|
"response-variant.array.json",
|
||||||
|
{ type: "application/json" },
|
||||||
|
);
|
||||||
|
dispatchFiles(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#array-input"),
|
||||||
|
[responseVariantFile],
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.querySelector("#save-state").textContent.includes("불러옴"),
|
||||||
|
"same-length response variant import",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#undo-button").click();
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||||
|
"undo detects changed response values even when the point count is unchanged",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#redo-button").click();
|
||||||
|
assert(
|
||||||
|
!documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||||
|
"response variant baseline is clean after redo",
|
||||||
|
);
|
||||||
|
|
||||||
const countBeforeInvalidImport = microphoneCount(documentObject);
|
const countBeforeInvalidImport = microphoneCount(documentObject);
|
||||||
const invalidFile = new windowObject.File(
|
const invalidFile = new windowObject.File(
|
||||||
@@ -266,12 +579,109 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
"invalid import preserves current array",
|
"invalid import preserves current array",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const cameraFrame = document.createElement("iframe");
|
||||||
|
cameraFrame.src = "/apps/acoustic-camera/?integration-test=1&array-builder-handoff=1";
|
||||||
|
cameraFrame.style.cssText =
|
||||||
|
"position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||||||
|
document.body.append(cameraFrame);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
cameraFrame.addEventListener("load", resolve, { once: true });
|
||||||
|
cameraFrame.addEventListener(
|
||||||
|
"error",
|
||||||
|
() => reject(new Error("Camera handoff iframe load failed")),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const cameraWindow = cameraFrame.contentWindow;
|
||||||
|
const cameraDocument = cameraFrame.contentDocument;
|
||||||
|
const cameraDownloads = [];
|
||||||
|
const originalCameraCreate = cameraWindow.URL.createObjectURL.bind(cameraWindow.URL);
|
||||||
|
const originalCameraRevoke = cameraWindow.URL.revokeObjectURL.bind(cameraWindow.URL);
|
||||||
|
const originalCameraClick = cameraWindow.HTMLAnchorElement.prototype.click;
|
||||||
|
cameraWindow.URL.createObjectURL = (blob) => {
|
||||||
|
const url = "blob:array-camera-test-" + String(cameraDownloads.length + 1);
|
||||||
|
cameraDownloads.push({ blob, url, fileName: null });
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
cameraWindow.URL.revokeObjectURL = () => {};
|
||||||
|
cameraWindow.HTMLAnchorElement.prototype.click = function captureCameraDownload() {
|
||||||
|
const download = cameraDownloads.find((item) => item.url === this.href);
|
||||||
|
if (download) download.fileName = this.download;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await waitFor(
|
||||||
|
() => cameraDocument.documentElement.dataset.appReady === "true",
|
||||||
|
"Camera handoff initialization",
|
||||||
|
);
|
||||||
|
const handoffFile = new cameraWindow.File(
|
||||||
|
[JSON.stringify(editedExport)],
|
||||||
|
"builder-handoff.array.json",
|
||||||
|
{ type: "application/json" },
|
||||||
|
);
|
||||||
|
dispatchFiles(cameraWindow, cameraDocument.querySelector("#array-input"), [handoffFile]);
|
||||||
|
await waitFor(
|
||||||
|
() => cameraDocument.querySelector("#array-preset").value === "custom" &&
|
||||||
|
cameraDocument.querySelector("#microphone-count").textContent.includes(
|
||||||
|
String(editedExport.microphones.length),
|
||||||
|
) &&
|
||||||
|
cameraDocument.documentElement.dataset.cameraValid === "true",
|
||||||
|
"Camera custom Array acceptance",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
cameraDocument.querySelector("#array-title").textContent,
|
||||||
|
editedExport.name,
|
||||||
|
"Camera shows the Builder array name",
|
||||||
|
);
|
||||||
|
cameraDocument.querySelector("#export-experiment-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() => cameraDownloads.some((item) => item.fileName),
|
||||||
|
"Camera Experiment export after handoff",
|
||||||
|
);
|
||||||
|
const cameraExperiment = JSON.parse(
|
||||||
|
await cameraDownloads.find((item) => item.fileName).blob.text(),
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
cameraExperiment.arrayId,
|
||||||
|
editedExport.id,
|
||||||
|
"Camera rebinds Experiment to the Builder Array ID",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cameraWindow.URL.createObjectURL = originalCameraCreate;
|
||||||
|
cameraWindow.URL.revokeObjectURL = originalCameraRevoke;
|
||||||
|
cameraWindow.HTMLAnchorElement.prototype.click = originalCameraClick;
|
||||||
|
cameraFrame.remove();
|
||||||
|
}
|
||||||
|
|
||||||
frame.style.width = "390px";
|
frame.style.width = "390px";
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
|
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
|
||||||
"compact Array Builder layout",
|
"compact Array Builder layout",
|
||||||
);
|
);
|
||||||
await nextPaint(windowObject);
|
await nextPaint(windowObject);
|
||||||
|
const compactWorkspace = documentObject.querySelector("#array-builder-workspace");
|
||||||
|
const compactControls = documentObject.querySelector("#array-builder-controls");
|
||||||
|
const compactWorkspaceRect = compactWorkspace.getBoundingClientRect();
|
||||||
|
const compactControlsRect = compactControls.getBoundingClientRect();
|
||||||
|
assert(
|
||||||
|
Boolean(
|
||||||
|
compactWorkspace.compareDocumentPosition(compactControls) &
|
||||||
|
windowObject.Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
),
|
||||||
|
"compact canvas workspace also precedes settings in DOM order",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
compactWorkspaceRect.bottom <= compactControlsRect.top + 1,
|
||||||
|
"compact canvas workspace appears before the long settings form",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector(".array-metadata-section").getBoundingClientRect().top <
|
||||||
|
documentObject.querySelector(".aperture-section").getBoundingClientRect().top &&
|
||||||
|
documentObject.querySelector(".aperture-section").getBoundingClientRect().top <
|
||||||
|
documentObject.querySelector(".preset-section").getBoundingClientRect().top &&
|
||||||
|
documentObject.querySelector(".preset-section").getBoundingClientRect().top <
|
||||||
|
documentObject.querySelector(".microphone-section").getBoundingClientRect().top,
|
||||||
|
"compact controls keep the numbered source and visual order",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
documentObject.documentElement.scrollWidth <=
|
documentObject.documentElement.scrollWidth <=
|
||||||
documentObject.documentElement.clientWidth + 1,
|
documentObject.documentElement.clientWidth + 1,
|
||||||
@@ -281,6 +691,16 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||||
"Array canvas remains usable at 390px",
|
"Array canvas remains usable at 390px",
|
||||||
);
|
);
|
||||||
|
assertEqual(
|
||||||
|
windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction,
|
||||||
|
"pan-y",
|
||||||
|
"mobile canvas permits vertical page scrolling",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
windowObject.getComputedStyle(documentObject.querySelector("#mobile-export-button")).display
|
||||||
|
!== "none",
|
||||||
|
"compact settings end with a visible save action",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
windowObject.URL.createObjectURL = originalCreate;
|
windowObject.URL.createObjectURL = originalCreate;
|
||||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const LAYOUT_CASES = [
|
|||||||
defaultGridTracks: 2,
|
defaultGridTracks: 2,
|
||||||
homePanelWidth: 360,
|
homePanelWidth: 360,
|
||||||
homeGridTracks: 1,
|
homeGridTracks: 1,
|
||||||
|
compactWorkspaceFirst: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -444,7 +445,12 @@ async function exerciseSplitLayout(definition) {
|
|||||||
|
|
||||||
const panelRect = mounted.panel.getBoundingClientRect();
|
const panelRect = mounted.panel.getBoundingClientRect();
|
||||||
const workspaceRect = mounted.workspace.getBoundingClientRect();
|
const workspaceRect = mounted.workspace.getBoundingClientRect();
|
||||||
if (definition.panelSide === "start") {
|
if (definition.compactWorkspaceFirst) {
|
||||||
|
assert(
|
||||||
|
panelRect.top >= workspaceRect.bottom - 1,
|
||||||
|
definition.label + " compact workspace precedes the long settings panel",
|
||||||
|
);
|
||||||
|
} else if (definition.panelSide === "start") {
|
||||||
assert(
|
assert(
|
||||||
workspaceRect.top >= panelRect.bottom - 1,
|
workspaceRect.top >= panelRect.bottom - 1,
|
||||||
definition.label + " compact workspace follows the panel vertically",
|
definition.label + " compact workspace follows the panel vertically",
|
||||||
|
|||||||
@@ -28,11 +28,16 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
|||||||
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
||||||
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
||||||
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
||||||
|
- `array-builder.test.js`: Array 문서 모델, 격자·선형·둘레·Fermat·seed random 프리셋,
|
||||||
|
최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단
|
||||||
|
- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test,
|
||||||
|
1/2/5 grid와 keyboard nudge
|
||||||
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
||||||
beam-spread covariance ellipse
|
beam-spread covariance ellipse
|
||||||
- `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증
|
- `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증
|
||||||
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
|
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
|
||||||
장치 한계와 typed CPU fallback 정책
|
장치 한계와 typed CPU fallback 정책
|
||||||
|
- `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
|
||||||
|
|
||||||
WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는
|
WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는
|
||||||
integration test에서 adapter가 있을 때 수행합니다.
|
integration test에서 adapter가 있을 때 수행합니다.
|
||||||
|
|||||||
@@ -86,6 +86,47 @@ test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채
|
|||||||
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
|
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Array canvas hit test는 viewport 변환에서 overflow된 마이크를 건너뛴다", () => {
|
||||||
|
const viewport = {
|
||||||
|
plot: { x: 0, y: 0 },
|
||||||
|
xMin: 0,
|
||||||
|
yMax: 100,
|
||||||
|
scale: 2,
|
||||||
|
};
|
||||||
|
const microphones = [
|
||||||
|
{ id: "overflow", position: [Number.MAX_VALUE, 0, 0] },
|
||||||
|
{ id: "normal", position: [5, 5, 0] },
|
||||||
|
];
|
||||||
|
assertEqual(
|
||||||
|
arrayPositionToCanvas(microphones[0].position, viewport).x,
|
||||||
|
Infinity,
|
||||||
|
"fixture overflows after viewport conversion",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
hitTestMicrophone({ x: 10, y: 190 }, microphones, viewport, 2)?.id,
|
||||||
|
"normal",
|
||||||
|
"finite microphone remains hittable",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Array canvas fit은 극단적인 유한 좌표에서도 aperture fallback viewport를 유지한다", () => {
|
||||||
|
const arrayDefinition = fixture();
|
||||||
|
arrayDefinition.microphones[0].position = [-Number.MAX_VALUE, 0, 0];
|
||||||
|
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, 0, 0];
|
||||||
|
const viewport = fitArrayViewport(800, 420, arrayDefinition);
|
||||||
|
for (const [name, value] of Object.entries({
|
||||||
|
scale: viewport.scale,
|
||||||
|
metersPerPixel: viewport.metersPerPixel,
|
||||||
|
xMin: viewport.xMin,
|
||||||
|
xMax: viewport.xMax,
|
||||||
|
yMin: viewport.yMin,
|
||||||
|
yMax: viewport.yMax,
|
||||||
|
})) {
|
||||||
|
assert(Number.isFinite(value), name + " remains finite");
|
||||||
|
}
|
||||||
|
assert(viewport.xMin < 0 && viewport.xMax > 0, "fallback keeps the aperture around the origin");
|
||||||
|
});
|
||||||
|
|
||||||
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
|
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
|
||||||
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
|
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
|
||||||
for (const step of candidates) {
|
for (const step of candidates) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
MAX_MICROPHONES,
|
MAX_MICROPHONES,
|
||||||
addMicrophone,
|
addMicrophone,
|
||||||
arrayGeometryIssues,
|
arrayGeometryIssues,
|
||||||
|
arrayStatistics,
|
||||||
centerMicrophones,
|
centerMicrophones,
|
||||||
clampPositionToAperture,
|
clampPositionToAperture,
|
||||||
createArrayDefinition,
|
createArrayDefinition,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
generatePresetMicrophones,
|
generatePresetMicrophones,
|
||||||
inferApertureShape,
|
inferApertureShape,
|
||||||
moveMicrophone,
|
moveMicrophone,
|
||||||
|
nextMicrophoneId,
|
||||||
positionInsideAperture,
|
positionInsideAperture,
|
||||||
removeMicrophone,
|
removeMicrophone,
|
||||||
replaceMicrophonesWithPreset,
|
replaceMicrophonesWithPreset,
|
||||||
@@ -110,6 +112,45 @@ test("seed random preset은 재현되고 최소 간격과 형상 경계를 지
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("1,024채널 random preset은 공간 index로 고유 좌표를 빠르게 생성한다", () => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const microphones = generatePresetMicrophones({
|
||||||
|
type: "random",
|
||||||
|
aperture: { width: 0.45, height: 0.45 },
|
||||||
|
shape: "rectangle",
|
||||||
|
count: 1024,
|
||||||
|
seed: 20260828,
|
||||||
|
minimumSpacingM: 0.002,
|
||||||
|
});
|
||||||
|
const elapsedMs = performance.now() - startedAt;
|
||||||
|
assertEqual(microphones.length, 1024, "maximum random channel count");
|
||||||
|
assertEqual(
|
||||||
|
new Set(microphones.map((microphone) => microphone.position.join(","))).size,
|
||||||
|
1024,
|
||||||
|
"maximum random positions are unique",
|
||||||
|
);
|
||||||
|
assert(elapsedMs < 3000, "maximum random generation stays interactive: " + elapsedMs + " ms");
|
||||||
|
|
||||||
|
const failedAt = performance.now();
|
||||||
|
assertThrows(
|
||||||
|
() => generatePresetMicrophones({
|
||||||
|
type: "random",
|
||||||
|
aperture: { width: 0.45, height: 0.45 },
|
||||||
|
shape: "rectangle",
|
||||||
|
count: 1024,
|
||||||
|
seed: 7,
|
||||||
|
minimumSpacingM: 0.03,
|
||||||
|
}),
|
||||||
|
"배치할 수 없습니다",
|
||||||
|
"dense maximum random failure",
|
||||||
|
);
|
||||||
|
const failedElapsedMs = performance.now() - failedAt;
|
||||||
|
assert(
|
||||||
|
failedElapsedMs < 3000,
|
||||||
|
"impossible maximum random layout fails within a bound: " + failedElapsedMs + " ms",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => {
|
test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => {
|
||||||
const arrayDefinition = createArrayDefinition();
|
const arrayDefinition = createArrayDefinition();
|
||||||
const before = JSON.stringify(arrayDefinition.microphones);
|
const before = JSON.stringify(arrayDefinition.microphones);
|
||||||
@@ -185,7 +226,7 @@ test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작
|
|||||||
assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height");
|
assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한다", () => {
|
test("문서의 명시적 형상 단서만으로 타원 생성 경계를 추론한다", () => {
|
||||||
const spiral = createArrayDefinition({
|
const spiral = createArrayDefinition({
|
||||||
description: "Fermat spiral on circular aperture",
|
description: "Fermat spiral on circular aperture",
|
||||||
presetType: "spiral",
|
presetType: "spiral",
|
||||||
@@ -194,4 +235,42 @@ test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한
|
|||||||
assertEqual(inferApertureShape(spiral), "ellipse", "description inference");
|
assertEqual(inferApertureShape(spiral), "ellipse", "description inference");
|
||||||
const grid = createArrayDefinition();
|
const grid = createArrayDefinition();
|
||||||
assertEqual(inferApertureShape(grid), "rectangle", "grid inference");
|
assertEqual(inferApertureShape(grid), "rectangle", "grid inference");
|
||||||
|
grid.description = "Spring-mounted rectangular array";
|
||||||
|
assertEqual(inferApertureShape(grid), "rectangle", "word boundary avoids spring false positive");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("매우 큰 유한 좌표에서도 배열 중심 통계가 유한하게 유지된다", () => {
|
||||||
|
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||||||
|
arrayDefinition.microphones[0].position = [Number.MAX_VALUE, Number.MAX_VALUE, 0];
|
||||||
|
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, -Number.MAX_VALUE, 0];
|
||||||
|
const statistics = arrayStatistics(arrayDefinition);
|
||||||
|
assert(Number.isFinite(statistics.centroid[0]), "large x centroid remains finite");
|
||||||
|
assert(Number.isFinite(statistics.centroid[1]), "large y centroid remains finite");
|
||||||
|
assertEqual(statistics.centroid[0], Number.MAX_VALUE, "large x centroid");
|
||||||
|
assertEqual(statistics.centroid[1], 0, "balanced y centroid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("큰 숫자 suffix ID도 마이크 추가를 멈추지 않고 정확히 증가시킨다", () => {
|
||||||
|
const arrayDefinition = createArrayDefinition({ columns: 1, rows: 1 });
|
||||||
|
arrayDefinition.microphones[0].id = "mic-9007199254740992";
|
||||||
|
assertEqual(
|
||||||
|
nextMicrophoneId(arrayDefinition),
|
||||||
|
"mic-9007199254740993",
|
||||||
|
"BigInt suffix increment",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
addMicrophone(arrayDefinition, [0.01, 0, 0]).id,
|
||||||
|
"mic-9007199254740993",
|
||||||
|
"manual add uses the safe ID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("고정밀 import 좌표는 실제 값이 다르면 중복으로 오인하지 않는다", () => {
|
||||||
|
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||||||
|
arrayDefinition.microphones[0].position = [0.0000000001, 0, 0];
|
||||||
|
arrayDefinition.microphones[1].position = [0.0000000002, 0, 0];
|
||||||
|
assert(
|
||||||
|
!arrayGeometryIssues(arrayDefinition).some((issue) => issue.severity === "error"),
|
||||||
|
"distinct high-precision positions",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user