Skip to content

Instantly share code, notes, and snippets.

@fmarcia
Last active February 11, 2018 18:56
Show Gist options
  • Save fmarcia/028ebbb40dba5ba0f483162c635c3685 to your computer and use it in GitHub Desktop.
Save fmarcia/028ebbb40dba5ba0f483162c635c3685 to your computer and use it in GitHub Desktop.
html {
height: 100%;
}
body {
font-family: arial;
font-size: 12px;
height: 100%;
margin: 0;
}
#header {
background-color: #000;
height: 55px;
left: 0;
position: fixed;
right: 0;
top: 0;
}
#title {
bottom: 0;
color: #fff;
font-size: 24px;
left: 8px;
line-height: 38px;
position: absolute;
}
#title span {
font-size: 16px;
}
#content {
bottom: 0;
left: 0;
overflow-x: hidden;
overflow-y: scroll;
position: absolute;
right: 0;
top: 55px;
z-index: 2;
}
#labels {
background-color: #fff;
left: 0;
line-height: 20px;
padding: 20px 5px 0 10px;
position: absolute;
text-align: right;
top: 0;
width: 150px;
z-index: 3;
}
#scale {
background-color: #fff;
position: absolute;
right: 0;
top: 0;
z-index: 2;
}
#graphs {
position: absolute;
right: 0;
top: 20px;
z-index: 1;
}
svg text {
font-family: arial;
font-size: 12px;
text-anchor: middle;
}
canvas {
cursor: pointer;
display: block;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Canvas experiment</title>
<link rel="stylesheet" href="canvas.css"/>
</head>
<body>
<div id="header">
<div id="title">XXX <span>dashboard</span></div>
</div>
<div id="content">
<div id="labels"></div>
<div id="scale"></div>
<div id="graphs"></div>
</div>
<script src="canvas.js"></script>
</body>
</html>
/**
* Canvas experiment
*/
// time
const totalDur = 12 * 60 * 60; // total displayed range, in seconds
const secPerPix = 30; // seconds per pixel
const rightMargin = 15 * 60; // right margin in seconds
const overlap = 20 * 60; // label overlap limit
const update = 5000; // graphps update, in milliseconds
const clock = 500; // clock update, in milliseconds
// DOM elements
const canvas = [];
const contexts = [];
const content = document.getElementById("content");
const scale = document.getElementById("scale");
const graphs = document.getElementById("graphs");
const labels = document.getElementById("labels");
// miscellaneous
const lineHeight = 20; // graph line height, in pixels
const hourHeight = 15; // labels height
const width = (totalDur + rightMargin) / secPerPix;
const selectedColor = "#6390c5";
const colors = { // status colors
success: "#93c47d",
failure: "#f50000",
warning: "#f6b26b"
};
let selected = { context: null, check: null };
// fake data
const seriesCount = 50; // number of series
const randStart = 7 * 60; // random start offset, in seconds
// =============================================================================
// SIMULATION
// generate a random integer in a range
const makeNumber = (min, max) => parseInt(Math.random()*(max-min+1) + min, 10);
// simulate current time
const getNow = _ => new Date();
// generate data of a time serie
const makeSerie = (start, now, longPer, shortPer, warnPct, failPct) => {
const realStart = _ => parseInt(start + randStart * Math.random(), 10);
const totalPct = warnPct + failPct;
const result = [];
let previous;
let count = 0;
for (let realDate = realStart(); realDate < now; ) {
const val = Math.random() * 100;
let status = "success";
let period = longPer;
if (val < failPct) {
status = "failure";
if (previous !== "failure" || count < 2) {
period = shortPer;
}
} else if (val < totalPct) {
status = "warning";
}
if (previous === status) {
count += 1;
} else {
count = 1;
}
previous = status;
const date = realDate - (realDate % secPerPix);
result.push({ date, status, period });
realDate += period * 60;
}
return result;
};
// =============================================================================
// DRAWING
// calculate abscissa from a date
const abscissa = (start, date) => parseInt((date - start) / secPerPix, 10);
// convert a date to a number of seconds
const secCount = d => parseInt(d.getTime() / 1000, 10);
// left-pad a value
const pad = v => v < 10 ? `0${v}` : v;
// insert labels
const drawLabels = _ => {
let html = "";
series.forEach((serie, index) => {
html += `<div class="label">scenario #${index + 1}</div>`;
});
labels.innerHTML = html;
};
// build y-scale labels
const drawHours = _ => {
let html = "";
// now
let x = abscissa(start, now);
let n = getNow();
let hour = `${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`;
html += `<text x="${x}" y="${hourHeight}">${hour}</text>`;
// hours
n = now - now % 3600;
if (now - n < overlap) { // avoid labels overlap
n -= 3600;
}
x = abscissa(start, secCount(new Date(n * 1000)));
while (x > 0) {
hour = `${pad(new Date(n*1000).getHours())}:00`;
html += `<text x="${x}" y="${hourHeight}">${hour}</text>`;
n -= 3600;
x -= 3600 / secPerPix;
}
scale.innerHTML = `<svg height="${lineHeight}" width="${width}">${html}</svg>`;
};
// draw vertical bars
const drawBars = (ctx, start, now) => {
// now
ctx.beginPath();
ctx.fillStyle = "#000";
let x = abscissa(start, now);
ctx.rect(x, 0, 1, lineHeight);
ctx.fill();
// hours
let i = now - now % 3600;
x = abscissa(start, secCount(new Date(i * 1000)));
while (x > 0) {
ctx.rect(x, 4, 1, lineHeight - 8);
ctx.fill();
x -= 3600 / secPerPix;
}
// half hours
ctx.beginPath();
i += 1800;
x = abscissa(start, secCount(new Date(i * 1000)));
const step = parseInt(lineHeight / 10, 10);
while (x > 0) {
for (let j = 1; j < lineHeight; j += (2*step)) {
ctx.rect(x, j, 1, step);
ctx.fill();
}
x -= 3600 / secPerPix;
}
};
// draw series
const draw = series => {
now = secCount(getNow());
start = now - totalDur;
drawHours();
series.forEach((serie, index) => {
canvas[index].height = lineHeight;
canvas[index].width = width;
const ctx = contexts[index];
// series
let fill;
serie.forEach(check => {
check.x = abscissa(start, check.date);
check.w = check.period * 60 / secPerPix;
const color = check.selected ? selectedColor : colors[check.status];
if (color != fill) {
ctx.beginPath();
ctx.fillStyle = fill = color;
}
ctx.rect(check.x, 1, check.w-1, lineHeight);
ctx.fill();
});
drawBars(ctx, start, now);
});
// next turn
setTimeout(requestAnimationFrame.bind(null, draw.bind(null, series)), update);
};
const placeHours = _ => {
const rect = scale.getBoundingClientRect();
scale.style.position = "fixed";
scale.style.top = "55px";
scale.style.left = rect.left + "px";
scale.style.right = "auto";
};
// =============================================================================
// MOUSE & SCROLL
const unselect = _ => {
if (selected.context) {
const context = selected.context;
const check = selected.check;
delete check.selected;
context.beginPath();
context.fillStyle = colors[check.status];
context.rect(check.x, 1, check.w - 1, lineHeight);
context.fill();
drawBars(context, start, now);
selected = {};
}
};
const select = (context, check) => {
selected = { context, check };
check.selected = true;
context.beginPath();
context.fillStyle = selectedColor;
context.rect(check.x, 1, check.w - 1, lineHeight);
context.fill();
drawBars(context, start, now);
};
const mousemove = event => {
const index = parseInt(event.target.id.slice(1), 10);
const serie = series[index];
const x = event.layerX;
try {
serie.forEach(check => {
if (x >= check.x && x <= check.x + check.w) {
if (!check.selected) {
requestAnimationFrame(unselect);
requestAnimationFrame(select.bind(null, contexts[index], check));
throw "stop!";
}
}
});
} catch(_) {
}
};
const mouseout = _ => requestAnimationFrame(unselect);
const click = _ => {
const check = selected.check;
const from = new Date(check.date*1000);
const to = new Date(from.getTime() + check.w*secPerPix*1000);
const show = {
from: from.toLocaleString(),
to: to.toLocaleString()
};
alert(JSON.stringify(show, 0, " "));
};
// =============================================================================
// MAIN
// limits
let now = secCount(getNow());
let start = now - totalDur;
// series
const series = [];
for (let i = 0; i < seriesCount; ++i) {
let short = makeNumber(2, 5);
let long = makeNumber(short*2, makeNumber(short*2, 60));
let warn = makeNumber(1, 10);
let fail = makeNumber(1, 90);
series.push( makeSerie(start, now, long, short, warn, fail));
}
// scale
scale.style.height = `${lineHeight}px`;
// canvas elements
for (let i = 0; i < seriesCount; ++i) {
const cnvs = document.createElement("canvas");
cnvs.height = lineHeight;
cnvs.width = width;
cnvs.id = `c${i}`;
cnvs.addEventListener("mousemove", mousemove, false);
cnvs.addEventListener("mouseout", mouseout, false);
cnvs.addEventListener("click", click);
graphs.appendChild(cnvs);
canvas.push(cnvs);
contexts.push(cnvs.getContext("2d"));
}
// start
drawLabels();
requestAnimationFrame(function() {
draw(series);
placeHours();
});
setInterval(drawHours, clock);
☐ series groups (service)
☐ hours position on resize (reset right property in placeHours
☐ convert to es5
☐ efficient labels overlap
☐ compute width with next value, not with state (values associated to state can change)
☐ new data addition: just add to serie's array
☐ old data deletion: just delete from serie's array when x and x+w are negative
☐ keep real date of checks for links
☐ initialize href wrapping anchor for links
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment