@acorex/charts 20.7.59 → 20.7.61

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.
@@ -5,10 +5,10 @@ import { InjectionToken, input, output, viewChild, signal, inject, computed, eff
5
5
 
6
6
  const AXLineChartDefaultConfig = {
7
7
  margins: {
8
- top: 20,
9
- right: 25,
10
- bottom: 40,
11
- left: 50,
8
+ top: 12,
9
+ right: 12,
10
+ bottom: 8,
11
+ left: 8,
12
12
  },
13
13
  showXAxis: true,
14
14
  showYAxis: true,
@@ -59,6 +59,7 @@ class AXLineChartComponent extends AXChartComponent {
59
59
  svg;
60
60
  chart;
61
61
  xScale;
62
+ xScaleKind = 'linear';
62
63
  yScale;
63
64
  xAxis;
64
65
  yAxis;
@@ -109,15 +110,18 @@ class AXLineChartComponent extends AXChartComponent {
109
110
  }, ...(ngDevMode ? [{ debugName: "effectiveMessages" }] : []));
110
111
  // Layout & Dimensions
111
112
  MIN_DIMENSION = 100;
112
- MIN_CONTAINER_DIMENSION = 200;
113
- DEFAULT_MARGIN_TOP = 20;
114
- DEFAULT_MARGIN_RIGHT = 25;
115
- DEFAULT_MARGIN_BOTTOM = 40;
116
- DEFAULT_MARGIN_LEFT = 50;
117
- MIN_MARGIN_BOTTOM = 45;
118
- MIN_MARGIN_LEFT = 55;
119
- MAX_EXTRA_MARGIN = 60;
120
- CHART_EDGE_PADDING = 12;
113
+ DEFAULT_MARGIN_TOP = 12;
114
+ DEFAULT_MARGIN_RIGHT = 12;
115
+ DEFAULT_MARGIN_BOTTOM = 8;
116
+ DEFAULT_MARGIN_LEFT = 8;
117
+ MIN_MARGIN_BOTTOM = 28;
118
+ MIN_MARGIN_LEFT = 36;
119
+ X_AXIS_TITLE_GAP = 10;
120
+ TICK_AREA_PADDING = 4;
121
+ AXIS_TICK_PADDING = 6;
122
+ ROTATION_TOLERANCE = 0.92;
123
+ FORCE_ROTATE_LABEL_LENGTH = 14;
124
+ Y_AXIS_TITLE_PADDING = 10;
121
125
  // Styling & Visual
122
126
  DEFAULT_LINE_WIDTH = 2;
123
127
  DEFAULT_POINT_RADIUS = 4;
@@ -136,16 +140,178 @@ class AXLineChartComponent extends AXChartComponent {
136
140
  POINT_ANIMATION_DELAY_RATIO = 0.5;
137
141
  POINT_ANIMATION_DURATION_RATIO = 0.3;
138
142
  // Text & Labels
139
- AXIS_LABEL_FONT_SIZE = 14;
140
143
  CHAR_WIDTH_RATIO = 0.6;
141
144
  FONT_WIDTH_MULTIPLIER = 0.6;
142
145
  MAX_LABEL_LENGTH = 20;
143
- MIN_FONT_SIZE_X = 11;
144
- MAX_FONT_SIZE_X = 15;
146
+ MIN_FONT_SIZE_X = 10;
147
+ MAX_FONT_SIZE_X = 16;
145
148
  MIN_FONT_SIZE_Y = 11;
146
- MAX_FONT_SIZE_Y = 15;
147
- FONT_PADDING = 10;
148
- Y_AXIS_PADDING = 10;
149
+ MAX_FONT_SIZE_Y = 16;
150
+ FONT_PADDING = 6;
151
+ xAxisLabelLayout = {
152
+ fontSize: 12,
153
+ willRotate: false,
154
+ tickAreaHeight: 24,
155
+ displayedTickCount: 1,
156
+ };
157
+ /**
158
+ * Resolves X-axis tick font size, rotation, and reserved tick area height.
159
+ */
160
+ resolveXAxisLabelLayout(chartWidth, categoricalLabels) {
161
+ if (!categoricalLabels || categoricalLabels.length === 0) {
162
+ const displayedTickCount = Math.min(12, Math.max(5, Math.floor(chartWidth / 80)));
163
+ const fontSize = this.getXAxisTickFontSize(chartWidth, displayedTickCount);
164
+ const tickAreaHeight = fontSize + this.TICK_AREA_PADDING + this.AXIS_TICK_PADDING;
165
+ return { fontSize, willRotate: false, tickAreaHeight, displayedTickCount };
166
+ }
167
+ const itemCount = categoricalLabels.length;
168
+ const longestLabel = categoricalLabels.reduce((a, b) => (a.length > b.length ? a : b), '');
169
+ const effectiveLength = this.getEffectiveXLabelLength(longestLabel, itemCount);
170
+ const step = itemCount > 1 ? chartWidth / (itemCount - 1) : chartWidth;
171
+ let fontSize = this.getXAxisTickFontSize(chartWidth, itemCount);
172
+ let effectiveWidth = effectiveLength * fontSize * this.CHAR_WIDTH_RATIO;
173
+ while (effectiveWidth > step * this.ROTATION_TOLERANCE && fontSize > this.MIN_FONT_SIZE_X) {
174
+ fontSize--;
175
+ effectiveWidth = effectiveLength * fontSize * this.CHAR_WIDTH_RATIO;
176
+ }
177
+ const willRotate = longestLabel.length >= this.FORCE_ROTATE_LABEL_LENGTH ||
178
+ effectiveWidth > step * this.ROTATION_TOLERANCE;
179
+ if (willRotate && effectiveWidth > step) {
180
+ while (effectiveWidth > step * 1.35 && fontSize > this.MIN_FONT_SIZE_X) {
181
+ fontSize--;
182
+ effectiveWidth = effectiveLength * fontSize * this.CHAR_WIDTH_RATIO;
183
+ }
184
+ }
185
+ const tickAreaHeight = this.estimateXAxisTickAreaHeight(fontSize, effectiveWidth, willRotate);
186
+ let displayedTickCount = itemCount;
187
+ if (itemCount > this.VERY_MANY_ITEMS_THRESHOLD) {
188
+ displayedTickCount = Math.ceil(itemCount / 5);
189
+ }
190
+ else if (itemCount > this.MANY_ITEMS_THRESHOLD) {
191
+ displayedTickCount = Math.ceil(itemCount / 2);
192
+ }
193
+ return { fontSize, willRotate, tickAreaHeight, displayedTickCount };
194
+ }
195
+ /**
196
+ * Returns truncated label length used for layout calculations.
197
+ */
198
+ getEffectiveXLabelLength(label, itemCount) {
199
+ const maxLength = itemCount > this.MANY_ITEMS_THRESHOLD ? 10 : this.MAX_LABEL_LENGTH;
200
+ return Math.min(label.length, maxLength);
201
+ }
202
+ /**
203
+ * Estimates vertical space required below the X-axis for tick labels.
204
+ */
205
+ estimateXAxisTickAreaHeight(fontSize, effectiveLabelWidth, willRotate) {
206
+ if (willRotate) {
207
+ return effectiveLabelWidth * Math.SQRT1_2 + fontSize * Math.SQRT1_2 + this.TICK_AREA_PADDING + this.AXIS_TICK_PADDING;
208
+ }
209
+ return fontSize + this.TICK_AREA_PADDING + this.AXIS_TICK_PADDING;
210
+ }
211
+ /**
212
+ * Measures rendered X-axis tick label area using the axis group bounding box.
213
+ */
214
+ measureXAxisTickAreaHeight(axisGroup, fontSize, fallbackHeight) {
215
+ if (!axisGroup) {
216
+ return fallbackHeight;
217
+ }
218
+ try {
219
+ const bbox = axisGroup.getBBox();
220
+ const measured = Math.max(0, bbox.y + bbox.height) + this.TICK_AREA_PADDING;
221
+ return Math.max(measured, fallbackHeight);
222
+ }
223
+ catch {
224
+ return fallbackHeight;
225
+ }
226
+ }
227
+ /**
228
+ * Measures rendered Y-axis tick label width using SVG bounding boxes.
229
+ */
230
+ measureMaxYAxisTickLabelWidth(tickNodes, fontSize) {
231
+ try {
232
+ if (tickNodes.length > 0) {
233
+ return Math.max(...tickNodes.map((node) => node.getBBox().width)) + this.FONT_PADDING;
234
+ }
235
+ }
236
+ catch {
237
+ // Fall through to estimation
238
+ }
239
+ return this.calculateMaxYAxisTickLabelWidth(fontSize);
240
+ }
241
+ /**
242
+ * Calculates required bottom margin for X-axis ticks, optional rotation, and title.
243
+ */
244
+ calculateRequiredBottomMargin(options, chartWidth, categoricalLabels) {
245
+ const baseBottom = options.margins?.bottom ?? this.DEFAULT_MARGIN_BOTTOM;
246
+ if (options.showXAxis === false) {
247
+ return Math.max(baseBottom, 8);
248
+ }
249
+ const layout = this.resolveXAxisLabelLayout(chartWidth, categoricalLabels);
250
+ this.xAxisLabelLayout.fontSize = layout.fontSize;
251
+ this.xAxisLabelLayout.willRotate = layout.willRotate;
252
+ this.xAxisLabelLayout.tickAreaHeight = layout.tickAreaHeight;
253
+ this.xAxisLabelLayout.displayedTickCount = layout.displayedTickCount;
254
+ const titleBlock = options.xAxisLabel
255
+ ? this.X_AXIS_TITLE_GAP + this.getAxisTitleFontSize(layout.fontSize) + 2
256
+ : 0;
257
+ const rotatedBuffer = layout.willRotate ? 12 : 4;
258
+ return Math.max(baseBottom, this.MIN_MARGIN_BOTTOM, layout.tickAreaHeight + titleBlock + rotatedBuffer);
259
+ }
260
+ /**
261
+ * Calculates required left margin for Y-axis ticks and title.
262
+ */
263
+ calculateRequiredLeftMargin(options, chartHeight) {
264
+ const baseLeft = options.margins?.left ?? this.DEFAULT_MARGIN_LEFT;
265
+ if (options.showYAxis === false) {
266
+ return baseLeft;
267
+ }
268
+ const fontSize = this.getYAxisTickFontSize(chartHeight);
269
+ const tickLabelWidth = this.calculateMaxYAxisTickLabelWidth(fontSize);
270
+ const titleBlock = options.yAxisLabel
271
+ ? this.Y_AXIS_TITLE_PADDING + this.getAxisTitleFontSize(fontSize) + 4
272
+ : 0;
273
+ return Math.max(baseLeft, this.MIN_MARGIN_LEFT, tickLabelWidth + this.AXIS_TICK_PADDING + titleBlock + 4);
274
+ }
275
+ createXAxisTitle(axesGroup, title, tickAreaHeight, tickFontSize) {
276
+ const titleFontSize = this.getAxisTitleFontSize(tickFontSize);
277
+ const titleY = Math.min(this.height + tickAreaHeight + this.X_AXIS_TITLE_GAP, this.height + this.margin.bottom - titleFontSize - 2);
278
+ axesGroup
279
+ .append('text')
280
+ .attr('class', 'ax-line-chart-axis-label ax-x-axis-label')
281
+ .attr('text-anchor', 'middle')
282
+ .attr('dominant-baseline', 'hanging')
283
+ .attr('x', this.width / 2)
284
+ .attr('y', titleY)
285
+ .attr('direction', 'ltr')
286
+ .attr('style', `
287
+ font-size: ${titleFontSize}px;
288
+ font-weight: 500;
289
+ fill: rgb(var(--ax-comp-line-chart-text-color));
290
+ pointer-events: none;
291
+ `)
292
+ .text(title);
293
+ }
294
+ createYAxisTitle(axesGroup, title, tickFontSize, maxTickLabelWidth) {
295
+ const titleFontSize = this.getAxisTitleFontSize(tickFontSize);
296
+ const labelY = -maxTickLabelWidth - this.Y_AXIS_TITLE_PADDING;
297
+ const labelX = -this.height / 2;
298
+ axesGroup
299
+ .append('text')
300
+ .attr('class', 'ax-line-chart-axis-label ax-y-axis-label')
301
+ .attr('text-anchor', 'middle')
302
+ .attr('dominant-baseline', 'middle')
303
+ .attr('transform', 'rotate(-90)')
304
+ .attr('x', labelX)
305
+ .attr('y', labelY)
306
+ .attr('direction', 'ltr')
307
+ .attr('style', `
308
+ font-size: ${titleFontSize}px;
309
+ font-weight: 500;
310
+ fill: rgb(var(--ax-comp-line-chart-text-color));
311
+ pointer-events: none;
312
+ `)
313
+ .text(title);
314
+ }
149
315
  // Data & Performance
150
316
  MAX_POINTS_TO_RENDER = 100;
151
317
  POINT_COORDINATE_PRECISION = 10;
@@ -172,10 +338,41 @@ class AXLineChartComponent extends AXChartComponent {
172
338
  return series.id || series.label || `series-${series.originalIndex}`;
173
339
  }
174
340
  /**
175
- * Calculates dynamic font size based on available space
341
+ * Calculates adaptive X-axis tick font size based on chart width and label count.
342
+ */
343
+ getXAxisTickFontSize(chartWidth, itemCount = 1) {
344
+ let size = chartWidth / 42;
345
+ if (itemCount > this.VERY_MANY_ITEMS_THRESHOLD) {
346
+ size *= 0.65;
347
+ }
348
+ else if (itemCount > this.MANY_ITEMS_THRESHOLD) {
349
+ size *= 0.8;
350
+ }
351
+ return Math.max(this.MIN_FONT_SIZE_X, Math.min(this.MAX_FONT_SIZE_X, Math.round(size)));
352
+ }
353
+ /**
354
+ * Calculates adaptive Y-axis tick font size based on chart height.
355
+ */
356
+ getYAxisTickFontSize(chartHeight) {
357
+ const size = chartHeight / 28;
358
+ return Math.max(this.MIN_FONT_SIZE_Y, Math.min(this.MAX_FONT_SIZE_Y, Math.round(size)));
359
+ }
360
+ /**
361
+ * Calculates axis title font size relative to tick labels.
176
362
  */
177
- calculateDynamicFontSize(dimension, divisor, min, max) {
178
- return Math.max(min, Math.min(max, Math.round(dimension / divisor)));
363
+ getAxisTitleFontSize(tickFontSize) {
364
+ return Math.min(15, Math.max(12, tickFontSize));
365
+ }
366
+ /**
367
+ * Estimates inner chart dimensions before margins are finalized.
368
+ */
369
+ estimateChartDimensions(containerWidth, containerHeight, options) {
370
+ const outerWidth = options.width ?? Math.max(containerWidth, 1);
371
+ const outerHeight = options.height ?? Math.max(containerHeight, 1);
372
+ return {
373
+ width: Math.max(outerWidth - this.margin.left - this.margin.right, this.MIN_DIMENSION),
374
+ height: Math.max(outerHeight - this.margin.top - this.margin.bottom, this.MIN_DIMENSION),
375
+ };
179
376
  }
180
377
  /**
181
378
  * Creates a unique key for point coordinates (for overlap detection)
@@ -202,7 +399,7 @@ class AXLineChartComponent extends AXChartComponent {
202
399
  /**
203
400
  * Calculates maximum width needed for Y-axis tick labels
204
401
  */
205
- calculateMaxYAxisTickLabelWidth() {
402
+ calculateMaxYAxisTickLabelWidth(fontSize) {
206
403
  const allSeriesData = this._fullNormalizedData.filter((s) => !this.hiddenSeries.has(this.getSeriesIdentifier(s)));
207
404
  let maxValue = 0;
208
405
  let minValue = 0;
@@ -219,7 +416,7 @@ class AXLineChartComponent extends AXChartComponent {
219
416
  // Check both max and min (for negative values)
220
417
  const maxAbsValue = Math.max(Math.abs(maxValue), Math.abs(minValue));
221
418
  const tickLabelText = Number.isFinite(maxAbsValue) ? formatLargeNumber(maxAbsValue) : '00000';
222
- return tickLabelText.length * this.AXIS_LABEL_FONT_SIZE * this.FONT_WIDTH_MULTIPLIER + this.FONT_PADDING;
419
+ return tickLabelText.length * fontSize * this.FONT_WIDTH_MULTIPLIER + this.FONT_PADDING;
223
420
  }
224
421
  ngOnInit() {
225
422
  this.loadD3();
@@ -369,95 +566,54 @@ class AXLineChartComponent extends AXChartComponent {
369
566
  this._tooltipVisible.set(false);
370
567
  }
371
568
  setupDimensions(containerElement, options) {
372
- this.calculateMargins(options, containerElement.clientWidth);
373
- const containerWidth = containerElement.clientWidth;
374
- const containerHeight = containerElement.clientHeight;
375
- const minDim = Math.min(this.MIN_CONTAINER_DIMENSION, containerWidth, containerHeight);
569
+ const containerWidth = Math.max(containerElement.clientWidth, 1);
570
+ const containerHeight = Math.max(containerElement.clientHeight, 1);
571
+ this.calculateMargins(options, containerWidth, containerHeight);
376
572
  if (options.width && options.height) {
377
573
  this.width = options.width - this.margin.left - this.margin.right;
378
574
  this.height = options.height - this.margin.top - this.margin.bottom;
379
575
  }
380
576
  else {
381
- this.width = Math.max(containerWidth, minDim) - this.margin.left - this.margin.right;
382
- this.height = Math.max(containerHeight, minDim) - this.margin.top - this.margin.bottom;
577
+ this.width = containerWidth - this.margin.left - this.margin.right;
578
+ this.height = containerHeight - this.margin.top - this.margin.bottom;
383
579
  }
384
580
  this.width = Math.max(this.width, this.MIN_DIMENSION);
385
581
  this.height = Math.max(this.height, this.MIN_DIMENSION);
386
582
  const totalWidth = this.width + this.margin.left + this.margin.right;
387
583
  const totalHeight = this.height + this.margin.top + this.margin.bottom;
388
- const viewBoxWidth = totalWidth + this.CHART_EDGE_PADDING * 2;
389
- const viewBoxHeight = totalHeight + this.CHART_EDGE_PADDING * 2;
390
584
  const svg = this.d3
391
585
  .select(containerElement)
392
586
  .append('svg')
393
587
  .attr('width', '100%')
394
588
  .attr('height', '100%')
395
- .attr('viewBox', `0 0 ${viewBoxWidth} ${viewBoxHeight}`)
589
+ .attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
396
590
  .attr('preserveAspectRatio', 'xMidYMid meet');
397
591
  this.svg = svg;
398
592
  this.chart = this.svg
399
593
  .append('g')
400
- .attr('transform', `translate(${this.margin.left + this.CHART_EDGE_PADDING},${this.margin.top + this.CHART_EDGE_PADDING})`);
594
+ .attr('transform', `translate(${this.margin.left},${this.margin.top})`);
401
595
  }
402
- calculateMargins(options, containerWidth) {
596
+ calculateMargins(options, containerWidth, containerHeight) {
403
597
  this.margin = {
404
598
  top: options.margins?.top ?? this.DEFAULT_MARGIN_TOP,
405
599
  right: options.margins?.right ?? this.DEFAULT_MARGIN_RIGHT,
406
600
  bottom: options.margins?.bottom ?? this.DEFAULT_MARGIN_BOTTOM,
407
601
  left: options.margins?.left ?? this.DEFAULT_MARGIN_LEFT,
408
602
  };
603
+ const estimatedDimensions = this.estimateChartDimensions(containerWidth, containerHeight, options);
409
604
  const allDataPoints = this._fullNormalizedData.flatMap((series) => series.data);
410
- if (allDataPoints.length > 0) {
411
- const allNumericX = allDataPoints.every((d) => typeof d.x === 'number');
412
- if (!allNumericX) {
413
- const visibleSeries = this._fullNormalizedData.filter((s) => !this.hiddenSeries.has(this.getSeriesIdentifier(s)));
414
- const allXValues = new Set(visibleSeries.flatMap((series) => series.data.map((d) => String(d.x))));
415
- const labelCount = allXValues.size;
416
- if (labelCount > 0 && containerWidth > 0) {
417
- const workingWidth = containerWidth - this.margin.left - this.margin.right;
418
- if (workingWidth > 0) {
419
- const availableWidthPerLabel = workingWidth / labelCount;
420
- const labels = Array.from(allXValues);
421
- const longestLabel = labels.reduce((a, b) => (a.length > b.length ? a : b), '');
422
- const estimatedFontSize = this.calculateDynamicFontSize(workingWidth, 45, this.MIN_FONT_SIZE_X, this.MAX_FONT_SIZE_X);
423
- // Account for label truncation in width calculation
424
- const maxLabelLength = labelCount > this.MANY_ITEMS_THRESHOLD ? 10 : this.MAX_LABEL_LENGTH;
425
- const effectiveLabelLength = Math.min(longestLabel.length, maxLabelLength);
426
- const estimatedLongestLabelWidth = effectiveLabelLength * estimatedFontSize * this.CHAR_WIDTH_RATIO;
427
- if (estimatedLongestLabelWidth > availableWidthPerLabel) {
428
- // Calculate diagonal height when rotated -45 degrees
429
- const diagonalHeight = estimatedLongestLabelWidth * Math.sin(Math.PI / 4);
430
- const requiredExtraMargin = diagonalHeight + 15; // Extra padding for safety
431
- this.margin.bottom += Math.min(this.MAX_EXTRA_MARGIN, requiredExtraMargin);
432
- }
433
- }
434
- }
435
- }
436
- }
437
- if (options.xAxisLabel) {
438
- const xLabelLength = options.xAxisLabel.length;
439
- const extraBottomMargin = Math.min(20, Math.max(10, xLabelLength * 0.8));
440
- this.margin.bottom = Math.max(this.margin.bottom, 40 + extraBottomMargin);
441
- }
442
- if (options.yAxisLabel) {
443
- // Calculate space needed for Y-axis: tick labels + padding + title
444
- const maxTickLabelWidth = this.calculateMaxYAxisTickLabelWidth();
445
- const yAxisTitleThickness = 20; // Height of rotated title text
446
- const yAxisTitlePadding = this.Y_AXIS_PADDING;
447
- const totalYAxisWidth = maxTickLabelWidth + yAxisTitlePadding + yAxisTitleThickness;
448
- this.margin.left = Math.max(this.margin.left, totalYAxisWidth);
449
- }
450
- else if (options.showYAxis !== false) {
451
- // Just tick labels, no title
452
- const maxTickLabelWidth = this.calculateMaxYAxisTickLabelWidth();
453
- this.margin.left = Math.max(this.margin.left, maxTickLabelWidth + 10);
454
- }
455
- if (options.showXAxis !== false) {
456
- this.margin.bottom = Math.max(this.margin.bottom, this.MIN_MARGIN_BOTTOM);
457
- }
458
- if (options.showYAxis !== false) {
459
- this.margin.left = Math.max(this.margin.left, this.MIN_MARGIN_LEFT);
605
+ const allNumericX = allDataPoints.length === 0 || allDataPoints.every((d) => typeof d.x === 'number');
606
+ let categoricalLabels = null;
607
+ if (!allNumericX) {
608
+ const visibleSeries = this._fullNormalizedData.filter((s) => !this.hiddenSeries.has(this.getSeriesIdentifier(s)));
609
+ categoricalLabels = [...new Set(visibleSeries.flatMap((series) => series.data.map((d) => String(d.x))))];
460
610
  }
611
+ this.margin.bottom = this.calculateRequiredBottomMargin(options, estimatedDimensions.width, categoricalLabels);
612
+ this.margin.left = this.calculateRequiredLeftMargin(options, estimatedDimensions.height);
613
+ // Second pass: margins affect inner chart size, which affects label layout
614
+ const refinedDimensions = this.estimateChartDimensions(containerWidth, containerHeight, options);
615
+ this.margin.bottom = this.calculateRequiredBottomMargin(options, refinedDimensions.width, categoricalLabels);
616
+ this.margin.left = this.calculateRequiredLeftMargin(options, refinedDimensions.height);
461
617
  }
462
618
  setupScales(data) {
463
619
  // Expects already filtered data for scales
@@ -472,20 +628,21 @@ class AXLineChartComponent extends AXChartComponent {
472
628
  }
473
629
  const allNumericX = allDataPoints.every((d) => typeof d.x === 'number');
474
630
  if (allNumericX) {
631
+ this.xScaleKind = 'linear';
632
+ const xMin = this.d3.min(allDataPoints, (d) => d.x) ?? 0;
475
633
  const xMax = this.d3.max(allDataPoints, (d) => d.x) ?? 0;
476
- if (xMax === 0) {
477
- // If all values are 0, show -1 to 1 range
634
+ if (xMin === xMax) {
478
635
  this.xScale = this.d3
479
636
  .scaleLinear()
480
- .domain([-1, 1])
637
+ .domain([xMin - 1, xMax + 1])
481
638
  .range([0, this.width]);
482
639
  }
483
640
  else {
484
- // Always start X axis from 0 for numeric data
485
- this.xScale = this.d3.scaleLinear().domain([0, xMax]).range([0, this.width]);
641
+ this.xScale = this.d3.scaleLinear().domain([xMin, xMax]).range([0, this.width]);
486
642
  }
487
643
  }
488
644
  else {
645
+ this.xScaleKind = 'point';
489
646
  const xDomain = [];
490
647
  const seenX = new Set();
491
648
  for (const point of allDataPoints) {
@@ -496,40 +653,54 @@ class AXLineChartComponent extends AXChartComponent {
496
653
  }
497
654
  }
498
655
  this.xScale = this.d3
499
- .scaleBand()
656
+ .scalePoint()
500
657
  .domain(xDomain)
501
658
  .range([0, this.width])
502
- .paddingInner(0.2)
503
- .paddingOuter(0);
659
+ .padding(0);
504
660
  }
505
661
  const yAxisStartsAtZero = chartOptions.yAxisStartsAtZero !== false;
506
- let yMin = this.d3.min(allDataPoints, (d) => d.y) ?? 0;
662
+ const dataYMin = this.d3.min(allDataPoints, (d) => d.y) ?? 0;
663
+ const dataYMax = this.d3.max(allDataPoints, (d) => d.y) ?? 0;
664
+ let yMin;
665
+ let yMax;
507
666
  if (yAxisStartsAtZero) {
508
- yMin = Math.min(0, yMin);
667
+ yMin = Math.min(0, dataYMin);
668
+ yMax = Math.max(0, dataYMax);
669
+ }
670
+ else {
671
+ yMin = dataYMin;
672
+ yMax = dataYMax;
509
673
  }
510
- const yMax = this.d3.max(allDataPoints, (d) => d.y) ?? 0;
511
- const yRange = yMax - yMin;
674
+ const yRange = yMax - yMin || 1;
512
675
  this.yScale = this.d3
513
676
  .scaleLinear()
514
677
  .domain([yMin, yMax + yRange * paddingMultiplier])
515
678
  .nice()
516
679
  .range([this.height, 0]);
680
+ if (yAxisStartsAtZero && dataYMin >= 0) {
681
+ this.yScale.domain([0, this.yScale.domain()[1]]);
682
+ }
517
683
  }
518
684
  createAxes(options) {
519
685
  const showXAxis = options.showXAxis !== false;
520
686
  const showYAxis = options.showYAxis !== false;
521
687
  const showGrid = options.showGrid !== false;
522
- const isBandScale = this.xScale.bandwidth !== undefined;
688
+ const isPointScale = this.xScaleKind === 'point';
689
+ const pointScale = isPointScale ? this.xScale : null;
523
690
  const isRtl = document.documentElement.dir === 'rtl' || document.body.dir === 'rtl';
524
691
  const axesGroup = this.chart.append('g').attr('class', 'ax-line-chart-axes');
525
692
  if (showXAxis) {
526
693
  let xAxisGenerator;
527
694
  let itemCount = 0;
528
- if (isBandScale) {
529
- // Band scale (categorical data)
530
- itemCount = this.xScale.domain().length;
695
+ if (isPointScale && pointScale) {
696
+ itemCount = pointScale.domain().length;
697
+ const pointLayout = this.resolveXAxisLabelLayout(this.width, pointScale.domain());
698
+ this.xAxisLabelLayout.fontSize = pointLayout.fontSize;
699
+ this.xAxisLabelLayout.willRotate = pointLayout.willRotate;
700
+ this.xAxisLabelLayout.tickAreaHeight = pointLayout.tickAreaHeight;
701
+ this.xAxisLabelLayout.displayedTickCount = pointLayout.displayedTickCount;
531
702
  // Smart tick reduction for many items (like bar chart)
532
- let tickValues = this.xScale.domain();
703
+ let tickValues = pointScale.domain();
533
704
  if (itemCount > this.VERY_MANY_ITEMS_THRESHOLD) {
534
705
  // Show every 5th tick for 50+ items
535
706
  tickValues = tickValues.filter((_d, i) => i % 5 === 0);
@@ -539,7 +710,7 @@ class AXLineChartComponent extends AXChartComponent {
539
710
  tickValues = tickValues.filter((_d, i) => i % 2 === 0);
540
711
  }
541
712
  xAxisGenerator = this.d3
542
- .axisBottom(this.xScale)
713
+ .axisBottom(pointScale)
543
714
  .tickValues(tickValues)
544
715
  .tickSize(5)
545
716
  .tickPadding(8)
@@ -582,21 +753,17 @@ class AXLineChartComponent extends AXChartComponent {
582
753
  .attr('stroke', 'rgba(var(--ax-comp-line-chart-grid-lines-color), 0.2)')
583
754
  .attr('stroke-dasharray', '2,2')
584
755
  .attr('stroke-opacity', '0.5');
585
- const dynamicXAxisTickFontSize = this.calculateDynamicFontSize(this.width, 45, this.MIN_FONT_SIZE_X, this.MAX_FONT_SIZE_X);
756
+ const dynamicXAxisTickFontSize = isPointScale
757
+ ? this.xAxisLabelLayout.fontSize
758
+ : this.getXAxisTickFontSize(this.width, itemCount);
586
759
  const xAxisTicks = this.xAxis
587
760
  .selectAll('text')
588
761
  .style('font-size', `${dynamicXAxisTickFontSize}px`)
589
762
  .style('font-weight', '400')
590
- .style('fill', 'rgba(var(--ax-comp-line-chart-labels-color), 0.7)');
591
- // Automatically rotate labels if they are likely to overlap (only for band scale)
592
- let labelsAreRotated = false;
593
- if (isBandScale && this.xScale.bandwidth && this.xScale.domain().length > 0) {
594
- const step = this.xScale.step();
595
- const longestLabel = this.xScale.domain().reduce((a, b) => (a.length > b.length ? a : b), '');
596
- // Using char width ratio constant for better estimate
597
- const estimatedLongestLabelWidth = longestLabel.length * dynamicXAxisTickFontSize * this.CHAR_WIDTH_RATIO;
598
- if (estimatedLongestLabelWidth > step) {
599
- labelsAreRotated = true;
763
+ .style('fill', 'rgba(var(--ax-comp-line-chart-labels-color), 0.7)')
764
+ .attr('direction', 'ltr');
765
+ if (isPointScale && pointScale && pointScale.domain().length > 0) {
766
+ if (this.xAxisLabelLayout.willRotate) {
600
767
  xAxisTicks
601
768
  .attr('transform', 'rotate(-45)')
602
769
  .style('text-anchor', 'end')
@@ -604,24 +771,9 @@ class AXLineChartComponent extends AXChartComponent {
604
771
  .attr('dy', '0.15em');
605
772
  }
606
773
  }
607
- // Only show X-axis title if labels are NOT rotated and item count is reasonable
608
- // This prevents overlap with rotated labels and improves readability
609
- const shouldShowXAxisTitle = options.xAxisLabel && !labelsAreRotated && itemCount <= this.MANY_ITEMS_THRESHOLD;
610
- if (shouldShowXAxisTitle) {
611
- axesGroup
612
- .append('text')
613
- .attr('class', 'ax-line-chart-axis-label ax-x-axis-label')
614
- .attr('text-anchor', 'middle')
615
- .attr('x', this.width / 2)
616
- .attr('y', this.height + this.margin.bottom - 5)
617
- .attr('direction', 'ltr')
618
- .attr('style', `
619
- font-size: 14px;
620
- font-weight: 500;
621
- fill: rgb(var(--ax-comp-line-chart-text-color));
622
- pointer-events: none;
623
- `)
624
- .text(options.xAxisLabel);
774
+ const xTickAreaHeight = this.measureXAxisTickAreaHeight(this.xAxis.node(), dynamicXAxisTickFontSize, this.xAxisLabelLayout.tickAreaHeight);
775
+ if (options.xAxisLabel) {
776
+ this.createXAxisTitle(axesGroup, options.xAxisLabel, xTickAreaHeight, dynamicXAxisTickFontSize);
625
777
  }
626
778
  }
627
779
  if (showYAxis) {
@@ -650,7 +802,7 @@ class AXLineChartComponent extends AXChartComponent {
650
802
  .attr('stroke', 'rgba(var(--ax-comp-line-chart-grid-lines-color), 0.2)')
651
803
  .attr('stroke-dasharray', '2,2')
652
804
  .attr('stroke-opacity', '0.5');
653
- const dynamicYAxisTickFontSize = this.calculateDynamicFontSize(this.height, 30, this.MIN_FONT_SIZE_Y, this.MAX_FONT_SIZE_Y);
805
+ const dynamicYAxisTickFontSize = this.getYAxisTickFontSize(this.height);
654
806
  const yTickTexts = this.yAxis.selectAll('text').attr('style', `
655
807
  font-size: ${dynamicYAxisTickFontSize}px;
656
808
  font-weight: 400;
@@ -659,31 +811,13 @@ class AXLineChartComponent extends AXChartComponent {
659
811
  if (isRtl) {
660
812
  yTickTexts.attr('text-anchor', 'start');
661
813
  }
814
+ else {
815
+ yTickTexts.attr('text-anchor', 'end').attr('direction', 'ltr');
816
+ }
817
+ const yTickNodes = (yTickTexts.nodes() || []);
818
+ const maxTickLabelWidth = this.measureMaxYAxisTickLabelWidth(yTickNodes, dynamicYAxisTickFontSize);
662
819
  if (options.yAxisLabel) {
663
- // Calculate proper position for Y-axis title
664
- // Position it to the left of the tick labels with proper spacing
665
- const maxTickLabelWidth = this.calculateMaxYAxisTickLabelWidth();
666
- const padding = this.Y_AXIS_PADDING;
667
- // Position title to the left of the tick labels
668
- const labelY = -maxTickLabelWidth - padding;
669
- // Center the title vertically
670
- const labelX = -this.height / 2;
671
- axesGroup
672
- .append('text')
673
- .attr('class', 'ax-line-chart-axis-label ax-y-axis-label')
674
- .attr('text-anchor', 'middle')
675
- .attr('dominant-baseline', 'middle')
676
- .attr('transform', 'rotate(-90)')
677
- .attr('x', labelX)
678
- .attr('y', labelY)
679
- .attr('direction', 'ltr')
680
- .attr('style', `
681
- font-size: 14px;
682
- font-weight: 500;
683
- fill: rgb(var(--ax-comp-line-chart-text-color));
684
- pointer-events: none;
685
- `)
686
- .text(options.yAxisLabel);
820
+ this.createYAxisTitle(axesGroup, options.yAxisLabel, dynamicYAxisTickFontSize, maxTickLabelWidth);
687
821
  }
688
822
  }
689
823
  if (showGrid) {
@@ -716,7 +850,7 @@ class AXLineChartComponent extends AXChartComponent {
716
850
  .axisBottom(this.xScale)
717
851
  .tickSize(-this.height)
718
852
  .tickFormat(() => '')
719
- .tickValues(isBandScale ? undefined : this.xScale.ticks()));
853
+ .tickValues(isPointScale ? undefined : this.xScale.ticks()));
720
854
  // Style the vertical grid path and lines
721
855
  xGrid.selectAll('path').attr('stroke-width', '0');
722
856
  xGrid
@@ -728,16 +862,14 @@ class AXLineChartComponent extends AXChartComponent {
728
862
  }
729
863
  }
730
864
  }
865
+ getXPosition(point) {
866
+ if (this.xScaleKind === 'point') {
867
+ return this.xScale(String(point.x)) ?? 0;
868
+ }
869
+ return this.xScale(point.x);
870
+ }
731
871
  renderLines(allSeriesData) {
732
- const isBandScale = this.xScale.bandwidth !== undefined;
733
- const getX = (d) => {
734
- if (isBandScale) {
735
- return this.xScale(String(d.x)) + this.xScale.bandwidth() / 2;
736
- }
737
- else {
738
- return this.xScale(d.x);
739
- }
740
- };
872
+ const getX = (d) => this.getXPosition(d);
741
873
  const lineGenerator = this.d3
742
874
  .line()
743
875
  .x(getX)
@@ -1024,14 +1156,7 @@ class AXLineChartComponent extends AXChartComponent {
1024
1156
  }
1025
1157
  }
1026
1158
  showCrosshairLines(dataPoint) {
1027
- const isBandScale = this.xScale.bandwidth !== undefined;
1028
- let x;
1029
- if (isBandScale) {
1030
- x = this.xScale(String(dataPoint.x)) + this.xScale.bandwidth() / 2;
1031
- }
1032
- else {
1033
- x = this.xScale(dataPoint.x);
1034
- }
1159
+ const x = this.getXPosition(dataPoint);
1035
1160
  const y = this.yScale(dataPoint.y);
1036
1161
  let crosshairGroup = this.chart.select('.ax-line-chart-crosshair');
1037
1162
  if (crosshairGroup.empty()) {
@@ -1191,11 +1316,11 @@ class AXLineChartComponent extends AXChartComponent {
1191
1316
  }
1192
1317
  }
1193
1318
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXLineChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
1194
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.3.3", 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:0;box-sizing:border-box;--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;box-sizing:border-box;padding:clamp(.5rem,1.2vw,.875rem);border-radius:.5rem;overflow:hidden;contain:layout style;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{display:block;width:100%;height:100%;max-width:100%;max-height:100%;overflow:hidden;flex-shrink:0}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 });
1319
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.3.3", 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:0;box-sizing:border-box;--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;box-sizing:border-box;padding:clamp(.25rem,.6vw,.5rem);border-radius:.5rem;overflow:hidden;contain:layout style;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{display:block;width:100%;height:100%;max-width:100%;max-height:100%;overflow:hidden;flex-shrink:0}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 });
1195
1320
  }
1196
1321
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXLineChartComponent, decorators: [{
1197
1322
  type: Component,
1198
- args: [{ selector: 'ax-line-chart', encapsulation: ViewEncapsulation.None, imports: [AXChartTooltipComponent], changeDetection: ChangeDetectionStrategy.OnPush, 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:0;box-sizing:border-box;--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;box-sizing:border-box;padding:clamp(.5rem,1.2vw,.875rem);border-radius:.5rem;overflow:hidden;contain:layout style;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{display:block;width:100%;height:100%;max-width:100%;max-height:100%;overflow:hidden;flex-shrink:0}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"] }]
1323
+ args: [{ selector: 'ax-line-chart', encapsulation: ViewEncapsulation.None, imports: [AXChartTooltipComponent], changeDetection: ChangeDetectionStrategy.OnPush, 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:0;box-sizing:border-box;--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;box-sizing:border-box;padding:clamp(.25rem,.6vw,.5rem);border-radius:.5rem;overflow:hidden;contain:layout style;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{display:block;width:100%;height:100%;max-width:100%;max-height:100%;overflow:hidden;flex-shrink:0}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"] }]
1199
1324
  }] });
1200
1325
 
1201
1326
  /**