DEV Community

Cover image for 8 JavaScript Techniques That Transform Data Into Interactive Visual Stories
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

8 JavaScript Techniques That Transform Data Into Interactive Visual Stories

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Working with data visualization in JavaScript has completely changed how I approach complex information. I remember struggling with static charts that failed to tell the story behind the numbers. Modern libraries have given me the tools to create experiences that help people actually understand what data means, not just see it.

Let me share eight techniques that have transformed my approach to building visualizations. These aren't just about making pretty charts—they're about creating interfaces where data becomes meaningful.

Canvas: Performance Meets Customization

When I need to display thousands of points or create custom visualization types, I turn to the Canvas API. Think of Canvas as a blank drawing surface where you control every pixel. It's perfect for situations where you need speed and complete creative freedom.

Here's how I approach canvas-based visualizations. I start by creating a base class that handles the setup and basic rendering. The beauty of this approach is that once the foundation is solid, I can extend it for specific visualization types without rewriting everything.

class CustomScatterPlot {
  constructor(containerId, config = {}) {
    this.container = document.getElementById(containerId);
    this.canvas = document.createElement('canvas');
    this.container.appendChild(this.canvas);
    this.ctx = this.canvas.getContext('2d');

    this.config = {
      width: config.width || 800,
      height: config.height || 500,
      margin: config.margin || { top: 40, right: 30, bottom: 50, left: 50 },
      pointRadius: config.pointRadius || 4,
      transitionDuration: config.transitionDuration || 750
    };

    this.data = [];
    this.scales = {};
    this.isAnimating = false;
    this.currentFrame = null;

    this.init();
  }

  init() {
    this.resizeCanvas();
    this.setupScales();
    this.setupEvents();
  }

  resizeCanvas() {
    const dpr = window.devicePixelRatio || 1;

    this.canvas.style.width = `${this.config.width}px`;
    this.canvas.style.height = `${this.config.height}px`;

    this.canvas.width = this.config.width * dpr;
    this.canvas.height = this.config.height * dpr;

    this.ctx.scale(dpr, dpr);
  }

  setupScales() {
    // Linear scale for x-axis
    this.scales.x = (value) => {
      const range = this.config.width - this.config.margin.left - this.config.margin.right;
      const domain = this.xDomain || [0, 1];
      return this.config.margin.left + 
             ((value - domain[0]) / (domain[1] - domain[0])) * range;
    };

    // Linear scale for y-axis
    this.scales.y = (value) => {
      const range = this.config.height - this.config.margin.top - this.config.margin.bottom;
      const domain = this.yDomain || [0, 1];
      return this.config.margin.top + 
             range - ((value - domain[0]) / (domain[1] - domain[0])) * range;
    };
  }

  setData(newData, options = {}) {
    if (options.animate && this.data.length > 0) {
      this.animateTransition(newData, options);
    } else {
      this.data = newData;
      this.calculateDomains();
      this.render();
    }
  }

  calculateDomains() {
    if (this.data.length === 0) {
      this.xDomain = [0, 1];
      this.yDomain = [0, 1];
      return;
    }

    const xValues = this.data.map(d => d.x);
    const yValues = this.data.map(d => d.y);

    const xPadding = (Math.max(...xValues) - Math.min(...xValues)) * 0.05;
    const yPadding = (Math.max(...yValues) - Math.min(...yValues)) * 0.05;

    this.xDomain = [
      Math.min(...xValues) - xPadding,
      Math.max(...xValues) + xPadding
    ];

    this.yDomain = [
      Math.min(...yValues) - yPadding,
      Math.max(...yValues) + yPadding
    ];
  }

  animateTransition(newData, options) {
    if (this.isAnimating) {
      cancelAnimationFrame(this.currentFrame);
    }

    this.isAnimating = true;
    const startData = [...this.data];
    const startTime = performance.now();
    const duration = options.duration || this.config.transitionDuration;

    const animate = (currentTime) => {
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);

      // Easing function for smooth animation
      const ease = progress < 0.5 
        ? 2 * progress * progress 
        : 1 - Math.pow(-2 * progress + 2, 2) / 2;

      // Interpolate between old and new data
      this.data = this.interpolateData(startData, newData, ease);
      this.calculateDomains();
      this.render();

      if (progress < 1) {
        this.currentFrame = requestAnimationFrame(animate);
      } else {
        this.isAnimating = false;
        this.data = newData;
      }
    };

    this.currentFrame = requestAnimationFrame(animate);
  }

  interpolateData(start, end, progress) {
    if (start.length !== end.length) {
      return progress > 0.5 ? end : start;
    }

    return start.map((startPoint, i) => {
      const endPoint = end[i];
      return {
        x: startPoint.x + (endPoint.x - startPoint.x) * progress,
        y: startPoint.y + (endPoint.y - startPoint.y) * progress,
        value: startPoint.value + (endPoint.value - startPoint.value) * progress,
        category: endPoint.category
      };
    });
  }

  render() {
    this.clearCanvas();
    this.drawGrid();
    this.drawAxes();
    this.drawDataPoints();
    this.drawLabels();
  }

  clearCanvas() {
    this.ctx.clearRect(0, 0, this.config.width, this.config.height);
    this.ctx.fillStyle = '#ffffff';
    this.ctx.fillRect(0, 0, this.config.width, this.config.height);
  }

  drawGrid() {
    this.ctx.strokeStyle = '#f0f0f0';
    this.ctx.lineWidth = 1;

    // Vertical grid lines
    for (let i = 0; i <= 10; i++) {
      const x = this.config.margin.left + 
               (i / 10) * (this.config.width - this.config.margin.left - this.config.margin.right);

      this.ctx.beginPath();
      this.ctx.moveTo(x, this.config.margin.top);
      this.ctx.lineTo(x, this.config.height - this.config.margin.bottom);
      this.ctx.stroke();
    }

    // Horizontal grid lines
    for (let i = 0; i <= 10; i++) {
      const y = this.config.margin.top + 
               (i / 10) * (this.config.height - this.config.margin.top - this.config.margin.bottom);

      this.ctx.beginPath();
      this.ctx.moveTo(this.config.margin.left, y);
      this.ctx.lineTo(this.config.width - this.config.margin.right, y);
      this.ctx.stroke();
    }
  }

  drawAxes() {
    this.ctx.strokeStyle = '#333333';
    this.ctx.lineWidth = 2;
    this.ctx.font = '14px Arial';
    this.ctx.fillStyle = '#333333';

    // X-axis
    this.ctx.beginPath();
    this.ctx.moveTo(this.config.margin.left, 
                   this.config.height - this.config.margin.bottom);
    this.ctx.lineTo(this.config.width - this.config.margin.right,
                   this.config.height - this.config.margin.bottom);
    this.ctx.stroke();

    // Y-axis
    this.ctx.beginPath();
    this.ctx.moveTo(this.config.margin.left, this.config.margin.top);
    this.ctx.lineTo(this.config.margin.left,
                   this.config.height - this.config.margin.bottom);
    this.ctx.stroke();

    // Axis labels
    this.ctx.textAlign = 'center';
    this.ctx.fillText('X Axis', 
                     this.config.width / 2,
                     this.config.height - 10);

    this.ctx.save();
    this.ctx.translate(20, this.config.height / 2);
    this.ctx.rotate(-Math.PI / 2);
    this.ctx.textAlign = 'center';
    this.ctx.fillText('Y Axis', 0, 0);
    this.ctx.restore();
  }

  drawDataPoints() {
    this.data.forEach(point => {
      const x = this.scales.x(point.x);
      const y = this.scales.y(point.y);

      // Determine color based on category or value
      let color = '#3b82f6'; // Default blue

      if (point.category !== undefined) {
        const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'];
        color = colors[point.category % colors.length];
      } else if (point.value !== undefined) {
        // Color based on value (heatmap style)
        const intensity = Math.max(0, Math.min(1, point.value));
        const r = Math.floor(59 + (239 - 59) * intensity);
        const g = Math.floor(130 + (68 - 130) * intensity);
        const b = Math.floor(246 + (68 - 246) * intensity);
        color = `rgb(${r}, ${g}, ${b})`;
      }

      // Draw point with shadow for depth
      this.ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
      this.ctx.shadowBlur = 4;
      this.ctx.shadowOffsetY = 2;

      this.ctx.beginPath();
      this.ctx.arc(x, y, this.config.pointRadius, 0, Math.PI * 2);
      this.ctx.fillStyle = color;
      this.ctx.fill();

      this.ctx.shadowColor = 'transparent';
      this.ctx.shadowBlur = 0;
      this.ctx.shadowOffsetY = 0;

      // Draw outline
      this.ctx.strokeStyle = '#ffffff';
      this.ctx.lineWidth = 1;
      this.ctx.stroke();
    });
  }

  drawLabels() {
    if (this.data.length === 0) return;

    // Find and label outliers or significant points
    const meanY = this.data.reduce((sum, d) => sum + d.y, 0) / this.data.length;
    const stdY = Math.sqrt(
      this.data.reduce((sum, d) => sum + Math.pow(d.y - meanY, 2), 0) / this.data.length
    );

    this.data.forEach((point, index) => {
      // Label points that are more than 2 standard deviations from mean
      if (Math.abs(point.y - meanY) > 2 * stdY && index % 3 === 0) {
        const x = this.scales.x(point.x);
        const y = this.scales.y(point.y);

        this.ctx.font = '12px Arial';
        this.ctx.fillStyle = '#666666';
        this.ctx.textAlign = 'center';

        const label = `(${point.x.toFixed(1)}, ${point.y.toFixed(1)})`;
        this.ctx.fillText(label, x, y - 15);
      }
    });
  }

  setupEvents() {
    let hoveredPoint = null;

    this.canvas.addEventListener('mousemove', (event) => {
      const rect = this.canvas.getBoundingClientRect();
      const x = event.clientX - rect.left;
      const y = event.clientY - rect.top;

      // Check if mouse is over any point
      const oldHovered = hoveredPoint;
      hoveredPoint = null;

      this.data.forEach((point, index) => {
        const pointX = this.scales.x(point.x);
        const pointY = this.scales.y(point.y);
        const distance = Math.sqrt(Math.pow(x - pointX, 2) + Math.pow(y - pointY, 2));

        if (distance < this.config.pointRadius + 5) {
          hoveredPoint = index;
        }
      });

      // Redraw if hover state changed
      if (hoveredPoint !== oldHovered) {
        this.render();
        if (hoveredPoint !== null) {
          this.drawTooltip(this.data[hoveredPoint], x, y);
        }
      }
    });

    this.canvas.addEventListener('click', (event) => {
      if (hoveredPoint !== null) {
        console.log('Clicked point:', this.data[hoveredPoint]);
        // Trigger custom event
        const clickEvent = new CustomEvent('pointClick', {
          detail: { point: this.data[hoveredPoint], index: hoveredPoint }
        });
        this.canvas.dispatchEvent(clickEvent);
      }
    });
  }

  drawTooltip(point, mouseX, mouseY) {
    this.ctx.save();

    const tooltipWidth = 120;
    const tooltipHeight = 60;
    const padding = 10;

    // Position tooltip (avoid going off screen)
    let x = mouseX + 15;
    let y = mouseY - tooltipHeight / 2;

    if (x + tooltipWidth > this.config.width) {
      x = mouseX - tooltipWidth - 15;
    }
    if (y + tooltipHeight > this.config.height) {
      y = this.config.height - tooltipHeight - 10;
    }
    if (y < 10) {
      y = 10;
    }

    // Draw tooltip background
    this.ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
    this.ctx.strokeStyle = '#ddd';
    this.ctx.lineWidth = 1;

    this.ctx.beginPath();
    this.ctx.roundRect(x, y, tooltipWidth, tooltipHeight, 5);
    this.ctx.fill();
    this.ctx.stroke();

    // Draw tooltip content
    this.ctx.fillStyle = '#333';
    this.ctx.font = 'bold 12px Arial';
    this.ctx.textAlign = 'left';

    this.ctx.fillText(`X: ${point.x.toFixed(2)}`, x + padding, y + 20);
    this.ctx.fillText(`Y: ${point.y.toFixed(2)}`, x + padding, y + 40);

    if (point.value !== undefined) {
      this.ctx.fillText(`Value: ${point.value.toFixed(2)}`, x + padding, y + 60);
    }

    this.ctx.restore();
  }

  exportAsImage() {
    return this.canvas.toDataURL('image/png');
  }

  destroy() {
    if (this.currentFrame) {
      cancelAnimationFrame(this.currentFrame);
    }
    this.canvas.remove();
  }
}

