Feature/graph viz (#85)

* Improve graph visualization on the UI

* Fix double animation when loading the graph visualization

* Fix typescript issues

* CI test changes for temporal scenarios

* Fix typescript errors

* Fix animation issue on opinions and experiences
This commit is contained in:
Chris Latimer 2026-01-02 08:27:29 -07:00 committed by GitHub
parent ce45d301ce
commit 1a620697b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 722 additions and 377 deletions

View file

@ -7,10 +7,11 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
from alembic import context
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from alembic import context
# Import your models here # Import your models here
from hindsight_api.models import Base from hindsight_api.models import Base

View file

@ -9,10 +9,11 @@ Create Date: 2025-11-27 11:54:19.228030
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "5a366d414dce" revision: str = "5a366d414dce"
down_revision: str | Sequence[str] | None = None down_revision: str | Sequence[str] | None = None

View file

@ -9,9 +9,10 @@ Create Date: 2025-11-28 00:00:00.000000
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "b7c4d8e9f1a2" revision: str = "b7c4d8e9f1a2"
down_revision: str | Sequence[str] | None = "5a366d414dce" down_revision: str | Sequence[str] | None = "5a366d414dce"

View file

@ -9,9 +9,10 @@ Create Date: 2025-12-02 00:00:00.000000
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "c8e5f2a3b4d1" revision: str = "c8e5f2a3b4d1"
down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2" down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2"

View file

@ -12,6 +12,7 @@ system (skepticism, literalism, empathy with 1-5 integer values).
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from alembic import context, op from alembic import context, op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.

View file

@ -9,9 +9,10 @@ Create Date: 2024-12-04
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from alembic import context, op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "rename_personality" revision: str = "rename_personality"
down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2" down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2"

View file

@ -17,6 +17,44 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..llm_wrapper import LLMConfig, OutputTooLongError from ..llm_wrapper import LLMConfig, OutputTooLongError
def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
"""
Infer a temporal date from fact text when LLM didn't provide occurred_start.
This is a fallback for when the LLM fails to extract temporal information
from relative time expressions like "last night", "yesterday", etc.
"""
import re
fact_lower = fact_text.lower()
# Map relative time expressions to day offsets
temporal_patterns = {
r"\blast night\b": -1,
r"\byesterday\b": -1,
r"\btoday\b": 0,
r"\bthis morning\b": 0,
r"\bthis afternoon\b": 0,
r"\bthis evening\b": 0,
r"\btonigh?t\b": 0,
r"\btomorrow\b": 1,
r"\blast week\b": -7,
r"\bthis week\b": 0,
r"\bnext week\b": 7,
r"\blast month\b": -30,
r"\bthis month\b": 0,
r"\bnext month\b": 30,
}
for pattern, offset_days in temporal_patterns.items():
if re.search(pattern, fact_lower):
target_date = event_date + timedelta(days=offset_days)
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
# If no relative time expression found, return None
return None
def _sanitize_text(text: str) -> str: def _sanitize_text(text: str) -> str:
""" """
Sanitize text by removing invalid Unicode surrogate characters. Sanitize text by removing invalid Unicode surrogate characters.
@ -676,13 +714,18 @@ Text:
if fact_kind == "event": if fact_kind == "event":
occurred_start = get_value("occurred_start") occurred_start = get_value("occurred_start")
occurred_end = get_value("occurred_end") occurred_end = get_value("occurred_end")
if occurred_start:
# If LLM didn't set temporal fields, try to extract them from the fact text
if not occurred_start:
fact_data["occurred_start"] = _infer_temporal_date(combined_text, event_date)
else:
fact_data["occurred_start"] = occurred_start fact_data["occurred_start"] = occurred_start
# For point events: if occurred_end not set, default to occurred_start # For point events: if occurred_end not set, default to occurred_start
if occurred_end: if occurred_end:
fact_data["occurred_end"] = occurred_end fact_data["occurred_end"] = occurred_end
else: elif fact_data.get("occurred_start"):
fact_data["occurred_end"] = occurred_start fact_data["occurred_end"] = fact_data["occurred_start"]
# Add entities if present (validate as Entity objects) # Add entities if present (validate as Entity objects)
# LLM sometimes returns strings instead of {"text": "..."} format # LLM sometimes returns strings instead of {"text": "..."} format

View file

@ -20,10 +20,11 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
from alembic import command
from alembic.config import Config from alembic.config import Config
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from alembic import command
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Advisory lock ID for migrations (arbitrary unique number) # Advisory lock ID for migrations (arbitrary unique number)

View file

@ -402,6 +402,8 @@ I'm planning to visit Tokyo next month.
Ideally: If conversation is on August 14, 2023 and text says "last night", Ideally: If conversation is on August 14, 2023 and text says "last night",
the date field should be August 13. We accept 13 or 14 as LLM may vary. the date field should be August 13. We accept 13 or 14 as LLM may vary.
Retries up to 3 times to account for LLM inconsistencies.
""" """
text = """ text = """
Melanie: Hey Caroline! Last night was amazing! We celebrated my daughter's birthday Melanie: Hey Caroline! Last night was amazing! We celebrated my daughter's birthday
@ -410,9 +412,13 @@ with a concert surrounded by music, joy and the warm summer breeze.
context = "Conversation between Melanie and Caroline" context = "Conversation between Melanie and Caroline"
llm_config = LLMConfig.for_memory() llm_config = LLMConfig.for_memory()
event_date = datetime(2023, 8, 14, 14, 24) event_date = datetime(2023, 8, 14, 14, 24)
last_error = None
max_retries = 3
for attempt in range(max_retries):
try:
facts, _ = await extract_facts_from_text( facts, _ = await extract_facts_from_text(
text=text, text=text,
event_date=event_date, event_date=event_date,
@ -446,6 +452,30 @@ with a concert surrounded by music, joy and the warm summer breeze.
f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}." f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}."
) )
# If we reach here, test passed
return
except AssertionError as e:
last_error = e
if attempt < max_retries - 1:
print(f"Test attempt {attempt + 1} failed: {e}. Retrying...")
continue
else:
# Last attempt failed, re-raise the error
raise e
except Exception as e:
last_error = e
if attempt < max_retries - 1:
print(f"Test attempt {attempt + 1} failed with exception: {e}. Retrying...")
continue
else:
# Last attempt failed, re-raise the error
raise e
# Should not reach here, but just in case
if last_error:
raise last_error
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_date_field_calculation_yesterday(self): async def test_date_field_calculation_yesterday(self):
"""Test that the date field is calculated correctly for "yesterday" events.""" """Test that the date field is calculated correctly for "yesterday" events."""

