Last active
February 11, 2018 18:56
-
-
Save fmarcia/028ebbb40dba5ba0f483162c635c3685 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
☐ 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