VulpesOS/gaia/apps/costcontrol/js/utils/chart.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

585 lines
18 KiB
JavaScript
Executable file

/* global _, debug, Toolkit, Formatting, Common */
/* exported ChartUtils */
'use strict';
var ChartUtils = (function() {
var DEVICE_PIXEL_RATIO = window.devicePixelRatio || 1;
var DAY = 24 * 60 * 60 * 1000;
var NEVER_PERIOD = 30 * DAY;
var NEVER_ANCHOR = 21 * DAY;
var CHART_BG_RATIO = 0.87;
var FONT_SIZE = toDevicePixels(13);
var FONT_WEIGHT = '600';
var FONT_WEIGHT_AXIS = '400';
var WIFI_CHART_STROKE = '#8b9052';
var WIFI_CHART_FILL = '#cbd936';
var MOBILE_CHART_STROKE = '#762d4a';
var MOBILE_CHART_FILL = 'rgba(147, 21, 98, 0.7)';
function toDevicePixels(value) {
return value * DEVICE_PIXEL_RATIO;
}
function makeCSSFontString(fontSize, fontWeight) {
return fontWeight + ' ' + fontSize + 'px sans-serif';
}
function formatChartDate(date) {
return _('verbose-chart-date-format', {
'monthday-number': date.getDate(),
'em-month': _('month-' + date.getMonth() + '-short')
});
}
function getLimitInBytes(settings) {
var multiplier = 1000000; // MB
if (settings.dataLimitUnit === 'GB') {
multiplier = 1000000000; // GB
}
var value = settings.dataLimitValue;
return (value && value !== 0) ? value * multiplier : null;
}
function calculateUpperDate(settings) {
var today = new Date();
var trackingPeriod = settings.trackingPeriod;
var nextReset = settings.nextReset;
if (trackingPeriod !== 'never' && nextReset) {
return new Date(nextReset.getTime() - DAY);
}
var lastReset = settings.lastCompleteDataReset || today;
var offset = today.getTime() - lastReset.getTime();
var upperDate = new Date(lastReset.getTime() + NEVER_PERIOD);
if (offset >= NEVER_ANCHOR) {
upperDate = new Date(today.getTime() + (NEVER_PERIOD - NEVER_ANCHOR));
}
debug('Upper date:', upperDate);
return upperDate;
}
function calculateLowerDate(settings) {
var today = new Date();
var lowerDate = Toolkit.toMidnight(new Date());
var nextReset = settings.nextReset || lowerDate;
var trackingPeriod = settings.trackingPeriod;
if (trackingPeriod === 'weekly') {
lowerDate.setTime(nextReset.getTime() - (7 * DAY));
} else if (trackingPeriod === 'monthly') {
var monthDate = settings.resetTime;
var isFirstDayOfPeriod = today.getDate() == monthDate;
var isAfterFirstDayOfPeriod = today.getDate() > monthDate;
if (isAfterFirstDayOfPeriod) {
// lowerDate is in the current month
lowerDate.setDate(monthDate);
} else if (!isFirstDayOfPeriod) {
// lowerDate is on the previous month
var LAST_DAY_OF_PREVIOUS_MONTH = 0;
var newMonth = today.getMonth() - 1;
var newYear = today.getFullYear();
if (newMonth < 0) {
newMonth = 11;
newYear--;
}
lowerDate = Toolkit.toMidnight(new Date(newYear, newMonth, monthDate));
// The day of the month of lowerDate is different to settings.resetTime
// value when the resetTime day doesn't exists this month, (eg. 30th
// February), on this case, the lowerDate must be the last day of the
// previous month
if (lowerDate.getDate() != monthDate) {
lowerDate = Toolkit.toMidnight(new Date());
lowerDate.setDate(LAST_DAY_OF_PREVIOUS_MONTH);
}
}
} else {
var lastReset = settings.lastCompleteDataReset || lowerDate;
lowerDate = lastReset;
var offset = today.getTime() - lastReset.getTime();
if (offset >= NEVER_ANCHOR) {
lowerDate = new Date(today.getTime() - NEVER_ANCHOR);
}
}
debug('Lower date:', lowerDate);
return lowerDate;
}
// Expand the model with some computed values
function expandModel(base) {
// Update today
var today = Toolkit.toMidnight(new Date());
// Graphic settings
base.originY = Math.floor(base.height * CHART_BG_RATIO);
// Today value
base.axis.X.today = today;
// Normalize today
Toolkit.toMidnight(base.axis.X.today);
Toolkit.toMidnight(base.axis.X.lower);
Toolkit.toMidnight(base.axis.X.upper);
// X axis projection function to convert a value into a pixel value
var xLowerBound = base.axis.X.lower.getTime();
var xSize = base.axis.X.upper.getTime() - xLowerBound;
var realWidth = base.endX - base.originX;
base.axis.X.len = realWidth;
base.axis.X.get = function cc_dataToXPx(value) {
var projection = (value.getTime() - xLowerBound) / xSize;
return projection * realWidth + base.originX;
};
// Y max value
var limitEnabled = true; // XXX: model.limits.enabled;
base.axis.Y.maxValue = Math.max(limitEnabled ? base.limits.value : 0,
base.data.mobile.total,
base.data.wifi.total);
// Y axis projection function and automatic values
base.axis.Y.range = base.axis.Y.upper - base.axis.Y.lower;
base.axis.Y.upper = (1 + base.axis.Y.margin) * base.axis.Y.maxValue;
var yLowerBound = base.axis.Y.lower;
var ySize = base.axis.Y.upper - yLowerBound;
base.axis.Y.get = function cc_dataToYPx(value) {
var projection = (value - yLowerBound) / ySize;
return base.originY * (1 - projection);
};
base.axis.Y.step = base.axis.Y.get(base.axis.Y.maxValue);
// Limits
base.limits.warning = Common.DATA_USAGE_WARNING;
base.limits.warningValue = base.limits.value * Common.DATA_USAGE_WARNING;
}
// Check if the segment of the graph is inside chart area. If so, draw it
function clipAndDrawSegment(ctx, model, x0, y0, x1, y1) {
if (x0 >= model.originX && x1 <= model.endX) {
var x0Fixed = Math.floor(x0) - 0.5;
var x1Fixed = Math.floor(x1) + 0.5;
// Fill
ctx.globalCompositeOperation = 'destination-over';
ctx.beginPath();
ctx.lineTo(x1Fixed, y1);
ctx.lineTo(x1Fixed, model.originY);
ctx.lineTo(x0Fixed, model.originY);
ctx.lineTo(x0Fixed, y0);
ctx.moveTo(x1Fixed, y0);
ctx.fill();
// Stroke
ctx.globalCompositeOperation = 'source-over';
ctx.beginPath();
// It's necessary add 1 to the X coordinate to make up for the offset
// produced with the drawing of the previous line.
ctx.moveTo(x0Fixed + 1, y0);
ctx.lineTo(x1Fixed, y1);
ctx.stroke();
}
}
function drawTodayMark(ctx, x, y, color) {
ctx.save();
var radius = toDevicePixels(4);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fill();
ctx.restore();
}
function drawBackgroundLayer(canvas, model, showMobile) {
canvas.height = model.height;
var width = canvas.width = model.width;
var ctx = canvas.getContext('2d');
ctx.save();
// White bg
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, width, model.originY);
// Horizontal lines every step
var step = model.axis.Y.step;
var limitY = model.axis.Y.get(model.limits.value);
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = toDevicePixels(1);
var displayLimit = model.limits.enabled && showMobile;
for (var y = model.originY - step; y > step; y -= step) {
if (displayLimit && same(y, limitY, 0.1)) {
continue;
}
var drawY = Math.floor(y) - 0.5;
ctx.beginPath();
ctx.moveTo(model.originX, drawY);
ctx.lineTo(model.endX, drawY);
ctx.stroke();
}
// Vertical lines every day
var days = (model.axis.X.upper - model.axis.X.lower) / DAY;
step = model.axis.X.len / days;
ctx.strokeStyle = '#eeeeee';
ctx.lineWidth = toDevicePixels(1);
for (var x = model.originX; x <= model.endX; x += step) {
var drawX = Math.floor(x) + 0.5;
ctx.beginPath();
ctx.moveTo(drawX, model.originY);
ctx.lineTo(drawX, 0);
ctx.stroke();
// Ensure draw the last vertical line
if (((x + step) > model.endX) && (x !== model.endX)) {
// the 0.5 offset is needed to avoid drawing a double line.
drawX = model.endX - 0.5;
ctx.beginPath();
ctx.moveTo(drawX, model.originY);
ctx.lineTo(drawX, 0);
ctx.stroke();
}
}
ctx.restore();
// Return true if a and b not differ more than threshold
function same(a, b, threshold) {
threshold = threshold || 0;
return Math.abs(a - b) <= threshold;
}
}
function drawTodayLayer(canvas, model) {
canvas.height = model.height;
canvas.width = model.width;
var ctx = canvas.getContext('2d');
// Compute the X offset
var offsetX = model.axis.X.get(model.axis.X.today);
// Configure Centered today text
var marginTop = toDevicePixels(10);
var todayTag = formatChartDate(model.axis.X.today);
// Render the text
ctx.font = makeCSSFontString(FONT_SIZE, FONT_WEIGHT);
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// Update measues of today label
var metrics = ctx.measureText(todayTag);
model.todayLabel.x0 = offsetX - metrics.width / 2;
model.todayLabel.x1 = offsetX + metrics.width / 2;
if (model.todayLabel.x1 > model.endX) {
ctx.textAlign = 'right';
}
ctx.fillStyle = 'black';
ctx.fillText(todayTag, offsetX, model.originY + marginTop);
setAccessibilityAttributes(canvas, 'today-layer', { today: todayTag });
}
function drawAxisLayer(canvas, model, showMobile) {
canvas.height = model.height;
canvas.width = model.width;
var ctx = canvas.getContext('2d');
// Start drawing Y axis
var step = model.axis.Y.step;
var dataStep = model.axis.Y.upper - model.axis.Y.maxValue;
var marginRight = 4;
var offsetX = model.originX - marginRight;
ctx.font = makeCSSFontString(FONT_SIZE, FONT_WEIGHT_AXIS);
ctx.textAlign = 'right';
var displayLimit = showMobile && model.limits.enabled;
var lastUnit;
for (var y = 0.5 + model.originY, value = 0;
y > step; y -= step, value += dataStep) {
// First X label for 0 is aligned with the bottom
if (value === 0) {
lastUnit = Formatting.smartRound(dataStep, 0)[1];
ctx.textBaseline = 'bottom';
ctx.fillStyle = '#6a6a6a';
ctx.fillText(Formatting.formatData([0, lastUnit]), offsetX, y - 2.5);
continue;
}
// Rest of labels are aligned with the middle
var rounded = Formatting.smartRound(value, -1);
var v = rounded[0];
var u = rounded[1];
var label = v;
if (lastUnit !== u) {
label = Formatting.formatData([v, u]);
}
lastUnit = u;
ctx.textBaseline = 'middle';
ctx.fillStyle = displayLimit &&
(value === model.limits.value) &&
model.limits.enabled ?
'#b50202' : '#6a6a6a';
ctx.fillText(label, offsetX, y);
}
// Now the X axis
ctx.fillStyle = '#6a6a6a';
var marginTop = toDevicePixels(10);
// Left tag
var leftTag = formatChartDate(model.axis.X.lower);
ctx.font = makeCSSFontString(FONT_SIZE, FONT_WEIGHT);
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
var isBelowToday = model.todayLabel.x0 <=
model.originX + ctx.measureText(leftTag).width;
if (!isBelowToday) {
ctx.fillText(leftTag, model.originX, model.originY + marginTop);
}
// Right tag
var rightTag = formatChartDate(model.axis.X.upper);
ctx.textAlign = 'right';
isBelowToday = model.todayLabel.x1 >=
model.endX - ctx.measureText(rightTag).width;
if (!isBelowToday) {
ctx.fillText(rightTag, model.endX, model.originY + marginTop);
}
setAccessibilityAttributes(canvas, 'axis-layer',
{ from: leftTag, to: rightTag });
}
function setAccessibilityAttributes(element, identifier, data) {
if (identifier) {
element.setAttribute('data-l10n-id', identifier);
if (data) {
element.setAttribute('data-l10n-args', JSON.stringify(data));
}
} else {
element.removeAttribute('data-l10n-id');
element.removeAttribute('data-l10n-args');
element.removeAttribute('aria-label');
}
}
function drawLimits(canvas, model, showMobile) {
var set = model.limits.value;
var color = '#b50202';
canvas.height = model.height;
canvas.width = model.width;
var ctx = canvas.getContext('2d');
var displayLimit = showMobile && model.limits.enabled;
if (!displayLimit) {
return;
}
ctx.save();
var marginTop = 1;
var offsetY = set ? model.axis.Y.get(model.limits.value) :
FONT_SIZE + 2 * marginTop;
ctx.font = makeCSSFontString(FONT_SIZE, FONT_WEIGHT);
// The dashed limit line
var lineLength = toDevicePixels(15);
var gapLength = toDevicePixels(7);
ctx.strokeStyle = color;
ctx.lineWidth = toDevicePixels(1);
ctx.beginPath();
for (var x = model.originX, drawY = Math.floor(offsetY) - 0.5;
x < model.endX; x += gapLength) {
ctx.moveTo(x, drawY);
ctx.lineTo(Math.min(x += lineLength, model.endX), drawY);
}
ctx.stroke();
ctx.restore();
setAccessibilityAttributes(canvas,
'limit-layer-' + model.limits.dataLimitUnit,
{ value: model.limits.dataLimitValue });
}
function drawDataLayer(canvas, model, sampleType, style) {
var samples = (model.data[sampleType] || {}).samples;
if (!samples) {
return;
}
canvas.height = model.height;
canvas.width = model.width;
var ctx = canvas.getContext('2d');
// Style
ctx.fillStyle = style.fill;
ctx.strokeStyle = style.stroke;
ctx.lineWidth = toDevicePixels(2);
ctx.lineJoin = 'round';
ctx.moveTo(model.originX, model.originY);
var today = Toolkit.toMidnight(new Date());
// offset in milliseconds
var offset = today.getTimezoneOffset() * 60 * 1000;
var sum = 0; var x, y = model.originY;
var lastX = model.originX, lastY = model.axis.Y.get(sum);
// Only dealing with the use cases of negative UTC offset because, due to
// the database granularity, the returned samples are given in UTC days, and
// we don't know what part of the traffic sample values correspond to the
// current localtime day. The query for the samples is generated on the
// costcontrol module, and the case into which the returned samples only
// have one record with the data for the localtime day of yesterday is not
// possible.
for (var i = 0, len = samples.length; i < len; i++) {
var sample = samples[i];
var sampleLocalTime = sample.date.getTime() + offset;
var sampleUTCDate = Toolkit.toMidnight(new Date(sampleLocalTime));
var isToday = (today.getTime() === sampleUTCDate.getTime());
var isTomorrow = (today.getTime() + DAY === sampleUTCDate.getTime());
var thereIsATomorrowSample = (isToday && (i + 2 === len));
// Depends on the hour of the day and the offset, it is possible the
// networkStats API returns the current data mobile in the tomorrow
// sample, because on the UTC hour is another day.
if (thereIsATomorrowSample) {
// Join the value of the samples for today and tomorrow
var tomorrowSample = samples[i+1];
if (typeof sample.value === 'undefined') {
sample.value = tomorrowSample.value;
} else if (typeof tomorrowSample.value !== 'undefined') {
sample.value += tomorrowSample.value;
}
if (i === 0) {
lastX = model.axis.X.get(sample.date);
}
i++;
}
if (typeof sample.value === 'undefined') {
lastX = x = model.axis.X.get(sample.date);
ctx.moveTo(x, y);
} else {
if (i === 0) {
lastX = model.axis.X.get(sample.date);
}
sum += sample.value;
x = model.axis.X.get(sample.date);
y = model.axis.Y.get(sum);
clipAndDrawSegment(ctx, model, lastX, lastY, x, y);
lastX = x;
lastY = y;
}
var onlyExistTomorrowSample = (i===0 && isTomorrow);
var isXInsideTheGraph = (x >= model.originX);
if ((isToday || onlyExistTomorrowSample) && isXInsideTheGraph) {
drawTodayMark(ctx, x, y, style.stroke);
return;
}
}
if (!style.pattern) {
return;
}
var pattern = ctx.createPattern(style.pattern, 'repeat');
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, model.width, model.originY);
}
function drawWarningLayer(canvas, model) {
canvas.height = model.height;
canvas.width = model.width;
var ctx = canvas.getContext('2d');
if (!model.limits.enabled || model.limits.value === null) {
setAccessibilityAttributes(canvas);
return;
}
// No problem here
var mobileUsage = model.data.mobile.total;
var mobileUsageDifference = mobileUsage - model.limits.value;
if (mobileUsage <= model.limits.warningValue) {
setAccessibilityAttributes(canvas);
return;
}
// Warning mode
if (mobileUsage <= model.limits.value) {
var limitValue = Math.floor(model.axis.Y.get(model.limits.value));
var warningValue =
Math.round(model.axis.Y.get(model.limits.warningValue));
ctx.beginPath();
ctx.fillStyle = 'rgba(255, 112, 0, 0.5)';
ctx.fillRect(
model.originX, limitValue,
model.axis.X.len + 0.5, warningValue - limitValue
);
setAccessibilityAttributes(canvas, 'warning-layer', {
value: Formatting.formatData(Formatting.roundData(
-mobileUsageDifference))
});
return;
}
// Limit exceeded
var limitValueExceeded = model.axis.Y.get(model.limits.value);
ctx.beginPath();
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
ctx.fillRect(
model.originX, 0,
model.axis.X.len + 0.5, limitValueExceeded
);
setAccessibilityAttributes(canvas, 'limit-exceeded-layer', {
value: Formatting.formatData(Formatting.roundData(mobileUsageDifference))
});
}
return {
// Constants
WIFI_CHART_STROKE: WIFI_CHART_STROKE,
WIFI_CHART_FILL: WIFI_CHART_FILL,
MOBILE_CHART_STROKE: MOBILE_CHART_STROKE,
MOBILE_CHART_FILL: MOBILE_CHART_FILL,
// Functions
toDevicePixels: toDevicePixels,
makeCSSFontString: makeCSSFontString,
formatChartDate: formatChartDate,
getLimitInBytes: getLimitInBytes,
calculateUpperDate: calculateUpperDate,
calculateLowerDate: calculateLowerDate,
expandModel: expandModel,
clipAndDrawSegment: clipAndDrawSegment,
drawTodayMark: drawTodayMark,
drawBackgroundLayer: drawBackgroundLayer,
drawTodayLayer: drawTodayLayer,
drawAxisLayer: drawAxisLayer,
drawLimits: drawLimits,
drawDataLayer: drawDataLayer,
drawWarningLayer: drawWarningLayer
};
}());