|
/* ranges from http://www.heart.org/HEARTORG/Conditions/HighBloodPressure/AboutHighBloodPressure/Understanding-Blood-Pressure-Readings_UCM_301764_Article.jsp */ |
|
/* adapted from http://bl.ocks.org/mbostock/1667367 Focus+Context via Brushing */ |
|
(function () { |
|
'use strict'; |
|
|
|
var svg = d3.select('svg'), |
|
svgDims = svg.node().getBoundingClientRect(), |
|
margin = { top: 30, right: 10, bottom: 20, left: 40 }, |
|
margin2 = { top: 0, right: 10, bottom: 20, left: 40 }, |
|
width = svgDims.width - margin.left - margin.right, |
|
chartHeights = svgDims.height - margin.top, |
|
height = ( 0.7 * chartHeights ) - margin.bottom, |
|
height2 = chartHeights - height - margin.bottom - margin2.bottom - 10, |
|
timeFormat = d3.time.format("%-I:%M %p %a, %b %-d '%y"); |
|
|
|
margin2.top = height + margin.bottom + margin2.bottom + 10; |
|
|
|
var x = d3.time.scale().range([width, 0]), |
|
x2 = d3.time.scale().range([width, 0]), |
|
y = d3.scale.linear().range([height, 0]), |
|
y2 = d3.scale.linear().range([height2, 0]); |
|
|
|
var xAxis = d3.svg.axis().scale(x).orient('bottom'), |
|
xAxis2 = d3.svg.axis().scale(x2).orient('bottom'), |
|
yAxis = d3.svg.axis().scale(y).orient('left'); |
|
|
|
var brush = d3.svg.brush() |
|
.x(x2) |
|
.on('brush', brushed) |
|
.on('brushend', function () { |
|
showFocusInfo(avgExtent); |
|
addUX(); |
|
}); |
|
|
|
svg.select('defs #focus-clip rect') |
|
.attr('width', width) |
|
.attr('height', height); |
|
|
|
var focusInfo = svg.append('g') |
|
.attr('class', 'focus-info') |
|
.attr('transform', 'translate(' + margin.left + ',' + '0)'); |
|
|
|
|
|
var focus = svg.append('g') |
|
.attr('class', 'focus') |
|
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); |
|
|
|
var context = svg.append('g') |
|
.attr('class', 'context') |
|
.attr('transform', 'translate(' + margin2.left + ',' + margin2.top + ')'); |
|
|
|
var bpData = []; |
|
var timeExtent = []; |
|
var avgExtent = []; |
|
|
|
d3.json('bp.json', function(err, data) { |
|
bpData = data; |
|
|
|
var sysExtent = d3.extent(data, function(d) { |
|
return d.systolic; |
|
}); |
|
|
|
var diaExtent = d3.extent(data, function(d) { |
|
return d.diastolic; |
|
}); |
|
|
|
var bpExtent = d3.extent(sysExtent.concat(diaExtent)); |
|
bpExtent[0] -=10; |
|
bpExtent[1] +=10; |
|
// -10 & +10 added so dots, lines don't occupy full height of group. |
|
|
|
y.domain(bpExtent); |
|
|
|
timeExtent = d3.extent(data, function(d) { return d.ts }) |
|
avgExtent[0] = timeExtent[0]; |
|
avgExtent[1] = timeExtent[1]; |
|
|
|
x.domain(timeExtent); |
|
x2.domain(x.domain()); |
|
y2.domain(y.domain()); |
|
|
|
focus.append('g') |
|
.attr('class', 'x axis') |
|
.attr('transform', 'translate(0,' + height + ')') |
|
.call(xAxis); |
|
|
|
focus.append('g') |
|
.attr('class', 'y axis') |
|
.call(yAxis); |
|
|
|
genBpChart ( focus, x, y, bpData, 4 ); |
|
|
|
showFocusInfo([ timeExtent[0], timeExtent[1] ]); |
|
|
|
context.append('g') |
|
.attr('class', 'x axis') |
|
.attr('transform', 'translate(0,' + height2 + ')') |
|
.call(xAxis2); |
|
|
|
genBpChart ( context, x2, y2, bpData, 3 ); |
|
|
|
context.append('g') |
|
.attr('class', 'x brush') |
|
.call(brush) |
|
.selectAll('rect') |
|
.attr('y', 2) |
|
.attr('height', height2 ); |
|
}); |
|
|
|
function getRange (key, val) { |
|
var rx = 0, |
|
ranges = [ |
|
{ systolic : 120, diastolic : 80, fill : '#1ebc16', color: '#fff', title : 'Normal' }, |
|
{ systolic : 140, diastolic : 90, fill : 'gold', color: '#fff', title : 'Prehypertension' }, |
|
{ systolic : 160, diastolic : 100, fill : '#ff9315', color: '#000', title : 'Stage 1 Hypertension' }, |
|
{ systolic : 180, diastolic : 110, fill : 'tomato', color: '#fff', title : 'Stage 2 Hypertension' }, |
|
{ systolic : 999, diastolic : 999, fill : 'crimson', color: '#fff', title : 'Hypertensive Crisis' } |
|
], |
|
rlen = ranges.length; |
|
|
|
for ( rx; rx < rlen; rx += 1 ) { |
|
if ( val < ranges[rx][key] ) { |
|
return ranges[rx]; |
|
} |
|
} |
|
} |
|
|
|
function genPath ( grp, val, xScale, yScale, data ) { |
|
|
|
var line = d3.svg.line() |
|
.x(function(d) { return xScale( d.ts ) }) |
|
.y(function(d) { return yScale( d[val] ) }); |
|
|
|
grp.append('path') |
|
.attr('class', 'bp-line ' + val) |
|
.attr('stroke', '#ddd') |
|
.attr('fill', 'none') |
|
.attr('stroke-width', '.5') |
|
.attr('d', line(data)); |
|
} |
|
|
|
function genDots ( grp, val, xScale, yScale, data, dotRadius) { |
|
|
|
grp.append('circle') |
|
.attr('class', val) |
|
.attr('r', dotRadius) |
|
.attr('stroke', 'none') |
|
.attr('fill', function (d) { return getRange( val, d[val] ).fill }) |
|
.attr('cx', function(d) { return xScale( d.ts ) }) |
|
.attr('cy', function(d) { return yScale( d[val] ) }); |
|
} |
|
|
|
function genBpChart ( grp, xScale, yScale, data, dotRadius ) { |
|
var sys = 'systolic', |
|
dia = 'diastolic'; |
|
|
|
genPath( grp, sys, xScale, yScale, data ); |
|
genPath( grp, dia, xScale, yScale, data ); |
|
|
|
var bpGrp = grp.selectAll('bp-grp') |
|
.data(data) |
|
.enter() |
|
.append('g') |
|
.attr('class', 'bp-grp'); |
|
|
|
genDots( bpGrp, sys, xScale, yScale, data, dotRadius ); |
|
genDots( bpGrp, dia, xScale, yScale, data, dotRadius ); |
|
addUX(); |
|
} |
|
|
|
function addUX() { |
|
var sys = 'systolic', |
|
dia = 'diastolic', |
|
rad = 4; |
|
|
|
focus.selectAll('.bp-grp') |
|
.each(function () { |
|
var dis = d3.select(this); |
|
var sysDot = dis.select('.systolic'); |
|
var sysX = sysDot.attr('cx'); |
|
var sysY = sysDot.attr('cy'); |
|
|
|
dis.append('rect') // add first rect so that mouseover works for bp group |
|
.attr('x', function(d) { return x( d.ts ) - rad } ) |
|
.attr('y', function(d) { return y( d.systolic ) }) |
|
.attr('width', 8) |
|
.attr('height', function(d) { return y( d.diastolic ) - y( d.systolic ) }) |
|
.attr('fill', 'transparent') |
|
.attr('stroke', 'none'); |
|
|
|
dis.on('mouseenter', function(d) { |
|
dis.append('rect') // add second rect on mouseenter to highlight bp group |
|
.attr('x', function(d) { return ( x( d.ts ) - rad ) - 1 }) |
|
.attr('rx', rad + 1 ) |
|
.attr('y', function(d) { return y( d[sys] ) - ( rad + 1 ) }) |
|
.attr('ry', rad + 1 ) |
|
.attr('width', ( rad * 2 ) + 2 ) |
|
.attr('height', function(d) { return ( y( d[dia] ) - y( d[sys]) ) + ( rad * 2 ) + 2 }) |
|
.attr('stroke', '#ddd') |
|
.attr('stroke-width', '.5') |
|
.attr('fill', 'rgba(220,220,220,0.2)') |
|
.attr('class', 'bp-outline'); |
|
}) |
|
.on('mouseleave', function(d) { |
|
d3.select(this).select('.bp-outline').remove(); |
|
}) |
|
.on('mousedown', function(d) { |
|
popupBp(d, sysX, sysY) |
|
}); |
|
}); |
|
} |
|
|
|
function getFocusInfo(extent) { |
|
|
|
var daysLen = 0; |
|
var evesLen = 0; |
|
|
|
var daySumSys = 0; |
|
var daySumDia = 0; |
|
var eveSumSys = 0; |
|
var eveSumDia = 0; |
|
|
|
var daysAvg = ''; |
|
var evesAvg = ''; |
|
|
|
|
|
bpData.forEach(function(d) { |
|
var hr; |
|
if ( d.ts >= extent[0] && d.ts <= extent[1] ) { |
|
hr = d.time.substring(11,13); |
|
if ( hr > '06' && hr < '18' ) { |
|
daysLen += 1; |
|
daySumSys += d.systolic; |
|
daySumDia += d.diastolic; |
|
} else { |
|
evesLen += 1; |
|
eveSumSys += d.systolic; |
|
eveSumDia += d.diastolic; |
|
} |
|
} |
|
}); |
|
|
|
daysAvg = daysLen ? Math.round( daySumSys / daysLen ) + '/' + Math.round( daySumDia / daysLen ) : 'none' |
|
evesAvg = evesLen ? Math.round( eveSumSys / evesLen ) + '/' + Math.round( eveSumDia / evesLen ) : 'none' |
|
|
|
return { |
|
range : { |
|
from: showDate(extent[0]), |
|
to: showDate(extent[1]) |
|
}, |
|
days : { |
|
avg: daysAvg, |
|
samples: daysLen |
|
}, |
|
eves : { |
|
avg: evesAvg, |
|
samples: evesLen |
|
} |
|
} |
|
} |
|
|
|
function showFocusInfo(extent) { |
|
var periodText, bpText, |
|
info = getFocusInfo(extent), |
|
// ugghh! no padding or margins for svg text |
|
periodSpans = [ |
|
{ txt : 'Period', dx : 0, hasCss : 'bold' }, |
|
{ txt : 'To: ' + info.range.to, dx : 30 }, |
|
{ txt : 'From: ' + info.range.from, dx : 12 }, |
|
], |
|
bpSpans = [ |
|
{ txt : 'Blood Pressure Averages ', dx : 0, hasCss : 'bold' }, |
|
{ txt : 'Days: ' + info.days.avg, dx : 12 }, |
|
{ txt : 'Samples: ' + info.days.samples, dx : 12 }, |
|
{ txt : 'Eves: ' + info.eves.avg, dx : 20 }, |
|
{ txt : 'Samples: ' + info.eves.samples, dx : 12 } |
|
]; |
|
|
|
focusInfo.selectAll('text').remove(); |
|
focusInfo.selectAll('.line-arrow').remove(); |
|
|
|
periodText = focusInfo.append('text') |
|
.attr('x', (width * .5) - 12.5 ) // offset for width of one arrow |
|
.attr('y', 15) |
|
.attr('text-anchor', 'middle'); |
|
|
|
appendSpans(periodText, periodSpans); |
|
|
|
bpText = focusInfo.append('text') |
|
.attr('x', width * .5) |
|
.attr('y', 35) |
|
.attr('text-anchor', 'middle'); |
|
|
|
appendSpans(bpText, bpSpans); |
|
|
|
appendArrow(focusInfo.select('tspan'), '#line-arrow-left') |
|
appendArrow(focusInfo.select('text'), '#line-arrow-right') |
|
|
|
function appendSpans(el, spans) { |
|
spans.forEach(function (span) { |
|
var tspan = el.append('tspan') |
|
.text(span.txt) |
|
.attr('dx', span.dx); |
|
|
|
if ( span.hasCss ) { tspan.attr('class', span.hasCss ) } |
|
}); |
|
} |
|
|
|
function appendArrow(d3el, arrowId) { |
|
var txtEl = d3el.node(), |
|
transX = (txtEl.getBoundingClientRect().left + txtEl.offsetWidth) - getTrans(focusInfo).tx, |
|
arrowGrp = focusInfo.append('g') |
|
.attr('class', 'line-arrow') |
|
.attr('transform', 'translate(' + transX + ',5.5)'); |
|
|
|
arrowGrp.append('use') |
|
.attr('xlink:href', arrowId); |
|
}; |
|
} |
|
|
|
function brushed() { |
|
var xBefore, xAfter, xLast; |
|
var domain = brush.empty() ? x2.domain() : brush.extent(); |
|
|
|
var minTs = domain[0].getTime(); |
|
var maxTs = domain[1].getTime(); |
|
|
|
x.domain(domain); |
|
|
|
avgExtent[0] = minTs; |
|
avgExtent[1] = maxTs; |
|
|
|
focus.selectAll('.bp-line').remove(); |
|
focus.selectAll('.bp-grp').remove(); |
|
|
|
genBpChart ( focus, x, y, bpData, 4 ); |
|
|
|
focus.select('.x.axis').call(xAxis); |
|
} |
|
|
|
function popupBp( d, bpx, bpy ) { |
|
var popupWidth = 0; |
|
var transX = 0; |
|
|
|
var popup = svg.append('g') // appended to svg so it floats over everything else |
|
.datum(d) |
|
.attr('class', 'popup'); |
|
|
|
var popupBg = popup.append('rect') |
|
.attr('x', 0) |
|
.attr('y', 0) |
|
.attr('rx', 2) |
|
.attr('ry', 2) |
|
.attr('width', 100) |
|
.attr('height', 80) |
|
.attr('fill', '#efefef') |
|
.attr('stroke', '#ddd') |
|
.attr('stroke-width', '.5') |
|
|
|
var sysDot = popup.append('circle') |
|
.attr('class', 'systolic') |
|
.attr('cx', 17) |
|
.attr('cy', 17) |
|
.attr('r', 7) |
|
.attr('fill', getRange('systolic', d.systolic).fill) |
|
|
|
var sysText = popup.append('text') |
|
.attr('x', 30) |
|
.attr('y', 22) |
|
.text('Systolic:') |
|
.append('tspan') |
|
.text(d.systolic) |
|
.attr('dx', 10) |
|
|
|
var diaDot = popup.append('circle') |
|
.attr('class', 'diastolic') |
|
.attr('cx', 17) |
|
.attr('cy', 43) |
|
.attr('r', 7) |
|
.attr('fill', getRange('diastolic', d.diastolic).fill) |
|
|
|
var diaText = popup.append('text') |
|
.attr('x', 30) |
|
.attr('y', 48) |
|
.text('Diastolic:') |
|
.append('tspan') |
|
.text(d.diastolic) |
|
.attr('dx', 5) |
|
|
|
var timeText = popup.append('text') |
|
.attr('x', 10) |
|
.attr('y', 70) |
|
.text(showDate(d.ts)); |
|
|
|
popupWidth = timeText.node().getBoundingClientRect().width + 20; |
|
|
|
popupBg.attr('width', popupWidth); |
|
|
|
var closeButton = popup.append('g') |
|
.attr('class', 'close-button') |
|
.attr('transform', 'translate(' + (popupWidth - 28) + ',8)') |
|
.on('click', function(evt) { |
|
popup.remove(); |
|
}); |
|
|
|
closeButton.append('use') |
|
.attr('xlink:href', '#close-button'); |
|
|
|
bpx = parseFloat(bpx, 10); |
|
bpy = parseFloat(bpy, 10); |
|
if ( bpx + popupWidth + margin.left + 25 > width ) { |
|
transX = bpx - popupWidth - margin.left - 25 |
|
} else { |
|
transX = bpx + 25 + margin.left; |
|
} |
|
|
|
popup.attr('transform', 'translate(' + transX + ',' + (bpy + margin.top) + ')'); |
|
|
|
popupDrag(popup); |
|
} |
|
|
|
function popupDrag(dragger) { |
|
|
|
dragger.call(d3.behavior.drag() |
|
.on('dragstart', dragStr) |
|
.on('drag', dragging) |
|
.on('dragend', dragEnd) |
|
); |
|
|
|
function dragStr () { |
|
dragger.transition() |
|
.duration(200) |
|
.attr('opacity', .4); |
|
} |
|
|
|
function dragging () { |
|
d3.event.sourceEvent.cancelBubble = true; |
|
|
|
var trans = getTrans(dragger) |
|
var transX = d3.event.dx + trans.tx; |
|
var transY = d3.event.dy + trans.ty; |
|
|
|
dragger.attr('transform', 'translate(' + transX + ',' + transY + ')'); |
|
} |
|
|
|
function dragEnd () { |
|
d3.event.sourceEvent.cancelBubble = true; |
|
|
|
dragger.transition() |
|
.duration(200) |
|
.attr('opacity', 1); |
|
} |
|
} |
|
|
|
function showDate(ts) { |
|
var tsloc = ts + (new Date(ts).getTimezoneOffset() * 60 * 1000); // shows date/time in time local to browser |
|
return timeFormat(new Date(tsloc)).replace(/AM/g, 'am').replace(/PM/g, 'pm'); |
|
} |
|
|
|
function getTrans(el) { |
|
var trans = el.attr('transform').match(/translate\(([\d\.,]+\))/g) |
|
var txy = [0,0]; |
|
if (trans) { |
|
txy = trans[0].replace(/translate\(/, '') .replace(/\)/, '').split(','); |
|
} |
|
var tx = txy[1] ? parseFloat(txy[0]) : 0; |
|
var ty = txy[1] ? parseFloat(txy[1]) : 0; |
|
return { tx: tx, ty: ty }; |
|
} |
|
|
|
function padZ(num, z) { |
|
var padded = num + '', |
|
len = z || 2; |
|
|
|
if (padded.length < len) { |
|
while (padded.length < len) { |
|
padded = '0' + padded; |
|
} |
|
} |
|
return padded; |
|
} |
|
}()); |