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
+}