<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Space-Themed Heatmap</title> <script src="https://d3js.org/d3.v7.min.js"></script> <style> html, body { background-color: #0B0C10; color: #E5E5E5; font-family: Arial, sans-serif; } svg { display: block; margin: auto; } #title { padding: 1rem 2rem; } .divider { height: 1px; width: 80%; background-color: #E5E5E5; } .legend { font-size: 12px; display: flex; justify-content: center; flex-wrap: wrap; } .legend-item { display: flex; align-items: center; margin: 5px; } .legend-color { width: 15px; height: 15px; margin-right: 5px; } </style> </head> <body> <h2 id="title">Live Activity</h2> <div id="chart"></div> <div id="legend" class="legend"></div> <script> const horizontal = true; const categoryAmount = 10; const parentWidth = document.querySelector('#chart').parentElement.offsetWidth; // Dimensions const margin = { top: 20, right: 20, bottom: 20, left: 50 }; //const width = 1200 - margin.left - margin.right; const width = parentWidth - margin.left - margin.right; const blockLength = width / (60 * 2); // 60 seconds * 2 poll rate pr second const height = 75 * categoryAmount / 2 - margin.top - margin.bottom; // Categories with labels const categories = [ { id: 0, label: "User Onboarded", color: "#D90429" }, // NASA Red { id: 1, label: "Payment Accepted", color: "#0E79B2" }, // Space Blue { id: 2, label: "Payment Failed", color: "#F46036" }, // Fuel Orange { id: 3, label: "Product Added to Cart", color: "#F2C94C" }, // Solar Gold { id: 4, label: "Checkout Started", color: "#9CA3AF" }, // Neutral Gray { id: 5, label: "Order Placed", color: "#8B4513" }, { id: 6, label: "Order Cancelled", color: "#FF69B4" }, { id: 7, label: "Refund Processed", color: "#A9A9A9" }, { id: 8, label: "Subscription Renewed", color: "#BDB76B" }, { id: 9, label: "Feedback Submitted", color: "#00FFFF" }, ].filter((c) => c.id < categoryAmount); // Store last intensity for each category const lastIntensity = new Map(categories.map(c => [c.id, Math.random()])); // Create Legend const legend = d3.select("#legend"); categories.forEach(category => { const item = legend.append("div").attr("class", "legend-item"); item.append("div") .attr("class", "legend-color") .style("background-color", category.color); item.append("span").text(category.label); }); // Create SVG const svg = d3.select("#chart") .append("svg") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom) .append("g") .attr("transform", `translate(${margin.left},${margin.top})`); const defs = svg.append("defs"); defs.append("filter") .attr("id", "blend-multiply") .append("feBlend") .attr("mode", "multiply") .attr("in", "SourceGraphic") .attr("in2", "BackgroundImage"); // Time scales var x, y; if (horizontal) { x = d3.scaleTime() .domain([new Date(Date.now() - 60000), new Date()]) // Past 1 min to now .range([width, 0]); y = d3.scaleBand() .domain(categories.map(c => c.id)) // Category IDs .range([0, height]); } else { y = d3.scaleTime() .domain([new Date(Date.now() - 60000), new Date()]) // Past 1 min to now .range([height, 0]); x = d3.scaleBand() .domain(categories.map(c => c.id)) // Category IDs .range([0, width]); } // Heatmap group const grid = svg.append("g"); // Initialize heatmap function updateHeatmap(data) { const cells = grid.selectAll("rect").data(data, d => d.id); if (horizontal) { cells.enter() .append("rect") .attr("y", d => y(d.category)) //.attr("width", 12) .attr("width", blockLength) .attr("x", d => x(new Date(d.timestamp))) .attr("height", 25) // Minimized spacing between blocks .attr("fill", d => categories[d.category].color) .attr("opacity", d => d.intensity) //.attr("filter", "url(#blend-multiply)") // Apply blend mode .merge(cells) // Merge updates .attr("x", d => x(new Date(d.timestamp))) .attr("opacity", d => d.intensity) .attr("visibility", d => x(new Date(d.timestamp)) >= 0 && x(new Date(d.timestamp)) <= width ? "visible" : "hidden" ); } else { // Enter new data cells.enter() .append("rect") .attr("x", d => x(d.category)) .attr("width", x.bandwidth()) .attr("y", d => y(new Date(d.timestamp))) .attr("height", 4) // Minimized spacing between blocks .attr("fill", d => categories[d.category].color) .attr("opacity", d => d.intensity) .merge(cells) // Merge updates .attr("y", d => y(new Date(d.timestamp))) .attr("opacity", d => d.intensity) .attr("visibility", d => y(new Date(d.timestamp)) >= 0 && y(new Date(d.timestamp)) <= height ? "visible" : "hidden" ); } // Remove old data that moves off the screen cells.exit().remove(); } // Real-time simulation let allData = []; const scrollingSpeed = 20; // Pixels per second function generateData() { // Simulate sporadic events with intensity const newData = categories.map(c => { if (Math.random() < 0.7) return null; // 70% chance no data for this category const newIntensity = Math.random(); // Random intensity const smoothIntensity = d3.interpolate(lastIntensity.get(c.id), newIntensity)(0.5); // Smooth transition lastIntensity.set(c.id, smoothIntensity); // Update last intensity return { id: `${Date.now()}-${c.id}`, category: c.id, timestamp: Date.now(), intensity: smoothIntensity, }; }).filter(Boolean); // Remove null values // Append new data and remove older ones beyond the last 60 seconds allData = [...allData, ...newData].filter(d => new Date(d.timestamp) >= new Date(Date.now() - 60000) ); } // Continuous scroll let lastTimestamp = Date.now(); d3.timer(elapsed => { const now = Date.now(); const delta = now - lastTimestamp; // Generate data periodically (every 500ms for more activity) if (delta >= 500) { generateData(); lastTimestamp = now; } // Update the domain of the y-axis const currentTime = new Date(); const pastTime = new Date(currentTime.getTime() - 60000); if (horizontal) { x.domain([pastTime, currentTime]); } else { y.domain([pastTime, currentTime]); } // Update positions of all heatmap cells updateHeatmap(allData); }); </script> </body> </html>