tsichart-core 2.0.0-beta.7 → 2.1.0

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