diff --git a/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.html b/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.html index 5fee715cae..aa01c7fc73 100644 --- a/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.html +++ b/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.html @@ -1,5 +1,19 @@
- +
+ + +
+

{{ "frontend.de.iteratec.chart.errorHeader" | translate }}

+
+
+
{{ "frontend.de.iteratec.chart.datapointSelection.error.multipleServer" | translate }}
+
+ +
diff --git a/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.scss b/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.scss index 6f84a12c9a..e49bc45d3b 100644 --- a/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.scss +++ b/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.scss @@ -75,3 +75,21 @@ osm-time-series-line-chart { .legend-text { font-size: 12px; } + +#pointSelectionErrorModal { + header { + padding: 15px; + border-bottom: 1px solid #e5e5e5; + } + + main { + position: relative; + padding: 15px; + } + + footer { + padding: 15px; + text-align: right; + border-top: 1px solid #e5e5e5; + } +} diff --git a/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.ts b/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.ts index 5b79dfdf08..f641ec0831 100644 --- a/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.ts +++ b/frontend/src/app/modules/time-series/components/time-series-line-chart/time-series-line-chart.component.ts @@ -12,6 +12,7 @@ import { import {EventResultData} from '../../models/event-result-data.model'; import {LineChartService} from '../../services/line-chart.service'; +import {NgxSmartModalService} from "ngx-smart-modal"; @Component({ @@ -30,13 +31,12 @@ export class TimeSeriesLineChartComponent implements AfterContentInit, OnChanges private _resizeTimeoutId: number; - constructor( - private lineChartService: LineChartService - ) { + constructor(private lineChartService: LineChartService, + private ngxSmartModalService: NgxSmartModalService) { } ngAfterContentInit(): void { - this.lineChartService.initChart(this.svgElement); + this.lineChartService.initChart(this.svgElement, () => this.handlePointSelectionError()); } ngOnChanges(changes: SimpleChanges): void { @@ -61,4 +61,8 @@ export class TimeSeriesLineChartComponent implements AfterContentInit, OnChanges this.lineChartService.setLegendData(this.timeSeriesResults); this.lineChartService.drawLineChart(this.timeSeriesResults); } + + handlePointSelectionError() { + this.ngxSmartModalService.open("pointSelectionErrorModal"); + } } diff --git a/frontend/src/app/modules/time-series/models/event-result-point.model.ts b/frontend/src/app/modules/time-series/models/event-result-point.model.ts index 2f5e00859b..0cbd93b852 100644 --- a/frontend/src/app/modules/time-series/models/event-result-point.model.ts +++ b/frontend/src/app/modules/time-series/models/event-result-point.model.ts @@ -1,17 +1,22 @@ +import {WptInfo} from "./wpt-info.model"; + export interface EventResultPointDTO { date: Date; value: number; agent: string; + wptInfo: WptInfo; } export class EventResultPoint implements EventResultPointDTO { date: Date; value: number; agent: string; + wptInfo: WptInfo; constructor(dto: EventResultPointDTO) { this.date = dto.date; this.value = dto.value; this.agent = dto.agent; + this.wptInfo = dto.wptInfo; } } diff --git a/frontend/src/app/modules/time-series/models/points-selection.model.ts b/frontend/src/app/modules/time-series/models/points-selection.model.ts new file mode 100644 index 0000000000..eec89a3b88 --- /dev/null +++ b/frontend/src/app/modules/time-series/models/points-selection.model.ts @@ -0,0 +1,37 @@ +import {TimeSeriesPoint} from "./time-series-point.model"; + +export class PointsSelection { + + private selectedPoints: TimeSeriesPoint[] = []; + + public unselectAll() { + this.selectedPoints = []; + } + + public selectPoint(pointToSelect: TimeSeriesPoint) { + this.selectedPoints.push(pointToSelect); + } + + public isPointSelected(pointToCheck: TimeSeriesPoint): boolean { + return this.selectedPoints.some(elem => elem.equals(pointToCheck)); + } + + public unselectPoint(pointToSelect: TimeSeriesPoint) { + this.selectedPoints = this.selectedPoints.filter(elem => !elem.equals(pointToSelect)) + } + + public count(): number { + return this.selectedPoints.length; + } + + public getAll(): TimeSeriesPoint[] { + return this.selectedPoints; + } + + public getFirst(): TimeSeriesPoint { + if(this.count() === 0) { + return null; + } + return this.selectedPoints[0]; + } +} diff --git a/frontend/src/app/modules/time-series/models/time-series-point.model.ts b/frontend/src/app/modules/time-series/models/time-series-point.model.ts index f38ed02090..5b2b411a64 100644 --- a/frontend/src/app/modules/time-series/models/time-series-point.model.ts +++ b/frontend/src/app/modules/time-series/models/time-series-point.model.ts @@ -1,10 +1,17 @@ /** * Representation of one point on the y-axis (value) with additional informations. */ +import {WptInfo} from "./wpt-info.model"; + export class TimeSeriesPoint { date: Date; value: number; tooltipText: string; + wptInfo: WptInfo; constructor() {} + + public equals(other: TimeSeriesPoint) { + return other && this.date.getTime() == other.date.getTime() && this.tooltipText == other.tooltipText && this.value == other.value; + } } diff --git a/frontend/src/app/modules/time-series/models/wpt-info.model.ts b/frontend/src/app/modules/time-series/models/wpt-info.model.ts new file mode 100644 index 0000000000..14bead0b59 --- /dev/null +++ b/frontend/src/app/modules/time-series/models/wpt-info.model.ts @@ -0,0 +1,6 @@ +export class WptInfo { + baseUrl: string; + testId: string; + runNumber: number; + indexInJourney: number; +} diff --git a/frontend/src/app/modules/time-series/services/line-chart.service.ts b/frontend/src/app/modules/time-series/services/line-chart.service.ts index 3caf23c59d..673830e3e5 100644 --- a/frontend/src/app/modules/time-series/services/line-chart.service.ts +++ b/frontend/src/app/modules/time-series/services/line-chart.service.ts @@ -47,6 +47,8 @@ import {TimeSeriesPoint} from 'src/app/modules/time-series/models/time-series-po import {parseDate} from 'src/app/utils/date.util'; import {getColorScheme} from 'src/app/enums/color-scheme.enum'; import {ChartCommons} from "../../../enums/chart-commons.enum"; +import {UrlBuilderService} from "./url-builder.service"; +import {PointsSelection} from "../models/points-selection.model"; import {SummaryLabel} from "../models/summary-label.model"; /** @@ -79,13 +81,152 @@ export class LineChartService { private _xAxisCluster: any = {}; // Mouse events + private _pointSelectionErrorHandler: Function; private _mouseEventCatcher: D3Selection; private _markerTooltip: D3Selection; + private _contextMenuBackground: D3Selection; + private _contextMenu: D3Selection; + + private _dotsOnMarker: D3Selection; + private _pointsSelection: PointsSelection; + private _contextMenuPoint: D3Selection; + + private contextMenu: ContextMenuPosition[] = [ + { + title: 'summary', + icon: "fas fa-file-alt", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildSummaryUrl(d.wptInfo)); + } + }, + { + title: 'waterfall', + icon: "fas fa-bars", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildUrlByOption(d.wptInfo, this.urlBuilderService.options.waterfall)); + } + }, + { + title: 'performanceReview', + icon: "fas fa-check", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildUrlByOption(d.wptInfo, this.urlBuilderService.options.performanceReview)); + } + }, + { + title: 'contentBreakdown', + icon: "fas fa-chart-pie", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildUrlByOption(d.wptInfo, this.urlBuilderService.options.contentBreakdown)); + } + }, + { + title: 'domains', + icon: "fas fa-list", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildUrlByOption(d.wptInfo, this.urlBuilderService.options.domains)); + } + }, + { + title: 'screenshot', + icon: "fas fa-image", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildUrlByOption(d.wptInfo, this.urlBuilderService.options.screenshot)); + } + }, + { + title: 'filmstrip', + icon: "fas fa-film", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildFilmstripUrl(d.wptInfo)); + } + }, + { + title: 'filmstripTool', + icon: "fas fa-money-check", + action: (d: TimeSeriesPoint) => { + window.open(this.urlBuilderService + .buildFilmstripToolUrl(d.wptInfo)); + } + }, + { + title: 'compareFilmstrips', + icon: "fas fa-columns", + visible: () => { + return this._pointsSelection.count() > 0; + }, + action: () => { + const selectedDots = this._pointsSelection.getAll(); + const wptInfos = selectedDots.map(it => it.wptInfo); + window.open(this.urlBuilderService + .buildFilmstripsComparisionUrl(wptInfos)); + } + }, + { + divider: true + }, + { + title: 'selectPoint', + icon: "fas fa-dot-circle", + visible: (d: TimeSeriesPoint) => { + return !this._pointsSelection.isPointSelected(d); + }, + action: (d: TimeSeriesPoint) => { + this.changePointSelection(d); + } + }, + { + title: 'deselectPoint', + icon: "fas fa-trash-alt", + visible: (d: TimeSeriesPoint) => { + return this._pointsSelection.isPointSelected(d); + }, + action: (d: TimeSeriesPoint) => { + this.changePointSelection(d); + } + }, + ]; + + private backgroundContextMenu: ContextMenuPosition[] = [ + { + title: 'compareFilmstrips', + icon: "fas fa-columns", + visible: () => { + return this._pointsSelection.count() >= 2; + }, + action: () => { + const selectedDots = this._pointsSelection.getAll(); + const wptInfos = selectedDots.map(it => it.wptInfo); + window.open(this.urlBuilderService + .buildFilmstripsComparisionUrl(wptInfos)); + } + }, + { + title: 'deselectAllPoints', + icon: "fas fa-trash-alt", + visible: () => { + return this._pointsSelection.count() > 0; + }, + action: () => { + this.unselectAllPoints(); + } + }, + ]; - constructor(private translationService: TranslateService) { + constructor(private translationService: TranslateService, + private urlBuilderService: UrlBuilderService) { } - public initChart(svgElement: ElementRef): void { + public initChart(svgElement: ElementRef, pointSelectionErrorHandler: Function): void { + this._pointSelectionErrorHandler = pointSelectionErrorHandler; + let data: TimeSeries[] = [new TimeSeries()]; let chart: D3Selection = this.createChart(svgElement); let xScale: D3ScaleTime = this.getXScale(data); @@ -105,31 +246,47 @@ export class LineChartService { return; } + this._pointsSelection = new PointsSelection(); + this._contextMenuPoint = null; + this._xAxisCluster = {}; + let data: TimeSeries[] = this.prepareData(incomingData); let chart: D3Selection = d3Select('g#time-series-chart-drawing-area'); let xScale: D3ScaleTime = this.getXScale(data); let yScale: D3ScaleLinear = this.getYScale(data); -this.calculateLegendDimensions(); + this.calculateLegendDimensions(); d3Select('osm-time-series-line-chart').transition().duration(500).style('visibility', 'visible'); d3Select('svg#time-series-chart').transition().duration(500).attr('height', this._height + this._legendGroupHeight + this._margin.top + this._margin.bottom); d3Select('.x-axis').transition().call(this.updateXAxis, xScale); d3Select('.y-axis').transition().call(this.updateYAxis, yScale, this._width, this._margin); - this.brush = d3BrushX().extent([[0, 0], [this._width, this._height]]); - this.addBrush(chart, xScale, yScale, data); this.addLegendsToChart(chart, incomingData); this.setSummaryLabel(chart, incomingData.summaryLabels); this.addDataLinesToChart(chart, xScale, yScale, data); + this.drawAllSelectedPoints(); + this.resizeChartBackground(); this.bringMouseMarkerToTheFront(xScale, yScale); } + private resizeChartBackground() { + d3Select('.char-background') + .attr('width', this._width) + .attr('height', this._height); + this._mouseEventCatcher + .attr('width', this._width) + .attr('height', this._height); + } + private bringMouseMarkerToTheFront(xScale: D3ScaleTime, yScale: D3ScaleLinear) { - const markerGroup = d3Select('#marker-group').remove(); - d3Select('#time-series-chart-drawing-area').append(() => { - return markerGroup.node(); - }); - this._mouseEventCatcher.on('mousemove', (_, index, nodes: D3ContainerElement[]) => this.moveMarker(nodes[index], xScale, yScale, this._height)); + const markerLine = d3Select('.marker-line').remove(); + d3Select('#time-series-chart-drawing-area') + .append(() => markerLine.node()); + + this._mouseEventCatcher + .on('mousemove', (_, index, nodes: D3ContainerElement[]) => { + this.moveMarker(nodes[index], xScale, yScale, this._height) + }); } /** @@ -171,6 +328,7 @@ this.calculateLegendDimensions(); lineChartDataPoint.date = parseDate(point.date); lineChartDataPoint.value = point.value; lineChartDataPoint.tooltipText = data.identifier + ' : '; + lineChartDataPoint.wptInfo = point.wptInfo; return lineChartDataPoint; }); @@ -480,7 +638,6 @@ this.calculateLegendDimensions(); return yScale(point.value); }) // ... and for the Y-Coordinate // .curve(d3CurveMonotoneX); // smooth the line - } /** @@ -493,10 +650,10 @@ this.calculateLegendDimensions(); // Remove after resize chart.selectAll('.line').remove(); // Create one group per line / data entry - chart.selectAll('.line') // Get all lines already drawn + chart.select('.mouse-event-catcher') + .selectAll('.line') // Get all lines already drawn .data(data, (timeSeries: TimeSeries) => timeSeries.key) // ... for this data - .join( - enter => { + .join(enter => { this.addDataPointsToXAxisCluster(enter); const lineSelection: any = this.drawLine(enter, xScale, yScale); @@ -535,56 +692,17 @@ this.calculateLegendDimensions(); }); } - private addBrush(chart: D3Selection, - xScale: D3ScaleTime, - yScale: D3ScaleLinear, - data: TimeSeries[]): void { - chart.selectAll('.brush') - .remove(); - this.brush.on('end', () => this.updateChart(chart, xScale, yScale)); - chart.append('g') - .attr('class', 'brush') - .data([1]) - .call(this.brush) - .on('dblclick', () => { - xScale.domain([this.getMinDate(data), this.getMaxDate(data)]); - this.resetChart(xScale, yScale); - }); - } - - private resetChart(xScale: D3ScaleTime, yScale: D3ScaleLinear) { - d3Select('.x-axis').transition().call(this.updateXAxis, xScale); - d3Select('g#time-series-chart-drawing-area').selectAll('.line') - .each((data, index, nodes) => { - d3Select(nodes[index]) - .attr('d', (dataItem: TimeSeries) => this.getLineGenerator(xScale, yScale)(dataItem.values)); - }) + private drawAllSelectedPoints() { + this.drawSelectedPoints(d3SelectAll(".dot")); } - private updateChart(selection: any, xScale: D3ScaleTime, yScale: D3ScaleLinear) { - // selected boundaries - let extent = d3Event.selection; - // If no selection, back to initial coordinate. Otherwise, update X axis domain - if (!extent) { - return - } else { - let minDate = xScale.invert(extent[0]); - let maxDate = xScale.invert(extent[1]); - xScale.domain([minDate, maxDate]); - selection.select(".brush").call(this.brush.move, null); // This remove the grey brush area - d3Select('.x-axis').transition().call(this.updateXAxis, xScale); - selection.selectAll('.line').each((_, index, nodes) => { - d3Select(nodes[index]) - .transition() - .attr('d', (dataItem: TimeSeries) => { - let newDataValues = dataItem.values.filter((point) => { - return point.date <= maxDate && point.date >= minDate; - }); - return this.getLineGenerator(xScale, yScale)(newDataValues); - }) - } - ) - } + private drawSelectedPoints(dotsToCheck: D3Selection) { + dotsToCheck.each((currentDotData: TimeSeriesPoint, index: number, dots: D3BaseType[]) => { + const isDotSelected = this._pointsSelection.isPointSelected(currentDotData); + if (isDotSelected) { + d3Select(dots[index]).style("opacity", 1) + } + }) } private drawLine(selection: any, @@ -596,6 +714,7 @@ this.calculateLegendDimensions(); .attr('class', (timeSeries: TimeSeries) => 'line line-' + timeSeries.key) .style('opacity', '0') .append('path') // Draw one path for every item in the data set + .style('pointer-events', 'none') .attr('fill', 'none') .attr('stroke-width', 1.5) .attr('d', (dataItem: TimeSeries) => { @@ -619,7 +738,6 @@ this.calculateLegendDimensions(); lineGroups.each((timeSeries: TimeSeries, index: number, nodes: D3BaseType[]) => { const lineGroup = d3Select(nodes[index]); - // TODO: Some dots are group into the wrong parent line group (and in turn do have the wrong color!) and I fucking don't know why! lineGroup .append('g') .selectAll('.dot-' + timeSeries.key) @@ -627,34 +745,70 @@ this.calculateLegendDimensions(); .enter() .append('circle') .attr('class', (dot: TimeSeriesPoint) => 'dot dot-' + timeSeries.key + ' dot-x-' + xScale(dot.date).toString().replace('.', '_')) - .style('opacity', '0') + .style('opacity', 0) .attr('r', this.DOT_RADIUS) - .attr('cx', (dot: TimeSeriesPoint) => { - return xScale(dot.date); - }) - .attr('cy', (dot: TimeSeriesPoint) => { - return yScale(dot.value); - }) + .attr('cx', (dot: TimeSeriesPoint) => xScale(dot.date)) + .attr('cy', (dot: TimeSeriesPoint) => yScale(dot.value)) }); } + private hideMarker() { + d3Select('.marker-line').style('opacity', 0); + d3Select('#marker-tooltip').style('opacity', 0); + this.hideOldDotsOnMarker(); + } + private addMouseMarkerToChart(chart: D3Selection, xScale: D3ScaleTime, data: TimeSeries[]): void { - let markerGroup = chart.append('g').attr('id', 'marker-group'); + let markerGroup = chart; - // Append the marker line, initially hidden - markerGroup.append('path') - .attr('class', 'marker-line') - .style('opacity', '0'); + this._contextMenuBackground = d3Select('body') + .selectAll('.d3-context-menu-background') + .data([1]) + .enter() + .append('div') + .attr('class', 'd3-context-menu-background') + .on('click', () => { + this.closeContextMenu(); + }).on('contextmenu', () => { + this.closeContextMenu(); + }, false); + + this._contextMenu = d3Select('body') + .selectAll('.d3-context-menu') + .data([1]) + .enter() + .append('rect') + .attr('class', 'd3-context-menu') + .on('contextmenu', () => d3Event.preventDefault()); + + const showMarker = () => { + d3Select('.marker-line').style('opacity', 1); + d3Select('#marker-tooltip').style('opacity', 1); + }; // Watcher for mouse events - this._mouseEventCatcher = markerGroup.append('svg:rect') + this._mouseEventCatcher = markerGroup.append('svg:g') + .attr('class', 'mouse-event-catcher') .attr('width', this._width) .attr('height', this._height) .attr('fill', 'none') - .attr('pointer-events', 'all'); + .on('mouseenter', () => showMarker()) + .on('mouseleave', () => this.hideMarker()) + .on("contextmenu", () => d3Event.preventDefault()); - this._mouseEventCatcher.on('mouseover', this.showMarker); - this._mouseEventCatcher.on('mouseout', this.hideMarker); + this._mouseEventCatcher.append('svg:rect') + .attr('class', 'char-background') + .attr('width', this._width) + .attr('height', this._height) + .attr('fill', 'none') + .style('pointer-events', 'visible') + .on('contextmenu', this.showContextMenu(this.backgroundContextMenu)); + + // Append the marker line, initially hidden + markerGroup.append('path') + .attr('class', 'marker-line') + .style('opacity', '0') + .style('pointer-events', 'none'); this.addTooltipBoxToChart(); } @@ -667,14 +821,20 @@ this.calculateLegendDimensions(); .style('opacity', '0.9'); } - private showMarker() { - d3Select('.marker-line').style('opacity', '1'); - } + private hideOldDotsOnMarker() { + if (this._dotsOnMarker) { + const contextMenuPointData = this._contextMenuPoint && this._contextMenuPoint.data()[0]; - private hideMarker() { - d3Select('.marker-line').style('opacity', '0'); - d3SelectAll('.dot').style('opacity', '0'); - } + this._dotsOnMarker + .filter((s: TimeSeriesPoint) => !s.equals(contextMenuPointData)) + .attr('r', this.DOT_RADIUS) + .style('opacity', '0') + .style('cursor', 'auto') + .on('click', null) + .on('contextmenu', null); + this.drawSelectedPoints(this._dotsOnMarker); + } + }; private moveMarker(node: D3ContainerElement, xScale: D3ScaleTime, yScale: D3ScaleLinear, containerHeight: number) { const mouseCoordinates = d3Mouse(node); @@ -705,6 +865,7 @@ this.calculateLegendDimensions(); pointX = pointXBefore; point = firstPointFromClusterBefore; } + //draw marker line d3Select('.marker-line') .attr('d', function () { let d = "M" + pointX + "," + containerHeight; @@ -712,19 +873,150 @@ this.calculateLegendDimensions(); return d; }); - const visibleDots = this.findDotsOnMarkerAndShow(pointX, xScale); + const findDotsOnMarker = (pointX: number) => { + const cx = pointX.toString().replace('.', '_'); + return d3SelectAll('.dot-x-' + cx); + }; + + const showDotsOnMarker = (dotsOnMarker: D3Selection) => { + //show dots on marker + dotsOnMarker + .style('opacity', '1') + .style('fill', 'white') + .style('cursor', 'pointer') + .on("contextmenu", this.showContextMenu(this.contextMenu)) + .on("click", (dotData: TimeSeriesPoint) => { + d3Event.preventDefault(); + if (d3Event.metaKey || d3Event.ctrlKey) { + this.changePointSelection(dotData); + } else { + window.open(this.urlBuilderService + .buildUrlByOption(dotData.wptInfo, this.urlBuilderService.options.waterfall)); + } + }); + }; + + this.hideOldDotsOnMarker(); + const dotsOnMarker = findDotsOnMarker(pointX); + showDotsOnMarker(dotsOnMarker); + + this._dotsOnMarker = dotsOnMarker; const mouseY = mouseCoordinates[1]; - const nearestDot = this.highlightNearestDot(visibleDots, mouseY, yScale); - this.showTooltip(nearestDot, visibleDots, point.date); + const nearestDot = this.highlightNearestDot(dotsOnMarker, mouseY, yScale); + this.showTooltip(nearestDot, dotsOnMarker, point.date); } - private findDotsOnMarkerAndShow(pointX: number, xScale: D3ScaleTime) { - // Hide all dots before showing the current ones - d3SelectAll('.dot').attr('r', this.DOT_RADIUS).style('opacity', '0'); + private showContextMenu(menu: ContextMenuPosition[]) { + // this gets executed when a contextmenu event occurs + return (data, currentIndex, viewElements) => { + const selectedNode = viewElements[currentIndex]; + this._contextMenuPoint = d3Select(selectedNode); + + const visibleMenuElements = menu.filter(elem => { + //visible is optional value, so even without this property the element is visible + return (elem.visible == undefined) || (elem.visible(data, currentIndex, selectedNode)); + }); + + if (visibleMenuElements.length == 0) { + //do not show empty context menu + return; + } - const cx = pointX.toString().replace('.', '_'); - return d3SelectAll('.dot-x-' + cx).style('opacity', '1'); + const background = this._contextMenuBackground.html(''); + const contextMenu = this._contextMenu.html(''); + const contextMenuPositions = contextMenu + .selectAll('li') + .data(visibleMenuElements) + .enter() + .append('li'); + + const clickListener = (e: ContextMenuPosition) => { + e.action(data, currentIndex, selectedNode); + this.closeContextMenu(); + }; + + contextMenuPositions.each((ctxMenuPositionData: ContextMenuPosition, ctxMenuPositionIndex, ctxMenuPositions) => { + const currentMenuPosition = d3Select(ctxMenuPositions[ctxMenuPositionIndex]); + if (ctxMenuPositionData.divider) { + currentMenuPosition + .attr('class', 'd3-context-menu-divider') + .on('contextmenu', () => d3Event.preventDefault()); + } else { + currentMenuPosition.append('i').attr('class', (d: ContextMenuPosition) => d.icon); + currentMenuPosition.append('span').html((d: ContextMenuPosition) => { + return this.translationService.instant("frontend.de.iteratec.chart.contextMenu." + d.title); + }); + currentMenuPosition + .on('click', clickListener) + .on('contextmenu', clickListener); + } + }); + + // display context menu + background.style('display', 'block'); + contextMenu.style('display', 'block'); + + //context menu must be displayed to take its width + const contextMenuWidth = (this._contextMenu.node()).offsetWidth; + const left = ((d3Event.pageX + contextMenuWidth + 40) < window.innerWidth) ? (d3Event.pageX) : (d3Event.pageX - contextMenuWidth); + + //move context menu + contextMenu + .style('left', left + 'px') + .style('top', (d3Event.pageY - 2) + 'px'); + + d3Event.preventDefault(); + }; + }; + + private closeContextMenu() { + d3Event.preventDefault(); + this._contextMenuBackground.style('display', 'none'); + this._contextMenu.style('display', 'none'); + + //hide context menu point + this._contextMenuPoint = null; + this.hideOldDotsOnMarker(); + }; + + private changePointSelection(point: TimeSeriesPoint) { + let canPointBeSelected = true; + if(this._pointsSelection.count() > 0) { + const testServerUrl = this._pointsSelection.getFirst().wptInfo.baseUrl; + if(point.wptInfo.baseUrl != testServerUrl) { + canPointBeSelected = false; + } + } + + if(!canPointBeSelected) { + this._pointSelectionErrorHandler(); + return; + } + + if (this._pointsSelection.isPointSelected(point)) { + this._pointsSelection.unselectPoint(point); + } else { + this._pointsSelection.selectPoint(point); + } + this.drawAllSelectedPoints(); + } + + private unselectAllPoints() { + const arePointEqual = (t1: TimeSeriesPoint, t2: TimeSeriesPoint) => { + return t1.tooltipText == t2.tooltipText && t1.date.getTime() == t2.date.getTime(); + }; + + d3SelectAll(".dot").each((currentDotData: TimeSeriesPoint, index: number, dots: D3BaseType[]) => { + const wasDotSelected = this._pointsSelection.isPointSelected(currentDotData); + const isDotOnMarkerLine = this._dotsOnMarker.data().some((elem: TimeSeriesPoint) => { + return arePointEqual(currentDotData, elem); + }); + if (wasDotSelected && !isDotOnMarkerLine) { + d3Select(dots[index]).style("opacity", "0"); + } + }); + this._pointsSelection.unselectAll(); } private highlightNearestDot(visibleDots: D3Selection, mouseY: number, yScale: D3ScaleLinear) { @@ -744,7 +1036,6 @@ this.calculateLegendDimensions(); private showTooltip(nearestDot: D3Selection, visibleDots: D3Selection, highlightedDate: Date) { const tooltip = d3Select('#marker-tooltip'); - const svg = d3Select('#time-series-chart'); const tooltipText = this.generateTooltipText(nearestDot, visibleDots, highlightedDate); tooltip.html(tooltipText.outerHTML); @@ -757,25 +1048,24 @@ this.calculateLegendDimensions(); (nearestDotXPosition - tooltipWidth + this._margin.right + 10) : nearestDotXPosition + this._margin.left + 50; tooltip.style('top', top + 'px'); tooltip.style('left', left + 'px'); - } private generateTooltipText(nearestDot: D3Selection, visibleDots: D3Selection, highlightedDate: Date): HTMLTableElement { + const nearestDotData = nearestDot.datum() as TimeSeriesPoint; + const table: HTMLTableElement = document.createElement('table'); const tableBody: HTMLTableSectionElement = document.createElement('tbody'); table.append(tableBody); - tableBody.append(this.generateTooltipTimestampRow(highlightedDate)); + const tempArray = []; visibleDots - .sort((a: TimeSeriesPoint, b: TimeSeriesPoint) => { - if (a.value > b.value) return -1; - else if (a.value < b.value) return 1; - else return 0; - }) .each((timeSeriesPoint: TimeSeriesPoint, index: number, nodes: D3BaseType[]) => { - tableBody.append(this.generateTooltipDataPointRow(timeSeriesPoint, nodes[index], nearestDot)); + tempArray.push({"value": timeSeriesPoint.value, "htmlNode": this.generateTooltipDataPointRow(timeSeriesPoint, nodes[index], nearestDotData)}); }); + tempArray + .sort((a, b) => b.value - a.value) + .forEach(elem => table.append(elem.htmlNode)); return table; } @@ -793,23 +1083,21 @@ this.calculateLegendDimensions(); return row; } - private generateTooltipDataPointRow(timeSeriesPoint: TimeSeriesPoint, node: D3BaseType, nearestDot: D3Selection): string | Node { - const nodeSelection = d3Select(node); - + private generateTooltipDataPointRow(currentPoint: TimeSeriesPoint, node: D3BaseType, nearestDotData: TimeSeriesPoint): string | Node { const label: HTMLTableCellElement = document.createElement('td'); - label.append(timeSeriesPoint.tooltipText); + label.append(currentPoint.tooltipText); const value: HTMLTableCellElement = document.createElement('td'); const lineColorDot: HTMLElement = document.createElement('i'); lineColorDot.className = 'fas fa-circle'; - lineColorDot.style.color = nodeSelection.style('stroke'); + lineColorDot.style.color = d3Select(node).style('stroke'); value.append(lineColorDot); - if (timeSeriesPoint.value !== undefined && timeSeriesPoint.value !== null) { - value.append(timeSeriesPoint.value.toString()); + if (currentPoint.value !== undefined && currentPoint.value !== null) { + value.append(currentPoint.value.toString()); } const row: HTMLTableRowElement = document.createElement('tr'); - if (nodeSelection.node() === nearestDot.node()) { + if (currentPoint.equals(nearestDotData)) { row.className = 'active'; } row.append(label); @@ -893,3 +1181,11 @@ this.calculateLegendDimensions(); this.drawLineChart(incomingData); } } + +class ContextMenuPosition { + title?: string; + icon?: string; + visible?: (d: TimeSeriesPoint, i: number, elem) => boolean; + action?: (d: TimeSeriesPoint, i: number, elem) => void; + divider?: boolean = false; +} diff --git a/frontend/src/app/modules/time-series/services/url-builder.service.ts b/frontend/src/app/modules/time-series/services/url-builder.service.ts new file mode 100644 index 0000000000..43d78595b3 --- /dev/null +++ b/frontend/src/app/modules/time-series/services/url-builder.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import {WptInfo} from "../models/wpt-info.model"; + +@Injectable({ + providedIn: 'root' +}) +export class UrlBuilderService { + + options = { + waterfall: new UrlOption("details", "waterfall_view_step"), + performanceReview: new UrlOption("performance_optimization", "review_step"), + contentBreakdown: new UrlOption("breakdown", "breakdown_fv_step"), + domains: new UrlOption("domains", "breakdown_fv_step"), + screenshot: new UrlOption("screen_shot", "step_"), + }; + + buildSummaryUrl(wptInfo: WptInfo): string { + return `${wptInfo.baseUrl}result/${wptInfo.testId}/#run${wptInfo.runNumber}_step${wptInfo.indexInJourney}`; + } + + buildUrlByOption(wptInfo: WptInfo, option: UrlOption): string { + return `${wptInfo.baseUrl}result/${wptInfo.testId}/${wptInfo.runNumber}/${option.pathArgName}/#${option.stepArgName}${wptInfo.indexInJourney}`; + } + + buildFilmstripUrl(wptInfo: WptInfo): string { + const wptInfoAsTestUrlData = this.wptInfoToTestUrlData(wptInfo); + return `${wptInfo.baseUrl}video/compare.php?tests=${wptInfoAsTestUrlData}&ival=100&end=full&sticky=true`; + } + + buildFilmstripToolUrl(wptInfo: WptInfo): string { + const filmstripUrl = `${wptInfo.baseUrl}&testId=${wptInfo.testId}&view=filmstrip&step=${wptInfo.indexInJourney}`; + return `https://iteratec.github.io/wpt-filmstrip/#wptUrl=${filmstripUrl}` + } + + buildFilmstripsComparisionUrl(wptInfos: WptInfo[]): string { + //baseUrl for every point must be the same + const baseUrl = wptInfos[0].baseUrl; + + const testsDataString = wptInfos.map((info: WptInfo) => this.wptInfoToTestUrlData(info)).join(','); + return `${baseUrl}video/compare.php?tests=${testsDataString}&ival=100&end=full&sticky=true`; + } + + private wptInfoToTestUrlData(wptInfo: WptInfo): string { + return `${wptInfo.testId}-r:${wptInfo.runNumber}-c:0-s:${wptInfo.indexInJourney}`; + } +} + +class UrlOption { + pathArgName: string; + stepArgName: string; + + constructor(pathArgName: string, stepArgName: string) { + this.pathArgName = pathArgName; + this.stepArgName = stepArgName; + } +} diff --git a/frontend/src/app/modules/time-series/time-series.component.scss b/frontend/src/app/modules/time-series/time-series.component.scss index e69de29bb2..7aa3bd12c2 100644 --- a/frontend/src/app/modules/time-series/time-series.component.scss +++ b/frontend/src/app/modules/time-series/time-series.component.scss @@ -0,0 +1,67 @@ +.d3-context-menu-background { + width: 100%; + height: 100%; + display: none; + position: fixed; + z-index: 1; + top: 0; + left: 0; +} + +.d3-context-menu { + position: absolute; + display: none; + min-width: 13em; + max-width: 26em; + padding: .25em 0; + margin: .3em; + font-family: inherit; + font-size: 14px; + list-style-type: none; + background: #ffffff; + border: 1px solid #bebebe; + border-radius: .2em; + box-shadow: 0 2px 5px rgba(0,0,0,.5); + cursor: default; + z-index:1200; + pointer-events: visible; + + li { + position: relative; + box-sizing: content-box; + padding: .2em 2em; + user-select: none; + + background-color: #ffffff; + color: #000000; + + i { + position: absolute; + top: .3em; + left: .5em; + + color: #2980b9; + } + } + + li:hover { + background-color: #2980b9; + color: #ffffff; + cursor: pointer; + + i { + color: #ffffff; + } + } + + .d3-context-menu-divider { + padding: 0; + margin: .35em 0; + border-bottom: 1px solid #e6e6e6; + cursor: default; + } + + .d3-context-menu-divider:hover { + cursor: default; + } +} diff --git a/frontend/src/app/modules/time-series/time-series.component.ts b/frontend/src/app/modules/time-series/time-series.component.ts index 97e7e836e7..15c7ae2c60 100644 --- a/frontend/src/app/modules/time-series/time-series.component.ts +++ b/frontend/src/app/modules/time-series/time-series.component.ts @@ -1,4 +1,4 @@ -import {Component, OnInit} from '@angular/core'; +import {Component, OnInit, ViewEncapsulation} from '@angular/core'; import {URL} from "../../enums/url.enum"; import {LinechartDataService} from "./services/linechart-data.service"; import {ResultSelectionStore} from "../result-selection/services/result-selection.store"; @@ -8,7 +8,10 @@ import {BehaviorSubject} from 'rxjs'; @Component({ selector: 'osm-time-series', templateUrl: './time-series.component.html', - styleUrls: ['./time-series.component.scss'] + styleUrls: ['./time-series.component.scss'], + + //used to render context menu with styles from time-series.component.scss file + encapsulation: ViewEncapsulation.None }) export class TimeSeriesComponent implements OnInit { diff --git a/grails-app/i18n/messages.properties b/grails-app/i18n/messages.properties index 971f65e9d3..5089fb41d3 100644 --- a/grails-app/i18n/messages.properties +++ b/grails-app/i18n/messages.properties @@ -106,7 +106,7 @@ de.iteratec.chart.contextMenu.waterfall=Waterfall de.iteratec.chart.contextMenu.performanceReview=Performance Review de.iteratec.chart.contextMenu.contentBreakdown=Content Breakdown de.iteratec.chart.contextMenu.domains=Domains -de.iteratec.chart.contextMenu.screenshot=Screen Shot +de.iteratec.chart.contextMenu.screenshot=Screenshot de.iteratec.chart.contextMenu.filmstrip=Filmstrip de.iteratec.chart.contextMenu.filmstripTool=Filmstrip Overview de.iteratec.chart.contextMenu.compareFilmstrips=Compare Filmstrips @@ -1277,6 +1277,20 @@ frontend.de.iteratec.osm.barchart.filter.noFilterAsc=Ascending frontend.de.iteratec.osm.barchart.filter.label=Filter frontend.de.iteratec.osm.barchart.filter.customerJourneyHeader=Customer Journey frontend.de.iteratec.osm.timeSeries.loadTimes=Load times +frontend.de.iteratec.chart.contextMenu.summary=Summary +frontend.de.iteratec.chart.contextMenu.waterfall=Waterfall +frontend.de.iteratec.chart.contextMenu.performanceReview=Performance Review +frontend.de.iteratec.chart.contextMenu.contentBreakdown=Content Breakdown +frontend.de.iteratec.chart.contextMenu.domains=Domains +frontend.de.iteratec.chart.contextMenu.screenshot=Screenshot +frontend.de.iteratec.chart.contextMenu.filmstrip=Filmstrip +frontend.de.iteratec.chart.contextMenu.filmstripTool=Filmstrip Overview +frontend.de.iteratec.chart.contextMenu.compareFilmstrips=Compare Filmstrips +frontend.de.iteratec.chart.contextMenu.selectPoint=Select Point +frontend.de.iteratec.chart.contextMenu.deselectPoint=Deselect Point +frontend.de.iteratec.chart.contextMenu.deselectAllPoints=Deselect all Points +frontend.de.iteratec.chart.errorHeader=Error +frontend.de.iteratec.chart.datapointSelection.error.multipleServer=Comparison of the filmstrips is only possible for measurements on the same server. frontend.de.iteratec.osm.timeSeries.chart.label.measurand=Measurand frontend.de.iteratec.osm.timeSeries.chart.label.application=Application frontend.de.iteratec.osm.timeSeries.chart.label.measuredEvent=Measured step diff --git a/grails-app/i18n/messages_de.properties b/grails-app/i18n/messages_de.properties index 13a7b17883..65381dea6d 100644 --- a/grails-app/i18n/messages_de.properties +++ b/grails-app/i18n/messages_de.properties @@ -1251,6 +1251,20 @@ frontend.de.iteratec.osm.barchart.filter.noFilterAsc=Aufsteigend frontend.de.iteratec.osm.barchart.filter.label=Filtern frontend.de.iteratec.osm.barchart.filter.customerJourneyHeader=Customer Journey frontend.de.iteratec.osm.timeSeries.loadTimes=Ladezeiten +frontend.de.iteratec.chart.contextMenu.summary=Zusammenfassung +frontend.de.iteratec.chart.contextMenu.waterfall=Wasserfall +frontend.de.iteratec.chart.contextMenu.performanceReview=Performance Bericht +frontend.de.iteratec.chart.contextMenu.contentBreakdown=Inhaltsanalyse +frontend.de.iteratec.chart.contextMenu.domains=Domains +frontend.de.iteratec.chart.contextMenu.screenshot=Bildschirmfoto +frontend.de.iteratec.chart.contextMenu.filmstrip=Filmstreifen +frontend.de.iteratec.chart.contextMenu.filmstripTool=Filmstreifen Übersicht +frontend.de.iteratec.chart.contextMenu.compareFilmstrips=Filmstreifen vergleichen +frontend.de.iteratec.chart.contextMenu.selectPoint=Punkt auswählen +frontend.de.iteratec.chart.contextMenu.deselectPoint=Punkt abwählen +frontend.de.iteratec.chart.contextMenu.deselectAllPoints=Alle Punkte abwählen +frontend.de.iteratec.chart.errorHeader=Fehler +frontend.de.iteratec.chart.datapointSelection.error.multipleServer=Vergleich der Filmstreifen ist nur für Messungen des gleichen Servers möglich. frontend.de.iteratec.osm.timeSeries.chart.label.measurand=Messgröße frontend.de.iteratec.osm.timeSeries.chart.label.application=Anwendung frontend.de.iteratec.osm.timeSeries.chart.label.measuredEvent=Messschritt diff --git a/grails-app/services/de/iteratec/osm/linechart/LineChartTimeSeriesService.groovy b/grails-app/services/de/iteratec/osm/linechart/LineChartTimeSeriesService.groovy index 562d61c895..ed058e21d9 100644 --- a/grails-app/services/de/iteratec/osm/linechart/LineChartTimeSeriesService.groovy +++ b/grails-app/services/de/iteratec/osm/linechart/LineChartTimeSeriesService.groovy @@ -3,6 +3,7 @@ package de.iteratec.osm.linechart import de.iteratec.osm.measurement.environment.Location import de.iteratec.osm.measurement.schedule.ConnectivityProfile import de.iteratec.osm.measurement.schedule.JobGroup +import de.iteratec.osm.report.chart.WptEventResultInfo import de.iteratec.osm.result.CachedView import de.iteratec.osm.result.MeasuredEvent import de.iteratec.osm.result.PerformanceAspectType @@ -165,6 +166,12 @@ class LineChartTimeSeriesService { identifier = addToIdentifier(connectivity?.name, identifier) } + TimeSeriesDataPointWptInfo wptInfo = new TimeSeriesDataPointWptInfo( + baseUrl: eventResultProjection.wptServerBaseurl, + testId: eventResultProjection.testId, + runNumber: eventResultProjection.numberOfWptRun, + indexInJourney: eventResultProjection.oneBasedStepIndexInJourney) + measurands.each { measurand -> String dataBaseRelevantName = measurand.getDatabaseRelevantName() String measurandName = measurand.getName() @@ -173,7 +180,7 @@ class LineChartTimeSeriesService { if ((measurands.size() + performanceAspectTypes.size()) > 1) { identifierMeasurand = addMeasurandToIdentifier(measurandName, identifier) } - buildSeries(value, identifierMeasurand, date, measurandName, jobGroup, measuredEvent, location, + buildSeries(value, identifierMeasurand, date, wptInfo, measurandName, jobGroup, measuredEvent, location, connectivity, timeSeriesChartDTO) } @@ -183,7 +190,7 @@ class LineChartTimeSeriesService { if ((measurands.size() + performanceAspectTypes.size()) > 1) { identifierAspect = addMeasurandToIdentifier(performanceAspectType.toString(), identifier) } - buildSeries(value, identifierAspect, date, performanceAspectType.toString(), jobGroup, + buildSeries(value, identifierAspect, date, wptInfo, performanceAspectType.toString(), jobGroup, measuredEvent, location, connectivity, timeSeriesChartDTO) } } @@ -196,7 +203,7 @@ class LineChartTimeSeriesService { return timeSeriesChartDTO } - private void buildSeries(Double value, String identifier, Date date, String measurandName, JobGroup jobGroup, + private void buildSeries(Double value, String identifier, Date date, TimeSeriesDataPointWptInfo wptInfo, String measurandName, JobGroup jobGroup, MeasuredEvent measuredEvent, Location location, ConnectivityProfile connectivity, TimeSeriesChartDTO timeSeriesChartDTO) { TimeSeries timeSeries = timeSeriesChartDTO.series.find({ it.identifier == identifier }) @@ -211,7 +218,7 @@ class LineChartTimeSeriesService { ) timeSeriesChartDTO.series.add(timeSeries) } - TimeSeriesDataPoint timeSeriesDataPoint = new TimeSeriesDataPoint(date: date, value: value) + TimeSeriesDataPoint timeSeriesDataPoint = new TimeSeriesDataPoint(date: date, value: value, wptInfo: wptInfo) timeSeries.data.add(timeSeriesDataPoint) } diff --git a/grails-app/services/de/iteratec/osm/result/EventResultDashboardService.groovy b/grails-app/services/de/iteratec/osm/result/EventResultDashboardService.groovy index dd47f09147..8b821de249 100644 --- a/grails-app/services/de/iteratec/osm/result/EventResultDashboardService.groovy +++ b/grails-app/services/de/iteratec/osm/result/EventResultDashboardService.groovy @@ -328,7 +328,7 @@ public class EventResultDashboardService { return chartPointsForEachGraph } - private getChartPointsWptInfos(EventResultProjection eventResult) { + public getChartPointsWptInfos(EventResultProjection eventResult) { String serverBaseUrl = eventResult.wptServerBaseurl String testId = eventResult.testId Integer numberOfWptRun = eventResult.numberOfWptRun diff --git a/src/main/groovy/de/iteratec/osm/linechart/TimeSeriesDataPoint.groovy b/src/main/groovy/de/iteratec/osm/linechart/TimeSeriesDataPoint.groovy index 5507598c50..1ea5d245a1 100644 --- a/src/main/groovy/de/iteratec/osm/linechart/TimeSeriesDataPoint.groovy +++ b/src/main/groovy/de/iteratec/osm/linechart/TimeSeriesDataPoint.groovy @@ -4,4 +4,5 @@ class TimeSeriesDataPoint { Date date = new Date() Double value = 0.0 String agent = "" + TimeSeriesDataPointWptInfo wptInfo = null } diff --git a/src/main/groovy/de/iteratec/osm/linechart/TimeSeriesDataPointWptInfo.groovy b/src/main/groovy/de/iteratec/osm/linechart/TimeSeriesDataPointWptInfo.groovy new file mode 100644 index 0000000000..97be448c67 --- /dev/null +++ b/src/main/groovy/de/iteratec/osm/linechart/TimeSeriesDataPointWptInfo.groovy @@ -0,0 +1,8 @@ +package de.iteratec.osm.linechart + +class TimeSeriesDataPointWptInfo { + String baseUrl = "" + String testId = "" + Integer runNumber = 0 + Integer indexInJourney = 0 +}