fix: harden array builder editing workflows

This commit is contained in:
2026-08-28 23:10:41 +09:00
parent 3714146df9
commit 122b6cf7be
14 changed files with 1148 additions and 165 deletions
+5
View File
@@ -59,6 +59,11 @@ Array JSON을 저장하세요.
- **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다.
확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다.
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
스크롤하려는 동작으로 채널이 생기지 않습니다.
`JSON 열기`로 다른 배열을 불러온 직후에도 한 번 실행 취소하면 불러오기 전 문서를 복구할 수
있습니다. 다시 실행하면 불러온 배열로 돌아갑니다.
## 보정값 보존
+110 -12
View File
@@ -5,6 +5,7 @@ const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
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 visibleWidth = plot.width / 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 {
width: canvasWidth,
@@ -168,6 +189,7 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
const microphone = microphones[index];
if (!validPosition(microphone?.position)) continue;
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);
if (distancePx <= maximumDistance && (!best || distancePx < best.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 drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
const point = arrayPositionToCanvas(position, viewport);
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
const isSelected = microphone.id === selectedId;
const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape);
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
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.shadowBlur = isSelected ? 16 : 6;
context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
@@ -449,10 +472,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let zoom = 1;
let tool = "select";
let drag = null;
let pendingAdd = null;
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
if (canvas.style) canvas.style.touchAction = "none";
function updateCanvasAffordance() {
if (canvas.dataset) {
canvas.dataset.tool = tool;
@@ -492,6 +514,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function setTool(nextTool) {
const resolved = normalizedTool(nextTool);
if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null;
tool = resolved;
updateCanvasAffordance();
callbacks.onToolChange?.(tool);
@@ -550,14 +573,48 @@ export function createArrayCanvas(canvas, callbacks = {}) {
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) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
if (drag || pendingAdd) return;
focus();
if (tool === "add") {
const position = constrainedEventPosition(event, 0);
if (position) {
event.preventDefault();
callbacks.onAdd?.(position);
if (event.pointerType === "touch") {
pendingAdd = {
pointerId: event.pointerId,
startClient: clientPoint(event),
};
} else {
event.preventDefault();
callbacks.onAdd?.(position);
}
}
return;
}
@@ -577,15 +634,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
id: hit.id,
start: [...hit.microphone.position],
last: [...hit.microphone.position],
startClient: clientPoint(event),
pointerType: event.pointerType,
pending: event.pointerType === "touch",
};
canvas.setPointerCapture?.(event.pointerId);
callbacks.onDragStart?.(hit.id);
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic pointers and interrupted input do not always own capture.
}
if (!drag.pending) callbacks.onDragStart?.(hit.id);
updateCanvasAffordance();
schedule();
}
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.pending) {
if (!exceedsTouchThreshold(drag.startClient, event)) return;
drag.pending = false;
callbacks.onDragStart?.(drag.id);
}
const microphone = microphoneById(drag.id);
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
if (!position) return;
@@ -599,17 +672,37 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return;
const completed = drag;
drag = null;
const position = cancel ? completed.start : completed.last;
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
callbacks.onDragEnd?.(completed.id, [...position]);
if (!completed.pending) {
const position = cancel ? completed.start : completed.last;
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
callbacks.onDragEnd?.(completed.id, [...position]);
}
const pointerId = event?.pointerId ?? completed.pointerId;
if (canvas.hasPointerCapture?.(pointerId)) {
canvas.releasePointerCapture?.(pointerId);
try {
if (canvas.hasPointerCapture?.(pointerId)) {
canvas.releasePointerCapture?.(pointerId);
}
} catch {
// The browser may have released capture before pointercancel/lostcapture.
}
updateCanvasAffordance();
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) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
@@ -649,10 +742,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerUp(event) {
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
function onPointerCancel(event) {
if (finishPendingAdd(event, true)) return;
finishDrag(event, true);
}
@@ -665,6 +760,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerCancel);
canvas.addEventListener("lostpointercapture", onLostPointerCapture);
canvas.addEventListener("touchstart", onTouchStart, { passive: false });
canvas.addEventListener("keydown", onKeyDown);
let resizeObserver = null;
@@ -698,10 +794,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerCancel);
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
canvas.removeEventListener("touchstart", onTouchStart);
canvas.removeEventListener("keydown", onKeyDown);
model = null;
viewport = null;
drag = null;
pendingAdd = null;
},
};
}
+68 -62
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="/apps/array-builder/styles.css" />
</head>
<body>
<a class="skip-link" href="#array-canvas">배열 편집 화면으로 건너뛰기</a>
<div class="app-shell array-builder-shell">
<header class="app-header array-builder-header">
<a class="app-brand" href="/">
@@ -39,6 +40,65 @@
</header>
<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
class="array-controls"
id="array-builder-controls"
@@ -58,7 +118,7 @@
</div>
</section>
<section class="control-section">
<section class="control-section array-metadata-section">
<div class="section-title"><span>01</span><h2>배열 정보</h2></div>
<label class="field field--wide">
<span>이름</span>
@@ -81,7 +141,7 @@
</label>
</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="field-grid">
<label class="field">
@@ -100,7 +160,7 @@
<option value="ellipse">타원</option>
</select>
</label>
<p class="section-note">크기를 바꿔도 기존 마이크 좌표는 자동으로 늘어나거나 줄어들지 않습니다.</p>
<p class="section-note">크기를 바꿔도 기존 좌표는 자동으로 늘어나거나 줄어들지 않습니다. 생성 경계 형상은 편집용이며 JSON에는 최종 좌표와 너비·높이만 저장됩니다.</p>
<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="fit-aperture-button" type="button">좌표에 크기 맞춤</button>
@@ -108,7 +168,7 @@
</div>
</section>
<section class="control-section">
<section class="control-section preset-section">
<div class="section-title"><span>03</span><h2>프리셋 생성</h2></div>
<label class="field field--wide">
<span>배치 방식</span>
@@ -201,6 +261,10 @@
</div>
</div>
</section>
<div class="mobile-save-actions">
<button class="button button--primary button--full" id="mobile-export-button" type="button">Array JSON 저장</button>
</div>
</aside>
<div
@@ -216,64 +280,6 @@
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
></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>
</div>
+245 -44
View File
@@ -49,7 +49,7 @@ const optionalIds = [
"move-microphone-down-button", "microphone-sensitivity", "microphone-phase",
"frequency-response-summary", "preset-axis", "preset-edge-inset",
"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);
elements.validationCard = elements["validation-title"].closest(".validation-card");
@@ -80,14 +80,20 @@ const state = {
history: [],
future: [],
dragSnapshot: null,
importRevision: 0,
baselineSignature: "",
};
const MAX_HISTORY = 50;
const MAX_HISTORY_BYTES = 24 * 1024 * 1024;
const frequencyResponseFingerprintCache = new WeakMap();
const BUILTIN_ARRAY_IDS = new Set([
"rectangular-2x2", "rectangular-4x3", "rectangular-8x6",
"rectangular-16x12", "rectangular-24x16", "rectangular-16x12-compact",
"rectangular-2x2-450mm", "rectangular-4x3-450mm",
"rectangular-8x6-450mm", "rectangular-16x12",
"rectangular-24x16-450mm", "rectangular-16x12-compact",
"rectangular-16x12-large", "fermat-spiral-192",
]);
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
function finiteInput(element, label, options = {}) {
const value = element.valueAsNumber;
@@ -121,35 +127,152 @@ function selectedMicrophone() {
) ?? 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() {
return {
arrayDefinition: structuredClone(state.arrayDefinition),
arrayDefinition: cloneArrayForHistory(state.arrayDefinition),
apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId,
};
}
function snapshotSignature(snapshot) {
return JSON.stringify(snapshot);
function editableSnapshotSignature(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) {
state.arrayDefinition = structuredClone(snapshot.arrayDefinition);
state.arrayDefinition = cloneArrayForHistory(snapshot.arrayDefinition);
state.apertureShape = snapshot.apertureShape;
state.selectedMicrophoneId = snapshot.selectedMicrophoneId;
if (!selectedMicrophone()) {
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
}
state.dirty = true;
refreshDirtyState();
renderAll(message);
}
function recordSnapshot(before, label) {
if (snapshotSignature(before) === snapshotSignature(captureSnapshot())) return false;
state.history.push({ snapshot: before, label });
if (state.history.length > MAX_HISTORY) state.history.shift();
if (editableSnapshotSignature(before) === editableSnapshotSignature(currentSnapshotView())) {
return false;
}
state.history.push(historyEntry(before, label));
state.future.length = 0;
state.dirty = true;
trimHistoryMemory();
refreshDirtyState();
return true;
}
@@ -165,7 +288,11 @@ function showActionError(error) {
if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함";
elements["validation-title"].textContent = "작업을 적용하지 못했습니다";
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;
}
@@ -174,6 +301,9 @@ function safely(callback) {
delete elements["array-builder-workspace"].dataset.actionError;
callback();
} catch (error) {
renderDocumentEditor();
syncSelectedEditor();
setNumber(elements["snap-step"], state.snapStepM * 1000);
showActionError(error);
}
}
@@ -183,9 +313,14 @@ function announce(message) {
elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨";
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 = {}) {
@@ -196,6 +331,10 @@ function setTool(tool, options = {}) {
elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add"));
elements["array-canvas"].dataset.tool = state.tool;
canvasView.render(canvasState());
if (options.userInitiated) {
dismissCanvasInstructions();
announceSelection(state.tool === "add" ? "추가 도구 · 빈 곳을 클릭하세요." : "선택 도구");
}
if (options.focus) canvasView.focus();
}
@@ -258,15 +397,29 @@ function selectMicrophone(id, options = {}) {
renderMicrophoneList();
syncSelectedEditor();
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();
}
const canvasView = createArrayCanvas(elements["array-canvas"], {
onSelect(id) {
selectMicrophone(id);
dismissCanvasInstructions();
selectMicrophone(id, {
scrollList: window.matchMedia("(min-width: 761px)").matches,
});
},
onAdd(position) {
dismissCanvasInstructions();
safely(() => mutate("마이크 추가됨", () => {
const microphone = addMicrophone(
state.arrayDefinition,
@@ -276,6 +429,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
}));
},
onDragStart(id) {
dismissCanvasInstructions();
if (state.dragSnapshot) return;
state.selectedMicrophoneId = id;
state.dragSnapshot = captureSnapshot();
@@ -320,7 +474,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
}));
},
onToolChange(tool) {
setTool(tool);
setTool(tool, { userInitiated: true });
},
onFit() {
canvasView.fit();
@@ -372,11 +526,14 @@ function renderMicrophoneList() {
const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? "";
list.replaceChildren();
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");
button.type = "button";
button.className = "microphone-row";
button.dataset.microphoneId = microphone.id;
button.dataset.searchPinned = String(!matchesQuery);
button.tabIndex = microphone.id === state.selectedMicrophoneId ? 0 : -1;
button.setAttribute("role", "option");
button.setAttribute(
"aria-selected",
@@ -412,9 +569,16 @@ function renderMicrophoneList() {
identity.append(calibration);
}
button.append(channel, identity);
button.addEventListener("click", () => selectMicrophone(microphone.id));
button.addEventListener("click", () => {
dismissCanvasInstructions();
selectMicrophone(microphone.id, { focusList: true });
});
list.append(button);
});
if (!list.querySelector('[tabindex="0"]')) {
const firstButton = list.querySelector("[data-microphone-id]");
if (firstButton) firstButton.tabIndex = 0;
}
if (!list.childElementCount) {
const empty = document.createElement("p");
empty.className = "microphone-list__empty";
@@ -521,6 +685,9 @@ async function validateCurrent() {
state.valid = errors.length === 0;
document.documentElement.dataset.arrayValid = String(state.valid);
elements["export-button"].disabled = !state.valid;
if (elements["mobile-export-button"]) {
elements["mobile-export-button"].disabled = !state.valid;
}
elements.validationCard?.setAttribute(
"data-severity",
errors.length ? "error" : warnings.length ? "warning" : "success",
@@ -603,24 +770,31 @@ function applySelectedPosition() {
}
function presetOptions() {
return {
type: elements["preset-type"].value,
const type = elements["preset-type"].value;
const options = {
type,
aperture: state.arrayDefinition.aperture,
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"]
? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 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(() => {
@@ -680,8 +854,10 @@ elements["generate-preset-button"].addEventListener("click", () => safely(() =>
);
}));
elements["select-tool-button"].addEventListener("click", () => setTool("select", { focus: true }));
elements["add-tool-button"].addEventListener("click", () => setTool("add", { focus: true }));
elements["select-tool-button"].addEventListener("click", () =>
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["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
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(() => {
dismissCanvasInstructions();
mutate("마이크 추가됨", () => {
const microphone = addMicrophone(state.arrayDefinition, availableManualPosition());
state.selectedMicrophoneId = microphone.id;
@@ -763,6 +940,19 @@ elements["move-microphone-down-button"]?.addEventListener("click", () => safely(
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1));
}));
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(() => {
mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition));
@@ -789,14 +979,16 @@ elements["clamp-microphones-button"]?.addEventListener("click", () => safely(()
function undo() {
const entry = state.history.pop();
if (!entry) return;
state.future.push({ snapshot: captureSnapshot(), label: entry.label });
state.future.push(historyEntry(captureSnapshot(), entry.label));
trimHistoryMemory();
restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label);
}
function redo() {
const entry = state.future.pop();
if (!entry) return;
state.history.push({ snapshot: captureSnapshot(), label: entry.label });
state.history.push(historyEntry(captureSnapshot(), entry.label));
trimHistoryMemory();
restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label);
}
@@ -821,31 +1013,36 @@ window.addEventListener("keydown", (event) => {
elements["array-input"].addEventListener("change", async () => {
const file = elements["array-input"].files?.[0];
if (!file) return;
const importRevision = ++state.importRevision;
try {
const value = await readJsonFile(file);
const issues = await validateArrayDefinition(value);
if (importRevision !== state.importRevision) return;
if (issues.length) {
const details = issues.slice(0, 4).map(
(issue) => issue.path + ": " + issue.message,
).join(" · ");
throw new Error("Array JSON 계약을 만족하지 않습니다. " + details);
}
const previousSnapshot = captureSnapshot();
state.arrayDefinition = prepareArrayForEditing(value);
state.apertureShape = inferApertureShape(value);
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
state.history.length = 0;
state.history = [historyEntry(previousSnapshot, "Array 불러오기")];
state.future.length = 0;
trimHistoryMemory();
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
state.dirty = false;
canvasView.fit();
renderAll(file.name + " 불러옴");
renderAll(file.name + " 불러옴 · 실행 취소 가능");
} catch (error) {
showActionError(error);
if (importRevision === state.importRevision) showActionError(error);
} finally {
elements["array-input"].value = "";
if (importRevision === state.importRevision) elements["array-input"].value = "";
}
});
elements["export-button"].addEventListener("click", async () => {
async function exportArray() {
await validateCurrent();
if (!state.valid) {
showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요."));
@@ -855,9 +1052,13 @@ elements["export-button"].addEventListener("click", async () => {
state.arrayDefinition,
safeArrayFileStem(state.arrayDefinition) + ".array.json",
);
state.dirty = false;
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
refreshDirtyState();
announce("Array JSON 저장됨");
});
}
elements["export-button"].addEventListener("click", exportArray);
elements["mobile-export-button"]?.addEventListener("click", exportArray);
window.addEventListener("beforeunload", (event) => {
if (!state.dirty) return;
+71 -29
View File
@@ -63,7 +63,10 @@ function microphoneFromPosition(position, index) {
}
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) {
@@ -218,16 +221,43 @@ function randomPositions(options, bounds, shape) {
const random = createSeededRandom(options.seed ?? 1);
const positions = [];
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) {
const candidate = normalizedPosition(randomCandidate(random, bounds, shape));
const key = pointKey(candidate);
if (keys.has(key)) continue;
if (positions.some((position) => squaredDistance(position, candidate) < minimumSpacingSquared)) {
continue;
}
if (violatesMinimumSpacing(candidate)) continue;
keys.add(key);
positions.push(candidate);
indexPosition(candidate);
}
if (positions.length !== count) {
throw new RangeError(
@@ -302,35 +332,36 @@ export function prepareArrayForEditing(value) {
}
export function inferApertureShape(arrayDefinition) {
const description = String(arrayDefinition?.description ?? "");
if (/circle|circular|ellipse|elliptic|ring|spiral/i.test(description)) return "ellipse";
const aperture = arrayDefinition?.aperture;
const microphones = arrayDefinition?.microphones ?? [];
if (!aperture || microphones.length < 3) return "rectangle";
let maximumNormalizedRadius = 0;
for (const microphone of microphones) {
const position = microphone?.position;
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";
const shapeWords = [
arrayDefinition?.id,
arrayDefinition?.name,
arrayDefinition?.description,
].filter(Boolean).join(" ");
return /(^|[^a-z])(circle|circular|ellipse|elliptic|ring|spiral)([^a-z]|$)/i.test(shapeWords)
? "ellipse"
: "rectangle";
}
export function nextMicrophoneId(arrayDefinition) {
const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id));
let maximumSuffix = 0;
let maximumSuffix = 0n;
for (const id of used) {
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;
while (used.has(microphoneId(candidate))) candidate += 1;
return microphoneId(candidate);
let candidate = maximumSuffix + 1n;
for (let attempt = 0; attempt <= used.size; attempt += 1) {
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 = {}) {
@@ -514,8 +545,19 @@ export function arrayStatistics(arrayDefinition) {
if (microphones.length === 0) {
return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 };
}
const centroid = [0, 1, 2].map((axis) =>
microphones.reduce((sum, microphone) => sum + microphone.position[axis], 0) / microphones.length);
const centroid = [0, 1, 2].map((axis) => {
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;
for (let left = 0; left < microphones.length; left += 1) {
for (let right = left + 1; right < microphones.length; right += 1) {
+69 -10
View File
@@ -3,6 +3,25 @@
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 {
position: sticky;
z-index: 30;
@@ -116,6 +135,11 @@
clip-path: inset(50%);
}
.file-button:focus-within {
border-color: var(--mint);
outline: 2px solid rgb(101 242 191 / 16%);
}
.array-workflow {
min-width: 0;
margin-left: auto;
@@ -169,6 +193,8 @@
}
.array-controls {
grid-column: 1;
grid-row: 1;
position: sticky;
top: 59px;
min-width: 0;
@@ -185,6 +211,8 @@
}
.array-builder-layout > .split-handle {
grid-column: 2;
grid-row: 1;
position: sticky;
top: 59px;
height: calc(100vh - 59px);
@@ -577,7 +605,13 @@ details > summary::-webkit-details-marker {
padding: 0 10px 10px;
}
.mobile-save-actions {
display: none;
}
.array-workspace {
grid-column: 3;
grid-row: 1;
min-width: 0;
padding: 18px clamp(18px, 2.5vw, 38px) 38px;
}
@@ -857,41 +891,60 @@ body[data-array-tool="add"] #array-canvas,
@media (max-width: 1100px) {
.array-builder-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: minmax(340px, 38%) minmax(0, 1fr);
}
.array-builder-layout > .split-handle {
display: none;
}
.array-controls {
grid-column: 1;
}
.array-workspace {
grid-column: 2;
}
.array-controls,
.array-workspace {
width: 100%;
min-width: 0;
}
.array-controls {
position: static;
overflow: visible;
max-height: none;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.microphone-list {
max-height: 360px;
}
}
@media (max-width: 760px) {
.array-builder-layout {
grid-template-columns: minmax(0, 1fr);
}
.array-builder-header {
position: static;
flex-wrap: wrap;
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 {
order: 3;
width: 100%;
margin: 0;
}
@@ -935,11 +988,17 @@ body[data-array-tool="add"] #array-canvas,
#array-canvas {
height: 500px;
touch-action: pan-y;
}
.shortcut-help > div {
grid-template-columns: minmax(0, 1fr);
}
.mobile-save-actions {
display: block;
padding: 2px 0 16px;
}
}
@media (max-width: 520px) {