diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index c213266..9692c1f 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -112,13 +112,13 @@ export interface RendererCallbacks { const LABEL_SIZE = 12; // px — the default label size (settings.labelSize scales it) const LABEL_WEIGHT = "500"; const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip -const LABEL_GAP = 6; // px between node edge and first label line +const LABEL_GAP = 2; // px between node edge and first label line (was 6 — user: way too far) const LABEL_LINE_HEIGHT = 1.3; /** Zoom span (camera-ratio units) over which labels fade together. */ export const LABELS_FADE_SPAN = 0.5; -const GLOW_PAD = 6; // px the halo extends beyond the node disc (constant) -const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward +const GLOW_SCALE = 1.7; // halo outer radius = node radius x this (proportional, like blurring a copy of the disc) +const GLOW_ALPHA = 0.3; // halo opacity at the disc edge, fading outward const HOVER_FADE_MS = 180; // hover halo opacity fade length const ZOOM_EASE_MS = 45; // wheel-zoom glide: exponential-ease time constant (~250ms per notch) @@ -327,6 +327,11 @@ export class GraphCanvasRenderer { // Hover transition: fade 0 = resting, 1 = fully hovered; the animating // flag keeps the loop scheduled until the fade settles (then rAF stops). private hoverId: string | null = null; + /** The node the glow is drawn on. OUTLIVES hoverId on hover-out so the + * fade has something to draw (hoverId nulls instantly; without this + * the glow popped off in one frame instead of fading). Cleared when + * the outgoing fade completes. */ + private hoverNode: CanvasNode | null = null; private hoverFade = 0; private hoverFrom = 0; private hoverTarget = 0; @@ -548,6 +553,9 @@ export class GraphCanvasRenderer { private setHover(nodeId: string | null): void { if (nodeId === this.hoverId && this.hoverTarget === (nodeId ? 1 : 0)) return; this.hoverId = nodeId; + // Hover-out deliberately KEEPS hoverNode: the glow must fade out + // from the last hovered node, not vanish with the id. + if (nodeId) this.hoverNode = this.nodeById.get(nodeId) ?? null; // Rebase the animation from the CURRENT fade: rapid hover toggles // glide instead of snapping (ported from the sigma-era behavior). this.hoverFrom = this.hoverFade; @@ -596,6 +604,8 @@ export class GraphCanvasRenderer { if (this.hoverFade === this.hoverTarget) return false; const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1); this.hoverFade = this.hoverFrom + (this.hoverTarget - this.hoverFrom) * t; + // Outgoing fade finished — release the glow node. + if (this.hoverTarget === 0 && this.hoverFade === 0) this.hoverNode = null; return t < 1; } @@ -655,7 +665,7 @@ export class GraphCanvasRenderer { const nodeR = settings.nodeSize * s; this.world = { nodeR, - glowOuter: (settings.nodeSize + GLOW_PAD) * s, + glowOuter: nodeR * GLOW_SCALE, labelSize: settings.labelSize * s, labelGap: LABEL_GAP * s, }; @@ -813,10 +823,10 @@ export class GraphCanvasRenderer { // name, both on the SAME fade factor. World units like everything // else. Shows even when labels are zoomed out (hover is its own // affordance, not a label). - if (this.hoverId && this.hoverFade > 0.02) { - const n = this.visible && !this.visible.has(this.hoverId) + if (this.hoverNode && this.hoverFade > 0.02) { + const n = this.visible && !this.visible.has(this.hoverNode.id) ? null // filtered out by search — no glow on hidden nodes - : this.nodeById.get(this.hoverId); + : this.hoverNode; if (n) { const glow = ctx.createRadialGradient(n.x, n.y, nodeR, n.x, n.y, glowOuter); glow.addColorStop(0, rgbaFromHex(data.colors.node, GLOW_ALPHA * this.hoverFade)); diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 7be2d28..6e58b61 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -69,11 +69,17 @@ import forceAtlas2 from "graphology-layout-forceatlas2"; import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { + ARROW_LENGTH_RANGE, DEFAULT_RENDER_SETTINGS, + EDGE_WIDTH_RANGE, + LABEL_SIZE_RANGE, + LABEL_ZOOM_MAX, + LABEL_ZOOM_MIN, + NODE_SIZE_RANGE, + fromSliderPosition, labelZoomDisplay, - labelZoomToSlider, sanitizeRenderSettings, - sliderToLabelZoom, + toSliderPosition, } from "@/src/core"; /** The uniform node look (no state classes — canvas wants hex, not oklch): @@ -133,6 +139,11 @@ interface Selection { } /** One labeled range row for the rendering-settings panel. */ +/** Compact size display for the log sliders: "2", "1.6", "0.75". */ +function sizeDisplay(v: number): string { + return `${v >= 1 ? Math.round(v * 10) / 10 : Math.round(v * 100) / 100}`; +} + function SettingSlider({ label, min, @@ -498,50 +509,58 @@ export function GraphView() { {settingsOpen && (
+ {/* All sliders: log-scaled 0-100, step 5, so every + DEFAULT sits at 50 (src/core/render-settings.ts). */} changeSettings((cur) => ({ ...cur, nodeSize: v }))} + min={0} + max={100} + step={5} + value={toSliderPosition(settings.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max)} + display={sizeDisplay(settings.nodeSize)} + onChange={(p) => + changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))} /> changeSettings((cur) => ({ ...cur, edgeWidth: v }))} + min={0} + max={100} + step={5} + value={toSliderPosition(settings.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max)} + display={sizeDisplay(settings.edgeWidth)} + onChange={(p) => + changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))} /> changeSettings((cur) => ({ ...cur, arrowLength: v }))} + min={0} + max={100} + step={5} + value={toSliderPosition(settings.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max)} + display={sizeDisplay(settings.arrowLength)} + onChange={(p) => + changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))} /> changeSettings((cur) => ({ ...cur, labelSize: v }))} + min={0} + max={100} + step={5} + value={toSliderPosition(settings.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max)} + display={sizeDisplay(settings.labelSize)} + onChange={(p) => + changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))} /> - {/* Log-scaled: the useful band (0.25–0.5 in camera-ratio - units) sits mid-slider; displayed units normalize the - default (0.5) to "1×". Slider position ↔ labelZoom via - src/core/render-settings.ts. */} + {/* Displayed units normalize the default (0.5) to "1×". */} changeSettings((cur) => ({ ...cur, labelZoom: sliderToLabelZoom(v) }))} + onChange={(p) => + changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))} />