import { canvasHelper } from '../CanvasHelper' import { colorPicker } from '../ColorPicker' import { Arc } from '../component/Arc' import { Component } from '../component/Component' import { Path } from '../component/Path' import { Rect } from '../component/Rect' import { Text } from '../component/Text' import { Stage } from '../Stage' import { BaseChart, BaseChartOptions } from './BaseChart' import { font } from '../Constant' import { bisector, extent, max, range } from 'd3-array' import { curveMonotoneX, line, area } from 'd3-shape' import { scaleLinear, ScaleLinear } from 'd3-scale' import { timeFormat } from 'd3-time-format' import { Ani } from '../ani/Ani' interface LineChartOptions extends BaseChartOptions { pointerR?: number } export class LineChart extends BaseChart { pointerR: number labelPlaceholder: number = 0 labelSize: number = 32 labelPadding: number = 4 topN: number = 5 constructor (options: LineChartOptions = {}) { super(options) this.pointerR = options.pointerR ?? 10 } scales: { x: ScaleLinear y: ScaleLinear } async setup (stage: Stage, parent: Ani) { await super.setup(stage, parent) this.xTickFormat = (n: number | { valueOf: () => number }) => { return timeFormat(this.dateFormat)(this.secToDate(n)) } // Calculate label placeholder const textModel = new Text({ key: 'label-placeholder', fontSize: this.labelSize, font, }) const labelMaxWidth = max(this.data, (d) => { textModel.text = this.labelFormat(d[this.idField], this.meta, d) return canvasHelper.measure(textModel)?.width ?? 0 }) ?? 0 this.labelPlaceholder = labelMaxWidth } getComponent (sec: number) { const res = new Component({ key: 'line-chart', position: this.position, alpha: this.alphaScale(sec - this.fadeTime[0] - this.freezeTime[0]), }) if (this.aniTime[0] > sec) return null this.scales = this.getScalesBySec(sec) const { xAxis, yAxis } = this.getAxis(sec, this.scales) const lineGen = line() .defined((d: any) => !isNaN(d[this.valueField])) .x((d: any) => this.scales.x(this.secToDate.invert(d[this.dateField]))) .y((d: any) => this.scales.y(d[this.valueField])) const areaGen = area() .defined((d: any) => !isNaN(d[this.valueField])) .x((d: any) => this.scales.x(this.secToDate.invert(d[this.dateField]))) .y0(this.shape.height) .y1((d: any) => this.scales.y(d[this.valueField])) const lineArea = new Rect({ key: 'line-area', clip: true, position: { x: this.margin.left + this.yAxisWidth + this.xAxisPadding, y: this.margin.top + this.xAxisHeight + this.yAxisPadding, }, shape: { width: this.shape.width - this.margin.left - this.margin.right - this.yAxisWidth - this.yAxisPadding - this.labelPlaceholder - this.labelPadding - this.pointerR, height: this.shape.height - this.margin.top - this.margin.bottom - this.xAxisHeight - this.xAxisPadding, }, fillStyle: '#0000', }) const points = new Component({ key: 'points', position: { x: this.margin.left + this.yAxisWidth + this.yAxisPadding, y: this.margin.top + this.xAxisHeight + this.xAxisPadding, }, }) const labels = new Component({ key: 'labels', position: { x: this.margin.left + this.yAxisWidth + this.yAxisPadding, y: this.margin.top + this.xAxisHeight + this.xAxisPadding, }, }) const maxX = max(this.scales.x.range()) // 找不到最大值说明啥数据都没有,直接返回 if (!maxX) return res this.dataGroupByID.forEach((v: any[], k) => { const line = new Path() const color = colorPicker.getColor(k) line.strokeStyle = color line.path = lineGen.curve(curveMonotoneX)(v) line.lineWidth = 3 lineArea.children.push(line) const areaPath = areaGen.curve(curveMonotoneX)(v) // 如果画不出Path直接返回 if (!areaPath) return const currentY = this.findY(areaPath, maxX) const point = new Arc({ key: `point-${k}`, fillStyle: color, radius: this.pointerR, alpha: currentY !== undefined ? 1 : 0, position: { x: maxX, y: currentY }, }) const maxValue = this.scales.y.invert(currentY) if (maxValue > this.historyMax) { this.historyMax = maxValue } else if (maxValue < this.historyMin) { this.historyMin = maxValue } points.children.push(point) const data = new Map() data.set(this.valueField, maxValue) // 如果找不到值,则说明此时并没有数据 if (currentY) { const label = new Text({ key: `label-${k}`, text: this.labelFormat(k, this.meta, data), fontSize: this.labelSize, font, textAlign: 'left', textBaseline: 'middle', position: { x: maxX + this.labelPadding + this.pointerR, y: currentY, }, fillStyle: color, }) labels.children.push(label) } }) res.children.push(lineArea) res.children.push(points) if (this.showAxis) { if (this.showXAxis) res.children.push(xAxis) if (this.showYAxis) res.children.push(yAxis) } res.children.push(labels) return res } protected getScalesBySec (sec: number) { const currentData = this.getCurrentData(sec) let valueRange = extent(currentData, (d) => d[this.valueField] as number) if (valueRange[0] === undefined) { valueRange = [0, 0] } if (this.historyMax > valueRange[1]) { valueRange[1] = this.historyMax } if (this.historyMin < valueRange[0]) { valueRange[0] = this.historyMin } const delta = (valueRange[1] - valueRange[0]) * 0.1 valueRange[0] -= delta valueRange[1] += delta const trueSec = sec < this.aniTime[0] ? this.aniTime[0] : sec > this.aniTime[1] ? this.aniTime[1] : sec const scales = { x: scaleLinear( [this.aniTime[0], trueSec], [ 0, this.shape.width - this.margin.left - this.margin.right - this.yAxisWidth - this.yAxisPadding - this.labelPlaceholder - this.labelPadding - this.pointerR, ], ), y: scaleLinear(valueRange, [ this.shape.height - this.margin.top - this.margin.bottom - this.xAxisHeight, this.xAxisPadding, ]), } return scales } private findY (area: Path2D | string, x: number) { const l = 0 const r = this.shape.height // 使用中值优化,O(n^2) -> O(log(n)) const b = bisector((d: number) => { return canvasHelper.isPointInPath(area, x, d) }).left const yRange = range(l, r, 1) const index = b(yRange, true) return yRange[index] } }