Source

index.js

import style from './index.css';

/**
 * A module to visualize time-related data.
 * @module TimeCharts
 */

///// PRIVATE HELPERS /////

/**
 * Sets multiple attributes for a dom element.
 * @private
 * @param {Object} obj - the dom element
 * @param {Object} params - the attributes to be set
 */
function setAttributes(obj, params) {
    for (const [key, value] of Object.entries(params)) {
        if (key === "class") {
            obj.classList.add(...value);
        } else {
            obj.setAttribute(key, value);
        }
    }
}

/**
 * Removes all child elements from a DOM element.
 * @private
 * @param {Object} obj - the dom element
 */
function clear(obj) {
    while (obj.firstChild) {
        obj.removeChild(obj.firstChild);
    }
}


/**
 * Merges an object into another one.
 * @private
 * @param {Object} obj - the object into which to merge 
 * @param {Object} merger - the object to merge 
 * @param {boolean} [overwrite = false] - whether to overwrite the original value of it exists
 */
function mergeObjects(obj, merger, overwrite) {
    overwrite = overwrite === true; // defaults to false

    for (let key of Object.keys(merger)) {
        if (!(key in obj)) {
            obj[key] = merger[key];
        } else if (typeof merger[key] === "object") {
            mergeObjects(obj[key], merger[key], overwrite);
        } else if (overwrite) {
            obj[key] = merger[key];
        }
    }
}

/**
 * Generates coordinates for arcs based on the center position, the radius and the angle
 * @param {number} centerX - the x-coordinate of the center
 * @param {number} centerY - the y-coordinate of the center
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} angleInDegrees - the angle
 */
function polarToCartesian(centerX, centerY, radiusX, radiusY, angleInDegrees) {
    const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;

    return {
        x: centerX + (radiusX * Math.cos(angleInRadians)),
        y: centerY + (radiusY * Math.sin(angleInRadians))
    };
}

/**
 * Creates the svg path string for an arc
 * @param {number} x - the x-coordinate of the center
 * @param {number} y - the y-coordinate of the center
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} startAngle - the angle at which the arc starts
 * @param {number} endAngle - the angle at which the arc ends
 * @param {number} outerArc - the flag for the outer arc
 */
function describeArc(x, y, radiusX, radiusY, startAngle, endAngle, outerArc) {
    const start = polarToCartesian(x, y, radiusX, radiusY, endAngle);
    const end = polarToCartesian(x, y, radiusX, radiusY, startAngle);

    const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";

    const d = [
        "M", start.x, start.y,
        "A", radiusX, radiusY, 0, largeArcFlag, outerArc, end.x, end.y
    ].join(" ");

    return [d, start, end];
}

/**
 * Creates the svg path string for the top circle/arc of a bar
 * @param {number} x - the x-coordinate of the center
 * @param {number} y - the y-coordinate of the center
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} heightDelta - the height of the arc (not the height of the bar i.e. must be <= radiusY - heightOld)
 * @param {number} heightOld - the old height (where the arc should start)
 */
function createTopArc(x, y, radiusX, radiusY, heightDelta, heightOld) {
    // Calculate degrees
    const alphaOld = Math.asin((radiusY - heightOld) / radiusY) / Math.PI * 180;
    const alphaNew = Math.asin((radiusY - (heightOld + heightDelta)) / radiusY)  / Math.PI * 180;
    
    const arc1 = describeArc(x, y, radiusX, radiusY, alphaOld, alphaNew, 1);
    const arc2 = describeArc(x, y, radiusX, radiusY, 360-alphaOld, 360-alphaNew, 0);
  
    const d = [
        arc1[0],
        "L", arc2[2].x, arc2[2].y,
        arc2[0],
        "L", arc1[1].x, arc1[1].y,
    ].join(" ");
  
  return d;
}

/**
 * Creates the svg path string for the right circle/arc of a bar
 * @param {number} x - the x-coordinate of the center
 * @param {number} y - the y-coordinate of the center
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} widthDelta - the width of the arc (not the height of the bar i.e. must be <= radiusX - widthOld)
 * @param {number} widthOld - the old width (where the arc should start)
 */
function createRightArc(x, y, radiusX, radiusY, widthDelta, widthOld) {
    // Calculate degrees
    const alphaOld = Math.asin((radiusX - widthOld) / radiusX) / Math.PI * 180;
    const alphaNew = Math.asin((radiusX - (widthOld + widthDelta)) / radiusX)  / Math.PI * 180;
    
    const arc1 = describeArc(x, y, radiusX, radiusY, 90+alphaOld, 90+alphaNew, 1);
    const arc2 = describeArc(x, y, radiusX, radiusY, 90+360-alphaOld, 90+360-alphaNew, 0);
  
    const d = [
        arc1[0],
        "L", arc2[2].x, arc2[2].y,
        arc2[0],
        "L", arc1[1].x, arc1[1].y,
    ].join(" ");
  
  return d;
}

/**
 * Creates the svg path string for the bottom circle/arc of a bar
 * @param {number} x - the x-coordinate of the center
 * @param {number} y - the y-coordinate of the center
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} heightDelta - the height of the arc (not the height of the bar i.e. must be <= radiusY - heightOld)
 * @param {number} heightOld - the old height (where the arc should start)
 */
function createBottomArc(x, y, radiusX, radiusY, heightDelta, heightOld) {
    // Calculate degrees
    const alphaOld = Math.acos((radiusY - heightOld) / radiusY) / Math.PI * 180;
    const alphaNew = Math.acos((radiusY - (heightOld + heightDelta)) / radiusY)  / Math.PI * 180;
    
    const arc1 = describeArc(x, y, radiusX, radiusY, 180+alphaOld, 180+alphaNew, 0);
    const arc2 = describeArc(x, y, radiusX, radiusY, 180-alphaOld, 180-alphaNew, 1);
  
    const d = [
        arc1[0],
        "L", arc2[2].x, arc2[2].y,
        arc2[0],
        "L", arc1[1].x, arc1[1].y,
    ].join(" ");
  
  return d;
}

/**
 * Creates the svg path string for the left circle/arc of a bar
 * @param {number} x - the x-coordinate of the center
 * @param {number} y - the y-coordinate of the center
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} widthDelta - the width of the arc (not the height of the bar i.e. must be <= radiusX - widthOld)
 * @param {number} widthOld - the old width (where the arc should start)
 */
function createLeftArc(x, y, radiusX, radiusY, widthDelta, widthOld) {
    // Calculate degrees
    const alphaOld = Math.acos((radiusX - widthOld) / radiusX) / Math.PI * 180;
    const alphaNew = Math.acos((radiusX - (widthOld + widthDelta)) / radiusX)  / Math.PI * 180;
    
    const arc1 = describeArc(x, y, radiusX, radiusY, 90+180+alphaOld, 90+180+alphaNew, 0);
    const arc2 = describeArc(x, y, radiusX, radiusY, 90+180+360-alphaOld, 90+180+360-alphaNew, 1);
  
    const d = [
        arc1[0],
        "L", arc2[2].x, arc2[2].y,
        arc2[0],
        "L", arc1[1].x, arc1[1].y,
    ].join(" ");
  
  return d;
}

/**
 * Creates the svg path string for a vertical bar
 * @param {number} x - x-coordinate of the left side of the bar
 * @param {number} y - y-coordinate of the bottom side of the bar
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} heightDelta - the height of the bar-portion
 * @param {number} heightOld - the height where the bar-portion should start
 * @param {number} heightMax - the full height of the bar chart
 */
function createVerticalBar(x, y, radiusX, radiusY, heightDelta, heightOld, heightMax) {
    let bottomArcHeight = 0;
    let bottomArc = "";
    
    if(heightOld < radiusY) { // Bar starts in the bottom circle
        bottomArcHeight = Math.min(heightDelta, radiusY - heightOld);
        bottomArc = createBottomArc(x + radiusX, y - radiusY, radiusX, radiusY, bottomArcHeight, heightOld);
    } 

    let topArcHeight = 0;
    let topArc = "";

    if(heightOld + heightDelta > heightMax - radiusY) { // Bar ends in the top circle
        topArcHeight = Math.min(heightDelta, (heightOld + heightDelta) - (heightMax - radiusY));
        topArc = createTopArc(x + radiusX, y - heightMax + radiusY, radiusX, radiusY, topArcHeight, Math.max(0, heightOld - (heightMax - radiusY)));
    }

    let middleHeight = heightDelta - bottomArcHeight - topArcHeight;
    let middle = "";
    if(middleHeight > 0){
        middle = [
            "M", x, y - heightOld - bottomArcHeight,
            "v", -middleHeight,
            "h", radiusX * 2,
            "v", middleHeight,
            "h", -radiusX * 2,
        ].join(" ");
    }

    const d = [
        bottomArc,
        middle,
        topArc
    ].join(" ");

    return d;
}

/**
 * Creates the svg path string for a horizontal bar
 * @param {number} x - x-coordinate of the left side of the bar
 * @param {number} y - y-coordinate of the top side of the bar
 * @param {number} radiusX - the x-radius
 * @param {number} radiusY - the y-radius
 * @param {number} widthDelta - the width of the bar-portion
 * @param {number} widthOld - the width where the bar-portion should start
 * @param {number} widthMax - the full width of the bar chart
 */
function createHorizontalBar(x, y, radiusX, radiusY, widthDelta, widthOld, widthMax) {
    let leftArcWidth = 0;
    let leftArc = "";
    
    if(widthOld < radiusX) { // Bar starts in the left circle
        leftArcWidth = Math.min(widthDelta, radiusX - widthOld);
        leftArc = createLeftArc(x + radiusX, y + radiusY, radiusX, radiusY, leftArcWidth, widthOld);
    } 

    let rightArcWidth = 0;
    let rightArc = "";

    if(widthOld + widthDelta > widthMax - radiusX) { // Bar ends in the top circle
        rightArcWidth = Math.min(widthDelta, (widthOld + widthDelta) - (widthMax - radiusX));
        rightArc = createRightArc(x + widthMax - radiusX, y + radiusY, radiusX, radiusY, rightArcWidth, Math.max(0, widthOld - (widthMax - radiusX)));
    }

    let middleWidth = widthDelta - leftArcWidth - rightArcWidth;
    let middle = "";
    if(middleWidth > 0){
        middle = [
            "M", x + widthOld + leftArcWidth, y,
            "h", middleWidth,
            "v", radiusY * 2,
            "h", -middleWidth,
            "v", -radiusY * 2,
        ].join(" ");
    }

    const d = [
        leftArc,
        middle,
        rightArc
    ].join(" ");

    return d;
}

class Draw {
    /**
     * Creates an svg object.
     * @private
     * @param {number|string} width - width of the svg
     * @param {number|string} height - height of the svg
     * @param {number} vbWidth - width of the viewbox
     * @param {number} vbHeight - height of the viewbox
     * @param {Object} [options] - additional attributes for the svg
     * @returns {Object} - svg object
     */
    static svg(width, height, vbWidth, vbHeight, options) {
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
        setAttributes(svg, {
            width,
            height,
            viewBox: "0 0 " + vbWidth + " " + vbHeight,
            preserveAspectRatio: "none"
        });
        setAttributes(svg, options || {});
        return svg;
    }

    /**
     * Draws an svg rectangle.
     * @private
     * @param {number} x - x-coordinate
     * @param {number} y - y-coordinate
     * @param {number} width - width of the rectangle
     * @param {number} height - height of the rectangle
     * @param {string} color - the fill color of the rectangle
     * @param {Object} [options] additional attributes for the rectangle
     * @returns {Object} - svg rectangle
     */
    static rect(x, y, width, height, color, options) {
        const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect")
        setAttributes(rect, {
            width,
            height,
            x,
            y,
            fill: color
        });
        setAttributes(rect, options || {});
        return rect;
    }

    /**
     * Draws an svg line.
     * @private
     * @param {number} x1 - x-coordinate where the line begins
     * @param {number} y1 - y-coordinate where the line begins
     * @param {number} x2 - x-coordinate where the line ends
     * @param {number} y2 - y-coordinate where the line begins
     * @param {string} color - color of the line
     * @param {number} width - width of the line
     * @param {Object} [options] - additional attributes for the line
     * @returns {Object} - svg line
     */
    static line(x1, y1, x2, y2, color, width, options) {
        const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
        setAttributes(line, {
            x1,
            y1,
            x2,
            y2,
            stroke: color,
            "stroke-width": width
        });
        setAttributes(line, options || {});
        return line;
    }

    /**
     * Draws an svg path.
     * @private
     * @param {string} shape - the shape of the path 
     * @param {string} color - the color of the path
     * @param {Object} [options] - additional attributes for the path
     * @returns {Object} - svg path
     */
    static path(shape, color, options) {
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        setAttributes(path, {
            d: shape,
            fill: color
        });
        setAttributes(path, options || {});
        return path;
    }

    /**
     * Draws an svg text.
     * @private
     * @param {number} x - x-coordinate of the top right left corner of the text
     * @param {number} y - y-coordinate of the top right left corner of the text
     * @param {string} content - the text that is displayed
     * @param {string} color - the text color
     * @param {string} [font = 'Roboto'] - the font name
     * @param {Object} [options] - additional attributes for the text
     * @returns {Object} - svg text
     */
    static text(x, y, content, color, font, options) {
        color = color || "black";
        font = font || 'Roboto';
        const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
        setAttributes(text, {
            x,
            y,
            "font-size": "14px",
            fill: color,
            stroke: "none",
            "font-family": font,
            "text-anchor": "middle",
            class: ["static"]
        });
        setAttributes(text, options || {});
        text.appendChild(document.createTextNode(content))
        return text;
    }

    /**
     * Creates an svg group.
     * @private
     * @returns {Object} - svg group
     */
    static group() {
        return document.createElementNS("http://www.w3.org/2000/svg", "g");
    }
}

///// PUBLIC FUNCTIONS /////

/**
 * Creates a bar chart
 * @class
 */
