Files
micarrsim/apps/array-builder/canvas-view.js
T

708 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
},
};
}