File

lib/components/plot/plot.component.ts

Description

Wrapper for a Plotly plot using angular-ploty.js

Handles setting the plot height based on width and wanted aspect ratio.

See https://plotly.com/javascript/ See https://github.com/plotly/angular-plotly.js

Metadata

Index

Properties
Methods

Constructor

constructor()

Methods

Private addLegendSnackBar
addLegendSnackBar()
Returns : void
Private createMobileData
createMobileData(plotData: Partial[])

Changes the plot data to better be shown on mobiles screens.

Parameters :
Name Type Optional Description
plotData Partial<PlotData>[] No

The plot data

Returns : Partial[]
onLegendClick
onLegendClick(curveIndex: number, allowEmit)
Parameters :
Name Type Optional Default value
curveIndex number No
allowEmit No true
Returns : void
onPlotInitialized
onPlotInitialized()
Returns : void
Private onSelectLegendUid
onSelectLegendUid(uid: string, allowEmit)
Parameters :
Name Type Optional Default value
uid string No
allowEmit No true
Returns : void
onUpdate
onUpdate()

Handle update event.

Returns : void
Private redrawPlot
redrawPlot()

Redraw the plot by updating the revision value.

Returns : void
Private removeMinorAxisTicks
removeMinorAxisTicks(ticks: SVGTextElement[])

Remove minor ticks on mobile devices for single axis.

Parameters :
Name Type Optional Description
ticks SVGTextElement[] No

The tick elements

Returns : void
Private removeMinorTicks
removeMinorTicks()

Remove minor ticks.

Returns : void
Private setColorScheme
setColorScheme()

Sets the color scheme of the plot based on application scheme.

Returns : void
Private updateDimensions
updateDimensions()

Update the height of the plot based on the width and aspect ratio.

Returns : void
updatePlot
updatePlot()

Update the plot based on screen size.

Returns : void

Properties

config
Type : Partial<PlotlyConfig>
Default value : {}

Plot config, changes based on screen size

data
Type : Partial<PlotData>[]
Default value : []

Plot data, changes based on screen size

Private dataAdded
Type : boolean
Private dataClicked
Type : Partial<PlotData>
Default value : {}
Private destroyRef
Default value : inject(DestroyRef)
Private headerService
Default value : inject(HeaderService)
isSmallScreen
Default value : false

Whether it is small screen, based on panel breakpoint

layout
Type : Partial<PlotlyLayout>
Default value : {}

Plotly layout, changes based on screen size

legendClick
Default value : output<Partial<PlotData>>()
legendSelectUid
Default value : input<string, string>('', { transform: uid => { this.onSelectLegendUid(uid); return uid; }, })
Private lineWidth
Type : number
Default value : 2
Private markerSize
Type : number
Default value : 6
mobileData
Type : Partial<PlotData>[]
Default value : []

Data for mobile screens

Readonly nshmpPlotlyPlotEl
Default value : viewChild.required<ElementRef<HTMLElement>>('nshmpPlotlyPlot')

Wrapper element around plotly-plot

Private nshmpTemplateService
Default value : inject(NshmpTemplateService)
panelBreakpoint
Type : number
Default value : 660

Panel breakpoint for small screen

Readonly plot
Default value : input.required<PlotlyPlot, PlotlyPlot>({ transform: plot => { this.updateDimensions(); this.showSkeleton = true; this.data = plot.data; this.mobileData = this.createMobileData(this.data); this.layout = plot.layout; this.config = plot.config; this.dataAdded = false; this.dataClicked = {}; this.selectedOptions = { ...plot.lineSelected, ...this.selectedOptions, }; this.onSelectLegendUid(this.legendSelectUid(), false); return plot; }, })

Set the Plotly plot data and options.

Readonly plotlyComponent
Default value : viewChild.required<PlotlyComponent>('plotlyComponent')

The ploty-plot componenet