class Barchart {
    /**
     * Constructs a bar chart
     * @constructor
     * @param {string} element - css query selector of the container dom element into which the chart is placed.
     * @param {Object} [params] - options
     * @param {Object} [params.barSize = 25] - the size of a bar in px.
     * @param {Object[]} [params.data] - the data to be displayed. A list of bars that make up the bar chart.
     * @param {string} [params.data[].label] - the labels underneath the bar.
     * @param {Object[]} [params.data[].datasets] - each dataset represents one "block" of a bar.
     * @param {number} params.data[].datasets[].value - the value of the block.
     * @param {string} [params.data[].datasets[].title] - the title of the block.
     * @param {string} [params.data[].datasets[].color] - the color of the block.
     * @param {number|string} [params.max = 'relative'] - the max value of the chart.
     * @param {Object} [params.padding] - padding in all directions of the chart.
     * @param {number|string} [params.padding.top] - top padding for the chart.
     * @param {number|string} [params.padding.right] - right padding for the chart.
     * @param {number|string} [params.padding.bottom] - bottom padding for the chart.
     * @param {number|string} [params.padding.left] - left padding for the chart.
     * @param {Object} [params.colors] - custom colors
     * @param {boolean} [params.colors.fixToTitle = true] - Whether bar-portions with the same title should also have the same color.
     * @param {string} [params.colors.background = "#E3E6E9"] - the color of the background of the bars (not the color of background of the whole chart).
     * @param {string} [params.colors.text = "black"] - the color of the text.
     * @param {'vertical' | 'horizontal'} [params.orientation = 'vertical'] - orientation for the chart.
     * @param {string} [params.font = 'Roboto'] - the font for all writing. Font must be imported separately.
     * @param {Object} [params.hover] - options for the hover effect.
     * @param {boolean} [params.hover.visible = true] - whether the titles should be shown on hover or not.
     * @param {Function} [params.hover.callback] - function that returns html that is displayed in the hover effect. Receives (title, value).
     * @param {'variable' | number} [params.distance = 'variable'] - whether the distance between timelines should be variable (based on svg size) or a fixed number of px.
     * @param {number} [params.minDistance = 0] - the minimum number of pixels between bars.
     * @param {boolean} [params.adjustSize = false] - whether the size of the container should be adjusted based on the needed space. Only works if params.distance != 'variable'.
     * @param {Object} [params.scale] - options for the scale
     * @param {boolean} [params.scale.visible = true] - whether the scale should be visible or not
     * @param {number} [params.scale.interval = 10] - the interval at which to draw the scale
     * @param {number} [params.scale.color = "#E3E6E9"] - the color of the scale lines
     * @param {boolean} [params.draggable = false] - whether the chart can be dragged
     * @param {Function} [params.onScroll] - called when the user scrolls on the chart
     * @throws Will throw an error if the container element is not found.
     */
    constructor(element, params) {
        this.container = document.querySelector(element);
        if (this.container == null) {
            console.error("Container for chart does not exist");
            return;
        }

        // Extract parameters and sets defaults if parameters not available
        mergeObjects(params, {
            data: [],
            padding: {
                top: 0,
                right: 0,
                bottom: 0,
                left: 0
            },
            colors: {
                fixToTitle: true,
                background: "#E3E6E9",
                text: "black"
            },
            orientation: "vertical",
            font: "Roboto",
            hover: {
                visible: true,
                callback: (title, value) => `<span style="color: gray">${value}</span>${title !== "" ? ": " + title : ""}`
            },
            barSize: 25,
            distance: 'variable',
            minDistance: 0,
            adjustSize: false,
            max: 'relative',
            scale: {
                visible: true,
                interval: 10,
                color: "#E3E6E9"
            },
            draggable: false,
            onScroll: e => {}
        });

        this.data = params.data;
        this.padding = params.padding;
        this.max = params.max;
        this.fixColorToTitle = params.colors.fixToTitle;
        this.foregroundColors = ['#7cd6fd', '#5e64ff', '#743ee2', '#ff5858', '#ffa00a', '#feef72', '#28a745', '#98d85b', '#b554ff', '#ffa3ef', '#36114C', '#bdd3e6', '#f0f4f7', '#b8c2cc'];
        this.backgroundColor = params.colors.background;
        this.textColor = params.colors.text;
        this.orientation = params.orientation;
        this.font = params.font;
        this.hover = params.hover;
        this.barSize = params.barSize;
        this.distance = params.distance;
        this.minDistance = params.minDistance;
        this.adjustSize = this.distance !== 'variable' && params.adjustSize;
        this.scale = params.scale;
        this.draggable = params.draggable;
        this.onScroll = params.onScroll;
        this.drawing = false;

        if (this.orientation !== "horizontal") {
            this.drawVertical();

            if (typeof ResizeObserver === "function") {
                const ro = new ResizeObserver(entries => {
                    if(this.drawing || entries[0].contentRect.width === 0 || entries[0].contentRect.height === 0)
                        return;
                    this.drawVertical();
                });
                ro.observe(this.container);
            } else {
                window.addEventListener('resize', () => {
                    if(this.drawing)
                        return;
                    this.drawVertical();
                });
            }
        } else {
            this.drawHorizontal();
            if (typeof ResizeObserver === "function") {
                const ro = new ResizeObserver(entries => {
                    if(this.drawing || entries[0].contentRect.width === 0 || entries[0].contentRect.height === 0)
                        return;
                    this.drawHorizontal();
                });
                ro.observe(this.container);
            } else {
                window.addEventListener('resize', () => {
                    if(this.drawing)
                        return;
                    this.drawHorizontal();
                });
            }
        }
    }

    /**
     * Draws a vertical chart
     * @private
     */
    drawVertical() {
        this.drawing = true;
        const realHeight = this.container.clientHeight - this.padding.top - this.padding.bottom;
        const viewboxHeightScale = 100 / realHeight;
        const barCount = this.data.length;
        const barWidth = this.barSize;
        const barHeight = 100 - 25 * viewboxHeightScale;

        if (this.adjustSize) {
            const width = (barWidth + this.distance) * barCount + this.padding.left + this.padding.right;
            this.container.style.width = `${width}px`;
        }

        const realWidth = this.container.clientWidth - this.padding.right - this.padding.left;
        const viewboxWidthScale = realWidth / 100;
        const barSpacing = Math.max(this.minDistance, this.distance === 'variable' ? (100 * viewboxWidthScale - (this.scale.visible ? 30 : 0)) / barCount - barWidth : this.distance);

        // Find max value
        let max = 0
        if(this.max === 'relative') {
            max = this.data.reduce((p, c) => Math.max(p, c.datasets.reduce((p, c) => p + c.value, 0)), 0);
        } else {
            max = this.max;
        }

        const valueMap = {};

        if(viewboxWidthScale <= 0) {
            clear(this.container);
            return;
        }

        this.svg = Draw.svg(`calc(100% - ${this.padding.right + this.padding.left}px)`, `calc(100% - ${this.padding.top + this.padding.bottom}px)`, 100 * viewboxWidthScale, 100);

        // Padding
        this.svg.style.paddingTop = this.padding.top;
        this.svg.style.paddingRight = this.padding.right;
        this.svg.style.paddingBottom = this.padding.bottom;
        this.svg.style.paddingLeft = this.padding.left;
        this.svg.style.boxSizing = "initial";

        // Draw scale
        const scaleStepSize = barHeight / Math.floor(max / this.scale.interval);

        if(this.scale.visible) {
            for(let i = 1; i < Math.floor(max / this.scale.interval); i++) { // Skip the first bar
                const line = Draw.rect(30, barHeight - i * scaleStepSize, realWidth, 1 * viewboxHeightScale, this.scale.color);
                this.svg.appendChild(line);
            }
        }

        // Draw data
        this.dataContainer = Draw.group();
        this.svg.appendChild(this.dataContainer);

        for (let i = 0; i < barCount; i++) {
            const label = this.data[i].label || "";

            const rx = barWidth / 2;
            const ry = barWidth / 2 * viewboxHeightScale;

            const background = Draw.path(
                `M ${(this.scale.visible ? 30 : 0) + (i + 0.5) * barSpacing + i * barWidth},${0} m 0, ${barHeight - ry} a ${rx},${ry} 0 0 0 ${barWidth},0 v ${ry * 2 - barHeight} a ${rx},${ry} 0 0 0 ${-barWidth},0 z`,
                this.backgroundColor
            );
            this.dataContainer.appendChild(background);

            let y = 0; // height of the bar. Contains the position at which to draw the next rectangle

            for (let j = 0; j < this.data[i].datasets.length; j++) {
                const value = this.data[i].datasets[j].value || 0;
                const title = this.data[i].datasets[j].title || "";
                let color = this.data[i].datasets[j].color || "";

                if(color === "") {
                    if(this.fixColorToTitle){
                        if (!(title in valueMap)) { // sub-category has not be encountered before
                            color = this.foregroundColors[Object.keys(valueMap).length % this.foregroundColors.length];
                            valueMap[title] = color;
                        } else {
                            color = valueMap[title];
                        }
                    } else {
                        color = this.foregroundColors[j % this.foregroundColors.length];
                    }
                }

                const height = (barHeight * value / max);
                if(height > 0 && y < barHeight) {
                    const foreground = Draw.path(
                        createVerticalBar((this.scale.visible ? 30 : 0) + (i + 0.5) * barSpacing + i * barWidth, barHeight, rx, ry, height, y, barHeight),
                        color
                    );

                    if (this.hover.visible) {
                        foreground.addEventListener('mouseenter', evt => { this.showTooltip(true, foreground, value, title) });
                        foreground.addEventListener("mouseleave", evt => { this.showTooltip(false) });
                    }

                    if (y < barHeight) { // only draw the part if it would not overshoot
                        this.dataContainer.appendChild(foreground);
                    }

                    y = y + height;
                }
            }

            const text = Draw.text((this.scale.visible ? 30 : 0) + (i + 0.5) * (barSpacing + barWidth), barHeight + (20 * viewboxHeightScale), label, this.textColor, this.font, {"style": "user-select: none;"});
            text.setAttribute("transform", `scale(1,${viewboxHeightScale}) translate(0, ${parseFloat(text.getAttribute("y")) / viewboxHeightScale - parseFloat(text.getAttribute("y"))})`);
            this.dataContainer.appendChild(text);
        }

        // Draw scale text
        if(this.scale.visible) {
            const rect = Draw.rect(0, 0, 30, 100, "white");
            this.svg.appendChild(rect);
            for(let i = 1; i < Math.floor(max / this.scale.interval); i++) { // Skip the first bar
                const text = Draw.text(0, barHeight - i * scaleStepSize, i * this.scale.interval, this.textColor, this.font, { "text-anchor": "start", "alignment-baseline": "central", "style": "user-select: none;" });
                text.setAttribute("transform", `scale(1,${viewboxHeightScale}) translate(0, ${parseFloat(text.getAttribute("y")) / viewboxHeightScale - parseFloat(text.getAttribute("y"))})`);
                this.svg.appendChild(text);
            }
        }

        clear(this.container);
        this.tooltip = undefined;
        this.container.appendChild(this.svg);

        if(this.draggable) {
            let startPos;
            let currentTranslate = 0;
            
            this.svg.addEventListener('mousedown',e => startPos = e.clientX);
            this.svg.addEventListener("mousemove", e => {
                if(startPos) {
                    let newPos = currentTranslate + e.clientX - startPos;
                    newPos = Math.min(0, newPos);
                    newPos = Math.max(newPos, -Math.max(0, (this.scale.visible ? 30 : 0) + 0.5 * barSpacing + this.dataContainer.getBoundingClientRect().width - realWidth));

                    this.dataContainer.style.transform = `translateX(${newPos}px)`;
                } else {
                    currentTranslate = parseFloat(this.dataContainer.style.transform.replace("translateX(", "").replace("px)", "")) || 0;
                }
            });
            document.addEventListener('mouseup', () => startPos = undefined);
        }

        this.svg.addEventListener("wheel", this.onScroll);
        this.drawing = false;
    }

    /**
     * Draws a horizontal chart
     * @private
     */
    drawHorizontal() {
        this.drawing = true;
        const realWidth = this.container.clientWidth - this.padding.right - this.padding.left;
        const viewboxWidthScale = 100 / realWidth;
        const barCount = this.data.length;
        const textWidth = this.data.reduce((p, c) => Math.max(p, c.label !== undefined && c.label.length > 0 ? (2 + c.label.length * 7.5) * viewboxWidthScale : 0), 0); // 7.5 per char 
        const barWidth = 100 - textWidth;
        const barHeight = this.barSize;

        if (this.adjustSize) {
            const height = (barCount + this.distance) * barHeight + this.padding.top + this.padding.bottom;
            this.container.style.height = `${height}px`;
        }

        const realHeight = this.container.clientHeight - this.padding.top - this.padding.bottom;
        const viewboxHeightScale = realHeight / 100;
        const barSpacing = Math.max(this.minDistance, this.distance === 'variable' ? (100 * viewboxHeightScale - (this.scale.visible ? 30 : 0)) / barCount - barHeight : this.distance);

        // Find max value
        let max = 0
        if(this.max === 'relative') {
            max = this.data.reduce((p, c) => Math.max(p, c.datasets.reduce((p, c) => p + c.value, 0)), 0);
        } else {
            max = this.max;
        }

        const valueMap = {};

        if(viewboxHeightScale <= 0) {
            clear(this.container);
            return;
        }
  
        this.svg = Draw.svg(`calc(100% - ${this.padding.right + this.padding.left}px)`, `calc(100% - ${this.padding.top + this.padding.bottom}px)`, 100, 100 * viewboxHeightScale);

        // Padding
        this.svg.style.paddingTop = this.padding.top;
        this.svg.style.paddingRight = this.padding.right;
        this.svg.style.paddingBottom = this.padding.bottom;
        this.svg.style.paddingLeft = this.padding.left;
        this.svg.style.boxSizing = "initial";

        // Draw scale
        const scaleStepSize = barWidth / Math.floor(max / this.scale.interval);

        if(this.scale.visible) {
            for(let i = 1; i < Math.floor(max / this.scale.interval); i++) { // Skip the first bar
                const line = Draw.rect(i * scaleStepSize, 30, 1 * viewboxWidthScale, realHeight, this.scale.color);
                this.svg.appendChild(line);
            }
        }

        // Draw data
        this.dataContainer = Draw.group();
        this.svg.appendChild(this.dataContainer);

        for (let i = 0; i < barCount; i++) {
            const label = this.data[i].label || "";

            const rx = barHeight / 2 * viewboxWidthScale;
            const ry = barHeight / 2;

            const background = Draw.path(
                `M ${textWidth + rx}, ${(this.scale.visible ? 30 : 0) + (i + 0.5) * barSpacing + i * barHeight} a ${rx},${ry} 0 0 0 0,${barHeight} h ${barWidth - rx * 2} a ${rx},${ry} 0 0 0 0,${-barHeight} z`,
                this.backgroundColor
            );
            this.dataContainer.appendChild(background);

            let x = 0; // width of the bar. Contains the position at which to draw the next rectangle

            for (let j = 0; j < this.data[i].datasets.length; j++) {
                const value = this.data[i].datasets[j].value || 0;
                const title = this.data[i].datasets[j].title || "";
                let color = this.data[i].datasets[j].color || "";

                if(color === "") {
                    if(this.fixColorToTitle){
                        if (!(title in valueMap)) { // sub-category has not be encountered before
                            color = this.foregroundColors[Object.keys(valueMap).length % this.foregroundColors.length];
                            valueMap[title] = color;
                        } else {
                            color = valueMap[title];
                        }
                    } else {
                        color = this.foregroundColors[j % this.foregroundColors.length];
                    }
                }

                const width = (barWidth * value / max);
                if(width > 0 && x < barWidth) {
                    const foreground = Draw.path(
                        createHorizontalBar(textWidth, (this.scale.visible ? 30 : 0) + (i + 0.5) * barSpacing + i * barHeight, rx, ry, width, x, barWidth),
                        color
                    );

                    if (this.hover.visible) {
                        foreground.addEventListener('mouseenter', evt => { this.showTooltip(true, foreground, value, title) });
                        foreground.addEventListener("mouseleave", evt => { this.showTooltip(false) });
                    }
    
                    if (x < barWidth) { // only draw the part if it would not overshoot
                        this.dataContainer.appendChild(foreground);
                    }
    
                    x = x + width;
                }     
            }

            const text = Draw.text(0, (this.scale.visible ? 30 : 0) + (i + 0.5) * (barSpacing + barHeight), label, this.textColor, this.font, { "text-anchor": "start", "alignment-baseline": "central", "style": "user-select: none;" });
            text.setAttribute("transform", `scale(${viewboxWidthScale},1) translate(${parseFloat(text.getAttribute("x")) / viewboxWidthScale - parseFloat(text.getAttribute("x"))}, 0)`);
            this.dataContainer.appendChild(text);
        }

         // Draw scale text
         if(this.scale.visible) {
            const rect = Draw.rect(0, 0, 100, 30, "white");
            this.svg.appendChild(rect);
            for(let i = 1; i < Math.floor(max / this.scale.interval); i++) { // Skip the first bar
                const text = Draw.text(i * scaleStepSize, 20, i * this.scale.interval, this.textColor, this.font, { "text-anchor": "middle", "style": "user-select: none;" });
                text.setAttribute("transform", `scale(${viewboxWidthScale},1) translate(${parseFloat(text.getAttribute("x")) / viewboxWidthScale - parseFloat(text.getAttribute("x"))}, 0)`);
                this.svg.appendChild(text);
            }
        }

        clear(this.container);
        this.tooltip = undefined;
        this.container.appendChild(this.svg);

        if(this.draggable) {
            let startPos;
            let currentTranslate = 0;
            
            this.svg.addEventListener('mousedown',e => startPos = e.clientY);
            this.svg.addEventListener("mousemove", e => {
                if(startPos) {
                    let newPos = currentTranslate + e.clientY - startPos;
                    newPos = Math.min(0, newPos);
                    newPos = Math.max(newPos, -Math.max(0, (this.scale.visible ? 30 : 0) + 0.5 * barSpacing + this.dataContainer.getBoundingClientRect().height - realHeight));

                    this.dataContainer.style.transform = `translateY(${newPos}px)`;
                } else {
                    currentTranslate = parseFloat(this.dataContainer.style.transform.replace("translateY(", "").replace("px)", "")) || 0;
                }
            });
            document.addEventListener('mouseup', () => startPos = undefined);
        }

        this.svg.addEventListener("wheel", this.onScroll);
        this.drawing = false;
    }