// Usage example
const scatterPlot = new CustomScatterPlot('chart-container', {
  width: 800,
  height: 500,
  margin: { top: 40, right: 30, bottom: 50, left: 50 }
});

// Generate sample data
const sampleData = Array.from({ length: 200 }, (_, i) => ({
  x: Math.random() * 100,
  y: Math.random() * 100 + Math.sin(i * 0.1) * 20,
  value: Math.random(),
  category: Math.floor(Math.random() * 3)
}));

scatterPlot.setData(sampleData, { animate: true });

// Listen for point clicks
scatterPlot.canvas.addEventListener('pointClick', (event) => {
  console.log('Point clicked:', event.detail);
});

// Update data periodically
setTimeout(() => {
  const newData = Array.from({ length: 200 }, (_, i) => ({
    x: Math.random() * 100 + 50,
    y: Math.random() * 100 + Math.cos(i * 0.1) * 20,
    value: Math.random(),
    category: Math.floor(Math.random() * 3)
  }));

  scatterPlot.setData(newData, { animate: true, duration: 1000 });
}, 3000);
Enter fullscreen mode Exit fullscreen mode

WebGL: Handling Massive Datasets

When working with tens of thousands of data points, regular Canvas starts to struggle. That's where WebGL comes in. It uses your computer's graphics card to render visualizations, which can handle millions of points smoothly. The learning curve is steeper, but the performance gains are worth it.

I use WebGL when building financial charts with historical data or geographical visualizations with many points. Here's a simplified approach I've found effective.

class WebGLScatterPlot {
  constructor(canvasId) {
    this.canvas = document.getElementById(canvasId);
    this.gl = this.canvas.getContext('webgl');

    if (!this.gl) {
      console.error('WebGL not supported');
      return;
    }

    this.points = [];
    this.program = null;
    this.buffers = {};

    this.initWebGL();
  }

  initWebGL() {
    const gl = this.gl;

    // Set clear color to white
    gl.clearColor(1.0, 1.0, 1.0, 1.0);

    // Enable blending for transparency
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    // Create shader program
    this.program = this.createProgram(
      this.getVertexShader(),
      this.getFragmentShader()
    );

    gl.useProgram(this.program);
  }

  getVertexShader() {
    return `
      attribute vec2 aPosition;
      attribute vec3 aColor;
      attribute float aSize;

      uniform vec2 uResolution;
      uniform vec2 uScale;
      uniform vec2 uTranslate;

      varying vec3 vColor;

      void main() {
        // Apply scaling and translation
        vec2 position = aPosition * uScale + uTranslate;

        // Convert to clip space
        vec2 clipSpace = (position / uResolution) * 2.0 - 1.0;

        gl_Position = vec4(clipSpace, 0.0, 1.0);
        gl_PointSize = aSize;
        vColor = aColor;
      }
    `;
  }

  getFragmentShader() {
    return `
      precision mediump float;
      varying vec3 vColor;

      void main() {
        // Create circular points instead of squares
        float distance = length(gl_PointCoord - vec2(0.5, 0.5));

        if (distance > 0.5) {
          discard;
        }

        // Soft edges
        float alpha = 1.0 - smoothstep(0.4, 0.5, distance);

        gl_FragColor = vec4(vColor, alpha);
      }
    `;
  }

