297 lines
8.1 KiB
JavaScript
297 lines
8.1 KiB
JavaScript
import BaseChart from './BaseChart';
|
|
import { getComponent } from '../objects/ChartComponents';
|
|
import { makeText, heatSquare } from '../utils/draw';
|
|
import { DAY_NAMES_SHORT, addDays, areInSameMonth, getLastDateInMonth, setDayToSunday, getYyyyMmDd, getWeeksBetween, getMonthName, clone,
|
|
NO_OF_MILLIS, NO_OF_YEAR_MONTHS, NO_OF_DAYS_IN_WEEK } from '../utils/date-utils';
|
|
import { calcDistribution, getMaxCheckpoint } from '../utils/intervals';
|
|
import { getExtraHeight, getExtraWidth, HEATMAP_DISTRIBUTION_SIZE, HEATMAP_SQUARE_SIZE,
|
|
HEATMAP_GUTTER_SIZE } from '../utils/constants';
|
|
|
|
const COL_WIDTH = HEATMAP_SQUARE_SIZE + HEATMAP_GUTTER_SIZE;
|
|
const ROW_HEIGHT = COL_WIDTH;
|
|
// const DAY_INCR = 1;
|
|
|
|
export default class Heatmap extends BaseChart {
|
|
constructor(parent, options) {
|
|
super(parent, options);
|
|
this.type = 'heatmap';
|
|
|
|
this.countLabel = options.countLabel || '';
|
|
|
|
let validStarts = ['Sunday', 'Monday'];
|
|
let startSubDomain = validStarts.includes(options.startSubDomain)
|
|
? options.startSubDomain : 'Sunday';
|
|
this.startSubDomainIndex = validStarts.indexOf(startSubDomain);
|
|
|
|
this.setup();
|
|
}
|
|
|
|
setMeasures(options) {
|
|
let m = this.measures;
|
|
this.discreteDomains = options.discreteDomains === 0 ? 0 : 1;
|
|
|
|
m.paddings.top = ROW_HEIGHT * 3;
|
|
m.paddings.bottom = 0;
|
|
m.legendHeight = ROW_HEIGHT * 2;
|
|
m.baseHeight = ROW_HEIGHT * NO_OF_DAYS_IN_WEEK
|
|
+ getExtraHeight(m);
|
|
|
|
let d = this.data;
|
|
let spacing = this.discreteDomains ? NO_OF_YEAR_MONTHS : 0;
|
|
this.independentWidth = (getWeeksBetween(d.start, d.end)
|
|
+ spacing) * COL_WIDTH + getExtraWidth(m);
|
|
}
|
|
|
|
updateWidth() {
|
|
let spacing = this.discreteDomains ? NO_OF_YEAR_MONTHS : 0;
|
|
let noOfWeeks = this.state.noOfWeeks ? this.state.noOfWeeks : 52;
|
|
this.baseWidth = (noOfWeeks + spacing) * COL_WIDTH
|
|
+ getExtraWidth(this.measures);
|
|
}
|
|
|
|
prepareData(data=this.data) {
|
|
if(data.start && data.end && data.start > data.end) {
|
|
throw new Error('Start date cannot be greater than end date.');
|
|
}
|
|
|
|
if(!data.start) {
|
|
data.start = new Date();
|
|
data.start.setFullYear( data.start.getFullYear() - 1 );
|
|
}
|
|
if(!data.end) { data.end = new Date(); }
|
|
data.dataPoints = data.dataPoints || {};
|
|
|
|
if(parseInt(Object.keys(data.dataPoints)[0]) > 100000) {
|
|
let points = {};
|
|
Object.keys(data.dataPoints).forEach(timestampSec => {
|
|
let date = new Date(timestampSec * NO_OF_MILLIS);
|
|
points[getYyyyMmDd(date)] = data.dataPoints[timestampSec];
|
|
});
|
|
data.dataPoints = points;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
calc() {
|
|
let s = this.state;
|
|
|
|
s.start = clone(this.data.start);
|
|
s.end = clone(this.data.end);
|
|
|
|
s.firstWeekStart = clone(s.start);
|
|
s.noOfWeeks = getWeeksBetween(s.start, s.end);
|
|
s.distribution = calcDistribution(
|
|
Object.values(this.data.dataPoints), HEATMAP_DISTRIBUTION_SIZE);
|
|
|
|
s.domainConfigs = this.getDomains();
|
|
}
|
|
|
|
setupComponents() {
|
|
let s = this.state;
|
|
let lessCol = this.discreteDomains ? 0 : 1;
|
|
|
|
let componentConfigs = s.domainConfigs.map((config, i) => [
|
|
'heatDomain',
|
|
{
|
|
index: config.index,
|
|
colWidth: COL_WIDTH,
|
|
rowHeight: ROW_HEIGHT,
|
|
squareSize: HEATMAP_SQUARE_SIZE,
|
|
radius: this.rawChartArgs.radius || 0,
|
|
xTranslate: s.domainConfigs
|
|
.filter((config, j) => j < i)
|
|
.map(config => config.cols.length - lessCol)
|
|
.reduce((a, b) => a + b, 0)
|
|
* COL_WIDTH
|
|
},
|
|
function() {
|
|
return s.domainConfigs[i];
|
|
}.bind(this)
|
|
|
|
]);
|
|
|
|
this.components = new Map(componentConfigs
|
|
.map((args, i) => {
|
|
let component = getComponent(...args);
|
|
return [args[0] + '-' + i, component];
|
|
})
|
|
);
|
|
|
|
let y = 0;
|
|
DAY_NAMES_SHORT.forEach((dayName, i) => {
|
|
if([1, 3, 5].includes(i)) {
|
|
let dayText = makeText('subdomain-name', -COL_WIDTH/2, y, dayName,
|
|
{
|
|
fontSize: HEATMAP_SQUARE_SIZE,
|
|
dy: 8,
|
|
textAnchor: 'end'
|
|
}
|
|
);
|
|
this.drawArea.appendChild(dayText);
|
|
}
|
|
y += ROW_HEIGHT;
|
|
});
|
|
}
|
|
|
|
update(data) {
|
|
if(!data) {
|
|
console.error('No data to update.');
|
|
}
|
|
|
|
this.data = this.prepareData(data);
|
|
this.draw();
|
|
this.bindTooltip();
|
|
}
|
|
|
|
bindTooltip() {
|
|
this.container.addEventListener('mousemove', (e) => {
|
|
this.components.forEach(comp => {
|
|
let daySquares = comp.store;
|
|
let daySquare = e.target;
|
|
if(daySquares.includes(daySquare)) {
|
|
|
|
let count = daySquare.getAttribute('data-value');
|
|
let dateParts = daySquare.getAttribute('data-date').split('-');
|
|
|
|
let month = getMonthName(parseInt(dateParts[1])-1, true);
|
|
|
|
let gOff = this.container.getBoundingClientRect(), pOff = daySquare.getBoundingClientRect();
|
|
|
|
let width = parseInt(e.target.getAttribute('width'));
|
|
let x = pOff.left - gOff.left + width/2;
|
|
let y = pOff.top - gOff.top;
|
|
let value = count + ' ' + this.countLabel;
|
|
let name = ' on ' + month + ' ' + dateParts[0] + ', ' + dateParts[2];
|
|
|
|
this.tip.setValues(x, y, {name: name, value: value, valueFirst: 1}, []);
|
|
this.tip.showTip();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
renderLegend() {
|
|
this.legendArea.textContent = '';
|
|
let x = 0;
|
|
let y = ROW_HEIGHT;
|
|
let radius = this.rawChartArgs.radius || 0
|
|
|
|
let lessText = makeText('subdomain-name', x, y, 'Less',
|
|
{
|
|
fontSize: HEATMAP_SQUARE_SIZE + 1,
|
|
dy: 9
|
|
}
|
|
);
|
|
x = (COL_WIDTH * 2) + COL_WIDTH/2;
|
|
this.legendArea.appendChild(lessText);
|
|
|
|
this.colors.slice(0, HEATMAP_DISTRIBUTION_SIZE).map((color, i) => {
|
|
const square = heatSquare('heatmap-legend-unit', x + (COL_WIDTH + 3) * i,
|
|
y, HEATMAP_SQUARE_SIZE, radius, color);
|
|
this.legendArea.appendChild(square);
|
|
});
|
|
|
|
let moreTextX = x + HEATMAP_DISTRIBUTION_SIZE * (COL_WIDTH + 3) + COL_WIDTH/4;
|
|
let moreText = makeText('subdomain-name', moreTextX, y, 'More',
|
|
{
|
|
fontSize: HEATMAP_SQUARE_SIZE + 1,
|
|
dy: 9
|
|
}
|
|
);
|
|
this.legendArea.appendChild(moreText);
|
|
}
|
|
|
|
getDomains() {
|
|
let s = this.state;
|
|
const [startMonth, startYear] = [s.start.getMonth(), s.start.getFullYear()];
|
|
const [endMonth, endYear] = [s.end.getMonth(), s.end.getFullYear()];
|
|
|
|
const noOfMonths = (endMonth - startMonth + 1) + (endYear - startYear) * 12;
|
|
|
|
let domainConfigs = [];
|
|
|
|
let startOfMonth = clone(s.start);
|
|
for(var i = 0; i < noOfMonths; i++) {
|
|
let endDate = s.end;
|
|
if(!areInSameMonth(startOfMonth, s.end)) {
|
|
let [month, year] = [startOfMonth.getMonth(), startOfMonth.getFullYear()];
|
|
endDate = getLastDateInMonth(month, year);
|
|
}
|
|
domainConfigs.push(this.getDomainConfig(startOfMonth, endDate));
|
|
|
|
addDays(endDate, 1);
|
|
startOfMonth = endDate;
|
|
}
|
|
|
|
return domainConfigs;
|
|
}
|
|
|
|
getDomainConfig(startDate, endDate='') {
|
|
let [month, year] = [startDate.getMonth(), startDate.getFullYear()];
|
|
let startOfWeek = setDayToSunday(startDate); // TODO: Monday as well
|
|
endDate = clone(endDate) || getLastDateInMonth(month, year);
|
|
|
|
let domainConfig = {
|
|
index: month,
|
|
cols: []
|
|
};
|
|
|
|
addDays(endDate, 1);
|
|
let noOfMonthWeeks = getWeeksBetween(startOfWeek, endDate);
|
|
|
|
let cols = [], col;
|
|
for(var i = 0; i < noOfMonthWeeks; i++) {
|
|
col = this.getCol(startOfWeek, month);
|
|
cols.push(col);
|
|
|
|
startOfWeek = new Date(col[NO_OF_DAYS_IN_WEEK - 1].yyyyMmDd);
|
|
addDays(startOfWeek, 1);
|
|
}
|
|
|
|
if(col[NO_OF_DAYS_IN_WEEK - 1].dataValue !== undefined) {
|
|
addDays(startOfWeek, 1);
|
|
cols.push(this.getCol(startOfWeek, month, true));
|
|
}
|
|
|
|
domainConfig.cols = cols;
|
|
|
|
return domainConfig;
|
|
}
|
|
|
|
getCol(startDate, month, empty = false) {
|
|
let s = this.state;
|
|
|
|
// startDate is the start of week
|
|
let currentDate = clone(startDate);
|
|
let col = [];
|
|
|
|
for(var i = 0; i < NO_OF_DAYS_IN_WEEK; i++, addDays(currentDate, 1)) {
|
|
let config = {};
|
|
|
|
// Non-generic adjustment for entire heatmap, needs state
|
|
let currentDateWithinData = currentDate >= s.start && currentDate <= s.end;
|
|
|
|
if(empty || currentDate.getMonth() !== month || !currentDateWithinData) {
|
|
config.yyyyMmDd = getYyyyMmDd(currentDate);
|
|
} else {
|
|
config = this.getSubDomainConfig(currentDate);
|
|
}
|
|
col.push(config);
|
|
}
|
|
|
|
return col;
|
|
}
|
|
|
|
getSubDomainConfig(date) {
|
|
let yyyyMmDd = getYyyyMmDd(date);
|
|
let dataValue = this.data.dataPoints[yyyyMmDd];
|
|
let config = {
|
|
yyyyMmDd: yyyyMmDd,
|
|
dataValue: dataValue || 0,
|
|
fill: this.colors[getMaxCheckpoint(dataValue, this.state.distribution)]
|
|
};
|
|
return config;
|
|
}
|
|
}
|