Files
micarrsim/apps/dataset-builder/canvas-view.js
T
2026-08-28 16:04:55 +09:00

191 lines
7.2 KiB
JavaScript

import { SOURCE_COLORS } from "./model.js";
import { sourcePositionAtTime } from "/packages/acoustics-core/trajectory.js";
function finitePlane(plane) {
return [plane.xMin, plane.xMax, plane.yMin, plane.yMax].every(Number.isFinite) &&
plane.xMin < plane.xMax && plane.yMin < plane.yMax;
}
export function coordinateToPixel(position, plane, width, height) {
return {
x: ((position[0] - plane.xMin) / (plane.xMax - plane.xMin)) * width,
y: ((plane.yMax - position[1]) / (plane.yMax - plane.yMin)) * height,
};
}
export function pixelToCoordinate(point, plane, width, height) {
return {
x: plane.xMin + (point.x / width) * (plane.xMax - plane.xMin),
y: plane.yMax - (point.y / height) * (plane.yMax - plane.yMin),
};
}
function drawGrid(context, width, height, plane) {
context.fillStyle = "#0a1512";
context.fillRect(0, 0, width, height);
context.lineWidth = 1;
for (let index = 0; index <= 10; index += 1) {
const x = (index / 10) * width;
const y = (index / 10) * height;
context.strokeStyle = index === 5 ? "rgba(101,242,191,.17)" : "rgba(145,170,162,.08)";
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
context.fillStyle = "rgba(205,225,218,.52)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
context.fillText(`x ${plane.xMin.toFixed(2)} m`, 14, height - 16);
context.textAlign = "right";
context.fillText(`${plane.xMax.toFixed(2)} m`, width - 14, height - 16);
context.textAlign = "left";
}
function drawTrajectory(context, source, index, selected, selectedKeyframeIndex, plane, width, height) {
if (!Array.isArray(source.trajectory) || source.trajectory.length < 2) return;
const points = source.trajectory.map(({ position }) => coordinateToPixel(position, plane, width, height));
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
context.save();
context.strokeStyle = color;
context.globalAlpha = selected ? 0.85 : 0.28;
context.lineWidth = selected ? 2.5 : 1.5;
context.setLineDash([7, 5]);
context.beginPath();
points.forEach((point, pointIndex) => {
if (pointIndex === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.stroke();
context.setLineDash([]);
points.forEach((point, pointIndex) => {
context.globalAlpha = selected ? 1 : 0.42;
context.fillStyle = color;
context.beginPath();
context.arc(point.x, point.y, pointIndex === selectedKeyframeIndex && selected ? 6 : 4, 0, Math.PI * 2);
context.fill();
if (pointIndex === selectedKeyframeIndex && selected) {
context.strokeStyle = "#fff";
context.lineWidth = 2;
context.beginPath();
context.arc(point.x, point.y, 9, 0, Math.PI * 2);
context.stroke();
}
});
context.restore();
}
function positionAtPreviewTime(source, previewTime) {
try {
return sourcePositionAtTime(source, previewTime);
} catch {
return source.position;
}
}
function drawSource(context, source, position, index, selected, plane, width, height) {
if (!position?.every(Number.isFinite)) return;
const point = coordinateToPixel(position, plane, width, height);
if (point.x < -30 || point.x > width + 30 || point.y < -30 || point.y > height + 30) return;
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
context.save();
context.shadowColor = color;
context.shadowBlur = selected ? 22 : 12;
context.fillStyle = color;
context.beginPath();
context.arc(point.x, point.y, selected ? 10 : 8, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
if (selected) {
context.strokeStyle = "rgba(255,255,255,.9)";
context.lineWidth = 2;
context.beginPath();
context.arc(point.x, point.y, 16, 0, Math.PI * 2);
context.stroke();
}
const label = `${index + 1} · ${source.name}`;
context.font = "700 12px Inter, system-ui, sans-serif";
const labelWidth = context.measureText(label).width + 18;
const labelX = Math.max(8, Math.min(width - labelWidth - 8, point.x + 18));
const labelY = Math.max(26, Math.min(height - 10, point.y - 12));
context.fillStyle = "rgba(6,15,12,.88)";
context.fillRect(labelX, labelY - 18, labelWidth, 27);
context.fillStyle = "#eef8f4";
context.fillText(label, labelX + 9, labelY);
context.restore();
}
export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
const context = canvas.getContext("2d");
let current = null;
function render(scene, image, plane, selectedSourceId, previewTime = 0, selectedKeyframeIndex = 0) {
current = { scene, image, plane, selectedSourceId, previewTime, selectedKeyframeIndex };
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const targetWidth = Math.round(width * pixelRatio);
const targetHeight = Math.round(height * pixelRatio);
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
canvas.width = targetWidth;
canvas.height = targetHeight;
}
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
if (!finitePlane(plane)) {
context.fillStyle = "#0a1512";
context.fillRect(0, 0, width, height);
context.fillStyle = "#ff7b73";
context.font = "700 14px Inter, system-ui, sans-serif";
context.fillText("표시 평면 범위를 확인하세요.", 20, 30);
return;
}
drawGrid(context, width, height, plane);
if (image) {
context.globalAlpha = 0.7;
context.drawImage(image, 0, 0, width, height);
context.globalAlpha = 1;
context.fillStyle = "rgba(6,15,12,.12)";
context.fillRect(0, 0, width, height);
}
scene.sources.forEach((source, index) => {
drawTrajectory(
context, source, index, source.id === selectedSourceId,
source.id === selectedSourceId ? selectedKeyframeIndex : -1,
plane, width, height,
);
});
scene.sources.forEach((source, index) => {
drawSource(
context, source, positionAtPreviewTime(source, previewTime), index,
source.id === selectedSourceId, plane, width, height,
);
});
emptyElement.classList.toggle("is-hidden", Boolean(image));
badgeElement.textContent =
`x ${plane.xMin.toFixed(2)}…${plane.xMax.toFixed(2)} m · ` +
`y ${plane.yMin.toFixed(2)}…${plane.yMax.toFixed(2)} m · t ${previewTime.toFixed(2)} s`;
}
canvas.addEventListener("pointerdown", (event) => {
if (!current || !current.selectedSourceId || !finitePlane(current.plane)) return;
const rect = canvas.getBoundingClientRect();
const xRatio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const yRatio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
onMove(pixelToCoordinate({ x: xRatio * rect.width, y: yRatio * rect.height }, current.plane, rect.width, rect.height));
});
new ResizeObserver(() => {
if (current) render(
current.scene, current.image, current.plane, current.selectedSourceId,
current.previewTime, current.selectedKeyframeIndex,
);
}).observe(canvas);
return { render };
}