Created
March 21, 2024 23:54
-
-
Save e-neko/b955460a77fa90628e3539b2f321fe12 to your computer and use it in GitHub Desktop.
SVG-based timeline control mockup
This file contains hidden or 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> | |
<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