@acorex/charts 21.0.0-next.13 → 21.0.0-next.15

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.
@@ -1,4 +1,4 @@
1
- import { AXChartComponent, AX_CHART_COLOR_PALETTE, getEasingFunction, getChartColor } from '@acorex/charts';
1
+ import { AXChartComponent, AX_CHART_COLOR_PALETTE, formatLargeNumber, getEasingFunction, getChartColor } from '@acorex/charts';
2
2
  import { AXChartTooltipComponent } from '@acorex/charts/chart-tooltip';
3
3
  import * as i0 from '@angular/core';
4
4
  import { InjectionToken, input, output, viewChild, signal, inject, computed, afterNextRender, effect, ChangeDetectionStrategy, ViewEncapsulation, Component } from '@angular/core';
@@ -52,7 +52,7 @@ class AXBarChartComponent extends AXChartComponent {
52
52
  chartContainerEl = viewChild.required('chartContainer');
53
53
  // D3 reference - loaded asynchronously
54
54
  d3;
55
- // Chart elements
55
+ // Chart elements - using proper D3 types
56
56
  svg;
57
57
  chart;
58
58
  xScale;
@@ -64,10 +64,42 @@ class AXBarChartComponent extends AXChartComponent {
64
64
  width;
65
65
  height;
66
66
  margin = { top: 0, right: 0, bottom: 0, left: 0 };
67
- // Character width ratio heuristic for mixed Latin/Persian texts
67
+ // Constants for layout calculations
68
68
  CHAR_WIDTH_RATIO = 0.65;
69
- // Tooltip gap in pixels for consistent spacing
70
69
  TOOLTIP_GAP = 10;
70
+ AXIS_TICK_PADDING = 10;
71
+ Y_AXIS_TITLE_THICKNESS = 14;
72
+ Y_AXIS_TITLE_PADDING = 14;
73
+ X_AXIS_TITLE_FONT_SIZE = 14;
74
+ X_AXIS_TITLE_GAP = 14;
75
+ X_AXIS_TITLE_BLOCK_HEIGHT = 24;
76
+ TOP_EDGE_PADDING = 6;
77
+ FALLBACK_PLOT_HEIGHT = 240;
78
+ MIN_PLOT_HEIGHT = 120;
79
+ MIN_TOTAL_WIDTH = 200;
80
+ OUTER_PADDING_CLUSTERED = 0.2;
81
+ INNER_PADDING_CLUSTERED = 0.1;
82
+ MIN_PADDING = 0.1;
83
+ HOVER_TRANSITION_DURATION = 150;
84
+ DEFAULT_ANIMATION_DELAY = 50;
85
+ LABEL_ANIMATION_DELAY = 100;
86
+ MIN_TOP_SPACE_FOR_LABEL = 20;
87
+ LABEL_OFFSET = 8;
88
+ ROTATION_TOLERANCE_SMALL_DATASET = 1.4;
89
+ SMALL_DATASET_THRESHOLD = 6;
90
+ Y_AXIS_PADDING = 20;
91
+ TICK_AREA_PADDING = 6;
92
+ TICK_AREA_GAP = 8;
93
+ DEFAULT_FONT_SIZE = 13;
94
+ FONT_WIDTH_MULTIPLIER = 0.6;
95
+ FONT_PADDING = 8;
96
+ MAX_LABEL_LENGTH = 20;
97
+ MIN_FONT_SIZE_X_AXIS = 8;
98
+ MAX_FONT_SIZE_X_AXIS = 14;
99
+ MIN_FONT_SIZE_Y_AXIS = 9;
100
+ MAX_FONT_SIZE_Y_AXIS = 15;
101
+ MANY_ITEMS_THRESHOLD = 20;
102
+ VERY_MANY_ITEMS_THRESHOLD = 50;
71
103
  // Animation state
72
104
  _initialAnimationComplete = signal(false, ...(ngDevMode ? [{ debugName: "_initialAnimationComplete" }] : []));
73
105
  // Tooltip state
@@ -114,17 +146,39 @@ class AXBarChartComponent extends AXChartComponent {
114
146
  ...this.effectiveOptions().messages,
115
147
  };
116
148
  }, ...(ngDevMode ? [{ debugName: "effectiveMessages" }] : []));
117
- // Track hidden bars
149
+ // Track hidden bars and series
118
150
  hiddenBars = new Set();
119
- // Track hidden series for clustered charts (by series label)
120
151
  hiddenSeries = new Set();
