- New self-contained HopVizModal.vue (Teleport to body): 560px balanced panel, glowing endpoint medallions ringed with EQ segments (ScreensaverRing motif), per-transport accent track with staggered relay markers and an animated packet traveling sender → recipient - Vertical stacked chain below 560px (sender top → recipient bottom, packet travels downward); prefers-reduced-motion disables all loops - Accent colors match the chat transport pills exactly (meshtastic mint, meshcore orange, reticulum blue, lora amber, fips violet, tor indigo) - Tor (3 anonymous relays), FIPS (direct P2P) and unknown-transport shapes preserved, as are SNR/RSSI + E2E/delivery metadata (now glass chips) - Old inline modal markup removed from Mesh.vue; .mesh-hopviz-* rules removed from mesh-styles.css (shared transport-modal classes untouched) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
439 lines
13 KiB
Vue
439 lines
13 KiB
Vue
<template>
|
|
<!-- Hop-route visualization modal: click a message's transport pill to see
|
|
how it traveled. Teleported to body (full-screen backdrop, project rule).
|
|
Branded redesign: glowing endpoint medallions ringed with EQ segments
|
|
(ScreensaverRing motif), accent-colored track with staggered relay
|
|
markers and an animated packet traveling sender → recipient. Vertical
|
|
stacked layout below 560px; prefers-reduced-motion disables all loops. -->
|
|
<Teleport to="body">
|
|
<div class="mesh-transport-modal-backdrop" @click.self="emit('close')">
|
|
<div class="glass-card hopviz-panel" :style="panelStyle">
|
|
<h3 class="hopviz-title">{{ transportLabel || 'Message' }} route</h3>
|
|
<p class="hopviz-sub">{{ senderName }} → {{ recipientName }}</p>
|
|
|
|
<div class="hopviz-chain">
|
|
<div class="hopviz-endpoint">
|
|
<div class="hopviz-medallion">
|
|
<span v-for="i in RING_SEGMENTS" :key="i" class="hopviz-seg" :style="ringSegStyle(i)" />
|
|
<span class="hopviz-glyph">🏝️</span>
|
|
</div>
|
|
<span class="hopviz-name">{{ senderName }}</span>
|
|
</div>
|
|
|
|
<div class="hopviz-path">
|
|
<div class="hopviz-track" :class="{ 'hopviz-track-unknown': !msg.transport }">
|
|
<div class="hopviz-track-line"></div>
|
|
<span
|
|
v-for="i in relayCount"
|
|
:key="i"
|
|
class="hopviz-relay"
|
|
:style="relayStyle(i)"
|
|
title="relay node"
|
|
>
|
|
<template v-if="msg.transport === 'tor'">🧅</template>
|
|
<template v-else>
|
|
<i v-for="b in 3" :key="b" class="hopviz-relay-bar" :style="{ '--bar-i': b }" />
|
|
</template>
|
|
</span>
|
|
<div class="hopviz-packet"></div>
|
|
</div>
|
|
<div class="hopviz-track-label">{{ routeLabel }}</div>
|
|
</div>
|
|
|
|
<div class="hopviz-endpoint">
|
|
<div class="hopviz-medallion">
|
|
<span v-for="i in RING_SEGMENTS" :key="i" class="hopviz-seg" :style="ringSegStyle(i)" />
|
|
<span class="hopviz-glyph">🏝️</span>
|
|
</div>
|
|
<span class="hopviz-name">{{ recipientName }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showSignal" class="hopviz-meta-row">
|
|
<span class="hopviz-chip hopviz-chip-strong">{{ signalLabel }}</span>
|
|
<span v-if="peer?.snr != null" class="hopviz-chip">SNR {{ peer.snr.toFixed(1) }} dB</span>
|
|
<span v-if="peer?.rssi != null" class="hopviz-chip">RSSI {{ peer.rssi }} dBm</span>
|
|
<p class="hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
|
|
</div>
|
|
<div class="hopviz-meta-row">
|
|
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
|
<span class="hopviz-chip">{{ deliveryLabel }}</span>
|
|
<span class="hopviz-chip">{{ timeLabel }}</span>
|
|
</div>
|
|
|
|
<button class="mesh-transport-cancel" @click="emit('close')">Close</button>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { MeshMessage, MeshPeer } from '@/stores/mesh'
|
|
|
|
const props = defineProps<{
|
|
msg: MeshMessage
|
|
peer: MeshPeer | null
|
|
/** Pre-computed by Mesh.vue's transportLabel() — don't duplicate the logic. */
|
|
transportLabel: string | null
|
|
/** Pre-computed by Mesh.vue's signalQualityLabel(). */
|
|
signalLabel: string
|
|
/** Pre-computed by Mesh.vue's timeAgo(). */
|
|
timeLabel: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{ close: [] }>()
|
|
|
|
/** EQ segments around each endpoint medallion (compact ScreensaverRing echo). */
|
|
const RING_SEGMENTS = 14
|
|
|
|
/** Accent per transport — MUST match the chat transport pill colors in
|
|
* mesh-styles.css (.mesh-chat-transport.transport-*). */
|
|
const TRANSPORT_ACCENTS: Record<string, string> = {
|
|
meshtastic: '#3eb489', // mint
|
|
meshcore: '#fb923c', // orange
|
|
reticulum: '#60a5fa', // blue
|
|
lora: '#f59e0b', // amber
|
|
fips: '#a78bfa', // violet
|
|
tor: '#818cf8', // indigo
|
|
}
|
|
|
|
function hexToRgba(hex: string, alpha: number): string {
|
|
const r = parseInt(hex.slice(1, 3), 16)
|
|
const g = parseInt(hex.slice(3, 5), 16)
|
|
const b = parseInt(hex.slice(5, 7), 16)
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
}
|
|
|
|
const accent = computed(() => TRANSPORT_ACCENTS[props.msg.transport ?? ''] ?? '#fb923c')
|
|
|
|
const panelStyle = computed(() => ({
|
|
'--hop-accent': accent.value,
|
|
'--hop-accent-soft': hexToRgba(accent.value, 0.35),
|
|
'--hop-accent-faint': hexToRgba(accent.value, 0.14),
|
|
}))
|
|
|
|
const peerName = computed(() => props.peer?.advert_name || props.msg.peer_name || 'Peer')
|
|
const senderName = computed(() => (props.msg.direction === 'sent' ? 'You' : peerName.value))
|
|
const recipientName = computed(() => (props.msg.direction === 'sent' ? peerName.value : 'You'))
|
|
|
|
/** LoRa transports only know a hop COUNT (peer.hops; 0 or 0xff/null = direct). */
|
|
const hops = computed<number | null>(() => {
|
|
const p = props.peer
|
|
if (!p || p.hops == null || p.hops === 0xff) return null
|
|
return p.hops
|
|
})
|
|
|
|
const showSignal = computed(() => props.msg.transport !== 'tor' && props.msg.transport !== 'fips')
|
|
|
|
const relayCount = computed(() => {
|
|
const t = props.msg.transport
|
|
if (t === 'tor') return 3 // fixed circuit shape, anonymous
|
|
if (t === 'fips' || !t) return 0 // direct P2P / not recorded
|
|
return hops.value ? Math.min(hops.value, 6) : 0
|
|
})
|
|
|
|
const routeLabel = computed(() => {
|
|
const t = props.msg.transport
|
|
if (t === 'tor') return '🧅 3 anonymous relays'
|
|
if (t === 'fips') return '⚡ FIPS overlay · direct peer-to-peer'
|
|
if (!t) return "🛰 transport wasn't recorded for this message"
|
|
const h = hops.value
|
|
return `📡 ${h === null || h === 0 ? 'direct radio link' : `${h} hop${h === 1 ? '' : 's'}`}`
|
|
})
|
|
|
|
const deliveryLabel = computed(() =>
|
|
props.msg.delivered && props.msg.direction === 'sent'
|
|
? 'delivered ✓✓'
|
|
: props.msg.direction === 'sent'
|
|
? 'sent'
|
|
: 'received',
|
|
)
|
|
|
|
function ringSegStyle(i: number) {
|
|
return {
|
|
'--seg-deg': `${((i - 1) / RING_SEGMENTS) * 360}deg`,
|
|
'--seg-i': String(i - 1),
|
|
}
|
|
}
|
|
|
|
function relayStyle(i: number) {
|
|
return {
|
|
'--relay-pos': `${Math.round((i / (relayCount.value + 1)) * 100)}%`,
|
|
'--relay-i': String(i - 1),
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hopviz-panel {
|
|
width: min(560px, 94vw);
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
padding: 26px 28px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.hopviz-title {
|
|
margin: 0;
|
|
font-family: 'Montserrat', sans-serif;
|
|
font-weight: 700;
|
|
font-size: 1.15rem;
|
|
letter-spacing: 0.02em;
|
|
color: var(--hop-accent);
|
|
text-shadow: 0 0 18px var(--hop-accent-faint);
|
|
}
|
|
.hopviz-sub {
|
|
margin: 0;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
font-size: 0.85rem;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
/* ── Chain: sender medallion → track → recipient medallion ─────────────── */
|
|
.hopviz-chain {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 18px 4px 10px;
|
|
}
|
|
/* Staggered route reveal: sender, then track+relays, then recipient. */
|
|
.hopviz-chain > * {
|
|
opacity: 0;
|
|
animation: hopviz-appear 0.4s ease forwards;
|
|
}
|
|
.hopviz-chain > *:nth-child(1) { animation-delay: 0.05s; }
|
|
.hopviz-chain > *:nth-child(2) { animation-delay: 0.35s; }
|
|
.hopviz-chain > *:nth-child(3) { animation-delay: 0.65s; }
|
|
@keyframes hopviz-appear {
|
|
from { opacity: 0; transform: translateY(8px); }
|
|
to { opacity: 1; transform: none; }
|
|
}
|
|
|
|
.hopviz-endpoint {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 84px;
|
|
}
|
|
|
|
/* Endpoint medallion: island glyph inside a compact EQ-segment ring
|
|
(ScreensaverRing technique: rotate + translateY(-radius) + scaleY pulse). */
|
|
.hopviz-medallion {
|
|
position: relative;
|
|
width: 72px;
|
|
height: 72px;
|
|
--ring-radius: 30px;
|
|
}
|
|
.hopviz-glyph {
|
|
position: absolute;
|
|
inset: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.6rem;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle, var(--hop-accent-faint) 0%, rgba(255, 255, 255, 0.03) 70%);
|
|
border: 1px solid var(--hop-accent-faint);
|
|
box-shadow: 0 0 26px var(--hop-accent-soft);
|
|
}
|
|
.hopviz-seg {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 50%;
|
|
width: 3px;
|
|
height: 11px;
|
|
margin-left: -1.5px;
|
|
margin-top: -5.5px;
|
|
border-radius: 2px;
|
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.85), var(--hop-accent-soft));
|
|
transform-origin: center center;
|
|
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius)));
|
|
animation: hopviz-seg-pulse 2.6s ease-in-out infinite;
|
|
animation-delay: calc(var(--seg-i) * 0.12s);
|
|
}
|
|
@keyframes hopviz-seg-pulse {
|
|
0%, 100% {
|
|
opacity: 0.35;
|
|
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius))) scaleY(0.5);
|
|
}
|
|
50% {
|
|
opacity: 0.95;
|
|
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius))) scaleY(1);
|
|
}
|
|
}
|
|
|
|
.hopviz-name {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
max-width: 110px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Path: accent track + relay markers + traveling packet + label ─────── */
|
|
.hopviz-path {
|
|
flex: 1 1 auto;
|
|
min-width: 140px;
|
|
align-self: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.hopviz-track {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 44px;
|
|
}
|
|
.hopviz-track-line {
|
|
position: absolute;
|
|
left: 6px;
|
|
right: 6px;
|
|
top: 50%;
|
|
height: 2px;
|
|
transform: translateY(-50%);
|
|
background: linear-gradient(to right, transparent, var(--hop-accent) 12%, var(--hop-accent) 88%, transparent);
|
|
opacity: 0.75;
|
|
box-shadow: 0 0 8px var(--hop-accent-soft);
|
|
border-radius: 1px;
|
|
}
|
|
.hopviz-track-unknown .hopviz-track-line { opacity: 0.3; box-shadow: none; }
|
|
|
|
/* Relay markers sit ON the track: tiny EQ clusters (🧅 for Tor's circuit). */
|
|
.hopviz-relay {
|
|
position: absolute;
|
|
left: var(--relay-pos);
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
font-size: 0.85rem;
|
|
line-height: 1;
|
|
filter: drop-shadow(0 0 6px var(--hop-accent-soft));
|
|
}
|
|
.hopviz-relay-bar {
|
|
display: block;
|
|
width: 3px;
|
|
height: 12px;
|
|
border-radius: 2px;
|
|
background: linear-gradient(to bottom, #fff, var(--hop-accent));
|
|
transform-origin: center;
|
|
animation: hopviz-bar-pulse 1.8s ease-in-out infinite;
|
|
animation-delay: calc((var(--relay-i, 0) * 0.3s) + (var(--bar-i, 0) * 0.15s));
|
|
}
|
|
.hopviz-relay-bar:nth-child(2) { height: 16px; }
|
|
@keyframes hopviz-bar-pulse {
|
|
0%, 100% { transform: scaleY(0.45); opacity: 0.5; }
|
|
50% { transform: scaleY(1); opacity: 1; }
|
|
}
|
|
|
|
/* The packet: a bright glowing dot traveling sender → recipient. */
|
|
.hopviz-packet {
|
|
position: absolute;
|
|
left: 0%;
|
|
top: 50%;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: #fff;
|
|
box-shadow:
|
|
0 0 6px 1px #fff,
|
|
0 0 14px 4px var(--hop-accent),
|
|
0 0 26px 8px var(--hop-accent-soft);
|
|
opacity: 0;
|
|
animation: hopviz-packet-x 2.2s ease-in-out infinite;
|
|
animation-delay: 0.9s;
|
|
}
|
|
@keyframes hopviz-packet-x {
|
|
0% { left: 0%; opacity: 0; }
|
|
10% { opacity: 1; }
|
|
88% { opacity: 1; }
|
|
100% { left: 100%; opacity: 0; }
|
|
}
|
|
@keyframes hopviz-packet-y {
|
|
0% { top: 0%; opacity: 0; }
|
|
10% { opacity: 1; }
|
|
88% { opacity: 1; }
|
|
100% { top: 100%; opacity: 0; }
|
|
}
|
|
|
|
.hopviz-track-label {
|
|
text-align: center;
|
|
font-size: 0.8rem;
|
|
color: rgba(255, 255, 255, 0.75);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
/* ── Metadata footer: glass chips ──────────────────────────────────────── */
|
|
.hopviz-meta-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.hopviz-chip {
|
|
font-size: 0.75rem;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 999px;
|
|
padding: 3px 10px;
|
|
}
|
|
.hopviz-chip-strong {
|
|
color: #fff;
|
|
border-color: var(--hop-accent-soft);
|
|
background: var(--hop-accent-faint);
|
|
}
|
|
.hopviz-note {
|
|
flex-basis: 100%;
|
|
text-align: center;
|
|
font-size: 0.68rem;
|
|
color: rgba(255, 255, 255, 0.35);
|
|
margin: 2px 0 0;
|
|
}
|
|
|
|
/* ── Mobile: vertical chain, sender top → recipient bottom ─────────────── */
|
|
@media (max-width: 560px) {
|
|
.hopviz-panel { width: 94vw; padding: 22px 18px; }
|
|
.hopviz-chain { flex-direction: column; gap: 8px; padding: 14px 0 8px; }
|
|
.hopviz-medallion { width: 56px; height: 56px; --ring-radius: 23px; }
|
|
.hopviz-glyph { inset: 9px; font-size: 1.3rem; }
|
|
.hopviz-endpoint { min-width: 0; }
|
|
.hopviz-name { max-width: 80vw; }
|
|
.hopviz-path { width: 100%; min-width: 0; }
|
|
.hopviz-track { height: 110px; }
|
|
.hopviz-track-line {
|
|
left: 50%;
|
|
right: auto;
|
|
top: 6px;
|
|
bottom: 6px;
|
|
width: 2px;
|
|
height: auto;
|
|
transform: translateX(-50%);
|
|
background: linear-gradient(to bottom, transparent, var(--hop-accent) 12%, var(--hop-accent) 88%, transparent);
|
|
}
|
|
.hopviz-relay { left: 50%; top: var(--relay-pos); }
|
|
.hopviz-packet {
|
|
left: 50%;
|
|
top: 0%;
|
|
animation-name: hopviz-packet-y;
|
|
}
|
|
}
|
|
|
|
/* ── Reduced motion: static layout, no loops, no entrance ──────────────── */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.hopviz-chain > * { animation: none; opacity: 1; }
|
|
.hopviz-seg { animation: none; opacity: 0.7; }
|
|
.hopviz-relay-bar { animation: none; }
|
|
.hopviz-packet { display: none; }
|
|
}
|
|
</style>
|