import supportDom from '../decorators/supportDom' import chartCommon from '../decorators/chartCommon' import isDef from '../utils/isDef' import isUndef from '../utils/isUndef' import isInt from '../utils/isInt' import { mem, range, sortBy, throttle, uniqBy } from '../utils' import { DEFAULT_CHART_STYLES } from '../consts'
@supportDom @chartCommon export default class BarChart {
constructor(dom, options = {}) { this.dom = dom this.options = options this.bars = [] this.height = options.height this.width = options.width 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.yStep = options.yStep this.yGutter = isDef(options.yGutter) ? options.yGutter : 10 this.xLabelMargin = isDef(options.xLabelMargin) ? options.xLabelMargin : 10 this.yLabelMargin = isDef(options.yLanelMargin) ? options.yLabelMargin : 14 this.fontSize = options.fontSize || 12 this.bg = options.bg || '#fff' this.barStyles = options.barStyles || DEFAULT_CHART_STYLES this.yLabelRows = [] this.barPosMap = new Map() this.init() } init() { this.setDpr() this.setDomSizeIfNeeded() this.setCanvas() this.clear() this.bindMedia() this.bindBarVisible() } get contentWidth() { return this.width - (this.xPadding * 2) - this.yLabelMargin - this.yLabelWidth - this.halfXLabelWidth } get contentHeight() { return this.height - (this.yPadding * 2) - this.xLabelMargin - this.xLabelHeight } get firstY() { return this.yLabelRows[0].value } get halfXLabelWidth() { return this.xLabelWidth / 2 } get halfYLabelHeight() { return this.yLabelHeight / 2 } get xAxisStart() { return this.xPadding + this.halfXLabelWidth } get xAxisEnd() { return this.width - this.xPadding - this.yLabelWidth - this.yLabelMargin - this.halfXLabelWidth } get yAxisStart() { return this.height - this.yPadding - this.xLabelHeight - this.xLabelMargin + this.halfYLabelHeight } get yAxisEnd() { return this.yAxisStart - this.contentHeight } get lastY() { const { yLabelRows } = this return yLabelRows[yLabelRows.length - 1].value } get yRatio() { const lineHeight = this.yAxisStart - this.yAxisEnd const yDelta = Math.abs(this.lastY - this.firstY) return yDelta / lineHeight } bindBarVisible() { if (isUndef(this.options.onBarMouseOver)) { return } if (! ('onmousemove' in this.canvas)) { return } this.addLayer() const canvas = this.getHighestCanvas() this.addEvent(canvas, 'mousemove', throttle(this.handleMouseMove.bind(this), 30)) } clearBarPos() { this.barPosMap.clear() } draw() { this.clear() this.drawXAxis() this.drawYAxis() this.drawBgLines() this.drawBars() } drawBars() { const { barPosMap, barStyles, ctx, xLabelRows } = this xLabelRows.forEach((row, i) => { const pos = barPosMap.get(row) if (pos) { const { x, y, width, height } = pos ctx.fillStyle = barStyles[i] || '#000' ctx.fillRect(x, y, width, height) } }) } drawBgLines() { const { ctx, yLabelRows, firstY, yAxisStart, yRatio } = this const xStart = this.xPadding const xEnd = this.width - this.xPadding - this.yLabelWidth - this.yLabelMargin ctx.strokeStyle = 'rgba(224, 224, 224, .5)' ctx.lineWidth = 1 yLabelRows.forEach(row => { const y = yAxisStart - ((row.value - firstY) / yRatio) ctx.beginPath() ctx.moveTo(xStart, y) ctx.lineTo(xEnd, y) ctx.stroke() ctx.closePath }) } drawXAxis() { const { ctx, xLabelRows, xAxisStart, xAxisEnd } = this const totalWidth = xLabelRows.reduce((w, row) => w + row.length, 0) const contentWidth = xAxisEnd - xAxisStart const gutter = (contentWidth - totalWidth) / (xLabelRows.length - 1) const y = this.height - this.yPadding let x = xAxisStart ctx.textBaseline = 'top' ctx.fillStyle = '#3c4257' xLabelRows.forEach((row, i) => { ctx.fillText(row.label, x, y) x += (row.length + gutter) }) } drawYAxis() { const { ctx, firstY, yLabelRows, yAxisStart, yRatio, yLabelHeight } = this const x = this.width - this.xPadding const delta = yLabelHeight * .45 ctx.fillStyle = '#3c4257' ctx.textAlign = 'right' yLabelRows.forEach(row => { const y = yAxisStart - ((row.value - firstY) / yRatio) ctx.fillText(row.label, x, y - delta) }) } findMouseOverBarPos(canvasMousePos) { const { barPosMap, xLabelRows } = this const { x: mouseX, y: mouseY } = canvasMousePos let index = 0 for (const row of xLabelRows) { const pos = barPosMap.get(row) const { x, y, width, height } = pos if ((x <= mouseX) && (mouseX <= (x + width)) && (y <= mouseY) && (mouseY <= (y + height))) { return { index, row, pos } } ++index } } drawBarGlow(res) { this.clearBarGlow() const ctx = this.firstLayer.canvas.getContext('2d') ctx.save() const { x, y, width, height } = res.pos const glowWidth = width * 1.4 const glowHeight = ((glowWidth - width) / 2) + height const glowX = x - ((glowWidth - width) / 2) const glowY = y - (glowHeight - height) ctx.globalAlpha = 0.2 ctx.fillStyle = this.barStyles[res.index] ctx.fillRect(glowX, glowY, glowWidth, glowHeight) ctx.restore() } clearBarGlow() { const ctx = this.firstLayer.canvas.getContext('2d') ctx.clearRect(0, 0, this.width, this.height) } handleMouseMove(event) { const canvasMousePos = this.getMousePosInCanvas(event) const mouseOverRes = this.findMouseOverBarPos(canvasMousePos) const { lastMouseOverRes } = this // don't repaint the same index if (lastMouseOverRes && mouseOverRes && (lastMouseOverRes.index === mouseOverRes.index)) { return } // don't re-clear if (isUndef(mouseOverRes) && isUndef(lastMouseOverRes)) { return } if (mouseOverRes) { this.drawBarGlow(mouseOverRes) } else { this.clearBarGlow() } this.lastMouseOverRes = mouseOverRes const mousePos = this.getMousePos(canvasMousePos) const res = this.getBarMouseOverRes(mouseOverRes) this.options.onBarMouseOver(mousePos, res) } getBarMouseOverRes(res) { if (res) { return { index: res.index, bar: res.row } } } getUniqSortedBars() { const bars = uniqBy(this.bars, 'value') return sortBy(bars, ['value']) } getXLabelRows() { const { ctx } = this return this.bars.map(bar => { const { label } = bar return { label, length: ctx.measureText(label).width, value: bar.value } }) } getYLabelRows() { const { contentHeight } = this const gutter = this.yGutter const toLabel = this.toYLabel const measureLength = () => this.fontSize const bars = this.getUniqSortedBars() const firstBar = bars[0] const lastBar = bars[bars.length - 1] if (bars.length <= 2) { return bars.map(bar => { const value = bar.value const label = toLabel(value) const length = measureLength() return { label, length, value } }) } const firstBarValue = firstBar.value const lastBarValue = lastBar.value const barCount = bars.length let step = this.yStep if (isUndef(step)) { step = this.getAutoStep(firstBarValue, lastBarValue, barCount) } const [stepStart, stepEnd] = this.getStepStartEnd(step, firstBarValue, lastBarValue) const values = range(stepStart, stepEnd + step, step) .map(value => { return isInt(value) ? value : parseFloat(value.toFixed(2)) }) 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(), value: firstValue }, { label: lastLabel, length: measureLength(), value: lastValue }, ] for (let gap = initialGap; gap >= 0; gap--) { const { lengthTotal, rows } = this.getLengthTotalData(gap, gutter, values, measureLength, toLabel) if (lengthTotal <= contentHeight) { stepRows = rows continue } return stepRows } return stepRows } refresh() { this.raf(() => { this.clearBarPos() 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.setBarPos() this.draw() }) } setAxisData() { this.xLabelRows = this.getXLabelRows() this.yLabelRows = this.getYLabelRows() } setBarPos() { const barWidth = 45 const halfBarWidth = parseInt(barWidth / 2, 10) const { barPosMap, firstY, xLabelRows, xAxisStart, xAxisEnd, yAxisStart, yRatio } = this const totalWidth = xLabelRows.reduce((w, row) => w + row.length, 0) const contentWidth = xAxisEnd - xAxisStart const gutter = (contentWidth - totalWidth) / (xLabelRows.length - 1) const { centerPoints } = xLabelRows.reduce((res, row, i) => { const { x } = res const centerPoints = res.centerPoints.slice() const width = row.length const halfWidth = parseInt(width / 2, 10) centerPoints.push(x + halfWidth) return { x: x + (width + gutter), centerPoints } }, { x: xAxisStart, centerPoints: [] }) xLabelRows.forEach((row, i) => { const barHeight = (row.value - firstY) / yRatio const centerPoint = centerPoints[i] const x = centerPoint - halfBarWidth const y = yAxisStart - barHeight const pos = { x, y, width: barWidth, height: barHeight } barPosMap.set(row, pos) }) } setData(bars) { this.clearBarPos() this.bars = bars this.setLabelWidths() this.setLabelHeights() this.setAxisData() this.setBarPos() this.raf(() => this.draw()) } setLabelWidths() { const { toYLabel, ctx } = this const res = this.bars.filter(bar => bar) .reduce((o, bar) => { const { xLabelWidth, yLabelWidth } = o const measuredXLabelWidth = ctx.measureText(bar.label).width const measuredYLabelWidth = ctx.measureText(toYLabel(bar.value)).width return { xLabelWidth: (measuredXLabelWidth > xLabelWidth) ? measuredXLabelWidth : xLabelWidth, yLabelWidth: (measuredYLabelWidth > yLabelWidth) ? measuredYLabelWidth : yLabelWidth, } }, { xLabelWidth: 0, yLabelWidth: 0 }) this.xLabelWidth = res.xLabelWidth this.yLabelWidth = res.yLabelWidth } setLabelHeights() { this.xLabelHeight = this.fontSize this.yLabelHeight = this.fontSize } handleDprChange() { this.setDpr() this.refresh() } destroy() { const { dom, canvas } = this const { toYLabel } = this.options if (isDef(toYLabel)) { mem.clear(this.toYLabel) } this.clearBarPos() this.unbindMedia() this.removeAllLayers() if (dom.contains(canvas)) { dom.removeChild(canvas) dom.style.removeProperty('position') } }
}