121
- // Helpers
152
+ // Computed values for better performance
153
+ isClusteredMode = computed(() => {
154
+ const value = this.data();
155
+ return Array.isArray(value) && value.length > 0 && 'chartData' in value[0];
156
+ }, ...(ngDevMode ? [{ debugName: "isClusteredMode" }] : []));
157
+ visibleData = computed(() => {
158
+ const value = this.data() || [];
159
+ if (this.isClusteredMode()) {
160
+ return value;
161
+ }
162
+ return value.filter((d) => !this.hiddenBars.has(d.id));
163
+ }, ...(ngDevMode ? [{ debugName: "visibleData" }] : []));
164
+ hasVisibleData = computed(() => {
165
+ const value = this.data();
166
+ if (!Array.isArray(value) || value.length === 0)
167
+ return false;
168
+ if (this.isClusteredMode()) {
169
+ const groups = value;
170
+ const seriesLabels = this.getClusterSeriesLabels(groups);
171
+ const visibleSeries = seriesLabels.filter((s) => !this.hiddenSeries.has(s));
172
+ return groups.some((g) => visibleSeries.some((_, idx) => !!g.chartData[idx]));
173
+ }
174
+ return value.some((d) => !this.hiddenBars.has(d.id));
175
+ }, ...(ngDevMode ? [{ debugName: "hasVisibleData" }] : []));
176
+ // Type guard helper
122
177
  isClusteredData(value) {
123
- return Array.isArray(value) && value.length > 0 && value[0] && 'chartData' in value[0];
178
+ return Array.isArray(value) && value.length > 0 && 'chartData' in value[0];
124
179
  }
125
180
  getClusterSeriesLabels(groups) {
126
- const first = groups[0]?.chartData ?? [];
127
- return first.map((d) => d.label);
181
+ return groups[0]?.chartData?.map((d) => d.label) ?? [];
128
182
  }