  createProgram(vertexSource, fragmentSource) {
    const gl = this.gl;

    const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource);
    const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource);

    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);

    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error('Program linking failed:', gl.getProgramInfoLog(program));
      return null;
    }

    return program;
  }

  compileShader(type, source) {
    const gl = this.gl;
    const shader = gl.createShader(type);

    gl.shaderSource(shader, source);
    gl.compileShader(shader);

    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
      console.error('Shader compilation failed:', gl.getShaderInfoLog(shader));
      gl.deleteShader(shader);
      return null;
    }

    return shader;
  }

  setData(points) {
    this.points = points;
    this.updateBuffers();
  }

  updateBuffers() {
    const gl = this.gl;
    const positions = [];
    const colors = [];
    const sizes = [];

    // Prepare data for GPU
    this.points.forEach(point => {
      positions.push(point.x, point.y);

      // Convert hex color to RGB
      const color = this.hexToRgb(point.color || '#3b82f6');
      colors.push(color.r / 255, color.g / 255, color.b / 255);

      sizes.push(point.size || 3.0);
    });

    // Create or update position buffer
    if (!this.buffers.position) {
      this.buffers.position = gl.createBuffer();
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

    // Create or update color buffer
    if (!this.buffers.color) {
      this.buffers.color = gl.createBuffer();
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.color);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);

    // Create or update size buffer
    if (!this.buffers.size) {
      this.buffers.size = gl.createBuffer();
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.size);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sizes), gl.STATIC_DRAW);
  }

  hexToRgb(hex) {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result ? {
      r: parseInt(result[1], 16),
      g: parseInt(result[2], 16),
      b: parseInt(result[3], 16)
    } : { r: 59, g: 130, b: 246 };
  }

  render(scale = [1, 1], translate = [0, 0]) {
    const gl = this.gl;

    if (!this.program || this.points.length === 0) return;

    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set uniforms
    const resolutionLocation = gl.getUniformLocation(this.program, 'uResolution');
    const scaleLocation = gl.getUniformLocation(this.program, 'uScale');
    const translateLocation = gl.getUniformLocation(this.program, 'uTranslate');

    gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);
    gl.uniform2f(scaleLocation, scale[0], scale[1]);
    gl.uniform2f(translateLocation, translate[0], translate[1]);

    // Set up position attribute
    const positionLocation = gl.getAttribLocation(this.program, 'aPosition');
    gl.enableVertexAttribArray(positionLocation);
    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

    // Set up color attribute
    const colorLocation = gl.getAttribLocation(this.program, 'aColor');
    gl.enableVertexAttribArray(colorLocation);
    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.color);
    gl.vertexAttribPointer(colorLocation, 3, gl.FLOAT, false, 0, 0);

    // Set up size attribute
    const sizeLocation = gl.getAttribLocation(this.program, 'aSize');
    gl.enableVertexAttribArray(sizeLocation);
    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.size);
    gl.vertexAttribPointer(sizeLocation, 1, gl.FLOAT, false, 0, 0);

    // Draw points
    gl.drawArrays(gl.POINTS, 0, this.points.length);
  }

  // Generate sample data with 100,000 points
  generateLargeDataset(count = 100000) {
    const points = [];

    for (let i = 0; i < count; i++) {
      points.push({
        x: Math.random() * 2000 - 1000,
        y: Math.random() * 2000 - 1000,
        color: this.getRandomColor(),
        size: Math.random() * 5 + 1
      });
    }

    return points;
  }

  getRandomColor() {
    const colors = [
      '#3b82f6', '#ef4444', '#10b981', '#f59e0b', 
      '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
    ];
    return colors[Math.floor(Math.random() * colors.length)];
  }
}

// Usage
const webglPlot = new WebGLScatterPlot('webgl-canvas');

// Generate and render 100,000 points
const largeDataset = webglPlot.generateLargeDataset(100000);
webglPlot.setData(largeDataset);
webglPlot.render([0.5, 0.5], [0, 0]);
Enter fullscreen mode Exit fullscreen mode

SVG: Precision and Flexibility

SVG offers a different approach. Instead of painting pixels like Canvas, SVG creates mathematical shapes that stay sharp at any size. I find SVG particularly useful for interactive dashboards and responsive designs.

What I love about SVG is how each element remains accessible in the DOM. This makes adding interactions much simpler compared to Canvas. Here's how I structure SVG visualizations.

class SVGLineChart {
  constructor(containerId, options = {}) {
    this.container = document.getElementById(containerId);
    this.options = {
      width: options.width || 800,
      height: options.height || 400,
      margin: options.margin || { top: 20, right: 30, bottom: 30, left: 40 },
      color: options.color || '#3b82f6',
      strokeWidth: options.strokeWidth || 2,
      showPoints: options.showPoints !== false,
      showArea: options.showArea || false
    };

    this.data = [];
    this.svg = null;
    this.scales = {};
    this.axes = {};

    this.init();
  }

  init() {
    this.createSVG();
    this.setupScales();
    this.setupAxes();
  }

  createSVG() {
    // Clear container
    this.container.innerHTML = '';

    // Create SVG element
    this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    this.svg.setAttribute('width', this.options.width);
    this.svg.setAttribute('height', this.options.height);
    this.svg.setAttribute('viewBox', `0 0 ${this.options.width} ${this.options.height}`);
    this.svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');

    this.container.appendChild(this.svg);
  }

  setupScales() {
    // X scale (time or linear)
    this.scales.x = (value) => {
      const range = this.options.width - 
                   this.options.margin.left - 
                   this.options.margin.right;
      const domain = this.xDomain || [0, 1];
      return this.options.margin.left + 
             ((value - domain[0]) / (domain[1] - domain[0])) * range;
    };

    // Y scale
    this.scales.y = (value) => {
      const range = this.options.height - 
                   this.options.margin.top - 
                   this.options.margin.bottom;
      const domain = this.yDomain || [0, 1];
      return this.options.margin.top + 
             range - ((value - domain[0]) / (domain[1] - domain[0])) * range;
    };
  }

  setupAxes() {
    // X axis group
    this.axes.x = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    this.axes.x.setAttribute('class', 'x-axis');
    this.axes.x.setAttribute('transform', 
      `translate(0, ${this.options.height - this.options.margin.bottom})`);
    this.svg.appendChild(this.axes.x);

    // Y axis group
    this.axes.y = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    this.axes.y.setAttribute('class', 'y-axis');
    this.axes.y.setAttribute('transform', 
      `translate(${this.options.margin.left}, 0)`);
    this.svg.appendChild(this.axes.y);
  }

  setData(data) {
    this.data = data;
    this.calculateDomains();
    this.updateAxes();
    this.drawChart();
  }

  calculateDomains() {
    if (this.data.length === 0) {
      this.xDomain = [0, 1];
      this.yDomain = [0, 1];
      return;
    }

    const xValues = this.data.map(d => d.x);
    const yValues = this.data.map(d => d.y);

    const xPadding = (Math.max(...xValues) - Math.min(...xValues)) * 0.05;
    const yPadding = (Math.max(...yValues) - Math.min(...yValues)) * 0.05;

    this.xDomain = [
      Math.min(...xValues) - xPadding,
      Math.max(...xValues) + xPadding
    ];

    this.yDomain = [
      Math.min(0, Math.min(...yValues) - yPadding),
      Math.max(...yValues) + yPadding
    ];
  }

  updateAxes() {
    // Clear existing axis elements
    while (this.axes.x.firstChild) {
      this.axes.x.removeChild(this.axes.x.firstChild);
    }

    while (this.axes.y.firstChild) {
      this.axes.y.removeChild(this.axes.y.firstChild);
    }

    // Draw X axis line
    const xAxisLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    xAxisLine.setAttribute('x1', this.options.margin.left);
    xAxisLine.setAttribute('x2', this.options.width - this.options.margin.right);
    xAxisLine.setAttribute('stroke', '#333');
    xAxisLine.setAttribute('stroke-width', '1');
    this.axes.x.appendChild(xAxisLine);

    // Draw Y axis line
    const yAxisLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    yAxisLine.setAttribute('y1', this.options.margin.top);
    yAxisLine.setAttribute('y2', this.options.height - this.options.margin.bottom);
    yAxisLine.setAttribute('stroke', '#333');
    yAxisLine.setAttribute('stroke-width', '1');
    this.axes.y.appendChild(yAxisLine);

    // Add X axis ticks
    const xTicks = this.generateTicks(this.xDomain, 5);
    xTicks.forEach(tick => {
      const x = this.scales.x(tick);

      // Tick line
      const tickLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
      tickLine.setAttribute('x1', x);
      tickLine.setAttribute('x2', x);
      tickLine.setAttribute('y1', 0);
      tickLine.setAttribute('y2', 5);
      tickLine.setAttribute('stroke', '#333');
      tickLine.setAttribute('stroke-width', '1');
      this.axes.x.appendChild(tickLine);

      // Tick label
      const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
      label.setAttribute('x', x);
      label.setAttribute('y', 20);
      label.setAttribute('text-anchor', 'middle');
      label.setAttribute('font-size', '12');
      label.setAttribute('fill', '#333');
      label.textContent = this.formatTickLabel(tick);
      this.axes.x.appendChild(label);
    });

    // Add Y axis ticks
    const yTicks = this.generateTicks(this.yDomain, 5);
    yTicks.forEach(tick => {
      const y = this.scales.y(tick);

      // Tick line
      const tickLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
      tickLine.setAttribute('x1', -5);
      tickLine.setAttribute('x2', 0);
      tickLine.setAttribute('y1', y);
      tickLine.setAttribute('y2', y);
      tickLine.setAttribute('stroke', '#333');
      tickLine.setAttribute('stroke-width', '1');
      this.axes.y.appendChild(tickLine);

      // Tick label
      const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
      label.setAttribute('x', -10);
      label.setAttribute('y', y + 4);
      label.setAttribute('text-anchor', 'end');
      label.setAttribute('font-size', '12');
      label.setAttribute('fill', '#333');
      label.textContent = this.formatTickLabel(tick);
      this.axes.y.appendChild(label);
    });
  }

  generateTicks(domain, count) {
    const [min, max] = domain;
    const range = max - min;
    const step = range / count;

    const ticks = [];
    for (let i = 0; i <= count; i++) {
      ticks.push(min + (step * i));
    }

    return ticks;
  }

  formatTickLabel(value) {
    if (Math.abs(value) >= 1000) {
      return (value / 1000).toFixed(1) + 'k';
    }
    return value.toFixed(1);
  }