    /**
     * Draws a tooltip at the horizontal center of the element
     * @private
     * @param {boolean} show - Whether to show or hide the tooltip
     * @param {Object} g - the element on which the tooltip is centered
     * @param {number|string} value - the value of the element
     * @param {number|string} title - the title of the element
     */
    showTooltip(show, g, value, title) {
        this.drawing = true;
        if (this.tooltip === undefined) {
            this.tooltip = document.createElement('div');
            this.tooltip.style.display = "block";
            this.tooltip.style.position = "absolute";
            this.tooltip.style.fontFamily = this.font;
            this.tooltip.classList.add('time-chart-tooltip');
            this.tooltip.appendChild(document.createElement('span'));
            this.container.appendChild(this.tooltip);
        }

        if (!show) {
            this.tooltip.style.visibility = "hidden";
            return;
        }

        clear(this.tooltip);
        this.tooltip.innerHTML = this.hover.callback(title, value);
        this.tooltip.style.top = g.getBoundingClientRect().y - 43 + "px";
        this.tooltip.style.left = `calc(${g.getBoundingClientRect().x + g.getBoundingClientRect().width / 2 - this.tooltip.getBoundingClientRect().width / 2}px)`;
        this.tooltip.style.visibility = "visible";
        this.drawing = false;
    }

    /**
     * Replaces the existing data with new data.
     * @param {array} [data] - the data to be displayed
     * @param {string[]} [data.labels] - the labels underneath each bar
     * @param {Object[]} [data.datasets] - each dataset represents one "block" of a bar. To create a stacked bar chart have multiple datasets.
     * @param {number[]} data.datasets[].values - the values for each "block" of a bar. Should be between 0 and 1. 
     * @param {string} [data.datasets[].title] - the title for the dataset.
     */
    setData(data) {
        this.data = data;
        if (this.orientation !== "horizontal") {
            this.drawVertical();
        } else {
            this.drawHorizontal();
        }
    }
}

/**
 * Creates a timeline
 * @class
 */
class Timeline {
    /**
     * Constructs a timeline
     * @constructor
     * @param {string} element - css query selector of the container dom element into which the chart is placed.
     * @param {Object} [params] - options.
     * @param {Object} [params.lineHeight = 25] - the hight of a bar in a timeline in px.
     * @param {Object} [params.scale] - options for the scale at the top of the timelines
     * @param {number} [params.scale.from = 0] - the time in minutes at which the timeline should start.
     * @param {number} [params.scale.to = 1440] - the time in minutes at which the timeline should end.
     * @param {number} [params.scale.interval = 240] - the interval at which labels are shown on the scale.
     * @param {number} [params.scale.intervalStart = 0] - the point at which the interval starts counting.
     * @param {array} [params.data] - the data to be displayed.
     * @param {Object[]} [params.data.timelines] - each object represents one timeline. For multiple timelines under each other, have multiple objects.
     * @param {string} [params.data.timelines[].label] - the label to the right of the timeline.
     * @param {Object[]} params.data.timelines[].values - the values (marked time slots).
     * @param {number} params.data.timelines[].values[].start - the point at which the time slot starts in minutes.
     * @param {number} params.data.timelines[].values[].length - the point at which the time slot ends in minutes.
     * @param {string} [params.data.timelines[].values[].title] - the title of the time slot.
     * @param {string[]} [params.data.timelines[].colors = ['#7cd6fd', '#5e64ff', '#743ee2', '#ff5858', '#ffa00a', '#feef72', '#28a745', '#98d85b', '#b554ff', '#ffa3ef', '#36114C', '#bdd3e6', '#f0f4f7', '#b8c2cc']] - the colors for the timeline.
     * @param {Object} [params.padding] - padding in all directions of the chart.
     * @param {number|string} [params.padding.top] - top padding for the chart.
     * @param {number|string} [params.padding.right] - right padding for the chart.
     * @param {number|string} [params.padding.bottom] - bottom padding for the chart.
     * @param {number|string} [params.padding.left] - left padding for the chart.
     * @param {Object} [params.colors] - custom colors
     * @param {string} [params.colors.background = "#E3E6E9"] - the color of the background of the bars (not the color of background of the whole chart).
     * @param {string} [params.colors.text = "black"] - the color of the text.
     * @param {string} [params.round = true] - if the timeline is round.
     * @param {string} [params.font = 'Roboto'] - the font for all writing. Font must be imported separately.
     * @param {Object} [params.hover] - options for the hover effect.
     * @param {boolean} [params.hover.visible = true] - whether the titles should be shown on hover or not.
     * @param {Function} [params.hover.callback] - function that returns html that is displayed in the hover effect. Receives (title, start, end).
     * @param {Object} [params.legend] - options for the legend.
     * @param {boolean} [params.legend.visible = true] - whether a legend should be shown underneath the timelines.
     * @param {number} [params.legend.distance = 15] - distance from the last timeline to the legend in px. Always set to 0 if params.legend.visible === false.
     * @param {number} [params.legend.textColor = "white"] - the color of the text in the legend.
     * @param {number} [params.legend.textWidth = "variable"] - distance between the legend text and the legend.
     * @param {'variable' | number} [params.distance = 'variable'] - whether the distance between timelines should be variable (based on svg size) or a fixed number of px.
     * @param {boolean} [params.adjustSize = false] - whether the size of the container should be adjusted based on the needed space. Only works if params.distance != 'variable'.
     * @throws Will throw an error if the container element is not found.
     */
    constructor(element, params) {
        this.container = document.querySelector(element);
        if (this.container == null) {
            console.error("Container for chart does not exist");
            return;
        }

        // Extract parameters and sets defaults if parameters not available
        mergeObjects(params, {
            scale: {
                from: 0,
                to: 1440,
                interval: 240,
                intervalStart: 0
            },
            data: {
                timelines: [],
            },
            padding: {
                top: 0,
                right: 0,
                bottom: 0,
                left: 0
            },
            colors: {
                background: "#E3E6E9",
                text: "black"
            },
            font: "Roboto",
            hover: {
                visible: true,
                callback: (title, start, end) => `<span style="color: gray">${this.formatMinutes2(start)} - ${this.formatMinutes2(end)}</span>${title !== "" ? ": " + title : ""}`
            },
            legend: {
                visible: true,
                distance: 15,
                textColor: "white",
                textWidth: "variable"
            },
            round: true,
            lineHeight: 25,
            distance: 'variable',
            adjustSize: false,
        });

        this.scale = params.scale;
        this.data = params.data;
        this.padding = params.padding;
        this.font = params.font;
        this.hover = params.hover;
        this.legend = params.legend.visible;
        this.legendDistance = params.legend.distance;
        this.legendTextColor = params.legend.textColor;
        this.legendTextWidth = params.legend.textWidth;
        this.lineHeight = params.lineHeight;
        this.distance = params.distance;
        this.adjustSize = this.distance !== 'variable' && params.adjustSize;
        this.backgroundColor = params.colors.background;
        this.textColor = params.colors.text;
        this.drawing = false;
        this.round = params.round;

        this.draw();
        if (typeof ResizeObserver === "function") {
            const ro = new ResizeObserver(entries => {
                if(this.drawing || entries[0].contentRect.width === 0 || entries[0].contentRect.height === 0)
                    return;
                this.draw();
            });
            ro.observe(this.container);
        } else {
            window.addEventListener('resize', () => {
                if(this.drawing)
                    return;
                this.draw();
            });
        }
    }

    /**
     * Draws the timeline
     * @private
     */
    draw() {
        this.drawing = true;
        const realWidth = this.container.clientWidth - this.padding.right - this.padding.left;
        const viewboxWidthScale = 100 / realWidth;
        const lineCount = this.data.timelines.length;
        const textWidth1 = this.data.timelines.reduce((p, c) => Math.max(p, c.label.length > 0 ? (40 + c.label.length * 7.5) * viewboxWidthScale : 0), 0); // 7.5 per char 
        const textWidth2 = this.data.timelines.reduce((p, c) => Math.max(p, c.values.reduce((p, c) => Math.max(p, (10 + this.formatMinutes(c.length).length * 7.5) * viewboxWidthScale), 0)), 0); // 7.5 per char
        const widthLeft = this.legendTextWidth === "variable" ? Math.max(textWidth1 + textWidth2, 20 * viewboxWidthScale) : this.legendTextWidth * viewboxWidthScale;
        const widthRight = 20 * viewboxWidthScale;
        const scaleHeight = 20;
        const lineWidth = 100 - widthLeft - widthRight;
        const lineHeight = this.lineHeight;
        let legendLines = this.legend ? 1 : 0;
        if(this.legend) {
            const legendTitles = [].concat.apply([], this.data.timelines.map(t => t.values.map(v => {
                return {
                    title: v.title,
                    length: v.length
                }
            })));

            const groupedTitles = {};

            for(const l of legendTitles) {
                if(!groupedTitles[l.title])
                groupedTitles[l.title] = l.length;
                else
                groupedTitles[l.title] += l.length;
            }
            
            let x = 0;
            for(let key of Object.keys(groupedTitles)) {
                const width = ((`${key} - ${this.formatMinutes(groupedTitles[key])}`).length * 7.5 * viewboxWidthScale) + 2 * (lineHeight / 2 * viewboxWidthScale);
                if(x + width > lineWidth) {
                    legendLines++;
                    x = 0;
                }
                x = x + width + 10 * viewboxWidthScale;
            }
        }
        
        const legendHeight = this.legend ? lineHeight : 10;
        const legendTotalHeight = this.legend ? (legendHeight + 10) * legendLines - 10 : 0;
        const legendSpacing = this.legend ? this.legendDistance : 0;

        if (this.adjustSize) {
            const height = scaleHeight + legendTotalHeight + legendSpacing + lineCount * (lineHeight + this.distance) + this.padding.top + this.padding.bottom;
            this.container.style.height = `${height}px`;
        }

        const realHeight = this.container.clientHeight - this.padding.top - this.padding.bottom;
        const viewboxHeightScale = realHeight / 100;
        const lineSpacing = this.distance === 'variable' ? (100 * viewboxHeightScale - scaleHeight - legendHeight - legendSpacing) / lineCount - lineHeight : this.distance;
        const scaleStart = Math.max(0.5 * lineSpacing - scaleHeight, 0);

        if(viewboxHeightScale <= 0) {
            clear(this.container);
            return;
        }

        this.svg = Draw.svg(`calc(100% - ${this.padding.right + this.padding.left}px)`, `calc(100% - ${this.padding.top + this.padding.bottom}px)`, 100, 100 * viewboxHeightScale);

        // Padding
        this.svg.style.paddingTop = this.padding.top;
        this.svg.style.paddingRight = this.padding.right;
        this.svg.style.paddingBottom = this.padding.bottom;
        this.svg.style.paddingLeft = this.padding.left;
        this.svg.style.boxSizing = "initial";

        // Draw scale
        const from = this.scale.from;
        const to = this.scale.to;
        const interval = this.scale.interval;
        const intervalStart = (this.scale.intervalStart) / (to - from) * lineWidth;
        const intervalSteps = Math.floor((to - from) / interval);
        const intervalStepsWidth = lineWidth / intervalSteps;

        for (let i = 0; i <= intervalSteps; i++) {
            const text = Draw.text(widthLeft + intervalStart + i * intervalStepsWidth, scaleStart, this.formatMinutes2(from + this.scale.intervalStart + i * interval), this.textColor, this.font, { "text-anchor": "middle", "alignment-baseline": "text-before-edge" });
            text.setAttribute("transform", `scale(${viewboxWidthScale},1) translate(${parseFloat(text.getAttribute("x")) / viewboxWidthScale - parseFloat(text.getAttribute("x"))}, 0)`);
            this.svg.appendChild(text);
        }

        let x = 0;
        let y = 0;

        // Draw data
        for (let i = 0; i < lineCount; i++) {
            const label = this.data.timelines[i].label || "";
            const values = this.data.timelines[i].values || [];
            const colors = this.data.timelines[i].colors || ['#7cd6fd', '#5e64ff', '#743ee2', '#ff5858', '#ffa00a', '#feef72', '#28a745', '#98d85b', '#b554ff', '#ffa3ef', '#36114C', '#bdd3e6', '#f0f4f7', '#b8c2cc'];
            const valueMap = {}; // Helper to calculate grouped values and store color codes
            const sum = this.data.timelines[i].values.reduce((p, c) => p + c.length, 0);

            const rx = lineHeight / 2 * viewboxWidthScale;
            const ry = lineHeight / 2;

            // Draw background
            // Gray background
            const background = Draw.path(
                `M ${widthLeft + rx}, ${scaleStart + scaleHeight + i * (lineSpacing + lineHeight)} a ${rx},${ry} 0 0 0 0,${lineHeight} h ${lineWidth - rx * 2} a ${rx},${ry} 0 0 0 0,${-lineHeight} z`,
                this.backgroundColor
            );
            this.svg.appendChild(background);

            // White stripes each hour
            const steps = (to - from) / 60;
            const stepWidth = lineWidth / steps;

            for (let j = 1; j < steps; j++) {
                const rect = Draw.rect(widthLeft + j * stepWidth - (1 * viewboxWidthScale), scaleStart + scaleHeight + i * (lineSpacing + lineHeight), (2 * viewboxWidthScale), lineHeight, "white");
                this.svg.appendChild(rect);
            }

            // Draw foreground
            for (let j = 0; j < values.length; j++) {
                const relativeStart = (Math.max(0, values[j].start - from) / (to - from));
                const relativeLength = (Math.max(0, values[j].start - from + values[j].length) / (to - from));
                const title = values[j].title || "";

                let color = "";

                if (!(title in valueMap)) { // sub-category has not be encountered before
                    color = colors[Object.keys(valueMap).length % colors.length];
                    valueMap[title] = {
                        color: color,
                        value: values[j].length
                    }
                } else {
                    color = valueMap[title].color;
                    valueMap[title].value = valueMap[title].value + values[j].length;
                }

                let foreground;
                const width = (lineWidth * (relativeLength - relativeStart));

                const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
                    const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;

                    return {
                        x: centerX + (radius * Math.cos(angleInRadians)),
                        y: centerY + (radius * Math.sin(angleInRadians))
                    };
                }

                const partialCircle = (x, y, radius, startAngle, endAngle) => {

                    const start = polarToCartesian(x, y, radius, endAngle);
                    const end = polarToCartesian(x, y, radius, startAngle);

                    const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";

                    const d = [
                        "M", start.x, start.y,
                        "A", radius, radius, 0, largeArcFlag, 0, end.x, end.y,
                        "L",
                    ].join(" ");

                    return d;
                }
                const steepness = 0.05; // The smaller, the rounder

                if(this.round) {
                    if (width - rx * 2 < 0) { // bar to short to form circle
                        foreground = Draw.path(
                            `M ${widthLeft + lineWidth * relativeStart + width / 2}, ${scaleStart + scaleHeight + i * (lineSpacing + lineHeight)} c ${-(width / 2 / 0.75)} ${lineHeight * steepness}, ${-(width / 2 / 0.75)} ${lineHeight * (1 - steepness)}, 0 ${lineHeight} v ${-lineHeight} c ${width / 2 / 0.75} ${lineHeight * steepness}, ${width / 2 / 0.75} ${lineHeight * (1 - steepness)}, 0 ${lineHeight} z`,
                            color
                        );
                    } else {
                        foreground = Draw.path(
                            `M ${widthLeft + lineWidth * relativeStart + rx},${scaleStart + scaleHeight + i * (lineSpacing + lineHeight)} a ${rx},${ry} 0 0 0 0,${lineHeight} h ${(lineWidth * (relativeLength - relativeStart)) - rx * 2} a ${rx},${ry} 0 0 0 0,${-lineHeight} z`,
                            color
                        );
                    }
                } else {
                    foreground = Draw.path(
                        `M ${widthLeft + lineWidth * relativeStart + rx},${scaleStart + scaleHeight + i * (lineSpacing + lineHeight)} h ${(lineWidth * (relativeLength - relativeStart))} v ${lineHeight} h ${-(lineWidth * (relativeLength - relativeStart))} z`,
                        color
                    );
                }

                this.svg.appendChild(foreground);

                if (this.hover.visible) {
                    foreground.addEventListener('mouseenter', evt => { this.showTooltip(true, foreground, values[j].start, values[j].start + values[j].length, title) });
                    foreground.addEventListener("mouseleave", evt => { this.showTooltip(false) });
                }
            }

            // Draw label
            const text = Draw.text(0.5 * textWidth1, scaleStart + scaleHeight + i * lineSpacing + (i + 0.5) * lineHeight, label, this.textColor, this.font, { "text-anchor": "middle", "alignment-baseline": "central", "font-weight": "bold" });
            text.setAttribute("transform", `scale(${viewboxWidthScale},1) translate(${parseFloat(text.getAttribute("x")) / viewboxWidthScale - parseFloat(text.getAttribute("x"))}, 0)`);
            this.svg.appendChild(text);

            // Draw sum
            const text2 = Draw.text(textWidth1, scaleStart + scaleHeight + i * lineSpacing + (i + 0.5) * lineHeight, this.formatMinutes(sum), this.textColor, this.font, { "text-anchor": "start", "alignment-baseline": "central" });
            text2.setAttribute("transform", `scale(${viewboxWidthScale},1) translate(${parseFloat(text2.getAttribute("x")) / viewboxWidthScale - parseFloat(text2.getAttribute("x"))}, 0)`);
            this.svg.appendChild(text2);

            // Draw legend
            if (this.legend) {
                for (let key of Object.keys(valueMap)) {
                    const content = `${key} - ${this.formatMinutes(valueMap[key].value)}`;
                    const width = (content.length * 7.5 * viewboxWidthScale) + 2 * rx;
                    if(x + width > lineWidth) {
                        x = 0;
                        y += legendHeight + 10;
                    }

                    const legend = Draw.path(
                        `M ${widthLeft + x + rx},${scaleStart + legendSpacing + scaleHeight + (lineCount - 1) * lineSpacing + lineCount * lineHeight + y} a ${rx},${ry} 0 0 0 0,${legendHeight} h ${width - rx * 2} a ${rx},${ry} 0 0 0 0,${-legendHeight} z`,
                        valueMap[key].color
                    );
                    this.svg.appendChild(legend);

                    const text = Draw.text(widthLeft + x + 0.5 * width, scaleStart + legendSpacing + legendHeight * 0.5 + scaleHeight + (lineCount - 1) * lineSpacing + lineCount * lineHeight + y, content, this.legendTextColor, this.font, { "text-anchor": "middle", "alignment-baseline": "central" });
                    text.setAttribute("transform", `scale(${viewboxWidthScale},1) translate(${parseFloat(text.getAttribute("x")) / viewboxWidthScale - parseFloat(text.getAttribute("x"))}, 0)`);
                    this.svg.appendChild(text);

                    x = x + width + 10 * viewboxWidthScale; // previous x, width of the rectangle and padding
                }
            }
            
        }
        
