'use client'; import { useMemo, useState, useRef, useEffect } from 'react'; import { Target, Zap, Clock, LayoutGrid, TrendingUp, Users, Wrench, X, ChevronRight, MessageSquare, Star, Maximize2 } from 'lucide-react'; import type { OpportunityMatrix as OpportunityMatrixType, OpportunityItem, OpportunitySpan } from '../types'; import { ReviewModal } from '../tables/ReviewModal'; import { DOMAIN_COLORS, COMPLEXITY_LABELS } from '../types'; interface OpportunityMatrixProps { matrix: OpportunityMatrixType | null; onSubcodeClick?: (subcode: string) => void; } interface QuadrantProps { title: string; icon: React.ReactNode; items: OpportunityItem[]; bgColor: string; borderColor: string; textColor: string; iconBg: string; onItemClick?: (item: OpportunityItem) => void; onItemHover?: (item: OpportunityItem | null) => void; selectedItem?: OpportunityItem | null; } type ConnectorSide = 'top' | 'bottom' | 'left' | 'right'; interface LabelPosition { x: number; // Label center X y: number; // Label center Y connector: ConnectorSide; // Which side the edge connects to nodeX: number; nodeY: number; } // Get the connector point on the label edge based on connector side function getConnectorPoint(pos: LabelPosition): { x: number; y: number } { const halfW = LABEL_WIDTH / 2; const halfH = LABEL_HEIGHT / 2; switch (pos.connector) { case 'top': return { x: pos.x, y: pos.y - halfH }; case 'bottom': return { x: pos.x, y: pos.y + halfH }; case 'left': return { x: pos.x - halfW, y: pos.y }; case 'right': return { x: pos.x + halfW, y: pos.y }; } } // Determine the best connector side based on angle from node to label function getConnectorSide(nodeX: number, nodeY: number, labelX: number, labelY: number): ConnectorSide { const angle = Math.atan2(labelY - nodeY, labelX - nodeX) * (180 / Math.PI); // Angle ranges for each connector: // Right: -45 to 45 (label is to the right of node, connect on left side of label) // Bottom: 45 to 135 (label is below node, connect on top of label) // Left: 135 to 180 or -180 to -135 (label is to the left, connect on right side) // Top: -135 to -45 (label is above node, connect on bottom of label) if (angle >= -45 && angle < 45) { return 'left'; // Label is to the right, connect on its left side } else if (angle >= 45 && angle < 135) { return 'top'; // Label is below, connect on its top } else if (angle >= -135 && angle < -45) { return 'bottom'; // Label is above, connect on its bottom } else { return 'right'; // Label is to the left, connect on its right side } } // Estimated label dimensions in percentage units const LABEL_WIDTH = 18; // ~18% width for truncated names (up to 14 chars) const LABEL_HEIGHT = 6; // ~6% height // Check if two rectangles overlap (with padding) function rectsOverlap( r1: { x: number; y: number; w: number; h: number }, r2: { x: number; y: number; w: number; h: number }, padding = 1 ): boolean { return !( r1.x + r1.w + padding < r2.x || r2.x + r2.w + padding < r1.x || r1.y + r1.h + padding < r2.y || r2.y + r2.h + padding < r1.y ); } // Get label bounding box based on center position function getLabelRect(pos: { x: number; y: number }) { const w = LABEL_WIDTH; const h = LABEL_HEIGHT; const x = pos.x - w / 2; const y = pos.y - h / 2; return { x, y, w, h }; } // Check if two line segments intersect function linesIntersect( p1: { x: number; y: number }, p2: { x: number; y: number }, p3: { x: number; y: number }, p4: { x: number; y: number } ): boolean { const ccw = (A: { x: number; y: number }, B: { x: number; y: number }, C: { x: number; y: number }) => { return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x); }; // Check if segments share an endpoint (not a real crossing) const samePoint = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5; if (samePoint(p1, p3) || samePoint(p1, p4) || samePoint(p2, p3) || samePoint(p2, p4)) { return false; } return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4); } interface Cluster { centerX: number; centerY: number; radius: number; nodeIndices: number[]; } // Find clusters of nearby nodes using distance threshold function findClusters(nodes: Array<{ x: number; y: number }>, threshold: number): Cluster[] { const visited = new Set(); const clusters: Cluster[] = []; for (let i = 0; i < nodes.length; i++) { if (visited.has(i)) continue; // BFS to find all connected nodes const clusterIndices: number[] = []; const queue = [i]; while (queue.length > 0) { const current = queue.shift()!; if (visited.has(current)) continue; visited.add(current); clusterIndices.push(current); // Find neighbors within threshold for (let j = 0; j < nodes.length; j++) { if (visited.has(j)) continue; const dist = Math.hypot(nodes[j].x - nodes[current].x, nodes[j].y - nodes[current].y); if (dist < threshold) { queue.push(j); } } } // Calculate cluster center and radius if (clusterIndices.length > 0) { const clusterNodes = clusterIndices.map((idx) => nodes[idx]); const centerX = clusterNodes.reduce((sum, n) => sum + n.x, 0) / clusterNodes.length; const centerY = clusterNodes.reduce((sum, n) => sum + n.y, 0) / clusterNodes.length; // Radius is max distance from center + padding for labels let maxDist = 0; for (const n of clusterNodes) { const dist = Math.hypot(n.x - centerX, n.y - centerY); maxDist = Math.max(maxDist, dist); } const radius = maxDist + 15; // Padding for labels clusters.push({ centerX, centerY, radius, nodeIndices: clusterIndices }); } } return clusters; } // Generate candidate positions for a node based on cluster membership function generateCandidatePositions( node: { x: number; y: number }, cluster: Cluster | null ): Array<{ x: number; y: number }> { const positions: Array<{ x: number; y: number }> = []; if (cluster && cluster.nodeIndices.length > 1) { // For clustered nodes: project outward from cluster center through the node const baseAngle = Math.atan2(node.y - cluster.centerY, node.x - cluster.centerX); // Primary position: on the cluster circle boundary // Plus variations around that angle for fine-tuning const angleVariations = [0, -0.25, 0.25, -0.5, 0.5, -0.75, 0.75]; const distanceVariations = [cluster.radius, cluster.radius + 6, cluster.radius - 4]; for (const distVar of distanceVariations) { for (const angleVar of angleVariations) { const angle = baseAngle + angleVar; const x = cluster.centerX + Math.cos(angle) * distVar; const y = cluster.centerY + Math.sin(angle) * distVar * 0.85; positions.push({ x, y }); } } } else { // For isolated nodes: use clock positions around the node const distances = [12, 16]; for (const dist of distances) { for (let hour = 0; hour < 12; hour++) { const angle = (hour * 30 - 90) * (Math.PI / 180); const dx = Math.cos(angle) * dist; const dy = Math.sin(angle) * dist * 0.75; positions.push({ x: node.x + dx, y: node.y + dy }); } } } return positions; } // Calculate optimal label positions to avoid overlaps function calculateLabelPositions(items: OpportunityItem[]): LabelPosition[] { if (items.length === 0) return []; // Convert items to node positions const nodes = items.map((item) => ({ x: 15 + item.x * 70, y: 30 + item.y * 55, subcode: item.subcode, })); // Find clusters of nearby nodes const clusters = findClusters(nodes, 15); // Map each node to its cluster (if any) const nodeToCluster = new Map(); for (const cluster of clusters) { if (cluster.nodeIndices.length > 1) { for (const idx of cluster.nodeIndices) { nodeToCluster.set(idx, cluster); } } } // Initialize result array with default positions const result: LabelPosition[] = nodes.map((node) => ({ x: node.x + 12, y: node.y, connector: 'left' as ConnectorSide, nodeX: node.x, nodeY: node.y, })); // Track which labels have been placed const placedLabels: LabelPosition[] = []; // Sort: process clustered nodes first (by cluster size desc), then by angle within cluster const sortedIndices = nodes .map((_, i) => i) .sort((a, b) => { const clusterA = nodeToCluster.get(a); const clusterB = nodeToCluster.get(b); const sizeA = clusterA?.nodeIndices.length ?? 0; const sizeB = clusterB?.nodeIndices.length ?? 0; if (sizeA !== sizeB) return sizeB - sizeA; // Larger clusters first // Within same cluster, sort by angle from center if (clusterA && clusterA === clusterB) { const angleA = Math.atan2(nodes[a].y - clusterA.centerY, nodes[a].x - clusterA.centerX); const angleB = Math.atan2(nodes[b].y - clusterA.centerY, nodes[b].x - clusterA.centerX); return angleA - angleB; } return nodes[a].y - nodes[b].y; }); for (const idx of sortedIndices) { const node = nodes[idx]; const cluster = nodeToCluster.get(idx) ?? null; const candidates = generateCandidatePositions(node, cluster); let bestPosition: LabelPosition | null = null; let minScore = Infinity; for (const candidate of candidates) { // Determine which connector side based on angle from node to label center const connector = getConnectorSide(node.x, node.y, candidate.x, candidate.y); const pos: LabelPosition = { x: candidate.x, y: candidate.y, connector, nodeX: node.x, nodeY: node.y, }; // Check bounds (keep label within quadrant) const rect = getLabelRect(pos); if (rect.x < 3 || rect.x + rect.w > 97 || rect.y < 22 || rect.y + rect.h > 93) { continue; } // Calculate score (lower is better) let score = 0; // Penalty for overlapping with placed labels for (const placed of placedLabels) { const placedRect = getLabelRect(placed); if (rectsOverlap(rect, placedRect, 2)) { const overlapX = Math.max(0, Math.min(rect.x + rect.w, placedRect.x + placedRect.w) - Math.max(rect.x, placedRect.x)); const overlapY = Math.max(0, Math.min(rect.y + rect.h, placedRect.y + placedRect.h) - Math.max(rect.y, placedRect.y)); score += (overlapX * overlapY) * 10; } } // Penalty for overlapping with other nodes (dots) for (const otherNode of nodes) { if (otherNode === node) continue; const nodeRect = { x: otherNode.x - 3, y: otherNode.y - 3, w: 6, h: 6 }; if (rectsOverlap(rect, nodeRect, 0)) { score += 100; } } // Heavy penalty for leader line crossing other leader lines (using connector points) const connectorPoint = getConnectorPoint(pos); for (const placed of placedLabels) { const placedConnector = getConnectorPoint(placed); if (linesIntersect( { x: node.x, y: node.y }, connectorPoint, { x: placed.nodeX, y: placed.nodeY }, placedConnector )) { score += 200; } } // Small penalty for distance from node (prefer closer positions) const dist = Math.hypot(candidate.x - node.x, candidate.y - node.y); score += dist * 0.05; if (score < minScore) { minScore = score; bestPosition = pos; } if (score < 0.1) break; } if (bestPosition) { result[idx] = bestPosition; } placedLabels.push(result[idx]); } return result; } function Quadrant({ title, icon, items, bgColor, borderColor, textColor, iconBg, onItemClick, onItemHover, selectedItem, }: QuadrantProps) { // Calculate optimized label positions const labelPositions = useMemo(() => calculateLabelPositions(items), [items]); return (
{/* Header */}
{icon}
{title} {items.length}
{/* Positioned items with dots and leader lines */} {items.length === 0 ? (
None
) : ( {items.map((item, index) => { const pos = labelPositions[index]; if (!pos) return null; // Get the connector point on the label edge const connector = getConnectorPoint(pos); const isSelected = selectedItem?.subcode === item.subcode; return ( {/* Leader line from node to label connector */} {/* Dot at node position */} ); })} )} {/* Clickable labels */} {items.length > 0 && items.map((item, index) => { const pos = labelPositions[index]; if (!pos) return null; // Truncate long names for display const displayName = item.name.length > 14 ? item.name.slice(0, 12) + 'โ€ฆ' : item.name; const isSelected = selectedItem?.subcode === item.subcode; return ( ); })}
); } // Hover preview card function HoverCard({ item, position }: { item: OpportunityItem; position: { x: number; y: number } }) { return (
{item.name}
{item.domain_name}
Negative {item.negative_pct}%
{item.rating_impact && (
Rating impact +{item.rating_impact}
)}
Click for details โ†’
); } // Render stars for rating function RatingStars({ rating }: { rating: number }) { return (
{[1, 2, 3, 4, 5].map((star) => ( ))}
); } // Normalize text for matching (handles spacing differences around punctuation) function normalizeForMatching(text: string): string { return text .toLowerCase() .replace(/\s+([.,!?;:])/g, '$1') // Remove space before punctuation .replace(/([.,!?;:])\s+/g, '$1 ') // Normalize space after punctuation .replace(/\s+/g, ' ') // Multiple spaces to single .trim(); } // Find span in review using normalized matching, return original indices function findSpanInReview(reviewText: string, spanText: string): { start: number; end: number } | null { // Try exact match first (case-insensitive) const lowerReview = reviewText.toLowerCase(); const lowerSpan = spanText.toLowerCase(); let idx = lowerReview.indexOf(lowerSpan); if (idx !== -1) { return { start: idx, end: idx + spanText.length }; } // Try normalized matching const normReview = normalizeForMatching(reviewText); const normSpan = normalizeForMatching(spanText); const normIdx = normReview.indexOf(normSpan); if (normIdx === -1) return null; // Map normalized index back to original text // Walk through original text, tracking normalized position let origIdx = 0; let normPos = 0; const targetNormStart = normIdx; const targetNormEnd = normIdx + normSpan.length; let origStart = -1; let origEnd = -1; while (origIdx <= reviewText.length && normPos <= normReview.length) { if (normPos === targetNormStart) origStart = origIdx; if (normPos === targetNormEnd) { origEnd = origIdx; break; } if (origIdx < reviewText.length) { const origChar = reviewText[origIdx]; const normChar = normReview[normPos]; // Skip characters that were removed in normalization if (origChar.toLowerCase() === normChar) { origIdx++; normPos++; } else if (/\s/.test(origChar)) { origIdx++; // Skip extra whitespace in original } else { origIdx++; normPos++; } } else { break; } } if (origStart !== -1 && origEnd === -1) { origEnd = reviewText.length; } return origStart !== -1 ? { start: origStart, end: origEnd } : null; } // Highlight span text within review text function HighlightedReviewText({ reviewText, spanText, maxLength = 150, expanded = false, }: { reviewText: string; spanText: string; maxLength?: number; expanded?: boolean; }) { // Find the span within the review using fuzzy matching const match = findSpanInReview(reviewText, spanText); if (!match) { // Span not found, just show truncated review const displayText = expanded ? reviewText : reviewText.slice(0, maxLength); return ( {displayText} {!expanded && reviewText.length > maxLength && '...'} ); } const spanIndex = match.start; const spanEnd = match.end; // If span covers most of the review (>80%), highlight the whole thing const spanCoverage = (spanEnd - spanIndex) / reviewText.length; if (spanCoverage > 0.8) { const displayText = expanded ? reviewText : reviewText.slice(0, maxLength); return ( {displayText} {!expanded && reviewText.length > maxLength && '...'} ); } // Calculate display window around the span let displayStart = 0; let displayEnd = reviewText.length; if (!expanded) { // Show context around the span const contextBefore = 40; const contextAfter = 60; displayStart = Math.max(0, spanIndex - contextBefore); displayEnd = Math.min(reviewText.length, spanEnd + contextAfter); // Adjust to not cut words if (displayStart > 0) { const spaceIndex = reviewText.indexOf(' ', displayStart); if (spaceIndex !== -1 && spaceIndex < spanIndex) { displayStart = spaceIndex + 1; } } if (displayEnd < reviewText.length) { const spaceIndex = reviewText.lastIndexOf(' ', displayEnd); if (spaceIndex !== -1 && spaceIndex > spanEnd) { displayEnd = spaceIndex; } } } const before = reviewText.slice(displayStart, spanIndex); const highlight = reviewText.slice(spanIndex, spanEnd); const after = reviewText.slice(spanEnd, displayEnd); return ( {displayStart > 0 && '...'} {before} {highlight} {after} {!expanded && displayEnd < reviewText.length && '...'} ); } // Google-style review card for customer feedback function SpanCard({ span, onViewReview }: { span: OpportunitySpan; onViewReview?: (reviewId: string, spanId: string) => void; }) { const [expanded, setExpanded] = useState(false); const reviewText = span.review_text || span.span_text; const isLongReview = reviewText.length > 150; return (
{/* Header: Stars + Date + Expand button */}
{span.rating && } {span.review_date && ( {span.review_date} )} {span.review_id && onViewReview && ( )}
{/* Review text with highlighted span */}

