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:
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>
|
||||
<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),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user