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 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"]); 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; 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, 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); 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 }; } } 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); 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 : microphones.length > 256 ? 0 : 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; let pendingAdd = null; if (canvas.tabIndex < 0) canvas.tabIndex = 0; 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; if (resolved !== "add") pendingAdd = null; 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 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) { if (event.pointerType === "touch") { pendingAdd = { pointerId: event.pointerId, startClient: clientPoint(event), }; } else { 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], startClient: clientPoint(event), pointerType: event.pointerType, pending: event.pointerType === "touch", }; 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; 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; 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; 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; 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) { if (finishPendingAdd(event, false)) return; finishDrag(event, false); } function onPointerCancel(event) { if (finishPendingAdd(event, true)) return; 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("touchstart", onTouchStart, { passive: false }); 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("touchstart", onTouchStart); canvas.removeEventListener("keydown", onKeyDown); model = null; viewport = null; drag = null; pendingAdd = null; }, }; }