Spaces:
Running
Running
| // Mind Map Visualization Component | |
| class MindMap { | |
| static render(container, data) { | |
| if (!container || !data) return; | |
| // Clear previous content | |
| container.innerHTML = ''; | |
| // Create SVG element | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| svg.setAttribute('width', '100%'); | |
| svg.setAttribute('height', '100%'); | |
| svg.setAttribute('viewBox', '0 0 800 400'); | |
| container.appendChild(svg); | |
| // Setup D3 force simulation | |
| const simulation = d3.forceSimulation(data.nodes) | |
| .force('link', d3.forceLink(data.edges).id(d => d.id).distance(150)) | |
| .force('charge', d3.forceManyBody().strength(-500)) | |
| .force('center', d3.forceCenter(400, 200)) | |
| .force('collision', d3.forceCollide().radius(60)); | |
| // Create links | |
| const link = svg.append('g') | |
| .selectAll('line') | |
| .data(data.edges) | |
| .enter().append('line') | |
| .attr('stroke', '#d1d5db') | |
| .attr('stroke-width', 2); | |
| // Create nodes | |
| const node = svg.append('g') | |
| .selectAll('g') | |
| .data(data.nodes) | |
| .enter().append('g') | |
| .attr('class', 'mindmap-node') | |
| .style('cursor', 'pointer') | |
| .call(d3.drag() | |
| .on('start', this.dragstarted) | |
| .on('drag', this.dragged) | |
| .on('end', this.dragended)); | |
| // Add node circles | |
| node.append('circle') | |
| .attr('r', d => this.getNodeRadius(d)) | |
| .attr('fill', d => this.getNodeColor(d)) | |
| .attr('stroke', '#ffffff') | |
| .attr('stroke-width', 2); | |
| // Add node labels | |
| node.append('text') | |
| .text(d => this.truncateLabel(d.label)) | |
| .attr('text-anchor', 'middle') | |
| .attr('dy', '.35em') | |
| .attr('font-size', d => this.getFontSize(d)) | |
| .attr('font-weight', 'bold') | |
| .attr('fill', d => this.getTextColor(d)) | |
| .style('pointer-events', 'none'); | |
| // Add score display for product nodes | |
| node.filter(d => d.score) | |
| .append('text') | |
| .text(d => d.score) | |
| .attr('text-anchor', 'middle') | |
| .attr('dy', '2.5em') | |
| .attr('font-size', '12px') | |
| .attr('font-weight', 'normal') | |
| .attr('fill', '#6b7280') | |
| .style('pointer-events', 'none'); | |
| // Add click handlers | |
| node.on('click', (event, d) => { | |
| this.handleNodeClick(d, data); | |
| }); | |
| // Add hover effects | |
| node.on('mouseover', function(event, d) { | |
| d3.select(this).select('circle') | |
| .transition() | |
| .duration(200) | |
| .attr('r', d => d3.select(this).select('circle').attr('r') * 1.1); | |
| }); | |
| node.on('mouseout', function(event, d) { | |
| d3.select(this).select('circle') | |
| .transition() | |
| .duration(200) | |
| .attr('r', d => this.getNodeRadius(d)); | |
| }.bind(this)); | |
| // Update positions on simulation tick | |
| simulation.on('tick', () => { | |
| link | |
| .attr('x1', d => d.source.x) | |
| .attr('y1', d => d.source.y) | |
| .attr('x2', d => d.target.x) | |
| .attr('y2', d => d.target.y); | |
| node | |
| .attr('transform', d => `translate(${d.x},${d.y})`); | |
| }); | |
| } | |
| static getNodeRadius(d) { | |
| switch (d.level) { | |
| case 0: return 50; // Root node | |
| case 1: return 35; // Category/solution nodes | |
| case 2: return 25; // Product nodes | |
| default: return 20; | |
| } | |
| } | |
| static getNodeColor(d) { | |
| switch (d.level) { | |
| case 0: return '#3b82f6'; // Primary blue | |
| case 1: return '#10b981'; // Secondary green | |
| case 2: return '#f59e0b'; // Warning orange for products | |
| default: return '#6b7280'; | |
| } | |
| } | |
| static getTextColor(d) { | |
| return d.level <= 1 ? '#ffffff' : '#374151'; | |
| } | |
| static getFontSize(d) { | |
| switch (d.level) { | |
| case 0: return '16px'; | |
| case 1: return '14px'; | |
| case 2: return '11px'; | |
| default: return '12px'; | |
| } | |
| } | |
| static truncateLabel(label) { | |
| if (label.length > 20) { | |
| return label.substring(0, 17) + '...'; | |
| } | |
| return label; | |
| } | |
| static handleNodeClick(node, data) { | |
| // Handle different types of node clicks | |
| if (node.level === 2) { // Product node | |
| this.selectProduct(node); | |
| } else if (node.level === 1) { // Category node | |
| this.focusCategory(node, data); | |
| } else if (node.level === 0) { // Root node | |
| this.showSearchDetails(); | |
| } | |
| } | |
| static selectProduct(productNode) { | |
| // Highlight selected product | |
| const event = new CustomEvent('productSelected', { | |
| detail: { productId: productNode.id, productTitle: productNode.label } | |
| }); | |
| document.dispatchEvent(event); | |
| // Update search results to focus on this product | |
| this.updateResultsFocus(productNode.id); | |
| } | |
| static focusCategory(categoryNode, data) { | |
| // Filter results to show only products in this category | |
| const categoryProducts = data.nodes.filter(n => | |
| n.level === 2 && this.isConnectedToCategory(n, categoryNode, data.edges) | |
| ); | |
| const event = new CustomEvent('categoryFocused', { | |
| detail: { category: categoryNode.label, products: categoryProducts } | |
| }); | |
| document.dispatchEvent(event); | |
| } | |
| static isConnectedToCategory(productNode, categoryNode, edges) { | |
| return edges.some(edge => | |
| (edge.source.id === categoryNode.id && edge.target.id === productNode.id) || | |
| (edge.source.id === productNode.id && edge.target.id === categoryNode.id) | |
| ); | |
| } | |
| static showSearchDetails() { | |
| // Show expanded search details | |
| const event = new CustomEvent('searchDetailsRequested'); | |
| document.dispatchEvent(event); | |
| } | |
| static updateResultsFocus(productId) { | |
| const resultsContainer = document.getElementById('search-results'); | |
| if (!resultsContainer) return; | |
| // Remove previous highlights | |
| const highlighted = resultsContainer.querySelectorAll('.bg-blue-50'); | |
| highlighted.forEach(el => el.classList.remove('bg-blue-50')); | |
| // Add highlight to selected product | |
| const productResult = Array.from(resultsContainer.children).find(child => { | |
| return child.querySelector('h4') && child.querySelector('h4').textContent.includes(productId); | |
| }); | |
| if (productResult) { | |
| productResult.classList.add('bg-blue-50', 'border-l-4', 'border-blue-500'); | |
| productResult.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| } | |
| } | |
| static updateVisualization(newData) { | |
| // Update the mind map with new data | |
| const container = document.getElementById('mindmap-container'); | |
| if (container) { | |
| this.render(container, newData); | |
| } | |
| } | |
| static addProductNode(product, categoryNodeId) { | |
| // Add a new product node to the existing visualization | |
| const container = document.getElementById('mindmap-container'); | |
| if (!container || !container.querySelector('svg')) return; | |
| const event = new CustomEvent('addProductToMindMap', { | |
| detail: { product, categoryNodeId } | |
| }); | |
| document.dispatchEvent(event); | |
| } | |
| static setupInteractionHandlers() { | |
| // Setup global interaction handlers | |
| document.addEventListener('productSelected', (event) => { | |
| console.log('Product selected:', event.detail); | |
| }); | |
| document.addEventListener('categoryFocused', (event) => { | |
| console.log('Category focused:', event.detail); | |
| }); | |
| document.addEventListener('searchDetailsRequested', () => { | |
| console.log('Search details requested'); | |
| }); | |
| } | |
| static exportVisualization() { | |
| // Export the current visualization as SVG or image | |
| const svg = document.querySelector('#mindmap-container svg'); | |
| if (!svg) return null; | |
| const serializer = new XMLSerializer(); | |
| const svgString = serializer.serializeToString(svg); | |
| return { | |
| svg: svgString, | |
| timestamp: new Date().toISOString(), | |
| dimensions: { | |
| width: svg.viewBox.baseVal.width, | |
| height: svg.viewBox.baseVal.height | |
| } | |
| }; | |
| } | |
| // Drag event handlers | |
| static dragstarted(event, d) { | |
| if (!event.active) this.simulation.alphaTarget(0.3).restart(); | |
| d.fx = d.x; | |
| d.fy = d.y; | |
| } | |
| static dragged(event, d) { | |
| d.fx = event.x; | |
| d.fy = event.y; | |
| } | |
| static dragended(event, d) { | |
| if (!event.active) this.simulation.alphaTarget(0); | |
| d.fx = null; | |
| d.fy = null; | |
| } | |
| } | |
| // Initialize interaction handlers when component loads | |
| MindMap.setupInteractionHandlers(); | |
| // Export for use in main app | |
| if (typeof window !== 'undefined') { | |
| window.MindMap = MindMap; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = MindMap; | |
| } |