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