feat(ui): add interactive 3D LoRa devices and OpenWrt router
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { createDeviceScene } from '@/graphics/deviceScene'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
model: string
|
||||
label: string
|
||||
fallback?: string
|
||||
fillViewport?: boolean
|
||||
}>(), { fallback: '', fillViewport: false })
|
||||
const host = ref<HTMLElement>()
|
||||
const frame = ref<HTMLElement>()
|
||||
const ready = ref(false)
|
||||
const failed = ref(false)
|
||||
const paused = ref(false)
|
||||
const reducedMotion = ref(false)
|
||||
const dragging = ref(false)
|
||||
const remainingHeight = ref('360px')
|
||||
let view: ReturnType<typeof createDeviceScene> | undefined
|
||||
let observer: IntersectionObserver | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let motion: MediaQueryList | undefined
|
||||
let visible = false
|
||||
let mounted = false
|
||||
let generation = 0
|
||||
let raf = 0
|
||||
let previous = 0
|
||||
let lastDraw = 0
|
||||
let pointer: number | undefined
|
||||
let lastX = 0
|
||||
let lastY = 0
|
||||
|
||||
function stop() { cancelAnimationFrame(raf); raf = 0; previous = 0; lastDraw = 0 }
|
||||
function canSpin() { return view && visible && !document.hidden && !paused.value && !motion?.matches && !dragging.value }
|
||||
function animate(now: number) {
|
||||
raf = 0
|
||||
if (!canSpin()) { previous = 0; return }
|
||||
// Cap to 30fps; complete a turn in about a minute, independent of refresh rate.
|
||||
if (now - lastDraw >= 32) {
|
||||
view!.rotation.y += previous ? Math.min((now - previous) / 1000, .1) * .105 : 0
|
||||
previous = now; lastDraw = now; view!.render()
|
||||
}
|
||||
raf = requestAnimationFrame(animate)
|
||||
}
|
||||
function sync() { reducedMotion.value = !!motion?.matches; stop(); if (canSpin()) raf = requestAnimationFrame(animate) }
|
||||
function layout() {
|
||||
if (props.fillViewport && frame.value) {
|
||||
// Use the layout position, not its changing scrolled screen position.
|
||||
let scroll = 0
|
||||
for (let el = frame.value.parentElement; el; el = el.parentElement) scroll += el.scrollTop
|
||||
const top = frame.value.getBoundingClientRect().top + scroll
|
||||
remainingHeight.value = `${Math.max(260, window.innerHeight - top - 24)}px`
|
||||
}
|
||||
view?.resize()
|
||||
}
|
||||
function disposeScene() {
|
||||
stop(); ready.value = false
|
||||
view?.canvas.removeEventListener('webglcontextlost', contextLost)
|
||||
view?.dispose(); view = undefined
|
||||
}
|
||||
function contextLost(event: Event) {
|
||||
event.preventDefault(); failed.value = true; disposeScene()
|
||||
}
|
||||
async function init() {
|
||||
if (!mounted || !visible || view || failed.value) return
|
||||
const token = ++generation
|
||||
try {
|
||||
const { createDeviceScene } = await import('@/graphics/deviceScene')
|
||||
if (!mounted || token !== generation || !host.value) return
|
||||
view = createDeviceScene(host.value, props.model)
|
||||
view.canvas.addEventListener('webglcontextlost', contextLost)
|
||||
ready.value = true; layout(); sync()
|
||||
} catch {
|
||||
if (token === generation) { failed.value = true; disposeScene() }
|
||||
}
|
||||
}
|
||||
function down(event: PointerEvent) {
|
||||
if (!view || (event.pointerType === 'mouse' && event.button !== 0) || pointer !== undefined) return
|
||||
pointer = event.pointerId; lastX = event.clientX; lastY = event.clientY
|
||||
dragging.value = true; stop(); host.value?.setPointerCapture(pointer)
|
||||
}
|
||||
function move(event: PointerEvent) {
|
||||
if (pointer !== event.pointerId || !view) return
|
||||
view.rotation.y += (event.clientX - lastX) * .012
|
||||
// Touch preserves vertical page scrolling; mouse can also tilt the model.
|
||||
if (event.pointerType === 'mouse') view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.clientY - lastY) * .008))
|
||||
lastX = event.clientX; lastY = event.clientY; view.render()
|
||||
}
|
||||
function up() { pointer = undefined; dragging.value = false; sync() }
|
||||
function key(event: KeyboardEvent) {
|
||||
if (!view || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault(); paused.value = true; stop()
|
||||
if (event.key === 'Home') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, -.4, 0)
|
||||
else if (event.key === 'End') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, Math.PI - .4, 0)
|
||||
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') view.rotation.y += event.key === 'ArrowLeft' ? -.2 : .2
|
||||
else view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.key === 'ArrowUp' ? -.12 : .12)))
|
||||
view.render()
|
||||
}
|
||||
watch(paused, sync)
|
||||
watch(() => props.model, () => {
|
||||
generation++; up()
|
||||
if (view) {
|
||||
try { view.setModel(props.model) } catch { failed.value = true; disposeScene() }
|
||||
} else { failed.value = false; void init() }
|
||||
})
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
if (!window.matchMedia || !window.IntersectionObserver || !window.ResizeObserver) {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
motion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
motion.addEventListener('change', sync)
|
||||
document.addEventListener('visibilitychange', sync)
|
||||
window.addEventListener('resize', layout)
|
||||
resizeObserver = new ResizeObserver(layout)
|
||||
if (host.value) resizeObserver.observe(host.value)
|
||||
if (frame.value?.parentElement) resizeObserver.observe(frame.value.parentElement)
|
||||
observer = new IntersectionObserver(entries => {
|
||||
visible = entries.some(entry => entry.isIntersecting)
|
||||
if (visible) void init()
|
||||
sync()
|
||||
})
|
||||
if (frame.value) observer.observe(frame.value)
|
||||
layout()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false; generation++; observer?.disconnect(); resizeObserver?.disconnect()
|
||||
motion?.removeEventListener('change', sync)
|
||||
document.removeEventListener('visibilitychange', sync); window.removeEventListener('resize', layout)
|
||||
disposeScene()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure ref="frame" class="device-model" :class="{ 'device-model--viewport': fillViewport }"
|
||||
:style="{ '--device-height': remainingHeight }" :data-model="model" :data-ready="ready">
|
||||
<div ref="host" class="device-model__stage" :class="{ 'is-dragging': dragging }"
|
||||
:tabindex="ready ? 0 : -1" role="group" :aria-label="`${label}. Drag or use arrow keys to rotate. Home shows the front; End shows the back.`"
|
||||
@pointerdown="down" @pointermove="move" @pointerup="up" @pointercancel="up" @lostpointercapture="up" @keydown="key">
|
||||
<img v-if="!ready && fallback" :src="fallback" :alt="label" class="device-model__fallback" />
|
||||
<div v-else-if="!ready" class="device-model__placeholder" role="img" :aria-label="label">
|
||||
<svg viewBox="0 0 200 120" aria-hidden="true"><rect x="31" y="59" width="138" height="46" rx="15"/><path d="M38 65V17Q38 9 45 9T52 17V60M148 60V17Q148 9 155 9T162 17V65M92 87h16"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption class="device-model__caption">
|
||||
<span>{{ failed ? label : 'Drag to explore' }}</span>
|
||||
<button v-if="ready && !reducedMotion" type="button" :aria-label="paused ? 'Resume automatic rotation' : 'Pause automatic rotation'"
|
||||
:aria-pressed="paused" @click="paused = !paused">{{ paused ? 'Rotate' : 'Pause' }}</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-model { position: relative; width: 100%; height: 240px; margin: 0; isolation: isolate; }
|
||||
.device-model::before { content: ''; position: absolute; inset: 14% 9%; background: radial-gradient(ellipse, rgba(110,167,180,.09), transparent 68%); pointer-events: none; z-index: -1; }
|
||||
.device-model__stage { position: absolute; inset: 0 0 28px; cursor: grab; touch-action: pan-y; border-radius: 16px; }
|
||||
.device-model__stage.is-dragging { cursor: grabbing; }
|
||||
.device-model__stage:focus-visible { outline: 1px solid rgba(255,255,255,.5); outline-offset: 2px; }
|
||||
.device-model__stage :deep(canvas) { display: block; width: 100%; height: 100%; }
|
||||
.device-model__fallback { width: 100%; height: 100%; object-fit: contain; padding: 22px; }
|
||||
.device-model__placeholder { height: 100%; display: grid; place-items: center; }
|
||||
.device-model__placeholder svg { width: min(65%, 280px); fill: rgba(63,126,146,.2); stroke: #619ba9; stroke-width: 2; }
|
||||
.device-model__caption { position: absolute; inset: auto 0 0; display: flex; justify-content: center; align-items: center; gap: 14px; color: rgba(255,255,255,.4); font-size: 11px; }
|
||||
.device-model__caption button { padding: 4px 9px; color: rgba(255,255,255,.65); border: 1px solid rgba(255,255,255,.12); border-radius: 20px; background: transparent; cursor: pointer; }
|
||||
.device-model--viewport { height: 330px; }
|
||||
@media (min-width: 1024px) { .device-model--viewport { height: var(--device-height); min-height: 260px; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user