{span.review_text ? ( ) : ( "{span.span_text}" )} {isLongReview && !expanded && ( )} {expanded && isLongReview && ( )}

); } // Detail panel component function DetailPanel({ item, onClose, onViewReview, }: { item: OpportunityItem; onClose: () => void; onViewReview?: (reviewId: string, spanId: string) => void; }) { return (
{/* Header */}

{item.name}

{item.domain_name} ยท {item.subcode}
{/* Metrics */}
Negative Sentiment
{item.negative_pct}%
{item.span_count} mentions
{item.rating_impact && (
If Fixed
+{item.rating_impact}
rating points
)}
{/* Complexity */}
Complexity: {COMPLEXITY_LABELS[item.complexity] || item.complexity}
{/* Customer Feedback Spans (evidence of the problem) - expands to fill available space */} {item.spans && item.spans.length > 0 && (
Customer Feedback ({item.spans.length})
{item.spans.map((span) => ( ))}
)} {/* Solution */} {item.solution && (
๐Ÿ’ก Suggested Solution

{item.solution}

)} {/* Owner */} {item.owner && (
Assign to: {item.owner}
)} {/* CTA */}
); } /** * 2x2 Opportunity Matrix visualization. * Shows issues categorized by frequency vs complexity with coordinate positioning. */ export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixProps) { const [selectedItem, setSelectedItem] = useState(null); const [hoveredItem, setHoveredItem] = useState(null); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); const [gridSize, setGridSize] = useState(null); const matrixRef = useRef(null); const gridContainerRef = useRef(null); const componentRef = useRef(null); const [reviewModal, setReviewModal] = useState<{ reviewId: string; spanId: string } | null>(null); // Calculate grid size based on available space useEffect(() => { const updateGridSize = () => { // Calculate max available height for the grid (viewport - margins - header - labels - legend) const maxAvailableHeight = window.innerHeight - 300; // Get component width, then subtract detail panel min (280px) + y-axis label (40px) + gaps (32px) const componentWidth = componentRef.current?.offsetWidth ?? window.innerWidth * 0.6; const maxAvailableWidth = componentWidth - 280 - 40 - 32; // detail panel min + y-label + padding/gaps // Grid must be square - use the smaller of available height or width const size = Math.min(maxAvailableHeight, maxAvailableWidth); setGridSize(Math.max(250, size)); }; // Delay initial calculation to allow layout to settle const timeoutId = setTimeout(updateGridSize, 50); window.addEventListener('resize', updateGridSize); // Also observe component width changes if (componentRef.current) { const resizeObserver = new ResizeObserver(updateGridSize); resizeObserver.observe(componentRef.current); return () => { clearTimeout(timeoutId); window.removeEventListener('resize', updateGridSize); resizeObserver.disconnect(); }; } return () => { clearTimeout(timeoutId); window.removeEventListener('resize', updateGridSize); }; }, []); if (!matrix) { return null; } const totalItems = matrix.quick_wins.length + matrix.critical.length + matrix.nice_to_have.length + matrix.strategic.length; if (totalItems === 0) { return null; } const handleItemClick = (item: OpportunityItem) => { setSelectedItem(item); onSubcodeClick?.(item.subcode); }; const handleItemHover = (item: OpportunityItem | null) => { setHoveredItem(item); }; const handleMouseMove = (e: React.MouseEvent) => { setMousePos({ x: e.clientX, y: e.clientY }); }; return (

