Commit 0f8901d6 authored by Martin Wortschack's avatar Martin Wortschack

Migrate contribution analytics chart

- Ports the charts on the
contributio analytics page from
d3 to echarts
parent 28cfd6d0
<script>
import * as d3 from 'd3';
import tooltip from '../directives/tooltip';
import Icon from './icon.vue';
import SvgGradient from './svg_gradient.vue';
import {
GRADIENT_COLORS,
GRADIENT_OPACITY,
INVERSE_GRADIENT_COLORS,
INVERSE_GRADIENT_OPACITY,
} from './bar_chart_constants';
/**
* Renders a bar chart that can be dragged(scrolled) when the number
* of elements to renders surpasses that of the available viewport space
* while keeping even padding and a width of 24px (customizable)
*
* It can render data with the following format:
* graphData: [{
* name: 'element' // x domain data
* value: 1 // y domain data
* }]
*
* Used in:
* - Contribution analytics - all of the rows describing pushes, merge requests and issues
*/
export default {
directives: {
tooltip,
},
components: {
Icon,
SvgGradient,
},
props: {
graphData: {
type: Array,
required: true,
},
barWidth: {
type: Number,
required: false,
default: 24,
},
yAxisLabel: {
type: String,
required: true,
},
},
data() {
return {
minX: -40,
minY: 0,
vbWidth: 0,
vbHeight: 0,
vpWidth: 0,
vpHeight: 200,
preserveAspectRatioType: 'xMidYMin meet',
containerMargin: {
leftRight: 30,
},
viewBoxMargin: {
topBottom: 100,
},
panX: 0,
xScale: {},
yScale: {},
zoom: {},
bars: {},
xGraphRange: 0,
isLoading: true,
paddingThreshold: 50,
showScrollIndicator: false,
showLeftScrollIndicator: false,
isGrabbed: false,
isPanAvailable: false,
gradientColors: GRADIENT_COLORS,
gradientOpacity: GRADIENT_OPACITY,
inverseGradientColors: INVERSE_GRADIENT_COLORS,
inverseGradientOpacity: INVERSE_GRADIENT_OPACITY,
maxTextWidth: 72,
rectYAxisLabelDims: {},
xAxisTextElements: {},
yAxisRectTransformPadding: 20,
yAxisTextTransformPadding: 10,
yAxisTextRotation: 90,
};
},
computed: {
svgViewBox() {
return `${this.minX} ${this.minY} ${this.vbWidth} ${this.vbHeight}`;
},
xAxisLocation() {
return `translate(${this.panX}, ${this.vbHeight})`;
},
barTranslationTransform() {
return `translate(${this.panX}, 0)`;
},
scrollIndicatorTransform() {
return `translate(${this.vbWidth - 80}, 0)`;
},
activateGrabCursor() {
return {
'svg-graph-container-with-grab': this.isPanAvailable,
'svg-graph-container-grabbed': this.isPanAvailable && this.isGrabbed,
};
},
yAxisLabelRectTransform() {
const rectWidth =
this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0;
const yCoord = this.vbHeight / 2 - rectWidth;
return `translate(${this.minX - this.yAxisRectTransformPadding}, ${yCoord})`;
},
yAxisLabelTextTransform() {
const rectWidth =
this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0;
const yCoord = this.vbHeight / 2 + rectWidth - 5;
return `translate(${this.minX + this.yAxisTextTransformPadding}, ${yCoord}) rotate(-${
this.yAxisTextRotation
})`;
},
},
mounted() {
if (!this.allValuesEmpty) {
this.draw();
}
},
methods: {
draw() {
// update viewport
this.vpWidth = this.$refs.svgContainer.clientWidth - this.containerMargin.leftRight;
// update viewbox
this.vbWidth = this.vpWidth;
this.vbHeight = this.vpHeight - this.viewBoxMargin.topBottom;
let padding = 0;
if (this.graphData.length * this.barWidth > this.vbWidth) {
this.xGraphRange = this.graphData.length * this.barWidth;
padding = this.calculatePadding(this.barWidth);
this.showScrollIndicator = true;
this.isPanAvailable = true;
} else {
this.xGraphRange = this.vbWidth - Math.abs(this.minX);
}
this.xScale = d3
.scaleBand()
.range([0, this.xGraphRange])
.round(true)
.paddingInner(padding);
this.yScale = d3.scaleLinear().rangeRound([this.vbHeight, 0]);
this.xScale.domain(this.graphData.map(d => d.name));
this.yScale.domain([0, d3.max(this.graphData.map(d => d.value))]);
// Zoom/Panning Function
this.zoom = d3
.zoom()
.translateExtent([[0, 0], [this.xGraphRange, this.vbHeight]])
.on('zoom', this.panGraph)
.on('end', this.removeGrabStyling);
const xAxis = d3.axisBottom().scale(this.xScale);
const yAxis = d3
.axisLeft()
.scale(this.yScale)
.ticks(4);
const renderedXAxis = d3
.select(this.$refs.baseSvg)
.select('.x-axis')
.call(xAxis);
this.xAxisTextElements = this.$refs.xAxis.querySelectorAll('text');
renderedXAxis.select('.domain').remove();
renderedXAxis
.selectAll('text')
.style('text-anchor', 'end')
.attr('dx', '-.3em')
.attr('dy', '-.95em')
.attr('class', 'tick-text')
.attr('transform', 'rotate(-90)');
renderedXAxis.selectAll('line').remove();
const { maxTextWidth } = this;
renderedXAxis.selectAll('text').each(function formatText() {
const axisText = d3.select(this);
let textLength = axisText.node().getComputedTextLength();
let textContent = axisText.text();
while (textLength > maxTextWidth && textContent.length > 0) {
textContent = textContent.slice(0, -1);
axisText.text(`${textContent}...`);
textLength = axisText.node().getComputedTextLength();
}
});
const width = this.vbWidth;
const renderedYAxis = d3
.select(this.$refs.baseSvg)
.select('.y-axis')
.call(yAxis);
renderedYAxis.selectAll('.tick').each(function createTickLines(d, i) {
if (i > 0) {
d3.select(this)
.select('line')
.attr('x2', width)
.attr('class', 'axis-tick');
}
});
// Add the panning capabilities
if (this.isPanAvailable) {
d3.select(this.$refs.baseSvg)
.call(this.zoom)
.on('wheel.zoom', null); // This disables the pan of the graph with the scroll of the mouse wheel
}
this.isLoading = false;
// Update the yAxisLabel coordinates
const labelDims = this.$refs.yAxisLabel.getBBox();
this.rectYAxisLabelDims = {
height: labelDims.width + 10,
};
},
panGraph() {
const allowedRightScroll = this.xGraphRange - this.vbWidth - this.paddingThreshold;
const graphMaxPan = Math.abs(d3.event.transform.x) < allowedRightScroll;
this.isGrabbed = true;
this.panX = d3.event.transform.x;
if (d3.event.transform.x === 0) {
this.showLeftScrollIndicator = false;
} else {
this.showLeftScrollIndicator = true;
this.showScrollIndicator = true;
}
if (!graphMaxPan) {
this.panX = -1 * (this.xGraphRange - this.vbWidth + this.paddingThreshold);
this.showScrollIndicator = false;
}
},
setTooltipTitle(data) {
return data !== null ? `${data.name}: ${data.value}` : '';
},
calculatePadding(desiredBarWidth) {
const widthWithMargin = this.vbWidth - Math.abs(this.minX);
const dividend = widthWithMargin - this.graphData.length * desiredBarWidth;
const divisor = widthWithMargin - desiredBarWidth;
return dividend / divisor;
},
removeGrabStyling() {
this.isGrabbed = false;
},
barHoveredIn(index) {
this.xAxisTextElements[index].classList.add('x-axis-text');
},
barHoveredOut(index) {
this.xAxisTextElements[index].classList.remove('x-axis-text');
},
},
};
</script>
<template>
<div ref="svgContainer" :class="activateGrabCursor" class="svg-graph-container">
<svg
ref="baseSvg"
class="svg-graph overflow-visible pt-5"
:width="vpWidth"
:height="vpHeight"
:viewBox="svgViewBox"
:preserveAspectRatio="preserveAspectRatioType"
>
<g ref="xAxis" :transform="xAxisLocation" class="x-axis" />
<g v-if="!isLoading">
<template v-for="(data, index) in graphData">
<rect
:key="index"
v-tooltip
:width="xScale.bandwidth()"
:x="xScale(data.name)"
:y="yScale(data.value)"
:height="vbHeight - yScale(data.value)"
:transform="barTranslationTransform"
:title="setTooltipTitle(data)"
class="bar-rect"
data-placement="top"
@mouseover="barHoveredIn(index)"
@mouseout="barHoveredOut(index)"
/>
</template>
</g>
<rect :height="vbHeight + 100" transform="translate(-100, -5)" width="100" fill="#fff" />
<g class="y-axis-label">
<line :x1="0" :x2="0" :y1="0" :y2="vbHeight" transform="translate(-35, 0)" stroke="black" />
<!-- Get text length and change the height of this rect accordingly -->
<rect
:height="rectYAxisLabelDims.height"
:transform="yAxisLabelRectTransform"
:width="30"
fill="#fff"
/>
<text ref="yAxisLabel" :transform="yAxisLabelTextTransform">{{ yAxisLabel }}</text>
</g>
<g class="y-axis" />
<g v-if="showScrollIndicator">
<rect
:height="vbHeight + 100"
:transform="`translate(${vpWidth - 60}, -5)`"
width="40"
fill="#fff"
/>
<icon
:x="vpWidth - 50"
:y="vbHeight / 2"
:width="14"
:height="14"
name="chevron-right"
class="animate-flicker"
/>
</g>
<!-- The line that shows up when the data elements surpass the available width -->
<g v-if="showScrollIndicator" :transform="scrollIndicatorTransform">
<rect :height="vbHeight" x="0" y="0" width="20" fill="url(#shadow-gradient)" />
</g>
<!-- Left scroll indicator -->
<g v-if="showLeftScrollIndicator" transform="translate(0, 0)">
<rect :height="vbHeight" x="0" y="0" width="20" fill="url(#left-shadow-gradient)" />
</g>
<svg-gradient
:colors="gradientColors"
:opacity="gradientOpacity"
identifier-name="shadow-gradient"
/>
<svg-gradient
:colors="inverseGradientColors"
:opacity="inverseGradientOpacity"
identifier-name="left-shadow-gradient"
/>
</svg>
</div>
</template>
export const GRADIENT_COLORS = ['#000', '#a7a7a7'];
export const GRADIENT_OPACITY = ['0', '0.4'];
export const INVERSE_GRADIENT_COLORS = ['#a7a7a7', '#000'];
export const INVERSE_GRADIENT_OPACITY = ['0.4', '0'];
---
title: Move contribution analytics chart to echarts
merge_request: 24272
author:
type: other
<script>
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
const CHART_HEIGHT = 220;
export default {
components: {
GlColumnChart,
ResizableChartContainer,
},
props: {
chartData: {
type: Array,
required: true,
},
xAxisTitle: {
type: String,
required: false,
default: '',
},
yAxisTitle: {
type: String,
required: false,
default: '',
},
},
data() {
return {
svgs: {},
};
},
computed: {
dataZoomConfig() {
const handleIcon = this.svgs['scroll-handle'];
return handleIcon ? { handleIcon } : {};
},
chartOptions() {
return {
dataZoom: [this.dataZoomConfig],
};
},
seriesData() {
return { full: this.chartData };
},
},
methods: {
setSvg(name) {
return getSvgIconPathContent(name)
.then(path => {
if (path) {
this.$set(this.svgs, name, `path://${path}`);
}
})
.catch(e => {
// eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings
console.error('SVG could not be rendered correctly: ', e);
});
},
onChartCreated() {
this.setSvg('scroll-handle');
},
},
height: CHART_HEIGHT,
};
</script>
<template>
<resizable-chart-container>
<gl-column-chart
slot-scope="{ width }"
v-bind="$attrs"
:width="width"
:height="$options.height"
:data="seriesData"
:x-axis-title="xAxisTitle"
:y-axis-title="yAxisTitle"
x-axis-type="category"
:option="chartOptions"
@created="onChartCreated"
/>
</resizable-chart-container>
</template>
import Vue from 'vue';
import { sortBy } from 'lodash';
import ColumnChart from './components/column_chart.vue';
import { __ } from '~/locale';
const sortByValue = data => sortBy(data, item => item[1]).reverse();
const allValuesEmpty = graphData =>
graphData.reduce((acc, data) => acc + Math.min(0, data[1]), 0) === 0;
export default dataEl => {
if (!dataEl) return;
const data = JSON.parse(dataEl.innerHTML);
const outputElIds = ['push', 'issues_closed', 'merge_requests_created'];
const xAxisType = 'category';
const xAxisTitle = __('User');
const formattedData = {
push: [],
issues_closed: [],
merge_requests_created: [],
};
outputElIds.forEach(id => {
data[id].data.forEach((d, index) => {
formattedData[id].push([data.labels[index], d]);
});
});
const pushesEl = document.getElementById('js_pushes_chart_vue');
if (allValuesEmpty(formattedData.push)) {
// eslint-disable-next-line no-new
new Vue({
el: pushesEl,
components: {
ColumnChart,
},
render(h) {
return h(ColumnChart, {
props: {
chartData: sortByValue(formattedData.push),
xAxisTitle,
yAxisTitle: __('Pushes'),
xAxisType,
},
});
},
});
}
const mergeRequestEl = document.getElementById('js_merge_requests_chart_vue');
if (allValuesEmpty(formattedData.merge_requests_created)) {
// eslint-disable-next-line no-new
new Vue({
el: mergeRequestEl,
components: {
ColumnChart,
},
render(h) {
return h(ColumnChart, {
props: {
chartData: sortByValue(formattedData.merge_requests_created),
xAxisTitle,
yAxisTitle: __('Merge Requests created'),
xAxisType,
},
});
},
});
}
const issueEl = document.getElementById('js_issues_chart_vue');
if (allValuesEmpty(formattedData.issues_closed)) {
// eslint-disable-next-line no-new
new Vue({
el: issueEl,
components: {
ColumnChart,
},
render(h) {
return h(ColumnChart, {
props: {
chartData: sortByValue(formattedData.issues_closed),
xAxisTitle,
yAxisTitle: __('Issues closed'),
xAxisType,
},
});
},
});
}
};
import Vue from 'vue';
import _ from 'underscore';
import initContributionAanalyticsCharts from 'ee/analytics/contribution_analytics/contribution_analytics_bundle';
import initGroupMemberContributions from 'ee/group_member_contributions';
import BarChart from '~/vue_shared/components/bar_chart.vue';
import { __ } from '~/locale';
function sortByValue(data) {
return _.sortBy(data, 'value').reverse();
}
function allValuesEmpty(graphData) {
const emptyCount = graphData.reduce((acc, data) => acc + Math.min(0, data.value), 0);
return emptyCount === 0;
}
document.addEventListener('DOMContentLoaded', () => {
const dataEl = document.getElementById('js-analytics-data');
if (dataEl) {
const data = JSON.parse(dataEl.innerHTML);
const outputElIds = ['push', 'issues_closed', 'merge_requests_created'];
const formattedData = {
push: [],
issues_closed: [],
merge_requests_created: [],
};
outputElIds.forEach(id => {
data[id].data.forEach((d, index) => {
formattedData[id].push({
name: data.labels[index],
value: d,
});
});
});
initContributionAanalyticsCharts(dataEl);
initGroupMemberContributions();
const pushesEl = document.getElementById('js_pushes_chart_vue');
if (allValuesEmpty(formattedData.push)) {
// eslint-disable-next-line no-new
new Vue({
el: pushesEl,
components: {
BarChart,
},
render(createElement) {
return createElement('bar-chart', {
props: {
graphData: sortByValue(formattedData.push),
yAxisLabel: __('Pushes'),
},
});
},
});
}
const mergeRequestEl = document.getElementById('js_merge_requests_chart_vue');
if (allValuesEmpty(formattedData.merge_requests_created)) {
// eslint-disable-next-line no-new
new Vue({
el: mergeRequestEl,
components: {
BarChart,
},
render(createElement) {
return createElement('bar-chart', {
props: {
graphData: sortByValue(formattedData.merge_requests_created),
yAxisLabel: __('Merge Requests created'),
},
});
},
});
}
const issueEl = document.getElementById('js_issues_chart_vue');
if (allValuesEmpty(formattedData.issues_closed)) {
// eslint-disable-next-line no-new
new Vue({
el: issueEl,
components: {
BarChart,
},
render(createElement) {
return createElement('bar-chart', {
props: {
graphData: sortByValue(formattedData.issues_closed),
yAxisLabel: __('Issues closed'),
},
});
},
});
}
}
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Contribution Analytics Column Chart matches the snapshot 1`] = `
<div>
<gl-column-chart-stub
data="[object Object]"
height="220"
option="[object Object]"
width="0"
xaxistitle="Username"
xaxistype="category"
yaxistitle="Pushes"
/>
</div>
`;
import { mount } from '@vue/test-utils';
import Component from 'ee/analytics/contribution_analytics/components/column_chart.vue';
const mockChartData = [['root', 100], ['desiree', 30], ['katlyn', 70], ['myrtis', 0]];
describe('Contribution Analytics Column Chart', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(Component, {
propsData: {
chartData: mockChartData,
xAxisTitle: 'Username',
yAxisTitle: 'Pushes',
},
stubs: {
'gl-column-chart': true,
},
});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import BarChart from '~/vue_shared/components/bar_chart.vue';
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
function generateRandomData(dataNumber) {
const randomGraphData = [];
for (let i = 1; i <= dataNumber; i += 1) {
randomGraphData.push({
name: `random ${i}`,
value: parseInt(getRandomArbitrary(1, 8), 10),
});
}
return randomGraphData;
}
describe('Bar chart component', () => {
let barChart;
const graphData = generateRandomData(10);
beforeEach(() => {
const BarChartComponent = Vue.extend(BarChart);
barChart = mountComponent(BarChartComponent, {
graphData,
yAxisLabel: 'data',
});
});
afterEach(() => {
barChart.$destroy();
});
it('calculates the padding for even distribution across bars', () => {
barChart.vbWidth = 1000;
const result = barChart.calculatePadding(30);
// since padding can't be higher than 1 and lower than 0
// for more info: https://github.com/d3/d3-scale#band-scales
expect(result).not.toBeLessThan(0);
expect(result).not.toBeGreaterThan(1);
});
it('formats the tooltip title', () => {
const tooltipTitle = barChart.setTooltipTitle(barChart.graphData[0]);
expect(tooltipTitle).toContain('random 1:');
});
it('has a translates the bar graphs on across the X axis', () => {
barChart.panX = 100;
expect(barChart.barTranslationTransform).toEqual('translate(100, 0)');
});
it('translates the scroll indicator to the far right side', () => {
barChart.vbWidth = 500;
expect(barChart.scrollIndicatorTransform).toEqual('translate(420, 0)');
});
it('translates the x-axis to the bottom of the viewbox and pan coordinates', () => {
barChart.panX = 100;
barChart.vbHeight = 250;
expect(barChart.xAxisLocation).toEqual('translate(100, 250)');
});
it('rotates the x axis labels a total of 90 degress (CCW)', () => {
const xAxisLabel = barChart.$el.querySelector('.x-axis').querySelectorAll('text')[0];
expect(xAxisLabel.getAttribute('transform')).toEqual('rotate(-90)');
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment