Files
momentry_core/portal/src/components/SpaceTimeCube.vue

354 lines
10 KiB
Vue
Raw 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.
<template>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-lg font-semibold mb-4 text-blue-400">V5: 3D Space-Time Cube</h3>
<div class="text-xs text-gray-500 mb-3 flex gap-2 items-center">
<span>X/Y = 畫面位置</span>
<span>Z = 深度bbox 大小</span>
<span>T = 時間</span>
</div>
<!-- Trace selector -->
<div class="flex gap-2 mb-3">
<select v-model="selectedTraceId"
class="bg-gray-700 text-white px-3 py-1.5 rounded text-sm flex-1">
<option :value="null" disabled>選擇 Trace</option>
<option v-for="t in traceOptions" :key="t.id"
:value="t.id">{{ t.label }}</option>
</select>
<button @click="loadData"
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded text-sm"
:disabled="!selectedTraceId || loading">
{{ loading ? '載入中...' : '載入' }}
</button>
</div>
<div ref="container" class="w-full h-[400px] bg-gray-900 rounded-lg overflow-hidden"></div>
<div class="text-xs text-gray-500 mt-2 flex gap-4">
<span>🖱 拖曳旋轉</span>
<span>🔍 滾輪縮放</span>
<span v-if="faceCount">{{ faceCount }} 個檢測點</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { httpFetch, getCurrentConfig } from '@/api/client'
const props = defineProps<{
fileUuid: string
traces?: any[]
frameWidth?: number
frameHeight?: number
}>()
const container = ref<HTMLElement>()
const selectedTraceId = ref<number | null>(null)
const loading = ref(false)
const faceCount = ref(0)
const traceOptions = computed(() => {
return (props.traces || []).map((t: any) => ({
id: t.trace_id,
label: `#${t.trace_id} (${t.face_count} faces, ${(t.duration_sec || 0).toFixed(1)}s)`
}))
})
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let animId: number
let objects: THREE.Object3D[] = []
function disposeScene() {
cancelAnimationFrame(animId)
for (const obj of objects) {
scene?.remove(obj)
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points || obj instanceof THREE.Line) {
obj.geometry?.dispose()
const mat = (obj as any).material
if (mat) {
if (Array.isArray(mat)) mat.forEach((m: any) => m.dispose())
else mat.dispose()
}
}
}
objects = []
controls?.dispose()
controls = null
if (renderer) {
renderer.dispose()
renderer = null
}
scene = null
camera = null
}
type FacePoint = {
frame: number
t: number
x: number
y: number
w: number
h: number
z: number
}
function loadData() {
if (!selectedTraceId.value) return
loading.value = true
const config = getCurrentConfig()
httpFetch(`${config.api_base_url}/api/v1/file/${props.fileUuid}/trace/${selectedTraceId.value}/faces?interpolate=true&limit=2000&dimension=3d`)
.then((res: any) => {
const faces = res?.faces || []
const fw = props.frameWidth || 1920
const fh = props.frameHeight || 1080
const points: FacePoint[] = faces.map((f: any) => {
const w = f.width || 1
const h = f.height || 1
const areaPct = (w * h) / (fw * fh)
const z = f.z_rel !== undefined && f.z_rel !== null
? f.z_rel
: 1.0 - Math.min(areaPct * 50, 1.0)
return {
frame: f.start_frame || 0,
t: f.start_time || 0,
x: f.x || 0,
y: f.y || 0,
w,
h,
z
}
})
faceCount.value = points.length
buildScene(points)
})
.catch((err: any) => {
console.error('Failed to load trace faces:', err)
})
.finally(() => {
loading.value = false
})
}
function buildScene(points: FacePoint[]) {
if (!container.value) return
disposeScene()
// Normalize coordinates to [-1, 1] range
const fw = props.frameWidth || 1920
const fh = props.frameHeight || 1080
const maxT = points.length > 0 ? points[points.length - 1].t : 100
const vertexData = points.map(p => ({
x: (p.x / fw) * 2 - 1,
y: -((p.y / fh) * 2 - 1),
z: p.z * 2 - 1,
t: (p.t / maxT) * 2 - 1
}))
const rect = container.value.getBoundingClientRect()
const w = rect.width || 600, h = rect.height || 400
scene = new THREE.Scene()
scene.background = new THREE.Color(0x111827)
camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 10)
camera.position.set(2.5, 1.8, 3)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(w, h)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
container.value.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.08
controls.target.set(0, 0, 0)
controls.update()
// ---- Axes helper with labels ----
const axesLen = 1.2
const axesMat = (color: number) => new THREE.LineBasicMaterial({ color })
// X axis (red) — screen x
const xLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(axesLen, -axesLen, -axesLen)
]),
axesMat(0xff4444)
)
scene.add(xLine)
objects.push(xLine)
// Y axis (green) — screen y
const yLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(-axesLen, axesLen, -axesLen)
]),
axesMat(0x44ff44)
)
scene.add(yLine)
objects.push(yLine)
// Z axis (blue) — depth
const zLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(-axesLen, -axesLen, axesLen)
]),
axesMat(0x4488ff)
)
scene.add(zLine)
objects.push(zLine)
// T axis (yellow) — time (at an angle for 3D effect)
const tLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(axesLen, axesLen, axesLen)
]),
axesMat(0xffdd44)
)
scene.add(tLine)
objects.push(tLine)
// ---- Cube wireframe ----
const cubeSize = axesLen * 2
const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize)
const cubeWire = new THREE.LineSegments(
new THREE.EdgesGeometry(cubeGeo),
new THREE.LineBasicMaterial({ color: 0x444466, transparent: true, opacity: 0.3 })
)
cubeWire.position.set(0, 0, 0)
scene.add(cubeWire)
objects.push(cubeWire)
// ---- Points: color by time (t) ----
if (vertexData.length > 0) {
const positions = new Float32Array(vertexData.length * 3)
const colors = new Float32Array(vertexData.length * 3)
const color = new THREE.Color()
for (let i = 0; i < vertexData.length; i++) {
const p = vertexData[i]
// Position: (x, y, z) with time as movement along diagonal
positions[i * 3] = p.x
positions[i * 3 + 1] = p.y
positions[i * 3 + 2] = p.t * 0.5 // compress time a bit
// Color gradient: blue (early) → cyan → yellow → red (late)
const tNorm = (p.t + 1) / 2 // 0..1
color.setHSL(0.6 - tNorm * 0.6, 0.9, 0.5)
colors[i * 3] = color.r
colors[i * 3 + 1] = color.g
colors[i * 3 + 2] = color.b
}
const ptGeo = new THREE.BufferGeometry()
ptGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
ptGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
const ptMat = new THREE.PointsMaterial({
size: 0.03,
vertexColors: true,
transparent: true,
opacity: 0.8,
sizeAttenuation: true
})
const pointsObj = new THREE.Points(ptGeo, ptMat)
scene.add(pointsObj)
objects.push(pointsObj)
// ---- Trajectory line ----
const linePositions = new Float32Array(vertexData.length * 3)
for (let i = 0; i < vertexData.length; i++) {
const p = vertexData[i]
linePositions[i * 3] = p.x
linePositions[i * 3 + 1] = p.y
linePositions[i * 3 + 2] = p.t * 0.5
}
const lineGeo = new THREE.BufferGeometry()
lineGeo.setAttribute('position', new THREE.BufferAttribute(linePositions, 3))
const lineMat = new THREE.LineBasicMaterial({
color: 0x88ccff,
transparent: true,
opacity: 0.35
})
const line = new THREE.Line(lineGeo, lineMat)
scene.add(line)
objects.push(line)
}
// ---- Lights ----
const ambient = new THREE.AmbientLight(0x404060)
scene.add(ambient)
const dir = new THREE.DirectionalLight(0xffffff, 0.8)
dir.position.set(1, 2, 1)
scene.add(dir)
// ---- Grid helper (subtle) ----
const gridHelper = new THREE.GridHelper(2.5, 10, 0x444466, 0x333355)
gridHelper.position.y = -axesLen - 0.05
scene.add(gridHelper)
objects.push(gridHelper)
// Resize
const resizeObserver = new ResizeObserver(() => {
if (!container.value || !renderer || !camera) return
const r = container.value.getBoundingClientRect()
const rw = r.width || 600, rh = r.height || 400
renderer.setSize(rw, rh)
camera.aspect = rw / rh
camera.updateProjectionMatrix()
})
resizeObserver.observe(container.value)
;(container.value as any).__resizeObserver = resizeObserver
animate()
// Notify demo runner via callback URL if present
const cb = new URLSearchParams(window.location.search).get("_callback")
if (cb) {
fetch(cb, { mode: "no-cors" }).catch(() => {})
}
}
function animate() {
animId = requestAnimationFrame(animate)
controls?.update()
if (renderer && scene && camera) renderer.render(scene, camera)
}
onMounted(() => {
tryAutoLoad()
})
watch(() => props.traces, () => {
tryAutoLoad()
}, { deep: false })
function tryAutoLoad() {
if (props.traces?.length && !selectedTraceId.value && !loading.value) {
selectedTraceId.value = props.traces[0].trace_id
loadData()
}
}
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
if ((container.value as any)?.__resizeObserver) {
(container.value as any).__resizeObserver.disconnect()
}
disposeScene()
})
</script>