x4js 2.3.9 → 2.3.11

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.
@@ -0,0 +1,758 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file chart.ts
9
+ * @author Etienne Cochard
10
+ *
11
+ * @copyright (c) 2026 R-libre ingenierie
12
+ *
13
+ * Use of this source code is governed by an MIT-style license
14
+ * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
+ **/
16
+
17
+ import { ComponentEvent, ComponentEvents } from '../../core/component';
18
+ import { EventCallback } from '../../core/core_events';
19
+ import { SvgBuilder, SvgComponent, SvgGroup, SvgPath, SvgProps, SvgShape } from '../../core/core_svg';
20
+ import { class_ns, clamp, IRect, isNumber, sanitizeHtml } from '../../core/core_tools';
21
+
22
+ import "./chart.module.scss"
23
+
24
+ export type ChartType = "line" | "area" | "bar" | "pie" | "donut";
25
+
26
+ export interface ChartTick {
27
+ value: number; // on x axis: index of the point
28
+ text: string;
29
+ }
30
+
31
+ export interface ChartAxis {
32
+ name?: string;
33
+ ticks?: ChartTick[];
34
+ min?: number;
35
+ max?: number; // on x axis: number of points
36
+ step?: number; // on x axis: one label every 'step' points
37
+ }
38
+
39
+ export interface ChartSerie {
40
+ name?: string;
41
+ values: number[]; // undefined: no value (hole in the line)
42
+ color?: string; // default: palette --chart-color-1..8
43
+ fill?: string; // area fill color
44
+ dots?: boolean; // always show the points
45
+ }
46
+
47
+ export interface EvChartClick extends ComponentEvent {
48
+ serie: number; // serie index
49
+ index: number; // value index
50
+ value: number;
51
+ }
52
+
53
+ interface ChartEvents extends ComponentEvents {
54
+ click: EvChartClick;
55
+ }
56
+
57
+ export interface ChartProps extends Omit<SvgProps,"svg"|"viewbox"|"content"> {
58
+ type?: ChartType; // default "line"
59
+ series: ChartSerie[]; // pie & donut use the first serie
60
+ labels?: string[]; // default: 1, 2, 3...
61
+ xaxis?: ChartAxis;
62
+ yaxis?: ChartAxis;
63
+ legend?: boolean; // default: shown when there is more than one named serie
64
+ stacked?: boolean;
65
+ formatter?: ( value: number ) => string;
66
+ click?: EventCallback<EvChartClick>;
67
+ }
68
+
69
+ const PADDING = 8;
70
+ const CHAR_WIDTH = 6; // estimated, font size is 10px
71
+ const NUM_COLORS = 8;
72
+ const MAX_BAR = 32;
73
+ const MAX_GHOSTS = 500; // hover points are not created above this count
74
+
75
+
76
+ // :: COMPUTATIONS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
77
+
78
+ /**
79
+ * format a number with k, M, B... units
80
+ */
81
+
82
+ export function chartFormatUnit( value: number, decimals = 2 ) {
83
+ const units = ['', 'k', 'M', 'B', 'T', 'P', 'E'];
84
+ let unitIndex = 0;
85
+ let scaledValue = Math.abs( value );
86
+
87
+ while (scaledValue >= 1000 && unitIndex < units.length - 1) {
88
+ scaledValue /= 1000;
89
+ unitIndex++;
90
+ }
91
+
92
+ const pow = Math.pow(10, decimals);
93
+ const roundedValue = Math.round(scaledValue * pow) / pow;
94
+ const text = roundedValue % 1 == 0 ? `${roundedValue}` : roundedValue.toFixed(decimals).replace(/\.?0+$/, '');
95
+
96
+ return ( value<0 && roundedValue!=0 ? '-' : '' ) + text + units[unitIndex];
97
+ }
98
+
99
+ /**
100
+ * compute a readable scale (bounds are multiples of step)
101
+ */
102
+
103
+ function niceScale( min: number, max: number, maxTicks: number ) {
104
+
105
+ if( min==max ) {
106
+ if( min==0 ) { max = 1; }
107
+ else if( min>0 ) { min = 0; }
108
+ else { max = 0; }
109
+ }
110
+
111
+ const rawStep = ( max-min ) / Math.max( 1, maxTicks );
112
+ const exponent = Math.floor( Math.log10(rawStep) );
113
+ const fraction = rawStep / Math.pow( 10, exponent );
114
+
115
+ let nFraction;
116
+ if (fraction <= 1) nFraction = 1;
117
+ else if (fraction <= 2) nFraction = 2;
118
+ else if (fraction <= 5) nFraction = 5;
119
+ else nFraction = 10;
120
+
121
+ const step = nFraction * Math.pow( 10, exponent );
122
+
123
+ return {
124
+ min: Math.floor( min/step ) * step,
125
+ max: Math.ceil( max/step ) * step,
126
+ step
127
+ };
128
+ }
129
+
130
+ function makeTicks( min: number, max: number, step: number, fmt: ( v: number ) => string ) {
131
+ const ticks: ChartTick[] = [];
132
+ const first = Math.ceil( min/step - 1e-9 ) * step;
133
+
134
+ for( let i=0; i<100; i++ ) {
135
+ const value = parseFloat( ( first + i*step ).toPrecision( 12 ) );
136
+ if( value>max+step*1e-6 ) {
137
+ break;
138
+ }
139
+
140
+ ticks.push( { value, text: fmt(value) } );
141
+ }
142
+
143
+ return ticks;
144
+ }
145
+
146
+ /**
147
+ * cumulated values: [serie][index] = { lo, hi }
148
+ */
149
+
150
+ function computeStack( series: ChartSerie[], count: number ) {
151
+ const pos = new Array<number>( count ).fill( 0 );
152
+ const neg = new Array<number>( count ).fill( 0 );
153
+
154
+ return series.map( s => {
155
+ const out: { lo: number, hi: number }[] = [];
156
+ for( let i=0; i<count; i++ ) {
157
+ const v = isNumber( s.values[i] ) ? s.values[i] : 0;
158
+ const acc = v>=0 ? pos : neg;
159
+
160
+ out.push( { lo: acc[i], hi: acc[i]+v } );
161
+ acc[i] += v;
162
+ }
163
+
164
+ return out;
165
+ });
166
+ }
167
+
168
+ function textWidth( text: string ) {
169
+ return ( text?.length ?? 0 ) * CHAR_WIDTH;
170
+ }
171
+
172
+ function maxTextWidth( ticks: ChartTick[] ) {
173
+ return ticks.reduce( ( w, t ) => Math.max( w, textWidth(t.text) ), 0 );
174
+ }
175
+
176
+
177
+ // :: DRAWING HELPERS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
178
+
179
+ function drawText( grp: SvgGroup, x: number, y: number, text: string, halign: 'left' | 'center' | 'right', valign: 'top' | 'center' | 'bottom' ) {
180
+ const baseline = valign=='top' ? 'hanging' : ( valign=='center' ? 'central' : 'auto' );
181
+ return grp.text( x, y, text )
182
+ .textAlign( halign )
183
+ .setAttr( 'dominant-baseline', baseline );
184
+ }
185
+
186
+ /**
187
+ * bar from the baseline (y0) to the value (y1), rounded on the value side
188
+ */
189
+
190
+ function drawBar( grp: SvgGroup, x: number, w: number, y0: number, y1: number, radius: number ) {
191
+ const dir = y1<y0 ? 1 : -1;
192
+ const r = Math.min( radius, w/2, Math.abs(y1-y0) );
193
+
194
+ return grp.path( )
195
+ .moveTo( x, y0 )
196
+ .lineTo( x, y1+dir*r )
197
+ .curveTo( x, y1, x, y1, x+r, y1 )
198
+ .lineTo( x+w-r, y1 )
199
+ .curveTo( x+w, y1, x+w, y1, x+w, y1+dir*r )
200
+ .lineTo( x+w, y0 )
201
+ .closePath( );
202
+ }
203
+
204
+
205
+ /**
206
+ * Minimal chart (line, area, bar, pie, donut) drawn in svg.
207
+ *
208
+ * @example
209
+ * new Chart( {
210
+ * type: "bar",
211
+ * labels: [ "Jan", "Feb", "Mar" ],
212
+ * series: [
213
+ * { name: "2025", values: [ 12, 19, 7 ] },
214
+ * { name: "2026", values: [ 15, 11, 14 ] },
215
+ * ],
216
+ * click: ( ev ) => console.log( ev.serie, ev.index, ev.value ),
217
+ * })
218
+ *
219
+ * @cssvar
220
+ * ```
221
+ * --chart-background
222
+ * --chart-text
223
+ * --chart-grid
224
+ * --chart-axis
225
+ * --chart-color-1 ... --chart-color-8
226
+ * ```
227
+ */
228
+
229
+ @class_ns( "x4" )
230
+ export class Chart extends SvgComponent<ChartProps,ChartEvents> {
231
+
232
+ private _type: ChartType;
233
+ private _series: ChartSerie[];
234
+ private _labels: string[];
235
+ private _xaxis: ChartAxis;
236
+ private _yaxis: ChartAxis;
237
+
238
+ constructor( props: ChartProps ) {
239
+ super( props );
240
+
241
+ this._type = props.type ?? "line";
242
+ this._series = props.series ?? [];
243
+ this._labels = props.labels;
244
+ this._xaxis = { ...props.xaxis };
245
+ this._yaxis = { ...props.yaxis };
246
+
247
+ this.mapPropEvents( props, "click" );
248
+
249
+ this.addDOMEvent( "created", ( ) => {
250
+ this._render( );
251
+ });
252
+
253
+ this.addDOMEvent( "resized", ( ) => {
254
+ this._render( );
255
+ });
256
+
257
+ this.addDOMEvent( "click", ( e ) => {
258
+ const el = ( e.target as Element ).closest( "[data-index]" );
259
+ if( el && this.dom.contains( el ) ) {
260
+ const serie = parseInt( el.getAttribute( "data-serie" ) );
261
+ const index = parseInt( el.getAttribute( "data-index" ) );
262
+ this.fire( "click", { serie, index, value: this._series[serie]?.values[index] } );
263
+ }
264
+ });
265
+ }
266
+
267
+ setType( type: ChartType ) {
268
+ this._type = type;
269
+ this._render( );
270
+ }
271
+
272
+ /**
273
+ * @param labels - if given, replace the labels too
274
+ */
275
+
276
+ setSeries( series: ChartSerie[], labels?: string[] ) {
277
+ this._series = series ?? [];
278
+ if( labels!==undefined ) {
279
+ this._labels = labels;
280
+ }
281
+
282
+ this._render( );
283
+ }
284
+
285
+ setLabels( labels: string[] ) {
286
+ this._labels = labels;
287
+ this._render( );
288
+ }
289
+
290
+ setTicks( sens: "x" | "y", ticks: ChartTick[] ) {
291
+ if( sens=="x" ) {
292
+ this._xaxis.ticks = ticks;
293
+ }
294
+ else {
295
+ this._yaxis.ticks = ticks;
296
+ }
297
+
298
+ this._render( );
299
+ }
300
+
301
+ /**
302
+ *
303
+ */
304
+
305
+ private _label( index: number ) {
306
+ return this._labels?.[index] ?? ( index+1 )+"";
307
+ }
308
+
309
+ private _format( value: number ) {
310
+ return this.props.formatter ? this.props.formatter( value ) : chartFormatUnit( value );
311
+ }
312
+
313
+ /**
314
+ * set the color of an element: palette slot or explicit color
315
+ */
316
+
317
+ private _paint<T extends SvgShape | SvgPath>( item: T, slot: number, color?: string ): T {
318
+ item.addClass( "c"+( slot % NUM_COLORS + 1 ) );
319
+ if( color ) {
320
+ item.getDom().style.setProperty( "--chart-c", color );
321
+ }
322
+
323
+ return item;
324
+ }
325
+
326
+ /**
327
+ * make an element clickable with a tooltip
328
+ */
329
+
330
+ private _mark<T extends SvgShape | SvgPath>( item: T, serie: number, index: number, tip: string ): T {
331
+ item.setAttr( "data-serie", serie+"" )
332
+ .setAttr( "data-index", index+"" )
333
+ .setAttr( "tooltip", sanitizeHtml(tip) );
334
+
335
+ return item;
336
+ }
337
+
338
+ /**
339
+ *
340
+ */
341
+
342
+ private _render( ) {
343
+
344
+ if( !this.dom.isConnected || !this.dom.checkVisibility() ) {
345
+ return;
346
+ }
347
+
348
+ const grc = this.getBoundingRect( );
349
+ const area: IRect = { left: PADDING, top: PADDING, width: grc.width-PADDING*2, height: grc.height-PADDING*2 };
350
+
351
+ const bld = new SvgBuilder( );
352
+
353
+ if( area.width>0 && area.height>0 ) {
354
+ const pie = this._type=="pie" || this._type=="donut";
355
+
356
+ let entries: string[];
357
+ let legend = this.props.legend;
358
+
359
+ if( pie ) {
360
+ entries = ( this._series[0]?.values ?? [] ).map( ( _, i ) => this._label(i) );
361
+ legend ??= !!this._labels;
362
+ }
363
+ else {
364
+ entries = this._series.map( ( s, i ) => s.name ?? ( i+1 )+"" );
365
+ legend ??= this._series.filter( s => s.name ).length>1;
366
+ }
367
+
368
+ if( legend && entries.length ) {
369
+ area.height -= this._drawLegend( bld, entries, area, pie );
370
+ }
371
+
372
+ if( area.height>0 ) {
373
+ if( pie ) {
374
+ this._drawPie( bld, area );
375
+ }
376
+ else {
377
+ this._drawXY( bld, area );
378
+ }
379
+ }
380
+ }
381
+
382
+ this.setSvg( bld );
383
+ }
384
+
385
+ /**
386
+ * legend at the bottom of the area
387
+ * @returns the height used
388
+ */
389
+
390
+ private _drawLegend( bld: SvgBuilder, entries: string[], area: IRect, pie: boolean ) {
391
+ const ROW = 18;
392
+
393
+ // wrap entries
394
+ let x = 0;
395
+ let row = 0;
396
+
397
+ const pos = entries.map( text => {
398
+ const w = 16 + textWidth(text) + 16;
399
+ if( x>0 && x+w>area.width ) {
400
+ x = 0;
401
+ row++;
402
+ }
403
+
404
+ const p = { x, row };
405
+ x += w;
406
+ return p;
407
+ });
408
+
409
+ const height = ( row+1 )*ROW + 6;
410
+ const top = area.top + area.height - height + 6;
411
+
412
+ const grp = bld.group( ).addClass( "legend" );
413
+
414
+ entries.forEach( ( text, i ) => {
415
+ const px = area.left + pos[i].x;
416
+ const cy = top + pos[i].row*ROW + ROW/2;
417
+
418
+ this._paint( grp.rect( px, cy-5, 10, 10 ).addClass( "mark" ), i, pie ? undefined : this._series[i].color );
419
+ drawText( grp, px+16, cy, text, 'left', 'center' );
420
+ });
421
+
422
+ return height;
423
+ }
424
+
425
+ /**
426
+ * pie & donut: one slice for each value of the first serie
427
+ */
428
+
429
+ private _drawPie( bld: SvgBuilder, area: IRect ) {
430
+ const values = ( this._series[0]?.values ?? [] ).map( v => isNumber(v) && v>0 ? v : 0 );
431
+ const total = values.reduce( ( a, v ) => a+v, 0 );
432
+ const count = values.filter( v => v>0 ).length;
433
+
434
+ const cx = area.left + area.width/2;
435
+ const cy = area.top + area.height/2;
436
+ const radius = Math.min( area.width, area.height )/2 - 2;
437
+
438
+ if( !total || radius<=0 ) {
439
+ return;
440
+ }
441
+
442
+ const donut = this._type=="donut";
443
+ const thick = radius*0.4;
444
+ const rr = donut ? radius-thick/2 : radius;
445
+
446
+ // slices of a donut are strokes: separate them by a small angle
447
+ const gap = donut && count>1 ? 2/( Math.PI*2*rr )*360 : 0;
448
+
449
+ const grp = bld.group( ).addClass( "plot" );
450
+ let start = 0;
451
+
452
+ values.forEach( ( v, i ) => {
453
+ if( !v ) {
454
+ return;
455
+ }
456
+
457
+ const sweep = v/total*360;
458
+ const full = sweep>=359.99;
459
+
460
+ let item: SvgShape | SvgPath;
461
+
462
+ if( donut ) {
463
+ const g = sweep>gap*2 ? gap/2 : 0;
464
+ item = full ? grp.circle( cx, cy, rr ) : grp.path( ).arc( cx, cy, rr, start+g, start+sweep-g );
465
+ item.addClass( "ring" ).strokeWidth( thick );
466
+ }
467
+ else {
468
+ item = full ? grp.circle( cx, cy, rr ) : grp.path( ).arc( cx, cy, rr, start, start+sweep ).lineTo( cx, cy ).closePath( );
469
+ item.addClass( "slice" );
470
+ }
471
+
472
+ const perc = Math.round( v/total*1000 )/10;
473
+ this._paint( item, i );
474
+ this._mark( item, 0, i, `${this._label(i)}: ${this._format(v)} (${perc}%)` );
475
+
476
+ start += sweep;
477
+ });
478
+ }
479
+
480
+ /**
481
+ * line, area & bar
482
+ */
483
+
484
+ private _drawXY( bld: SvgBuilder, area: IRect ) {
485
+
486
+ const series = this._series;
487
+ const type = this._type;
488
+ const stacked = !!this.props.stacked;
489
+ const xaxis = this._xaxis;
490
+ const yaxis = this._yaxis;
491
+
492
+ // number of points
493
+ let count = xaxis.max ?? 0;
494
+ if( xaxis.max===undefined ) {
495
+ series.forEach( s => { count = Math.max( count, s.values?.length ?? 0 ); } );
496
+ }
497
+
498
+ if( count<=0 ) {
499
+ return;
500
+ }
501
+
502
+ const value = ( s: number, i: number ) => {
503
+ const v = series[s].values?.[i];
504
+ return isNumber(v) ? v : undefined;
505
+ }
506
+
507
+ const stack = stacked ? computeStack( series, count ) : null;
508
+
509
+ // y scale
510
+ let dmin = Infinity;
511
+ let dmax = -Infinity;
512
+
513
+ series.forEach( ( _, s ) => {
514
+ for( let i=0; i<count; i++ ) {
515
+ if( stack ) {
516
+ dmin = Math.min( dmin, stack[s][i].lo, stack[s][i].hi );
517
+ dmax = Math.max( dmax, stack[s][i].lo, stack[s][i].hi );
518
+ }
519
+ else {
520
+ const v = value( s, i );
521
+ if( v!==undefined ) {
522
+ dmin = Math.min( dmin, v );
523
+ dmax = Math.max( dmax, v );
524
+ }
525
+ }
526
+ }
527
+ });
528
+
529
+ if( dmin==Infinity ) {
530
+ dmin = 0;
531
+ dmax = 1;
532
+ }
533
+
534
+ // bars always start from 0
535
+ if( type=="bar" || stacked ) {
536
+ dmin = Math.min( dmin, 0 );
537
+ dmax = Math.max( dmax, 0 );
538
+ }
539
+
540
+ const nice = niceScale( yaxis.min ?? dmin, yaxis.max ?? dmax, clamp( Math.floor( area.height/40 ), 2, 10 ) );
541
+ const min = yaxis.min ?? nice.min;
542
+ let max = yaxis.max ?? nice.max;
543
+ if( max<=min ) {
544
+ max = min+1;
545
+ }
546
+
547
+ const step = yaxis.step>0 ? yaxis.step : nice.step;
548
+ const yticks = yaxis.ticks ?? makeTicks( min, max, step, v => this._format(v) );
549
+
550
+ // plot area
551
+ const band = type=="bar";
552
+ const yname = yaxis.name ? 14 : 0;
553
+ const xname = xaxis.name ? 14 : 0;
554
+ const ywidth = maxTextWidth( yticks ) + 8;
555
+
556
+ const plot: IRect = {
557
+ left: area.left + yname + ywidth,
558
+ top: area.top + 6,
559
+ width: area.width - yname - ywidth - ( band ? 0 : 10 ),
560
+ height: area.height - 6 - 16 - xname,
561
+ };
562
+
563
+ if( plot.width<=0 || plot.height<=0 ) {
564
+ return;
565
+ }
566
+
567
+ const right = plot.left + plot.width;
568
+ const bottom = plot.top + plot.height;
569
+
570
+ const xstep = band ? plot.width/count : ( count>1 ? plot.width/( count-1 ) : 0 );
571
+ const xpos = ( i: number ) => {
572
+ if( band ) {
573
+ return plot.left + ( i+0.5 )*xstep;
574
+ }
575
+
576
+ return count>1 ? plot.left + i*xstep : plot.left + plot.width/2;
577
+ }
578
+
579
+ const yfac = plot.height / ( max-min );
580
+ const ypos = ( v: number ) => bottom - ( v-min )*yfac;
581
+ const ybase = ypos( clamp( 0, min, max ) );
582
+
583
+ // x ticks
584
+ let xticks = xaxis.ticks;
585
+ if( !xticks ) {
586
+ xticks = [];
587
+
588
+ let maxw = 0;
589
+ for( let i=0; i<count; i++ ) {
590
+ maxw = Math.max( maxw, textWidth( this._label(i) ) );
591
+ }
592
+
593
+ // too many labels: show one every n
594
+ const every = xaxis.step>0 ? Math.round( xaxis.step ) : Math.max( 1, Math.ceil( ( maxw+8 ) / ( xstep || plot.width ) ) );
595
+ for( let i=0; i<count; i+=every ) {
596
+ xticks.push( { value: i, text: this._label(i) } );
597
+ }
598
+ }
599
+
600
+ // grid & axis
601
+ const grid = bld.group( ).addClass( "axis" );
602
+
603
+ for( const t of yticks ) {
604
+ const y = ypos( t.value );
605
+ if( y<plot.top-0.5 || y>bottom+0.5 ) {
606
+ continue;
607
+ }
608
+
609
+ grid.path( ).moveTo( plot.left, y ).lineTo( right, y ).addClass( "grid" );
610
+ drawText( grid, plot.left-6, y, t.text, 'right', 'center' );
611
+ }
612
+
613
+ for( const t of xticks ) {
614
+ const x = xpos( t.value );
615
+ if( xaxis.ticks && !band ) {
616
+ grid.path( ).moveTo( x, plot.top ).lineTo( x, bottom ).addClass( "grid" );
617
+ }
618
+
619
+ drawText( grid, x, bottom+5, t.text, 'center', 'top' );
620
+ }
621
+
622
+ grid.path( ).moveTo( plot.left, ybase ).lineTo( right, ybase ).addClass( "base" );
623
+
624
+ if( xaxis.name ) {
625
+ drawText( grid, plot.left+plot.width/2, bottom+19, xaxis.name, 'center', 'top' ).addClass( "name" );
626
+ }
627
+
628
+ if( yaxis.name ) {
629
+ const cy = plot.top + plot.height/2;
630
+ drawText( grid, area.left, cy, yaxis.name, 'center', 'top' ).addClass( "name" ).rotate( -90, area.left, cy );
631
+ }
632
+
633
+ // series
634
+ const grp = bld.group( ).addClass( "plot" );
635
+
636
+ // values may be out of fixed bounds
637
+ if( yaxis.min!==undefined || yaxis.max!==undefined ) {
638
+ const { id } = bld.addClip( plot.left-5, plot.top, plot.width+10, plot.height );
639
+ grp.clip( id );
640
+ }
641
+
642
+ const tip = ( s: number, i: number, v: number ) => {
643
+ const name = series[s].name ? ` · ${series[s].name}` : '';
644
+ return `${this._label(i)}${name}: ${this._format(v)}`;
645
+ }
646
+
647
+ if( band ) {
648
+ const n = stacked ? 1 : Math.max( 1, series.length );
649
+ const w = Math.min( xstep*0.8/n, MAX_BAR );
650
+ const gap = w>6 ? 1 : 0; // 2px between bars
651
+
652
+ series.forEach( ( serie, s ) => {
653
+ for( let i=0; i<count; i++ ) {
654
+ const v = value( s, i );
655
+ if( v===undefined ) {
656
+ continue;
657
+ }
658
+
659
+ let bar: SvgPath;
660
+
661
+ if( stack ) {
662
+ const y0 = ypos( stack[s][i].lo );
663
+ let y1 = ypos( stack[s][i].hi );
664
+
665
+ // 2px between segments
666
+ if( Math.abs( y1-y0 )>3 ) {
667
+ y1 += y1<y0 ? 2 : -2;
668
+ }
669
+
670
+ bar = drawBar( grp, xpos(i)-w/2, w, y0, y1, 0 );
671
+ }
672
+ else {
673
+ const x = xpos(i) - w*n/2 + s*w;
674
+ bar = drawBar( grp, x+gap, w-gap*2, ybase, ypos(v), 4 );
675
+ }
676
+
677
+ this._paint( bar.addClass( "bar" ), s, serie.color );
678
+ this._mark( bar, s, i, tip( s, i, v ) );
679
+ }
680
+ });
681
+ }
682
+ else {
683
+ const areas = grp.group( );
684
+ const lines = grp.group( );
685
+ const dots = grp.group( );
686
+
687
+ const ghosts = series.length*count<=MAX_GHOSTS;
688
+
689
+ series.forEach( ( serie, s ) => {
690
+
691
+ // consecutive points
692
+ type Pt = { i: number, x: number, y: number, ylo: number };
693
+ const runs: Pt[][] = [];
694
+ let run: Pt[] = null;
695
+
696
+ for( let i=0; i<count; i++ ) {
697
+ const v = stack ? stack[s][i].hi : value( s, i );
698
+ if( v===undefined ) {
699
+ run = null;
700
+ continue;
701
+ }
702
+
703
+ if( !run ) {
704
+ run = [];
705
+ runs.push( run );
706
+ }
707
+
708
+ run.push( { i, x: xpos(i), y: ypos(v), ylo: stack ? ypos( stack[s][i].lo ) : ybase } );
709
+ }
710
+
711
+ for( const pts of runs ) {
712
+ const first = pts[0];
713
+ const last = pts[pts.length-1];
714
+
715
+ if( type=="area" && pts.length>1 ) {
716
+ const pth = areas.path( ).moveTo( first.x, first.ylo );
717
+ pts.forEach( p => pth.lineTo( p.x, p.y ) );
718
+
719
+ if( stack ) {
720
+ for( let k=pts.length-1; k>0; k-- ) {
721
+ pth.lineTo( pts[k].x, pts[k].ylo );
722
+ }
723
+ }
724
+ else {
725
+ pth.lineTo( last.x, last.ylo );
726
+ }
727
+
728
+ pth.closePath( ).addClass( "area" );
729
+ this._paint( pth, s, serie.color );
730
+
731
+ if( serie.fill ) {
732
+ pth.setStyle( "fill", serie.fill ).setStyle( "fillOpacity", "1" );
733
+ }
734
+ }
735
+
736
+ if( pts.length>1 ) {
737
+ const pth = lines.path( ).moveTo( first.x, first.y );
738
+ for( let k=1; k<pts.length; k++ ) {
739
+ pth.lineTo( pts[k].x, pts[k].y );
740
+ }
741
+
742
+ this._paint( pth.addClass( "line" ), s, serie.color );
743
+ }
744
+
745
+ // points: always visible, or only on hover (ghost)
746
+ const visible = serie.dots || pts.length==1;
747
+ if( visible || ghosts ) {
748
+ for( const p of pts ) {
749
+ const dot = dots.circle( p.x, p.y, 4 ).addClass( visible ? "dot" : "dot ghost" );
750
+ this._paint( dot, s, serie.color );
751
+ this._mark( dot, s, p.i, tip( s, p.i, value( s, p.i ) ?? 0 ) );
752
+ }
753
+ }
754
+ }
755
+ });
756
+ }
757
+ }
758
+ }