A simple D3 Chronograph Stopwatch.
Use the buttons to interact with it. To reset the watch, hold the mouse over the button for two seconds.
license: gpl-3.0 | |
height: 300 | |
border: no |
A simple D3 Chronograph Stopwatch.
Use the buttons to interact with it. To reset the watch, hold the mouse over the button for two seconds.
<!DOCTYPE html> | |
<html> | |
<meta charset="utf-8"> | |
<head> | |
<script src="//d3js.org/d3.v3.min.js"></script> | |
</head> | |
<body> | |
<svg id="drawing"></svg> | |
<script src="stopwatch.js"></script> | |
</body> | |
</html> |
var w = 500, // svg width | |
h = 300, // svg height | |
padding = 40, //svg padding | |
//radius settings | |
r1 = Math.min(w,h)/2- padding, | |
r2 = 0.25*r1, | |
r3 = 0.30*r1, | |
r4 = 0.30*r1, | |
//marker settings | |
markers = { | |
second: { | |
primary: { | |
width: 1, | |
height: 6, | |
count: 3, | |
color: 'orange', | |
} | |
}, | |
minute: { | |
primary: { | |
width: 1, | |
height: 10, | |
color: 'orange', | |
}, | |
secondary:{ | |
width: 1, | |
height: 4, | |
color: 'orange', | |
} | |
}, | |
hour: { | |
primary: { | |
width: 2, | |
height: 15, | |
color: 'orange', | |
}, | |
secondary:{ | |
width: 1, | |
height: 8, | |
color: 'orange', | |
} | |
} | |
}, | |
//marker labels | |
labels = { | |
primary:{ | |
color: '#666', | |
font: 14, | |
}, | |
secondary: { | |
color: '#666', | |
font: 10, | |
} | |
}, | |
//needle settings | |
needles = { | |
primary: { | |
width: 2, | |
length: r1, | |
wheelRadius: 8, | |
wheelStroke: 2, | |
color: '#607D8B', | |
}, | |
secondary: { | |
width: 1, | |
length: r2, | |
wheelRadius: 2.5, | |
wheelStroke: 5, | |
color: '#E91E63', | |
}, | |
tertiary: { | |
width: 1, | |
length: r3*0.9, | |
wheelRadius: 2.5, | |
wheelStroke: 5, | |
color: '#E91E63', | |
} | |
}; | |
// linear line function | |
var lineFun = d3.svg.line() | |
.x(function(d){return d[0];}) | |
.y(function(d){return d[1];}) | |
.interpolate('linear'); | |
//svg element | |
var svg = d3.select('#drawing') | |
.attr({ | |
width: w, | |
height: h, | |
}); | |
// Scales | |
var scale = d3.scale.linear() | |
.range([0, 360]); | |
var milliSecondScale = scale.copy().domain([0,100]), | |
secondMarkerScale = scale.copy() | |
.domain([0, 60*(markers.second.primary.count+1)]), | |
minuteMarkerScale = scale.copy().domain([0,60]), | |
hourMarkerScale = scale.copy().domain([0,12]), | |
labelScale = scale.copy().domain([0,60,5]); | |
//group for start/stop buttons | |
var g4 = svg.append('g') | |
.attr({ | |
transform: 'translate('+ (w/2 ) +','+ ( h/2 + r1/2) +')' | |
}); | |
//Tertiary clock face for milisecond | |
var g3 = svg.append('g') | |
.attr({ | |
'class': 'tertiary', | |
transform: 'translate('+ (w/2 + r1/2 + needles.primary.wheelRadius) +','+ h/2 +')' | |
}); | |
//Secondary clock face for minutes | |
var g2 = svg.append('g') | |
.attr({ | |
'class': 'secondary', | |
transform: 'translate('+ w/2 +','+ (h/2 - r1/2 - needles.primary.wheelRadius) +')' | |
}); | |
//Primary clock face for seconds | |
var g1 = svg.append('g') | |
.attr({ | |
'class': 'primary', | |
transform: 'translate('+ w/2 +','+ h/2 +')', | |
}); | |
// Draw markers on primary clock face | |
g1.selectAll('.hour-marker').data(d3.range(0,12)) | |
.enter().append('rect') | |
.attr({ | |
x: -markers.hour.primary.width/2, | |
y: - r1 + 2 - markers.hour.primary.height, | |
width: markers.hour.primary.width, | |
height: markers.hour.primary.height, | |
fill: markers.hour.primary.color, | |
transform: function(d){ | |
return 'rotate('+ hourMarkerScale(d) +')'; | |
}, | |
'class': 'hour-marker', | |
}); | |
g1.selectAll('.min-marker').data(d3.range(0,60)) | |
.enter().append('rect') | |
.attr({ | |
x: -markers.minute.primary.width/2, | |
y: - r1 - markers.minute.primary.height, | |
width: markers.minute.primary.width, | |
height: markers.minute.primary.height, | |
fill: markers.minute.primary.color, | |
transform: function(d){ | |
return 'rotate('+ minuteMarkerScale(d) +')'; | |
}, | |
'class': 'min-marker', | |
}); | |
g1.selectAll('.second-marker') | |
.data(d3.range.apply(null,secondMarkerScale.domain())) | |
.enter().append('rect') | |
.attr({ | |
x: -markers.second.primary.width/2, | |
y: -r1 - markers.minute.primary.height, | |
width: markers.second.primary.width, | |
height: markers.second.primary.height, | |
fill: markers.second.primary.color, | |
transform: function(d){ | |
return 'rotate('+ secondMarkerScale(d)+')'; | |
}, | |
'class': 'second-marker', | |
}); | |
g1.selectAll('.minute-label').data(d3.range(5,65,5)) | |
.enter() | |
.append('g') | |
.attr({ | |
'class': 'minute-label', | |
'transform': function(d){ | |
return 'rotate('+ labelScale(d) +')'; | |
} | |
}) | |
.append('text') | |
.text(function(d){return d;}) | |
.attr({ | |
"text-anchor": "middle", | |
"font-family": "sans-serif", | |
"font-size": 14, | |
x: 0, | |
y: -(r1 + markers.hour.primary.height + 8), | |
dy: 5, | |
fill: labels.primary.color, | |
transform: function(d){ | |
var deg = -labelScale(d), | |
x = d3.select(this).attr('x'), | |
y = d3.select(this).attr('y'); | |
return 'rotate('+ deg +' ' + x +' '+ y + ')'; | |
} | |
}); | |
g2.selectAll('.hour-marker').data(d3.range(0,12)) | |
.enter().append('rect') | |
.attr({ | |
x: -markers.hour.secondary.width/2, | |
y: - r2 - markers.hour.secondary.height , | |
width: markers.hour.secondary.width, | |
height: markers.hour.secondary.height, | |
fill: markers.hour.secondary.color, | |
transform: function(d){ | |
return 'rotate('+ hourMarkerScale(d) +')'; | |
}, | |
'class': 'hour-marker', | |
}); | |
g2.selectAll('.min-marker').data(d3.range(0,60)) | |
.enter().append('rect') | |
.attr({ | |
x: -1*markers.minute.secondary.width/2, | |
y: - r2 - (markers.hour.secondary.height), | |
width: markers.minute.secondary.width, | |
height: markers.minute.secondary.height, | |
fill: markers.minute.secondary.color, | |
transform: function(d){ | |
return 'rotate('+ minuteMarkerScale(d) +')'; | |
}, | |
'class': 'min-marker', | |
}); | |
g2.selectAll('.minute-label').data(d3.range(5,65,5)) | |
.enter() | |
.append('g') | |
.attr({ | |
'class': 'minute-label', | |
'transform': function(d){ | |
return 'rotate('+ labelScale(d) +')'; | |
} | |
}) | |
.append('text') | |
.text(function(d){return d;}) | |
.attr({ | |
"text-anchor": "middle", | |
"font-family": "sans-serif", | |
"font-size": 10, | |
x: 0, | |
y: -(r2 + markers.hour.secondary.height + 7), | |
dy: 4, | |
fill: labels.secondary.color, | |
transform: function(d){ | |
var deg = -labelScale(d), | |
x = d3.select(this).attr('x'), | |
y = d3.select(this).attr('y'); | |
return 'rotate('+ deg +' ' + x +' '+ y + ')'; | |
} | |
}); | |
g2.append('circle').attr({ | |
fill: "none", | |
cx: 0, | |
cy: 0, | |
r: r2 + (markers.hour.secondary.height), | |
stroke: markers.minute.secondary.color, | |
"stroke-width": 1 | |
}); | |
g3.append('circle').attr({ | |
fill: "none", | |
cx: 0, | |
cy: 0, | |
r: r3, | |
stroke: markers.second.primary.color, | |
"stroke-width": 1 | |
}); | |
// BUTTONS | |
// Button Symbols | |
var buttons = d3.map([ | |
{type: "triangle-up", size: r4*0.8, id: 'play' }, | |
{type: "square", size: r4*0.9, id:'stop' } | |
], function(d){ | |
return d.id; | |
}); | |
// Change Button Symbol | |
function updateButton(state){ | |
return g4.select('.button') | |
.transition() | |
.ease("linear") | |
.attr({ | |
d: d3.svg.symbol() | |
.size(function(){ | |
return buttons.get(state) | |
.size*buttons.get(state).size;}) | |
.type(function(){ | |
return buttons.get(state).type;}), | |
transform: 'rotate(90)' | |
}); | |
} | |
// Draw Button | |
g4.append("path") | |
.attr({ | |
d: d3.svg.symbol() | |
.size(function(){ | |
return buttons.get('play') | |
.size*buttons.get('play').size; | |
}) | |
.type(function(){ | |
return buttons.get('play').type; | |
}), | |
fill: "#E91E63", | |
transform: 'rotate(90)', | |
'class': 'button', | |
'style': 'cursor: pointer' | |
}).on('click', function(){ | |
toggleTimer(); | |
g4.select('.reset-timeout').remove(); | |
}) | |
.on('mouseover', function(){ | |
if(!elapsedTime) return; | |
g4.append("path") | |
.datum({endAngle: 2*Math.PI}) | |
.attr({ | |
d: arc, | |
fill: "#E91E63", | |
class: 'reset-timeout' | |
}) | |
.transition() | |
.duration(2000) | |
.call(arcTween, 0) | |
.each('end',function(){ | |
resetTimer(); | |
this.remove(); | |
}); | |
}) | |
.on('mouseout', function(){ | |
g4.select('.reset-timeout').transition().duration(0).remove(); | |
}); | |
var arc = d3.svg.arc() | |
.innerRadius(r4-5) | |
.outerRadius(r4) | |
.startAngle(2*Math.PI); | |
function arcTween(transition, newAngle){ | |
transition.attrTween("d", function(d) { | |
var interpolate = d3.interpolate(d.endAngle, newAngle); | |
return function(t) { | |
d.endAngle = interpolate(t); | |
return arc(d); | |
}; | |
}); | |
} | |
// Timer variables | |
var timeoutHandle, now, startTime, isStarted = false, elapsedTime = 0; | |
// Toggle timer state | |
function toggleTimer(){ | |
isStarted = !isStarted; | |
g4.select('.reset-timeout').transition(); | |
if(isStarted){ | |
updateButton('stop'); | |
startTime = clock.now(); | |
tick(); | |
} | |
else { | |
clearTimeout(timeoutHandle); | |
updateButton('play'); | |
} | |
} | |
var clock = typeof performance === "object" ? performance: Date; | |
function tick(){ | |
now = clock.now(); | |
elapsedTime = elapsedTime + now - startTime; | |
startTime = now; | |
var ms = elapsedTime/10, | |
seconds = ms/100, | |
minutes = seconds/60; | |
updateNeedle(g1.needle, minuteMarkerScale(seconds)); | |
updateNeedle(g2.needle, minuteMarkerScale(minutes)); | |
updateNeedle(g3.needle, milliSecondScale(ms)); | |
timeoutHandle = setTimeout(tick,0); | |
} | |
function resetTimer(){ | |
clearTimeout(timeoutHandle); | |
isStarted = false; | |
updateButton('play'); | |
elapsedTime = now = startTime = 0; | |
updateNeedle(g1.needle, minuteMarkerScale(0), 500); | |
updateNeedle(g2.needle, minuteMarkerScale(0), 500); | |
updateNeedle(g3.needle, milliSecondScale(0), 500); | |
} | |
// Clock Needle/Hand | |
//data for needle shape | |
function needleData(data){ | |
var wa = data.width, | |
wb = data.width*3, | |
lb = data.wheelRadius + 5, | |
la = data.length - lb; | |
return [ | |
[wb/2, lb], | |
[wb/2, -lb], | |
[wa/2,-lb], | |
[wa/2,-la-lb], | |
[-wa/2,-la-lb], | |
[-wa/2,-lb], | |
[-wb/2,-lb], | |
[-wb/2,lb] | |
]; | |
} | |
//draw needle | |
function drawNeedle(g, data){ | |
g.needle = g.append('g'); | |
// Needle Shape | |
g.needle.append('path') | |
.attr({ | |
d: lineFun(needleData(data)), | |
class: 'needle', | |
fill: data.color, | |
"stroke-width": 0 | |
}); | |
// Needle Wheel | |
g.needle.append('circle') | |
.attr({ | |
r: data.wheelRadius, | |
fill: "#fff", | |
stroke: data.color, | |
"stroke-width": data.wheelStroke | |
}); | |
} | |
// update needle angle | |
function updateNeedle(needle, angle, transition){ | |
transition = transition || 0; | |
needle | |
.transition() | |
.duration(transition) | |
.ease('quad-out') | |
.attr({ | |
transform: "rotate("+ angle +")" | |
}); | |
} | |
drawNeedle(g1, needles.primary); | |
drawNeedle(g2, needles.secondary); | |
drawNeedle(g3, needles.tertiary); |