perf(rnetwork): optimize 3D graph rendering + fix cross-platform mobile PWA

Graph performance: seed node positions to prevent NaN geometry warnings,
cache sprite textures, reduce simulation ticks (120→50 warmup, 300→150
cooldown), cap particles at 2, lower sphere segments, halve label canvas
size, add sessionStorage graph data cache with 60s TTL.

Mobile PWA: add both mobile-web-app-capable (Chrome/Android) and
apple-mobile-web-app-capable (iOS), viewport-fit=cover for notch support,
apple-mobile-web-app-title, safe-area CSS insets on header/body, fix
admin.html missing all mobile meta tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 22:08:22 -07:00
parent e498233666
commit 9008640f79
7 changed files with 88 additions and 26 deletions

View File

@ -160,6 +160,10 @@ class FolkGraphViewer extends HTMLElement {
private resizeObserver: ResizeObserver | null = null;
private companyColors: Map<string, number> = new Map();
// Sprite caches — avoid recreating Canvas+Texture per node per frame
private _textSpriteCache = new Map<string, any>();
private _badgeSpriteCache = new Map<string, any>();
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
@ -192,16 +196,32 @@ class FolkGraphViewer extends HTMLElement {
const base = this.getApiBase();
try {
const trustParam = this.trustMode ? `?trust=true&authority=${encodeURIComponent(this.authority)}` : "";
const graphCacheKey = `rnetwork:graph:${this.space}:${this.trustMode}:${this.authority}`;
// Check sessionStorage cache (60s TTL, matches server cache)
let graphData: any = null;
try {
const cached = sessionStorage.getItem(graphCacheKey);
if (cached) {
const { data, ts } = JSON.parse(cached);
if (Date.now() - ts < 60_000) graphData = data;
}
} catch { /* storage unavailable */ }
const [wsRes, infoRes, graphRes] = await Promise.all([
fetch(`${base}/api/workspaces`),
fetch(`${base}/api/info`),
fetch(`${base}/api/graph${trustParam}`),
graphData ? Promise.resolve(null) : fetch(`${base}/api/graph${trustParam}`),
]);
if (wsRes.ok) this.workspaces = await wsRes.json();
if (infoRes.ok) this.info = await infoRes.json();
if (graphRes.ok) {
const graph = await graphRes.json();
this.importGraph(graph);
if (graphData) {
this.importGraph(graphData);
} else if (graphRes && graphRes.ok) {
graphData = await graphRes.json();
this.importGraph(graphData);
try { sessionStorage.setItem(graphCacheKey, JSON.stringify({ data: graphData, ts: Date.now() })); } catch { /* quota */ }
}
} catch { /* offline */ }
this.updateStatsBar();
@ -213,6 +233,8 @@ class FolkGraphViewer extends HTMLElement {
private async reloadWithAuthority(authority: AuthoritySelection) {
this.authority = authority;
this.trustMode = true;
this._textSpriteCache.clear();
this._badgeSpriteCache.clear();
await this.loadData();
}
@ -968,8 +990,8 @@ class FolkGraphViewer extends HTMLElement {
})
.linkDirectionalArrowRelPos(1)
.linkDirectionalParticles((link: GraphEdge) => {
if (link.type === "cross_layer_flow") return Math.ceil((link.strength || 0.5) * 3);
return link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0;
if (link.type === "cross_layer_flow") return Math.min(2, Math.ceil((link.strength || 0.5) * 3));
return link.type === "delegates_to" ? Math.min(2, Math.ceil((link.weight || 0.5) * 4)) : 0;
})
.linkDirectionalParticleSpeed((link: GraphEdge) =>
link.type === "cross_layer_flow" ? 0.006 : 0.004
@ -1018,8 +1040,8 @@ class FolkGraphViewer extends HTMLElement {
})
.d3AlphaDecay(0.02)
.d3VelocityDecay(0.3)
.warmupTicks(120)
.cooldownTicks(300);
.warmupTicks(50)
.cooldownTicks(150);
this.graph = graph;
@ -1118,8 +1140,8 @@ class FolkGraphViewer extends HTMLElement {
const group = new THREE.Group();
// Sphere geometry
const segments = (node.type === "module" || node.type === "feed") ? 24 : 16;
const geometry = new THREE.SphereGeometry(radius, segments, segments * 3 / 4);
const segments = (node.type === "module" || node.type === "feed") ? 24 : 12;
const geometry = new THREE.SphereGeometry(radius, segments, Math.round(segments * 3 / 4));
const opacity = node.type === "module" ? 0.95 : node.type === "feed" ? 0.85 : node.type === "company" ? 0.9 : 0.75;
const material = new THREE.MeshLambertMaterial({
color,
@ -1205,22 +1227,27 @@ class FolkGraphViewer extends HTMLElement {
}
private createTextSprite(THREE: any, node: GraphNode): any {
const isSelected = this.selectedNode?.id === node.id;
const cacheKey = `${node.id}:${isSelected}:${this.trustMode}:${this.authority}`;
const cached = this._textSpriteCache.get(cacheKey);
if (cached) return cached.clone();
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const text = node.name;
const fontSize = node.type === "module" ? 44 : node.type === "company" ? 42 : node.type === "feed" ? 30 : 36;
canvas.width = 512;
canvas.height = 96;
canvas.width = 256;
canvas.height = 48;
ctx.font = `${(node.type === "company" || node.type === "module") ? "600" : "500"} ${fontSize}px system-ui, sans-serif`;
ctx.font = `${(node.type === "company" || node.type === "module") ? "600" : "500"} ${Math.round(fontSize / 2)}px system-ui, sans-serif`;
ctx.fillStyle = "#e2e8f0";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0,0,0,0.9)";
ctx.shadowBlur = 6;
ctx.fillText(text.length > 24 ? text.slice(0, 22) + "\u2026" : text, 256, 48);
ctx.shadowBlur = 3;
ctx.fillText(text.length > 24 ? text.slice(0, 22) + "\u2026" : text, 128, 24);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
@ -1231,10 +1258,15 @@ class FolkGraphViewer extends HTMLElement {
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.scale.set(14, 3.5, 1);
return sprite;
this._textSpriteCache.set(cacheKey, sprite);
return sprite.clone();
}
private createBadgeSprite(THREE: any, text: string, color = "#7c3aed"): any {
const cacheKey = `${text}:${color}`;
const cached = this._badgeSpriteCache.get(cacheKey);
if (cached) return cached.clone();
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return null;
@ -1262,7 +1294,8 @@ class FolkGraphViewer extends HTMLElement {
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.scale.set(1.5, 1.5, 1);
return sprite;
this._badgeSpriteCache.set(cacheKey, sprite);
return sprite.clone();
}
// ── Data update (no DOM rebuild) ──
@ -1304,6 +1337,13 @@ class FolkGraphViewer extends HTMLElement {
});
}
// Seed initial positions to prevent NaN geometry warnings during sim warmup
for (const n of filtered) {
if (n.x == null || isNaN(n.x)) n.x = (Math.random() - 0.5) * 100;
if (n.y == null || isNaN(n.y)) n.y = (Math.random() - 0.5) * 100;
if (n.z == null || isNaN(n.z)) n.z = (Math.random() - 0.5) * 100;
}
this.graph.graphData({
nodes: filtered,
links: filteredEdges,

View File

@ -113,13 +113,15 @@ export function renderShell(opts: ShellOptions): string {
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(title)}</title>
<meta name="description" content="rSpace — local-first community platform with 25+ composable apps. Encrypted, interoperable, self-sovereign.">
@ -1508,13 +1510,15 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(mod.name)} rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
@ -1851,13 +1855,15 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(subPage.title)} ${escapeHtml(mod.name)} | rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>

View File

@ -2,8 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Admin Dashboard — rSpace</title>
<style>
* {

View File

@ -2,13 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>rSpace Canvas</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>

View File

@ -2,13 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Create a Space — rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>

View File

@ -2,13 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="rSpace">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>(you)rSpace — Collaborative Community Spaces</title>
<style>

View File

@ -7,6 +7,7 @@ body {
min-height: 100vh;
background: var(--rs-bg-page);
color: var(--rs-text-primary);
padding-bottom: env(safe-area-inset-bottom);
}
/* ── Header bar ── */
@ -18,7 +19,8 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
padding: 0 max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));
padding-top: env(safe-area-inset-top);
z-index: 9999;
border-bottom: 1px solid var(--rs-glass-border);
color: var(--rs-text-primary);
@ -357,7 +359,7 @@ body.rstack-sidebar-open #toolbar {
the header wrap naturally to two rows. */
.rstack-header {
position: sticky;
padding: 6px 12px;
padding: calc(6px + env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 6px max(12px, env(safe-area-inset-left));
height: auto;
flex-wrap: wrap;
gap: 0;