@fc-plot/ts-graph 0.6.0

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/src/index.ts ADDED
@@ -0,0 +1,540 @@
1
+ import * as d3 from 'd3';
2
+ import { assign, merge, debounce, throttle, uniqueId, isEmpty } from 'lodash';
3
+ import { addListener, removeListener } from 'resize-detector';
4
+ import XAxis from './xAxis';
5
+ import YAxis from './yAxis';
6
+ import Tooltip from './tooltip';
7
+ import Line from './line';
8
+ import Zoom from './zoom';
9
+ import Legend from './legend';
10
+ import { TsGraphType, Options, EventPosition, Transform, Serie, SerieDataItem, XScales, YScales } from './interface';
11
+ import { getTouchPosition } from './utils';
12
+ import '../assets/style.less';
13
+
14
+ const dispatch = d3.dispatch('plotmove', 'plotleave');
15
+
16
+ export default class TsGraph {
17
+ options!: Options;
18
+ container!: HTMLElement;
19
+ frontContext!: CanvasRenderingContext2D;
20
+ backContext!: CanvasRenderingContext2D;
21
+ eventCanvas!: HTMLCanvasElement;
22
+ frontCanvas!: HTMLCanvasElement;
23
+ backCanvas!: HTMLCanvasElement;
24
+ xAxis!: XAxis;
25
+ xScales!: XScales;
26
+ yAxis!: YAxis;
27
+ yScales!: YScales;
28
+ line!: Line;
29
+ legend!: Legend;
30
+ tooltip!: Tooltip;
31
+ zoom!: Zoom;
32
+ transform!: Transform;
33
+ constructor(userOptions: Options) {
34
+ const defaultOptions = {
35
+ type: 'line',
36
+ ratio: window.devicePixelRatio || 1,
37
+ xkey: 0,
38
+ ykey: 1,
39
+ timestamp: 'x',
40
+ chart: {
41
+ id: uniqueId('ts-graph-'),
42
+ colors: ['#3399CC', '#CC9933', '#9966CC', '#66CC66', '#CC3333', '#99CCCC', '#CCCC66', '#CC99CC', '#99CC99', '#CC6666', '#336699', '#996633', '#993399', '#339966', '#993333'],
43
+ width: userOptions.chart.renderTo.offsetWidth,
44
+ height: userOptions.chart.renderTo.offsetHeight || 350,
45
+ marginTop: 10,
46
+ marginRight: 10,
47
+ marginBottom: 10,
48
+ marginLeft: 10,
49
+ },
50
+ xAxis: {
51
+ visible: true,
52
+ lineColor: '#ccc',
53
+ lineWidth: 1,
54
+ tickLength: 5,
55
+ tickpadding: 5,
56
+ tickColor: '#ccc',
57
+ labels: {
58
+ color: '#999',
59
+ fontSize: 11,
60
+ },
61
+ },
62
+ yAxis: {
63
+ // min: -Infinity,
64
+ // max: Infinity,
65
+ visible: true,
66
+ lineColor: '#ccc',
67
+ lineWidth: 1,
68
+ tickLength: 5,
69
+ tickpadding: 5,
70
+ tickColor: '#ccc',
71
+ gridLineColor: '#efefef',
72
+ labels: {
73
+ color: '#999',
74
+ shadowColor: '#fff',
75
+ fontSize: 11,
76
+ style: {
77
+ fontSize: 11,
78
+ color: '#999',
79
+ },
80
+ },
81
+ },
82
+ line: {
83
+ width: 2,
84
+ },
85
+ tooltip: {
86
+ shared: true,
87
+ },
88
+ series: [],
89
+ legend: {
90
+ align: 'center',
91
+ verticalAlign: 'top',
92
+ enabled: false,
93
+ },
94
+ fillNull: undefined,
95
+ onClick: () => {},
96
+ onZoom: () => {},
97
+ };
98
+ const options = merge({}, defaultOptions, userOptions);
99
+
100
+ if (userOptions.chart.marginTop === undefined && options.legend.enabled) {
101
+ options.chart.marginTop = 20;
102
+ }
103
+ this.handleResize = debounce(this.handleResize, 300);
104
+ this.init(options);
105
+ }
106
+
107
+ init(options: Options) {
108
+ const { chart } = options;
109
+
110
+ this.options = options;
111
+ this.options.notDisplayedSeries = [];
112
+ this.options.chart.containerWidth = chart.width;
113
+ this.options.chart.containerHeight = chart.height;
114
+ this.options.chart.width = chart.width - chart.marginLeft - chart.marginRight;
115
+ this.options.chart.height = chart.height - chart.marginTop - chart.marginBottom;
116
+ this.createContainer();
117
+ this.createCanvas();
118
+ this.initEvent();
119
+ this.initLine();
120
+ this.initLegend();
121
+ this.initTooltip();
122
+ this.initZoom();
123
+ this.initSeries();
124
+ this.initScales();
125
+ // this.initScales(); // TODO: 同 update
126
+ this.draw();
127
+ }
128
+
129
+ clearRect(ctx: CanvasRenderingContext2D) {
130
+ const { chart: { containerWidth, containerHeight } } = this.options;
131
+
132
+ ctx.clearRect(0, 0, containerWidth, containerHeight);
133
+ }
134
+
135
+ draw() {
136
+ this.clearRect(this.backContext);
137
+ if (this.options.xAxis.visible) {
138
+ this.yAxis.drawGridLine(this.yScales);
139
+ }
140
+ this.line.draw(this.xScales, this.yScales);
141
+ if (this.options.xAxis.visible) {
142
+ this.xAxis.draw(this.xScales);
143
+ }
144
+ if (this.options.yAxis.visible) {
145
+ this.yAxis.draw(this.yScales);
146
+ }
147
+ this.xAxis.drawPlotLines(this.xScales);
148
+ this.yAxis.drawPlotLines(this.yScales);
149
+ this.legend.draw();
150
+ }
151
+
152
+ update(newOptions: Options) {
153
+ /**
154
+ * 更新功能目前还没有想到最优的方案,暂时先根据目前的需求来强定制
155
+ */
156
+ const options = assign({}, this.options, newOptions);
157
+
158
+ if (newOptions.series) {
159
+ options.series = newOptions.series;
160
+ }
161
+ this.options = options;
162
+ this.initSeries();
163
+ this.initLine();
164
+ this.initTooltip();
165
+ this.initScales();
166
+ // this.initScales(); // TODO: 更新偶尔会出现 colsePath 的情况,但是触发两次 initScales 就不会出现 closePath 情况
167
+ this.legend.updateOptions(options);
168
+ this.draw();
169
+ }
170
+
171
+ getSeries() {
172
+ return [...this.options.series];
173
+ }
174
+
175
+ showTooltip(eventPosition: EventPosition) {
176
+ this.tooltip.draw(eventPosition, this.xScales, this.yScales);
177
+ }
178
+
179
+ hideTooltip() {
180
+ this.tooltip.clear();
181
+ }
182
+
183
+ initEvent() {
184
+ let mousedownStatus = false;
185
+ let mousedownPos: EventPosition = {} as EventPosition;
186
+ let mouseupPos: EventPosition = {} as EventPosition;
187
+ let mouseleavePos: EventPosition;
188
+ let isMouserover = false;
189
+ // eslint-disable-next-line no-underscore-dangle
190
+ const _this = this;
191
+ const handleMouseover = debounce((eventPosition: EventPosition) => {
192
+ if (isMouserover) {
193
+ _this.tooltip.draw(eventPosition, _this.xScales, _this.yScales);
194
+ if (mousedownStatus) {
195
+ _this.zoom.drawMarker(mousedownPos, eventPosition);
196
+ }
197
+ }
198
+ }, 10);
199
+ const handleMouseLeave = throttle(() => {
200
+ _this.tooltip.clear();
201
+ }, 10);
202
+ d3.select(this.eventCanvas).on('mousemove', function mousemove(this: HTMLCanvasElement) {
203
+ isMouserover = true;
204
+ handleMouseover(d3.event);
205
+ _this.emit('plotmove', d3.event);
206
+ }).on('mouseleave', function mouseleave(this: HTMLCanvasElement) {
207
+ mouseleavePos = d3.event;
208
+ isMouserover = false;
209
+ handleMouseLeave();
210
+ _this.emit('plotleave');
211
+ }).on('mousedown', function mousedown(this: HTMLCanvasElement) {
212
+ mousedownStatus = true;
213
+ mousedownPos = d3.event;
214
+ }).on('mouseup', function mouseup(this: HTMLCanvasElement) {
215
+ d3.event.stopPropagation();
216
+ const eventPosition = d3.event as EventPosition;
217
+ if (mousedownPos && (mousedownPos.offsetX !== eventPosition.offsetX)) {
218
+ _this.zoom.clearMarker();
219
+ _this.zoom.onZoom(mousedownPos, eventPosition, (transform: Transform) => {
220
+ _this.handleZoom(transform);
221
+ });
222
+ } else {
223
+ _this.options.onClick(d3.event);
224
+ }
225
+ mousedownStatus = false;
226
+ mousedownPos = {} as EventPosition;
227
+ }).on('touchstart', function touchmove(this: HTMLCanvasElement) {
228
+ // 模拟 mousedown
229
+ if (d3.event.touches.length > 1) {
230
+ mousedownStatus = true;
231
+ // mousedownPos = getTouchPosition(_this.eventCanvas, d3.event);
232
+ }
233
+ }).on('touchmove', function touchmove(this: HTMLCanvasElement) {
234
+ const eventPosition = getTouchPosition(_this.eventCanvas, d3.event) as EventPosition;
235
+
236
+ if (d3.event.touches.length === 1) {
237
+ _this.tooltip.draw(eventPosition, _this.xScales, _this.yScales);
238
+ }
239
+
240
+ // 触发 zoom marker
241
+ if (mousedownStatus) {
242
+ mousedownPos = eventPosition;
243
+ mouseupPos = getTouchPosition(_this.eventCanvas, d3.event, 1) as EventPosition;
244
+ _this.zoom.drawMarker(mousedownPos, mouseupPos);
245
+ }
246
+ }).on('touchend', function mouseleave(this: HTMLCanvasElement) {
247
+ isMouserover = false;
248
+ handleMouseLeave();
249
+
250
+ // 触发 zoom
251
+ if (mousedownPos && (mousedownPos.offsetX !== mouseupPos.offsetX)) {
252
+ _this.zoom.clearMarker();
253
+ _this.zoom.onZoom(mousedownPos, mouseupPos, (transform: Transform) => {
254
+ _this.handleZoom(transform);
255
+ });
256
+ } else {
257
+ _this.options.onClick(d3.event);
258
+ }
259
+ mousedownStatus = false;
260
+ mousedownPos = {} as EventPosition;
261
+ mouseupPos = {} as EventPosition;
262
+ });
263
+
264
+ // TODO: removeListener
265
+ window.addEventListener('mouseup', () => {
266
+ if (!isEmpty(mousedownPos)) {
267
+ const eventPosition = mouseleavePos;
268
+ mousedownStatus = false;
269
+ _this.zoom.clearMarker();
270
+ _this.zoom.onZoom(mousedownPos, eventPosition, (transform: Transform) => {
271
+ _this.handleZoom(transform);
272
+ });
273
+ mousedownPos = {} as EventPosition;
274
+ }
275
+ });
276
+
277
+ addListener(this.options.chart.renderTo, this.handleResize);
278
+ }
279
+
280
+ on = (type: string, callback: () => void) => {
281
+ dispatch.on(type, callback);
282
+ }
283
+
284
+ emit = (type: string, params?: any) => {
285
+ dispatch.call(type, this, params);
286
+ }
287
+
288
+ getZoomedSeries = () => {
289
+ const { chart: { renderTo }, series, timestamp, xkey } = this.options;
290
+ const renderToWidth = renderTo.offsetWidth;
291
+ return series.map((serie) => {
292
+ if (Object.prototype.toString.call(serie.data) === '[object Array]') {
293
+ const data = serie.data.filter((point) => {
294
+ const xVal = timestamp === 'X' ? point[xkey] * 1000 : point[xkey];
295
+ const x = this.xScales(new Date(xVal));
296
+ return x >= 0 && x <= renderToWidth;
297
+ });
298
+ return {
299
+ ...serie,
300
+ data,
301
+ };
302
+ }
303
+ return serie;
304
+ });
305
+ }
306
+
307
+ handleResize = () => {
308
+ const { chart } = this.options;
309
+ const width = chart.renderTo.offsetWidth;
310
+ const height = chart.renderTo.offsetHeight;
311
+
312
+ this.options.chart.width = width - chart.marginLeft - chart.marginRight;
313
+ this.options.chart.height = height - chart.marginTop - chart.marginBottom;
314
+ this.options.chart.containerWidth = width;
315
+ this.options.chart.containerHeight = height;
316
+ this.container.style.width = `${width}px`;
317
+ this.container.style.height = `${height}px`;
318
+ this.retinaScaled(this.backCanvas, 'back');
319
+ this.retinaScaled(this.frontCanvas, 'front');
320
+ this.retinaScaled(this.eventCanvas, 'event');
321
+ this.initScales();
322
+ this.draw();
323
+ }
324
+
325
+ handleZoom = (transform?: Transform) => {
326
+ const { ykey, yAxis } = this.options
327
+ let { series } = this.options;
328
+ if (transform) {
329
+ this.transform = transform;
330
+ this.xScales = transform.rescaleX(this.xScales);
331
+ series = this.getZoomedSeries();
332
+ } else {
333
+ this.transform = undefined;
334
+ this.xScales = this.xAxis.init();
335
+ }
336
+
337
+ if (Array.isArray(series)) {
338
+ let ymin = yAxis.min || -Infinity;
339
+ let ymax = yAxis.max || Infinity;
340
+ for (const serie of series) {
341
+ const data = Array.isArray(serie.data) ? serie.data : [];
342
+ let dataIdx;
343
+ for (dataIdx = 0; dataIdx < data.length; dataIdx++) {
344
+ const item = data[dataIdx];
345
+ const y = item[ykey];
346
+ if (y === null) continue;
347
+ // get axis domain
348
+ if (ymin < y) {
349
+ ymin = y;
350
+ }
351
+ if (ymax > y) {
352
+ ymax = y;
353
+ }
354
+ }
355
+ }
356
+ this.yAxis.setDomain(this.yScales, { ymin, ymax });
357
+ }
358
+ this.draw();
359
+ this.options.onZoom(this.getZoomedSeries);
360
+ }
361
+
362
+ handleLegendItemClick = (serieName: string) => {
363
+ const { notDisplayedSeries } = this.options;
364
+
365
+ if (notDisplayedSeries.indexOf(serieName) === -1) {
366
+ notDisplayedSeries.push(serieName);
367
+ } else {
368
+ this.options.notDisplayedSeries = notDisplayedSeries.filter((serie) => {
369
+ return serie !== serieName;
370
+ });
371
+ this.update(this.options);
372
+ }
373
+ }
374
+
375
+ retinaScaled(canvas: HTMLCanvasElement, name: string) {
376
+ const { chart, ratio } = this.options;
377
+ const ctx = canvas.getContext('2d')!;
378
+ const width = name === 'front' ? chart.containerWidth : chart.width;
379
+ const height = name === 'front' ? chart.containerHeight : chart.height;
380
+ const left = name === 'front' ? '0px' : `${chart.marginLeft}px`;
381
+ const top = name === 'front' ? '0px' : `${chart.marginTop}px`;
382
+
383
+ canvas.width = width * ratio;
384
+ canvas.height = height * ratio;
385
+ canvas.style.width = `${width}px`;
386
+ canvas.style.height = `${height}px`;
387
+ canvas.style.position = 'absolute';
388
+ canvas.style.left = left;
389
+ canvas.style.top = top;
390
+ if (name === 'front') {
391
+ ctx.translate(chart.marginLeft * ratio, chart.marginTop * ratio);
392
+ }
393
+ ctx.scale(ratio, ratio);
394
+ }
395
+
396
+ createContainer() {
397
+ const { chart } = this.options;
398
+ const container = document.createElement('div');
399
+
400
+ container.style.position = 'relative';
401
+ container.style.width = `${chart.containerWidth}px`;
402
+ container.style.height = `${chart.containerHeight}px`;
403
+ container.style.overflow = 'hidden';
404
+ container.className = 'd3-graph-container';
405
+ chart.renderTo.appendChild(container);
406
+ this.container = container;
407
+ }
408
+
409
+ createCanvas() {
410
+ const { chart: { id } } = this.options;
411
+
412
+ function create(this: TsGraphType, name: string) {
413
+ const canvas = document.createElement('canvas');
414
+ const context = canvas.getContext('2d');
415
+
416
+ canvas.setAttribute('id', `${id}-${name}Canvas`);
417
+ this.container.appendChild(canvas);
418
+ this[`${name}Canvas`] = canvas;
419
+ this[`${name}Context`] = context;
420
+ this.retinaScaled(canvas, name);
421
+ }
422
+ create.call(this, 'back');
423
+ create.call(this, 'front');
424
+ create.call(this, 'event');
425
+ }
426
+
427
+ initSeries() {
428
+ const { fillNull, series, xkey, ykey, yAxis } = this.options;
429
+ let xmin = -Infinity;
430
+ let xmax = Infinity;
431
+ let ymin = yAxis.min;
432
+ let ymax = yAxis.max;
433
+
434
+ if (Array.isArray(series)) {
435
+ const newSeries: Serie[] = [];
436
+ for (const serie of series) {
437
+ const data = Array.isArray(serie.data) ? serie.data : [];
438
+ const newData: SerieDataItem[] = [];
439
+ let flag = false; // 起始标记,后面有连续空值即丢掉
440
+ let dataIdx;
441
+ for (dataIdx = 0; dataIdx < data.length; dataIdx++) {
442
+ const item = data[dataIdx];
443
+ const x = item[xkey];
444
+ const y = item[ykey];
445
+
446
+ // filter is invalid points
447
+ if (fillNull === undefined) {
448
+ if (
449
+ dataIdx === 0
450
+ || dataIdx === data.length - 1
451
+ || (dataIdx > 0 && dataIdx < data.length -1 && typeof y === 'number')
452
+ ) {
453
+ newData.push(item);
454
+ flag = true;
455
+ } else if (flag) {
456
+ newData.push(item);
457
+ flag = false;
458
+ }
459
+ } else {
460
+ newData.push(item);
461
+ }
462
+
463
+ // get axis domain
464
+ if (xmin < x) {
465
+ xmin = x;
466
+ }
467
+ if (xmax > x) {
468
+ xmax = x;
469
+ }
470
+ if (typeof y === 'number') {
471
+ if (typeof ymin !== 'number' || y < ymin) {
472
+ ymin = y;
473
+ }
474
+ if (typeof ymax !== 'number' || y > ymax) {
475
+ ymax = y;
476
+ }
477
+ } else if (typeof fillNull === 'number') {
478
+ if (fillNull < ymin) {
479
+ ymin = fillNull;
480
+ }
481
+ if (fillNull > ymax) {
482
+ ymax = fillNull;
483
+ }
484
+ }
485
+ }
486
+ newSeries.push({
487
+ ...serie,
488
+ data: newData,
489
+ });
490
+ }
491
+ // TODO: 这里不能直接 set,应该保留用户的 options
492
+ this.options.series = newSeries;
493
+ this.options.xmin = xmin;
494
+ this.options.xmax = xmax;
495
+ this.options.ymin = ymin;
496
+ this.options.ymax = ymax;
497
+ }
498
+ }
499
+
500
+ initLine() { // TODO: 涉及重复实例导
501
+ this.line = new Line(this.options, this.backContext);
502
+ }
503
+
504
+ initTooltip() { // TODO: 涉及重复实例导
505
+ this.tooltip = new Tooltip(this.options, this.frontContext, this.container, this.eventCanvas);
506
+ }
507
+
508
+ initZoom() {
509
+ this.zoom = new Zoom(this.options, this.handleZoom, this.container, this.eventCanvas);
510
+ }
511
+
512
+ initLegend() {
513
+ this.legend = new Legend(this.options, this.handleLegendItemClick, this.container, this.eventCanvas);
514
+ }
515
+
516
+ initScales() { // TODO: 涉及重复实例导
517
+ const xAxis = new XAxis(this.options, this.backContext);
518
+ const yAxis = new YAxis(this.options, this.backContext);
519
+
520
+ this.xAxis = xAxis;
521
+ let xScales = xAxis.init();
522
+ if (this.transform) {
523
+ xScales = this.transform.rescaleX(xScales);
524
+ }
525
+ this.xScales = xScales;
526
+ this.yAxis = yAxis;
527
+ this.yScales = yAxis.init();
528
+ }
529
+
530
+ destroy() {
531
+ this.tooltip.destroy();
532
+ d3.select(this.eventCanvas).on('mousemove', null)
533
+ .on('mouseleave', null)
534
+ .on('mousedown', null)
535
+ .on('mouseup', null)
536
+ .on('touchmove', null);
537
+ d3.select(this.container).remove();
538
+ removeListener(this.options.chart.renderTo, this.handleResize);
539
+ }
540
+ }