  drawChart() {
    // Remove existing chart elements
    const existing = this.svg.querySelectorAll('.chart-element');
    existing.forEach(el => el.remove());

    if (this.data.length < 2) return;

    // Create area under line (if enabled)
    if (this.options.showArea) {
      this.drawArea();
    }

    // Create line
    this.drawLine();

    // Create points (if enabled)
    if (this.options.showPoints) {
      this.drawPoints();
    }

    // Add grid lines
    this.drawGrid();
  }

  drawArea() {
    let pathData = `M ${this.scales.x(this.data[0].x)} ${this.scales.y(0)} `;

    // Draw line along data points
    this.data.forEach(point => {
      pathData += `L ${this.scales.x(point.x)} ${this.scales.y(point.y)} `;
    });

    // Close the path back to baseline
    pathData += `L ${this.scales.x(this.data[this.data.length - 1].x)} ${this.scales.y(0)} Z`;

    const area = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    area.setAttribute('d', pathData);
    area.setAttribute('fill', `${this.options.color}20`);
    area.setAttribute('stroke', 'none');
    area.setAttribute('class', 'chart-element area');
    this.svg.appendChild(area);
  }

  drawLine() {
    let pathData = `M ${this.scales.x(this.data[0].x)} ${this.scales.y(this.data[0].y)}`;

    for (let i = 1; i < this.data.length; i++) {
      pathData += ` L ${this.scales.x(this.data[i].x)} ${this.scales.y(this.data[i].y)}`;
    }

    const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    line.setAttribute('d', pathData);
    line.setAttribute('fill', 'none');
    line.setAttribute('stroke', this.options.color);
    line.setAttribute('stroke-width', this.options.strokeWidth);
    line.setAttribute('stroke-linecap', 'round');
    line.setAttribute('stroke-linejoin', 'round');
    line.setAttribute('class', 'chart-element line');
    this.svg.appendChild(line);
  }

  drawPoints() {
    this.data.forEach((point, index) => {
      const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
      circle.setAttribute('cx', this.scales.x(point.x));
      circle.setAttribute('cy', this.scales.y(point.y));
      circle.setAttribute('r', '3');
      circle.setAttribute('fill', this.options.color);
      circle.setAttribute('stroke', '#ffffff');
      circle.setAttribute('stroke-width', '1');
      circle.setAttribute('class', 'chart-element point');
      circle.setAttribute('data-index', index);

      // Add hover effect
      circle.addEventListener('mouseenter', (e) => {
        circle.setAttribute('r', '5');
        this.showTooltip(point, e.clientX, e.clientY);
      });

      circle.addEventListener('mouseleave', () => {
        circle.setAttribute('r', '3');
        this.hideTooltip();
      });

      this.svg.appendChild(circle);
    });
  }

  drawGrid() {
    // Create grid group if it doesn't exist
    let gridGroup = this.svg.querySelector('.grid-lines');
    if (!gridGroup) {
      gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
      gridGroup.setAttribute('class', 'grid-lines chart-element');
      this.svg.appendChild(gridGroup);
    }

    // Clear existing grid lines
    while (gridGroup.firstChild) {
      gridGroup.removeChild(gridGroup.firstChild);
    }

    // Horizontal grid lines
    const yTicks = this.generateTicks(this.yDomain, 5);
    yTicks.forEach(tick => {
      const y = this.scales.y(tick);

      const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
      line.setAttribute('x1', this.options.margin.left);
      line.setAttribute('x2', this.options.width - this.options.margin.right);
      line.setAttribute('y1', y);
      line.setAttribute('y2', y);
      line.setAttribute('stroke', '#f0f0f0');
      line.setAttribute('stroke-width', '1');
      gridGroup.appendChild(line);
    });

    // Vertical grid lines
    const xTicks = this.generateTicks(this.xDomain, 10);
    xTicks.forEach(tick => {
      const x = this.scales.x(tick);

      const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
      line.setAttribute('x1', x);
      line.setAttribute('x2', x);
      line.setAttribute('y1', this.options.margin.top);
      line.setAttribute('y2', this.options.height - this.options.margin.bottom);
      line.setAttribute('stroke', '#f0f0f0');
      line.setAttribute('stroke-width', '1');
      gridGroup.appendChild(line);
    });
  }

  showTooltip(point, x, y) {
    // Remove existing tooltip
    this.hideTooltip();

    // Create tooltip
    const tooltip = document.createElement('div');
    tooltip.className = 'chart-tooltip';
    tooltip.style.cssText = `
      position: fixed;
      background: rgba(0, 0, 0, 0.8);
      color: white;
      padding: 8px 12px;
      border-radius: 4px;
      font-size: 14px;
      pointer-events: none;
      z-index: 1000;
      transform: translate(-50%, -100%);
      margin-top: -10px;
    `;

    tooltip.innerHTML = `
      <div><strong>X:</strong> ${point.x.toFixed(2)}</div>
      <div><strong>Y:</strong> ${point.y.toFixed(2)}</div>
      ${point.label ? `<div>${point.label}</div>` : ''}
    `;

    tooltip.style.left = `${x}px`;
    tooltip.style.top = `${y}px`;

    document.body.appendChild(tooltip);
    this.currentTooltip = tooltip;
  }

  hideTooltip() {
    if (this.currentTooltip) {
      document.body.removeChild(this.currentTooltip);
      this.currentTooltip = null;
    }
  }

  animatePath() {
    const path = this.svg.querySelector('.line');
    if (!path) return;

    const length = path.getTotalLength();

    // Set initial state
    path.style.strokeDasharray = `${length} ${length}`;
    path.style.strokeDashoffset = length;

    // Trigger animation
    path.getBoundingClientRect(); // Force reflow

    path.style.transition = `stroke-dashoffset 2s ease-in-out`;
    path.style.strokeDashoffset = '0';
  }

  update(options) {
    Object.assign(this.options, options);
    this.createSVG();
    this.setupScales();
    this.setupAxes();
    this.updateAxes();
    this.drawChart();
  }
}

// Usage
const lineChart = new SVGLineChart('line-chart-container', {
  width: 800,
  height: 400,
  showArea: true,
  showPoints: true,
  color: '#3b82f6'
});

// Generate sample time series data
const timeSeriesData = Array.from({ length: 50 }, (_, i) => ({
  x: i,
  y: Math.sin(i * 0.2) * 30 + 50 + Math.random() * 20,
  label: `Day ${i + 1}`
}));

lineChart.setData(timeSeriesData);

// Animate the line drawing
setTimeout(() => {
  lineChart.animatePath();
}, 500);

// Update data with animation
setTimeout(() => {
  const newData = Array.from({ length: 50 }, (_, i) => ({
    x: i,
    y: Math.cos(i * 0.2) * 30 + 50 + Math.random() * 20,
    label: `Day ${i + 1}`
  }));

  lineChart.setData(newData);
  lineChart.animatePath();
}, 3000);
Enter fullscreen mode Exit fullscreen mode

Animation: Bringing Data to Life

Animations aren't just decorative—they help users follow changes in data. When values update, a smooth transition helps maintain context. I've found that well-executed animations make complex data changes understandable.

The key is using requestAnimationFrame for smooth performance. This method synchronizes with the browser's refresh rate, preventing jerky animations.

class AnimatedVisualization {
  constructor(containerId) {
    this.container = document.getElementById(containerId);
    this.canvas = document.createElement('canvas');
    this.container.appendChild(this.canvas);
    this.ctx = this.canvas.getContext('2d');

    this.width = 800;
    this.height = 400;
    this.canvas.width = this.width;
    this.canvas.height = this.height;

    this.data = [];
    this.targetData = [];
    this.isAnimating = false;
    this.animationStartTime = null;
    this.animationDuration = 1000; // ms

    this.easingFunctions = {
      linear: t => t,
      easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
      easeOut: t => 1 - Math.pow(1 - t, 2),
      easeIn: t => t * t,
      bounce: t => {
        const n1 = 7.5625;
        const d1 = 2.75;

        if (t < 1 / d1) {
          return n1 * t * t;
        } else if (t < 2 / d1) {
          return n1 * (t -= 1.5 / d1) * t + 0.75;
        } else if (t < 2.5 / d1) {
          return n1 * (t -= 2.25 / d1) * t + 0.9375;
        } else {
          return n1 * (t -= 2.625 / d1) * t + 0.984375;
        }
      }
    };
  }

  setInitialData(data) {
    this.data = data;
    this.render();
  }

  transitionTo(newData, options = {}) {
    this.targetData = newData;

    if (options.duration) {
      this.animationDuration = options.duration;
    }

    if (options.easing && this.easingFunctions[options.easing]) {
      this.currentEasing = this.easingFunctions[options.easing];
    } else {
      this.currentEasing = this.easingFunctions.easeInOut;
    }

    if (!this.isAnimating) {
      this.startAnimation();
    }
  }

  startAnimation() {
    this.isAnimating = true;
    this.animationStartTime = performance.now();
    this.animateFrame();
  }