        clear(this.container);
        this.tooltip = undefined;
        this.container.appendChild(this.svg);
        this.drawing = false;
    }

    /**
     * Converts a number of minutes into a string
     * @private
     * @param {number} minutes - the minutes
     * @returns {string} - format: 4h 35m
     */
    formatMinutes(minutes) {
        const h = Math.floor(minutes / 60);
        const m = Math.floor(minutes % 60);

        if (m === 0 && h === 0) {
            return "";
        } else if (m === 0) {
            return `${h}h`;
        } else if (h === 0) {
            return `${m}m`;
        } {
            return `${h}h ${m}m`;
        }
    }

    /**
     * Converts a number of minutes into a string
     * @private
     * @param {number} minutes - the minutes
     * @returns {string} - format: 4:30 am
     */
    formatMinutes2(minutes) {
        let h = Math.floor(minutes / 60);
        let ending = "am";
        const m = Math.floor(minutes % 60);

        if (h > 12) {
            ending = "pm";
            h = h - 12;
        }

        if (m === 0) {
            return `${h} ${ending}`;
        } else {
            return `${h}:${m < 10 ? "0" + m : m} ${ending}`;
        }
    }

    /**
     * Draws a tooltip at the horizontal center of the element
     * @private
     * @param {boolean} show - Whether to show or hide the tooltip
     * @param {Object} g - the element on which the tooltip is centered
     * @param {number} start - the start value in minutes
     * @param {number} end - the vend value in minutes
     * @param {number|string} title - the title of the element
     */
    showTooltip(show, g, start, end, title) {
        this.drawing = true;
        if (this.tooltip === undefined) {
            this.tooltip = document.createElement('div');
            this.tooltip.style.display = "block";
            this.tooltip.style.position = "absolute";
            this.tooltip.style.fontFamily = this.font;
            this.tooltip.classList.add('time-chart-tooltip');
            this.tooltip.appendChild(document.createElement('span'));
            this.container.appendChild(this.tooltip);
        }

        if (!show) {
            this.tooltip.style.visibility = "hidden";
            return;
        }

        this.tooltip.style.top = g.getBoundingClientRect().y - 43 + "px";
        clear(this.tooltip);
        this.tooltip.innerHTML = this.hover.callback(title, start, end);
        this.tooltip.style.left = `calc(${g.getBoundingClientRect().x + g.getBoundingClientRect().width / 2 - this.tooltip.getBoundingClientRect().width / 2}px)`;
        this.tooltip.style.visibility = "visible";
        this.drawing = false;
    }

    /**
     * Replaces the existing data with new data. 
     * @param {array} [params.data] - the data to be displayed.
     * @param {Object[]} [params.data.timelines] - each object represents one timeline. For multiple timelines under each other, have multiple objects.
     * @param {string} [params.data.timelines[].label] - the label to the right of the timeline.
     * @param {Object[]} params.data.timelines[].values - the values (marked time slots).
     * @param {number} params.data.timelines[].values[].start - the point at which the time slot starts in minutes.
     * @param {number} params.data.timelines[].values[].length - the point at which the time slot ends in minutes.
     * @param {string} [params.data.timelines[].values[].title] - the title of the time slot.
     * @param {string[]} [params.data.timelines[].colors = ['#7cd6fd', '#5e64ff', '#743ee2', '#ff5858', '#ffa00a', '#feef72', '#28a745', '#98d85b', '#b554ff', '#ffa3ef', '#36114C', '#bdd3e6', '#f0f4f7', '#b8c2cc']] - the colors for the timeline.
     */
    setData(data) {
        this.data = data;
        this.draw();
    }
}

