Files
codebase-memory-mcp/graph-ui/src/components/DisplaySettingsMenu.tsx
T
Martin Vogel 8e65f8f1be feat(graph-ui): density-aware contrast + Display controls
High-density graphs washed out to a cyan-white blob: edges blend additively
and the per-node boost rewarded luminance, so ~80k overlapping edges plus the
white/yellow mid-tier stars saturated the center.

Compensate by density so contrast stays roughly constant as the graph grows:
- edges dim by ~1/sqrt(edgeCount) (they cause the blob) while nodes and bloom
  stay at full strength up to 25k nodes and only ease gently past that, so the
  bright-star look is preserved on moderate graphs;
- the per-node glow is now colour-aware by channel dominance instead of
  luminance: blue hubs (high-degree) glow brightest, red leaves modestly,
  white/yellow least — matching the star-class meaning and giving the graph
  depth instead of a uniform white core.

Add a Display menu (edge brightness / node glow / bloom multipliers, 0–3×,
persisted) layered on top of the adaptive defaults, so the look is tunable
live. A selection is never density-scaled and stays bright against the
dimmed rest.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-07 23:34:22 +02:00

150 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}