'use strict';
var _ = require('lodash');
var times = require('../times');
var consts = require('../constants');
var DEFAULT_FOCUS = times.hours(3).msecs
, WIDTH_SMALL_DOTS = 420
, WIDTH_BIG_DOTS = 800
, TOOLTIP_WIDTH = 150 //min-width + padding
;
const zeroDate = new Date(0);
function init (client, d3) {
var renderer = {};
var utils = client.utils;
var translate = client.translate;
function getOrAddDate(entry) {
if (entry.date) return entry.date;
entry.date = new Date(entry.mills);
return entry.date;
}
//chart isn't created till the client gets data, so can grab the var at init
function chart () {
return client.chart;
}
function focusRangeAdjustment () {
return client.focusRangeMS === DEFAULT_FOCUS ? 1 : 1 + ((client.focusRangeMS - DEFAULT_FOCUS) / DEFAULT_FOCUS / 8);
}
var dotRadius = function(type) {
var radius = chart().prevChartWidth > WIDTH_BIG_DOTS ? 4 : (chart().prevChartWidth < WIDTH_SMALL_DOTS ? 2 : 3);
if (type === 'mbg') {
radius *= 2;
} else if (type === 'forecast') {
radius = Math.min(3, radius - 1);
} else if (type === 'rawbg') {
radius = Math.min(2, radius - 1);
}
return radius / focusRangeAdjustment();
};
function tooltipLeft () {
var windowWidth = $(client.tooltip.node()).parent().parent().width();
var left = d3.event.pageX + TOOLTIP_WIDTH < windowWidth ? d3.event.pageX : windowWidth - TOOLTIP_WIDTH - 10;
return left + 'px';
}
function hideTooltip () {
client.tooltip.style('opacity', 0);
}
// get the desired opacity for context chart based on the brush extent
renderer.highlightBrushPoints = function highlightBrushPoints (data, from, to) {
if (client.latestSGV && data.mills >= from && data.mills <= to) {
return chart().futureOpacity(data.mills - client.latestSGV.mills);
} else {
return 0.5;
}
};
renderer.bubbleScale = function bubbleScale () {
// a higher bubbleScale will produce smaller bubbles (it's not a radius like focusDotRadius)
return (chart().prevChartWidth < WIDTH_SMALL_DOTS ? 4 : (chart().prevChartWidth < WIDTH_BIG_DOTS ? 3 : 2)) * focusRangeAdjustment();
};
renderer.addFocusCircles = function addFocusCircles () {
function updateFocusCircles (sel) {
var badData = [];
sel.attr('cx', function(d) {
if (!d) {
console.error('Bad data', d);
return chart().xScale(zeroDate);
} else if (!d.mills) {
console.error('Bad data, no mills', d);
return chart().xScale(zeroDate);
} else {
return chart().xScale(getOrAddDate(d));
}
})
.attr('cy', function(d) {
var scaled = client.sbx.scaleEntry(d);
if (isNaN(scaled)) {
badData.push(d);
return chart().yScale(utils.scaleMgdl(450));
} else {
return chart().yScale(scaled);
}
})
.attr('opacity', function(d) {
if (d.noFade) {
return null;
} else {
return !client.latestSGV ? 1 : chart().futureOpacity(d.mills - client.latestSGV.mills);
}
})
.attr('r', function(d) {
return dotRadius(d.type);
});
if (badData.length > 0) {
console.warn('Bad Data: isNaN(sgv)', badData);
}
return sel;
}
function prepareFocusCircles (sel) {
updateFocusCircles(sel)
.attr('fill', function(d) {
return d.type === 'forecast' ? 'none' : d.color;
})
.attr('stroke-width', function(d) {
return d.type === 'mbg' ? 2 : d.type === 'forecast' ? 2 : 0;
})
.attr('stroke', function(d) {
return (d.type === 'mbg' ? 'white' : d.color);
});
return sel;
}
function focusCircleTooltip (d) {
if (d.type !== 'sgv' && d.type !== 'mbg' && d.type !== 'forecast') {
return;
}
function getRawbgInfo () {
var info = {};
var sbx = client.sbx.withExtendedSettings(client.rawbg);
if (d.type === 'sgv') {
info.noise = client.rawbg.noiseCodeToDisplay(d.mgdl, d.noise);
if (client.rawbg.showRawBGs(d.mgdl, d.noise, client.ddata.cal, sbx)) {
info.value = utils.scaleMgdl(client.rawbg.calc(d, client.ddata.cal, sbx));
}
}
return info;
}
var rawbgInfo = getRawbgInfo();
client.tooltip.style('opacity', .9);
client.tooltip.html('' + translate('BG') + ': ' + client.sbx.scaleEntry(d) +
(d.type === 'mbg' ? '
' + translate('Device') + ': ' + d.device : '') +
(d.type === 'forecast' && d.forecastType ? '
' + translate('Forecast Type') + ': ' + d.forecastType : '') +
(rawbgInfo.value ? '
' + translate('Raw BG') + ': ' + rawbgInfo.value : '') +
(rawbgInfo.noise ? '
' + translate('Noise') + ': ' + rawbgInfo.noise : '') +
'
' + translate('Time') + ': ' + client.formatTime(getOrAddDate(d)))
.style('left', tooltipLeft())
.style('top', (d3.event.pageY + 15) + 'px');
}
// CGM data
var focusData = client.entries;
// bind up the focus chart data to an array of circles
// selects all our data into data and uses date function to get current max date
var focusCircles = chart().focus.selectAll('circle.entry-dot').data(focusData, function genKey (d) {
return "cgmreading." + d.mills;
});
// if already existing then transition each circle to its new position
updateFocusCircles(focusCircles);
// if new circle then just display
prepareFocusCircles(focusCircles.enter().append('circle'))
.attr('class', 'entry-dot')
.on('mouseover', focusCircleTooltip)
.on('mouseout', hideTooltip);
focusCircles.exit().remove();
// Forecasts
var shownForecastPoints = client.chart.getForecastData();
// bind up the focus chart data to an array of circles
// selects all our data into data and uses date function to get current max date
var forecastCircles = chart().focus.selectAll('circle.forecast-dot').data(shownForecastPoints, function genKey (d) {
return d.forecastType + d.mills;
});
forecastCircles.exit().remove();
prepareFocusCircles(forecastCircles.enter().append('circle'))
.attr('class', 'forecast-dot')
.on('mouseover', focusCircleTooltip)
.on('mouseout', hideTooltip);
updateFocusCircles(forecastCircles);
};
renderer.addTreatmentCircles = function addTreatmentCircles (nowDate) {
function treatmentTooltip (d) {
var targetBottom = d.targetBottom;
var targetTop = d.targetTop;
if (client.settings.units === 'mmol') {
targetBottom = Math.round(targetBottom / consts.MMOL_TO_MGDL * 10) / 10;
targetTop = Math.round(targetTop / consts.MMOL_TO_MGDL * 10) / 10;
}
var correctionRangeText;
if (d.correctionRange) {
var min = d.correctionRange[0];
var max = d.correctionRange[1];
if (client.settings.units === 'mmol') {
max = client.sbx.roundBGToDisplayFormat(client.sbx.scaleMgdl(max));
min = client.sbx.roundBGToDisplayFormat(client.sbx.scaleMgdl(min));
}
if (d.correctionRange[0] === d.correctionRange[1]) {
correctionRangeText = '' + min;
} else {
correctionRangeText = '' + min + ' - ' + max;
}
}
var durationText;
if (d.durationType === "indefinite") {
durationText = translate("Indefinite");
} else if (d.duration) {
var durationMinutes = Math.round(d.duration);
if (durationMinutes > 0 && durationMinutes % 60 == 0) {
var durationHours = durationMinutes / 60;
if (durationHours > 1) {
durationText = durationHours + ' hours';
} else {
durationText = durationHours + ' hour';
}
} else {
durationText = durationMinutes + ' min';
}
}
return '' + translate('Time') + ': ' + client.formatTime(getOrAddDate(d)) + '
' +
(d.eventType ? '' + translate('Treatment type') + ': ' + translate(client.careportal.resolveEventName(d.eventType)) + '
' : '') +
(d.reason ? '' + translate('Reason') + ': ' + translate(d.reason) + '
' : '') +
(d.glucose ? '' + translate('BG') + ': ' + d.glucose + (d.glucoseType ? ' (' + translate(d.glucoseType) + ')' : '') + '
' : '') +
(d.enteredBy ? '' + translate('Entered By') + ': ' + d.enteredBy + '
' : '') +
(d.targetTop ? '' + translate('Target Top') + ': ' + targetTop + '
' : '') +
(d.targetBottom ? '' + translate('Target Bottom') + ': ' + targetBottom + '
' : '') +
(durationText ? '' + translate('Duration') + ': ' + durationText + '
' : '') +
(d.insulinNeedsScaleFactor ? '' + translate('Insulin Scale Factor') + ': ' + d.insulinNeedsScaleFactor * 100 + '%
' : '') +
(correctionRangeText ? '' + translate('Correction Range') + ': ' + correctionRangeText + '
' : '') +
(d.transmitterId ? '' + translate('Transmitter ID') + ': ' + d.transmitterId + '
' : '') +
(d.sensorCode ? '' + translate('Sensor Code') + ': ' + d.sensorCode + '
' : '') +
(d.notes ? '' + translate('Notes') + ': ' + d.notes : '');
}
function announcementTooltip (d) {
return '' + translate('Time') + ': ' + client.formatTime(getOrAddDate(d)) + '
' +
(d.eventType ? '' + translate('Announcement') + '
' : '') +
(d.notes && d.notes.length > 1 ? '' + translate('Message') + ': ' + d.notes + '
' : '') +
(d.enteredBy ? '' + translate('Entered By') + ': ' + d.enteredBy + '
' : '');
}
//TODO: filter in oref0 instead of here and after most people upgrade take this out
var openAPSSpam = ['BasalProfileStart', 'ResultDailyTotal', 'BGReceived'];
//NOTE: treatments with insulin or carbs are drawn by drawTreatment()
// bind up the focus chart data to an array of circles
var treatCircles = chart().focus.selectAll('.treatment-dot').data(client.ddata.treatments.filter(function(treatment) {
var notCarbsOrInsulin = !treatment.carbs && !treatment.insulin;
var notTempOrProfile = !_.includes(['Temp Basal', 'Profile Switch', 'Combo Bolus', 'Temporary Target'], treatment.eventType);
var notes = treatment.notes || '';
var enteredBy = treatment.enteredBy || '';
var notOpenAPSSpam = enteredBy.indexOf('openaps://') === -1 || _.isUndefined(_.find(openAPSSpam, function startsWith (spam) {
return notes.indexOf(spam) === 0;
}));
return notCarbsOrInsulin && !treatment.duration && treatment.durationType !== 'indefinite' && notTempOrProfile && notOpenAPSSpam;
}), function (d) { return d._id; });
function updateTreatCircles (sel) {
sel.attr('cx', function(d) {
return chart().xScale(getOrAddDate(d));
})
.attr('cy', function(d) {
return chart().yScale(client.sbx.scaleEntry(d));
})
.attr('r', function() {
return dotRadius('mbg');
});
return sel;
}
function prepareTreatCircles (sel) {
function strokeColor (d) {
var color = 'white';
if (d.isAnnouncement) {
color = 'orange';
} else if (d.glucose) {
color = 'grey';
}
return color;
}
function fillColor (d) {
var color = 'grey';
if (d.isAnnouncement) {
color = 'orange';
} else if (d.glucose) {
color = 'red';
}
return color;
}
updateTreatCircles(sel)
.attr('stroke-width', 2)
.attr('stroke', strokeColor)
.attr('fill', fillColor);
return sel;
}
// if already existing then transition each circle to its new position
updateTreatCircles(treatCircles);
// if new circle then just display
prepareTreatCircles(treatCircles.enter().append('circle'))
.attr('class', 'treatment-dot')
.on('mouseover', function(d) {
client.tooltip.style('opacity', .9);
client.tooltip.html(d.isAnnouncement ? announcementTooltip(d) : treatmentTooltip(d))
.style('left', tooltipLeft())
.style('top', (d3.event.pageY + 15) + 'px');
})
.on('mouseout', hideTooltip);
treatCircles.exit().remove();
var durationTreatments = client.ddata.treatments.filter(function(treatment) {
return !treatment.carbs && !treatment.insulin && (treatment.duration || treatment.durationType !== undefined) &&
!_.includes(['Temp Basal', 'Profile Switch', 'Combo Bolus', 'Temporary Target'], treatment.eventType);
});
//use the processed temp target so there are no overlaps
durationTreatments = durationTreatments.concat(client.ddata.tempTargetTreatments);
// treatments with duration
var treatRects = chart().focus.selectAll('.g-duration').data(durationTreatments);
function fillColor (d) {
// this is going to be updated by Event Type
var color = 'grey';
if (d.eventType === 'Exercise') {
color = 'Violet';
} else if (d.eventType === 'Note') {
color = 'Salmon';
} else if (d.eventType === 'Temporary Target') {
color = 'lightgray';
}
return color;
}
function rectHeight (d) {
var height = 20;
if (d.targetTop && d.targetTop > 0 && d.targetBottom && d.targetBottom > 0) {
height = Math.max(5, d.targetTop - d.targetBottom);
}
return height;
}
function rectTranslate (d) {
var top = 50;
if (d.eventType === 'Temporary Target') {
top = d.targetTop === d.targetBottom ? d.targetTop + rectHeight(d) : d.targetTop;
}
return 'translate(' + chart().xScale(getOrAddDate(d)) + ',' + chart().yScale(utils.scaleMgdl(top)) + ')';
}
function treatmentRectWidth (d) {
if (d.durationType === "indefinite") {
return chart().xScale(chart().xScale.domain()[1].getTime()) - chart().xScale(getOrAddDate(d));
} else {
return chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(getOrAddDate(d));
}
}
function treatmentTextTransform (d) {
if (d.durationType === "indefinite") {
var offset = 0;
if (chart().xScale(getOrAddDate(d)) < chart().xScale(chart().xScale.domain()[0].getTime())) {
offset = chart().xScale(nowDate) - chart().xScale(getOrAddDate(d));
}
return 'translate(' + offset + ',' + 10 + ')';
} else {
return 'translate(' + (chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(getOrAddDate(d))) / 2 + ',' + 10 + ')';
}
}
function treatmentText (d) {
if (d.eventType === 'Temporary Target') {
return '';
}
return d.notes || d.reason || d.eventType;
}
function treatmentTextAnchor (d) {
return d.durationType === "indefinite" ? 'left' : 'middle';
}
// if transitioning, update rect text, position, and width
var rectUpdates = treatRects;
rectUpdates.attr('transform', rectTranslate);
rectUpdates.select('text')
.text(treatmentText)
.attr('text-anchor', treatmentTextAnchor)
.attr('transform', treatmentTextTransform);
rectUpdates.select('rect')
.attr('width', treatmentRectWidth)
// if new rect then create new elements
var newRects = treatRects.enter().append('g')
.attr('class', 'g-duration')
.attr('transform', rectTranslate)
.on('mouseover', function(d) {
client.tooltip.style('opacity', .9);
client.tooltip.html(d.isAnnouncement ? announcementTooltip(d) : treatmentTooltip(d))
.style('left', tooltipLeft())
.style('top', (d3.event.pageY + 15) + 'px');
})
.on('mouseout', hideTooltip);
newRects.append('rect')
.attr('class', 'g-duration-rect')
.attr('width', treatmentRectWidth)
.attr('height', rectHeight)
.attr('rx', 5)
.attr('ry', 5)
.attr('opacity', .2)
.attr('fill', fillColor);
newRects.append('text')
.attr('class', 'g-duration-text')
.style('font-size', 15)
.attr('fill', 'white')
.attr('text-anchor', treatmentTextAnchor)
.attr('dy', '.35em')
.attr('transform', treatmentTextTransform)
.text(treatmentText);
// Remove any rects no longer needed
treatRects.exit().remove();
};
renderer.addContextCircles = function addContextCircles () {
// bind up the context chart data to an array of circles
var contextCircles = chart().context.selectAll('circle').data(client.entries);
function prepareContextCircles (sel) {
var badData = [];
sel.attr('cx', function(d) { return chart().xScale2(getOrAddDate(d)); })
.attr('cy', function(d) {
var scaled = client.sbx.scaleEntry(d);
if (isNaN(scaled)) {
badData.push(d);
return chart().yScale2(utils.scaleMgdl(450));
} else {
return chart().yScale2(scaled);
}
})
.attr('fill', function(d) { return d.color; })
//.style('opacity', function(d) { return renderer.highlightBrushPoints(d) })
.attr('stroke-width', function(d) { return d.type === 'mbg' ? 2 : 0; })
.attr('stroke', function() { return 'white'; })
.attr('r', function(d) { return d.type === 'mbg' ? 4 : 2; });
if (badData.length > 0) {
console.warn('Bad Data: isNaN(sgv)', badData);
}
return sel;
}
// if already existing then transition each circle to its new position
prepareContextCircles(contextCircles);
// if new circle then just display
prepareContextCircles(contextCircles.enter().append('circle'));
contextCircles.exit().remove();
};
function calcTreatmentRadius (treatment, opts, carbratio) {
var CR = treatment.CR || carbratio || 20;
var carbsOrInsulin = CR;
if (treatment.carbs) {
carbsOrInsulin = treatment.carbs;
} else if (treatment.insulin) {
carbsOrInsulin = treatment.insulin * CR;
}
// R1 determines the size of the treatment dot
var R1 = Math.sqrt(carbsOrInsulin) / opts.scale
, R2 = R1
// R3/R4 determine how far from the treatment dot the labels are placed
, R3 = R1 + 8 / opts.scale
, R4 = R1 + 25 / opts.scale;
return {
R1: R1
, R2: R2
, R3: R3
, R4: R4
, isNaN: isNaN(R1) || isNaN(R3) || isNaN(R3)
};
}
function prepareArc (treatment, radius, bolusSettings) {
var arc_data = [
// white carb half-circle on top
{ 'element': '', 'color': 'white', 'start': -1.5708, 'end': 1.5708, 'inner': 0, 'outer': radius.R1 }
, { 'element': '', 'color': 'transparent', 'start': -1.5708, 'end': 1.5708, 'inner': radius.R2, 'outer': radius.R3 },
// blue insulin half-circle on bottom
{ 'element': '', 'color': '#0099ff', 'start': 1.5708, 'end': 4.7124, 'inner': 0, 'outer': radius.R1 },
// these form a very short transparent arc along the bottom of an insulin treatment to position the label
// these used to be semicircles from 1.5708 to 4.7124, but that made the tooltip target too big
{ 'element': '', 'color': 'transparent', 'start': 3.1400, 'end': 3.1432, 'inner': radius.R2, 'outer': radius.R3 }
, { 'element': '', 'color': 'transparent', 'start': 3.1400, 'end': 3.1432, 'inner': radius.R2, 'outer': radius.R4 }
]
, arc_data_1_elements = [];
arc_data[0].outlineOnly = !treatment.carbs;
arc_data[2].outlineOnly = !treatment.insulin;
if (treatment.carbs > 0) {
arc_data_1_elements.push(Math.round(treatment.carbs) + ' g');
}
if (treatment.protein > 0) {
arc_data_1_elements.push(Math.round(treatment.protein) + ' g');
}
if (treatment.fat > 0) {
arc_data_1_elements.push(Math.round(treatment.fat) + ' g');
}
arc_data[1].element = arc_data_1_elements.join(' / ');
if (treatment.foodType) {
arc_data[1].element = arc_data[1].element + " " + treatment.foodType;
}
if (treatment.insulin > 0) {
var dosage_units = '' + Math.round(treatment.insulin * 100) / 100;
var format = treatment.insulin < bolusSettings.renderOver ? bolusSettings.renderFormatSmall : bolusSettings.renderFormat;
if (_.includes(['concise', 'minimal'], format)) {
dosage_units = (dosage_units + "").replace(/^0/, "");
}
var unit_of_measurement = (format === 'minimal' ? '' : ' U'); // One international unit of insulin (1 IU) is shown as '1 U'
arc_data[3].element = dosage_units + unit_of_measurement;
}
if (treatment.status) {
arc_data[4].element = translate(treatment.status);
}
var arc = d3.arc()
.innerRadius(function(d) {
return 5 * d.inner;
})
.outerRadius(function(d) {
return 5 * d.outer;
})
.endAngle(function(d) {
return d.start;
})
.startAngle(function(d) {
return d.end;
});
return {
data: arc_data
, svg: arc
};
}
function isInRect (x, y, rect) {
return !(x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height);
}
function appendTreatments (treatment, arc) {
function boluscalcTooltip (treatment) {
if (!treatment.boluscalc) {
return '';
}
var html = '
| ' + translate('Food') + ' | ||
| ' + f.name + ' | '; html += '' + (f.portion * f.portions).toFixed(1) + ' ' + f.unit + ' | '; html += '(' + (f.carbs * f.portions).toFixed(1) + ' g) | '; html += '