import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, NgZone, Output, Renderer2, ViewChild } from '@angular/core'; import {ColorHashService} from '@common/shared/services/color-hash/color-hash.service'; import {wordWrap} from '@common/tasks/tasks.utils'; import {attachColorChooser} from '@common/shared/ui-components/directives/choose-color/choose-color.directive'; import {debounceTime, filter} from 'rxjs/operators'; import {cloneDeep, escape, get, getOr} from 'lodash/fp'; import { AxisType, Config, Data, Datum, Margin, ModeBarButton, ModeBarDefaultButtons, PlotData, PlotlyHTMLElement, PlotMarker, Root } from 'plotly.js'; import {ScalarKeyEnum} from '~/business-logic/model/events/scalarKeyEnum'; import {ExtData, ExtFrame, ExtLayout, ExtLegend, PlotlyGraphBaseComponent} from './plotly-graph-base'; import {Store} from '@ngrx/store'; import {select} from 'd3-selection'; import {MatDialog} from '@angular/material/dialog'; import {GraphViewerComponent} from './graph-viewer/graph-viewer.component'; import {PALLET} from '@common/constants'; import {download} from '@common/shared/utils/download'; import {Subject} from 'rxjs'; import {hexToRgb} from '@common/shared/services/color-hash/color-hash.utils'; import {chooseTimeUnit} from '@common/shared/utils/choose-time-unit'; // eslint-disable-next-line @typescript-eslint/naming-convention declare const Plotly; const DARK_THEME_GRAPH_LINES_COLOR = '#39405f'; const DARK_THEME_GRAPH_TICK_COLOR = '#c1cdf3'; const ORIGIN_COLOR = 'origin-color'; const RATIO_OFFSET_FIX = 37; export type ChartHoverModeEnum = 'x' | 'y' | 'closest' | false | 'x unified' | 'y unified'; @Component({ selector: 'sm-single-graph', templateUrl: './single-graph.component.html', styleUrls: ['./single-graph.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class SingleGraphComponent extends PlotlyGraphBaseComponent { public alreadyDrawn: boolean = false; public shouldRefresh: boolean = false; public loading: boolean; public type: PlotData['type'] | 'table'; public ratio: number; public title: string; private yaxisType: AxisType = 'linear'; private originalChart: ExtFrame; private _chart: ExtFrame; private smoothnessTimeout: number; private chartData: HTMLDivElement; private previousOffsetWidth: number; private modeBar: HTMLElement; private previousHeight: number; private ratioEnable: boolean; private smooth$ = new Subject(); private _height: number; private _hoverMode: ChartHoverModeEnum; private drawGraph$ = new Subject(); @Input() identifier: string; @Input() hideTitle: boolean = false; @Input() isDarkTheme: boolean; @Input() showLoaderOnDraw = true; @Input() hideMaximize: 'show' | 'hide' | 'disabled' = 'show'; @Input() legendConfiguration: Partial = {}; @Input() set height(height: number) { this._height = height; if (this.chart) { this.drawGraph$.next({forceRedraw: true, forceSkipReact: false}); } } get height() { return this._height; } @Input() set width(width: number) { if (this.chart) { this.drawGraph$.next({forceRedraw: true, forceSkipReact: false}); } } @Input() set chart(chart: ExtFrame) { if (chart) { this.ratioEnable = !!chart.layout.width && !!chart.layout.height; this.ratio = this.ratioEnable ? chart.layout.width / chart.layout.height : null; this.height = chart.layout.height || this.height || 450; this.originalChart = chart; this._chart = cloneDeep(chart); this.drawGraph$.next({forceRedraw: true, forceSkipReact: false}); } } get chart(): ExtFrame { return this._chart; } @Input() moveLegendToTitle = false; @Input() legendStringLength = 19; @Input() graphsNumber: number; @Input() xAxisType: ScalarKeyEnum; @Input() set hoverMode(hoverMode) { this._hoverMode = hoverMode; if (this.chart) { this.drawGraph$.next({forceRedraw: true, forceSkipReact: false}); } } get hoverMode() { return this._hoverMode; } @Input() set smoothWeight(ratio: number) { this._smoothWeight = ratio; this.isSmooth = ratio > 0; if (this.alreadyDrawn) { this.smooth$.next(ratio); } } get smoothWeight() { return this._smoothWeight; } @Input() exportForReport = false; @Input() hideDownloadButtons = false; @Input() noMargins = false; @Output() hoverModeChanged = new EventEmitter(); @Output() createEmbedCode = new EventEmitter(); @Output() maximizeClicked = new EventEmitter(); @ViewChild('drawHere', {static: true}) plotlyContainer: ElementRef; private chartElm; private _smoothWeight: number; constructor( protected renderer: Renderer2, private colorHash: ColorHashService, public changeDetector: ChangeDetectorRef, protected store: Store, private dialog: MatDialog, private readonly zone: NgZone ) { super(store); this.sub.add(this.smooth$ .pipe( debounceTime(50), filter(() => !!this.chart)) .subscribe(() => { this._chart = cloneDeep(this.originalChart); this.drawGraph$.next({forceRedraw: true, forceSkipReact: false}); }) ); this.sub.add(this.drawGraph$.pipe(debounceTime(40)).subscribe(({forceRedraw, forceSkipReact}) => { if (this.showLoaderOnDraw) { this.loading = true; this.changeDetector.detectChanges(); } const container = this.plotlyContainer.nativeElement; if (this.alreadyDrawn && !forceRedraw && !this.shouldRefresh) { return; } this.alreadyDrawn = this.alreadyDrawn && !forceRedraw; this.shouldRefresh = false; const [root, data, layout, config, graphEl] = this.drawPlotly(); if (!document.body.contains(graphEl)) { container.appendChild(graphEl); } let skipReact = false; // root.height > 0 to avoid rare plotly exception if ((this.plotlyContainer.nativeElement.offsetWidth !== this.previousOffsetWidth || forceSkipReact) && (root as HTMLElement).offsetHeight > 0) { skipReact = true; this.zone.runOutsideAngular(() => Plotly.relayout(root, { width: this.ratio ? this.height * this.ratio + RATIO_OFFSET_FIX : Math.max(getOr(0, 'data[0].cells.values.length', data) * 100, this.plotlyContainer.nativeElement.offsetWidth - 3), height: this.height, }).then(() => { this.loading = false; this.changeDetector.detectChanges(); this.updateLegend(); })); } this.previousOffsetWidth = this.plotlyContainer.nativeElement.offsetWidth; this.previousHeight = this.height; if (!skipReact) { this.zone.runOutsideAngular(() => Plotly.react(root, data, layout, config).then(() => { this.loading = false; this.changeDetector.detectChanges(); })); } this.initColorSubscription(); if (!this.alreadyDrawn) { setTimeout(() => { this.subscribeColorButtons(container); this.updateLegend(); this.listenToHoverModeChange(); }); } this.alreadyDrawn = true; })); } listenToHoverModeChange() { this.plotlyContainer.nativeElement.querySelector('.chart')?.on('plotly_afterplot', () => { const activeBtns = this.plotlyContainer.nativeElement.querySelectorAll('.modebar-btn.active'); if (activeBtns.length > 0) { activeBtns.forEach((bt => { if (bt.attributes['data-attr'].value === 'hovermode') { if (bt.attributes['data-val'].value !== this.hoverMode) { this._hoverMode = bt.attributes['data-val'].value; this.hoverModeChanged.emit(bt.attributes['data-val'].value); } } })); } }); } drawPlotly(): [Root, Data[], Partial, Partial, Element] { this.chartData = this.chartData || this.renderer.createElement('div'); this.chartElm = this.chartElm || this.renderer.createElement('div'); this.chartElm.classList.add('chart'); if (!document.body.contains(this.chartElm)) { this.chartData.appendChild(this.chartElm); } const graph = this.formatChartLines() as ExtFrame; this.type = getOr(graph.layout.type, 'data[0].type', graph); const title = graph.variants?.length > 1? '' : this.addIterationString(graph.layout.title as string, graph.iter) || (graph.layout.title as Record).text; this.title = this.isDarkTheme ? '' : title; let layout = { ...this.addParametersIfDarkTheme({ font: { color: '#FFFFFF', family: '"Heebo", sans-serif', } }), ...graph.layout, // eslint-disable-next-line @typescript-eslint/naming-convention ...this.addParametersIfDarkTheme({plot_bgcolor: 'transparent'}), height: this.height, width: this.ratio ? (this.height * this.ratio) + RATIO_OFFSET_FIX : undefined, modebar: { color: '#5a658e', activecolor: '#4D66FF', ...this.addParametersIfDarkTheme({ color: PALLET.blue300, activecolor: PALLET.blue100, bgcolor: 'transparent', }), }, title: { ...this.addParametersIfDarkTheme({ font: { color: this.isDarkTheme ? PALLET.blue100 : 'dce0ee' }, }), }, ...this.addParametersIfDarkTheme({ xaxis: { color: DARK_THEME_GRAPH_LINES_COLOR, gridcolor: DARK_THEME_GRAPH_LINES_COLOR, zerolinecolor: DARK_THEME_GRAPH_LINES_COLOR, tickfont: { color: DARK_THEME_GRAPH_TICK_COLOR }, title: { text: graph.layout.xaxis.title, font: { color: '#dce0ee' } }, ...graph.layout.xaxis, }, yaxis: { color: DARK_THEME_GRAPH_LINES_COLOR, gridcolor: DARK_THEME_GRAPH_LINES_COLOR, zerolinecolor: DARK_THEME_GRAPH_LINES_COLOR, tickfont: { color: DARK_THEME_GRAPH_TICK_COLOR }, ...graph.layout.yaxis, } }), uirevision: 'static', // Saves the UI state between redraws https://plot.ly/javascript/uirevision/ hoverlabel: {namelength: -1}, legend: { traceorder: 'normal', xanchor: 'left', yanchor: 'top', ...(this.moveLegendToTitle ? { y: 1, orientation: 'v', } : { orientation: 'h', y: -0.2 }), borderwidth: 2, bordercolor: '#FFFFFF', valign: 'top', font: {color: '#000', size: 12, family: 'sans-serif'}, ...this.addParametersIfDarkTheme({ bgcolor: 'transparent', bordercolor: 'transparent', font: {color: '#dce0ee', size: 12, family: 'sans-serif'}, }), ...this.legendConfiguration, ...graph.layout.legend, }, showlegend: this.chartElm.layout && Object.prototype.hasOwnProperty.call(this.chartElm.layout, 'showlegend') ? this.chartElm.layout.showlegend : graph.layout?.showlegend !== false, margin: graph.layout.margin ? graph.layout.margin : this.noMargins ? { l: 50, r: 50, t: 50, b: 50, pad: 0, autoexpand: true } : { l: 70, r: 50, t: 80, b: 90, pad: 0, autoexpand: true } as Partial, } as Partial; if (this.type === 'table') { this.changeDetector.detectChanges(); // override header design graph.data[0].header = { ...graph.data[0].header, line: {width: 1, color: this.isDarkTheme ? '#5a658e' : '#d4d6e0'}, height: 29, align: 'left', font: { color: [this.isDarkTheme ? PALLET.blue200 : PALLET.blue400], size: 12 }, fill: {...graph.data[0].header?.fill, color: this.isDarkTheme ? PALLET.blue800 : PALLET.blue50} }; // override cells design const isFireFox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; graph.data[0].cells = { ...graph.data[0].cells, align: 'left', height: (isFireFox && this.isDarkTheme) ? 'auto' : 30, font: { color: [this.isDarkTheme ? PALLET.blue200 : PALLET.blue400], size: 12 }, fill: {...graph.data[0].cells, color: this.isDarkTheme ? PALLET.blue950 : '#ffffff'}, line: {width: 1, color: this.isDarkTheme ? DARK_THEME_GRAPH_LINES_COLOR : PALLET.blue100} }; layout.width = Math.max(getOr(0, 'data[0].cells.values.length', graph) * 100, this.plotlyContainer.nativeElement.offsetWidth - 3); layout.title = { ...layout.title as Record, xanchor: 'left', xref: 'paper', x: 0 }; layout.margin = { l: 24, t: 52, r: 24, b: 0 }; layout.height = Math.min(getOr(15, 'data[0].cells.values[0].length', graph) * 30 + 150, this.height); } const barLayoutConfig = { hovermode: 'closest', }; const scatterLayoutConfig: Partial = { // spikedistance: -1, // hoverdistance: -1, // hovermode : 'x', xaxis: { ...graph.layout.xaxis, spikecolor: '#000000FF', showspikes: true, spikemode: 'across', spikesnap: 'cursor', spikethickness: 1, spikedash: 'solid', rangeslider: {visible: false}, fixedrange: false, ...this.addParametersIfDarkTheme({ color: DARK_THEME_GRAPH_LINES_COLOR, title: { text: graph.layout.xaxis.title, font: { color: '#dce0ee' } }, gridcolor: DARK_THEME_GRAPH_LINES_COLOR, zerolinecolor: DARK_THEME_GRAPH_LINES_COLOR, tickfont: { color: DARK_THEME_GRAPH_TICK_COLOR } }) }, yaxis: { ...graph.layout.yaxis, spikecolor: '#000000FF', showspikes: false, spikemode: 'across', spikesnap: 'cursor', spikethickness: 1, spikedash: 'dash', rangeslider: {visible: false}, fixedrange: false, type: this.yaxisType, ...this.addParametersIfDarkTheme({ color: DARK_THEME_GRAPH_LINES_COLOR, gridcolor: DARK_THEME_GRAPH_LINES_COLOR, zerolinecolor: DARK_THEME_GRAPH_LINES_COLOR, tickfont: { color: DARK_THEME_GRAPH_TICK_COLOR } }) }, }; if (['multiScalar', 'scalar'].includes(graph.layout.type)) { if (['scatter', 'scatter3d'].includes(this.type)) { layout = {hovermode: this.hoverMode, ...layout, ...scatterLayoutConfig} as Partial; } } if (['bar'].includes(this.type)) { layout = {...layout, ...barLayoutConfig} as Partial; } const modeBarButtonsToAdd = ['v1hovermode', 'togglespikelines'] as undefined as ModeBarButton[]; if (['multiScalar', 'scalar'].includes(graph.layout.type)) { modeBarButtonsToAdd.push({ name: 'Log view', title: this.getLogButtonTitle(this.yaxisType === 'log'), icon: this.getLogIcon(this.yaxisType === 'log'), click: (gd: PlotlyHTMLElement, ev: MouseEvent) => { this.yaxisType = this.yaxisType === 'log' ? 'linear' : 'log'; const icon = this.getLogIcon(this.yaxisType === 'log'); let path: SVGPathElement; let svg: HTMLElement; if ((ev.target as SVGElement).tagName === 'svg') { svg = ev.target as HTMLElement; path = (ev.target as SVGElement).firstChild as SVGPathElement; } else { path = ev.target as SVGPathElement; svg = path.parentElement as HTMLElement; } svg.parentElement.attributes['data-title'].value = this.getLogButtonTitle(this.yaxisType === 'log'); path.attributes[0].value = icon.path; this.smoothnessTimeout = window.setTimeout(() => { this._chart = cloneDeep(this.originalChart); this.drawGraph$.next({forceRedraw: true, forceSkipReact: false}); }, 400); } }); } if (!['table', 'parcoords'].includes(get('data[0].type', graph)) && graph.layout?.showlegend !== false && !this.moveLegendToTitle) { modeBarButtonsToAdd.push({ name: 'Hide legend', title: this.getHideButtonTitle(), icon: this.getToggleLegendIcon(), click: (element, ev: MouseEvent) => { const pathElement = (ev.target as HTMLElement).tagName === 'path' ? (ev.target as HTMLElement) : (ev.target as HTMLElement).querySelector('path'); const svg = pathElement.parentElement; pathElement.style.fill = this.chartElm.layout?.showlegend ? 'rgb(77, 102, 255)' : 'rgb(143, 157, 201)'; svg.parentElement.attributes['data-title'].value = this.getHideButtonTitle(); this.chartElm.layout.showlegend = !this.chartElm.layout.showlegend; if (this.chartElm.layout.showlegend) { setTimeout(() => { this.subscribeColorButtons(this.plotlyContainer.nativeElement); this.updateLegend(); }, 20); } this.zone.runOutsideAngular(() => Plotly.relayout(this.chartElm, {showlegend: this.chartElm.layout.showlegend})); } }); } if (!['table', 'parcoords'].includes(get('data[0].type', graph)) && this.ratioEnable && !this.moveLegendToTitle) { modeBarButtonsToAdd.push({ name: 'Auto Layout', title: 'Auto Layout', icon: this.getToggleRatioIcon(), click: (element, ev: MouseEvent) => { const pathElement = (ev.target as HTMLElement).tagName === 'path' ? (ev.target as HTMLElement) : (ev.target as HTMLElement).querySelector('path'); const svg = pathElement.parentElement; pathElement.style.fill = this.ratio ? 'rgb(77, 102, 255)' : 'rgb(143, 157, 201)'; svg.parentElement.attributes['data-title'].value = this.getLockRatioTitle(); this.ratio = this.ratio ? null : this.originalChart.layout.width / this.originalChart.layout.height; this.drawGraph$.next({forceRedraw: true, forceSkipReact: true}); } }); } if (!this.hideDownloadButtons) { modeBarButtonsToAdd.push({ name: 'Download JSON', title: 'Download JSON', icon: this.getJsonDownloadIcon(), click: () => { this.downloadGraphAsJson(cloneDeep(this.originalChart)); } }); } if (this.type === 'table') { modeBarButtonsToAdd.push({ name: 'Download CSV', title: 'Download CSV', icon: this.getCSVDownloadIcon(), click: () => { this.downloadTableAsCSV(); } }); } if (this.exportForReport) { const button: ModeBarButton = { name: 'Embed', title: 'Copy embed code', attr: 'plotly-embedded-modebar-button', icon: this.getEmbedIcon(), click: (event) => { this.createEmbedCode.emit(event.querySelector('[data-title="Copy embed code"]').getBoundingClientRect() ); } }; modeBarButtonsToAdd.push(button); } if (this.hideMaximize !== 'hide') { const maximizeButton: ModeBarButton = { name: 'Maximize', title: this.hideMaximize === 'disabled' ? `Can't maximize because an iframe with the same name exists` : 'Maximize Graph', attr: this.hideMaximize === 'disabled' ? 'plotly-disabled-maximize' : '', icon: this.getMaximizeIcon(), click: () => { this.hideMaximize !== 'disabled' && this.maximizeGraph(); } }; modeBarButtonsToAdd.push(maximizeButton); } const config = { modeBarButtonsToRemove: (this.hideDownloadButtons ? ['sendDataToCloud', 'toImage']: ['sendDataToCloud']) as ModeBarDefaultButtons[], displaylogo: false, modeBarButtonsToAdd }; return [this.chartElm, graph.data, layout, config, this.chartData]; } private updateLegend() { const graph = select(this.plotlyContainer.nativeElement); graph.selectAll('.legendpoints path') .attr('d', 'M5.5,0A5.5,5.5 0 1,1 0,-5.5A5.5,5.5 0 0,1 5.5,0Z'); graph.selectAll('.legendtoggle') .on('click', () => window.setTimeout(() => graph.selectAll('.legendpoints path') .attr('d', 'M5.5,0A5.5,5.5 0 1,1 0,-5.5A5.5,5.5 0 0,1 5.5,0Z'), 300) ); } private formatChartLines() { if (this.alreadyDrawn || !this.chart) { return this.chart; } const graph = this.chart; if (this.isCompare) { graph.data = this.addIdToDuplicateExperiments(this.chart.data, this.chart.task); } const smoothLines = []; const timeUnit = chooseTimeUnit(graph.data) as { time: number; str: string }; for (let i = 0; i < graph.data.length; i++) { if (!graph.data[i].name) { graph.data[i].name = `graph.metric ${i}`; } if (!this.alreadyDrawn && !graph.data[i].name.includes(' (timestamp - zeroTime) / timeUnit.time); // graph.data[i].hovertext = graph.data[i].x.map(timestamp => timeInWords((timestamp - zeroTime))); } if (graph.data[i].type === 'bar' && !graph.data[i].marker) { graph.data[i].marker = {} as Partial; } const genColorKey = this.generateColorKey(graph, i); const wrappedText = this.legendConfiguration.noTextWrap || this.scaleFactor === 100 ? graph.data[i].name : wordWrap(graph.data[i].name, this.legendStringLength / 2); const skipColor = !!this.getOriginalColor(i) ? ORIGIN_COLOR : ''; graph.data[i].name = wrappedText + ``; } const colorKey = this.extractColorKey(graph.data[i].name); const originalColor = this.getOriginalColor(i); if (!Array.isArray(originalColor)) { const color = (originalColor && !this.colorHash.hasColor(colorKey)) ? hexToRgb(this.getOriginalColor(i)) : this.colorHash.initColor(colorKey, null, this.isDarkTheme); // if (this.colorHash.hasColor(colorKey)) { // We don't save init color in color cache, so we need to recolor everytime this._reColorTrace(graph.data[i], color); // } if (this.isSmooth && !graph.data[i].isSmoothed) { graph.data[i].legendgroup = graph.data[i].name; graph.data[i].showlegend = false; graph.data[i].hoverinfo = 'skip'; smoothLines.push(this.resmoothDataset(graph.data[i], color)); } } } this.setAxisText(graph, timeUnit); graph.data = graph.data.filter(line => !line.isSmoothed); graph.data = graph.data.concat(smoothLines); return graph; } private getOriginalColor(i: number) { return this.originalChart.data[i]?.marker?.color || this.originalChart.data[i]?.line?.color; } public generateColorKey(graph: ExtFrame, i: number) { const variant = graph.data[i].colorHash || graph.data[i].name; if (!this.isCompare) { return `${variant}?`; // "?" to adjust desired colors (legend title is removing this ?) } else { const task = graph.data[i].task; return `${variant}-${task}`; } } private resmoothDataset(data: ExtData, color) { let last = data.y?.[0] as number ?? NaN; return { ...data, line: {...data.line, color: `rgb(${color[0]},${color[1]},${color[2]})`}, legendgroup: data.name, name: `${data.name} (Smoothed)`, showlegend: true, isSmoothed: true, hovertext: data.hovertext ? data.hovertext + '(Smoothed)' : '(Smoothed)', hoverinfo: 'all', y: data.y.map((d) => { if (!isFinite(last)) { return null; } else { // 1st-order IIR low-pass filter to attenuate the higher- // frequency components of the time-series. last = last * this.smoothWeight + (1 - this.smoothWeight) * d; return last; } }) } as ExtData; } private initColorSubscription(forceRedraw = false) { if (this.colorSub) { // Subscription is already running if (forceRedraw) { this.colorSub.unsubscribe(); } else { return; } } this.colorSub = this.colorHash.getColorsObservable() .pipe( filter(colorObj => !!colorObj), debounceTime(100) ) .subscribe(colorObj => { const graph = this.chart; let changed: boolean = false; graph.data.forEach(trace => { const name = trace.name; const colorKey = this.extractColorKey(name); if (!name || !this.colorHash.hasColor(colorKey)) { return; } const oldColor = this._getTraceColor(trace); const newColorArr = colorObj[colorKey]; const newColor = newColorArr ? `rgb(${newColorArr[0]},${newColorArr[1]},${newColorArr[2]})` : false; if (oldColor !== newColor && newColor) { changed = true; this._reColorTrace(trace, newColorArr); } }); if (changed) { this.zone.runOutsideAngular(() => Plotly.redraw(this.chartElm)); this.updateLegend(); } }); } private subscribeColorButtons(container) { this.repositionModeBar(this.plotlyContainer.nativeElement); if (this.moveLegendToTitle) { const graphTitle = container.querySelector('.gtitle') as SVGTextElement; if (graphTitle) { const endOfTitlePosition = ( (this.plotlyContainer.nativeElement.offsetWidth / 2) + (graphTitle.getClientRects()[0].width * this.scaleFactor / 200) ); const legend = container.querySelector('.legend') as SVGGElement; legend.style.transform = `translate(${endOfTitlePosition}px, 30px)`; legend.classList.add('hide-text'); } } const traces = container.querySelectorAll('.traces'); for (const trace of traces) { const textEl = trace.querySelector('.legendtext') as SVGTextElement; const textElData = textEl.getAttribute('data-unformatted'); const text = textEl ? this.extractColorKey(textElData) : ''; // const skipColor = textElData.includes(ORIGIN_COLOR); const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); title.textContent = text.replace('?', ''); textEl.parentElement.appendChild(title); const layers = trace.querySelector('.layers'); const parentEl = layers.parentElement; parentEl.removeChild(layers); // Needed because z-index in svg is by element order parentEl.appendChild(layers); attachColorChooser(text, layers, this.colorHash, this.store); } } private setAxisText(chart: ExtFrame, timeUnit?: { time: number; str: string }) { const title = this.getAxisText(timeUnit); if (!chart.layout.xaxis) { chart.layout.xaxis = {}; } if (title) { chart.layout.xaxis.title = {text: title, standoff: 10}; } return chart; } downloadGraphAsJson(chart: ExtFrame) { let timeUnit; if (this.xAxisType === ScalarKeyEnum.Timestamp) { chart.data.forEach(graphData => { if (!graphData.name) { return; } timeUnit = typeof timeUnit === 'undefined' ? chooseTimeUnit(chart.data) : timeUnit; const zeroTime = graphData.x[0] as number; graphData.x = (graphData.x as number[]).map(timestamp => (timestamp - zeroTime) / timeUnit.time); // graph.data[i].hovertext = graph.data[i].x.map(timestamp => timeInWords((timestamp - zeroTime))); }); } const exportName = `${chart.layout.title} - ${this.getAxisText(timeUnit) || chart.layout.name}.json`; const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(chart.data)); download(dataStr, exportName); } getJsonDownloadIcon() { return { path: 'M21,6H15V0ZM14,7h7V22a2,2,0,0,1-2,2H5a2,2,0,0,1-2-2V2A2,2,0,0,1,5,0h9Zm1.34,13.22h.58a2.09,2.09,0,0,0,1.3-.28,1.59,1.59,0,0,0,.32-1.16v-.84a1.14,1.14,0,0,1,.22-.81,1,1,0,0,1,.76-.23h.24v-.74h-.24a1,1,0,0,1-.76-.23,1.14,1.14,0,0,1-.22-.81v-.84a1.55,1.55,0,0,0-.32-1.15,2.08,2.08,0,0,0-1.3-.29h-.58v.74h.47a.71.71,0,0,1,.55.17,1.25,1.25,0,0,1,.14.72v.82a1.54,1.54,0,0,0,.19.9.94.94,0,0,0,.68.34,1,1,0,0,0-.68.35,1.56,1.56,0,0,0-.19.9v.8a1.27,1.27,0,0,1-.14.73.72.72,0,0,1-.55.17h-.47ZM9.11,12.84H8.53a2.12,2.12,0,0,0-1.31.29,1.5,1.5,0,0,0-.33,1.15v.84a1.29,1.29,0,0,1-.2.82,1.08,1.08,0,0,1-.76.22H5.69v.74h.24a1,1,0,0,1,.76.23,1.26,1.26,0,0,1,.2.81v.84a1.54,1.54,0,0,0,.33,1.16,2.13,2.13,0,0,0,1.31.28h.58v-.74H8.63a.68.68,0,0,1-.54-.17A1.27,1.27,0,0,1,8,18.58v-.8a1.56,1.56,0,0,0-.19-.9,1,1,0,0,0-.68-.35,1,1,0,0,0,.68-.34,1.54,1.54,0,0,0,.19-.9v-.82a1.25,1.25,0,0,1,.14-.72.68.68,0,0,1,.54-.17h.48Zm4,1.7H10.83v.88H12v3.33a1.21,1.21,0,0,1-.16.73.61.61,0,0,1-.54.22h-.91v.88h1.23a1.44,1.44,0,0,0,1.17-.42,2.17,2.17,0,0,0,.36-1.41Zm0-2.12H12v1.33h1.15Z', width: 24, height: 24 }; } getCSVDownloadIcon() { return { path: 'M15,6V0l6,6Zm6,1V22a2,2,0,0,1-2,2H5a2,2,0,0,1-2-2V2A2,2,0,0,1,5,0h9V7ZM8.83,14.28a2.78,2.78,0,0,0-.57-.21A2.67,2.67,0,0,0,7.62,14a2.18,2.18,0,0,0-1.8.78A3.57,3.57,0,0,0,5.2,17a3.54,3.54,0,0,0,.62,2.24,2.18,2.18,0,0,0,1.8.78A2.59,2.59,0,0,0,8.25,20a2.66,2.66,0,0,0,.58-.21V18.49a2.42,2.42,0,0,1-.58.4,1.44,1.44,0,0,1-.57.13,1.06,1.06,0,0,1-1-.51A2.8,2.8,0,0,1,6.4,17a2.82,2.82,0,0,1,.32-1.49,1.05,1.05,0,0,1,1-.5,1.42,1.42,0,0,1,.57.12,2.42,2.42,0,0,1,.58.4Zm4.93,4a1.72,1.72,0,0,0-.33-1.08,2.27,2.27,0,0,0-1-.68l-.49-.19a2,2,0,0,1-.68-.35.52.52,0,0,1-.16-.4.55.55,0,0,1,.22-.48,1.06,1.06,0,0,1,.64-.17,2.31,2.31,0,0,1,.76.14,3,3,0,0,1,.75.4V14.36a4,4,0,0,0-.8-.27,3.57,3.57,0,0,0-.8-.09,2,2,0,0,0-1.4.45,1.61,1.61,0,0,0-.5,1.25,1.44,1.44,0,0,0,.31,1,2.87,2.87,0,0,0,1.17.7l.57.21a1,1,0,0,1,.45.31.72.72,0,0,1,.16.46.67.67,0,0,1-.23.54,1,1,0,0,1-.66.2,2.61,2.61,0,0,1-.86-.16,4,4,0,0,1-.89-.49v1.19a3.71,3.71,0,0,0,1.71.41,2.44,2.44,0,0,0,1.58-.43A1.64,1.64,0,0,0,13.76,18.3Zm5.07-4.19H17.67l-1,4.87-1-4.87H14.46l1.39,5.83h1.6Z', width: 24, height: 24, }; } getToggleLegendIcon() { return { width: 1000, fill: 'rgb(77, 102, 255)', path: 'M200,250H50a50,50,0,0,1,0-100H200a50,50,0,0,1,0,100Zm800-50a50,50,0,0,0-50-50H400a50,50,0,0,0,0,100H950A50,50,0,0,0,1000,200ZM250,400a50,50,0,0,0-50-50H50a50,50,0,0,0,0,100H200A50,50,0,0,0,250,400Zm750,0a50,50,0,0,0-50-50H400a50,50,0,0,0,0,100H950A50,50,0,0,0,1000,400ZM250,600a50,50,0,0,0-50-50H50a50,50,0,0,0,0,100H200A50,50,0,0,0,250,600Zm750,0a50,50,0,0,0-50-50H400a50,50,0,0,0,0,100H950A50,50,0,0,0,1000,600ZM250,800a50,50,0,0,0-50-50H50a50,50,0,0,0,0,100H200A50,50,0,0,0,250,800Zm750,0a50,50,0,0,0-50-50H400a50,50,0,0,0,0,100H950A50,50,0,0,0,1000,800Z', ascent: 1000, descent: 0, transform: 'translate(0, -100)' }; } getToggleRatioIcon() { return { width: 500, height: 500, fill: 'rgb(77, 102, 255)', path: 'M419.3,256.4H373v69.9h-69.9v46.3h116.2V256.4z M139.8,186.5h69.9v-46.3h-117v116.2H139v-69.9H139.8z M465.7,46.7H46.3C20.6,46.7,0,68,0,93.1V419c0,25.7,20.6,46.3,46.3,46.3h419.3c25.7,0,46.3-21.3,46.3-46.3V93.1C512,68,491.4,46.7,465.7,46.7z M465.7,419.7H46.3V93.1h419.3v326.6H465.7z', ascent: 300, descent: 0, transform: 'translate(0, -50)' }; } getMaximizeIcon() { return { width: 1000, height: 1000, fill: 'rgb(77, 102, 255)', path: 'M920,80V436.38L771.51,287.89,559.4,500,500,440.6,712.11,228.49,563.62,80ZM500,559.4,440.6,500,228.49,712.11,80,563.62V920H436.38L287.89,771.51Z', ascent: 1000, descent: 0, transform: 'translate(0, -100)' }; } getEmbedIcon = () => ({ width: 24, height: 24, fill: 'rgb(77, 102, 255)', path: 'M22,0H2C.9,0,0,.9,0,2V22c0,1.1,.9,2,2,2H22c1.1,0,2-.9,2-2V2c0-1.1-.9-2-2-2ZM8.02,9.54l-2.59,2.59,2.59,2.58v2.83L2.6,12.13l5.42-5.42v2.83Zm3.11,11.59l-1.96-.4L12.91,3.13l1.96,.4-3.74,17.6Zm4.89-3.59v-2.83l2.59-2.58-2.59-2.59v-2.83l5.42,5.42-5.42,5.41Z', }); getLogIcon(onOrOff: boolean) { if (!onOrOff) { return { width: 1000, path: 'M797,772a29.4,29.4,0,0,1-3.1-.16c-130-13.31-240.09-51.57-327.17-113.74-70.33-50.2-125.62-115.78-164.34-194.91C236.94,329.34,241.79,203.92,242,198.64A30,30,0,0,1,302,201.31h0c-.05,1.16-4.17,117.36,55.41,237.65,34.47,69.59,83.42,127.19,145.5,171.2,78.3,55.51,178.29,89.82,297.18,102A30,30,0,0,1,797,772Zm111,80.5H147.5V92H38v28H92.5v29H37v28H92.5v41H38v28H92.5v64H38v28H92.5V446H38v28H92.5V639H38v28H92.5V852.5H0v55H92.5V1000h55V907.5H908Z', ascent: 1000, descent: 0, transform: 'translate(0, -100)' }; } return { width: 1000, path: 'M908,907.5H147.5V1000h-55V907.5H0v-55H92.5V120H37V92H147.5V852.5H908ZM883.79,239.14a30,30,0,0,0-41.65,8.07L672,499.21,471.48,411.78a59.49,59.49,0,0,0-117.89-.59l-154.22,71.6a30,30,0,1,0,25.26,54.42l151.74-70.45a59.48,59.48,0,0,0,71.85.33L667,562.5A29.91,29.91,0,0,0,679,565c.85,0,1.68,0,2.52-.12s1.65.11,2.47.11a30,30,0,0,0,24.89-13.21l183-271A30,30,0,0,0,883.79,239.14ZM894,627a30,30,0,0,0-41-11l-129.1,74.28a59.5,59.5,0,0,0-87.83,25.11L219.32,711H219a30,30,0,0,0-.31,60l424.43,4.47a59.48,59.48,0,0,0,106.65-30.82L883,668A30,30,0,0,0,894,627Z', ascent: 1000, descent: 0, transform: 'translate(0, -100)' }; } getLogButtonTitle(onOrOff: boolean) { return `Switch to ${onOrOff ? 'Linear' : 'Logarithmic'} scale`; } private getAxisText(timeUnit: { time: number; str: string }) { switch (this.xAxisType) { case ScalarKeyEnum.Iter: return 'Iterations'; case ScalarKeyEnum.IsoTime: return 'Wall Time'; case ScalarKeyEnum.Timestamp: return (timeUnit && timeUnit.str) ? `${timeUnit.str} From Start` : 'Relative Time'; default: return null; } } private getHideButtonTitle() { return this.chartElm.layout?.showlegend ? 'Show legend' : 'Hide legend'; } private getLockRatioTitle() { return this.ratio ? 'Original Layout' : 'Auto Layout'; } private maximizeGraph() { this.maximizeClicked.emit(); this.zone.run(() => { this.dialog.open(GraphViewerComponent, { data: { ...(this.exportForReport && {embedFunction: (rect: DOMRect) => this.createEmbedCode.emit(rect)}), // signed url are updated after originChart was cloned - need to update images urls! chart: cloneDeep({ ...this.originalChart, layout: {...this.originalChart.layout, images: this.chart.layout?.images} }), id: this.identifier, xAxisType: this.xAxisType, smoothWeight: this.smoothWeight, darkTheme: this.isDarkTheme, isCompare: this.isCompare, }, panelClass: ['image-viewer-dialog', this.isDarkTheme ? 'dark-theme' : 'light-theme'], height: '100%', maxHeight: 'auto', width: '100%', maxWidth: 'auto' }).beforeClosed().subscribe(() => this.maximizeClicked.emit()); }); } private addParametersIfDarkTheme(object: Record) { return this.isDarkTheme ? object : {}; } private addIterationString(name: string, iter: number) { return name + ((iter || (this.graphsNumber > 1 && iter === 0)) ? ` - Iteration ${iter}` : ''); } public repositionModeBar(singleGraphEl) { if (this.type === 'table') { this.modeBar = this.modeBar || this.chartElm.querySelector('.modebar-container'); this.modeBar.style.right = `${singleGraphEl.scrollWidth - singleGraphEl.clientWidth - singleGraphEl.scrollLeft}px`; } } private downloadTableAsCSV() { const vals = this.chart?.data?.[0]?.cells?.values; const headers = this.chart?.data?.[0]?.header?.values; if (vals && headers) { let data = headers.flat().join(',') + '\n'; for (let i = 0; i < vals[0].length; ++i) { for (let j = 0; j < headers.length; ++j) { data += `${vals[j][i]},`; } data = data.slice(0, -1) + '\n'; } const exportName = `${this.chart.layout.title} - ${this.chart.layout.name}.csv`; data = 'data:text/csv;charset=utf-8,' + encodeURIComponent(data); download(data, exportName); } } public redrawPlot() { this.drawGraph$.next({forceRedraw: true, forceSkipReact: false}); }; }