Source: frontend/js/components/vsom/Clustering/Clustering.js

import log from 'loglevel';
import * as d3 from 'd3';
import { BaseVisualization } from '../BaseVisualization.js';

/**
 * Clustering Visualization Component
 * Displays clustering results from the SOM
 */
export class Clustering extends BaseVisualization {
  constructor(container, options = {}) {
    const defaultOptions = {
      width: 800,
      height: 600,
      margin: { top: 20, right: 20, bottom: 30, left: 40 },
      colorScheme: d3.schemeCategory10,
      ...options
    };

    super(container, defaultOptions);
    
    // Initialize state
    this.clusters = [];
    this.selectedCluster = null;
    this.colorScale = null;
    
    // Bind methods
    this.updateClusters = this.updateClusters.bind(this);
    this.selectCluster = this.selectCluster.bind(this);
  }
  
  /**
   * Initialize the visualization
   */
  async init() {
    await super.init();
    this.setupSVG();
    this.setupScales();
    this.initialized = true;
    return this;
  }
  
  /**
   * Set up the SVG container
   */
  setupSVG() {
    const { width, height } = this.options;
    
    this.svg = d3.select(this.container)
      .append('svg')
      .attr('width', '100%')
      .attr('height', '100%')
      .attr('viewBox', `0 0 ${width} ${height}`);
      
    this.clusterGroup = this.svg.append('g')
      .attr('class', 'clusters');
      
    this.legendGroup = this.svg.append('g')
      .attr('class', 'legend');
  }
  
  /**
   * Set up D3 scales
   */
  setupScales() {
    // Color scale for clusters
    this.colorScale = d3.scaleOrdinal(this.options.colorScheme);
  }
  
  /**
   * Update clusters with new data
   * @param {Array} clusters - Array of cluster data
   */
  updateClusters(clusters) {
    if (!this.initialized || !clusters || !clusters.length) return;
    
    this.clusters = clusters;
    this.colorScale.domain(clusters.map((_, i) => i));
    
    // If no cluster is selected, select the first one
    if (this.selectedCluster === null && clusters.length > 0) {
      this.selectCluster(0);
    }
    
    this.render();
  }
  
  /**
   * Select a cluster to display details
   * @param {number} clusterIndex - Index of the cluster to select
   */
  selectCluster(clusterIndex) {
    if (clusterIndex < 0 || clusterIndex >= this.clusters.length) return;
    
    this.selectedCluster = clusterIndex;
    this.render();
  }
  
  /**
   * Render the visualization
   */
  render() {
    if (!this.initialized || !this.clusters.length) return;
    
    const { width, height, margin } = this.options;
    
    // Clear existing clusters
    this.clusterGroup.selectAll('*').remove();
    
    // Create force simulation for cluster layout
    const simulation = d3.forceSimulation()
      .force('charge', d3.forceManyBody().strength(-100))
      .force('center', d3.forceCenter(width / 2, height / 2))
      .force('collision', d3.forceCollide().radius(d => Math.sqrt(d.size) * 2));
    
    // Create nodes for each data point in clusters
    const nodes = [];
    this.clusters.forEach((cluster, clusterIndex) => {
      cluster.points.forEach((point, pointIndex) => {
        nodes.push({
          ...point,
          cluster: clusterIndex,
          id: `${clusterIndex}-${pointIndex}`
        });
      });
    });
    
    // Draw clusters
    const node = this.clusterGroup.selectAll('.node')
      .data(nodes, d => d.id)
      .join('circle')
        .attr('class', 'node')
        .attr('r', 5)
        .attr('fill', d => this.colorScale(d.cluster))
        .attr('opacity', 0.7)
        .call(drag(simulation));
    
    // Update simulation with nodes
    simulation.nodes(nodes)
      .on('tick', () => {
        node
          .attr('cx', d => d.x)
          .attr('cy', d => d.y);
      });
    
    // Add legend
    this.renderLegend();
  }
  
  /**
   * Render the cluster legend
   */
  renderLegend() {
    const { width, margin } = this.options;
    const legendItemHeight = 20;
    const legendItemWidth = 100;
    
    // Clear existing legend
    this.legendGroup.selectAll('*').remove();
    
    // Create legend items
    const legend = this.legendGroup.selectAll('.legend-item')
      .data(this.clusters)
      .join('g')
        .attr('class', 'legend-item')
        .attr('transform', (d, i) => `translate(0, ${i * (legendItemHeight + 5)})`)
        .on('click', (event, d, i) => this.selectCluster(i));
    
    // Add color swatches
    legend.append('rect')
      .attr('x', width - margin.right - legendItemWidth + 10)
      .attr('width', legendItemHeight)
      .attr('height', legendItemHeight)
      .attr('fill', (_, i) => this.colorScale(i))
      .attr('opacity', (_, i) => i === this.selectedCluster ? 1 : 0.5);
    
    // Add cluster labels
    legend.append('text')
      .attr('x', width - margin.right - legendItemWidth + 40)
      .attr('y', legendItemHeight / 2)
      .attr('dy', '0.32em')
      .text((d, i) => `Cluster ${i + 1} (${d.size} points)`)
      .style('font-size', '12px')
      .style('fill', (_, i) => i === this.selectedCluster ? '#000' : '#666');
  }
}

// Drag behavior for nodes
function drag(simulation) {
  function dragstarted(event, d) {
    if (!event.active) simulation.alphaTarget(0.3).restart();
    d.fx = d.x;
    d.fy = d.y;
  }
  
  function dragged(event, d) {
    d.fx = event.x;
    d.fy = event.y;
  }
  
  function dragended(event, d) {
    if (!event.active) simulation.alphaTarget(0);
    d.fx = null;
    d.fy = null;
  }
  
  return d3.drag()
    .on('start', dragstarted)
    .on('drag', dragged)
    .on('end', dragended);
}

export default Clustering;