require('../../scss/chart.scss');
const api = require("../utils/api");
const Chart = require("chart.js/auto");
const $ = require('jquery');
const is_mobile = require('../utils/is_mobile');
const list_row = require('../ui/list_row');
let chart_requires_refresh = false;
let chart;
$('#toggle-chart-visibility').on('click', function() {
if ($(".chart-wrapper").is(":visible")) {
$(".chart-wrapper").slideUp(500, 'swing', function() {
$('#toggle-chart-visibility').text("Show Chart");
$(".content").css('max-height', 'unset');
});
} else {
$(".content").css('max-height', '');
$(".chart-wrapper").slideDown(500, 'swing', function() {
$('#toggle-chart-visibility').text("Hide Chart");
if (chart_requires_refresh) {
refresh().then(function() {
$(".chart-wrapper").animate({
opacity: 1
}, 500);
})
}
});
}
});
if (is_mobile) {
$(".chart-wrapper").hide();
$('#toggle-chart-visibility').text("Show Chart");
$(".content").css('max-height', 'unset');
chart_requires_refresh = true;
} else {
refresh().then(function() {
$(".chart-wrapper").animate({
opacity: 1
}, 500);
});
}
let on_refresh = [];
module.exports = {
refresh: refresh,
on_refresh: function(action) {
on_refresh.push(action);
}
}
async function refresh() {
const {
chart_data
} = await api.get(`/budget/chart_data`);
on_refresh.forEach(function(action) {
action();
});
if (!$(".chart-wrapper").is(":visible")) {
chart_requires_refresh = true;
return;
}
chart_requires_refresh = false;
if (chart) {
chart.data.labels = chart_data.labels;
chart.data.datasets = map_datasets(chart_data);
chart.options.plugins.tooltip.external = externalTooltipHandler(chart_data.tooltip_data);
chart.update();
} else {
create_chart(chart_data);
}
$("#messages-list").empty();
Object.values(chart_data.messages).forEach(function(message) {
list_row.create('messages', {
icon: message.icon,
title: message.title,
text: message.text
}).on('click', function() {
// scroll to message.date_index
// TODO: do we want to have a tooltip associated with this, or is that too much clutter?
// Should at minimum highlight the point on the graph
// Get distance between each point
// 366 = 365 days per year + today.
// Not accounting for leap years - is the worst case a
// slightly wrong scroll offset for far out datapoints?
const dx = chart.chartArea.width / 366;
console.log(chart.chartArea)
console.log(chart.data)
const date_offest = message.date_index * dx;
// Scroll such that the target appears in the center of the window
const scroll_location = Math.min(
chart.chartArea.width,
Math.max(0, date_offest - window.innerWidth / 2)
);
console.log('scroll to', message.date_index, scroll_location);
$(".chart-area-wrapper").animate({
scrollLeft: scroll_location
});
});
});
}
function create_chart(chart_data) {
chart = new Chart(document.getElementById("budget-chart").getContext("2d"), {
type: "line",
plugins: [htmlLegendPlugin],
options: {
responsive: true,
maintainAspectRatio: false,
pointStyle: "circle",
pointRadius: 5,
pointHoverRadius: 15,
interaction: {
mode: "index",
intersect: false
},
plugins: {
tooltip: {
enabled: false,
position: "nearest",
external: externalTooltipHandler(chart_data.tooltip_data)
},
htmlLegend: {
containerID: 'budget-legend-scrollable-container'
},
legend: {
display: false
}
},
animation: {
duration: 0
}
},
data: {
labels: chart_data.labels,
datasets: map_datasets(chart_data)
}
});
const $inner_wrapper = $('.chart-area-wrapper-inner');
// Add arbitrary value that made is such that the x-axis has a label for each day
$inner_wrapper.width($inner_wrapper.width() + 8000);
const {
scaled_width,
scaled_height
} = refresh_y_axis(chart);
const chart_canvas = chart.ctx.canvas.getContext('2d');
chart_canvas.clearRect(
0, 0, scaled_width, scaled_height
);
}
function map_datasets(chart_data) {
return chart_data.datasets.map(dataset => ({
label: dataset.label,
fill: false,
lineTension: 0,
borderColor: dataset.color,
data: dataset.data
}));
}
function refresh_y_axis(chart) {
const width = chart.scales.y.width - 10;
const height = chart.scales.y.height + chart.scales.y.top + 10;
const scaled_width = width * window.devicePixelRatio;
const scaled_height = height * window.devicePixelRatio;
const axis_canvas = document.getElementById("budget-chart-axis").getContext("2d");
axis_canvas.scale(window.devicePixelRatio, window.devicePixelRatio);
axis_canvas.canvas.width = scaled_width;
axis_canvas.canvas.height = scaled_height;
axis_canvas.canvas.style.width = `${width}px`;
axis_canvas.canvas.style.height = `${height}px`;
// Magic incantation that draws only the y-axis
axis_canvas.drawImage(
chart.ctx.canvas,
0, 0, scaled_width, scaled_height,
0, 0, scaled_width, scaled_height
);
return {
scaled_width,
scaled_height
};
}
const getOrCreateLegendList = (chart, id) => {
const legendContainer = document.getElementById(id);
let listContainer = legendContainer.querySelector('div');
if (!listContainer) {
listContainer = document.createElement('div');
listContainer.className = 'legend-container';
legendContainer.appendChild(listContainer);
}
return listContainer;
};
const htmlLegendPlugin = {
id: 'htmlLegend',
afterUpdate(chart, args, options) {
const container = getOrCreateLegendList(chart, options.containerID);
// Remove old legend items
while (container.firstChild) {
container.firstChild.remove();
}
// Reuse the built-in legendItems generator
const items = chart.options.plugins.legend.labels.generateLabels(chart);
items.forEach(item => {
const legend_entry = document.createElement('div');
legend_entry.className = "legend-entry"
legend_entry.onclick = () => {
chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex));
chart.update();
refresh_y_axis(chart);
};
// Color box
const boxSpan = document.createElement('div');
boxSpan.className = "legend-entry-color-box";
boxSpan.style.background = item.hidden ? '' : item.strokeStyle;
boxSpan.style.border = `2px solid ${item.strokeStyle}`;
// Text
const textContainer = document.createElement('div');
textContainer.className = "legend-entry-text";
//textContainer.style.textDecoration = item.hidden ? 'line-through' : '';
const text = document.createTextNode(item.text);
textContainer.appendChild(text);
legend_entry.appendChild(boxSpan);
legend_entry.appendChild(textContainer);
container.appendChild(legend_entry);
});
}
};
const getOrCreateTooltip = (chart) => {
let tooltipEl = chart.canvas.parentNode.querySelector("div");
if (!tooltipEl) {
tooltipEl = document.createElement("div");
tooltipEl.style.background = "rgba(0, 0, 0, 0.7)";
tooltipEl.style.borderRadius = "3px";
tooltipEl.style.color = "white";
tooltipEl.style.opacity = 1;
tooltipEl.style.pointerEvents = "none";
tooltipEl.style.position = "absolute";
tooltipEl.style.transform = "translate(-50%, 0)";
tooltipEl.style.transition = "all .1s ease";
chart.canvas.parentNode.appendChild(tooltipEl);
}
return tooltipEl;
};
const externalTooltipHandler = function(tooltip_data) {
return function(context) {
const {
chart,
tooltip
} = context;
const tooltip_div = getOrCreateTooltip(chart);
// Hide by default
tooltip_div.style.opacity = 0;
// Hide if we know we shouldn't show anything
if (tooltip.opacity === 0) {
return;
}
// Overly safe checking to avoid nullref
const dataPoints = tooltip.dataPoints;
if (!dataPoints.length) {
return;
}
const firstDataPoint = dataPoints[0];
if (!firstDataPoint) {
return;
}
const tooltip_body_lines = tooltip_data[firstDataPoint.dataIndex];
if (!tooltip_body_lines || !tooltip_body_lines.length) {
return;
}
// Remove old children
while (tooltip_div.firstChild) {
tooltip_div.firstChild.remove();
}
const text = document.createTextNode(tooltip_body_lines[0]);
tooltip_div.appendChild(text);
for (let i = 1; i < tooltip_body_lines.length; i++) {
const colors = tooltip.labelColors[0];
const line = document.createElement("div");
line.style.background = colors.backgroundColor;
line.style.borderColor = colors.borderColor;
line.style.borderWidth = "2px";
const text = document.createTextNode(tooltip_body_lines[i]);
line.appendChild(text);
tooltip_div.appendChild(line);
}
// Horrifying
const scrollLeft = chart.canvas.parentElement.parentElement.scrollLeft;
// Display, position, and set styles for font
tooltip_div.style.opacity = 1;
tooltip_div.style.left = `${chart.canvas.offsetLeft - scrollLeft + tooltip.caretX}px`;
tooltip_div.style.top = `${chart.canvas.offsetTop + tooltip.caretY}px`;
tooltip_div.style.font = tooltip.options.bodyFont.string;
tooltip_div.style.padding = `${tooltip.options.padding}px ${tooltip.options.padding}px`;
};
};