tsichart-core 2.0.0 → 2.1.2

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.umd.js CHANGED
@@ -4,6 +4,21 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.TsiClient = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
+ function _mergeNamespaces(n, m) {
8
+ m.forEach(function (e) {
9
+ e && typeof e !== 'string' && !Array.isArray(e) && Object.keys(e).forEach(function (k) {
10
+ if (k !== 'default' && !(k in n)) {
11
+ var d = Object.getOwnPropertyDescriptor(e, k);
12
+ Object.defineProperty(n, k, d.get ? d : {
13
+ enumerable: true,
14
+ get: function () { return e[k]; }
15
+ });
16
+ }
17
+ });
18
+ });
19
+ return Object.freeze(n);
20
+ }
21
+
7
22
  function ascending$3(a, b) {
8
23
  return a == null || b == null ? NaN : a < b ? -1 : a > b ? 1 : a >= b ? 0 : NaN;
9
24
  }
@@ -29015,7 +29030,7 @@
29015
29030
  swimLaneLabelHeightPadding: 8,
29016
29031
  labelLeftPadding: 28
29017
29032
  };
29018
- const CharactersToEscapeForExactSearchInstance = ['"', '`', '\'', '!', '(', ')', '^', '[', '{', ':', ']', '}', '~', '/', '\\', '@', '#', '$', '%', '&', '*', ';', '=', '.', '_', '-', '<', '>', ',', '?'];
29033
+ const CharactersToEscapeForExactSearchInstance = ['"', '`', '\'', '!', '(', ')', '^', '[', '{', ':', '}', ']', '~', '/', '\\', '@', '#', '$', '%', '&', '*', ';', '=', '.', '_', '-', '<', '>', ',', '?'];
29019
29034
  const NONNUMERICTOPMARGIN = 8;
29020
29035
  const LINECHARTTOPPADDING = 16;
29021
29036
  const GRIDCONTAINERCLASS = 'tsi-gridContainer';
@@ -30451,35 +30466,41 @@
30451
30466
  }
30452
30467
  }
30453
30468
 
30454
- const NUMERICSPLITBYHEIGHT = 44;
30455
- const NONNUMERICSPLITBYHEIGHT = 24;
30469
+ /**
30470
+ * Constants for Legend component layout and behavior
30471
+ */
30472
+ const LEGEND_CONSTANTS = {
30473
+ /** Height in pixels for each numeric split-by item (includes type selector dropdown) */
30474
+ NUMERIC_SPLITBY_HEIGHT: 44,
30475
+ /** Height in pixels for each non-numeric (categorical/events) split-by item */
30476
+ NON_NUMERIC_SPLITBY_HEIGHT: 24,
30477
+ /** Height in pixels for the series name label header */
30478
+ NAME_LABEL_HEIGHT: 24,
30479
+ /** Buffer distance in pixels from scroll edge before triggering "load more" */
30480
+ SCROLL_BUFFER: 40,
30481
+ /** Number of split-by items to load per batch when paginating */
30482
+ BATCH_SIZE: 20,
30483
+ /** Minimum height in pixels for aggregate container */
30484
+ MIN_AGGREGATE_HEIGHT: 201,
30485
+ /** Minimum width in pixels for each series label in compact mode */
30486
+ MIN_SERIES_WIDTH: 124,
30487
+ };
30456
30488
  class Legend extends Component {
30457
30489
  constructor(drawChart, renderTarget, legendWidth) {
30458
30490
  super(renderTarget);
30459
30491
  this.renderSplitBys = (aggKey, aggSelection, dataType, noSplitBys) => {
30460
- var splitByLabelData = Object.keys(this.chartComponentData.timeArrays[aggKey]);
30461
- var firstSplitBy = this.chartComponentData.displayState[aggKey].splitBys[Object.keys(this.chartComponentData.displayState[aggKey].splitBys)[0]];
30462
- var firstSplitByType = firstSplitBy ? firstSplitBy.visibleType : null;
30463
- Object.keys(this.chartComponentData.displayState[aggKey].splitBys).reduce((isSame, curr) => {
30464
- return (firstSplitByType == this.chartComponentData.displayState[aggKey].splitBys[curr].visibleType) && isSame;
30465
- }, true);
30466
- let showMoreSplitBys = () => {
30467
- const oldShownSplitBys = this.chartComponentData.displayState[aggKey].shownSplitBys;
30468
- this.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + 20, splitByLabelData.length);
30469
- if (oldShownSplitBys != this.chartComponentData.displayState[aggKey].shownSplitBys) {
30470
- this.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
30471
- }
30472
- };
30492
+ const splitByLabelData = Object.keys(this.chartComponentData.timeArrays[aggKey]);
30493
+ const showMoreSplitBys = () => this.handleShowMoreSplitBys(aggKey, splitByLabelData, aggSelection, dataType, noSplitBys);
30473
30494
  let splitByContainer = aggSelection.selectAll(".tsi-splitByContainer").data([aggKey]);
30474
- var splitByContainerEntered = splitByContainer.enter().append("div")
30495
+ const splitByContainerEntered = splitByContainer.enter().append("div")
30475
30496
  .merge(splitByContainer)
30476
30497
  .classed("tsi-splitByContainer", true);
30477
- var splitByLabels = splitByContainerEntered.selectAll('.tsi-splitByLabel')
30498
+ const splitByLabels = splitByContainerEntered.selectAll('.tsi-splitByLabel')
30478
30499
  .data(splitByLabelData.slice(0, this.chartComponentData.displayState[aggKey].shownSplitBys), function (d) {
30479
30500
  return d;
30480
30501
  });
30481
- let self = this;
30482
- var splitByLabelsEntered = splitByLabels
30502
+ const self = this;
30503
+ const splitByLabelsEntered = splitByLabels
30483
30504
  .enter()
30484
30505
  .append("div")
30485
30506
  .merge(splitByLabels)
@@ -30493,135 +30514,60 @@
30493
30514
  }
30494
30515
  })
30495
30516
  .on("click", function (event, splitBy) {
30496
- if (self.legendState == "compact") {
30497
- self.toggleSplitByVisible(aggKey, splitBy);
30498
- }
30499
- else {
30500
- self.toggleSticky(aggKey, splitBy);
30501
- }
30502
- self.drawChart();
30517
+ self.handleSplitByClick(aggKey, splitBy);
30503
30518
  })
30504
30519
  .on("mouseover", function (event, splitBy) {
30505
30520
  event.stopPropagation();
30506
- self.labelMouseover(aggKey, splitBy);
30521
+ self.handleSplitByMouseOver(aggKey, splitBy);
30507
30522
  })
30508
30523
  .on("mouseout", function (event) {
30509
30524
  event.stopPropagation();
30510
- self.svgSelection.selectAll(".tsi-valueElement")
30511
- .attr("stroke-opacity", 1)
30512
- .attr("fill-opacity", 1);
30513
- self.labelMouseout(self.svgSelection, aggKey);
30525
+ self.handleSplitByMouseOut(aggKey);
30514
30526
  })
30515
30527
  .attr("class", (splitBy, i) => {
30516
- let compact = (dataType !== DataTypes.Numeric) ? 'tsi-splitByLabelCompact' : '';
30517
- let shown = Utils.getAgVisible(self.chartComponentData.displayState, aggKey, splitBy) ? 'shown' : '';
30518
- return `tsi-splitByLabel tsi-splitByLabel ${compact} ${shown}`;
30528
+ const compact = (dataType !== DataTypes.Numeric) ? 'tsi-splitByLabelCompact' : '';
30529
+ const shown = Utils.getAgVisible(self.chartComponentData.displayState, aggKey, splitBy) ? 'shown' : '';
30530
+ return `tsi-splitByLabel ${compact} ${shown}`;
30519
30531
  })
30520
- .classed("stickied", (splitBy, i) => {
30521
- if (self.chartComponentData.stickiedKey != null) {
30522
- return aggKey == self.chartComponentData.stickiedKey.aggregateKey && splitBy == self.chartComponentData.stickiedKey.splitBy;
30523
- }
30524
- });
30525
- var colors = Utils.createSplitByColors(self.chartComponentData.displayState, aggKey, self.chartOptions.keepSplitByColor);
30532
+ .classed("stickied", (splitBy, i) => self.isStickied(aggKey, splitBy));
30533
+ // Use helper methods to render each split-by element
30526
30534
  splitByLabelsEntered.each(function (splitBy, j) {
30527
- let color = (self.chartComponentData.isFromHeatmap) ? self.chartComponentData.displayState[aggKey].color : colors[j];
30535
+ const selection = select(this);
30536
+ // Add color key (conditionally based on data type and legend state)
30528
30537
  if (dataType === DataTypes.Numeric || noSplitBys || self.legendState === 'compact') {
30529
- let colorKey = select(this).selectAll('.tsi-colorKey').data([color]);
30530
- let colorKeyEntered = colorKey.enter()
30531
- .append("div")
30532
- .attr("class", 'tsi-colorKey')
30533
- .merge(colorKey);
30534
- if (dataType === DataTypes.Numeric) {
30535
- colorKeyEntered.style('background-color', (d) => {
30536
- return d;
30537
- });
30538
- }
30539
- else {
30540
- self.createNonNumericColorKey(dataType, colorKeyEntered, aggKey);
30541
- }
30542
- select(this).classed('tsi-nonCompactNonNumeric', (dataType === DataTypes.Categorical || dataType === DataTypes.Events) && this.legendState !== 'compact');
30543
- colorKey.exit().remove();
30538
+ self.addColorKey(selection, aggKey, splitBy, dataType);
30539
+ selection.classed('tsi-nonCompactNonNumeric', (dataType === DataTypes.Categorical || dataType === DataTypes.Events) && self.legendState !== 'compact');
30544
30540
  }
30545
30541
  else {
30546
- select(this).selectAll('.tsi-colorKey').remove();
30547
- }
30548
- if (select(this).select('.tsi-eyeIcon').empty()) {
30549
- select(this).append("button")
30550
- .attr("class", "tsi-eyeIcon")
30551
- .attr('aria-label', () => {
30552
- let showOrHide = self.chartComponentData.displayState[aggKey].splitBys[splitBy].visible ? self.getString('hide series') : self.getString('show series');
30553
- return `${showOrHide} ${splitBy} ${self.getString('in group')} ${self.chartComponentData.displayState[aggKey].name}`;
30554
- })
30555
- .attr('title', () => self.getString('Show/Hide values'))
30556
- .on("click", function (event) {
30557
- event.stopPropagation();
30558
- self.toggleSplitByVisible(aggKey, splitBy);
30559
- select(this)
30560
- .classed("shown", Utils.getAgVisible(self.chartComponentData.displayState, aggKey, splitBy));
30561
- self.drawChart();
30562
- });
30563
- }
30564
- if (select(this).select('.tsi-seriesName').empty()) {
30565
- let seriesName = select(this)
30566
- .append('div')
30567
- .attr('class', 'tsi-seriesName');
30568
- Utils.appendFormattedElementsFromString(seriesName, noSplitBys ? (self.chartComponentData.displayState[aggKey].name) : splitBy);
30542
+ selection.selectAll('.tsi-colorKey').remove();
30569
30543
  }
30544
+ // Add eye icon
30545
+ self.addEyeIcon(selection, aggKey, splitBy);
30546
+ // Add series name
30547
+ self.addSeriesName(selection, aggKey, splitBy);
30548
+ // Add series type selection for numeric data
30570
30549
  if (dataType === DataTypes.Numeric) {
30571
- if (select(this).select('.tsi-seriesTypeSelection').empty()) {
30572
- select(this).append("select")
30573
- .attr('aria-label', `${self.getString("Series type selection for")} ${splitBy} ${self.getString('in group')} ${self.chartComponentData.displayState[aggKey].name}`)
30574
- .attr('class', 'tsi-seriesTypeSelection')
30575
- .on("change", function (data) {
30576
- var seriesType = select(this).property("value");
30577
- self.chartComponentData.displayState[aggKey].splitBys[splitBy].visibleType = seriesType;
30578
- self.drawChart();
30579
- })
30580
- .on("click", (event) => {
30581
- event.stopPropagation();
30582
- });
30583
- }
30584
- select(this).select('.tsi-seriesTypeSelection')
30585
- .each(function (d) {
30586
- var typeLabels = select(this).selectAll('option')
30587
- .data(data => self.chartComponentData.displayState[aggKey].splitBys[splitBy].types.map((type) => {
30588
- return {
30589
- type: type,
30590
- aggKey: aggKey,
30591
- splitBy: splitBy,
30592
- visibleMeasure: Utils.getAgVisibleMeasure(self.chartComponentData.displayState, aggKey, splitBy)
30593
- };
30594
- }));
30595
- typeLabels
30596
- .enter()
30597
- .append("option")
30598
- .attr("class", "seriesTypeLabel")
30599
- .merge(typeLabels)
30600
- .property("selected", (data) => {
30601
- return ((data.type == Utils.getAgVisibleMeasure(self.chartComponentData.displayState, data.aggKey, data.splitBy)) ?
30602
- " selected" : "");
30603
- })
30604
- .text((data) => data.type);
30605
- typeLabels.exit().remove();
30606
- });
30550
+ self.addSeriesTypeSelection(selection, aggKey, splitBy);
30607
30551
  }
30608
30552
  else {
30609
- select(this).selectAll('.tsi-seriesTypeSelection').remove();
30553
+ selection.selectAll('.tsi-seriesTypeSelection').remove();
30610
30554
  }
30611
30555
  });
30612
30556
  splitByLabels.exit().remove();
30613
- let shouldShowMore = self.chartComponentData.displayState[aggKey].shownSplitBys < splitByLabelData.length;
30557
+ // Show more button
30558
+ const shouldShowMore = self.chartComponentData.displayState[aggKey].shownSplitBys < splitByLabelData.length;
30614
30559
  splitByContainerEntered.selectAll('.tsi-legendShowMore').remove();
30615
30560
  if (this.legendState === 'shown' && shouldShowMore) {
30616
30561
  splitByContainerEntered.append('button')
30617
30562
  .text(this.getString('Show more'))
30618
30563
  .attr('class', 'tsi-legendShowMore')
30619
- .style('display', (this.legendState === 'shown' && shouldShowMore) ? 'block' : 'none')
30564
+ .style('display', 'block')
30620
30565
  .on('click', showMoreSplitBys);
30621
30566
  }
30567
+ // Scroll handler for infinite scrolling
30622
30568
  splitByContainerEntered.on("scroll", function () {
30623
30569
  if (self.chartOptions.legend === 'shown') {
30624
- if (this.scrollTop + this.clientHeight + 40 > this.scrollHeight) {
30570
+ if (this.scrollTop + this.clientHeight + LEGEND_CONSTANTS.SCROLL_BUFFER > this.scrollHeight) {
30625
30571
  showMoreSplitBys();
30626
30572
  }
30627
30573
  }
@@ -30646,10 +30592,125 @@
30646
30592
  };
30647
30593
  this.drawChart = drawChart;
30648
30594
  this.legendWidth = legendWidth;
30649
- this.legendElement = select(renderTarget).insert("div", ":first-child")
30595
+ this.legendElement = select(renderTarget)
30596
+ .insert("div", ":first-child")
30650
30597
  .attr("class", "tsi-legend")
30651
- .style("left", "0px")
30652
- .style("width", (this.legendWidth) + "px"); // - 16 for the width of the padding
30598
+ .style("left", "0px");
30599
+ // Note: width is set conditionally in draw() based on legendState
30600
+ // to allow CSS to control width in compact mode
30601
+ }
30602
+ getHeightPerSplitBy(aggKey) {
30603
+ const dataType = this.chartComponentData.displayState[aggKey].dataType;
30604
+ return dataType === DataTypes.Numeric
30605
+ ? LEGEND_CONSTANTS.NUMERIC_SPLITBY_HEIGHT
30606
+ : LEGEND_CONSTANTS.NON_NUMERIC_SPLITBY_HEIGHT;
30607
+ }
30608
+ addColorKey(selection, aggKey, splitBy, dataType) {
30609
+ const colors = Utils.createSplitByColors(this.chartComponentData.displayState, aggKey, this.chartOptions.keepSplitByColor);
30610
+ const splitByKeys = Object.keys(this.chartComponentData.timeArrays[aggKey]);
30611
+ const splitByIndex = splitByKeys.indexOf(splitBy);
30612
+ const color = this.chartComponentData.isFromHeatmap
30613
+ ? this.chartComponentData.displayState[aggKey].color
30614
+ : colors[splitByIndex];
30615
+ const colorKey = selection.selectAll('.tsi-colorKey').data([color]);
30616
+ const colorKeyEntered = colorKey.enter()
30617
+ .append('div')
30618
+ .attr('class', 'tsi-colorKey')
30619
+ .merge(colorKey);
30620
+ if (dataType === DataTypes.Numeric) {
30621
+ colorKeyEntered.style('background-color', d => d);
30622
+ }
30623
+ else {
30624
+ this.createNonNumericColorKey(dataType, colorKeyEntered, aggKey);
30625
+ }
30626
+ colorKey.exit().remove();
30627
+ }
30628
+ addEyeIcon(selection, aggKey, splitBy) {
30629
+ if (selection.select('.tsi-eyeIcon').empty()) {
30630
+ selection.append('button')
30631
+ .attr('class', 'tsi-eyeIcon')
30632
+ .attr('aria-label', () => {
30633
+ const showOrHide = this.chartComponentData.displayState[aggKey].splitBys[splitBy].visible
30634
+ ? this.getString('hide series')
30635
+ : this.getString('show series');
30636
+ return `${showOrHide} ${splitBy} ${this.getString('in group')} ${this.chartComponentData.displayState[aggKey].name}`;
30637
+ })
30638
+ .attr('title', () => this.getString('Show/Hide values'))
30639
+ .on('click', (event) => {
30640
+ event.stopPropagation();
30641
+ this.toggleSplitByVisible(aggKey, splitBy);
30642
+ this.drawChart();
30643
+ });
30644
+ }
30645
+ selection.select('.tsi-eyeIcon')
30646
+ .classed('shown', Utils.getAgVisible(this.chartComponentData.displayState, aggKey, splitBy));
30647
+ }
30648
+ addSeriesName(selection, aggKey, splitBy) {
30649
+ if (selection.select('.tsi-seriesName').empty()) {
30650
+ const seriesName = selection.append('div')
30651
+ .attr('class', 'tsi-seriesName');
30652
+ const noSplitBys = Object.keys(this.chartComponentData.timeArrays[aggKey]).length === 1
30653
+ && Object.keys(this.chartComponentData.timeArrays[aggKey])[0] === '';
30654
+ const displayText = noSplitBys
30655
+ ? this.chartComponentData.displayState[aggKey].name
30656
+ : splitBy;
30657
+ Utils.appendFormattedElementsFromString(seriesName, displayText);
30658
+ }
30659
+ }
30660
+ addSeriesTypeSelection(selection, aggKey, splitBy) {
30661
+ if (selection.select('.tsi-seriesTypeSelection').empty()) {
30662
+ selection.append('select')
30663
+ .attr('aria-label', `${this.getString('Series type selection for')} ${splitBy} ${this.getString('in group')} ${this.chartComponentData.displayState[aggKey].name}`)
30664
+ .attr('class', 'tsi-seriesTypeSelection')
30665
+ .on('change', (event) => {
30666
+ const seriesType = select(event.target).property('value');
30667
+ this.chartComponentData.displayState[aggKey].splitBys[splitBy].visibleType = seriesType;
30668
+ this.drawChart();
30669
+ })
30670
+ .on('click', (event) => {
30671
+ event.stopPropagation();
30672
+ });
30673
+ }
30674
+ selection.select('.tsi-seriesTypeSelection')
30675
+ .each((d, i, nodes) => {
30676
+ const typeLabels = select(nodes[i])
30677
+ .selectAll('option')
30678
+ .data(this.chartComponentData.displayState[aggKey].splitBys[splitBy].types.map(type => ({
30679
+ type,
30680
+ aggKey,
30681
+ splitBy,
30682
+ visibleMeasure: Utils.getAgVisibleMeasure(this.chartComponentData.displayState, aggKey, splitBy)
30683
+ })));
30684
+ typeLabels.enter()
30685
+ .append('option')
30686
+ .attr('class', 'seriesTypeLabel')
30687
+ .merge(typeLabels)
30688
+ .property('selected', (data) => data.type === Utils.getAgVisibleMeasure(this.chartComponentData.displayState, data.aggKey, data.splitBy))
30689
+ .text((data) => data.type);
30690
+ typeLabels.exit().remove();
30691
+ });
30692
+ }
30693
+ handleSplitByClick(aggKey, splitBy) {
30694
+ if (this.legendState === 'compact') {
30695
+ this.toggleSplitByVisible(aggKey, splitBy);
30696
+ }
30697
+ else {
30698
+ this.toggleSticky(aggKey, splitBy);
30699
+ }
30700
+ this.drawChart();
30701
+ }
30702
+ handleSplitByMouseOver(aggKey, splitBy) {
30703
+ this.labelMouseover(aggKey, splitBy);
30704
+ }
30705
+ handleSplitByMouseOut(aggKey) {
30706
+ this.svgSelection.selectAll(".tsi-valueElement")
30707
+ .attr("stroke-opacity", 1)
30708
+ .attr("fill-opacity", 1);
30709
+ this.labelMouseout(this.svgSelection, aggKey);
30710
+ }
30711
+ isStickied(aggKey, splitBy) {
30712
+ const stickied = this.chartComponentData.stickiedKey;
30713
+ return stickied?.aggregateKey === aggKey && stickied?.splitBy === splitBy;
30653
30714
  }
30654
30715
  labelMouseoutWrapper(labelMouseout, svgSelection, event) {
30655
30716
  return (svgSelection, aggKey) => {
@@ -30691,14 +30752,11 @@
30691
30752
  return d == aggKey;
30692
30753
  }).node();
30693
30754
  var prospectiveScrollTop = Math.max((indexOfSplitBy - 1) * this.getHeightPerSplitBy(aggKey), 0);
30694
- if (splitByNode.scrollTop < prospectiveScrollTop - (splitByNode.clientHeight - 40) || splitByNode.scrollTop > prospectiveScrollTop) {
30755
+ if (splitByNode.scrollTop < prospectiveScrollTop - (splitByNode.clientHeight - LEGEND_CONSTANTS.SCROLL_BUFFER) || splitByNode.scrollTop > prospectiveScrollTop) {
30695
30756
  splitByNode.scrollTop = prospectiveScrollTop;
30696
30757
  }
30697
30758
  }
30698
30759
  }
30699
- getHeightPerSplitBy(aggKey) {
30700
- return (this.chartComponentData.displayState[aggKey].dataType === DataTypes.Numeric ? NUMERICSPLITBYHEIGHT : NONNUMERICSPLITBYHEIGHT);
30701
- }
30702
30760
  createGradient(gradientKey, svg, values) {
30703
30761
  let gradient = svg.append('defs').append('linearGradient')
30704
30762
  .attr('id', gradientKey).attr('x1', '0%').attr('x2', '0%').attr('y1', '0%').attr('y2', '100%');
@@ -30717,10 +30775,6 @@
30717
30775
  .attr("stop-opacity", 1);
30718
30776
  });
30719
30777
  }
30720
- isNonNumeric(aggKey) {
30721
- let dataType = this.chartComponentData.displayState[aggKey].dataType;
30722
- return (dataType === DataTypes.Categorical || dataType === DataTypes.Events);
30723
- }
30724
30778
  createNonNumericColorKey(dataType, colorKey, aggKey) {
30725
30779
  if (dataType === DataTypes.Categorical) {
30726
30780
  this.createCategoricalColorKey(colorKey, aggKey);
@@ -30776,6 +30830,13 @@
30776
30830
  rect.attr('fill', "url(#" + gradientKey + ")");
30777
30831
  }
30778
30832
  }
30833
+ handleShowMoreSplitBys(aggKey, splitByLabelData, aggSelection, dataType, noSplitBys) {
30834
+ const oldShownSplitBys = this.chartComponentData.displayState[aggKey].shownSplitBys;
30835
+ this.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + LEGEND_CONSTANTS.BATCH_SIZE, splitByLabelData.length);
30836
+ if (oldShownSplitBys !== this.chartComponentData.displayState[aggKey].shownSplitBys) {
30837
+ this.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
30838
+ }
30839
+ }
30779
30840
  draw(legendState, chartComponentData, labelMouseover, svgSelection, options, labelMouseoutAction = null, stickySeriesAction = null, event) {
30780
30841
  this.chartOptions.setOptions(options);
30781
30842
  this.chartComponentData = chartComponentData;
@@ -30790,6 +30851,13 @@
30790
30851
  legend.style('visibility', this.legendState != 'hidden')
30791
30852
  .classed('compact', this.legendState == 'compact')
30792
30853
  .classed('hidden', this.legendState == 'hidden');
30854
+ // Set width conditionally - let CSS handle compact mode width
30855
+ if (this.legendState !== 'compact') {
30856
+ legend.style('width', `${this.legendWidth}px`);
30857
+ }
30858
+ else {
30859
+ legend.style('width', null); // Remove inline width style in compact mode
30860
+ }
30793
30861
  let seriesNames = Object.keys(this.chartComponentData.displayState);
30794
30862
  var seriesLabels = legend.selectAll(".tsi-seriesLabel")
30795
30863
  .data(seriesNames, d => d);
@@ -30800,7 +30868,7 @@
30800
30868
  return "tsi-seriesLabel " + (this.chartComponentData.displayState[d]["visible"] ? " shown" : "");
30801
30869
  })
30802
30870
  .style("min-width", () => {
30803
- return Math.min(124, this.legendElement.node().clientWidth / seriesNames.length) + 'px';
30871
+ return Math.min(LEGEND_CONSTANTS.MIN_SERIES_WIDTH, this.legendElement.node().clientWidth / seriesNames.length) + 'px';
30804
30872
  })
30805
30873
  .style("border-color", function (d, i) {
30806
30874
  if (select(this).classed("shown"))
@@ -30808,9 +30876,8 @@
30808
30876
  return "lightgray";
30809
30877
  });
30810
30878
  var self = this;
30811
- const heightPerNameLabel = 25;
30812
30879
  const usableLegendHeight = legend.node().clientHeight;
30813
- var prospectiveAggregateHeight = Math.ceil(Math.max(201, (usableLegendHeight / seriesLabelsEntered.size())));
30880
+ var prospectiveAggregateHeight = Math.ceil(Math.max(LEGEND_CONSTANTS.MIN_AGGREGATE_HEIGHT, (usableLegendHeight / seriesLabelsEntered.size())));
30814
30881
  var contentHeight = 0;
30815
30882
  seriesLabelsEntered.each(function (aggKey, i) {
30816
30883
  let heightPerSplitBy = self.getHeightPerSplitBy(aggKey);
@@ -30866,12 +30933,12 @@
30866
30933
  seriesNameLabel.exit().remove();
30867
30934
  var splitByContainerHeight;
30868
30935
  if (splitByLabelData.length > (prospectiveAggregateHeight / heightPerSplitBy)) {
30869
- splitByContainerHeight = prospectiveAggregateHeight - heightPerNameLabel;
30870
- contentHeight += splitByContainerHeight + heightPerNameLabel;
30936
+ splitByContainerHeight = prospectiveAggregateHeight - LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
30937
+ contentHeight += splitByContainerHeight + LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
30871
30938
  }
30872
30939
  else if (splitByLabelData.length > 1 || (splitByLabelData.length === 1 && splitByLabelData[0] !== "")) {
30873
- splitByContainerHeight = splitByLabelData.length * heightPerSplitBy + heightPerNameLabel;
30874
- contentHeight += splitByContainerHeight + heightPerNameLabel;
30940
+ splitByContainerHeight = splitByLabelData.length * heightPerSplitBy + LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
30941
+ contentHeight += splitByContainerHeight + LEGEND_CONSTANTS.NAME_LABEL_HEIGHT;
30875
30942
  }
30876
30943
  else {
30877
30944
  splitByContainerHeight = heightPerSplitBy;
@@ -30884,43 +30951,28 @@
30884
30951
  select(this).style("height", "unset");
30885
30952
  }
30886
30953
  var splitByContainer = select(this).selectAll(".tsi-splitByContainer").data([aggKey]);
30887
- var splitByContainerEntered = splitByContainer.enter().append("div")
30954
+ splitByContainer.enter().append("div")
30888
30955
  .merge(splitByContainer)
30889
30956
  .classed("tsi-splitByContainer", true);
30890
30957
  let aggSelection = select(this);
30891
30958
  self.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
30892
- splitByContainerEntered.on("scroll", function () {
30893
- if (self.chartOptions.legend == "shown") {
30894
- if (this.scrollTop + this.clientHeight + 40 > this.scrollHeight) {
30895
- const oldShownSplitBys = self.chartComponentData.displayState[aggKey].shownSplitBys;
30896
- self.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + 20, splitByLabelData.length);
30897
- if (oldShownSplitBys != self.chartComponentData.displayState[aggKey].shownSplitBys) {
30898
- self.renderSplitBys(aggKey, aggSelection, dataType, noSplitBys);
30899
- }
30900
- }
30901
- }
30902
- });
30959
+ // Compact mode horizontal scroll handler
30903
30960
  select(this).on('scroll', function () {
30904
30961
  if (self.chartOptions.legend == "compact") {
30905
- if (this.scrollLeft + this.clientWidth + 40 > this.scrollWidth) {
30906
- const oldShownSplitBys = self.chartComponentData.displayState[aggKey].shownSplitBys;
30907
- self.chartComponentData.displayState[aggKey].shownSplitBys = Math.min(oldShownSplitBys + 20, splitByLabelData.length);
30908
- if (oldShownSplitBys != self.chartComponentData.displayState[aggKey].shownSplitBys) {
30909
- this.renderSplitBys(dataType);
30910
- }
30962
+ if (this.scrollLeft + this.clientWidth + LEGEND_CONSTANTS.SCROLL_BUFFER > this.scrollWidth) {
30963
+ self.handleShowMoreSplitBys(aggKey, splitByLabelData, aggSelection, dataType, noSplitBys);
30911
30964
  }
30912
30965
  }
30913
30966
  });
30914
30967
  splitByContainer.exit().remove();
30915
30968
  });
30916
30969
  if (this.chartOptions.legend == 'shown') {
30917
- legend.node().clientHeight;
30918
30970
  //minSplitBysForFlexGrow: the minimum number of split bys for flex-grow to be triggered
30919
30971
  if (contentHeight < usableLegendHeight) {
30920
30972
  this.legendElement.classed("tsi-flexLegend", true);
30921
30973
  seriesLabelsEntered.each(function (d) {
30922
30974
  let heightPerSplitBy = self.getHeightPerSplitBy(d);
30923
- var minSplitByForFlexGrow = (prospectiveAggregateHeight - heightPerNameLabel) / heightPerSplitBy;
30975
+ var minSplitByForFlexGrow = (prospectiveAggregateHeight - LEGEND_CONSTANTS.NAME_LABEL_HEIGHT) / heightPerSplitBy;
30924
30976
  var splitBysCount = Object.keys(self.chartComponentData.displayState[String(select(this).data()[0])].splitBys).length;
30925
30977
  if (splitBysCount > minSplitByForFlexGrow) {
30926
30978
  select(this).style("flex-grow", 1);
@@ -30933,6 +30985,12 @@
30933
30985
  }
30934
30986
  seriesLabels.exit().remove();
30935
30987
  }
30988
+ destroy() {
30989
+ this.legendElement.remove();
30990
+ // Note: Virtual list cleanup will be added when virtual scrolling is implemented
30991
+ // this.virtualLists.forEach(list => list.destroy());
30992
+ // this.virtualLists.clear();
30993
+ }
30936
30994
  }
30937
30995
 
30938
30996
  class ChartComponentData {
@@ -36749,6 +36807,8 @@
36749
36807
  .append("text")
36750
36808
  .attr("class", (d) => `tsi-swimLaneLabel-${lane} tsi-swimLaneLabel ${onClickPresentAndValid(d) ? 'tsi-boldOnHover' : ''}`)
36751
36809
  .attr("role", "heading")
36810
+ .attr("aria-roledescription", this.getString("Swimlane label"))
36811
+ .attr("aria-label", d => d.label)
36752
36812
  .attr("aria-level", "3")
36753
36813
  .merge(label)
36754
36814
  .style("text-anchor", "middle")
@@ -37290,19 +37350,19 @@
37290
37350
  var momentExports = requireMoment();
37291
37351
  var moment = /*@__PURE__*/getDefaultExportFromCjs(momentExports);
37292
37352
 
37293
- var pikaday$1 = {exports: {}};
37353
+ var pikaday$2 = {exports: {}};
37294
37354
 
37295
37355
  /*!
37296
37356
  * Pikaday
37297
37357
  *
37298
37358
  * Copyright © 2014 David Bushell | BSD & MIT license | https://github.com/dbushell/Pikaday
37299
37359
  */
37300
- var pikaday = pikaday$1.exports;
37360
+ var pikaday$1 = pikaday$2.exports;
37301
37361
 
37302
37362
  var hasRequiredPikaday;
37303
37363
 
37304
37364
  function requirePikaday () {
37305
- if (hasRequiredPikaday) return pikaday$1.exports;
37365
+ if (hasRequiredPikaday) return pikaday$2.exports;
37306
37366
  hasRequiredPikaday = 1;
37307
37367
  (function (module, exports) {
37308
37368
  (function (root, factory) {
@@ -37317,7 +37377,7 @@
37317
37377
  }(typeof self !== 'undefined' ? self :
37318
37378
  typeof window !== 'undefined' ? window :
37319
37379
  typeof commonjsGlobal !== 'undefined' ? commonjsGlobal :
37320
- pikaday, function (moment) {
37380
+ pikaday$1, function (moment) {
37321
37381
 
37322
37382
  /**
37323
37383
  * feature detection and helper functions
@@ -38476,33 +38536,77 @@
38476
38536
 
38477
38537
  return Pikaday;
38478
38538
  }));
38479
- } (pikaday$1));
38480
- return pikaday$1.exports;
38539
+ } (pikaday$2));
38540
+ return pikaday$2.exports;
38481
38541
  }
38482
38542
 
38483
38543
  var pikadayExports = /*@__PURE__*/ requirePikaday();
38484
- var Pikaday = /*@__PURE__*/getDefaultExportFromCjs(pikadayExports);
38544
+ var pikaday = /*@__PURE__*/getDefaultExportFromCjs(pikadayExports);
38545
+
38546
+ var pikadayNamespace = /*#__PURE__*/_mergeNamespaces({
38547
+ __proto__: null,
38548
+ default: pikaday
38549
+ }, [pikadayExports]);
38485
38550
 
38486
38551
  // Ensure moment is available globally for Pikaday
38487
38552
  if (typeof window !== 'undefined') {
38488
38553
  window.moment = moment;
38489
38554
  }
38555
+ // Get Pikaday from the module or window
38556
+ // The UMD wrapper will execute and either:
38557
+ // 1. Return the constructor via module.exports (Rollup/CommonJS)
38558
+ // 2. Attach it to window.Pikaday (Browser/UMD)
38559
+ let Pikaday$1 = null;
38560
+ if (typeof window !== 'undefined') {
38561
+ // Check window first (UMD attached it there)
38562
+ if (window.Pikaday) {
38563
+ Pikaday$1 = window.Pikaday;
38564
+ }
38565
+ // Check if imported as default (CommonJS module.exports)
38566
+ else if (pikaday && typeof pikaday === 'function') {
38567
+ Pikaday$1 = pikaday;
38568
+ window.Pikaday = Pikaday$1;
38569
+ }
38570
+ // Check if it's the namespace itself (rare)
38571
+ else if (typeof pikadayNamespace === 'function') {
38572
+ Pikaday$1 = pikadayNamespace;
38573
+ window.Pikaday = Pikaday$1;
38574
+ }
38575
+ // Try any other property that might be the constructor
38576
+ else if (pikadayNamespace) {
38577
+ const possiblePikaday = Object.values(pikadayNamespace).find(val => typeof val === 'function');
38578
+ if (possiblePikaday) {
38579
+ Pikaday$1 = possiblePikaday;
38580
+ window.Pikaday = Pikaday$1;
38581
+ }
38582
+ }
38583
+ }
38490
38584
  // Export a function to safely create Pikaday instances
38491
38585
  function createPikaday(options) {
38492
38586
  if (typeof window === 'undefined') {
38493
38587
  console.warn('Pikaday requires a browser environment');
38494
38588
  return null;
38495
38589
  }
38496
- const Pikaday = window.Pikaday;
38497
- if (!Pikaday) {
38590
+ // Try multiple sources for Pikaday
38591
+ const PikadayConstructor = Pikaday$1 || window.Pikaday;
38592
+ if (!PikadayConstructor) {
38498
38593
  console.error('Pikaday not available. Make sure pikaday.js is loaded.');
38594
+ console.error('Failed to create Pikaday calendar. Check moment.js availability.');
38499
38595
  return null;
38500
38596
  }
38501
38597
  if (!moment || !window.moment) {
38502
38598
  console.error('Moment.js not available. Pikaday requires moment.js.');
38599
+ console.error('Failed to create Pikaday calendar. Check moment.js availability.');
38600
+ return null;
38601
+ }
38602
+ try {
38603
+ return new PikadayConstructor(options);
38604
+ }
38605
+ catch (error) {
38606
+ console.error('Failed to create Pikaday instance:', error);
38607
+ console.error('Failed to create Pikaday calendar. Check moment.js availability.');
38503
38608
  return null;
38504
38609
  }
38505
- return new Pikaday(options);
38506
38610
  }
38507
38611
 
38508
38612
  class DateTimePicker extends ChartComponent {
@@ -39077,7 +39181,30 @@
39077
39181
  this.pickerIsVisible = false;
39078
39182
  }
39079
39183
  buttonDateTimeFormat(millis) {
39080
- return Utils.timeFormat(!this.chartOptions.minutesForTimeLabels, !this.chartOptions.minutesForTimeLabels, this.chartOptions.offset, this.chartOptions.is24HourTime, 0, null, this.chartOptions.dateLocale)(millis);
39184
+ const date = new Date(millis);
39185
+ const locale = this.chartOptions.dateLocale || 'en-US';
39186
+ const is24Hour = this.chartOptions.is24HourTime !== false;
39187
+ const formatOptions = {
39188
+ year: 'numeric',
39189
+ month: '2-digit',
39190
+ day: '2-digit',
39191
+ hour: '2-digit',
39192
+ minute: '2-digit',
39193
+ second: '2-digit',
39194
+ hour12: !is24Hour
39195
+ };
39196
+ try {
39197
+ if (this.chartOptions.offset && this.chartOptions.offset !== 'Local') {
39198
+ formatOptions.timeZone = this.getTimezoneFromOffset(this.chartOptions.offset);
39199
+ }
39200
+ const baseFormat = date.toLocaleString(locale, formatOptions);
39201
+ const milliseconds = date.getMilliseconds().toString().padStart(3, '0');
39202
+ return `${baseFormat}.${milliseconds}`;
39203
+ }
39204
+ catch (error) {
39205
+ console.warn(`Failed to format date for locale ${locale}:`, error);
39206
+ return Utils.timeFormat(!this.chartOptions.minutesForTimeLabels, !this.chartOptions.minutesForTimeLabels, this.chartOptions.offset, this.chartOptions.is24HourTime, 0, null, this.chartOptions.dateLocale)(millis);
39207
+ }
39081
39208
  }
39082
39209
  render(chartOptions, minMillis, maxMillis, onSet = null) {
39083
39210
  this.chartOptions.setOptions(chartOptions);
@@ -39097,11 +39224,22 @@
39097
39224
  }
39098
39225
  super.themify(select(this.renderTarget), this.chartOptions.theme);
39099
39226
  }
39227
+ getTimezoneFromOffset(offset) {
39228
+ const timezoneMap = {
39229
+ 'UTC': 'UTC',
39230
+ 'EST': 'America/New_York',
39231
+ 'PST': 'America/Los_Angeles',
39232
+ 'CST': 'America/Chicago',
39233
+ 'MST': 'America/Denver'
39234
+ };
39235
+ return timezoneMap[offset] || 'UTC';
39236
+ }
39100
39237
  }
39101
39238
 
39102
39239
  class DateTimeButtonRange extends DateTimeButton {
39103
39240
  constructor(renderTarget) {
39104
39241
  super(renderTarget);
39242
+ this.clickOutsideHandler = null;
39105
39243
  }
39106
39244
  setButtonText(fromMillis, toMillis, isRelative, quickTime) {
39107
39245
  let fromString = this.buttonDateTimeFormat(fromMillis);
@@ -39121,10 +39259,38 @@
39121
39259
  onClose() {
39122
39260
  this.dateTimePickerContainer.style("display", "none");
39123
39261
  this.dateTimeButton.node().focus();
39262
+ this.removeClickOutsideHandler();
39263
+ }
39264
+ removeClickOutsideHandler() {
39265
+ if (this.clickOutsideHandler) {
39266
+ document.removeEventListener('click', this.clickOutsideHandler);
39267
+ this.clickOutsideHandler = null;
39268
+ }
39269
+ }
39270
+ setupClickOutsideHandler() {
39271
+ // Remove any existing handler first
39272
+ this.removeClickOutsideHandler();
39273
+ // Add handler after a small delay to prevent the opening click from immediately closing the picker
39274
+ setTimeout(() => {
39275
+ this.clickOutsideHandler = (event) => {
39276
+ const pickerElement = this.dateTimePickerContainer.node();
39277
+ const buttonElement = this.dateTimeButton.node();
39278
+ const target = event.target;
39279
+ // Check if click is outside both the picker and the button
39280
+ if (pickerElement && buttonElement &&
39281
+ !pickerElement.contains(target) &&
39282
+ !buttonElement.contains(target)) {
39283
+ this.onClose();
39284
+ }
39285
+ };
39286
+ document.addEventListener('click', this.clickOutsideHandler);
39287
+ }, 0);
39124
39288
  }
39125
39289
  render(chartOptions = {}, minMillis, maxMillis, fromMillis = null, toMillis = null, onSet = null, onCancel = null) {
39126
39290
  super.render(chartOptions, minMillis, maxMillis, onSet);
39127
- select(this.renderTarget).classed('tsi-dateTimeContainerRange', true);
39291
+ let container = select(this.renderTarget);
39292
+ container.classed('tsi-dateTimeContainerRange', true);
39293
+ container.style('position', 'relative');
39128
39294
  this.fromMillis = fromMillis;
39129
39295
  this.toMillis = toMillis;
39130
39296
  this.onCancel = onCancel ? onCancel : () => { };
@@ -39150,6 +39316,7 @@
39150
39316
  this.onClose();
39151
39317
  this.onCancel();
39152
39318
  });
39319
+ this.setupClickOutsideHandler();
39153
39320
  }
39154
39321
  });
39155
39322
  }
@@ -43455,7 +43622,7 @@
43455
43622
  super(renderTarget);
43456
43623
  this.chartOptions = new ChartOptions(); // TODO handle onkeyup and oninput in chart options
43457
43624
  }
43458
- render(environmentFqdn, getToken, chartOptions) {
43625
+ render(chartOptions) {
43459
43626
  this.chartOptions.setOptions(chartOptions);
43460
43627
  let targetElement = select(this.renderTarget);
43461
43628
  targetElement.html("");
@@ -43809,20 +43976,102 @@
43809
43976
  }
43810
43977
  }
43811
43978
 
43979
+ // Centralized renderer for the hierarchy tree. Keeps a stable D3 data-join and
43980
+ // updates existing DOM nodes instead of fully recreating them on each render.
43981
+ class TreeRenderer {
43982
+ static render(owner, data, target) {
43983
+ // Ensure an <ul> exists for this target (one list per level)
43984
+ let list = target.select('ul');
43985
+ if (list.empty()) {
43986
+ list = target.append('ul').attr('role', target === owner.hierarchyElem ? 'tree' : 'group');
43987
+ }
43988
+ const entries = Object.keys(data).map(k => ({ key: k, item: data[k] }));
43989
+ const liSelection = list.selectAll('li').data(entries, (d) => d && d.key);
43990
+ liSelection.exit().remove();
43991
+ const liEnter = liSelection.enter().append('li')
43992
+ .attr('role', 'none')
43993
+ .classed('tsi-leaf', (d) => !!d.item.isLeaf);
43994
+ const liMerged = liEnter.merge(liSelection);
43995
+ const setSize = entries.length;
43996
+ liMerged.each((d, i, nodes) => {
43997
+ const entry = d;
43998
+ const li = select(nodes[i]);
43999
+ if (owner.selectedIds && owner.selectedIds.includes(entry.item.id)) {
44000
+ li.classed('tsi-selected', true);
44001
+ }
44002
+ else {
44003
+ li.classed('tsi-selected', false);
44004
+ }
44005
+ // determine instance vs hierarchy node by presence of isLeaf flag
44006
+ const isInstance = !!entry.item.isLeaf;
44007
+ const nodeNameToCheckIfExists = isInstance ? owner.instanceNodeString(entry.item) : entry.key;
44008
+ const displayName = (entry.item && (entry.item.displayName || nodeNameToCheckIfExists)) || nodeNameToCheckIfExists;
44009
+ li.attr('data-display-name', displayName);
44010
+ let itemElem = li.select('.tsi-hierarchyItem');
44011
+ if (itemElem.empty()) {
44012
+ const newListElem = owner.createHierarchyItemElem(entry.item, entry.key);
44013
+ li.node().appendChild(newListElem.node());
44014
+ itemElem = li.select('.tsi-hierarchyItem');
44015
+ }
44016
+ itemElem.attr('aria-label', isInstance ? owner.getAriaLabel(entry.item) : entry.key);
44017
+ itemElem.attr('title', isInstance ? owner.getAriaLabel(entry.item) : entry.key);
44018
+ itemElem.attr('aria-expanded', String(entry.item.isExpanded));
44019
+ // accessibility: set treeitem level and position in set
44020
+ const ariaLevel = String(((entry.item && typeof entry.item.level === 'number') ? entry.item.level : 0) + 1);
44021
+ itemElem.attr('aria-level', ariaLevel);
44022
+ itemElem.attr('aria-posinset', String(i + 1));
44023
+ itemElem.attr('aria-setsize', String(setSize));
44024
+ if (!isInstance) {
44025
+ itemElem.select('.tsi-caret-icon').attr('style', `left: ${(entry.item.level) * 18 + 20}px`);
44026
+ itemElem.select('.tsi-name').text(entry.key);
44027
+ itemElem.select('.tsi-instanceCount').text(entry.item.cumulativeInstanceCount);
44028
+ }
44029
+ else {
44030
+ const nameSpan = itemElem.select('.tsi-name');
44031
+ nameSpan.html('');
44032
+ Utils.appendFormattedElementsFromString(nameSpan, owner.instanceNodeStringToDisplay(entry.item));
44033
+ }
44034
+ entry.item.node = li;
44035
+ if (entry.item.children) {
44036
+ entry.item.isExpanded = true;
44037
+ li.classed('tsi-expanded', true);
44038
+ // recurse using TreeRenderer to keep rendering logic centralized
44039
+ TreeRenderer.render(owner, entry.item.children, li);
44040
+ }
44041
+ else {
44042
+ li.classed('tsi-expanded', false);
44043
+ li.selectAll('ul').remove();
44044
+ }
44045
+ });
44046
+ }
44047
+ }
44048
+
43812
44049
  class HierarchyNavigation extends Component {
43813
44050
  constructor(renderTarget) {
43814
44051
  super(renderTarget);
43815
44052
  this.path = [];
44053
+ // debounce + request cancellation fields
44054
+ this.debounceTimer = null;
44055
+ this.debounceDelay = 250; // ms
44056
+ this.requestCounter = 0; // increments for each outgoing request
44057
+ this.latestRequestId = 0; // id of the most recent request
43816
44058
  //selectedIds
43817
44059
  this.selectedIds = [];
43818
44060
  this.searchEnabled = true;
43819
- this.renderSearchResult = (r, payload, target) => {
44061
+ this.autocompleteEnabled = true; // Enable/disable autocomplete suggestions
44062
+ // Search mode state
44063
+ this.isSearchMode = false;
44064
+ // Paths that should be auto-expanded (Set of path strings like "Factory North/Building A")
44065
+ this.pathsToAutoExpand = new Set();
44066
+ this.renderSearchResult = async (r, payload, target) => {
43820
44067
  const hierarchyData = r.hierarchyNodes?.hits?.length
43821
44068
  ? this.fillDataRecursively(r.hierarchyNodes, payload, payload)
43822
44069
  : {};
43823
44070
  const instancesData = r.instances?.hits?.length
43824
44071
  ? r.instances.hits.reduce((acc, i) => {
43825
- acc[this.instanceNodeIdentifier(i)] = new InstanceNode(i.timeSeriesId, i.name, payload.path.length - this.path.length, i.id, i.description);
44072
+ const inst = new InstanceNode(i.timeSeriesId, i.name, payload.path.length - this.path.length, i.id, i.description);
44073
+ inst.displayName = this.instanceNodeStringToDisplay(i) || '';
44074
+ acc[this.instanceNodeIdentifier(i)] = inst;
43826
44075
  return acc;
43827
44076
  }, {})
43828
44077
  : {};
@@ -43833,7 +44082,17 @@
43833
44082
  }
43834
44083
  hitCountElem.text(r.hierarchyNodes.hitCount);
43835
44084
  }
43836
- this.renderTree({ ...hierarchyData, ...instancesData }, target);
44085
+ const merged = { ...hierarchyData, ...instancesData };
44086
+ this.renderTree(merged, target);
44087
+ // Auto-expand nodes that should be expanded and load their children
44088
+ for (const key in hierarchyData) {
44089
+ const node = hierarchyData[key];
44090
+ if (node.isExpanded && !node.children) {
44091
+ // This node should be expanded but doesn't have children loaded yet
44092
+ // We need to trigger expansion after the node is rendered
44093
+ await this.autoExpandNode(node);
44094
+ }
44095
+ }
43837
44096
  };
43838
44097
  this.hierarchyNodeIdentifier = (hName) => {
43839
44098
  return hName ? hName : '(' + this.getString("Empty") + ')';
@@ -43855,12 +44114,20 @@
43855
44114
  const targetElement = select(this.renderTarget).text('');
43856
44115
  this.hierarchyNavWrapper = this.createHierarchyNavWrapper(targetElement);
43857
44116
  this.selectedIds = preselectedIds;
44117
+ // Allow disabling autocomplete via options
44118
+ if (hierarchyNavOptions.autocompleteEnabled !== undefined) {
44119
+ this.autocompleteEnabled = hierarchyNavOptions.autocompleteEnabled;
44120
+ }
44121
+ // Pre-compute paths that need to be auto-expanded for preselected instances
44122
+ if (preselectedIds && preselectedIds.length > 0) {
44123
+ await this.computePathsToAutoExpand(preselectedIds);
44124
+ }
43858
44125
  //render search wrapper
43859
- //this.renderSearchBox()
44126
+ this.renderSearchBox();
43860
44127
  super.themify(this.hierarchyNavWrapper, this.chartOptions.theme);
43861
44128
  const results = this.createResultsWrapper(this.hierarchyNavWrapper);
43862
44129
  this.hierarchyElem = this.createHierarchyElem(results);
43863
- this.pathSearchAndRenderResult({ search: { payload: this.requestPayload() }, render: { target: this.hierarchyElem } });
44130
+ await this.pathSearchAndRenderResult({ search: { payload: this.requestPayload() }, render: { target: this.hierarchyElem } });
43864
44131
  }
43865
44132
  createHierarchyNavWrapper(targetElement) {
43866
44133
  return targetElement.append('div').attr('class', 'tsi-hierarchy-nav-wrapper');
@@ -43868,8 +44135,129 @@
43868
44135
  createResultsWrapper(hierarchyNavWrapper) {
43869
44136
  return hierarchyNavWrapper.append('div').classed('tsi-hierarchy-or-list-wrapper', true);
43870
44137
  }
44138
+ // create hierarchy container and attach keyboard handler
43871
44139
  createHierarchyElem(results) {
43872
- return results.append('div').classed('tsi-hierarchy', true).attr("role", "navigation").on('scroll', () => { });
44140
+ const sel = results.append('div').classed('tsi-hierarchy', true).attr("role", "navigation").on('scroll', () => { });
44141
+ // attach keydown listener for keyboard navigation (delegated)
44142
+ // use native event to preserve focus handling
44143
+ const node = sel.node();
44144
+ if (node) {
44145
+ node.addEventListener('keydown', (ev) => this.onKeyDown(ev));
44146
+ }
44147
+ return sel;
44148
+ }
44149
+ // Keyboard navigation handlers and helpers
44150
+ onKeyDown(ev) {
44151
+ const key = ev.key;
44152
+ const active = document.activeElement;
44153
+ const container = this.hierarchyElem?.node();
44154
+ if (!container)
44155
+ return;
44156
+ const isInside = active && container.contains(active);
44157
+ if (!isInside && (key === 'ArrowDown' || key === 'ArrowUp')) {
44158
+ // focus first visible item on navigation keys
44159
+ const visible = this.getVisibleItemElems();
44160
+ if (visible.length) {
44161
+ this.focusItem(visible[0]);
44162
+ ev.preventDefault();
44163
+ }
44164
+ return;
44165
+ }
44166
+ if (!active)
44167
+ return;
44168
+ const current = active.classList && active.classList.contains('tsi-hierarchyItem') ? active : active.closest('.tsi-hierarchyItem');
44169
+ if (!current)
44170
+ return;
44171
+ switch (key) {
44172
+ case 'ArrowDown':
44173
+ this.focusNext(current);
44174
+ ev.preventDefault();
44175
+ break;
44176
+ case 'ArrowUp':
44177
+ this.focusPrev(current);
44178
+ ev.preventDefault();
44179
+ break;
44180
+ case 'ArrowRight':
44181
+ this.handleArrowRight(current);
44182
+ ev.preventDefault();
44183
+ break;
44184
+ case 'ArrowLeft':
44185
+ this.handleArrowLeft(current);
44186
+ ev.preventDefault();
44187
+ break;
44188
+ case 'Enter':
44189
+ case ' ':
44190
+ // activate (toggle expand or select)
44191
+ current.click();
44192
+ ev.preventDefault();
44193
+ break;
44194
+ }
44195
+ }
44196
+ getVisibleItemElems() {
44197
+ if (!this.hierarchyElem)
44198
+ return [];
44199
+ const root = this.hierarchyElem.node();
44200
+ if (!root)
44201
+ return [];
44202
+ const items = Array.from(root.querySelectorAll('.tsi-hierarchyItem'));
44203
+ return items.filter(i => i.offsetParent !== null && getComputedStyle(i).display !== 'none');
44204
+ }
44205
+ focusItem(elem) {
44206
+ if (!this.hierarchyElem)
44207
+ return;
44208
+ const root = this.hierarchyElem.node();
44209
+ if (!root)
44210
+ return;
44211
+ const items = Array.from(root.querySelectorAll('.tsi-hierarchyItem'));
44212
+ items.forEach(i => i.setAttribute('tabindex', '-1'));
44213
+ elem.setAttribute('tabindex', '0');
44214
+ elem.focus();
44215
+ }
44216
+ focusNext(current) {
44217
+ const visible = this.getVisibleItemElems();
44218
+ const idx = visible.indexOf(current);
44219
+ if (idx >= 0 && idx < visible.length - 1) {
44220
+ this.focusItem(visible[idx + 1]);
44221
+ }
44222
+ }
44223
+ focusPrev(current) {
44224
+ const visible = this.getVisibleItemElems();
44225
+ const idx = visible.indexOf(current);
44226
+ if (idx > 0) {
44227
+ this.focusItem(visible[idx - 1]);
44228
+ }
44229
+ }
44230
+ handleArrowRight(current) {
44231
+ const caret = current.querySelector('.tsi-caret-icon');
44232
+ const expanded = current.getAttribute('aria-expanded') === 'true';
44233
+ if (caret && !expanded) {
44234
+ // expand
44235
+ current.click();
44236
+ return;
44237
+ }
44238
+ // if already expanded, move to first child
44239
+ if (caret && expanded) {
44240
+ const li = current.closest('li');
44241
+ const childLi = li?.querySelector('ul > li');
44242
+ const childItem = childLi?.querySelector('.tsi-hierarchyItem');
44243
+ if (childItem)
44244
+ this.focusItem(childItem);
44245
+ }
44246
+ }
44247
+ handleArrowLeft(current) {
44248
+ const caret = current.querySelector('.tsi-caret-icon');
44249
+ const expanded = current.getAttribute('aria-expanded') === 'true';
44250
+ if (caret && expanded) {
44251
+ // collapse
44252
+ current.click();
44253
+ return;
44254
+ }
44255
+ // move focus to parent
44256
+ const li = current.closest('li');
44257
+ const parentLi = li?.parentElement?.closest('li');
44258
+ const parentItem = parentLi?.querySelector('.tsi-hierarchyItem');
44259
+ if (parentItem)
44260
+ this.focusItem(parentItem);
43873
44261
  }
43874
44262
  // prepares the parameters for search request
43875
44263
  requestPayload(hierarchy = null) {
@@ -43878,32 +44266,7 @@
43878
44266
  }
43879
44267
  // 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
43880
44268
  renderTree(data, target) {
43881
- let list = target.append('ul').attr("role", target === this.hierarchyElem ? "tree" : "group");
43882
- Object.keys(data).forEach(el => {
43883
- let nodeNameToCheckIfExists = data[el] instanceof InstanceNode ? this.instanceNodeString(data[el]) : el;
43884
- let li;
43885
- if (list.selectAll(".tsi-name").nodes().find(e => e.innerText === nodeNameToCheckIfExists)) {
43886
- li = null;
43887
- }
43888
- else {
43889
- li = list.append('li').classed('tsi-leaf', data[el].isLeaf);
43890
- //if the node is already selected, we want to highlight it
43891
- if (this.selectedIds && this.selectedIds.includes(data[el].id)) {
43892
- li.classed('tsi-selected', true);
43893
- }
43894
- }
43895
- if (!li)
43896
- return;
43897
- li.attr("role", "none");
43898
- let newListElem = this.createHierarchyItemElem(data[el], el);
43899
- li.node().appendChild(newListElem.node());
43900
- data[el].node = li;
43901
- if (data[el].children) {
43902
- data[el].isExpanded = true;
43903
- data[el].node.classed('tsi-expanded', true);
43904
- this.renderTree(data[el].children, data[el].node);
43905
- }
43906
- });
44269
+ TreeRenderer.render(this, data, target);
43907
44270
  }
43908
44271
  renderSearchBox() {
43909
44272
  this.searchWrapperElem = this.hierarchyNavWrapper.append('div').classed('tsi-hierarchy-search', true);
@@ -43912,40 +44275,140 @@
43912
44275
  let input = inputWrapper
43913
44276
  .append("input")
43914
44277
  .attr("class", "tsi-searchInput")
43915
- .attr("aria-label", this.getString("Search Time Series Instances"))
43916
- .attr("aria-describedby", "tsi-search-desc")
44278
+ .attr("aria-label", this.getString("Search"))
44279
+ .attr("aria-describedby", "tsi-hierarchy-search-desc")
43917
44280
  .attr("role", "combobox")
43918
44281
  .attr("aria-owns", "tsi-search-results")
43919
44282
  .attr("aria-expanded", "false")
43920
44283
  .attr("aria-haspopup", "listbox")
43921
- .attr("placeholder", this.getString("Search Time Series Instances") + "...");
44284
+ .attr("placeholder", this.getString("Search") + "...");
44285
+ // Add ARIA description for screen readers
44286
+ inputWrapper
44287
+ .append("span")
44288
+ .attr("id", "tsi-hierarchy-search-desc")
44289
+ .style("display", "none")
44290
+ .text(this.getString("Search suggestion instruction") || "Use arrow keys to navigate suggestions");
44291
+ // Add live region for search results info
44292
+ inputWrapper
44293
+ .append("div")
44294
+ .attr("class", "tsi-search-results-info")
44295
+ .attr("aria-live", "assertive");
44296
+ // Add clear button
44297
+ let clear = inputWrapper
44298
+ .append("div")
44299
+ .attr("class", "tsi-clear")
44300
+ .attr("tabindex", "0")
44301
+ .attr("role", "button")
44302
+ .attr("aria-label", "Clear Search")
44303
+ .on("click keydown", function (event) {
44304
+ if (Utils.isKeyDownAndNotEnter(event)) {
44305
+ return;
44306
+ }
44307
+ input.node().value = "";
44308
+ self.exitSearchMode();
44309
+ self.ap.close();
44310
+ select(this).classed("tsi-shown", false);
44311
+ });
44312
+ // Initialize Awesomplete for autocomplete (only if enabled)
44313
+ let Awesomplete = window.Awesomplete;
44314
+ if (this.autocompleteEnabled && Awesomplete) {
44315
+ this.ap = new Awesomplete(input.node(), {
44316
+ minChars: 1,
44317
+ maxItems: 10,
44318
+ autoFirst: true
44319
+ });
44320
+ }
44321
+ else {
44322
+ // Create a dummy object if autocomplete is disabled
44323
+ this.ap = {
44324
+ list: [],
44325
+ close: () => { },
44326
+ evaluate: () => { }
44327
+ };
44328
+ }
43922
44329
  let self = this;
44330
+ let noSuggest = false;
44331
+ let justAwesompleted = false;
44332
+ // Handle autocomplete selection (only if enabled)
44333
+ if (this.autocompleteEnabled) {
44334
+ input.node().addEventListener("awesomplete-selectcomplete", (event) => {
44335
+ noSuggest = true;
44336
+ const selectedValue = event.text.value;
44337
+ // Trigger search with selected value
44338
+ self.performDeepSearch(selectedValue);
44339
+ justAwesompleted = true;
44340
+ });
44341
+ }
43923
44342
  input.on("keydown", (event) => {
44343
+ // Handle ESC key to clear the search box
44344
+ if (event.key === 'Escape') {
44345
+ const inputElement = event.target;
44346
+ inputElement.value = '';
44347
+ // Trigger input event to clear search results
44348
+ self.exitSearchMode();
44349
+ self.ap.close();
44350
+ clear.classed("tsi-shown", false);
44351
+ return;
44352
+ }
43924
44353
  this.chartOptions.onKeydown(event, this.ap);
43925
44354
  });
43926
- var searchText;
44355
+ input.node().addEventListener("keyup", function (event) {
44356
+ if (justAwesompleted) {
44357
+ justAwesompleted = false;
44358
+ return;
44359
+ }
44360
+ let key = event.which || event.keyCode;
44361
+ if (key === 13) {
44362
+ noSuggest = true;
44363
+ }
44364
+ });
44365
+ // Debounced input handler to reduce work while typing
43927
44366
  input.on("input", function (event) {
43928
- searchText = event.target.value;
43929
- if (searchText.length === 0) {
43930
- //clear the tree
43931
- self.hierarchyElem.selectAll('ul').remove();
43932
- self.pathSearchAndRenderResult({ search: { payload: self.requestPayload() }, render: { target: self.hierarchyElem } });
44367
+ const val = event.target.value;
44368
+ // always clear existing timer
44369
+ if (self.debounceTimer) {
44370
+ clearTimeout(self.debounceTimer);
44371
+ self.debounceTimer = null;
44372
+ }
44373
+ // Show/hide clear button
44374
+ clear.classed("tsi-shown", val.length > 0);
44375
+ if (!val || val.length === 0) {
44376
+ // Exit search mode and restore navigation view
44377
+ self.exitSearchMode();
44378
+ self.ap.close();
44379
+ return;
44380
+ }
44381
+ // Populate autocomplete suggestions with instance leaves (only if enabled)
44382
+ if (self.autocompleteEnabled && !noSuggest && val.length >= 1) {
44383
+ self.fetchAutocompleteSuggestions(val);
43933
44384
  }
43934
44385
  else {
43935
- //filter the tree
43936
- self.filterTree(searchText);
44386
+ self.ap.close();
43937
44387
  }
44388
+ // Use deep search for comprehensive results
44389
+ self.debounceTimer = setTimeout(() => {
44390
+ self.performDeepSearch(val);
44391
+ }, self.debounceDelay);
44392
+ noSuggest = false;
43938
44393
  });
43939
44394
  }
43940
44395
  async pathSearchAndRenderResult({ search: { payload, bubbleUpReject = false }, render: { target, locInTarget = null } }) {
44396
+ const requestId = ++this.requestCounter;
44397
+ this.latestRequestId = requestId;
43941
44398
  try {
43942
44399
  const result = await this.searchFunction(payload);
44400
+ if (requestId !== this.latestRequestId) {
44401
+ return;
44402
+ }
43943
44403
  if (result.error) {
43944
44404
  throw result.error;
43945
44405
  }
43946
- this.renderSearchResult(result, payload, target);
44406
+ await this.renderSearchResult(result, payload, target);
43947
44407
  }
43948
44408
  catch (err) {
44409
+ if (requestId !== this.latestRequestId) {
44410
+ return;
44411
+ }
43949
44412
  this.chartOptions.onError("Error in hierarchy navigation", "Failed to complete search", err instanceof XMLHttpRequest ? err : null);
43950
44413
  if (bubbleUpReject) {
43951
44414
  throw err;
@@ -43953,11 +44416,18 @@
43953
44416
  }
43954
44417
  }
43955
44418
  filterTree(searchText) {
43956
- let tree = this.hierarchyElem.selectAll('ul').nodes()[0];
43957
- let list = tree.querySelectorAll('li');
44419
+ const nodes = this.hierarchyElem.selectAll('ul').nodes();
44420
+ if (!nodes || !nodes.length)
44421
+ return;
44422
+ const tree = nodes[0];
44423
+ if (!tree)
44424
+ return;
44425
+ const list = tree.querySelectorAll('li');
44426
+ const needle = searchText.toLowerCase();
43958
44427
  list.forEach((li) => {
43959
- let name = li.querySelector('.tsi-name').innerText;
43960
- if (name.toLowerCase().includes(searchText.toLowerCase())) {
44428
+ const attrName = li.getAttribute('data-display-name');
44429
+ let name = attrName && attrName.length ? attrName : (li.querySelector('.tsi-name')?.textContent || '');
44430
+ if (name.toLowerCase().includes(needle)) {
43961
44431
  li.style.display = 'block';
43962
44432
  }
43963
44433
  else {
@@ -43965,11 +44435,300 @@
43965
44435
  }
43966
44436
  });
43967
44437
  }
44438
+ // Fetch autocomplete suggestions for instances (leaves)
44439
+ async fetchAutocompleteSuggestions(searchText) {
44440
+ if (!searchText || searchText.length < 1) {
44441
+ this.ap.list = [];
44442
+ return;
44443
+ }
44444
+ try {
44445
+ // Call server search to get instance suggestions
44446
+ const payload = {
44447
+ path: this.path,
44448
+ searchTerm: searchText,
44449
+ recursive: true,
44450
+ includeInstances: true,
44451
+ // Limit results for autocomplete
44452
+ maxResults: 10
44453
+ };
44454
+ const results = await this.searchFunction(payload);
44455
+ if (results.error) {
44456
+ this.ap.list = [];
44457
+ return;
44458
+ }
44459
+ // Extract instance names for autocomplete suggestions
44460
+ const suggestions = [];
44461
+ if (results.instances?.hits) {
44462
+ results.instances.hits.forEach((i) => {
44463
+ const displayName = this.instanceNodeStringToDisplay(i);
44464
+ const pathStr = i.hierarchyPath && i.hierarchyPath.length > 0
44465
+ ? i.hierarchyPath.join(' > ') + ' > '
44466
+ : '';
44467
+ suggestions.push({
44468
+ label: pathStr + displayName,
44469
+ value: displayName
44470
+ });
44471
+ });
44472
+ }
44473
+ // Update Awesomplete list
44474
+ this.ap.list = suggestions;
44475
+ }
44476
+ catch (err) {
44477
+ // Silently fail for autocomplete - don't interrupt user experience
44478
+ this.ap.list = [];
44479
+ }
44480
+ }
44481
+ // Perform deep search across entire hierarchy using server-side search
44482
+ async performDeepSearch(searchText) {
44483
+ if (!searchText || searchText.length < 2) {
44484
+ this.exitSearchMode();
44485
+ return;
44486
+ }
44487
+ this.isSearchMode = true;
44488
+ const requestId = ++this.requestCounter;
44489
+ this.latestRequestId = requestId;
44490
+ try {
44491
+ // Call server search with recursive flag
44492
+ const payload = {
44493
+ path: this.path,
44494
+ searchTerm: searchText,
44495
+ recursive: true, // Search entire subtree
44496
+ includeInstances: true
44497
+ };
44498
+ const results = await this.searchFunction(payload);
44499
+ if (requestId !== this.latestRequestId)
44500
+ return; // Stale request
44501
+ if (results.error) {
44502
+ throw results.error;
44503
+ }
44504
+ // Render search results in flat list view
44505
+ this.renderSearchResults(results, searchText);
44506
+ }
44507
+ catch (err) {
44508
+ if (requestId !== this.latestRequestId)
44509
+ return;
44510
+ this.chartOptions.onError("Search failed", "Unable to search hierarchy", err instanceof XMLHttpRequest ? err : null);
44511
+ }
44512
+ }
44513
+ // Render search results with breadcrumb paths
44514
+ renderSearchResults(results, searchText) {
44515
+ this.hierarchyElem.selectAll('*').remove();
44516
+ const flatResults = [];
44517
+ // Flatten hierarchy results with full paths
44518
+ if (results.hierarchyNodes?.hits) {
44519
+ results.hierarchyNodes.hits.forEach((h) => {
44520
+ flatResults.push({
44521
+ type: 'hierarchy',
44522
+ name: h.name,
44523
+ path: h.path || [],
44524
+ id: h.id,
44525
+ cumulativeInstanceCount: h.cumulativeInstanceCount,
44526
+ highlightedName: this.highlightMatch(h.name, searchText),
44527
+ node: h
44528
+ });
44529
+ });
44530
+ }
44531
+ // Flatten instance results with full paths
44532
+ if (results.instances?.hits) {
44533
+ results.instances.hits.forEach((i) => {
44534
+ const displayName = this.instanceNodeStringToDisplay(i);
44535
+ flatResults.push({
44536
+ type: 'instance',
44537
+ name: i.name,
44538
+ path: i.hierarchyPath || [],
44539
+ id: i.id,
44540
+ timeSeriesId: i.timeSeriesId,
44541
+ description: i.description,
44542
+ highlightedName: this.highlightMatch(displayName, searchText),
44543
+ node: i
44544
+ });
44545
+ });
44546
+ }
44547
+ // Render flat list with breadcrumbs
44548
+ const searchList = this.hierarchyElem
44549
+ .append('div')
44550
+ .classed('tsi-search-results', true);
44551
+ if (flatResults.length === 0) {
44552
+ searchList.append('div')
44553
+ .classed('tsi-noResults', true)
44554
+ .text(this.getString('No results'));
44555
+ return;
44556
+ }
44557
+ searchList.append('div')
44558
+ .classed('tsi-search-results-header', true)
44559
+ .html(`<strong>${flatResults.length}</strong> ${this.getString('results found') || 'results found'}`);
44560
+ const resultItems = searchList.selectAll('.tsi-search-result-item')
44561
+ .data(flatResults)
44562
+ .enter()
44563
+ .append('div')
44564
+ .classed('tsi-search-result-item', true)
44565
+ .attr('tabindex', '0')
44566
+ .attr('role', 'option')
44567
+ .attr('aria-label', (d) => {
44568
+ const pathStr = d.path.length > 0 ? d.path.join(' > ') + ' > ' : '';
44569
+ return pathStr + d.name;
44570
+ });
44571
+ const self = this;
44572
+ resultItems.each(function (d) {
44573
+ const item = select(this);
44574
+ // Breadcrumb path
44575
+ if (d.path.length > 0) {
44576
+ item.append('div')
44577
+ .classed('tsi-search-breadcrumb', true)
44578
+ .text(d.path.join(' > '));
44579
+ }
44580
+ // Highlighted name
44581
+ item.append('div')
44582
+ .classed('tsi-search-result-name', true)
44583
+ .html(d.highlightedName);
44584
+ // Instance description or count
44585
+ if (d.type === 'instance' && d.description) {
44586
+ item.append('div')
44587
+ .classed('tsi-search-result-description', true)
44588
+ .text(d.description);
44589
+ }
44590
+ else if (d.type === 'hierarchy') {
44591
+ item.append('div')
44592
+ .classed('tsi-search-result-count', true)
44593
+ .text(`${d.cumulativeInstanceCount || 0} instances`);
44594
+ }
44595
+ });
44596
+ // Click handlers
44597
+ resultItems.on('click keydown', function (event, d) {
44598
+ if (Utils.isKeyDownAndNotEnter(event))
44599
+ return;
44600
+ if (d.type === 'instance') {
44601
+ // Handle instance selection
44602
+ if (self.chartOptions.onInstanceClick) {
44603
+ const inst = new InstanceNode(d.timeSeriesId, d.name, d.path.length, d.id, d.description);
44604
+ // Update selection state
44605
+ if (self.selectedIds && self.selectedIds.includes(d.id)) {
44606
+ self.selectedIds = self.selectedIds.filter(id => id !== d.id);
44607
+ select(this).classed('tsi-selected', false);
44608
+ }
44609
+ else {
44610
+ self.selectedIds.push(d.id);
44611
+ select(this).classed('tsi-selected', true);
44612
+ }
44613
+ self.chartOptions.onInstanceClick(inst);
44614
+ }
44615
+ }
44616
+ else {
44617
+ // Navigate to hierarchy node - exit search and expand to that path
44618
+ self.navigateToPath(d.path);
44619
+ }
44620
+ });
44621
+ // Apply selection state to already-selected instances
44622
+ resultItems.each(function (d) {
44623
+ if (d.type === 'instance' && self.selectedIds && self.selectedIds.includes(d.id)) {
44624
+ select(this).classed('tsi-selected', true);
44625
+ }
44626
+ });
44627
+ }
44628
+ // Exit search mode and restore tree
44629
+ exitSearchMode() {
44630
+ this.isSearchMode = false;
44631
+ this.hierarchyElem.selectAll('*').remove();
44632
+ this.pathSearchAndRenderResult({
44633
+ search: { payload: this.requestPayload() },
44634
+ render: { target: this.hierarchyElem }
44635
+ });
44636
+ }
44637
+ // Navigate to a specific path in the hierarchy
44638
+ async navigateToPath(targetPath) {
44639
+ this.exitSearchMode();
44640
+ // For now, just exit search mode and return to root
44641
+ // In a more advanced implementation, this would progressively
44642
+ // expand nodes along the path to reveal the target
44643
+ // This would require waiting for each level to load before expanding the next
44644
+ }
44645
+ // Pre-compute which paths need to be auto-expanded for preselected instances
44646
+ async computePathsToAutoExpand(instanceIds) {
44647
+ if (!instanceIds || instanceIds.length === 0) {
44648
+ return;
44649
+ }
44650
+ // console.log('[HierarchyNavigation] Computing paths to auto-expand for:', instanceIds);
44651
+ try {
44652
+ this.pathsToAutoExpand.clear();
44653
+ for (const instanceId of instanceIds) {
44654
+ // Search for this specific instance
44655
+ const result = await this.searchFunction({
44656
+ path: this.path,
44657
+ searchTerm: instanceId,
44658
+ recursive: true,
44659
+ includeInstances: true
44660
+ });
44661
+ if (result?.instances?.hits) {
44662
+ for (const instance of result.instances.hits) {
44663
+ // Match by ID
44664
+ if (instance.id === instanceId ||
44665
+ (instance.id && instance.id.includes(instanceId))) {
44666
+ if (instance.hierarchyPath && instance.hierarchyPath.length > 0) {
44667
+ // Add all parent paths that need to be expanded
44668
+ const hierarchyPath = instance.hierarchyPath;
44669
+ for (let i = 1; i <= hierarchyPath.length; i++) {
44670
+ const pathArray = hierarchyPath.slice(0, i);
44671
+ const pathKey = pathArray.join('/');
44672
+ this.pathsToAutoExpand.add(pathKey);
44673
+ }
44674
+ }
44675
+ }
44676
+ }
44677
+ }
44678
+ }
44679
+ // console.log('[HierarchyNavigation] Paths to auto-expand:', Array.from(this.pathsToAutoExpand));
44680
+ }
44681
+ catch (err) {
44682
+ console.warn('Failed to compute paths to auto-expand:', err);
44683
+ }
44684
+ }
44685
+ // Check if a path should be auto-expanded
44686
+ shouldAutoExpand(pathArray) {
44687
+ if (this.pathsToAutoExpand.size === 0) {
44688
+ return false;
44689
+ }
44690
+ const pathKey = pathArray.join('/');
44691
+ return this.pathsToAutoExpand.has(pathKey);
44692
+ }
44693
+ // Auto-expand a node by triggering its expand function
44694
+ async autoExpandNode(node) {
44695
+ if (!node || !node.expand || !node.node) {
44696
+ return;
44697
+ }
44698
+ try {
44699
+ // Wait for the DOM node to be available
44700
+ await new Promise(resolve => setTimeout(resolve, 10));
44701
+ // Mark as expanded visually
44702
+ node.node.classed('tsi-expanded', true);
44703
+ // Call the expand function to load children
44704
+ await node.expand();
44705
+ // console.log(`[HierarchyNavigation] Auto-expanded node: ${node.path.join('/')}`);
44706
+ }
44707
+ catch (err) {
44708
+ console.warn(`Failed to auto-expand node ${node.path.join('/')}:`, err);
44709
+ }
44710
+ }
44711
+ // Highlight search term in text
44712
+ highlightMatch(text, searchTerm) {
44713
+ if (!text)
44714
+ return '';
44715
+ const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44716
+ const regex = new RegExp(`(${escapedTerm})`, 'gi');
44717
+ return text.replace(regex, '<mark>$1</mark>');
44718
+ }
43968
44719
  // creates in-depth data object using the server response for hierarchyNodes to show in the tree all expanded, considering UntilChildren
43969
44720
  fillDataRecursively(hierarchyNodes, payload, payloadForContinuation = null) {
43970
44721
  let data = {};
43971
44722
  hierarchyNodes.hits.forEach((h) => {
43972
44723
  let hierarchy = new HierarchyNode(h.name, payload.path, payload.path.length - this.path.length, h.cumulativeInstanceCount, h.id);
44724
+ // cache display name on node for client-side filtering
44725
+ hierarchy.displayName = h.name || '';
44726
+ // Check if this path should be auto-expanded
44727
+ const shouldExpand = this.shouldAutoExpand(hierarchy.path);
44728
+ if (shouldExpand) {
44729
+ hierarchy.isExpanded = true;
44730
+ //console.log(`[HierarchyNavigation] Auto-expanding node: ${hierarchy.path.join('/')}`);
44731
+ }
43973
44732
  hierarchy.expand = () => {
43974
44733
  hierarchy.isExpanded = true;
43975
44734
  hierarchy.node.classed('tsi-expanded', true);
@@ -43993,7 +44752,7 @@
43993
44752
  .attr('style', `padding-left: ${hORi.isLeaf ? hORi.level * 18 + 20 : (hORi.level + 1) * 18 + 20}px`)
43994
44753
  .attr('tabindex', 0)
43995
44754
  //.attr('arialabel', isHierarchyNode ? key : Utils.getTimeSeriesIdString(hORi))
43996
- .attr('arialabel', isHierarchyNode ? key : self.getAriaLabel(hORi))
44755
+ .attr('aria-label', isHierarchyNode ? key : self.getAriaLabel(hORi))
43997
44756
  .attr('title', isHierarchyNode ? key : self.getAriaLabel(hORi))
43998
44757
  .attr("role", "treeitem").attr('aria-expanded', hORi.isExpanded)
43999
44758
  .on('click keydown', async function (event) {
@@ -44045,6 +44804,8 @@
44045
44804
  return hORi.description || hORi.name || hORi.id || Utils.getTimeSeriesIdString(hORi);
44046
44805
  }
44047
44806
  }
44807
+ // TreeRenderer has been moved to its own module: ./TreeRenderer
44808
+ // The rendering logic was extracted to reduce file size and improve testability.
44048
44809
  class HierarchyNode {
44049
44810
  constructor(name, parentPath, level, cumulativeInstanceCount = null, id = null) {
44050
44811
  this.name = name;
@@ -44289,6 +45050,7 @@
44289
45050
  class DateTimeButtonSingle extends DateTimeButton {
44290
45051
  constructor(renderTarget) {
44291
45052
  super(renderTarget);
45053
+ this.clickOutsideHandler = null;
44292
45054
  this.sDTPOnSet = (millis = null) => {
44293
45055
  if (millis !== null) {
44294
45056
  this.dateTimeButton.text(this.buttonDateTimeFormat(millis));
@@ -44301,6 +45063,32 @@
44301
45063
  closeSDTP() {
44302
45064
  this.dateTimePickerContainer.style("display", "none");
44303
45065
  this.dateTimeButton.node().focus();
45066
+ this.removeClickOutsideHandler();
45067
+ }
45068
+ removeClickOutsideHandler() {
45069
+ if (this.clickOutsideHandler) {
45070
+ document.removeEventListener('click', this.clickOutsideHandler);
45071
+ this.clickOutsideHandler = null;
45072
+ }
45073
+ }
45074
+ setupClickOutsideHandler() {
45075
+ // Remove any existing handler first
45076
+ this.removeClickOutsideHandler();
45077
+ // Add handler after a small delay to prevent the opening click from immediately closing the picker
45078
+ setTimeout(() => {
45079
+ this.clickOutsideHandler = (event) => {
45080
+ const pickerElement = this.dateTimePickerContainer.node();
45081
+ const buttonElement = this.dateTimeButton.node();
45082
+ const target = event.target;
45083
+ // Check if click is outside both the picker and the button
45084
+ if (pickerElement && buttonElement &&
45085
+ !pickerElement.contains(target) &&
45086
+ !buttonElement.contains(target)) {
45087
+ this.closeSDTP();
45088
+ }
45089
+ };
45090
+ document.addEventListener('click', this.clickOutsideHandler);
45091
+ }, 0);
44304
45092
  }
44305
45093
  render(chartOptions = {}, minMillis, maxMillis, selectedMillis = null, onSet = null) {
44306
45094
  super.render(chartOptions, minMillis, maxMillis, onSet);
@@ -44310,12 +45098,11 @@
44310
45098
  if (!this.dateTimePicker) {
44311
45099
  this.dateTimePicker = new SingleDateTimePicker(this.dateTimePickerContainer.node());
44312
45100
  }
44313
- let targetElement = select(this.renderTarget);
44314
- (targetElement.select(".tsi-dateTimePickerContainer")).selectAll("*");
44315
45101
  this.dateTimeButton.on("click", () => {
44316
45102
  this.chartOptions.dTPIsModal = true;
44317
45103
  this.dateTimePickerContainer.style("display", "block");
44318
45104
  this.dateTimePicker.render(this.chartOptions, this.minMillis, this.maxMillis, this.selectedMillis, this.sDTPOnSet);
45105
+ this.setupClickOutsideHandler();
44319
45106
  });
44320
45107
  }
44321
45108
  }
@@ -44613,8 +45400,16 @@
44613
45400
  class PlaybackControls extends Component {
44614
45401
  constructor(renderTarget, initialTimeStamp = null) {
44615
45402
  super(renderTarget);
44616
- this.handleRadius = 7;
44617
- this.minimumPlaybackInterval = 1000; // 1 second
45403
+ this.playbackInterval = null;
45404
+ this.playButton = null;
45405
+ this.handleElement = null;
45406
+ this.controlsContainer = null;
45407
+ this.track = null;
45408
+ this.selectTimeStampCallback = null;
45409
+ this.wasPlayingWhenDragStarted = false;
45410
+ this.rafId = null;
45411
+ this.handleRadius = PlaybackControls.CONSTANTS.HANDLE_RADIUS;
45412
+ this.minimumPlaybackInterval = PlaybackControls.CONSTANTS.MINIMUM_PLAYBACK_INTERVAL_MS;
44618
45413
  this.playbackInterval = null;
44619
45414
  this.selectedTimeStamp = initialTimeStamp;
44620
45415
  }
@@ -44622,6 +45417,21 @@
44622
45417
  return this.selectedTimeStamp;
44623
45418
  }
44624
45419
  render(start, end, onSelectTimeStamp, options, playbackSettings) {
45420
+ // Validate inputs
45421
+ if (!(start instanceof Date) || !(end instanceof Date)) {
45422
+ throw new TypeError('start and end must be Date objects');
45423
+ }
45424
+ if (start >= end) {
45425
+ throw new RangeError('start must be before end');
45426
+ }
45427
+ if (!onSelectTimeStamp || typeof onSelectTimeStamp !== 'function') {
45428
+ throw new TypeError('onSelectTimeStamp must be a function');
45429
+ }
45430
+ // Clean up any pending animation frames before re-rendering
45431
+ if (this.rafId !== null) {
45432
+ cancelAnimationFrame(this.rafId);
45433
+ this.rafId = null;
45434
+ }
44625
45435
  this.end = end;
44626
45436
  this.selectTimeStampCallback = onSelectTimeStamp;
44627
45437
  this.chartOptions.setOptions(options);
@@ -44683,6 +45493,9 @@
44683
45493
  this.playButton = this.controlsContainer.append('button')
44684
45494
  .classed('tsi-play-button', this.playbackInterval === null)
44685
45495
  .classed('tsi-pause-button', this.playbackInterval !== null)
45496
+ // Accessibility attributes
45497
+ .attr('aria-label', 'Play/Pause playback')
45498
+ .attr('title', 'Play/Pause playback')
44686
45499
  .on('click', () => {
44687
45500
  if (this.playbackInterval === null) {
44688
45501
  this.play();
@@ -44734,6 +45547,27 @@
44734
45547
  this.updateSelection(handlePosition, this.selectedTimeStamp);
44735
45548
  this.selectTimeStampCallback(this.selectedTimeStamp);
44736
45549
  }
45550
+ /**
45551
+ * Cleanup resources to prevent memory leaks
45552
+ */
45553
+ destroy() {
45554
+ this.pause();
45555
+ // Cancel any pending animation frames
45556
+ if (this.rafId !== null) {
45557
+ cancelAnimationFrame(this.rafId);
45558
+ this.rafId = null;
45559
+ }
45560
+ // Remove event listeners
45561
+ if (this.controlsContainer) {
45562
+ this.controlsContainer.selectAll('*').on('.', null);
45563
+ }
45564
+ // Clear DOM references
45565
+ this.playButton = null;
45566
+ this.handleElement = null;
45567
+ this.controlsContainer = null;
45568
+ this.track = null;
45569
+ this.selectTimeStampCallback = null;
45570
+ }
44737
45571
  clamp(number, min, max) {
44738
45572
  let clamped = Math.max(number, min);
44739
45573
  return Math.min(clamped, max);
@@ -44742,9 +45576,17 @@
44742
45576
  this.wasPlayingWhenDragStarted = this.wasPlayingWhenDragStarted ||
44743
45577
  (this.playbackInterval !== null);
44744
45578
  this.pause();
44745
- let handlePosition = this.clamp(positionX, 0, this.trackWidth);
44746
- this.selectedTimeStamp = this.timeStampToPosition.invert(handlePosition);
44747
- this.updateSelection(handlePosition, this.selectedTimeStamp);
45579
+ // Use requestAnimationFrame to batch DOM updates for better performance
45580
+ // Cancel any pending animation frame to prevent stacking updates
45581
+ if (this.rafId !== null) {
45582
+ cancelAnimationFrame(this.rafId);
45583
+ }
45584
+ this.rafId = requestAnimationFrame(() => {
45585
+ const handlePosition = this.clamp(positionX, 0, this.trackWidth);
45586
+ this.selectedTimeStamp = this.timeStampToPosition.invert(handlePosition);
45587
+ this.updateSelection(handlePosition, this.selectedTimeStamp);
45588
+ this.rafId = null;
45589
+ });
44748
45590
  }
44749
45591
  onDragEnd() {
44750
45592
  this.selectTimeStampCallback(this.selectedTimeStamp);
@@ -44767,6 +45609,12 @@
44767
45609
  .text(this.timeFormatter(timeStamp));
44768
45610
  }
44769
45611
  }
45612
+ PlaybackControls.CONSTANTS = {
45613
+ HANDLE_RADIUS: 7,
45614
+ MINIMUM_PLAYBACK_INTERVAL_MS: 1000,
45615
+ HANDLE_PADDING: 8,
45616
+ AXIS_OFFSET: 6,
45617
+ };
44770
45618
  class TimeAxis extends TemporalXAxisComponent {
44771
45619
  constructor(renderTarget) {
44772
45620
  super(renderTarget);