Files

1288 lines
45 KiB
JavaScript
Raw Permalink 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";
import { orbitForViewDirection } from "./orbit-view.js";
const BACKGROUND = "#07110f";
const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
const MAX_ORBIT_PITCH = Math.PI / 2;
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) &&
position.length >= 3 &&
Number.isFinite(Number(position[0])) &&
Number.isFinite(Number(position[1])) &&
Number.isFinite(Number(position[2]));
}
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;
}
function rotateArrayPosition3d(position, yaw, pitch) {
const x = finiteNumber(position?.[0]);
const y = finiteNumber(position?.[1]);
const z = finiteNumber(position?.[2]);
const cosYaw = Math.cos(yaw);
const sinYaw = Math.sin(yaw);
const cosPitch = Math.cos(pitch);
const sinPitch = Math.sin(pitch);
const horizontal = cosYaw * x - sinYaw * y;
const away = sinYaw * x + cosYaw * y;
return {
horizontal,
vertical: z * cosPitch + away * sinPitch,
depth: z * sinPitch - away * cosPitch,
};
}
/** Project an array [x,y,z] position into a fitted 3D inspection viewport. */
export function projectArrayPosition3d(position, viewport) {
const rotated = rotateArrayPosition3d(position, viewport.yaw, viewport.pitch);
return {
x: viewport.centerPixelX + (rotated.horizontal - viewport.centerHorizontal) * viewport.scale,
y: viewport.centerPixelY - (rotated.vertical - viewport.centerVertical) * viewport.scale,
depth: rotated.depth,
};
}
function fitArrayViewport3d(width, height, model, zoom, orbit) {
const canvasWidth = Math.max(1, finiteNumber(width, 1));
const canvasHeight = Math.max(1, finiteNumber(height, 1));
const margins = resolvedMargins(canvasWidth, canvasHeight, {
left: 32,
right: 32,
top: 45,
bottom: 38,
});
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(model.arrayDefinition);
const referenceSpan = Math.max(aperture.width, aperture.height, 0.000001);
const points = [
[-aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, aperture.height / 2, 0],
[-aperture.width / 2, aperture.height / 2, 0],
[0, 0, referenceSpan * 0.72],
];
for (const microphone of model.arrayDefinition.microphones ?? []) {
if (validPosition(microphone?.position)) points.push(microphone.position);
}
const rotated = points.map((position) => rotateArrayPosition3d(position, orbit.yaw, orbit.pitch));
const horizontalValues = rotated.map((point) => point.horizontal);
const verticalValues = rotated.map((point) => point.vertical);
const horizontalMin = Math.min(...horizontalValues);
const horizontalMax = Math.max(...horizontalValues);
const verticalMin = Math.min(...verticalValues);
const verticalMax = Math.max(...verticalValues);
const horizontalSpan = Math.max(0.000001, horizontalMax - horizontalMin);
const verticalSpan = Math.max(0.000001, verticalMax - verticalMin);
const scale = Math.max(
Number.MIN_VALUE,
Math.min(plot.width / (horizontalSpan * 1.22), plot.height / (verticalSpan * 1.22)) * zoom,
);
return {
width: canvasWidth,
height: canvasHeight,
plot,
scale,
zoom,
yaw: orbit.yaw,
pitch: orbit.pitch,
centerHorizontal: (horizontalMin + horizontalMax) / 2,
centerVertical: (verticalMin + verticalMax) / 2,
centerPixelX: plot.x + plot.width / 2,
centerPixelY: plot.y + plot.height / 2,
referenceSpan,
};
}
/** 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";
context.textBaseline = "top";
context.fillStyle = "rgba(101,242,191,.88)";
context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.beginPath();
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 5, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 1.8, 0, Math.PI * 2);
context.fill();
context.fillText("+Z FRONT", viewport.plot.x + 20, viewport.plot.y + 5);
context.textAlign = "left";
return viewport;
}
function traceProjectedPositions(context, positions, viewport, close = false) {
context.beginPath();
positions.forEach((position, index) => {
const point = projectArrayPosition3d(position, viewport);
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
if (close) context.closePath();
}
function aperturePerimeter3d(model, sampleCount = 72) {
const aperture = apertureDimensions(model.arrayDefinition);
if (model.apertureShape !== "ellipse") {
return [
[-aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, aperture.height / 2, 0],
[-aperture.width / 2, aperture.height / 2, 0],
];
}
return Array.from({ length: sampleCount }, (_, index) => {
const angle = index / sampleCount * Math.PI * 2;
return [
Math.cos(angle) * aperture.width / 2,
Math.sin(angle) * aperture.height / 2,
0,
];
});
}
function drawProjectedLine(context, start, end, viewport) {
const a = projectArrayPosition3d(start, viewport);
const b = projectArrayPosition3d(end, viewport);
context.beginPath();
context.moveTo(a.x, a.y);
context.lineTo(b.x, b.y);
context.stroke();
return b;
}
function drawArrayPlane3d(context, model, viewport) {
const perimeter = aperturePerimeter3d(model);
traceProjectedPositions(context, perimeter, viewport, true);
context.fillStyle = "rgba(101,242,191,.055)";
context.fill();
context.strokeStyle = "rgba(174,229,210,.72)";
context.lineWidth = 1.5;
context.setLineDash([7, 5]);
context.stroke();
context.setLineDash([]);
const aperture = apertureDimensions(model.arrayDefinition);
context.strokeStyle = "rgba(166,196,186,.14)";
context.lineWidth = 1;
for (const fraction of [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75]) {
if (model.apertureShape === "ellipse") {
const bound = Math.sqrt(Math.max(0, 1 - fraction * fraction));
drawProjectedLine(
context,
[fraction * aperture.width / 2, -bound * aperture.height / 2, 0],
[fraction * aperture.width / 2, bound * aperture.height / 2, 0],
viewport,
);
drawProjectedLine(
context,
[-bound * aperture.width / 2, fraction * aperture.height / 2, 0],
[bound * aperture.width / 2, fraction * aperture.height / 2, 0],
viewport,
);
} else {
drawProjectedLine(
context,
[fraction * aperture.width / 2, -aperture.height / 2, 0],
[fraction * aperture.width / 2, aperture.height / 2, 0],
viewport,
);
drawProjectedLine(
context,
[-aperture.width / 2, fraction * aperture.height / 2, 0],
[aperture.width / 2, fraction * aperture.height / 2, 0],
viewport,
);
}
}
}
function drawAxis3d(context, viewport, endpoint, color, label) {
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 2;
const point = drawProjectedLine(context, [0, 0, 0], endpoint, viewport);
context.beginPath();
context.arc(point.x, point.y, 3, 0, Math.PI * 2);
context.fill();
context.font = "800 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textAlign = "left";
context.textBaseline = "middle";
context.fillText(label, point.x + 6, point.y);
}
function drawAxes3d(context, model, viewport) {
const aperture = apertureDimensions(model.arrayDefinition);
drawAxis3d(context, viewport, [aperture.width * 0.58, 0, 0], "#ff9e75", "+X");
drawAxis3d(context, viewport, [0, aperture.height * 0.58, 0], "#76b9ff", "+Y");
drawAxis3d(
context,
viewport,
[0, 0, viewport.referenceSpan * 0.72],
"#65f2bf",
"+Z FRONT",
);
}
function projectedMicrophones(model, viewport) {
return (model.arrayDefinition.microphones ?? [])
.filter((microphone) => validPosition(microphone?.position))
.map((microphone, index) => ({
microphone,
index,
point: projectArrayPosition3d(microphone.position, viewport),
}))
.sort((left, right) => left.point.depth - right.point.depth);
}
function drawMicrophones3d(context, model, viewport) {
const microphones = projectedMicrophones(model, viewport);
const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7);
let selected = null;
for (const item of microphones) {
const { microphone, point } = item;
const isSelected = microphone.id === model.selectedMicrophoneId;
const inside = positionInsideAperture(
microphone.position,
model.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 (isSelected) selected = { microphone, 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();
drawSelectedLabel(context, selected.microphone, selected.point, viewport);
}
return microphones;
}
function drawArray3d(context, size, model, zoom, orbit, viewDirection) {
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 = fitArrayViewport3d(size.width, size.height, model, zoom, orbit);
context.fillStyle = PLOT_BACKGROUND;
context.fillRect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.save();
context.beginPath();
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.clip();
drawArrayPlane3d(context, model, viewport);
drawAxes3d(context, model, viewport);
viewport.projectedMicrophones = drawMicrophones3d(context, model, viewport);
context.restore();
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.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textBaseline = "top";
context.fillStyle = "rgba(205,225,218,.72)";
context.textAlign = "left";
context.fillText(
viewDirection === "custom"
? "3D INSPECT - FREE ORBIT"
: "3D INSPECT - VIEW FROM " + viewDirection.toUpperCase(),
viewport.plot.x,
8,
);
context.textAlign = "right";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
return viewport;
}
function hitTestProjectedMicrophone(point, model, viewport, radiusPx = 12) {
let best = null;
for (const item of viewport?.projectedMicrophones ?? projectedMicrophones(model, viewport)) {
const distancePx = Math.hypot(item.point.x - point.x, item.point.y - point.y);
const isCloser = !best || distancePx < best.distancePx - 1e-9;
const isNearerTie = best &&
Math.abs(distancePx - best.distancePx) <= 1e-9 &&
item.point.depth > best.point.depth;
if (distancePx <= radiusPx && (isCloser || isNearerTie)) {
best = { ...item, id: item.microphone.id, distancePx };
}
}
return best;
}
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),
* onViewDirectionChange(direction), 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 viewMode = "2d";
let viewDirection = "iso";
let orbit = orbitForViewDirection(viewDirection);
let orbitDrag = null;
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);
canvas.dataset.viewMode = viewMode;
canvas.dataset.viewAxis = viewDirection;
canvas.dataset.orbiting = String(Boolean(orbitDrag));
}
if (canvas.style) {
canvas.style.cursor = viewMode === "3d"
? orbitDrag ? "grabbing" : "grab"
: tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
}
}
function drawCurrent() {
frameId = 0;
if (destroyed) return;
const size = prepareContext(canvas, context, view);
viewport = viewMode === "3d"
? drawArray3d(context, size, model, zoom, orbit, viewDirection)
: 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 = viewMode === "3d"
? fitArrayViewport3d(rect.width, rect.height, model, zoom, orbit)
: fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
return viewport;
}
function render(nextModel) {
if (destroyed) return;
model = nextModel;
const nextViewMode = nextModel?.viewMode === "3d" ? "3d" : "2d";
if (viewMode !== nextViewMode) {
viewMode = nextViewMode;
drag = null;
pendingAdd = null;
orbitDrag = null;
}
tool = normalizedTool(nextModel?.tool ?? tool);
if (viewMode === "3d") tool = "select";
viewport = null;
schedule();
}
function setTool(nextTool) {
const resolved = viewMode === "3d" ? "select" : normalizedTool(nextTool);
if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null;
tool = resolved;
updateCanvasAffordance();
callbacks.onToolChange?.(tool);
schedule();
return tool;
}
function updateViewDirection(nextDirection) {
if (viewDirection === nextDirection) return;
viewDirection = nextDirection;
updateCanvasAffordance();
callbacks.onViewDirectionChange?.(viewDirection);
}
function setViewDirection(nextDirection) {
orbit = orbitForViewDirection(nextDirection);
updateViewDirection(nextDirection);
viewport = null;
schedule();
return viewDirection;
}
function markFreeOrbit() {
updateViewDirection("custom");
}
function fit() {
zoom = 1;
if (viewMode === "3d") {
orbit = orbitForViewDirection("iso");
updateViewDirection("iso");
}
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 finishOrbit(event, selectOnClick = false) {
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
return false;
}
const completed = orbitDrag;
const pointerId = event?.pointerId ?? completed.pointerId;
orbitDrag = null;
try {
if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId);
} catch {
// Capture can be released by the browser before cancellation arrives.
}
if (selectOnClick && !completed.moved) {
callbacks.onSelect?.(completed.hitId);
}
updateCanvasAffordance();
schedule();
return true;
}
function onTouchStart(event) {
if (destroyed || !model?.arrayDefinition) return;
if (viewMode === "3d") {
event.preventDefault();
return;
}
if (tool !== "select") 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 || orbitDrag) return;
focus();
if (viewMode === "3d") {
event.preventDefault();
const currentViewport = ensureViewport();
if (!currentViewport) return;
const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport);
const startClient = clientPoint(event);
orbitDrag = {
pointerId: event.pointerId,
hitId: hit?.id ?? null,
startClient,
lastClient: startClient,
moved: false,
};
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic pointers and interrupted input do not always own capture.
}
updateCanvasAffordance();
schedule();
return;
}
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 (orbitDrag?.pointerId === event.pointerId) {
const current = clientPoint(event);
if (!orbitDrag.moved) {
const distance = Math.hypot(
current.x - orbitDrag.startClient.x,
current.y - orbitDrag.startClient.y,
);
if (distance <= TOUCH_GESTURE_THRESHOLD_PX) return;
orbitDrag.moved = true;
}
const dx = current.x - orbitDrag.lastClient.x;
const dy = current.y - orbitDrag.lastClient.y;
orbitDrag.lastClient = current;
const nextYaw = orbit.yaw + dx * 0.01;
const nextPitch = clamp(
orbit.pitch - dy * 0.01,
-MAX_ORBIT_PITCH,
MAX_ORBIT_PITCH,
);
if (nextYaw !== orbit.yaw || nextPitch !== orbit.pitch) markFreeOrbit();
orbit.yaw = nextYaw;
orbit.pitch = nextPitch;
viewport = null;
event.preventDefault();
schedule();
return;
}
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;
if (viewMode === "3d") {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (key === "f" || key === "r") {
event.preventDefault();
fit();
callbacks.onFit?.();
return;
}
if (event.key === "Escape") {
event.preventDefault();
finishOrbit();
return;
}
const orbitStep = event.shiftKey ? 0.18 : 0.06;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
markFreeOrbit();
orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep;
viewport = null;
schedule();
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
const nextPitch = clamp(
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
-MAX_ORBIT_PITCH,
MAX_ORBIT_PITCH,
);
if (nextPitch !== orbit.pitch) markFreeOrbit();
orbit.pitch = nextPitch;
viewport = null;
schedule();
}
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 (finishOrbit(event, true)) return;
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
function onPointerCancel(event) {
if (finishOrbit(event)) return;
if (finishPendingAdd(event, true)) return;
finishDrag(event, true);
}
function onLostPointerCapture(event) {
if (orbitDrag?.pointerId === event.pointerId) {
finishOrbit(event);
return;
}
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,
setViewDirection,
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;
orbitDrag = null;
drag = null;
pendingAdd = null;
},
};
}