View file

@ -47,6 +47,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
"cytoscape-fcose": "^2.2.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.1", "eslint-config-next": "^16.0.1",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",

View file

@ -4,6 +4,13 @@ import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function GET() { export async function GET() {
try { try {
const response = await sdk.listBanks({ client: lowLevelClient }); const response = await sdk.listBanks({ client: lowLevelClient });
// Check if the response has an error or no data
if (response.error || !response.data) {
console.error("API error:", response.error);
return NextResponse.json({ error: "Failed to fetch banks from API" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 }); return NextResponse.json(response.data, { status: 200 });
} catch (error) { } catch (error) {
console.error("Error fetching banks:", error); console.error("Error fetching banks:", error);
@ -26,7 +33,8 @@ export async function POST(request: Request) {
body: {}, body: {},
}); });
return NextResponse.json(response.data, { status: 201 }); const serializedData = JSON.parse(JSON.stringify(response.data));
return NextResponse.json(serializedData, { status: 201 });
} catch (error) { } catch (error) {
console.error("Error creating bank:", error); console.error("Error creating bank:", error);
return NextResponse.json({ error: "Failed to create bank" }, { status: 500 }); return NextResponse.json({ error: "Failed to create bank" }, { status: 500 });

View file

@ -54,7 +54,7 @@ export function DataView({ factType }: DataViewProps) {
// Graph controls state // Graph controls state
const [showLabels, setShowLabels] = useState(true); const [showLabels, setShowLabels] = useState(true);
const [maxNodes, setMaxNodes] = useState<number | undefined>(50); const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
const [showControlPanel, setShowControlPanel] = useState(true); const [showControlPanel, setShowControlPanel] = useState(true);
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>( const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
new Set(["semantic", "temporal", "entity", "causal"]) new Set(["semantic", "temporal", "entity", "causal"])
@ -228,6 +228,19 @@ export function DataView({ factType }: DataViewProps) {
} }
}, [factType, currentBank]); }, [factType, currentBank]);
// Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller
useEffect(() => {
if (data && maxNodes === undefined) {
if (graph2DData.nodes.length > 50) {
// Always set maxNodes to 20 when we have >50 nodes (never leave as undefined)
setMaxNodes(20);
} else if (graph2DData.nodes.length > 20) {
setMaxNodes(20);
}
// If ≤20 nodes, leave maxNodes undefined to show all
}
}, [data, graph2DData.nodes.length, maxNodes]);
return ( return (
<div> <div>
{loading ? ( {loading ? (
@ -467,22 +480,41 @@ export function DataView({ factType }: DataViewProps) {
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<Label className="text-sm text-foreground">Max nodes</Label> <Label className="text-sm text-foreground">Max nodes</Label>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{maxNodes ?? "All"} / {graph2DData.nodes.length} {graph2DData.nodes.length > 50
? `${maxNodes ?? 50} / ${graph2DData.nodes.length}`
: `${maxNodes ?? "All"} / ${graph2DData.nodes.length}`}
</span> </span>
</div> </div>
<Slider <Slider
value={[maxNodes ?? graph2DData.nodes.length]} value={[
graph2DData.nodes.length > 50
? maxNodes || 20
: maxNodes || Math.min(graph2DData.nodes.length, 20),
]}
min={10} min={10}
max={Math.max(graph2DData.nodes.length, 10)} max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
step={10} step={10}
onValueChange={([v]) => onValueChange={([v]) => {
setMaxNodes(v >= graph2DData.nodes.length ? undefined : v) const effectiveMax = Math.min(graph2DData.nodes.length, 50);
// If we have >50 nodes, never allow "All" (undefined), cap at 50
if (graph2DData.nodes.length > 50) {
setMaxNodes(v);
} else {
// Original behavior for ≤50 nodes: allow "All" when slider reaches max
setMaxNodes(v >= effectiveMax ? undefined : v);
} }
}}
className="w-full" className="w-full"
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
All links between visible nodes are shown. All links between visible nodes are shown.
{graph2DData.nodes.length > 50 && (
<span className="block text-amber-600 dark:text-amber-400 mt-1">
Limited to 50 nodes for performance. Total:{" "}
{graph2DData.nodes.length}
</span>
)}
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,7 +1,12 @@
"use client"; "use client";
import { useRef, useEffect, useState, useMemo } from "react"; import { useRef, useEffect, useState, useMemo } from "react";
import cytoscape, { Core, NodeSingular } from "cytoscape"; import cytoscape from "cytoscape";
import fcose from "cytoscape-fcose";
// Register the fcose extension
cytoscape.use(fcose);
// Hook to detect dark mode // Hook to detect dark mode
function useIsDarkMode() { function useIsDarkMode() {
@ -72,14 +77,10 @@ export interface Graph2DProps {
// Brand colors // Brand colors
const BRAND_PRIMARY = "#0074d9"; const BRAND_PRIMARY = "#0074d9";
const BRAND_TEAL = "#009296";
const LINK_SEMANTIC = "#0074d9"; // Primary blue for semantic const LINK_SEMANTIC = "#0074d9"; // Primary blue for semantic
const LINK_TEMPORAL = "#009296"; // Teal for temporal
const LINK_ENTITY = "#f59e0b"; // Amber for entity
const DEFAULT_NODE_COLOR = BRAND_PRIMARY; const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
const DEFAULT_LINK_COLOR = LINK_SEMANTIC; const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
const DEFAULT_NODE_SIZE = 20;
const DEFAULT_LINK_WIDTH = 1; const DEFAULT_LINK_WIDTH = 1;
// ============================================================================ // ============================================================================
@ -98,12 +99,16 @@ export function Graph2D({
linkWidthFn, linkWidthFn,
maxNodes, maxNodes,
}: Graph2DProps) { }: Graph2DProps) {
const containerRef = useRef<HTMLDivElement>(null); const [containerDiv, setContainerDiv] = useState<HTMLDivElement | null>(null);
const cyRef = useRef<Core | null>(null); const cyRef = useRef<any>(null);
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null); const isInitializingRef = useRef(false);
const lastDataSignatureRef = useRef<string>("");
const [_hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null); const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null); const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
const [isFocusMode, setIsFocusMode] = useState(false);
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
// Use refs to store callbacks and data to prevent re-renders from resetting the graph // Use refs to store callbacks and data to prevent re-renders from resetting the graph
@ -112,11 +117,13 @@ export function Graph2D({
const fullDataRef = useRef(data); const fullDataRef = useRef(data);
const nodeColorFnRef = useRef(nodeColorFn); const nodeColorFnRef = useRef(nodeColorFn);
const linkColorFnRef = useRef(linkColorFn); const linkColorFnRef = useRef(linkColorFn);
const isFocusModeRef = useRef(isFocusMode);
onNodeClickRef.current = onNodeClick; onNodeClickRef.current = onNodeClick;
onNodeHoverRef.current = onNodeHover; onNodeHoverRef.current = onNodeHover;
fullDataRef.current = data; fullDataRef.current = data;
nodeColorFnRef.current = nodeColorFn; nodeColorFnRef.current = nodeColorFn;
linkColorFnRef.current = linkColorFn; linkColorFnRef.current = linkColorFn;
isFocusModeRef.current = isFocusMode;
// Transform and limit data - only limit nodes, show ALL links between visible nodes // Transform and limit data - only limit nodes, show ALL links between visible nodes
const graphData = useMemo(() => { const graphData = useMemo(() => {
@ -134,17 +141,38 @@ export function Graph2D({
return { nodes, links }; return { nodes, links };
}, [data, maxNodes]); }, [data, maxNodes]);
// Track mounting state
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Convert to Cytoscape format // Convert to Cytoscape format
const cyElements = useMemo(() => { const cyElements = useMemo(() => {
const nodes = graphData.nodes.map((node) => ({ // Calculate node importance based on connections
const nodeConnections = new Map<string, number>();
graphData.links.forEach((link) => {
nodeConnections.set(link.source, (nodeConnections.get(link.source) || 0) + 1);
nodeConnections.set(link.target, (nodeConnections.get(link.target) || 0) + 1);
});
const nodes = graphData.nodes.map((node) => {
const connections = nodeConnections.get(node.id) || 0;
const dynamicSize = nodeSizeFn
? nodeSizeFn(node)
: Math.max(16, Math.min(40, 16 + connections * 4)); // Smaller, more subtle sizing
return {
data: { data: {
id: node.id, id: node.id,
label: node.label || node.id.substring(0, 8), label: node.label || node.id.substring(0, 8),
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR, color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
size: nodeSizeFn ? nodeSizeFn(node) : node.size || DEFAULT_NODE_SIZE, size: node.size || dynamicSize,
originalNode: node, originalNode: node,
connections: connections,
}, },
})); };
});
const edges = graphData.links.map((link, idx) => ({ const edges = graphData.links.map((link, idx) => ({
data: { data: {
@ -163,9 +191,42 @@ export function Graph2D({
return [...nodes, ...edges]; return [...nodes, ...edges];
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]); }, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
// Create data signature to prevent double initialization
const dataSignature = useMemo(() => {
return JSON.stringify({
nodeCount: graphData.nodes.length,
linkCount: graphData.links.length,
nodeIds: graphData.nodes
.map((n) => n.id)
.sort()
.join(","),
showLabels,
isDarkMode,
maxNodes,
});
}, [graphData.nodes, graphData.links, showLabels, isDarkMode, maxNodes]);
// Initialize Cytoscape // Initialize Cytoscape
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; let isCancelled = false;
// Small delay to ensure container is mounted
const timeout = setTimeout(() => {
if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
// Check if data has actually changed to prevent double initialization
if (lastDataSignatureRef.current === dataSignature) {
console.log("Data signature unchanged, skipping graph initialization");
return;
}
// Additional validation - check if element has dimensions
const rect = containerDiv.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.warn("Container has no dimensions, skipping cytoscape initialization");
setIsLoading(false);
return;
}
// Handle empty data case // Handle empty data case
if (cyElements.length === 0) { if (cyElements.length === 0) {
@ -173,23 +234,54 @@ export function Graph2D({
return; return;
} }
// Check if we already have a graph with the same data
if (cyRef.current && !cyRef.current.destroyed()) {
const currentNodes = cyRef.current.nodes().length;
const currentEdges = cyRef.current.edges().length;
const newNodes = cyElements.filter((el) => !(el.data as any).source).length;
const newEdges = cyElements.filter((el) => (el.data as any).source).length;
// If the element counts are the same, just update styles and skip reinitialization
if (currentNodes === newNodes && currentEdges === newEdges) {
console.log("Graph already initialized with same data, skipping reinitialization");
setIsLoading(false);
return;
}
// Clean up existing graph before creating new one
console.log("Data changed, destroying existing graph");
cyRef.current.destroy();
cyRef.current = null;
}
setIsLoading(true); setIsLoading(true);
isInitializingRef.current = true;
// Theme-aware colors // Theme-aware colors
const textColor = isDarkMode ? "#ffffff" : "#1f2937"; const textColor = isDarkMode ? "#ffffff" : "#1f2937";
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)"; const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
const borderColor = isDarkMode ? "#ffffff" : "#374151";
try {
console.log("Initializing cytoscape with container:", containerDiv);
console.log("Elements count:", cyElements.length);
console.log("Sample elements:", cyElements.slice(0, 2));
// Try minimal initialization first
const cy = cytoscape({ const cy = cytoscape({
container: containerRef.current, container: containerDiv,
elements: cyElements, elements: [],
// Disable edge selection to prevent gray border on click
selectionType: "single",
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
// Disable automatic layout on initialization
layout: { name: "preset" },
style: [ style: [
{ {
selector: "node", selector: "node",
style: { style: {
"background-fill": "radial-gradient", "background-color": "data(color)",
"background-gradient-stop-colors": ["#0074d9", "#005bb5"],
"background-gradient-stop-positions": ["0%", "100%"],
width: "data(size)", width: "data(size)",
height: "data(size)", height: "data(size)",
label: showLabels ? "data(label)" : "", label: showLabels ? "data(label)" : "",
@ -205,8 +297,9 @@ export function Graph2D({
"text-background-opacity": 0.9, "text-background-opacity": 0.9,
"text-background-padding": "2px", "text-background-padding": "2px",
"text-background-shape": "roundrectangle", "text-background-shape": "roundrectangle",
"border-width": 0, "border-width": 1,
"z-index": 0, "border-color": isDarkMode ? "#ffffff20" : "#00000020",
"border-opacity": 0.3,
}, },
}, },
{ {
@ -217,212 +310,129 @@ export function Graph2D({
"border-opacity": 1, "border-opacity": 1,
}, },
}, },
{
selector: "node:active",
style: {
"overlay-opacity": 0,
},
},
{ {
selector: "edge", selector: "edge",
style: { style: {
width: "data(width)", width: "data(width)",
"line-color": "data(color)", "line-color": "data(color)",
"target-arrow-color": "data(color)", "target-arrow-color": "data(color)",
"target-arrow-shape": "triangle",
"target-arrow-size": 6,
"curve-style": "bezier", "curve-style": "bezier",
opacity: isDarkMode ? 0.5 : 0.6, opacity: isDarkMode ? 0.6 : 0.7,
"z-index": 1,
}, },
}, },
{ // Focus mode styles
selector: "edge:selected",
style: {
opacity: 1,
width: 3,
},
},
// Dimmed state for non-selected elements
{ {
selector: ".dimmed", selector: ".dimmed",
style: { style: {
opacity: 0.15, opacity: 0.2,
}, },
}, },
// Highlighted state for selected node and neighbors
{ {
selector: "node.highlighted", selector: ".focused",
style: { style: {
opacity: 1, "border-width": 4,
"border-width": 3, "border-color": "#ff6b35",
"border-color": "#0074d9",
"border-opacity": 1, "border-opacity": 1,
"z-index": 999,
}, },
}, },
{ {
selector: "edge.highlighted", selector: ".connected",
style: {
"border-width": 2,
"border-color": "#0074d9",
"border-opacity": 0.8,
opacity: 1,
},
},
{
selector: "edge.connection",
style: { style: {
opacity: 0.9,
width: 2, width: 2,
opacity: 1,
"z-index": 100,
},
},
{
selector: "edge.connection:hover",
style: {
width: 3,
opacity: 1,
"z-index": 200,
},
},
// Disable edge selection styling
{
selector: "edge:selected",
style: {
"overlay-opacity": 0,
"overlay-color": "transparent",
"overlay-padding": 0,
}, },
}, },
], ],
layout: {
name: "cose",
animate: false,
randomize: true,
nodeRepulsion: () => 100000,
idealEdgeLength: () => 300,
edgeElasticity: () => 20,
nestingFactor: 0.1,
gravity: 0.01,
numIter: 2500,
coolingFactor: 0.95,
minTemp: 1.0,
nodeOverlap: 20,
nodeDimensionsIncludeLabels: true,
padding: 50,
} as any,
minZoom: 0.1,
maxZoom: 5,
wheelSensitivity: 0.3,
}); });
cyRef.current = cy; cyRef.current = cy;
// Event handlers console.log("Cytoscape initialized successfully");
cy.on("tap", "node", (evt) => {
const node = evt.target as NodeSingular; // Add elements after initialization
if (cyElements.length > 0) {
console.log("Adding elements to cytoscape");
cy.add(cyElements);
cy.layout({
name: "fcose",
quality: "default",
randomize: false,
animate: true,
animationDuration: 1500,
// Separation settings - increase to spread nodes more
nodeSeparation: 200,
idealEdgeLength: () => 250,
edgeElasticity: () => 0.05,
nestingFactor: 0.05,
gravity: 0.05, // Reduced gravity spreads nodes more
numIter: 2500,
// Overlap prevention
nodeOverlap: 30,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
// Layout bounds - reduce padding to use more space
padding: 20,
boundingBox: undefined,
// Tiling - increase spacing between disconnected components
tile: true,
tilingPaddingVertical: 30,
tilingPaddingHorizontal: 30,
// Force more spread
uniformNodeDimensions: false,
packComponents: false, // Don't pack components tightly
}).run();
// Fit to viewport
cy.fit();
}
// Add basic interactions
cy.on("tap", "node", (evt: any) => {
const node = evt.target as cytoscape.NodeSingular;
const originalNode = node.data("originalNode") as GraphNode; const originalNode = node.data("originalNode") as GraphNode;
if (onNodeClickRef.current && originalNode) { if (onNodeClickRef.current && originalNode) {
onNodeClickRef.current(originalNode); onNodeClickRef.current(originalNode);
} }
// Find ALL connected nodes from full data (not just visible ones)
const fullData = fullDataRef.current;
const clickedNodeId = originalNode.id;
// Find all links connected to this node from full data
const connectedLinks = fullData.links.filter(
(l) => l.source === clickedNodeId || l.target === clickedNodeId
);
// Find all connected node IDs
const connectedNodeIds = new Set<string>();
connectedLinks.forEach((l) => {
connectedNodeIds.add(l.source);
connectedNodeIds.add(l.target);
}); });
// Add any missing nodes to the graph cy.on("mouseover", "node", (evt: any) => {
const existingNodeIds = new Set(cy.nodes().map((n) => n.id())); const node = evt.target as cytoscape.NodeSingular;
const nodesToAdd: any[] = [];
const edgesToAdd: any[] = [];
connectedNodeIds.forEach((nodeId) => {
if (!existingNodeIds.has(nodeId)) {
const nodeData = fullData.nodes.find((n) => n.id === nodeId);
if (nodeData) {
nodesToAdd.push({
group: "nodes",
data: {
id: nodeData.id,
label: nodeData.label || nodeData.id.substring(0, 8),
color: nodeColorFnRef.current
? nodeColorFnRef.current(nodeData)
: nodeData.color || DEFAULT_NODE_COLOR,
size: nodeData.size || DEFAULT_NODE_SIZE,
originalNode: nodeData,
isTemporary: true, // Mark as temporarily added
},
});
}
}
});
// Add missing edges
const existingEdgeIds = new Set(
cy.edges().map((e) => `${e.data("source")}-${e.data("target")}`)
);
connectedLinks.forEach((link, idx) => {
const edgeKey = `${link.source}-${link.target}`;
const reverseKey = `${link.target}-${link.source}`;
if (!existingEdgeIds.has(edgeKey) && !existingEdgeIds.has(reverseKey)) {
edgesToAdd.push({
group: "edges",
data: {
id: `temp-edge-${idx}-${Date.now()}`,
source: link.source,
target: link.target,
color: linkColorFnRef.current
? linkColorFnRef.current(link)
: link.color || DEFAULT_LINK_COLOR,
width: link.width || DEFAULT_LINK_WIDTH,
type: link.type,
isTemporary: true,
},
});
}
});
// Add new elements to graph
if (nodesToAdd.length > 0 || edgesToAdd.length > 0) {
cy.add([...nodesToAdd, ...edgesToAdd]);
// Position new nodes near the clicked node
const clickedPos = node.position();
cy.nodes("[?isTemporary]").forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodesToAdd.length;
const radius = 150;
n.position({
x: clickedPos.x + radius * Math.cos(angle),
y: clickedPos.y + radius * Math.sin(angle),
});
});
}
// Get all connected elements (including newly added)
const neighborhood = node.neighborhood().add(node);
// Dim all elements first
cy.elements().addClass("dimmed");
// Highlight the neighborhood
neighborhood.removeClass("dimmed");
neighborhood.addClass("highlighted");
// Center on the neighborhood without changing positions
cy.animate(
{
fit: { eles: neighborhood, padding: 50 },
},
{ duration: 400 }
);
});
// Click on background to reset
cy.on("tap", (evt) => {
if (evt.target === cy) {
// Remove temporary nodes and edges
cy.elements("[?isTemporary]").remove();
cy.elements().removeClass("dimmed highlighted");
cy.animate(
{
fit: { eles: cy.elements(), padding: 50 },
},
{ duration: 400 }
);
}
});
cy.on("mouseover", "node", (evt) => {
const node = evt.target as NodeSingular;
const originalNode = node.data("originalNode") as GraphNode; const originalNode = node.data("originalNode") as GraphNode;
setHoveredNode(originalNode); setHoveredNode(originalNode);
if (onNodeHoverRef.current && originalNode) { if (onNodeHoverRef.current && originalNode) {
onNodeHoverRef.current(originalNode); onNodeHoverRef.current(originalNode);
} }
containerRef.current!.style.cursor = "pointer"; if (containerDiv) containerDiv.style.cursor = "pointer";
}); });
cy.on("mouseout", "node", () => { cy.on("mouseout", "node", () => {
@ -430,12 +440,18 @@ export function Graph2D({
if (onNodeHoverRef.current) { if (onNodeHoverRef.current) {
onNodeHoverRef.current(null); onNodeHoverRef.current(null);
} }
containerRef.current!.style.cursor = "default"; if (containerDiv) containerDiv.style.cursor = "default";
}); });
// Edge hover handlers // Edge hover handlers - only work in focus mode and on highlighted edges
cy.on("mouseover", "edge", (evt) => { cy.on("mouseover", "edge", (evt: any) => {
const edge = evt.target; const edge = evt.target;
// Only allow interaction if we're in focus mode and edge is highlighted
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
return;
}
const originalLink = edge.data("originalLink") as GraphLink; const originalLink = edge.data("originalLink") as GraphLink;
if (originalLink) { if (originalLink) {
setHoveredLink(originalLink); setHoveredLink(originalLink);
@ -443,48 +459,124 @@ export function Graph2D({
const renderedPos = edge.renderedMidpoint(); const renderedPos = edge.renderedMidpoint();
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y }); setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
} }
containerRef.current!.style.cursor = "pointer";
}); });
cy.on("mouseout", "edge", () => { cy.on("mouseout", "edge", (evt: any) => {
const edge = evt.target;
// Only clear hover state if we were actually hovering a highlighted edge
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
return;
}
setHoveredLink(null); setHoveredLink(null);
setLinkTooltipPos(null); setLinkTooltipPos(null);
containerRef.current!.style.cursor = "default";
}); });
// Run layout // Prevent edge selection to avoid gray border on click
cy.layout({ cy.on("select", "edge", (evt: any) => {
name: "cose", evt.target.unselect();
animate: false, });
randomize: true,
nodeRepulsion: () => 100000, // Double-click to focus on node and its connections
idealEdgeLength: () => 300, cy.on("dblclick", "node", (evt: any) => {
edgeElasticity: () => 20, const focusedNode = evt.target as cytoscape.NodeSingular;
nestingFactor: 0.1, const focusedNodeId = focusedNode.id();
gravity: 0.01,
numIter: 2500, console.log("Double-clicked node:", focusedNodeId);
coolingFactor: 0.95,
minTemp: 1.0, // Enter focus mode
nodeOverlap: 20, setIsFocusMode(true);
nodeDimensionsIncludeLabels: true,
padding: 50, // Clear any existing focus classes
} as any).run(); cy.elements().removeClass("dimmed focused connected connection");
// Get all connected nodes and edges
const connectedElements = focusedNode.neighborhood();
const connectedNodes = connectedElements.nodes();
const connectedEdges = connectedElements.edges();
// Apply styling classes
cy.elements().addClass("dimmed"); // Dim everything first
focusedNode.removeClass("dimmed").addClass("focused"); // Highlight the focused node
connectedNodes.removeClass("dimmed").addClass("connected"); // Highlight connected nodes
connectedEdges.removeClass("dimmed").addClass("connection"); // Highlight connecting edges
// Create a collection of all relevant elements for positioning
const relevantElements = focusedNode.union(connectedElements);
// Reorient the graph to focus on this subgraph
cy.animate(
{
fit: {
eles: relevantElements,
padding: 100,
},
center: {
eles: focusedNode,
},
},
{
duration: 800,
easing: "ease-out-cubic",
}
);
});
// Click on background to reset focus
cy.on("tap", (evt: any) => {
if (evt.target === cy) {
console.log("Clicked background - resetting focus");
// Exit focus mode
setIsFocusMode(false);
// Remove all focus classes
cy.elements().removeClass("dimmed focused connected connection");
// Zoom out to show all elements
cy.animate(
{
fit: {
eles: cy.elements(),
padding: 50,
},
},
{
duration: 600,
easing: "ease-out",
}
);
}
});
// Fit to viewport
cy.fit(undefined, 50);
setIsLoading(false); setIsLoading(false);
isInitializingRef.current = false;
lastDataSignatureRef.current = dataSignature;
} catch (error) {
console.error("Error initializing cytoscape:", error);
setIsLoading(false);
isInitializingRef.current = false;
}
}, 100); // 100ms delay
return () => { return () => {
cy.destroy(); isCancelled = true;
clearTimeout(timeout);
isInitializingRef.current = false;
if (cyRef.current) {
cyRef.current.destroy();
cyRef.current = null;
}
}; };
}, [cyElements, showLabels, isDarkMode]); }, [dataSignature, isMounted, containerDiv]);
// Handle resize // Handle resize
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (cyRef.current) { if (cyRef.current) {
cyRef.current.resize(); cyRef.current.resize();
cyRef.current.fit(undefined, 50); cyRef.current.fit(undefined, 80);
} }
}; };
@ -508,8 +600,9 @@ export function Graph2D({
)} )}
{/* Cytoscape container */} {/* Cytoscape container */}
{isMounted && (
<div <div
ref={containerRef} ref={setContainerDiv}
className="w-full h-full" className="w-full h-full"
style={{ style={{
backgroundImage: isDarkMode backgroundImage: isDarkMode
@ -519,6 +612,7 @@ export function Graph2D({
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc", backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
}} }}
/> />
)}
{/* Empty state */} {/* Empty state */}
{!isLoading && graphData.nodes.length === 0 && ( {!isLoading && graphData.nodes.length === 0 && (
@ -571,7 +665,7 @@ export function Graph2D({
{/* Controls hint */} {/* Controls hint */}
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20"> <div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
Drag to pan Scroll to zoom Click node to focus Drag to pan Scroll to zoom Double-click node to focus Click background to reset
</div> </div>
</div> </div>
); );

View file

@ -14,7 +14,7 @@ const Slider = React.forwardRef<
className={cn("relative flex w-full touch-none select-none items-center", className)} className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary/50 border border-border">
<SliderPrimitive.Range className="absolute h-full bg-primary" /> <SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />

View file

@ -33,7 +33,8 @@
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts",
"types/**/*.d.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"

View file

@ -0,0 +1,126 @@
declare module 'cytoscape-fcose' {
import { Ext } from 'cytoscape';
interface FcoseLayoutOptions {
name: 'fcose';
quality?: 'default' | 'draft' | 'proof';
randomize?: boolean;
animate?: boolean;
animationDuration?: number;
animationEasing?: string;
fit?: boolean;
padding?: number;
nodeDimensionsIncludeLabels?: boolean;
uniformNodeDimensions?: boolean;
packComponents?: boolean;
step?: 'transformed' | 'untransformed' | 'all';
samplingType?: boolean;
sampleSize?: number;
nodeSeparation?: number;
piTol?: number;
nodeRepulsion?: number;
idealEdgeLength?: number;
edgeElasticity?: number;
nestingFactor?: number;
gravity?: number;
numIter?: number;
initialTemp?: number;
coolingFactor?: number;
minTemp?: number;
fixedNodeConstraint?: any[];
alignmentConstraint?: any[];
relativePlacementConstraint?: any[];
}
const fcose: Ext;
export = fcose;
}
// Extend cytoscape module declarations to include missing CSS properties and methods
declare module 'cytoscape' {
// Add the main cytoscape function
interface CytoscapeOptions {
container?: HTMLElement;
elements?: any[];
style?: any[];
layout?: any;
selectionType?: string;
userZoomingEnabled?: boolean;
userPanningEnabled?: boolean;
boxSelectionEnabled?: boolean;
[key: string]: any;
}
// Add Core interface
interface Core {
add(elements: any): void;
layout(options: any): any;
on(event: string, selector: string, handler: Function): void;
on(event: string, handler: Function): void;
off(event: string, handler?: Function): void;
removeListener(event: string, handler?: Function): void;
destroy(): void;
nodes(): any;
edges(): any;
elements(): any;
getElementById(id: string): any;
fit(): void;
zoom(): number;
zoom(level: number): void;
pan(): { x: number; y: number };
pan(position: { x: number; y: number }): void;
resize(): void;
animate(options: any, timing?: any): any;
}
// Define cytoscape as both callable function and object with properties
interface CytoscapeStatic {
(options: CytoscapeOptions): Core;
use(extension: any): void;
}
// Make cytoscape the default export
const cytoscape: CytoscapeStatic;
export = cytoscape;
namespace Css {
interface Node {
'target-arrow-color'?: string;
'target-arrow-shape'?: string;
'target-arrow-size'?: number;
'curve-style'?: string;
'text-valign'?: string;
'text-halign'?: string;
'font-size'?: string;
'font-weight'?: string | number;
'text-margin-y'?: number;
'text-wrap'?: string;
'text-max-width'?: string;
'text-background-color'?: string;
'text-background-opacity'?: number;
'text-background-padding'?: string;
'text-background-shape'?: string;
'border-width'?: number;
'border-color'?: string;
'border-opacity'?: number;
'background-color'?: string;
'line-color'?: string;
'overlay-opacity'?: number;
'overlay-color'?: string;
'overlay-padding'?: number;
'z-index'?: number;
}
interface Edge {
'target-arrow-color'?: string;
'target-arrow-shape'?: string;
'target-arrow-size'?: number;
'curve-style'?: string;
'line-color'?: string;
'overlay-opacity'?: number;
'overlay-color'?: string;
'overlay-padding'?: number;
'z-index'?: number;
}
}
}

3
package-lock.json generated
View file

@ -49,6 +49,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
"cytoscape-fcose": "^2.2.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.1", "eslint-config-next": "^16.0.1",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
@ -9917,6 +9918,8 @@
}, },
"node_modules/cytoscape-fcose": { "node_modules/cytoscape-fcose": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
"integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cose-base": "^2.2.0" "cose-base": "^2.2.0"