feat(ui): add interactive 3D LoRa devices and OpenWrt router

This commit is contained in:
yaya
2026-10-09 13:15:59 +01:00
parent 5812f53c7c
commit 5d64991203
8 changed files with 705 additions and 37 deletions
+168
View File
@@ -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>
@@ -8,25 +8,11 @@
>
<!-- Step 1: detection graphic + what's currently on the radio -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-40 h-40 relative">
<!-- pulsing signal waves behind the board image -->
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
<path d="M130 44 a30 30 0 0 1 0 28" />
<path d="M138 36 a42 42 0 0 1 0 44" />
<path d="M146 28 a54 54 0 0 1 0 60" />
</g>
</svg>
<!-- actual board image (vendored from the Meshtastic web flasher) -->
<img
:src="deviceImage.image"
:alt="deviceImage.label"
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
@error="imageFailed = true"
v-if="!imageFailed"
/>
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
</div>
<DeviceModel
:model="deviceImage.image.split('/').pop()!.replace('.svg', '')"
:label="deviceImage.label"
:fallback="deviceImage.image"
/>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1">
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
@@ -306,6 +292,7 @@ import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type Flas
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
import DeviceModel from '@/components/DeviceModel.vue'
const mesh = useMeshStore()
const appStore = useAppStore()
@@ -344,7 +331,6 @@ function stopProbeProgress(done = false) {
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() =>
resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
@@ -417,7 +403,6 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = ''
probing.value = false
stopProbeProgress()
imageFailed.value = false
openFlashStep()
const t = (mesh.status?.device_type ?? '').toLowerCase()
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
@@ -427,7 +412,6 @@ watch([show, devicePath], async ([visible]) => {
}
step.value = 1
error.value = ''
imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago'
@@ -629,17 +613,3 @@ function closeFlashStep() {
step.value = 1
}
</script>
<style scoped>
.mesh-detect-waves path {
animation: mesh-wave-pulse 2.2s ease-out infinite;
opacity: 0;
}
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
@keyframes mesh-wave-pulse {
0% { opacity: 0; }
25% { opacity: 1; }
100% { opacity: 0; }
}
</style>