From e0aebf50f848c4341c2805ad0b39fbf73485034e Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 9 Aug 2026 17:31:07 -0400 Subject: [PATCH] feat(federation): 2D/3D projection toggle + interaction fixes + mobile layout polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/federation/NetworkMap3D.vue | 194 +++++++++++++----- neode-ui/src/style.css | 9 + neode-ui/src/views/Federation.vue | 10 + .../src/views/federation/DidCardMobile.vue | 39 ++++ .../src/views/federation/FederationHeader.vue | 11 +- 5 files changed, 205 insertions(+), 58 deletions(-) create mode 100644 neode-ui/src/views/federation/DidCardMobile.vue 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 @@ + + + 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 }}

-
- - -
+