tsichart-core 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -108,7 +108,7 @@ const swimlaneLabelConstants = {
108
108
  swimLaneLabelHeightPadding: 8,
109
109
  labelLeftPadding: 28
110
110
  };
111
- const CharactersToEscapeForExactSearchInstance = ['"', '`', '\'', '!', '(', ')', '^', '[', '{', ':', ']', '}', '~', '/', '\\', '@', '#', '$', '%', '&', '*', ';', '=', '.', '_', '-', '<', '>', ',', '?'];
111
+ const CharactersToEscapeForExactSearchInstance = ['"', '`', '\'', '!', '(', ')', '^', '[', '{', ':', '}', ']', '~', '/', '\\', '@', '#', '$', '%', '&', '*', ';', '=', '.', '_', '-', '<', '>', ',', '?'];
112
112
  const NONNUMERICTOPMARGIN = 8;
113
113
  const LINECHARTTOPPADDING = 16;
114
114
  const GRIDCONTAINERCLASS = 'tsi-gridContainer';
@@ -1544,35 +1544,41 @@ class Component {
1544
1544
  }
1545
1545
  }
1546
1546
 
1547
- const NUMERICSPLITBYHEIGHT = 44;
1548
- const NONNUMERICSPLITBYHEIGHT = 24;
1547
+ /**
1548
+ * Constants for Legend component layout and behavior
1549
+ */
1550
+ const LEGEND_CONSTANTS = {
1551
+ /** Height in pixels for each numeric split-by item (includes type selector dropdown) */
1552
+ NUMERIC_SPLITBY_HEIGHT: 44,
1553
+ /** Height in pixels for each non-numeric (categorical/events) split-by item */
1554
+ NON_NUMERIC_SPLITBY_HEIGHT: 24,
1555
+ /** Height in pixels for the series name label header */
1556
+ NAME_LABEL_HEIGHT: 24,
1557
+ /** Buffer distance in pixels from scroll edge before triggering "load more" */
1558
+ SCROLL_BUFFER: 40,
1559
+ /** Number of split-by items to load per batch when paginating */
1560
+ BATCH_SIZE: 20,
1561
+ /** Minimum height in pixels for aggregate container */
1562
+ MIN_AGGREGATE_HEIGHT: 201,
1563
+ /** Minimum width in pixels for each series label in compact mode */
1564
+ MIN_SERIES_WIDTH: 124,
1565
+ };
1549
1566
  class Legend extends Component {
1550
1567
  constructor(drawChart, renderTarget, legendWidth) {
1551
1568
  super(renderTarget);
1552
1569
  this.renderSplitBys = (aggKey, aggSelection, dataType, noSplitBys) => {
1553
- var splitByLabelData = Object.keys(this.chartComponentData.timeArrays[aggKey]);
1554
- var firstSplitBy = this.chartComponentData.displayState[aggKey].splitBys[Object.keys(this.chartComponentData.displayState[aggKey].splitBys)[0]];
1555
- var firstSplitByType = firstSplitBy ? firstSplitBy.visibleType : null;
1556
- Object.keys(this.chartComponentData.displayState[aggKey].splitBys).reduce((isSame, curr) => {
1557
- return (firstSplitByType == this.chartComponentData.displayState[aggKey].splitBys[curr].visibleType) && isSame;
1558
- }, true);
1559
- let showMoreSplitBys = () => {
1560
- const oldShownSplitBys = this.chartComponentData.displayState[aggKey].shownSplitBys;
1561
- this.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + 20, splitByLabelData.length);
1562
- if (oldShownSplitBys != this.chartComponentData.displayState[aggKey].shownSplitBys) {
1563
- this.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
1564
- }
1565
- };
1570
+ const splitByLabelData = Object.keys(this.chartComponentData.timeArrays[aggKey]);
1571
+ const showMoreSplitBys = () => this.handleShowMoreSplitBys(aggKey, splitByLabelData, aggSelection, dataType, noSplitBys);
1566
1572
  let splitByContainer = aggSelection.selectAll(".tsi-splitByContainer").data([aggKey]);
1567
- var splitByContainerEntered = splitByContainer.enter().append("div")
1573
+ const splitByContainerEntered = splitByContainer.enter().append("div")
1568
1574
  .merge(splitByContainer)
1569
1575
  .classed("tsi-splitByContainer", true);
1570
- var splitByLabels = splitByContainerEntered.selectAll('.tsi-splitByLabel')
1576
+ const splitByLabels = splitByContainerEntered.selectAll('.tsi-splitByLabel')
1571
1577
  .data(splitByLabelData.slice(0, this.chartComponentData.displayState[aggKey].shownSplitBys), function (d) {
1572
1578
  return d;
1573
1579
  });
1574
- let self = this;
1575
- var splitByLabelsEntered = splitByLabels
1580
+ const self = this;
1581
+ const splitByLabelsEntered = splitByLabels
1576
1582
  .enter()
1577
1583
  .append("div")
1578
1584
  .merge(splitByLabels)
@@ -1586,135 +1592,60 @@ class Legend extends Component {
1586
1592
  }
1587
1593
  })
1588
1594
  .on("click", function (event, splitBy) {
1589
- if (self.legendState == "compact") {
1590
- self.toggleSplitByVisible(aggKey, splitBy);
1591
- }
1592
- else {
1593
- self.toggleSticky(aggKey, splitBy);
1594
- }
1595
- self.drawChart();
1595
+ self.handleSplitByClick(aggKey, splitBy);
1596
1596
  })
1597
1597
  .on("mouseover", function (event, splitBy) {
1598
1598
  event.stopPropagation();
1599
- self.labelMouseover(aggKey, splitBy);
1599
+ self.handleSplitByMouseOver(aggKey, splitBy);
1600
1600
  })
1601
1601
  .on("mouseout", function (event) {
1602
1602
  event.stopPropagation();
1603
- self.svgSelection.selectAll(".tsi-valueElement")
1604
- .attr("stroke-opacity", 1)
1605
- .attr("fill-opacity", 1);
1606
- self.labelMouseout(self.svgSelection, aggKey);
1603
+ self.handleSplitByMouseOut(aggKey);
1607
1604
  })
1608
1605
  .attr("class", (splitBy, i) => {
1609
- let compact = (dataType !== DataTypes.Numeric) ? 'tsi-splitByLabelCompact' : '';
1610
- let shown = Utils.getAgVisible(self.chartComponentData.displayState, aggKey, splitBy) ? 'shown' : '';
1611
- return `tsi-splitByLabel tsi-splitByLabel ${compact} ${shown}`;
1606
+ const compact = (dataType !== DataTypes.Numeric) ? 'tsi-splitByLabelCompact' : '';
1607
+ const shown = Utils.getAgVisible(self.chartComponentData.displayState, aggKey, splitBy) ? 'shown' : '';
1608
+ return `tsi-splitByLabel ${compact} ${shown}`;
1612
1609
  })
1613
- .classed("stickied", (splitBy, i) => {
1614
- if (self.chartComponentData.stickiedKey != null) {
1615
- return aggKey == self.chartComponentData.stickiedKey.aggregateKey && splitBy == self.chartComponentData.stickiedKey.splitBy;
1616
- }
1617
- });
1618
- var colors = Utils.createSplitByColors(self.chartComponentData.displayState, aggKey, self.chartOptions.keepSplitByColor);
1610
+ .classed("stickied", (splitBy, i) => self.isStickied(aggKey, splitBy));
1611
+ // Use helper methods to render each split-by element
1619
1612
  splitByLabelsEntered.each(function (splitBy, j) {
1620
- let color = (self.chartComponentData.isFromHeatmap) ? self.chartComponentData.displayState[aggKey].color : colors[j];
1613
+ const selection = d3.select(this);
1614
+ // Add color key (conditionally based on data type and legend state)
1621
1615
  if (dataType === DataTypes.Numeric || noSplitBys || self.legendState === 'compact') {
1622
- let colorKey = d3.select(this).selectAll('.tsi-colorKey').data([color]);
1623
- let colorKeyEntered = colorKey.enter()
1624
- .append("div")
1625
- .attr("class", 'tsi-colorKey')
1626
- .merge(colorKey);
1627
- if (dataType === DataTypes.Numeric) {
1628
- colorKeyEntered.style('background-color', (d) => {
1629
- return d;
1630
- });
1631
- }
1632
- else {
1633
- self.createNonNumericColorKey(dataType, colorKeyEntered, aggKey);
1634
- }
1635
- d3.select(this).classed('tsi-nonCompactNonNumeric', (dataType === DataTypes.Categorical || dataType === DataTypes.Events) && this.legendState !== 'compact');
1636
- colorKey.exit().remove();
1616
+ self.addColorKey(selection, aggKey, splitBy, dataType);
1617
+ selection.classed('tsi-nonCompactNonNumeric', (dataType === DataTypes.Categorical || dataType === DataTypes.Events) && self.legendState !== 'compact');
1637
1618
  }
1638
1619
  else {
1639
- d3.select(this).selectAll('.tsi-colorKey').remove();
1640
- }
1641
- if (d3.select(this).select('.tsi-eyeIcon').empty()) {
1642
- d3.select(this).append("button")
1643
- .attr("class", "tsi-eyeIcon")
1644
- .attr('aria-label', () => {
1645
- let showOrHide = self.chartComponentData.displayState[aggKey].splitBys[splitBy].visible ? self.getString('hide series') : self.getString('show series');
1646
- return `${showOrHide} ${splitBy} ${self.getString('in group')} ${self.chartComponentData.displayState[aggKey].name}`;
1647
- })
1648
- .attr('title', () => self.getString('Show/Hide values'))
1649
- .on("click", function (event) {
1650
- event.stopPropagation();
1651
- self.toggleSplitByVisible(aggKey, splitBy);
1652
- d3.select(this)
1653
- .classed("shown", Utils.getAgVisible(self.chartComponentData.displayState, aggKey, splitBy));
1654
- self.drawChart();
1655
- });
1656
- }
1657
- if (d3.select(this).select('.tsi-seriesName').empty()) {
1658
- let seriesName = d3.select(this)
1659
- .append('div')
1660
- .attr('class', 'tsi-seriesName');
1661
- Utils.appendFormattedElementsFromString(seriesName, noSplitBys ? (self.chartComponentData.displayState[aggKey].name) : splitBy);
1620
+ selection.selectAll('.tsi-colorKey').remove();
1662
1621
  }
1622
+ // Add eye icon
1623
+ self.addEyeIcon(selection, aggKey, splitBy);
1624
+ // Add series name
1625
+ self.addSeriesName(selection, aggKey, splitBy);
1626
+ // Add series type selection for numeric data
1663
1627
  if (dataType === DataTypes.Numeric) {
1664
- if (d3.select(this).select('.tsi-seriesTypeSelection').empty()) {
1665
- d3.select(this).append("select")
1666
- .attr('aria-label', `${self.getString("Series type selection for")} ${splitBy} ${self.getString('in group')} ${self.chartComponentData.displayState[aggKey].name}`)
1667
- .attr('class', 'tsi-seriesTypeSelection')
1668
- .on("change", function (data) {
1669
- var seriesType = d3.select(this).property("value");
1670
- self.chartComponentData.displayState[aggKey].splitBys[splitBy].visibleType = seriesType;
1671
- self.drawChart();
1672
- })
1673
- .on("click", (event) => {
1674
- event.stopPropagation();
1675
- });
1676
- }
1677
- d3.select(this).select('.tsi-seriesTypeSelection')
1678
- .each(function (d) {
1679
- var typeLabels = d3.select(this).selectAll('option')
1680
- .data(data => self.chartComponentData.displayState[aggKey].splitBys[splitBy].types.map((type) => {
1681
- return {
1682
- type: type,
1683
- aggKey: aggKey,
1684
- splitBy: splitBy,
1685
- visibleMeasure: Utils.getAgVisibleMeasure(self.chartComponentData.displayState, aggKey, splitBy)
1686
- };
1687
- }));
1688
- typeLabels
1689
- .enter()
1690
- .append("option")
1691
- .attr("class", "seriesTypeLabel")
1692
- .merge(typeLabels)
1693
- .property("selected", (data) => {
1694
- return ((data.type == Utils.getAgVisibleMeasure(self.chartComponentData.displayState, data.aggKey, data.splitBy)) ?
1695
- " selected" : "");
1696
- })
1697
- .text((data) => data.type);
1698
- typeLabels.exit().remove();
1699
- });
1628
+ self.addSeriesTypeSelection(selection, aggKey, splitBy);
1700
1629
  }
1701
1630
  else {
1702
- d3.select(this).selectAll('.tsi-seriesTypeSelection').remove();
1631
+ selection.selectAll('.tsi-seriesTypeSelection').remove();
1703
1632
  }
1704
1633
  });
1705
1634
  splitByLabels.exit().remove();
1706
- let shouldShowMore = self.chartComponentData.displayState[aggKey].shownSplitBys < splitByLabelData.length;
1635
+ // Show more button
1636
+ const shouldShowMore = self.chartComponentData.displayState[aggKey].shownSplitBys < splitByLabelData.length;
1707
1637
  splitByContainerEntered.selectAll('.tsi-legendShowMore').remove();
1708
1638
  if (this.legendState === 'shown' && shouldShowMore) {
1709
1639
  splitByContainerEntered.append('button')
1710
1640
  .text(this.getString('Show more'))
1711
1641
  .attr('class', 'tsi-legendShowMore')
1712
- .style('display', (this.legendState === 'shown' && shouldShowMore) ? 'block' : 'none')
1642
+ .style('display', 'block')
1713
1643
  .on('click', showMoreSplitBys);
1714
1644
  }
1645
+ // Scroll handler for infinite scrolling
1715
1646
  splitByContainerEntered.on("scroll", function () {
1716
1647
  if (self.chartOptions.legend === 'shown') {
1717
- if (this.scrollTop + this.clientHeight + 40 > this.scrollHeight) {
1648
+ if (this.scrollTop + this.clientHeight + LEGEND_CONSTANTS.SCROLL_BUFFER > this.scrollHeight) {
1718
1649
  showMoreSplitBys();
1719
1650
  }
1720
1651
  }
@@ -1739,10 +1670,125 @@ class Legend extends Component {
1739
1670
  };
1740
1671
  this.drawChart = drawChart;
1741
1672
  this.legendWidth = legendWidth;
1742
- this.legendElement = d3.select(renderTarget).insert("div", ":first-child")
1673
+ this.legendElement = d3.select(renderTarget)
1674
+ .insert("div", ":first-child")
1743
1675
  .attr("class", "tsi-legend")
1744
- .style("left", "0px")
1745
- .style("width", (this.legendWidth) + "px"); // - 16 for the width of the padding
1676
+ .style("left", "0px");
1677
+ // Note: width is set conditionally in draw() based on legendState
1678
+ // to allow CSS to control width in compact mode
1679
+ }
1680
+ getHeightPerSplitBy(aggKey) {
1681
+ const dataType = this.chartComponentData.displayState[aggKey].dataType;
1682
+ return dataType === DataTypes.Numeric
1683
+ ? LEGEND_CONSTANTS.NUMERIC_SPLITBY_HEIGHT
1684
+ : LEGEND_CONSTANTS.NON_NUMERIC_SPLITBY_HEIGHT;
1685
+ }
1686
+ addColorKey(selection, aggKey, splitBy, dataType) {
1687
+ const colors = Utils.createSplitByColors(this.chartComponentData.displayState, aggKey, this.chartOptions.keepSplitByColor);
1688
+ const splitByKeys = Object.keys(this.chartComponentData.timeArrays[aggKey]);
1689
+ const splitByIndex = splitByKeys.indexOf(splitBy);
1690
+ const color = this.chartComponentData.isFromHeatmap
1691
+ ? this.chartComponentData.displayState[aggKey].color
1692
+ : colors[splitByIndex];
1693
+ const colorKey = selection.selectAll('.tsi-colorKey').data([color]);
1694
+ const colorKeyEntered = colorKey.enter()
1695
+ .append('div')
1696
+ .attr('class', 'tsi-colorKey')
1697
+ .merge(colorKey);
1698
+ if (dataType === DataTypes.Numeric) {
1699
+ colorKeyEntered.style('background-color', d => d);
1700
+ }
1701
+ else {
1702
+ this.createNonNumericColorKey(dataType, colorKeyEntered, aggKey);
1703
+ }
1704
+ colorKey.exit().remove();
1705
+ }
1706
+ addEyeIcon(selection, aggKey, splitBy) {
1707
+ if (selection.select('.tsi-eyeIcon').empty()) {
1708
+ selection.append('button')
1709
+ .attr('class', 'tsi-eyeIcon')
1710
+ .attr('aria-label', () => {
1711
+ const showOrHide = this.chartComponentData.displayState[aggKey].splitBys[splitBy].visible
1712
+ ? this.getString('hide series')
1713
+ : this.getString('show series');
1714
+ return `${showOrHide} ${splitBy} ${this.getString('in group')} ${this.chartComponentData.displayState[aggKey].name}`;
1715
+ })
1716
+ .attr('title', () => this.getString('Show/Hide values'))
1717
+ .on('click', (event) => {
1718
+ event.stopPropagation();
1719
+ this.toggleSplitByVisible(aggKey, splitBy);
1720
+ this.drawChart();
1721
+ });
1722
+ }
1723
+ selection.select('.tsi-eyeIcon')
1724
+ .classed('shown', Utils.getAgVisible(this.chartComponentData.displayState, aggKey, splitBy));
1725
+ }
1726
+ addSeriesName(selection, aggKey, splitBy) {
1727
+ if (selection.select('.tsi-seriesName').empty()) {
1728
+ const seriesName = selection.append('div')
1729
+ .attr('class', 'tsi-seriesName');
1730
+ const noSplitBys = Object.keys(this.chartComponentData.timeArrays[aggKey]).length === 1
1731
+ && Object.keys(this.chartComponentData.timeArrays[aggKey])[0] === '';
1732
+ const displayText = noSplitBys
1733
+ ? this.chartComponentData.displayState[aggKey].name
1734
+ : splitBy;
1735
+ Utils.appendFormattedElementsFromString(seriesName, displayText);
1736
+ }
1737
+ }
1738
+ addSeriesTypeSelection(selection, aggKey, splitBy) {
1739
+ if (selection.select('.tsi-seriesTypeSelection').empty()) {
1740
+ selection.append('select')
1741
+ .attr('aria-label', `${this.getString('Series type selection for')} ${splitBy} ${this.getString('in group')} ${this.chartComponentData.displayState[aggKey].name}`)
1742
+ .attr('class', 'tsi-seriesTypeSelection')
1743
+ .on('change', (event) => {
1744
+ const seriesType = d3.select(event.target).property('value');
1745
+ this.chartComponentData.displayState[aggKey].splitBys[splitBy].visibleType = seriesType;
1746
+ this.drawChart();
1747
+ })
1748
+ .on('click', (event) => {
1749
+ event.stopPropagation();
1750
+ });
1751
+ }
1752
+ selection.select('.tsi-seriesTypeSelection')
1753
+ .each((d, i, nodes) => {
1754
+ const typeLabels = d3.select(nodes[i])
1755
+ .selectAll('option')
1756
+ .data(this.chartComponentData.displayState[aggKey].splitBys[splitBy].types.map(type => ({
1757
+ type,
1758
+ aggKey,
1759
+ splitBy,
1760
+ visibleMeasure: Utils.getAgVisibleMeasure(this.chartComponentData.displayState, aggKey, splitBy)
1761
+ })));
1762
+ typeLabels.enter()
1763
+ .append('option')
1764
+ .attr('class', 'seriesTypeLabel')
1765
+ .merge(typeLabels)
1766
+ .property('selected', (data) => data.type === Utils.getAgVisibleMeasure(this.chartComponentData.displayState, data.aggKey, data.splitBy))
1767
+ .text((data) => data.type);
1768
+ typeLabels.exit().remove();
1769
+ });
1770
+ }
1771
+ handleSplitByClick(aggKey, splitBy) {
1772
+ if (this.legendState === 'compact') {
1773
+ this.toggleSplitByVisible(aggKey, splitBy);
1774
+ }
1775
+ else {
1776
+ this.toggleSticky(aggKey, splitBy);
1777
+ }
1778
+ this.drawChart();
1779
+ }
1780
+ handleSplitByMouseOver(aggKey, splitBy) {
1781
+ this.labelMouseover(aggKey, splitBy);
1782
+ }
1783
+ handleSplitByMouseOut(aggKey) {
1784
+ this.svgSelection.selectAll(".tsi-valueElement")
1785
+ .attr("stroke-opacity", 1)
1786
+ .attr("fill-opacity", 1);
1787
+ this.labelMouseout(this.svgSelection, aggKey);
1788
+ }
1789
+ isStickied(aggKey, splitBy) {
1790
+ const stickied = this.chartComponentData.stickiedKey;
1791
+ return stickied?.aggregateKey === aggKey && stickied?.splitBy === splitBy;
1746
1792
  }
1747
1793
  labelMouseoutWrapper(labelMouseout, svgSelection, event) {
1748
1794
  return (svgSelection, aggKey) => {
@@ -1784,14 +1830,11 @@ class Legend extends Component {
1784
1830
  return d == aggKey;
1785
1831
  }).node();
1786
1832
  var prospectiveScrollTop = Math.max((indexOfSplitBy - 1) * this.getHeightPerSplitBy(aggKey), 0);
1787
- if (splitByNode.scrollTop < prospectiveScrollTop - (splitByNode.clientHeight - 40) || splitByNode.scrollTop > prospectiveScrollTop) {
1833
+ if (splitByNode.scrollTop < prospectiveScrollTop - (splitByNode.clientHeight - LEGEND_CONSTANTS.SCROLL_BUFFER) || splitByNode.scrollTop > prospectiveScrollTop) {
1788
1834
  splitByNode.scrollTop = prospectiveScrollTop;
1789
1835
  }
1790
1836
  }
1791
1837
  }
1792
- getHeightPerSplitBy(aggKey) {
1793
- return (this.chartComponentData.displayState[aggKey].dataType === DataTypes.Numeric ? NUMERICSPLITBYHEIGHT : NONNUMERICSPLITBYHEIGHT);
1794
- }
1795
1838
  createGradient(gradientKey, svg, values) {
1796
1839
  let gradient = svg.append('defs').append('linearGradient')
1797
1840
  .attr('id', gradientKey).attr('x1', '0%').attr('x2', '0%').attr('y1', '0%').attr('y2', '100%');
@@ -1810,10 +1853,6 @@ class Legend extends Component {
1810
1853
  .attr("stop-opacity", 1);
1811
1854
  });
1812
1855
  }
1813
- isNonNumeric(aggKey) {
1814
- let dataType = this.chartComponentData.displayState[aggKey].dataType;
1815
- return (dataType === DataTypes.Categorical || dataType === DataTypes.Events);
1816
- }
1817
1856
  createNonNumericColorKey(dataType, colorKey, aggKey) {
1818
1857
  if (dataType === DataTypes.Categorical) {
1819
1858
  this.createCategoricalColorKey(colorKey, aggKey);
@@ -1869,6 +1908,13 @@ class Legend extends Component {
1869
1908
  rect.attr('fill', "url(#" + gradientKey + ")");
1870
1909
  }
1871
1910
  }
1911
+ handleShowMoreSplitBys(aggKey, splitByLabelData, aggSelection, dataType, noSplitBys) {
1912
+ const oldShownSplitBys = this.chartComponentData.displayState[aggKey].shownSplitBys;
1913
+ this.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + LEGEND_CONSTANTS.BATCH_SIZE, splitByLabelData.length);
1914
+ if (oldShownSplitBys !== this.chartComponentData.displayState[aggKey].shownSplitBys) {
1915
+ this.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
1916
+ }
1917
+ }
1872
1918
  draw(legendState, chartComponentData, labelMouseover, svgSelection, options, labelMouseoutAction = null, stickySeriesAction = null, event) {
1873
1919
  this.chartOptions.setOptions(options);
1874
1920
  this.chartComponentData = chartComponentData;
@@ -1883,6 +1929,13 @@ class Legend extends Component {
1883
1929
  legend.style('visibility', this.legendState != 'hidden')
1884
1930
  .classed('compact', this.legendState == 'compact')
1885
1931
  .classed('hidden', this.legendState == 'hidden');
1932
+ // Set width conditionally - let CSS handle compact mode width
1933
+ if (this.legendState !== 'compact') {
1934
+ legend.style('width', `${this.legendWidth}px`);
1935
+ }
1936
+ else {
1937
+ legend.style('width', null); // Remove inline width style in compact mode
1938
+ }
1886
1939
  let seriesNames = Object.keys(this.chartComponentData.displayState);
1887
1940
  var seriesLabels = legend.selectAll(".tsi-seriesLabel")
1888
1941
  .data(seriesNames, d => d);
@@ -1893,7 +1946,7 @@ class Legend extends Component {
1893
1946
  return "tsi-seriesLabel " + (this.chartComponentData.displayState[d]["visible"] ? " shown" : "");
1894
1947
  })
1895
1948
  .style("min-width", () => {
1896
- return Math.min(124, this.legendElement.node().clientWidth / seriesNames.length) + 'px';
1949
+ return Math.min(LEGEND_CONSTANTS.MIN_SERIES_WIDTH, this.legendElement.node().clientWidth / seriesNames.length) + 'px';
1897
1950
  })
1898
1951
  .style("border-color", function (d, i) {
1899
1952
  if (d3.select(this).classed("shown"))
@@ -1901,9 +1954,8 @@ class Legend extends Component {
1901
1954
  return "lightgray";
1902
1955
  });
1903
1956
  var self = this;
1904
- const heightPerNameLabel = 25;
1905
1957
  const usableLegendHeight = legend.node().clientHeight;
1906
- var prospectiveAggregateHeight = Math.ceil(Math.max(201, (usableLegendHeight / seriesLabelsEntered.size())));
1958
+ var prospectiveAggregateHeight = Math.ceil(Math.max(LEGEND_CONSTANTS.MIN_AGGREGATE_HEIGHT, (usableLegendHeight / seriesLabelsEntered.size())));
1907
1959
  var contentHeight = 0;
1908
1960
  seriesLabelsEntered.each(function (aggKey, i) {
1909
1961
  let heightPerSplitBy = self.getHeightPerSplitBy(aggKey);
@@ -1959,12 +2011,12 @@ class Legend extends Component {
1959
2011
  seriesNameLabel.exit().remove();
1960
2012
  var splitByContainerHeight;
1961
2013
  if (splitByLabelData.length > (prospectiveAggregateHeight / heightPerSplitBy)) {
1962
- splitByContainerHeight = prospectiveAggregateHeight - heightPerNameLabel;
1963
- contentHeight += splitByContainerHeight + heightPerNameLabel;
2014
+ splitByContainerHeight = prospectiveAggregateHeight - LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
2015
+ contentHeight += splitByContainerHeight + LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
1964
2016
  }
1965
2017
  else if (splitByLabelData.length > 1 || (splitByLabelData.length === 1 && splitByLabelData[0] !== "")) {
1966
- splitByContainerHeight = splitByLabelData.length * heightPerSplitBy + heightPerNameLabel;
1967
- contentHeight += splitByContainerHeight + heightPerNameLabel;
2018
+ splitByContainerHeight = splitByLabelData.length * heightPerSplitBy + LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
2019
+ contentHeight += splitByContainerHeight + LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
1968
2020
  }
1969
2021
  else {
1970
2022
  splitByContainerHeight = heightPerSplitBy;
@@ -1977,43 +2029,28 @@ class Legend extends Component {
1977
2029
  d3.select(this).style("height", "unset");
1978
2030
  }
1979
2031
  var splitByContainer = d3.select(this).selectAll(".tsi-splitByContainer").data([aggKey]);
1980
- var splitByContainerEntered = splitByContainer.enter().append("div")
2032
+ splitByContainer.enter().append("div")
1981
2033
  .merge(splitByContainer)
1982
2034
  .classed("tsi-splitByContainer", true);
1983
2035
  let aggSelection = d3.select(this);
1984
2036
  self.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
1985
- splitByContainerEntered.on("scroll", function () {
1986
- if (self.chartOptions.legend == "shown") {
1987
- if (this.scrollTop + this.clientHeight + 40 > this.scrollHeight) {
1988
- const oldShownSplitBys = self.chartComponentData.displayState[aggKey].shownSplitBys;
1989
- self.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + 20, splitByLabelData.length);
1990
- if (oldShownSplitBys != self.chartComponentData.displayState[aggKey].shownSplitBys) {
1991
- self.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
1992
- }
1993
- }
1994
- }
1995
- });
2037
+ // Compact mode horizontal scroll handler
1996
2038
  d3.select(this).on('scroll', function () {
1997
2039
  if (self.chartOptions.legend == "compact") {
1998
- if (this.scrollLeft + this.clientWidth + 40 > this.scrollWidth) {
1999
- const oldShownSplitBys = self.chartComponentData.displayState[aggKey].shownSplitBys;
2000
- self.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + 20, splitByLabelData.length);
2001
- if (oldShownSplitBys != self.chartComponentData.displayState[aggKey].shownSplitBys) {
2002
- this.renderSplitBys(dataType);
2003
- }
2040
+ if (this.scrollLeft + this.clientWidth + LEGEND_CONSTANTS.SCROLL_BUFFER > this.scrollWidth) {
2041
+ self.handleShowMoreSplitBys(aggKey, splitByLabelData, aggSelection, dataType, noSplitBys);
2004
2042
  }
2005
2043
  }
2006
2044
  });
2007
2045
  splitByContainer.exit().remove();
2008
2046
  });
2009
2047
  if (this.chartOptions.legend == 'shown') {
2010
- legend.node().clientHeight;
2011
2048
  //minSplitBysForFlexGrow: the minimum number of split bys for flex-grow to be triggered
2012
2049
  if (contentHeight < usableLegendHeight) {
2013
2050
  this.legendElement.classed("tsi-flexLegend", true);
2014
2051
  seriesLabelsEntered.each(function (d) {
2015
2052
  let heightPerSplitBy = self.getHeightPerSplitBy(d);
2016
- var minSplitByForFlexGrow = (prospectiveAggregateHeight - heightPerNameLabel) / heightPerSplitBy;
2053
+ var minSplitByForFlexGrow = (prospectiveAggregateHeight - LEGEND_CONSTANTS.NAME_LABEL_HEIGHT) / heightPerSplitBy;
2017
2054
  var splitBysCount = Object.keys(self.chartComponentData.displayState[String(d3.select(this).data()[0])].splitBys).length;
2018
2055
  if (splitBysCount > minSplitByForFlexGrow) {
2019
2056
  d3.select(this).style("flex-grow", 1);
@@ -2026,6 +2063,12 @@ class Legend extends Component {
2026
2063
  }
2027
2064
  seriesLabels.exit().remove();
2028
2065
  }
2066
+ destroy() {
2067
+ this.legendElement.remove();
2068
+ // Note: Virtual list cleanup will be added when virtual scrolling is implemented
2069
+ // this.virtualLists.forEach(list => list.destroy());
2070
+ // this.virtualLists.clear();
2071
+ }
2029
2072
  }
2030
2073
 
2031
2074
  class ChartComponentData {
@@ -6301,6 +6344,8 @@ class LineChart extends TemporalXAxisComponent {
6301
6344
  .append("text")
6302
6345
  .attr("class", (d) => `tsi-swimLaneLabel-${lane} tsi-swimLaneLabel ${onClickPresentAndValid(d) ? 'tsi-boldOnHover' : ''}`)
6303
6346
  .attr("role", "heading")
6347
+ .attr("aria-roledescription", this.getString("Swimlane label"))
6348
+ .attr("aria-label", d => d.label)
6304
6349
  .attr("aria-level", "3")
6305
6350
  .merge(label)
6306
6351
  .style("text-anchor", "middle")
@@ -6839,1205 +6884,6 @@ class TimezonePicker extends ChartComponent {
6839
6884
  }
6840
6885
  }
6841
6886
 
6842
- var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
6843
-
6844
- function getDefaultExportFromCjs (x) {
6845
- return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
6846
- }
6847
-
6848
- var pikaday$1 = {exports: {}};
6849
-
6850
- /*!
6851
- * Pikaday
6852
- *
6853
- * Copyright © 2014 David Bushell | BSD & MIT license | https://github.com/dbushell/Pikaday
6854
- */
6855
- var pikaday = pikaday$1.exports;
6856
-
6857
- var hasRequiredPikaday;
6858
-
6859
- function requirePikaday () {
6860
- if (hasRequiredPikaday) return pikaday$1.exports;
6861
- hasRequiredPikaday = 1;
6862
- (function (module, exports) {
6863
- (function (root, factory) {
6864
-
6865
- var moment;
6866
- {
6867
- // CommonJS module
6868
- // Load moment.js as an optional dependency
6869
- try { moment = require('moment'); } catch (e) { moment = (typeof window !== 'undefined' && window.moment) || undefined; }
6870
- module.exports = factory(moment);
6871
- }
6872
- }(typeof self !== 'undefined' ? self :
6873
- typeof window !== 'undefined' ? window :
6874
- typeof commonjsGlobal !== 'undefined' ? commonjsGlobal :
6875
- pikaday, function (moment) {
6876
-
6877
- /**
6878
- * feature detection and helper functions
6879
- */
6880
- var hasMoment = typeof moment === 'function' || (moment && typeof moment.version === 'string'),
6881
-
6882
- hasEventListeners = !!window.addEventListener,
6883
-
6884
- document = window.document,
6885
-
6886
- sto = window.setTimeout,
6887
-
6888
- addEvent = function (el, e, callback, capture) {
6889
- if (hasEventListeners) {
6890
- el.addEventListener(e, callback, !!capture);
6891
- } else {
6892
- el.attachEvent('on' + e, callback);
6893
- }
6894
- },
6895
-
6896
- removeEvent = function (el, e, callback, capture) {
6897
- if (hasEventListeners) {
6898
- el.removeEventListener(e, callback, !!capture);
6899
- } else {
6900
- el.detachEvent('on' + e, callback);
6901
- }
6902
- },
6903
-
6904
- trim = function (str) {
6905
- return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, '');
6906
- },
6907
-
6908
- hasClass = function (el, cn) {
6909
- return (' ' + el.className + ' ').indexOf(' ' + cn + ' ') !== -1;
6910
- },
6911
-
6912
- addClass = function (el, cn) {
6913
- if (!hasClass(el, cn)) {
6914
- el.className = (el.className === '') ? cn : el.className + ' ' + cn;
6915
- }
6916
- },
6917
-
6918
- removeClass = function (el, cn) {
6919
- el.className = trim((' ' + el.className + ' ').replace(' ' + cn + ' ', ' '));
6920
- },
6921
-
6922
- isArray = function (obj) {
6923
- return (/Array/).test(Object.prototype.toString.call(obj));
6924
- },
6925
-
6926
- isDate = function (obj) {
6927
- return (/Date/).test(Object.prototype.toString.call(obj)) && !isNaN(obj.getTime());
6928
- },
6929
-
6930
- isWeekend = function (date) {
6931
- var day = date.getDay();
6932
- return day === 0 || day === 6;
6933
- },
6934
-
6935
- isLeapYear = function (year) {
6936
- // solution by Matti Virkkunen: http://stackoverflow.com/a/4881951
6937
- return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
6938
- },
6939
-
6940
- getDaysInMonth = function (year, month) {
6941
- return [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month];
6942
- },
6943
-
6944
- setToStartOfDay = function (date) {
6945
- if (isDate(date)) date.setHours(0, 0, 0, 0);
6946
- },
6947
-
6948
- compareDates = function (a, b) {
6949
- // weak date comparison (use setToStartOfDay(date) to ensure correct result)
6950
- return a.getTime() === b.getTime();
6951
- },
6952
-
6953
- extend = function (to, from, overwrite) {
6954
- var prop, hasProp;
6955
- for (prop in from) {
6956
- hasProp = to[prop] !== undefined;
6957
- if (hasProp && typeof from[prop] === 'object' && from[prop] !== null && from[prop].nodeName === undefined) {
6958
- if (isDate(from[prop])) {
6959
- if (overwrite) {
6960
- to[prop] = new Date(from[prop].getTime());
6961
- }
6962
- }
6963
- else if (isArray(from[prop])) {
6964
- if (overwrite) {
6965
- to[prop] = from[prop].slice(0);
6966
- }
6967
- } else {
6968
- to[prop] = extend({}, from[prop], overwrite);
6969
- }
6970
- } else if (overwrite || !hasProp) {
6971
- to[prop] = from[prop];
6972
- }
6973
- }
6974
- return to;
6975
- },
6976
-
6977
- fireEvent = function (el, eventName, data) {
6978
- var ev;
6979
-
6980
- if (document.createEvent) {
6981
- ev = document.createEvent('HTMLEvents');
6982
- ev.initEvent(eventName, true, false);
6983
- ev = extend(ev, data);
6984
- el.dispatchEvent(ev);
6985
- } else if (document.createEventObject) {
6986
- ev = document.createEventObject();
6987
- ev = extend(ev, data);
6988
- el.fireEvent('on' + eventName, ev);
6989
- }
6990
- },
6991
-
6992
- adjustCalendar = function (calendar) {
6993
- if (calendar.month < 0) {
6994
- calendar.year -= Math.ceil(Math.abs(calendar.month) / 12);
6995
- calendar.month += 12;
6996
- }
6997
- if (calendar.month > 11) {
6998
- calendar.year += Math.floor(Math.abs(calendar.month) / 12);
6999
- calendar.month -= 12;
7000
- }
7001
- return calendar;
7002
- },
7003
-
7004
- /**
7005
- * defaults and localisation
7006
- */
7007
- defaults = {
7008
-
7009
- // bind the picker to a form field
7010
- field: null,
7011
-
7012
- // automatically show/hide the picker on `field` focus (default `true` if `field` is set)
7013
- bound: undefined,
7014
-
7015
- // position of the datepicker, relative to the field (default to bottom & left)
7016
- // ('bottom' & 'left' keywords are not used, 'top' & 'right' are modifier on the bottom/left position)
7017
- position: 'bottom left',
7018
-
7019
- // automatically fit in the viewport even if it means repositioning from the position option
7020
- reposition: true,
7021
-
7022
- // the default output format for `.toString()` and `field` value
7023
- format: 'YYYY-MM-DD',
7024
-
7025
- // the toString function which gets passed a current date object and format
7026
- // and returns a string
7027
- toString: null,
7028
-
7029
- // used to create date object from current input string
7030
- parse: null,
7031
-
7032
- // the initial date to view when first opened
7033
- defaultDate: null,
7034
-
7035
- // make the `defaultDate` the initial selected value
7036
- setDefaultDate: false,
7037
-
7038
- // first day of week (0: Sunday, 1: Monday etc)
7039
- firstDay: 0,
7040
-
7041
- // the default flag for moment's strict date parsing
7042
- formatStrict: false,
7043
-
7044
- // the minimum/earliest date that can be selected
7045
- minDate: null,
7046
- // the maximum/latest date that can be selected
7047
- maxDate: null,
7048
-
7049
- // number of years either side, or array of upper/lower range
7050
- yearRange: 10,
7051
-
7052
- // show week numbers at head of row
7053
- showWeekNumber: false,
7054
-
7055
- // Week picker mode
7056
- pickWholeWeek: false,
7057
-
7058
- // used internally (don't config outside)
7059
- minYear: 0,
7060
- maxYear: 9999,
7061
- minMonth: undefined,
7062
- maxMonth: undefined,
7063
-
7064
- startRange: null,
7065
- endRange: null,
7066
-
7067
- isRTL: false,
7068
-
7069
- // Additional text to append to the year in the calendar title
7070
- yearSuffix: '',
7071
-
7072
- // Render the month after year in the calendar title
7073
- showMonthAfterYear: false,
7074
-
7075
- // Render days of the calendar grid that fall in the next or previous month
7076
- showDaysInNextAndPreviousMonths: false,
7077
-
7078
- // Allows user to select days that fall in the next or previous month
7079
- enableSelectionDaysInNextAndPreviousMonths: false,
7080
-
7081
- // how many months are visible
7082
- numberOfMonths: 1,
7083
-
7084
- // when numberOfMonths is used, this will help you to choose where the main calendar will be (default `left`, can be set to `right`)
7085
- // only used for the first display or when a selected date is not visible
7086
- mainCalendar: 'left',
7087
-
7088
- // Specify a DOM element to render the calendar in
7089
- container: undefined,
7090
-
7091
- // Blur field when date is selected
7092
- blurFieldOnSelect: true,
7093
-
7094
- // internationalization
7095
- i18n: {
7096
- previousMonth: 'Previous Month',
7097
- nextMonth: 'Next Month',
7098
- months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
7099
- weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
7100
- weekdaysShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
7101
- },
7102
-
7103
- // Theme Classname
7104
- theme: null,
7105
-
7106
- // events array
7107
- events: [],
7108
-
7109
- // callback function
7110
- onSelect: null,
7111
- onOpen: null,
7112
- onClose: null,
7113
- onDraw: null,
7114
-
7115
- // Enable keyboard input
7116
- keyboardInput: true
7117
- },
7118
-
7119
-
7120
- /**
7121
- * templating functions to abstract HTML rendering
7122
- */
7123
- renderDayName = function (opts, day, abbr) {
7124
- day += opts.firstDay;
7125
- while (day >= 7) {
7126
- day -= 7;
7127
- }
7128
- return abbr ? opts.i18n.weekdaysShort[day] : opts.i18n.weekdays[day];
7129
- },
7130
-
7131
- renderDay = function (opts) {
7132
- var arr = [];
7133
- var ariaSelected = 'false';
7134
- if (opts.isEmpty) {
7135
- if (opts.showDaysInNextAndPreviousMonths) {
7136
- arr.push('is-outside-current-month');
7137
-
7138
- if (!opts.enableSelectionDaysInNextAndPreviousMonths) {
7139
- arr.push('is-selection-disabled');
7140
- }
7141
-
7142
- } else {
7143
- return '<td class="is-empty"></td>';
7144
- }
7145
- }
7146
- if (opts.isDisabled) {
7147
- arr.push('is-disabled');
7148
- }
7149
- if (opts.isToday) {
7150
- arr.push('is-today');
7151
- }
7152
- if (opts.isSelected) {
7153
- arr.push('is-selected');
7154
- ariaSelected = 'true';
7155
- }
7156
- if (opts.hasEvent) {
7157
- arr.push('has-event');
7158
- }
7159
- if (opts.isInRange) {
7160
- arr.push('is-inrange');
7161
- }
7162
- if (opts.isStartRange) {
7163
- arr.push('is-startrange');
7164
- }
7165
- if (opts.isEndRange) {
7166
- arr.push('is-endrange');
7167
- }
7168
- return '<td data-day="' + opts.day + '" class="' + arr.join(' ') + '" aria-selected="' + ariaSelected + '">' +
7169
- '<button tabIndex="-1" class="pika-button pika-day" type="button" ' +
7170
- 'data-pika-year="' + opts.year + '" data-pika-month="' + opts.month + '" data-pika-day="' + opts.day + '">' +
7171
- opts.day +
7172
- '</button>' +
7173
- '</td>';
7174
- },
7175
-
7176
- renderWeek = function (d, m, y) {
7177
- // Lifted from http://javascript.about.com/library/blweekyear.htm, lightly modified.
7178
- var onejan = new Date(y, 0, 1),
7179
- weekNum = Math.ceil((((new Date(y, m, d) - onejan) / 86400000) + onejan.getDay() + 1) / 7);
7180
- return '<td class="pika-week">' + weekNum + '</td>';
7181
- },
7182
-
7183
- renderRow = function (days, isRTL, pickWholeWeek, isRowSelected) {
7184
- return '<tr class="pika-row' + (pickWholeWeek ? ' pick-whole-week' : '') + (isRowSelected ? ' is-selected' : '') + '">' + (isRTL ? days.reverse() : days).join('') + '</tr>';
7185
- },
7186
-
7187
- renderBody = function (rows) {
7188
- return '<tbody>' + rows.join('') + '</tbody>';
7189
- },
7190
-
7191
- renderHead = function (opts) {
7192
- var i, arr = [];
7193
- if (opts.showWeekNumber) {
7194
- arr.push('<th></th>');
7195
- }
7196
- for (i = 0; i < 7; i++) {
7197
- arr.push('<th scope="col"><abbr title="' + renderDayName(opts, i) + '">' + renderDayName(opts, i, true) + '</abbr></th>');
7198
- }
7199
- return '<thead><tr>' + (opts.isRTL ? arr.reverse() : arr).join('') + '</tr></thead>';
7200
- },
7201
-
7202
- renderTitle = function (instance, c, year, month, refYear, randId) {
7203
- var i, j, arr,
7204
- opts = instance._o,
7205
- isMinYear = year === opts.minYear,
7206
- isMaxYear = year === opts.maxYear,
7207
- html = '<div id="' + randId + '" class="pika-title">',
7208
- monthHtml,
7209
- yearHtml,
7210
- prev = true,
7211
- next = true;
7212
-
7213
- for (arr = [], i = 0; i < 12; i++) {
7214
- arr.push('<option value="' + (year === refYear ? i - c : 12 + i - c) + '"' +
7215
- (i === month ? ' selected="selected"' : '') +
7216
- ((isMinYear && i < opts.minMonth) || (isMaxYear && i > opts.maxMonth) ? 'disabled="disabled"' : '') + '>' +
7217
- opts.i18n.months[i] + '</option>');
7218
- }
7219
-
7220
- monthHtml = '<div class="pika-label">' + opts.i18n.months[month] + '<select aria-label="select month" class="pika-select pika-select-month" tabindex="-1">' + arr.join('') + '</select></div>';
7221
-
7222
- if (isArray(opts.yearRange)) {
7223
- i = opts.yearRange[0];
7224
- j = opts.yearRange[1] + 1;
7225
- } else {
7226
- i = year - opts.yearRange;
7227
- j = 1 + year + opts.yearRange;
7228
- }
7229
-
7230
- for (arr = []; i < j && i <= opts.maxYear; i++) {
7231
- if (i >= opts.minYear) {
7232
- arr.push('<option value="' + i + '"' + (i === year ? ' selected="selected"' : '') + '>' + (i) + '</option>');
7233
- }
7234
- }
7235
- yearHtml = '<div class="pika-label">' + year + opts.yearSuffix + '<select aria-label="select year" class="pika-select pika-select-year" tabindex="-1">' + arr.join('') + '</select></div>';
7236
-
7237
- if (opts.showMonthAfterYear) {
7238
- html += yearHtml + monthHtml;
7239
- } else {
7240
- html += monthHtml + yearHtml;
7241
- }
7242
-
7243
- if (isMinYear && (month === 0 || opts.minMonth >= month)) {
7244
- prev = false;
7245
- }
7246
-
7247
- if (isMaxYear && (month === 11 || opts.maxMonth <= month)) {
7248
- next = false;
7249
- }
7250
-
7251
- if (c === 0) {
7252
- html += '<button tabIndex="-1" class="pika-prev' + (prev ? '' : ' is-disabled') + '" type="button">' + opts.i18n.previousMonth + '</button>';
7253
- }
7254
- if (c === (instance._o.numberOfMonths - 1)) {
7255
- html += '<button tabIndex="-1" class="pika-next' + (next ? '' : ' is-disabled') + '" type="button">' + opts.i18n.nextMonth + '</button>';
7256
- }
7257
-
7258
- return html += '</div>';
7259
- },
7260
-
7261
- renderTable = function (opts, data, randId) {
7262
- return '<table cellpadding="0" cellspacing="0" class="pika-table" role="grid" aria-labelledby="' + randId + '">' + renderHead(opts) + renderBody(data) + '</table>';
7263
- },
7264
-
7265
-
7266
- /**
7267
- * Pikaday constructor
7268
- */
7269
- Pikaday = function (options) {
7270
- var self = this,
7271
- opts = self.config(options);
7272
-
7273
- self._onMouseDown = function (e) {
7274
- if (!self._v) {
7275
- return;
7276
- }
7277
- e = e || window.event;
7278
- var target = e.target || e.srcElement;
7279
- if (!target) {
7280
- return;
7281
- }
7282
-
7283
- if (!hasClass(target, 'is-disabled')) {
7284
- if (hasClass(target, 'pika-button') && !hasClass(target, 'is-empty') && !hasClass(target.parentNode, 'is-disabled')) {
7285
- self.setDate(new Date(target.getAttribute('data-pika-year'), target.getAttribute('data-pika-month'), target.getAttribute('data-pika-day')));
7286
- if (opts.bound) {
7287
- sto(function () {
7288
- self.hide();
7289
- if (opts.blurFieldOnSelect && opts.field) {
7290
- opts.field.blur();
7291
- }
7292
- }, 100);
7293
- }
7294
- }
7295
- else if (hasClass(target, 'pika-prev')) {
7296
- self.prevMonth();
7297
- }
7298
- else if (hasClass(target, 'pika-next')) {
7299
- self.nextMonth();
7300
- }
7301
- }
7302
- if (!hasClass(target, 'pika-select')) {
7303
- // if this is touch event prevent mouse events emulation
7304
- if (e.preventDefault) {
7305
- e.preventDefault();
7306
- } else {
7307
- e.returnValue = false;
7308
- return false;
7309
- }
7310
- } else {
7311
- self._c = true;
7312
- }
7313
- };
7314
-
7315
- self._onChange = function (e) {
7316
- e = e || window.event;
7317
- var target = e.target || e.srcElement;
7318
- if (!target) {
7319
- return;
7320
- }
7321
- if (hasClass(target, 'pika-select-month')) {
7322
- self.gotoMonth(target.value);
7323
- }
7324
- else if (hasClass(target, 'pika-select-year')) {
7325
- self.gotoYear(target.value);
7326
- }
7327
- };
7328
-
7329
- self._onKeyChange = function (e) {
7330
- e = e || window.event;
7331
- // ignore if event comes from input box
7332
- if (self.isVisible() && e.target && e.target.type !== 'text') {
7333
-
7334
- switch (e.keyCode) {
7335
- case 13:
7336
- case 27:
7337
- if (opts.field) {
7338
- opts.field.blur();
7339
- }
7340
- break;
7341
- case 37:
7342
- e.preventDefault();
7343
- self.adjustDate('subtract', 1);
7344
- break;
7345
- case 38:
7346
- self.adjustDate('subtract', 7);
7347
- break;
7348
- case 39:
7349
- self.adjustDate('add', 1);
7350
- break;
7351
- case 40:
7352
- self.adjustDate('add', 7);
7353
- break;
7354
- }
7355
- }
7356
- };
7357
-
7358
- self._onInputChange = function (e) {
7359
- var date;
7360
-
7361
- if (e.firedBy === self) {
7362
- return;
7363
- }
7364
- if (opts.parse) {
7365
- date = opts.parse(opts.field.value, opts.format);
7366
- } else if (hasMoment) {
7367
- date = moment(opts.field.value, opts.format, opts.formatStrict);
7368
- date = (date && date.isValid()) ? date.toDate() : null;
7369
- }
7370
- else {
7371
- date = new Date(Date.parse(opts.field.value));
7372
- }
7373
- // if (isDate(date)) {
7374
- // self.setDate(date);
7375
- // }
7376
- // if (!self._v) {
7377
- // self.show();
7378
- // }
7379
- };
7380
-
7381
- self._onInputFocus = function () {
7382
- self.show();
7383
- };
7384
-
7385
- self._onInputClick = function () {
7386
- self.show();
7387
- };
7388
-
7389
- self._onInputBlur = function () {
7390
- // IE allows pika div to gain focus; catch blur the input field
7391
- var pEl = document.activeElement;
7392
- do {
7393
- if (hasClass(pEl, 'pika-single')) {
7394
- return;
7395
- }
7396
- }
7397
- while ((pEl = pEl.parentNode));
7398
-
7399
- if (!self._c) {
7400
- self._b = sto(function () {
7401
- self.hide();
7402
- }, 50);
7403
- }
7404
- self._c = false;
7405
- };
7406
-
7407
- self._onClick = function (e) {
7408
- e = e || window.event;
7409
- var target = e.target || e.srcElement,
7410
- pEl = target;
7411
- if (!target) {
7412
- return;
7413
- }
7414
- if (!hasEventListeners && hasClass(target, 'pika-select')) {
7415
- if (!target.onchange) {
7416
- target.setAttribute('onchange', 'return;');
7417
- addEvent(target, 'change', self._onChange);
7418
- }
7419
- }
7420
- do {
7421
- if (hasClass(pEl, 'pika-single') || pEl === opts.trigger) {
7422
- return;
7423
- }
7424
- }
7425
- while ((pEl = pEl.parentNode));
7426
- if (self._v && target !== opts.trigger && pEl !== opts.trigger) {
7427
- self.hide();
7428
- }
7429
- };
7430
-
7431
- self.el = document.createElement('div');
7432
- self.el.className = 'pika-single' + (opts.isRTL ? ' is-rtl' : '') + (opts.theme ? ' ' + opts.theme : '');
7433
-
7434
- addEvent(self.el, 'mousedown', self._onMouseDown, true);
7435
- addEvent(self.el, 'touchend', self._onMouseDown, true);
7436
- addEvent(self.el, 'change', self._onChange);
7437
-
7438
- if (opts.keyboardInput) {
7439
- addEvent(document, 'keydown', self._onKeyChange);
7440
- }
7441
-
7442
- if (opts.field) {
7443
- if (opts.container) {
7444
- opts.container.appendChild(self.el);
7445
- } else if (opts.bound) {
7446
- document.body.appendChild(self.el);
7447
- } else {
7448
- opts.field.parentNode.insertBefore(self.el, opts.field.nextSibling);
7449
- }
7450
- addEvent(opts.field, 'change', self._onInputChange);
7451
-
7452
- if (!opts.defaultDate) {
7453
- if (hasMoment && opts.field.value) {
7454
- opts.defaultDate = moment(opts.field.value, opts.format).toDate();
7455
- } else {
7456
- opts.defaultDate = new Date(Date.parse(opts.field.value));
7457
- }
7458
- opts.setDefaultDate = true;
7459
- }
7460
- }
7461
-
7462
- var defDate = opts.defaultDate;
7463
-
7464
- if (isDate(defDate)) {
7465
- if (opts.setDefaultDate) {
7466
- self.setDate(defDate, true);
7467
- } else {
7468
- self.gotoDate(defDate);
7469
- }
7470
- } else {
7471
- self.gotoDate(new Date());
7472
- }
7473
-
7474
- if (opts.bound) {
7475
- this.hide();
7476
- self.el.className += ' is-bound';
7477
- addEvent(opts.trigger, 'click', self._onInputClick);
7478
- addEvent(opts.trigger, 'focus', self._onInputFocus);
7479
- addEvent(opts.trigger, 'blur', self._onInputBlur);
7480
- } else {
7481
- this.show();
7482
- }
7483
- };
7484
-
7485
-
7486
- /**
7487
- * public Pikaday API
7488
- */
7489
- Pikaday.prototype = {
7490
-
7491
-
7492
- /**
7493
- * configure functionality
7494
- */
7495
- config: function (options) {
7496
- if (!this._o) {
7497
- this._o = extend({}, defaults, true);
7498
- }
7499
-
7500
- var opts = extend(this._o, options, true);
7501
-
7502
- opts.isRTL = !!opts.isRTL;
7503
-
7504
- opts.field = (opts.field && opts.field.nodeName) ? opts.field : null;
7505
-
7506
- opts.theme = (typeof opts.theme) === 'string' && opts.theme ? opts.theme : null;
7507
-
7508
- opts.bound = !!(opts.bound !== undefined ? opts.field && opts.bound : opts.field);
7509
-
7510
- opts.trigger = (opts.trigger && opts.trigger.nodeName) ? opts.trigger : opts.field;
7511
-
7512
- opts.disableWeekends = !!opts.disableWeekends;
7513
-
7514
- opts.disableDayFn = (typeof opts.disableDayFn) === 'function' ? opts.disableDayFn : null;
7515
-
7516
- var nom = parseInt(opts.numberOfMonths, 10) || 1;
7517
- opts.numberOfMonths = nom > 4 ? 4 : nom;
7518
-
7519
- if (!isDate(opts.minDate)) {
7520
- opts.minDate = false;
7521
- }
7522
- if (!isDate(opts.maxDate)) {
7523
- opts.maxDate = false;
7524
- }
7525
- if ((opts.minDate && opts.maxDate) && opts.maxDate < opts.minDate) {
7526
- opts.maxDate = opts.minDate = false;
7527
- }
7528
- if (opts.minDate) {
7529
- this.setMinDate(opts.minDate);
7530
- }
7531
- if (opts.maxDate) {
7532
- this.setMaxDate(opts.maxDate);
7533
- }
7534
-
7535
- if (isArray(opts.yearRange)) {
7536
- var fallback = new Date().getFullYear() - 10;
7537
- opts.yearRange[0] = parseInt(opts.yearRange[0], 10) || fallback;
7538
- opts.yearRange[1] = parseInt(opts.yearRange[1], 10) || fallback;
7539
- } else {
7540
- opts.yearRange = Math.abs(parseInt(opts.yearRange, 10)) || defaults.yearRange;
7541
- if (opts.yearRange > 100) {
7542
- opts.yearRange = 100;
7543
- }
7544
- }
7545
-
7546
- return opts;
7547
- },
7548
-
7549
- /**
7550
- * return a formatted string of the current selection (using Moment.js if available)
7551
- */
7552
- toString: function (format) {
7553
- format = format || this._o.format;
7554
- if (!isDate(this._d)) {
7555
- return '';
7556
- }
7557
- if (this._o.toString) {
7558
- return this._o.toString(this._d, format);
7559
- }
7560
- if (hasMoment) {
7561
- return moment(this._d).format(format);
7562
- }
7563
- return this._d.toDateString();
7564
- },
7565
-
7566
- /**
7567
- * return a Moment.js object of the current selection (if available)
7568
- */
7569
- getMoment: function () {
7570
- return hasMoment ? moment(this._d) : null;
7571
- },
7572
-
7573
- /**
7574
- * set the current selection from a Moment.js object (if available)
7575
- */
7576
- setMoment: function (date, preventOnSelect) {
7577
- if (hasMoment && moment.isMoment(date)) {
7578
- this.setDate(date.toDate(), preventOnSelect);
7579
- }
7580
- },
7581
-
7582
- /**
7583
- * return a Date object of the current selection
7584
- */
7585
- getDate: function () {
7586
- return isDate(this._d) ? new Date(this._d.getTime()) : null;
7587
- },
7588
-
7589
- /**
7590
- * set the current selection
7591
- */
7592
- setDate: function (date, preventOnSelect) {
7593
- if (!date) {
7594
- this._d = null;
7595
-
7596
- if (this._o.field) {
7597
- this._o.field.value = '';
7598
- fireEvent(this._o.field, 'change', { firedBy: this });
7599
- }
7600
-
7601
- return this.draw();
7602
- }
7603
- if (typeof date === 'string') {
7604
- date = new Date(Date.parse(date));
7605
- }
7606
- if (!isDate(date)) {
7607
- return;
7608
- }
7609
-
7610
- var min = this._o.minDate,
7611
- max = this._o.maxDate;
7612
-
7613
- if (isDate(min) && date < min) {
7614
- date = min;
7615
- } else if (isDate(max) && date > max) {
7616
- date = max;
7617
- }
7618
-
7619
- this._d = new Date(date.getTime());
7620
- setToStartOfDay(this._d);
7621
- this.gotoDate(this._d);
7622
-
7623
- if (this._o.field) {
7624
- this._o.field.value = this.toString();
7625
- fireEvent(this._o.field, 'change', { firedBy: this });
7626
- }
7627
- if (!preventOnSelect && typeof this._o.onSelect === 'function') {
7628
- this._o.onSelect.call(this, this.getDate());
7629
- }
7630
- },
7631
-
7632
- /**
7633
- * change view to a specific date
7634
- */
7635
- gotoDate: function (date) {
7636
- var newCalendar = true;
7637
-
7638
- if (!isDate(date)) {
7639
- return;
7640
- }
7641
-
7642
- if (this.calendars) {
7643
- var firstVisibleDate = new Date(this.calendars[0].year, this.calendars[0].month, 1),
7644
- lastVisibleDate = new Date(this.calendars[this.calendars.length - 1].year, this.calendars[this.calendars.length - 1].month, 1),
7645
- visibleDate = date.getTime();
7646
- // get the end of the month
7647
- lastVisibleDate.setMonth(lastVisibleDate.getMonth() + 1);
7648
- lastVisibleDate.setDate(lastVisibleDate.getDate() - 1);
7649
- newCalendar = (visibleDate < firstVisibleDate.getTime() || lastVisibleDate.getTime() < visibleDate);
7650
- }
7651
-
7652
- if (newCalendar) {
7653
- this.calendars = [{
7654
- month: date.getMonth(),
7655
- year: date.getFullYear()
7656
- }];
7657
- if (this._o.mainCalendar === 'right') {
7658
- this.calendars[0].month += 1 - this._o.numberOfMonths;
7659
- }
7660
- }
7661
-
7662
- this.adjustCalendars();
7663
- },
7664
-
7665
- adjustDate: function (sign, days) {
7666
-
7667
- var day = this.getDate() || new Date();
7668
- var difference = parseInt(days) * 24 * 60 * 60 * 1000;
7669
-
7670
- var newDay;
7671
-
7672
- if (sign === 'add') {
7673
- newDay = new Date(day.valueOf() + difference);
7674
- } else if (sign === 'subtract') {
7675
- newDay = new Date(day.valueOf() - difference);
7676
- }
7677
-
7678
- this.setDate(newDay);
7679
- },
7680
-
7681
- adjustCalendars: function () {
7682
- this.calendars[0] = adjustCalendar(this.calendars[0]);
7683
- for (var c = 1; c < this._o.numberOfMonths; c++) {
7684
- this.calendars[c] = adjustCalendar({
7685
- month: this.calendars[0].month + c,
7686
- year: this.calendars[0].year
7687
- });
7688
- }
7689
- this.draw();
7690
- },
7691
-
7692
- gotoToday: function () {
7693
- this.gotoDate(new Date());
7694
- },
7695
-
7696
- /**
7697
- * change view to a specific month (zero-index, e.g. 0: January)
7698
- */
7699
- gotoMonth: function (month) {
7700
- if (!isNaN(month)) {
7701
- this.calendars[0].month = parseInt(month, 10);
7702
- this.adjustCalendars();
7703
- }
7704
- },
7705
-
7706
- nextMonth: function () {
7707
- this.calendars[0].month++;
7708
- this.adjustCalendars();
7709
- },
7710
-
7711
- prevMonth: function () {
7712
- this.calendars[0].month--;
7713
- this.adjustCalendars();
7714
- },
7715
-
7716
- /**
7717
- * change view to a specific full year (e.g. "2012")
7718
- */
7719
- gotoYear: function (year) {
7720
- if (!isNaN(year)) {
7721
- this.calendars[0].year = parseInt(year, 10);
7722
- this.adjustCalendars();
7723
- }
7724
- },
7725
-
7726
- /**
7727
- * change the minDate
7728
- */
7729
- setMinDate: function (value) {
7730
- if (value instanceof Date) {
7731
- setToStartOfDay(value);
7732
- this._o.minDate = value;
7733
- this._o.minYear = value.getFullYear();
7734
- this._o.minMonth = value.getMonth();
7735
- } else {
7736
- this._o.minDate = defaults.minDate;
7737
- this._o.minYear = defaults.minYear;
7738
- this._o.minMonth = defaults.minMonth;
7739
- this._o.startRange = defaults.startRange;
7740
- }
7741
-
7742
- this.draw();
7743
- },
7744
-
7745
- /**
7746
- * change the maxDate
7747
- */
7748
- setMaxDate: function (value) {
7749
- if (value instanceof Date) {
7750
- setToStartOfDay(value);
7751
- this._o.maxDate = value;
7752
- this._o.maxYear = value.getFullYear();
7753
- this._o.maxMonth = value.getMonth();
7754
- } else {
7755
- this._o.maxDate = defaults.maxDate;
7756
- this._o.maxYear = defaults.maxYear;
7757
- this._o.maxMonth = defaults.maxMonth;
7758
- this._o.endRange = defaults.endRange;
7759
- }
7760
-
7761
- this.draw();
7762
- },
7763
-
7764
- setStartRange: function (value) {
7765
- this._o.startRange = value;
7766
- },
7767
-
7768
- setEndRange: function (value) {
7769
- this._o.endRange = value;
7770
- },
7771
-
7772
- /**
7773
- * refresh the HTML
7774
- */
7775
- draw: function (force) {
7776
- if (!this._v && !force) {
7777
- return;
7778
- }
7779
- var opts = this._o,
7780
- minYear = opts.minYear,
7781
- maxYear = opts.maxYear,
7782
- minMonth = opts.minMonth,
7783
- maxMonth = opts.maxMonth,
7784
- html = '',
7785
- randId;
7786
-
7787
- if (this._y <= minYear) {
7788
- this._y = minYear;
7789
- if (!isNaN(minMonth) && this._m < minMonth) {
7790
- this._m = minMonth;
7791
- }
7792
- }
7793
- if (this._y >= maxYear) {
7794
- this._y = maxYear;
7795
- if (!isNaN(maxMonth) && this._m > maxMonth) {
7796
- this._m = maxMonth;
7797
- }
7798
- }
7799
-
7800
- for (var c = 0; c < opts.numberOfMonths; c++) {
7801
- randId = 'pika-title-' + Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 2);
7802
- html += '<div class="pika-lendar">' + renderTitle(this, c, this.calendars[c].year, this.calendars[c].month, this.calendars[0].year, randId) + this.render(this.calendars[c].year, this.calendars[c].month, randId) + '</div>';
7803
- }
7804
-
7805
- this.el.innerHTML = html;
7806
-
7807
- if (opts.bound) {
7808
- if (opts.field.type !== 'hidden') {
7809
- sto(function () {
7810
- opts.trigger.focus();
7811
- }, 1);
7812
- }
7813
- }
7814
-
7815
- if (typeof this._o.onDraw === 'function') {
7816
- this._o.onDraw(this);
7817
- }
7818
-
7819
- if (opts.bound) {
7820
- // let the screen reader user know to use arrow keys
7821
- opts.field.setAttribute('aria-label', 'Use the arrow keys to pick a date');
7822
- }
7823
- },
7824
-
7825
- adjustPosition: function () {
7826
- var field, pEl, width, height, viewportWidth, viewportHeight, scrollTop, left, top, clientRect;
7827
-
7828
- if (this._o.container) return;
7829
-
7830
- this.el.style.position = 'absolute';
7831
-
7832
- field = this._o.trigger;
7833
- pEl = field;
7834
- width = this.el.offsetWidth;
7835
- height = this.el.offsetHeight;
7836
- viewportWidth = window.innerWidth || document.documentElement.clientWidth;
7837
- viewportHeight = window.innerHeight || document.documentElement.clientHeight;
7838
- scrollTop = window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop;
7839
-
7840
- if (typeof field.getBoundingClientRect === 'function') {
7841
- clientRect = field.getBoundingClientRect();
7842
- left = clientRect.left + window.pageXOffset;
7843
- top = clientRect.bottom + window.pageYOffset;
7844
- } else {
7845
- left = pEl.offsetLeft;
7846
- top = pEl.offsetTop + pEl.offsetHeight;
7847
- while ((pEl = pEl.offsetParent)) {
7848
- left += pEl.offsetLeft;
7849
- top += pEl.offsetTop;
7850
- }
7851
- }
7852
-
7853
- // default position is bottom & left
7854
- if ((this._o.reposition && left + width > viewportWidth) ||
7855
- (
7856
- this._o.position.indexOf('right') > -1 &&
7857
- left - width + field.offsetWidth > 0
7858
- )
7859
- ) {
7860
- left = left - width + field.offsetWidth;
7861
- }
7862
- if ((this._o.reposition && top + height > viewportHeight + scrollTop) ||
7863
- (
7864
- this._o.position.indexOf('top') > -1 &&
7865
- top - height - field.offsetHeight > 0
7866
- )
7867
- ) {
7868
- top = top - height - field.offsetHeight;
7869
- }
7870
-
7871
- this.el.style.left = left + 'px';
7872
- this.el.style.top = top + 'px';
7873
- },
7874
-
7875
- /**
7876
- * render HTML for a particular month
7877
- */
7878
- render: function (year, month, randId) {
7879
- var opts = this._o,
7880
- now = new Date(),
7881
- days = getDaysInMonth(year, month),
7882
- before = new Date(year, month, 1).getDay(),
7883
- data = [],
7884
- row = [];
7885
- setToStartOfDay(now);
7886
- if (opts.firstDay > 0) {
7887
- before -= opts.firstDay;
7888
- if (before < 0) {
7889
- before += 7;
7890
- }
7891
- }
7892
- var previousMonth = month === 0 ? 11 : month - 1,
7893
- nextMonth = month === 11 ? 0 : month + 1,
7894
- yearOfPreviousMonth = month === 0 ? year - 1 : year,
7895
- yearOfNextMonth = month === 11 ? year + 1 : year,
7896
- daysInPreviousMonth = getDaysInMonth(yearOfPreviousMonth, previousMonth);
7897
- var cells = days + before,
7898
- after = cells;
7899
- while (after > 7) {
7900
- after -= 7;
7901
- }
7902
- cells += 7 - after;
7903
- var isWeekSelected = false;
7904
- for (var i = 0, r = 0; i < cells; i++) {
7905
- var day = new Date(year, month, 1 + (i - before)),
7906
- isSelected = isDate(this._d) ? compareDates(day, this._d) : false,
7907
- isToday = compareDates(day, now),
7908
- hasEvent = opts.events.indexOf(day.toDateString()) !== -1 ? true : false,
7909
- isEmpty = i < before || i >= (days + before),
7910
- dayNumber = 1 + (i - before),
7911
- monthNumber = month,
7912
- yearNumber = year,
7913
- isStartRange = opts.startRange && compareDates(opts.startRange, day),
7914
- isEndRange = opts.endRange && compareDates(opts.endRange, day),
7915
- isInRange = opts.startRange && opts.endRange && opts.startRange < day && day < opts.endRange,
7916
- isDisabled = (opts.minDate && day < opts.minDate) ||
7917
- (opts.maxDate && day > opts.maxDate) ||
7918
- (opts.disableWeekends && isWeekend(day)) ||
7919
- (opts.disableDayFn && opts.disableDayFn(day));
7920
-
7921
- if (isEmpty) {
7922
- if (i < before) {
7923
- dayNumber = daysInPreviousMonth + dayNumber;
7924
- monthNumber = previousMonth;
7925
- yearNumber = yearOfPreviousMonth;
7926
- } else {
7927
- dayNumber = dayNumber - days;
7928
- monthNumber = nextMonth;
7929
- yearNumber = yearOfNextMonth;
7930
- }
7931
- }
7932
-
7933
- var dayConfig = {
7934
- day: dayNumber,
7935
- month: monthNumber,
7936
- year: yearNumber,
7937
- hasEvent: hasEvent,
7938
- isSelected: isSelected,
7939
- isToday: isToday,
7940
- isDisabled: isDisabled,
7941
- isEmpty: isEmpty,
7942
- isStartRange: isStartRange,
7943
- isEndRange: isEndRange,
7944
- isInRange: isInRange,
7945
- showDaysInNextAndPreviousMonths: opts.showDaysInNextAndPreviousMonths,
7946
- enableSelectionDaysInNextAndPreviousMonths: opts.enableSelectionDaysInNextAndPreviousMonths
7947
- };
7948
-
7949
- if (opts.pickWholeWeek && isSelected) {
7950
- isWeekSelected = true;
7951
- }
7952
-
7953
- row.push(renderDay(dayConfig));
7954
-
7955
- if (++r === 7) {
7956
- if (opts.showWeekNumber) {
7957
- row.unshift(renderWeek(i - before, month, year));
7958
- }
7959
- data.push(renderRow(row, opts.isRTL, opts.pickWholeWeek, isWeekSelected));
7960
- row = [];
7961
- r = 0;
7962
- isWeekSelected = false;
7963
- }
7964
- }
7965
- return renderTable(opts, data, randId);
7966
- },
7967
-
7968
- isVisible: function () {
7969
- return this._v;
7970
- },
7971
-
7972
- show: function () {
7973
- if (!this.isVisible()) {
7974
- this._v = true;
7975
- this.draw();
7976
- removeClass(this.el, 'is-hidden');
7977
- if (this._o.bound) {
7978
- addEvent(document, 'click', this._onClick);
7979
- this.adjustPosition();
7980
- }
7981
- if (typeof this._o.onOpen === 'function') {
7982
- this._o.onOpen.call(this);
7983
- }
7984
- }
7985
- },
7986
-
7987
- hide: function () {
7988
- var v = this._v;
7989
- if (v !== false) {
7990
- if (this._o.bound) {
7991
- removeEvent(document, 'click', this._onClick);
7992
- }
7993
- this.el.style.position = 'static'; // reset
7994
- this.el.style.left = 'auto';
7995
- this.el.style.top = 'auto';
7996
- addClass(this.el, 'is-hidden');
7997
- this._v = false;
7998
- if (v !== undefined && typeof this._o.onClose === 'function') {
7999
- this._o.onClose.call(this);
8000
- }
8001
- }
8002
- },
8003
-
8004
- /**
8005
- * GAME OVER
8006
- */
8007
- destroy: function () {
8008
- var opts = this._o;
8009
-
8010
- this.hide();
8011
- removeEvent(this.el, 'mousedown', this._onMouseDown, true);
8012
- removeEvent(this.el, 'touchend', this._onMouseDown, true);
8013
- removeEvent(this.el, 'change', this._onChange);
8014
- if (opts.keyboardInput) {
8015
- removeEvent(document, 'keydown', this._onKeyChange);
8016
- }
8017
- if (opts.field) {
8018
- removeEvent(opts.field, 'change', this._onInputChange);
8019
- if (opts.bound) {
8020
- removeEvent(opts.trigger, 'click', this._onInputClick);
8021
- removeEvent(opts.trigger, 'focus', this._onInputFocus);
8022
- removeEvent(opts.trigger, 'blur', this._onInputBlur);
8023
- }
8024
- }
8025
- if (this.el.parentNode) {
8026
- this.el.parentNode.removeChild(this.el);
8027
- }
8028
- }
8029
-
8030
- };
8031
-
8032
- return Pikaday;
8033
- }));
8034
- } (pikaday$1));
8035
- return pikaday$1.exports;
8036
- }
8037
-
8038
- var pikadayExports = /*@__PURE__*/ requirePikaday();
8039
- var Pikaday = /*@__PURE__*/getDefaultExportFromCjs(pikadayExports);
8040
-
8041
6887
  // Ensure moment is available globally for Pikaday
8042
6888
  if (typeof window !== 'undefined') {
8043
6889
  window.moment = moment$1;
@@ -8632,7 +7478,30 @@ class DateTimeButton extends ChartComponent {
8632
7478
  this.pickerIsVisible = false;
8633
7479
  }
8634
7480
  buttonDateTimeFormat(millis) {
8635
- return Utils.timeFormat(!this.chartOptions.minutesForTimeLabels, !this.chartOptions.minutesForTimeLabels, this.chartOptions.offset, this.chartOptions.is24HourTime, 0, null, this.chartOptions.dateLocale)(millis);
7481
+ const date = new Date(millis);
7482
+ const locale = this.chartOptions.dateLocale || 'en-US';
7483
+ const is24Hour = this.chartOptions.is24HourTime !== false;
7484
+ const formatOptions = {
7485
+ year: 'numeric',
7486
+ month: '2-digit',
7487
+ day: '2-digit',
7488
+ hour: '2-digit',
7489
+ minute: '2-digit',
7490
+ second: '2-digit',
7491
+ hour12: !is24Hour
7492
+ };
7493
+ try {
7494
+ if (this.chartOptions.offset && this.chartOptions.offset !== 'Local') {
7495
+ formatOptions.timeZone = this.getTimezoneFromOffset(this.chartOptions.offset);
7496
+ }
7497
+ const baseFormat = date.toLocaleString(locale, formatOptions);
7498
+ const milliseconds = date.getMilliseconds().toString().padStart(3, '0');
7499
+ return `${baseFormat}.${milliseconds}`;
7500
+ }
7501
+ catch (error) {
7502
+ console.warn(`Failed to format date for locale ${locale}:`, error);
7503
+ return Utils.timeFormat(!this.chartOptions.minutesForTimeLabels, !this.chartOptions.minutesForTimeLabels, this.chartOptions.offset, this.chartOptions.is24HourTime, 0, null, this.chartOptions.dateLocale)(millis);
7504
+ }
8636
7505
  }
8637
7506
  render(chartOptions, minMillis, maxMillis, onSet = null) {
8638
7507
  this.chartOptions.setOptions(chartOptions);
@@ -8652,11 +7521,22 @@ class DateTimeButton extends ChartComponent {
8652
7521
  }
8653
7522
  super.themify(d3.select(this.renderTarget), this.chartOptions.theme);
8654
7523
  }
7524
+ getTimezoneFromOffset(offset) {
7525
+ const timezoneMap = {
7526
+ 'UTC': 'UTC',
7527
+ 'EST': 'America/New_York',
7528
+ 'PST': 'America/Los_Angeles',
7529
+ 'CST': 'America/Chicago',
7530
+ 'MST': 'America/Denver'
7531
+ };
7532
+ return timezoneMap[offset] || 'UTC';
7533
+ }
8655
7534
  }
8656
7535
 
8657
7536
  class DateTimeButtonRange extends DateTimeButton {
8658
7537
  constructor(renderTarget) {
8659
7538
  super(renderTarget);
7539
+ this.clickOutsideHandler = null;
8660
7540
  }
8661
7541
  setButtonText(fromMillis, toMillis, isRelative, quickTime) {
8662
7542
  let fromString = this.buttonDateTimeFormat(fromMillis);
@@ -8676,10 +7556,38 @@ class DateTimeButtonRange extends DateTimeButton {
8676
7556
  onClose() {
8677
7557
  this.dateTimePickerContainer.style("display", "none");
8678
7558
  this.dateTimeButton.node().focus();
7559
+ this.removeClickOutsideHandler();
7560
+ }
7561
+ removeClickOutsideHandler() {
7562
+ if (this.clickOutsideHandler) {
7563
+ document.removeEventListener('click', this.clickOutsideHandler);
7564
+ this.clickOutsideHandler = null;
7565
+ }
7566
+ }
7567
+ setupClickOutsideHandler() {
7568
+ // Remove any existing handler first
7569
+ this.removeClickOutsideHandler();
7570
+ // Add handler after a small delay to prevent the opening click from immediately closing the picker
7571
+ setTimeout(() => {
7572
+ this.clickOutsideHandler = (event) => {
7573
+ const pickerElement = this.dateTimePickerContainer.node();
7574
+ const buttonElement = this.dateTimeButton.node();
7575
+ const target = event.target;
7576
+ // Check if click is outside both the picker and the button
7577
+ if (pickerElement && buttonElement &&
7578
+ !pickerElement.contains(target) &&
7579
+ !buttonElement.contains(target)) {
7580
+ this.onClose();
7581
+ }
7582
+ };
7583
+ document.addEventListener('click', this.clickOutsideHandler);
7584
+ }, 0);
8679
7585
  }
8680
7586
  render(chartOptions = {}, minMillis, maxMillis, fromMillis = null, toMillis = null, onSet = null, onCancel = null) {
8681
7587
  super.render(chartOptions, minMillis, maxMillis, onSet);
8682
- d3.select(this.renderTarget).classed('tsi-dateTimeContainerRange', true);
7588
+ let container = d3.select(this.renderTarget);
7589
+ container.classed('tsi-dateTimeContainerRange', true);
7590
+ container.style('position', 'relative');
8683
7591
  this.fromMillis = fromMillis;
8684
7592
  this.toMillis = toMillis;
8685
7593
  this.onCancel = onCancel ? onCancel : () => { };
@@ -8705,6 +7613,7 @@ class DateTimeButtonRange extends DateTimeButton {
8705
7613
  this.onClose();
8706
7614
  this.onCancel();
8707
7615
  });
7616
+ this.setupClickOutsideHandler();
8708
7617
  }
8709
7618
  });
8710
7619
  }
@@ -12419,7 +11328,7 @@ class ModelAutocomplete extends Component {
12419
11328
  super(renderTarget);
12420
11329
  this.chartOptions = new ChartOptions(); // TODO handle onkeyup and oninput in chart options
12421
11330
  }
12422
- render(environmentFqdn, getToken, chartOptions) {
11331
+ render(chartOptions) {
12423
11332
  this.chartOptions.setOptions(chartOptions);
12424
11333
  let targetElement = d3.select(this.renderTarget);
12425
11334
  targetElement.html("");
@@ -12773,20 +11682,102 @@ class TsqExpression extends ChartDataOptions {
12773
11682
  }
12774
11683
  }
12775
11684
 
11685
+ // Centralized renderer for the hierarchy tree. Keeps a stable D3 data-join and
11686
+ // updates existing DOM nodes instead of fully recreating them on each render.
11687
+ class TreeRenderer {
11688
+ static render(owner, data, target) {
11689
+ // Ensure an <ul> exists for this target (one list per level)
11690
+ let list = target.select('ul');
11691
+ if (list.empty()) {
11692
+ list = target.append('ul').attr('role', target === owner.hierarchyElem ? 'tree' : 'group');
11693
+ }
11694
+ const entries = Object.keys(data).map(k => ({ key: k, item: data[k] }));
11695
+ const liSelection = list.selectAll('li').data(entries, (d) => d && d.key);
11696
+ liSelection.exit().remove();
11697
+ const liEnter = liSelection.enter().append('li')
11698
+ .attr('role', 'none')
11699
+ .classed('tsi-leaf', (d) => !!d.item.isLeaf);
11700
+ const liMerged = liEnter.merge(liSelection);
11701
+ const setSize = entries.length;
11702
+ liMerged.each((d, i, nodes) => {
11703
+ const entry = d;
11704
+ const li = d3.select(nodes[i]);
11705
+ if (owner.selectedIds && owner.selectedIds.includes(entry.item.id)) {
11706
+ li.classed('tsi-selected', true);
11707
+ }
11708
+ else {
11709
+ li.classed('tsi-selected', false);
11710
+ }
11711
+ // determine instance vs hierarchy node by presence of isLeaf flag
11712
+ const isInstance = !!entry.item.isLeaf;
11713
+ const nodeNameToCheckIfExists = isInstance ? owner.instanceNodeString(entry.item) : entry.key;
11714
+ const displayName = (entry.item && (entry.item.displayName || nodeNameToCheckIfExists)) || nodeNameToCheckIfExists;
11715
+ li.attr('data-display-name', displayName);
11716
+ let itemElem = li.select('.tsi-hierarchyItem');
11717
+ if (itemElem.empty()) {
11718
+ const newListElem = owner.createHierarchyItemElem(entry.item, entry.key);
11719
+ li.node().appendChild(newListElem.node());
11720
+ itemElem = li.select('.tsi-hierarchyItem');
11721
+ }
11722
+ itemElem.attr('aria-label', isInstance ? owner.getAriaLabel(entry.item) : entry.key);
11723
+ itemElem.attr('title', isInstance ? owner.getAriaLabel(entry.item) : entry.key);
11724
+ itemElem.attr('aria-expanded', String(entry.item.isExpanded));
11725
+ // accessibility: set treeitem level and position in set
11726
+ const ariaLevel = String(((entry.item && typeof entry.item.level === 'number') ? entry.item.level : 0) + 1);
11727
+ itemElem.attr('aria-level', ariaLevel);
11728
+ itemElem.attr('aria-posinset', String(i + 1));
11729
+ itemElem.attr('aria-setsize', String(setSize));
11730
+ if (!isInstance) {
11731
+ itemElem.select('.tsi-caret-icon').attr('style', `left: ${(entry.item.level) * 18 + 20}px`);
11732
+ itemElem.select('.tsi-name').text(entry.key);
11733
+ itemElem.select('.tsi-instanceCount').text(entry.item.cumulativeInstanceCount);
11734
+ }
11735
+ else {
11736
+ const nameSpan = itemElem.select('.tsi-name');
11737
+ nameSpan.html('');
11738
+ Utils.appendFormattedElementsFromString(nameSpan, owner.instanceNodeStringToDisplay(entry.item));
11739
+ }
11740
+ entry.item.node = li;
11741
+ if (entry.item.children) {
11742
+ entry.item.isExpanded = true;
11743
+ li.classed('tsi-expanded', true);
11744
+ // recurse using TreeRenderer to keep rendering logic centralized
11745
+ TreeRenderer.render(owner, entry.item.children, li);
11746
+ }
11747
+ else {
11748
+ li.classed('tsi-expanded', false);
11749
+ li.selectAll('ul').remove();
11750
+ }
11751
+ });
11752
+ }
11753
+ }
11754
+
12776
11755
  class HierarchyNavigation extends Component {
12777
11756
  constructor(renderTarget) {
12778
11757
  super(renderTarget);
12779
11758
  this.path = [];
11759
+ // debounce + request cancellation fields
11760
+ this.debounceTimer = null;
11761
+ this.debounceDelay = 250; // ms
11762
+ this.requestCounter = 0; // increments for each outgoing request
11763
+ this.latestRequestId = 0; // id of the most recent request
12780
11764
  //selectedIds
12781
11765
  this.selectedIds = [];
12782
11766
  this.searchEnabled = true;
12783
- this.renderSearchResult = (r, payload, target) => {
11767
+ this.autocompleteEnabled = true; // Enable/disable autocomplete suggestions
11768
+ // Search mode state
11769
+ this.isSearchMode = false;
11770
+ // Paths that should be auto-expanded (Set of path strings like "Factory North/Building A")
11771
+ this.pathsToAutoExpand = new Set();
11772
+ this.renderSearchResult = async (r, payload, target) => {
12784
11773
  const hierarchyData = r.hierarchyNodes?.hits?.length
12785
11774
  ? this.fillDataRecursively(r.hierarchyNodes, payload, payload)
12786
11775
  : {};
12787
11776
  const instancesData = r.instances?.hits?.length
12788
11777
  ? r.instances.hits.reduce((acc, i) => {
12789
- acc[this.instanceNodeIdentifier(i)] = new InstanceNode(i.timeSeriesId, i.name, payload.path.length - this.path.length, i.id, i.description);
11778
+ const inst = new InstanceNode(i.timeSeriesId, i.name, payload.path.length - this.path.length, i.id, i.description);
11779
+ inst.displayName = this.instanceNodeStringToDisplay(i) || '';
11780
+ acc[this.instanceNodeIdentifier(i)] = inst;
12790
11781
  return acc;
12791
11782
  }, {})
12792
11783
  : {};
@@ -12797,7 +11788,17 @@ class HierarchyNavigation extends Component {
12797
11788
  }
12798
11789
  hitCountElem.text(r.hierarchyNodes.hitCount);
12799
11790
  }
12800
- this.renderTree({ ...hierarchyData, ...instancesData }, target);
11791
+ const merged = { ...hierarchyData, ...instancesData };
11792
+ this.renderTree(merged, target);
11793
+ // Auto-expand nodes that should be expanded and load their children
11794
+ for (const key in hierarchyData) {
11795
+ const node = hierarchyData[key];
11796
+ if (node.isExpanded && !node.children) {
11797
+ // This node should be expanded but doesn't have children loaded yet
11798
+ // We need to trigger expansion after the node is rendered
11799
+ await this.autoExpandNode(node);
11800
+ }
11801
+ }
12801
11802
  };
12802
11803
  this.hierarchyNodeIdentifier = (hName) => {
12803
11804
  return hName ? hName : '(' + this.getString("Empty") + ')';
@@ -12819,12 +11820,20 @@ class HierarchyNavigation extends Component {
12819
11820
  const targetElement = d3.select(this.renderTarget).text('');
12820
11821
  this.hierarchyNavWrapper = this.createHierarchyNavWrapper(targetElement);
12821
11822
  this.selectedIds = preselectedIds;
11823
+ // Allow disabling autocomplete via options
11824
+ if (hierarchyNavOptions.autocompleteEnabled !== undefined) {
11825
+ this.autocompleteEnabled = hierarchyNavOptions.autocompleteEnabled;
11826
+ }
11827
+ // Pre-compute paths that need to be auto-expanded for preselected instances
11828
+ if (preselectedIds && preselectedIds.length > 0) {
11829
+ await this.computePathsToAutoExpand(preselectedIds);
11830
+ }
12822
11831
  //render search wrapper
12823
- //this.renderSearchBox()
11832
+ this.renderSearchBox();
12824
11833
  super.themify(this.hierarchyNavWrapper, this.chartOptions.theme);
12825
11834
  const results = this.createResultsWrapper(this.hierarchyNavWrapper);
12826
11835
  this.hierarchyElem = this.createHierarchyElem(results);
12827
- this.pathSearchAndRenderResult({ search: { payload: this.requestPayload() }, render: { target: this.hierarchyElem } });
11836
+ await this.pathSearchAndRenderResult({ search: { payload: this.requestPayload() }, render: { target: this.hierarchyElem } });
12828
11837
  }
12829
11838
  createHierarchyNavWrapper(targetElement) {
12830
11839
  return targetElement.append('div').attr('class', 'tsi-hierarchy-nav-wrapper');
@@ -12832,8 +11841,129 @@ class HierarchyNavigation extends Component {
12832
11841
  createResultsWrapper(hierarchyNavWrapper) {
12833
11842
  return hierarchyNavWrapper.append('div').classed('tsi-hierarchy-or-list-wrapper', true);
12834
11843
  }
11844
+ // create hierarchy container and attach keyboard handler
12835
11845
  createHierarchyElem(results) {
12836
- return results.append('div').classed('tsi-hierarchy', true).attr("role", "navigation").on('scroll', () => { });
11846
+ const sel = results.append('div').classed('tsi-hierarchy', true).attr("role", "navigation").on('scroll', () => { });
11847
+ // attach keydown listener for keyboard navigation (delegated)
11848
+ // use native event to preserve focus handling
11849
+ const node = sel.node();
11850
+ if (node) {
11851
+ node.addEventListener('keydown', (ev) => this.onKeyDown(ev));
11852
+ }
11853
+ return sel;
11854
+ }
11855
+ // Keyboard navigation handlers and helpers
11856
+ onKeyDown(ev) {
11857
+ const key = ev.key;
11858
+ const active = document.activeElement;
11859
+ const container = this.hierarchyElem?.node();
11860
+ if (!container)
11861
+ return;
11862
+ const isInside = active && container.contains(active);
11863
+ if (!isInside && (key === 'ArrowDown' || key === 'ArrowUp')) {
11864
+ // focus first visible item on navigation keys
11865
+ const visible = this.getVisibleItemElems();
11866
+ if (visible.length) {
11867
+ this.focusItem(visible[0]);
11868
+ ev.preventDefault();
11869
+ }
11870
+ return;
11871
+ }
11872
+ if (!active)
11873
+ return;
11874
+ const current = active.classList && active.classList.contains('tsi-hierarchyItem') ? active : active.closest('.tsi-hierarchyItem');
11875
+ if (!current)
11876
+ return;
11877
+ switch (key) {
11878
+ case 'ArrowDown':
11879
+ this.focusNext(current);
11880
+ ev.preventDefault();
11881
+ break;
11882
+ case 'ArrowUp':
11883
+ this.focusPrev(current);
11884
+ ev.preventDefault();
11885
+ break;
11886
+ case 'ArrowRight':
11887
+ this.handleArrowRight(current);
11888
+ ev.preventDefault();
11889
+ break;
11890
+ case 'ArrowLeft':
11891
+ this.handleArrowLeft(current);
11892
+ ev.preventDefault();
11893
+ break;
11894
+ case 'Enter':
11895
+ case ' ':
11896
+ // activate (toggle expand or select)
11897
+ current.click();
11898
+ ev.preventDefault();
11899
+ break;
11900
+ }
11901
+ }
11902
+ getVisibleItemElems() {
11903
+ if (!this.hierarchyElem)
11904
+ return [];
11905
+ const root = this.hierarchyElem.node();
11906
+ if (!root)
11907
+ return [];
11908
+ const items = Array.from(root.querySelectorAll('.tsi-hierarchyItem'));
11909
+ return items.filter(i => i.offsetParent !== null && getComputedStyle(i).display !== 'none');
11910
+ }
11911
+ focusItem(elem) {
11912
+ if (!this.hierarchyElem)
11913
+ return;
11914
+ const root = this.hierarchyElem.node();
11915
+ if (!root)
11916
+ return;
11917
+ const items = Array.from(root.querySelectorAll('.tsi-hierarchyItem'));
11918
+ items.forEach(i => i.setAttribute('tabindex', '-1'));
11919
+ elem.setAttribute('tabindex', '0');
11920
+ elem.focus();
11921
+ }
11922
+ focusNext(current) {
11923
+ const visible = this.getVisibleItemElems();
11924
+ const idx = visible.indexOf(current);
11925
+ if (idx >= 0 && idx < visible.length - 1) {
11926
+ this.focusItem(visible[idx + 1]);
11927
+ }
11928
+ }
11929
+ focusPrev(current) {
11930
+ const visible = this.getVisibleItemElems();
11931
+ const idx = visible.indexOf(current);
11932
+ if (idx > 0) {
11933
+ this.focusItem(visible[idx - 1]);
11934
+ }
11935
+ }
11936
+ handleArrowRight(current) {
11937
+ const caret = current.querySelector('.tsi-caret-icon');
11938
+ const expanded = current.getAttribute('aria-expanded') === 'true';
11939
+ if (caret && !expanded) {
11940
+ // expand
11941
+ current.click();
11942
+ return;
11943
+ }
11944
+ // if already expanded, move to first child
11945
+ if (caret && expanded) {
11946
+ const li = current.closest('li');
11947
+ const childLi = li?.querySelector('ul > li');
11948
+ const childItem = childLi?.querySelector('.tsi-hierarchyItem');
11949
+ if (childItem)
11950
+ this.focusItem(childItem);
11951
+ }
11952
+ }
11953
+ handleArrowLeft(current) {
11954
+ const caret = current.querySelector('.tsi-caret-icon');
11955
+ const expanded = current.getAttribute('aria-expanded') === 'true';
11956
+ if (caret && expanded) {
11957
+ // collapse
11958
+ current.click();
11959
+ return;
11960
+ }
11961
+ // move focus to parent
11962
+ const li = current.closest('li');
11963
+ const parentLi = li?.parentElement?.closest('li');
11964
+ const parentItem = parentLi?.querySelector('.tsi-hierarchyItem');
11965
+ if (parentItem)
11966
+ this.focusItem(parentItem);
12837
11967
  }
12838
11968
  // prepares the parameters for search request
12839
11969
  requestPayload(hierarchy = null) {
@@ -12842,32 +11972,7 @@ class HierarchyNavigation extends Component {
12842
11972
  }
12843
11973
  // renders tree for both 'Navigate' and 'Filter' mode (with Hierarchy View option selected), locInTarget refers to the 'show more' element -either hierarchy or instance- within the target
12844
11974
  renderTree(data, target) {
12845
- let list = target.append('ul').attr("role", target === this.hierarchyElem ? "tree" : "group");
12846
- Object.keys(data).forEach(el => {
12847
- let nodeNameToCheckIfExists = data[el] instanceof InstanceNode ? this.instanceNodeString(data[el]) : el;
12848
- let li;
12849
- if (list.selectAll(".tsi-name").nodes().find(e => e.innerText === nodeNameToCheckIfExists)) {
12850
- li = null;
12851
- }
12852
- else {
12853
- li = list.append('li').classed('tsi-leaf', data[el].isLeaf);
12854
- //if the node is already selected, we want to highlight it
12855
- if (this.selectedIds && this.selectedIds.includes(data[el].id)) {
12856
- li.classed('tsi-selected', true);
12857
- }
12858
- }
12859
- if (!li)
12860
- return;
12861
- li.attr("role", "none");
12862
- let newListElem = this.createHierarchyItemElem(data[el], el);
12863
- li.node().appendChild(newListElem.node());
12864
- data[el].node = li;
12865
- if (data[el].children) {
12866
- data[el].isExpanded = true;
12867
- data[el].node.classed('tsi-expanded', true);
12868
- this.renderTree(data[el].children, data[el].node);
12869
- }
12870
- });
11975
+ TreeRenderer.render(this, data, target);
12871
11976
  }
12872
11977
  renderSearchBox() {
12873
11978
  this.searchWrapperElem = this.hierarchyNavWrapper.append('div').classed('tsi-hierarchy-search', true);
@@ -12876,40 +11981,140 @@ class HierarchyNavigation extends Component {
12876
11981
  let input = inputWrapper
12877
11982
  .append("input")
12878
11983
  .attr("class", "tsi-searchInput")
12879
- .attr("aria-label", this.getString("Search Time Series Instances"))
12880
- .attr("aria-describedby", "tsi-search-desc")
11984
+ .attr("aria-label", this.getString("Search"))
11985
+ .attr("aria-describedby", "tsi-hierarchy-search-desc")
12881
11986
  .attr("role", "combobox")
12882
11987
  .attr("aria-owns", "tsi-search-results")
12883
11988
  .attr("aria-expanded", "false")
12884
11989
  .attr("aria-haspopup", "listbox")
12885
- .attr("placeholder", this.getString("Search Time Series Instances") + "...");
11990
+ .attr("placeholder", this.getString("Search") + "...");
11991
+ // Add ARIA description for screen readers
11992
+ inputWrapper
11993
+ .append("span")
11994
+ .attr("id", "tsi-hierarchy-search-desc")
11995
+ .style("display", "none")
11996
+ .text(this.getString("Search suggestion instruction") || "Use arrow keys to navigate suggestions");
11997
+ // Add live region for search results info
11998
+ inputWrapper
11999
+ .append("div")
12000
+ .attr("class", "tsi-search-results-info")
12001
+ .attr("aria-live", "assertive");
12002
+ // Add clear button
12003
+ let clear = inputWrapper
12004
+ .append("div")
12005
+ .attr("class", "tsi-clear")
12006
+ .attr("tabindex", "0")
12007
+ .attr("role", "button")
12008
+ .attr("aria-label", "Clear Search")
12009
+ .on("click keydown", function (event) {
12010
+ if (Utils.isKeyDownAndNotEnter(event)) {
12011
+ return;
12012
+ }
12013
+ input.node().value = "";
12014
+ self.exitSearchMode();
12015
+ self.ap.close();
12016
+ d3.select(this).classed("tsi-shown", false);
12017
+ });
12018
+ // Initialize Awesomplete for autocomplete (only if enabled)
12019
+ let Awesomplete = window.Awesomplete;
12020
+ if (this.autocompleteEnabled && Awesomplete) {
12021
+ this.ap = new Awesomplete(input.node(), {
12022
+ minChars: 1,
12023
+ maxItems: 10,
12024
+ autoFirst: true
12025
+ });
12026
+ }
12027
+ else {
12028
+ // Create a dummy object if autocomplete is disabled
12029
+ this.ap = {
12030
+ list: [],
12031
+ close: () => { },
12032
+ evaluate: () => { }
12033
+ };
12034
+ }
12886
12035
  let self = this;
12036
+ let noSuggest = false;
12037
+ let justAwesompleted = false;
12038
+ // Handle autocomplete selection (only if enabled)
12039
+ if (this.autocompleteEnabled) {
12040
+ input.node().addEventListener("awesomplete-selectcomplete", (event) => {
12041
+ noSuggest = true;
12042
+ const selectedValue = event.text.value;
12043
+ // Trigger search with selected value
12044
+ self.performDeepSearch(selectedValue);
12045
+ justAwesompleted = true;
12046
+ });
12047
+ }
12887
12048
  input.on("keydown", (event) => {
12049
+ // Handle ESC key to clear the search box
12050
+ if (event.key === 'Escape') {
12051
+ const inputElement = event.target;
12052
+ inputElement.value = '';
12053
+ // Trigger input event to clear search results
12054
+ self.exitSearchMode();
12055
+ self.ap.close();
12056
+ clear.classed("tsi-shown", false);
12057
+ return;
12058
+ }
12888
12059
  this.chartOptions.onKeydown(event, this.ap);
12889
12060
  });
12890
- var searchText;
12061
+ input.node().addEventListener("keyup", function (event) {
12062
+ if (justAwesompleted) {
12063
+ justAwesompleted = false;
12064
+ return;
12065
+ }
12066
+ let key = event.which || event.keyCode;
12067
+ if (key === 13) {
12068
+ noSuggest = true;
12069
+ }
12070
+ });
12071
+ // Debounced input handler to reduce work while typing
12891
12072
  input.on("input", function (event) {
12892
- searchText = event.target.value;
12893
- if (searchText.length === 0) {
12894
- //clear the tree
12895
- self.hierarchyElem.selectAll('ul').remove();
12896
- self.pathSearchAndRenderResult({ search: { payload: self.requestPayload() }, render: { target: self.hierarchyElem } });
12073
+ const val = event.target.value;
12074
+ // always clear existing timer
12075
+ if (self.debounceTimer) {
12076
+ clearTimeout(self.debounceTimer);
12077
+ self.debounceTimer = null;
12078
+ }
12079
+ // Show/hide clear button
12080
+ clear.classed("tsi-shown", val.length > 0);
12081
+ if (!val || val.length === 0) {
12082
+ // Exit search mode and restore navigation view
12083
+ self.exitSearchMode();
12084
+ self.ap.close();
12085
+ return;
12086
+ }
12087
+ // Populate autocomplete suggestions with instance leaves (only if enabled)
12088
+ if (self.autocompleteEnabled && !noSuggest && val.length >= 1) {
12089
+ self.fetchAutocompleteSuggestions(val);
12897
12090
  }
12898
12091
  else {
12899
- //filter the tree
12900
- self.filterTree(searchText);
12092
+ self.ap.close();
12901
12093
  }
12094
+ // Use deep search for comprehensive results
12095
+ self.debounceTimer = setTimeout(() => {
12096
+ self.performDeepSearch(val);
12097
+ }, self.debounceDelay);
12098
+ noSuggest = false;
12902
12099
  });
12903
12100
  }
12904
12101
  async pathSearchAndRenderResult({ search: { payload, bubbleUpReject = false }, render: { target, locInTarget = null } }) {
12102
+ const requestId = ++this.requestCounter;
12103
+ this.latestRequestId = requestId;
12905
12104
  try {
12906
12105
  const result = await this.searchFunction(payload);
12106
+ if (requestId !== this.latestRequestId) {
12107
+ return;
12108
+ }
12907
12109
  if (result.error) {
12908
12110
  throw result.error;
12909
12111
  }
12910
- this.renderSearchResult(result, payload, target);
12112
+ await this.renderSearchResult(result, payload, target);
12911
12113
  }
12912
12114
  catch (err) {
12115
+ if (requestId !== this.latestRequestId) {
12116
+ return;
12117
+ }
12913
12118
  this.chartOptions.onError("Error in hierarchy navigation", "Failed to complete search", err instanceof XMLHttpRequest ? err : null);
12914
12119
  if (bubbleUpReject) {
12915
12120
  throw err;
@@ -12917,11 +12122,18 @@ class HierarchyNavigation extends Component {
12917
12122
  }
12918
12123
  }
12919
12124
  filterTree(searchText) {
12920
- let tree = this.hierarchyElem.selectAll('ul').nodes()[0];
12921
- let list = tree.querySelectorAll('li');
12125
+ const nodes = this.hierarchyElem.selectAll('ul').nodes();
12126
+ if (!nodes || !nodes.length)
12127
+ return;
12128
+ const tree = nodes[0];
12129
+ if (!tree)
12130
+ return;
12131
+ const list = tree.querySelectorAll('li');
12132
+ const needle = searchText.toLowerCase();
12922
12133
  list.forEach((li) => {
12923
- let name = li.querySelector('.tsi-name').innerText;
12924
- if (name.toLowerCase().includes(searchText.toLowerCase())) {
12134
+ const attrName = li.getAttribute('data-display-name');
12135
+ let name = attrName && attrName.length ? attrName : (li.querySelector('.tsi-name')?.textContent || '');
12136
+ if (name.toLowerCase().includes(needle)) {
12925
12137
  li.style.display = 'block';
12926
12138
  }
12927
12139
  else {
@@ -12929,11 +12141,300 @@ class HierarchyNavigation extends Component {
12929
12141
  }
12930
12142
  });
12931
12143
  }
12144
+ // Fetch autocomplete suggestions for instances (leaves)
12145
+ async fetchAutocompleteSuggestions(searchText) {
12146
+ if (!searchText || searchText.length < 1) {
12147
+ this.ap.list = [];
12148
+ return;
12149
+ }
12150
+ try {
12151
+ // Call server search to get instance suggestions
12152
+ const payload = {
12153
+ path: this.path,
12154
+ searchTerm: searchText,
12155
+ recursive: true,
12156
+ includeInstances: true,
12157
+ // Limit results for autocomplete
12158
+ maxResults: 10
12159
+ };
12160
+ const results = await this.searchFunction(payload);
12161
+ if (results.error) {
12162
+ this.ap.list = [];
12163
+ return;
12164
+ }
12165
+ // Extract instance names for autocomplete suggestions
12166
+ const suggestions = [];
12167
+ if (results.instances?.hits) {
12168
+ results.instances.hits.forEach((i) => {
12169
+ const displayName = this.instanceNodeStringToDisplay(i);
12170
+ const pathStr = i.hierarchyPath && i.hierarchyPath.length > 0
12171
+ ? i.hierarchyPath.join(' > ') + ' > '
12172
+ : '';
12173
+ suggestions.push({
12174
+ label: pathStr + displayName,
12175
+ value: displayName
12176
+ });
12177
+ });
12178
+ }
12179
+ // Update Awesomplete list
12180
+ this.ap.list = suggestions;
12181
+ }
12182
+ catch (err) {
12183
+ // Silently fail for autocomplete - don't interrupt user experience
12184
+ this.ap.list = [];
12185
+ }
12186
+ }
12187
+ // Perform deep search across entire hierarchy using server-side search
12188
+ async performDeepSearch(searchText) {
12189
+ if (!searchText || searchText.length < 2) {
12190
+ this.exitSearchMode();
12191
+ return;
12192
+ }
12193
+ this.isSearchMode = true;
12194
+ const requestId = ++this.requestCounter;
12195
+ this.latestRequestId = requestId;
12196
+ try {
12197
+ // Call server search with recursive flag
12198
+ const payload = {
12199
+ path: this.path,
12200
+ searchTerm: searchText,
12201
+ recursive: true, // Search entire subtree
12202
+ includeInstances: true
12203
+ };
12204
+ const results = await this.searchFunction(payload);
12205
+ if (requestId !== this.latestRequestId)
12206
+ return; // Stale request
12207
+ if (results.error) {
12208
+ throw results.error;
12209
+ }
12210
+ // Render search results in flat list view
12211
+ this.renderSearchResults(results, searchText);
12212
+ }
12213
+ catch (err) {
12214
+ if (requestId !== this.latestRequestId)
12215
+ return;
12216
+ this.chartOptions.onError("Search failed", "Unable to search hierarchy", err instanceof XMLHttpRequest ? err : null);
12217
+ }
12218
+ }
12219
+ // Render search results with breadcrumb paths
12220
+ renderSearchResults(results, searchText) {
12221
+ this.hierarchyElem.selectAll('*').remove();
12222
+ const flatResults = [];
12223
+ // Flatten hierarchy results with full paths
12224
+ if (results.hierarchyNodes?.hits) {
12225
+ results.hierarchyNodes.hits.forEach((h) => {
12226
+ flatResults.push({
12227
+ type: 'hierarchy',
12228
+ name: h.name,
12229
+ path: h.path || [],
12230
+ id: h.id,
12231
+ cumulativeInstanceCount: h.cumulativeInstanceCount,
12232
+ highlightedName: this.highlightMatch(h.name, searchText),
12233
+ node: h
12234
+ });
12235
+ });
12236
+ }
12237
+ // Flatten instance results with full paths
12238
+ if (results.instances?.hits) {
12239
+ results.instances.hits.forEach((i) => {
12240
+ const displayName = this.instanceNodeStringToDisplay(i);
12241
+ flatResults.push({
12242
+ type: 'instance',
12243
+ name: i.name,
12244
+ path: i.hierarchyPath || [],
12245
+ id: i.id,
12246
+ timeSeriesId: i.timeSeriesId,
12247
+ description: i.description,
12248
+ highlightedName: this.highlightMatch(displayName, searchText),
12249
+ node: i
12250
+ });
12251
+ });
12252
+ }
12253
+ // Render flat list with breadcrumbs
12254
+ const searchList = this.hierarchyElem
12255
+ .append('div')
12256
+ .classed('tsi-search-results', true);
12257
+ if (flatResults.length === 0) {
12258
+ searchList.append('div')
12259
+ .classed('tsi-noResults', true)
12260
+ .text(this.getString('No results'));
12261
+ return;
12262
+ }
12263
+ searchList.append('div')
12264
+ .classed('tsi-search-results-header', true)
12265
+ .html(`<strong>${flatResults.length}</strong> ${this.getString('results found') || 'results found'}`);
12266
+ const resultItems = searchList.selectAll('.tsi-search-result-item')
12267
+ .data(flatResults)
12268
+ .enter()
12269
+ .append('div')
12270
+ .classed('tsi-search-result-item', true)
12271
+ .attr('tabindex', '0')
12272
+ .attr('role', 'option')
12273
+ .attr('aria-label', (d) => {
12274
+ const pathStr = d.path.length > 0 ? d.path.join(' > ') + ' > ' : '';
12275
+ return pathStr + d.name;
12276
+ });
12277
+ const self = this;
12278
+ resultItems.each(function (d) {
12279
+ const item = d3.select(this);
12280
+ // Breadcrumb path
12281
+ if (d.path.length > 0) {
12282
+ item.append('div')
12283
+ .classed('tsi-search-breadcrumb', true)
12284
+ .text(d.path.join(' > '));
12285
+ }
12286
+ // Highlighted name
12287
+ item.append('div')
12288
+ .classed('tsi-search-result-name', true)
12289
+ .html(d.highlightedName);
12290
+ // Instance description or count
12291
+ if (d.type === 'instance' && d.description) {
12292
+ item.append('div')
12293
+ .classed('tsi-search-result-description', true)
12294
+ .text(d.description);
12295
+ }
12296
+ else if (d.type === 'hierarchy') {
12297
+ item.append('div')
12298
+ .classed('tsi-search-result-count', true)
12299
+ .text(`${d.cumulativeInstanceCount || 0} instances`);
12300
+ }
12301
+ });
12302
+ // Click handlers
12303
+ resultItems.on('click keydown', function (event, d) {
12304
+ if (Utils.isKeyDownAndNotEnter(event))
12305
+ return;
12306
+ if (d.type === 'instance') {
12307
+ // Handle instance selection
12308
+ if (self.chartOptions.onInstanceClick) {
12309
+ const inst = new InstanceNode(d.timeSeriesId, d.name, d.path.length, d.id, d.description);
12310
+ // Update selection state
12311
+ if (self.selectedIds && self.selectedIds.includes(d.id)) {
12312
+ self.selectedIds = self.selectedIds.filter(id => id !== d.id);
12313
+ d3.select(this).classed('tsi-selected', false);
12314
+ }
12315
+ else {
12316
+ self.selectedIds.push(d.id);
12317
+ d3.select(this).classed('tsi-selected', true);
12318
+ }
12319
+ self.chartOptions.onInstanceClick(inst);
12320
+ }
12321
+ }
12322
+ else {
12323
+ // Navigate to hierarchy node - exit search and expand to that path
12324
+ self.navigateToPath(d.path);
12325
+ }
12326
+ });
12327
+ // Apply selection state to already-selected instances
12328
+ resultItems.each(function (d) {
12329
+ if (d.type === 'instance' && self.selectedIds && self.selectedIds.includes(d.id)) {
12330
+ d3.select(this).classed('tsi-selected', true);
12331
+ }
12332
+ });
12333
+ }
12334
+ // Exit search mode and restore tree
12335
+ exitSearchMode() {
12336
+ this.isSearchMode = false;
12337
+ this.hierarchyElem.selectAll('*').remove();
12338
+ this.pathSearchAndRenderResult({
12339
+ search: { payload: this.requestPayload() },
12340
+ render: { target: this.hierarchyElem }
12341
+ });
12342
+ }
12343
+ // Navigate to a specific path in the hierarchy
12344
+ async navigateToPath(targetPath) {
12345
+ this.exitSearchMode();
12346
+ // For now, just exit search mode and return to root
12347
+ // In a more advanced implementation, this would progressively
12348
+ // expand nodes along the path to reveal the target
12349
+ // This would require waiting for each level to load before expanding the next
12350
+ }
12351
+ // Pre-compute which paths need to be auto-expanded for preselected instances
12352
+ async computePathsToAutoExpand(instanceIds) {
12353
+ if (!instanceIds || instanceIds.length === 0) {
12354
+ return;
12355
+ }
12356
+ // console.log('[HierarchyNavigation] Computing paths to auto-expand for:', instanceIds);
12357
+ try {
12358
+ this.pathsToAutoExpand.clear();
12359
+ for (const instanceId of instanceIds) {
12360
+ // Search for this specific instance
12361
+ const result = await this.searchFunction({
12362
+ path: this.path,
12363
+ searchTerm: instanceId,
12364
+ recursive: true,
12365
+ includeInstances: true
12366
+ });
12367
+ if (result?.instances?.hits) {
12368
+ for (const instance of result.instances.hits) {
12369
+ // Match by ID
12370
+ if (instance.id === instanceId ||
12371
+ (instance.id && instance.id.includes(instanceId))) {
12372
+ if (instance.hierarchyPath && instance.hierarchyPath.length > 0) {
12373
+ // Add all parent paths that need to be expanded
12374
+ const hierarchyPath = instance.hierarchyPath;
12375
+ for (let i = 1; i <= hierarchyPath.length; i++) {
12376
+ const pathArray = hierarchyPath.slice(0, i);
12377
+ const pathKey = pathArray.join('/');
12378
+ this.pathsToAutoExpand.add(pathKey);
12379
+ }
12380
+ }
12381
+ }
12382
+ }
12383
+ }
12384
+ }
12385
+ // console.log('[HierarchyNavigation] Paths to auto-expand:', Array.from(this.pathsToAutoExpand));
12386
+ }
12387
+ catch (err) {
12388
+ console.warn('Failed to compute paths to auto-expand:', err);
12389
+ }
12390
+ }
12391
+ // Check if a path should be auto-expanded
12392
+ shouldAutoExpand(pathArray) {
12393
+ if (this.pathsToAutoExpand.size === 0) {
12394
+ return false;
12395
+ }
12396
+ const pathKey = pathArray.join('/');
12397
+ return this.pathsToAutoExpand.has(pathKey);
12398
+ }
12399
+ // Auto-expand a node by triggering its expand function
12400
+ async autoExpandNode(node) {
12401
+ if (!node || !node.expand || !node.node) {
12402
+ return;
12403
+ }
12404
+ try {
12405
+ // Wait for the DOM node to be available
12406
+ await new Promise(resolve => setTimeout(resolve, 10));
12407
+ // Mark as expanded visually
12408
+ node.node.classed('tsi-expanded', true);
12409
+ // Call the expand function to load children
12410
+ await node.expand();
12411
+ // console.log(`[HierarchyNavigation] Auto-expanded node: ${node.path.join('/')}`);
12412
+ }
12413
+ catch (err) {
12414
+ console.warn(`Failed to auto-expand node ${node.path.join('/')}:`, err);
12415
+ }
12416
+ }
12417
+ // Highlight search term in text
12418
+ highlightMatch(text, searchTerm) {
12419
+ if (!text)
12420
+ return '';
12421
+ const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12422
+ const regex = new RegExp(`(${escapedTerm})`, 'gi');
12423
+ return text.replace(regex, '<mark>$1</mark>');
12424
+ }
12932
12425
  // creates in-depth data object using the server response for hierarchyNodes to show in the tree all expanded, considering UntilChildren
12933
12426
  fillDataRecursively(hierarchyNodes, payload, payloadForContinuation = null) {
12934
12427
  let data = {};
12935
12428
  hierarchyNodes.hits.forEach((h) => {
12936
12429
  let hierarchy = new HierarchyNode(h.name, payload.path, payload.path.length - this.path.length, h.cumulativeInstanceCount, h.id);
12430
+ // cache display name on node for client-side filtering
12431
+ hierarchy.displayName = h.name || '';
12432
+ // Check if this path should be auto-expanded
12433
+ const shouldExpand = this.shouldAutoExpand(hierarchy.path);
12434
+ if (shouldExpand) {
12435
+ hierarchy.isExpanded = true;
12436
+ //console.log(`[HierarchyNavigation] Auto-expanding node: ${hierarchy.path.join('/')}`);
12437
+ }
12937
12438
  hierarchy.expand = () => {
12938
12439
  hierarchy.isExpanded = true;
12939
12440
  hierarchy.node.classed('tsi-expanded', true);
@@ -12957,7 +12458,7 @@ class HierarchyNavigation extends Component {
12957
12458
  .attr('style', `padding-left: ${hORi.isLeaf ? hORi.level * 18 + 20 : (hORi.level + 1) * 18 + 20}px`)
12958
12459
  .attr('tabindex', 0)
12959
12460
  //.attr('arialabel', isHierarchyNode ? key : Utils.getTimeSeriesIdString(hORi))
12960
- .attr('arialabel', isHierarchyNode ? key : self.getAriaLabel(hORi))
12461
+ .attr('aria-label', isHierarchyNode ? key : self.getAriaLabel(hORi))
12961
12462
  .attr('title', isHierarchyNode ? key : self.getAriaLabel(hORi))
12962
12463
  .attr("role", "treeitem").attr('aria-expanded', hORi.isExpanded)
12963
12464
  .on('click keydown', async function (event) {
@@ -13009,6 +12510,8 @@ class HierarchyNavigation extends Component {
13009
12510
  return hORi.description || hORi.name || hORi.id || Utils.getTimeSeriesIdString(hORi);
13010
12511
  }
13011
12512
  }
12513
+ // TreeRenderer has been moved to its own module: ./TreeRenderer
12514
+ // The rendering logic was extracted to reduce file size and improve testability.
13012
12515
  class HierarchyNode {
13013
12516
  constructor(name, parentPath, level, cumulativeInstanceCount = null, id = null) {
13014
12517
  this.name = name;
@@ -13253,6 +12756,7 @@ class SingleDateTimePicker extends ChartComponent {
13253
12756
  class DateTimeButtonSingle extends DateTimeButton {
13254
12757
  constructor(renderTarget) {
13255
12758
  super(renderTarget);
12759
+ this.clickOutsideHandler = null;
13256
12760
  this.sDTPOnSet = (millis = null) => {
13257
12761
  if (millis !== null) {
13258
12762
  this.dateTimeButton.text(this.buttonDateTimeFormat(millis));
@@ -13265,6 +12769,32 @@ class DateTimeButtonSingle extends DateTimeButton {
13265
12769
  closeSDTP() {
13266
12770
  this.dateTimePickerContainer.style("display", "none");
13267
12771
  this.dateTimeButton.node().focus();
12772
+ this.removeClickOutsideHandler();
12773
+ }
12774
+ removeClickOutsideHandler() {
12775
+ if (this.clickOutsideHandler) {
12776
+ document.removeEventListener('click', this.clickOutsideHandler);
12777
+ this.clickOutsideHandler = null;
12778
+ }
12779
+ }
12780
+ setupClickOutsideHandler() {
12781
+ // Remove any existing handler first
12782
+ this.removeClickOutsideHandler();
12783
+ // Add handler after a small delay to prevent the opening click from immediately closing the picker
12784
+ setTimeout(() => {
12785
+ this.clickOutsideHandler = (event) => {
12786
+ const pickerElement = this.dateTimePickerContainer.node();
12787
+ const buttonElement = this.dateTimeButton.node();
12788
+ const target = event.target;
12789
+ // Check if click is outside both the picker and the button
12790
+ if (pickerElement && buttonElement &&
12791
+ !pickerElement.contains(target) &&
12792
+ !buttonElement.contains(target)) {
12793
+ this.closeSDTP();
12794
+ }
12795
+ };
12796
+ document.addEventListener('click', this.clickOutsideHandler);
12797
+ }, 0);
13268
12798
  }
13269
12799
  render(chartOptions = {}, minMillis, maxMillis, selectedMillis = null, onSet = null) {
13270
12800
  super.render(chartOptions, minMillis, maxMillis, onSet);
@@ -13274,12 +12804,11 @@ class DateTimeButtonSingle extends DateTimeButton {
13274
12804
  if (!this.dateTimePicker) {
13275
12805
  this.dateTimePicker = new SingleDateTimePicker(this.dateTimePickerContainer.node());
13276
12806
  }
13277
- let targetElement = d3.select(this.renderTarget);
13278
- (targetElement.select(".tsi-dateTimePickerContainer")).selectAll("*");
13279
12807
  this.dateTimeButton.on("click", () => {
13280
12808
  this.chartOptions.dTPIsModal = true;
13281
12809
  this.dateTimePickerContainer.style("display", "block");
13282
12810
  this.dateTimePicker.render(this.chartOptions, this.minMillis, this.maxMillis, this.selectedMillis, this.sDTPOnSet);
12811
+ this.setupClickOutsideHandler();
13283
12812
  });
13284
12813
  }
13285
12814
  }
@@ -13577,8 +13106,16 @@ class ProcessGraphic extends HistoryPlayback {
13577
13106
  class PlaybackControls extends Component {
13578
13107
  constructor(renderTarget, initialTimeStamp = null) {
13579
13108
  super(renderTarget);
13580
- this.handleRadius = 7;
13581
- this.minimumPlaybackInterval = 1000; // 1 second
13109
+ this.playbackInterval = null;
13110
+ this.playButton = null;
13111
+ this.handleElement = null;
13112
+ this.controlsContainer = null;
13113
+ this.track = null;
13114
+ this.selectTimeStampCallback = null;
13115
+ this.wasPlayingWhenDragStarted = false;
13116
+ this.rafId = null;
13117
+ this.handleRadius = PlaybackControls.CONSTANTS.HANDLE_RADIUS;
13118
+ this.minimumPlaybackInterval = PlaybackControls.CONSTANTS.MINIMUM_PLAYBACK_INTERVAL_MS;
13582
13119
  this.playbackInterval = null;
13583
13120
  this.selectedTimeStamp = initialTimeStamp;
13584
13121
  }
@@ -13586,6 +13123,21 @@ class PlaybackControls extends Component {
13586
13123
  return this.selectedTimeStamp;
13587
13124
  }
13588
13125
  render(start, end, onSelectTimeStamp, options, playbackSettings) {
13126
+ // Validate inputs
13127
+ if (!(start instanceof Date) || !(end instanceof Date)) {
13128
+ throw new TypeError('start and end must be Date objects');
13129
+ }
13130
+ if (start >= end) {
13131
+ throw new RangeError('start must be before end');
13132
+ }
13133
+ if (!onSelectTimeStamp || typeof onSelectTimeStamp !== 'function') {
13134
+ throw new TypeError('onSelectTimeStamp must be a function');
13135
+ }
13136
+ // Clean up any pending animation frames before re-rendering
13137
+ if (this.rafId !== null) {
13138
+ cancelAnimationFrame(this.rafId);
13139
+ this.rafId = null;
13140
+ }
13589
13141
  this.end = end;
13590
13142
  this.selectTimeStampCallback = onSelectTimeStamp;
13591
13143
  this.chartOptions.setOptions(options);
@@ -13647,6 +13199,9 @@ class PlaybackControls extends Component {
13647
13199
  this.playButton = this.controlsContainer.append('button')
13648
13200
  .classed('tsi-play-button', this.playbackInterval === null)
13649
13201
  .classed('tsi-pause-button', this.playbackInterval !== null)
13202
+ // Accessibility attributes
13203
+ .attr('aria-label', 'Play/Pause playback')
13204
+ .attr('title', 'Play/Pause playback')
13650
13205
  .on('click', () => {
13651
13206
  if (this.playbackInterval === null) {
13652
13207
  this.play();
@@ -13698,6 +13253,27 @@ class PlaybackControls extends Component {
13698
13253
  this.updateSelection(handlePosition, this.selectedTimeStamp);
13699
13254
  this.selectTimeStampCallback(this.selectedTimeStamp);
13700
13255
  }
13256
+ /**
13257
+ * Cleanup resources to prevent memory leaks
13258
+ */
13259
+ destroy() {
13260
+ this.pause();
13261
+ // Cancel any pending animation frames
13262
+ if (this.rafId !== null) {
13263
+ cancelAnimationFrame(this.rafId);
13264
+ this.rafId = null;
13265
+ }
13266
+ // Remove event listeners
13267
+ if (this.controlsContainer) {
13268
+ this.controlsContainer.selectAll('*').on('.', null);
13269
+ }
13270
+ // Clear DOM references
13271
+ this.playButton = null;
13272
+ this.handleElement = null;
13273
+ this.controlsContainer = null;
13274
+ this.track = null;
13275
+ this.selectTimeStampCallback = null;
13276
+ }
13701
13277
  clamp(number, min, max) {
13702
13278
  let clamped = Math.max(number, min);
13703
13279
  return Math.min(clamped, max);
@@ -13706,9 +13282,17 @@ class PlaybackControls extends Component {
13706
13282
  this.wasPlayingWhenDragStarted = this.wasPlayingWhenDragStarted ||
13707
13283
  (this.playbackInterval !== null);
13708
13284
  this.pause();
13709
- let handlePosition = this.clamp(positionX, 0, this.trackWidth);
13710
- this.selectedTimeStamp = this.timeStampToPosition.invert(handlePosition);
13711
- this.updateSelection(handlePosition, this.selectedTimeStamp);
13285
+ // Use requestAnimationFrame to batch DOM updates for better performance
13286
+ // Cancel any pending animation frame to prevent stacking updates
13287
+ if (this.rafId !== null) {
13288
+ cancelAnimationFrame(this.rafId);
13289
+ }
13290
+ this.rafId = requestAnimationFrame(() => {
13291
+ const handlePosition = this.clamp(positionX, 0, this.trackWidth);
13292
+ this.selectedTimeStamp = this.timeStampToPosition.invert(handlePosition);
13293
+ this.updateSelection(handlePosition, this.selectedTimeStamp);
13294
+ this.rafId = null;
13295
+ });
13712
13296
  }
13713
13297
  onDragEnd() {
13714
13298
  this.selectTimeStampCallback(this.selectedTimeStamp);
@@ -13731,6 +13315,12 @@ class PlaybackControls extends Component {
13731
13315
  .text(this.timeFormatter(timeStamp));
13732
13316
  }
13733
13317
  }
13318
+ PlaybackControls.CONSTANTS = {
13319
+ HANDLE_RADIUS: 7,
13320
+ MINIMUM_PLAYBACK_INTERVAL_MS: 1000,
13321
+ HANDLE_PADDING: 8,
13322
+ AXIS_OFFSET: 6,
13323
+ };
13734
13324
  class TimeAxis extends TemporalXAxisComponent {
13735
13325
  constructor(renderTarget) {
13736
13326
  super(renderTarget);