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.
- package/aicontext.md +70 -3
- package/aiguide.md +60 -0
- package/package.json +1 -1
- package/src/components/autocomplete/autocomplete.module.scss +28 -0
- package/src/components/autocomplete/autocomplete.ts +361 -0
- package/src/components/boxes/boxes.ts +66 -0
- package/src/components/button/button.ts +1 -1
- package/src/components/chart/chart.module.scss +126 -0
- package/src/components/chart/chart.ts +758 -0
- package/src/components/combobox/combobox.ts +5 -5
- package/src/components/components.ts +2 -0
- package/src/components/header/header.ts +38 -3
- package/src/components/listbox/listbox.module.scss +4 -5
- package/src/components/listbox/listbox.ts +1 -1
- package/src/components/select/select.module.scss +26 -17
- package/src/components/select/select.ts +60 -10
- package/src/components/spreadsheet/spreadsheet.module.scss +31 -0
- package/src/components/spreadsheet/spreadsheet.ts +522 -11
- package/src/components/textarea/textarea.ts +2 -2
- package/src/components/textedit/textedit.ts +4 -4
- package/src/core/core_application.ts +1 -0
- package/src/core/core_svg.ts +2 -2
- package/readme.npm.md +0 -248
|
@@ -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
|
+
}
|