Skip to content

Instantly share code, notes, and snippets.

@e-neko
Created March 21, 2024 23:54
Show Gist options
  • Save e-neko/b955460a77fa90628e3539b2f321fe12 to your computer and use it in GitHub Desktop.
Save e-neko/b955460a77fa90628e3539b2f321fe12 to your computer and use it in GitHub Desktop.
SVG-based timeline control mockup
<!DOCTYPE html>
<html>
<head>
<title></title>
<style type="text/css">
svg {
background-color: #ccc;
border: 1px solid silver;
position: relative;
display: block;
}
line.log:hover{
stroke: #009cba;
}
marker {
overflow: visible;
}
text.date {
text-anchor: middle;
}
svg#display.unrestorable line[id].selected, svg#display.unrestorable line[id].selected+line[id^='m-'] {
/*filter: sepia(0.9) hue-rotate(-65deg);*/
}
svg#display.restorable line[id]:not(.selected):not(.rejected) {
cursor: not-allowed;
}
div#tooltip {
opacity: 0.7;
transition: opacity 100ms ease;
transition-delay: 1s;
position: absolute;
transform-origin: 50% calc(100% + 10px);
top: 10px;
left: 10px;
height: 0;
white-space: nowrap;
color: white;
border: none;
overflow: visible;
pointer-events: none;
}
div#tooltip:after {
position: absolute;
bottom: -12px;
left: -5px;
box-sizing: content-box;
width: 0;
height: 0;
border-style: solid;
border-color: #222 transparent transparent transparent;
border-width: 5px;
content: '';
}
div#tooltip span {
background-color: #222;
padding: 5px;
text-align: center;
position: relative;
top: calc(-10px - 1em);
display: inline-block;
margin-left: -50%;
}
div#tooltip.hidden {
opacity: 0;
transition-delay: 0s;
}
</style>
</head>
<body>
<svg id="display" width="1000" height="200" viewBox="0 0 10000 200" preserveAspectRatio="xMinYMax meet">
<defs>
<marker
id="full"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h10v10l-5,5l-5,-5z" stroke="darkslateblue" fill="white"/>
<path d="M5,15v200" stroke="white"/>
<text x="5" y="5" transform="rotate(-45)">full</text>
</marker>
<marker
id="full_h"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h10v10l-5,5l-5,-5z" stroke="#006c9a" fill="#009cba"/>
<path d="M5,15v200" stroke="#009cba"/>
<text x="5" y="5" transform="rotate(-45)">full</text>
</marker>
<marker
id="full_s"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h10v10l-5,5l-5,-5z" stroke="lime" fill="#00acba"/>
<path d="M5,15v200" stroke="#00acba"/>
<text x="5" y="5" stroke="green" transform="rotate(-45)">full</text>
</marker>
<marker
id="full_r"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h10v10l-5,5l-5,-5z" stroke="maroon" fill="orange"/>
<path d="M5,15v200" stroke="maroon"/>
<text x="5" y="5" fill="#bc7a00" transform="rotate(-45)">full</text>
</marker>
<marker
id="full_b"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h10v10l-5,5l-5,-5z" stroke="red" fill="#ffaaaa"/>
<path d="M5,15v200" stroke="red"/>
<text x="5" y="5" fill="maroon" transform="rotate(-45)">full</text>
</marker>
<marker
id="diff"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h5l5,5v5l-5,5l-5,-5z" stroke="darkslateblue" fill="gray"/>
<path d="M5,15v200" stroke="gray"/>
<text x="5" y="5" transform="rotate(-45)">diff</text>
</marker>
<marker
id="diff_h"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h5l5,5v5l-5,5l-5,-5z" stroke="blue" fill="#006c9a"/>
<path d="M5,15v200" stroke="#006c9a"/>
<text x="5" y="5" transform="rotate(-45)">diff</text>
</marker>
<marker
id="diff_s"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h5l5,5v5l-5,5l-5,-5z" stroke="lime" fill="#007c9a"/>
<path d="M5,15v200" stroke="#007c9a"/>
<text x="5" y="5" stroke="green" transform="rotate(-45)">diff</text>
</marker>
<marker
id="diff_r"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h5l5,5v5l-5,5l-5,-5z" stroke="maroon" fill="#bc7a00"/>
<path d="M5,15v200" stroke="maroon"/>
<text x="5" y="5" fill="#bc7a00" transform="rotate(-45)">diff</text>
</marker>
<marker
id="diff_b"
viewBox="0 0 10 15"
refX="5" refY="36"
markerUnits="strokeWidth"
markerWidth="10"
markerHeight="15"
orient="0">
<path d="M0,0h5l5,5v5l-5,5l-5,-5z" stroke="red" fill="maroon"/>
<path d="M5,15v200" stroke="red"/>
<text x="5" y="5" fill="maroon" transform="rotate(-45)">diff</text>
</marker>
<style>
line.full {
marker-end: url(#full);
}
line.diff {
marker-end: url(#diff);
}
line.mwrap:hover+line.full {
marker-end: url(#full_h);
}
line.mwrap:hover+line.diff {
marker-end: url(#diff_h);
}
line.mwrap.selected+line.full {
marker-end: url(#full_s);
}
line.mwrap.selected+line.diff {
marker-end: url(#diff_s);
}
line.mwrap.rejected+line.full {
marker-end: url(#full_r);
}
line.mwrap.rejected+line.diff {
marker-end: url(#diff_r);
}
line.log.selected {
stroke: #00acba;
}
line.log.rejected {
stroke: #bc7a00;
}
svg.unrestorable line.log.selected {
stroke: #c00000;
}
svg.unrestorable line.mwrap.selected+line.full {
marker-end: url(#full_b);
}
svg.unrestorable line.mwrap.selected+line.diff {
marker-end: url(#diff_b);
}
line.mwrap[id]{
cursor: pointer;
}
line.log {
cursor: pointer;
}
</style>
</defs>
<!-- marker-end="url(#full)" -->
<line x1="10" y1="200" x2="300" y2="200" stroke="none" vector-effect="non-scaling-stroke"/>
<line x1="10" y1="200" x2="400" y2="200" stroke="none" vector-effect="non-scaling-stroke"/>
<line class="log" x1="0" y1="200" x2="0" y2="200" stroke="#00c300" stroke-width="40" vector-effect="non-scaling-stroke"/>
<line class="mwrap" x1="0" y1="0" x2="0" y2="0" stroke-width="15" vector-effect="non-scaling-stroke" stroke-linecap="round" stroke="#00000000"/>
</svg>
<svg id="coords" width="1000" height="50" viewbox="0 0 10000 50" preserveAspectRatio="xMinYMin meet">
<defs>
<marker id="year" viewBox="0 0 2 20" markerUnits="strokeWidth" markerWidth="2" markerHeight="20" orient="0" refX="1" refY="0">
<path d="M0,0v20" stroke-width="2" stroke="black"/>
<text class="date" x="0" y="50">2024</text>
</marker>
<marker id="month" viewBox="0 0 2 15" markerUnits="strokeWidth" markerWidth="2" markerHeight="20" orient="0" refX="1" refY="0">
<path d="M0,0v15" stroke-width="2" stroke="black"/>
</marker>
<marker id="week" viewBox="0 0 1 10" markerUnits="strokeWidth" markerWidth="1" markerHeight="20" orient="0" refX="0" refY="0">
<path d="M0,0v10" stroke-width="1" stroke="black"/>
</marker>
<marker id="day" viewBox="0 0 1 5" markerUnits="strokeWidth" markerWidth="1" markerHeight="20" orient="0" refX="0" refY="0">
<path d="M0,0v5" stroke-width="1" stroke="black"/>
</marker>
<marker id="hour" viewBox="0 0 1 2" markerUnits="strokeWidth" markerWidth="1" markerHeight="20" orient="0" refX="0" refY="0">
<path d="M0,0v2" stroke-width="1" stroke="black"/>
</marker>
<style>
path#years {
marker: url(#year);
}
path#months {
marker: url(#month);
}
path#weeks {
marker: url(#week);
}
</style>
</defs>
<path id="years" d="M0,0h52704" stroke="none" vector-effect="non-scaling-stroke"/>
<path id="months" d="M0,0h4464h4176h4464h4320h4464h4320h4464h4464h4320h4464h4320h4464" vector-effect="non-scaling-stroke"/>
<path id="weeks" d="M0,0h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008h1008" vector-effect="non-scaling-stroke"/>
</svg>
<div id="tooltip" class="hidden"><span>Hello world!</span></div>
</body>
<script type="text/javascript">
const Backups = [
{type: "full", timestamp: new Date("10 Jan 2024"), firstLSN: 1000, lastLSN: 10000},
{type: "log", timestamp: new Date("12 Jan 2024 11:30"), firstLSN: 1000, lastLSN: 15000},
{type: "diff", timestamp: new Date("15 Jan 2024 02:30"), firstLSN: 10000, lastLSN: 20000},
{type: "log", timestamp: new Date("18 Jan 2024 11:30"), firstLSN: 15001, lastLSN: 28000},
{type: "diff", timestamp: new Date("22 Jan 2024 02:30"), firstLSN: 10000, lastLSN: 30000},
{type: "log", timestamp: new Date("25 Jan 2024 11:30"), firstLSN: 31000, lastLSN: 35000},
{type: "diff", timestamp: new Date("29 Jan 2024 02:30"), firstLSN: 10000, lastLSN: 40000},
{type: "log", timestamp: new Date("1 Feb 2024 11:30"), firstLSN: 35001, lastLSN: 49000},
{type: "full", timestamp: new Date("5 Feb 2024"), firstLSN: 1000, lastLSN: 50000},
{type: "diff", timestamp: new Date("5 Feb 2024 02:30"), firstLSN: 50000, lastLSN: 60000},
{type: "log", timestamp: new Date("11 Feb 2024 11:30"), firstLSN: 49001, lastLSN: 69000},
{type: "diff", timestamp: new Date("12 Feb 2024 02:30"), firstLSN: 50000, lastLSN: 70000},
{type: "diff", timestamp: new Date("15 Feb 2024 02:30"), firstLSN: 50000, lastLSN: 80000},
{type: "log", timestamp: new Date("25 Feb 2024 11:30"), firstLSN: 69001, lastLSN: 135000},
];
let $display = document.getElementById("display");
let $scale = document.getElementById("coords");
let offset = 0;
let extent = 10000;
let dStart = new Date("1 Jan 2000");
let dEnd = new Date("31 Dec 2100");
const tick = (1000*60*10);
/* algo:
: use Date.now() or specificDate.getTime() for msec/epoch
: use node.cloneNode(true) to clone a specific marker/path type; change ID to fit
- assume sorted backup list
- offset to first date
- extents to last date
- location: date->minute/10 (every 10 minutes = tick)
+ for log backups
- lastLSN = date taken
- firstLSN: check if covered by previous log or is last LSN for full/diff
- if not, extrapolate:
- calculate average lsn's per tick over entire range
- calculate average lsn's per tick in gap if log is not first backup
- use it if it's not first, otherwise use average
+ create ticks
- years around range
- months inside range (max: 12 lines; ticks on weekends too)
- dates for weekends (max: 31 lines, ticks on months and weekends)
+ create markers
- copy by id
- attach by class?
*/
const processBackups = backups=>{
let result = {};
result.offset = backups[0].timestamp.getTime()/tick | 0;
result.extent = backups.slice(-1)[0].timestamp.getTime()/tick | 0;
result.extent -= result.offset;
result.backups = [];
let avg=0;
for (var i = 0; i < backups.length; i++) {
const b = {...backups[i], state: null};
if (i>0) {
let timeDiff = (b.timestamp.getTime()-backups[i-1].timestamp.getTime())/tick;
let lsnDiff = b.lastLSN-backups[i-1].lastLSN;
b.lsnPerTime = lsnDiff/timeDiff;
avg+=b.lsnPerTime;
b.id = i;
}
result.backups.push(b);
}
result.backups[0].lsnPerTime = avg/(backups.length-1);
result.backups[0].id = 0;
console.log(result);
return result;
};
let processed = processBackups(Backups);
const adjustScale = ()=>{
$display.setAttribute("viewBox", `${offset|0} 0 ${extent|0} 200`);
$scale.setAttribute("viewBox", `${offset|0} 0 ${extent|0} 50`);
const base = 200;
let stack = 0;
let lastTick = -1;
let factor = (1000/extent);
for (let i=processed.backups.length-1; i>=0; i--){
if (processed.backups[i].type==='log')
continue;
let thisTick = processed.backups[i].timestamp.getTime()/tick;
let gap = 100;
if (lastTick>=0){
gap = (lastTick - thisTick)*factor;
}
if (gap>15+stack)
stack = 0;
else
stack = stack+19-gap;
let lid = `svg#display>line#m-${processed.backups[i].type}_${processed.backups[i].id}`;
let mid = `svg#display>line#${processed.backups[i].type}_${processed.backups[i].id}`;
let line = document.querySelector(lid);
let marker = document.querySelector(mid);
if (line){
line.setAttribute('y1', base-stack/factor);
line.setAttribute('y2', base-stack/factor);
}
if (marker){
marker.setAttribute('y1', base-(30+stack)/factor);
marker.setAttribute('y2', base-(30+stack)/factor);
}
lastTick = thisTick;
}
}
offset = 0
extent = processed.extent;
const addBackupMarkers = pb=>{
for (const b of pb.backups){
if(b.type==="log")
continue;
let line = document.querySelector('svg#display>line').cloneNode(true);
line.setAttribute('id', `m-${b.type}_${b.id}`);
let xoffs = b.timestamp.getTime()/tick-pb.offset;
line.setAttribute('x2', `${xoffs}`);
//line.setAttribute('marker-end', `url(#${b.type})`);
line.setAttribute('class', b.type);
let marker = document.querySelector('svg#display>line.mwrap').cloneNode(true);
marker.setAttribute('id', `${b.type}_${b.id}`);
marker.setAttribute('x1', `${xoffs}`);
marker.setAttribute('x2', `${xoffs}`);
$display.appendChild(marker);
$display.appendChild(line);
}
}
const addBackupSpans = pb=>{
let prevLog = undefined;
let prevAny = undefined;
for (const b of pb.backups){
if(b.type!=="log"){
prevAny = b;
continue;
}
let line = document.querySelector('svg#display>line.log').cloneNode(true);
line.setAttribute('id', `${b.type}_${b.id}`);
let endTime = b.timestamp.getTime()/tick-pb.offset;
let startTime = 0;
line.setAttribute('x2', `${endTime}`);
if (prevLog) {
startTime = prevLog.timestamp.getTime()/tick-pb.offset;
if (Math.abs(b.firstLSN-prevLog.lastLSN)>2){
if ((b.firstLSN - prevLog.lastLSN > 2)&&
prevAny &&
prevAny.type!=="log" &&
prevAny.lastLSN>prevLog.lastLSN &&
prevAny.lastLSN < b.firstLSN) {
startTime = prevAny.timestamp.getTime()/tick-pb.offset;
startTime = startTime+(b.firstLSN-prevAny.lastLSN)/b.lsnPerTime
}
else
startTime = startTime+(b.firstLSN-prevLog.lastLSN)/b.lsnPerTime
}
}
else {
startTime = endTime - ((b.lastLSN-b.firstLSN)/b.lsnPerTime);
}
line.setAttribute('x1', `${startTime}`);
prevLog = b;
prevAny = b;
$display.appendChild(line);
}
}
addBackupSpans(processed);
addBackupMarkers(processed);
adjustScale();
const toggleSelection = (id, selected)=>{
let bkup = processed.backups[id];
let first = processed.backups.slice(id+1).reverse().find(b=>b.state==='selected');
// check state
switch(bkup.state){
case "selected":
if (!first){ //this was the first, clean it up
processed.backups.forEach(b=>{b.state = null;})
}
else
bkup.state = 'rejected';
break;
case "rejected":
bkup.state = null; //might not get selected anyway
break;
default:
if (!first){
bkup.state = 'selected';
first = bkup;
}
}
// recalc recovery plan
let prev = null;
let outcome = undefined;
for (let i = processed.backups.length-1; i>=0; i--){
bkup = processed.backups[i];
let lid = `svg#display>line#${bkup.type}_${bkup.id}`;
let line = document.querySelector(lid);
if (i>(first?.id ?? -1))
bkup.state = null;
else if (!prev){
line.classList.add(bkup.state); //should be 'selected'
prev = bkup;
outcome = (bkup.type === 'full');
}
else if (prev.type === 'full')
bkup.state = null;
else if (prev.type === 'diff'){
if (bkup.type!=='full' || bkup.state==='rejected' || prev.firstLSN-bkup.lastLSN>1){
if (bkup.state !== 'rejected')
bkup.state = null;
}
else {
bkup.state = 'selected';
line.classList.add(bkup.state);
outcome = true;
prev = bkup;
}
}
else if (prev.type === 'log'){
if (bkup.state === 'rejected' || prev.firstLSN-bkup.lastLSN>1){
if (bkup.state !== 'rejected')
bkup.state = null;
}
else {
bkup.state = 'selected';
line.classList.add(bkup.state);
outcome = (bkup.type === 'full');
prev = bkup
}
}
if (bkup.state !== 'selected')
line.classList.remove('selected');
if (bkup.state !== 'rejected')
line.classList.remove('rejected');
else
line.classList.add('rejected');
}
$display.setAttribute("class", outcome ? 'restorable' : (first ? 'unrestorable' : ''));
}
$display.addEventListener("wheel", evt=>{
//offsetX/offsetY for coords
//deltaX for pan, deltaY for zoom (maybe wheelDeltaX/wheelDeltaY)
if (evt.deltaX) {
let deltaX = evt.deltaX*extent/500;
offset = offset+deltaX;
}
if (evt.deltaY) {
let deltaY = evt.deltaY*extent/500;
let newExtent = Math.max(extent+deltaY, 1000);
if (newExtent!=extent){
let proportion = evt.offsetX/1000.0;
offset-=(newExtent-extent)*proportion;
extent = newExtent;
}
}
adjustScale();
evt.stopPropagation();
evt.preventDefault();
});
$display.addEventListener("dblclick", evt=>{
offset = 0;
extent = processed.extent;
adjustScale();
console.log('reset');
});
$display.addEventListener("click", evt=>{
let tgt = evt.target;
if (tgt.tagName!=='line')
return;
let id = tgt.getAttribute("id");
toggleSelection(parseInt(id.split('_')[1]), tgt.classList.contains('selected'));
//let bkup = processed.backups[parseInt(id.split('_')[1])];
//console.log(bkup);
});
$display.addEventListener("mousemove", evt=>{
let tgt = evt.target;
let tip = document.querySelector('#tooltip');
if (tgt.tagName!=='line'){
tip.classList.add('hidden');
return;
}
let id = tgt.getAttribute("id");
let bkup = processed.backups[parseInt(id.split('_')[1])];
document.querySelector('#tooltip>span').innerText =
`${bkup.state ? bkup.state+': ' : ''}${bkup.type}, LSNs: ${bkup.firstLSN}-${bkup.lastLSN}`;
tip.style.top = `${evt.offsetY}px`;
tip.style.left = `${evt.offsetX+8}px`;
tip.classList.remove('hidden');
});
</script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment