index.js

import { merge } from "lodash";
import ChartEvent from "./event";
import ChartMode from "./mode";
import { DOCS_FULL_URL } from "./utils.js";
import { DateTime, Interval } from "luxon";
import format from "string-template";
import ChartBar from "./bar.js";
import { Grid, BestFirstFinder } from "pathfinding";

/**
 * DateTime object from {@link https://moment.github.io/luxon|luxon}
 * @external DateTime
 * @see {@link https://moment.github.io/luxon/api-docs/index.html#datetime|DateTime}
 */

/**
 * @type {Chart}
 * @hideconstructor
 */
class Chart
{
  /**
   * Main entrypoint to use the library. Provided {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Element} will be the container of the Gantt Chart. Recommended to use a {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div|div} element with sufficient width and height
   * @param {Element} container {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Element}
   * @param {ChartOptions|undefined} options
   * @param {external:DateTime|String|Number|undefined} start If a String or a Number, will be treated as UTC, and then converted to {@link ChartOptions#timezone}. If {@link external:DateTime|DateTime}, will only convert to {@link ChartOptions#timezone}
   * @param {external:DateTime|String|Number|undefined} end If a String or a Number, will be treated as UTC, and then converted to {@link ChartOptions#timezone}. If {@link external:DateTime|DateTime}, will only convert to {@link ChartOptions#timezone}
   * @param {ChartBar[]} data
   * @returns {Chart}
   */
  static get(container, options, start, end, data)
  {
    if(!container instanceof Element)
    {
      throw new Error(`Expected argument of type Element, got ${typeof container}`);
    }

    let instance = this.findInstance(container);

    if(!(instance instanceof Chart) || options !== undefined)
    {
      if(typeof options !== `object` || options === null)
      {
        options = {};
      }

      options = merge(this.defaultOptions, options);
    }

    if(!(instance instanceof Chart))
    {
      if(start === undefined || end === undefined)
      {
        throw new Error(`Can't pass 'start' or 'end' as undefined, when creating a new instance`);
      }

      instance = new this(false);

      instance.container = container;
      instance.options = options;
      instance.processOptions();

      container.UGLAGanttInstance = instance;
      instance.setAttribute(`container`, ``);

      instance.updateContainerStyle();
      instance.setPeriod(start, end, false);

      if(data !== undefined)
      {
        instance.setData(data);
      }
      else
      {
        instance.render();
      }
    }
    else if(options !== undefined)
    {
      instance.options = options;
      instance.processOptions();

      if(start !== undefined || end !== undefined)
      {
        instance.setPeriod(start, end, false);
      }

      if(data !== undefined)
      {
        instance.setData(data);
      }
      else
      {
        instance.render();
      }
    }

    return instance;
  }

  /**
   * @param {Element} container {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Element}
   * @private
   * @returns {Chart|undefined}
   */
  static findInstance(container)
  {
    return container.UGLAGanttInstance;
  }

  /**
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Element}
   * @type {Element}
   * @readonly
   */
  container;

  /**
   * @type {ChartOptions}
   * @readonly
   */
  options;

  /**
   * @type {external:DateTime|Number}
   * @readonly
   */
  start;

  /**
   * @type {external:DateTime|Number}
   * @readonly
   */
  end;

  /**
   * @type {ChartBar[]}
   * @readonly
   */
  data;

  /**
   * @private
   * @type {Map<String,Number>}
   */
  formatToColumnMap = new Map();

  /**
   * @private
   * @type {Map<String,ChartBar[]>}
   */
  barIDToDataMap = new Map();

  /**
   * @private
   * @type {Grid}
   */
  pathfindingGrid;

  /**
   * @private
   */
  pathfinder = new BestFirstFinder();

  /**
   * @readonly
   * @type {Number}
   */
  fontSize = 16.0;

  /**
   * @readonly
   * @type {Number}
   */
  columnWidth = 0.0;

  /**
   * @readonly
   * @type {Number}
   */
  columnWidthEm = 0.0;

  /**
   * @readonly
   * @type {Number}
   */
  rowHeight = 0.0;

  /**
   * Height of the {@link Chart} measured in number of rows
   * @readonly
   * @type {Number}
   */
  chartHeight = 0;

  /**
   * @readonly
   * @type {Number}
   */
  columnsNumber = 0;

  /**
   * @readonly
   * @type {Number}
   */
  get rowsNumber()
  {
    return this.data.length;
  }

  // CONSTANTS

  /**
   * @private
   * @constant
   * @type {Number}
   */
  static BAR_HEIGHT_COEFFICIENT = 0.6;

  /**
   * @private
   * @constant
   * @type {Number}
   */
  static BAR_HORIZONTAL_MARGIN = 0.8;

  constructor(warn = true)
  {
    if(warn)
    {
      console.warn(`You should not use the constructor directly, unless you know what you are doing. It's better to use ${this.constructor.name}.get() instead.`)
    }
  }

  /**
   * @typedef {Object} ChartOptions
   * @property {String|ChartMode} [mode=`days`]
   * @property {String} [locale=`en-gb`]
   * @property {String} [timezone=`local`]
   * @property {String} [attributePrefix=`data-ugla-gantt`]
   * @property {Boolean} [editableBars=true]
   * @property {Boolean} [panning=true]
   * @property {Number} [panSpeed=1]
   * 
   * @property {Object} customization
   * 
   * @property {Object} customization.container
   * @property {CSSStyleDeclaration} [customization.container.style={}]
   * 
   * @property {Object} customization.chart
   * @property {Number} [customization.chart.minWidthEm=2]
   * 
   * @property {Object} customization.chart.header
   * @property {Object} customization.chart.header.container
   * @property {CSSStyleDeclaration} [customization.chart.header.container.style={}]
   * @property {String} [customization.chart.header.template=`{formatted}`]
   * @property {CSSStyleDeclaration} [customization.chart.header.style={}]
   * 
   * @property {Object} customization.chart.body
   * @property {Number} [customization.chart.body.rowHeightEm=3]
   * @property {Object} customization.chart.body.container
   * @property {CSSStyleDeclaration} [customization.chart.body.container.style={}]
   * @property {CSSStyleDeclaration} [customization.chart.body.style={}]
   * @property {CSSStyleDeclaration} [customization.chart.body.lastStyle={}]
   * @property {CSSStyleDeclaration} [customization.chart.body.firstStyle={}]
   * 
   * @property {Object} customization.chart.bar
   * @property {String} [customization.chart.bar.class=``]
   * @property {Boolean} [customization.chart.bar.highlightConnectedOnHover=false]
   * @property {CSSStyleDeclaration} [customization.chart.bar.style={}]
   * @property {Number} [customization.chart.bar.heightCoef=0.6] A number between 0 and 1 (0, 1]. Represents the percentage of row height that a bar will take up. The bar will be automatically centered vertically. If not between 0 and 1, will revert to default value
   * @property {Number} [customization.chart.bar.horizontalMarginEm=0.8]
   * 
   * @property {Object} customization.connectingLines Configuration relating to the lines that are drawn onto the canvas, connecting {@link Bar} instances
   * @property {Number} [customization.connectingLines.thickness=2]
   * @property {String} [customization.connectingLines.color=`#464646`]
   */

  /**
   * @type {ChartOptions}
   * @const
   */
  static defaultOptions = {
    attributePrefix: `data-gantt`,
    mode: `days`,
    locale: `en-gb`,
    timezone: `local`,
    editableBars: true,
    panning: true,
    panSpeed: 1,
    customization: {
      container: {
        style: {
          padding: `1em`,
          background: `#FFFFFF`,
          borderRadius: `1em`,
          fontFamily: `Lato`,
          height: `100%`,
        },
      },
      chart: {
        minWidthEm: 2,
        header: {
          container: {
            style: {
              display: `flex`,
              alignItems: `center`,
            },
          },
          template: `{formatted}`,
          style: {
            display: `flex`,
            alignItems: `center`,
            justifyContent: `center`,
            padding: `0.5em 1em`,
            fontWeight: 600,
            color: `#464646`,
          },
        },
        body: {
          rowHeightEm: 3,
          container: {
            style: {
              display: `flex`,
              marginBottom: `1em`,
              overflowX: `hidden`,
              overflowY: `auto`,
              borderTopWidth: `0.15em`,
              borderTopStyle: `solid`,
              borderTopColor: `#464646`,
              borderBottomWidth: `0.15em`,
              borderBottomStyle: `solid`,
              borderBottomColor: `#464646`,
              width: `min-content`,
              flexGrow: 1,
              position: `relative`,
            },
          },
          firstStyle: {
            borderLeftWidth: `0.1em`,
          },
          lastStyle: {
            borderRightWidth: `0.1em`,
          },
          style: {
            borderLeftColor: `#464646`,
            borderLeftStyle: `dashed`,
            borderLeftWidth: `0.05em`,
            borderRightColor: `#464646`,
            borderRightStyle: `dashed`,
            borderRightWidth: `0.05em`,
          },
        },
        bar: {
          heightCoef: this.BAR_HEIGHT_COEFFICIENT,
          horizontalMarginEm: this.BAR_HORIZONTAL_MARGIN,
          highlightConnectedOnHover: false,
          class: ``,
          style: {
            background: `red`,
            borderRadius: `1em`,
          },
        },
      },
      connectingLines: {
        thickness: 2,
        color: `#464646`,
      },
    },
  };

  /**
   * @type {Element|undefined}
   * @readonly
   */
  get chartHeader()
  {
    return this.chartScroll?.childNodes?.[0];
  }

  /**
   * @type {Element|undefined}
   * @readonly
   */
  get chartBody()
  {
    return this.chartScroll?.childNodes?.[1];
  }

  /**
   * @type {Element|undefined}
   * @readonly
   */
  get chartScroll()
  {
    return this.container?.childNodes?.[0];
  }

  /**
   * @type {HTMLCanvasElement|undefined}
   * @readonly
   */
  get chartCanvas()
  {
    const canvas = this.chartBody?.childNodes?.[this.columnsNumber];

    canvas.width = canvas.clientWidth;
    canvas.height = canvas.clientHeight;

    return canvas;
  }

  /**
   * @private
   */
  attributeName(name)
  {
    return `${this.options.attributePrefix}-${name}`;
  }

  /**
   * @private
   */
  setAttribute(name, value, el = this.container)
  {
    el.setAttribute(this.attributeName(name), value);
  }

  /**
   * @private
   */
  getAttribute(name, defaultValue = undefined, el = this.container)
  {
    return el.getAttribute(this.attributeName(name)) ?? defaultValue;
  }

  /**
   * @private
   */
  updateContainerStyle()
  {
    Object.assign(this.container.style, this.options.customization.container.style);
  }

  /**
   * @private
   */
  updateColumnWidth()
  {
    const chartHeader = this.chartHeader;
    const chartBody = this.chartBody;

    const columns = chartHeader.childNodes;

    if(columns?.length > 0)
    {
      const fontSize = parseFloat(window.getComputedStyle(columns[0]).fontSize);
      let columnWidth = 0;
      let trueColumnWidth = 0;

      columns.forEach(column => {
        const computedStyle = window.getComputedStyle(column);
        const width = parseFloat(computedStyle.width);
        columnWidth = Math.max(columnWidth, width - parseFloat(computedStyle.paddingLeft) - parseFloat(computedStyle.paddingRight));
        trueColumnWidth = Math.max(trueColumnWidth, width);
      });

      trueColumnWidth = Math.max(this.options.customization.chart.minWidthEm * fontSize, trueColumnWidth);

      const trueColumnWidthEm = `${trueColumnWidth / fontSize}em`;

      const bodyColumnFontSize = parseFloat(window.getComputedStyle(chartBody.childNodes[0]).fontSize);

      this.fontSize = bodyColumnFontSize;
      this.columnWidth = trueColumnWidth;
      this.columnWidthEm = trueColumnWidth / bodyColumnFontSize;
      this.rowHeight = this.options.customization.chart.body.rowHeightEm * this.fontSize;

      columns.forEach((column, idx) => {
        Object.assign(column.style, { width: trueColumnWidthEm });
        Object.assign(chartBody.childNodes[idx].style, { minWidth: `${trueColumnWidth / bodyColumnFontSize}em` });
      });

      this.chartCanvas.style.height = `${this.chartHeight * this.rowHeight}px`;
    }
  }

  /**
   * @param {ChartOptions} options
   * @param {Boolean} [render=true]
   * @returns {Promise<Chart>}
   */
  setOptions(options, render = true)
  {
    if(options !== undefined)
    {
      if(typeof options !== `object` || options === null)
      {
        options = {};
      }

      const newMode = options.mode;

      options = merge(this.options, options);

      options.mode = newMode;

      this.options = options;
      this.processOptions();
    }

    this.reindexData();

    return render ? this.render() : Promise.resolve(this);
  }

  /**
   * @private
   */
  processOptions()
  {
    /**
     * Unpacking ChartMode preset by string key
     */
    if(typeof this.options.mode === `string`)
    {
      if(ChartMode.DEFAULTS[this.options.mode] !== undefined)
      {
        this.options.mode = ChartMode.DEFAULTS[this.options.mode];
      }
      else
      {
        throw new Error(`Undefined mode preset '${this.options.mode}'. If you want to use a custom interval and/or format, pass an instance of ChartMode. See ${DOCS_FULL_URL}/ChartMode.html`);
      }
    }

    this.options.mode.index = this.options.mode.index === undefined ? false : this.options.mode.index;

    /**
     * Bar height coefficient check
     */
    if(typeof this.options.customization.chart.bar.heightCoef !== `number` || this.options.customization.chart.bar.heightCoef <= 0 || this.options.customization.chart.bar.heightCoef > 1)
    {
      console.warn(`Option {customization.chart.bar.heightCoef} reverted back to '${this.BAR_HEIGHT_COEFFICIENT}' due to invalid value - '${this.options.customization.chart.bar.heightCoef}'`);
      this.options.customization.chart.bar.heightCoef = this.BAR_HEIGHT_COEFFICIENT;
    }
  }

  /**
   * 
   * @param {external:DateTime|String|Number|undefined} start
   * @param {external:DateTime|String|Number|undefined} end
   * @param {Boolean} [render=true]
   * @returns {Promise<Chart>}
   */
  setPeriod(start, end, render = true)
  {
    if(start !== undefined)
    {
      if(this.options.mode.index === true)
      {
        if(typeof start === `number` && (start = parseInt(start)) >= 0)
        {
          this.start = start;
        }
        else
        {
          throw new Error(`In Index mode 'start' parameter must be an unsigned integer`);
        }
      }
      else
      {
        this.start = this.castToDateTime(start);
      }
    }

    if(end !== undefined)
    {
      if(this.options.mode.index === true)
      {
        if(typeof end === `number` && (end = parseInt(end)) >= 0)
        {
          this.end = end;
        }
        else
        {
          throw new Error(`In Index mode 'end' parameter must be an unsigned integer`);
        }
      }
      else
      {
        this.end = this.castToDateTime(end);
      }
    }

    this.reindexData();

    return render ? this.render() : Promise.resolve(this);
  }

  /**
   * @private
   * @param {DateTime|String|Number|undefined} dt
   * @param {String|undefined}
   * @returns {DateTime}
   */
  castToDateTime(dt, format)
  {
    if(typeof dt === `number`)
    {
      return DateTime.fromSeconds(dt, { zone: `UTC`, locale: this.options.locale }).setZone(this.options.timezone);
    }
    else if(typeof dt === `string`)
    {
      let result = null;
      if(format === undefined)
      {
        result = DateTime.fromFormat(dt, `yyyy-MM-dd HH:mm:ss`, { zone: `UTC`, locale: this.options.locale }).setZone(this.options.timezone);
  
        result = result.isValid ? result : DateTime.fromFormat(dt, `yyyy-MM-dd`, { zone: `UTC`, locale: this.options.locale }).setZone(this.options.timezone);
      }
      else
      {
        result = DateTime.fromFormat(dt, format, { zone: `UTC`, locale: this.options.locale }).setZone(this.options.timezone);
      }

      if(!result.isValid)
      {
        throw new Error(`Invalid DateTime from '${dt}' with format '${format || `undefined`}'`);
      }

      return result;
    }
    else
    {
      return dt;
    }
  }

  /**
   * @private
   */
  reindexData()
  {
    this.buildFormatToColumnMap();

    if(this.data !== undefined)
    {
      if(this.options.mode.index !== true)
      {
        this.data.forEach(bar => {
          if(this.options.mode.idxFormat !== undefined)
          {
            bar.startIDX = this.formatToColumnMap.get(bar.start.toFormat(this.options.mode.idxFormat));
            bar.endIDX = this.formatToColumnMap.get(bar.end.toFormat(this.options.mode.idxFormat));
          }
          else
          {
            bar.startIDX = this.formatToColumnMap.get(((typeof this.options.mode.format) === `function`) ? this.options.mode.format.call(this, this.start, this) : bar.start.toFormat(this.options.mode.format));
            bar.endIDX = this.formatToColumnMap.get(((typeof this.options.mode.format) === `function`) ? this.options.mode.format.call(this, bar.end, this) : bar.end.toFormat(this.options.mode.format));
          }
        });
      }

      this.calculateChartHeight();
      this.buildPathfindingGrid();
    }
  }

  /**
   * @private
   */
  buildFormatToColumnMap()
  {
    this.formatToColumnMap.clear();

    if(this.options.mode.index === true)
    {
      const interval = ((typeof this.options.mode.interval === `number`) ? parseInt(this.options.mode.interval) : 1);
      for(let i = this.start; i <= this.end; i += interval)
      {
        this.formatToColumnMap.set(i, i);
      }
    }
    else
    {
      const interval = Interval.fromDateTimes(this.start, this.end);
  
      if(this.options.mode.idxFormat !== undefined)
      {
        interval.splitBy(this.options.mode.interval).map((dt, idx) => this.formatToColumnMap.set(dt.start.toFormat(this.options.mode.idxFormat), idx));
      }
      else
      {
        interval.splitBy(this.options.mode.interval).map((dt, idx) => this.formatToColumnMap.set(((typeof this.options.mode.format) === `function`) ? this.options.mode.format.call(this, dt.start, this) : dt.start.toFormat(this.options.mode.format), idx));
      }
    }
  }

  /**
   * @private
   */
  buildBarIDToDataMap()
  {
    this.barIDToDataMap.clear();

    this.data.forEach(bar => {
      const arr = this.barIDToDataMap.get(String(bar.id)) ?? [];
      arr.push(bar);
      this.barIDToDataMap.set(String(bar.id), arr);
    });
  }

  /**
   * @private
   */
  buildPathfindingGrid()
  {
    const sorted = this.data.toSorted((a, b) => {
      if(a.yIndex > b.yIndex)
      {
        return 1;
      }
      else
      {
        return -1;
      }
    });

    const gridLastIDX = (this.columnsNumber) * 2 + 1;
    const emptyRow = Array(gridLastIDX).fill(0);
    const matrix = [emptyRow];

    sorted.forEach(bar => {
      const length = ((bar.endIDX - bar.startIDX + 1) * 2) - 1;
      const start = (bar.startIDX * 2) + 1;
      const end = (bar.endIDX * 2) + 1;

      let rowArray = Array(gridLastIDX);

      if(start > 0)
      {
        rowArray = rowArray.fill(0, 0, start)
      }

      rowArray = rowArray.fill(1, start, start + length);

      if(end < gridLastIDX)
      {
        rowArray = rowArray.fill(0, end + 1, gridLastIDX);
      }

      matrix.push(rowArray);
      matrix.push(emptyRow);
    });

    if(matrix.length > 0)
    {
      this.pathfindingGrid = new Grid(matrix);
    }
  }

  /**
   * @param {ChartBar[]} data
   * @param {Boolean} [render=true]
   * @param {Boolean} [renderOnlyBars=false] if set to `true`, only {@link ChartBar|ChartBars} will be rendered, not the entire {@link Chart}
   * @returns {Promise<Chart>}
   */
  setData(data, render = true, renderOnlyBars = false)
  {
    /**
     * @param {ChartBar} bar
     */
    data.forEach(bar => {
      this.processBar(bar);
    });

    this.data = data;
    this.buildBarIDToDataMap();
    this.calculateChartHeight();

    return !render ? Promise.resolve(Chart) : (renderOnlyBars ? this.renderBars() : this.render());
  }

  /**
   * @private
   */
  calculateChartHeight()
  {
    const sorted = this.data.toSorted((a, b) => {
      if(a.startIDX === b.startIDX)
      {
        if(this.options.mode.index === true)
        {
          return 0;
        }
        else
        {
          const aMillis = a.start.toMillis();
          const bMillis = b.start.toMillis();
          if(aMillis === bMillis)
          {
            return 0;
          }
          else if(aMillis > bMillis)
          {
            return -1;
          }
          else
          {
            return 1;
          }
        }
      }
      else if(a.startIDX > b.startIDX)
      {
        return 1;
      }
      else
      {
        return -1;
      }
    });

    sorted.forEach((bar, idx) => {
      bar.yIndex = idx;
    });

    this.chartHeight = Math.max(sorted.length, 1);
  }

  /**
   * @returns {Promise<Chart>}
   */
  renderBars()
  {
    return new Promise(resolve => {
      const rowHeightEm = this.options.customization.chart.body.rowHeightEm;
      const barHeightCoef = this.options.customization.chart.bar.heightCoef;

      const promises = this.data.map((bar, dataIDX) => {
        return new Promise(resolveBar => {
          const uid = `${dataIDX}_${bar.id}`;

          let barEl = this.container.querySelector(`[${this.attributeName(`bar-uid`)}="${uid}"]`);

          if(barEl === null)
          {
            barEl = document.createElement(`span`);

            this.initBarEvents(bar, barEl);

            this.setAttribute(`bar-id`, bar.id, barEl);
            this.setAttribute(`bar-uid`, uid, barEl);
          }

          if(bar.content !== ``)
          {
            barEl[bar.contentIsHTML ? `innerHTML` : `textContent`] = bar.content;
          }

          barEl.className = this.options.customization.chart.bar.class;
          Object.assign(barEl.style, this.options.customization.chart.bar.style);

          barEl.style.left = `${bar.startIDX * this.columnWidthEm + this.options.customization.chart.bar.horizontalMarginEm}em`;
          barEl.style.top = `${(bar.yIndex * rowHeightEm) + rowHeightEm * ((1 - barHeightCoef) / 2)}em`;

          barEl.style.height = `${rowHeightEm * barHeightCoef}em`;
          barEl.style.width = `${((bar.endIDX - bar.startIDX + 1) * this.columnWidthEm) - this.options.customization.chart.bar.horizontalMarginEm * 2}em`;
          barEl.style.position = `absolute`;

          barEl.UGLAGanttBarData = bar;

          resolveBar({ barEl, uid});
        });
      });

      Promise.all(promises).then(async data => {
        /**
         * @type {Element[]}
         */
        const bars = data.map(el => el.barEl);
        /**
         * @type {Set<String>}
         */
        const uids = new Set(data.map(el => el.uid));

        for(let i = this.chartBody.childNodes.length - 1; i >= this.columnsNumber; i--)
        {
          if(!(this.chartBody.childNodes[i] instanceof HTMLCanvasElement) && !uids.has(this.getAttribute(`bar-uid`, undefined, this.chartBody.childNodes[i])))
          {
            this.chartBody.childNodes[i].remove();
          }
        }

        this.chartBody.append(...bars);

        this.buildPathfindingGrid();

        await this.renderConnectingLines();

        resolve(this);
      }).catch(console.error);
    });
  }

  /**
   * @param {ChartBar|undefined} forBar
   * @returns {Promise<Chart>}
   */
  renderConnectingLines(forBar)
  {
    if(forBar !== undefined && (forBar.connectedTo === undefined || forBar.connectedTo.length === 0))
    {
      forBar = undefined;
    }

    const context = this.chartCanvas.getContext(`2d`);
    context.clearRect(0, 0, this.chartCanvas.width, this.chartCanvas.height);

    context.lineCap = `round`;
    context.lineJoin = `round`;
    context.lineWidth = this.options.customization.connectingLines.thickness;
    context.strokeStyle = this.options.customization.connectingLines.color;
    context.fillStyle = this.options.customization.connectingLines.color;

    return new Promise(resolve => {
      const promises = [];

      (forBar === undefined ? this.data : [forBar]).forEach(bar => {
        bar.connectedTo?.forEach(barID => {
          this.barIDToDataMap.get(String(barID))?.forEach(barTo => {
            promises.push(this.drawLineBetween(context, bar, barTo));
          });
        });
      });

      Promise.all(promises).then(() => resolve(this)).catch(console.error);
    });
  }

  /**
   * @private
   * @param {CanvasRenderingContext2D} context
   * @param {ChartBar} barFrom
   * @param {ChartBar} barTo
   * @returns {Promise<void>}
   */
  drawLineBetween(context, barFrom, barTo)
  {
    return new Promise(resolve => {
      const from = this.getBarCoordinates(barFrom);
      const to = this.getBarCoordinates(barTo);

      const gridr2l = this.pathfindingGrid.clone();
      const gridr2t = this.pathfindingGrid.clone();
      const gridb2l = this.pathfindingGrid.clone();
      const gridb2t = this.pathfindingGrid.clone();

      const paths = [
        { dir: `left`, path: this.pathfinder.findPath(from.right.x, from.right.y, to.left.x, to.left.y, gridr2l) }, // r2l
        { dir: `top`, path: this.pathfinder.findPath(from.right.x, from.right.y, to.top.x, to.top.y, gridr2t) }, // r2t
        { dir: `left`, path: this.pathfinder.findPath(from.bottom.x, from.bottom.y, to.left.x, to.left.y, gridb2l) }, // b2l
        { dir: `top`, path: this.pathfinder.findPath(from.bottom.x, from.bottom.y, to.top.x, to.top.y, gridb2t) }, // b2t
      ].filter(path => path.path.length > 0);

      paths.sort((a, b) => {
        if(a.path.length === b.path.length)
        {
          return 0;
        }
        else if(a.path.length > b.path.length)
        {
          return 1;
        }
        else
        {
          return -1;
        }
      });

      const shortest = paths[0];

      context.beginPath();

      context.moveTo(...this.pathGridCoordsToCanvasCoords(from.center));

      shortest.path.forEach(coords => {
        context.lineTo(...this.pathGridCoordsToCanvasCoords(coords));
      });

      const lastCoords = { x: shortest.path[shortest.path.length - 1][0], y: shortest.path[shortest.path.length - 1][1]};
      const arrowTipPoint = { x: lastCoords.x, y: lastCoords.y };

      if(shortest.dir === `left`)
      {
        arrowTipPoint.x += 0.18;
      }
      else
      {
        arrowTipPoint.y += 0.15;
      }

      context.lineTo(...this.pathGridCoordsToCanvasCoords(arrowTipPoint));

      context.stroke();
      context.closePath();

      this.drawArrow(context, this.pathGridCoordsToCanvasCoords(lastCoords, true), this.pathGridCoordsToCanvasCoords(arrowTipPoint, true), 6);

      resolve();
    });
  }

  /**
   * @private
   * @param {{x: Number, y: Number}|Array<Number, Number>} coords
   * @param {Boolean} [asObject=false]
   */
  pathGridCoordsToCanvasCoords(coords, asObject = false)
  {
    if(!Array.isArray(coords))
    {
      coords = [ coords.x, coords.y ];
    }

    const result = [coords[0] * this.columnWidth / 2, coords[1] * this.rowHeight / 2];

    return asObject ? { x: result[0], y: result[1] } : result;
  }

  /**
   * @private
   */
  drawArrow(context, from, to, radius)
  {
    let x_center = to.x;
    let y_center = to.y;
  
    let angle;
    let x;
    let y;
  
    context.beginPath();
  
    angle = Math.atan2(to.y - from.y, to.x - from.x)
    x = radius * Math.cos(angle) + x_center;
    y = radius * Math.sin(angle) + y_center;
  
    context.lineTo(x, y);
  
    angle += (1.0/3.0) * (2 * Math.PI)
    x = radius * Math.cos(angle) + x_center;
    y = radius * Math.sin(angle) + y_center;
  
    context.lineTo(x, y);
  
    angle += (1.0/3.0) * (2 * Math.PI)
    x = radius *Math.cos(angle) + x_center;
    y = radius *Math.sin(angle) + y_center;
  
    context.lineTo(x, y);
  
    context.closePath();
  
    context.fill();
  }

  /**
   * @private
   * @param {ChartBar} bar
   * @param {Boolean} [inPathFindingGridCoordinates=true]
   * @returns {Object}
   */
  getBarCoordinates(bar, inPathFindingGridCoordinates = true)
  {
    const coords = {
      center: { x: 0, y: 0 },
      top: { x: 0, y: 0 },
      right: { x: 0, y: 0 },
      left: { x: 0, y: 0 },
      bottom: { x: 0, y: 0 },
    };

    const length = (bar.endIDX - bar.startIDX + 1);

    coords.center.y = bar.yIndex + 0.5;
    coords.center.x = bar.startIDX + (length / 2);

    coords.top.y = coords.center.y - 0.5;
    coords.top.x = coords.center.x;

    coords.right.y = coords.center.y;
    coords.right.x = bar.endIDX + 1;

    coords.bottom.y = coords.center.y + 0.5;
    coords.bottom.x = coords.center.x;

    coords.left.y = coords.center.y;
    coords.left.x = bar.startIDX;

    if(inPathFindingGridCoordinates)
    {
      coords.center.y *= 2;
      coords.top.y *= 2;
      coords.right.y *= 2;
      coords.bottom.y *= 2;
      coords.left.y *= 2;

      coords.center.x *= 2;
      coords.top.x *= 2;
      coords.right.x *= 2;
      coords.bottom.x *= 2;
      coords.left.x *= 2;
    }

    return coords;
  }

  /**
   * @private
   * @param {ChartBar} bar
   * @param {Element} barEl
   */
  initBarEvents(bar, barEl)
  {
    barEl.addEventListener(`click`, (e) => {
      /**
       * <code>{ bubbles: <b>true</b>, cancellable: <b>true</b>, composed: <b>false</b> }</code>
       * @event ChartEvent#ChartBarClick
       * @type {Event}
       * @property {Object} detail
       * @property {Chart} detail.chart
       * @property {ChartBar} detail.bar
       * @see {@link ChartEvent#BARCLICK}
       */
      this.trigger(new ChartEvent(ChartEvent.BARCLICK, { chart: this, bar }, { bubbles: true, cancelable: true }));
    });

    barEl.addEventListener(`dblclick`, (e) => {
      /**
       * <code>{ bubbles: <b>true</b>, cancellable: <b>true</b>, composed: <b>false</b> }</code>
       * @event ChartEvent#ChartBarDoubleClick
       * @type {Event}
       * @property {Object} detail
       * @property {Chart} detail.chart
       * @property {ChartBar} detail.bar
       * @see {@link ChartEvent#BARDBLCLICK}
       */
      this.trigger(new ChartEvent(ChartEvent.BARDBLCLICK, { chart: this, bar }, { bubbles: true, cancelable: true }));
    });

    barEl.addEventListener(`mouseover`, (e) => {
      /**
       * <code>{ bubbles: <b>true</b>, cancellable: <b>true</b>, composed: <b>false</b> }</code>
       * @event ChartEvent#ChartBarHover
       * @type {Event}
       * @property {Object} detail
       * @property {Chart} detail.chart
       * @property {ChartBar} detail.bar
       * @see {@link ChartEvent#BARHOVER}
       */
      this.trigger(new ChartEvent(ChartEvent.BARHOVER, { chart: this, bar }, { bubbles: true, cancelable: true }));
    });

    barEl.addEventListener(`mouseenter`, (e) => {
      /**
       * <code>{ bubbles: <b>true</b>, cancellable: <b>true</b>, composed: <b>false</b> }</code>
       * @event ChartEvent#ChartBarMouseEnter
       * @type {Event}
       * @property {Object} detail
       * @property {Chart} detail.chart
       * @property {ChartBar} detail.bar
       * @see {@link ChartEvent#BARMOUSEENTER}
       */
      this.trigger(new ChartEvent(ChartEvent.BARMOUSEENTER, { chart: this, bar }, { bubbles: true, cancelable: true }));
    });

    barEl.addEventListener(`mouseleave`, (e) => {
      /**
       * <code>{ bubbles: <b>true</b>, cancellable: <b>true</b>, composed: <b>false</b> }</code>
       * @event ChartEvent#ChartBarMouseLeave
       * @type {Event}
       * @property {Object} detail
       * @property {Chart} detail.chart
       * @property {ChartBar} detail.bar
       * @see {@link ChartEvent#BARMOUSELEAVE}
       */
      this.trigger(new ChartEvent(ChartEvent.BARMOUSELEAVE, { chart: this, bar }, { bubbles: true, cancelable: true }));
    });
  }

  /**
   * @private
   * @param {ChartBar} bar 
   */
  processBar(bar)
  {
    if(bar.startIDX === undefined)
    {
      if(this.options.mode.index === true)
      {
        throw new Error(`In Index mode each bar must have 'startIDX' property as unsigned integer`);
      }
      else
      {
        let idxFormatted = ``;
        bar.start = this.castToDateTime(bar.start);

        if(this.options.mode.idxFormat !== undefined)
        {
          idxFormatted = bar.start.toFormat(this.options.mode.idxFormat);
          bar.startIDX = this.formatToColumnMap.get(idxFormatted);
        }
        else
        {
          idxFormatted = ((typeof this.options.mode.format) === `function`) ? this.options.mode.format.call(this, bar.start, this) : bar.start.toFormat(this.options.mode.format);
          bar.startIDX = this.formatToColumnMap.get(idxFormatted);
        }

        if(bar.startIDX === undefined)
        {
          bar.startIDX = this.findLowerBoundary(idxFormatted);
        }
      }
    }
    else if(typeof bar.endIDX !== `number` && this.options.mode.index === true)
    {
      throw new Error(`In Index mode each bar must have 'endIDX' property as unsigned integer`);
    }

    if(bar.endIDX === undefined)
    {
      if(this.options.mode.index === true)
      {
        throw new Error(`In Index mode each bar must have 'endIDX' property as unsigned integer`);
      }
      else
      {
        let idxFormatted = ``;
        bar.end = this.castToDateTime(bar.end);

        if(this.options.mode.idxFormat !== undefined)
        {
          idxFormatted = bar.end.toFormat(this.options.mode.idxFormat);
          bar.endIDX = this.formatToColumnMap.get(idxFormatted);
        }
        else
        {
          idxFormatted = ((typeof this.options.mode.format) === `function`) ? this.options.mode.format.call(this, bar.end, this) : bar.end.toFormat(this.options.mode.format);
          bar.endIDX = this.formatToColumnMap.get(idxFormatted);
        }

        if(bar.endIDX === undefined)
        {
          bar.endIDX = this.findLowerBoundary(idxFormatted);
        }
      }
    }
    else if(typeof bar.endIDX !== `number` && this.options.mode.index === true)
    {
      throw new Error(`In Index mode each bar must have 'endIDX' property as unsigned integer`);
    }
  }

  /**
   * @private
   * @param {String} idxFormatted 
   */
  findLowerBoundary(idxFormatted)
  {
    let found = null;

    Array.from(this.formatToColumnMap.keys()).reverse().some(formatted => {
      if(formatted < idxFormatted)
      {
        found = this.formatToColumnMap.get(formatted);
        return true;
      }
    });

    return found;
  }

  /**
   * <p>Called automatically, when initializing a container for the first time through {@link Chart.get}</p>
   * <p>Will trigger {@link ChartEvent#event:ChartRendered} immediately <b>AFTER</b> resolving</p>
   * 
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise|Promise}
   * @fires ChartEvent#event:ChartRendered
   * @returns {Promise<Chart>}
   */
  render()
  {
    return new Promise(async (resolve, reject) => {
      try
      {
        const { chartHeader, chartBody } = await this.renderChartTemplate();

        const scrollBody = document.createElement(`div`);

        Object.assign(scrollBody.style, {
          display: `flex`,
          flexDirection: `column`,
          width: `100%`,
          height: `100%`,
          overflowX: `auto`,
          overflowY: `hidden`,
        });

        scrollBody.append(chartHeader, chartBody);

        this.container.replaceChildren(scrollBody);

        this.initPan();
        this.initEditableBars();
        this.updateColumnWidth();

        const computedStyle = window.getComputedStyle(chartBody);
        const borderHeightsEm = ((parseFloat(computedStyle.borderTopWidth) || 0) + (parseFloat(computedStyle.borderBottomWidth) || 0)) / parseFloat(computedStyle.fontSize);
        Object.assign(chartBody.style, { height: `${this.options.customization.chart.body.rowHeightEm * this.chartHeight + borderHeightsEm}em` });

        await this.renderBars();

        resolve(this);

        /**
         * <code>{ bubbles: <b>true</b>, cancellable: <b>true</b>, composed: <b>false</b> }</code>
         * @event ChartEvent#ChartRendered
         * @type {Event}
         * @property {Object} detail
         * @property {Chart} detail.chart
         * @see {@link ChartEvent#RENDERED}
         */
        this.trigger(new ChartEvent(ChartEvent.RENDERED, { chart: this }, { bubbles: true, cancelable: true }));
      }
      catch(err)
      {
        console.error(err);
        reject(err);
      }
    });
  }

  /**
   * @private
   * @returns {Promise<Element>}
   */
  renderChartTemplate()
  {
    return new Promise(resolve => {
      let promises = [];

      if(this.options.mode.index === true)
      {
        const interval = ((typeof this.options.mode.interval === `number`) ? parseInt(this.options.mode.interval) : 1);
        for(let i = this.start; i <= this.end; i += interval)
        {
          promises.push(this.renderColumn(i, i));
        }
      }
      else
      {
        const interval = Interval.fromDateTimes(this.start, this.end);
        promises = interval.splitBy(this.options.mode.interval).map((dt, idx) => this.renderColumn(dt.start, idx));
      }

      Promise.all(promises).then(columns => {
        const chartHeader = document.createElement(`div`);
        const chartBody = document.createElement(`div`);

        this.columnsNumber = columns.length;

        if(columns.length > 0)
        {
          Object.assign(chartHeader.style, this.options.customization.chart.header.container.style);
          Object.assign(chartHeader.style, {
            overflow: `hidden`,
            width: `min-content`,
            minWidth: `100%`,
            flexShrink: 0,
          });

          chartHeader.append(...columns);

          Object.assign(chartBody.style, this.options.customization.chart.body.container.style);

          const bodyColumn = document.createElement(`div`);
          Object.assign(bodyColumn.style, this.options.customization.chart.body.style);
          Object.assign(bodyColumn.style, {
            height: `${this.chartHeight * this.options.customization.chart.body.rowHeightEm}em`,
            minHeight: `100%`,
          });

          const bodyColumns = [];

          for(let i = 0; i < columns.length; i++)
          {
            const cloned = bodyColumn.cloneNode(true);

            if(i === 0)
            {
              Object.assign(cloned.style, this.options.customization.chart.body.firstStyle);
            }
            else if( i === columns.length - 1)
            {
              Object.assign(cloned.style, this.options.customization.chart.body.lastStyle);
            }

            bodyColumns.push(cloned);
          }

          chartBody.append(...bodyColumns);
          const canvas = document.createElement(`canvas`);
          canvas.style.position = `absolute`;
          canvas.style.width = `100%`;
          canvas.style.height = `100%`;
          chartBody.append(canvas);
        }

        resolve({ chartHeader, chartBody });
      }).catch(console.error);
    });
  }

  /**
   * @private
   * @param {DateTime|Number} dt
   * @param {Number} idx
   * @returns {Promise<Element>}
   */
  renderColumn(dt, idx)
  {
    return new Promise(resolve => {
      const headerContainer = document.createElement(`div`);
      Object.assign(headerContainer.style, this.options.customization.chart.header.style);
      headerContainer.innerHTML = format(this.options.customization.chart.header.template, { formatted: this.options.mode.index === true ? idx : (((typeof this.options.mode.format) === `function`) ? this.options.mode.format.call(this, dt, this) : dt.toFormat(this.options.mode.format)), idx });

      resolve(headerContainer);
    });
  }

  /**
   * 
   * @param {Number} x
   * @param {Number} y
   * @param {Boolean} [smooth=true]
   */
  scrollTo(x, y, smooth = true)
  {
    const xOldScrollBehavior = this.chartScroll.style.scrollBehavior;
    this.chartScroll.style.scrollBehavior = smooth ? `smooth` : `auto`;
    this.chartScroll.scrollLeft = x;
    this.chartScroll.style.scrollBehavior = xOldScrollBehavior;

    const yOldScrollBehavior = this.chartBody.style.scrollBehavior;
    this.chartBody.style.scrollBehavior = smooth ? `smooth` : `auto`;
    this.chartBody.scrollLeft = y;
    this.chartBody.style.scrollBehavior = yOldScrollBehavior;
  }

  /**
   * @param {ChartEvent} event
   * @private
   */
  trigger(event)
  {
    this.container.dispatchEvent(event);

    if(event.type === ChartEvent.BARMOUSEENTER)
    {
      this.mouseEnterHandler(event.detail.bar);
    }
    else if(event.type === ChartEvent.BARMOUSELEAVE)
    {
      this.mouseLeaveHandler(event.detail.bar);
    }
  }

  /**
   * @private
   * @param {ChartBar} bar
   */
  mouseEnterHandler(bar)
  {
    if(this.options.customization.chart.bar.highlightConnectedOnHover === true)
    {
      this.renderConnectingLines(bar);
    }
  }

  /**
   * @private
   * @param {ChartBar} bar
   */
  mouseLeaveHandler(bar)
  {
    if(this.options.customization.chart.bar.highlightConnectedOnHover === true)
    {
      this.renderConnectingLines();
    }
  }

  // MOVING BARS

  /**
   * @private
   */
  #movingBarData = {
    pointerIsDown: false,
    bar: null,
    startX: 0,
    startY: 0,
    startXBar: 0,
    startYBar: 0,
  };

  /**
   * @private
   */
  initEditableBars()
  {
    if(!this.options.editableBars)
    {
      return;
    }

    this.chartBody.addEventListener(`pointerdown`, (e) => {
      if(e.target.UGLAGanttBarData === undefined)
      {
        return;
      }

      this.#movingBarData.pointerIsDown = true;
      this.#movingBarData.bar = e.target.UGLAGanttBarData;

      this.#movingBarData.startX = e.pageX - this.chartScroll.offsetLeft;
      this.#movingBarData.startY = e.pageY - this.chartBody.offsetTop;
      this.#movingBarData.startXBar = this.#movingBarData.bar.startIDX * this.columnWidth;
      this.#movingBarData.startYBar = this.#movingBarData.bar.yIndex * this.rowHeight;
    });

    this.chartBody.addEventListener(`pointerup`, () => {
      this.#movingBarData.pointerIsDown = false;
    });

    this.chartBody.addEventListener(`pointerleave`, () => {
      this.#movingBarData.pointerIsDown = false;
    });

    this.chartBody.addEventListener(`pointermove`, (e) => {
      if(!this.#movingBarData.pointerIsDown)
      {
        return;
      }

      e.preventDefault();

      const x = e.pageX - this.chartScroll.offsetLeft;
      const deltaX = x - this.#movingBarData.startX;

      const y = e.pageY - this.chartBody.offsetTop;
      const deltaY = y - this.#movingBarData.startY;

      const xIndex = Math.min(this.columnsNumber - 1 - (this.#movingBarData.bar.endIDX - this.#movingBarData.bar.startIDX), Math.max(0, Math.round((this.#movingBarData.startXBar + deltaX) / this.columnWidth)));
      const yIndex = Math.min(this.rowsNumber - 1, Math.max(0, Math.round((this.#movingBarData.startYBar + deltaY) / this.rowHeight)));

      if(this.#movingBarData.bar.startIDX != xIndex)
      {
        const deltaXIndex = xIndex - this.#movingBarData.bar.startIDX;
        const interval = this.multipliedInterval(deltaXIndex);

        const from = {
          startIDX: this.#movingBarData.bar.startIDX,
          endIDX: this.#movingBarData.bar.endIDX,
          start: this.#movingBarData.bar.start,
          end: this.#movingBarData.bar.end,
        };

        this.#movingBarData.bar.endIDX = xIndex + (this.#movingBarData.bar.endIDX - this.#movingBarData.bar.startIDX);
        this.#movingBarData.bar.startIDX = xIndex;

        this.#movingBarData.bar.start = this.#movingBarData.bar.start?.plus(interval);
        this.#movingBarData.bar.end = this.#movingBarData.bar.end?.plus(interval);

        const to = {
          startIDX: this.#movingBarData.bar.startIDX,
          endIDX: this.#movingBarData.bar.endIDX,
          start: this.#movingBarData.bar.start,
          end: this.#movingBarData.bar.end,
        };

        const revert = () => {
          this.#movingBarData.bar.startIDX = from.startIDX;
          this.#movingBarData.bar.endIDX = from.endIDX;
          this.#movingBarData.bar.start = from.start;
          this.#movingBarData.bar.end = from.end;
          return this.renderBars();
        };

        this.renderBars();

        /**
       * <code>{ bubbles: <b>true</b>, cancellable: <b>true</b>, composed: <b>false</b> }</code>
       * @event ChartEvent#ChartBarMove
       * @type {Event}
       * @property {Object} detail
       * @property {Chart} detail.chart
       * @property {ChartBar} detail.bar
       * @property {Object} detail.from
       * @property {Number} detail.from.startIDX
       * @property {Number} detail.from.endIDX
       * @property {external:DateTime} detail.from.start
       * @property {external:DateTime} detail.from.end
       * @property {Object} detail.to
       * @property {Number} detail.to.startIDX
       * @property {Number} detail.to.endIDX
       * @property {external:DateTime} detail.to.start
       * @property {external:DateTime} detail.to.end
       * @property {function():Promise<Chart>} detail.revert
       * @see {@link ChartEvent#BARMOVE}
       */
      this.trigger(new ChartEvent(ChartEvent.BARMOVE, { chart: this, bar: this.#movingBarData.bar, from, to, revert }, { bubbles: true, cancelable: true }));
      }
    });
  }

  /**
   * @private
   */
  multipliedInterval(multiplier)
  {
    const newInterval = {};
    Object.keys(this.options.mode.interval).forEach(key => {
      newInterval[key] = this.options.mode.interval[key] * multiplier;
    });
    return newInterval;
  }

  // PANNING

  /**
   * @private
   */
  #panningData = {
    mouseIsDown: false,
    startY: 0,
    startScrollTop: 0,
    startX: 0,
    startScrollLeft: 0,
  };

  /**
   * @private
   */
  initPan()
  {
    if(!this.options.panning)
    {
      return;
    }

    this.chartBody.addEventListener(`mousedown`, (e) => {
      if(!(e.target instanceof HTMLCanvasElement))
      {
        return;
      }

      this.#panningData.mouseIsDown = true;

      this.#panningData.startY = e.pageY - this.chartBody.offsetTop;
      this.#panningData.startScrollTop = this.chartBody.scrollTop;
      this.#panningData.startX = e.pageX - this.chartScroll.offsetLeft;
      this.#panningData.startScrollLeft = this.chartScroll.scrollLeft;
    });

    this.chartBody.addEventListener(`mouseup`, () => {
      this.#panningData.mouseIsDown = false;
    });

    this.chartBody.addEventListener(`mouseleave`, () => {
      this.#panningData.mouseIsDown = false;
    });

    this.chartBody.addEventListener(`mousemove`, (e) => {
      if(!this.#panningData.mouseIsDown)
      {
        return;
      }

      e.preventDefault();

      const x = e.pageX - this.chartScroll.offsetLeft;
      const deltaX = (x - this.#panningData.startX) * this.options.panSpeed;

      const y = e.pageY - this.chartBody.offsetTop;
      const deltaY = (y - this.#panningData.startY) * this.options.panSpeed;

      this.chartScroll.scrollLeft = this.#panningData.startScrollLeft - deltaX;
      this.chartBody.scrollTop = this.#panningData.startScrollTop - deltaY;
    });
  }
}

export { Chart, ChartEvent };