  animateFrame() {
    if (!this.isAnimating) return;

    const currentTime = performance.now();
    const elapsed = currentTime - this.animationStartTime;
    const progress = Math.min(elapsed / this.animationDuration, 1);
    const easedProgress = this.currentEasing(progress);

    // Interpolate between current data and target data
    this.data = this.interpolateData(this.data, this.targetData, easedProgress);
    this.render();

    if (progress < 1) {
      requestAnimationFrame(() => this.animateFrame());
    } else {
      this.isAnimating = false;
      this.data = [...this.targetData];
      this.render();
    }
  }

  interpolateData(start, end, progress) {
    // Handle different array lengths
    const maxLength = Math.max(start.length, end.length);
    const interpolated = [];

    for (let i = 0; i < maxLength; i++) {
      const startPoint = start[i] || { x: 0, y: 0, value: 0 };
      const endPoint = end[i] || { x: 0, y: 0, value: 0 };

      interpolated.push({
        x: startPoint.x + (endPoint.x - startPoint.x) * progress,
        y: startPoint.y + (endPoint.y - startPoint.y) * progress,
        value: startPoint.value + (endPoint.value - startPoint.value) * progress,
        category: endPoint.category !== undefined ? endPoint.category : startPoint.category
      });
    }

    return interpolated;
  }

  render() {
    this.ctx.clearRect(0, 0, this.width, this.height);

    // Draw background
    this.ctx.fillStyle = '#ffffff';
    this.ctx.fillRect(0, 0, this.width, this.height);

    // Draw data points
    this.data.forEach(point => {
      const x = point.x * this.width;
      const y = point.y * this.height;
      const radius = 5 + point.value * 15;

      // Create gradient for each point
      const gradient = this.ctx.createRadialGradient(x, y, 0, x, y, radius);
      gradient.addColorStop(0, 'rgba(59, 130, 246, 0.8)');
      gradient.addColorStop(1, 'rgba(59, 130, 246, 0.1)');

      this.ctx.beginPath();
      this.ctx.arc(x, y, radius, 0, Math.PI * 2);
      this.ctx.fillStyle = gradient;
      this.ctx.fill();

      // Draw center dot
      this.ctx.beginPath();
      this.ctx.arc(x, y, 2, 0, Math.PI * 2);
      this.ctx.fillStyle = '#3b82f6';
      this.ctx.fill();
    });

    // Draw connections if we have a sequence
    if (this.data.length > 1) {
      this.ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)';
      this.ctx.lineWidth = 1;
      this.ctx.beginPath();

      this.data.forEach((point, i) => {
        const x = point.x * this.width;
        const y = point.y * this.height;

        if (i === 0) {
          this.ctx.moveTo(x, y);
        } else {
          this.ctx.lineTo(x, y);
        }
      });

      this.ctx.stroke();
    }
  }

  generateRandomData(count = 20) {
    return Array.from({ length: count }, () => ({
      x: Math.random(),
      y: Math.random(),
      value: Math.random(),
      category: Math.floor(Math.random() * 3)
    }));
  }
}

// Usage example with multiple animation types
const animViz = new AnimatedVisualization('animation-container');

// Set initial data
const initialData = animViz.generateRandomData(15);
animViz.setInitialData(initialData);

// Create a series of animated transitions
let transitionCount = 0;

function scheduleNextTransition() {
  transitionCount++;

  const newData = animViz.generateRandomData(15 + Math.floor(Math.random() * 10));

  // Cycle through different easing functions
  const easings = ['linear', 'easeInOut', 'easeOut', 'easeIn', 'bounce'];
  const easing = easings[transitionCount % easings.length];

  animViz.transitionTo(newData, {
    duration: 1500,
    easing: easing
  });

  // Schedule next transition
  setTimeout(scheduleNextTransition, 2000);
}

// Start the animation sequence
setTimeout(scheduleNextTransition, 1000);
Enter fullscreen mode Exit fullscreen mode

Interaction: Making Data Explorable

Interactive visualizations turn passive viewers into active explorers. I build interactions that allow users to ask questions of the data directly through the interface. Hover details, filtering, zooming, and selection tools transform static charts into analytical instruments.

The most effective interactions are those that feel natural and provide immediate feedback. Here's how I implement common interaction patterns.

class InteractiveHeatmap {
  constructor(containerId, options = {}) {
    this.container = document.getElementById(containerId);
    this.options = {
      width: options.width || 600,
      height: options.height || 400,
      cellSize: options.cellSize || 20,
      padding: options.padding || 2,
      colors: options.colors || ['#f0f9ff', '#0c4a6e']
    };

    this.data = [];
    this.filteredData = [];
    this.selectedCells = new Set();
    this.hoveredCell = null;

    this.init();
  }

  init() {
    this.createUI();
    this.setupEventListeners();
  }

  createUI() {
    // Clear container
    this.container.innerHTML = '';

    // Create main visualization container
    this.vizContainer = document.createElement('div');
    this.vizContainer.style.cssText = `
      display: grid;
      grid-template-columns: 1fr 200px;
      gap: 20px;
      margin-bottom: 20px;
    `;

    // Create canvas for heatmap
    this.canvas = document.createElement('canvas');
    this.canvas.width = this.options.width;
    this.canvas.height = this.options.height;
    this.canvas.style.border = '1px solid #e5e7eb';
    this.canvas.style.borderRadius = '4px';

    this.ctx = this.canvas.getContext('2d');

    // Create controls panel
    this.controls = document.createElement('div');
    this.controls.style.cssText = `
      display: flex;
      flex-direction: column;
      gap: 15px;
    `;

    // Create info display
    this.infoPanel = document.createElement('div');
    this.infoPanel.style.cssText = `
      background: #f9fafb;
      padding: 15px;
      border-radius: 6px;
      border: 1px solid #e5e7eb;
      font-size: 14px;
      line-height: 1.5;
    `;

    // Add controls to panel
    this.createControls();

    // Assemble UI
    const leftColumn = document.createElement('div');
    leftColumn.appendChild(this.canvas);
    leftColumn.appendChild(this.infoPanel);

    this.vizContainer.appendChild(leftColumn);
    this.vizContainer.appendChild(this.controls);
    this.container.appendChild(this.vizContainer);
  }

  createControls() {
    // Color scale control
    const colorControl = document.createElement('div');
    colorControl.innerHTML = '<strong>Color Scale</strong>';

    const colorSelect = document.createElement('select');
    colorSelect.innerHTML = `
      <option value="viridis">Viridis</option>
      <option value="plasma">Plasma</option>
      <option value="inferno">Inferno</option>
      <option value="magma">Magma</option>
      <option value="blues">Blues</option>
      <option value="rdbu">Red-Blue</option>
    `;

    colorSelect.addEventListener('change', (e) => {
      this.options.colors = this.getColorScale(e.target.value);
      this.render();
    });

    colorControl.appendChild(colorSelect);
    this.controls.appendChild(colorControl);

    // Filter control
    const filterControl = document.createElement('div');
    filterControl.innerHTML = '<strong>Value Filter</strong>';

    const filterInput = document.createElement('input');
    filterInput.type = 'range';
    filterInput.min = '0';
    filterInput.max = '100';
    filterInput.value = '0';
    filterInput.style.width = '100%';

    filterInput.addEventListener('input', (e) => {
      const threshold = parseInt(e.target.value) / 100;
      this.applyFilter(threshold);
      this.updateInfoPanel();
    });

    const filterLabel = document.createElement('div');
    filterLabel.style.fontSize = '12px';
    filterLabel.textContent = 'Min value: 0%';

    filterInput.addEventListener('input', (e) => {
      filterLabel.textContent = `Min value: ${e.target.value}%`;
    });

    filterControl.appendChild(filterInput);
    filterControl.appendChild(filterLabel);
    this.controls.appendChild(filterControl);

    // Selection mode control
    const selectionControl = document.createElement('div');
    selectionControl.innerHTML = '<strong>Selection Mode</strong>';

    const modeSelect = document.createElement('select');
    modeSelect.innerHTML = `
      <option value="single">Single Cell</option>
      <option value="multiple">Multiple Cells</option>
      <option value="rectangle">Rectangle Select</option>
    `;

    modeSelect.addEventListener('change', (e) => {
      this.selectionMode = e.target.value;
    });

    selectionControl.appendChild(modeSelect);
    this.controls.appendChild(selectionControl);

    // Clear selection button
    const clearButton = document.createElement('button');
    clearButton.textContent = 'Clear Selection';
    clearButton.style.cssText = `
      padding: 8px 16px;
      background: #ef4444;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    `;

    clearButton.addEventListener('click', () => {
      this.selectedCells.clear();
      this.render();
      this.updateInfoPanel();
    });

    this.controls.appendChild(clearButton);
  }

