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
if occurred_end: # For point events: if occurred_end not set, default to occurred_start
fact_data["occurred_end"] = occurred_end if occurred_end:
else: fact_data["occurred_end"] = occurred_end
fact_data["occurred_end"] = occurred_start elif fact_data.get("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,41 +412,69 @@ 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)
facts, _ = await extract_facts_from_text( last_error = None
text=text, max_retries = 3
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Melanie"
)
assert len(facts) > 0, "Should extract at least one fact" for attempt in range(max_retries):
try:
facts, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Melanie"
)
birthday_fact = None assert len(facts) > 0, "Should extract at least one fact"
for fact in facts:
if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower():
birthday_fact = fact
break
assert birthday_fact is not None, "Should extract fact about birthday celebration" birthday_fact = None
for fact in facts:
if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower():
birthday_fact = fact
break
fact_date_str = birthday_fact.occurred_start assert birthday_fact is not None, "Should extract fact about birthday celebration"
assert fact_date_str is not None, "occurred_start should not be None for temporal events"
if 'T' in fact_date_str: fact_date_str = birthday_fact.occurred_start
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00')) assert fact_date_str is not None, "occurred_start should not be None for temporal events"
else:
fact_date = datetime.fromisoformat(fact_date_str)
assert fact_date.year == 2023, "Year should be 2023" if 'T' in fact_date_str:
assert fact_date.month == 8, "Month should be August" fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
# Accept day 13 (ideal: last night) or 14 (conversation date) as valid else:
assert fact_date.day in (13, 14), ( fact_date = datetime.fromisoformat(fact_date_str)
f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}."
) assert fact_date.year == 2023, "Year should be 2023"
assert fact_date.month == 8, "Month should be August"
# Accept day 13 (ideal: last night) or 14 (conversation date) as valid
assert fact_date.day in (13, 14), (
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):

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
data: { const nodeConnections = new Map<string, number>();
id: node.id, graphData.links.forEach((link) => {
label: node.label || node.id.substring(0, 8), nodeConnections.set(link.source, (nodeConnections.get(link.source) || 0) + 1);
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR, nodeConnections.set(link.target, (nodeConnections.get(link.target) || 0) + 1);
size: nodeSizeFn ? nodeSizeFn(node) : node.size || DEFAULT_NODE_SIZE, });
originalNode: node,
}, 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: {
id: node.id,
label: node.label || node.id.substring(0, 8),
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
size: node.size || dynamicSize,
originalNode: node,
connections: connections,
},
};
});
const edges = graphData.links.map((link, idx) => ({ const edges = graphData.links.map((link, idx) => ({
data: { data: {
@ -163,328 +191,392 @@ 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;
// Handle empty data case // Small delay to ensure container is mounted
if (cyElements.length === 0) { const timeout = setTimeout(() => {
setIsLoading(false); if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
return;
}
setIsLoading(true); // Check if data has actually changed to prevent double initialization
if (lastDataSignatureRef.current === dataSignature) {
// Theme-aware colors console.log("Data signature unchanged, skipping graph initialization");
const textColor = isDarkMode ? "#ffffff" : "#1f2937"; return;
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
const borderColor = isDarkMode ? "#ffffff" : "#374151";
const cy = cytoscape({
container: containerRef.current,
elements: cyElements,
style: [
{
selector: "node",
style: {
"background-fill": "radial-gradient",
"background-gradient-stop-colors": ["#0074d9", "#005bb5"],
"background-gradient-stop-positions": ["0%", "100%"],
width: "data(size)",
height: "data(size)",
label: showLabels ? "data(label)" : "",
color: textColor,
"text-valign": "bottom",
"text-halign": "center",
"font-size": "8px",
"font-weight": 500,
"text-margin-y": 3,
"text-wrap": "wrap",
"text-max-width": "80px",
"text-background-color": textBgColor,
"text-background-opacity": 0.9,
"text-background-padding": "2px",
"text-background-shape": "roundrectangle",
"border-width": 0,
"z-index": 0,
},
},
{
selector: "node:selected",
style: {
"border-width": 3,
"border-color": "#0074d9",
"border-opacity": 1,
},
},
{
selector: "node:active",
style: {
"overlay-opacity": 0,
},
},
{
selector: "edge",
style: {
width: "data(width)",
"line-color": "data(color)",
"target-arrow-color": "data(color)",
"curve-style": "bezier",
opacity: isDarkMode ? 0.5 : 0.6,
"z-index": 1,
},
},
{
selector: "edge:selected",
style: {
opacity: 1,
width: 3,
},
},
// Dimmed state for non-selected elements
{
selector: ".dimmed",
style: {
opacity: 0.15,
},
},
// Highlighted state for selected node and neighbors
{
selector: "node.highlighted",
style: {
opacity: 1,
"border-width": 3,
"border-color": "#0074d9",
"border-opacity": 1,
},
},
{
selector: "edge.highlighted",
style: {
opacity: 0.9,
width: 2,
},
},
],
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;
// Event handlers
cy.on("tap", "node", (evt) => {
const node = evt.target as NodeSingular;
const originalNode = node.data("originalNode") as GraphNode;
if (onNodeClickRef.current && originalNode) {
onNodeClickRef.current(originalNode);
} }
// Find ALL connected nodes from full data (not just visible ones) // Additional validation - check if element has dimensions
const fullData = fullDataRef.current; const rect = containerDiv.getBoundingClientRect();
const clickedNodeId = originalNode.id; if (rect.width === 0 || rect.height === 0) {
console.warn("Container has no dimensions, skipping cytoscape initialization");
setIsLoading(false);
return;
}
// Find all links connected to this node from full data // Handle empty data case
const connectedLinks = fullData.links.filter( if (cyElements.length === 0) {
(l) => l.source === clickedNodeId || l.target === clickedNodeId setIsLoading(false);
); return;
}
// Find all connected node IDs // Check if we already have a graph with the same data
const connectedNodeIds = new Set<string>(); if (cyRef.current && !cyRef.current.destroyed()) {
connectedLinks.forEach((l) => { const currentNodes = cyRef.current.nodes().length;
connectedNodeIds.add(l.source); const currentEdges = cyRef.current.edges().length;
connectedNodeIds.add(l.target); const newNodes = cyElements.filter((el) => !(el.data as any).source).length;
}); const newEdges = cyElements.filter((el) => (el.data as any).source).length;
// Add any missing nodes to the graph // If the element counts are the same, just update styles and skip reinitialization
const existingNodeIds = new Set(cy.nodes().map((n) => n.id())); if (currentNodes === newNodes && currentEdges === newEdges) {
const nodesToAdd: any[] = []; console.log("Graph already initialized with same data, skipping reinitialization");
const edgesToAdd: any[] = []; setIsLoading(false);
return;
}
connectedNodeIds.forEach((nodeId) => { // Clean up existing graph before creating new one
if (!existingNodeIds.has(nodeId)) { console.log("Data changed, destroying existing graph");
const nodeData = fullData.nodes.find((n) => n.id === nodeId); cyRef.current.destroy();
if (nodeData) { cyRef.current = null;
nodesToAdd.push({ }
group: "nodes",
data: { setIsLoading(true);
id: nodeData.id, isInitializingRef.current = true;
label: nodeData.label || nodeData.id.substring(0, 8),
color: nodeColorFnRef.current // Theme-aware colors
? nodeColorFnRef.current(nodeData) const textColor = isDarkMode ? "#ffffff" : "#1f2937";
: nodeData.color || DEFAULT_NODE_COLOR, const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
size: nodeData.size || DEFAULT_NODE_SIZE,
originalNode: nodeData, try {
isTemporary: true, // Mark as temporarily added 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({
container: containerDiv,
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: [
{
selector: "node",
style: {
"background-color": "data(color)",
width: "data(size)",
height: "data(size)",
label: showLabels ? "data(label)" : "",
color: textColor,
"text-valign": "bottom",
"text-halign": "center",
"font-size": "8px",
"font-weight": 500,
"text-margin-y": 3,
"text-wrap": "wrap",
"text-max-width": "80px",
"text-background-color": textBgColor,
"text-background-opacity": 0.9,
"text-background-padding": "2px",
"text-background-shape": "roundrectangle",
"border-width": 1,
"border-color": isDarkMode ? "#ffffff20" : "#00000020",
"border-opacity": 0.3,
}, },
});
}
}
});
// 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,
}, },
}); {
} selector: "node:selected",
}); style: {
"border-width": 3,
// Add new elements to graph "border-color": "#0074d9",
if (nodesToAdd.length > 0 || edgesToAdd.length > 0) { "border-opacity": 1,
cy.add([...nodesToAdd, ...edgesToAdd]); },
},
// Position new nodes near the clicked node {
const clickedPos = node.position(); selector: "edge",
cy.nodes("[?isTemporary]").forEach((n, i) => { style: {
const angle = (2 * Math.PI * i) / nodesToAdd.length; width: "data(width)",
const radius = 150; "line-color": "data(color)",
n.position({ "target-arrow-color": "data(color)",
x: clickedPos.x + radius * Math.cos(angle), "target-arrow-shape": "triangle",
y: clickedPos.y + radius * Math.sin(angle), "target-arrow-size": 6,
}); "curve-style": "bezier",
opacity: isDarkMode ? 0.6 : 0.7,
},
},
// Focus mode styles
{
selector: ".dimmed",
style: {
opacity: 0.2,
},
},
{
selector: ".focused",
style: {
"border-width": 4,
"border-color": "#ff6b35",
"border-opacity": 1,
"z-index": 999,
},
},
{
selector: ".connected",
style: {
"border-width": 2,
"border-color": "#0074d9",
"border-opacity": 0.8,
opacity: 1,
},
},
{
selector: "edge.connection",
style: {
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,
},
},
],
}); });
cyRef.current = cy;
console.log("Cytoscape initialized successfully");
// 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;
if (onNodeClickRef.current && originalNode) {
onNodeClickRef.current(originalNode);
}
});
cy.on("mouseover", "node", (evt: any) => {
const node = evt.target as cytoscape.NodeSingular;
const originalNode = node.data("originalNode") as GraphNode;
setHoveredNode(originalNode);
if (onNodeHoverRef.current && originalNode) {
onNodeHoverRef.current(originalNode);
}
if (containerDiv) containerDiv.style.cursor = "pointer";
});
cy.on("mouseout", "node", () => {
setHoveredNode(null);
if (onNodeHoverRef.current) {
onNodeHoverRef.current(null);
}
if (containerDiv) containerDiv.style.cursor = "default";
});
// Edge hover handlers - only work in focus mode and on highlighted edges
cy.on("mouseover", "edge", (evt: any) => {
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;
if (originalLink) {
setHoveredLink(originalLink);
// Get position for tooltip
const renderedPos = edge.renderedMidpoint();
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
}
});
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);
setLinkTooltipPos(null);
});
// Prevent edge selection to avoid gray border on click
cy.on("select", "edge", (evt: any) => {
evt.target.unselect();
});
// Double-click to focus on node and its connections
cy.on("dblclick", "node", (evt: any) => {
const focusedNode = evt.target as cytoscape.NodeSingular;
const focusedNodeId = focusedNode.id();
console.log("Double-clicked node:", focusedNodeId);
// Enter focus mode
setIsFocusMode(true);
// Clear any existing focus classes
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",
}
);
}
});
setIsLoading(false);
isInitializingRef.current = false;
lastDataSignatureRef.current = dataSignature;
} catch (error) {
console.error("Error initializing cytoscape:", error);
setIsLoading(false);
isInitializingRef.current = false;
} }
}, 100); // 100ms delay
// 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;
setHoveredNode(originalNode);
if (onNodeHoverRef.current && originalNode) {
onNodeHoverRef.current(originalNode);
}
containerRef.current!.style.cursor = "pointer";
});
cy.on("mouseout", "node", () => {
setHoveredNode(null);
if (onNodeHoverRef.current) {
onNodeHoverRef.current(null);
}
containerRef.current!.style.cursor = "default";
});
// Edge hover handlers
cy.on("mouseover", "edge", (evt) => {
const edge = evt.target;
const originalLink = edge.data("originalLink") as GraphLink;
if (originalLink) {
setHoveredLink(originalLink);
// Get position for tooltip
const renderedPos = edge.renderedMidpoint();
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
}
containerRef.current!.style.cursor = "pointer";
});
cy.on("mouseout", "edge", () => {
setHoveredLink(null);
setLinkTooltipPos(null);
containerRef.current!.style.cursor = "default";
});
// Run layout
cy.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).run();
// Fit to viewport
cy.fit(undefined, 50);
setIsLoading(false);
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,17 +600,19 @@ export function Graph2D({
)} )}
{/* Cytoscape container */} {/* Cytoscape container */}
<div {isMounted && (
ref={containerRef} <div
className="w-full h-full" ref={setContainerDiv}
style={{ className="w-full h-full"
backgroundImage: isDarkMode style={{
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)" backgroundImage: isDarkMode
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)", ? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
backgroundSize: "20px 20px", : "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc", backgroundSize: "20px 20px",
}} 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"