mirror of https://github.com/ghostfolio/ghostfolio
				
				
			
				 9 changed files with 34 additions and 360 deletions
			
			
		@ -1,12 +0,0 @@ | 
				
			|||
<ngx-skeleton-loader | 
				
			|||
  *ngIf="isLoading" | 
				
			|||
  animation="pulse" | 
				
			|||
  [theme]="{ | 
				
			|||
    height: '15rem', | 
				
			|||
    width: '100%' | 
				
			|||
  }" | 
				
			|||
></ngx-skeleton-loader> | 
				
			|||
<canvas | 
				
			|||
  #chartCanvas | 
				
			|||
  [ngStyle]="{ display: isLoading ? 'none' : 'block' }" | 
				
			|||
></canvas> | 
				
			|||
@ -1,3 +0,0 @@ | 
				
			|||
:host { | 
				
			|||
  display: block; | 
				
			|||
} | 
				
			|||
@ -1,322 +0,0 @@ | 
				
			|||
import { | 
				
			|||
  ChangeDetectionStrategy, | 
				
			|||
  Component, | 
				
			|||
  Input, | 
				
			|||
  OnChanges, | 
				
			|||
  OnDestroy, | 
				
			|||
  OnInit, | 
				
			|||
  ViewChild | 
				
			|||
} from '@angular/core'; | 
				
			|||
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; | 
				
			|||
import { getTextColor } from '@ghostfolio/common/helper'; | 
				
			|||
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; | 
				
			|||
import { Currency } from '@prisma/client'; | 
				
			|||
import { Tooltip } from 'chart.js'; | 
				
			|||
import { LinearScale } from 'chart.js'; | 
				
			|||
import { ArcElement } from 'chart.js'; | 
				
			|||
import { DoughnutController } from 'chart.js'; | 
				
			|||
import { Chart } from 'chart.js'; | 
				
			|||
import ChartDataLabels from 'chartjs-plugin-datalabels'; | 
				
			|||
import * as Color from 'color'; | 
				
			|||
 | 
				
			|||
@Component({ | 
				
			|||
  selector: 'gf-portfolio-proportion-chart', | 
				
			|||
  changeDetection: ChangeDetectionStrategy.OnPush, | 
				
			|||
  templateUrl: './portfolio-proportion-chart.component.html', | 
				
			|||
  styleUrls: ['./portfolio-proportion-chart.component.scss'] | 
				
			|||
}) | 
				
			|||