  getColorScale(name) {
    const scales = {
      viridis: ['#f0f9ff', '#0c4a6e'],
      plasma: ['#f0f0ff', '#f0a000'],
      inferno: ['#fcffc7', '#6e0042'],
      magma: ['#fcfdbf', '#3b0f70'],
      blues: ['#eff6ff', '#1e40af'],
      rdbu: ['#3b82f6', '#ef4444']
    };

    return scales[name] || scales.viridis;
  }

  setData(matrix) {
    this.data = matrix;
    this.filteredData = [...matrix];
    this.calculateStats();
    this.render();
    this.updateInfoPanel();
  }

  calculateStats() {
    if (this.data.length === 0) return;

    const allValues = this.data.flat();
    this.stats = {
      min: Math.min(...allValues),
      max: Math.max(...allValues),
      mean: allValues.reduce((a, b) => a + b, 0) / allValues.length,
      total: allValues.length
    };
  }

  applyFilter(threshold) {
    const minValue = this.stats.min + (this.stats.max - this.stats.min) * threshold;

    this.filteredData = this.data.map(row => 
      row.map(value => value >= minValue ? value : null)
    );

    this.render();
  }

  render() {
    this.clearCanvas();
    this.drawHeatmap();
    this.drawSelection();
    this.drawHoverEffect();
  }

  clearCanvas() {
    this.ctx.clearRect(0, 0, this.options.width, this.options.height);
    this.ctx.fillStyle = '#ffffff';
    this.ctx.fillRect(0, 0, this.options.width, this.options.height);
  }

  drawHeatmap() {
    const rows = this.filteredData.length;
    const cols = this.filteredData[0]?.length || 0;

    const availableWidth = this.options.width - 40;
    const availableHeight = this.options.height - 40;

    const cellWidth = Math.min(this.options.cellSize, availableWidth / cols);
    const cellHeight = Math.min(this.options.cellSize, availableHeight / rows);

    const startX = (this.options.width - (cellWidth * cols)) / 2;
    const startY = (this.options.height - (cellHeight * rows)) / 2;

    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < cols; j++) {
        const value = this.filteredData[i][j];

        if (value === null) continue;

        const x = startX + j * cellWidth;
        const y = startY + i * cellHeight;

        // Calculate color based on value
        const normalized = (value - this.stats.min) / (this.stats.max - this.stats.min);
        const color = this.interpolateColor(
          this.options.colors[0],
          this.options.colors[1],
          normalized
        );

        // Draw cell
        this.ctx.fillStyle = color;
        this.ctx.fillRect(
          x + this.options.padding,
          y + this.options.padding,
          cellWidth - this.options.padding * 2,
          cellHeight - this.options.padding * 2
        );

        // Draw border
        this.ctx.strokeStyle = '#ffffff';
        this.ctx.lineWidth = 1;
        this.ctx.strokeRect(
          x + this.options.padding,
          y + this.options.padding,
          cellWidth - this.options.padding * 2,
          cellHeight - this.options.padding * 2
        );

        // Draw value label for high-contrast cells
        if (cellWidth > 25 && cellHeight > 25) {
          const textColor = normalized > 0.5 ? '#ffffff' : '#000000';
          this.ctx.fillStyle = textColor;
          this.ctx.font = '10px Arial';
          this.ctx.textAlign = 'center';
          this.ctx.textBaseline = 'middle';
          this.ctx.fillText(
            value.toFixed(1),
            x + cellWidth / 2,
            y + cellHeight / 2
          );
        }
      }
    }
  }

  interpolateColor(color1, color2, factor) {
    const hex = color => {
      const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
      return result ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16)
      } : null;
    };

    const c1 = hex(color1);
    const c2 = hex(color2);

    if (!c1 || !c2) return color1;

    const r = Math.round(c1.r + factor * (c2.r - c1.r));
    const g = Math.round(c1.g + factor * (c2.g - c1.g));
    const b = Math.round(c1.b + factor * (c2.b - c1.b));

    return `rgb(${r}, ${g}, ${b})`;
  }

  drawSelection() {
    if (this.selectedCells.size === 0) return;

    const rows = this.filteredData.length;
    const cols = this.filteredData[0]?.length || 0;

    const availableWidth = this.options.width - 40;
    const availableHeight = this.options.height - 40;

    const cellWidth = Math.min(this.options.cellSize, availableWidth / cols);
    const cellHeight = Math.min(this.options.cellSize, availableHeight / rows);

    const startX = (this.options.width - (cellWidth * cols)) / 2;
    const startY = (this.options.height - (cellHeight * rows)) / 2;

    this.ctx.strokeStyle = '#ef4444';
    this.ctx.lineWidth = 2;

    this.selectedCells.forEach(cellId => {
      const [i, j] = cellId.split(',').map(Number);
      const x = startX + j * cellWidth;
      const y = startY + i * cellHeight;

      this.ctx.strokeRect(
        x + this.options.padding - 1,
        y + this.options.padding - 1,
        cellWidth - this.options.padding * 2 + 2,
        cellHeight - this.options.padding * 2 + 2
      );
    });
  }

  drawHoverEffect() {
    if (!this.hoveredCell) return;

    const [i, j] = this.hoveredCell.split(',').map(Number);
    const rows = this.filteredData.length;
    const cols = this.filteredData[0]?.length || 0;

    const availableWidth = this.options.width - 40;
    const availableHeight = this.options.height - 40;

    const cellWidth = Math.min(this.options.cellSize, availableWidth / cols);
    const cellHeight = Math.min(this.options.cellSize, availableHeight / rows);

    const startX = (this.options.width - (cellWidth * cols)) / 2;
    const startY = (this.options.height - (cellHeight * rows)) / 2;

    const x = startX + j * cellWidth;
    const y = startY + i * cellHeight;

    this.ctx.strokeStyle = '#3b82f6';
    this.ctx.lineWidth = 2;

    this.ctx.strokeRect(
      x + this.options.padding - 1,
      y + this.options.padding - 1,
      cellWidth - this.options.padding * 2 + 2,
      cellHeight - this.options.padding * 2 + 2
    );
  }

  setupEventListeners() {
    this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
    this.canvas.addEventListener('click', this.handleClick.bind(this));
    this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
    this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
  }

  handleMouseMove(event) {
    const rect = this.canvas.getBoundingClientRect();
    const x = event.clientX - rect.left;
    const y = event.clientY - rect.top;

    const cell = this.getCellAtPosition(x, y);
    this.hoveredCell = cell;

    if (cell) {
      this.render();
      this.drawTooltip(cell, event.clientX, event.clientY);
    } else {
      this.hoveredCell = null;
      this.render();
      this.hideTooltip();
    }
  }

  handleClick(event) {
    const rect = this.canvas.getBoundingClientRect();
    const x = event.clientX - rect.left;
    const y = event.clientY - rect.top;

    const cell = this.getCellAtPosition(x, y);
    if (!cell) return;

    const [i, j] = cell.split(',').map(Number);
    const cellId = `${i},${j}`;

    if (this.selectionMode === 'single') {
      this.selectedCells.clear();
      this.selectedCells.add(cellId);
    } else if (this.selectionMode === 'multiple') {
      if (event.shiftKey) {
        if (this.selectedCells.has(cellId)) {
          this.selectedCells.delete(cellId);
        } else {
          this.selectedCells.add(cellId);
        }
      } else {
        this.selectedCells.clear();
        this.selectedCells.add(cellId);
      }
    }

    this.render();
    this.updateInfoPanel();
  }

  handleMouseDown(event) {
    if (this.selectionMode === 'rectangle') {
      const rect = this.canvas.getBoundingClientRect();
      this.selectionStart = {
        x: event.clientX - rect.left,
        y: event.clientY - rect.top
      };
      this.isSelecting = true;
    }
  }

  handleMouseUp(event) {
    if (this.selectionMode === 'rectangle' && this.isSelecting) {
      const rect = this.canvas.getBoundingClientRect();
      const endX = event.clientX - rect.left;
      const endY = event.clientY - rect.top;

      const startX = this.selectionStart.x;
      const startY = this.selectionStart.y;

      const minX = Math.min(startX, endX);
      const maxX = Math.max(startX, endX);
      const minY = Math.min(startY, endY);
      const maxY = Math.max(startY, endY);

      // Select all cells in rectangle
      const rows = this.filteredData.length;
      const cols = this.filteredData[0]?.length || 0;

      const availableWidth = this.options.width - 40;
      const availableHeight = this.options.height - 40;

      const cellWidth = Math.min(this.options.cellSize, availableWidth / cols);
      const cellHeight = Math.min(this.options.cellSize, availableHeight / rows);

      const startCellX = (this.options.width - (cellWidth * cols)) / 2;
      const startCellY = (this.options.height - (cellHeight * rows)) / 2;

      for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
          const cellX = startCellX + j * cellWidth + cellWidth / 2;
          const cellY = startCellY + i * cellHeight + cellHeight / 2;

          if (cellX >= minX && cellX <= maxX && 
              cellY >= minY && cellY <= maxY) {
            this.selectedCells.add(`${i},${j}`);
          }
        }
      }

      this.isSelecting = false;
      this.render();
      this.updateInfoPanel();
    }
  }

  getCellAtPosition(x, y) {
    const rows = this.filteredData.length;
    const cols = this.filteredData[0]?.length || 0;

    const availableWidth = this.options.width - 40;
    const availableHeight = this.options.height - 40;

    const cellWidth = Math.min(this.options.cellSize, availableWidth / cols);
    const cellHeight = Math.min(this.options.cellSize, availableHeight / rows);

    const startX = (this.options.width - (cellWidth * cols)) / 2;
    const startY = (this.options.height - (cellHeight * rows)) / 2;

    const j = Math.floor((x - startX) / cellWidth);
    const i = Math.floor((y - startY) / cellHeight);

    if (i >= 0 && i < rows && j >= 0 && j < cols && 
        this.filteredData[i][j] !== null) {
      return `${i},${j}`;
    }

    return null;
  }

  drawTooltip(cellId, clientX, clientY) {
    this.hideTooltip();

    const [i, j] = cellId.split(',').map(Number);
    const value = this.data[i][j];
    const normalized = (value - this.stats.min) / (this.stats.max - this.stats.min);

    this.tooltip = document.createElement('div');
    this.tooltip.style.cssText = `
      position: fixed;
      background: rgba(0, 0, 0, 0.8);
      color: white;
      padding: 8px 12px;
      border-radius: 4px;
      font-size: 14px;
      pointer-events: none;
      z-index: 1000;
      transform: translate(10px, 10px);
    `;

    this.tooltip.innerHTML = `
      <div><strong>Row:</strong> ${i + 1}</div>
      <div><strong>Column:</strong> ${j + 1}</div>
      <div><strong>Value:</strong> ${value.toFixed(2)}</div>
      <div><strong>Percentile:</strong> ${(normalized * 100).toFixed(1)}%</div>
    `;

    this.tooltip.style.left = `${clientX}px`;
    this.tooltip.style.top = `${clientY}px`;

    document.body.appendChild(this.tooltip);
  }

  hideTooltip() {
    if (this.tooltip) {
      document.body.removeChild(this.tooltip);
      this.tooltip = null;
    }
  }

  updateInfoPanel() {
    if (this.selectedCells.size === 0) {
      this.infoPanel.innerHTML = `
        <div><strong>Heatmap Statistics</strong></div>
        <div>Total cells: ${this.stats.total}</div>
        <div>Value range: ${this.stats.min.toFixed(2)} - ${this.stats.max.toFixed(2)}</div>
        <div>Mean value: ${this.stats.mean.toFixed(2)}</div>
        <div>Hover over cells for details</div>
      `;
    } else {
      const selectedValues = Array.from(this.selectedCells).map(cellId => {
        const [i, j] = cellId.split(',').map(Number);
        return this.data[i][j];
      });

      const sum = selectedValues.reduce((a, b) => a + b, 0);
      const avg = sum / selectedValues.length;
      const min = Math.min(...selectedValues);
      const max = Math.max(...selectedValues);

      this.infoPanel.innerHTML = `
        <div><strong>Selected Cells</strong></div>
        <div>Count: ${selectedValues.length}</div>
        <div>Total: ${sum.toFixed(2)}</div>
        <div>Average: ${avg.toFixed(2)}</div>
        <div>Range: ${min.toFixed(2)} - ${max.toFixed(2)}</div>
        <div>Click cells to select, Shift+Click for multiple</div>
      `;
    }
  }

  generateSampleData(rows = 20, cols = 30) {
    const data = [];

    for (let i = 0; i < rows; i++) {
      const row = [];
      for (let j = 0; j < cols; j++) {
        // Create some pattern in the data
        const value = Math.sin(i * 0.3) * Math.cos(j * 0.3) * 0.5 + 0.5;
        const noise = Math.random() * 0.2;
        row.push(Math.max(0, Math.min(1, value + noise)));
      }
      data.push(row);
    }

    return data;
  }
}

// Usage
const heatmap = new InteractiveHeatmap('heatmap-container', {
  width: 600,
  height: 400,
  cellSize: 25,
  colors: ['#f0f9ff', '#0c4a6e']
});

// Generate and set sample data
const sampleMatrix = heatmap.generateSampleData(15, 25);
heatmap.setData(sampleMatrix);
Enter fullscreen mode Exit fullscreen mode

Real-time Data: Streaming Visualizations

Working with live data requires a different approach. The visualization needs to update smoothly without interrupting the user's analysis. I've built systems that handle streaming financial data, IoT sensor readings, and live analytics.

The challenge is balancing performance with clarity. You need to show the latest data while maintaining context of what came before.

class RealTimeChart {
  constructor(containerId, options = {}) {
    this.container = document.getElementById(containerId);
    this.options = {
      width: options.width || 800,
      height: options.height || 400,
      bufferSize: options.bufferSize || 1000,
      updateInterval: options.updateInterval || 100,
      showMovingAverage: options.showMovingAverage !== false,
      showBounds: options.showBounds || false
    };

    this.data = [];
    this.isPlaying = false;
    this.lastUpdateTime = 0;
    this.frameId = null;

    this.init();
  }

  init() {
    this.createCanvas();
    this.setupControls();
    this.startAnimationLoop();
  }

  createCanvas() {
    this.canvas = document.createElement('canvas');
    this.canvas.width = this.options.width;
    this.canvas.height = this.options.height;
    this.canvas.style.cssText = `
      border: 1px solid #e5e7eb;
      border-radius: 4px;
      background: #ffffff;
    `;

    this.container.appendChild(this.canvas);
    this.ctx = this.canvas.getContext('2d');
  }

  setupControls() {
    const controls = document.createElement('div');
    controls.style.cssText = `
      display: flex;
      gap: 10px;
      margin-top: 10px;
      align-items: center;
    `;

    // Play/pause button
    this.playButton = document.createElement('button');
    this.playButton.textContent = '⏸️ Pause';
    this.playButton.addEventListener('click', () => this.togglePlay());

    // Speed control
    const speedLabel = document.createElement('span');
    speedLabel.textContent = 'Speed:';

    const speedControl = document.createElement('input');
    speedControl.type = 'range';
    speedControl.min = '10';
    speedControl.max = '1000';
    speedControl.value = this.options.updateInterval;
    speedControl.addEventListener('input', (e) => {
      this.options.updateInterval = parseInt(e.target.value);
    });

    // Buffer size control
    const bufferLabel = document.createElement('span');
    bufferLabel.textContent = 'Buffer:';

    const bufferControl = document.createElement('input');
    bufferControl.type = 'range';
    bufferControl.min = '100';
    bufferControl.max = '5000';
    bufferControl.value = this.options.bufferSize;
    bufferControl.addEventListener('input', (e) => {
      this.options.bufferSize = parseInt(e.target.value);
    });

    // Stats display
    this.statsDisplay = document.createElement('div');
    this.statsDisplay.style.marginLeft = 'auto';
    this.statsDisplay.style.fontSize = '12px';
    this.statsDisplay.style.color = '#666';

    controls.appendChild(this.playButton);
    controls.appendChild(speedLabel);
    controls.appendChild(speedControl);
    controls.appendChild(bufferLabel);
    controls.appendChild(bufferControl);
    controls.appendChild(this.statsDisplay);

    this.container.appendChild(controls);
  }

  togglePlay() {
    this.isPlaying = !this.isPlaying;
    this.playButton.textContent = this.isPlaying ? '⏸️ Pause' : '▶️ Play';
  }

  startAnimationLoop() {
    const animate = () => {
      const currentTime = Date.now();

      if (this.isPlaying && currentTime - this.lastUpdateTime >= this.options.updateInterval) {
        this.addDataPoint();
        this.lastUpdateTime = currentTime;
      }

      this.render();
      this.updateStats();
      this.frameId = requestAnimationFrame(animate);
    };

    this.frameId = requestAnimationFrame(animate);
  }

  addDataPoint() {
    const lastValue = this.data.length > 0 ? this.data[this.data.length - 1].value : 50;

    // Generate new data point with some randomness and trend
    const trend = Math.sin(Date.now() / 10000) * 10;
    const noise = (Math.random() - 0.5) * 20;
    const newValue = Math.max(0, Math.min(100, lastValue + trend + noise));

    const dataPoint = {
      timestamp: Date.now(),
      value: newValue,
      smoothed: this.calculateSmoothedValue(newValue)
    };

    this.data.push(dataPoint);

    // Keep buffer size manageable
    if (this.data.length > this.options.bufferSize) {
      this.data = this.data.slice(-this.options.bufferSize);
    }
  }

