@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, getChartColor, computeTooltipPosition } from '@acorex/charts';
1
+ import { AXChartComponent, AX_CHART_COLOR_PALETTE, formatLargeNumber, getChartColor, getEasingFunction, computeTooltipPosition } 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, effect, ChangeDetectionStrategy, ViewEncapsulation, Component } from '@angular/core';
@@ -105,22 +105,124 @@ class AXLineChartComponent extends AXChartComponent {
105
105
  ...this.effectiveOptions().messages,
106
106
  };
107
107
  }, ...(ngDevMode ? [{ debugName: "effectiveMessages" }] : []));
108
- // Tooltip gap in pixels for consistent spacing
108
+ // Layout & Dimensions
109
+ MIN_DIMENSION = 100;
110
+ MIN_CONTAINER_DIMENSION = 200;
111
+ DEFAULT_MARGIN_TOP = 20;
112
+ DEFAULT_MARGIN_RIGHT = 25;
113
+ DEFAULT_MARGIN_BOTTOM = 40;
114
+ DEFAULT_MARGIN_LEFT = 50;
115
+ MIN_MARGIN_BOTTOM = 45;
116
+ MIN_MARGIN_LEFT = 55;
117
+ MAX_EXTRA_MARGIN = 60;
118
+ // Styling & Visual
119
+ DEFAULT_LINE_WIDTH = 2;
120
+ DEFAULT_POINT_RADIUS = 4;
121
+ DEFAULT_FILL_OPACITY = 20;
122
+ LINE_HIGHLIGHT_MULTIPLIER = 1.5;
123
+ POINT_HIGHLIGHT_MULTIPLIER = 1.5;
124
+ POINT_STROKE_WIDTH = 1;
125
+ POINT_HOVER_STROKE_WIDTH = 2;
126
+ GRID_DASH_ARRAY = '2,2';
127
+ CROSSHAIR_DASH_ARRAY = '3,3';
128
+ CROSSHAIR_OPACITY = 0.7;
129
+ // Animation
130
+ HOVER_TRANSITION_DURATION = 150;
131
+ ANIMATION_END_BUFFER = 100;
132
+ POINT_ANIMATION_DELAY_MS = 50;
133
+ POINT_ANIMATION_DELAY_RATIO = 0.5;
134
+ POINT_ANIMATION_DURATION_RATIO = 0.3;
135
+ // Text & Labels
136
+ AXIS_LABEL_FONT_SIZE = 14;
137
+ CHAR_WIDTH_RATIO = 0.6;
138
+ FONT_WIDTH_MULTIPLIER = 0.6;
139
+ MAX_LABEL_LENGTH = 20;
140
+ MIN_FONT_SIZE_X = 11;
141
+ MAX_FONT_SIZE_X = 15;
142
+ MIN_FONT_SIZE_Y = 11;
143
+ MAX_FONT_SIZE_Y = 15;
144
+ FONT_PADDING = 10;
145
+ Y_AXIS_PADDING = 10;
146
+ // Data & Performance
147
+ MAX_POINTS_TO_RENDER = 100;
148
+ POINT_COORDINATE_PRECISION = 10;
149
+ MANY_ITEMS_THRESHOLD = 20;
150
+ VERY_MANY_ITEMS_THRESHOLD = 50;
151
+ // Tooltip
109
152
  TOOLTIP_GAP = 10;
