DEV Community

Cover image for Master JavaScript Data Visualization: From Canvas to 3D Interactive Charts with Performance Tips
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Master JavaScript Data Visualization: From Canvas to 3D Interactive Charts with Performance Tips

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!

I want to show you how to build pictures from your data. Not just static charts, but living, interactive stories that respond to a user's touch. When I started with JavaScript visualizations, it felt overwhelming. The tools seemed complex, the performance issues daunting. But I learned that by mastering a handful of key techniques, you can create almost anything.

This is a practical guide. I'll walk you through the core methods I use every day, with code you can adapt. We'll start from the ground up.

First, let's talk about drawing directly. Sometimes, you need total control over every pixel on the screen. That's where the HTML Canvas element comes in. Think of it as a digital sketchpad. You use JavaScript commands to draw lines, shapes, and text.

The trick is setting it up properly. You must account for high-resolution screens, or your visuals will look blurry. Here’s how I structure a basic Canvas visualization class.

class SimpleChart {
  constructor(canvasElement, dataset) {
    this.canvas = canvasElement;
    // This gives us the drawing tools
    this.ctx = this.canvas.getContext('2d');
    this.data = dataset;
    // We store the animation ID to stop it later
    this.activeAnimation = null;
    this.pixelRatio = window.devicePixelRatio || 1;
    this.prepareCanvas();
  }

  prepareCanvas() {
    // Get the visual size on the page
    const displayWidth = this.canvas.clientWidth;
    const displayHeight = this.canvas.clientHeight;

    // Set the internal canvas resolution
    this.canvas.width = displayWidth * this.pixelRatio;
    this.canvas.height = displayHeight * this.pixelRatio;
    // Scale the drawing context to match
    this.ctx.scale(this.pixelRatio, this.pixelRatio);

    // Keep the CSS size for layout
    this.canvas.style.width = `${displayWidth}px`;
    this.canvas.style.height = `${displayHeight}px`;
  }

  drawBasicBars() {
    const { width, height } = this.canvas;
    // Leave some space around the edges
    const margin = 25;
    const availableWidth = width - (margin * 2);
    const availableHeight = height - (margin * 2);

    // Find the biggest number in our data
    const topValue = Math.max(...this.data.map(d => d.amount));

    // Clear the previous frame
    this.ctx.clearRect(0, 0, width, height);

    this.data.forEach((item, i) => {
      // Calculate bar dimensions
      const barW = (availableWidth / this.data.length) * 0.7;
      const barH = (item.amount / topValue) * availableHeight;
      const xPos = margin + (i * (availableWidth / this.data.length));
      const yPos = height - margin - barH;

      // Pick a color
      this.ctx.fillStyle = this.assignColor(item.type);
      // Draw the rectangle
      this.ctx.fillRect(xPos, yPos, barW, barH);

      // Label the bar
      this.ctx.fillStyle = '#2c3e50';
      this.ctx.font = '12px sans-serif';
      this.ctx.textAlign = 'center';
      this.ctx.fillText(item.amount, xPos + (barW / 2), yPos - 8);
    });
  }

  // A helper to pick consistent colors
  assignColor(type) {
    const palette = {
      sales: '#3498db',
      expenses: '#e74c3c',
      growth: '#2ecc71',
      target: '#f39c12'
    };
    return palette[type] || '#95a5a6';
  }
}
Enter fullscreen mode Exit fullscreen mode

Canvas is fast and gives you raw power. But for many charts, especially those needing clean scaling or lots of user interaction, I prefer a different approach.

That brings us to SVG, or Scalable Vector Graphics. Instead of painting pixels, you create shapes using XML-like elements in your HTML. These shapes are math-based, so they stay sharp no matter how much you zoom. Adding a click event to a bar chart is as easy as adding one to a button.

A common pattern is to use JavaScript to build the SVG structure based on your data.

function buildSVGPieChart(containerId, data) {
    const container = document.getElementById(containerId);
    const size = 300;
    const center = size / 2;
    const radius = size * 0.4;

    // Clear and create a new SVG
    container.innerHTML = '';
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('width', size);
    svg.setAttribute('height', size);
    svg.setAttribute('viewBox', `0 0 ${size} ${size}`);

    let currentAngle = 0;
    const total = data.reduce((sum, item) => sum + item.value, 0);

    data.forEach(item => {
        // Calculate the slice size
        const sliceAngle = (item.value / total) * 2 * Math.PI;
        const endAngle = currentAngle + sliceAngle;

        // Calculate the path for this pie slice
        const startX = center + radius * Math.cos(currentAngle);
        const startY = center + radius * Math.sin(currentAngle);
        const endX = center + radius * Math.cos(endAngle);
        const endY = center + radius * Math.sin(endAngle);

        // The large-arc-flag is 1 if the slice is > 180 degrees
        const largeArcFlag = sliceAngle > Math.PI ? 1 : 0;

        const pathData = [
            `M ${center},${center}`,
            `L ${startX},${startY}`,
            `A ${radius},${radius} 0 ${largeArcFlag},1 ${endX},${endY}`,
            'Z'
        ].join(' ');

        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', pathData);
        path.setAttribute('fill', item.color);
        path.setAttribute('stroke', '#fff');
        path.setAttribute('stroke-width', '2');

        // Make it interactive
        path.addEventListener('mouseenter', (e) => {
            e.target.style.opacity = '0.8';
            showSVGTooltip(item.label, item.value, e.clientX, e.clientY);
        });
        path.addEventListener('mouseleave', (e) => {
            e.target.style.opacity = '1';
            hideTooltip();
        });

        svg.appendChild(path);
        currentAngle = endAngle;
    });

    container.appendChild(svg);
}
Enter fullscreen mode Exit fullscreen mode

SVG makes interactivity simple because each shape is a DOM element. But for highly complex or data-heavy transformations, I often reach for a specialized library.

D3.js is a powerhouse. It’s less a charting library and more a language for binding data to the document. It can feel intimidating, but its core idea is elegant: you tell D3 what data you have, what visual elements you want, and it figures out how to create, update, or remove elements to match.

The "enter-update-exit" pattern is the heart of D3. It's how you manage changes over time.

class DynamicD3Chart {
    constructor(elementId, initialData) {
        this.data = initialData;
        this.width = 600;
        this.height = 300;
        this.margins = { top: 20, right: 20, bottom: 30, left: 40 };

        // Select the container and set up the SVG stage
        this.svg = d3.select(`#${elementId}`)
            .append('svg')
            .attr('width', this.width)
            .attr('height', this.height);

        // A group for the main chart, offset by margins
        this.mainGroup = this.svg.append('g')
            .attr('transform', `translate(${this.margins.left},${this.margins.top})`);

        this.innerWidth = this.width - this.margins.left - this.margins.right;
        this.innerHeight = this.height - this.margins.top - this.margins.bottom;

        this.build();
    }

    build() {
        // SCALES: Map from data space to pixel space.
        // xScale: data index -> x-position
        this.xScale = d3.scaleBand()
            .domain(this.data.map((d, i) => i)) // Domain: [0, 1, 2, ...]
            .range([0, this.innerWidth])
            .padding(0.1);

        // yScale: data value -> y-position
        const maxVal = d3.max(this.data, d => d.value);
        this.yScale = d3.scaleLinear()
            .domain([0, maxVal])
            .range([this.innerHeight, 0]); // SVG Y is top-down, so we invert

        // AXES: Visual guides for the scales.
        const xAxis = d3.axisBottom(this.xScale).tickFormat(i => this.data[i].label);
        const yAxis = d3.axisLeft(this.yScale);

        // Draw or update axes
        if (!this.xAxisGroup) {
            this.xAxisGroup = this.mainGroup.append('g')
                .attr('transform', `translate(0,${this.innerHeight})`)
                .call(xAxis);
            this.yAxisGroup = this.mainGroup.append('g').call(yAxis);
        } else {
            this.xAxisGroup.call(xAxis);
            this.yAxisGroup.call(yAxis);
        }

        // DATA JOIN: This is the key pattern.
        // Select all rectangles that *could* represent our data
        const bars = this.mainGroup.selectAll('rect')
            .data(this.data);

        // ENTER: Handle new data points. Create new 'rect' elements.
        bars.enter()
            .append('rect')
            .attr('x', (d, i) => this.xScale(i))
            .attr('y', this.innerHeight) // Start at the bottom
            .attr('width', this.xScale.bandwidth())
            .attr('height', 0)
            .attr('fill', '#2980b9')
            // Animate them into place
            .transition()
            .duration(750)
            .attr('y', d => this.yScale(d.value))
            .attr('height', d => this.innerHeight - this.yScale(d.value));

        // UPDATE: Handle existing data points that remain.
        bars.transition()
            .duration(750)
            .attr('x', (d, i) => this.xScale(i))
            .attr('width', this.xScale.bandwidth())
            .attr('y', d => this.yScale(d.value))
            .attr('height', d => this.innerHeight - this.yScale(d.value));

        // EXIT: Handle data points that have been removed.
        bars.exit()
            .transition()
            .duration(750)
            .attr('height', 0)
            .attr('y', this.innerHeight)
            .remove();
    }

    updateWithNewData(newData) {
        this.data = newData;
        // Re-running build will trigger the enter/update/exit pattern
        this.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is what makes D3 so powerful for dashboards where data updates in real time. You don't have to manually track which bars to add or remove; D3 does it based on the data difference.

For more standard charts without needing D3's full power, I love Chart.js. It's simple, declarative, and gets beautiful, interactive charts working in minutes. You describe what you want, and it handles the rendering.

function setupRealTimeLineChart() {
    const ctx = document.getElementById('liveChart').getContext('2d');

    // Initial empty data
    let timeLabels = [];
    let valueSeries = [];

    const chart = new Chart(ctx, {
        type: 'line',
        data: {
            labels: timeLabels,
            datasets: [{
                label: 'Live Sensor Data',
                data: valueSeries,
                borderColor: '#9b59b6',
                backgroundColor: 'rgba(155, 89, 182, 0.1)',
                tension: 0.2, // Makes the line slightly curved
                fill: true
            }]
        },
        options: {
            responsive: true,
            interaction: {
                intersect: false, // Tooltip shows for nearest point, not just direct hover
                mode: 'index'
            },
            scales: {
                x: {
                    type: 'realtime', // Requires a plugin
                    realtime: {
                        duration: 20000, // Show last 20 seconds
                        refresh: 1000,   // Update every second
                        onRefresh: function(chart) {
                            // This function is called to get new data
                            const now = Date.now();
                            const newValue = Math.random() * 100; // Simulated data

                            chart.data.labels.push(now);
                            chart.data.datasets[0].data.push(newValue);
                        }
                    }
                },
                y: {
                    beginAtZero: true
                }
            },
            plugins: {
                legend: {
                    display: true
                },
                tooltip: {
                    callbacks: {
                        label: function(context) {
                            return `Value: ${context.parsed.y.toFixed(2)}`;
                        }
                    }
                }
            }
        }
    });

    // Example of manually adding a point
    function addDataPoint(manualValue) {
        const now = new Date().toLocaleTimeString();
        chart.data.labels.push(now);
        chart.data.datasets[0].data.push(manualValue);

        // Keep only the last 15 points for a rolling view
        if (chart.data.labels.length > 15) {
            chart.data.labels.shift();
            chart.data.datasets[0].data.shift();
        }

        chart.update('quiet'); // Update without animation
    }
}
Enter fullscreen mode Exit fullscreen mode

Chart.js handles responsiveness, tooltips, and basic animations out of the box. It's my go-to for internal dashboards or projects with tight deadlines.

But what if your data isn't flat? What if it has depth, relationships, and needs to be explored in three dimensions? For that, I use Three.js. It brings the world of 3D to the browser. You can create globes, network graphs, and molecular models.

Setting up a basic 3D scene involves a few core concepts: a Scene (the world), a Camera (your viewport), a Renderer (the painter), and Lights so you can see your objects.

class Basic3DDataViz {
    constructor(containerId) {
        this.container = document.getElementById(containerId);
        this.scene = new THREE.Scene();
        this.scene.background = new THREE.Color(0xf5f5f5);

        // PerspectiveCamera(field-of-view, aspect-ratio, near-clip, far-clip)
        this.camera = new THREE.PerspectiveCamera(
            60, 
            this.container.clientWidth / this.container.clientHeight, 
            0.1, 
            1000
        );
        this.camera.position.z = 15;

        this.renderer = new THREE.WebGLRenderer({ antialias: true });
        this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
        this.container.appendChild(this.renderer.domElement);

        // Add some lights
        const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
        this.scene.add(ambientLight);
        const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
        directionalLight.position.set(10, 20, 5);
        this.scene.add(directionalLight);

        // Let the user drag to look around
        this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
        this.controls.enableDamping = true; // Adds a smooth feel

        this.dataPoints = [];
        this.animate();
        window.addEventListener('resize', this.handleResize.bind(this));
    }

    // This function runs every frame
    animate() {
        requestAnimationFrame(this.animate.bind(this));
        this.controls.update(); // Required if damping is enabled
        this.renderer.render(this.scene, this.camera);
    }

    plotDataPoints(dataArray) {
        // Clear old points
        this.dataPoints.forEach(obj => this.scene.remove(obj));
        this.dataPoints = [];

        const geometry = new THREE.SphereGeometry(0.3, 16, 16);
        const material = new THREE.MeshLambertMaterial({ color: 0x3498db });

        dataArray.forEach(point => {
            const sphere = new THREE.Mesh(geometry, material);
            sphere.position.set(point.x, point.y, point.z);
            this.scene.add(sphere);
            this.dataPoints.push(sphere);

            // Add a line from the origin to the point
            const lineGeometry = new THREE.BufferGeometry().setFromPoints([
                new THREE.Vector3(0, 0, 0),
                new THREE.Vector3(point.x, point.y, point.z)
            ]);
            const lineMaterial = new THREE.LineBasicMaterial({ color: 0x7f8c8d });
            const line = new THREE.Line(lineGeometry, lineMaterial);
            this.scene.add(line);
            this.dataPoints.push(line);
        });
    }

    handleResize() {
        this.camera.aspect = this.container.clientWidth / this.container.clientHeight;
        this.camera.updateProjectionMatrix();
        this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
    }
}
Enter fullscreen mode Exit fullscreen mode

Three.js opens up possibilities for spatial data. I once used it to visualize a network of servers, where the Z-axis represented latency. Problems became obvious in seconds.

However, all these beautiful visuals are useless if your data isn't ready. The raw data from an API or a CSV file is rarely in the right shape. You need to process it.

Data processing is the unglamorous but critical step. It involves filtering, sorting, grouping, and calculating.

class DataPreparer {
    static processForVisualization(rawData, config) {
        let processed = [...rawData];

        // 1. Filter out unwanted data
        if (config.filters) {
            config.filters.forEach(filter => {
                processed = processed.filter(item => item[filter.key] === filter.value);
            });
        }

        // 2. Sort if needed
        if (config.sortBy) {
            processed.sort((a, b) => {
                if (a[config.sortBy] < b[config.sortBy]) return -1;
                if (a[config.sortBy] > b[config.sortBy]) return 1;
                return 0;
            });
        }

        // 3. Group and Aggregate (e.g., sum sales by month)
        if (config.groupBy) {
            const groups = {};
            processed.forEach(item => {
                const groupKey = item[config.groupBy];
                if (!groups[groupKey]) {
                    groups[groupKey] = { key: groupKey, total: 0, count: 0, items: [] };
                }
                groups[groupKey].total += item.value;
                groups[groupKey].count++;
                groups[groupKey].items.push(item);
            });
            // Convert the groups object back to an array for the chart
            processed = Object.values(groups).map(g => ({ 
                label: g.key, 
                value: g.total,
                average: g.total / g.count 
            }));
        }

        // 4. Normalize to a 0-1 range (useful for comparison)
        if (config.normalize) {
            const values = processed.map(d => d.value);
            const min = Math.min(...values);
            const max = Math.max(...values);
            const range = max - min;
            processed.forEach(d => {
                d.normalizedValue = range === 0 ? 0.5 : (d.value - min) / range;
            });
        }

        return processed;
    }

    // For huge datasets, we sample to improve performance
    static sampleData(data, sampleSize = 1000) {
        if (data.length <= sampleSize) return data;

        const step = Math.floor(data.length / sampleSize);
        const sampled = [];
        for (let i = 0; i < data.length; i += step) {
            sampled.push(data[i]);
        }
        return sampled;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now you have clean data and a way to draw it. The next step is to make it talk back. Interactivity is what turns a picture into a tool.

The best visualizations feel alive. Hovering highlights details, clicking filters other views, and dragging selects ranges. Implementing these features well makes your work professional.

class InteractiveFeatures {
    constructor(vizElement) {
        this.element = vizElement;
        this.highlightedItems = new Set();
        this.setupGlobalListeners();
    }

    setupGlobalListeners() {
        // Use event delegation for efficiency
        this.element.addEventListener('mouseover', (e) => {
            const target = e.target;
            if (target.classList.contains('data-item')) {
                this.highlightItem(target);
            }
        });

        this.element.addEventListener('mouseout', (e) => {
            const target = e.target;
            if (target.classList.contains('data-item')) {
                this.unhighlightItem(target);
            }
        });

        this.element.addEventListener('click', (e) => {
            const target = e.target;
            if (target.classList.contains('data-item')) {
                this.selectItem(target);
            }
        });
    }

    highlightItem(item) {
        item.style.opacity = '1';
        item.style.stroke = '#2c3e50';
        item.style.strokeWidth = '2';
        this.showTooltip(item.dataset.label, item.dataset.value, e.clientX, e.clientY);
    }

    unhighlightItem(item) {
        if (!this.highlightedItems.has(item)) {
            item.style.opacity = '0.7';
            item.style.stroke = 'none';
        }
        this.hideTooltip();
    }

    selectItem(item) {
        // Toggle selection
        if (this.highlightedItems.has(item)) {
            this.highlightedItems.delete(item);
            item.style.fill = item.dataset.originalColor;
        } else {
            this.highlightedItems.add(item);
            item.style.fill = '#e74c3c'; // Selection color
        }
        // Trigger a custom event so other charts can listen
        const event = new CustomEvent('dataSelectionChanged', { 
            detail: { selectedIds: Array.from(this.highlightedItems).map(i => i.dataset.id) }
        });
        this.element.dispatchEvent(event);
    }

    // Implement a brush for selecting a range on an axis
    setupBrushSelection(xScale, onBrushChange) {
        const brush = d3.brushX()
            .extent([[0, 0], [this.innerWidth, 30]])
            .on('brush end', function(event) {
                if (!event.selection) return;
                const [start, end] = event.selection;
                const startValue = xScale.invert(start);
                const endValue = xScale.invert(end);
                onBrushChange([startValue, endValue]);
            });

        this.svg.append('g')
            .attr('class', 'brush')
            .call(brush);
    }

    showTooltip(label, value, pageX, pageY) {
        // Create or update a tooltip div
        let tooltip = document.getElementById('data-tooltip');
        if (!tooltip) {
            tooltip = document.createElement('div');
            tooltip.id = 'data-tooltip';
            tooltip.style.cssText = `
                position: fixed;
                background: rgba(0,0,0,0.85);
                color: white;
                padding: 8px 12px;
                border-radius: 4px;
                font-size: 14px;
                pointer-events: none;
                z-index: 1000;
            `;
            document.body.appendChild(tooltip);
        }
        tooltip.innerHTML = `<strong>${label}</strong>: ${value}`;
        tooltip.style.left = `${pageX + 10}px`;
        tooltip.style.top = `${pageY - 10}px`;
        tooltip.style.display = 'block';
    }

    hideTooltip() {
        const tooltip = document.getElementById('data-tooltip');
        if (tooltip) tooltip.style.display = 'none';
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we must ensure everything runs smoothly. A choppy, slow visualization breaks the user's focus. Performance optimization is crucial, especially with thousands of data points.

Here are my go-to strategies for keeping things fast.

class PerformanceManager {
    static optimizeCanvasDrawing(ctx, data) {
        // Batch similar operations
        ctx.beginPath();
        data.forEach(point => {
            ctx.moveTo(point.x, point.y);
            ctx.arc(point.x, point.y, 3, 0, Math.PI * 2);
        });
        ctx.fill();
        // This is faster than drawing each circle separately
    }

    static useOffscreenCanvas(mainCanvas, drawFunction) {
        // Create an invisible canvas to draw on
        const offscreen = document.createElement('canvas');
        offscreen.width = mainCanvas.width;
        offscreen.height = mainCanvas.height;
        const offCtx = offscreen.getContext('2d');

        // Do the heavy drawing off-screen
        drawFunction(offCtx);

        // Then draw the result onto the visible canvas in one operation
        const mainCtx = mainCanvas.getContext('2d');
        mainCtx.drawImage(offscreen, 0, 0);
    }

    static implementVirtualScrolling(container, itemHeight, totalItems, renderItemCallback) {
        let scrollTop = container.scrollTop;
        const visibleCount = Math.ceil(container.clientHeight / itemHeight);
        const startIndex = Math.floor(scrollTop / itemHeight);
        const endIndex = startIndex + visibleCount;

        // Only render the visible items
        const fragment = document.createDocumentFragment();
        for (let i = startIndex; i <= endIndex && i < totalItems; i++) {
            const item = renderItemCallback(i);
            item.style.position = 'absolute';
            item.style.top = `${i * itemHeight}px`;
            fragment.appendChild(item);
        }

        // Update the container's inner height to allow scrolling
        container.style.height = `${totalItems * itemHeight}px`;
        // Clear and add new items efficiently
        container.innerHTML = '';
        container.appendChild(fragment);
    }

    static processDataInWebWorker(dataUrl) {
        return new Promise((resolve) => {
            const worker = new Worker('dataWorker.js');
            worker.postMessage({ command: 'process', url: dataUrl });

            worker.onmessage = function(e) {
                if (e.data.status === 'complete') {
                    resolve(e.data.processedData);
                    worker.terminate(); // Clean up
                }
            };
        });
    }
}

// Inside 'dataWorker.js':
self.onmessage = function(e) {
    if (e.data.command === 'process') {
        fetch(e.data.url)
            .then(r => r.json())
            .then(rawData => {
                // Do heavy computations here without blocking the UI
                const processed = heavyAggregation(rawData);
                self.postMessage({ status: 'complete', processedData: processed });
            });
    }
};
Enter fullscreen mode Exit fullscreen mode

These techniques—from direct Canvas drawing to Web Workers—form a complete toolkit. The choice depends on your goal. Need a custom, high-performance effect? Use Canvas. Building an admin dashboard with common charts? Chart.js will save you days. Creating a unique, intricate visualization that reacts to data? D3 is your friend. Presenting spatial or network data? Consider Three.js.

The most important thing I've learned is to start simple. Make a single bar chart work. Add interactivity. Then think about performance. Iteration is better than planning forever. Your visualizations will grow in complexity as you understand the needs of your data and your users.

Good data visualization is a conversation. You are asking the data questions, and presenting the answers in a way that prompts the next question. With these JavaScript techniques, you have the vocabulary to start that conversation.

📘 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)