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 }; }