mirror of
https://github.com/frappe/books.git
synced 2025-01-11 18:38:47 +00:00
refactor: rewrite linechart from scratch
This commit is contained in:
parent
a39b7a3d3e
commit
fb5318ddf3
235
src/components/Charts/LineChart.vue
Normal file
235
src/components/Charts/LineChart.vue
Normal 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>
|
20
src/components/Charts/chartUtils.ts
Normal file
20
src/components/Charts/chartUtils.ts
Normal 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}`;
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-4 -mb-14">
|
<div class="mx-4">
|
||||||
<template v-if="hasData">
|
<template v-if="hasData">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-medium">{{ t('Cashflow') }}</div>
|
<div class="font-medium">{{ t('Cashflow') }}</div>
|
||||||
@ -15,7 +15,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<PeriodSelector :value="period" @change="(value) => (period = value)" />
|
<PeriodSelector :value="period" @change="(value) => (period = value)" />
|
||||||
</div>
|
</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>
|
</template>
|
||||||
<svg
|
<svg
|
||||||
v-else
|
v-else
|
||||||
@ -93,40 +99,64 @@ import { Chart } from 'frappe-charts';
|
|||||||
import PeriodSelector from './PeriodSelector';
|
import PeriodSelector from './PeriodSelector';
|
||||||
import Cashflow from '../../../reports/Cashflow/Cashflow';
|
import Cashflow from '../../../reports/Cashflow/Cashflow';
|
||||||
import { getDatesAndPeriodicity } from './getDatesAndPeriodicity';
|
import { getDatesAndPeriodicity } from './getDatesAndPeriodicity';
|
||||||
|
import LineChart from '@/components/Charts/LineChart.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Cashflow',
|
name: 'Cashflow',
|
||||||
components: {
|
components: {
|
||||||
PeriodSelector,
|
PeriodSelector,
|
||||||
|
LineChart,
|
||||||
},
|
},
|
||||||
data: () => ({ period: 'This Year', hasData: false }),
|
data: () => ({
|
||||||
|
period: 'This Year',
|
||||||
|
data: [],
|
||||||
|
periodList: [],
|
||||||
|
}),
|
||||||
watch: {
|
watch: {
|
||||||
period: 'render',
|
period: 'setData',
|
||||||
|
},
|
||||||
|
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 };
|
||||||
},
|
},
|
||||||
activated() {
|
|
||||||
this.render();
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async render() {
|
async setData() {
|
||||||
let { fromDate, toDate, periodicity } = await getDatesAndPeriodicity(
|
let { fromDate, toDate, periodicity } = await getDatesAndPeriodicity(
|
||||||
this.period
|
this.period
|
||||||
);
|
);
|
||||||
|
|
||||||
let { data, periodList } = await new Cashflow().run({
|
const { data, periodList } = await new Cashflow().run({
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate,
|
toDate,
|
||||||
periodicity,
|
periodicity,
|
||||||
});
|
});
|
||||||
|
|
||||||
let totalInflow = data.reduce((sum, d) => d.inflow + sum, 0);
|
this.data = data;
|
||||||
let totalOutflow = data.reduce((sum, d) => d.outflow + sum, 0);
|
this.periodList = periodList;
|
||||||
this.hasData = !(totalInflow === 0 && totalOutflow === 0);
|
|
||||||
if (!this.hasData) return;
|
|
||||||
|
|
||||||
this.$nextTick(() => this.renderChart(periodList, data));
|
console.log(periodList, data);
|
||||||
},
|
},
|
||||||
|
renderChart() {
|
||||||
renderChart(periodList, data) {
|
|
||||||
new Chart(this.$refs['cashflow'], {
|
new Chart(this.$refs['cashflow'], {
|
||||||
title: '',
|
title: '',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@ -146,17 +176,17 @@ export default {
|
|||||||
formatTooltipY: (value) => frappe.format(value ?? 0, 'Currency'),
|
formatTooltipY: (value) => frappe.format(value ?? 0, 'Currency'),
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
labels: periodList.map((p) => p.split(' ')[0]),
|
labels: this.periodList.map((p) => p.split(' ')[0]),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
name: 'Inflow',
|
name: 'Inflow',
|
||||||
chartType: 'line',
|
chartType: 'line',
|
||||||
values: data.map((period) => period.inflow),
|
values: this.data.map((period) => period.inflow),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Outflow',
|
name: 'Outflow',
|
||||||
chartType: 'line',
|
chartType: 'line',
|
||||||
values: data.map((period) => period.outflow),
|
values: this.data.map((period) => period.outflow),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user