Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
1
crates/iamalive/.gitignore
vendored
Normal file
1
crates/iamalive/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
17
crates/iamalive/Cargo.toml
Normal file
17
crates/iamalive/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "iamvisual"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
dotenv.workspace = true
|
||||
axum.workspace = true
|
||||
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
uuid = { version = "1.7.0", features = ["v4"] }
|
||||
tower-http = { version = "0.5.2", features = ["cors", "trace"] }
|
238
crates/iamalive/assets/html/index.html
Normal file
238
crates/iamalive/assets/html/index.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<!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>
|
||||
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>
|
92
crates/iamalive/src/main.rs
Normal file
92
crates/iamalive/src/main.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use std::{net::SocketAddr, ops::Deref, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::http::Request;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use axum::{extract::MatchedPath, response::Html};
|
||||
use clap::{Parser, Subcommand};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None, subcommand_required = true)]
|
||||
struct Command {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
Serve {
|
||||
#[arg(env = "SERVICE_HOST", long, default_value = "127.0.0.1:3000")]
|
||||
host: SocketAddr,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let cli = Command::parse();
|
||||
|
||||
if let Some(Commands::Serve { host }) = cli.command {
|
||||
tracing::info!("Starting service");
|
||||
|
||||
let state = SharedState(Arc::new(State::new().await?));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.with_state(state.clone())
|
||||
.layer(
|
||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||
// Log the matched route's path (with placeholders not filled in).
|
||||
// Use request.uri() or OriginalUri if you want the real path.
|
||||
let matched_path = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(MatchedPath::as_str);
|
||||
|
||||
tracing::info_span!(
|
||||
"http_request",
|
||||
method = ?request.method(),
|
||||
matched_path,
|
||||
some_other_field = tracing::field::Empty,
|
||||
)
|
||||
}), // ...
|
||||
);
|
||||
|
||||
tracing::info!("listening on {}", host);
|
||||
let listener = tokio::net::TcpListener::bind(host).await.unwrap();
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const INDEX: &str = include_str!("../assets/html/index.html");
|
||||
|
||||
async fn root() -> Html<String> {
|
||||
Html(INDEX.to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SharedState(Arc<State>);
|
||||
|
||||
impl Deref for SharedState {
|
||||
type Target = Arc<State>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {}
|
||||
|
||||
impl State {
|
||||
pub async fn new() -> anyhow::Result<Self> {
|
||||
Ok(Self {})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user