• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

terrestris / d3-util / 6691537576

30 Oct 2023 10:33AM UTC coverage: 68.609%. Remained the same
6691537576

push

github

web-flow
Update dependencies (#197)

295 of 496 branches covered (0.0%)

Branch coverage included in aggregate %.

6 of 6 new or added lines in 3 files covered. (100.0%)

741 of 1014 relevant lines covered (73.08%)

4.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.76
/src/TimeseriesComponent/TimeseriesComponent.ts
1
import ChartDataUtil from '../ChartDataUtil/ChartDataUtil';
2✔
2
import ScaleUtil, { Scale, Scales } from '../ScaleUtil/ScaleUtil';
2✔
3
import AxesUtil, { AxisConfiguration } from '../AxesUtil/AxesUtil';
2✔
4
import BaseUtil, { NodeSelection, BackgroundConfiguration, TitleConfiguration } from '../BaseUtil/BaseUtil';
2✔
5
import LabelUtil from '../LabelUtil/LabelUtil';
2✔
6
import {
2✔
7
  xyzoomIdentity,
8
  xyzoom,
9
  xyzoomTransform as transform
10
} from 'd3-xyzoom';
11
import { ZoomBehavior, ZoomTransform } from 'd3-zoom';
12
import { select, event, ValueFn } from 'd3-selection';
2✔
13
import { tip as d3tip } from 'd3';
2✔
14
import { color as d3color } from 'd3-color';
2✔
15
import {
2✔
16
  curveStepBefore as stepBefore,
17
  curveStepAfter as stepAfter,
18
  curveLinear as linear,
19
  curveStep as step,
20
  curveBasis as basis,
21
  curveNatural as natural,
22
  curveMonotoneX as monotoneX,
23
  line as d3line,
24
  area as d3area
25
} from 'd3-shape';
26
import { ChartComponent, ZoomType } from '../ChartRenderer/ChartRenderer';
27

28
export type TimeseriesDatum = [number, number, Function | undefined, any | undefined];
29

30
export type ShapeType = 'line' | 'area';
31

32
export interface Timeseries {
33
  color?: string;
34
  showTooltip?: boolean;
35
  useTooltipFunc?: boolean;
36
  data: TimeseriesDatum[];
37
  axes: string[];
38
  curveType?: string;
39
  style?: any;
40
  initiallyVisible?: boolean;
41
  skipLine?: boolean;
42
  skipDots?: boolean;
43
}
44

45
export interface TimeseriesConfiguration extends BackgroundConfiguration, TitleConfiguration {
46
  size?: [number, number];
47
  position?: [number, number];
48
  series: Timeseries[];
49
  shapeType?: ShapeType;
50
  axes: {
51
    [name: string]: AxisConfiguration;
52
  };
53
  initialZoom?: ZoomTransform;
54
  extraClasses?: string;
55
  moveToRight?: boolean;
56
}
57

58
export interface YScales {
59
  [name: string]: Scale;
60
}
61

62
/**
63
 * A component that can be used in the chart renderer to render a timeseries
64
 * chart.
65
 */
66
class TimeseriesComponent implements ChartComponent {
2✔
67

68
  config: TimeseriesConfiguration;
69
  originalScales: Scales;
70
  zoomType: ZoomType;
71
  zoomBehaviour: ZoomBehavior<Element, {}>;
72
  xOffset: number;
73
  preventYAxisZoom: boolean;
74
  preventXAxisZoom: boolean;
75
  static counter: number = 0;
2✔
76
  rootNode: NodeSelection;
77
  yScales: YScales;
78
  mainScaleX: Scale;
79
  svgSize: [number, number];
80
  clipId: string;
81

82
  /**
83
   * Constructs a new timeseries component with a given configuration.
84
   * @param {object} config a configuration object
85
   */
86
  constructor(config: TimeseriesConfiguration) {
87
    this.config = config;
3✔
88
    if (config.size &&
3!
89
      (!Number.isFinite(config.size[0]) || !Number.isFinite(config.size[1]))
90
    ) {
91
      throw 'Invalid size config passed to TimeSeriesComponent: ' + config.size;
×
92
    }
93
    this.fillDefaults(config);
3✔
94
    this.originalScales = ScaleUtil.createScales(this.config);
3✔
95
  }
96

97
  /**
98
   * Fills in missing default values into the configuration.
99
   * @param  {object} config the object to fill the defaults in
100
   */
101
  fillDefaults(config: TimeseriesConfiguration) {
2✔
102
    config.series.forEach(line => {
3✔
103
      if (!line.color) {
3✔
104
        line.color = '#';
3✔
105
        [0, 1, 2, 3, 4, 5].forEach(() => line.color += '0123456789ABCDEF'[Math.floor(Math.random() * 16)]);
18✔
106
      }
107
    });
108
  }
109

110
  /**
111
   * Render the dots of the timeseries.
112
   * @param  {d3.selection} g the g node to render the dots into
113
   * @param  {object} line the series configuration
114
   * @param  {number} idx the series index
115
   * @param  {d3.scale} x the x scale
116
   * @param  {d3.scale} y the y scale
117
   */
118
  renderDots(g: NodeSelection, line: Timeseries, idx: number, x: Scale, y: Scale) {
2✔
119
    /** Empty fn. */
120
    let over = (...args: any): any => undefined;
2✔
121
    /** Empty fn. */
122
    let out = (...args: any): any => undefined;
2✔
123

124
    if (line.showTooltip) {
2!
125
      const tip: any = d3tip().attr('class', 'd3-tip').html((d: any) => d[1]);
×
126
      g.call(tip);
×
127
      over = tip.show;
×
128
      out = tip.hide;
×
129
    }
130
    if (line.useTooltipFunc) {
2!
131
      over = (d: any[], index: number, dots: any) => {
×
132
        d[2](dots[index]);
×
133
      };
134
      out = (d: any[], index: number, dots: any) => {
×
135
        if (d[4]) {
×
136
          d[4](dots[index]);
×
137
        }
138
      };
139
    }
140

141
    this.renderCircles(g, idx, line, x, y, over, out);
2✔
142
    this.renderRects(g, idx, line, x, y, over, out);
2✔
143
    this.renderStars(g, idx, line, x, y, over, out);
2✔
144
  }
145

146
  /**
147
   * Render circle type dots.
148
   * @param  {d3.selection} g to render the circles to
149
   * @param  {Number} idx index of the series
150
   * @param  {Object} line the series config
151
   * @param  {d3.scale} x x scale
152
   * @param  {d3.scale} y y scale
153
   * @param  {Function} over the mouseover callback
154
   * @param  {Function} out the mouseout callback
155
   */
156
  renderCircles(
2✔
157
    g: NodeSelection,
158
    idx: number,
159
    line: Timeseries,
160
    x: Scale,
161
    y: Scale,
162
    over: ValueFn<SVGElement, any[], void>,
163
    out: ValueFn<SVGElement, any[], void>
164
  ) {
165
    g.selectAll(`circle.series-${idx}`)
2✔
166
      .data(line.data)
167
      .enter()
168
      .filter(d => {
169
        if (d && d[3] && d[3].type && d[3].type !== 'circle') {
10✔
170
          return false;
6✔
171
        }
172
        return d !== undefined;
4✔
173
      })
174
      .append('circle')
175
      .attr('class', `series-${idx}`)
176
      .attr('cx', d => x(d[0] as any))
4✔
177
      .attr('cy', d => y(d[1] as any))
4✔
178
      .attr('r', d => d[3] ? d[3].radius || 5 : 5)
4✔
179
      .attr('fill', line.color)
180
      .style('fill', d => d[3] ? d[3].fill : undefined)
4✔
181
      .style('stroke', d => d[3] ? d[3].stroke : undefined)
4✔
182
      .on('mouseover', over)
183
      .on('mouseout', out);
184
  }
185

186
  /**
187
   * Render rectangle type dots.
188
   * @param  {d3.selection} g to render the rects to
189
   * @param  {Number} idx index of the series
190
   * @param  {Object} line the series config
191
   * @param  {d3.scale} x x scale
192
   * @param  {d3.scale} y y scale
193
   * @param  {Function} over the mouseover callback
194
   * @param  {Function} out the mouseout callback
195
   */
196
  renderStars(
2✔
197
    g: NodeSelection,
198
    idx: number,
199
    line: Timeseries,
200
    x: Scale,
201
    y: Scale,
202
    over: ValueFn<SVGElement, any[], void>,
203
    out: ValueFn<SVGElement, any[], void>
204
  ) {
205
    g.selectAll('polygon')
2✔
206
      .data(line.data)
207
      .enter()
208
      .filter(d => {
209
        if (d && d[3] && d[3].type && d[3].type !== 'star') {
10✔
210
          return false;
4✔
211
        }
212
        if (d && d[3] && d[3].type === 'star') {
6✔
213
          return true;
4✔
214
        }
215
        return false;
2✔
216
      })
217
      .append('svg')
218
      .attr('x', d => {
219
        let val = x(d[0] as any);
4✔
220
        if (d[3] && d[3].radius) {
4✔
221
          val -= d[3].radius;
2✔
222
        } else {
223
          val -= 10;
2✔
224
        }
225
        return val;
4✔
226
      })
227
      .attr('y', d => {
228
        let val = y(d[1] as any);
4✔
229
        if (d[3] && d[3].radius) {
4✔
230
          val -= d[3].radius;
2✔
231
        } else {
232
          val -= 10;
2✔
233
        }
234
        return val;
4✔
235
      })
236
      .attr('width', d => {
237
        let val = 20;
4✔
238
        if (d[3] && d[3].radius) {
4✔
239
          val = d[3].radius * 2;
2✔
240
        }
241
        return val;
4✔
242
      })
243
      .attr('height', d => {
244
        let val = 20;
4✔
245
        if (d[3] && d[3].radius) {
4✔
246
          val = d[3].radius * 2;
2✔
247
        }
248
        return val;
4✔
249
      })
250
      .append('polygon')
251
      .style('fill', d => {
252
        let color;
253
        if (d && d[3] && d[3].fill) {
4✔
254
          color = d[3].fill;
2✔
255
        } else {
256
          color = line.color;
2✔
257
        }
258
        return color;
4✔
259
      })
260
      .style('stroke', d => {
261
        let color;
262
        if (d && d[3] && d[3].stroke) {
4✔
263
          color = d[3].stroke;
2✔
264
        } else {
265
          color = d3color(line.color).darker();
2✔
266
        }
267
        return color;
4✔
268
      })
269
      .style('stroke-width', 1)
270
      .on('mouseover', over)
271
      .on('mouseout', out)
272
      .attr('points', function (d: any[]) {
273
        // inspired by http://svgdiscovery.com/C02/create-svg-star-polygon.htm
274
        let radius = 10;
4✔
275
        let sides = 5;
4✔
276
        if (d[3] && d[3].radius) {
4✔
277
          const r = d[3].radius;
2✔
278
          if (r) {
2✔
279
            radius = parseInt(r, 10);
2✔
280
          }
281
        }
282
        if (d[3] && d[3].sides) {
4✔
283
          const s = d[3].sides;
2✔
284
          if (s) {
2✔
285
            sides = parseInt(s, 10);
2✔
286
          }
287
        }
288
        const theta = Math.PI * 2 / sides;
4✔
289
        const x0: number = radius;
4✔
290
        const y0: number = radius;
4✔
291
        let star = '';
4✔
292
        for (let i = 0; i < sides; i++) {
4✔
293
          const k = i + 1;
34✔
294
          const sineAngle = Math.sin(theta * k);
34✔
295
          const cosineAngle = Math.cos(theta * k);
34✔
296
          const x1 = radius / 2 * sineAngle + x0;
34✔
297
          const y1 = radius / 2 * cosineAngle + y0;
34✔
298
          const sineAngleAlpha = Math.sin(theta * k + 0.5 * theta);
34✔
299
          const cosineAngleAlpha = Math.cos(theta * k + 0.5 * theta);
34✔
300
          const x2 = radius * sineAngleAlpha + x0;
34✔
301
          const y2 = radius * cosineAngleAlpha + y0;
34✔
302
          star += x1 + ',' + y1 + ' ';
34✔
303
          star += x2 + ',' + y2 + ' ';
34✔
304
        }
305
        return star;
4✔
306
      });
307
  }
308

309
  /**
310
   * Render rectangle type dots.
311
   * @param  {d3.selection} g to render the rects to
312
   * @param  {Number} idx index of the series
313
   * @param  {Object} line the series config
314
   * @param  {d3.scale} x x scale
315
   * @param  {d3.scale} y y scale
316
   * @param  {Function} over the mouseover callback
317
   * @param  {Function} out the mouseout callback
318
   */
319
  renderRects(
2✔
320
    g: NodeSelection,
321
    idx: number,
322
    line: Timeseries,
323
    x: Scale,
324
    y: Scale,
325
    over: ValueFn<SVGElement, any[], void>,
326
    out: ValueFn<SVGElement, any[], void>
327
  ) {
328
    g.selectAll('rect')
2✔
329
      .data(line.data)
330
      .enter()
331
      .filter(d => {
332
        if (d && d[3] && d[3].type && d[3].type !== 'rect') {
10✔
333
          return false;
6✔
334
        }
335
        if (d && d[3] && d[3].type === 'rect') {
4✔
336
          return true;
2✔
337
        }
338
        return false;
2✔
339
      })
340
      .append('rect')
341
      .style('fill', d => {
342
        if (d[3] && d[3].fill) {
2✔
343
          return d[3].fill;
2✔
344
        }
345
        return line.color;
×
346
      })
347
      .style('stroke', d => {
348
        let color;
349
        if (d[3] && d[3].fill) {
2✔
350
          color = d[3].fill;
2✔
351
        }
352
        color = line.color;
2✔
353
        return d3color(color).darker().toString();
2✔
354
      })
355
      .style('stroke-width', 2)
356
      .on('mouseover', over)
357
      .on('mouseout', out)
358
      .attr('x', d => {
359
        let val = x(d[0] as any);
2✔
360
        if (d[3] && d[3].width) {
2!
361
          val -= d[3].width / 2;
×
362
        } else {
363
          val -= 5;
2✔
364
        }
365
        return val;
2✔
366
      })
367
      .attr('y', d => {
368
        let val = y(d[1] as any);
2✔
369
        if (d[3] && d[3].height) {
2!
370
          val -= d[3].height / 2;
×
371
        } else {
372
          val -= 5;
2✔
373
        }
374
        return val;
2✔
375
      })
376
      .attr('width', d => {
377
        if (d[3] && d[3].width) {
2!
378
          return d[3].width;
×
379
        }
380
        return 10;
2✔
381
      })
382
      .attr('height', d => {
383
        if (d[3] && d[3].height) {
2!
384
          return d[3].height;
×
385
        }
386
        return 10;
2✔
387
      });
388
  }
389

390
  /**
391
   * Connect the dots with a line or as an area
392
   * @param  {d3.selection} lineg the g node to render the line into
393
   * @param  {object} line the series configuration
394
   * @param  {number} idx the series index
395
   * @param  {d3.scale} x the x scale
396
   * @param  {d3.scale} y the y scale
397
   */
398
  renderLineOrArea(
2✔
399
    lineg: NodeSelection,
400
    line: Timeseries,
401
    idx: number,
402
    x: Scale,
403
    y: Scale
404
  ) {
405
    if (this.config.shapeType === 'area') {
2!
406
      this.renderArea(lineg, line, idx, x, y);
×
407
    } else {
408
      this.renderLine(lineg, line, idx, x, y);
2✔
409
    }
410
  }
411

412
  /**
413
   * Connect the dots with a line.
414
   * @param  {d3.selection} g the g node to render the line into
415
   * @param  {object} line the series configuration
416
   * @param  {number} idx the series index
417
   * @param  {d3.scale} x the x scale
418
   * @param  {d3.scale} y the y scale
419
   */
420
  renderLine(
2✔
421
    g: NodeSelection,
422
    line: Timeseries,
423
    idx: number,
424
    x: Scale,
425
    y: Scale
426
  ) {
427
    const lineData = ChartDataUtil.lineDataFromPointData(line.data);
2✔
428
    let curve: any;
429
    switch (line.curveType) {
2!
430
      case undefined:
431
      case 'linear':
432
        curve = linear;
2✔
433
        break;
2✔
434
      case 'cubicBasisSpline':
435
        curve = basis;
×
436
        break;
×
437
      case 'curveMonotoneX':
438
        curve = monotoneX;
×
439
        break;
×
440
      case 'naturalCubicSpline':
441
        curve = natural;
×
442
        break;
×
443
      case 'curveStep':
444
        curve = step;
×
445
        break;
×
446
      case 'curveStepBefore':
447
        curve = stepBefore;
×
448
        break;
×
449
      case 'curveStepAfter':
450
        curve = stepAfter;
×
451
        break;
×
452
      default:
453
    }
454
    lineData.forEach(data => {
2✔
455
      const generator = d3line()
2✔
456
        .curve(curve)
457
        .x(((d: TimeseriesDatum) => x(d[0] as any)) as any)
10✔
458
        .y(((d: TimeseriesDatum) => y(d[1] as any)) as any);
10✔
459
      const width = line.style ? line.style['stroke-width'] : 1;
2!
460
      const dash = line.style ? line.style['stroke-dasharray'] : undefined;
2!
461
      const color = (line.style && line.style.stroke) ? line.style.stroke : line.color;
2!
462

463
      g.append('path')
2✔
464
        .datum(data)
465
        .attr('d', generator)
466
        .attr('class', `series-${idx}`)
467
        .style('fill', 'none')
468
        .style('stroke-width', width)
469
        .style('stroke-dasharray', dash)
470
        .style('stroke', color);
471
    });
472
  }
473

474
  /**
475
   * Connect the dots with a line as area.
476
   * @param  {d3.selection} g the g node to render the line into
477
   * @param  {object} line the series configuration
478
   * @param  {number} idx the series index
479
   * @param  {d3.scale} x the x scale
480
   * @param  {d3.scale} y the y scale
481
   */
482
  renderArea(
2✔
483
    g: NodeSelection,
484
    line: Timeseries,
485
    idx: number,
486
    x: Scale,
487
    y: Scale
488
  ) {
489
    const lineData = ChartDataUtil.lineDataFromPointData(line.data);
×
490
    let curve: any;
491
    switch (line.curveType) {
×
492
      case undefined:
493
      case 'linear':
494
        curve = linear;
×
495
        break;
×
496
      case 'cubicBasisSpline':
497
        curve = basis;
×
498
        break;
×
499
      case 'curveMonotoneX':
500
        curve = monotoneX;
×
501
        break;
×
502
      case 'naturalCubicSpline':
503
        curve = natural;
×
504
        break;
×
505
      case 'curveStep':
506
        curve = step;
×
507
        break;
×
508
      case 'curveStepBefore':
509
        curve = stepBefore;
×
510
        break;
×
511
      case 'curveStepAfter':
512
        curve = stepAfter;
×
513
        break;
×
514
      default:
515
    }
516
    lineData.forEach(data => {
×
517
      const generator = d3area()
×
518
        .curve(curve)
519
        .x(((d: TimeseriesDatum) => x(d[0] as any)) as any)
×
520
        .y1(((d: TimeseriesDatum) => y(d[1] as any)) as any)
×
521
        .y0(y(0 as any));
522

523
      const width = line.style ? line.style['stroke-width'] : 1;
×
524
      const dash = line.style ? line.style['stroke-dasharray'] : undefined;
×
525
      const color = (line.style && line.style.stroke) ? line.style.stroke : line.color;
×
526
      const fillColor = (line.style && line.style.fill) ? line.style.fill : line.color;
×
527

528
      g.append('path')
×
529
        .datum(data)
530
        .attr('d', generator)
531
        .attr('class', `series-${idx}`)
532
        .style('fill', fillColor)
533
        .style('stroke-width', width)
534
        .style('stroke-dasharray', dash)
535
        .style('stroke', color);
536
    });
537
  }
538

539
  /**
540
   * Calculate the width of all currently visible axes.
541
   * @param {d3.selection} node a node below which the axes are rendered
542
   * @return {Number} the width of all axes
543
   */
544
  calculateAxesWidth(node: NodeSelection) {
2✔
545
    const axisElems = node.selectAll('.y-axes').nodes();
12✔
546
    return axisElems.reduce((acc, cur: SVGElement) => acc + cur.getBoundingClientRect().width, 0);
12✔
547
  }
548

549
  /**
550
   * Calculate the height of all currently visible axes.
551
   * @param {d3.selection} node a node below which the axes are rendered
552
   * @return {Number} the height of all axes
553
   */
554
  calculateAxesHeight(node: NodeSelection) {
2✔
555
    const axisElems = node.selectAll('.x-axis').nodes();
×
556
    return axisElems.reduce((acc, cur: SVGElement) => acc + cur.getBoundingClientRect().height, 0);
×
557
  }
558

559
  /**
560
   * Creates an d3 axis from the given scale.
561
   * @param  {d3.scale} y the y scale
562
   * @param  {object} series the series configuration
563
   * @param  {selection} selection the d3 selection to append the axes to
564
   */
565
  drawYAxis(y: Scale, series: Timeseries, selection: NodeSelection) {
2✔
566
    const config = this.config.axes[series.axes[1]];
4✔
567
    if (!config.display) {
4!
568
      return;
×
569
    }
570
    const yAxis = AxesUtil.createYAxis(config, y);
4✔
571

572
    let width = this.calculateAxesWidth(select(selection.node().parentNode as any) as NodeSelection);
4✔
573
    let pad = config.labelSize || 13;
4✔
574
    if (config.labelPadding) {
4!
575
      pad += config.labelPadding;
×
576
    }
577

578
    const axis = selection.append('g')
4✔
579
      .attr('class', 'y-axis')
580
      .attr('transform', `translate(${width}, 0)`);
581
    axis.append('g')
4✔
582
      .attr('transform', `translate(${pad}, 0)`)
583
      .call(yAxis);
584
    if (config.sanitizeLabels) {
4!
585
      AxesUtil.sanitizeAxisLabels(axis);
×
586
    }
587
    const axisHeight = axis.node().getBoundingClientRect().height;
4✔
588
    const axisWidth = axis.node().getBoundingClientRect().width;
4✔
589
    axis.attr('transform', `translate(${width + axisWidth}, 0)`);
4✔
590
    if (config.label) {
4!
591
      axis.append('text')
×
592
        .attr('transform', `rotate(-90)`)
593
        .attr('x', -axisHeight / 2)
594
        .attr('y', (config.labelSize || 13) - axisWidth)
×
595
        .style('text-anchor', 'middle')
596
        .style('font-size', config.labelSize || 13)
×
597
        .style('fill', config.labelColor)
598
        .text(config.label);
599
    }
600
  }
601

602
  /**
603
   * Draws a y grid axis.
604
   * @param {d3.scale} y the y scale
605
   * @param {Object} config the axis configuration
606
   * @param {d3.selection} selection to append the grid to
607
   * @param {Number[]} size the chart size
608
   */
609
  drawYGridAxis(
2✔
610
    y: Scale,
611
    config: AxisConfiguration,
612
    selection: NodeSelection,
613
    size: [number, number]
614
  ) {
615
    if (!config.display || !config.showGrid) {
4!
616
      return;
×
617
    }
618
    let width = this.calculateAxesWidth(select(selection.node().parentNode as any) as NodeSelection);
4✔
619
    const gridAxis = AxesUtil.createYAxis(config, y);
4✔
620
    gridAxis
4✔
621
      .tickFormat('' as null)
622
      .tickSize(-(size[0] - width));
623
    selection.append('g')
4✔
624
      .attr('transform', `translate(${width}, 0)`)
625
      .attr('class', 'y-axis')
626
      .style('stroke-width', config.gridWidth || 1)
8✔
627
      .style('color', config.gridColor || '#d3d3d3')
8✔
628
      .style('stroke', config.gridColor || '#d3d3d3')
8✔
629
      .style('stroke-opacity', config.gridOpacity || 0.7)
8✔
630
      .call(gridAxis);
631
  }
632

633
  /**
634
   * Creates the x axis for a chart.
635
   * @param  {d3.scale} x the d3 scale
636
   * @param  {d3.selection} selection the d3 selection to add the axis to
637
   * @param  {number[]} size the remaining chart size
638
   * @param  {number} width the x offset to draw the chart at
639
   */
640
  drawXAxis(
2✔
641
    x: Scale,
642
    selection: NodeSelection,
643
    size: [number, number],
644
    width: number
645
  ) {
646
    const config = Object.values(this.config.axes).find(item => item.orientation === 'x');
4✔
647
    const xAxis = AxesUtil.createXAxis(config, x);
4✔
648

649
    const axis = selection.insert('g', ':first-child')
4✔
650
      .attr('transform', `translate(${width}, ${size[1]})`)
651
      .attr('class', 'x-axis')
652
      .call(xAxis);
653

654
    if (config.labelRotation) {
4!
655
      axis.selectAll('text')
×
656
        .attr('transform', `rotate(${config.labelRotation})`)
657
        .attr('dx', '-10px')
658
        .attr('dy', '1px')
659
        .style('text-anchor', 'end');
660
    } else {
661
      LabelUtil.handleLabelWrap(axis as NodeSelection);
4✔
662
    }
663
    if (config.label) {
4!
664
      let height = this.calculateAxesHeight(select(selection.node().parentNode as any) as NodeSelection);
×
665
      axis.append('text')
×
666
        .attr('y', (config.labelSize || 13) + height)
×
667
        .attr('x', size[0])
668
        .style('text-anchor', 'end')
669
        .style('font-size', config.labelSize || 13)
×
670
        .style('fill', config.labelColor)
671
        .text(config.label);
672
    }
673

674
    if (config.showGrid) {
4!
675
      const gridAxis = AxesUtil.createXAxis(config, x);
×
676
      gridAxis
×
677
        .tickFormat('' as null)
678
        .tickSize(-size[1]);
679
      selection.insert('g', ':first-child')
×
680
        .attr('transform', `translate(${width}, ${size[1]})`)
681
        .attr('class', 'x-grid-axis')
682
        .style('stroke-width', config.gridWidth || 1)
×
683
        .style('color', config.gridColor || '#d3d3d3')
×
684
        .style('stroke', config.gridColor || '#d3d3d3')
×
685
        .style('stroke-opacity', config.gridOpacity || 0.7)
×
686
        .call(gridAxis);
687
    }
688
  }
689

690
  /**
691
   * Enables zoom on the chart.
692
   * @param {d3.selection} root the node for which to enable zoom
693
   * @param {String} zoomType the zoom type
694
   */
695
  enableZoom(root: NodeSelection, zoomType: ZoomType) {
2✔
696
    this.zoomType = zoomType;
1✔
697
    if (zoomType === 'none') {
1!
698
      return;
×
699
    }
700

701
    this.zoomBehaviour = xyzoom()
1✔
702
      .extent([[0, 0], [this.config.size[0] - this.xOffset, this.config.size[1]]])
703
      .translateExtent([[0, 0], [this.config.size[0] - this.xOffset, this.config.size[1]]])
704
      .scaleExtent([1, Infinity])
705
      .on('zoom', () => {
706
        const eventTransform = event.transform;
×
707
        if (!this.preventXAxisZoom) {
×
708
          this.mainScaleX = eventTransform.rescaleX(this.originalScales.XSCALE);
×
709
        }
710

711
        if (!this.preventYAxisZoom) {
×
712
          this.yScales = {};
×
713
          Object.entries(this.originalScales)
×
714
            .filter(entry => this.config.axes[entry[0]] && this.config.axes[entry[0]].orientation === 'y')
×
715
            .forEach(([key, scale]) => this.yScales[key] = eventTransform.rescaleY(scale));
×
716
        }
717

718
        if (zoomType === 'transform') {
×
719
          root.selectAll('.x-axis').remove();
×
720
          root.selectAll('.y-axis').remove();
×
721
          root.selectAll('.timeseries-chart')
×
722
            .attr('transform', eventTransform);
723
        }
724
        this.render(root, this.svgSize, true);
×
725
      });
726

727
    const zoomSelection = root.select('.timeseries-chart');
1✔
728
    zoomSelection.call(this.zoomBehaviour);
1✔
729
    if (this.config.initialZoom) {
1!
730
      this.yScales = {};
×
731
      const trans = xyzoomIdentity
×
732
        .translate(this.config.initialZoom.x, this.config.initialZoom.y)
733
        .scale(
734
          (this.config.initialZoom as any).kx || this.config.initialZoom.k || 1,
×
735
          (this.config.initialZoom as any).ky || this.config.initialZoom.k || 1
×
736
        );
737
      Object.entries(this.originalScales)
×
738
        .filter(entry => this.config.axes[entry[0]] && this.config.axes[entry[0]].orientation === 'y')
×
739
        .forEach(([key, scale]) => this.yScales[key] = trans.rescaleY(scale));
×
740
      this.zoomBehaviour.transform(zoomSelection as NodeSelection, trans);
×
741
      if (this.config.moveToRight) {
×
742
        this.zoomBehaviour.translateTo(
×
743
          zoomSelection as NodeSelection,
744
          -this.mainScaleX(this.mainScaleX.domain()[1] as any),
745
          this.config.initialZoom.y
746
        );
747
      }
748
    }
749
  }
750

751
  /**
752
   * Append a clipping rect. this.clipId will contain the clipPath's id.
753
   * @param  {d3.selection} root svg node to append the clip rect to
754
   * @param  {Number} x where to start clipping
755
   * @param  {Number} y where to start clipping
756
   * @param  {Number} width where to end clipping
757
   * @param  {Number} height where to end clipping
758
   */
759
  appendClipRect(root: NodeSelection, x: number, y: number, width: number, height: number) {
2✔
760
    this.clipId = `clip-path-${++TimeseriesComponent.counter}`;
2✔
761
    root.select('defs').remove();
2✔
762
    root.append('defs')
2✔
763
      .append('clipPath')
764
      .attr('id', this.clipId)
765
      .append('rect')
766
      .attr('x', x)
767
      .attr('y', y)
768
      .attr('width', width)
769
      .attr('height', height);
770
  }
771

772
  /**
773
   * Determine the space the axes need by prerendering and removing them.
774
   * @param {boolean} rerender whether in rerender mode (probably doesn't matter here)
775
   * @param {d3.selection} g the node to render the axes to
776
   */
777
  determineAxesSpace(rerender: boolean, g: NodeSelection) {
2✔
778
    const x = rerender ? this.mainScaleX : this.originalScales.XSCALE.range([0, this.config.size[0]]);
2!
779
    this.prepareYAxes(rerender, g, [0, this.config.size[1]]);
2✔
780
    const width = this.calculateAxesWidth(g);
2✔
781
    this.drawXAxis(x as Scale, g, this.config.size, this.config.size[1]);
2✔
782
    const xAxisNode = g.select('.x-axis');
2✔
783
    const height = (xAxisNode.node() as Element).getBoundingClientRect().height;
2✔
784
    xAxisNode.remove();
2✔
785
    g.selectAll('.x-grid-axis,.y-axes,.y-grid-axes').remove();
2✔
786
    return [width, height];
2✔
787
  }
788

789
  /**
790
   * Render the timeseries to the given root node.
791
   * @param  {d3.selection} root the root node
792
   * @param  {number[]} size the size of the svg
793
   * @param  {boolean} rerender if true, rerendering mode is enabled
794
   */
795
  render(root: NodeSelection, size: [number, number], rerender?: boolean) {
2✔
796
    if (!this.config.series || this.config.series.length === 0) {
2!
797
      // refuse to render chart without series
798
      return;
×
799
    }
800
    this.rootNode = root;
2✔
801
    if (this.config.backgroundColor) {
2!
802
      root.style('background-color', this.config.backgroundColor);
×
803
    }
804

805
    let g = root.selectAll('g.timeseries');
2✔
806
    let chartRoot = g.selectAll('.timeseries-chart');
2✔
807
    let visibleState: any = {};
2✔
808
    if (g.node() && rerender && this.zoomType !== 'transform') {
2!
809
      // save visibility state for later
810
      root.selectAll('.timeseries-data,.timeseries-line').each(function() {
×
811
        const node = this as HTMLElement;
×
812
        if (node.hasAttribute('visible')) {
×
813
          visibleState[node.getAttribute('class')] = node.getAttribute('visible');
×
814
        }
815
      });
816
      root.selectAll(`#${this.clipId}`).remove();
×
817
    }
818
    root.selectAll('.y-axes,.y-grid-axes').remove();
2✔
819
    const needRecreate = !g.node() || this.zoomType === 'rerender';
2!
820
    if (needRecreate) {
2✔
821
      if (!g.node()) {
2!
822
        g = root.append('g').attr('class', `timeseries ${this.config.extraClasses ? this.config.extraClasses : ''}`);
2!
823
        const clip = g.append('g').attr('class', 'timeseries-clip');
2✔
824
        chartRoot = clip.append('g').attr('class', 'timeseries-chart');
2✔
825
      } else {
826
        g.selectAll('.timeseries-data,.x-axis,.x-grid-axis,.timeseries-line').remove();
×
827
      }
828
    }
829
    const offsets = this.determineAxesSpace(rerender, g as NodeSelection);
2✔
830
    g.attr('transform', `translate(${this.config.position[0]}, ${this.config.position[1]})`);
2✔
831

832
    const yScales = this.prepareYAxes(rerender, g as NodeSelection, [0, this.config.size[1] - offsets[1]]);
2✔
833
    const width = this.calculateAxesWidth(g as NodeSelection);
2✔
834
    const x = rerender ? this.mainScaleX : this.originalScales.XSCALE.range([0, this.config.size[0] - width]);
2!
835

836
    if (needRecreate) {
2✔
837
      this.appendClipRect(root, width, 0, this.config.size[0] - width, this.config.size[1] - offsets[1]);
2✔
838
      root.select('.timeseries-clip')
2✔
839
        .attr('clip-path', `url(#${this.clipId})`);
840
    }
841

842
    this.drawXAxis(x as Scale, g as NodeSelection, [this.config.size[0], this.config.size[1] - offsets[1]], width);
2✔
843
    g.select('.x-axis').attr('transform', `translate(${width}, ${this.config.size[1] - offsets[1]})`);
2✔
844

845
    // IMPORTANT: need to put the transform on the element upon which the zoom
846
    // behaviour works, else centering the zoom on the mouse position will be
847
    // next to impossible
848
    chartRoot.attr('transform', `translate(${width}, 0)`);
2✔
849
    this.renderSeries(rerender, chartRoot as NodeSelection, x as Scale, yScales as Scale[]);
2✔
850

851
    this.yScales = yScales;
2✔
852
    this.mainScaleX = x as Scale;
2✔
853
    this.xOffset = width;
2✔
854
    this.svgSize = size;
2✔
855

856
    BaseUtil.addBackground(
2✔
857
      chartRoot as NodeSelection,
858
      width,
859
      this.config,
860
      [this.config.size[0], this.config.size[1] - offsets[1]]
861
    );
862
    root.select('.timeseries-title').remove();
2✔
863
    BaseUtil.addTitle(root, this.config, width);
2✔
864

865
    // restore visibility state of hidden items
866
    Object.keys(visibleState).forEach(cls => {
2✔
867
      const visible = visibleState[cls];
×
868
      if (visible === 'false') {
×
869
        root.select(`.${cls}`.replace(' ', '.'))
×
870
          .attr('visible', 'false')
871
          .style('display', 'none');
872
      }
873
    });
874
  }
875

876
  /**
877
   * Actually render the series (dots and lines).
878
   * @param {Boolean} rerender if rerender mode is enabled
879
   * @param {d3.selection} g the node to render to
880
   * @param {d3.scale} x the x scale
881
   * @param {d3.scale[]} yScales the y scales
882
   */
883
  renderSeries(rerender: boolean, g: NodeSelection, x: Scale, yScales: Scale[]) {
2✔
884
    this.config.series.forEach((line: any, idx) => {
2✔
885
      if (rerender && this.zoomType === 'transform') {
2!
886
        return;
×
887
      }
888
      const y = yScales[line.axes[1]];
2✔
889
      const dotsg = g.append('g')
2✔
890
        .attr('class', `series-${idx} timeseries-data`);
891
      const lineg = g.append('g')
2✔
892
        .attr('class', `series-${idx} timeseries-line`);
893
      if (!line.skipDots) {
2✔
894
        this.renderDots(dotsg, line, idx, x, y);
2✔
895
      }
896
      if (!line.skipLine) {
2✔
897
        this.renderLineOrArea(lineg, line, idx, x, y);
2✔
898
      }
899
      if (line.initiallyVisible === false) {
2!
900
        dotsg.style('display', 'none')
×
901
          .attr('visible', false);
902
        lineg.style('display', 'none')
×
903
          .attr('visible', false);
904
      }
905
    });
906
  }
907

908
  /**
909
   * Prepares and renders y axis/scales.
910
   * @param {Boolean} rerender whether we are in rerender mode
911
   * @param {d3.selection} node the node to render the axes to
912
   * @param {number[]} yRange the y scale range
913
   * @return {Function[]} the y scales in order of the series
914
   */
915
  prepareYAxes(rerender: boolean, node: NodeSelection, yRange: [number, number]) {
4✔
916
    const yScales: any = {};
4✔
917
    const yAxesDrawn: string[] = [];
4✔
918
    let g = node.insert('g', ':first-child').attr('class', 'y-axes');
4✔
919
    this.config.series.forEach(line => {
4✔
920
      // sanitize y values if a log scale is used
921
      if (this.config.axes[line.axes[1]].scale === 'log') {
4✔
922
        line.data = line.data
4✔
923
          .filter(d => d)
20✔
924
          .map(d => [d[0], d[1] === 0 ? ScaleUtil.EPSILON : d[1], d[2], d[3]] as TimeseriesDatum);
20!
925
      }
926
      let y = this.originalScales[line.axes[1]];
4✔
927
      if (rerender) {
4!
928
        y = this.yScales[line.axes[1]];
×
929
      }
930
      y.range(yRange);
4✔
931
      yScales[line.axes[1]] = y;
4✔
932
      if (this.config.axes[line.axes[1]].display &&
4✔
933
        !yAxesDrawn.includes(line.axes[1]) && line.initiallyVisible !== false) {
934
        this.drawYAxis(y, line, g);
4✔
935
        yAxesDrawn.push(line.axes[1]);
4✔
936
      }
937
    });
938
    g = node.insert('g', ':first-child').attr('class', 'y-grid-axes');
4✔
939
    Object.entries(this.config.axes).forEach(([key, config]) => {
8✔
940
      if (config.orientation === 'y') {
8✔
941
        this.drawYGridAxis(yScales[key], config, g, this.config.size);
4✔
942
      }
943
    });
944
    return yScales;
4✔
945
  }
946

947
  /**
948
   * Toggle the visibility of a series.
949
   * @param  {Number} index index of the series to toggle
950
   */
951
  toggleSeries(index: number) {
2✔
952
    const nodes = this.rootNode.selectAll(`g.series-${index}`);
×
953
    const visible = nodes.attr('visible');
×
954
    if (visible === 'false') {
×
955
      nodes.attr('visible', true);
×
956
      nodes.style('display', 'block');
×
957
    } else {
958
      nodes.attr('visible', false);
×
959
      nodes.style('display', 'none');
×
960
    }
961
  }
962

963
  /**
964
   * Return whether a series is currently visible.
965
   * @param  {Number} index the index of the series
966
   * @return {Boolean} whether the series is visible
967
   */
968
  visible(index: number) {
2✔
969
    const nodes = this.rootNode.selectAll(`g.series-${index}`);
×
970
    const visible = nodes.attr('visible');
×
971
    return visible !== 'false';
×
972
  }
973

974
  /**
975
   * Reset the zoom.
976
   */
977
  resetZoom() {
2✔
978
    (this.rootNode.select('.timeseries-chart') as NodeSelection)
×
979
      .transition().duration(750).call(this.zoomBehaviour.transform, xyzoomIdentity);
980
  }
981

982
  /**
983
   * Returns the current zoom transformation.
984
   * @return {d3.transform} the current transform
985
   */
986
  getCurrentZoom() {
2✔
987
    return transform((this.rootNode.select('.timeseries-chart') as NodeSelection).node());
×
988
  }
989

990
  /**
991
   * Set whether y axis zoom is enabled.
992
   * @param {boolean} enable if true, y axis zoom will be possible
993
   */
994
  enableYAxisZoom(enable: boolean) {
2✔
995
    this.preventYAxisZoom = !enable;
×
996
    if (this.zoomBehaviour) {
×
997
      (this.zoomBehaviour as any).scaleRatio([this.preventXAxisZoom ? 0 : 1, this.preventYAxisZoom ? 0 : 1]);
×
998
    }
999
  }
1000

1001
  /**
1002
   * Set whether x axis zoom is enabled.
1003
   * @param {boolean} enable if true, x axis zoom will be possible
1004
   */
1005
  enableXAxisZoom(enable: boolean) {
2✔
1006
    this.preventXAxisZoom = !enable;
×
1007
    if (this.zoomBehaviour) {
×
1008
      (this.zoomBehaviour as any).scaleRatio([this.preventXAxisZoom ? 0 : 1, this.preventYAxisZoom ? 0 : 1]);
×
1009
    }
1010
  }
1011

1012
}
2✔
1013

1014
export default TimeseriesComponent;
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc