191 lines
7.2 KiB
JavaScript
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 };
|
|
}
|