ngx-trend
Version:
ngx-trend Angular component
403 lines (394 loc) • 17.8 kB
JavaScript
import { trigger, state, style, transition, animate, keyframes } from '@angular/animations';
import * as i0 from '@angular/core';
import { Component, Input, ViewChild, NgModule } from '@angular/core';
import * as i1 from '@angular/common';
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) {
// 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: Math.min(...data), max: 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;
}
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%';
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;
});
// 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);
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);
}
}
TrendComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "13.3.1", ngImport: i0, type: TrendComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
TrendComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "12.0.0", version: "13.3.1", type: TrendComponent, selector: "ngx-trend", inputs: { data: "data", smooth: "smooth", autoDraw: "autoDraw", autoDrawDuration: "autoDrawDuration", autoDrawEasing: "autoDrawEasing", width: "width", height: "height", padding: "padding", radius: "radius", stroke: "stroke", strokeLinecap: "strokeLinecap", strokeWidth: "strokeWidth", gradient: "gradient", preserveAspectRatio: "preserveAspectRatio", svgHeight: "svgHeight", svgWidth: "svgWidth" }, viewQueries: [{ propertyName: "pathEl", first: true, predicate: ["pathEl"], descendants: true }], usesOnChanges: true, ngImport: i0, 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"
[@pathAnimaiton]="{
value: animationState,
params: {
autoDrawDuration: autoDrawDuration,
autoDrawEasing: autoDrawEasing,
lineLength: lineLength
}
}"
/>
</svg>
`, isInline: true, directives: [{ type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }], animations: [
trigger('pathAnimaiton', [
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': '',
}),
]),
]),
] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "13.3.1", ngImport: i0, type: TrendComponent, decorators: [{
type: Component,
args: [{
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"
[@pathAnimaiton]="{
value: animationState,
params: {
autoDrawDuration: autoDrawDuration,
autoDrawEasing: autoDrawEasing,
lineLength: lineLength
}
}"
/>
</svg>
`,
animations: [
trigger('pathAnimaiton', [
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': '',
}),
]),
]),
],
}]
}], ctorParameters: function () { return []; }, propDecorators: { data: [{
type: Input
}], smooth: [{
type: Input
}], autoDraw: [{
type: Input
}], autoDrawDuration: [{
type: Input
}], autoDrawEasing: [{
type: Input
}], width: [{
type: Input
}], height: [{
type: Input
}], padding: [{
type: Input
}], radius: [{
type: Input
}], stroke: [{
type: Input
}], strokeLinecap: [{
type: Input
}], strokeWidth: [{
type: Input
}], gradient: [{
type: Input
}], preserveAspectRatio: [{
type: Input
}], svgHeight: [{
type: Input
}], svgWidth: [{
type: Input
}], pathEl: [{
type: ViewChild,
args: ['pathEl']
}] } });
class TrendModule {
}
TrendModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "13.3.1", ngImport: i0, type: TrendModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
TrendModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "12.0.0", version: "13.3.1", ngImport: i0, type: TrendModule, declarations: [TrendComponent], imports: [CommonModule], exports: [TrendComponent] });
TrendModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "13.3.1", ngImport: i0, type: TrendModule, imports: [[CommonModule]] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "13.3.1", ngImport: i0, type: TrendModule, decorators: [{
type: NgModule,
args: [{
imports: [CommonModule],
exports: [TrendComponent],
declarations: [TrendComponent],
}]
}] });
/**
* Generated bundle index. Do not edit.
*/
export { TrendComponent, TrendModule };
//# sourceMappingURL=ngx-trend.mjs.map