708 lines
25 KiB
JavaScript
708 lines
25 KiB
JavaScript
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;
|
||
},
|
||
};
|
||
}
|