127.0.0.1:8000 budget / master src / js / pages / chart_and_messages.js
master

Tree @master (Download .tar.gz)

chart_and_messages.js @masterraw · history · blame

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`;
    };
};