diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bc90f31603..023b606196 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8511,9 +8511,9 @@ "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" }, "rxjs": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", - "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz", + "integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==", "requires": { "tslib": "^1.9.0" } diff --git a/frontend/package.json b/frontend/package.json index 041275e314..afa5cb4c25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "d3": "^5.11.0", "ng-pick-datetime": "^7.0.0", "ngx-smart-modal": "^7.1.1", - "rxjs": "^6.4.0", + "rxjs": "^6.5.0", "spin.js": "^4.0.0", "zone.js": "^0.9.0" }, diff --git a/frontend/src/app/enums/url.enum.ts b/frontend/src/app/enums/url.enum.ts index 38f322e451..9653caba3d 100644 --- a/frontend/src/app/enums/url.enum.ts +++ b/frontend/src/app/enums/url.enum.ts @@ -10,5 +10,6 @@ export enum URL { RESULT_COUNT = '/resultSelection/getResultCount', AGGREGATION_BARCHART_DATA = '/aggregation/getBarchartData', EVENT_RESULT_DASHBOARD_LINECHART_DATA = '/eventResultDashboard/getLinechartData', + EVENTS = '/rest/events', DISTRIBUTION_VIOLINCHART_DATA = '/distributionChart/getViolinchartData' } diff --git a/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.scss b/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.scss index 95007d4c78..48d07832c8 100644 --- a/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.scss +++ b/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.scss @@ -31,6 +31,18 @@ osm-time-series-line-chart { } } + #event-marker-tooltip { + position: absolute; + text-align: left; + font: 10pt sans-serif; + color: white; + background: rgba(0, 0, 0, 0.7); + border: 1px solid rgba(0, 0, 0, 0.4); + border-radius: 3px; + pointer-events: none; + max-width: 250px; + } + #time-series-chart-drawing-area { .axis { .domain { @@ -64,13 +76,53 @@ osm-time-series-line-chart { .marker-line { stroke: $color-contour-medium; stroke-width: 1px; - opacity: 0.7; + shape-rendering: crispEdges; } circle.dot { stroke-width: 2px; fill: white; } + + circle.event-marker-dot { + fill: $color-contour-light; + opacity: 0.9; + } + + circle.selected-event-marker-dot { + fill: $color-contour-medium; + } + + .event-marker-line { + stroke: $color-contour-medium; + stroke-width: 1px; + shape-rendering: crispEdges; + } + + .unselected-event-marker-line { + opacity: 0; + } + + .selected-event-marker-line { + opacity: 1; + } + + circle.event-marker-dot:hover { + fill: $color-contour-medium; + opacity: 0.7; + } + + .event-time-line { + stroke: $color-contour-light; + stroke-width: 1px; + shape-rendering: crispEdges; + } + + .event-time-line-label { + font-size: 10px; + fill: $color-text-light; + opacity: 0.7; + } } } @@ -160,3 +212,25 @@ osm-time-series-line-chart { border-right: 0; } } + +.grid-container { + display: grid; +} + +.event-marker-tooltip-element { + padding: 0.2em 0.75em 0.2em 0.75em; +} + +.event-marker-tooltip-date { + margin-bottom: 10px; +} + +.event-marker-tooltip-title { + font-weight: bold; + margin: 0; +} + +.event-marker-tooltip-text { + margin: 0; + text-align: justify; +} diff --git a/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.ts b/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.ts index 9eba709d58..868b5a9607 100644 --- a/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.ts +++ b/frontend/src/app/modules/time-series/components/time-series-chart/time-series-chart.component.ts @@ -15,6 +15,8 @@ import {LineChartService} from '../../services/line-chart.service'; import {NgxSmartModalService} from 'ngx-smart-modal'; import {SpinnerService} from '../../../shared/services/spinner.service'; import {TranslateService} from '@ngx-translate/core'; +import {TimeEvent} from '../../models/event.model'; +import {TimeSeries} from '../../models/time-series.model'; @Component({ @@ -88,9 +90,11 @@ export class TimeSeriesChartComponent implements AfterContentInit, OnChanges { return; } - const timeSeries = this.lineChartService.prepareData(this.timeSeriesResults, this.selectedTrimValues); - this.lineChartService.prepareLegend(this.timeSeriesResults); - this.lineChartService.drawLineChart(timeSeries, this.timeSeriesResults.measurandGroups, + const timeSeries: { [key: string]: TimeSeries[] } = this.lineChartService.prepareData(this.timeSeriesResults, this.selectedTrimValues); + const eventData: TimeEvent[] = this.lineChartService.prepareEventsData(this.timeSeriesResults.events); + + this.lineChartService.prepareLegendData(this.timeSeriesResults); + this.lineChartService.drawLineChart(timeSeries, eventData, this.timeSeriesResults.measurandGroups, this.timeSeriesResults.summaryLabels, this.timeSeriesResults.numberOfTimeSeries, this.selectedTrimValues); this.spinnerService.hideSpinner('time-series-line-chart-spinner'); @@ -102,10 +106,12 @@ export class TimeSeriesChartComponent implements AfterContentInit, OnChanges { return; } - const timeSeries = this.lineChartService.prepareData(this.timeSeriesResults, this.selectedTrimValues); - this.lineChartService.drawLineChart(timeSeries, this.timeSeriesResults.measurandGroups, + const timeSeries: { [key: string]: TimeSeries[] } = this.lineChartService.prepareData(this.timeSeriesResults, this.selectedTrimValues); + const eventData: TimeEvent[] = this.lineChartService.prepareEventsData(this.timeSeriesResults.events); + + this.lineChartService.drawLineChart(timeSeries, eventData, this.timeSeriesResults.measurandGroups, this.timeSeriesResults.summaryLabels, this.timeSeriesResults.numberOfTimeSeries, this.selectedTrimValues); - this.lineChartService.restoreZoom(timeSeries, this.selectedTrimValues); + this.lineChartService.restoreZoom(timeSeries, this.selectedTrimValues, eventData); this.spinnerService.hideSpinner('time-series-line-chart-spinner'); } diff --git a/frontend/src/app/modules/time-series/models/event-result-data.model.ts b/frontend/src/app/modules/time-series/models/event-result-data.model.ts index 5c6d2a7c54..b21b0cb638 100644 --- a/frontend/src/app/modules/time-series/models/event-result-data.model.ts +++ b/frontend/src/app/modules/time-series/models/event-result-data.model.ts @@ -1,9 +1,11 @@ import {EventResultSeriesDTO} from './event-result-series.model'; +import {EventDTO} from './event.model'; import {SummaryLabel} from './summary-label.model'; export interface EventResultDataDTO { measurandGroups: { [key: string]: string }; series: EventResultSeriesDTO[]; + events: EventDTO[]; summaryLabels: SummaryLabel[]; numberOfTimeSeries: number; } @@ -11,12 +13,14 @@ export interface EventResultDataDTO { export class EventResultData implements EventResultDataDTO { measurandGroups: { [key: string]: string }; series: EventResultSeriesDTO[]; + events: EventDTO[]; summaryLabels: SummaryLabel[]; numberOfTimeSeries: number; constructor() { this.measurandGroups = {}; this.series = []; + this.events = []; this.summaryLabels = []; this.numberOfTimeSeries = 0; } diff --git a/frontend/src/app/modules/time-series/models/event.model.ts b/frontend/src/app/modules/time-series/models/event.model.ts new file mode 100644 index 0000000000..3933c67bc6 --- /dev/null +++ b/frontend/src/app/modules/time-series/models/event.model.ts @@ -0,0 +1,20 @@ +export interface EventDTO { + id: number; + eventDate: Date; + description: string; + shortName: string; +} + +export class TimeEvent implements EventDTO { + id: number; + eventDate: Date; + description: string; + shortName: string; + + constructor(id: number, eventDate: Date, description: string, shortName: string) { + this.id = id; + this.eventDate = eventDate; + this.description = description; + this.shortName = shortName; + } +} diff --git a/frontend/src/app/modules/time-series/services/chart-services/line-chart-event.service.ts b/frontend/src/app/modules/time-series/services/chart-services/line-chart-dom-event.service.ts similarity index 90% rename from frontend/src/app/modules/time-series/services/chart-services/line-chart-event.service.ts rename to frontend/src/app/modules/time-series/services/chart-services/line-chart-dom-event.service.ts index f714952475..3bbd3556e3 100644 --- a/frontend/src/app/modules/time-series/services/chart-services/line-chart-event.service.ts +++ b/frontend/src/app/modules/time-series/services/chart-services/line-chart-dom-event.service.ts @@ -19,11 +19,13 @@ import {LineChartDrawService} from './line-chart-draw.service'; import {LineChartScaleService} from './line-chart-scale.service'; import {PointsSelection} from '../../models/points-selection.model'; import {TranslateService} from '@ngx-translate/core'; +import {TimeEvent} from '../../models/event.model'; +import {LineChartTimeEventService} from './line-chart-time-event.service'; @Injectable({ providedIn: 'root' }) -export class LineChartEventService { +export class LineChartDomEventService { private _DOT_HIGHLIGHT_RADIUS = 5; private _contextMenuBackground: D3Selection; @@ -38,7 +40,8 @@ export class LineChartEventService { constructor(private urlBuilderService: UrlBuilderService, private translationService: TranslateService, private lineChartDrawService: LineChartDrawService, - private lineChartScaleService: LineChartScaleService) { + private lineChartScaleService: LineChartScaleService, + private lineChartTimeEventService: LineChartTimeEventService) { } private _pointSelectionErrorHandler: Function; @@ -53,7 +56,7 @@ export class LineChartEventService { return this._pointsSelection; } - private contextMenu: ContextMenuPosition[] = [ + private readonly contextMenu: ContextMenuPosition[] = [ { title: 'summary', icon: 'fas fa-file-alt', @@ -156,7 +159,7 @@ export class LineChartEventService { }, ]; - private backgroundContextMenu: ContextMenuPosition[] = [ + private readonly backgroundContextMenu: ContextMenuPosition[] = [ { title: 'compareFilmstrips', icon: 'fas fa-columns', @@ -241,22 +244,26 @@ export class LineChartEventService { width: number, height: number, yAxisWidth: number, + margin: { [key: string]: number }, xScale: D3ScaleTime, data: { [key: string]: TimeSeries[] }, dataTrimValues: { [key: string]: { [key: string]: number } }, - legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }): void { + legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }, + events: TimeEvent[]): void { // remove old brush d3Select('.brush').remove(); this.brush = d3BrushX() .extent([[0, 0], [width, height]]) - .on('end', () => this.zoomInTheChart(chartContentContainer, width, height, yAxisWidth, xScale, data, dataTrimValues, legendDataMap)); + .on('end', () => + this.zoomInTheChart(chartContentContainer, width, height, yAxisWidth, margin, xScale, data, dataTrimValues, legendDataMap, events)); chartContentContainer .append('g') .attr('class', 'brush') .call(this.brush); d3Select('.overlay') - .on('dblclick', () => this.resetChart(chartContentContainer, width, height, yAxisWidth, xScale, data, dataTrimValues, legendDataMap)) + .on('dblclick', () => + this.resetChart(chartContentContainer, width, height, yAxisWidth, margin, xScale, data, dataTrimValues, legendDataMap, events)) .on('contextmenu', (d, i, e) => this.showContextMenu(this.backgroundContextMenu)(d, i, e)); } @@ -264,12 +271,14 @@ export class LineChartEventService { width: number, height: number, yAxisWidth: number, + margin: { [key: string]: number }, xScale: D3ScaleTime, data: { [key: string]: TimeSeries[] }, dataTrimValues: { [key: string]: { [key: string]: number } }, - legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }): void { + legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }, + events: TimeEvent[]): void { if (this.brushMinDate !== null && this.brushMaxDate !== null) { - this.updateChart(chartContentContainer, width, height, yAxisWidth, xScale, data, dataTrimValues, legendDataMap); + this.updateChart(chartContentContainer, width, height, yAxisWidth, margin, xScale, data, dataTrimValues, legendDataMap, events); } } @@ -290,7 +299,7 @@ export class LineChartEventService { .select((_, index: number, elem) => (elem[index]).parentNode) .append('div') .attr('id', 'marker-tooltip') - .style('opacity', '0.9'); + .style('opacity', '1'); } private moveMarker(node: D3ContainerElement, width: number, containerHeight: number, marginTop: number, marginLeft: number): void { @@ -323,13 +332,15 @@ export class LineChartEventService { } private zoomInTheChart(chartContentContainer: D3Selection, - width: number, - height: number, - yAxisWidth: number, - xScale: D3ScaleTime, - data: { [key: string]: TimeSeries[] }, - dataTrimValues: { [key: string]: { [key: string]: number } }, - legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }): void { + width: number, + height: number, + yAxisWidth: number, + margin: { [key: string]: number }, + xScale: D3ScaleTime, + data: { [key: string]: TimeSeries[] }, + dataTrimValues: { [key: string]: { [key: string]: number } }, + legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }, + events: TimeEvent[]): void { const extent = d3Event.selection; if (!extent) { return; @@ -339,17 +350,19 @@ export class LineChartEventService { d3Select('.brush').call(this.brush.move, null); this.brushMinDate = xScale.invert(extent[0]); this.brushMaxDate = xScale.invert(extent[1]); - this.updateChart(chartContentContainer, width, height, yAxisWidth, xScale, data, dataTrimValues, legendDataMap); + this.updateChart(chartContentContainer, width, height, yAxisWidth, margin, xScale, data, dataTrimValues, legendDataMap, events); } private resetChart(chartContentContainer: D3Selection, width: number, height: number, yAxisWidth: number, + margin: { [key: string]: number }, xScale: D3ScaleTime, data: { [key: string]: TimeSeries[] }, dataTrimValues: { [key: string]: { [key: string]: number } }, - legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }): void { + legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }, + events: TimeEvent[]): void { if (this.brushMinDate === null || this.brushMaxDate === null) { return; } @@ -359,6 +372,7 @@ export class LineChartEventService { xScale.domain([this.lineChartScaleService.getMinDate(data), this.lineChartScaleService.getMaxDate(data)]); d3Select('.x-axis').transition().call((transition: D3Transition) => this.lineChartDrawService.updateXAxis(transition, xScale)); + this.lineChartTimeEventService.addEventTimeLineAndMarkersToChart(chartContentContainer, xScale, events, width, height, margin); const yNewScales = this.lineChartScaleService.getYScales(data, height, dataTrimValues); this.lineChartDrawService.updateYAxes(yNewScales, width, yAxisWidth); Object.keys(yNewScales).forEach((key: string, index: number) => { @@ -372,19 +386,22 @@ export class LineChartEventService { width: number, height: number, yAxisWidth: number, + margin: { [key: string]: number }, xScale: D3ScaleTime, data: { [key: string]: TimeSeries[] }, dataTrimValues: { [key: string]: { [key: string]: number } }, - legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }): void { + legendDataMap: { [key: string]: { [key: string]: (boolean | string) } }, + events: TimeEvent[]): void { xScale.domain([this.brushMinDate, this.brushMaxDate]); d3Select('.x-axis').transition().call((transition: D3Transition) => - this.lineChartDrawService.updateXAxis(transition, xScale)); + this.lineChartDrawService.updateXAxis(transition, xScale)); + this.lineChartTimeEventService.addEventTimeLineAndMarkersToChart(chartContentContainer, xScale, events, width, height, margin); const yNewScales = this.lineChartScaleService.getYScalesInTimeRange(data, height, dataTrimValues, this.brushMinDate, this.brushMaxDate); this.lineChartDrawService.updateYAxes(yNewScales, width, yAxisWidth); Object.keys(yNewScales).forEach((key: string, index: number) => { - this.lineChartDrawService.addDataLinesToChart( - chartContentContainer, this._pointsSelection, xScale, yNewScales[key], data[key], legendDataMap, index); + this.lineChartDrawService.addDataLinesToChart( + chartContentContainer, this._pointsSelection, xScale, yNewScales[key], data[key], legendDataMap, index); }); } @@ -438,7 +455,7 @@ export class LineChartEventService { } private showMarker(): void { - d3Select('.marker-line').style('opacity', 1); + d3Select('.marker-line').style('opacity', 0.5); d3Select('#marker-tooltip').style('opacity', 1); } @@ -647,6 +664,8 @@ export class LineChartEventService { value.append(lineColorDot); if (currentPoint.value !== undefined && currentPoint.value !== null) { value.append(currentPoint.value.toString()); + } else { + value.append(' -'); } const row: HTMLTableRowElement = document.createElement('tr'); diff --git a/frontend/src/app/modules/time-series/services/chart-services/line-chart-legend.service.ts b/frontend/src/app/modules/time-series/services/chart-services/line-chart-legend.service.ts index 60d61b3680..951aec28a4 100644 --- a/frontend/src/app/modules/time-series/services/chart-services/line-chart-legend.service.ts +++ b/frontend/src/app/modules/time-series/services/chart-services/line-chart-legend.service.ts @@ -16,7 +16,7 @@ import {EventResultSeriesDTO} from '../../models/event-result-series.model'; import {EventResultDataDTO} from '../../models/event-result-data.model'; import {TranslateService} from '@ngx-translate/core'; import {LineChartDrawService} from './line-chart-draw.service'; -import {LineChartEventService} from './line-chart-event.service'; +import {LineChartDomEventService} from './line-chart-dom-event.service'; @Injectable({ providedIn: 'root' @@ -30,7 +30,7 @@ export class LineChartLegendService { constructor(private translationService: TranslateService, private lineChartDrawService: LineChartDrawService, - private lineChartEventService: LineChartEventService) { + private lineChartDomEventService: LineChartDomEventService) { } get legendDataMap(): { [p: string]: { [p: string]: boolean | string } } { @@ -93,7 +93,7 @@ export class LineChartLegendService { if (this._legendGroupColumns < 1) { this._legendGroupColumns = 1; } - return Math.ceil(labels.length / this._legendGroupColumns) * ChartCommons.LABEL_HEIGHT + 30; + return Math.ceil(labels.length / this._legendGroupColumns) * ChartCommons.LABEL_HEIGHT; } addLegendsToChart(chartContentContainer: D3Selection, @@ -249,7 +249,7 @@ export class LineChartLegendService { Object.keys(yScales).forEach((key: string, index: number) => { this.lineChartDrawService.addDataLinesToChart( - chartContentContainer, this.lineChartEventService.pointsSelection, xScale, yScales[key], data[key], this._legendDataMap, index); + chartContentContainer, this.lineChartDomEventService.pointsSelection, xScale, yScales[key], data[key], this._legendDataMap, index); }); // redraw legend diff --git a/frontend/src/app/modules/time-series/services/chart-services/line-chart-time-event.service.ts b/frontend/src/app/modules/time-series/services/chart-services/line-chart-time-event.service.ts new file mode 100644 index 0000000000..7a14d1eeb1 --- /dev/null +++ b/frontend/src/app/modules/time-series/services/chart-services/line-chart-time-event.service.ts @@ -0,0 +1,187 @@ +import {Injectable} from '@angular/core'; +import {EventDTO, TimeEvent} from '../../models/event.model'; +import { + BaseType as D3BaseType, + ContainerElement as D3ContainerElement, + select as d3Select, + Selection as D3Selection +} from 'd3-selection'; +import {ScaleTime as D3ScaleTime} from 'd3-scale'; +import {parseDate} from '../../../../utils/date.util'; +import {TranslateService} from '@ngx-translate/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LineChartTimeEventService { + + private readonly EVENT_LINE_OFFSET = 100; + private readonly EVENT_MARKER_RADIUS = 8; + + private selectedEventMarkerIds: number[] = []; + + constructor(private translateService: TranslateService) { + } + + addEventMarkerGroupToChart(chart: D3Selection) { + chart + .append('g') + .attr('id', 'event-group'); + } + + addEventMarkerTooltipBoxToSvgParent() { + d3Select('#time-series-chart') + .select(function () { + return (this).parentNode; + }) + .append('div') + .attr('id', 'event-marker-tooltip') + .style('opacity', '0.9'); + } + + addEventTimeLineAndMarkersToChart(chart: D3Selection, + xScale: D3ScaleTime, + events: EventDTO[], + width: number, + height: number, + margin: { [key: string]: number }): void { + const eventGroup = d3Select('#event-group'); + eventGroup.selectAll('*').remove(); + + const eventTimeLineGroup = eventGroup + .append('g') + .attr('id', 'event-time-line-group'); + eventTimeLineGroup + .append('text') + .attr('class', 'event-time-line-label') + .attr('x', 0) + .attr('y', height + this.EVENT_LINE_OFFSET - 2.5 * this.EVENT_MARKER_RADIUS) + .text(this.translateService.instant('frontend.de.iteratec.osm.timeSeries.chart.eventTimeLine.label')); + eventTimeLineGroup.append('line') + .attr('class', 'event-time-line') + .attr('x1', 0) + .attr('x2', width) + .attr('y1', height + this.EVENT_LINE_OFFSET) + .attr('y2', height + this.EVENT_LINE_OFFSET); + + const eventMarkerGroup = eventGroup + .selectAll('g#event-marker-group') + .data(events, (event: EventDTO) => { + return event.id.toString(); + }) + .join( + enter => { + const eventMarker = enter + .append('g') + .attr('class', 'event-marker'); + eventMarker + .append('line') + .attr('class', 'event-marker-line unselected-event-marker-line') + .attr('y1', 0) + .attr('y2', height); + eventMarker + .append('circle') + .attr('class', 'event-marker-dot') + .style('cursor', 'pointer') + .on('mouseover', (event: EventDTO, index: number, nodes: []) => this.showEventMarkerTooltip(event, index, nodes, width, margin)) + .on('mouseout', () => d3Select('#event-marker-tooltip').style('opacity', 0)) + .on('click', (event: EventDTO, index: number, nodes: []) => this.setEventMarkerSelection(event.id, index, nodes)) + .attr('cy', height + this.EVENT_LINE_OFFSET) + .attr('r', this.EVENT_MARKER_RADIUS); + return eventMarker; + } + ); + + eventMarkerGroup.selectAll('.event-marker-line') + .attr('x1', (event: EventDTO) => xScale(event.eventDate)) + .attr('x2', (event: EventDTO) => xScale(event.eventDate)); + eventMarkerGroup.selectAll('.event-marker-dot') + .attr('cx', (event: EventDTO) => xScale(parseDate(event.eventDate))); + + if (this.selectedEventMarkerIds.length > 0) { + this.restoreEventMarkerSelection(eventMarkerGroup); + } + } + + clearEventMarkerSelection(): void { + this.selectedEventMarkerIds = []; + } + + private showEventMarkerTooltip(event: EventDTO, + index: number, + nodes: [], + width: number, + margin: { [key: string]: number }): D3Selection { + const eventMarkerTooltipBox = d3Select('#event-marker-tooltip'); + eventMarkerTooltipBox.style('opacity', '0.9'); + eventMarkerTooltipBox.html(this.createEventMarkerTooltipContent(event).outerHTML); + + const circle = d3Select(nodes[index]); + const top = parseFloat(circle.attr('cy')) + 5; + + const tooltipWidth: number = (eventMarkerTooltipBox.node()).getBoundingClientRect().width; + const xPos = parseFloat(circle.attr('cx')); + const left = (tooltipWidth + xPos > width) ? xPos - tooltipWidth + margin.right - 10 : xPos + margin.left + 25; + eventMarkerTooltipBox.style('top', top + 'px'); + eventMarkerTooltipBox.style('left', left + 'px'); + + return nodes[index]; + } + + private setEventMarkerSelection(eventId: number, index: number, nodes) { + const eventMarkerLineSelection = d3Select(nodes[index].parentNode).select('.event-marker-line'); + const eventMarkerDotSelection = d3Select(nodes[index]); + const arrayIndex = this.selectedEventMarkerIds.indexOf(eventId); + + if (arrayIndex !== -1) { + eventMarkerLineSelection.attr('class', 'event-marker-line unselected-event-marker-line'); + eventMarkerDotSelection.attr('class', 'event-marker-dot'); + this.selectedEventMarkerIds.splice(arrayIndex, 1); + } else { + eventMarkerLineSelection.attr('class', 'event-marker-line selected-event-marker-line'); + eventMarkerDotSelection.attr('class', 'selected-event-marker-dot'); + this.selectedEventMarkerIds.push(eventId); + } + } + + private createEventMarkerTooltipContent(event: EventDTO): HTMLDivElement { + const container: HTMLDivElement = document.createElement('div'); + container.className = 'grid-container'; + + const dateItem = document.createElement('div'); + dateItem.className = 'event-marker-tooltip-element event-marker-tooltip-date'; + dateItem.append(event.eventDate.toLocaleString()); + container.append(dateItem); + + const shortNameItem = document.createElement('div'); + shortNameItem.className = 'event-marker-tooltip-element'; + const shortNameParagraph = document.createElement('p'); + shortNameParagraph.className = 'event-marker-tooltip-title'; + shortNameParagraph.append(`${event.shortName}:`); + shortNameItem.append(shortNameParagraph); + container.append(shortNameItem); + + const descriptionItem = document.createElement('div'); + descriptionItem.className = 'event-marker-tooltip-element'; + const descriptionParagraph = document.createElement('p'); + descriptionParagraph.className = 'event-marker-tooltip-text'; + descriptionParagraph.append(event.description); + descriptionItem.append(descriptionParagraph); + container.append(descriptionItem); + + return container; + } + + private restoreEventMarkerSelection(eventMarkerGroup: D3Selection): void { + eventMarkerGroup.selectAll('.event-marker-line') + .attr('class', (event: EventDTO) => + this.selectedEventMarkerIds.includes(event.id) ? + 'event-marker-line selected-event-marker-line' : + 'event-marker-line unselected-event-marker-line' + ); + eventMarkerGroup.selectAll('.event-marker-dot') + .attr('class', (event: EventDTO) => + this.selectedEventMarkerIds.includes(event.id) ? 'selected-event-marker-dot' : 'event-marker-dot'); + + } +} diff --git a/frontend/src/app/modules/time-series/services/line-chart-data.service.ts b/frontend/src/app/modules/time-series/services/line-chart-data.service.ts index 0fb5a477fd..575c7d7c24 100644 --- a/frontend/src/app/modules/time-series/services/line-chart-data.service.ts +++ b/frontend/src/app/modules/time-series/services/line-chart-data.service.ts @@ -25,6 +25,21 @@ export class LineChartDataService { ); } + fetchEvents(resultSeclectionCommand: ResultSelectionCommand, url: string): Observable { + const from: Date = resultSeclectionCommand.from; + const to: Date = resultSeclectionCommand.to; + const jobGroupIds: number[] = resultSeclectionCommand.jobGroupIds; + + let params = new HttpParams(); + params = params.append('from', from.toISOString()); + params = params.append('to', to.toISOString()); + params = params.append('jobGroups', JSON.stringify(jobGroupIds)); + + return this.http.get(url, {params: params}).pipe( + this.handleError() + ); + } + private buildCommand(resultSelectionCommand: ResultSelectionCommand, remainingResultSelection: RemainingResultSelection): GetEventResultDataCommand { return new GetEventResultDataCommand({ 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 8a6a621c06..4dea0adfd2 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 @@ -17,15 +17,17 @@ import {Transition as D3Transition} from 'd3-transition'; import {EventResultDataDTO} from 'src/app/modules/time-series/models/event-result-data.model'; import {EventResultSeriesDTO} from 'src/app/modules/time-series/models/event-result-series.model'; import {EventResultPointDTO} from 'src/app/modules/time-series/models/event-result-point.model'; +import {EventDTO, TimeEvent} from 'src/app/modules/time-series/models/event.model'; import {TimeSeries} from 'src/app/modules/time-series/models/time-series.model'; import {TimeSeriesPoint} from 'src/app/modules/time-series/models/time-series-point.model'; import {parseDate} from 'src/app/utils/date.util'; import {UrlBuilderService} from './url-builder.service'; import {LineChartScaleService} from './chart-services/line-chart-scale.service'; import {LineChartDrawService} from './chart-services/line-chart-draw.service'; -import {LineChartEventService} from './chart-services/line-chart-event.service'; +import {LineChartDomEventService} from './chart-services/line-chart-dom-event.service'; import {LineChartLegendService} from './chart-services/line-chart-legend.service'; import {SummaryLabel} from '../models/summary-label.model'; +import {LineChartTimeEventService} from './chart-services/line-chart-time-event.service'; /** * Generate line charts with ease and fun 😎 @@ -37,8 +39,9 @@ export class LineChartService { // D3 margin conventions // > With this convention, all subsequent code can ignore margins. // see: https://bl.ocks.org/mbostock/3019563 - private MARGIN: { [key: string]: number } = {top: 60, right: 60, bottom: 40, left: 75}; + private MARGIN: { [key: string]: number } = {top: 60, right: 60, bottom: 20, left: 75}; private Y_AXIS_WIDTH = 65; + private readonly LEGEND_GROUP_OFFSET = 130; private _margin: { [key: string]: number } = { top: this.MARGIN.top, @@ -48,15 +51,15 @@ export class LineChartService { }; private _width: number = 600 - this._margin.left - this._margin.right; private _height: number = 550 - this._margin.top - this._margin.bottom; - private _legendGroupTop: number = this._margin.top + this._height + 50; private _xScale: D3ScaleTime; constructor(private translationService: TranslateService, private urlBuilderService: UrlBuilderService, private lineChartScaleService: LineChartScaleService, private lineChartDrawService: LineChartDrawService, - private lineChartEventService: LineChartEventService, - private lineChartLegendService: LineChartLegendService) { + private lineChartDomEventService: LineChartDomEventService, + private lineChartLegendService: LineChartLegendService, + private lineChartTimeEventService: LineChartTimeEventService) { } private _dataTrimLabels: { [key: string]: string } = {}; @@ -72,14 +75,16 @@ export class LineChartService { } initChart(svgElement: ElementRef, pointSelectionErrorHandler: Function): void { - this.lineChartEventService.pointSelectionErrorHandler = pointSelectionErrorHandler; + this.lineChartDomEventService.pointSelectionErrorHandler = pointSelectionErrorHandler; const data: { [key: string]: TimeSeries[] } = {}; const chart: D3Selection = this.createChart(svgElement); this._xScale = this.lineChartScaleService.getXScale(data, this._width); this.addXAxisToChart(chart); - this.lineChartEventService.prepareMouseEventCatcher(chart, this._width, this._height, this._margin.top, this._margin.left); + this.lineChartTimeEventService.addEventMarkerGroupToChart(chart); + this.lineChartTimeEventService.addEventMarkerTooltipBoxToSvgParent(); + this.lineChartDomEventService.prepareMouseEventCatcher(chart, this._width, this._height, this._margin.top, this._margin.left); } /** @@ -123,16 +128,24 @@ export class LineChartService { return data; } - prepareLegend(incomingData: EventResultDataDTO): void { + prepareLegendData(incomingData: EventResultDataDTO): void { this.lineChartLegendService.setLegendData(incomingData); } + prepareEventsData(events: EventDTO[]): TimeEvent[] { + this.lineChartTimeEventService.clearEventMarkerSelection(); + return events.map(eventDto => { + return new TimeEvent(eventDto.id, new Date(eventDto.eventDate), eventDto.description, eventDto.shortName); + }); + } + drawLineChart(timeSeries: { [key: string]: TimeSeries[] }, + eventData: TimeEvent[], measurandGroups: { [key: string]: string }, summaryLabels: SummaryLabel[], timeSeriesAmount: number, dataTrimValues: { [key: string]: { [key: string]: number } }): void { - this.lineChartEventService.prepareCleanState(); + this.lineChartDomEventService.prepareCleanState(); this.adjustChartDimensions(measurandGroups, summaryLabels); const chart: D3Selection = d3Select('g#time-series-chart-drawing-area'); @@ -147,7 +160,7 @@ export class LineChartService { d3Select('svg#time-series-chart') .transition() .duration(500) - .attr('height', this._height + legendGroupHeight + this._margin.top + this._margin.bottom); + .attr('height', this._margin.top + this._height + this.LEGEND_GROUP_OFFSET + legendGroupHeight + this._margin.bottom); d3Select('.x-axis').transition().call((transition: D3Transition) => { this.lineChartDrawService.updateXAxis(transition, this._xScale); @@ -156,20 +169,22 @@ export class LineChartService { this.addYAxisUnits(measurandGroups, width); const chartContentContainer = chart.select('.chart-content'); - this.lineChartEventService.createContextMenu(); - this.lineChartEventService.addBrush(chartContentContainer, this._width, this._height, this.Y_AXIS_WIDTH, this._xScale, - timeSeries, dataTrimValues, this.lineChartLegendService.legendDataMap); + this.lineChartDomEventService.createContextMenu(); + this.lineChartDomEventService.addBrush(chartContentContainer, this._width, this._height, this.Y_AXIS_WIDTH, this._margin, + this._xScale, timeSeries, dataTrimValues, this.lineChartLegendService.legendDataMap, eventData); + this.lineChartTimeEventService.addEventTimeLineAndMarkersToChart(chart, this._xScale, eventData, width, + this._height, this._margin); this.lineChartLegendService.addLegendsToChart(chartContentContainer, this._xScale, yScales, timeSeries, timeSeriesAmount); this.lineChartLegendService.setSummaryLabel(chart, summaryLabels, this._width); Object.keys(yScales).forEach((key: string, index: number) => { - this.lineChartDrawService.addDataLinesToChart(chartContentContainer, this.lineChartEventService.pointsSelection, + this.lineChartDrawService.addDataLinesToChart(chartContentContainer, this.lineChartDomEventService.pointsSelection, this._xScale, yScales[key], timeSeries[key], this.lineChartLegendService.legendDataMap, index); }); - this.lineChartEventService.addMouseMarkerToChart(chartContentContainer); - this.lineChartDrawService.drawAllSelectedPoints(this.lineChartEventService.pointsSelection); + this.lineChartDomEventService.addMouseMarkerToChart(chartContentContainer); + this.lineChartDrawService.drawAllSelectedPoints(this.lineChartDomEventService.pointsSelection); this.setDataTrimLabels(measurandGroups); @@ -179,10 +194,11 @@ export class LineChartService { } restoreZoom(timeSeriesData: { [key: string]: TimeSeries[] }, - dataTrimValues: { [key: string]: { [key: string]: number } }): void { + dataTrimValues: { [key: string]: { [key: string]: number } }, + events: TimeEvent[]): void { const chartContentContainer = d3Select('g#time-series-chart-drawing-area .chart-content'); - this.lineChartEventService.restoreSelectedZoom(chartContentContainer, this._width, this._height, - this.Y_AXIS_WIDTH, this._xScale, timeSeriesData, dataTrimValues, this.lineChartLegendService.legendDataMap); + this.lineChartDomEventService.restoreSelectedZoom(chartContentContainer, this._width, this._height, + this.Y_AXIS_WIDTH, this._margin, this._xScale, timeSeriesData, dataTrimValues, this.lineChartLegendService.legendDataMap, events); } startResize(svgElement: ElementRef): void { @@ -220,7 +236,7 @@ export class LineChartService { svg.append('g') .attr('id', 'time-series-chart-legend') .attr('class', 'legend-group') - .attr('transform', `translate(${this._margin.left}, ${this._legendGroupTop})`); + .attr('transform', `translate(${this._margin.left}, ${this._margin.top + this._height + this.LEGEND_GROUP_OFFSET})`); return chart; } @@ -257,7 +273,6 @@ export class LineChartService { } else { this._margin.top = this.MARGIN.top + 20; } - this._legendGroupTop = this._margin.top + this._height + 50; d3Select('#time-series-chart') .attr('width', this._width + this._margin.left + this._margin.right); @@ -269,7 +284,7 @@ export class LineChartService { .attr('transform', `translate(${this._margin.left}, ${this._margin.top})`); d3Select('#time-series-chart-legend') - .attr('transform', `translate(${this._margin.left}, ${this._legendGroupTop})`); + .attr('transform', `translate(${this._margin.left}, ${this._margin.top + this._height + this.LEGEND_GROUP_OFFSET})`); } private addYAxisUnits(measurandGroups: { [key: string]: string }, width: number): void { diff --git a/frontend/src/app/modules/time-series/time-series.component.html b/frontend/src/app/modules/time-series/time-series.component.html index c4124ab210..401e1ad9e3 100644 --- a/frontend/src/app/modules/time-series/time-series.component.html +++ b/frontend/src/app/modules/time-series/time-series.component.html @@ -8,8 +8,9 @@
- + +
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 48f5947523..60cf401030 100644 --- a/frontend/src/app/modules/time-series/time-series.component.ts +++ b/frontend/src/app/modules/time-series/time-series.component.ts @@ -3,7 +3,8 @@ import {URL} from '../../enums/url.enum'; import {LineChartDataService} from './services/line-chart-data.service'; import {ResultSelectionStore} from '../result-selection/services/result-selection.store'; import {EventResultData, EventResultDataDTO} from './models/event-result-data.model'; -import {BehaviorSubject} from 'rxjs'; +import {EventDTO} from './models/event.model'; +import {BehaviorSubject, forkJoin} from 'rxjs'; @Component({ selector: 'osm-time-series', @@ -15,26 +16,44 @@ import {BehaviorSubject} from 'rxjs'; }) export class TimeSeriesComponent implements OnInit { - public showTimeSeriesChart = false; - public results$ = new BehaviorSubject(new EventResultData()); + showTimeSeriesChart = false; + results$ = new BehaviorSubject(new EventResultData()); constructor(private linechartDataService: LineChartDataService, private resultSelectionStore: ResultSelectionStore) { } ngOnInit() { if (this.resultSelectionStore.validQuery) { - this.getTimeSeriesChartData(); + this.getData(); } } - getTimeSeriesChartData() { + getData() { this.showTimeSeriesChart = true; this.results$.next(null); - this.linechartDataService.fetchEventResultData( + forkJoin({ + eventResultData: this.getTimeSeriesChartData(), + events: this.getEvents() + }) + .subscribe((next) => { + next.eventResultData.events = next.events; + this.results$.next(next.eventResultData); + }); + } + + private getTimeSeriesChartData() { + return this.linechartDataService.fetchEventResultData( this.resultSelectionStore.resultSelectionCommand, this.resultSelectionStore.remainingResultSelection, URL.EVENT_RESULT_DASHBOARD_LINECHART_DATA - ).subscribe(next => this.results$.next(next)); + ); + } + + private getEvents() { + return this.linechartDataService.fetchEvents( + this.resultSelectionStore.resultSelectionCommand, + URL.EVENTS + ); } } diff --git a/grails-app/controllers/de/iteratec/osm/UrlMappings.groovy b/grails-app/controllers/de/iteratec/osm/UrlMappings.groovy index b88ab13b98..cba1fcd9e2 100755 --- a/grails-app/controllers/de/iteratec/osm/UrlMappings.groovy +++ b/grails-app/controllers/de/iteratec/osm/UrlMappings.groovy @@ -135,6 +135,10 @@ class UrlMappings { // GeneralMeasurementApiController ////////////////////////////////////////// + "/rest/events" { + controller = "Event" + action = [GET: "getEvents"] + } "/rest/event/create" { controller = "GeneralMeasurementApi" action = [POST: "securedViaApiKeyCreateEvent"] diff --git a/grails-app/controllers/de/iteratec/osm/report/chart/EventController.groovy b/grails-app/controllers/de/iteratec/osm/report/chart/EventController.groovy index 85d448bf86..dce2a4acaa 100644 --- a/grails-app/controllers/de/iteratec/osm/report/chart/EventController.groovy +++ b/grails-app/controllers/de/iteratec/osm/report/chart/EventController.groovy @@ -17,14 +17,14 @@ package de.iteratec.osm.report.chart + +import de.iteratec.osm.report.chart.events.EventDTO + +import de.iteratec.osm.report.chart.events.GetEventsCommand import de.iteratec.osm.util.ControllerUtils -import grails.converters.JSON -import org.joda.time.DateTime import org.springframework.http.HttpStatus import org.springframework.web.servlet.support.RequestContextUtils -import javax.servlet.http.HttpServletResponse - /** * EventController * A controller class handles incoming web requests and performs actions such as redirects, rendering views and so on. @@ -124,6 +124,18 @@ class EventController { } } + def getEvents(GetEventsCommand cmd) { + if (cmd.hasErrors()) { + ControllerUtils.sendSimpleResponseAsStream(response, HttpStatus.BAD_REQUEST, + "Invalid parameters: " + cmd.getErrors().fieldErrors.collect { it.field }.join(", ")) + + return + } + + List eventList = eventService.getEventsByDateRangeAndJobGroups(cmd) + ControllerUtils.sendObjectAsJSON(response, eventList) + } + /** * Combines time and date within the param list, where time ist 'time' and date is 'eventDate' * @param params diff --git a/grails-app/domain/de/iteratec/osm/report/chart/Event.groovy b/grails-app/domain/de/iteratec/osm/report/chart/Event.groovy index 04f56fa2e7..3ed0fac9ec 100644 --- a/grails-app/domain/de/iteratec/osm/report/chart/Event.groovy +++ b/grails-app/domain/de/iteratec/osm/report/chart/Event.groovy @@ -27,6 +27,7 @@ import grails.gorm.annotation.Entity @Entity class Event { + Long id Date eventDate String shortName String description diff --git a/grails-app/i18n/messages.properties b/grails-app/i18n/messages.properties index ce15ce5239..4e794a8b4b 100644 --- a/grails-app/i18n/messages.properties +++ b/grails-app/i18n/messages.properties @@ -1313,6 +1313,7 @@ frontend.de.iteratec.osm.timeSeries.chart.label.timestamp=Timestamp: frontend.de.iteratec.osm.timeSeries.chart.label.testAgent=Test agent: frontend.de.iteratec.osm.timeSeries.chart.settings.minimum=min frontend.de.iteratec.osm.timeSeries.chart.settings.maximum=max +frontend.de.iteratec.osm.timeSeries.chart.eventTimeLine.label=Events: frontend.de.iteratec.osm.distribution.chart.settings.maximum=Maximum value frontend.de.iteratec.osm.distribution.chart.settings.filter=Filter frontend.de.iteratec.osm.distribution.chart.settings.sort=Sort diff --git a/grails-app/i18n/messages_de.properties b/grails-app/i18n/messages_de.properties index 47d1773b2d..2f1deb8308 100644 --- a/grails-app/i18n/messages_de.properties +++ b/grails-app/i18n/messages_de.properties @@ -1287,6 +1287,7 @@ frontend.de.iteratec.osm.timeSeries.chart.label.timestamp=Zeitstempel: frontend.de.iteratec.osm.timeSeries.chart.label.testAgent=Testagent: frontend.de.iteratec.osm.timeSeries.chart.settings.minimum=min frontend.de.iteratec.osm.timeSeries.chart.settings.maximum=max +frontend.de.iteratec.osm.timeSeries.chart.eventTimeLine.label=Ereignisse: frontend.de.iteratec.osm.distribution.chart.settings.maximum=Maximalwert frontend.de.iteratec.osm.distribution.chart.settings.filter=Filtern frontend.de.iteratec.osm.distribution.chart.settings.sort=Sortierung diff --git a/grails-app/services/de/iteratec/osm/report/chart/EventService.groovy b/grails-app/services/de/iteratec/osm/report/chart/EventService.groovy index 9467f11363..cb93dfc76b 100644 --- a/grails-app/services/de/iteratec/osm/report/chart/EventService.groovy +++ b/grails-app/services/de/iteratec/osm/report/chart/EventService.groovy @@ -17,8 +17,10 @@ package de.iteratec.osm.report.chart +import de.iteratec.osm.report.chart.events.EventDTO + +import de.iteratec.osm.report.chart.events.GetEventsCommand import grails.gorm.transactions.Transactional -import org.hibernate.criterion.CriteriaSpecification import org.hibernate.sql.JoinType import org.springframework.dao.DataIntegrityViolationException @@ -95,6 +97,26 @@ class EventService { delegateMap.action.success.call(eventInstance) } + List getEventsByDateRangeAndJobGroups(GetEventsCommand cmd) { + Date from = cmd.from.toDate() + Date to = cmd.to.toDate() + List jobGroupIds = cmd.jobGroups.collect { it.toLong() } + def allEvents = retrieveEventsByDateRangeAndVisibilityAndJobGroup(from, to, jobGroupIds) + + List eventList = new ArrayList<>() + allEvents.forEach { event -> + EventDTO eventDTO = new EventDTO() + eventDTO.id = event.id + eventDTO.eventDate = event.eventDate + eventDTO.shortName = event.shortName + eventDTO.description = event.description + + eventList.add(eventDTO) + } + + return eventList + } + List findAllEventsBetweenDate(Date resetFromDate, Date resetToDate){ Event.findAllByEventDateBetween(resetFromDate, resetToDate) } diff --git a/src/main/groovy/de/iteratec/osm/report/chart/events/EventDTO.groovy b/src/main/groovy/de/iteratec/osm/report/chart/events/EventDTO.groovy new file mode 100644 index 0000000000..584b9cef10 --- /dev/null +++ b/src/main/groovy/de/iteratec/osm/report/chart/events/EventDTO.groovy @@ -0,0 +1,8 @@ +package de.iteratec.osm.report.chart.events + +class EventDTO { + long id + Date eventDate = null + String shortName = "" + String description = "" +} diff --git a/src/main/groovy/de/iteratec/osm/report/chart/events/GetEventsCommand.groovy b/src/main/groovy/de/iteratec/osm/report/chart/events/GetEventsCommand.groovy new file mode 100644 index 0000000000..f87af4b873 --- /dev/null +++ b/src/main/groovy/de/iteratec/osm/report/chart/events/GetEventsCommand.groovy @@ -0,0 +1,27 @@ +package de.iteratec.osm.report.chart.events + +import grails.databinding.BindUsing +import grails.validation.Validateable +import groovy.json.JsonSlurper +import org.joda.time.DateTime + +class GetEventsCommand implements Validateable { + DateTime from + DateTime to + + @BindUsing({ obj, source -> + return new JsonSlurper().parseText(source['jobGroups']) + }) + List jobGroups = [] + + static constraints = { + from(nullable: false) + to(nullable: false) + jobGroups(nullable: false, validator: { List jobGroups, GetEventsCommand cmd -> + if (jobGroups.isEmpty()) { + return false + } + }) + } + +}