diff --git a/graph-ui/src/components/DisplaySettingsMenu.tsx b/graph-ui/src/components/DisplaySettingsMenu.tsx new file mode 100644 index 000000000..3b78f814c --- /dev/null +++ b/graph-ui/src/components/DisplaySettingsMenu.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DEFAULT_DISPLAY_SETTINGS, + DISPLAY_LIMITS, + type DisplaySettings, +} from "../lib/density"; + +interface DisplaySettingsMenuProps { + settings: DisplaySettings; + onChange: (next: DisplaySettings) => void; +} + +interface SliderRowProps { + label: string; + hint: string; + value: number; + min: number; + max: number; + onChange: (value: number) => void; +} + +function SliderRow({ label, hint, value, min, max, onChange }: SliderRowProps) { + return ( + + ); +} + +/* Contrast / brightness controls for the 3D graph. These ride on top of the + * automatic density compensation — the defaults already adapt to graph size, + * so 1.00× is "auto"; the sliders let the user push it. */ +export function DisplaySettingsMenu({ + settings, + onChange, +}: DisplaySettingsMenuProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + /* Close on outside click / Escape */ + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const set = (patch: Partial) => + onChange({ ...settings, ...patch }); + + const isDefault = + settings.edgeBrightness === DEFAULT_DISPLAY_SETTINGS.edgeBrightness && + settings.nodeGlow === DEFAULT_DISPLAY_SETTINGS.nodeGlow && + settings.bloom === DEFAULT_DISPLAY_SETTINGS.bloom; + + return ( +
+ + + {open && ( +
+
+ + Contrast + + +
+ + set({ edgeBrightness })} + /> + set({ nodeGlow })} + /> + set({ bloom })} + /> + +

+ 1.00× follows the automatic density compensation. Lower the + edge/glow/bloom values when a large graph washes out to white. +

+
+ )} +
+ ); +} diff --git a/graph-ui/src/components/EdgeLines.tsx b/graph-ui/src/components/EdgeLines.tsx index 50052d821..4cb7eba8e 100644 --- a/graph-ui/src/components/EdgeLines.tsx +++ b/graph-ui/src/components/EdgeLines.tsx @@ -1,12 +1,16 @@ import { useMemo } from "react"; import * as THREE from "three"; import type { GraphNode, GraphEdge } from "../lib/types"; +import { edgeIntensityScale } from "../lib/density"; interface EdgeLinesProps { nodes: GraphNode[]; edges: GraphEdge[]; highlightedIds: Set | null; opacity?: number; + /* User edge-brightness multiplier (see DisplaySettings). Layered on top of + * the automatic density scale. */ + brightness?: number; /* When set, edge.target is looked up in this array instead of `nodes`. * Used for cross-galaxy edges where source lives in the primary graph * and target lives in a linked project's offset-adjusted nodes. */ @@ -52,9 +56,13 @@ export function EdgeLines({ edges, highlightedIds, opacity = 1.0, + brightness = 1.0, targetNodes, }: EdgeLinesProps) { const geometry = useMemo(() => { + /* Shrink per-edge glow as the edge count grows so the additively-blended + * center doesn't saturate to white; the user multiplier rides on top. */ + const densityScale = edgeIntensityScale(edges.length) * brightness; const srcMap = new Map(); for (let i = 0; i < nodes.length; i++) { srcMap.set(nodes[i].id, i); @@ -91,7 +99,11 @@ export function EdgeLines({ * With additive blending + dark background, these glow nicely. */ let intensity = sameCluster ? 0.25 : 0.06; if (hasHighlight) { - intensity = sHL && tHL ? 0.5 : 0.04; + /* A selection stays at full strength (never density-scaled) so it + * pops against the dimmed rest; only the un-selected bulk is scaled. */ + intensity = sHL && tHL ? 0.5 : 0.04 * densityScale; + } else { + intensity *= densityScale; } const off = validCount * 6; @@ -125,7 +137,7 @@ export function EdgeLines({ new THREE.BufferAttribute(colors.slice(0, validCount * 6), 3), ); return geo; - }, [nodes, edges, highlightedIds, targetNodes]); + }, [nodes, edges, highlightedIds, targetNodes, brightness]); return ( diff --git a/graph-ui/src/components/GraphLoader.tsx b/graph-ui/src/components/GraphLoader.tsx new file mode 100644 index 000000000..0d9154b17 --- /dev/null +++ b/graph-ui/src/components/GraphLoader.tsx @@ -0,0 +1,84 @@ +import type { LoadProgress } from "../hooks/useGraphData"; + +/* Small constellation whose nodes pulse and whose edges draw themselves in a + * staggered loop — a miniature of the graph being loaded. Pure SVG/CSS + * (keyframes in globals.css) so it costs nothing while the real payload + * streams in, and it degrades to a static constellation under + * prefers-reduced-motion. */ + +const LOADER_NODES: Array<{ cx: number; cy: number; r: number; delay: string }> = [ + { cx: 60, cy: 18, r: 5, delay: "0s" }, + { cx: 22, cy: 42, r: 4, delay: "0.35s" }, + { cx: 98, cy: 40, r: 4, delay: "0.7s" }, + { cx: 42, cy: 78, r: 4.5, delay: "1.05s" }, + { cx: 84, cy: 82, r: 3.5, delay: "1.4s" }, + { cx: 60, cy: 52, r: 6, delay: "0.15s" }, + { cx: 14, cy: 88, r: 3, delay: "1.75s" }, +]; + +const LOADER_EDGES: Array<{ x1: number; y1: number; x2: number; y2: number; delay: string }> = [ + { x1: 60, y1: 18, x2: 60, y2: 52, delay: "0s" }, + { x1: 22, y1: 42, x2: 60, y2: 52, delay: "0.3s" }, + { x1: 98, y1: 40, x2: 60, y2: 52, delay: "0.6s" }, + { x1: 42, y1: 78, x2: 60, y2: 52, delay: "0.9s" }, + { x1: 84, y1: 82, x2: 60, y2: 52, delay: "1.2s" }, + { x1: 42, y1: 78, x2: 14, y2: 88, delay: "1.5s" }, + { x1: 22, y1: 42, x2: 60, y2: 18, delay: "1.8s" }, +]; + +function formatMegabytes(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1); +} + +interface GraphLoaderProps { + nodeBudget: number; + progress: LoadProgress; +} + +export function GraphLoader({ nodeBudget, progress }: GraphLoaderProps) { + const receiving = progress.receivedBytes > 0; + return ( +
+ +

+ {receiving ? "Receiving graph" : "Computing layout"} — up to{" "} + {nodeBudget.toLocaleString("en-US")} nodes +

+

+ {receiving + ? progress.totalBytes + ? `${formatMegabytes(progress.receivedBytes)} of ${formatMegabytes(progress.totalBytes)} MB` + : `${formatMegabytes(progress.receivedBytes)} MB received` + : " "} +

+
+ ); +} diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index 81ec5c9bc..663c2ad32 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -9,6 +9,14 @@ import { EdgeLines } from "./EdgeLines"; import { NodeLabels } from "./NodeLabels"; import { NodeTooltip } from "./NodeTooltip"; import type { GraphData, GraphNode, LinkedProject } from "../lib/types"; +import { + DEFAULT_DISPLAY_SETTINGS, + bloomIntensityScale, + nodeBoostScale, + type DisplaySettings, +} from "../lib/density"; + +const BASE_BLOOM_INTENSITY = 1.45; /* ── Camera fly-to animation ────────────────────────────── */ @@ -107,6 +115,7 @@ interface GraphSceneProps { highlightedIds: Set | null; cameraTarget: CameraTarget | null; showLabels: boolean; + display?: DisplaySettings; onNodeClick: (node: GraphNode) => void; } @@ -117,11 +126,21 @@ export function GraphScene({ highlightedIds, cameraTarget, showLabels, + display = DEFAULT_DISPLAY_SETTINGS, onNodeClick, }: GraphSceneProps) { const [hovered, setHovered] = useState(null); const controlsRef = useRef(null); + /* Adaptive density defaults × user multipliers. The automatic scale keeps + * contrast roughly constant as the graph grows; the sliders nudge it. + * NodeCloud applies `nodeBoost` directly (no internal density scaling), + * whereas EdgeLines scales by edge density itself — so it receives only the + * user edge-brightness multiplier to avoid double-applying. */ + const nodeBoost = nodeBoostScale(data.nodes.length) * display.nodeGlow; + const bloomIntensity = + BASE_BLOOM_INTENSITY * bloomIntensityScale(data.nodes.length) * display.bloom; + return ( {showLabels && } @@ -170,6 +191,7 @@ export function GraphScene({ edges={lp.edges} highlightedIds={null} opacity={0.3} + brightness={display.edgeBrightness} /> {/* Inter-galaxy CROSS_* edges: source is in primary, target in * this linked project's offset nodes. */} @@ -187,6 +210,7 @@ export function GraphScene({ edges={lp.cross_edges} highlightedIds={highlightedIds} opacity={0.85} + brightness={display.edgeBrightness} /> )} @@ -202,7 +226,7 @@ export function GraphScene({ diff --git a/graph-ui/src/components/GraphTab.test.ts b/graph-ui/src/components/GraphTab.test.ts index 6056eeef0..0e68e76d9 100644 --- a/graph-ui/src/components/GraphTab.test.ts +++ b/graph-ui/src/components/GraphTab.test.ts @@ -20,7 +20,7 @@ describe("formatGraphLimitNotice", () => { } satisfies GraphData; expect(formatGraphLimitNotice(data)).toBe( - "Showing 2,000 of 43,729 nodes. Use filters to narrow.", + "Showing 2,000 of 43,729 nodes (0 edges). Raise the node budget or use filters.", ); }); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index 8d267ca03..75f32a700 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -1,6 +1,19 @@ import { useEffect, useState, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { useGraphData } from "../hooks/useGraphData"; +import { + useGraphData, + clampNodeBudget, + GRAPH_RENDER_NODE_LIMIT, + GRAPH_NODE_BUDGET_STEP, + GRAPH_NODE_BUDGET_MAX, +} from "../hooks/useGraphData"; +import { GraphLoader } from "./GraphLoader"; +import { DisplaySettingsMenu } from "./DisplaySettingsMenu"; +import { + loadDisplaySettings, + saveDisplaySettings, + type DisplaySettings, +} from "../lib/density"; import { GraphScene, computeCameraTarget, @@ -26,27 +39,65 @@ function saveWidth(key: string, value: number) { try { localStorage.setItem(key, String(Math.round(value))); } catch { /* ignore */ } } +/* Persist the node budget per project */ +function budgetKey(project: string): string { + return `cbm-node-budget:${project}`; +} +function loadNodeBudget(project: string): number { + try { + const v = localStorage.getItem(budgetKey(project)); + if (v) return clampNodeBudget(parseInt(v, 10)); + } catch { /* ignore */ } + return GRAPH_RENDER_NODE_LIMIT; +} +function saveNodeBudget(project: string, value: number) { + try { localStorage.setItem(budgetKey(project), String(value)); } catch { /* ignore */ } +} + interface GraphTabProps { project: string | null; } export function formatGraphLimitNotice(data: GraphData | null): string | null { if (!data || data.total_nodes <= data.nodes.length) return null; - return `Showing ${data.nodes.length.toLocaleString("en-US")} of ${data.total_nodes.toLocaleString("en-US")} nodes. Use filters to narrow.`; + return `Showing ${data.nodes.length.toLocaleString("en-US")} of ${data.total_nodes.toLocaleString("en-US")} nodes (${data.edges.length.toLocaleString("en-US")} edges). Raise the node budget or use filters.`; } export function GraphTab({ project }: GraphTabProps) { - const { data, loading, error, fetchOverview } = useGraphData(); + const { data, loading, error, progress, fetchOverview } = useGraphData(); const [highlightedIds, setHighlightedIds] = useState | null>(null); const [selectedPath, setSelectedPath] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [cameraTarget, setCameraTarget] = useState(null); const [repoInfo, setRepoInfo] = useState(null); const [showLabels, setShowLabels] = useState(true); + const [display, setDisplay] = useState(() => + loadDisplaySettings(), + ); + const updateDisplay = useCallback((next: DisplaySettings) => { + setDisplay(next); + saveDisplaySettings(next); + }, []); const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260)); const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280)); const limitNotice = formatGraphLimitNotice(data); + /* Node budget — keyed to its project so switching projects re-reads the + * persisted value and triggers exactly one fetch. */ + const [budget, setBudget] = useState<{ project: string | null; value: number }>( + { project: null, value: GRAPH_RENDER_NODE_LIMIT }, + ); + const [budgetDraft, setBudgetDraft] = useState(String(GRAPH_RENDER_NODE_LIMIT)); + + const commitBudget = useCallback(() => { + const parsed = clampNodeBudget(parseInt(budgetDraft, 10)); + setBudgetDraft(String(parsed)); + if (project && parsed !== budget.value) { + saveNodeBudget(project, parsed); + setBudget({ project, value: parsed }); + } + }, [budgetDraft, project, budget.value]); + /* Filter state — all enabled by default */ const [enabledLabels, setEnabledLabels] = useState>(new Set()); const [enabledEdgeTypes, setEnabledEdgeTypes] = useState>(new Set()); @@ -121,13 +172,23 @@ export function GraphTab({ project }: GraphTabProps) { hideTests, ]); + /* Re-read the persisted budget when the project changes… */ useEffect(() => { if (project) { - fetchOverview(project); + const value = loadNodeBudget(project); + setBudget({ project, value }); + setBudgetDraft(String(value)); + } + }, [project]); + + /* …and fetch only once budget and project agree (one fetch per change). */ + useEffect(() => { + if (project && budget.project === project) { + fetchOverview(project, budget.value); setHighlightedIds(null); setSelectedPath(null); } - }, [project, fetchOverview]); + }, [project, budget, fetchOverview]); /* Fetch git remote metadata for GitHub deep-links */ useEffect(() => { @@ -236,10 +297,7 @@ export function GraphTab({ project }: GraphTabProps) { if (loading) { return (
-
-
-

Computing layout...

-
+
); } @@ -330,6 +388,7 @@ export function GraphTab({ project }: GraphTabProps) { highlightedIds={highlightedIds} cameraTarget={cameraTarget} showLabels={showLabels} + display={display} onNodeClick={handleNodeClick} /> @@ -355,7 +414,7 @@ export function GraphTab({ project }: GraphTabProps) { )}
-
+
{highlightedIds && (