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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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",
|
"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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue