Spaces:
Running
Running
fix: Fixed missing visualization canvases, repaired ML Lab link, and fixed MathJax tab rendering logic.
84b67b2 | /** | |
| * Data Visualization Masterclass - Interactive Canvas Visualizations | |
| * Demonstrates core visualization concepts through live examples | |
| */ | |
| (function () { | |
| 'use strict'; | |
| // Utility functions | |
| const $ = id => document.getElementById(id); | |
| const $$ = sel => document.querySelectorAll(sel); | |
| // Color palette | |
| const COLORS = { | |
| primary: '#6366f1', | |
| secondary: '#8b5cf6', | |
| accent: '#06b6d4', | |
| success: '#10b981', | |
| warning: '#f59e0b', | |
| danger: '#ef4444', | |
| blue: '#3b82f6', | |
| orange: '#f97316', | |
| pink: '#ec4899', | |
| gray: '#6b7280', | |
| dark: '#1f2937', | |
| light: '#f3f4f6' | |
| }; | |
| // ==================== ANSCOMBE'S QUARTET ==================== | |
| function drawAnscombe() { | |
| const canvas = $('canvas-anscombe'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Anscombe's Quartet data | |
| const datasets = [ | |
| { x: [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5], y: [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68] }, | |
| { x: [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5], y: [9.14, 8.14, 8.74, 8.77, 9.26, 8.10, 6.13, 3.10, 9.13, 7.26, 4.74] }, | |
| { x: [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5], y: [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73] }, | |
| { x: [8, 8, 8, 8, 8, 8, 8, 19, 8, 8, 8], y: [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.50, 5.56, 7.91, 6.89] } | |
| ]; | |
| const titles = ['Dataset I', 'Dataset II', 'Dataset III', 'Dataset IV']; | |
| const panelWidth = canvas.width / 2 - 20; | |
| const panelHeight = canvas.height / 2 - 30; | |
| datasets.forEach((data, i) => { | |
| const col = i % 2; | |
| const row = Math.floor(i / 2); | |
| const offsetX = col * (panelWidth + 20) + 40; | |
| const offsetY = row * (panelHeight + 40) + 30; | |
| // Draw axes | |
| ctx.strokeStyle = '#374151'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(offsetX, offsetY + panelHeight - 20); | |
| ctx.lineTo(offsetX + panelWidth - 40, offsetY + panelHeight - 20); | |
| ctx.moveTo(offsetX, offsetY + panelHeight - 20); | |
| ctx.lineTo(offsetX, offsetY); | |
| ctx.stroke(); | |
| // Draw title | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.font = 'bold 12px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(titles[i], offsetX + (panelWidth - 40) / 2, offsetY - 8); | |
| // Draw points | |
| const xMin = 2, xMax = 20, yMin = 2, yMax = 14; | |
| data.x.forEach((x, j) => { | |
| const px = offsetX + ((x - xMin) / (xMax - xMin)) * (panelWidth - 50); | |
| const py = offsetY + panelHeight - 25 - ((data.y[j] - yMin) / (yMax - yMin)) * (panelHeight - 40); | |
| ctx.beginPath(); | |
| ctx.arc(px, py, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = [COLORS.primary, COLORS.secondary, COLORS.accent, COLORS.success][i]; | |
| ctx.fill(); | |
| }); | |
| }); | |
| // Stats text | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('All 4 datasets: Mean X = 9, Mean Y = 7.5, Variance = 11, Correlation = 0.816', 40, canvas.height - 10); | |
| } | |
| // ==================== VISUAL PERCEPTION ==================== | |
| let perceptionMode = 'position'; | |
| function drawPerception() { | |
| const canvas = $('canvas-perception'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const data = [45, 78, 32, 91, 56, 67, 23, 82, 41, 65]; | |
| const categories = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']; | |
| if (perceptionMode === 'position') { | |
| // Bar chart (position encoding) | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Position Encoding (Bar Chart) - Most Accurate!', canvas.width / 2, 25); | |
| const barWidth = 50; | |
| const startX = 50; | |
| data.forEach((v, i) => { | |
| const x = startX + i * (barWidth + 15); | |
| const height = v * 2.5; | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.fillRect(x, 280 - height, barWidth, height); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(categories[i], x + barWidth / 2, 300); | |
| ctx.fillText(v, x + barWidth / 2, 275 - height); | |
| }); | |
| } else if (perceptionMode === 'color') { | |
| // Color encoding | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Color Encoding - Good for Categories', canvas.width / 2, 25); | |
| const squareSize = 60; | |
| const startX = 50; | |
| data.forEach((v, i) => { | |
| const x = startX + i * (squareSize + 10); | |
| const hue = (v / 100) * 240; // Blue to red gradient | |
| ctx.fillStyle = `hsl(${240 - hue}, 70%, 50%)`; | |
| ctx.fillRect(x, 80, squareSize, squareSize); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(categories[i], x + squareSize / 2, 165); | |
| ctx.fillStyle = 'white'; | |
| ctx.fillText(v, x + squareSize / 2, 115); | |
| }); | |
| // Legend | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('Low', 50, 220); | |
| ctx.fillText('High', 650, 220); | |
| const gradWidth = 600; | |
| for (let i = 0; i < gradWidth; i++) { | |
| const hue = 240 - (i / gradWidth) * 240; | |
| ctx.fillStyle = `hsl(${hue}, 70%, 50%)`; | |
| ctx.fillRect(50 + i, 230, 1, 20); | |
| } | |
| } else if (perceptionMode === 'size') { | |
| // Size encoding (bubble) | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Size Encoding (Bubbles) - Humans Underestimate Area!', canvas.width / 2, 25); | |
| const startX = 60; | |
| data.forEach((v, i) => { | |
| const x = startX + i * 65; | |
| const radius = Math.sqrt(v) * 3.5; | |
| ctx.beginPath(); | |
| ctx.arc(x, 150, radius, 0, Math.PI * 2); | |
| ctx.fillStyle = COLORS.accent; | |
| ctx.globalAlpha = 0.7; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '10px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(categories[i], x, 230); | |
| ctx.fillText(v, x, 155); | |
| }); | |
| } | |
| } | |
| // ==================== GRAMMAR OF GRAPHICS ==================== | |
| function drawGrammar() { | |
| const canvas = $('canvas-grammar'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const components = [ | |
| { name: 'Data', icon: '📊', color: COLORS.primary }, | |
| { name: 'Aesthetics', icon: '🎨', color: COLORS.secondary }, | |
| { name: 'Geometry', icon: '◼️', color: COLORS.accent }, | |
| { name: 'Facets', icon: '🔲', color: COLORS.success }, | |
| { name: 'Statistics', icon: '📈', color: COLORS.warning }, | |
| { name: 'Coordinates', icon: '📐', color: COLORS.danger }, | |
| { name: 'Theme', icon: '🎭', color: COLORS.pink } | |
| ]; | |
| // Draw connected layers | |
| const centerX = canvas.width / 2; | |
| const startY = 60; | |
| const layerHeight = 45; | |
| components.forEach((comp, i) => { | |
| const y = startY + i * layerHeight; | |
| const width = 180 - i * 10; | |
| // Layer rectangle | |
| ctx.fillStyle = comp.color; | |
| ctx.globalAlpha = 0.8; | |
| ctx.beginPath(); | |
| ctx.roundRect(centerX - width / 2, y, width, 35, 5); | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| // Text | |
| ctx.fillStyle = 'white'; | |
| ctx.font = 'bold 13px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(`${comp.icon} ${comp.name}`, centerX, y + 22); | |
| // Connector | |
| if (i < components.length - 1) { | |
| ctx.strokeStyle = '#94a3b8'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(centerX, y + 35); | |
| ctx.lineTo(centerX, y + layerHeight); | |
| ctx.stroke(); | |
| } | |
| }); | |
| // Right side - example | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('Example in Python:', 450, 60); | |
| const code = [ | |
| 'ggplot(data=iris,', | |
| ' aes(x=sepal_length,', | |
| ' y=sepal_width,', | |
| ' color=species)) +', | |
| 'geom_point(size=3) +', | |
| 'facet_wrap(~species) +', | |
| 'stat_smooth(method="lm") +', | |
| 'coord_fixed() +', | |
| 'theme_minimal()' | |
| ]; | |
| ctx.font = '11px monospace'; | |
| ctx.fillStyle = COLORS.gray; | |
| code.forEach((line, i) => { | |
| ctx.fillText(line, 450, 85 + i * 18); | |
| }); | |
| } | |
| // ==================== CHOOSING CHARTS ==================== | |
| let chartPurpose = 'comparison'; | |
| function drawChoosingCharts() { | |
| const canvas = $('canvas-choosing'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.textAlign = 'center'; | |
| if (chartPurpose === 'comparison') { | |
| ctx.fillText('Comparison: Bar Chart / Grouped Bar / Line', canvas.width / 2, 25); | |
| const data = [ | |
| { name: 'Q1', values: [40, 55, 30] }, | |
| { name: 'Q2', values: [65, 45, 50] }, | |
| { name: 'Q3', values: [55, 70, 45] }, | |
| { name: 'Q4', values: [75, 60, 70] } | |
| ]; | |
| const colors = [COLORS.primary, COLORS.secondary, COLORS.accent]; | |
| const barWidth = 35; | |
| const groupWidth = barWidth * 3 + 30; | |
| const startX = 100; | |
| data.forEach((group, i) => { | |
| const groupX = startX + i * groupWidth; | |
| group.values.forEach((v, j) => { | |
| const x = groupX + j * (barWidth + 5); | |
| const height = v * 3; | |
| ctx.fillStyle = colors[j]; | |
| ctx.fillRect(x, 320 - height, barWidth, height); | |
| }); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(group.name, groupX + groupWidth / 2 - 15, 340); | |
| }); | |
| // Legend | |
| ['Product A', 'Product B', 'Product C'].forEach((label, i) => { | |
| ctx.fillStyle = colors[i]; | |
| ctx.fillRect(580, 60 + i * 25, 15, 15); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(label, 600, 72 + i * 25); | |
| }); | |
| } else if (chartPurpose === 'composition') { | |
| ctx.fillText('Composition: Stacked Bar / Pie Chart (use sparingly!)', canvas.width / 2, 25); | |
| // Stacked bar | |
| const data = [ | |
| { name: '2020', values: [30, 25, 45] }, | |
| { name: '2021', values: [35, 30, 35] }, | |
| { name: '2022', values: [25, 40, 35] }, | |
| { name: '2023', values: [40, 35, 25] } | |
| ]; | |
| const colors = [COLORS.primary, COLORS.secondary, COLORS.accent]; | |
| const barWidth = 60; | |
| const startX = 80; | |
| data.forEach((group, i) => { | |
| const x = startX + i * (barWidth + 40); | |
| let y = 320; | |
| group.values.forEach((v, j) => { | |
| const height = v * 2.5; | |
| ctx.fillStyle = colors[j]; | |
| ctx.fillRect(x, y - height, barWidth, height); | |
| y -= height; | |
| }); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(group.name, x + barWidth / 2, 340); | |
| }); | |
| // Simple pie | |
| const pieX = 550, pieY = 180, pieR = 80; | |
| const pieData = [0.4, 0.35, 0.25]; | |
| let angle = -Math.PI / 2; | |
| pieData.forEach((v, i) => { | |
| ctx.beginPath(); | |
| ctx.moveTo(pieX, pieY); | |
| ctx.arc(pieX, pieY, pieR, angle, angle + v * Math.PI * 2); | |
| ctx.fillStyle = colors[i]; | |
| ctx.fill(); | |
| angle += v * Math.PI * 2; | |
| }); | |
| } else if (chartPurpose === 'distribution') { | |
| ctx.fillText('Distribution: Histogram / Box Plot / Violin', canvas.width / 2, 25); | |
| // Simple histogram | |
| const bins = [5, 12, 25, 42, 55, 48, 30, 18, 8, 3]; | |
| const barWidth = 50; | |
| const startX = 80; | |
| const maxVal = Math.max(...bins); | |
| bins.forEach((v, i) => { | |
| const x = startX + i * (barWidth + 8); | |
| const height = (v / maxVal) * 200; | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.globalAlpha = 0.8; | |
| ctx.fillRect(x, 300 - height, barWidth, height); | |
| ctx.globalAlpha = 1; | |
| }); | |
| // Box plot representation | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('← Histogram shows full distribution', 350, 340); | |
| } else if (chartPurpose === 'relationship') { | |
| ctx.fillText('Relationship: Scatter Plot / Bubble / Heatmap', canvas.width / 2, 25); | |
| // Random scatter with trend | |
| ctx.strokeStyle = '#e5e7eb'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 10; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(60, 60 + i * 25); | |
| ctx.lineTo(700, 60 + i * 25); | |
| ctx.stroke(); | |
| } | |
| const points = []; | |
| for (let i = 0; i < 50; i++) { | |
| const x = 80 + Math.random() * 580; | |
| const baseY = 300 - (x - 80) * 0.35; | |
| const y = baseY + (Math.random() - 0.5) * 80; | |
| points.push({ x, y, size: 4 + Math.random() * 8 }); | |
| } | |
| points.forEach(p => { | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); | |
| ctx.fillStyle = COLORS.accent; | |
| ctx.globalAlpha = 0.6; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| }); | |
| // Trend line | |
| ctx.strokeStyle = COLORS.danger; | |
| ctx.lineWidth = 2; | |
| ctx.setLineDash([5, 5]); | |
| ctx.beginPath(); | |
| ctx.moveTo(80, 300); | |
| ctx.lineTo(660, 80); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| } | |
| // ==================== MATPLOTLIB ANATOMY ==================== | |
| function drawAnatomy() { | |
| const canvas = $('canvas-anatomy'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw figure border | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 3; | |
| ctx.setLineDash([10, 5]); | |
| ctx.strokeRect(30, 30, canvas.width - 60, canvas.height - 60); | |
| ctx.setLineDash([]); | |
| // Label: Figure | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('Figure (plt.figure())', 40, 25); | |
| // Draw axes area | |
| ctx.fillStyle = '#f8fafc'; | |
| ctx.fillRect(100, 80, 550, 350); | |
| ctx.strokeStyle = COLORS.secondary; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(100, 80, 550, 350); | |
| // Label: Axes | |
| ctx.fillStyle = COLORS.secondary; | |
| ctx.fillText('Axes (ax = fig.add_subplot())', 110, 75); | |
| // Plot area | |
| ctx.fillStyle = 'white'; | |
| ctx.fillRect(160, 100, 450, 280); | |
| ctx.strokeStyle = COLORS.accent; | |
| ctx.strokeRect(160, 100, 450, 280); | |
| // Sample line plot | |
| ctx.beginPath(); | |
| ctx.moveTo(180, 330); | |
| const points = [[220, 280], [280, 200], [340, 240], [400, 150], [460, 180], [520, 120], [580, 140]]; | |
| points.forEach(p => ctx.lineTo(p[0], p[1])); | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 3; | |
| ctx.stroke(); | |
| // X-axis | |
| ctx.strokeStyle = COLORS.dark; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(160, 380); | |
| ctx.lineTo(610, 380); | |
| ctx.stroke(); | |
| // Y-axis | |
| ctx.beginPath(); | |
| ctx.moveTo(160, 100); | |
| ctx.lineTo(160, 380); | |
| ctx.stroke(); | |
| // Tick marks | |
| ctx.font = '10px Inter, sans-serif'; | |
| ctx.fillStyle = COLORS.gray; | |
| for (let i = 0; i <= 5; i++) { | |
| const x = 160 + i * 90; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 380); | |
| ctx.lineTo(x, 390); | |
| ctx.stroke(); | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(i * 20, x, 405); | |
| } | |
| for (let i = 0; i <= 4; i++) { | |
| const y = 100 + i * 70; | |
| ctx.beginPath(); | |
| ctx.moveTo(150, y); | |
| ctx.lineTo(160, y); | |
| ctx.stroke(); | |
| ctx.textAlign = 'right'; | |
| ctx.fillText((4 - i) * 25, 145, y + 4); | |
| } | |
| // Labels with arrows | |
| const annotations = [ | |
| { x: 680, y: 100, label: 'Title', target: [400, 85] }, | |
| { x: 680, y: 160, label: 'Y-axis Label', target: [130, 240] }, | |
| { x: 680, y: 220, label: 'Line (Artist)', target: [400, 180] }, | |
| { x: 680, y: 280, label: 'X-axis', target: [400, 395] }, | |
| { x: 680, y: 340, label: 'Tick & Label', target: [250, 405] } | |
| ]; | |
| ctx.font = '11px Inter, sans-serif'; | |
| annotations.forEach(a => { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(a.label, a.x, a.y); | |
| ctx.strokeStyle = '#94a3b8'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([3, 3]); | |
| ctx.beginPath(); | |
| ctx.moveTo(a.x - 5, a.y - 4); | |
| ctx.lineTo(a.target[0], a.target[1]); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| }); | |
| // Title | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 16px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Sample Line Plot', 385, 60); | |
| // Axis labels | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.fillText('X Axis (Time)', 385, 440); | |
| ctx.save(); | |
| ctx.translate(50, 240); | |
| ctx.rotate(-Math.PI / 2); | |
| ctx.fillText('Y Axis (Value)', 0, 0); | |
| ctx.restore(); | |
| } | |
| // ==================== BASIC PLOTS ==================== | |
| let basicPlotType = 'line'; | |
| function drawBasicPlots() { | |
| const canvas = $('canvas-basic'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const margin = { top: 50, right: 50, bottom: 50, left: 70 }; | |
| const width = canvas.width - margin.left - margin.right; | |
| const height = canvas.height - margin.top - margin.bottom; | |
| // Grid | |
| ctx.strokeStyle = '#e5e7eb'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 5; i++) { | |
| const y = margin.top + (i / 5) * height; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, y); | |
| ctx.lineTo(margin.left + width, y); | |
| ctx.stroke(); | |
| } | |
| if (basicPlotType === 'line') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Line Plot: Time Series Data', canvas.width / 2, 25); | |
| const data = [20, 35, 28, 45, 52, 48, 65, 58, 72, 68, 85, 78]; | |
| const xStep = width / (data.length - 1); | |
| ctx.beginPath(); | |
| data.forEach((v, i) => { | |
| const x = margin.left + i * xStep; | |
| const y = margin.top + height - (v / 100) * height; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 3; | |
| ctx.stroke(); | |
| // Points | |
| data.forEach((v, i) => { | |
| const x = margin.left + i * xStep; | |
| const y = margin.top + height - (v / 100) * height; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = 'white'; | |
| ctx.fill(); | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| }); | |
| } else if (basicPlotType === 'scatter') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Scatter Plot: Two Variables Relationship', canvas.width / 2, 25); | |
| // Generate correlated data | |
| for (let i = 0; i < 60; i++) { | |
| const x = margin.left + Math.random() * width; | |
| const baseY = margin.top + height - ((x - margin.left) / width) * height * 0.7; | |
| const y = baseY + (Math.random() - 0.5) * 100; | |
| ctx.beginPath(); | |
| ctx.arc(x, Math.max(margin.top, Math.min(margin.top + height, y)), 6, 0, Math.PI * 2); | |
| ctx.fillStyle = COLORS.accent; | |
| ctx.globalAlpha = 0.7; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } else if (basicPlotType === 'bar') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Bar Chart: Categorical Comparison', canvas.width / 2, 25); | |
| const data = [75, 52, 88, 45, 92, 67]; | |
| const categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; | |
| const barWidth = width / data.length - 20; | |
| data.forEach((v, i) => { | |
| const x = margin.left + i * (barWidth + 20) + 10; | |
| const barHeight = (v / 100) * height; | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.fillRect(x, margin.top + height - barHeight, barWidth, barHeight); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(categories[i], x + barWidth / 2, margin.top + height + 20); | |
| ctx.fillText(v, x + barWidth / 2, margin.top + height - barHeight - 8); | |
| }); | |
| } else if (basicPlotType === 'hist') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Histogram: Distribution of Values', canvas.width / 2, 25); | |
| // Normal-ish distribution | |
| const bins = [3, 8, 18, 35, 52, 48, 32, 15, 7, 2]; | |
| const barWidth = width / bins.length - 2; | |
| const maxVal = Math.max(...bins); | |
| bins.forEach((v, i) => { | |
| const x = margin.left + i * (barWidth + 2); | |
| const barHeight = (v / maxVal) * height; | |
| ctx.fillStyle = COLORS.secondary; | |
| ctx.globalAlpha = 0.8; | |
| ctx.fillRect(x, margin.top + height - barHeight, barWidth, barHeight); | |
| ctx.globalAlpha = 1; | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x, margin.top + height - barHeight, barWidth, barHeight); | |
| }); | |
| // KDE curve | |
| ctx.beginPath(); | |
| ctx.strokeStyle = COLORS.danger; | |
| ctx.lineWidth = 2; | |
| bins.forEach((v, i) => { | |
| const x = margin.left + i * (barWidth + 2) + barWidth / 2; | |
| const y = margin.top + height - (v / maxVal) * height; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| } else if (basicPlotType === 'pie') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Pie Chart: Part-to-Whole (Use Sparingly!)', canvas.width / 2, 25); | |
| const data = [35, 25, 20, 12, 8]; | |
| const labels = ['Chrome', 'Safari', 'Firefox', 'Edge', 'Others']; | |
| const colors = [COLORS.primary, COLORS.secondary, COLORS.accent, COLORS.success, COLORS.gray]; | |
| const centerX = canvas.width / 2 - 100; | |
| const centerY = canvas.height / 2 + 20; | |
| const radius = 120; | |
| let angle = -Math.PI / 2; | |
| data.forEach((v, i) => { | |
| const sliceAngle = (v / 100) * Math.PI * 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(centerX, centerY); | |
| ctx.arc(centerX, centerY, radius, angle, angle + sliceAngle); | |
| ctx.fillStyle = colors[i]; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Label | |
| const labelAngle = angle + sliceAngle / 2; | |
| const labelX = centerX + Math.cos(labelAngle) * (radius + 30); | |
| const labelY = centerY + Math.sin(labelAngle) * (radius + 30); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(`${labels[i]} (${v}%)`, labelX, labelY); | |
| angle += sliceAngle; | |
| }); | |
| // Warning | |
| ctx.fillStyle = COLORS.warning; | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('⚠️ Bar charts are usually better!', 550, 300); | |
| } | |
| // Y-axis | |
| ctx.strokeStyle = COLORS.dark; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top); | |
| ctx.lineTo(margin.left, margin.top + height); | |
| ctx.lineTo(margin.left + width, margin.top + height); | |
| ctx.stroke(); | |
| } | |
| // ==================== SEABORN RELATIONSHIP PLOTS ==================== | |
| let relPlotType = 'scatter'; | |
| function drawRelationships() { | |
| const canvas = $('canvas-relationships'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const margin = { top: 50, right: 150, bottom: 50, left: 70 }; | |
| const width = canvas.width - margin.left - margin.right; | |
| const height = canvas.height - margin.top - margin.bottom; | |
| // Generate correlated data | |
| const n = 60; | |
| const data = []; | |
| for (let i = 0; i < n; i++) { | |
| const x = margin.left + (i / n) * width; | |
| const group = i % 2 === 0 ? 'A' : 'B'; | |
| const noise = (Math.random() - 0.5) * 50; | |
| const slope = group === 'A' ? 0.5 : 0.8; | |
| const intercept = group === 'A' ? 20 : -30; | |
| const yRaw = (i / n) * 100 * slope + intercept + noise; | |
| // Map to canvas Y | |
| const y = margin.top + height - ((yRaw + 50) / 150) * height; | |
| data.push({ x, y, group }); | |
| } | |
| if (relPlotType === 'scatter') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.scatterplot(..., hue="group")', canvas.width / 2, 25); | |
| data.forEach(d => { | |
| ctx.beginPath(); | |
| ctx.arc(d.x, d.y, 6, 0, Math.PI * 2); | |
| ctx.fillStyle = d.group === 'A' ? COLORS.primary : COLORS.secondary; | |
| ctx.globalAlpha = 0.7; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'white'; | |
| ctx.globalAlpha = 1; | |
| ctx.stroke(); | |
| }); | |
| // Legend | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.fillRect(canvas.width - 120, 60, 15, 15); | |
| ctx.fillStyle = COLORS.secondary; | |
| ctx.fillRect(canvas.width - 120, 85, 15, 15); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('Group A', canvas.width - 95, 72); | |
| ctx.fillText('Group B', canvas.width - 95, 97); | |
| } else if (relPlotType === 'regplot') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.regplot() - Scatter + Regression Line + 95% CI', canvas.width / 2, 25); | |
| // Draw confidence interval band (approximated) | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top + height - 30); | |
| ctx.lineTo(margin.left + width, margin.top + 20); | |
| ctx.lineTo(margin.left + width, margin.top + 80); | |
| ctx.lineTo(margin.left, margin.top + height - (-30)); | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.globalAlpha = 0.2; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| // Draw regression line | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top + height - 10); | |
| ctx.lineTo(margin.left + width, margin.top + 50); | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 3; | |
| ctx.stroke(); | |
| // Draw points | |
| data.forEach(d => { | |
| ctx.beginPath(); | |
| ctx.arc(d.x, d.y, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.globalAlpha = 0.5; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| }); | |
| } else if (relPlotType === 'residplot') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.residplot() - Plotting Regression Residuals', canvas.width / 2, 25); | |
| // Zero line | |
| const midY = margin.top + height / 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, midY); | |
| ctx.lineTo(margin.left + width, midY); | |
| ctx.strokeStyle = COLORS.danger; | |
| ctx.setLineDash([5, 5]); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Residuals | |
| data.forEach(d => { | |
| // Calculate distance from an imaginary regression line | |
| const expectedY = margin.top + height - 10 - ((d.x - margin.left) / width) * (height - 60); | |
| const residual = d.y - expectedY; | |
| ctx.beginPath(); | |
| ctx.arc(d.x, midY + residual, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = COLORS.blue; | |
| ctx.globalAlpha = 0.6; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| // Stem | |
| ctx.beginPath(); | |
| ctx.moveTo(d.x, midY); | |
| ctx.lineTo(d.x, midY + residual); | |
| ctx.strokeStyle = COLORS.blue; | |
| ctx.globalAlpha = 0.3; | |
| ctx.stroke(); | |
| ctx.globalAlpha = 1; | |
| }); | |
| } else if (relPlotType === 'pairplot') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.pairplot() - Pairwise relationships', canvas.width / 2, 25); | |
| const gridSize = 3; | |
| const cellW = width / gridSize; | |
| const cellH = height / gridSize; | |
| ctx.lineWidth = 1; | |
| for (let r = 0; r < gridSize; r++) { | |
| for (let c = 0; c < gridSize; c++) { | |
| const cx = margin.left + c * cellW; | |
| const cy = margin.top + r * cellH; | |
| ctx.strokeStyle = COLORS.gray; | |
| ctx.strokeRect(cx, cy, cellW, cellH); | |
| // Diagonal: KDE | |
| if (r === c) { | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, cy + cellH); | |
| for (let i = 0; i <= cellW; i += 5) { | |
| const h = Math.sin((i / cellW) * Math.PI) * (cellH * 0.8) + (Math.random() * 10 - 5); | |
| ctx.lineTo(cx + i, cy + cellH - Math.max(0, h)); | |
| } | |
| ctx.lineTo(cx + cellW, cy + cellH); | |
| ctx.fillStyle = COLORS.success; | |
| ctx.globalAlpha = 0.3; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| // Off-diagonal: Scatter | |
| else { | |
| for (let i = 0; i < 20; i++) { | |
| ctx.beginPath(); | |
| ctx.arc(cx + 5 + Math.random() * (cellW - 10), cy + 5 + Math.random() * (cellH - 10), 3, 0, Math.PI * 2); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.globalAlpha = 0.4; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Axes | |
| ctx.strokeStyle = COLORS.dark; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top); | |
| ctx.lineTo(margin.left, margin.top + height); | |
| ctx.lineTo(margin.left + width, margin.top + height); | |
| ctx.stroke(); | |
| } | |
| // ==================== SEABORN DISTRIBUTION PLOTS ==================== | |
| let distPlotType = 'histplot'; | |
| function drawDistributions() { | |
| const canvas = $('canvas-distributions'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const margin = { top: 50, right: 50, bottom: 50, left: 70 }; | |
| const width = canvas.width - margin.left - margin.right; | |
| const height = canvas.height - margin.top - margin.bottom; | |
| if (distPlotType === 'histplot') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.histplot(data, kde=True)', canvas.width / 2, 25); | |
| const bins = [2, 5, 12, 22, 38, 45, 42, 35, 20, 10, 5, 2]; | |
| const barWidth = width / bins.length - 4; | |
| const maxVal = Math.max(...bins); | |
| bins.forEach((v, i) => { | |
| const x = margin.left + i * (barWidth + 4); | |
| const barHeight = (v / maxVal) * height * 0.9; | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.globalAlpha = 0.6; | |
| ctx.fillRect(x, margin.top + height - barHeight, barWidth, barHeight); | |
| ctx.globalAlpha = 1; | |
| }); | |
| // KDE overlay | |
| ctx.beginPath(); | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 3; | |
| bins.forEach((v, i) => { | |
| const x = margin.left + i * (barWidth + 4) + barWidth / 2; | |
| const y = margin.top + height - (v / maxVal) * height * 0.9; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else { | |
| const prevX = margin.left + (i - 1) * (barWidth + 4) + barWidth / 2; | |
| const prevY = margin.top + height - (bins[i - 1] / maxVal) * height * 0.9; | |
| const cpX = (prevX + x) / 2; | |
| ctx.quadraticCurveTo(prevX, prevY, cpX, (prevY + y) / 2); | |
| } | |
| }); | |
| ctx.stroke(); | |
| } else if (distPlotType === 'kdeplot') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.kdeplot(data, fill=True, multiple="stack")', canvas.width / 2, 25); | |
| // Draw two overlapping KDEs | |
| const kde1 = [5, 15, 35, 55, 70, 60, 40, 20, 8, 3]; | |
| const kde2 = [3, 8, 20, 40, 60, 75, 55, 35, 15, 5]; | |
| const maxVal = Math.max(...kde1, ...kde2); | |
| const stepWidth = width / (kde1.length - 1); | |
| // Draw KDE 1 | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top + height); | |
| kde1.forEach((v, i) => { | |
| const x = margin.left + i * stepWidth; | |
| const y = margin.top + height - (v / maxVal) * height * 0.8; | |
| ctx.lineTo(x, y); | |
| }); | |
| ctx.lineTo(margin.left + width, margin.top + height); | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.globalAlpha = 0.5; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| // Draw KDE 2 | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top + height); | |
| kde2.forEach((v, i) => { | |
| const x = margin.left + i * stepWidth; | |
| const y = margin.top + height - (v / maxVal) * height * 0.8; | |
| ctx.lineTo(x, y); | |
| }); | |
| ctx.lineTo(margin.left + width, margin.top + height); | |
| ctx.fillStyle = COLORS.secondary; | |
| ctx.globalAlpha = 0.5; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| // Legend | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.fillRect(620, 60, 20, 12); | |
| ctx.fillStyle = COLORS.secondary; | |
| ctx.fillRect(620, 80, 20, 12); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('Group A', 645, 70); | |
| ctx.fillText('Group B', 645, 90); | |
| } else if (distPlotType === 'ecdfplot') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.ecdfplot(data) - Empirical CDF', canvas.width / 2, 25); | |
| // S-curve | |
| ctx.beginPath(); | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 3; | |
| for (let i = 0; i <= 100; i++) { | |
| const x = margin.left + (i / 100) * width; | |
| // Sigmoid-like curve | |
| const t = (i - 50) / 15; | |
| const y = margin.top + height - (1 / (1 + Math.exp(-t))) * height; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.stroke(); | |
| // Labels | |
| ctx.fillStyle = COLORS.gray; | |
| ctx.font = '10px Inter, sans-serif'; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText('1.0', margin.left - 10, margin.top + 5); | |
| ctx.fillText('0.5', margin.left - 10, margin.top + height / 2); | |
| ctx.fillText('0.0', margin.left - 10, margin.top + height); | |
| } else if (distPlotType === 'rugplot') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.rugplot(data) - Shows Individual Data Points', canvas.width / 2, 25); | |
| // KDE | |
| const kde = [5, 15, 35, 55, 70, 75, 60, 40, 20, 8, 3]; | |
| const maxVal = Math.max(...kde); | |
| const stepWidth = width / (kde.length - 1); | |
| ctx.beginPath(); | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.globalAlpha = 0.3; | |
| ctx.moveTo(margin.left, margin.top + height - 40); | |
| kde.forEach((v, i) => { | |
| const x = margin.left + i * stepWidth; | |
| const y = margin.top + height - 40 - (v / maxVal) * (height - 60); | |
| ctx.lineTo(x, y); | |
| }); | |
| ctx.lineTo(margin.left + width, margin.top + height - 40); | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| // Rug plot (vertical lines at bottom) | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i < 80; i++) { | |
| // Cluster around center | |
| const x = margin.left + width / 2 + (Math.random() - 0.5) * width * 0.8 * (1 - Math.abs((Math.random() - 0.5) * 1.5)); | |
| ctx.beginPath(); | |
| ctx.moveTo(x, margin.top + height - 35); | |
| ctx.lineTo(x, margin.top + height); | |
| ctx.stroke(); | |
| } | |
| ctx.fillStyle = COLORS.gray; | |
| ctx.font = '10px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Each line = one data point', canvas.width / 2, margin.top + height + 25); | |
| } | |
| // Axes | |
| ctx.strokeStyle = COLORS.dark; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top); | |
| ctx.lineTo(margin.left, margin.top + height); | |
| ctx.lineTo(margin.left + width, margin.top + height); | |
| ctx.stroke(); | |
| } | |
| // ==================== HEATMAPS ==================== | |
| let heatmapType = 'basic'; | |
| function drawHeatmaps() { | |
| const canvas = $('canvas-heatmaps'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| if (heatmapType === 'basic' || heatmapType === 'corr') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(heatmapType === 'basic' ? 'sns.heatmap(data, annot=True)' : 'Correlation Matrix (mask upper triangle)', canvas.width / 2, 25); | |
| const size = 6; | |
| const cellSize = 60; | |
| const startX = (canvas.width - size * cellSize) / 2; | |
| const startY = 60; | |
| const labels = ['A', 'B', 'C', 'D', 'E', 'F']; | |
| // Generate correlation matrix | |
| const corr = []; | |
| for (let i = 0; i < size; i++) { | |
| corr[i] = []; | |
| for (let j = 0; j < size; j++) { | |
| if (i === j) corr[i][j] = 1; | |
| else if (j > i) { | |
| corr[i][j] = (Math.random() * 2 - 1).toFixed(2); | |
| if (heatmapType === 'corr') corr[i][j] = null; // mask | |
| } else { | |
| corr[i][j] = corr[j] ? corr[j][i] : (Math.random() * 2 - 1).toFixed(2); | |
| } | |
| } | |
| } | |
| for (let i = 0; i < size; i++) { | |
| for (let j = 0; j < size; j++) { | |
| const x = startX + j * cellSize; | |
| const y = startY + i * cellSize; | |
| const val = corr[i][j]; | |
| if (val === null) { | |
| ctx.fillStyle = '#f3f4f6'; | |
| } else { | |
| // Colormap: blue (negative) -> white (0) -> red (positive) | |
| const v = parseFloat(val); | |
| if (v < 0) { | |
| const intensity = Math.abs(v); | |
| ctx.fillStyle = `rgb(${66 + (1 - intensity) * 189}, ${102 + (1 - intensity) * 153}, ${241})`; | |
| } else { | |
| const intensity = v; | |
| ctx.fillStyle = `rgb(${239}, ${68 + (1 - intensity) * 187}, ${68 + (1 - intensity) * 187})`; | |
| } | |
| } | |
| ctx.fillRect(x, y, cellSize - 2, cellSize - 2); | |
| if (val !== null) { | |
| ctx.fillStyle = Math.abs(parseFloat(val)) > 0.5 ? 'white' : COLORS.dark; | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(val, x + cellSize / 2 - 1, y + cellSize / 2 + 4); | |
| } | |
| } | |
| } | |
| // Labels | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '12px Inter, sans-serif'; | |
| labels.forEach((l, i) => { | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(l, startX + i * cellSize + cellSize / 2, startY + size * cellSize + 20); | |
| ctx.textAlign = 'right'; | |
| ctx.fillText(l, startX - 10, startY + i * cellSize + cellSize / 2 + 4); | |
| }); | |
| // Color bar | |
| const barX = startX + size * cellSize + 30; | |
| const barHeight = size * cellSize; | |
| for (let i = 0; i < barHeight; i++) { | |
| const v = 1 - (i / barHeight) * 2; | |
| if (v < 0) { | |
| const intensity = Math.abs(v); | |
| ctx.fillStyle = `rgb(${66 + (1 - intensity) * 189}, ${102 + (1 - intensity) * 153}, ${241})`; | |
| } else { | |
| const intensity = v; | |
| ctx.fillStyle = `rgb(${239}, ${68 + (1 - intensity) * 187}, ${68 + (1 - intensity) * 187})`; | |
| } | |
| ctx.fillRect(barX, startY + i, 20, 1); | |
| } | |
| ctx.font = '10px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.fillText('+1.0', barX + 25, startY + 10); | |
| ctx.fillText('0.0', barX + 25, startY + barHeight / 2); | |
| ctx.fillText('-1.0', barX + 25, startY + barHeight - 5); | |
| } else if (heatmapType === 'clustermap') { | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('sns.clustermap(data) - Hierarchically Clustered', canvas.width / 2, 25); | |
| // Draw dendrogram (simplified tree) | |
| ctx.strokeStyle = COLORS.gray; | |
| ctx.lineWidth = 1; | |
| // Top dendrogram | |
| const dendroY = 60; | |
| ctx.beginPath(); | |
| ctx.moveTo(200, dendroY + 30); | |
| ctx.lineTo(200, dendroY + 15); | |
| ctx.lineTo(300, dendroY + 15); | |
| ctx.lineTo(300, dendroY + 30); | |
| ctx.moveTo(250, dendroY + 15); | |
| ctx.lineTo(250, dendroY); | |
| ctx.lineTo(450, dendroY); | |
| ctx.lineTo(450, dendroY + 30); | |
| ctx.moveTo(400, dendroY + 30); | |
| ctx.lineTo(400, dendroY + 15); | |
| ctx.lineTo(500, dendroY + 15); | |
| ctx.lineTo(500, dendroY + 30); | |
| ctx.stroke(); | |
| // Heatmap body | |
| const startX = 180; | |
| const startY = 100; | |
| const cellSize = 50; | |
| const rows = 5; | |
| const cols = 6; | |
| for (let i = 0; i < rows; i++) { | |
| for (let j = 0; j < cols; j++) { | |
| const x = startX + j * cellSize; | |
| const y = startY + i * cellSize; | |
| // Clustered pattern | |
| let val = Math.random(); | |
| if ((i < 2 && j < 3) || (i >= 2 && j >= 3)) val = val * 0.3 + 0.7; // high values in clusters | |
| else val = val * 0.3; | |
| const hue = 240 - val * 240; | |
| ctx.fillStyle = `hsl(${hue}, 70%, 50%)`; | |
| ctx.fillRect(x, y, cellSize - 2, cellSize - 2); | |
| } | |
| } | |
| // Side dendrogram (simplified) | |
| ctx.beginPath(); | |
| ctx.moveTo(startX - 10, startY + 25); | |
| ctx.lineTo(startX - 25, startY + 25); | |
| ctx.lineTo(startX - 25, startY + 75); | |
| ctx.lineTo(startX - 10, startY + 75); | |
| ctx.moveTo(startX - 25, startY + 50); | |
| ctx.lineTo(startX - 40, startY + 50); | |
| ctx.lineTo(startX - 40, startY + 175); | |
| ctx.lineTo(startX - 10, startY + 175); | |
| ctx.stroke(); | |
| ctx.fillStyle = COLORS.gray; | |
| ctx.font = '10px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Rows and columns reordered by similarity', canvas.width / 2, canvas.height - 30); | |
| } | |
| } | |
| // ==================== ANIMATIONS ==================== | |
| let animationId = null; | |
| let animFrame = 0; | |
| function drawAnimation() { | |
| const canvas = $('canvas-animation'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Gapminder-style Animation: GDP vs Life Expectancy over Time', canvas.width / 2, 25); | |
| const margin = { top: 50, right: 100, bottom: 50, left: 70 }; | |
| const width = canvas.width - margin.left - margin.right; | |
| const height = canvas.height - margin.top - margin.bottom; | |
| // Grid | |
| ctx.strokeStyle = '#e5e7eb'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 5; i++) { | |
| const y = margin.top + (i / 5) * height; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, y); | |
| ctx.lineTo(margin.left + width, y); | |
| ctx.stroke(); | |
| } | |
| // Countries (bubbles) | |
| const countries = [ | |
| { name: 'USA', color: COLORS.primary, baseX: 0.85, baseY: 0.2, size: 35 }, | |
| { name: 'China', color: COLORS.danger, baseX: 0.4, baseY: 0.25, size: 50 }, | |
| { name: 'India', color: COLORS.warning, baseX: 0.2, baseY: 0.4, size: 45 }, | |
| { name: 'Brazil', color: COLORS.success, baseX: 0.35, baseY: 0.35, size: 25 }, | |
| { name: 'Nigeria', color: COLORS.secondary, baseX: 0.15, baseY: 0.55, size: 28 } | |
| ]; | |
| // Animate movement | |
| const progress = (animFrame % 100) / 100; | |
| countries.forEach(country => { | |
| // Countries move up and right over time (improving) | |
| const xOffset = progress * 0.3 * Math.sin(progress * Math.PI); | |
| const yOffset = progress * 0.2; | |
| const x = margin.left + (country.baseX + xOffset) * width; | |
| const y = margin.top + (country.baseY - yOffset) * height; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, country.size * (1 + progress * 0.3), 0, Math.PI * 2); | |
| ctx.fillStyle = country.color; | |
| ctx.globalAlpha = 0.7; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Label | |
| ctx.fillStyle = country.color; | |
| ctx.font = 'bold 10px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(country.name, x, y + 4); | |
| }); | |
| // Year indicator | |
| const year = Math.floor(1960 + progress * 60); | |
| ctx.fillStyle = COLORS.gray; | |
| ctx.font = 'bold 60px Inter, sans-serif'; | |
| ctx.textAlign = 'right'; | |
| ctx.globalAlpha = 0.3; | |
| ctx.fillText(year, canvas.width - 30, canvas.height - 30); | |
| ctx.globalAlpha = 1; | |
| // Axes | |
| ctx.strokeStyle = COLORS.dark; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top); | |
| ctx.lineTo(margin.left, margin.top + height); | |
| ctx.lineTo(margin.left + width, margin.top + height); | |
| ctx.stroke(); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('GDP per Capita ($)', margin.left + width / 2, canvas.height - 10); | |
| ctx.save(); | |
| ctx.translate(20, margin.top + height / 2); | |
| ctx.rotate(-Math.PI / 2); | |
| ctx.fillText('Life Expectancy (years)', 0, 0); | |
| ctx.restore(); | |
| if (animationId) { | |
| animFrame++; | |
| animationId = requestAnimationFrame(drawAnimation); | |
| } | |
| } | |
| function startAnimation() { | |
| if (!animationId) { | |
| animFrame = 0; | |
| animationId = requestAnimationFrame(drawAnimation); | |
| } | |
| } | |
| function stopAnimation() { | |
| if (animationId) { | |
| cancelAnimationFrame(animationId); | |
| animationId = null; | |
| } | |
| } | |
| // ==================== GEOSPATIAL ==================== | |
| let geoType = 'choropleth'; | |
| function drawGeo() { | |
| const canvas = $('canvas-geo'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| if (geoType === 'choropleth') { | |
| ctx.fillText('Choropleth Map: Values mapped to regions', canvas.width / 2, 25); | |
| // Draw grid lines (longitude/latitude) | |
| ctx.strokeStyle = '#e5e7eb'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 8; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(i * 100, 50); | |
| ctx.lineTo(i * 100, 450); | |
| ctx.stroke(); | |
| } | |
| for (let i = 0; i <= 4; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, 50 + i * 100); | |
| ctx.lineTo(800, 50 + i * 100); | |
| ctx.stroke(); | |
| } | |
| // Draw stylized map regions | |
| const regions = [ | |
| { path: [[100, 100], [300, 80], [350, 200], [150, 250]], val: 0.8 }, | |
| { path: [[350, 200], [500, 150], [600, 300], [400, 350]], val: 0.4 }, | |
| { path: [[150, 250], [400, 350], [300, 450], [100, 400]], val: 0.2 }, | |
| { path: [[500, 150], [700, 100], [750, 250], [600, 300]], val: 0.9 }, | |
| { path: [[400, 350], [600, 300], [650, 450], [450, 480]], val: 0.6 } | |
| ]; | |
| regions.forEach(r => { | |
| ctx.beginPath(); | |
| ctx.moveTo(r.path[0][0], r.path[0][1]); | |
| for (let i = 1; i < r.path.length; i++) { | |
| ctx.lineTo(r.path[i][0], r.path[i][1]); | |
| } | |
| ctx.closePath(); | |
| // Color mapping | |
| const hue = 240 - r.val * 240; | |
| ctx.fillStyle = `hsla(${hue}, 70%, 50%, 0.7)`; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| }); | |
| } else if (geoType === 'scatter') { | |
| ctx.fillText('Scatter on Map: Point data over geography', canvas.width / 2, 25); | |
| // Draw basic continent outlines | |
| ctx.strokeStyle = COLORS.gray; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| // Rough Americas | |
| ctx.moveTo(200, 100); ctx.quadraticCurveTo(100, 200, 250, 250); ctx.lineTo(300, 400); ctx.lineTo(350, 380); ctx.lineTo(250, 200); ctx.lineTo(350, 80); ctx.closePath(); | |
| // Rough Afro-Eurasia | |
| ctx.moveTo(400, 150); ctx.quadraticCurveTo(500, 50, 700, 100); ctx.lineTo(750, 250); ctx.lineTo(600, 300); ctx.lineTo(450, 400); ctx.lineTo(380, 250); ctx.closePath(); | |
| ctx.stroke(); | |
| ctx.fillStyle = '#f3f4f6'; | |
| ctx.fill(); | |
| // Scatter points | |
| for (let i = 0; i < 40; i++) { | |
| const x = 150 + Math.random() * 600; | |
| const y = 80 + Math.random() * 350; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 3 + Math.random() * 8, 0, Math.PI * 2); | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.globalAlpha = 0.6; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } else if (geoType === 'heatmap') { | |
| ctx.fillText('Density Heatmap: Clustering over geography', canvas.width / 2, 25); | |
| // Base map | |
| ctx.fillStyle = '#1f2937'; | |
| ctx.fillRect(50, 50, 700, 400); | |
| // Gradient density clusters | |
| const clusters = [ | |
| { x: 250, y: 150, r: 100, intensity: 1 }, | |
| { x: 550, y: 200, r: 150, intensity: 0.8 }, | |
| { x: 300, y: 350, r: 120, intensity: 0.9 }, | |
| { x: 650, y: 350, r: 80, intensity: 0.6 } | |
| ]; | |
| clusters.forEach(c => { | |
| const grad = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, c.r); | |
| grad.addColorStop(0, `rgba(239, 68, 68, ${c.intensity})`); // red | |
| grad.addColorStop(0.3, `rgba(245, 158, 11, ${c.intensity * 0.7})`); // orange | |
| grad.addColorStop(0.6, `rgba(16, 185, 129, ${c.intensity * 0.4})`); // green | |
| grad.addColorStop(1, 'rgba(0, 0, 0, 0)'); | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); | |
| ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| } | |
| } | |
| // ==================== 3D PLOTS ==================== | |
| let plot3DType = 'scatter'; | |
| function draw3D() { | |
| const canvas = $('canvas-3d'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| const cx = canvas.width / 2; | |
| const cy = canvas.height / 2 + 20; | |
| // Draw isometric axes | |
| ctx.strokeStyle = COLORS.gray; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| // Z axis (up) | |
| ctx.moveTo(cx, cy); ctx.lineTo(cx, cy - 150); | |
| // X axis (down-left) | |
| ctx.moveTo(cx, cy); ctx.lineTo(cx - 150, cy + 86); | |
| // Y axis (down-right) | |
| ctx.moveTo(cx, cy); ctx.lineTo(cx + 150, cy + 86); | |
| ctx.stroke(); | |
| ctx.fillStyle = COLORS.gray; | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.fillText('Z', cx, cy - 160); | |
| ctx.fillText('X', cx - 160, cy + 96); | |
| ctx.fillText('Y', cx + 160, cy + 96); | |
| // Helper for isometric projection | |
| const isoMap = (x, y, z) => { | |
| // 30 degree isometric projection | |
| const cos30 = Math.cos(Math.PI / 6); | |
| const sin30 = Math.sin(Math.PI / 6); | |
| return { | |
| px: cx + (y - x) * cos30, | |
| py: cy + (x + y) * sin30 - z | |
| }; | |
| }; | |
| if (plot3DType === 'scatter') { | |
| ctx.fillText('3D Scatter Plot', canvas.width / 2, 25); | |
| for (let i = 0; i < 80; i++) { | |
| // Random 3D coords [0, 100] | |
| const x = Math.random() * 100; | |
| const y = Math.random() * 100; | |
| const z = (x + y) / 2 + (Math.random() - 0.5) * 40; // correlated Z | |
| const pos = isoMap(x, y, z); | |
| ctx.beginPath(); | |
| ctx.arc(pos.px, pos.py, 4, 0, Math.PI * 2); | |
| const hue = (z / 120) * 240; | |
| ctx.fillStyle = `hsl(${240 - hue}, 70%, 50%)`; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| } else if (plot3DType === 'surface' || plot3DType === 'wireframe') { | |
| ctx.fillText(`3D ${plot3DType === 'surface' ? 'Surface' : 'Wireframe'} Plot (sin(R)/R)`, canvas.width / 2, 25); | |
| const resolution = 15; | |
| const step = 120 / resolution; | |
| const grid = []; | |
| for (let x = -60; x <= 60; x += step) { | |
| const row = []; | |
| for (let y = -60; y <= 60; y += step) { | |
| const r = Math.sqrt(x * x + y * y); | |
| const z = r === 0 ? 80 : Math.sin(r * 0.15) / (r * 0.15) * 80; | |
| row.push({ x: x + 60, y: y + 60, z: z + 40 }); | |
| } | |
| grid.push(row); | |
| } | |
| ctx.lineWidth = 1; | |
| // Draw polygons from back to front (rough Painter's Algorithm) | |
| // Since it's symmetric, a simple loop works decently | |
| for (let i = 0; i < grid.length - 1; i++) { | |
| for (let j = 0; j < grid[i].length - 1; j++) { | |
| const p1 = isoMap(grid[i][j].x, grid[i][j].y, grid[i][j].z); | |
| const p2 = isoMap(grid[i + 1][j].x, grid[i + 1][j].y, grid[i + 1][j].z); | |
| const p3 = isoMap(grid[i + 1][j + 1].x, grid[i + 1][j + 1].y, grid[i + 1][j + 1].z); | |
| const p4 = isoMap(grid[i][j + 1].x, grid[i][j + 1].y, grid[i][j + 1].z); | |
| ctx.beginPath(); | |
| ctx.moveTo(p1.px, p1.py); | |
| ctx.lineTo(p2.px, p2.py); | |
| ctx.lineTo(p3.px, p3.py); | |
| ctx.lineTo(p4.px, p4.py); | |
| ctx.closePath(); | |
| const avgZ = (grid[i][j].z + grid[i + 1][j].z + grid[i + 1][j + 1].z + grid[i][j + 1].z) / 4; | |
| const hue = (avgZ / 120) * 240 + 60; // offset hue | |
| if (plot3DType === 'surface') { | |
| ctx.fillStyle = `hsl(${240 - hue}, 70%, 50%)`; | |
| ctx.fill(); | |
| ctx.strokeStyle = `hsl(${240 - hue}, 70%, 30%)`; | |
| } else { | |
| ctx.strokeStyle = COLORS.primary; | |
| } | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| } | |
| // ==================== STORYTELLING ==================== | |
| function drawStorytelling() { | |
| const canvas = $('canvas-storytelling'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Title: Big, bold conclusion | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.font = 'bold 22px Inter, sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('Product B exceeded sales targets by 45% in Q4', 50, 40); | |
| // Subtitle / context | |
| ctx.fillStyle = COLORS.gray; | |
| ctx.font = '14px Inter, sans-serif'; | |
| ctx.fillText('Driven by the new marketing campaign launched in September.', 50, 65); | |
| // Plot Area | |
| const margin = { left: 50, right: 150, top: 120, bottom: 50 }; | |
| const width = canvas.width - margin.left - margin.right; | |
| const height = canvas.height - margin.top - margin.bottom; | |
| // Remove harsh gridlines, only light Y grid | |
| ctx.strokeStyle = '#f3f4f6'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 4; i++) { | |
| const y = margin.top + (i / 4) * height; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, y); | |
| ctx.lineTo(margin.left + width, y); | |
| ctx.stroke(); | |
| } | |
| // Data Series A (Context - Greyed out) | |
| const dataA = [120, 130, 125, 140]; | |
| const dataB = [90, 100, 110, 205]; // Massive spike | |
| const target = 140; | |
| const quarters = ['Q1', 'Q2', 'Q3', 'Q4']; | |
| const xStep = width / 3; | |
| // Draw Target Line | |
| const targetY = margin.top + height - (target / 250) * height; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, targetY); | |
| ctx.lineTo(margin.left + width, targetY); | |
| ctx.strokeStyle = COLORS.warning; | |
| ctx.setLineDash([5, 5]); | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = COLORS.warning; | |
| ctx.fillText('Q4 Target (140)', margin.left + width + 10, targetY + 5); | |
| // Draw Line A (Less important) | |
| ctx.beginPath(); | |
| dataA.forEach((v, i) => { | |
| const x = margin.left + i * xStep; | |
| const y = margin.top + height - (v / 250) * height; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.strokeStyle = '#cbd5e1'; // light slate | |
| ctx.lineWidth = 3; | |
| ctx.stroke(); | |
| ctx.fillStyle = '#94a3b8'; | |
| ctx.fillText('Product A', margin.left + width + 10, margin.top + height - (dataA[3] / 250) * height + 5); | |
| // Draw Line B (The Hero) | |
| ctx.beginPath(); | |
| dataB.forEach((v, i) => { | |
| const x = margin.left + i * xStep; | |
| const y = margin.top + height - (v / 250) * height; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.strokeStyle = COLORS.primary; | |
| ctx.lineWidth = 5; | |
| ctx.stroke(); | |
| // Highlight points for B | |
| dataB.forEach((v, i) => { | |
| const x = margin.left + i * xStep; | |
| const y = margin.top + height - (v / 250) * height; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 6, 0, Math.PI * 2); | |
| ctx.fillStyle = i === 3 ? COLORS.success : COLORS.primary; // Make final point stand out more | |
| ctx.fill(); | |
| // Data labels directly on line | |
| ctx.fillStyle = i === 3 ? COLORS.success : COLORS.dark; | |
| ctx.font = i === 3 ? 'bold 16px Inter, sans-serif' : '12px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(v, x, y - 15); | |
| }); | |
| // Label Hero line | |
| ctx.fillStyle = COLORS.primary; | |
| ctx.textAlign = 'left'; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.fillText('Product B', margin.left + width + 10, margin.top + height - (dataB[3] / 250) * height + 5); | |
| // X Axis | |
| ctx.strokeStyle = COLORS.dark; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(margin.left, margin.top + height); | |
| ctx.lineTo(margin.left + width, margin.top + height); | |
| ctx.stroke(); | |
| ctx.fillStyle = COLORS.dark; | |
| ctx.textAlign = 'center'; | |
| quarters.forEach((q, i) => { | |
| ctx.fillText(q, margin.left + i * xStep, margin.top + height + 20); | |
| }); | |
| // Storytelling annotation | |
| ctx.beginPath(); | |
| const finalX = margin.left + 3 * xStep; | |
| const finalY = margin.top + height - (dataB[3] / 250) * height; | |
| ctx.moveTo(finalX - 60, finalY + 40); | |
| ctx.lineTo(finalX - 10, finalY + 10); | |
| ctx.strokeStyle = COLORS.success; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.fillStyle = COLORS.success; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText('+45% over Target!', finalX - 65, finalY + 45); | |
| } | |
| // ==================== SUBPLOTS & LAYOUTS ==================== | |
| let subplotType = '2x2'; | |
| function drawSubplots() { | |
| const canvas = $('canvas-subplots'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = COLORS.text; | |
| ctx.font = '14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| if (subplotType === '2x2') { | |
| ctx.fillText('2x2 Grid Layout', canvas.width / 2, 30); | |
| drawRect(ctx, 50, 60, 300, 180, COLORS.primary, false); | |
| drawRect(ctx, 400, 60, 300, 180, COLORS.secondary, false); | |
| drawRect(ctx, 50, 270, 300, 180, COLORS.accent, false); | |
| drawRect(ctx, 400, 270, 300, 180, COLORS.success, false); | |
| ctx.fillText('ax1', 200, 150); ctx.fillText('ax2', 550, 150); | |
| ctx.fillText('ax3', 200, 360); ctx.fillText('ax4', 550, 360); | |
| } else if (subplotType === 'uneven') { | |
| ctx.fillText('Uneven Grid (1 top, 2 bottom)', canvas.width / 2, 30); | |
| drawRect(ctx, 50, 60, 650, 180, COLORS.primary, false); | |
| drawRect(ctx, 50, 270, 300, 180, COLORS.secondary, false); | |
| drawRect(ctx, 400, 270, 300, 180, COLORS.accent, false); | |
| ctx.fillText('ax1 (colspan=2)', 375, 150); | |
| ctx.fillText('ax2', 200, 360); ctx.fillText('ax3', 550, 360); | |
| } else if (subplotType === 'gridspec') { | |
| ctx.fillText('GridSpec Layout (Complex sizing)', canvas.width / 2, 30); | |
| drawRect(ctx, 50, 60, 450, 390, COLORS.primary, false); | |
| drawRect(ctx, 530, 60, 200, 180, COLORS.secondary, false); | |
| drawRect(ctx, 530, 270, 200, 180, COLORS.accent, false); | |
| ctx.fillText('Main Plot (3x3 grid, spans 2x3)', 275, 255); | |
| ctx.fillText('Side Plot 1', 630, 150); | |
| ctx.fillText('Side Plot 2', 630, 360); | |
| } | |
| } | |
| // ==================== STYLING & THEMES ==================== | |
| let styleType = 'default'; | |
| function drawStyling() { | |
| const canvas = $('canvas-styling'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| // Background based on style | |
| let bg = '#ffffff'; | |
| let fg = '#333333'; | |
| let lineColors = ['#1f77b4', '#ff7f0e', '#2ca02c']; | |
| if (styleType === 'dark') { bg = '#121212'; fg = '#eeeeee'; lineColors = ['#00d4ff', '#ff5555', '#55ff55']; } | |
| else if (styleType === 'seaborn') { bg = '#eaeaf2'; fg = '#222222'; lineColors = ['#4c72b0', '#55a868', '#c44e52']; } | |
| else if (styleType === 'ggplot') { bg = '#e5e5e5'; fg = '#555555'; lineColors = ['#f8766d', '#00ba38', '#619cff']; } | |
| else if (styleType === '538') { bg = '#f0f0f0'; fg = '#333333'; lineColors = ['#008fd5', '#fc4f30', '#e5ae38']; } | |
| ctx.fillStyle = bg; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = fg; | |
| ctx.font = styleType === '538' ? 'bold 16px Arial' : '14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(`${styleType.toUpperCase()} Theme Preview`, canvas.width / 2, 30); | |
| // Draw generic line chart | |
| const startX = 100, endX = 700, baseY = 300; | |
| // Axes | |
| ctx.strokeStyle = (styleType === 'seaborn' || styleType === 'ggplot') ? '#ffffff' : fg; | |
| ctx.lineWidth = (styleType === 'seaborn' || styleType === 'ggplot') ? 2 : 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(startX, 50); ctx.lineTo(startX, baseY); ctx.lineTo(endX, baseY); | |
| ctx.stroke(); | |
| // Grid | |
| ctx.strokeStyle = (styleType === 'dark') ? '#333' : '#ddd'; | |
| for (let i = 1; i <= 5; i++) { | |
| let y = baseY - (i * 50); | |
| ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(endX, y); ctx.stroke(); | |
| } | |
| // Lines | |
| for (let l = 0; l < 3; l++) { | |
| ctx.strokeStyle = lineColors[l]; | |
| ctx.lineWidth = styleType === '538' ? 4 : 2; | |
| ctx.beginPath(); | |
| for (let i = 0; i <= 10; i++) { | |
| let x = startX + (i * 60); | |
| let y = baseY - 50 - (Math.sin(i * 0.5 + l) * 50) - (l * 40); | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.stroke(); | |
| } | |
| } | |
| // ==================== SEABORN INTRO ==================== | |
| function drawSeabornIntro() { | |
| const canvas = $('canvas-seaborn-intro'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = COLORS.text; | |
| ctx.font = '14px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Matplotlib vs Seaborn Comparison', canvas.width / 2, 30); | |
| // Fake Matplotlib code | |
| drawRect(ctx, 50, 60, 300, 200, '#1e293b', true); | |
| ctx.fillStyle = '#fff'; ctx.font = '12px Courier'; ctx.textAlign = 'left'; | |
| ctx.fillText('import matplotlib.pyplot as plt', 60, 80); | |
| ctx.fillText('fig, ax = plt.subplots()', 60, 100); | |
| ctx.fillText('ax.scatter(df[\'target\'], df[\'value\'])', 60, 120); | |
| ctx.fillText('ax.set_title(\'Complex to Style\')', 60, 140); | |
| // Fake Seaborn | |
| drawRect(ctx, 400, 60, 300, 200, '#1e293b', true); | |
| ctx.fillStyle = '#fff'; ctx.fillText('import seaborn as sns', 410, 80); | |
| ctx.fillText('sns.scatterplot(', 410, 100); | |
| ctx.fillText(' data=df, x=\'target\', y=\'value\',', 410, 120); | |
| ctx.fillText(' hue=\'category\', style=\'type\')', 410, 140); | |
| ctx.fillStyle = COLORS.accent; ctx.fillText('// 1 line creates a styled mapped plot!', 410, 180); | |
| } | |
| // ==================== CATEGORICAL PLOTS ==================== | |
| let catPlotType = 'boxplot'; | |
| function drawCategorical() { | |
| const canvas = $('canvas-categorical'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const categories = ['Group A', 'Group B', 'Group C']; | |
| const colors = [COLORS.primary, COLORS.accent, COLORS.warning]; | |
| ctx.fillStyle = COLORS.text; | |
| ctx.textAlign = 'center'; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.fillText(catPlotType.toUpperCase() + ' PREVIEW', canvas.width / 2, 30); | |
| const startX = 150; | |
| const spacing = 200; | |
| const baseY = 320; | |
| // Axis | |
| ctx.strokeStyle = COLORS.textSecondary; | |
| ctx.beginPath(); ctx.moveTo(50, baseY); ctx.lineTo(750, baseY); ctx.stroke(); | |
| for (let c = 0; c < 3; c++) { | |
| let cx = startX + c * spacing; | |
| ctx.fillStyle = COLORS.text; | |
| ctx.fillText(categories[c], cx, baseY + 20); | |
| ctx.fillStyle = colors[c]; | |
| ctx.strokeStyle = colors[c]; | |
| if (catPlotType === 'boxplot') { | |
| // Box | |
| ctx.fillRect(cx - 30, baseY - 150 + c * 10, 60, 80); | |
| // Whiskers | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, baseY - 150 + c * 10); ctx.lineTo(cx, baseY - 200 + c * 20); | |
| ctx.moveTo(cx, baseY - 70 + c * 10); ctx.lineTo(cx, baseY - 30 - c * 10); | |
| // Caps | |
| ctx.moveTo(cx - 15, baseY - 200 + c * 20); ctx.lineTo(cx + 15, baseY - 200 + c * 20); | |
| ctx.moveTo(cx - 15, baseY - 30 - c * 10); ctx.lineTo(cx + 15, baseY - 30 - c * 10); | |
| ctx.stroke(); | |
| // Median (black line) | |
| ctx.strokeStyle = '#000'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(cx - 30, baseY - 110 + c * 10); ctx.lineTo(cx + 30, baseY - 110 + c * 10); ctx.stroke(); | |
| } else if (catPlotType === 'violinplot') { | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, baseY - 50); | |
| ctx.bezierCurveTo(cx - 50, baseY - 100, cx - 70, baseY - 180, cx, baseY - 230); | |
| ctx.bezierCurveTo(cx + 70, baseY - 180, cx + 50, baseY - 100, cx, baseY - 50); | |
| ctx.globalAlpha = 0.5; ctx.fill(); ctx.globalAlpha = 1; | |
| ctx.stroke(); | |
| } else if (catPlotType === 'barplot') { | |
| let h = 150 + c * 30; | |
| ctx.fillRect(cx - 40, baseY - h, 80, h); | |
| // Error bar | |
| ctx.strokeStyle = '#000'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(cx, baseY - h - 20); ctx.lineTo(cx, baseY - h + 20); | |
| ctx.moveTo(cx - 10, baseY - h - 20); ctx.lineTo(cx + 10, baseY - h - 20); | |
| ctx.moveTo(cx - 10, baseY - h + 20); ctx.lineTo(cx + 10, baseY - h + 20); | |
| ctx.stroke(); | |
| } else if (catPlotType === 'stripplot' || catPlotType === 'swarmplot') { | |
| ctx.globalAlpha = 0.6; | |
| for (let i = 0; i < 40; i++) { | |
| let y = baseY - 50 - Math.random() * 150; | |
| let x = (catPlotType === 'swarmplot') ? cx + (Math.sin(y) * 30 * Math.random()) : cx - 30 + Math.random() * 60; | |
| ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| } | |
| // ==================== PLOTLY & DASHBOARDS ==================== | |
| function drawPlotly() { | |
| const canvas = $('canvas-plotly'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = '#1e293b'; | |
| ctx.fillRect(40, 40, canvas.width - 80, canvas.height - 80); | |
| ctx.fillStyle = COLORS.cyan || '#06b6d4'; | |
| ctx.font = '16px Inter'; ctx.textAlign = 'center'; | |
| ctx.fillText('Plotly / Interactive Charts Visualization', canvas.width / 2, 80); | |
| ctx.font = '12px Inter'; | |
| ctx.fillText('Hover tooltips, zooming, and panning enabled out of the box.', canvas.width / 2, 100); | |
| // Draw a fake cursor and tooltip | |
| ctx.beginPath(); ctx.arc(400, 200, 8, 0, Math.PI * 2); ctx.fillStyle = COLORS.orange; ctx.fill(); | |
| ctx.fillStyle = '#fff'; ctx.fillRect(415, 175, 100, 50); | |
| ctx.fillStyle = '#000'; ctx.fillText('X: 2024-01-01', 465, 195); | |
| ctx.fillText('Y: $450.2K', 465, 215); | |
| } | |
| function drawDashboard() { | |
| const canvas = $('canvas-dashboard'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = COLORS.text; | |
| ctx.font = '16px Inter'; ctx.textAlign = 'center'; | |
| ctx.fillText('Streamlit Dashboard Architecture', canvas.width / 2, 30); | |
| drawRect(ctx, 100, 60, 200, 250, '#1e293b', true); | |
| ctx.fillStyle = '#fff'; ctx.fillText('Sidebar / Inputs', 200, 90); | |
| drawRect(ctx, 120, 120, 160, 30, '#334155', true); | |
| drawRect(ctx, 120, 170, 160, 30, '#334155', true); | |
| drawRect(ctx, 350, 60, 400, 250, '#0f172a', true); | |
| ctx.fillStyle = COLORS.cyan || '#06b6d4'; ctx.fillText('Main Chart Area', 550, 90); | |
| ctx.beginPath(); ctx.arc(550, 180, 50, 0, Math.PI * 2); ctx.strokeStyle = COLORS.accent; ctx.lineWidth = 15; ctx.stroke(); | |
| } | |
| function drawRect(ctx, x, y, width, height, color, filled = true) { | |
| if (filled) { | |
| ctx.fillStyle = color; | |
| ctx.fillRect(x, y, width, height); | |
| } else { | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(x, y, width, height); | |
| } | |
| } | |
| // ==================== EVENT LISTENERS ==================== | |
| function bindEvents() { | |
| // Perception buttons | |
| $('btn-position')?.addEventListener('click', () => { perceptionMode = 'position'; drawPerception(); }); | |
| $('btn-color')?.addEventListener('click', () => { perceptionMode = 'color'; drawPerception(); }); | |
| $('btn-size')?.addEventListener('click', () => { perceptionMode = 'size'; drawPerception(); }); | |
| // Subplots | |
| $('btn-grid-2x2')?.addEventListener('click', () => { subplotType = '2x2'; drawSubplots(); }); | |
| $('btn-grid-uneven')?.addEventListener('click', () => { subplotType = 'uneven'; drawSubplots(); }); | |
| $('btn-gridspec')?.addEventListener('click', () => { subplotType = 'gridspec'; drawSubplots(); }); | |
| // Styling | |
| $('btn-style-default')?.addEventListener('click', () => { styleType = 'default'; drawStyling(); }); | |
| $('btn-style-seaborn')?.addEventListener('click', () => { styleType = 'seaborn'; drawStyling(); }); | |
| $('btn-style-ggplot')?.addEventListener('click', () => { styleType = 'ggplot'; drawStyling(); }); | |
| $('btn-style-dark')?.addEventListener('click', () => { styleType = 'dark'; drawStyling(); }); | |
| $('btn-style-538')?.addEventListener('click', () => { styleType = '538'; drawStyling(); }); | |
| // Categorical | |
| $('btn-stripplot')?.addEventListener('click', () => { catPlotType = 'stripplot'; drawCategorical(); }); | |
| $('btn-swarmplot')?.addEventListener('click', () => { catPlotType = 'swarmplot'; drawCategorical(); }); | |
| $('btn-boxplot')?.addEventListener('click', () => { catPlotType = 'boxplot'; drawCategorical(); }); | |
| $('btn-violinplot')?.addEventListener('click', () => { catPlotType = 'violinplot'; drawCategorical(); }); | |
| $('btn-barplot')?.addEventListener('click', () => { catPlotType = 'barplot'; drawCategorical(); }); | |
| // Chart choosing buttons | |
| $('btn-comparison')?.addEventListener('click', () => { chartPurpose = 'comparison'; drawChoosingCharts(); }); | |
| $('btn-composition')?.addEventListener('click', () => { chartPurpose = 'composition'; drawChoosingCharts(); }); | |
| $('btn-distribution')?.addEventListener('click', () => { chartPurpose = 'distribution'; drawChoosingCharts(); }); | |
| $('btn-relationship')?.addEventListener('click', () => { chartPurpose = 'relationship'; drawChoosingCharts(); }); | |
| // Basic plot buttons | |
| $('btn-line')?.addEventListener('click', () => { basicPlotType = 'line'; drawBasicPlots(); }); | |
| $('btn-scatter')?.addEventListener('click', () => { basicPlotType = 'scatter'; drawBasicPlots(); }); | |
| $('btn-bar')?.addEventListener('click', () => { basicPlotType = 'bar'; drawBasicPlots(); }); | |
| $('btn-hist')?.addEventListener('click', () => { basicPlotType = 'hist'; drawBasicPlots(); }); | |
| $('btn-pie')?.addEventListener('click', () => { basicPlotType = 'pie'; drawBasicPlots(); }); | |
| // Distribution buttons | |
| $('btn-histplot')?.addEventListener('click', () => { distPlotType = 'histplot'; drawDistributions(); }); | |
| $('btn-kdeplot')?.addEventListener('click', () => { distPlotType = 'kdeplot'; drawDistributions(); }); | |
| $('btn-ecdfplot')?.addEventListener('click', () => { distPlotType = 'ecdfplot'; drawDistributions(); }); | |
| $('btn-rugplot')?.addEventListener('click', () => { distPlotType = 'rugplot'; drawDistributions(); }); | |
| // Relationship buttons | |
| $('btn-scatter-hue')?.addEventListener('click', () => { relPlotType = 'scatter'; drawRelationships(); }); | |
| $('btn-regplot')?.addEventListener('click', () => { relPlotType = 'regplot'; drawRelationships(); }); | |
| $('btn-residplot')?.addEventListener('click', () => { relPlotType = 'residplot'; drawRelationships(); }); | |
| $('btn-pairplot')?.addEventListener('click', () => { relPlotType = 'pairplot'; drawRelationships(); }); | |
| // Heatmap buttons | |
| $('btn-heatmap-basic')?.addEventListener('click', () => { heatmapType = 'basic'; drawHeatmaps(); }); | |
| $('btn-corr-matrix')?.addEventListener('click', () => { heatmapType = 'corr'; drawHeatmaps(); }); | |
| $('btn-clustermap')?.addEventListener('click', () => { heatmapType = 'clustermap'; drawHeatmaps(); }); | |
| // Animation | |
| $('btn-animate')?.addEventListener('click', startAnimation); | |
| $('btn-stop')?.addEventListener('click', stopAnimation); | |
| // Geospatial | |
| $('btn-choropleth')?.addEventListener('click', () => { geoType = 'choropleth'; drawGeo(); }); | |
| $('btn-scatter-geo')?.addEventListener('click', () => { geoType = 'scatter'; drawGeo(); }); | |
| $('btn-heatmap-geo')?.addEventListener('click', () => { geoType = 'heatmap'; drawGeo(); }); | |
| // 3D Plots | |
| $('btn-3d-scatter')?.addEventListener('click', () => { plot3DType = 'scatter'; draw3D(); }); | |
| $('btn-3d-surface')?.addEventListener('click', () => { plot3DType = 'surface'; draw3D(); }); | |
| $('btn-3d-wireframe')?.addEventListener('click', () => { plot3DType = 'wireframe'; draw3D(); }); | |
| // Smooth scroll for nav links | |
| $$('.nav__link').forEach(link => { | |
| link.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| const targetId = link.getAttribute('href').slice(1); | |
| const target = document.getElementById(targetId); | |
| if (target) { | |
| target.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| // Update active state | |
| $$('.nav__link').forEach(l => l.classList.remove('active')); | |
| link.classList.add('active'); | |
| }); | |
| }); | |
| } | |
| // ==================== INITIALIZATION ==================== | |
| function init() { | |
| bindEvents(); | |
| // Draw all initial visualizations | |
| drawSubplots(); | |
| drawStyling(); | |
| drawSeabornIntro(); | |
| drawCategorical(); | |
| drawPlotly(); | |
| drawDashboard(); | |
| drawAnscombe(); | |
| perceptionMode = 'position'; | |
| drawPerception(); | |
| drawGrammar(); | |
| chartPurpose = 'comparison'; | |
| drawChoosingCharts(); | |
| drawAnatomy(); | |
| basicPlotType = 'line'; | |
| drawBasicPlots(); | |
| distPlotType = 'histplot'; | |
| drawDistributions(); | |
| relPlotType = 'scatter'; | |
| drawRelationships(); | |
| heatmapType = 'basic'; | |
| drawHeatmaps(); | |
| drawAnimation(); | |
| // Draw Advanced Topics initial state | |
| geoType = 'choropleth'; | |
| drawGeo(); | |
| plot3DType = 'scatter'; | |
| draw3D(); | |
| drawStorytelling(); | |
| } | |
| // Run on DOM ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| })(); | |