import supportDom from '../decorators/supportDom' import chartCommon from '../decorators/chartCommon' import isDef from '../utils/isDef' import isUndef from '../utils/isUndef' import { mem, range, sortBy, throttle, uniqBy } from '../utils' import { DEFAULT_CHART_STYLES } from '../consts'
/**
* ------------------------------------------------------------------------------------------------- * ^ | * | | * yPadding | * | | * v | * ----------------------------------------------------------- yLabel | * ----------------------------------------------------------- yLabel | * <- xPadding -> ----------------------------------------------------------- yLabel <- xPadding -> | * ----------------------------------------------------------- yLabel | * ^ | * yGutter | * v | * ----------------------------------------------------------- yLabel | * xLabel <- xGutter -> xLabel <- xGutter -> xLabel | * ^ | * | | * yPadding | * | | * v | * -------------------------------------------------------------------------------------------------- **/
@supportDom @chartCommon export default class LineChart {
constructor(dom, options = {}) { this.dom = dom this.options = options this.pointsArr = [] this.height = options.height this.width = options.width this.toXLabel = isDef(options.toXLabel) ? mem(options.toXLabel) : (v => v) this.toYLabel = isDef(options.toYLabel) ? mem(options.toYLabel) : (v => v) this.xPadding = isDef(options.xPadding) ? options.xPadding : 20 this.yPadding = isDef(options.yPadding) ? options.yPadding : 20 this.xGutter = isDef(options.xGutter) ? options.xGutter : 100 this.yGutter = isDef(options.yGutter) ? options.yGutter : 10 this.xLabelMargin = isDef(options.xLabelMargin) ? options.xLabelMargin : 10 this.yLabelMargin = isDef(options.yLanelMargin) ? options.yLabelMargin : 10 this.lineStyles = options.lineStyles || DEFAULT_CHART_STYLES this.bg = options.bg || '#fff' this.fontSize = options.fontSize || 12 this.xStep = options.xStep this.yStep = options.yStep this.lineLabels = options.lineLabels || [] this.pointPosMap = new Map() this.xLabelRows = [] this.yLabelRows = [] this.init() } init() { this.setDpr() this.setDomSizeIfNeeded() this.setCanvas() this.setLabelBox() this.clear() this.bindMedia() this.bindPointMouseOver() } get noData() { return (this.xLabelRows.length === 0) && (this.yLabelRows.length === 0) } get contentWidth() { return this.width - (this.xPadding * 2) - this.yLabelMargin - this.yLabelWidth - (this.xLabelWidth / 2) } get contentHeight() { return this.height - (this.yPadding * 2) - this.xLabelMargin - this.xLabelHeight } get firstX() { return this.xLabelRows[0].value } get lastX() { const { xLabelRows } = this return xLabelRows[xLabelRows.length - 1].value } get firstY() { return this.yLabelRows[0].value } get lastY() { const { yLabelRows } = this return yLabelRows[yLabelRows.length - 1].value } get xAxisStart() { return this.xPadding + (this.xLabelWidth / 2) } get xAxisEnd() { return this.xAxisStart + this.contentWidth } get xAxisMiddle() { return (this.xAxisEnd - this.xAxisStart) / 2 } get xRatio() { const lineWidth = this.xAxisEnd - this.xAxisStart const xDelta = this.lastX - this.firstX return xDelta / lineWidth } get yAxisStart() { return this.height - this.yPadding - this.xLabelHeight - this.xLabelMargin + (this.yLabelHeight / 2) } get yAxisEnd() { return this.yAxisStart - this.contentHeight } get yAxisMiddle() { return (this.yAxisStart - this.yAxisEnd) / 2 } get yRatio() { const lineHeight = this.yAxisStart - this.yAxisEnd const yDelta = Math.abs(this.lastY - this.firstY) return yDelta / lineHeight } bindPointMouseOver() { if (isUndef(this.options.onPointMouseOver)) { return } if (! ('onmousemove' in this.canvas)) { return } this.addLayer() const canvas = this.getHighestCanvas() this.addEvent(canvas, 'mousemove', throttle(this.handleMouseMove.bind(this), 30)) } clearPointPos() { this.pointPosMap.clear() } draw() { this.clear() this.drawXAxis() this.drawYAxis() this.drawBgLines() this.drawLines() } drawBgLines() { const { ctx, yLabelRows, contentWidth, firstY, xAxisStart, yAxisStart, yRatio } = this ctx.strokeStyle = 'rgba(224, 224, 224, .5)' ctx.lineWidth = 1 yLabelRows.forEach(row => { const y = yAxisStart - ((row.value - firstY) / yRatio) ctx.beginPath() ctx.moveTo(xAxisStart, y) ctx.lineTo(xAxisStart + contentWidth, y) ctx.stroke() ctx.closePath }) } drawLines() { const { ctx, pointPosMap, lineStyles } = this ctx.lineWidth = 2 this.pointsArr.forEach((points, i) => { const style = lineStyles[i] ? lineStyles[i] : '#000' // only one point in a line if (points.length === 1) { const pos = pointPosMap.get(points[0]) this.fillCircle(ctx, pos.x, pos.y, 2, style) return } ctx.beginPath() ctx.strokeStyle = style points.forEach(p => { const pos = pointPosMap.get(p) ctx.lineTo(pos.x, pos.y) }) ctx.stroke() ctx.closePath() }) } clearVerticalLine() { const { ctx } = this.firstLayer ctx.clearRect(0, 0, this.width, this.height) } drawVerticalLine(point, index) { const { ctx } = this.firstLayer const pos = this.pointPosMap.get(point) const style = this.lineStyles[index] || '#000' ctx.strokeStyle = style ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(pos.x, 0) ctx.lineTo(pos.x, this.height) ctx.stroke() ctx.closePath() this.fillCircle(ctx, pos.x, pos.y, 8, style, .2) this.fillCircle(ctx, pos.x, pos.y, 4, style) } drawXAxis() { const { ctx, firstX, xLabelRows, xAxisStart, xRatio } = this const y = this.height - this.yPadding const scaleMargin = 4 const scaleSize = 4 const scaleStart = y - scaleMargin - scaleSize const scaleEnd = y - scaleMargin ctx.textBaseline = 'top' ctx.fillStyle = '#3c4257' ctx.textAlign = 'center' ctx.strokeStyle = '#3c4257' let x = this.xAxisMiddle xLabelRows.forEach((row, i) => { if (xRatio !== 0) { x = xAxisStart + ((row.value - firstX) / xRatio) } ctx.beginPath() ctx.moveTo(x, scaleStart) ctx.lineTo(x, scaleEnd) ctx.stroke() ctx.closePath() ctx.fillText(row.label, x, y) }) } drawYAxis() { const { ctx, firstY, yLabelRows, yAxisStart, yRatio } = this const x = this.width - this.xPadding const halfYLabelHeight = this.yLabelHeight / 2 ctx.fillStyle = '#3c4257' ctx.textAlign = 'right' let y = this.yAxisMiddle yLabelRows.forEach(row => { if (yRatio !== 0) { y = yAxisStart - ((row.value - firstY) / yRatio) } ctx.fillText(row.label, x, y - halfYLabelHeight) }) } findClosetPoint(canvasMousePos) { const { pointsArr, pointPosMap } = this let index = 0 for (const points of pointsArr) { for (const point of points) { const pos = pointPosMap.get(point) if (this.inDetectedZone(canvasMousePos, pos)) { return { index, point } } } index++ } } handleDprChange() { this.setDpr() this.refresh() } handleMouseMove(event) { const canvasMousePos = this.getMousePosInCanvas(event) const res = this.findClosetPoint(canvasMousePos) this.raf(() => { this.clearVerticalLine() if (res) { this.drawVerticalLine(res.point, res.index) } // only fires if res differs if (this.lastClosetPointRes !== res) { const mousePos = this.getMousePos(canvasMousePos) this.options.onPointMouseOver(mousePos, res) } this.lastClosetPointRes = res }) } inDetectedZone(canvasMousePos, pointPos) { const zoneLength = 14 const { x: mouseX, y: mouseY } = canvasMousePos const { x: pointX, y: pointY } = pointPos /** * # is canvasMousePos * see if PointPos is landed in the zone below * * A -------- B * | | * | | * # * | | * | | * C -------- D */ const a = { x: mouseX - zoneLength, y: mouseY - zoneLength } const b = { x: mouseX + zoneLength, y: mouseY - zoneLength } const c = { x: mouseX - zoneLength, y: mouseY + zoneLength } return (a.x <= pointX) && (pointX <= b.x) && (a.y <= pointY) && (pointY <= c.y) } getUniqSortedPoints(axis) { const points = uniqBy(this.pointsArr.flat(), axis) return sortBy(points, [axis]) } getLabelRows(options = {}) { const axis = options.axis || 'x' const gutter = options.gutter || this.xGutter const contentLength = options.contentLength || this.contentWidth const toLabel = options.toLabel || this.toXLabel const measureLength = options.measureLength || (v => this.ctx.measureText(v).width) const points = this.getUniqSortedPoints(axis) const firstPoint = points[0] const lastPoint = points[points.length - 1] if (points.length <= 2) { return points.map(p => { const value = p[axis] const label = toLabel(value) const length = measureLength(label) return { label, length, value } }) } const firstPointValue = firstPoint[axis] const lastPointValue = lastPoint[axis] const pointLength = points.length let step = options.step if (isUndef(step)) { step = this.getAutoStep(firstPointValue, lastPointValue, pointLength) } const [stepStart, stepEnd] = this.getStepStartEnd(step, firstPointValue, lastPointValue) const values = range(stepStart, stepEnd + step, step) const valueCount = values.length const initialGap = parseInt((valueCount - 2) / 2, 10) const firstValue = values[0] const lastValue = values[valueCount - 1] const firstLabel = toLabel(firstValue) const lastLabel = toLabel(lastValue) let stepRows = [ { label: firstLabel, length: measureLength(firstLabel), value: firstValue }, { label: lastLabel, length: measureLength(lastLabel), value: lastValue }, ] for (let gap = initialGap; gap >= 0; gap--) { const { lengthTotal, rows } = this.getLengthTotalData(gap, gutter, values, measureLength, toLabel) if (lengthTotal <= contentLength) { stepRows = rows continue } return stepRows } return stepRows } refresh() { this.raf(() => { this.clearPointPos() this.clearCanvasSize(this.canvas) this.layers.forEach(layer => this.clearCanvasSize(layer.canvas)) this.setDomSizeIfNeeded() this.setCanvasSize(this.canvas) this.layers.forEach(layer => this.setCanvasSize(layer.canvas)) this.setLabelWidths() this.setLabelHeights() this.setAxisData() this.updateLabelSizeForAutoStep() if (this.noData) { return this.raf(() => this.clear()) } this.setPointPos() this.raf(() => this.draw()) }) } setLabelHeights() { this.xLabelHeight = this.fontSize this.yLabelHeight = this.fontSize } setLabelWidths() { const { toXLabel, toYLabel, ctx } = this const { xLabelWidth, yLabelWidth } = this.pointsArr.flat() .filter(p => p) .reduce((o, p) => { const { xLabelWidth, yLabelWidth } = o const measuredXLabelWidth = ctx.measureText(toXLabel(p.x)).width const measuredYLabelWidth = ctx.measureText(toYLabel(p.y)).width return { xLabelWidth: (measuredXLabelWidth > xLabelWidth) ? measuredXLabelWidth : xLabelWidth, yLabelWidth: (measuredYLabelWidth > yLabelWidth) ? measuredYLabelWidth : yLabelWidth, } }, { xLabelWidth: 0, yLabelWidth: 0 }) this.xLabelWidth = xLabelWidth this.yLabelWidth = yLabelWidth } setAxisData() { this.xLabelRows = this.getLabelRows({ step: this.xStep }) this.yLabelRows = this.getLabelRows({ axis: 'y', step: this.yStep, gutter: this.yGutter, contentLength: this.contentHeight, toLabel: this.toYLabel, measureLength: () => this.yLabelHeight }) } setData(pointsArr) { this.pointsArr = pointsArr this.clearPointPos() this.setLabelWidths() this.setLabelHeights() this.setAxisData() this.updateLabelSizeForAutoStep() if (this.noData) { return this.raf(() => this.clear()) } this.setPointPos() this.raf(() => { this.drawLabels(this.lineLabels, this.lineStyles) this.draw() }) } setPointPos() { const { firstX, firstY, pointPosMap, xAxisStart, xRatio, yAxisStart, yRatio } = this // edge case: only one point let x = this.xAxisMiddle let y = this.yAxisMiddle this.pointsArr.forEach((points, i) => { points.forEach(point => { if (xRatio !== 0) { x = xAxisStart + ((point.x - firstX) / xRatio) } if (yRatio !== 0) { y = yAxisStart - ((point.y - firstY) / yRatio) } const pos = { x, y } pointPosMap.set(point, pos) }) }) } updateLabelSizeForAutoStep() { const { measureWidth } = this if (isUndef(this.xStep)) { this.xLabelWidth = this.xLabelRows.reduce((width, row) => { const measuredWidth = row.length return (measuredWidth > width) ? measuredWidth : width }, 0) } if (isUndef(this.yStep)) { this.yLabelWidth = this.yLabelRows.reduce((width, row) => { const measuredWidth = measureWidth.call(this, row.label) return (measuredWidth > width) ? measuredWidth : width }, 0) } } destroy() { const { dom, canvas } = this const { toXLabel, toYLabel } = this.options if (isDef(toXLabel)) { mem.clear(this.toXLabel) } if (isDef(toYLabel)) { mem.clear(this.toYLabel) } this.pointsArr.length = 0 this.clearPointPos() this.unbindMedia() this.removeAllLayers() if (dom.contains(canvas)) { dom.removeChild(canvas) dom.style.removeProperty('position') } }
}