From d3dce860f02047b0b7eb587cb14b6dcfc7913ef7 Mon Sep 17 00:00:00 2001 From: Kyungdeok Kim Date: Sat, 31 Jan 2026 20:52:57 +0900 Subject: [PATCH 1/4] REFACTOR: align DTO types and subcluster support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Graph2D 리팩토링: GraphNodeDto/GraphEdgeDto/PositionedNode 기준으로 통일 - 서브클러스터(중븐류) 로직 추가(접기/펼치기, 그룹 노드 렌더링) - Graph2D에 rawSubclusters props 추가됨 → VisualizeToggle에서 전달 필요 - 현재 VisualizeToggle은 nodeData.subclusters → nodeData.stats.metadata.subclusters → statisticData.metadata.subclusters 순으로 탐색해 전달 - 더미 데이터 확인용으로 DUMMY_GRAPH에 subclusters 추가됨 - hover 시 thread title 로딩, NodeChatPreview, zoomToCluster 애니메이션 추가 - 확인 포인트: 실데이터에서 subclusters가 없으면 그룹 노드가 안 나옴 --- src/components/visualize/Graph2D.tsx | 476 +- src/components/visualize/VisualizeToggle.tsx | 13 + src/constants/DUMMY_GRAPH.ts | 5793 +++++++++++++++++- src/types/GraphData.ts | 13 + 4 files changed, 6124 insertions(+), 171 deletions(-) diff --git a/src/components/visualize/Graph2D.tsx b/src/components/visualize/Graph2D.tsx index 75a382e..9cb3f89 100644 --- a/src/components/visualize/Graph2D.tsx +++ b/src/components/visualize/Graph2D.tsx @@ -1,16 +1,17 @@ import threadRepo from "@/managers/threadRepo"; +import { + ClusterCircle, + PositionedEdge, + PositionedNode, + Subcluster, +} from "@/types/GraphData"; import * as d3Force from "d3-force"; import { GraphEdgeDto, GraphNodeDto, } from "node_modules/@taco_tsinghua/graphnode-sdk/dist/types/graph"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import NodeChatPreview from "./NodeChatPreview"; -import { - PositionedEdge, - PositionedNode, - ClusterCircle, -} from "@/types/GraphData"; type SimNode = d3Force.SimulationNodeDatum & GraphNodeDto & { @@ -21,12 +22,32 @@ type SimNode = d3Force.SimulationNodeDatum & edgeCount: number; }; +type DisplayNode = { + id: string | number; + isGroupNode?: boolean; + subcluster_id?: string | null; + x: number; + y: number; + size?: number; + color?: string; + label?: string; + edgeCount?: number; + cluster_name?: string; + orig_node?: PositionedNode; +}; + +type DisplayEdge = { + source: string | number; + target: string | number; + isIntraCluster: boolean; + id?: string; +}; + function classifyEdges( nodes: GraphNodeDto[], - edges: GraphEdgeDto[] + edges: GraphEdgeDto[], ): { edges: PositionedEdge[]; - nodeMap: Map; edgeCounts: Map; } { const nodeMap = new Map(); @@ -48,14 +69,152 @@ function classifyEdges( return { ...e, isIntraCluster: !!isIntra }; }); - return { edges: positionedEdges, nodeMap, edgeCounts }; + return { edges: positionedEdges, edgeCounts }; +} + +// 클러스터별로 서브클러스터 그룹화 +function groupSubclustersByCluster( + subclusters: Subcluster[], +): Map { + const subclustersByCluster = new Map(); + subclusters.forEach((sc) => { + const list = subclustersByCluster.get(sc.cluster_id) ?? []; + list.push(sc); + subclustersByCluster.set(sc.cluster_id, list); + }); + return subclustersByCluster; +} + +// 노드 -> 서브클러스터 매핑 생성 +function createNodeToSubclusterMap( + subclusters: Subcluster[], +): Map { + const nodeToSubcluster = new Map(); + subclusters.forEach((sc) => { + sc.node_ids.forEach((nodeId) => { + nodeToSubcluster.set(nodeId, sc.id); + }); + }); + return nodeToSubcluster; +} + +function getVisibleGraph( + allNodes: PositionedNode[], + allEdges: GraphEdgeDto[], + subclusters: Subcluster[], + collapsedSet: Set, +): { visibleNodes: DisplayNode[]; visibleEdges: DisplayEdge[] } { + const nodeToSubcluster = createNodeToSubclusterMap(subclusters); + const scMap = new Map(subclusters.map((sc) => [sc.id, sc])); + + const nodeMap = new Map(); + const visibleNodes: DisplayNode[] = []; + + // 그룹 노드 생성 (접힌 서브클러스터) + collapsedSet.forEach((scId) => { + const sc = scMap.get(scId); + if (!sc) return; + + let sumX = 0; + let sumY = 0; + let count = 0; + let clusterName: string | undefined; + + const memberNodeIds = new Set(sc.node_ids); + allNodes.forEach((n) => { + if (!memberNodeIds.has(n.id)) return; + sumX += n.x; + sumY += n.y; + count += 1; + if (!clusterName) clusterName = n.clusterName; + }); + + const groupNodeId = `__group_${scId}`; + const groupNode: DisplayNode = { + id: groupNodeId, + isGroupNode: true, + subcluster_id: scId, + label: sc.top_keywords?.[0] || `Group ${scId}`, + x: count > 0 ? sumX / count : 0, + y: count > 0 ? sumY / count : 0, + size: sc.size, + color: "var(--color-node-default)", + edgeCount: 0, + cluster_name: clusterName, + }; + + visibleNodes.push(groupNode); + sc.node_ids.forEach((nodeId) => { + nodeMap.set(nodeId, groupNode); + }); + }); + + // 일반 노드 처리 + allNodes.forEach((node) => { + const subclusterId = + (node as GraphNodeDto & { subclusterId?: string | null }).subclusterId ?? + nodeToSubcluster.get(node.id) ?? + null; + if (subclusterId && collapsedSet.has(subclusterId)) return; + + const displayNode: DisplayNode = { + id: node.id, + isGroupNode: false, + subcluster_id: subclusterId, + label: node.origId, + x: node.x, + y: node.y, + edgeCount: node.edgeCount, + cluster_name: node.clusterName, + orig_node: node, + }; + + visibleNodes.push(displayNode); + nodeMap.set(node.id, displayNode); + }); + + const visibleEdges: DisplayEdge[] = []; + const edgeKeys = new Set(); + const edgeCounts = new Map(); + + allEdges.forEach((e) => { + const sNode = nodeMap.get(e.source); + const tNode = nodeMap.get(e.target); + if (!sNode || !tNode) return; + if (sNode.id === tNode.id) return; + + const key = [String(sNode.id), String(tNode.id)].sort().join("-"); + if (edgeKeys.has(key)) return; + + edgeKeys.add(key); + visibleEdges.push({ + source: sNode.id, + target: tNode.id, + isIntraCluster: !!( + sNode.cluster_name && + tNode.cluster_name && + sNode.cluster_name === tNode.cluster_name + ), + id: key, + }); + + edgeCounts.set(sNode.id, (edgeCounts.get(sNode.id) ?? 0) + 1); + edgeCounts.set(tNode.id, (edgeCounts.get(tNode.id) ?? 0) + 1); + }); + + const nodesWithEdgeCounts = visibleNodes.map((n) => ({ + ...n, + edgeCount: edgeCounts.get(n.id) ?? n.edgeCount ?? 0, + })); + + return { visibleNodes: nodesWithEdgeCounts, visibleEdges }; } function layoutWithBoundedForce( nodes: GraphNodeDto[], edges: GraphEdgeDto[], width: number, - height: number + height: number, ): { nodes: PositionedNode[]; edges: PositionedEdge[]; @@ -66,7 +225,7 @@ function layoutWithBoundedForce( // 클러스터별 노드 그룹화 const clusterGroups = new Map(); nodes.forEach((n) => { - // cluster_name을 기준으로 그룹화 + // clusterName을 기준으로 그룹화 const list = clusterGroups.get(n.clusterName) ?? []; list.push(n); clusterGroups.set(n.clusterName, list); @@ -91,12 +250,12 @@ function layoutWithBoundedForce( const n = clusterNodes.length; - const tempNodeMap = new Map(clusterNodes.map((n) => [n.id, n])); + const tempNodeMap = new Map(clusterNodes.map((node) => [node.id, node])); const intraClusterEdges = classifiedEdges.filter( (e) => e.isIntraCluster && tempNodeMap.has(e.source) && - tempNodeMap.has(e.target) + tempNodeMap.has(e.target), ); const edgeCount = intraClusterEdges.length; @@ -121,7 +280,7 @@ function layoutWithBoundedForce( }; }); - const nodeMap = new Map(simNodes.map((n) => [n.id, n])); + const nodeMap = new Map(simNodes.map((node) => [node.id, node])); const simClusterEdges = intraClusterEdges.map((e) => ({ source: nodeMap.get(e.source)!, @@ -145,7 +304,7 @@ function layoutWithBoundedForce( .forceLink(simClusterEdges) .id((d: any) => d.id) .distance(20 + Math.min(15, density * 1.2)) - .strength(0.5) + .strength(0.5), ) .force("collision", d3Force.forceCollide(collideRadius).iterations(3)) .stop(); @@ -214,34 +373,46 @@ function getNodeRadius(edgeCount: number, maxEdgeCount: number): number { type GraphProps = { rawNodes: GraphNodeDto[]; rawEdges: GraphEdgeDto[]; + rawSubclusters?: Subcluster[]; width: number; height: number; avatarUrl: string | null; onClustersReady?: ( clusters: ClusterCircle[], nodes: PositionedNode[], - edges: PositionedEdge[] + edges: PositionedEdge[], ) => void; zoomToClusterId?: string | null; }; +const EMPTY_SUBCLUSTERS: Subcluster[] = []; + export default function Graph2D({ rawNodes, rawEdges, + rawSubclusters, width, height, avatarUrl, onClustersReady, zoomToClusterId, }: GraphProps) { - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); + const subclustersInput = rawSubclusters ?? EMPTY_SUBCLUSTERS; + const subclusters = subclustersInput; + const [positionedNodes, setPositionedNodes] = useState([]); + const [displayNodes, setDisplayNodes] = useState([]); + const [displayEdges, setDisplayEdges] = useState([]); const [circles, setCircles] = useState([]); const [maxEdgeCount, setMaxEdgeCount] = useState(0); - const [hoveredId, setHoveredId] = useState(null); + // 서브클러스터 관련 상태 + const [collapsedSubclusters, setCollapsedSubclusters] = useState>( + () => new Set(subclustersInput.map((sc) => sc.id)), + ); + + const [hoveredId, setHoveredId] = useState(null); const [hoveredThreadTitle, setHoveredThreadTitle] = useState( - null + null, ); const [focusNodeId, setFocusNodeId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -255,45 +426,54 @@ export default function Graph2D({ const dragNodeOffset = useRef<{ dx: number; dy: number } | null>(null); const dragStartPos = useRef<{ x: number; y: number } | null>(null); const [draggingClusterId, setDraggingClusterId] = useState( - null + null, ); const dragClusterOffset = useRef<{ dx: number; dy: number } | null>(null); const svgRef = useRef(null); const isAnimatingRef = useRef(false); + const positionedNodeMap = useMemo( + () => new Map(positionedNodes.map((n) => [n.id, n])), + [positionedNodes], + ); + + const displayNodeMap = useMemo( + () => new Map(displayNodes.map((n) => [n.id, n])), + [displayNodes], + ); + + const focusActive = focusNodeId !== null && displayNodeMap.has(focusNodeId); + // 엣지 분류 - const normalIntraEdges = edges.filter((e) => { + const normalIntraEdges = displayEdges.filter((e) => { if (!e.isIntraCluster) return false; - if (!focusNodeId) return true; + if (!focusActive) return true; return e.source !== focusNodeId && e.target !== focusNodeId; }); - const focusedIntraEdges = edges.filter((e) => { - if (!focusNodeId) return false; + const focusedIntraEdges = displayEdges.filter((e) => { + if (!focusActive) return false; if (!e.isIntraCluster) return false; return e.source === focusNodeId || e.target === focusNodeId; }); - const focusedInterEdges = edges.filter((e) => { - if (!focusNodeId) return false; + const focusedInterEdges = displayEdges.filter((e) => { + if (!focusActive) return false; if (e.isIntraCluster) return false; return e.source === focusNodeId || e.target === focusNodeId; }); - const normalInterEdges = edges.filter((e) => { - if (e.isIntraCluster) return false; - return true; - }); + const normalInterEdges = displayEdges.filter((e) => !e.isIntraCluster); // hoveredId가 변경될 때 thread title 가져오기 useEffect(() => { - if (hoveredId == null) { + if (hoveredId == null || typeof hoveredId !== "number") { setHoveredThreadTitle(null); return; } - const n = nodes.find((node) => node.id === hoveredId); + const n = positionedNodeMap.get(hoveredId); if (!n) { setHoveredThreadTitle(null); return; @@ -307,7 +487,7 @@ export default function Graph2D({ .catch(() => { setHoveredThreadTitle(null); }); - }, [hoveredId, nodes]); + }, [hoveredId, positionedNodeMap]); const onClustersReadyRef = useRef(onClustersReady); @@ -319,14 +499,12 @@ export default function Graph2D({ useEffect(() => { if (rawNodes.length === 0) return; - const { nodes, edges, circles } = layoutWithBoundedForce( - rawNodes, - rawEdges, - width, - height - ); - setNodes(nodes); - setEdges(edges); + const { + nodes, + edges: newEdges, + circles, + } = layoutWithBoundedForce(rawNodes, rawEdges, width, height); + setPositionedNodes(nodes); setCircles(circles); const max = Math.max(...nodes.map((n) => n.edgeCount), 1); @@ -334,9 +512,39 @@ export default function Graph2D({ // 클러스터 정보와 노드 위치, 엣지를 부모 컴포넌트에 전달 (ref를 통해 호출하여 무한 루프 방지) if (onClustersReadyRef.current) { - onClustersReadyRef.current(circles, nodes, edges); + onClustersReadyRef.current(circles, nodes, newEdges); } - }, [rawNodes, rawEdges, width, height]); // onClustersReady를 의존성 배열에서 제거 + }, [rawNodes, rawEdges, width, height]); + + // 서브클러스터 초기화 (초기에는 모두 접힌 상태) + useEffect(() => { + setCollapsedSubclusters(new Set(subclustersInput.map((sc) => sc.id))); + }, [subclustersInput]); + + useEffect(() => { + if (positionedNodes.length === 0) return; + const { visibleNodes, visibleEdges } = getVisibleGraph( + positionedNodes, + rawEdges, + subclusters, + collapsedSubclusters, + ); + setDisplayNodes(visibleNodes); + setDisplayEdges(visibleEdges); + + const max = Math.max(...visibleNodes.map((n) => n.edgeCount ?? 0), 1); + setMaxEdgeCount(max); + }, [positionedNodes, rawEdges, subclusters, collapsedSubclusters]); + + const nodeToSubclusterMap = useMemo( + () => createNodeToSubclusterMap(subclusters), + [subclusters], + ); + + const subclusterMap = useMemo( + () => new Map(subclusters.map((sc) => [sc.id, sc])), + [subclusters], + ); // 클러스터로 줌인 (애니메이션) useEffect(() => { @@ -397,8 +605,6 @@ export default function Graph2D({ requestAnimationFrame(animate); }, [zoomToClusterId, circles, scale, offset]); - const nodeById = (id: number) => nodes.find((n) => n.id === id); - const screenToWorld = (clientX: number, clientY: number) => { const svg = svgRef.current!; const rect = svg.getBoundingClientRect(); @@ -447,7 +653,7 @@ export default function Graph2D({ const newCenterY = worldY + dragClusterOffset.current.dy; const originalCircle = circles.find( - (c) => c.clusterId === draggingClusterId + (c) => c.clusterId === draggingClusterId, ); if (!originalCircle) return; @@ -458,16 +664,16 @@ export default function Graph2D({ prev.map((c) => c.clusterId === draggingClusterId ? { ...c, centerX: newCenterX, centerY: newCenterY } - : c - ) + : c, + ), ); - setNodes((prev) => + setPositionedNodes((prev) => prev.map((n) => n.clusterName === draggingClusterId ? { ...n, x: n.x + dx, y: n.y + dy } - : n - ) + : n, + ), ); return; @@ -479,10 +685,10 @@ export default function Graph2D({ const newX = worldX + dragNodeOffset.current.dx; const newY = worldY + dragNodeOffset.current.dy; - setNodes((prev) => + setPositionedNodes((prev) => prev.map((n) => - n.id === draggingNodeId ? { ...n, x: newX, y: newY } : n - ) + n.id === draggingNodeId ? { ...n, x: newX, y: newY } : n, + ), ); return; } @@ -516,7 +722,7 @@ export default function Graph2D({ // 드래그가 아니고 노드를 클릭한 경우에만 채팅 미리보기 표시 if (!wasDragging && prevDraggingNodeId) { - const node = nodeById(prevDraggingNodeId); + const node = positionedNodeMap.get(prevDraggingNodeId); if (node) { setSelectedNodeId(node.origId); } @@ -530,28 +736,30 @@ export default function Graph2D({ setDraggingClusterId(null); dragNodeOffset.current = null; dragClusterOffset.current = null; + dragStartPos.current = null; }; const handleNodeMouseDown = ( - e: React.MouseEvent, - nodeId: number + e: React.MouseEvent, + node: DisplayNode, ) => { e.stopPropagation(); + if (node.isGroupNode || typeof node.id !== "number") return; const { worldX, worldY } = screenToWorld(e.clientX, e.clientY); - const node = nodeById(nodeId); - if (!node) return; + const positionedNode = positionedNodeMap.get(node.id); + if (!positionedNode) return; dragNodeOffset.current = { - dx: node.x - worldX, - dy: node.y - worldY, + dx: positionedNode.x - worldX, + dy: positionedNode.y - worldY, }; dragStartPos.current = { x: e.clientX, y: e.clientY }; - setDraggingNodeId(nodeId); + setDraggingNodeId(node.id); }; const handleClusterLabelMouseDown = ( e: React.MouseEvent, - clusterId: string + clusterId: string, ) => { e.stopPropagation(); const { worldX, worldY } = screenToWorld(e.clientX, e.clientY); @@ -565,27 +773,80 @@ export default function Graph2D({ setDraggingClusterId(clusterId); }; + // 서브클러스터 클릭 핸들러 - 접기/펴기 토글 + const handleSubclusterClick = (subclusterId: string) => { + setCollapsedSubclusters((prev) => { + const newSet = new Set(prev); + if (newSet.has(subclusterId)) { + newSet.delete(subclusterId); // 펴기 + } else { + newSet.add(subclusterId); // 접기 + } + return newSet; + }); + }; + + // 노드 클릭 핸들러 - 서브클러스터에 속한 경우 서브클러스터 접기 + const handleNodeClick = (e: React.MouseEvent, node: DisplayNode) => { + e.stopPropagation(); + + if (node.isGroupNode) { + if (node.subcluster_id) { + handleSubclusterClick(node.subcluster_id); + } + return; + } + + if (typeof node.id === "number") { + const numericId = node.id; + setFocusNodeId((prev) => (prev === numericId ? null : numericId)); + } + + if (e.altKey) { + const subclusterId = + node.subcluster_id ?? nodeToSubclusterMap.get(node.id as number); + if (subclusterId) { + setCollapsedSubclusters((prev) => { + const next = new Set(prev); + next.add(subclusterId); + return next; + }); + } + } + }; + return (
{/* 툴팁 */} {hoveredId != null && - hoveredThreadTitle != null && (() => { - const n = nodeById(hoveredId); + const n = displayNodeMap.get(hoveredId); if (!n) return null; const left = n.x * scale + offset.x; const top = n.y * scale + offset.y - 24; + const sc = + n.isGroupNode && n.subcluster_id + ? subclusterMap.get(n.subcluster_id) + : null; + + const label = n.isGroupNode + ? (n.label ?? String(n.id)) + : (hoveredThreadTitle ?? n.label ?? String(n.id)); return (
- {hoveredThreadTitle} +
{label}
+ {sc && ( +
+ Size: {sc.size} | Density: {sc.density.toFixed(2)} +
+ )}
); })()} @@ -654,12 +915,12 @@ export default function Graph2D({ {/* Inter-cluster 엣지 (일반) */} {normalInterEdges.map((e, idx) => { - const s = nodeById(e.source); - const t = nodeById(e.target); + const s = displayNodeMap.get(e.source); + const t = displayNodeMap.get(e.target); if (!s || !t) return null; return ( { - const s = nodeById(e.source); - const t = nodeById(e.target); + const s = displayNodeMap.get(e.source); + const t = displayNodeMap.get(e.target); if (!s || !t) return null; return ( { - const s = nodeById(e.source); - const t = nodeById(e.target); + const s = displayNodeMap.get(e.source); + const t = displayNodeMap.get(e.target); if (!s || !t) return null; return ( { + {/* 노드 (그룹/일반 통합) */} + {displayNodes.map((n) => { const isHovered = hoveredId === n.id; - const isFocused = focusNodeId === n.id; - - const baseRadius = getNodeRadius(n.edgeCount, maxEdgeCount); + const isFocused = + !n.isGroupNode && + typeof n.id === "number" && + focusNodeId === n.id; + + if (n.isGroupNode) { + const baseRadius = 12; + const radius = Math.max(baseRadius, Math.sqrt(n.size ?? 0) * 2); + const displayRadius = isHovered ? radius + 2 : radius; + return ( + handleNodeMouseDown(e, n)} + onMouseEnter={() => setHoveredId(n.id)} + onMouseLeave={() => setHoveredId(null)} + onClick={(e) => handleNodeClick(e, n)} + > + + + {n.size} + + + ); + } + + const baseRadius = getNodeRadius(n.edgeCount ?? 0, maxEdgeCount); const radius = isHovered ? baseRadius + 2 : baseRadius; - + const hasSubcluster = !!n.subcluster_id; const fill = isFocused ? "var(--color-node-focus)" : isHovered ? "var(--color-node-focus)" - : "var(--color-node-default)"; + : hasSubcluster + ? "var(--color-node-default)" + : "var(--color-node-default)"; return ( handleNodeMouseDown(e, n.id)} + onMouseDown={(e) => handleNodeMouseDown(e, n)} onMouseEnter={() => setHoveredId(n.id)} onMouseLeave={() => setHoveredId(null)} - onClick={(e) => { - e.stopPropagation(); - setFocusNodeId((prev) => (prev === n.id ? null : n.id)); - }} + onClick={(e) => handleNodeClick(e, n)} /> ); })} diff --git a/src/components/visualize/VisualizeToggle.tsx b/src/components/visualize/VisualizeToggle.tsx index 2532a84..642c7f7 100644 --- a/src/components/visualize/VisualizeToggle.tsx +++ b/src/components/visualize/VisualizeToggle.tsx @@ -11,6 +11,7 @@ import { ClusterCircle, PositionedNode, PositionedEdge, + Subcluster, } from "@/types/GraphData"; export default function VisualizeToggle({ @@ -29,6 +30,17 @@ export default function VisualizeToggle({ const [edges, setEdges] = useState([]); const [zoomToClusterId, setZoomToClusterId] = useState(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[], @@ -222,6 +234,7 @@ export default function VisualizeToggle({ Date: Wed, 4 Feb 2026 16:29:56 +0900 Subject: [PATCH 2/4] ADD: Graph Summary UI/UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본 컴포넌트 구조 생성 1. src/types/GraphSummary.ts 파일 생성 2. src/constants/DUMMY_GRAPH_SUMMARY.ts 파일 생성 3. src/components/visualize/summary/ 폴더 구조 생성: - index.ts (exports) - GraphSummaryPanel.tsx (메인 컨테이너) - OverviewCard.tsx - ClusterCard.tsx - PatternItem.tsx - ConnectionItem.tsx - RecommendationCard.tsx GraphSummaryPanel은 DUMMY_GRAPH_SUMMARY를 import하여 각 섹션을 렌더링합니다. Tailwind CSS 사용, 한국어 UI. --- src/components/visualize/VisualizeToggle.tsx | 28 +- .../visualize/summary/ClusterCard.tsx | 107 +++++ .../visualize/summary/ConnectionItem.tsx | 64 +++ .../visualize/summary/GraphSummaryPanel.tsx | 219 ++++++++++ .../visualize/summary/OverviewCard.tsx | 82 ++++ .../visualize/summary/PatternItem.tsx | 99 +++++ .../visualize/summary/RecommendationCard.tsx | 112 +++++ src/components/visualize/summary/index.ts | 6 + src/constants/DUMMY_GRAPH_SUMMARY.ts | 408 ++++++++++++++++++ src/types/GraphSummary.ts | 192 +++++++++ 10 files changed, 1312 insertions(+), 5 deletions(-) create mode 100644 src/components/visualize/summary/ClusterCard.tsx create mode 100644 src/components/visualize/summary/ConnectionItem.tsx create mode 100644 src/components/visualize/summary/GraphSummaryPanel.tsx create mode 100644 src/components/visualize/summary/OverviewCard.tsx create mode 100644 src/components/visualize/summary/PatternItem.tsx create mode 100644 src/components/visualize/summary/RecommendationCard.tsx create mode 100644 src/components/visualize/summary/index.ts create mode 100644 src/constants/DUMMY_GRAPH_SUMMARY.ts create mode 100644 src/types/GraphSummary.ts diff --git a/src/components/visualize/VisualizeToggle.tsx b/src/components/visualize/VisualizeToggle.tsx index 642c7f7..201054f 100644 --- a/src/components/visualize/VisualizeToggle.tsx +++ b/src/components/visualize/VisualizeToggle.tsx @@ -13,6 +13,7 @@ import { PositionedEdge, Subcluster, } from "@/types/GraphData"; +import { GraphSummaryPanel } from "./summary"; export default function VisualizeToggle({ nodeData, @@ -23,7 +24,7 @@ export default function VisualizeToggle({ statisticData: GraphStatsDto; avatarUrl: string | null; }) { - const [mode, setMode] = useState<"2d" | "3d">("2d"); + const [mode, setMode] = useState<"2d" | "3d" | "summary">("2d"); const [toggleTopClutserPanel, setToggleTopClutserPanel] = useState(false); const [clusters, setClusters] = useState([]); const [nodes, setNodes] = useState([]); @@ -202,9 +203,9 @@ export default function VisualizeToggle({ )} - {/* 2D/3D 모드 토글 패널 */} + {/* 2D/3D/Summary 모드 토글 패널 */}
-
+
setMode("2d")} className={`flex-1 flex items-center justify-center text-sm font-medium cursor-pointer relative z-10 transition-colors duration-200 ${ @@ -221,9 +222,17 @@ export default function VisualizeToggle({ > 3D
+
setMode("summary")} + className={`flex-1 flex items-center justify-center text-sm font-medium cursor-pointer relative z-10 transition-colors duration-200 ${ + mode === "summary" ? "text-primary" : "text-text-secondary" + }`} + > + Summary +
@@ -241,8 +250,17 @@ export default function VisualizeToggle({ onClustersReady={handleClustersReady} zoomToClusterId={zoomToClusterId} /> - ) : ( + ) : mode === "3d" ? ( + ) : ( + { + setZoomToClusterId(clusterId); + setMode("2d"); + // Reset zoom after animation completes + setTimeout(() => setZoomToClusterId(null), 900); + }} + /> )}
); diff --git a/src/components/visualize/summary/ClusterCard.tsx b/src/components/visualize/summary/ClusterCard.tsx new file mode 100644 index 0000000..0d559b3 --- /dev/null +++ b/src/components/visualize/summary/ClusterCard.tsx @@ -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 ( +
+
+ {label} + + {percentage}% + +
+
+
+
+
+ ); +} + +// 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 ( +
+ {/* Header */} +
+
+

{cluster.name}

+

{cluster.size}개 대화

+
+ + {getRecencyLabel(cluster.recency)} + +
+ + {/* Progress Bars */} +
+ + +
+ + {/* Top Keywords */} +
+

주요 키워드

+
+ {cluster.top_keywords.slice(0, 5).map((keyword, idx) => ( + + {keyword} + + ))} +
+
+ + {/* Insight Text */} +
+

인사이트

+

+ {cluster.insight_text} +

+
+
+ ); +} diff --git a/src/components/visualize/summary/ConnectionItem.tsx b/src/components/visualize/summary/ConnectionItem.tsx new file mode 100644 index 0000000..a782a0b --- /dev/null +++ b/src/components/visualize/summary/ConnectionItem.tsx @@ -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 ( +
+ {/* Cluster Connection Header */} +
+ + {connection.source_cluster} + + + + {connection.target_cluster} + +
+ + {/* Connection Strength Progress Bar */} +
+
+ 연결 강도 + + {percentage}% + +
+
+
+
+
+ + {/* Bridge Keywords */} + {connection.bridge_keywords && connection.bridge_keywords.length > 0 && ( +
+

브릿지 키워드

+
+ {connection.bridge_keywords.map((keyword, idx) => ( + + {keyword} + + ))} +
+
+ )} + + {/* Description */} + {connection.description && ( +

+ {connection.description} +

+ )} +
+ ); +} diff --git a/src/components/visualize/summary/GraphSummaryPanel.tsx b/src/components/visualize/summary/GraphSummaryPanel.tsx new file mode 100644 index 0000000..5003e06 --- /dev/null +++ b/src/components/visualize/summary/GraphSummaryPanel.tsx @@ -0,0 +1,219 @@ +import { useState, useEffect } from "react"; +import { DUMMY_GRAPH_SUMMARY } from "@/constants/DUMMY_GRAPH_SUMMARY"; +import OverviewCard from "./OverviewCard"; +import ClusterCard from "./ClusterCard"; +import PatternItem from "./PatternItem"; +import ConnectionItem from "./ConnectionItem"; +import RecommendationCard from "./RecommendationCard"; + +// Section Header Component +function SectionHeader({ icon, title, subtitle }: { icon: string; title: string; subtitle?: string }) { + return ( +
+ {icon} +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ ); +} + +interface GraphSummaryPanelProps { + onClusterClick?: (clusterId: string) => void; +} + +export default function GraphSummaryPanel({ onClusterClick }: GraphSummaryPanelProps) { + const summary = DUMMY_GRAPH_SUMMARY; + const [currentSlide, setCurrentSlide] = useState(0); + + // Define slides + const slides = [ + { id: "overview", name: "개요" }, + { id: "clusters", name: "클러스터" }, + { id: "patterns", name: "패턴" }, + { id: "connections", name: "연결" }, + { id: "recommendations", name: "추천" }, + ]; + + const totalSlides = slides.length; + + // Navigation functions + const goToNextSlide = () => { + setCurrentSlide((prev) => (prev + 1) % totalSlides); + }; + + const goToPrevSlide = () => { + setCurrentSlide((prev) => (prev - 1 + totalSlides) % totalSlides); + }; + + const goToSlide = (index: number) => { + setCurrentSlide(index); + }; + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft") { + goToPrevSlide(); + } else if (e.key === "ArrowRight") { + goToNextSlide(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + return ( +
+ {/* Navigation Arrows */} + + + + + {/* Slides Container */} +
+ {/* Slide 0: Overview */} +
+
+ +
+
+ + {/* Slide 1: Clusters */} +
+
+ +
+ {summary.clusters.map((cluster) => ( + onClusterClick?.(cluster.cluster_id)} + /> + ))} +
+
+
+ + {/* Slide 2: Patterns */} +
+
+ +
+ {summary.patterns.map((pattern, idx) => ( + + ))} +
+
+
+ + {/* Slide 3: Connections */} +
+
+ +
+ {summary.connections.map((connection, idx) => ( + + ))} +
+
+
+ + {/* Slide 4: Recommendations */} +
+
+ +
+ {summary.recommendations.map((rec, idx) => ( + + ))} +
+
+
+
+ + {/* Slide Indicators */} +
+ {slides.map((slide, index) => ( + + ))} +
+ + {/* Footer Info */} +
+

생성: {new Date(summary.generated_at).toLocaleString("ko-KR")} · {summary.detail_level}

+
+
+ ); +} diff --git a/src/components/visualize/summary/OverviewCard.tsx b/src/components/visualize/summary/OverviewCard.tsx new file mode 100644 index 0000000..483396e --- /dev/null +++ b/src/components/visualize/summary/OverviewCard.tsx @@ -0,0 +1,82 @@ +import { OverviewSection } from "@/types/GraphSummary"; + +interface OverviewCardProps { + overview: OverviewSection; +} + +export default function OverviewCard({ overview }: OverviewCardProps) { + return ( +
+ {/* Header */} +
+ 📊 +

그래프 개요

+
+ + {/* Stats Grid */} +
+ {/* Total Conversations */} +
+

전체 대화

+

+ {overview.total_conversations} +

+
+ + {/* Time Span */} + {overview.time_span && overview.time_span !== "N/A" && ( +
+

기간

+

+ {overview.time_span} +

+
+ )} + + {/* Most Active Period */} + {overview.most_active_period && + overview.most_active_period !== "N/A" && ( +
+

+ 가장 활발한 시간 +

+

+ {overview.most_active_period} +

+
+ )} +
+ + {/* Conversation Style */} +
+

대화 스타일

+ + {overview.conversation_style} + +
+ + {/* Primary Interests */} +
+

주요 관심사

+
+ {overview.primary_interests.map((interest, idx) => ( + + {interest} + + ))} +
+
+ + {/* Summary Text */} +
+

요약

+

+ {overview.summary_text} +

+
+
+ ); +} diff --git a/src/components/visualize/summary/PatternItem.tsx b/src/components/visualize/summary/PatternItem.tsx new file mode 100644 index 0000000..e006bae --- /dev/null +++ b/src/components/visualize/summary/PatternItem.tsx @@ -0,0 +1,99 @@ +import { Pattern, PatternType, Significance } from "@/types/GraphSummary"; + +interface PatternItemProps { + pattern: Pattern; +} + +// Helper to get pattern type icon +function getPatternIcon(patternType: PatternType): string { + switch (patternType) { + case "repetition": + return "🔄"; + case "progression": + return "📈"; + case "gap": + return "⚠️"; + case "bridge": + return "🔗"; + default: + return "🔍"; + } +} + +// Helper to get pattern type label +function getPatternLabel(patternType: PatternType): string { + switch (patternType) { + case "repetition": + return "반복 패턴"; + case "progression": + return "진행 패턴"; + case "gap": + return "공백 패턴"; + case "bridge": + return "브릿지 패턴"; + default: + return "기타 패턴"; + } +} + +// Helper to get significance badge styles +function getSignificanceStyles(significance: Significance): string { + switch (significance) { + case "high": + return "bg-red-500/10 text-red-600 border-red-500/20"; + case "medium": + return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20"; + case "low": + return "bg-gray-500/10 text-gray-600 border-gray-500/20"; + default: + return "bg-gray-500/10 text-gray-600 border-gray-500/20"; + } +} + +// Helper to get significance label +function getSignificanceLabel(significance: Significance): string { + switch (significance) { + case "high": + return "높음"; + case "medium": + return "중간"; + case "low": + return "낮음"; + default: + return "알 수 없음"; + } +} + +export default function PatternItem({ pattern }: PatternItemProps) { + return ( +
+ {/* Header */} +
+
+ {getPatternIcon(pattern.pattern_type)} + + {getPatternLabel(pattern.pattern_type)} + +
+ + {getSignificanceLabel(pattern.significance)} + +
+ + {/* Description */} +

+ {pattern.description} +

+ + {/* Evidence Count */} + {pattern.evidence && pattern.evidence.length > 0 && ( +
+ 📌 + {pattern.evidence.length}개의 증거 +
+ )} +
+ ); +} diff --git a/src/components/visualize/summary/RecommendationCard.tsx b/src/components/visualize/summary/RecommendationCard.tsx new file mode 100644 index 0000000..a74e272 --- /dev/null +++ b/src/components/visualize/summary/RecommendationCard.tsx @@ -0,0 +1,112 @@ +import { Recommendation, RecommendationType, Priority } from "@/types/GraphSummary"; + +interface RecommendationCardProps { + recommendation: Recommendation; +} + +// Helper to get recommendation type icon +function getRecommendationIcon(type: RecommendationType): string { + switch (type) { + case "consolidate": + return "📚"; + case "explore": + return "🔍"; + case "review": + return "📖"; + case "connect": + return "🔗"; + default: + return "💡"; + } +} + +// Helper to get recommendation type label +function getRecommendationLabel(type: RecommendationType): string { + switch (type) { + case "consolidate": + return "통합"; + case "explore": + return "탐색"; + case "review": + return "복습"; + case "connect": + return "연결"; + default: + return "제안"; + } +} + +// Helper to get priority badge styles +function getPriorityStyles(priority: Priority): string { + switch (priority) { + case "high": + return "bg-red-500/10 text-red-600 border-red-500/20"; + case "medium": + return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20"; + case "low": + return "bg-gray-500/10 text-gray-600 border-gray-500/20"; + default: + return "bg-gray-500/10 text-gray-600 border-gray-500/20"; + } +} + +// Helper to get priority label +function getPriorityLabel(priority: Priority): string { + switch (priority) { + case "high": + return "높음"; + case "medium": + return "중간"; + case "low": + return "낮음"; + default: + return "알 수 없음"; + } +} + +export default function RecommendationCard({ recommendation }: RecommendationCardProps) { + const isHighPriority = recommendation.priority === "high"; + + return ( +
+ {/* Header */} +
+
+ {getRecommendationIcon(recommendation.type)} + + {getRecommendationLabel(recommendation.type)} + +
+ + {getPriorityLabel(recommendation.priority)} + +
+ + {/* Title */} +

+ {recommendation.title} +

+ + {/* Description */} +

+ {recommendation.description} +

+ + {/* Related Nodes Count */} + {recommendation.related_nodes && recommendation.related_nodes.length > 0 && ( +
+ 📌 + {recommendation.related_nodes.length}개 관련 대화 +
+ )} +
+ ); +} diff --git a/src/components/visualize/summary/index.ts b/src/components/visualize/summary/index.ts new file mode 100644 index 0000000..489e313 --- /dev/null +++ b/src/components/visualize/summary/index.ts @@ -0,0 +1,6 @@ +export { default as GraphSummaryPanel } from "./GraphSummaryPanel"; +export { default as OverviewCard } from "./OverviewCard"; +export { default as ClusterCard } from "./ClusterCard"; +export { default as PatternItem } from "./PatternItem"; +export { default as ConnectionItem } from "./ConnectionItem"; +export { default as RecommendationCard } from "./RecommendationCard"; diff --git a/src/constants/DUMMY_GRAPH_SUMMARY.ts b/src/constants/DUMMY_GRAPH_SUMMARY.ts new file mode 100644 index 0000000..6ab6037 --- /dev/null +++ b/src/constants/DUMMY_GRAPH_SUMMARY.ts @@ -0,0 +1,408 @@ +/** + * Dummy GraphSummary Data + * + * Based on the actual graph summary document provided. + * This data mirrors the output from GraphNode_AI/Ky/src/insights module. + * + * Location: src/constants/DUMMY_GRAPH_SUMMARY.ts + * Usage: Import in development/testing before backend integration + */ + +import type { GraphSummary } from "../types/GraphSummary"; + +export const DUMMY_GRAPH_SUMMARY: GraphSummary = { + overview: { + total_conversations: 376, + time_span: "N/A", + primary_interests: [ + "소프트웨어 및 머신러닝", + "언어 및 작문", + "수학 및 알고리즘", + "정치 및 역사", + "마케팅 및 분석", + ], + conversation_style: "기술 심화형", + most_active_period: "N/A", + summary_text: + "대부분의 대화는 소프트웨어 및 머신러닝(376건 중 234건)에 집중되어 있으며, 언어 및 작문, 수학 및 알고리즘에 대한 후속 관심도 상당합니다. 정치 및 역사, 마케팅 및 분석 분야에서도 작지만 꾸준한 참여가 관찰됩니다. 사용자는 직접적인 코딩, 모델 개발, 알고리즘 추론과 같은 기술적 심층 분석을 선호하며, 동시에 언어적 정교화와 맥락 분석도 요청합니다. 이는 기초 알고리즘에서 응용 머신러닝 및 커뮤니케이션 기술로 나아가는 학습 여정을 반영하며, 종종 기술적 작업을 정책이나 비즈니스 맥락과 연결합니다.", + }, + + clusters: [ + { + cluster_id: "cluster_1", + name: "소프트웨어 및 머신러닝", + size: 234, + density: 0.03, + centrality: 0.85, + recency: "active", + top_keywords: [ + "반복 루프(iteration loop)", + "실시간 자산 포인터", + "현재 대화 상대 확인", + "zshrc 파일 명령", + "csv 읽기 함수", + ], + key_themes: [ + "머신러닝 모델 개발", + "소프트웨어 디버깅", + "환경 설정", + "교차 인코더 재순위화", + "어텐션 분석", + ], + common_question_types: ["디버깅", "구현", "최적화", "코드 리뷰"], + insight_text: + "응용 머신러닝 및 소프트웨어 공학에 집중합니다. 저수준 환경 설정 및 디버깅 작업과 고수준 모델 작업(교차 인코더 재순위화, 평가, 어텐션 분석) 사이를 반복적으로 오갑니다. 도구 활용 단계에서 시작하여 모델 중심의 심화 문제로 발전하는 학습 패턴을 보입니다.", + notable_conversations: [ + "conv_ml_001", + "conv_ml_015", + "conv_ml_089", + "conv_ml_142", + ], + }, + { + cluster_id: "cluster_2", + name: "수학 및 알고리즘", + size: 42, + density: 0.19, + centrality: 0.72, + recency: "active", + top_keywords: [ + "조합 점수 규칙", + "극한 계산(limit)", + "다변수 설정 최적화", + "행렬식", + "수렴 증명", + ], + key_themes: [ + "기초 계산 연습", + "응용 알고리즘 사고", + "점수 규칙 설계", + "분포 모델링", + ], + common_question_types: ["증명", "계산", "개념 설명", "응용"], + insight_text: + "기초적인 계산 연습(극한, 행렬식, 조합론)과 응용 알고리즘 사고(점수 규칙 설계, 분포 모델링)를 병행합니다. 기호 유도부터 수치 시뮬레이션까지, 증명 기반의 이해와 실제 구현을 모두 강조하는 모습을 보입니다.", + notable_conversations: [ + "conv_math_003", + "conv_math_021", + "conv_math_038", + ], + }, + { + cluster_id: "cluster_3", + name: "언어 및 작문", + size: 49, + density: 0.01, + centrality: 0.45, + recency: "active", + top_keywords: [ + "병원 맥락", + "지사/분점 표현", + "회사 및 기관 명칭", + "강남분원", + "서울 중앙병원", + ], + key_themes: [ + "한국어-영어 번역", + "의료 어휘", + "비즈니스 커뮤니케이션", + "격식 표현", + ], + common_question_types: ["번역", "교정", "어휘 선택", "맥락 파악"], + insight_text: + "한국어 모국어 사용자로 추정되며, 의료(치과 등) 및 비즈니스 상황에서 정확하고 맥락에 맞는 번역과 기술 어휘를 탐구합니다. 단순 어휘 습득을 넘어 전문 영역에 적합한 격식 있고 자연스러운 표현을 추구합니다.", + notable_conversations: [ + "conv_lang_007", + "conv_lang_023", + "conv_lang_041", + ], + }, + { + cluster_id: "cluster_4", + name: "정치 및 역사", + size: 38, + density: 0.05, + centrality: 0.38, + recency: "dormant", + top_keywords: [ + "중국 정치 학자", + "중국 내 민족주의 정서", + "중국 시위 비교", + "정책적 함의", + "한중 관계", + ], + key_themes: [ + "중국 민족주의", + "동아시아 정치", + "역사 비교 분석", + "학술 연구", + ], + common_question_types: ["분석", "비교", "문장 교정", "자료 요청"], + insight_text: + "언어적 도움과 실질적인 정치/역사적 탐구를 결합합니다. 중국과 한국을 중심으로 문장 수정에서 시작하여 심층적인 비교 및 기록 분석으로 나아가는 양상을 보입니다.", + notable_conversations: [ + "conv_pol_002", + "conv_pol_019", + "conv_pol_028", + "conv_pol_035", + ], + }, + { + cluster_id: "cluster_5", + name: "마케팅 및 분석", + size: 13, + density: 0.35, + centrality: 0.25, + recency: "new", + top_keywords: [ + "광고 효율성", + "플랫폼별 정액제 광고", + "참여도 기반 가치 해석", + "예측 모델", + "ROI 분석", + ], + key_themes: [ + "광고 성과 측정", + "플랫폼 비교", + "가격 민감도 분석", + "마케팅 전략", + ], + common_question_types: ["분석", "전략 제안", "데이터 해석", "모델링"], + insight_text: + "광고 성과를 수량화하고 플랫폼별 가치를 비교하는 데 큰 관심이 있습니다. 단순한 개념 설명을 넘어 가격 민감도 분석 및 플랫폼별 실행 전략으로 확장되는 경향이 있습니다.", + notable_conversations: ["conv_mkt_001", "conv_mkt_008", "conv_mkt_012"], + }, + ], + + patterns: [ + { + pattern_type: "repetition", + description: + "정치 및 역사 클러스터에서 중국 민족주의 주제가 반복적으로 다뤄집니다. 특히 매우 유사한 대화 쌍이 많아 거의 중복된 논의가 발생하고 있음을 나타냅니다.", + evidence: [ + "conv_pol_002", + "conv_pol_005", + "conv_pol_019", + "conv_pol_022", + "conv_pol_028", + "conv_pol_031", + ], + significance: "high", + }, + { + pattern_type: "repetition", + description: + "소프트웨어 및 머신러닝 클러스터에는 수많은 유사 대화와 연결된 '허브' 대화들이 존재합니다. 이는 전형적인 트러블슈팅 스레드나 기술 질문 템플릿이 반복 사용되고 있음을 의미합니다.", + evidence: [ + "conv_ml_001", + "conv_ml_015", + "conv_ml_032", + "conv_ml_067", + "conv_ml_089", + "conv_ml_112", + "conv_ml_145", + "conv_ml_178", + ], + significance: "high", + }, + { + pattern_type: "bridge", + description: + "소프트웨어 및 머신러닝과 수학 및 알고리즘 사이에 매우 강력한 구조적 연결(강도 1.00)이 존재합니다. 이는 응용 머신러닝 주제와 그 기반이 되는 수학적 개념이 실질적으로 긴밀하게 연결되어 있음을 보여줍니다.", + evidence: [ + "conv_ml_042", + "conv_math_015", + "conv_ml_098", + "conv_math_029", + ], + significance: "high", + }, + { + pattern_type: "progression", + description: + "소프트웨어 클러스터 내에서 도구 활용 → 디버깅 → 모델 최적화 → 고급 어텐션 분석으로 이어지는 학습 진행이 관찰됩니다.", + evidence: [ + "conv_ml_003", + "conv_ml_025", + "conv_ml_078", + "conv_ml_156", + "conv_ml_201", + ], + significance: "medium", + }, + { + pattern_type: "gap", + description: + "마케팅 클러스터의 'A/B 테스트 설계' 주제가 1회 탐색 후 후속 논의가 없습니다. 실무 적용을 위해 복습이 필요할 수 있습니다.", + evidence: ["conv_mkt_006"], + significance: "low", + }, + ], + + connections: [ + { + source_cluster: "소프트웨어 및 머신러닝", + target_cluster: "수학 및 알고리즘", + connection_strength: 1.0, + bridge_keywords: [ + "최적화", + "수렴", + "행렬 연산", + "확률 분포", + "그래디언트", + ], + description: + "응용 머신러닝 기법과 그 기반이 되는 수학적 원리 사이의 매우 강한 연결", + }, + { + source_cluster: "언어 및 작문", + target_cluster: "정치 및 역사", + connection_strength: 0.45, + bridge_keywords: ["문장 교정", "학술 작문", "번역"], + description: "정치/역사 주제에 대한 글쓰기 및 번역 작업을 통한 연결", + }, + { + source_cluster: "소프트웨어 및 머신러닝", + target_cluster: "마케팅 및 분석", + connection_strength: 0.32, + bridge_keywords: ["예측 모델", "데이터 분석", "파이썬"], + description: "마케팅 데이터 분석을 위한 ML 기법 활용", + }, + { + source_cluster: "수학 및 알고리즘", + target_cluster: "마케팅 및 분석", + connection_strength: 0.28, + bridge_keywords: ["통계", "최적화", "모델링"], + description: "마케팅 분석에 활용되는 통계적/수학적 방법론", + }, + ], + + recommendations: [ + { + type: "consolidate", + title: "'반복 루프' 및 ML 구현 이슈 표준 가이드 제작", + description: + "소프트웨어 클러스터에서 빈번하게 발생하는 'iteration loop' 관련 디버깅 템플릿을 통합하여 증상, 원인, 코드 패턴 및 해결책을 담은 표준 가이드를 구축하십시오.", + related_nodes: [ + "conv_ml_001", + "conv_ml_032", + "conv_ml_067", + "conv_ml_112", + ], + priority: "high", + }, + { + type: "consolidate", + title: "중국 민족주의 정치 스레드 종합 입문서 제작", + description: + "거의 중복되는 정치/역사 대화들을 핵심 논거, 주요 학자, 흔한 오해를 정리한 하나의 완성된 입문서로 병합하여 반복되는 논의를 줄이십시오.", + related_nodes: ["conv_pol_002", "conv_pol_019", "conv_pol_028"], + priority: "medium", + }, + { + type: "connect", + title: "머신러닝 실무와 수학적 기초를 잇는 큐레이션 맵 제작", + description: + "소프트웨어와 수학 클러스터 사이의 강력한 연결고리를 활용하여, 공통 ML 기법(최적화, 정규화 등)을 그 기반이 되는 수학적 원리(선형 대수, 수렴 증명 등)와 매핑한 자료를 만드십시오.", + related_nodes: [ + "conv_ml_042", + "conv_math_015", + "conv_ml_098", + "conv_math_029", + ], + priority: "high", + }, + { + type: "explore", + title: "도메인 교차 프로젝트: NLP + 정치 담론 분석", + description: + "언어/작문, 정치/역사, 머신러닝을 결합한 실습 프로젝트(예: 민족주의 수사법 분류기 구축 또는 학술적 합성을 돕는 도구 제작)를 통해 여러 분야의 전문성을 동시에 심화하십시오.", + related_nodes: ["conv_ml_156", "conv_pol_035", "conv_lang_041"], + priority: "medium", + }, + { + type: "review", + title: "A/B 테스트 설계 기법 복습", + description: + "마케팅 클러스터에서 1회만 탐색된 A/B 테스트 설계 주제를 복습하여 실무 적용 준비를 갖추십시오.", + related_nodes: ["conv_mkt_006"], + priority: "low", + }, + ], + + generated_at: "2025-02-04T12:00:00Z", + detail_level: "standard", +}; + +/** + * Alternative minimal dummy data for testing edge cases + */ +export const DUMMY_GRAPH_SUMMARY_MINIMAL: GraphSummary = { + overview: { + total_conversations: 15, + time_span: "2025-01-01 ~ 2025-01-31", + primary_interests: ["프로그래밍", "데이터 분석"], + conversation_style: "탐색형", + most_active_period: "평일 오후", + summary_text: + "짧은 기간 동안 프로그래밍과 데이터 분석에 집중한 탐색적 대화들입니다.", + }, + clusters: [ + { + cluster_id: "cluster_1", + name: "프로그래밍 기초", + size: 10, + density: 0.25, + centrality: 0.6, + recency: "active", + top_keywords: ["파이썬", "변수", "함수"], + key_themes: ["기초 문법", "디버깅"], + common_question_types: ["개념 설명", "코드 리뷰"], + insight_text: "프로그래밍 기초를 학습하는 초기 단계입니다.", + notable_conversations: ["conv_001"], + }, + { + cluster_id: "cluster_2", + name: "데이터 분석", + size: 5, + density: 0.4, + centrality: 0.4, + recency: "new", + top_keywords: ["pandas", "시각화"], + key_themes: ["데이터 처리"], + common_question_types: ["구현"], + insight_text: "데이터 분석 도구를 탐색하기 시작했습니다.", + notable_conversations: ["conv_011"], + }, + ], + patterns: [ + { + pattern_type: "progression", + description: "기초 문법에서 데이터 처리로 자연스럽게 발전", + evidence: ["conv_001", "conv_011"], + significance: "medium", + }, + ], + connections: [ + { + source_cluster: "프로그래밍 기초", + target_cluster: "데이터 분석", + connection_strength: 0.65, + bridge_keywords: ["파이썬"], + description: "파이썬을 통한 연결", + }, + ], + recommendations: [ + { + type: "explore", + title: "머신러닝 입문 탐색", + description: "데이터 분석 경험을 바탕으로 머신러닝 기초를 탐색해보세요.", + related_nodes: ["conv_011"], + priority: "medium", + }, + ], + generated_at: "2025-02-04T12:00:00Z", + detail_level: "brief", +}; + +export default DUMMY_GRAPH_SUMMARY; diff --git a/src/types/GraphSummary.ts b/src/types/GraphSummary.ts new file mode 100644 index 0000000..6b04b06 --- /dev/null +++ b/src/types/GraphSummary.ts @@ -0,0 +1,192 @@ +/** + * GraphSummary Types + * + * TypeScript interfaces mirroring the Python dataclasses from + * TACO-FOR-ALL/GraphNode_AI/Ky/src/insights/discovery/schema.py + * + * These types define the structure for graph analysis insights + * generated by the GraphSummarizer module. + */ + +/** + * High-level overview of the conversation graph. + */ +export interface OverviewSection { + /** Total number of conversations in the graph */ + total_conversations: number; + + /** Time range of conversations (e.g., "2024-01-01 ~ 2025-01-01" or "N/A") */ + time_span: string; + + /** Top 3-5 topics/interests identified */ + primary_interests: string[]; + + /** Characterization of conversation style (e.g., "기술 심화형", "탐색형") */ + conversation_style: string; + + /** When most conversations occur (or "N/A" if no timestamp data) */ + most_active_period: string; + + /** LLM-generated natural language summary */ + summary_text: string; +} + +/** + * Detailed analysis of a single cluster. + */ +export interface ClusterAnalysis { + /** Unique cluster identifier (e.g., "cluster_1") */ + cluster_id: string; + + /** Human-readable cluster name */ + name: string; + + /** Number of conversations in this cluster */ + size: number; + + /** Internal edge density (0-1) */ + density: number; + + /** How connected to other clusters (0-1) */ + centrality: number; + + /** Activity status: "active" | "dormant" | "new" | "unknown" */ + recency: "active" | "dormant" | "new" | "unknown"; + + /** Top extracted keywords */ + top_keywords: string[]; + + /** Key themes identified by LLM */ + key_themes: string[]; + + /** Common question types (e.g., ["디버깅", "개념 설명", "비교"]) */ + common_question_types: string[]; + + /** LLM-generated insight text */ + insight_text: string; + + /** Node IDs of notable conversations */ + notable_conversations: string[]; +} + +/** + * Pattern types that can be identified in the graph. + */ +export type PatternType = "repetition" | "progression" | "gap" | "bridge"; + +/** + * Significance levels for patterns. + */ +export type Significance = "high" | "medium" | "low"; + +/** + * Identified pattern across the graph. + */ +export interface Pattern { + /** Type of pattern detected */ + pattern_type: PatternType; + + /** Description of the pattern */ + description: string; + + /** Node IDs or evidence supporting this pattern */ + evidence: string[]; + + /** Significance level */ + significance: Significance; +} + +/** + * Connection between two clusters. + */ +export interface ClusterConnection { + /** Source cluster name */ + source_cluster: string; + + /** Target cluster name */ + target_cluster: string; + + /** Connection strength (0-1) */ + connection_strength: number; + + /** Keywords that bridge the two clusters */ + bridge_keywords: string[]; + + /** Description of the connection */ + description: string; +} + +/** + * Recommendation types for actionable items. + */ +export type RecommendationType = + | "consolidate" + | "explore" + | "review" + | "connect"; + +/** + * Priority levels for recommendations. + */ +export type Priority = "high" | "medium" | "low"; + +/** + * Actionable recommendation for the user. + */ +export interface Recommendation { + /** Type of recommendation */ + type: RecommendationType; + + /** Short, actionable title */ + title: string; + + /** 1-2 sentence explanation */ + description: string; + + /** Related node IDs */ + related_nodes: string[]; + + /** Priority level */ + priority: Priority; +} + +/** + * Detail levels for summary generation. + */ +export type DetailLevel = "brief" | "standard" | "detailed"; + +/** + * Complete graph summary with insights. + * This is the main response type from the GraphSummarizer. + */ +export interface GraphSummary { + /** High-level overview */ + overview: OverviewSection; + + /** Cluster analyses */ + clusters: ClusterAnalysis[]; + + /** Cross-cutting patterns */ + patterns: Pattern[]; + + /** Cluster connections */ + connections: ClusterConnection[]; + + /** Actionable recommendations */ + recommendations: Recommendation[]; + + /** ISO timestamp when summary was generated */ + generated_at: string; + + /** Level of detail in the summary */ + detail_level: DetailLevel; +} + +/** + * API response wrapper (if needed) + */ +export interface GraphSummaryResponse { + success: boolean; + data: GraphSummary | null; + error?: string; +} From 4f68a9023ceb9505002ffd55548bbe205832df1d Mon Sep 17 00:00:00 2001 From: Kyungdeok Kim Date: Wed, 4 Feb 2026 17:15:59 +0900 Subject: [PATCH 3/4] UPDATE: refine the cards UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - made the summary panel overlay on graph - added opacity and blur on the background. - added fade effect for card scrolling 이슈: - container 사이즈 및 margin 조정 필요 --- .../visualize/summary/GraphSummaryPanel.tsx | 430 ++++++++++++------ 1 file changed, 292 insertions(+), 138 deletions(-) diff --git a/src/components/visualize/summary/GraphSummaryPanel.tsx b/src/components/visualize/summary/GraphSummaryPanel.tsx index 5003e06..fb69f0a 100644 --- a/src/components/visualize/summary/GraphSummaryPanel.tsx +++ b/src/components/visualize/summary/GraphSummaryPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { DUMMY_GRAPH_SUMMARY } from "@/constants/DUMMY_GRAPH_SUMMARY"; import OverviewCard from "./OverviewCard"; import ClusterCard from "./ClusterCard"; @@ -7,7 +7,15 @@ import ConnectionItem from "./ConnectionItem"; import RecommendationCard from "./RecommendationCard"; // Section Header Component -function SectionHeader({ icon, title, subtitle }: { icon: string; title: string; subtitle?: string }) { +function SectionHeader({ + icon, + title, + subtitle, +}: { + icon: string; + title: string; + subtitle?: string; +}) { return (
{icon} @@ -23,9 +31,13 @@ function SectionHeader({ icon, title, subtitle }: { icon: string; title: string; interface GraphSummaryPanelProps { onClusterClick?: (clusterId: string) => void; + onClose?: () => void; } -export default function GraphSummaryPanel({ onClusterClick }: GraphSummaryPanelProps) { +export default function GraphSummaryPanel({ + onClusterClick, + onClose, +}: GraphSummaryPanelProps) { const summary = DUMMY_GRAPH_SUMMARY; const [currentSlide, setCurrentSlide] = useState(0); @@ -40,6 +52,81 @@ export default function GraphSummaryPanel({ onClusterClick }: GraphSummaryPanelP const totalSlides = slides.length; + const fadeSize = 12; + + const edgeFadeX = (fadeStart: boolean, fadeEnd: boolean) => ({ + WebkitMaskImage: `linear-gradient(to right, ${ + fadeStart ? "transparent" : "black" + } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ + fadeEnd ? "transparent" : "black" + } 100%)`, + maskImage: `linear-gradient(to right, ${ + fadeStart ? "transparent" : "black" + } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ + fadeEnd ? "transparent" : "black" + } 100%)`, + }); + + const edgeFadeY = (fadeStart: boolean, fadeEnd: boolean) => ({ + WebkitMaskImage: `linear-gradient(to bottom, ${ + fadeStart ? "transparent" : "black" + } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ + fadeEnd ? "transparent" : "black" + } 100%)`, + maskImage: `linear-gradient(to bottom, ${ + fadeStart ? "transparent" : "black" + } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ + fadeEnd ? "transparent" : "black" + } 100%)`, + }); + + const useScrollFade = (axis: "x" | "y") => { + const ref = useRef(null); + const [fade, setFade] = useState({ start: false, end: false }); + + const update = useCallback(() => { + const el = ref.current; + if (!el) return; + if (axis === "x") { + const max = el.scrollWidth - el.clientWidth; + setFade({ + start: el.scrollLeft > 1, + end: el.scrollLeft < max - 1, + }); + } else { + const max = el.scrollHeight - el.clientHeight; + setFade({ + start: el.scrollTop > 1, + end: el.scrollTop < max - 1, + }); + } + }, [axis]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + update(); + const onScroll = () => update(); + el.addEventListener("scroll", onScroll, { passive: true }); + const ro = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(update) + : null; + if (ro) ro.observe(el); + return () => { + el.removeEventListener("scroll", onScroll); + if (ro) ro.disconnect(); + }; + }, [update]); + + return { ref, fade }; + }; + + const clustersFade = useScrollFade("x"); + const patternsFade = useScrollFade("y"); + const connectionsFade = useScrollFade("y"); + const recommendationsFade = useScrollFade("y"); + // Navigation functions const goToNextSlide = () => { setCurrentSlide((prev) => (prev + 1) % totalSlides); @@ -60,160 +147,227 @@ export default function GraphSummaryPanel({ onClusterClick }: GraphSummaryPanelP goToPrevSlide(); } else if (e.key === "ArrowRight") { goToNextSlide(); + } else if (e.key === "Escape" && onClose) { + onClose(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, []); + }, [onClose]); return ( -
- {/* Navigation Arrows */} - - - - - {/* Slides Container */} + <> + {/* Backdrop Overlay */}
- {/* Slide 0: Overview */} -
-
- -
-
+ className="fixed inset-0 bg-black/10 backdrop-blur-[1px] z-40 transition-opacity duration-300" + onClick={onClose} + /> + + {/* Summary Panel */} +
+
+
+ {/* Close Button */} + {onClose && ( + + )} - {/* Slide 1: Clusters */} -
-
- -
- {summary.clusters.map((cluster) => ( - onClusterClick?.(cluster.cluster_id)} + {/* Navigation Arrows */} +
-
-
+ + - {/* Slide 2: Patterns */} -
-
- -
- {summary.patterns.map((pattern, idx) => ( - - ))} + + + {/* Slides Container */} +
+ {/* Slide 0: Overview */} +
+
+ +
+
+ + {/* Slide 1: Clusters */} +
+
+ +
+ {summary.clusters.map((cluster) => ( + onClusterClick?.(cluster.cluster_id)} + /> + ))} +
+
+
+ + {/* Slide 2: Patterns */} +
+
+ +
+ {summary.patterns.map((pattern, idx) => ( + + ))} +
+
+
+ + {/* Slide 3: Connections */} +
+
+ +
+ {summary.connections.map((connection, idx) => ( + + ))} +
+
+
+ + {/* Slide 4: Recommendations */} +
+
+ +
+ {summary.recommendations.map((rec, idx) => ( + + ))} +
+
+
-
-
- {/* Slide 3: Connections */} -
-
- -
- {summary.connections.map((connection, idx) => ( - + {/* Slide Indicators */} +
+ {slides.map((slide, index) => ( + ))}
-
-
- {/* Slide 4: Recommendations */} -
-
- -
- {summary.recommendations.map((rec, idx) => ( - - ))} + {/* Footer Info */} +
+

+ 생성: {new Date(summary.generated_at).toLocaleString("ko-KR")} ·{" "} + {summary.detail_level} +

- - {/* Slide Indicators */} -
- {slides.map((slide, index) => ( - - ))} -
- - {/* Footer Info */} -
-

생성: {new Date(summary.generated_at).toLocaleString("ko-KR")} · {summary.detail_level}

-
-
+ ); } From 402d14fd6081ef3527b10c6d3c3b676979f3c455 Mon Sep 17 00:00:00 2001 From: Kyungdeok Kim Date: Wed, 4 Feb 2026 17:32:58 +0900 Subject: [PATCH 4/4] REFACTOR: add docstrings and README.md --- src/components/visualize/VisualizeToggle.tsx | 32 ++- .../visualize/summary/GraphSummaryPanel.tsx | 250 ++++++++++-------- src/components/visualize/summary/README.md | 46 ++++ 3 files changed, 202 insertions(+), 126 deletions(-) create mode 100644 src/components/visualize/summary/README.md diff --git a/src/components/visualize/VisualizeToggle.tsx b/src/components/visualize/VisualizeToggle.tsx index 201054f..17a4404 100644 --- a/src/components/visualize/VisualizeToggle.tsx +++ b/src/components/visualize/VisualizeToggle.tsx @@ -24,7 +24,8 @@ export default function VisualizeToggle({ statisticData: GraphStatsDto; avatarUrl: string | null; }) { - const [mode, setMode] = useState<"2d" | "3d" | "summary">("2d"); + const [mode, setMode] = useState<"2d" | "3d">("2d"); + const [showSummary, setShowSummary] = useState(false); const [toggleTopClutserPanel, setToggleTopClutserPanel] = useState(false); const [clusters, setClusters] = useState([]); const [nodes, setNodes] = useState([]); @@ -207,32 +208,38 @@ export default function VisualizeToggle({
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
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
setMode("summary")} + onClick={() => setShowSummary(true)} className={`flex-1 flex items-center justify-center text-sm font-medium cursor-pointer relative z-10 transition-colors duration-200 ${ - mode === "summary" ? "text-primary" : "text-text-secondary" + showSummary ? "text-primary" : "text-text-secondary" }`} > Summary
@@ -250,13 +257,18 @@ export default function VisualizeToggle({ onClustersReady={handleClustersReady} zoomToClusterId={zoomToClusterId} /> - ) : mode === "3d" ? ( - ) : ( + + )} + + {/* Summary Overlay */} + {showSummary && ( setShowSummary(false)} onClusterClick={(clusterId) => { setZoomToClusterId(clusterId); setMode("2d"); + setShowSummary(false); // Reset zoom after animation completes setTimeout(() => setZoomToClusterId(null), 900); }} diff --git a/src/components/visualize/summary/GraphSummaryPanel.tsx b/src/components/visualize/summary/GraphSummaryPanel.tsx index fb69f0a..6178f45 100644 --- a/src/components/visualize/summary/GraphSummaryPanel.tsx +++ b/src/components/visualize/summary/GraphSummaryPanel.tsx @@ -6,7 +6,90 @@ import PatternItem from "./PatternItem"; import ConnectionItem from "./ConnectionItem"; import RecommendationCard from "./RecommendationCard"; -// Section Header Component +type FadeState = { start: boolean; end: boolean }; + +// 요약 화면 내 슬라이드 정의 (순서가 곧 네비게이션 순서) +const SLIDES = [ + { id: "overview", name: "개요" }, + { id: "clusters", name: "클러스터" }, + { id: "patterns", name: "패턴" }, + { id: "connections", name: "연결" }, + { id: "recommendations", name: "추천" }, +] as const; + +// 페이드 마스크의 두께(px). 스크롤 경계가 잘릴 때만 적용됨 +const FADE_SIZE = 12; + +/** 스크롤 경계가 잘릴 때만 페이드가 보이도록 마스크를 생성 */ +const buildFadeStyle = (axis: "x" | "y", fade: FadeState) => { + const direction = axis === "x" ? "to right" : "to bottom"; + const gradient = `linear-gradient(${direction}, ${ + fade.start ? "transparent" : "black" + } 0px, black ${FADE_SIZE}px, black calc(100% - ${FADE_SIZE}px), ${ + fade.end ? "transparent" : "black" + } 100%)`; + + return { + WebkitMaskImage: gradient, + maskImage: gradient, + }; +}; + +/** + * 스크롤 위치를 관찰해 시작/끝 페이드 여부를 계산 + * - start: 스크롤이 시작 방향(왼쪽/위)에서 이미 이동했는지 + * - end: 스크롤이 끝 방향(오른쪽/아래)에 아직 남았는지 + */ +const useScrollFade = (axis: "x" | "y") => { + const ref = useRef(null); + const [fade, setFade] = useState({ start: false, end: false }); + + const update = useCallback(() => { + const el = ref.current; + if (!el) return; + + if (axis === "x") { + const max = el.scrollWidth - el.clientWidth; + setFade({ + start: el.scrollLeft > 1, + end: el.scrollLeft < max - 1, + }); + return; + } + + const max = el.scrollHeight - el.clientHeight; + setFade({ + start: el.scrollTop > 1, + end: el.scrollTop < max - 1, + }); + }, [axis]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + update(); + + const onScroll = () => update(); + el.addEventListener("scroll", onScroll, { passive: true }); + + // 컨텐츠 크기 변경(리사이즈)에도 페이드 상태가 업데이트되도록 옵저버 등록 + const ro = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(update) + : null; + if (ro) ro.observe(el); + + return () => { + el.removeEventListener("scroll", onScroll); + if (ro) ro.disconnect(); + }; + }, [update]); + + return { ref, fade }; +}; + +// 섹션 제목 컴포넌트 function SectionHeader({ icon, title, @@ -34,100 +117,30 @@ interface GraphSummaryPanelProps { onClose?: () => void; } +/** 그래프 위에 얹히는 요약 팝업(배경 그래프가 보이도록 투명 처리) */ export default function GraphSummaryPanel({ onClusterClick, onClose, }: GraphSummaryPanelProps) { const summary = DUMMY_GRAPH_SUMMARY; const [currentSlide, setCurrentSlide] = useState(0); + const totalSlides = SLIDES.length; - // Define slides - const slides = [ - { id: "overview", name: "개요" }, - { id: "clusters", name: "클러스터" }, - { id: "patterns", name: "패턴" }, - { id: "connections", name: "연결" }, - { id: "recommendations", name: "추천" }, - ]; - - const totalSlides = slides.length; - - const fadeSize = 12; - - const edgeFadeX = (fadeStart: boolean, fadeEnd: boolean) => ({ - WebkitMaskImage: `linear-gradient(to right, ${ - fadeStart ? "transparent" : "black" - } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ - fadeEnd ? "transparent" : "black" - } 100%)`, - maskImage: `linear-gradient(to right, ${ - fadeStart ? "transparent" : "black" - } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ - fadeEnd ? "transparent" : "black" - } 100%)`, - }); - - const edgeFadeY = (fadeStart: boolean, fadeEnd: boolean) => ({ - WebkitMaskImage: `linear-gradient(to bottom, ${ - fadeStart ? "transparent" : "black" - } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ - fadeEnd ? "transparent" : "black" - } 100%)`, - maskImage: `linear-gradient(to bottom, ${ - fadeStart ? "transparent" : "black" - } 0px, black ${fadeSize}px, black calc(100% - ${fadeSize}px), ${ - fadeEnd ? "transparent" : "black" - } 100%)`, - }); - - const useScrollFade = (axis: "x" | "y") => { - const ref = useRef(null); - const [fade, setFade] = useState({ start: false, end: false }); - - const update = useCallback(() => { - const el = ref.current; - if (!el) return; - if (axis === "x") { - const max = el.scrollWidth - el.clientWidth; - setFade({ - start: el.scrollLeft > 1, - end: el.scrollLeft < max - 1, - }); - } else { - const max = el.scrollHeight - el.clientHeight; - setFade({ - start: el.scrollTop > 1, - end: el.scrollTop < max - 1, - }); - } - }, [axis]); - - useEffect(() => { - const el = ref.current; - if (!el) return; - update(); - const onScroll = () => update(); - el.addEventListener("scroll", onScroll, { passive: true }); - const ro = - typeof ResizeObserver !== "undefined" - ? new ResizeObserver(update) - : null; - if (ro) ro.observe(el); - return () => { - el.removeEventListener("scroll", onScroll); - if (ro) ro.disconnect(); - }; - }, [update]); - - return { ref, fade }; - }; - + // 각 스크롤 영역별 페이드 상태 const clustersFade = useScrollFade("x"); const patternsFade = useScrollFade("y"); const connectionsFade = useScrollFade("y"); const recommendationsFade = useScrollFade("y"); - // Navigation functions + const clustersFadeStyle = buildFadeStyle("x", clustersFade.fade); + const patternsFadeStyle = buildFadeStyle("y", patternsFade.fade); + const connectionsFadeStyle = buildFadeStyle("y", connectionsFade.fade); + const recommendationsFadeStyle = buildFadeStyle( + "y", + recommendationsFade.fade + ); + + // 슬라이드 이동 const goToNextSlide = () => { setCurrentSlide((prev) => (prev + 1) % totalSlides); }; @@ -140,7 +153,7 @@ export default function GraphSummaryPanel({ setCurrentSlide(index); }; - // Keyboard navigation + // 키보드 네비게이션 (좌/우, ESC 닫기) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") { @@ -158,15 +171,16 @@ export default function GraphSummaryPanel({ return ( <> - {/* Backdrop Overlay */} + {/* Backdrop Overlay: 배경 그래프가 보이도록 약한 dim + 최소 blur */}
- {/* Summary Panel */} + {/* Summary Panel: 중앙 정렬 팝업 컨테이너 */}
+ {/* 실제 컨텐츠 영역 (크기 조절은 max-w/max-h) */}
{/* Close Button */} {onClose && ( @@ -232,7 +246,7 @@ export default function GraphSummaryPanel({ - {/* Slides Container */} + {/* Slides Container: 좌우 슬라이드 전환 영역 */}
-
- {summary.clusters.map((cluster) => ( - + {summary.clusters.map((cluster) => ( + onClusterClick?.(cluster.cluster_id)} @@ -276,13 +291,14 @@ export default function GraphSummaryPanel({ title="발견된 패턴" subtitle={`${summary.patterns.length}개의 인사이트`} /> -
- {summary.patterns.map((pattern, idx) => ( - + {/* 세로 스크롤: 잘릴 때만 가장자리에 페이드 */} +
+ {summary.patterns.map((pattern, idx) => ( + ))}
@@ -296,13 +312,14 @@ export default function GraphSummaryPanel({ title="클러스터 연결" subtitle={`${summary.connections.length}개의 연결 고리`} /> -
- {summary.connections.map((connection, idx) => ( - + {/* 세로 스크롤: 잘릴 때만 가장자리에 페이드 */} +
+ {summary.connections.map((connection, idx) => ( + ))}
@@ -316,13 +333,14 @@ export default function GraphSummaryPanel({ title="추천 액션" subtitle={`${summary.recommendations.length}개의 제안사항`} /> -
- {summary.recommendations.map((rec, idx) => ( - + {/* 세로 스크롤(2열 그리드): 잘릴 때만 가장자리에 페이드 */} +
+ {summary.recommendations.map((rec, idx) => ( + ))}
@@ -331,7 +349,7 @@ export default function GraphSummaryPanel({ {/* Slide Indicators */}
- {slides.map((slide, index) => ( + {SLIDES.map((slide, index) => (