class Piechart {
    /**
     * Constructs a Piechart
     * @constructor
     * @param {string} element - css query selector of the container dom element into which the chart is placed.
     * @param {Object} [params] - options.
     * @param {Object[]} [params.data] - the data to be displayed.
     * @param {number} [params.data[].value] - value of the part.
     * @param {number} [params.data[].label] - label of the part.
     * @param {number} [params.data[].color] - color of the part.
     * @param {Object} [params.padding] - padding in all directions of the chart.
     * @param {number|string} [params.padding.top] - top padding for the chart.
     * @param {number|string} [params.padding.right] - right padding for the chart.
     * @param {number|string} [params.padding.bottom] - bottom padding for the chart.
     * @param {number|string} [params.padding.left] - left padding for the chart.
     * @param {Array} [params.colors] - custom colors
     * @param {string} [params.font = 'Roboto'] - the font for all writing. Font must be imported separately.
     * @param {Object} [params.hover] - options for the hover effect.
     * @param {boolean} [params.hover.visible = true] - whether the titles should be shown on hover or not.
     * @param {Function} [params.hover.callback] - function that returns html that is displayed in the hover effect. Receives (title, start, end).
     * @throws Will throw an error if the container element is not found.
     */
    constructor(element, params) {
        this.container = document.querySelector(element);
        if (this.container == null) {
            console.error("Container for chart does not exist");
            return;
        }

        // Extract parameters and sets defaults if parameters not available
        mergeObjects(params, {
            data: [

            ],
            padding: {
                top: 0,
                right: 0,
                bottom: 0,
                left: 0
            },
            colors: ['#7cd6fd', '#5e64ff', '#743ee2', '#ff5858', '#ffa00a', '#feef72', '#28a745', '#98d85b', '#b554ff', '#ffa3ef', '#36114C', '#bdd3e6', '#f0f4f7', '#b8c2cc'],
            font: "Roboto",
            hover: {
                visible: true,
                callback: (title, value) => `<span style="color: gray">${value}</span>${title !== "" ? ": " + title : ""}`
            },
            adjustSize: false,
            donutFactor: 0
        });

        this.scale = params.scale;
        this.data = params.data;
        this.padding = params.padding;
        this.font = params.font;
        this.hover = params.hover;
        this.colors = params.colors;
        this.drawing = false;
        this.donutFactor = params.donutFactor;

        this.draw();
        if (typeof ResizeObserver === "function") {
            const ro = new ResizeObserver(entries => {
                if(this.drawing || entries[0].contentRect.width === 0 || entries[0].contentRect.height === 0)
                    return;
                this.draw();
            });
            ro.observe(this.container);
        } else {
            window.addEventListener('resize', () => {
                if(this.drawing)
                    return;
                this.draw();
            });
        }
    }

    /**
     * Draws the piechart
     * @private
     */
    draw() {
        this.drawing = true;
        this.svg = Draw.svg(`calc(100% - ${this.padding.right + this.padding.left}px)`, `calc(100% - ${this.padding.top + this.padding.bottom}px)`, 100, 100, {
            preserveAspectRatio: "xMidYMin"
        });

        // Padding
        this.svg.style.paddingTop = this.padding.top;
        this.svg.style.paddingRight = this.padding.right;
        this.svg.style.paddingBottom = this.padding.bottom;
        this.svg.style.paddingLeft = this.padding.left;
        this.svg.style.boxSizing = "initial";

        // Draw data
        const drawCircle = (cx, cy, rx, ry, part, prev, size) => {
            const startX = cx + rx * Math.sin(prev * 2 * Math.PI);
            const startY = cy + ry * Math.cos((0.5 + prev) * 2 * Math.PI);
            const dx = rx * part;
            const dy = ry * part;

            size = Math.min(size, 0.999);

            const path = `M ${startX} ${startY} A ${rx} ${ry} 0 ${size >= 0.5 ? 1 : 0} 1 ${cx + rx * Math.sin((size + prev) * 2 * Math.PI)}, ${cy + ry * Math.cos((0.5 + size + prev) * 2 * Math.PI)} L ${cx + dx * Math.sin((size + prev) * 2 * Math.PI)}, ${cy + dy * Math.cos((0.5 + size + prev) * 2 * Math.PI)} A ${dx} ${dy} 0 ${size >= 0.5 ? 1 : 0} 0 ${cx + (startX - cx) / rx * dx}, ${cy + (startY - cy) / ry * dy} z`;

            return path;
        }

        let deg = 0;
        const sum = this.data.reduce((p, c) => p + c.value, 0);
        // Draw foreground
        for (let i = 0; i < this.data.length; i++) {
            const label = this.data[i].label || "";
            const value = this.data[i].value ? this.data[i].value / sum : 0;
            const color = this.data[i].color || this.colors[i % this.colors.length];

            let  foreground = Draw.path(
                drawCircle(50, 50, 50, 50, this.donutFactor, deg, value),
                color
            );

            deg += value;

            this.svg.appendChild(foreground);

            if (this.hover.visible) {
                foreground.addEventListener('mousemove', evt => { this.showTooltip(true, foreground, value, label, evt) });
                foreground.addEventListener("mouseleave", evt => { this.showTooltip(false) });
            }
        }
        
        clear(this.container);
        this.tooltip = undefined;
        this.container.appendChild(this.svg);
        this.drawing = false;
    }

    /**
     * Draws a tooltip at the horizontal center of the element
     * @private
     * @param {boolean} show - Whether to show or hide the tooltip
     * @param {Object} g - the element on which the tooltip is centered
     * @param {number} value - the value
     * @param {number|string} title - the title of the element
     *  @param {Object} title - the mouse event
     */
    showTooltip(show, g, value, title, event) {
        this.drawing = true;
        if (this.tooltip === undefined) {
            this.tooltip = document.createElement('div');
            this.tooltip.style.display = "block";
            this.tooltip.style.position = "absolute";
            this.tooltip.style.fontFamily = this.font;
            this.tooltip.classList.add('time-chart-tooltip');
            this.tooltip.appendChild(document.createElement('span'));
            this.container.appendChild(this.tooltip);
        }

        if (!show) {
            this.tooltip.style.visibility = "hidden";
            return;
        }

        this.tooltip.style.top = (event.pageY - 47) + "px";
        clear(this.tooltip);
        this.tooltip.innerHTML = this.hover.callback(title, value);
        this.tooltip.style.left = (event.pageX - this.tooltip.getBoundingClientRect().width / 2) + "px";
        this.tooltip.style.visibility = "visible";
        this.drawing = false;
    }

    /**
     * Replaces the existing data with new data. 
     * @param {array} [params.data] - the data to be displayed.
     * @param {Object[]} [params.data] - the data to be displayed.
     * @param {number} [params.data[].value] - value of the part.
     * @param {number} [params.data[].label] - label of the part.
     * @param {number} [params.data[].color] - color of the part.
     */
    setData(data) {
        this.data = data;
        this.draw();
    }
}

// attach properties to the exports object to define
// the exported module properties.
export {
    Barchart,
    Piechart,
    Timeline
}