Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions graph-ui/src/components/DisplaySettingsMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<label className="block">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-foreground/70">{label}</span>
<span className="text-[10px] font-mono text-cyan-300/70 tabular-nums">
{value.toFixed(2)}×
</span>
</div>
<input
type="range"
min={min}
max={max}
step={0.05}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-cyan-400 cursor-pointer"
aria-label={`${label} (${hint})`}
/>
<p className="text-[9px] text-foreground/30 mt-0.5">{hint}</p>
</label>
);
}

/* 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<HTMLDivElement>(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<DisplaySettings>) =>
onChange({ ...settings, ...patch });

const isDefault =
settings.edgeBrightness === DEFAULT_DISPLAY_SETTINGS.edgeBrightness &&
settings.nodeGlow === DEFAULT_DISPLAY_SETTINGS.nodeGlow &&
settings.bloom === DEFAULT_DISPLAY_SETTINGS.bloom;

return (
<div ref={rootRef} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="dialog"
title="Contrast & brightness"
>
Display{!isDefault && <span className="ml-1 text-cyan-300">•</span>}
</Button>

{open && (
<div
role="dialog"
aria-label="Display settings"
className="absolute top-10 right-0 w-64 p-4 rounded-lg border border-border/60 bg-[#0b1920]/95 backdrop-blur-md shadow-xl z-20 space-y-3.5"
>
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
Contrast
</span>
<button
onClick={() => onChange(DEFAULT_DISPLAY_SETTINGS)}
className="text-[10px] text-primary/70 hover:text-primary transition-colors disabled:opacity-30"
disabled={isDefault}
>
Reset
</button>
</div>

<SliderRow
label="Edge brightness"
hint="Dim the web of links on dense graphs"
value={settings.edgeBrightness}
min={DISPLAY_LIMITS.edgeBrightness.min}
max={DISPLAY_LIMITS.edgeBrightness.max}
onChange={(edgeBrightness) => set({ edgeBrightness })}
/>
<SliderRow
label="Node glow"
hint="Halo boost around each node"
value={settings.nodeGlow}
min={DISPLAY_LIMITS.nodeGlow.min}
max={DISPLAY_LIMITS.nodeGlow.max}
onChange={(nodeGlow) => set({ nodeGlow })}
/>
<SliderRow
label="Bloom"
hint="Overall glow bloom strength"
value={settings.bloom}
min={DISPLAY_LIMITS.bloom.min}
max={DISPLAY_LIMITS.bloom.max}
onChange={(bloom) => set({ bloom })}
/>

<p className="text-[9px] text-foreground/30 pt-1 border-t border-border/30">
1.00× follows the automatic density compensation. Lower the
edge/glow/bloom values when a large graph washes out to white.
</p>
</div>
)}
</div>
);
}
16 changes: 14 additions & 2 deletions graph-ui/src/components/EdgeLines.tsx
Original file line number Diff line number Diff line change
@@ -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<number> | 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. */
Expand Down Expand Up @@ -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<number, number>();
for (let i = 0; i < nodes.length; i++) {
srcMap.set(nodes[i].id, i);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<lineSegments geometry={geometry}>
Expand Down
84 changes: 84 additions & 0 deletions graph-ui/src/components/GraphLoader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="text-center" role="status" aria-live="polite">
<svg
width="120"
height="104"
viewBox="0 0 120 104"
className="mx-auto"
aria-hidden="true"
>
{LOADER_EDGES.map((e, i) => (
<line
key={`e${i}`}
x1={e.x1}
y1={e.y1}
x2={e.x2}
y2={e.y2}
className="graph-loader-edge"
style={{ animationDelay: e.delay }}
/>
))}
{LOADER_NODES.map((n, i) => (
<circle
key={`n${i}`}
cx={n.cx}
cy={n.cy}
r={n.r}
className="graph-loader-node"
style={{ animationDelay: n.delay }}
/>
))}
</svg>
<p className="text-white/50 text-sm mt-4">
{receiving ? "Receiving graph" : "Computing layout"} — up to{" "}
{nodeBudget.toLocaleString("en-US")} nodes
</p>
<p className="text-cyan-300/60 text-xs font-mono mt-1 h-4">
{receiving
? progress.totalBytes
? `${formatMegabytes(progress.receivedBytes)} of ${formatMegabytes(progress.totalBytes)} MB`
: `${formatMegabytes(progress.receivedBytes)} MB received`
: " "}
</p>
</div>
);
}
26 changes: 25 additions & 1 deletion graph-ui/src/components/GraphScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────── */

Expand Down Expand Up @@ -107,6 +115,7 @@ interface GraphSceneProps {
highlightedIds: Set<number> | null;
cameraTarget: CameraTarget | null;
showLabels: boolean;
display?: DisplaySettings;
onNodeClick: (node: GraphNode) => void;
}

Expand All @@ -117,11 +126,21 @@ export function GraphScene({
highlightedIds,
cameraTarget,
showLabels,
display = DEFAULT_DISPLAY_SETTINGS,
onNodeClick,
}: GraphSceneProps) {
const [hovered, setHovered] = useState<GraphNode | null>(null);
const controlsRef = useRef<OrbitControlsImpl | null>(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 (
<Canvas
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
Expand All @@ -146,12 +165,14 @@ export function GraphScene({
nodes={data.nodes}
edges={data.edges}
highlightedIds={highlightedIds}
brightness={display.edgeBrightness}
/>
<NodeCloud
nodes={data.nodes}
highlightedIds={highlightedIds}
onHover={setHovered}
onClick={onNodeClick}
boost={nodeBoost}
/>
{showLabels && <NodeLabels nodes={data.nodes} highlightedIds={highlightedIds} />}

Expand All @@ -170,13 +191,15 @@ export function GraphScene({
edges={lp.edges}
highlightedIds={null}
opacity={0.3}
brightness={display.edgeBrightness}
/>
<NodeCloud
nodes={offsetNodes}
highlightedIds={null}
onHover={setHovered}
onClick={onNodeClick}
opacity={0.5}
boost={nodeBoost}
/>
{/* Inter-galaxy CROSS_* edges: source is in primary, target in
* this linked project's offset nodes. */}
Expand All @@ -187,6 +210,7 @@ export function GraphScene({
edges={lp.cross_edges}
highlightedIds={highlightedIds}
opacity={0.85}
brightness={display.edgeBrightness}
/>
)}
</group>
Expand All @@ -202,7 +226,7 @@ export function GraphScene({
<Bloom
luminanceThreshold={0.3}
luminanceSmoothing={0.7}
intensity={1.2}
intensity={bloomIntensity}
mipmapBlur
radius={0.6}
/>
Expand Down
2 changes: 1 addition & 1 deletion graph-ui/src/components/GraphTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
});

Expand Down
Loading
Loading