UNPKG

ngx-trend

Version:
403 lines (394 loc) 17.8 kB
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