fix: harden array builder editing workflows

This commit is contained in:
2026-08-28 23:10:41 +09:00
parent 3714146df9
commit 122b6cf7be
14 changed files with 1148 additions and 165 deletions
+110 -12
View File
@@ -5,6 +5,7 @@ const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
@@ -97,6 +98,26 @@ export function fitArrayViewport(width, height, arrayDefinition, options = {}) {
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,
@@ -168,6 +189,7 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
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 };
@@ -359,13 +381,14 @@ function drawMicrophones(context, model, viewport, drag) {
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 : 6;
context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
@@ -449,10 +472,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let zoom = 1;
let tool = "select";
let drag = null;
let pendingAdd = null;
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
if (canvas.style) canvas.style.touchAction = "none";
function updateCanvasAffordance() {
if (canvas.dataset) {
canvas.dataset.tool = tool;
@@ -492,6 +514,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function setTool(nextTool) {
const resolved = normalizedTool(nextTool);
if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null;
tool = resolved;
updateCanvasAffordance();
callbacks.onToolChange?.(tool);
@@ -550,14 +573,48 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return eventToCanvasPoint(event, canvas.getBoundingClientRect());
}
function clientPoint(event) {
return {
x: finiteNumber(event?.clientX, 0),
y: finiteNumber(event?.clientY, 0),
};
}
function exceedsTouchThreshold(start, event) {
const current = clientPoint(event);
return Math.hypot(current.x - start.x, current.y - start.y) >
TOUCH_GESTURE_THRESHOLD_PX;
}
function onTouchStart(event) {
if (destroyed || tool !== "select" || !model?.arrayDefinition) return;
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
const currentViewport = ensureViewport();
if (!touch || !currentViewport) return;
const hit = hitTestMicrophone(
pointerPoint(touch),
model.arrayDefinition.microphones,
currentViewport,
);
if (hit) event.preventDefault();
}
function onPointerDown(event) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
if (drag || pendingAdd) return;
focus();
if (tool === "add") {
const position = constrainedEventPosition(event, 0);
if (position) {
event.preventDefault();
callbacks.onAdd?.(position);
if (event.pointerType === "touch") {
pendingAdd = {
pointerId: event.pointerId,
startClient: clientPoint(event),
};
} else {
event.preventDefault();
callbacks.onAdd?.(position);
}
}
return;
}
@@ -577,15 +634,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
id: hit.id,
start: [...hit.microphone.position],
last: [...hit.microphone.position],
startClient: clientPoint(event),
pointerType: event.pointerType,
pending: event.pointerType === "touch",
};
canvas.setPointerCapture?.(event.pointerId);
callbacks.onDragStart?.(hit.id);
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic pointers and interrupted input do not always own capture.
}
if (!drag.pending) callbacks.onDragStart?.(hit.id);
updateCanvasAffordance();
schedule();
}
function onPointerMove(event) {
if (pendingAdd?.pointerId === event.pointerId) {
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
return;
}
if (!drag || event.pointerId !== drag.pointerId) return;
if (drag.pending) {
if (!exceedsTouchThreshold(drag.startClient, event)) return;
drag.pending = false;
callbacks.onDragStart?.(drag.id);
}
const microphone = microphoneById(drag.id);
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
if (!position) return;
@@ -599,17 +672,37 @@ export function createArrayCanvas(canvas, callbacks = {}) {
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]);
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;
if (canvas.hasPointerCapture?.(pointerId)) {
canvas.releasePointerCapture?.(pointerId);
try {
if (canvas.hasPointerCapture?.(pointerId)) {
canvas.releasePointerCapture?.(pointerId);
}
} catch {
// The browser may have released capture before pointercancel/lostcapture.
}
updateCanvasAffordance();
schedule();
}
function finishPendingAdd(event, cancel = false) {
if (!pendingAdd || event?.pointerId !== pendingAdd.pointerId) return false;
const completed = pendingAdd;
pendingAdd = null;
if (!cancel && tool === "add" && !exceedsTouchThreshold(completed.startClient, event)) {
const position = constrainedEventPosition(event, 0);
if (position) {
event.preventDefault();
callbacks.onAdd?.(position);
}
}
return true;
}
function onKeyDown(event) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
@@ -649,10 +742,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerUp(event) {
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
function onPointerCancel(event) {
if (finishPendingAdd(event, true)) return;
finishDrag(event, true);
}
@@ -665,6 +760,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
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;
@@ -698,10 +794,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerCancel);
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
canvas.removeEventListener("touchstart", onTouchStart);
canvas.removeEventListener("keydown", onKeyDown);
model = null;
viewport = null;
drag = null;
pendingAdd = null;
},
};
}