diff --git a/neode-ui/src/components/federation/NetworkMap3D.vue b/neode-ui/src/components/federation/NetworkMap3D.vue
index d6db03b5..d6700ce1 100644
--- a/neode-ui/src/components/federation/NetworkMap3D.vue
+++ b/neode-ui/src/components/federation/NetworkMap3D.vue
@@ -13,6 +13,19 @@
Observer
Untrusted
+
+
+
+
+
No peers yet
Invite a peer or discover nodes to grow your federation
@@ -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
('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))
diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css
index 9a47a6f4..a157d184 100644
--- a/neode-ui/src/style.css
+++ b/neode-ui/src/style.css
@@ -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);
+ }
+}
diff --git a/neode-ui/src/views/Federation.vue b/neode-ui/src/views/Federation.vue
index bdf8f29a..e69793af 100644
--- a/neode-ui/src/views/Federation.vue
+++ b/neode-ui/src/views/Federation.vue
@@ -35,6 +35,15 @@
+
+
+
@@ -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'
diff --git a/neode-ui/src/views/federation/DidCardMobile.vue b/neode-ui/src/views/federation/DidCardMobile.vue
new file mode 100644
index 00000000..3ef9fa1a
--- /dev/null
+++ b/neode-ui/src/views/federation/DidCardMobile.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
{{ serverName }}
+
{{ didCopied ? 'Copied!' : shortDidDisplay }}
+
+
+
+
+
+
+
diff --git a/neode-ui/src/views/federation/FederationHeader.vue b/neode-ui/src/views/federation/FederationHeader.vue
index d6669b4a..fbc1073a 100644
--- a/neode-ui/src/views/federation/FederationHeader.vue
+++ b/neode-ui/src/views/federation/FederationHeader.vue
@@ -18,15 +18,8 @@
-
-
-
-
{{ serverName }}
-
{{ didCopied ? 'Copied!' : shortDidDisplay }}
-
-
-
-
+