110
- ngOnInit() {
111
- this.loadD3();
112
- }
113
- #effect = effect(() => {
114
- const rawData = this.data();
153
+ /**
154
+ * Normalizes input data to consistent array format with originalIndex
155
+ */
156
+ normalizeData(rawData) {
115
157
  if (Array.isArray(rawData)) {
116
- this._fullNormalizedData = rawData.map((s, i) => ({ ...s, originalIndex: i }));
158
+ return rawData.map((s, i) => ({ ...s, originalIndex: i }));
117
159
  }
118
- else if (rawData && 'data' in rawData) {
119
- this._fullNormalizedData = [{ ...rawData, originalIndex: 0 }];
160
+ if (rawData && 'data' in rawData) {
161
+ return [{ ...rawData, originalIndex: 0 }];
120
162
  }
121
- else {
122
- this._fullNormalizedData = [];
163
+ return [];
164
+ }
165
+ /**
166
+ * Gets unique identifier for a series
167
+ */
168
+ getSeriesIdentifier(series) {
169
+ return series.id || series.label || `series-${series.originalIndex}`;
170
+ }
171
+ /**
172
+ * Calculates dynamic font size based on available space
173
+ */
174
+ calculateDynamicFontSize(dimension, divisor, min, max) {
175
+ return Math.max(min, Math.min(max, Math.round(dimension / divisor)));
176
+ }
177
+ /**
178
+ * Creates a unique key for point coordinates (for overlap detection)
179
+ */
180
+ createPointKey(x, y) {
181
+ const precision = this.POINT_COORDINATE_PRECISION;
182
+ return `${Math.round(x * precision) / precision},${Math.round(y * precision) / precision}`;
183
+ }
184
+ /**
185
+ * Removes series elements from the chart
186
+ */
187
+ removeSeriesElements(seriesIdentifier) {
188
+ this.chart.selectAll(`.ax-line-chart-series[data-series-identifier="${seriesIdentifier}"]`).remove();
189
+ this.chart.selectAll(`.ax-line-chart-points[data-series-identifier="${seriesIdentifier}"]`).remove();
190
+ }
191
+ /**
192
+ * Truncates long labels with ellipsis
193
+ */
194
+ truncateLabel(label, maxLength = this.MAX_LABEL_LENGTH) {
195
+ if (label.length <= maxLength)
196
+ return label;
197
+ return label.substring(0, maxLength - 1) + '…';
198
+ }
199
+ /**
200
+ * Calculates maximum width needed for Y-axis tick labels
201
+ */
202
+ calculateMaxYAxisTickLabelWidth() {
203
+ const allSeriesData = this._fullNormalizedData.filter((s) => !this.hiddenSeries.has(this.getSeriesIdentifier(s)));
204
+ let maxValue = 0;
205
+ let minValue = 0;
206
+ for (const series of allSeriesData) {
207
+ if (series.data && series.data.length > 0) {
208
+ for (const point of series.data) {
209
+ if (typeof point.y === 'number') {
210
+ maxValue = Math.max(maxValue, point.y);
211
+ minValue = Math.min(minValue, point.y);
212
+ }
213
+ }
214
+ }
123
215
  }
216
+ // Check both max and min (for negative values)
217
+ const maxAbsValue = Math.max(Math.abs(maxValue), Math.abs(minValue));
218
+ const tickLabelText = Number.isFinite(maxAbsValue) ? formatLargeNumber(maxAbsValue) : '00000';
219
+ return tickLabelText.length * this.AXIS_LABEL_FONT_SIZE * this.FONT_WIDTH_MULTIPLIER + this.FONT_PADDING;
220
+ }
221
+ ngOnInit() {
222
+ this.loadD3();
223
+ }
224
+ #effect = effect(() => {
225
+ this._fullNormalizedData = this.normalizeData(this.data());
124
226
  this.effectiveOptions();
125
227
  if (this._rendered()) {
126
228
  this.updateChart();
@@ -142,14 +244,15 @@ class AXLineChartComponent extends AXChartComponent {
142
244
  return sum + series.data.reduce((sSum, p) => sSum + p.y, 0);
143
245
  }, 0);
144
246
  return this._fullNormalizedData.map((series) => {
247
+ const seriesIdentifier = this.getSeriesIdentifier(series);
145
248
  const seriesSum = series.data.reduce((sum, p) => sum + p.y, 0);
146
249
  const percentage = totalSum > 0 ? (seriesSum / totalSum) * 100 : 0;
147
250
  return {
148
- id: series.id || series.label || `series-${series.originalIndex}`,
251
+ id: seriesIdentifier,
149
252
  name: series.label || `Series ${series.originalIndex + 1}`,
150
253
  value: seriesSum,
151
254
  color: series.lineColor || getChartColor(series.originalIndex, this.chartColors),
152
- hidden: this.hiddenSeries.has(series.id || series.label || `series-${series.originalIndex}`),
255
+ hidden: this.hiddenSeries.has(seriesIdentifier),
153
256
  percentage: parseFloat(percentage.toFixed(2)),
154
257
  };
155
258
  });
@@ -164,11 +267,11 @@ class AXLineChartComponent extends AXChartComponent {
164
267
  allPointsGroups.classed('ax-chart-dimmed', false).style('opacity', 1);
165
268
  allSeriesGroups
166
269
  .selectAll('.ax-line-chart-line')
167
- .attr('stroke-width', this.effectiveOptions().lineWidth ?? 2)
270
+ .attr('stroke-width', this.effectiveOptions().lineWidth ?? this.DEFAULT_LINE_WIDTH)
168
271
  .classed('ax-chart-highlighted-path', false);
169
272
  allPointsGroups
170
273
  .selectAll('circle.ax-line-chart-point')
171
- .attr('r', this.effectiveOptions().pointRadius ?? 4)
274
+ .attr('r', this.effectiveOptions().pointRadius ?? this.DEFAULT_POINT_RADIUS)
172
275
  .classed('ax-chart-highlighted-point', false);
173
276
  return;
174
277
  }
@@ -180,23 +283,22 @@ class AXLineChartComponent extends AXChartComponent {
180
283
  targetPointsGroup.classed('ax-chart-dimmed', false).style('opacity', 1);
181
284
  targetSeriesGroup
182
285
  .selectAll('.ax-line-chart-line')
183
- .attr('stroke-width', (this.effectiveOptions().lineWidth ?? 2) * 1.5)
286
+ .attr('stroke-width', (this.effectiveOptions().lineWidth ?? this.DEFAULT_LINE_WIDTH) * this.LINE_HIGHLIGHT_MULTIPLIER)
184
287
  .classed('ax-chart-highlighted-path', true);
185
288
  targetPointsGroup
186
289
  .selectAll('circle.ax-line-chart-point')
187
- .attr('r', (this.effectiveOptions().pointRadius ?? 4) * 1.5)
188
- .classed('ax-chart-highlighted-point', true);
290
+ .attr('r', (this.effectiveOptions().pointRadius ?? this.DEFAULT_POINT_RADIUS) * this.POINT_HIGHLIGHT_MULTIPLIER)
291
+ .classed('ax-chart-highlighted-path', true);
189
292
  }
190
293
  toggleSegment(id) {
191
- const seriesId = id;
192
- if (this.hiddenSeries.has(seriesId)) {
193
- this.hiddenSeries.delete(seriesId);
294
+ if (this.hiddenSeries.has(id)) {
295
+ this.hiddenSeries.delete(id);
194
296
  }
195
297
  else {
196
- this.hiddenSeries.add(seriesId);
298
+ this.hiddenSeries.add(id);
197
299
  }
198
300
  this.updateChart();
199
- return !this.hiddenSeries.has(seriesId);
301
+ return !this.hiddenSeries.has(id);
200
302
  }
201
303
  async loadD3() {
202
304
  try {
@@ -224,7 +326,7 @@ class AXLineChartComponent extends AXChartComponent {
224
326
  this.showNoDataMessage(containerElement);
225
327
  return;
226
328
  }
227
- const visibleDataForRendering = allSeriesData.filter((series) => !this.hiddenSeries.has(series.id || series.label || `series-${series.originalIndex}`));
329
+ const visibleDataForRendering = allSeriesData.filter((series) => !this.hiddenSeries.has(this.getSeriesIdentifier(series)));
228
330
  if (visibleDataForRendering.length === 0) {
229
331
  // All data is present, but all series are hidden by the legend
230
332
  this.showAllSeriesHiddenMessage(containerElement);
@@ -232,17 +334,11 @@ class AXLineChartComponent extends AXChartComponent {
232
334
  // For now, we will show the message and return, as scales/axes can't be drawn.
233
335
  return;
234
336
  }
235
- // Scales should be based on potentially visible data to avoid errors with empty domains
236
- // If all are hidden, scales might become an issue. Let's use all data for scales,
237
- // and rendering will handle visibility.
238
- const dataForScales = allSeriesData.flatMap((s) => s.data).length > 0 ? allSeriesData : [{ data: [{ x: 0, y: 0 }] }];
239
337
  const chartOptions = this.effectiveOptions();
240
338
  this.setupDimensions(containerElement, chartOptions);
241
- // Use allSeriesData for domain calculation to keep scales consistent even if some series are hidden
242
- // but filter out series with no data points for scale calculation.
339
+ // Use allSeriesData for domain calculation to keep scales consistent
243
340
  const dataForScaleSetup = allSeriesData.filter((s) => s.data && s.data.length > 0);
244
341
  if (dataForScaleSetup.length === 0) {
245
- // If after filtering, there's no data with points (e.g. all series have empty data arrays)
246
342
  this.showNoDataMessage(containerElement);
247
343
  return;
248
344
  }
@@ -265,7 +361,7 @@ class AXLineChartComponent extends AXChartComponent {
265
361
  this.calculateMargins(options, containerElement.clientWidth);
266
362
  const containerWidth = containerElement.clientWidth;
267
363
  const containerHeight = containerElement.clientHeight;
268
- const minDim = Math.min(200, containerWidth, containerHeight);
364
+ const minDim = Math.min(this.MIN_CONTAINER_DIMENSION, containerWidth, containerHeight);
269
365
  if (options.width && options.height) {
270
366
  this.width = options.width - this.margin.left - this.margin.right;
271
367
  this.height = options.height - this.margin.top - this.margin.bottom;
@@ -274,8 +370,8 @@ class AXLineChartComponent extends AXChartComponent {
274
370
  this.width = Math.max(containerWidth, minDim) - this.margin.left - this.margin.right;
275
371
  this.height = Math.max(containerHeight, minDim) - this.margin.top - this.margin.bottom;
276
372
  }
277
- this.width = Math.max(this.width, 100);
278
- this.height = Math.max(this.height, 100);
373
+ this.width = Math.max(this.width, this.MIN_DIMENSION);
374
+ this.height = Math.max(this.height, this.MIN_DIMENSION);
279
375
  const totalWidth = this.width + this.margin.left + this.margin.right;
280
376
  const totalHeight = this.height + this.margin.top + this.margin.bottom;
281
377
  const svg = this.d3
@@ -297,16 +393,16 @@ class AXLineChartComponent extends AXChartComponent {
297
393
  }
298
394
  calculateMargins(options, containerWidth) {
299
395
  this.margin = {
300
- top: options.margins?.top ?? 20,
301
- right: options.margins?.right ?? 25,
302
- bottom: options.margins?.bottom ?? 40,
303
- left: options.margins?.left ?? 50,
396
+ top: options.margins?.top ?? this.DEFAULT_MARGIN_TOP,
397
+ right: options.margins?.right ?? this.DEFAULT_MARGIN_RIGHT,
398
+ bottom: options.margins?.bottom ?? this.DEFAULT_MARGIN_BOTTOM,
399
+ left: options.margins?.left ?? this.DEFAULT_MARGIN_LEFT,
304
400
  };
305
401
  const allDataPoints = this._fullNormalizedData.flatMap((series) => series.data);
306
402
  if (allDataPoints.length > 0) {
307
403
  const allNumericX = allDataPoints.every((d) => typeof d.x === 'number');
308
404
  if (!allNumericX) {
309
- const visibleSeries = this._fullNormalizedData.filter((s) => !this.hiddenSeries.has(s.id || s.label || `series-${s.originalIndex}`));
405
+ const visibleSeries = this._fullNormalizedData.filter((s) => !this.hiddenSeries.has(this.getSeriesIdentifier(s)));
310
406
  const allXValues = new Set(visibleSeries.flatMap((series) => series.data.map((d) => String(d.x))));
311
407
  const labelCount = allXValues.size;
312
408
  if (labelCount > 0 && containerWidth > 0) {
@@ -315,11 +411,16 @@ class AXLineChartComponent extends AXChartComponent {
315
411
  const availableWidthPerLabel = workingWidth / labelCount;
316
412
  const labels = Array.from(allXValues);
317
413
  const longestLabel = labels.reduce((a, b) => (a.length > b.length ? a : b), '');
318
- const estimatedFontSize = Math.max(11, Math.min(15, Math.round(workingWidth / 45)));
319
- const estimatedLongestLabelWidth = longestLabel.length * estimatedFontSize * 0.6;
414
+ const estimatedFontSize = this.calculateDynamicFontSize(workingWidth, 45, this.MIN_FONT_SIZE_X, this.MAX_FONT_SIZE_X);
415
+ // Account for label truncation in width calculation
416
+ const maxLabelLength = labelCount > this.MANY_ITEMS_THRESHOLD ? 10 : this.MAX_LABEL_LENGTH;
417
+ const effectiveLabelLength = Math.min(longestLabel.length, maxLabelLength);
418
+ const estimatedLongestLabelWidth = effectiveLabelLength * estimatedFontSize * this.CHAR_WIDTH_RATIO;
320
419
  if (estimatedLongestLabelWidth > availableWidthPerLabel) {
321
- const requiredExtraMargin = estimatedLongestLabelWidth * Math.sin(Math.PI / 4) + 10;
322
- this.margin.bottom += Math.min(60, requiredExtraMargin);
420
+ // Calculate diagonal height when rotated -45 degrees
421
+ const diagonalHeight = estimatedLongestLabelWidth * Math.sin(Math.PI / 4);
422
+ const requiredExtraMargin = diagonalHeight + 15; // Extra padding for safety
423
+ this.margin.bottom += Math.min(this.MAX_EXTRA_MARGIN, requiredExtraMargin);
323
424
  }
324
425
  }
325
426
  }
@@ -331,15 +432,23 @@ class AXLineChartComponent extends AXChartComponent {
331
432
  this.margin.bottom = Math.max(this.margin.bottom, 40 + extraBottomMargin);
332
433
  }
333
434
  if (options.yAxisLabel) {
334
- const yLabelLength = options.yAxisLabel.length;
335
- const extraLeftMargin = Math.min(20, Math.max(10, yLabelLength * 0.8));
336
- this.margin.left = Math.max(this.margin.left, 50 + extraLeftMargin);
435
+ // Calculate space needed for Y-axis: tick labels + padding + title
436
+ const maxTickLabelWidth = this.calculateMaxYAxisTickLabelWidth();
437
+ const yAxisTitleThickness = 20; // Height of rotated title text
438
+ const yAxisTitlePadding = this.Y_AXIS_PADDING;
439
+ const totalYAxisWidth = maxTickLabelWidth + yAxisTitlePadding + yAxisTitleThickness;
440
+ this.margin.left = Math.max(this.margin.left, totalYAxisWidth);
441
+ }
442
+ else if (options.showYAxis !== false) {
443
+ // Just tick labels, no title
444
+ const maxTickLabelWidth = this.calculateMaxYAxisTickLabelWidth();
445
+ this.margin.left = Math.max(this.margin.left, maxTickLabelWidth + 10);
337
446
  }
338
447
  if (options.showXAxis !== false) {
339
- this.margin.bottom = Math.max(this.margin.bottom, 45);
448
+ this.margin.bottom = Math.max(this.margin.bottom, this.MIN_MARGIN_BOTTOM);
340
449
  }
341
450
  if (options.showYAxis !== false) {
342
- this.margin.left = Math.max(this.margin.left, 55);
451
+ this.margin.left = Math.max(this.margin.left, this.MIN_MARGIN_LEFT);
343
452
  }
344
453
  }
345
454
  setupScales(data) {
@@ -355,16 +464,17 @@ class AXLineChartComponent extends AXChartComponent {
355
464
  }
356
465
  const allNumericX = allDataPoints.every((d) => typeof d.x === 'number');
357
466
  if (allNumericX) {
358
- const xMin = this.d3.min(allDataPoints, (d) => d.x) ?? 0;
359
467
  const xMax = this.d3.max(allDataPoints, (d) => d.x) ?? 0;
360
- if (xMin === xMax) {
468
+ if (xMax === 0) {
469
+ // If all values are 0, show -1 to 1 range
361
470
  this.xScale = this.d3
362
471
  .scaleLinear()
363
- .domain([xMin - 1, xMax + 1])
472
+ .domain([-1, 1])
364
473
  .range([0, this.width]);
365
474
  }
366
475
  else {
367
- this.xScale = this.d3.scaleLinear().domain([xMin, xMax]).range([0, this.width]);
476
+ // Always start X axis from 0 for numeric data
477
+ this.xScale = this.d3.scaleLinear().domain([0, xMax]).range([0, this.width]);
368
478
  }
369
479
  }
370
480
  else {
@@ -396,11 +506,58 @@ class AXLineChartComponent extends AXChartComponent {
396
506
  const isRtl = document.documentElement.dir === 'rtl' || document.body.dir === 'rtl';
397
507
  const axesGroup = this.chart.append('g').attr('class', 'ax-line-chart-axes');
398
508
  if (showXAxis) {
509
+ let xAxisGenerator;
510
+ let itemCount = 0;
511
+ if (isBandScale) {
512
+ // Band scale (categorical data)
513
+ itemCount = this.xScale.domain().length;
514
+ // Smart tick reduction for many items (like bar chart)
515
+ let tickValues = this.xScale.domain();
516
+ if (itemCount > this.VERY_MANY_ITEMS_THRESHOLD) {
517
+ // Show every 5th tick for 50+ items
518
+ tickValues = tickValues.filter((_d, i) => i % 5 === 0);
519
+ }
520
+ else if (itemCount > this.MANY_ITEMS_THRESHOLD) {
521
+ // Show every 2nd tick for 20-50 items
522
+ tickValues = tickValues.filter((_d, i) => i % 2 === 0);
523
+ }
524
+ xAxisGenerator = this.d3
525
+ .axisBottom(this.xScale)
526
+ .tickValues(tickValues)
527
+ .tickSize(5)
528
+ .tickPadding(8)
529
+ .tickFormat((d) => {
530
+ const label = String(d);
531
+ // Truncate long labels intelligently
532
+ const maxLength = itemCount > this.MANY_ITEMS_THRESHOLD ? 10 : this.MAX_LABEL_LENGTH;
533
+ return this.truncateLabel(label, maxLength);
534
+ });
535
+ }
536
+ else {
537
+ // Linear scale (numeric data)
538
+ // Let D3 determine optimal tick count, but ensure we show reasonable number
539
+ // Calculate optimal tick count based on width
540
+ const optimalTickCount = Math.min(12, Math.max(5, Math.floor(this.width / 80)));
541
+ xAxisGenerator = this.d3
542
+ .axisBottom(this.xScale)
543
+ .ticks(optimalTickCount)
544
+ .tickSize(5)
545
+ .tickPadding(8)
546
+ .tickFormat((d) => {
547
+ // Format numbers nicely
548
+ const num = Number(d);
549
+ if (Number.isInteger(num)) {
550
+ return String(num);
551
+ }
552
+ return num.toFixed(1);
553
+ });
554
+ itemCount = optimalTickCount;
555
+ }
399
556
  this.xAxis = axesGroup
400
557
  .append('g')
401
558
  .attr('class', 'ax-line-chart-axis-x')
402
559
  .attr('transform', `translate(0,${this.height})`)
403
- .call(this.d3.axisBottom(this.xScale).tickSize(5).tickPadding(8));
560
+ .call(xAxisGenerator);
404
561
  // Style the x-axis path and lines
405
562
  this.xAxis.selectAll('path').attr('stroke', 'rgba(var(--ax-comp-line-chart-axis-color), 0.2)');
406
563
  this.xAxis
@@ -408,19 +565,21 @@ class AXLineChartComponent extends AXChartComponent {
408
565
  .attr('stroke', 'rgba(var(--ax-comp-line-chart-grid-lines-color), 0.2)')
409
566
  .attr('stroke-dasharray', '2,2')
410
567
  .attr('stroke-opacity', '0.5');
411
- const dynamicXAxisTickFontSize = Math.max(11, Math.min(15, Math.round(this.width / 45)));
568
+ const dynamicXAxisTickFontSize = this.calculateDynamicFontSize(this.width, 45, this.MIN_FONT_SIZE_X, this.MAX_FONT_SIZE_X);
412
569
  const xAxisTicks = this.xAxis
413
570
  .selectAll('text')
414
571
  .style('font-size', `${dynamicXAxisTickFontSize}px`)
415
572
  .style('font-weight', '400')
416
573
  .style('fill', 'rgba(var(--ax-comp-line-chart-labels-color), 0.7)');
417
- // Automatically rotate labels if they are likely to overlap
418
- if (isBandScale && this.xScale.domain().length > 0) {
574
+ // Automatically rotate labels if they are likely to overlap (only for band scale)
575
+ let labelsAreRotated = false;
576
+ if (isBandScale && this.xScale.bandwidth && this.xScale.domain().length > 0) {
419
577
  const step = this.xScale.step();
420
578
  const longestLabel = this.xScale.domain().reduce((a, b) => (a.length > b.length ? a : b), '');
421
- // Using 0.6 as a better estimate for char width-to-height ratio
422
- const estimatedLongestLabelWidth = longestLabel.length * dynamicXAxisTickFontSize * 0.6;
579
+ // Using char width ratio constant for better estimate
580
+ const estimatedLongestLabelWidth = longestLabel.length * dynamicXAxisTickFontSize * this.CHAR_WIDTH_RATIO;
423
581
  if (estimatedLongestLabelWidth > step) {
582
+ labelsAreRotated = true;
424
583
  xAxisTicks
425
584
  .attr('transform', 'rotate(-45)')
426
585
  .style('text-anchor', 'end')
@@ -428,16 +587,17 @@ class AXLineChartComponent extends AXChartComponent {
428
587
  .attr('dy', '0.15em');
429
588
  }
430
589
  }
431
- if (options.xAxisLabel) {
432
- const labelY = this.height + this.margin.bottom * 0.7;
590
+ // Only show X-axis title if labels are NOT rotated and item count is reasonable
591
+ // This prevents overlap with rotated labels and improves readability
592
+ const shouldShowXAxisTitle = options.xAxisLabel && !labelsAreRotated && itemCount <= this.MANY_ITEMS_THRESHOLD;
593
+ if (shouldShowXAxisTitle) {
433
594
  axesGroup
434
595
  .append('text')
435
596
  .attr('class', 'ax-line-chart-axis-label ax-x-axis-label')
436
597
  .attr('text-anchor', 'middle')
437
- .attr('dominant-baseline', 'middle')
438
598
  .attr('x', this.width / 2)
439
- .attr('y', labelY)
440
- .attr('transform', 'translate(0, 5)')
599
+ .attr('y', this.height + this.margin.bottom - 5)
600
+ .attr('direction', 'ltr')
441
601
  .attr('style', `
442
602
  font-size: 14px;
443
603
  font-weight: 500;
@@ -448,10 +608,24 @@ class AXLineChartComponent extends AXChartComponent {
448
608
  }
449
609
  }
450
610
  if (showYAxis) {
451
- this.yAxis = axesGroup
452
- .append('g')
453
- .attr('class', 'ax-line-chart-axis-y')
454
- .call(this.d3.axisLeft(this.yScale).tickSize(5).tickPadding(8));
611
+ // Create Y axis with smart number formatting
612
+ const yAxisGenerator = this.d3
613
+ .axisLeft(this.yScale)
614
+ .tickSize(5)
615
+ .tickPadding(8)
616
+ .tickFormat((value) => {
617
+ const numValue = typeof value === 'number' ? value : value.valueOf();
618
+ return formatLargeNumber(numValue);
619
+ });
620
+ // Reduce tick count for better readability with large numbers
621
+ const maxValue = this.yScale.domain()[1];
622
+ if (maxValue > 1e6) {
623
+ yAxisGenerator.ticks(6); // Fewer ticks for millions+
624
+ }
625
+ else if (maxValue > 1e3) {
626
+ yAxisGenerator.ticks(8);
627
+ }
628
+ this.yAxis = axesGroup.append('g').attr('class', 'ax-line-chart-axis-y').call(yAxisGenerator);
455
629
  // Style the y-axis path and lines
456
630
  this.yAxis.selectAll('path').attr('stroke', 'rgba(var(--ax-comp-line-chart-axis-color), 0.2)');
457
631
  this.yAxis
@@ -459,7 +633,7 @@ class AXLineChartComponent extends AXChartComponent {
459
633
  .attr('stroke', 'rgba(var(--ax-comp-line-chart-grid-lines-color), 0.2)')
460
634
  .attr('stroke-dasharray', '2,2')
461
635
  .attr('stroke-opacity', '0.5');
462
- const dynamicYAxisTickFontSize = Math.max(11, Math.min(15, Math.round(this.height / 30)));
636
+ const dynamicYAxisTickFontSize = this.calculateDynamicFontSize(this.height, 30, this.MIN_FONT_SIZE_Y, this.MAX_FONT_SIZE_Y);
463
637
  const yTickTexts = this.yAxis.selectAll('text').attr('style', `
464
638
  font-size: ${dynamicYAxisTickFontSize}px;
465
639
  font-weight: 400;
@@ -469,16 +643,23 @@ class AXLineChartComponent extends AXChartComponent {
469
643
  yTickTexts.attr('text-anchor', 'start');
470
644
  }
471
645
  if (options.yAxisLabel) {
646
+ // Calculate proper position for Y-axis title
647
+ // Position it to the left of the tick labels with proper spacing
648
+ const maxTickLabelWidth = this.calculateMaxYAxisTickLabelWidth();
649
+ const padding = this.Y_AXIS_PADDING;
650
+ // Position title to the left of the tick labels
651
+ const labelY = -maxTickLabelWidth - padding;
652
+ // Center the title vertically
472
653
  const labelX = -this.height / 2;
473
- const labelY = -this.margin.left * 0.8;
474
654
  axesGroup
475
655
  .append('text')
476
656
  .attr('class', 'ax-line-chart-axis-label ax-y-axis-label')
477
657
  .attr('text-anchor', 'middle')
478
658
  .attr('dominant-baseline', 'middle')
479
- .attr('transform', 'rotate(-90) translate(-5, 0)')
659
+ .attr('transform', 'rotate(-90)')
480
660
  .attr('x', labelX)
481
661
  .attr('y', labelY)
662
+ .attr('direction', 'ltr')
482
663
  .attr('style', `
483
664
  font-size: 14px;
484
665
  font-weight: 500;
@@ -568,12 +749,11 @@ class AXLineChartComponent extends AXChartComponent {
568
749
  this.chart.append('g').attr('class', 'ax-line-chart-crosshair').attr('pointer-events', 'none');
569
750
  }
570
751
  const animationDuration = this.effectiveOptions().animationDuration;
571
- const animationEasing = this.getEasingFunction(this.effectiveOptions().animationEasing);
752
+ const animationEasing = getEasingFunction(this.d3, this.effectiveOptions().animationEasing);
572
753
  allSeriesData.forEach((series) => {
573
- const seriesIdentifier = series.id || series.label || `series-${series.originalIndex}`;
754
+ const seriesIdentifier = this.getSeriesIdentifier(series);
574
755
  if (this.hiddenSeries.has(seriesIdentifier)) {
575
- this.chart.selectAll(`.ax-line-chart-series[data-series-identifier="${seriesIdentifier}"]`).remove();
576
- this.chart.selectAll(`.ax-line-chart-points[data-series-identifier="${seriesIdentifier}"]`).remove();
756
+ this.removeSeriesElements(seriesIdentifier);
577
757
  return;
578
758
  }
579
759
  if (!series.data || series.data.length === 0)
@@ -590,7 +770,7 @@ class AXLineChartComponent extends AXChartComponent {
590
770
  .datum(series.data)
591
771
  .attr('class', 'ax-line-chart-line')
592
772
  .attr('stroke', lineColor)
593
- .attr('stroke-width', this.effectiveOptions().lineWidth ?? 2)
773
+ .attr('stroke-width', this.effectiveOptions().lineWidth ?? this.DEFAULT_LINE_WIDTH)
594
774
  .attr('stroke-linejoin', 'round')
595
775
  .attr('stroke-linecap', 'round')
596
776
  .attr('d', lineGenerator)
@@ -601,14 +781,14 @@ class AXLineChartComponent extends AXChartComponent {
601
781
  .on('mouseenter', () => {
602
782
  line
603
783
  .transition()
604
- .duration(150)
605
- .attr('stroke-width', (this.effectiveOptions().lineWidth ?? 2) * 1.5);
784
+ .duration(this.HOVER_TRANSITION_DURATION)
785
+ .attr('stroke-width', (this.effectiveOptions().lineWidth ?? this.DEFAULT_LINE_WIDTH) * this.LINE_HIGHLIGHT_MULTIPLIER);
606
786
  })
607
787
  .on('mouseleave', () => {
608
788
  line
609
789
  .transition()
610
- .duration(150)
611
- .attr('stroke-width', this.effectiveOptions().lineWidth ?? 2);
790
+ .duration(this.HOVER_TRANSITION_DURATION)
791
+ .attr('stroke-width', this.effectiveOptions().lineWidth ?? this.DEFAULT_LINE_WIDTH);
612
792
  });
613
793
  const totalLength = line.node().getTotalLength();
614
794
  line
@@ -635,13 +815,13 @@ class AXLineChartComponent extends AXChartComponent {
635
815
  .on('mouseenter', () => {
636
816
  area
637
817
  .transition()
638
- .duration(150)
818
+ .duration(this.HOVER_TRANSITION_DURATION)
639
819
  .attr('opacity', ((this.effectiveOptions().fillOpacity ?? 20) / 100) * 1.5);
640
820
  })
641
821
  .on('mouseleave', () => {
642
822
  area
643
823
  .transition()
644
- .duration(150)
824
+ .duration(this.HOVER_TRANSITION_DURATION)
645
825
  .attr('opacity', (this.effectiveOptions().fillOpacity ?? 20) / 100);
646
826
  });
647
827
  area
@@ -657,17 +837,15 @@ class AXLineChartComponent extends AXChartComponent {
657
837
  if (this.effectiveOptions().showPoints !== false) {
658
838
  const pointMap = new Map();
659
839
  allSeriesData.forEach((series) => {
660
- const seriesIdentifier = series.id || series.label || `series-${series.originalIndex}`;
840
+ const seriesIdentifier = this.getSeriesIdentifier(series);
661
841
  if (this.hiddenSeries.has(seriesIdentifier) || !series.data || series.data.length === 0) {
662
842
  return;
663
843
  }
664
- const lineColor = series.lineColor || getChartColor(series.originalIndex, this.chartColors);
665
- const maxPoints = 100;
666
- const pointData = series.data.length > maxPoints ? this.getReducedDataPoints(series.data, maxPoints) : series.data;
844
+ const pointData = this.getReducedDataPoints(series.data, this.MAX_POINTS_TO_RENDER);
667
845
  pointData.forEach((point) => {
668
846
  const x = getX(point);
669
847
  const y = this.yScale(point.y);
670
- const key = `${Math.round(x * 10) / 10},${Math.round(y * 10) / 10}`;
848
+ const key = this.createPointKey(x, y);
671
849
  if (!pointMap.has(key)) {
672
850
  pointMap.set(key, []);
673
851
  }
@@ -675,9 +853,9 @@ class AXLineChartComponent extends AXChartComponent {
675
853
  });
676
854
  });
677
855
  let animationCounter = 0;
678
- const totalVisibleSeriesCount = allSeriesData.filter((s) => !this.hiddenSeries.has(s.id || s.label || `series-${s.originalIndex}`) && s.data && s.data.length > 0).length;
856
+ const totalVisibleSeriesCount = allSeriesData.filter((s) => !this.hiddenSeries.has(this.getSeriesIdentifier(s)) && s.data && s.data.length > 0).length;
679
857
  allSeriesData.forEach((series) => {
680
- const seriesIdentifier = series.id || series.label || `series-${series.originalIndex}`;
858
+ const seriesIdentifier = this.getSeriesIdentifier(series);
681
859
  if (this.hiddenSeries.has(seriesIdentifier) || !series.data || series.data.length === 0) {
682
860
  return;
683
861
  }
@@ -688,10 +866,9 @@ class AXLineChartComponent extends AXChartComponent {
688
866
  .attr('data-series-identifier', seriesIdentifier)
689
867
  .attr('pointer-events', 'none');
690
868
  pointsGroup
691
- .on('mouseenter', () => this.handlePointGroupEnter(series.originalIndex, lineColor))
869
+ .on('mouseenter', () => this.handlePointGroupEnter(series.originalIndex))
692
870
  .on('mouseleave', () => this.handlePointGroupLeave());
693
- const maxPoints = 100;
694
- const pointData = series.data.length > maxPoints ? this.getReducedDataPoints(series.data, maxPoints) : series.data;
871
+ const pointData = this.getReducedDataPoints(series.data, this.MAX_POINTS_TO_RENDER);
695
872
  pointsGroup
696
873
  .selectAll('circle')
697
874
  .data(pointData)
@@ -711,7 +888,7 @@ class AXLineChartComponent extends AXChartComponent {
711
888
  .on('mouseenter', (event, d) => {
712
889
  const x = getX(d);
713
890
  const y = this.yScale(d.y);
714
- const key = `${Math.round(x * 10) / 10},${Math.round(y * 10) / 10}`;
891
+ const key = this.createPointKey(x, y);
715
892
  const overlappingPoints = pointMap.get(key) || [];
716
893
  if (overlappingPoints.length > 1) {
717
894
  this.handleOverlappingPointsHover(event, overlappingPoints);
@@ -722,7 +899,7 @@ class AXLineChartComponent extends AXChartComponent {
722
899
  this.d3
723
900
  .select(event.target)
724
901
  .transition()
725
- .duration(150)
902
+ .duration(this.HOVER_TRANSITION_DURATION)
726
903
  .attr('r', (this.effectiveOptions().pointRadius ?? 4) * 1.5)
727
904
  .attr('stroke-width', 2)
728
905
  .attr('stroke', `rgb(var(--ax-comp-line-chart-bg-color))`);
@@ -733,15 +910,15 @@ class AXLineChartComponent extends AXChartComponent {
733
910
  this.d3
734
911
  .select(event.target)
735
912
  .transition()
736
- .duration(150)
737
- .attr('r', this.effectiveOptions().pointRadius ?? 4)
913
+ .duration(this.HOVER_TRANSITION_DURATION)
914
+ .attr('r', this.effectiveOptions().pointRadius ?? this.DEFAULT_POINT_RADIUS)
738
915
  .attr('stroke-width', 1)
739
916
  .attr('stroke', '#fff');
740
917
  })
741
918
  .on('click', (event, d) => {
742
919
  const x = getX(d);
743
920
  const y = this.yScale(d.y);
744
- const key = `${Math.round(x * 10) / 10},${Math.round(y * 10) / 10}`;
921
+ const key = this.createPointKey(x, y);
745
922
  const overlappingPoints = pointMap.get(key) || [];
746
923
  if (overlappingPoints.length > 1) {
747
924
  overlappingPoints.forEach(({ point, series }) => {
@@ -753,10 +930,11 @@ class AXLineChartComponent extends AXChartComponent {
753
930
  }
754
931
  })
755
932
  .transition()
756
- .delay((d, i) => i * Math.min(50, (animationDuration || 0) * 0.05) + (animationDuration || 0) * 0.5)
757
- .duration((animationDuration || 0) * 0.3)
933
+ .delay((_d, i) => i * Math.min(this.POINT_ANIMATION_DELAY_MS, (animationDuration || 0) * 0.05) +
934
+ (animationDuration || 0) * this.POINT_ANIMATION_DELAY_RATIO)
935
+ .duration((animationDuration || 0) * this.POINT_ANIMATION_DURATION_RATIO)
758
936
  .ease(animationEasing)
759
- .attr('r', this.effectiveOptions().pointRadius ?? 4)
937
+ .attr('r', this.effectiveOptions().pointRadius ?? this.DEFAULT_POINT_RADIUS)
760
938
  .on('end', (d, i, nodes) => {
761
939
  if (i === nodes.length - 1) {
762
940
  animationCounter++;
@@ -768,9 +946,7 @@ class AXLineChartComponent extends AXChartComponent {
768
946
  });
769
947
  }
770
948
  else {
771
- setTimeout(() => {
772
- this.enablePointerEventsAfterAnimation();
773
- }, animationDuration || 0 + 100);
949
+ setTimeout(() => this.enablePointerEventsAfterAnimation(), (animationDuration || 0) + this.ANIMATION_END_BUFFER);
774
950
  }
775
951
  }
776
952
  enablePointerEventsAfterAnimation() {
@@ -785,19 +961,19 @@ class AXLineChartComponent extends AXChartComponent {
785
961
  this.chart.selectAll('.ax-line-chart-point').attr('pointer-events', 'all');
786
962
  this.svg.classed('ax-line-chart-animating', false);
787
963
  }
788
- handlePointGroupEnter(originalIndex, color) {
964
+ handlePointGroupEnter(originalIndex) {
789
965
  this.chart
790
966
  .select(`.ax-line-chart-series-${originalIndex} .ax-line-chart-line`)
791
967
  .transition()
792
- .duration(150)
793
- .attr('stroke-width', (this.effectiveOptions().lineWidth ?? 2) * 1.5);
968
+ .duration(this.HOVER_TRANSITION_DURATION)
969
+ .attr('stroke-width', (this.effectiveOptions().lineWidth ?? this.DEFAULT_LINE_WIDTH) * this.LINE_HIGHLIGHT_MULTIPLIER);
794
970
  }
795
971
  handlePointGroupLeave() {
796
972
  this.chart
797
973
  .selectAll('.ax-line-chart-line')
798
974
  .transition()
799
- .duration(150)
800
- .attr('stroke-width', this.effectiveOptions().lineWidth ?? 2);
975
+ .duration(this.HOVER_TRANSITION_DURATION)
976
+ .attr('stroke-width', this.effectiveOptions().lineWidth ?? this.DEFAULT_LINE_WIDTH);
801
977
  if (this.effectiveOptions().showCrosshair === true) {
802
978
  this.chart.select('.ax-line-chart-crosshair').selectAll('*').remove();
803
979
  }
@@ -820,7 +996,7 @@ class AXLineChartComponent extends AXChartComponent {
820
996
  const color = series.lineColor || getChartColor(originalIndex, this.chartColors);
821
997
  this._tooltipData.set({
822
998
  title: dataPoint.tooltipLabel || series.tooltipLabel || series.label || `Series ${originalIndex + 1}`,
823
- value: dataPoint.y.toString(),
999
+ value: formatLargeNumber(dataPoint.y),
824
1000
  color: color,
825
1001
  });
826
1002
  this._tooltipVisible.set(true);
@@ -964,7 +1140,7 @@ class AXLineChartComponent extends AXChartComponent {
964
1140
  return;
965
1141
  const tooltipData = {
966
1142
  title: overlappingPoints.map(({ series }) => series.label),
967
- value: overlappingPoints[0].point.y.toString(),
1143
+ value: formatLargeNumber(overlappingPoints[0].point.y),
968
1144
  color: overlappingPoints.map(({ series }) => series.lineColor || getChartColor(series.originalIndex, this.chartColors)),
969
1145
  };
970
1146
  this._tooltipData.set(tooltipData);
@@ -974,30 +1150,6 @@ class AXLineChartComponent extends AXChartComponent {
974
1150
  this.showCrosshairLines(overlappingPoints[0].point);
975
1151
  }
976
1152
  }
977
- getEasingFunction(easing) {
978
- switch (easing) {
979
- case 'linear':
980
- return this.d3.easeLinear;
981
- case 'ease':
982
- return this.d3.easePolyInOut;
983
- case 'ease-in':
984
- return this.d3.easePolyIn;
985
- case 'ease-out':
986
- return this.d3.easePolyOut;
987
- case 'ease-in-out':
988
- return this.d3.easePolyInOut;
989
- case 'cubic':
990
- return this.d3.easeCubic;
991
- case 'cubic-in':
992
- return this.d3.easeCubicIn;
993
- case 'cubic-out':
994
- return this.d3.easeCubicOut;
995
- case 'cubic-in-out':
996
- return this.d3.easeCubicInOut;
997
- default:
998
- return this.d3.easeCubicOut;
999
- }
1000
- }
1001
1153
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.6", ngImport: i0, type: AXLineChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
1002
1154
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.3.6", type: AXLineChartComponent, isStandalone: true, selector: "ax-line-chart", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { pointClick: "pointClick" }, viewQueries: [{ propertyName: "chartContainerEl", first: true, predicate: ["chartContainer"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: "<div class=\"ax-line-chart\" #chartContainer>\n <!-- Shared tooltip component -->\n <ax-chart-tooltip\n [visible]=\"tooltipVisible()\"\n [position]=\"tooltipPosition()\"\n [data]=\"tooltipData()\"\n [showPercentage]=\"false\"\n ></ax-chart-tooltip>\n</div>\n", styles: ["ax-line-chart{display:block;width:100%;height:100%;min-height:200px;--ax-comp-line-chart-labels-color: var(--ax-sys-color-on-lightest-surface);--ax-comp-line-chart-axis-color: var(--ax-sys-color-on-lightest-surface);--ax-comp-line-chart-grid-lines-color: var(--ax-sys-color-on-lightest-surface);--ax-comp-line-chart-bg-color: 0, 0, 0, 0;--ax-comp-line-chart-text-color: var(--ax-sys-color-on-lightest-surface)}ax-line-chart .ax-line-chart{width:100%;height:100%;position:relative;display:flex;align-items:center;justify-content:center;border-radius:.5rem;color:rgba(var(--ax-comp-line-chart-text-color));background-color:rgb(var(--ax-comp-line-chart-bg-color))}ax-line-chart .ax-line-chart svg{width:100%;height:100%;max-width:100%;max-height:100%;overflow:visible}ax-line-chart .ax-line-chart svg g:has(text){font-family:inherit}ax-line-chart .ax-line-chart-no-data-message{text-align:center;background-color:rgb(var(--ax-comp-line-chart-bg-color));padding:1.5rem;border-radius:.5rem;border:1px solid rgba(var(--ax-sys-color-surface));width:80%;max-width:300px}ax-line-chart .ax-line-chart-no-data-message .ax-line-chart-no-data-icon{opacity:.6;margin-bottom:.75rem}ax-line-chart .ax-line-chart-no-data-message .ax-line-chart-no-data-text{font-size:1rem;font-weight:600;margin-bottom:.5rem}ax-line-chart .ax-line-chart-no-data-message .ax-line-chart-no-data-help{font-size:.8rem;opacity:.6}\n"], dependencies: [{ kind: "component", type: AXChartTooltipComponent, selector: "ax-chart-tooltip", inputs: ["data", "position", "visible", "showPercentage", "style"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1003
1155
  }