feat(federation): 2D/3D projection toggle + interaction fixes + mobile layout polish
- Small glass 2D/3D toggle top-right of the map: tweens tilt/perspective/fit
(0.9s inOut) so the scene folds flat into the original radial 2D layout and
back. Default: portrait/mobile opens 2D, desktop 3D; last choice saved to
localStorage.
- Fix: setPointerCapture retargeted pointerup to the container, suppressing
click synthesis on children — node taps and the toggle never fired. Drag
now tracks via window listeners, no capture.
- Fix: stale post-drag distance made the click-suppressor swallow toggle taps
indefinitely ('stuck' toggle). Distance resets on every pointerdown and the
suppressor is one-shot.
- Map no longer slides under the floating mobile back pill: the
mobile-scroll-pad-back panel keeps its full 64px clearance when filled.
- Mobile DID copy/rotate card moved out of the header to below the view tabs
(new DidCardMobile.vue) and hidden on the Network Map tab.
- Node labels: dark stroke halo removed; 10px on mobile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aea90984d6
commit
e0aebf50f8
@@ -13,6 +13,19 @@
|
||||
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
|
||||
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
|
||||
</div>
|
||||
<!-- 2D/3D projection toggle — tweens the camera between the flat radial
|
||||
layout and the depth view -->
|
||||
<div class="node-map-mode-toggle" role="group" aria-label="Map projection">
|
||||
<button
|
||||
v-for="m in (['2d', '3d'] as const)"
|
||||
:key="m"
|
||||
class="node-map-mode-btn"
|
||||
:class="{ 'node-map-mode-btn-active': viewMode === m }"
|
||||
:aria-pressed="viewMode === m"
|
||||
@click="setMode(m)"
|
||||
>{{ m.toUpperCase() }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="peerCount === 0" class="node-map-empty">
|
||||
<p class="node-map-empty-title">No peers yet</p>
|
||||
<p class="node-map-empty-sub">Invite a peer or discover nodes to grow your federation</p>
|
||||
@@ -62,12 +75,21 @@ const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
* ------------------------------------------------------------------ */
|
||||
const cam = {
|
||||
rotY: -1.1, // intro starts swung around; settles to 0
|
||||
tilt: -0.5, // fixed downward tilt so the orbit reads as a plane
|
||||
tilt: -0.5, // downward tilt; tweened between the 2D and 3D projections
|
||||
persp: 3.2, // perspective strength; large = near-orthographic (flat 2D)
|
||||
zoom: 0.82, // intro dollies in to 1
|
||||
spin: 0, // rad/s — only ever non-zero from drag inertia; the scene does not
|
||||
// idle-orbit (nodes hold position so the map stays readable)
|
||||
}
|
||||
|
||||
/** The two projections the top-right toggle tweens between: '2d' is the
|
||||
* original flat radial map (top-down, orthographic); '3d' is the depth view. */
|
||||
const MODES = {
|
||||
'2d': { tilt: -1.45, persp: 18 },
|
||||
'3d': { tilt: -0.5, persp: 3.2 },
|
||||
} as const
|
||||
type MapMode = keyof typeof MODES
|
||||
|
||||
/** One dot on a node's point-cloud sphere, in unit-sphere local coords. */
|
||||
interface GlobeDot {
|
||||
el: SVGCircleElement
|
||||
@@ -104,16 +126,17 @@ let ringsLayer: SVGGElement | null = null
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
let unit = 1
|
||||
let centerY = 0
|
||||
/** Scene scale + vertical centring — tweened alongside cam.tilt/persp during
|
||||
* a 2D/3D toggle so the layout re-fits as it folds. */
|
||||
const fit = { unit: 1, centerY: 0 }
|
||||
let elapsed = 0
|
||||
let frame = 0
|
||||
let selfDots: GlobeDot[] = []
|
||||
let selfRadiusPx = 15
|
||||
let modeTween: gsap.core.Tween[] = []
|
||||
let modeInitialized = false
|
||||
|
||||
/** Perspective strength — measure() sets it per form factor: 3.2 gives the
|
||||
* desktop scene real depth; a large value is near-orthographic (flat). */
|
||||
let persp = 3.2
|
||||
const viewMode = ref<MapMode>('3d')
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let intro: gsap.core.Timeline | null = null
|
||||
let spinTween: gsap.core.Tween | null = null
|
||||
@@ -157,10 +180,10 @@ function project(x: number, y: number, z: number) {
|
||||
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt)
|
||||
const y2 = y * ct - z1 * st
|
||||
const z2 = y * st + z1 * ct
|
||||
const s = persp / (persp + z2)
|
||||
const s = cam.persp / (cam.persp + z2)
|
||||
return {
|
||||
x: width / 2 + x1 * unit * s * cam.zoom,
|
||||
y: centerY + y2 * unit * s * cam.zoom,
|
||||
x: width / 2 + x1 * fit.unit * s * cam.zoom,
|
||||
y: fit.centerY + y2 * fit.unit * s * cam.zoom,
|
||||
s,
|
||||
z: z2,
|
||||
}
|
||||
@@ -511,12 +534,21 @@ let dragDistance = 0
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (staticMode) return
|
||||
// Reset BEFORE the toggle early-return: a stale post-drag distance would
|
||||
// otherwise make onClickCapture swallow toggle taps forever ("stuck" toggle)
|
||||
dragDistance = 0
|
||||
// Never hijack the projection toggle's taps
|
||||
if ((e.target as Element | null)?.closest?.('.node-map-mode-toggle')) return
|
||||
dragging = true
|
||||
lastX = e.clientX
|
||||
dragVel = 0
|
||||
dragDistance = 0
|
||||
spinTween?.kill()
|
||||
containerRef.value?.setPointerCapture(e.pointerId)
|
||||
// Deliberately NO setPointerCapture: capture retargets pointerup to the
|
||||
// container, which suppresses the browser's click synthesis on child
|
||||
// elements — it silently killed node taps and the 2D/3D toggle. Drag
|
||||
// continuity outside the container comes from window-level move/up
|
||||
// listeners instead.
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
@@ -534,13 +566,14 @@ function onClickCapture(e: MouseEvent) {
|
||||
if (dragDistance > 6) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
// One-shot: only the click synthesized from THIS drag is swallowed
|
||||
dragDistance = 0
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
function onPointerUp(_e: PointerEvent) {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
try { containerRef.value?.releasePointerCapture(e.pointerId) } catch { /* already released */ }
|
||||
// Inertia: carry the fling velocity, then settle to a full stop — the
|
||||
// scene never idle-orbits on its own.
|
||||
cam.spin = Math.max(-3, Math.min(3, dragVel))
|
||||
@@ -549,28 +582,15 @@ function onPointerUp(e: PointerEvent) {
|
||||
|
||||
/* ----------------------------- lifecycle --------------------------- */
|
||||
|
||||
function measure() {
|
||||
const c = containerRef.value
|
||||
if (!c) return
|
||||
width = c.clientWidth
|
||||
height = c.clientHeight
|
||||
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
// Portrait (phone/companion): go near top-down and near-orthographic so
|
||||
// the map reads like the old 2D radial view — rings become circles, no
|
||||
// perspective squash, maximum label spread. Desktop keeps the 3D depth.
|
||||
const portrait = height > width
|
||||
cam.tilt = portrait ? -1.35 : -0.5
|
||||
persp = portrait ? 16 : 3.2
|
||||
|
||||
// Auto-fit: sample the outermost orbit through the real camera math
|
||||
// (rotation-invariant — a ring about the Y axis projects identically at any
|
||||
// rotY) to get the scene's true projected bounds, then scale to fill the
|
||||
// container and centre vertically in the space between the overlays. This
|
||||
// is what makes the map fill a phone, the companion, and a desktop panel
|
||||
// equally well instead of assuming one aspect ratio.
|
||||
/** Auto-fit for a given projection: sample the outermost orbit through the
|
||||
* camera math (rotation-invariant — a ring about the Y axis projects
|
||||
* identically at any rotY) to get the scene's true projected bounds, then
|
||||
* scale to fill the container and centre vertically between the overlays.
|
||||
* Pure with respect to tilt/persp so a mode toggle can compute its target
|
||||
* fit and tween towards it. */
|
||||
function computeFit(tilt: number, perspVal: number): { unit: number; centerY: number } {
|
||||
const maxRing = ringPaths.length ? Math.max(...ringPaths.map(r => r.radius)) : 1
|
||||
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt)
|
||||
const ct = Math.cos(tilt), st = Math.sin(tilt)
|
||||
let maxAbsX = 0
|
||||
let yMin = Infinity
|
||||
let yMax = -Infinity
|
||||
@@ -581,22 +601,68 @@ function measure() {
|
||||
for (const y of [-0.2, 0.2]) { // covers yJitter + sway/bob amplitude
|
||||
const y2 = y * ct - z * st
|
||||
const z2 = y * st + z * ct
|
||||
const s = persp / (persp + z2)
|
||||
const s = perspVal / (perspVal + z2)
|
||||
maxAbsX = Math.max(maxAbsX, Math.abs(x * s))
|
||||
yMin = Math.min(yMin, y2 * s)
|
||||
yMax = Math.max(yMax, y2 * s)
|
||||
}
|
||||
}
|
||||
const marginX = 46 // node radius + label half-width clearance
|
||||
const marginTop = 54 // legend chip
|
||||
const marginTop = 54 // legend / toggle chips
|
||||
const marginBottom = 66 // hint line + node label below the front edge
|
||||
const bandH = Math.max(80, height - marginTop - marginBottom)
|
||||
const unitX = (width / 2 - marginX) / maxAbsX
|
||||
const unitY = bandH / Math.max(yMax - yMin, 0.001)
|
||||
unit = Math.max(20, Math.min(unitX, unitY))
|
||||
const u = Math.max(20, Math.min(unitX, unitY))
|
||||
// Place the projected ellipse's midpoint at the centre of the available band
|
||||
centerY = marginTop + bandH / 2 - ((yMax + yMin) / 2) * unit
|
||||
render()
|
||||
return { unit: u, centerY: marginTop + bandH / 2 - ((yMax + yMin) / 2) * u }
|
||||
}
|
||||
|
||||
/** Snap (or tween, via setMode) the camera + fit to the given mode. */
|
||||
function applyMode(mode: MapMode, animate: boolean) {
|
||||
const target = MODES[mode]
|
||||
const targetFit = computeFit(target.tilt, target.persp)
|
||||
for (const t of modeTween) t.kill()
|
||||
modeTween = []
|
||||
if (animate && !staticMode) {
|
||||
const opts = { duration: 0.9, ease: motionTokens.ease.inOut }
|
||||
modeTween.push(
|
||||
gsap.to(cam, { tilt: target.tilt, persp: target.persp, ...opts }),
|
||||
gsap.to(fit, { ...targetFit, ...opts }),
|
||||
)
|
||||
} else {
|
||||
cam.tilt = target.tilt
|
||||
cam.persp = target.persp
|
||||
fit.unit = targetFit.unit
|
||||
fit.centerY = targetFit.centerY
|
||||
render()
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode: MapMode) {
|
||||
if (viewMode.value === mode) return
|
||||
viewMode.value = mode
|
||||
try { localStorage.setItem('federation-map-projection', mode) } catch { /* private mode */ }
|
||||
applyMode(mode, true)
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const c = containerRef.value
|
||||
if (!c) return
|
||||
width = c.clientWidth
|
||||
height = c.clientHeight
|
||||
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
// First measurement decides the default projection: saved preference wins,
|
||||
// otherwise portrait containers (phone/companion) open in the flat 2D view.
|
||||
if (!modeInitialized) {
|
||||
modeInitialized = true
|
||||
let saved: string | null = null
|
||||
try { saved = localStorage.getItem('federation-map-projection') } catch { /* private mode */ }
|
||||
viewMode.value = saved === '2d' || saved === '3d' ? saved : (height > width ? '2d' : '3d')
|
||||
}
|
||||
// Resize: snap to the current mode's fit (no tween — tracks the drag)
|
||||
applyMode(viewMode.value, false)
|
||||
}
|
||||
|
||||
const graphSignature = computed(() => JSON.stringify({
|
||||
@@ -633,10 +699,11 @@ onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value)
|
||||
containerRef.value.addEventListener('pointerdown', onPointerDown)
|
||||
containerRef.value.addEventListener('pointermove', onPointerMove)
|
||||
containerRef.value.addEventListener('pointerup', onPointerUp)
|
||||
containerRef.value.addEventListener('pointercancel', onPointerUp)
|
||||
containerRef.value.addEventListener('click', onClickCapture, true)
|
||||
// Window-level so a drag that leaves the container keeps tracking
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -644,7 +711,11 @@ onUnmounted(() => {
|
||||
detachTicker()
|
||||
intro?.kill()
|
||||
spinTween?.kill()
|
||||
for (const t of modeTween) t.kill()
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
})
|
||||
|
||||
// KeepAlive-aware: pause the 60fps loop while the tab is cached, resume on return
|
||||
@@ -712,6 +783,38 @@ watch(graphSignature, () => rebuild(false))
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
}
|
||||
.node-map-mode-toggle {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.node-map-mode-btn {
|
||||
/* min-height !important: the global ≤767px 44px touch-target rule would
|
||||
deform this deliberately-compact control */
|
||||
min-height: 0 !important;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.node-map-mode-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.node-map-mode-btn-active {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
.node-map-hint {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
@@ -760,16 +863,9 @@ watch(graphSignature, () => rebuild(false))
|
||||
<!-- Unscoped on purpose: the SVG labels are created with createElementNS at
|
||||
runtime, so Vue's scoped-style data attributes never land on them. -->
|
||||
<style>
|
||||
/* Dark halo behind node labels — keeps them legible over dots and links */
|
||||
.node-map-stage text.nm-label {
|
||||
paint-order: stroke;
|
||||
stroke: rgba(0, 0, 0, 0.6);
|
||||
stroke-width: 2.5px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.node-map-stage text.nm-label {
|
||||
font-size: 12.5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3187,3 +3187,12 @@ select::-ms-expand {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pages with the floating mobile back button (.mobile-scroll-pad-back) keep
|
||||
its full clearance under the filled map so the stage never slides beneath
|
||||
the button. */
|
||||
@media (max-width: 920px) {
|
||||
.dashboard-scroll-panel.mobile-scroll-pad-back:has(.node-map-stage) {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where
|
||||
vertical space belongs to the map (desktop keeps the header card) -->
|
||||
<DidCardMobile
|
||||
v-if="!mapActive"
|
||||
:self-did="selfDid"
|
||||
:server-name="appStore.serverName"
|
||||
@rotate="showRotateModal = true"
|
||||
/>
|
||||
|
||||
<!-- Network Map View — fills all remaining height to the bottom edge -->
|
||||
<div v-if="mapActive" class="flex-1 min-h-0">
|
||||
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
|
||||
@@ -250,6 +259,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useSyncStore } from '@/stores/sync'
|
||||
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
|
||||
import FederationHeader from './federation/FederationHeader.vue'
|
||||
import DidCardMobile from './federation/DidCardMobile.vue'
|
||||
import RotateDidModal from './federation/RotateDidModal.vue'
|
||||
import QuickActions from './federation/QuickActions.vue'
|
||||
import NodeList from './federation/NodeList.vue'
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<!-- Mobile-only DID copy/rotate card. Lives BELOW the view tabs in
|
||||
Federation.vue (not in the header) and is hidden by the parent on the
|
||||
Network Map tab, where vertical space belongs to the map. -->
|
||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mb-6 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { shortDid } from './utils'
|
||||
import { safeClipboardWrite } from '../web5/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
selfDid: string
|
||||
serverName: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
rotate: []
|
||||
}>()
|
||||
|
||||
const didCopied = ref(false)
|
||||
const shortDidDisplay = computed(() => shortDid(props.selfDid))
|
||||
|
||||
function handleCopy() {
|
||||
if (props.selfDid) {
|
||||
safeClipboardWrite(props.selfDid)
|
||||
didCopied.value = true
|
||||
setTimeout(() => { didCopied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -18,15 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: DID below title -->
|
||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by
|
||||
Federation.vue below the view tabs (hidden on the map tab). -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user