Opportunity Matrix

{totalItems} opportunities
{/* Main layout: Matrix + Detail Panel */}
{/* Matrix section */}
{/* Matrix with L-shaped label area */}
{/* Y-axis label column - height matches grid */}
Effort โ†’
{/* Main matrix area */}
{/* Matrix with arrows */}
{/* Coordinate axes - L-shaped from bottom-left origin */} {/* Horizontal arrowhead - points right */} {/* Vertical arrowhead - points up (pre-rotated triangle) */} {/* X-axis (horizontal - frequency increases right) */} {/* Y-axis (vertical - effort increases up) */}
{/* Unified coordinate grid overlay */} {/* Vertical grid lines */} {[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((x) => ( ))} {/* Horizontal grid lines */} {[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((y) => ( ))}
{/* Top Row: Simple solutions */} } items={matrix.nice_to_have} bgColor="bg-gray-50" borderColor="border-gray-200" textColor="text-gray-700" iconBg="bg-gray-200" onItemClick={handleItemClick} onItemHover={handleItemHover} selectedItem={selectedItem} /> } items={matrix.quick_wins} bgColor="bg-green-50" borderColor="border-green-200" textColor="text-green-700" iconBg="bg-green-200" onItemClick={handleItemClick} onItemHover={handleItemHover} selectedItem={selectedItem} /> {/* Bottom Row: Complex solutions */} } items={matrix.strategic} bgColor="bg-purple-50" borderColor="border-purple-200" textColor="text-purple-700" iconBg="bg-purple-200" onItemClick={handleItemClick} onItemHover={handleItemHover} selectedItem={selectedItem} /> } items={matrix.critical} bgColor="bg-red-50" borderColor="border-red-200" textColor="text-red-700" iconBg="bg-red-200" onItemClick={handleItemClick} onItemHover={handleItemHover} selectedItem={selectedItem} />
{/* X-axis label row */}
Frequency โ†’
{/* Legend */}
Quick Wins
Critical
Strategic
Nice to Have
{/* Detail Panel - expands horizontally, height matches grid + labels */}
{selectedItem ? ( setSelectedItem(null)} onViewReview={(reviewId, spanId) => setReviewModal({ reviewId, spanId })} /> ) : (

Select an opportunity

Click on any item in the matrix to see details and suggested actions

)}
{/* Hover Card */} {hoveredItem && !selectedItem && ( )} {/* Review Modal */} {reviewModal && ( setReviewModal(null)} /> )}
); }