129
183
  constructor() {
130
184
  super();
@@ -177,25 +231,14 @@ class AXBarChartComponent extends AXChartComponent {
177
231
  const containerElement = this.chartContainerEl().nativeElement;
178
232
  const inputValue = (this.data() || []);
179
233
  const isClustered = this.isClusteredData(inputValue);
180
- // Visibility calculation
181
- let hasAnyVisible = false;
182
- let visibleSingleData = [];
183
- let groupedData = [];
184
- if (isClustered) {
185
- groupedData = inputValue;
186
- const seriesLabels = groupedData.length > 0 ? this.getClusterSeriesLabels(groupedData) : [];
187
- const visibleSeries = seriesLabels.filter((s) => !this.hiddenSeries.has(s));
188
- hasAnyVisible = groupedData.some((g) => visibleSeries.some((_, idx) => !!g.chartData[idx]));
189
- }
190
- else {
191
- const single = inputValue || [];
192
- visibleSingleData = single.filter((d) => !this.isBarHidden(d.id));
193
- hasAnyVisible = visibleSingleData.length > 0;
194
- }
234
+ // Use computed visibility
235
+ const hasAnyVisible = this.hasVisibleData();
236
+ const visibleSingleData = isClustered ? [] : this.visibleData();
237
+ const groupedData = isClustered ? inputValue : [];
195
238
  // Clear existing chart SVG and messages (do not remove tooltip component)
196
239
  this.d3.select(containerElement).selectAll('svg, .ax-chart-message-container').remove();
197
240
  // Early return if no data
198
- if (!inputValue.length) {
241
+ if (!Array.isArray(inputValue) || inputValue.length === 0) {
199
242
  this.showNoDataMessage(containerElement);
200
243
  return;
201
244
  }
@@ -234,8 +277,8 @@ class AXBarChartComponent extends AXChartComponent {
234
277
  // Only remove chart SVG and message containers to preserve tooltip component
235
278
  this.d3?.select(container).selectAll('svg, .ax-chart-message-container').remove();
236
279
  }
237
- this.svg = null;
238
- this.chart = null;
280
+ this.svg = undefined;
281
+ this.chart = undefined;
239
282
  this._tooltipVisible.set(false);
240
283
  }
241
284
  /**
@@ -251,11 +294,10 @@ class AXBarChartComponent extends AXChartComponent {
251
294
  const showXAxis = options.showXAxis !== false;
252
295
  const showYAxis = options.showYAxis !== false;
253
296
  // Internal left padding for Y-axis ticks/labels/title
254
- const axisAndTickPadding = 10;
255
- const yAxisTitleThickness = options.yAxisLabel ? 14 : 0; // rotated label thickness along X
256
- const yAxisTitlePadding = options.yAxisLabel ? 14 : 0; // gap between ticks and title
297
+ const yAxisTitleThickness = options.yAxisLabel ? this.Y_AXIS_TITLE_THICKNESS : 0;
298
+ const yAxisTitlePadding = options.yAxisLabel ? this.Y_AXIS_TITLE_PADDING : 0;
257
299
  const leftPaddingForYAxis = showYAxis
258
- ? this.calculateMaxYAxisTickLabelWidth() + axisAndTickPadding + yAxisTitlePadding + yAxisTitleThickness
300
+ ? this.calculateMaxYAxisTickLabelWidth() + this.AXIS_TICK_PADDING + yAxisTitlePadding + yAxisTitleThickness
259
301
  : 0;
260
302
  // Estimate internal bottom padding for X-axis ticks/labels/title
261
303
  let bottomPaddingForXAxis = 0;
@@ -274,41 +316,29 @@ class AXBarChartComponent extends AXChartComponent {
274
316
  const baseWidth = options.width ?? containerWidth;
275
317
  const workingWidth = Math.max(1, baseWidth - leftPaddingForYAxis);
276
318
  const dynamicFontSize = Math.max(10, Math.min(14, Math.round(workingWidth / 50)));
277
- const estimatedLongestLabelWidth = longestLabel.length * dynamicFontSize * 0.65;
319
+ // Use truncated label length for calculation (same logic as actual rendering)
320
+ const maxLabelLength = barCount > this.MANY_ITEMS_THRESHOLD ? 10 : this.MAX_LABEL_LENGTH;
321
+ const effectiveLabelLength = Math.min(longestLabel.length, maxLabelLength);
322
+ const estimatedLongestLabelWidth = effectiveLabelLength * dynamicFontSize * 0.65;
278
323
  const availableWidthPerBar = barCount > 0 ? workingWidth / barCount : workingWidth;
279
- const willRotate = options.rotateXAxisLabels === true
280
- ? true
281
- : options.rotateXAxisLabels === false
282
- ? false
283
- : estimatedLongestLabelWidth > availableWidthPerBar;
324
+ const rotateOption = typeof options.rotateXAxisLabels === 'boolean' ? options.rotateXAxisLabels : undefined;
325
+ const willRotate = this.shouldLabelsRotate(rotateOption, estimatedLongestLabelWidth, availableWidthPerBar);
284
326
  const tickAreaHeight = willRotate
285
- ? estimatedLongestLabelWidth * Math.SQRT1_2 + dynamicFontSize * Math.SQRT1_2 + 6
286
- : dynamicFontSize + 8;
287
- const gapBelowTicks = options.xAxisLabel && !willRotate ? 14 : 0; // matches renderer
288
- const titleBlockHeight = options.xAxisLabel && !willRotate ? 24 : 0; // 14px font + ~10px gap
327
+ ? estimatedLongestLabelWidth * Math.SQRT1_2 + dynamicFontSize * Math.SQRT1_2 + this.TICK_AREA_PADDING
328
+ : dynamicFontSize + this.TICK_AREA_PADDING + 2;
329
+ const gapBelowTicks = options.xAxisLabel && !willRotate ? this.X_AXIS_TITLE_GAP : 0;
330
+ const titleBlockHeight = options.xAxisLabel && !willRotate ? this.X_AXIS_TITLE_BLOCK_HEIGHT : 0;
289
331
  bottomPaddingForXAxis = tickAreaHeight + gapBelowTicks + titleBlockHeight;
290
332
  }
291
333
  // Determine plotting dimensions with sensible fallbacks
292
- const fallbackPlotHeight = 240; // ensures visible plot when container has no height
293
334
  const baseWidth = options.width ?? containerWidth;
294
- const totalWidth = Math.max(200, baseWidth > 0 ? baseWidth : 200);
335
+ const totalWidth = Math.max(this.MIN_TOTAL_WIDTH, baseWidth > 0 ? baseWidth : this.MIN_TOTAL_WIDTH);
295
336
  const plotWidth = Math.max(0, totalWidth - leftPaddingForYAxis);
296
- let plotHeight;
297
- if (options.height && options.height > 0) {
298
- plotHeight = Math.max(120, options.height - bottomPaddingForXAxis);
299
- }
300
- else if (containerHeight > 0) {
301
- plotHeight = Math.max(120, containerHeight - bottomPaddingForXAxis, fallbackPlotHeight);
302
- }
303
- else {
304
- plotHeight = fallbackPlotHeight;
305
- }
337
+ const plotHeight = this.calculatePlotHeight(options.height, containerHeight, bottomPaddingForXAxis);
306
338
  this.width = plotWidth;
307
339
  this.height = plotHeight;
308
340
  // Create SVG with explicit pixel dimensions to avoid collapse
309
- // Add a small internal top padding to avoid clipping the top-most Y-axis label
310
- const topPaddingForTopEdge = 6;
311
- const totalHeight = this.height + bottomPaddingForXAxis + topPaddingForTopEdge;
341
+ const totalHeight = this.height + bottomPaddingForXAxis + this.TOP_EDGE_PADDING;
312
342
  const svg = this.d3
313
343
  .select(containerElement)
314
344
  .append('svg')
@@ -321,17 +351,38 @@ class AXBarChartComponent extends AXChartComponent {
321
351
  this.chart = this.svg
322
352
  .append('g')
323
353
  .attr('class', 'chart-content')
324
- .attr('transform', `translate(${leftPaddingForYAxis},${topPaddingForTopEdge})`);
354
+ .attr('transform', `translate(${leftPaddingForYAxis},${this.TOP_EDGE_PADDING})`);
355
+ }
356
+ /**
357
+ * Calculates plot height based on options and container dimensions
358
+ */
359
+ calculatePlotHeight(optionHeight, containerHeight, bottomPadding) {
360
+ if (optionHeight && optionHeight > 0) {
361
+ return Math.max(this.MIN_PLOT_HEIGHT, optionHeight - bottomPadding);
362
+ }
363
+ if (containerHeight > 0) {
364
+ return Math.max(this.MIN_PLOT_HEIGHT, containerHeight - bottomPadding, this.FALLBACK_PLOT_HEIGHT);
365
+ }
366
+ return this.FALLBACK_PLOT_HEIGHT;
367
+ }
368
+ /**
369
+ * Determines if X-axis labels should rotate
370
+ */
371
+ shouldLabelsRotate(rotateOption, estimatedWidth, availableWidth) {
372
+ if (rotateOption === true)
373
+ return true;
374
+ if (rotateOption === false)
375
+ return false;
376
+ return estimatedWidth > availableWidth;
325
377
  }
326
- // calculateMargins was unused; removed to simplify and reduce maintenance surface
327
378
  /**
328
379
  * Creates x and y scales for the chart
329
380
  */
330
381
  setupScales(data) {
331
- // Get the bar width percentage (default 80%)
332
- const barWidthPercent = this.effectiveOptions().barWidth ?? 60 / 100;
382
+ // Get the bar width percentage (default 60%)
383
+ const barWidthPercent = this.effectiveOptions().barWidth ?? 0.6;
333
384
  // Calculate padding based on barWidth (inverse relationship)
334
- const padding = Math.max(0.1, 1 - barWidthPercent);
385
+ const padding = Math.max(this.MIN_PADDING, 1 - barWidthPercent);
335
386
  // Create x scale (band scale for categorical data)
336
387
  this.xScale = this.d3
337
388
  .scaleBand()
@@ -339,9 +390,12 @@ class AXBarChartComponent extends AXChartComponent {
339
390
  .range([0, this.width])
340
391
  .padding(padding);
341
392
  // Create y scale (linear scale for values)
393
+ // Support negative values by calculating min/max
394
+ const minValue = this.d3.min(data, (d) => d.value) || 0;
395
+ const maxValue = this.d3.max(data, (d) => d.value) || 0;
342
396
  this.yScale = this.d3
343
397
  .scaleLinear()
344
- .domain([0, this.d3.max(data, (d) => d.value) || 0])
398
+ .domain([Math.min(0, minValue), Math.max(0, maxValue)])
345
399
  .nice()
346
400
  .range([this.height, 0]);
347
401
  }
@@ -349,18 +403,21 @@ class AXBarChartComponent extends AXChartComponent {
349
403
  * Creates x and y scales for clustered charts
350
404
  */
351
405
  setupScalesClustered(groups) {
352
- const seriesLabels = groups.length > 0 ? this.getClusterSeriesLabels(groups) : [];
406
+ const seriesLabels = this.getClusterSeriesLabels(groups);
353
407
  const visibleSeries = seriesLabels.filter((s) => !this.hiddenSeries.has(s));
354
408
  // Outer band for clusters
355
- const outerPadding = 0.2;
356
409
  this.xScale = this.d3
357
410
  .scaleBand()
358
411
  .domain(groups.map((g) => g.id))
359
412
  .range([0, this.width])
360
- .padding(outerPadding);
413
+ .padding(this.OUTER_PADDING_CLUSTERED);
361
414
  // Inner band for series
362
- this.xSubScale = this.d3.scaleBand().domain(visibleSeries).range([0, this.xScale.bandwidth()]).padding(0.1);
363
- // Y scale across all visible values
415
+ this.xSubScale = this.d3
416
+ .scaleBand()
417
+ .domain(visibleSeries)
418
+ .range([0, this.xScale.bandwidth()])
419
+ .padding(this.INNER_PADDING_CLUSTERED);
420
+ // Y scale across all visible values (support negative values)
364
421
  const allVisibleValues = [];
365
422
  for (const g of groups) {
366
423
  visibleSeries.forEach((_, idx) => {
@@ -369,8 +426,13 @@ class AXBarChartComponent extends AXChartComponent {
369
426
  allVisibleValues.push(item.value);
370
427
  });
371
428
  }
429
+ const yMin = this.d3.min(allVisibleValues) || 0;
372
430
  const yMax = this.d3.max(allVisibleValues) || 0;
373
- this.yScale = this.d3.scaleLinear().domain([0, yMax]).nice().range([this.height, 0]);
431
+ this.yScale = this.d3
432
+ .scaleLinear()
433
+ .domain([Math.min(0, yMin), Math.max(0, yMax)])
434
+ .nice()
435
+ .range([this.height, 0]);
374
436
  }
375
437
  /**
376
438
  * Creates x and y axes with grid lines
@@ -396,8 +458,20 @@ class AXBarChartComponent extends AXChartComponent {
396
458
  this.buildXAxisCommon(axesGroup, idToLabel, options);
397
459
  }
398
460
  createYAxis(axesGroup, options) {
399
- // Create Y axis
400
- this.yAxis = axesGroup.append('g').attr('class', 'ax-bar-chart-axis-y').call(this.d3.axisLeft(this.yScale));
461
+ // Create Y axis with smart number formatting
462
+ const yAxisGenerator = this.d3.axisLeft(this.yScale).tickFormat((value) => {
463
+ const numValue = typeof value === 'number' ? value : value.valueOf();
464
+ return formatLargeNumber(numValue);
465
+ });
466
+ // Reduce tick count for better readability
467
+ const maxValue = this.yScale.domain()[1];
468
+ if (maxValue > 1e6) {
469
+ yAxisGenerator.ticks(6); // Fewer ticks for large numbers
470
+ }
471
+ else if (maxValue > 1e3) {
472
+ yAxisGenerator.ticks(8);
473
+ }
474
+ this.yAxis = axesGroup.append('g').attr('class', 'ax-bar-chart-axis-y').call(yAxisGenerator);
401
475
  // Style the axis text
402
476
  const dynamicFontSize = this.getYAxisTickFontSizeBasedOnHeight();
403
477
  this.yAxis
@@ -428,14 +502,14 @@ class AXBarChartComponent extends AXChartComponent {
428
502
  const labels = Array.from(idToLabel.values());
429
503
  const longestLabel = labels.reduce((a, b) => (a.length > b.length ? a : b), '');
430
504
  const estimatedWidth = longestLabel.length * fontSize * this.CHAR_WIDTH_RATIO;
431
- // Heuristic: avoid rotation when there are few categories (<= 6) even if estimation is slightly larger
505
+ // Heuristic: avoid rotation when there are few categories even if estimation is slightly larger
432
506
  const domainCount = this.xScale.domain().length;
433
- if (domainCount <= 6) {
434
- return estimatedWidth > step * 1.4; // be more tolerant for small datasets
507
+ if (domainCount <= this.SMALL_DATASET_THRESHOLD) {
508
+ return estimatedWidth > step * this.ROTATION_TOLERANCE_SMALL_DATASET;
435
509
  }
436
510
  return estimatedWidth > step;
437
511
  }
438
- createXAxisTitle(axesGroup, title, tickAreaHeight, gapBelowTicks = 8) {
512
+ createXAxisTitle(axesGroup, title, tickAreaHeight, gapBelowTicks = this.TICK_AREA_GAP) {
439
513
  // Title positioned directly below tick area with a consistent gap
440
514
  const labelY = this.height + tickAreaHeight + gapBelowTicks;
441
515
  axesGroup
@@ -446,7 +520,7 @@ class AXBarChartComponent extends AXChartComponent {
446
520
  .attr('x', this.width / 2)
447
521
  .attr('y', labelY)
448
522
  .attr('direction', 'ltr')
449
- .style('font-size', '14px')
523
+ .style('font-size', `${this.X_AXIS_TITLE_FONT_SIZE}px`)
450
524
  .style('font-weight', '500')
451
525
  .style('fill', 'rgb(var(--ax-comp-bar-chart-axis-label-color))')
452
526
  .text(title);
@@ -458,7 +532,7 @@ class AXBarChartComponent extends AXChartComponent {
458
532
  // Chart group is translated by (margin.left, margin.top)
459
533
  // So Y-axis is at X = margin.left in SVG coordinates
460
534
  const maxTickLabelWidth = this.calculateMaxYAxisTickLabelWidth();
461
- const padding = 20; // Increased padding between tick labels and axis title
535
+ const padding = this.Y_AXIS_PADDING;
462
536
  // Position title to the left of the tick labels
463
537
  // Since text-anchor: end aligns the text to the Y-axis, we need to go further left
464
538
  const labelY = -maxTickLabelWidth - padding;
@@ -499,9 +573,9 @@ class AXBarChartComponent extends AXChartComponent {
499
573
  const visibleData = raw.filter((d) => !this.isBarHidden(d.id));
500
574
  maxValue = visibleData.reduce((acc, d) => (d.value > acc ? d.value : acc), 0);
501
575
  }
502
- const tickLabelText = Number.isFinite(maxValue) ? maxValue.toLocaleString() : '00000';
503
- const fontSize = 13; // Approximate font size for Y-axis tick labels
504
- return tickLabelText.length * fontSize * 0.6 + 8; // Character width + padding
576
+ // Use formatted number for width calculation
577
+ const tickLabelText = Number.isFinite(maxValue) ? formatLargeNumber(maxValue) : '00000';
578
+ return tickLabelText.length * this.DEFAULT_FONT_SIZE * this.FONT_WIDTH_MULTIPLIER + this.FONT_PADDING;
505
579
  }
506
580
  createGridLines() {
507
581
  this.chart
@@ -592,28 +666,29 @@ class AXBarChartComponent extends AXChartComponent {
592
666
  .style('font-weight', '500')
593
667
  .style('fill', 'rgb(var(--ax-comp-bar-chart-data-labels-color))')
594
668
  .style('opacity', 0) // Start invisible for animation
595
- .text((d) => d.value);
669
+ .text((d) => {
670
+ // Format large numbers in data labels
671
+ return d.value >= 1000 ? formatLargeNumber(d.value) : d.value;
672
+ });
596
673
  // Animate data labels with smart positioning to avoid clipping
674
+ const animationDelay = this.effectiveOptions().animationDelay ?? this.DEFAULT_ANIMATION_DELAY;
597
675
  barGroups
598
676
  .selectAll('.ax-bar-chart-data-label')
599
677
  .transition()
600
678
  .duration(animationDuration)
601
- .delay((d, i) => i * 50 + 100) // Slightly delayed after bar animation
679
+ .delay((d, i) => i * animationDelay + this.LABEL_ANIMATION_DELAY)
602
680
  .attr('y', (d) => {
603
681
  const barTop = this.yScale(d.value);
604
- const labelOffset = 8;
605
- const minTopSpace = 20; // Minimum space from top of chart
606
682
  // If bar is near the top (100% or close), position label inside the bar
607
- if (barTop < minTopSpace) {
608
- return barTop + labelOffset; // Position inside the bar
683
+ if (barTop < this.MIN_TOP_SPACE_FOR_LABEL) {
684
+ return barTop + this.LABEL_OFFSET;
609
685
  }
610
686
  // Otherwise, position above the bar
611
- return barTop - labelOffset;
687
+ return barTop - this.LABEL_OFFSET;
612
688
  })
613
689
  .attr('data-inside-bar', (d) => {
614
690
  const barTop = this.yScale(d.value);
615
- const minTopSpace = 20;
616
- return barTop < minTopSpace ? 'true' : 'false';
691
+ return barTop < this.MIN_TOP_SPACE_FOR_LABEL ? 'true' : 'false';
617
692
  })
618
693
  .style('opacity', 1)
619
694
  .ease(animationEasing);
@@ -626,7 +701,10 @@ class AXBarChartComponent extends AXChartComponent {
626
701
  return;
627
702
  const barEl = this.d3.select(event.currentTarget).select('path');
628
703
  // Standard hover effect - darken the bar slightly and add a subtle shadow
629
- barEl.transition().duration(150).style('filter', 'brightness(0.7) drop-shadow(0 0 2px rgba(0,0,0,0.1))');
704
+ barEl
705
+ .transition()
706
+ .duration(this.HOVER_TRANSITION_DURATION)
707
+ .style('filter', 'brightness(0.7) drop-shadow(0 0 2px rgba(0,0,0,0.1))');
630
708
  this.handleBarHover(event, d);
631
709
  })
632
710
  .on('mousemove', (event) => {
@@ -641,7 +719,7 @@ class AXBarChartComponent extends AXChartComponent {
641
719
  return;
642
720
  const barEl = this.d3.select(event.currentTarget).select('path');
643
721
  // Remove hover effect
644
- barEl.transition().duration(150).style('filter', null);
722
+ barEl.transition().duration(this.HOVER_TRANSITION_DURATION).style('filter', null);
645
723
  this._tooltipVisible.set(false);
646
724
  })
647
725
  .on('click', (event, d) => {
@@ -651,10 +729,11 @@ class AXBarChartComponent extends AXChartComponent {
651
729
  }
652
730
  });
653
731
  // Add animation
732
+ const animationDelay = this.effectiveOptions().animationDelay ?? this.DEFAULT_ANIMATION_DELAY;
654
733
  bars
655
734
  .transition()
656
735
  .duration(animationDuration)
657
- .delay((d, i) => i * 50) // Stagger each bar animation
736
+ .delay((d, i) => i * animationDelay)
658
737
  .attrTween('d', (d) => {
659
738
  const x = this.xScale(d.id);
660
739
  const width = this.xScale.bandwidth();
@@ -684,7 +763,7 @@ class AXBarChartComponent extends AXChartComponent {
684
763
  * Renders bars for clustered charts
685
764
  */
686
765
  renderBarsClustered(groups) {
687
- const seriesLabels = groups.length > 0 ? this.getClusterSeriesLabels(groups) : [];
766
+ const seriesLabels = this.getClusterSeriesLabels(groups);
688
767
  const visibleSeries = seriesLabels.filter((s) => !this.hiddenSeries.has(s));
689
768
  this._initialAnimationComplete.set(false);
690
769
  const radius = this.effectiveOptions().cornerRadius;
@@ -712,13 +791,12 @@ class AXBarChartComponent extends AXChartComponent {
712
791
  .attr('d', (d) => {
713
792
  const x = this.xSubScale(d.seriesLabel);
714
793
  const width = this.xSubScale.bandwidth();
715
- const y = this.height - 0.5; // initial height is zero
794
+ const y = this.height - 0.5;
716
795
  return `M${x},${y} L${x},${y} L${x + width},${y} L${x + width},${y} Z`;
717
796
  })
718
797
  .attr('fill', (d) => {
719
798
  const item = d.group.chartData[d.seriesIndex];
720
- const colorIndex = d.seriesIndex;
721
- return item?.color || this.getColor(colorIndex);
799
+ return item?.color || this.getColor(d.seriesIndex);
722
800
  });
723
801
  if (this.effectiveOptions().showDataLabels !== false) {
724
802
  barGroups
@@ -732,35 +810,36 @@ class AXBarChartComponent extends AXChartComponent {
732
810
  .style('fill', 'rgb(var(--ax-comp-bar-chart-data-labels-color))')
733
811
  .style('opacity', 0)
734
812
  .text((d) => {
735
- const item = d.group.chartData[d.seriesIndex];
736
- return item ? item.value : '';
813
+ const value = d.group.chartData[d.seriesIndex]?.value;
814
+ if (value === undefined)
815
+ return '';
816
+ // Format large numbers in data labels
817
+ return value >= 1000 ? formatLargeNumber(value) : value;
737
818
  });
819
+ const animationDelay = this.effectiveOptions().animationDelay ?? this.DEFAULT_ANIMATION_DELAY;
738
820
  barGroups
739
821
  .selectAll('.ax-bar-chart-data-label')
740
822
  .transition()
741
823
  .duration(animationDuration)
742
- .delay((_, i) => i * 50 + 100)
824
+ .delay((_d, i) => i * animationDelay + this.LABEL_ANIMATION_DELAY)
743
825
  .attr('y', (d) => {
744
826
  const item = d.group.chartData[d.seriesIndex];
745
827
  if (!item)
746
828
  return this.height;
747
829
  const barTop = this.yScale(item.value);
748
- const labelOffset = 8;
749
- const minTopSpace = 20; // Minimum space from top of chart
750
830
  // If bar is near the top (100% or close), position label inside the bar
751
- if (barTop < minTopSpace) {
752
- return barTop + labelOffset; // Position inside the bar
831
+ if (barTop < this.MIN_TOP_SPACE_FOR_LABEL) {
832
+ return barTop + this.LABEL_OFFSET;
753
833
  }
754
834
  // Otherwise, position above the bar
755
- return barTop - labelOffset;
835
+ return barTop - this.LABEL_OFFSET;
756
836
  })
757
837
  .attr('data-inside-bar', (d) => {
758
838
  const item = d.group.chartData[d.seriesIndex];
759
839
  if (!item)
760
840
  return 'false';
761
841
  const barTop = this.yScale(item.value);
762
- const minTopSpace = 20;
763
- return barTop < minTopSpace ? 'true' : 'false';
842
+ return barTop < this.MIN_TOP_SPACE_FOR_LABEL ? 'true' : 'false';
764
843
  })
765
844
  .style('opacity', 1)
766
845
  .ease(animationEasing);
@@ -770,7 +849,10 @@ class AXBarChartComponent extends AXChartComponent {
770
849
  if (!this._initialAnimationComplete())
771
850
  return;
772
851
  const barEl = this.d3.select(event.currentTarget).select('path');
773
- barEl.transition().duration(150).style('filter', 'brightness(0.7) drop-shadow(0 0 2px rgba(0,0,0,0.1))');
852
+ barEl
853
+ .transition()
854
+ .duration(this.HOVER_TRANSITION_DURATION)
855
+ .style('filter', 'brightness(0.7) drop-shadow(0 0 2px rgba(0,0,0,0.1))');
774
856
  const item = d.group.chartData[d.seriesIndex];
775
857
  if (item)
776
858
  this.handleBarHover(event, item, d.group);
@@ -783,7 +865,7 @@ class AXBarChartComponent extends AXChartComponent {
783
865
  if (!this._initialAnimationComplete())
784
866
  return;
785
867
  const barEl = this.d3.select(event.currentTarget).select('path');
786
- barEl.transition().duration(150).style('filter', null);
868
+ barEl.transition().duration(this.HOVER_TRANSITION_DURATION).style('filter', null);
787
869
  this._tooltipVisible.set(false);
788
870
  })
789
871
  .on('click', (event, d) => {
@@ -793,10 +875,11 @@ class AXBarChartComponent extends AXChartComponent {
793
875
  if (item)
794
876
  this.handleBarClick(event, item);
795
877
  });
878
+ const animationDelay = this.effectiveOptions().animationDelay ?? this.DEFAULT_ANIMATION_DELAY;
796
879
  bars
797
880
  .transition()
798
881
  .duration(animationDuration)
799
- .delay((_, i) => i * 50)
882
+ .delay((_d, i) => i * animationDelay)
800
883
  .attrTween('d', (d) => {
801
884
  const x = this.xSubScale(d.seriesLabel);
802
885
  const width = this.xSubScale.bandwidth();
@@ -843,7 +926,7 @@ class AXBarChartComponent extends AXChartComponent {
843
926
  const percentage = total > 0 ? ((datum.value / total) * 100).toFixed(1) : '0';
844
927
  this._tooltipData.set({
845
928
  title: group ? `${datum.tooltipLabel || datum.label} — ${group.label}` : datum.tooltipLabel || datum.label,
846
- value: datum.value.toString(),
929
+ value: formatLargeNumber(datum.value),
847
930
  percentage: `${percentage}%`,
848
931
  color: color,
849
932
  });
@@ -1021,7 +1104,9 @@ class AXBarChartComponent extends AXChartComponent {
1021
1104
  resetAll();
1022
1105
  return;
1023
1106
  }
1024
- const targetBar = this.svg.selectAll('.ax-bar-chart-bar').filter((d) => d?.id === id);
1107
+ const targetBar = this.svg
1108
+ .selectAll('.ax-bar-chart-bar')
1109
+ .filter((d) => d?.id === id);
1025
1110
  if (targetBar.empty())
1026
1111
  return;
1027
1112
  const isCurrentlyHighlighted = targetBar.classed('ax-bar-chart-highlighted');
@@ -1046,7 +1131,9 @@ class AXBarChartComponent extends AXChartComponent {
1046
1131
  }
1047
1132
  return;
1048
1133
  }
1049
- const targetBars = this.svg.selectAll('.ax-bar-chart-bar').filter((_, i, nodes) => {
1134
+ const targetBars = this.svg
1135
+ .selectAll('.ax-bar-chart-bar')
1136
+ .filter((_d, i, nodes) => {
1050
1137
  const node = nodes[i];
1051
1138
  return node.getAttribute('data-series') === id;
1052
1139
  });
@@ -1067,7 +1154,7 @@ class AXBarChartComponent extends AXChartComponent {
1067
1154
  .style('transition', 'all 0.2s ease-in-out');
1068
1155
  this.svg
1069
1156
  .selectAll('.ax-bar-chart-bar')
1070
- .filter((_, i, nodes) => nodes[i].getAttribute('data-series') !== id)
1157
+ .filter((_d, i, nodes) => nodes[i].getAttribute('data-series') !== id)
1071
1158
  .classed('ax-bar-chart-dimmed', true)
1072
1159
  .attr('opacity', 0.5)
1073
1160
  .style('transition', 'opacity 0.2s ease-in-out');
@@ -1138,18 +1225,63 @@ class AXBarChartComponent extends AXChartComponent {
1138
1225
  clearChartArea(containerElement) {
1139
1226
  this.d3.select(containerElement).selectAll('svg, .ax-chart-message-container').remove();
1140
1227
  }
1228
+ /**
1229
+ * Truncates long labels with ellipsis
1230
+ */
1231
+ truncateLabel(label, maxLength = this.MAX_LABEL_LENGTH) {
1232
+ if (label.length <= maxLength)
1233
+ return label;
1234
+ return label.substring(0, maxLength - 1) + '…';
1235
+ }
1236
+ /**
1237
+ * Gets adaptive font size for X-axis based on width and item count
1238
+ */
1141
1239
  getXAxisTickFontSizeBasedOnWidth() {
1142
- return Math.max(10, Math.min(14, Math.round(this.width / 50)));
1240
+ const itemCount = this.xScale.domain().length;
1241
+ const baseSize = Math.round(this.width / 50);
1242
+ // Reduce font size for many items
1243
+ let adjustedSize = baseSize;
1244
+ if (itemCount > this.VERY_MANY_ITEMS_THRESHOLD) {
1245
+ adjustedSize = Math.round(baseSize * 0.7);
1246
+ }
1247
+ else if (itemCount > this.MANY_ITEMS_THRESHOLD) {
1248
+ adjustedSize = Math.round(baseSize * 0.85);
1249
+ }
1250
+ return Math.max(this.MIN_FONT_SIZE_X_AXIS, Math.min(this.MAX_FONT_SIZE_X_AXIS, adjustedSize));
1143
1251
  }
1252
+ /**
1253
+ * Gets adaptive font size for Y-axis based on height
1254
+ */
1144
1255
  getYAxisTickFontSizeBasedOnHeight() {
1145
- return Math.max(11, Math.min(15, Math.round(this.height / 30)));
1256
+ const baseSize = Math.round(this.height / 30);
1257
+ return Math.max(this.MIN_FONT_SIZE_Y_AXIS, Math.min(this.MAX_FONT_SIZE_Y_AXIS, baseSize));
1146
1258
  }
1147
1259
  buildXAxisCommon(axesGroup, idToLabel, options) {
1260
+ const itemCount = this.xScale.domain().length;
1261
+ // Smart tick reduction for many items
1262
+ let tickValues = this.xScale.domain();
1263
+ if (itemCount > this.VERY_MANY_ITEMS_THRESHOLD) {
1264
+ // Show every 5th tick
1265
+ tickValues = tickValues.filter((_d, i) => i % 5 === 0);
1266
+ }
1267
+ else if (itemCount > this.MANY_ITEMS_THRESHOLD) {
1268
+ // Show every 2nd tick
1269
+ tickValues = tickValues.filter((_d, i) => i % 2 === 0);
1270
+ }
1271
+ const axis = this.d3
1272
+ .axisBottom(this.xScale)
1273
+ .tickValues(tickValues)
1274
+ .tickFormat((id) => {
1275
+ const label = idToLabel.get(id) ?? String(id);
1276
+ // Truncate long labels intelligently
1277
+ const maxLength = itemCount > this.MANY_ITEMS_THRESHOLD ? 10 : this.MAX_LABEL_LENGTH;
1278
+ return this.truncateLabel(label, maxLength);
1279
+ });
1148
1280
  this.xAxis = axesGroup
1149
1281
  .append('g')
1150
1282
  .attr('class', 'ax-bar-chart-axis-x')
1151
1283
  .attr('transform', `translate(0,${this.height})`)
1152
- .call(this.d3.axisBottom(this.xScale).tickFormat((id) => idToLabel.get(id) ?? String(id)));
1284
+ .call(axis);
1153
1285
  const dynamicFontSize = this.getXAxisTickFontSizeBasedOnWidth();
1154
1286
  const xAxisTicks = this.xAxis
1155
1287
  .selectAll('text')
@@ -1165,10 +1297,17 @@ class AXBarChartComponent extends AXChartComponent {
1165
1297
  if (options.xAxisLabel && !isRotated) {
1166
1298
  const tickNodes = (this.xAxis.selectAll('text').nodes() || []);
1167
1299
  const measuredTickHeight = tickNodes.length > 0
1168
- ? Math.max(...tickNodes.map((n) => (n && n.getBBox ? n.getBBox().height : dynamicFontSize)))
1300
+ ? Math.max(...tickNodes.map((n) => {
1301
+ try {
1302
+ return n?.getBBox?.()?.height ?? dynamicFontSize;
1303
+ }
1304
+ catch {
1305
+ return dynamicFontSize;
1306
+ }
1307
+ }))
1169
1308
  : dynamicFontSize;
1170
- const tickAreaHeight = measuredTickHeight + 6;
1171
- const gapBelowTicks = 14;
1309
+ const tickAreaHeight = measuredTickHeight + this.TICK_AREA_PADDING;
1310
+ const gapBelowTicks = this.X_AXIS_TITLE_GAP;
1172
1311
  this.createXAxisTitle(axesGroup, options.xAxisLabel, tickAreaHeight, gapBelowTicks);
1173
1312
  }
1174
1313
  }