mirror of
https://github.com/frappe/books.git
synced 2024-11-10 07:40:55 +00:00
feat: add BarChart
This commit is contained in:
parent
d7f753f476
commit
9daee4c9be
345
src/components/Charts/BarChart.vue
Normal file
345
src/components/Charts/BarChart.vue
Normal file
@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg
|
||||
ref="chartSvg"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<!-- zero line -->
|
||||
<path
|
||||
v-if="drawZeroLine"
|
||||
:d="zLine"
|
||||
:stroke="zeroLineColor"
|
||||
: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="xLabels.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"
|
||||
>
|
||||
{{ xLabels[i - 1] || '' }}
|
||||
</text>
|
||||
</template>
|
||||
|
||||
<!-- y Labels -->
|
||||
<template v-if="yLabelDivisions > 0">
|
||||
<text
|
||||
:style="fontStyle"
|
||||
v-for="(i, j) in yLabelDivisions + 1"
|
||||
:key="j + '-ylabels'"
|
||||
:y="yScalerLocation(i - 1)"
|
||||
:x="axisPadding - xLabelOffset + left"
|
||||
text-anchor="end"
|
||||
>
|
||||
{{ yScalerValue(i - 1) }}
|
||||
</text>
|
||||
</template>
|
||||
|
||||
<defs>
|
||||
<clipPath id="positive-rect-clip">
|
||||
<rect x="0" y="0" :width="viewBoxWidth" :height="z" />
|
||||
</clipPath>
|
||||
<clipPath id="negative-rect-clip">
|
||||
<rect
|
||||
x="0"
|
||||
:y="z"
|
||||
:width="viewBoxWidth"
|
||||
:height="viewBoxHeight - z"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect
|
||||
v-for="(rec, i) in positiveRects"
|
||||
:key="i + 'prec'"
|
||||
:rx="radius"
|
||||
:ry="radius"
|
||||
:x="rec.x"
|
||||
:y="rec.y"
|
||||
:width="width"
|
||||
:height="rec.height"
|
||||
:fill="rec.color"
|
||||
@mouseenter="() => create(rec.xi, rec.yi)"
|
||||
@mousemove="update"
|
||||
@mouseleave="destroy"
|
||||
clip-path="url(#positive-rect-clip)"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-for="(rec, i) in negativeRects"
|
||||
:key="i + 'nrec'"
|
||||
:rx="radius"
|
||||
:ry="radius"
|
||||
:x="rec.x"
|
||||
:y="rec.y"
|
||||
:width="width"
|
||||
:height="rec.height"
|
||||
:fill="rec.color"
|
||||
@mouseenter="() => create(rec.xi, rec.yi)"
|
||||
@mousemove="update"
|
||||
@mouseleave="destroy"
|
||||
clip-path="url(#negative-rect-clip)"
|
||||
/>
|
||||
</svg>
|
||||
<Tooltip
|
||||
ref="tooltip"
|
||||
:offset="15"
|
||||
placement="top"
|
||||
class="text-sm shadow-md px-2 py-1 bg-white text-gray-900 border-l-2"
|
||||
:style="{ borderColor: activeColor }"
|
||||
>
|
||||
{{ xi > -1 ? xLabels[xi] : '' }}
|
||||
{{ yi > -1 ? format(points[yi][xi]) : '' }}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { prefixFormat } from './chartUtils';
|
||||
import Tooltip from '../Tooltip.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
colors: { type: Array, default: () => [] },
|
||||
xLabels: { type: Array, default: () => [] },
|
||||
yLabelDivisions: { type: Number, default: 4 },
|
||||
points: { type: Array, default: () => [[]] },
|
||||
drawAxis: { type: Boolean, default: false },
|
||||
drawXGrid: { type: Boolean, default: true },
|
||||
viewBoxHeight: { type: Number, default: 500 },
|
||||
aspectRatio: { type: Number, default: 1.75 },
|
||||
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)' },
|
||||
zeroLineColor: { type: String, default: 'rgba(0, 0, 0, 0.2)' },
|
||||
axisColor: { type: String, default: 'rgba(0, 0, 0, 0.5)' },
|
||||
thickness: { type: Number, default: 5 },
|
||||
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 },
|
||||
width: { type: Number, default: 30 },
|
||||
left: { type: Number, default: 55 },
|
||||
radius: { type: Number, default: 15 },
|
||||
extendGridX: { type: Number, default: -20 },
|
||||
tooltipDispDistThreshold: { type: Number, default: 20 },
|
||||
drawZeroLine: { type: Boolean, default: true },
|
||||
},
|
||||
computed: {
|
||||
inverseMatrix() {
|
||||
return this.$refs.chartSvg.getScreenCTM().inverse();
|
||||
},
|
||||
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)
|
||||
);
|
||||
},
|
||||
z() {
|
||||
return this.getViewBoxY(0);
|
||||
},
|
||||
ys() {
|
||||
return this.points.map((pp) => pp.map(this.getViewBoxY));
|
||||
},
|
||||
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 { l, r } = this.xLims;
|
||||
const lo = l + this.extendGridX;
|
||||
const ro = r - this.extendGridX;
|
||||
const ys = Array(this.yLabelDivisions + 1)
|
||||
.fill()
|
||||
.map((_, i) => this.yScalerLocation(i));
|
||||
return ys.map((y) => `M ${lo} ${y} H ${ro}`).join(' ');
|
||||
},
|
||||
yGrid() {
|
||||
return [];
|
||||
},
|
||||
zLine() {
|
||||
const { l, r } = this.xLims;
|
||||
const lo = l + this.extendGridX;
|
||||
const ro = r - this.extendGridX;
|
||||
return `M ${lo} ${this.z} H ${ro}`;
|
||||
},
|
||||
xLims() {
|
||||
const l = this.padding + this.left;
|
||||
const r = this.viewBoxWidth - this.padding;
|
||||
return { l, r };
|
||||
},
|
||||
positiveRects() {
|
||||
return this.rects.flat().filter(({ isPositive }) => isPositive);
|
||||
},
|
||||
negativeRects() {
|
||||
return this.rects.flat().filter(({ isPositive }) => !isPositive);
|
||||
},
|
||||
rects() {
|
||||
return this.xy.map(([x, ys], i) =>
|
||||
ys.map((y, j) => this.getRect(x, y, i, j))
|
||||
);
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { xi: -1, yi: -1, activeColor: 'rgba(0, 0, 0, 0.2)' };
|
||||
},
|
||||
methods: {
|
||||
gradY(i) {
|
||||
return Math.min(...this.ys[i]).toFixed();
|
||||
},
|
||||
getViewBoxY(value) {
|
||||
const min = this.yMin ?? this.min;
|
||||
const max = this.yMax ?? this.max;
|
||||
return (
|
||||
this.padding +
|
||||
(1 - (value - min) / (max - min)) *
|
||||
(this.viewBoxHeight - 2 * this.padding - this.bottom)
|
||||
);
|
||||
},
|
||||
getRect(px, py, i, j) {
|
||||
const isPositive = py <= this.z;
|
||||
const x = px - (this.width * this.num) / 2 + j * this.width;
|
||||
const y = isPositive ? py : this.z - this.radius;
|
||||
const h = Math.abs(py - this.z);
|
||||
const height = h + this.radius;
|
||||
const color = this.getColor(j, isPositive);
|
||||
return { x, y, height, color, isPositive, xi: i, yi: j };
|
||||
},
|
||||
getColor(j, isPositive) {
|
||||
if (this.colors.length > 0) {
|
||||
const c = this.colors[j];
|
||||
return typeof c === 'string'
|
||||
? c
|
||||
: c[isPositive ? 'positive' : 'negative'];
|
||||
}
|
||||
return this.getRandomColor();
|
||||
},
|
||||
yScalerLocation(i) {
|
||||
return (
|
||||
((this.yLabelDivisions - i) *
|
||||
(this.viewBoxHeight - this.padding * 2 - this.bottom)) /
|
||||
this.yLabelDivisions +
|
||||
this.padding
|
||||
);
|
||||
},
|
||||
yScalerValue(i) {
|
||||
const max = this.yMax ?? this.max;
|
||||
const min = this.yMin ?? this.min;
|
||||
return this.formatY((i * (max - min)) / this.yLabelDivisions + 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})`;
|
||||
},
|
||||
create(xi, yi) {
|
||||
this.xi = xi;
|
||||
this.yi = yi;
|
||||
this.activeColor = this.getColor(yi, this.points[yi][xi] > 0);
|
||||
this.$refs.tooltip.create();
|
||||
},
|
||||
update(event) {
|
||||
this.$refs.tooltip.update(event);
|
||||
},
|
||||
destroy() {
|
||||
this.xi = -1;
|
||||
this.yi = -1;
|
||||
this.$refs.tooltip.destroy();
|
||||
},
|
||||
},
|
||||
components: { Tooltip },
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
rect:hover {
|
||||
filter: brightness(115%);
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user