export class PortfolioProportionChartComponent | 
				
			|||
  implements OnChanges, OnDestroy, OnInit | 
				
			|||
{ | 
				
			|||
  @Input() baseCurrency: Currency; | 
				
			|||
  @Input() isInPercent: boolean; | 
				
			|||
  @Input() keys: string[]; | 
				
			|||
  @Input() locale: string; | 
				
			|||
  @Input() maxItems?: number; | 
				
			|||
  @Input() showLabels = false; | 
				
			|||
  @Input() positions: { | 
				
			|||
    [symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number }; | 
				
			|||
  }; | 
				
			|||
 | 
				
			|||
  @ViewChild('chartCanvas') chartCanvas; | 
				
			|||
 | 
				
			|||
  public chart: Chart; | 
				
			|||
  public isLoading = true; | 
				
			|||
 | 
				
			|||
  private colorMap: { | 
				
			|||
    [symbol: string]: string; | 
				
			|||
  } = { | 
				
			|||
    [UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)` | 
				
			|||
  }; | 
				
			|||
 | 
				
			|||
  public constructor() { | 
				
			|||
    Chart.register( | 
				
			|||
      ArcElement, | 
				
			|||
      ChartDataLabels, | 
				
			|||
      DoughnutController, | 
				
			|||
      LinearScale, | 
				
			|||
      Tooltip | 
				
			|||
    ); | 
				
			|||
  } | 
				
			|||
 | 
				
			|||
  public ngOnInit() {} | 
				
			|||
 | 
				
			|||
  public ngOnChanges() { | 
				
			|||
    if (this.positions) { | 
				
			|||
      this.initialize(); | 
				
			|||
    } | 
				
			|||
  } | 
				
			|||
 | 
				
			|||
  public ngOnDestroy() { | 
				
			|||
    this.chart?.destroy(); | 
				
			|||
  } | 
				
			|||
 | 
				
			|||
  private initialize() { | 
				
			|||
    this.isLoading = true; | 
				
			|||
    const chartData: { | 
				
			|||
      [symbol: string]: { | 
				
			|||
        color?: string; | 
				
			|||
        subCategory: { [symbol: string]: { value: number } }; | 
				
			|||
        value: number; | 
				
			|||
      }; | 
				
			|||
    } = {}; | 
				
			|||
 | 
				
			|||
    Object.keys(this.positions).forEach((symbol) => { | 
				
			|||
      if (this.positions[symbol][this.keys[0]]) { | 
				
			|||
        if (chartData[this.positions[symbol][this.keys[0]]]) { | 
				
			|||
          chartData[this.positions[symbol][this.keys[0]]].value += | 
				
			|||
            this.positions[symbol].value; | 
				
			|||
 | 
				
			|||
          if ( | 
				
			|||
            chartData[this.positions[symbol][this.keys[0]]].subCategory[ | 
				
			|||
              this.positions[symbol][this.keys[1]] | 
				
			|||
            ] | 
				
			|||
          ) { | 
				
			|||
            chartData[this.positions[symbol][this.keys[0]]].subCategory[ | 
				
			|||
              this.positions[symbol][this.keys[1]] | 
				
			|||
            ].value += this.positions[symbol].value; | 
				
			|||
          } else { | 
				
			|||
            chartData[this.positions[symbol][this.keys[0]]].subCategory[ | 
				
			|||
              this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY | 
				
			|||
            ] = { value: this.positions[symbol].value }; | 
				
			|||
          } | 
				
			|||
        } else { | 
				
			|||
          chartData[this.positions[symbol][this.keys[0]]] = { | 
				
			|||
            subCategory: {}, | 
				
			|||
            value: this.positions[symbol].value | 
				
			|||
          }; | 
				
			|||
 | 
				
			|||
          if (this.positions[symbol][this.keys[1]]) { | 
				
			|||
            chartData[this.positions[symbol][this.keys[0]]].subCategory = { | 
				
			|||
              [this.positions[symbol][this.keys[1]]]: { | 
				
			|||
                value: this.positions[symbol].value | 
				
			|||
              } | 
				
			|||
            }; | 
				
			|||
          } | 
				
			|||
        } | 
				
			|||
      } else { | 
				
			|||
        if (chartData[UNKNOWN_KEY]) { | 
				
			|||
          chartData[UNKNOWN_KEY].value += this.positions[symbol].value; | 
				
			|||
        } else { | 
				
			|||
          chartData[UNKNOWN_KEY] = { | 
				
			|||
            subCategory: this.keys[1] | 
				
			|||
              ? { [this.keys[1]]: { value: 0 } } | 
				
			|||
              : undefined, | 
				
			|||
            value: this.positions[symbol].value | 
				
			|||
          }; | 
				
			|||
        } | 
				
			|||
      } | 
				
			|||
    }); | 
				
			|||
 | 
				
			|||
    let chartDataSorted = Object.entries(chartData) | 
				
			|||
      .sort((a, b) => { | 
				
			|||
        return a[1].value - b[1].value; | 
				
			|||
      }) | 
				
			|||
      .reverse(); | 
				
			|||
 | 
				
			|||
    if (this.maxItems && chartDataSorted.length > this.maxItems) { | 
				
			|||
      // Add surplus items to unknown group
 | 
				
			|||
      const rest = chartDataSorted.splice( | 
				
			|||
        this.maxItems, | 
				
			|||
        chartDataSorted.length - 1 | 
				
			|||
      ); | 
				
			|||
 | 
				
			|||
      let unknownItem = chartDataSorted.find((charDataItem) => { | 
				
			|||
        return charDataItem[0] === UNKNOWN_KEY; | 
				
			|||
      }); | 
				
			|||
 | 
				
			|||
      if (!unknownItem) { | 
				
			|||
        const index = chartDataSorted.push([ | 
				
			|||
          UNKNOWN_KEY, | 
				
			|||
          { subCategory: {}, value: 0 } | 
				
			|||
        ]); | 
				
			|||
        unknownItem = chartDataSorted[index]; | 
				
			|||
      } | 
				
			|||
 | 
				
			|||
      rest.forEach((restItem) => { | 
				
			|||
        if (unknownItem?.[1]) { | 
				
			|||
          unknownItem[1] = { | 
				
			|||
            subCategory: {}, | 
				
			|||
            value: unknownItem[1].value + restItem[1].value | 
				
			|||
          }; | 
				
			|||
        } | 
				
			|||
      }); | 
				
			|||
 | 
				
			|||
      // Sort data again
 | 
				
			|||
      chartDataSorted = chartDataSorted | 
				
			|||
        .sort((a, b) => { | 
				
			|||
          return a[1].value - b[1].value; | 
				
			|||
        }) | 
				
			|||
        .reverse(); | 
				
			|||
    } | 
				
			|||
 | 
				
			|||
    chartDataSorted.forEach(([symbol, item], index) => { | 
				
			|||
      if (this.colorMap[symbol]) { | 
				
			|||
        // Reuse color
 | 
				
			|||
        item.color = this.colorMap[symbol]; | 
				
			|||
      } else { | 
				
			|||
        const color = | 
				
			|||
          this.getColorPalette()[index % this.getColorPalette().length]; | 
				
			|||
 | 
				
			|||
        // Store color for reuse
 | 
				
			|||
        this.colorMap[symbol] = color; | 
				
			|||
 | 
				
			|||
        item.color = color; | 
				
			|||
      } | 
				
			|||
    }); | 
				
			|||
 | 
				
			|||
    const backgroundColorSubCategory: string[] = []; | 
				
			|||
    const dataSubCategory: number[] = []; | 
				
			|||
    const labelSubCategory: string[] = []; | 
				
			|||
 | 
				
			|||
    chartDataSorted.forEach(([, item]) => { | 
				
			|||
      let lightnessRatio = 0.2; | 
				
			|||
 | 
				
			|||
      Object.keys(item.subCategory).forEach((subCategory) => { | 
				
			|||
        backgroundColorSubCategory.push( | 
				
			|||
          Color(item.color).lighten(lightnessRatio).hex() | 
				
			|||
        ); | 
				
			|||
        dataSubCategory.push(item.subCategory[subCategory].value); | 
				
			|||
        labelSubCategory.push(subCategory); | 
				
			|||
 | 
				
			|||
        lightnessRatio += 0.1; | 
				
			|||
      }); | 
				
			|||
    }); | 
				
			|||
 | 
				
			|||
    const datasets = [ | 
				
			|||
      { | 
				
			|||
        backgroundColor: chartDataSorted.map(([, item]) => { | 
				
			|||
          return item.color; | 
				
			|||
        }), | 
				
			|||
        borderWidth: 0, | 
				
			|||
        data: chartDataSorted.map(([, item]) => { | 
				
			|||
          return item.value; | 
				
			|||
        }) | 
				
			|||
      } | 
				
			|||
    ]; | 
				
			|||
 | 
				
			|||
    let labels = chartDataSorted.map(([label]) => { | 
				
			|||
      return label; | 
				
			|||
    }); | 
				
			|||
 | 
				
			|||
    if (this.keys[1]) { | 
				
			|||
      datasets.unshift({ | 
				
			|||
        backgroundColor: backgroundColorSubCategory, | 
				
			|||
        borderWidth: 0, | 
				
			|||
        data: dataSubCategory | 
				
			|||
      }); | 
				
			|||
 | 
				
			|||
      labels = labelSubCategory.concat(labels); | 
				
			|||
    } | 
				
			|||
 | 
				
			|||
    const data = { | 
				
			|||
      datasets, | 
				
			|||
      labels | 
				
			|||
    }; | 
				
			|||
 | 
				
			|||
    if (this.chartCanvas) { | 
				
			|||
      if (this.chart) { | 
				
			|||
        this.chart.data = data; | 
				
			|||
        this.chart.update(); | 
				
			|||
      } else { | 
				
			|||
        this.chart = new Chart(this.chartCanvas.nativeElement, { | 
				
			|||
          data, | 
				
			|||
          options: { | 
				
			|||
            cutout: '70%', | 
				
			|||
            layout: { | 
				
			|||
              padding: this.showLabels === true ? 100 : 0 | 
				
			|||
            }, | 
				
			|||
            plugins: { | 
				
			|||
              datalabels: { | 
				
			|||
                color: (context) => { | 
				
			|||
                  return this.getColorPalette()[ | 
				
			|||
                    context.dataIndex % this.getColorPalette().length | 
				
			|||
                  ]; | 
				
			|||
                }, | 
				
			|||
                display: this.showLabels === true ? 'auto' : false, | 
				
			|||
                labels: { | 
				
			|||
                  index: { | 
				
			|||
                    align: 'end', | 
				
			|||
                    anchor: 'end', | 
				
			|||
                    formatter: (value, context) => { | 
				
			|||
                      return value > 0 | 
				
			|||
                        ? context.chart.data.labels[context.dataIndex] | 
				
			|||
                        : ''; | 
				
			|||
                    }, | 
				
			|||
                    offset: 8 | 
				
			|||
                  } | 
				
			|||
                } | 
				
			|||
              }, | 
				
			|||
              legend: { display: false }, | 
				
			|||
              tooltip: { | 
				
			|||
                callbacks: { | 
				
			|||
                  label: (context) => { | 
				
			|||
                    const labelIndex = | 
				
			|||
                      (data.datasets[context.datasetIndex - 1]?.data?.length ?? | 
				
			|||
                        0) + context.dataIndex; | 
				
			|||
                    const label = context.chart.data.labels[labelIndex]; | 
				
			|||
 | 
				
			|||
                    if (this.isInPercent) { | 
				
			|||
                      const value = 100 * <number>context.raw; | 
				
			|||
                      return `${label} (${value.toFixed(2)}%)`; | 
				
			|||
                    } else { | 
				
			|||
                      const value = <number>context.raw; | 
				
			|||
                      return `${label} (${value.toLocaleString(this.locale, { | 
				
			|||
                        maximumFractionDigits: 2, | 
				
			|||
                        minimumFractionDigits: 2 | 
				
			|||
                      })} ${this.baseCurrency})`;
 | 
				
			|||
                    } | 
				
			|||
                  } | 
				
			|||
                } | 
				
			|||
              } | 
				
			|||
            } | 
				
			|||
          }, | 
				
			|||
          type: 'doughnut' | 
				
			|||
        }); | 
				
			|||
      } | 
				
			|||
 | 
				
			|||
      this.isLoading = false; | 
				
			|||
    } | 
				
			|||
  } | 
				
			|||
 | 
				
			|||
  /** | 
				
			|||
   * Color palette, inspired by https://yeun.github.io/open-color
 | 
				
			|||
   */ | 
				
			|||
  private getColorPalette() { | 
				
			|||
    //
 | 
				
			|||
    return [ | 
				
			|||
      '#329af0', // blue 5
 | 
				
			|||
      '#20c997', // teal 5
 | 
				
			|||
      '#94d82d', // lime 5
 | 
				
			|||
      '#ff922b', // orange 5
 | 
				
			|||
      '#f06595', // pink 5
 | 
				
			|||
      '#845ef7', // violet 5
 | 
				
			|||
      '#5c7cfa', // indigo 5
 | 
				
			|||
      '#22b8cf', // cyan 5
 | 
				
			|||
      '#51cf66', // green 5
 | 
				
			|||
      '#fcc419', // yellow 5
 | 
				
			|||
      '#ff6b6b', // red 5
 | 
				
			|||
      '#cc5de8' // grape 5
 | 
				
			|||
    ]; | 
				
			|||
  } | 
				
			|||
} | 
				
			|||
@ -1,13 +0,0 @@ | 
				
			|||
import { CommonModule } from '@angular/common'; | 
				
			|||
import { NgModule } from '@angular/core'; | 
				
			|||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; | 
				
			|||
 | 
				
			|||
import { PortfolioProportionChartComponent } from './portfolio-proportion-chart.component'; | 
				
			|||
 | 
				
			|||
@NgModule({ | 
				
			|||
  declarations: [PortfolioProportionChartComponent], | 
				
			|||
  exports: [PortfolioProportionChartComponent], | 
				
			|||
  imports: [CommonModule, NgxSkeletonLoaderModule], | 
				
			|||
  providers: [] | 
				
			|||
}) | 
				
			|||
export class GfPortfolioProportionChartModule {} | 
				
			|||
					Loading…
					
					
				
		Reference in new issue