Commit 1115cbad authored by Léonard Treille's avatar Léonard Treille
Browse files

Merge branch 'issues/chart-tooltip-improvment-2' into 'master'

Issues #2 - chart tooltip improvment

See merge request !3
parents 3b7c26af 162b4c08
......@@ -39,7 +39,6 @@ import { TimePipe, TimeUnitPipe, TimeSchedulePipe } from '@pipes/time.pipe';
import { ChartComponent } from '@components/chart/chart.component';
import { OccupancyDatasetPipe } from '@features/occupancy/occupancy-dataset.pipe';
import { VerticalBarChartType } from '@components/chart/types/vertical-bar.chart-type';
import { OccupancyChartType } from '@components/chart/types/occupancy.chart-type';
import { OccupancyChartDialogComponent } from '@features/occupancy/occupancy-chart-dialog/occupancy-chart-dialog.component';
import { ContributionFirstStepComponent } from './pages/contribution/contribution-first-step/contribution-first-step.component';
import { ContributionSecondStepComponent } from './pages/contribution/contribution-second-step/contribution-second-step.component';
......@@ -50,6 +49,7 @@ import { SearchInputComponent } from './features/search/search-input/search-inpu
import { SearchListComponent } from './features/search/search-list/search-list.component';
import { SearchItemDirective } from './features/search/search-item.directive';
import { IdPipe } from './pipes/id.pipe';
import { ChartTooltipDirective } from './components/chart/chart-tooltip.directive';
@NgModule({
declarations: [
......@@ -79,7 +79,8 @@ import { IdPipe } from './pipes/id.pipe';
SearchInputComponent,
SearchListComponent,
SearchItemDirective,
IdPipe
IdPipe,
ChartTooltipDirective
],
imports: [
BrowserModule,
......@@ -107,7 +108,6 @@ import { IdPipe } from './pipes/id.pipe';
providers: [
WINDOW_PROVIDERS,
// ChartTypes
{ provide: 'ChartType', useClass: OccupancyChartType, multi: true },
// { provide: 'ChartType', useClass: ScatterGraphChartType, multi: true },
// { provide: 'ChartType', useClass: LineChartType, multi: true },
{ provide: 'ChartType', useClass: VerticalBarChartType, multi: true },
......
import { Directive } from '@angular/core';
@Directive({
selector: '[appChartTooltip]'
})
export class ChartTooltipDirective {
constructor() { }
}
<div class="chart-tooltip" #tooltip *ngIf="tooltipVisible && !tooltipDisabled" [ngStyle]="{'top.px': tooltipTopPosition, 'left.px': tooltipLeftPosition}"
[class.fixed]="tooltipFixed">
<ng-container *ngTemplateOutlet="tooltipTemplate; context:{$implicit: {x: tooltipXValue, y: tooltipYValue}}"></ng-container>
</div>
<div class="loader-wrapper">
<mat-spinner mode="indeterminate" diameter="64" *ngIf="dataset.options.ghost && !dataset.options.unavailable"></mat-spinner>
</div>
<p class="empty-message" *ngIf="dataset?.options?.empty">Aucune donnée</p>
<p class="unavailable-message" *ngIf="dataset?.options?.unavailable">Données non disponibles</p>
:host {
display: block;
position: relative;
}
.loader-wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
import { Component, OnInit, ElementRef, Input, OnChanges, SimpleChanges, Optional, Inject, OnDestroy, HostListener, ViewChild, AfterViewInit, HostBinding } from '@angular/core';
import { Component, OnInit, ElementRef, Input, OnChanges, SimpleChanges, Optional, Inject, OnDestroy, HostListener, ViewChild, AfterViewInit, HostBinding, ContentChild, TemplateRef } from '@angular/core';
import * as d3 from 'd3';
import { Subject, fromEvent, merge } from 'rxjs';
import { debounceTime, takeUntil, tap } from 'rxjs/operators';
import { Dataset, ChartType, ChartContext, Point } from './chart.model';
import { ChartTooltipDirective } from './chart-tooltip.directive';
@Component({
selector: 'app-chart',
template: `
<div class="chart-tooltip" #tooltip *ngIf="tooltipVisible && !tooltipDisabled" [ngStyle]="{'top.px': tooltipTopPosition, 'left.px': tooltipLeftPosition}" [class.fixed]="tooltipFixed">
<span class="x">{{ tooltipXValue }}</span><br>
<span class="y">{{ tooltipYValue }}</span>
</div>
<div class="loader-wrapper">
<mat-spinner mode="indeterminate" diameter="64" *ngIf="dataset.options.ghost && !dataset.options.unavailable"></mat-spinner>
</div>
<p class="empty-message" *ngIf="dataset?.options?.empty">Aucune donnée</p>
<p class="unavailable-message" *ngIf="dataset?.options?.unavailable">Données non disponibles</p>
`,
styles: [
':host {display: block; position: relative;}',
'.loader-wrapper { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }'
]
templateUrl: './chart.component.html',
styleUrls: ['./chart.component.scss']
})
export class ChartComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
......@@ -39,6 +27,8 @@ export class ChartComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn
@ViewChild('tooltip') tooltip: ElementRef<HTMLElement>;
@ContentChild(ChartTooltipDirective, { read: TemplateRef }) tooltipTemplate: TemplateRef<any>;
private chart: d3.Selection;
private chartTypes: ChartType[];
private unsubscriber = new Subject<void>();
......
......@@ -110,17 +110,8 @@ export abstract class BaseChartType {
.attr('transform', `translate(0, ${context.height - (paddingBottom * 2)})`)
.call(axisX);
const fakeData = [];
if (dataset.points.length) {
fakeData.push('y');
}
const yAxisSelection = context.chart.selectAll('.y-axis').data(fakeData);
yAxisSelection
.transition(this.transitionDuration)
.call(axisY)
.selectAll('.full-width-line')
.attr('x1', -6)
.attr('x2', context.width - (paddingRight * 2) + 6);
const yAxisSelection = context.chart.selectAll('.y-axis').data(['y']);
yAxisSelection
.enter()
.append('g')
......@@ -131,11 +122,16 @@ export abstract class BaseChartType {
.append('line')
.attr('class', 'full-width-line')
.attr('x1', -6)
.attr('x2', 0)
.style('opacity', 0)
.transition(this.transitionDuration)
.delay((p: Point, i: number) => i * this.axisLineStaggerTime)
.style('opacity', 1)
.attr('x2', context.width - (paddingRight * 3));
const select = yAxisSelection
.call(axisY);
const ticks = select.selectAll('.tick');
const customLines = ticks.selectAll('.full-width-line').data(['y'])
customLines
.enter()
.append('line')
.attr('class', 'full-width-line')
.attr('x1', -6)
.attr('x2', context.width - (paddingRight * 3));
this.displayAxis = true;
}
......
import { Injectable } from '@angular/core';
import * as d3 from 'd3';
import { ChartType, Dataset, ChartContext, Point, ChartPadding } from '../chart.model';
import { BaseChartType } from './BaseChartType';
@Injectable()
export class OccupancyChartType extends BaseChartType implements ChartType {
protected radius = 2;
protected staggerTime = 100;
get type(): string {
return 'occupancy';
}
getXScale(dataset: Dataset, context: ChartContext): d3.Scale {
const startDate = new Date();
startDate.setHours(0);
startDate.setMinutes(0);
const endDate = new Date();
endDate.setHours(23);
endDate.setMinutes(59);
return d3.scaleTime()
.domain([startDate, endDate])
.range([this.getPadding(ChartPadding.LEFT, dataset) * 2, context.width - this.getPadding(ChartPadding.RIGHT, dataset) * 2]);
}
getXAxis(dataset: Dataset, scaleX: d3.Scale) {
const axis = super.getXAxis(dataset, scaleX);
axis.tickFormat(d3.timeFormat('%H:%M'));
return axis;
}
getYScale(dataset: Dataset, context: ChartContext): d3.Scale {
return d3.scaleLinear()
.domain([0, d3.max(this.getYDomain(dataset))])
.range([context.height - (this.getPadding(ChartPadding.BOTTOM, dataset) * 2), this.getPadding(ChartPadding.TOP, dataset) * 2]);
}
udpate(dataset: Dataset, context: ChartContext): void {
const scaleX = this.getXScale(dataset, context);
const scaleY = this.getYScale(dataset, context);
// Create axis:
this.buildAxis(dataset, context, this.getXAxis(dataset, scaleX), this.getYAxis(dataset, scaleY));
const x = (p: Point, index: number) => {
const [start, end] = this.getPointDates(p);
return scaleX(start);
};
// Create bars:
dataset.points.forEach((points: Point[], i: number) => {
const barsSelection = context.chart.selectAll(`.${this.type}-${i}`).data(points);
barsSelection
.transition(this.transitionDuration * 4)
.attr('d', (p: Point) => {
const height = context.height - scaleY(p.y) - this.getPadding(ChartPadding.BOTTOM, dataset) * 2;
return this.topRoundedColumn(x(p, i), scaleY(p.y), height, this.getBarWidth(p, scaleX));
});
barsSelection.enter()
.append('path')
.attr('class', `${this.type} ${this.type}-${i}`)
.attr('d', (p: Point) => {
return this.topRoundedColumn(x(p, i), context.height - this.getPadding(ChartPadding.BOTTOM, dataset) * 2, 0, this.getBarWidth(p, scaleX));
})
.style('opacity', 0)
.transition(this.transitionDuration * 4)
// .delay((point: Point, index: number) => index * this.staggerTime)
.style('opacity', 0.9)
.attr('d', (p: Point) => {
const height = context.height - scaleY(p.y) - this.getPadding(ChartPadding.BOTTOM, dataset) * 2;
return this.topRoundedColumn(x(p, i), scaleY(p.y), height, this.getBarWidth(p, scaleX));
});
barsSelection.exit()
.transition(this.transitionDuration * 4)
.style('opacity', 0)
.attr('d', (p: Point) => {
return this.topRoundedColumn(x(p, i), context.height - this.getPadding(ChartPadding.BOTTOM, dataset) * 2, 0, this.getBarWidth(p, scaleX));
})
.remove();
});
}
destroy(context: ChartContext): void {
context.chart.selectAll(`.${this.type}`)
.transition(this.transitionDuration)
.attr('r', 0)
.remove();
}
private topRoundedColumn(x: number, y: number, height: number, width: number) {
const path = d3.path();
path.moveTo(x, y + height);
path.lineTo(x, y + this.radius);
path.quadraticCurveTo(x, y, x + this.radius, y);
path.lineTo(x + width - this.radius, y);
path.quadraticCurveTo(x + width, y, x + width, y + this.radius);
path.lineTo(x + width, y + height);
path.closePath();
return path;
}
private getPointDates(point: Point) {
const [start, end] = (point.x as string).split('--');
const [startHours, startMinutes] = start.split(':');
const [endHours, endMinutes] = end.split(':');
const startDate = new Date();
startDate.setHours(parseInt(startHours, 16));
startDate.setMinutes(parseInt(startMinutes, 16));
const endDate = new Date();
endDate.setHours(parseInt(endHours, 16));
endDate.setMinutes(parseInt(endMinutes, 16));
return [startDate, endDate];
}
private getBarWidth(point: Point, scaleX: any) {
const [start, end] = this.getPointDates(point);
const x1 = scaleX(start);
const x2 = scaleX(end);
return x1 - x2;
}
}
......@@ -64,7 +64,8 @@ export class VerticalBarChartType extends BaseChartType implements ChartType {
}
return classes;
})
.style('opacity', dataset.options.ghost ? 0.3 : 0.9);
.style('opacity', dataset.options.ghost ? 0.3 : 0.9)
.attr('data-point', p => JSON.stringify(p));
barsSelection.enter()
.append('path')
.attr('class', p => {
......
......@@ -57,7 +57,7 @@ export class DetailProchainspassagesComponent implements OnInit, OnDestroy {
constructor(
private realtimeDataService: RealtimeDataService,
private appVisibilityService: AppVisibilityService,
public dialog: MatDialog
private dialog: MatDialog
) { }
ngOnInit() {
......@@ -138,8 +138,7 @@ export class DetailProchainspassagesComponent implements OnInit, OnDestroy {
openChartDialog(obj: NextStop) {
this.dialog.open(OccupancyChartDialogComponent, {
data: obj,
minWidth: '300px',
maxWidth: '100vw'
panelClass: 'occupancy-chart-dialog',
});
}
......
<h1 mat-dialog-title>Fréquentation en temps normal</h1>
<div mat-dialog-content>
<app-chart type="vertical-bar" [dataset]="data | occupancyDataset:axis | async" [tooltipOnClick]="true"></app-chart>
<app-chart type="vertical-bar" [dataset]="data | occupancyDataset:axis | async" [tooltipOnClick]="true">
<p *appChartTooltip="let values">
<b>{{ values.y }} personnes</b><br> avaient été enregistrées à la montée entre {{ values.x }}.
</p>
</app-chart>
</div>
......@@ -11,3 +11,7 @@ app-chart {
margin-top: $spacing * 5;
box-sizing: border-box;
}
p {
margin: 0;
}
......@@ -15,17 +15,14 @@ export class OccupancyChartDialogComponent implements OnInit {
let end = '';
dataset.points.forEach(group => {
group.forEach((p, i, list) => {
if (p.x === point.x && p.y === point.y) {
if (p.x === point.x) {
end = list[i + 1] ? list[i + 1].x as string : '23:59';
}
});
});
return `Entre ${value.split(':').join('h')} et ${end.split(':').join('h')} :`;
return `${value.split(':').join('h')} et ${end.split(':').join('h')}`;
},
},
y: {
tooltipFormatter: (value, point: Point, dataset: Dataset) => `${value} personnes`,
}
};
constructor(@Inject(MAT_DIALOG_DATA) public data: NextStop) { }
......
......@@ -54,6 +54,8 @@ export class OccupancyDatasetPipe implements PipeTransform {
}
};
if (!isEmpty) {
// Add a fake value to display the last column correctly.
points.push({ x: '23:59', y: 0 });
dataset.points = [points];
}
} else {
......
......@@ -62,7 +62,7 @@
&::after {
content: "";
position: absolute;
bottom: 0;
bottom: 1px;
left: 50%;
transform: translate(-50%, 100%);
width: 0;
......@@ -71,10 +71,10 @@
border-right: 8px solid transparent;
body.dark-theme & {
border-top: 8px solid rgba(dark-color-overlay(16), 0.9);
border-top: 8px solid rgba(dark-color-overlay(16), 0.99);
}
body.light-theme & {
border-top: 8px solid rgba(light-color-overlay(16), 0.9);
border-top: 8px solid rgba(light-color-overlay(16), 0.99);
}
}
}
......@@ -85,7 +85,7 @@
}
body.dark-theme & {
background: rgba(dark-color-overlay(16), 0.9);
background: rgba(dark-color-overlay(16), 0.99);
span {
&.x {
......@@ -98,7 +98,7 @@
}
}
body.light-theme & {
background: rgba(light-color-overlay(1), 0.9);
background: rgba(light-color-overlay(1), 0.99);
span {
&.x {
......
......@@ -22,3 +22,11 @@
}
}
}
.cdk-overlay-pane.occupancy-chart-dialog {
width: 90%;
@media screen and (min-width: $mpwa-breakpoint + 1px) {
width: 50vw;
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment