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:
parent
ce45d301ce
commit
1a620697b1
17 changed files with 722 additions and 377 deletions
|
|
@ -7,10 +7,11 @@ import logging
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import your models here
|
||||
from hindsight_api.models import Base
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ Create Date: 2025-11-27 11:54:19.228030
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5a366d414dce"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ Create Date: 2025-11-28 00:00:00.000000
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b7c4d8e9f1a2"
|
||||
down_revision: str | Sequence[str] | None = "5a366d414dce"
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ Create Date: 2025-12-02 00:00:00.000000
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c8e5f2a3b4d1"
|
||||
down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ system (skepticism, literalism, empathy with 1-5 integer values).
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ Create Date: 2024-12-04
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "rename_personality"
|
||||
down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,44 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|||
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:
|
||||
"""
|
||||
Sanitize text by removing invalid Unicode surrogate characters.
|
||||
|
|
@ -676,13 +714,18 @@ Text:
|
|||
if fact_kind == "event":
|
||||
occurred_start = get_value("occurred_start")
|
||||
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
|
||||
# For point events: if occurred_end not set, default to occurred_start
|
||||
if occurred_end:
|
||||
fact_data["occurred_end"] = occurred_end
|
||||
else:
|
||||
fact_data["occurred_end"] = occurred_start
|
||||
|
||||
# For point events: if occurred_end not set, default to occurred_start
|
||||
if occurred_end:
|
||||
fact_data["occurred_end"] = occurred_end
|
||||
elif fact_data.get("occurred_start"):
|
||||
fact_data["occurred_end"] = fact_data["occurred_start"]
|
||||
|
||||
# Add entities if present (validate as Entity objects)
|
||||
# LLM sometimes returns strings instead of {"text": "..."} format
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ import logging
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from alembic import command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Advisory lock ID for migrations (arbitrary unique number)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
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 = """
|
||||
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"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
event_date = datetime(2023, 8, 14, 14, 24)
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="Melanie"
|
||||
)
|
||||
last_error = None
|
||||
max_retries = 3
|
||||
|
||||
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
|
||||
for fact in facts:
|
||||
if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower():
|
||||
birthday_fact = fact
|
||||
break
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
||||
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 fact_date_str is not None, "occurred_start should not be None for temporal events"
|
||||
assert birthday_fact is not None, "Should extract fact about birthday celebration"
|
||||
|
||||
if 'T' in fact_date_str:
|
||||
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
fact_date = datetime.fromisoformat(fact_date_str)
|
||||
fact_date_str = birthday_fact.occurred_start
|
||||
assert fact_date_str is not None, "occurred_start should not be None for temporal events"
|
||||
|
||||
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 'T' in fact_date_str:
|
||||
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
fact_date = datetime.fromisoformat(fact_date_str)
|
||||
|
||||
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
|
||||
async def test_date_field_calculation_yesterday(self):
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import { sdk, lowLevelClient } from "@/lib/hindsight-client";
|
|||
export async function GET() {
|
||||
try {
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error("Error fetching banks:", error);
|
||||
|
|
@ -26,7 +33,8 @@ export async function POST(request: Request) {
|
|||
body: {},
|
||||
});
|
||||
|
||||
return NextResponse.json(response.data, { status: 201 });
|
||||
const serializedData = JSON.parse(JSON.stringify(response.data));
|
||||
return NextResponse.json(serializedData, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating bank:", error);
|
||||
return NextResponse.json({ error: "Failed to create bank" }, { status: 500 });
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function DataView({ factType }: DataViewProps) {
|
|||
|
||||
// Graph controls state
|
||||
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 [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
|
||||
new Set(["semantic", "temporal", "entity", "causal"])
|
||||
|
|
@ -228,6 +228,19 @@ export function DataView({ factType }: DataViewProps) {
|
|||
}
|
||||
}, [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 (
|
||||
<div>
|
||||
{loading ? (
|
||||
|
|
@ -467,22 +480,41 @@ export function DataView({ factType }: DataViewProps) {
|
|||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm text-foreground">Max nodes</Label>
|
||||
<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>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxNodes ?? graph2DData.nodes.length]}
|
||||
value={[
|
||||
graph2DData.nodes.length > 50
|
||||
? maxNodes || 20
|
||||
: maxNodes || Math.min(graph2DData.nodes.length, 20),
|
||||
]}
|
||||
min={10}
|
||||
max={Math.max(graph2DData.nodes.length, 10)}
|
||||
max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
|
||||
step={10}
|
||||
onValueChange={([v]) =>
|
||||
setMaxNodes(v >= graph2DData.nodes.length ? undefined : v)
|
||||
}
|
||||
onValueChange={([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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
"use client";
|
||||
|
||||
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
|
||||
function useIsDarkMode() {
|
||||
|
|
@ -72,14 +77,10 @@ export interface Graph2DProps {
|
|||
|
||||
// Brand colors
|
||||
const BRAND_PRIMARY = "#0074d9";
|
||||
const BRAND_TEAL = "#009296";
|
||||
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_LINK_COLOR = LINK_SEMANTIC;
|
||||
const DEFAULT_NODE_SIZE = 20;
|
||||
const DEFAULT_LINK_WIDTH = 1;
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -98,12 +99,16 @@ export function Graph2D({
|
|||
linkWidthFn,
|
||||
maxNodes,
|
||||
}: Graph2DProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cyRef = useRef<Core | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [containerDiv, setContainerDiv] = useState<HTMLDivElement | null>(null);
|
||||
const cyRef = useRef<any>(null);
|
||||
const isInitializingRef = useRef(false);
|
||||
const lastDataSignatureRef = useRef<string>("");
|
||||
const [_hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
|
||||
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// 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 nodeColorFnRef = useRef(nodeColorFn);
|
||||
const linkColorFnRef = useRef(linkColorFn);
|
||||
const isFocusModeRef = useRef(isFocusMode);
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onNodeHoverRef.current = onNodeHover;
|
||||
fullDataRef.current = data;
|
||||
nodeColorFnRef.current = nodeColorFn;
|
||||
linkColorFnRef.current = linkColorFn;
|
||||
isFocusModeRef.current = isFocusMode;
|
||||
|
||||
// Transform and limit data - only limit nodes, show ALL links between visible nodes
|
||||
const graphData = useMemo(() => {
|
||||
|
|
@ -134,17 +141,38 @@ export function Graph2D({
|
|||
return { nodes, links };
|
||||
}, [data, maxNodes]);
|
||||
|
||||
// Track mounting state
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Convert to Cytoscape format
|
||||
const cyElements = useMemo(() => {
|
||||
const nodes = graphData.nodes.map((node) => ({
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label || node.id.substring(0, 8),
|
||||
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
|
||||
size: nodeSizeFn ? nodeSizeFn(node) : node.size || DEFAULT_NODE_SIZE,
|
||||
originalNode: 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: {
|
||||
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) => ({
|
||||
data: {
|
||||
|
|
@ -163,328 +191,392 @@ export function Graph2D({
|
|||
return [...nodes, ...edges];
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
let isCancelled = false;
|
||||
|
||||
// Handle empty data case
|
||||
if (cyElements.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Small delay to ensure container is mounted
|
||||
const timeout = setTimeout(() => {
|
||||
if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Theme-aware colors
|
||||
const textColor = isDarkMode ? "#ffffff" : "#1f2937";
|
||||
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);
|
||||
// Check if data has actually changed to prevent double initialization
|
||||
if (lastDataSignatureRef.current === dataSignature) {
|
||||
console.log("Data signature unchanged, skipping graph initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find ALL connected nodes from full data (not just visible ones)
|
||||
const fullData = fullDataRef.current;
|
||||
const clickedNodeId = originalNode.id;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Find all links connected to this node from full data
|
||||
const connectedLinks = fullData.links.filter(
|
||||
(l) => l.source === clickedNodeId || l.target === clickedNodeId
|
||||
);
|
||||
// Handle empty data case
|
||||
if (cyElements.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all connected node IDs
|
||||
const connectedNodeIds = new Set<string>();
|
||||
connectedLinks.forEach((l) => {
|
||||
connectedNodeIds.add(l.source);
|
||||
connectedNodeIds.add(l.target);
|
||||
});
|
||||
// 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;
|
||||
|
||||
// Add any missing nodes to the graph
|
||||
const existingNodeIds = new Set(cy.nodes().map((n) => n.id()));
|
||||
const nodesToAdd: any[] = [];
|
||||
const edgesToAdd: any[] = [];
|
||||
// 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;
|
||||
}
|
||||
|
||||
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
|
||||
// Clean up existing graph before creating new one
|
||||
console.log("Data changed, destroying existing graph");
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// Theme-aware colors
|
||||
const textColor = isDarkMode ? "#ffffff" : "#1f2937";
|
||||
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
|
||||
|
||||
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({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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),
|
||||
});
|
||||
{
|
||||
selector: "node:selected",
|
||||
style: {
|
||||
"border-width": 3,
|
||||
"border-color": "#0074d9",
|
||||
"border-opacity": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: "data(width)",
|
||||
"line-color": "data(color)",
|
||||
"target-arrow-color": "data(color)",
|
||||
"target-arrow-shape": "triangle",
|
||||
"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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}, 100); // 100ms delay
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (cyRef.current) {
|
||||
cyRef.current.resize();
|
||||
cyRef.current.fit(undefined, 50);
|
||||
cyRef.current.fit(undefined, 80);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -508,17 +600,19 @@ export function Graph2D({
|
|||
)}
|
||||
|
||||
{/* Cytoscape container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: isDarkMode
|
||||
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
|
||||
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
{isMounted && (
|
||||
<div
|
||||
ref={setContainerDiv}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: isDarkMode
|
||||
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
|
||||
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && graphData.nodes.length === 0 && (
|
||||
|
|
@ -571,7 +665,7 @@ export function Graph2D({
|
|||
|
||||
{/* Controls hint */}
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const Slider = React.forwardRef<
|
|||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...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.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" />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@
|
|||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
".next/dev/types/**/*.ts",
|
||||
"types/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
|
|
|||
126
hindsight-control-plane/types/cytoscape-fcose.d.ts
vendored
Normal file
126
hindsight-control-plane/types/cytoscape-fcose.d.ts
vendored
Normal 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
3
package-lock.json
generated
|
|
@ -49,6 +49,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
|
|
@ -9917,6 +9918,8 @@
|
|||
},
|
||||
"node_modules/cytoscape-fcose": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
|
||||
"integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cose-base": "^2.2.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue