Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
476 changes: 387 additions & 89 deletions src/components/visualize/Graph2D.tsx

Large diffs are not rendered by default.

57 changes: 50 additions & 7 deletions src/components/visualize/VisualizeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
ClusterCircle,
PositionedNode,
PositionedEdge,
Subcluster,
} from "@/types/GraphData";
import { GraphSummaryPanel } from "./summary";

export default function VisualizeToggle({
nodeData,
Expand All @@ -23,12 +25,24 @@ export default function VisualizeToggle({
avatarUrl: string | null;
}) {
const [mode, setMode] = useState<"2d" | "3d">("2d");
const [showSummary, setShowSummary] = useState(false);
const [toggleTopClutserPanel, setToggleTopClutserPanel] = useState(false);
const [clusters, setClusters] = useState<ClusterCircle[]>([]);
const [nodes, setNodes] = useState<PositionedNode[]>([]);
const [edges, setEdges] = useState<PositionedEdge[]>([]);
const [zoomToClusterId, setZoomToClusterId] = useState<string | null>(null);

const snapshotSubclusters = (nodeData as { subclusters?: Subcluster[] })
.subclusters;
const statsSubclusters = (
nodeData.stats?.metadata as { subclusters?: Subcluster[] } | undefined
)?.subclusters;
const fallbackStatsSubclusters = (
statisticData.metadata as { subclusters?: Subcluster[] } | undefined
)?.subclusters;
const rawSubclusters =
snapshotSubclusters ?? statsSubclusters ?? fallbackStatsSubclusters;

const handleClustersReady = useCallback(
(
newClusters: ClusterCircle[],
Expand Down Expand Up @@ -190,28 +204,42 @@ export default function VisualizeToggle({
</>
)}

{/* 2D/3D 모드 토글 패널 */}
{/* 2D/3D/Summary 모드 토글 패널 */}
<div className="absolute z-20 top-6 right-6 flex flex-col gap-2">
<div className="flex gap-1 w-[170px] h-[32px] p-[2px] relative bg-bg-tertiary rounded-md">
<div className="flex gap-1 w-[255px] h-[32px] p-[2px] relative bg-bg-tertiary rounded-md">
<div
onClick={() => setMode("2d")}
onClick={() => {
setMode("2d");
setShowSummary(false);
}}
className={`flex-1 flex items-center justify-center text-sm font-medium cursor-pointer relative z-10 transition-colors duration-200 ${
mode === "2d" ? "text-primary" : "text-text-secondary"
mode === "2d" && !showSummary ? "text-primary" : "text-text-secondary"
}`}
>
2D
</div>
<div
onClick={() => setMode("3d")}
onClick={() => {
setMode("3d");
setShowSummary(false);
}}
className={`flex-1 flex items-center justify-center text-sm font-medium cursor-pointer relative z-10 transition-colors duration-200 ${
mode === "3d" ? "text-primary" : "text-text-secondary"
mode === "3d" && !showSummary ? "text-primary" : "text-text-secondary"
}`}
>
3D
</div>
<div
onClick={() => setShowSummary(true)}
className={`flex-1 flex items-center justify-center text-sm font-medium cursor-pointer relative z-10 transition-colors duration-200 ${
showSummary ? "text-primary" : "text-text-secondary"
}`}
>
Summary
</div>
<div
className={`absolute top-[2px] h-[28px] bg-white border-base-border border-solid border-[1px] rounded-md w-[81px] transition-all duration-300 ease-in-out ${
mode === "3d" ? "left-[87px]" : "left-[2px]"
mode === "3d" && !showSummary ? "left-[87px]" : showSummary ? "left-[172px]" : "left-[2px]"
}`}
></div>
</div>
Expand All @@ -222,6 +250,7 @@ export default function VisualizeToggle({
<Graph2D
rawNodes={nodeData.nodes}
rawEdges={nodeData.edges}
rawSubclusters={rawSubclusters}
width={window.innerWidth}
height={window.innerHeight}
avatarUrl={avatarUrl}
Expand All @@ -231,6 +260,20 @@ export default function VisualizeToggle({
) : (
<Graph3D data={nodeData} />
)}

{/* Summary Overlay */}
{showSummary && (
<GraphSummaryPanel
onClose={() => setShowSummary(false)}
onClusterClick={(clusterId) => {
setZoomToClusterId(clusterId);
setMode("2d");
setShowSummary(false);
// Reset zoom after animation completes
setTimeout(() => setZoomToClusterId(null), 900);
}}
/>
)}
</div>
);
}
107 changes: 107 additions & 0 deletions src/components/visualize/summary/ClusterCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { ClusterAnalysis } from "@/types/GraphSummary";

interface ClusterCardProps {
cluster: ClusterAnalysis;
onClick?: () => void;
}

// Helper component for progress bars
function ProgressBar({ value, label }: { value: number; label: string }) {
const percentage = Math.round(value * 100);

return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-secondary">{label}</span>
<span className="text-xs font-medium text-text-primary">
{percentage}%
</span>
</div>
<div className="w-full bg-bg-tertiary rounded-full h-2 overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}

// Helper to get recency badge styles
function getRecencyStyles(recency: string) {
switch (recency) {
case "active":
return "bg-green-500/10 text-green-600 border-green-500/20";
case "dormant":
return "bg-gray-500/10 text-gray-600 border-gray-500/20";
case "new":
return "bg-blue-500/10 text-blue-600 border-blue-500/20";
default:
return "bg-gray-500/10 text-gray-600 border-gray-500/20";
}
}

// Helper to get recency label
function getRecencyLabel(recency: string) {
switch (recency) {
case "active":
return "활성";
case "dormant":
return "휴면";
case "new":
return "신규";
default:
return "알 수 없음";
}
}

export default function ClusterCard({ cluster, onClick }: ClusterCardProps) {
return (
<div
className="flex-shrink-0 w-80 bg-bg-secondary border border-base-border rounded-lg p-4 cursor-pointer hover:border-primary/50 hover:shadow-md transition-all duration-200"
onClick={onClick}
>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-bold text-text-primary mb-1">{cluster.name}</h3>
<p className="text-sm text-text-secondary">{cluster.size}개 대화</p>
</div>
<span
className={`px-2 py-1 text-xs font-medium border rounded-full ${getRecencyStyles(cluster.recency)}`}
>
{getRecencyLabel(cluster.recency)}
</span>
</div>

{/* Progress Bars */}
<div className="space-y-3 mb-4">
<ProgressBar value={cluster.density} label="밀도 (Density)" />
<ProgressBar value={cluster.centrality} label="중심성 (Centrality)" />
</div>

{/* Top Keywords */}
<div className="mb-4">
<p className="text-xs text-text-secondary mb-2">주요 키워드</p>
<div className="flex flex-wrap gap-1">
{cluster.top_keywords.slice(0, 5).map((keyword, idx) => (
<span
key={idx}
className="inline-block px-2 py-0.5 bg-bg-tertiary border border-base-border rounded text-xs text-text-primary"
>
{keyword}
</span>
))}
</div>
</div>

{/* Insight Text */}
<div>
<p className="text-xs text-text-secondary mb-1">인사이트</p>
<p className="text-xs text-text-primary leading-relaxed line-clamp-3">
{cluster.insight_text}
</p>
</div>
</div>
);
}
64 changes: 64 additions & 0 deletions src/components/visualize/summary/ConnectionItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ClusterConnection } from "@/types/GraphSummary";

interface ConnectionItemProps {
connection: ClusterConnection;
}

export default function ConnectionItem({ connection }: ConnectionItemProps) {
const percentage = Math.round(connection.connection_strength * 100);

return (
<div className="bg-bg-secondary border border-base-border rounded-lg p-4 hover:border-primary/30 transition-colors duration-200">
{/* Cluster Connection Header */}
<div className="flex items-center justify-center gap-2 mb-3">
<span className="text-sm font-semibold text-text-primary truncate max-w-[40%]">
{connection.source_cluster}
</span>
<span className="text-lg">↔</span>
<span className="text-sm font-semibold text-text-primary truncate max-w-[40%]">
{connection.target_cluster}
</span>
</div>

{/* Connection Strength Progress Bar */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-secondary">연결 강도</span>
<span className="text-xs font-medium text-text-primary">
{percentage}%
</span>
</div>
<div className="w-full bg-bg-tertiary rounded-full h-2 overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>

{/* Bridge Keywords */}
{connection.bridge_keywords && connection.bridge_keywords.length > 0 && (
<div className="mb-3">
<p className="text-xs text-text-secondary mb-2">브릿지 키워드</p>
<div className="flex flex-wrap gap-1">
{connection.bridge_keywords.map((keyword, idx) => (
<span
key={idx}
className="inline-block px-2 py-0.5 bg-bg-tertiary border border-base-border rounded text-xs text-text-primary"
>
{keyword}
</span>
))}
</div>
</div>
)}

{/* Description */}
{connection.description && (
<p className="text-xs text-text-primary leading-relaxed">
{connection.description}
</p>
)}
</div>
);
}
Loading