2
0
mirror of https://github.com/frappe/books.git synced 2024-12-23 19:39:07 +00:00

refactor: rewrite linechart from scratch

This commit is contained in:
18alantom 2022-01-26 15:33:54 +05:30 committed by Alan
parent a39b7a3d3e
commit fb5318ddf3
3 changed files with 303 additions and 18 deletions

View File

@ -0,0 +1,235 @@
<template>
<svg
:viewBox="`0 0 ${viewBoxWidth} ${viewBoxHeight}`"
xmlns="http://www.w3.org/2000/svg"
>
<!-- x Grid Lines -->
<path
v-if="drawXGrid"
:d="xGrid"
:stroke="gridColor"
:stroke-width="gridThickness"
stroke-linecap="round"
fill="transparent"
/>
<!-- Axis -->
<path
v-if="drawAxis"
:d="axis"
:stroke-width="axisThickness"
:stroke="axisColor"
fill="transparent"
/>
<!-- x Labels -->
<template v-if="yLabels.length > 0">
<text
:style="fontStyle"
v-for="(i, j) in count"
:key="j + '-xlabels'"
:y="
viewBoxHeight -
axisPadding +
yLabelOffset +
fontStyle.fontSize / 2 -
bottom
"
:x="xs[i - 1]"
text-anchor="middle"
>
{{ yLabels[i - 1] || '' }}
</text>
</template>
<!-- y Labels -->
<template v-if="xLabelDivisions > 0">
<text
:style="fontStyle"
v-for="(i, j) in xLabelDivisions + 1"
:key="j + '-ylabels'"
:y="yScalerLocation(i - 1)"
:x="axisPadding - xLabelOffset + left"
text-anchor="end"
>
{{ yScalerValue(i - 1) }}
</text>
</template>
<!-- Gradients -->
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="0" y2="85%">
<stop offset="0%" stop-color="rgba(255, 255, 255, 0.4)" />
<stop offset="100%" stop-color="rgba(255, 255, 255, 0)" />
</linearGradient>
<mask id="rect-mask">
<rect x="0" y="0" width="100%" height="100%" fill="url('#grad')" />
</mask>
</defs>
<!-- Gradient Paths -->
<path
v-for="(i, j) in num"
:key="j + '-gpath'"
:d="getGradLine(i - 1)"
:stroke-width="thickness"
stroke-linecap="round"
:fill="colors[i - 1] || getRandomColor()"
mask="url('#rect-mask')"
/>
<!-- Lines -->
<path
v-for="(i, j) in num"
:key="j + '-line'"
:d="getLine(i - 1)"
:stroke="colors[i - 1] || getRandomColor()"
:stroke-width="thickness"
stroke-linecap="round"
fill="transparent"
/>
</svg>
</template>
<script>
import { prefixFormat } from './chartUtils';
export default {
props: {
colors: { type: Array, default: () => [] },
yLabels: { type: Array, default: () => [] },
xLabelDivisions: { type: Number, default: 4 },
points: { type: Array, default: () => [[100, 200, 300, 400, 500]] },
drawAxis: { type: Boolean, default: false },
drawXGrid: { type: Boolean, default: true },
viewBoxHeight: { type: Number, default: 500 },
aspectRatio: { type: Number, default: 3.5 },
axisPadding: { type: Number, default: 30 },
pointsPadding: { type: Number, default: 24 },
xLabelOffset: { type: Number, default: 5 },
yLabelOffset: { type: Number, default: 5 },
gridColor: { type: String, default: 'rgba(0, 0, 0, 0.2)' },
axisColor: { type: String, default: 'rgba(0, 0, 0, 0.5)' },
thickness: { type: Number, default: 4 },
axisThickness: { type: Number, default: 1 },
gridThickness: { type: Number, default: 0.5 },
yMin: { type: Number, default: null },
yMax: { type: Number, default: null },
format: { type: Function, default: (n) => n.toFixed(1) },
formatY: { type: Function, default: prefixFormat },
fontSize: { type: Number, default: 18 },
fontColor: { type: String, default: '#415668' },
bottom: { type: Number, default: 0 },
left: { type: Number, default: 55 },
},
computed: {
fontStyle() {
return { fontSize: this.fontSize, fill: this.fontColor };
},
viewBoxWidth() {
return this.aspectRatio * this.viewBoxHeight;
},
num() {
return this.points.length;
},
count() {
return Math.max(...this.points.map((p) => p.length));
},
xs() {
return Array(this.count)
.fill()
.map(
(_, i) =>
this.padding +
this.left +
(i * (this.viewBoxWidth - this.left - 2 * this.padding)) /
(this.count - 1)
);
},
ys() {
const min = this.yMin ?? this.min;
const max = this.yMax ?? this.max;
return this.points.map((pp) =>
pp.map(
(p) =>
this.padding +
(1 - (p - min) / (max - min)) *
(this.viewBoxHeight - 2 * this.padding - this.bottom)
)
);
},
xy() {
return this.xs.map((x, i) => [x, this.ys.map((y) => y[i])]);
},
min() {
return Math.min(...this.points.flat());
},
max() {
return Math.max(...this.points.flat());
},
axis() {
return `M ${this.axisPadding + this.left} ${this.axisPadding} V ${
this.viewBoxHeight - this.axisPadding - this.bottom
} H ${this.viewBoxWidth - this.axisPadding}`;
},
padding() {
return this.axisPadding + this.pointsPadding;
},
xGrid() {
const lo = this.padding + this.left;
const ro = this.viewBoxWidth - this.padding;
const ys = Array(this.xLabelDivisions + 1)
.fill()
.map((_, i) => this.yScalerLocation(i));
return ys.map((y) => `M ${lo} ${y} H ${ro}`).join(' ');
},
yGrid() {
return [];
},
},
data() {
return {};
},
mounted() {},
methods: {
yScalerLocation(i) {
return (
((this.xLabelDivisions - i) *
(this.viewBoxHeight - this.padding * 2 - this.bottom)) /
this.xLabelDivisions +
this.padding
);
},
yScalerValue(i) {
return this.formatY(
(i * (this.max - this.min)) / this.xLabelDivisions + this.min
);
},
getLine(i) {
const [x, y] = this.xy[0];
let d = `M ${x} ${y[i]} `;
this.xy.slice(1).forEach(([x, y]) => {
d += `L ${x} ${y[i]} `;
});
return d;
},
getGradLine(i) {
let bo = this.viewBoxHeight - this.padding - this.bottom;
let d = `M ${this.padding + this.left} ${bo}`;
this.xy.forEach(([x, y]) => {
d += `L ${x} ${y[i]} `;
});
return d + ` V ${bo} Z`;
},
getRandomColor() {
const rgb = Array(3)
.fill()
.map(() => parseInt(Math.random() * 255))
.join(',');
return `rgb(${rgb})`;
},
},
};
</script>

