lemon-ngx-trend
Version:
ngx-trend Angular component for Lemoncloud
442 lines (433 loc) • 17 kB
JavaScript
import { __decorate, __metadata } from 'tslib';
import { trigger, state, style, transition, animate, keyframes } from '@angular/animations';
import { Input, ViewChild, ElementRef, Component, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
/* eslint-disable no-restricted-properties */
/** normalize
* This lets us translate a value from one scale to another.
*
* @param value - Our initial value to translate
* @param min - the current minimum value possible
* @param max - the current maximum value possible
* @param scaleMin - the min value of the scale we're translating to
* @param scaleMax - the max value of the scale we're translating to
*
* @returns the value on its new scale
*/
function normalize(value, min, max, scaleMin = 0, scaleMax = 1) {
// If the `min` and `max` are the same value, it means our dataset is flat.
// For now, let's assume that flat data should be aligned to the bottom.
if (min === max) {
return scaleMin;
}
return scaleMin + (value - min) * (scaleMax - scaleMin) / (max - min);
}
/** moveTo
* the coordinate that lies at a midpoint between 2 lines, based on the radius
*
* @param to - Our initial point
* @param to.x - The x value of our initial point
* @param to.y - The y value of our initial point
* @param from - Our final point
* @param from.x - The x value of our final point
* @param from.y - The y value of our final point
* @param radius - The distance away from the final point
*
* @returns an object holding the x/y coordinates of the midpoint.
*/
function moveTo(to, from, radius) {
const length = Math.sqrt((to.x - from.x) * (to.x - from.x) + (to.y - from.y) * (to.y - from.y));
const unitVector = { x: (to.x - from.x) / length, y: (to.y - from.y) / length };
return {
x: from.x + unitVector.x * radius,
y: from.y + unitVector.y * radius,
};
}
/** getDistanceBetween
* Simple formula derived from pythagoras to calculate the distance between
* 2 points on a plane.
*
* @param p1 - Our initial point
* @param p1.x - The x value of our initial point
* @param p1.y - The y value of our initial point
* @param p2 - Our final point
* @param p2.x - The x value of our final point
* @param p2.y - The y value of our final point
*
* @returns the distance between the points.
*/
const getDistanceBetween = (p1, p2) => Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
/** checkForCollinearPoints
* Figure out if the midpoint fits perfectly on a line between the two others.
*
* @param p1 - Our initial point
* @param p1.x - The x value of our initial point
* @param p1.y - The y value of our initial point
* @param p2 - Our mid-point
* @param p2.x - The x value of our mid-point
* @param p2.y - The y value of our mid-point
* @param p3 - Our final point
* @param p3.x - The x value of our final point
* @param p3.y - The y value of our final point
* @returns whether or not p2 sits on the line between p1 and p3.
*/
const checkForCollinearPoints = (p1, p2, p3) => (p1.y - p2.y) * (p1.x - p3.x) === (p1.y - p3.y) * (p1.x - p2.x);
const buildLinearPath = (data) => data.reduce((path, point, index) => {
// The very first instruction needs to be a "move".
// The rest will be a "line".
const isFirstInstruction = index === 0;
const instruction = isFirstInstruction ? 'M' : 'L';
return `${path}${instruction} ${point.x},${point.y}\n`;
}, '');
function buildSmoothPath(data, radius) {
const [firstPoint, ...otherPoints] = data;
return otherPoints.reduce((path, point, index) => {
const next = otherPoints[index + 1];
const prev = otherPoints[index - 1] || firstPoint;
const isCollinear = next && checkForCollinearPoints(prev, point, next);
if (!next || isCollinear) {
// The very last line in the sequence can just be a regular line.
return `${path}\nL ${point.x},${point.y}`;
}
const distanceFromPrev = getDistanceBetween(prev, point);
const distanceFromNext = getDistanceBetween(next, point);
const threshold = Math.min(distanceFromPrev, distanceFromNext);
const isTooCloseForRadius = threshold / 2 < radius;
const radiusForPoint = isTooCloseForRadius ? threshold / 2 : radius;
const before = moveTo(prev, point, radiusForPoint);
const after = moveTo(next, point, radiusForPoint);
return [
path,
`L ${before.x},${before.y}`,
`S ${point.x},${point.y} ${after.x},${after.y}`,
].join('\n');
}, `M ${firstPoint.x},${firstPoint.y}`);
}
const generateId = () => Math.round(Math.random() * Math.pow(10, 16));
function normalizeDataset(data, minX, maxX, minY, maxY, minValue, maxValue) {
// For the X axis, we want to normalize it based on its index in the array.
// For the Y axis, we want to normalize it based on the element's value.
//
// X axis is easy: just evenly-space each item in the array.
// For the Y axis, we first need to find the min and max of our array,
// and then normalize those values between 0 and 1.
const boundariesX = { min: 0, max: data.length - 1 };
const boundariesY = { min: minValue || Math.min(...data), max: maxValue || Math.max(...data) };
const normalizedData = data.map((point, index) => ({
x: normalize(index, boundariesX.min, boundariesX.max, minX, maxX),
y: normalize(point, boundariesY.min, boundariesY.max, minY, maxY),
}));
// According to the SVG spec, paths with a height/width of `0` can't have
// linear gradients applied. This means that our lines are invisible when
// the dataset is flat (eg. [0, 0, 0, 0]).
//
// The hacky solution is to apply a very slight offset to the first point of
// the dataset. As ugly as it is, it's the best solution we can find (there
// are ways within the SVG spec of changing it, but not without causing
// breaking changes).
if (boundariesY.min === boundariesY.max) {
normalizedData[0].y += 0.0001;
}
return normalizedData;
}
let TrendComponent = class TrendComponent {
constructor() {
this.autoDraw = false;
this.autoDrawDuration = 2000;
this.autoDrawEasing = 'ease';
this.padding = 8;
this.radius = 10;
this.stroke = 'black';
this.strokeLinecap = '';
this.strokeWidth = 1;
this.gradient = [];
this.svgHeight = '25%';
this.svgWidth = '100%';
// Added for Circle
this.showCircle = false;
this.circleColor = 'black';
this.circleWidth = 1;
this.showLastLabel = false;
this.labelColor = 'black';
this.animationState = '';
this.id = generateId();
this.gradientId = `ngx-trend-vertical-gradient-${this.id}`;
}
ngOnChanges() {
// We need at least 2 points to draw a graph.
if (!this.data || this.data.length < 2) {
return;
}
// `data` can either be an array of numbers:
// [1, 2, 3]
// or, an array of objects containing a value:
// [{ value: 1 }, { value: 2 }, { value: 3 }]
//
// For now, we're just going to convert the second form to the first.
// Later on, if/when we support tooltips, we may adjust.
const plainValues = this.data.map((point) => {
if (typeof point === 'number') {
return point;
}
return point.value;
});
// reset to re-run animation
this.animationState = 'inactive';
// Our viewbox needs to be in absolute units, so we'll default to 300x75
// Our SVG can be a %, though; this is what makes it scalable.
// By defaulting to percentages, the SVG will grow to fill its parent
// container, preserving a 1/4 aspect ratio.
const viewBoxWidth = this.width || 300;
const viewBoxHeight = this.height || 75;
this.svgWidth = this.width || '100%';
this.svgHeight = this.height || '25%';
this.viewBox = `0 0 ${viewBoxWidth} ${viewBoxHeight}`;
const root = location.href.split(location.hash || '#')[0];
this.pathStroke = (this.gradient && this.gradient.length) ? `url('${root}#${this.gradientId}')` : undefined;
this.gradientTrimmed = this.gradient.slice().reverse().map((val, idx) => {
return {
idx,
stopColor: val,
offset: normalize(idx, 0, this.gradient.length - 1 || 1),
};
});
const normalizedValues = normalizeDataset(plainValues, this.padding, viewBoxWidth - this.padding,
// NOTE: Because SVGs are indexed from the top left, but most data is
// indexed from the bottom left, we're inverting the Y min/max.
viewBoxHeight - this.padding, this.padding, this.minValue, this.maxValue);
this.circleCoordinates = normalizedValues;
this.lastLabelCoordinates = this.getLabelCoordinateOfLast();
if (this.autoDraw && this.animationState !== 'active') {
this.animationState = 'inactive';
setTimeout(() => {
this.lineLength = this.pathEl.nativeElement.getTotalLength();
this.animationState = 'active';
});
}
this.d = this.smooth
? buildSmoothPath(normalizedValues, this.radius)
: buildLinearPath(normalizedValues);
}
getLabelCoordinateOfLast() {
const lastIndex = this.circleCoordinates.length - 1;
const { x, y } = this.circleCoordinates[lastIndex];
const result = {
x,
y: y + 15
};
return result;
}
};
__decorate([
Input(),
__metadata("design:type", Array)
], TrendComponent.prototype, "data", void 0);
__decorate([
Input(),
__metadata("design:type", Boolean)
], TrendComponent.prototype, "smooth", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "autoDraw", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "autoDrawDuration", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "autoDrawEasing", void 0);
__decorate([
Input(),
__metadata("design:type", Number)
], TrendComponent.prototype, "width", void 0);
__decorate([
Input(),
__metadata("design:type", Number)
], TrendComponent.prototype, "height", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "padding", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "radius", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "stroke", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "strokeLinecap", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "strokeWidth", void 0);
__decorate([
Input(),
__metadata("design:type", Array)
], TrendComponent.prototype, "gradient", void 0);
__decorate([
Input(),
__metadata("design:type", String)
], TrendComponent.prototype, "preserveAspectRatio", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "svgHeight", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "svgWidth", void 0);
__decorate([
ViewChild('pathEl'),
__metadata("design:type", ElementRef)
], TrendComponent.prototype, "pathEl", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "showCircle", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "circleColor", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "circleWidth", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "showLastLabel", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "labelColor", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "maxValue", void 0);
__decorate([
Input(),
__metadata("design:type", Object)
], TrendComponent.prototype, "minValue", void 0);
TrendComponent = __decorate([
Component({
selector: 'ngx-trend',
template: `
<svg *ngIf="data && data.length >= 2"
[attr.width]="svgWidth"
[attr.height]="svgHeight"
[attr.stroke]="stroke"
[attr.stroke-width]="strokeWidth"
[attr.stroke-linecap]="strokeLinecap"
[attr.viewBox]="viewBox"
[attr.preserveAspectRatio]="preserveAspectRatio"
>
<defs *ngIf="gradient && gradient.length">
<linearGradient [attr.id]="gradientId" x1="0%" y1="0%" x2="0%" y2="100%">
<stop
*ngFor="let g of gradientTrimmed;"
[attr.key]="g.idx"
[attr.offset]="g.offset"
[attr.stop-color]="g.stopColor"
/>
</linearGradient>
</defs>
<path fill="none" #pathEl
[attr.stroke]="pathStroke" [attr.d]="d"
[@pathAnimation]="{
value: animationState,
params: {
autoDrawDuration: autoDrawDuration,
autoDrawEasing: autoDrawEasing,
lineLength: lineLength
}
}" />
<ng-container *ngIf="showCircle" >
<style>
.small { font-size: 12px; font-weight: 400; text-anchor: end; stroke-width: 0;}
</style>
<ng-container *ngFor="let circle of circleCoordinates; index as i">
<circle [attr.cx]="circle.x" [attr.cy]="circle.y" [attr.r]="circleWidth"
[attr.fill]="circleColor" [attr.stroke]="circleColor"
[attr.strokeWidth]="circleWidth"
[@circleAnimation]="{
value: animationState,
params: {
autoDrawDuration: autoDrawDuration
}
}"
/>
<text *ngIf="showLastLabel && i === circleCoordinates.length - 1"
class="small"
[@circleAnimation]="{
value: animationState,
params: {
autoDrawDuration: autoDrawDuration
}
}"
[attr.fill]="labelColor"
[attr.x]="lastLabelCoordinates.x"
[attr.y]="lastLabelCoordinates.y">{{ data[data.length-1] | number }}</text>
</ng-container>
</ng-container>
</svg>
`,
animations: [
trigger('pathAnimation', [
state('inactive', style({ display: 'none' })),
transition('* => active', [
style({ display: 'initial' }),
// We do the animation using the dash array/offset trick
// https://css-tricks.com/svg-line-animation-works/
animate('{{ autoDrawDuration }}ms {{ autoDrawEasing }}', keyframes([
style({
'stroke-dasharray': '{{ lineLength }}px',
'stroke-dashoffset': '{{ lineLength }}px',
}),
style({
'stroke-dasharray': '{{ lineLength }}px',
'stroke-dashoffset': 0,
}),
])),
// One unfortunate side-effect of the auto-draw is that the line is
// actually 1 big dash, the same length as the line itself. If the
// line length changes (eg. radius change, new data), that dash won't
// be the same length anymore. We can fix that by removing those
// properties once the auto-draw is completed.
style({
'stroke-dashoffset': '',
'stroke-dasharray': '',
}),
]),
]),
trigger('circleAnimation', [
state('inactive', style({ visibility: 'hidden', opacity: 0 })),
transition('* => active', [
style({ visibility: 'hidden' }),
animate('{{ autoDrawDuration }}ms', keyframes([style({ visibility: 'visible' }),
])),
]),
])
]
}),
__metadata("design:paramtypes", [])
], TrendComponent);
let TrendModule = class TrendModule {
};
TrendModule = __decorate([
NgModule({
imports: [CommonModule],
exports: [TrendComponent],
declarations: [TrendComponent],
})
], TrendModule);
/**
* Generated bundle index. Do not edit.
*/
export { TrendComponent, TrendModule };
//# sourceMappingURL=lemon-ngx-trend.js.map