Private selectedOptions
Type : LineSelected
Default value : { lineWidth: 6, lineWidthMobile: 2, markerSize: 15, markerSizeMobile: 6, }
showSkeleton
Default value : true
Private snackBar
Default value : inject(MatSnackBar)
Private templateService
Default value : inject(NshmpTemplateService)
import {NgClass} from '@angular/common';
import {
  Component,
  DestroyRef,
  effect,
  ElementRef,
  inject,
  input,
  output,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {MatSnackBar, MatSnackBarRef, TextOnlySnackBar} from '@angular/material/snack-bar';
import {HeaderService, NshmpTemplateService} from '@ghsc/nshmp-template';
import {
  heightFromAspectRatio,
  LineSelected,
  PlotlyConfig,
  PlotlyLayout,
  PlotlyPlot,
} from '@ghsc/nshmp-utils-ts/libs/plotly';
import {PlotlyComponent, PlotlyViaCDNModule} from 'angular-plotly.js';
import {NgxSkeletonLoaderModule} from 'ngx-skeleton-loader';
import {DataTitle, PlotData} from 'plotly.js';

import {COLORWAY} from '../../utils';

PlotlyViaCDNModule.setPlotlyVersion('strict-3.0.1');

/**
 * Wrapper for a Plotly plot using angular-ploty.js
 *
 * Handles setting the plot height based on width and wanted aspect ratio.
 *
 * @see https://plotly.com/javascript/
 * @see https://github.com/plotly/angular-plotly.js
 */
@Component({
  encapsulation: ViewEncapsulation.None,
  imports: [NgClass, PlotlyViaCDNModule, NgxSkeletonLoaderModule],
  selector: 'nshmp-plot',
  styleUrl: './plot.component.scss',
  templateUrl: './plot.component.html',
})
export class NshmpPlotComponent {
  private nshmpTemplateService = inject(NshmpTemplateService);
  private headerService = inject(HeaderService);
  private templateService = inject(NshmpTemplateService);
  private snackBar = inject(MatSnackBar);
  private destroyRef = inject(DestroyRef);

  private dataClicked: Partial<PlotData> = {};
  private markerSize: number = 6;
  private lineWidth: number = 2;
  private dataAdded!: boolean;
  private selectedOptions: LineSelected = {
    lineWidth: 6,
    lineWidthMobile: 2,
    markerSize: 15,
    markerSizeMobile: 6,
  };

  /** Plot config, changes based on screen size */
  config: Partial<PlotlyConfig> = {};
  /** Plot data, changes based on screen size */
  data: Partial<PlotData>[] = [];
  /** Whether it is small screen, based on panel breakpoint */
  isSmallScreen = false;
  /** Plotly layout, changes based on screen size */
  layout: Partial<PlotlyLayout> = {};
  /** Data for mobile screens */
  mobileData: Partial<PlotData>[] = [];
  /** Wrapper element around plotly-plot */
  readonly nshmpPlotlyPlotEl = viewChild.required<ElementRef<HTMLElement>>('nshmpPlotlyPlot');
  /** Panel breakpoint for small screen */
  panelBreakpoint = 660;
  /** The ploty-plot componenet */
  readonly plotlyComponent = viewChild.required<PlotlyComponent>('plotlyComponent');

  /**
   * Set the Plotly plot data and options.
   */
  readonly plot = input.required<PlotlyPlot, PlotlyPlot>({
    transform: plot => {
      this.updateDimensions();
      this.showSkeleton = true;
      this.data = plot.data;
      this.mobileData = this.createMobileData(this.data);
      this.layout = plot.layout;
      this.config = plot.config;
      this.dataAdded = false;
      this.dataClicked = {};
      this.selectedOptions = {
        ...plot.lineSelected,
        ...this.selectedOptions,
      };
      this.onSelectLegendUid(this.legendSelectUid(), false);
      return plot;
    },
  });

  legendSelectUid = input<string, string>('', {
    transform: uid => {
      this.onSelectLegendUid(uid);
      return uid;
    },
  });

  legendClick = output<Partial<PlotData>>();

  showSkeleton = true;

  constructor() {
    effect(() => {
      this.headerService.controlPanelSelected();
      this.headerService.settingsSelected();
      this.redrawPlot();
    });
  }

  onLegendClick(curveIndex: number, allowEmit = true): void {
    const data = this.data[curveIndex];

    const lineWidthSelected = this.isSmallScreen
      ? this.selectedOptions.lineWidthMobile
      : this.selectedOptions.lineWidth;

    const markerSizeSelected = this.isSmallScreen
      ? this.selectedOptions.markerSizeMobile
      : this.selectedOptions.markerSize;

    if (data.line?.width !== this.selectedOptions.lineWidth) {
      this.lineWidth = data.line?.width ?? 2;
    }

    if (data.marker?.size !== this.selectedOptions.markerSize) {
      this.markerSize = (data.marker?.size as number) ?? 6;
    }

    if (this.dataAdded) {
      this.data.pop();
    }

    this.data.forEach(plotData => {
      plotData.line = {
        ...plotData.line,
        width: this.lineWidth,
      };

      plotData.marker = {
        ...plotData.marker,
        size: this.markerSize,
      };
    });

    if (data === this.dataClicked) {
      this.dataClicked = {};
      this.dataAdded = false;
    } else {
      const color = data.line?.color ?? COLORWAY[curveIndex % COLORWAY.length];

      data.line = {
        ...data.line,
        color,
        width: lineWidthSelected,
      };

      data.marker = {
        ...data.marker,
        color,
        size: markerSizeSelected,
      };

      const newData = {...data};
      newData.showlegend = false;

      this.data.push(newData);
      this.dataClicked = data;
      this.dataAdded = true;

      if (allowEmit) {
        this.legendClick.emit(data);
      }
    }
  }

  onPlotInitialized(): void {
    this.nshmpTemplateService.isSmallScreen$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.redrawPlot());

    this.templateService.colorSchemeChanged
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.setColorScheme());

    this.updatePlot();

    this.showSkeleton = false;
  }

  /**
   * Handle update event.
   */
  onUpdate(): void {
    this.removeMinorTicks();
    this.showSkeleton = false;
  }

  /**
   * Update the plot based on screen size.
   */
  updatePlot(): void {
    const panelWidth = this.nshmpPlotlyPlotEl().nativeElement.getBoundingClientRect().width;

    this.isSmallScreen = panelWidth <= (this.plot()?.panelBreakpoint ?? this.panelBreakpoint);

    if (this.isSmallScreen) {
      this.data = this.mobileData;
      this.layout = this.plot().mobileLayout ?? this.plot().layout;
      this.config = this.plot().mobileConfig ?? this.plot().config;
    } else {
      this.layout = this.plot().layout;
      this.config = this.plot().config;
      this.data = this.plot().data;
    }

    this.setColorScheme();
    this.updateDimensions();

    setTimeout(() => {
      this.removeMinorTicks();
      if (!this.isSmallScreen) {
        this.addLegendSnackBar();
      }
    }, 0);
  }

  private addLegendSnackBar(): void {
    let snackRef: MatSnackBarRef<TextOnlySnackBar> | undefined = undefined;

    const plotEl = this.plotlyComponent().plotEl.nativeElement as HTMLElement;

    plotEl
      .querySelector('g.legend')
      ?.querySelectorAll('.traces')
      .forEach(traceEl => {
        traceEl.addEventListener('mouseover', () => {
          snackRef = this.snackBar.open('Click on legend entry to highlight', '', {
            duration: 3000,
          });
        });

        traceEl.addEventListener('mouseout', () => snackRef?.dismiss());
      });
  }

  /**
   * Changes the plot data to better be shown on mobiles screens.
   *
   * @param plotData The plot data
   */
  private createMobileData(plotData: Partial<PlotData>[]): Partial<PlotData>[] {
    return [...plotData].map(data => {
      return {
        ...data,
        line: {
          ...data.line,
          width: this.selectedOptions.lineWidthMobile!,
        },
        marker: {
          ...data.marker,
          size: this.selectedOptions.markerSizeMobile!,
        },
      };
    });
  }

  private onSelectLegendUid(uid: string, allowEmit = true): void {
    const curveIndex = this.data.findIndex(plotData => plotData.uid === uid);

    if (curveIndex !== -1) {
      this.dataClicked = {};
      this.onLegendClick(curveIndex, allowEmit);
    }
  }

  /**
   * Redraw the plot by updating the revision value.
   */
  private redrawPlot(): void {
    if (this.plotlyComponent().plotlyInstance) {
      setTimeout(() => {
        this.plot().config.revision = Date.now();
        this.updatePlot();
      });
    }
  }

  /**
   * Remove minor ticks on mobile devices for single axis.
   *
   * @param ticks The tick elements
   */
  private removeMinorAxisTicks(ticks: SVGTextElement[]): void {
    const maxFontSize = Math.max(...ticks.map(tick => Number.parseInt(tick.style.fontSize)));

    ticks
      .filter(tick => Number.parseInt(tick.style.fontSize) < maxFontSize)
      .forEach(tick => (tick.style.display = 'none'));
  }

  /**
   * Remove minor ticks.
   */
  private removeMinorTicks(): void {
    const plotEl: HTMLElement = (this.plotlyComponent().plotEl as ElementRef<HTMLElement>)
      .nativeElement;

    const svgEl: SVGElement | null = plotEl.querySelector('svg.main-svg');

    const xticks: NodeListOf<SVGTextElement> | undefined = svgEl?.querySelectorAll('g.xtick text');
    const yticks: NodeListOf<SVGTextElement> | undefined = svgEl?.querySelectorAll('g.ytick text');

    this.removeMinorAxisTicks(Array.from(xticks ?? []));
    this.removeMinorAxisTicks(Array.from(yticks ?? []));
  }

  /**
   * Sets the color scheme of the plot based on application scheme.
   */
  private setColorScheme(): void {
    const black = 'black';
    const root = document.documentElement;
    const backgroundColors = getComputedStyle(root)
      .getPropertyValue('--mat-sys-surface-container-high')
      .replace('light-dark(', '')
      .replace(')', '')
      .split(',');

    const lightBackgroundColor = backgroundColors.shift() ?? '#f4f3f7';
    const darkBackgroundColor = backgroundColors.pop() ?? '#1a1c1e';

    if (this.templateService.isDarkMode) {
      const fontColor = '#eeeeee';
      this.layout.plot_bgcolor = darkBackgroundColor;
      this.layout.paper_bgcolor = darkBackgroundColor;

      if (this.data) {
        this.data.forEach(data => {
          data.textfont = {
            color: fontColor,
          };
        });
      }

      if (this.layout) {
        if (this.layout.xaxis) {
          this.layout.xaxis.color = fontColor;
        }

        if (this.layout.yaxis) {
          this.layout.yaxis.color = fontColor;
        }

        if (this.layout.legend?.font) {
          this.layout.legend.font.color = fontColor;
        }
      }
      (this.layout.title as DataTitle).font.color = fontColor;
    } else {
      this.layout.plot_bgcolor = lightBackgroundColor;
      this.layout.paper_bgcolor = lightBackgroundColor;

      if (this.data) {
        this.data.forEach(data => {
          data.textfont = {
            color: black,
          };
        });
      }

      if (this.layout) {
        if (this.layout.xaxis) {
          this.layout.xaxis.color = black;
        }

        if (this.layout.yaxis) {
          this.layout.yaxis.color = black;
        }

        if (this.layout.legend?.font) {
          this.layout.legend.font.color = black;
        }
      }
      (this.layout.title as DataTitle).font.color = black;
    }
  }

  /**
   * Update the height of the plot based on the width and aspect ratio.
   */
  private updateDimensions(): void {
    const aspectRatio = this.layout?.aspectRatio ?? '16:9';
    const nshmpEl = this.nshmpPlotlyPlotEl().nativeElement;

    const plotEl: HTMLElement = (this.plotlyComponent().plotEl as ElementRef<HTMLElement>)
      .nativeElement;

    const legendEl: SVGGElement | null | undefined = plotEl
      .querySelector('.legend')
      ?.querySelector('rect.bg');

    const layout = this.isSmallScreen ? this.plot().mobileLayout : this.layout;

    let height = heightFromAspectRatio(nshmpEl, aspectRatio);

    if (legendEl) {
      const legendHeight =
        (layout?.legend?.y ?? 1 <= 0) ? legendEl?.getBoundingClientRect()?.height : 0;

      height = this.isSmallScreen && legendHeight ? height + legendHeight : height;
    }

    nshmpEl.style.height = `${height}px`;
  }
}
<div>
  <div #nshmpPlotlyPlot class="nshmp-plotly-plot" [ngClass]="{'small-screen': isSmallScreen}">
    <div class="skeleton-container" [ngClass]="{hide: !showSkeleton}">
      <ngx-skeleton-loader class="skeleton-title" />
      <div class="skeleton grid-row">
        <div class="skeleton-border" #skeleton>
          <ngx-skeleton-loader class="skeleton-line line-1" />
          <ngx-skeleton-loader class="skeleton-line line-2" />
        </div>

        <div class="skeleton-legend">
          <ngx-skeleton-loader class="entry" />
          <ngx-skeleton-loader class="entry" />
        </div>
      </div>
    </div>

    <plotly-plot
      #plotlyComponent
      [config]="config"
      [className]="config.className"
      [data]="data"
      [debug]="config.debug ?? false"
      [divId]="plot().id"
      [layout]="layout"
      [revision]="config.revision ?? 1"
      [style]="config.style"
      [updateOnLayoutChange]="config.updateOnLayoutChange ?? true"
      [updateOnDataChange]="config.updateOnDataChange ?? true"
      [useResizeHandler]="true"
      (autoSize)="updatePlot()"
      (initialized)="onPlotInitialized()"
      (legendClick)="onLegendClick($event.curveNumber)"
      (update)="onUpdate()"
    />
  </div>
</div>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""