UNPKG

lemon-ngx-trend

Version:

ngx-trend Angular component for Lemoncloud

442 lines (433 loc) 17 kB
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