feat: add interactive microphone array builder
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user