kjuulh
aee6ba54c6
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
304 lines
11 KiB
HTML
304 lines
11 KiB
HTML
<!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 intervalTime = 500
|
|
|
|
let categoryQueue = [];
|
|
let intervalNow = Math.floor(Date.now() / 1000) - 1;
|
|
setInterval(() => {
|
|
const unixTimestampNow = Math.floor(Date.now() / 1000);
|
|
let resp = fetch("https://iamvisual.prod.kjuulh.app/metrics?start=" + intervalNow + "&end=" + unixTimestampNow)
|
|
.then((resp) => {
|
|
if (resp.ok) {
|
|
return resp.json()
|
|
} else {
|
|
throw new Error("failed to get response")
|
|
}
|
|
}).then((json) => {
|
|
categoryQueue = [
|
|
...categoryQueue,
|
|
json
|
|
]
|
|
console.log("received category")
|
|
});
|
|
intervalNow = unixTimestampNow;
|
|
}, intervalTime);
|
|
|
|
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
|
|
let maxIntensity = 1;
|
|
|
|
setInterval(() => {
|
|
let newMax = 1;
|
|
allData.map(c => {
|
|
if (c.amount > newMax) {
|
|
if (newMax < maxIntensity) {
|
|
maxIntensity = newMax;
|
|
}
|
|
}
|
|
})
|
|
}, 10000)
|
|
|
|
function generateData() {
|
|
const item = categoryQueue.pop();
|
|
if (item == undefined) {
|
|
return
|
|
}
|
|
|
|
const newData = item.metrics.map((c, i) => {
|
|
if (c.amount > maxIntensity) {
|
|
maxIntensity = c.amount;
|
|
}
|
|
if (c.amount == 0) {
|
|
return null
|
|
}
|
|
const newIntensity = c.amount / maxIntensity
|
|
console.log(maxIntensity);
|
|
const smoothIntensity = d3.interpolate(lastIntensity.get(i), newIntensity)(0.5); // Smooth transition
|
|
lastIntensity.set(c.event_name, smoothIntensity); // Update last intensity
|
|
return {
|
|
id: `${Date.now()}-${c.event_name}`,
|
|
category: i,
|
|
timestamp: Date.now(),
|
|
intensity: smoothIntensity,
|
|
color: categories[i].color,
|
|
amount: c.amount,
|
|
}
|
|
}).filter(Boolean);
|
|
|
|
|
|
|
|
allData = [...allData, ...newData].filter(d => new Date(d.timestamp) >= new Date(Date.now() - 600000))
|
|
return
|
|
|
|
// 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);
|
|
}, 150);
|
|
</script>
|
|
</body>
|
|
</html>
|