feat: add interactive microphone array builder

This commit is contained in:
2026-08-28 22:18:18 +09:00
parent acf68f4931
commit 0f9b4f8942
7 changed files with 3298 additions and 0 deletions
+707
View File
@@ -0,0 +1,707 @@
import { clampPositionToAperture, positionInsideAperture } from "./model.js";
const BACKGROUND = "#07110f";
const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function finiteNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function validPosition(position) {
return Array.isArray(position) &&
Number.isFinite(Number(position[0])) &&
Number.isFinite(Number(position[1]));
}
function apertureDimensions(arrayDefinition) {
return {
width: Math.max(0.000001, finiteNumber(arrayDefinition?.aperture?.width, 0.1)),
height: Math.max(0.000001, finiteNumber(arrayDefinition?.aperture?.height, 0.1)),
};
}
function resolvedMargins(width, height, requested = {}) {
const margins = { ...DEFAULT_MARGINS, ...requested };
const horizontalLimit = Math.max(0, width - 1);
const verticalLimit = Math.max(0, height - 1);
const horizontalScale = Math.min(1, horizontalLimit / Math.max(1, margins.left + margins.right));
const verticalScale = Math.min(1, verticalLimit / Math.max(1, margins.top + margins.bottom));
return {
left: margins.left * horizontalScale,
right: margins.right * horizontalScale,
top: margins.top * verticalScale,
bottom: margins.bottom * verticalScale,
};
}
/** Return a 1/2/5 × 10^n grid step near the requested line density. */
export function niceGridStep(span, targetLines = 8) {
const safeSpan = Math.max(1e-12, Math.abs(finiteNumber(span, 1)));
const safeTarget = Math.max(1, finiteNumber(targetLines, 8));
const raw = safeSpan / safeTarget;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const normalized = raw / magnitude;
const multiplier = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return multiplier * magnitude;
}
/**
* Fit an array into a CSS-pixel viewport while keeping x/y at exactly the same scale.
* The aperture and any microphones outside it are both included by the fitted view.
*/
export function fitArrayViewport(width, height, arrayDefinition, options = {}) {
const canvasWidth = Math.max(1, finiteNumber(width, 1));
const canvasHeight = Math.max(1, finiteNumber(height, 1));
const margins = resolvedMargins(canvasWidth, canvasHeight, options.margins);
const plot = {
x: margins.left,
y: margins.top,
width: Math.max(1, canvasWidth - margins.left - margins.right),
height: Math.max(1, canvasHeight - margins.top - margins.bottom),
};
const aperture = apertureDimensions(arrayDefinition);
let contentXMin = -aperture.width / 2;
let contentXMax = aperture.width / 2;
let contentYMin = -aperture.height / 2;
let contentYMax = aperture.height / 2;
for (const microphone of arrayDefinition?.microphones ?? []) {
if (!validPosition(microphone?.position)) continue;
contentXMin = Math.min(contentXMin, Number(microphone.position[0]));
contentXMax = Math.max(contentXMax, Number(microphone.position[0]));
contentYMin = Math.min(contentYMin, Number(microphone.position[1]));
contentYMax = Math.max(contentYMax, Number(microphone.position[1]));
}
const paddingRatio = clamp(finiteNumber(options.paddingRatio, 0.12), 0, 1);
const contentWidth = Math.max(0.000001, contentXMax - contentXMin);
const contentHeight = Math.max(0.000001, contentYMax - contentYMin);
const paddedWidth = contentWidth * (1 + paddingRatio * 2);
const paddedHeight = contentHeight * (1 + paddingRatio * 2);
const zoom = clamp(finiteNumber(options.zoom, 1), MIN_ZOOM, MAX_ZOOM);
const scale = Math.max(
Number.MIN_VALUE,
Math.min(plot.width / paddedWidth, plot.height / paddedHeight) * zoom,
);
const centerX = (contentXMin + contentXMax) / 2;
const centerY = (contentYMin + contentYMax) / 2;
const visibleWidth = plot.width / scale;
const visibleHeight = plot.height / scale;
return {
width: canvasWidth,
height: canvasHeight,
plot,
scale,
metersPerPixel: 1 / scale,
zoom,
centerX,
centerY,
xMin: centerX - visibleWidth / 2,
xMax: centerX + visibleWidth / 2,
yMin: centerY - visibleHeight / 2,
yMax: centerY + visibleHeight / 2,
};
}
/** Convert an array [x,y,z] position to CSS pixels. */
export function arrayPositionToCanvas(position, viewport) {
return {
x: viewport.plot.x + (Number(position[0]) - viewport.xMin) * viewport.scale,
y: viewport.plot.y + (viewport.yMax - Number(position[1])) * viewport.scale,
};
}
/** Convert CSS pixels to an array [x,y,z] position. `clamp` limits input to the plot. */
export function canvasPointToArray(point, viewport, options = {}) {
const plot = viewport.plot;
const pixelX = options.clamp
? clamp(finiteNumber(point?.x), plot.x, plot.x + plot.width)
: finiteNumber(point?.x);
const pixelY = options.clamp
? clamp(finiteNumber(point?.y), plot.y, plot.y + plot.height)
: finiteNumber(point?.y);
return [
viewport.xMin + (pixelX - plot.x) / viewport.scale,
viewport.yMax - (pixelY - plot.y) / viewport.scale,
finiteNumber(options.z, 0),
];
}
/** Convert a PointerEvent-like object into canvas-local CSS pixels without touching the DOM. */
export function eventToCanvasPoint(event, boundingRect, options = {}) {
const width = Math.max(0, finiteNumber(boundingRect?.width));
const height = Math.max(0, finiteNumber(boundingRect?.height));
let x = finiteNumber(event?.clientX) - finiteNumber(boundingRect?.left);
let y = finiteNumber(event?.clientY) - finiteNumber(boundingRect?.top);
if (options.clamp) {
x = clamp(x, 0, width);
y = clamp(y, 0, height);
}
return { x, y };
}
/** Convert a PointerEvent-like object to array coordinates; optionally clamp to the visible plot. */
export function eventToArrayPosition(event, boundingRect, viewport, options = {}) {
return canvasPointToArray(
eventToCanvasPoint(event, boundingRect, { clamp: Boolean(options.clampToCanvas) }),
viewport,
{ clamp: Boolean(options.clamp), z: options.z },
);
}
/** Find the closest microphone within a CSS-pixel radius. */
export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
const maximumDistance = Math.max(0, finiteNumber(radiusPx, 12));
let best = null;
for (let index = 0; index < (microphones?.length ?? 0); index += 1) {
const microphone = microphones[index];
if (!validPosition(microphone?.position)) continue;
const pixel = arrayPositionToCanvas(microphone.position, viewport);
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 };
}
}
return best;
}
/** Pure keyboard-to-array delta mapping used by the canvas controller. */
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
const step = (requestedStep > 0 ? requestedStep : 0.001) * (shiftKey ? 10 : 1);
if (key === "ArrowLeft") return { dx: -step, dy: 0, dz: 0 };
if (key === "ArrowRight") return { dx: step, dy: 0, dz: 0 };
if (key === "ArrowUp") return { dx: 0, dy: step, dz: 0 };
if (key === "ArrowDown") return { dx: 0, dy: -step, dz: 0 };
return null;
}
function resolveCanvasSize(canvas, view) {
const rect = canvas.getBoundingClientRect?.();
const width = Math.max(1, rect?.width || canvas.clientWidth || 1);
const height = Math.max(1, rect?.height || canvas.clientHeight || 1);
const pixelRatio = clamp(finiteNumber(view?.devicePixelRatio, 1), 1, MAX_PIXEL_RATIO);
const backingWidth = Math.max(1, Math.round(width * pixelRatio));
const backingHeight = Math.max(1, Math.round(height * pixelRatio));
if (canvas.width !== backingWidth || canvas.height !== backingHeight) {
canvas.width = backingWidth;
canvas.height = backingHeight;
}
return { width, height, pixelRatio };
}
function prepareContext(canvas, context, view) {
const size = resolveCanvasSize(canvas, view);
context.setTransform(size.pixelRatio, 0, 0, size.pixelRatio, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
context.imageSmoothingEnabled = true;
context.clearRect(0, 0, size.width, size.height);
return size;
}
function drawEmpty(context, width, height) {
context.fillStyle = BACKGROUND;
context.fillRect(0, 0, width, height);
context.fillStyle = "rgba(205,225,218,.66)";
context.font = "600 13px Inter, system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("마이크 어레이를 준비하고 있습니다.", width / 2, height / 2);
}
function formatMeters(value, step) {
const digits = clamp(Math.max(0, -Math.floor(Math.log10(step))) + 1, 0, 6);
const normalized = Math.abs(value) < step * 1e-7 ? 0 : value;
return normalized.toFixed(digits);
}
function gridValues(minimum, maximum, step) {
const first = Math.ceil((minimum - step * 1e-8) / step) * step;
const count = Math.min(200, Math.max(0, Math.floor((maximum - first) / step + 1 + 1e-8)));
return Array.from({ length: count }, (_, index) => first + index * step);
}
function drawGrid(context, viewport) {
const { plot } = viewport;
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.fillStyle = PLOT_BACKGROUND;
context.fillRect(plot.x, plot.y, plot.width, plot.height);
const step = niceGridStep(Math.max(viewport.xMax - viewport.xMin, viewport.yMax - viewport.yMin));
context.lineWidth = 1;
for (const x of gridValues(viewport.xMin, viewport.xMax, step)) {
const pixel = arrayPositionToCanvas([x, 0, 0], viewport).x;
context.strokeStyle = Math.abs(x) < step * 1e-7
? "rgba(101,242,191,.34)"
: "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(pixel, plot.y);
context.lineTo(pixel, plot.y + plot.height);
context.stroke();
}
for (const y of gridValues(viewport.yMin, viewport.yMax, step)) {
const pixel = arrayPositionToCanvas([0, y, 0], viewport).y;
context.strokeStyle = Math.abs(y) < step * 1e-7
? "rgba(101,242,191,.34)"
: "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(plot.x, pixel);
context.lineTo(plot.x + plot.width, pixel);
context.stroke();
}
context.restore();
context.fillStyle = "rgba(205,225,218,.62)";
context.font = "600 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textBaseline = "top";
context.textAlign = "center";
for (const x of gridValues(viewport.xMin, viewport.xMax, step)) {
const pixel = arrayPositionToCanvas([x, 0, 0], viewport).x;
if (pixel < plot.x + 14 || pixel > plot.x + plot.width - 14) continue;
context.fillText(formatMeters(x, step), pixel, plot.y + plot.height + 8);
}
context.textAlign = "right";
context.textBaseline = "middle";
for (const y of gridValues(viewport.yMin, viewport.yMax, step)) {
const pixel = arrayPositionToCanvas([0, y, 0], viewport).y;
if (pixel < plot.y + 8 || pixel > plot.y + plot.height - 8) continue;
context.fillText(formatMeters(y, step), plot.x - 8, pixel);
}
context.textAlign = "right";
context.textBaseline = "alphabetic";
context.fillText("x → m", plot.x + plot.width, plot.y + plot.height + 31);
context.save();
context.translate(Math.max(12, plot.x - 37), plot.y);
context.rotate(-Math.PI / 2);
context.textAlign = "right";
context.fillText("y ↑ m", 0, 0);
context.restore();
context.textAlign = "left";
}
function traceAperture(context, arrayDefinition, apertureShape, viewport) {
const aperture = apertureDimensions(arrayDefinition);
const center = arrayPositionToCanvas([0, 0, 0], viewport);
const radiusX = aperture.width * viewport.scale / 2;
const radiusY = aperture.height * viewport.scale / 2;
context.beginPath();
if (apertureShape === "ellipse") {
context.ellipse(center.x, center.y, radiusX, radiusY, 0, 0, Math.PI * 2);
} else {
context.rect(center.x - radiusX, center.y - radiusY, radiusX * 2, radiusY * 2);
}
}
function drawAperture(context, arrayDefinition, apertureShape, viewport) {
context.save();
context.beginPath();
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.clip();
traceAperture(context, arrayDefinition, apertureShape, viewport);
context.fillStyle = "rgba(101,242,191,.035)";
context.fill();
context.setLineDash([7, 5]);
context.lineWidth = 1.5;
context.strokeStyle = "rgba(174,229,210,.54)";
context.stroke();
context.setLineDash([]);
context.restore();
}
function drawSelectedLabel(context, microphone, point, viewport) {
const microphoneId = microphone.id.length > 24
? microphone.id.slice(0, 23) + "…"
: microphone.id;
const label = microphoneId + " [" +
microphone.position.map((value) => Number(value).toFixed(4)).join(", ") + "] m";
context.font = "700 11px ui-monospace, SFMono-Regular, Consolas, monospace";
const width = context.measureText(label).width + 16;
const x = clamp(point.x + 15, viewport.plot.x + 5, viewport.plot.x + viewport.plot.width - width - 5);
const y = clamp(point.y - 31, viewport.plot.y + 5, viewport.plot.y + viewport.plot.height - 28);
context.fillStyle = "rgba(4,13,10,.94)";
context.fillRect(x, y, width, 25);
context.strokeStyle = "rgba(101,242,191,.5)";
context.strokeRect(x + 0.5, y + 0.5, width - 1, 24);
context.fillStyle = "#eef8f4";
context.textAlign = "left";
context.textBaseline = "middle";
context.fillText(label, x + 8, y + 12.5);
}
function drawMicrophones(context, model, viewport, drag) {
const arrayDefinition = model.arrayDefinition;
const microphones = arrayDefinition.microphones ?? [];
const selectedId = drag?.id ?? model.selectedMicrophoneId;
const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7);
let selected = null;
context.save();
context.beginPath();
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.clip();
microphones.forEach((microphone, index) => {
if (!validPosition(microphone?.position)) return;
const position = drag?.id === microphone.id ? drag.last : microphone.position;
const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
const point = arrayPositionToCanvas(position, viewport);
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.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
context.strokeStyle = "rgba(239,255,249,.7)";
context.lineWidth = 1;
context.stroke();
if (microphones.length <= 24 && !isSelected) {
context.fillStyle = "rgba(239,249,245,.76)";
context.font = "700 9px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(String(index + 1), point.x, point.y - radius - 3);
}
if (isSelected) selected = { microphone: drawMicrophone, point, radius };
});
if (selected) {
context.strokeStyle = "rgba(255,255,255,.94)";
context.lineWidth = 2;
context.beginPath();
context.arc(selected.point.x, selected.point.y, selected.radius + 6, 0, Math.PI * 2);
context.stroke();
}
context.restore();
if (selected) drawSelectedLabel(context, selected.microphone, selected.point, viewport);
}
function drawArray(context, size, model, zoom, drag) {
context.fillStyle = BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) {
drawEmpty(context, size.width, size.height);
return null;
}
const viewport = fitArrayViewport(size.width, size.height, model.arrayDefinition, { zoom });
drawGrid(context, viewport);
drawAperture(context, model.arrayDefinition, model.apertureShape, viewport);
drawMicrophones(context, model, viewport, drag);
context.strokeStyle = "rgba(174,211,199,.28)";
context.lineWidth = 1;
context.strokeRect(
viewport.plot.x + 0.5,
viewport.plot.y + 0.5,
Math.max(0, viewport.plot.width - 1),
Math.max(0, viewport.plot.height - 1),
);
context.fillStyle = "rgba(205,225,218,.7)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textAlign = "right";
context.textBaseline = "top";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
return viewport;
}
function normalizedTool(tool) {
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
}
/**
* Create the editable microphone-array canvas.
*
* Callbacks: onSelect(id), onAdd(position), onDragStart(id), onDrag(id, position),
* onDragEnd(id, position), onDelete(), onNudge({dx,dy,dz}), onToolChange(tool), onFit().
*/
export function createArrayCanvas(canvas, callbacks = {}) {
if (!canvas) throw new TypeError("array canvas가 필요합니다.");
const context = canvas.getContext?.("2d", { alpha: false });
if (!context) throw new Error("2D canvas context를 만들 수 없습니다.");
const view = canvas.ownerDocument?.defaultView ?? globalThis;
const requestFrame = view.requestAnimationFrame?.bind(view) ?? ((callback) => view.setTimeout(callback, 16));
const cancelFrame = view.cancelAnimationFrame?.bind(view) ?? view.clearTimeout?.bind(view);
let model = null;
let viewport = null;
let frameId = 0;
let destroyed = false;
let zoom = 1;
let tool = "select";
let drag = null;
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
if (canvas.style) canvas.style.touchAction = "none";
function updateCanvasAffordance() {
if (canvas.dataset) {
canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom);
}
if (canvas.style) canvas.style.cursor = tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
}
function drawCurrent() {
frameId = 0;
if (destroyed) return;
const size = prepareContext(canvas, context, view);
viewport = drawArray(context, size, model, zoom, drag);
updateCanvasAffordance();
}
function schedule() {
if (!destroyed && !frameId) frameId = requestFrame(drawCurrent);
}
function ensureViewport() {
if (viewport) return viewport;
if (!model?.arrayDefinition) return null;
const rect = canvas.getBoundingClientRect();
viewport = fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
return viewport;
}
function render(nextModel) {
if (destroyed) return;
model = nextModel;
tool = normalizedTool(nextModel?.tool ?? tool);
viewport = null;
schedule();
}
function setTool(nextTool) {
const resolved = normalizedTool(nextTool);
if (resolved === tool) return tool;
tool = resolved;
updateCanvasAffordance();
callbacks.onToolChange?.(tool);
schedule();
return tool;
}
function fit() {
zoom = 1;
viewport = null;
schedule();
return zoom;
}
function zoomBy(factor) {
const multiplier = Number(factor);
if (!Number.isFinite(multiplier) || multiplier <= 0) {
throw new RangeError("zoom factor는 0보다 큰 유한한 숫자여야 합니다.");
}
zoom = clamp(zoom * multiplier, MIN_ZOOM, MAX_ZOOM);
viewport = null;
schedule();
return zoom;
}
function focus() {
try {
canvas.focus({ preventScroll: true });
} catch {
canvas.focus?.();
}
}
function microphoneById(id) {
return model?.arrayDefinition?.microphones?.find((microphone) => microphone.id === id) ?? null;
}
function constrainedEventPosition(event, z = 0) {
const currentViewport = ensureViewport();
if (!currentViewport || !model?.arrayDefinition?.aperture) return null;
const rect = canvas.getBoundingClientRect();
const raw = eventToArrayPosition(event, rect, currentViewport, { clamp: true, z });
const step = finiteNumber(model.snapStepM, 0);
if (step > 0) {
raw[0] = Math.round(raw[0] / step) * step;
raw[1] = Math.round(raw[1] / step) * step;
}
return clampPositionToAperture(
raw,
model.arrayDefinition.aperture,
model.apertureShape,
);
}
function pointerPoint(event) {
return eventToCanvasPoint(event, canvas.getBoundingClientRect());
}
function onPointerDown(event) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
focus();
if (tool === "add") {
const position = constrainedEventPosition(event, 0);
if (position) {
event.preventDefault();
callbacks.onAdd?.(position);
}
return;
}
const currentViewport = ensureViewport();
if (!currentViewport) return;
const hit = hitTestMicrophone(
pointerPoint(event),
model.arrayDefinition.microphones,
currentViewport,
);
callbacks.onSelect?.(hit?.id ?? null);
if (!hit) return;
event.preventDefault();
drag = {
pointerId: event.pointerId,
id: hit.id,
start: [...hit.microphone.position],
last: [...hit.microphone.position],
};
canvas.setPointerCapture?.(event.pointerId);
callbacks.onDragStart?.(hit.id);
updateCanvasAffordance();
schedule();
}
function onPointerMove(event) {
if (!drag || event.pointerId !== drag.pointerId) return;
const microphone = microphoneById(drag.id);
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
if (!position) return;
event.preventDefault();
drag.last = position;
callbacks.onDrag?.(drag.id, [...position]);
schedule();
}
function finishDrag(event, cancel = false) {
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]);
const pointerId = event?.pointerId ?? completed.pointerId;
if (canvas.hasPointerCapture?.(pointerId)) {
canvas.releasePointerCapture?.(pointerId);
}
updateCanvasAffordance();
schedule();
}
function onKeyDown(event) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (lowerKey === "a") {
event.preventDefault();
setTool("add");
return;
}
if (lowerKey === "v") {
event.preventDefault();
setTool("select");
return;
}
if (lowerKey === "f") {
event.preventDefault();
fit();
callbacks.onFit?.();
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (drag) finishDrag(undefined, true);
setTool("select");
return;
}
if ((event.key === "Delete" || event.key === "Backspace") && model?.selectedMicrophoneId) {
event.preventDefault();
callbacks.onDelete?.();
return;
}
if (!model?.selectedMicrophoneId) return;
const defaultStep = finiteNumber(model.snapStepM, 0) > 0 ? Number(model.snapStepM) : 0.001;
const delta = keyboardNudgeForKey(event.key, defaultStep, event.shiftKey);
if (!delta) return;
event.preventDefault();
callbacks.onNudge?.(delta);
}
function onPointerUp(event) {
finishDrag(event, false);
}
function onPointerCancel(event) {
finishDrag(event, true);
}
function onLostPointerCapture(event) {
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
}
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerCancel);
canvas.addEventListener("lostpointercapture", onLostPointerCapture);
canvas.addEventListener("keydown", onKeyDown);
let resizeObserver = null;
const onWindowResize = () => {
viewport = null;
schedule();
};
if (typeof view.ResizeObserver === "function") {
resizeObserver = new view.ResizeObserver(onWindowResize);
resizeObserver.observe(canvas);
} else {
view.addEventListener?.("resize", onWindowResize);
}
updateCanvasAffordance();
return {
render,
setTool,
fit,
zoomBy,
focus,
destroy() {
if (destroyed) return;
destroyed = true;
if (frameId) cancelFrame?.(frameId);
frameId = 0;
resizeObserver?.disconnect();
view.removeEventListener?.("resize", onWindowResize);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerCancel);
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
canvas.removeEventListener("keydown", onKeyDown);
model = null;
viewport = null;
drag = null;
},
};
}