  calculateSmoothedValue(newValue) {
    if (this.data.length === 0) return newValue;

    // Simple moving average of last 10 points
    const recentPoints = this.data.slice(-10).map(d => d.value);
    recentPoints.push(newValue);

    const sum = recentPoints.reduce((a, b) => a + b, 0);
    return sum / recentPoints.length;
  }

  render() {
    this.clearCanvas();
    this.drawGrid();
    this.drawData();
    this.drawCurrentValue();
  }

  clearCanvas() {
    this.ctx.clearRect(0, 0, this.options.width, this.options.height);

    // Draw background
    this.ctx.fillStyle = '#ffffff';
    this.ctx.fillRect(0, 0, this.options.width, this.options.height);
  }

  drawGrid() {
    this.ctx.strokeStyle = '#f0f0f0';
    this.ctx.lineWidth = 1;

    // Vertical grid lines (time)
    for (let i = 0; i <= 10; i++) {
      const x = (i / 10) * this.options.width;

      this.ctx.beginPath();
      this.ctx.moveTo(x, 0);
      this.ctx.lineTo(x, this.options.height);
      this.ctx.stroke();
    }

    // Horizontal grid lines (value)
    for (let i = 0; i <= 10; i++) {
      const y = (i / 10) * this.options.height;

      this.ctx.beginPath();
      this.ctx.moveTo(0, y);
      this.ctx.lineTo(this.options.width, y);
      this.ctx.stroke();
    }

    // Draw value labels
    this.ctx.fillStyle = '#999';
    this.ctx.font = '12px Arial';
    this.ctx.textAlign = 'right';

    for (let i = 0; i <= 5; i++) {
      const y = (i / 5) * this.options.height;
      const value = 100 - (i * 20);
      this.ctx.fillText(value.toString(), 40, y + 4);
    }
  }

  drawData() {
    if (this.data.length < 2) return;

    const visibleData = this.data;
    const pointWidth = this.options.width / (visibleData.length - 1);

    // Draw main line
    this.ctx.strokeStyle = '#3b82f6';
    this.ctx.lineWidth = 2;
    this.ctx.beginPath();

    visibleData.forEach((point, index) => {
      const x = index * pointWidth;
      const y = this.options.height * (1 - point.value / 100);

      if (index === 0) {
        this.ctx.moveTo(x, y);
      } else {
        this.ctx.lineTo(x, y);
      }
    });

    this.ctx.stroke();

    // Draw smoothed line if enabled
    if (this.options.showMovingAverage) {
      this.ctx.strokeStyle = '#ef4444';
      this.ctx.lineWidth = 1;
      this.ctx.setLineDash([5, 5]);
      this.ctx.beginPath();

      visibleData.forEach((point, index) => {
        const x = index * pointWidth;
        const y = this.options.height * (1 - point.smoothed / 100);

        if (index === 0) {
          this.ctx.moveTo(x, y);
        } else {
          this.ctx.lineTo(x, y);
        }
      });

      this.ctx.stroke();
      this.ctx.setLineDash([]);
    }

    // Draw bounds if enabled
    if (this.options.showBounds && visibleData.length > 0) {
      const values = visibleData.map(d => d.value);
      const min = Math.min(...values);
      const max = Math.max(...values);

      const minY = this.options.height * (1 - min / 100);
      const maxY = this.options.height * (1 - max / 100);

      this.ctx.fillStyle = 'rgba(59, 130, 246, 0.1)';
      this.ctx.fillRect(0, minY, this.options.width, maxY - minY);

      // Draw min/max lines
      this.ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)';
      this.ctx.lineWidth = 1;

      this.ctx.beginPath();
      this.ctx.moveTo(0, minY);
      this.ctx.lineTo(this.options.width, minY);
      this.ctx.stroke();

      this.ctx.beginPath();
      this.ctx.moveTo(0, maxY);
      this.ctx.lineTo(this.options.width, maxY);
      this.ctx.stroke();
    }
  }

  drawCurrentValue() {
    if (this.data.length === 0) return;

    const currentPoint = this.data[this.data.length - 1];
    const x = this.options.width - 10;
    const y = this.options.height * (1 - currentPoint.value / 100);

    // Draw current value indicator
    this.ctx.fillStyle = '#3b82f6';
    this.ctx.beginPath();
    this.ctx.arc(x, y, 6, 0, Math.PI * 2);
    this.ctx.fill();

    this.ctx.strokeStyle = '#ffffff';
    this.ctx.lineWidth = 2;
    this.ctx.stroke();

    // Draw value label
    this.ctx.fillStyle = '#333';
    this.ctx.font = 'bold 14px Arial';
    this.ctx.textAlign = 'right';
    this.ctx.fillText(currentPoint.value.toFixed(1), x - 10, y - 10);

    // Draw timestamp
    const timeStr = new Date(currentPoint.timestamp).toLocaleTimeString();
    this.ctx.font = '12px Arial';
    this.ctx.fillStyle = '#666';
    this.ctx.textAlign = 'right';
    this.ctx.fillText(timeStr, this.options.width - 10, this.options.height - 10);
  }

  updateStats() {
    if (this.data.length === 0) return;

    const current = this.data[this.data.length - 1];
    const values = this.data.map(d => d.value);

    const stats = {
      current: current.value.toFixed(2),
      min: Math.min(...values).toFixed(2),
      max: Math.max(...values).toFixed(2),
      avg: (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
      count: this.data.length
    };

    this.statsDisplay.innerHTML = `
      Current: ${stats.current} | 
      Min: ${stats.min} | 
      Max: ${stats.max} | 
      Avg: ${stats.avg} | 
      Points: ${stats.count}
    `;
  }

  addExternalData(value) {
    const dataPoint = {
      timestamp: Date.now(),
      value: value,
      smoothed: this.calculateSmoothedValue(value)
    };

    this.data.push(dataPoint);

    if (this.data.length > this.options.bufferSize) {
      this.data = this.data.slice(-this.options.bufferSize);
    }
  }

  reset() {
    this.data = [];
    this.render();
  }

  destroy() {
    if (this.frameId) {
      cancelAnimationFrame(this.frameId);
    }
    this.canvas.remove();
  }
}

// Usage with simulated data stream
const realTimeChart = new RealTimeChart('realtime-container', {
  width: 800,
  height: 400,
  bufferSize: 500,
  updateInterval: 100,
  showMovingAverage: true,
  showBounds: true
});

// Simulate external data source
setInterval(() => {
  const randomValue = 50 + Math.sin(Date.now() / 2000) * 30 + Math.random() * 10;
  realTimeChart.addExternalData(randomValue);
}, 200);

// Example of connecting to a WebSocket for real data
function connectToWebSocket(url) {
  const socket = new WebSocket(url);

  socket.onmessage = (event) => {
    try {
      const data = JSON.parse(event.data);
      if (data.value !== undefined) {
        realTimeChart.addExternalData(data.value);
      }
    } catch (error) {
      console.error('Error parsing WebSocket data:', error);
    }
  };

  socket.onerror = (error) => {
    console.error('WebSocket error:', error);
  };

  return socket;
}

// For demonstration, simulate WebSocket-like behavior
const mockWebSocket = {
  send: () => {},
  close: () => {}
};

// Example of handling user interaction for data control
document.addEventListener('keydown', (event) => {
  if (event.key === ' ') {
    realTimeChart.togglePlay();
  }

  if (event.key === 'r' || event.key === 'R') {
    realTimeChart.reset();
  }
});
Enter fullscreen mode Exit fullscreen mode

These techniques represent the core approaches I use for modern data visualization. Each has its strengths—Canvas for performance, SVG for flexibility, WebGL for scale, and careful combinations for interactivity and real-time display.

The most important lesson I've learned is to match the technique to the need. A dashboard with moderate data works well with SVG. A scientific visualization with millions of points requires WebGL. Real-time monitoring needs optimized Canvas rendering.

What makes these techniques valuable isn't just their individual capabilities, but how they work together. I often combine SVG for interface elements with Canvas for the main visualization, or use WebGL for rendering while managing interactions with regular DOM elements.

The code examples I've shared are starting points. In real projects, I extend these patterns with error handling, accessibility features, and responsive design considerations. Testing across devices and browsers is essential, as performance characteristics can vary significantly.

Data visualization is ultimately about communication. The best technical implementation serves the goal of making data understandable. Keeping this purpose in mind guides my decisions about which techniques to use and how to implement them effectively.

Remember that your users don't care about the rendering technology. They care about whether they can understand the data. The techniques I've described are tools to serve that understanding. Choose the simplest approach that meets your requirements, and add complexity only when it serves the user's needs.

As you work with these techniques, you'll develop your own patterns and preferences. The key is to start with a clear understanding of what you want to communicate, then select and implement the techniques that best serve that communication. With practice, you'll be able to transform raw data into clear, interactive, and meaningful visual experiences.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)