View File

@ -0,0 +1,20 @@
export function prefixFormat(value: number): string {
/*
1,000000,000,000,000,000 = 1 P (Pentillion)
1000,000,000,000,000 = 1 Q (Quadrillion)
1000,000,000,000 = 1 T (Trillion)
1000,000,000 = 1 B (Billion)
1000,000 = 1 M (Million)
1000 = 1 K (Thousand)
1 = 1
*/
if (Math.abs(value) < 1) {
return Math.round(value).toString();
}
const ten = Math.floor(Math.log10(Math.abs(value)));
const three = Math.floor(ten / 3);
const num = Math.round(value / Math.pow(10, three * 3));
const suffix = ['', 'K', 'M', 'B', 'T', 'Q', 'P'][three];
return `${num} ${suffix}`;
}

View File

@ -1,5 +1,5 @@
<template>
<div class="mx-4 -mb-14">
<div class="mx-4">
<template v-if="hasData">
<div class="flex items-center justify-between">
<div class="font-medium">{{ t('Cashflow') }}</div>
@ -15,7 +15,13 @@
</div>
<PeriodSelector :value="period" @change="(value) => (period = value)" />
</div>
<div class="chart-wrapper" ref="cashflow"></div>
<LineChart
class="h-90"
:colors="chartData.colors"
:points="chartData.points"
:y-labels="chartData.yLabels"
:format="chartData.format"
/>
</template>
<svg
v-else
@ -93,40 +99,64 @@ import { Chart } from 'frappe-charts';
import PeriodSelector from './PeriodSelector';
import Cashflow from '../../../reports/Cashflow/Cashflow';
import { getDatesAndPeriodicity } from './getDatesAndPeriodicity';
import LineChart from '@/components/Charts/LineChart.vue';
export default {
name: 'Cashflow',
components: {
PeriodSelector,
LineChart,
},
data: () => ({ period: 'This Year', hasData: false }),
data: () => ({
period: 'This Year',
data: [],
periodList: [],
}),
watch: {
period: 'render',
period: 'setData',
},
activated() {
this.render();
async activated() {
await this.setData();
console.log(this.hasData);
if (this.hasData) {
this.$nextTick(() => this.renderChart());
}
},
computed: {
hasData() {
let totalInflow = this.data.reduce((sum, d) => d.inflow + sum, 0);
let totalOutflow = this.data.reduce((sum, d) => d.outflow + sum, 0);
return !(totalInflow === 0 && totalOutflow === 0);
},
chartData() {
const yLabels = this.periodList.map((l) => l.split(' ')[0]);
const points = ['inflow', 'outflow'].map((k) =>
this.data.map((d) => d[k])
);
const colors = ['#2490EF', '#B7BFC6'];
const format = (value) => frappe.format(value ?? 0, 'Currency');
return { points, yLabels, colors, format };
},
},
methods: {
async render() {
async setData() {
let { fromDate, toDate, periodicity } = await getDatesAndPeriodicity(
this.period
);
let { data, periodList } = await new Cashflow().run({
const { data, periodList } = await new Cashflow().run({
fromDate,
toDate,
periodicity,
});
let totalInflow = data.reduce((sum, d) => d.inflow + sum, 0);
let totalOutflow = data.reduce((sum, d) => d.outflow + sum, 0);
this.hasData = !(totalInflow === 0 && totalOutflow === 0);
if (!this.hasData) return;
this.data = data;
this.periodList = periodList;
this.$nextTick(() => this.renderChart(periodList, data));
console.log(periodList, data);
},
renderChart(periodList, data) {
renderChart() {
new Chart(this.$refs['cashflow'], {
title: '',
type: 'line',
@ -146,17 +176,17 @@ export default {
formatTooltipY: (value) => frappe.format(value ?? 0, 'Currency'),
},
data: {
labels: periodList.map((p) => p.split(' ')[0]),
labels: this.periodList.map((p) => p.split(' ')[0]),
datasets: [
{
name: 'Inflow',
chartType: 'line',
values: data.map((period) => period.inflow),
values: this.data.map((period) => period.inflow),
},
{
name: 'Outflow',
chartType: 'line',
values: data.map((period) => period.outflow),
values: this.data.map((period) => period.outflow),
},
],
},