Merge pull request #20757 from tspmelo/wip-chart-tooltip

mgr/dashboard_v2: Improve charts tooltips

Reviewed-by: Stephan Müller <smueller@suse.com>
Reviewed-by: Ricardo Marques <rimarques@suse.com>
This commit is contained in:
Lenz Grimmer 2018-03-13 12:45:18 +01:00 committed by GitHub
commit be113e3e92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 651 additions and 307 deletions

View File

@ -0,0 +1,12 @@
<div class="chart-container">
<canvas baseChart
#chartCanvas
[datasets]="chart?.datasets"
[options]="chart?.options"
[chartType]="chart?.chartType">
</canvas>
<div class="chartjs-tooltip"
#chartTooltip>
<table></table>
</div>
</div>

View File

@ -0,0 +1,6 @@
@import '../../../../styles/chart-tooltip.scss';
.chart-container {
height: 500px;
width: 100%;
}

View File

@ -0,0 +1,29 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChartsModule } from 'ng2-charts/ng2-charts';
import { CephfsChartComponent } from './cephfs-chart.component';
describe('CephfsChartComponent', () => {
let component: CephfsChartComponent;
let fixture: ComponentFixture<CephfsChartComponent>;
beforeEach(
async(() => {
TestBed.configureTestingModule({
imports: [ChartsModule],
declarations: [CephfsChartComponent]
}).compileComponents();
})
);
beforeEach(() => {
fixture = TestBed.createComponent(CephfsChartComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,164 @@
import { Component, ElementRef, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
import * as _ from 'lodash';
import * as moment from 'moment';
import { ChartTooltip } from '../../../shared/models/chart-tooltip';
@Component({
selector: 'cd-cephfs-chart',
templateUrl: './cephfs-chart.component.html',
styleUrls: ['./cephfs-chart.component.scss']
})
export class CephfsChartComponent implements OnChanges, OnInit {
@ViewChild('chartCanvas') chartCanvas: ElementRef;
@ViewChild('chartTooltip') chartTooltip: ElementRef;
@Input() mdsCounter: any;
lhsCounter = 'mds.inodes';
rhsCounter = 'mds_server.handle_client_request';
chart: any;
constructor() {}
ngOnInit() {
if (_.isUndefined(this.mdsCounter)) {
return;
}
const getTitle = title => {
return moment(title).format('LTS');
};
const getStyleTop = tooltip => {
return tooltip.caretY - tooltip.height - 15 + 'px';
};
const getStyleLeft = tooltip => {
return tooltip.caretX + 'px';
};
const chartTooltip = new ChartTooltip(
this.chartCanvas,
this.chartTooltip,
getStyleLeft,
getStyleTop
);
chartTooltip.getTitle = getTitle;
chartTooltip.checkOffset = true;
const lhsData = this.convert_timeseries(this.mdsCounter[this.lhsCounter]);
const rhsData = this.delta_timeseries(this.mdsCounter[this.rhsCounter]);
this.chart = {
datasets: [
{
label: this.lhsCounter,
yAxisID: 'LHS',
data: lhsData,
tension: 0.1
},
{
label: this.rhsCounter,
yAxisID: 'RHS',
data: rhsData,
tension: 0.1
}
],
options: {
responsive: true,
maintainAspectRatio: false,
legend: {
position: 'top'
},
scales: {
xAxes: [
{
position: 'top',
type: 'time',
time: {
displayFormats: {
quarter: 'MMM YYYY'
}
}
}
],
yAxes: [
{
id: 'LHS',
type: 'linear',
position: 'left',
min: 0
},
{
id: 'RHS',
type: 'linear',
position: 'right',
min: 0
}
]
},
tooltips: {
enabled: false,
mode: 'index',
intersect: false,
position: 'nearest',
custom: tooltip => {
chartTooltip.customTooltips(tooltip);
}
}
},
chartType: 'line'
};
}
ngOnChanges() {
if (!this.chart) {
return;
}
const lhsData = this.convert_timeseries(this.mdsCounter[this.lhsCounter]);
const rhsData = this.delta_timeseries(this.mdsCounter[this.rhsCounter]);
this.chart.datasets[0].data = lhsData;
this.chart.datasets[1].data = rhsData;
}
// Convert ceph-mgr's time series format (list of 2-tuples
// with seconds-since-epoch timestamps) into what chart.js
// can handle (list of objects with millisecs-since-epoch
// timestamps)
convert_timeseries(sourceSeries) {
const data = [];
_.each(sourceSeries, dp => {
data.push({
x: dp[0] * 1000,
y: dp[1]
});
});
return data;
}
delta_timeseries(sourceSeries) {
let i;
let prev = sourceSeries[0];
const result = [];
for (i = 1; i < sourceSeries.length; i++) {
const cur = sourceSeries[i];
const tdelta = cur[0] - prev[0];
const vdelta = cur[1] - prev[1];
const rate = vdelta / tdelta;
result.push({
x: cur[0] * 1000,
y: rate
});
prev = cur;
}
return result;
}
}

View File

@ -6,6 +6,7 @@ import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
import { AppRoutingModule } from '../../app-routing.module';
import { SharedModule } from '../../shared/shared.module';
import { CephfsChartComponent } from './cephfs-chart/cephfs-chart.component';
import { CephfsService } from './cephfs.service';
import { CephfsComponent } from './cephfs/cephfs.component';
import { ClientsComponent } from './clients/clients.component';
@ -18,7 +19,7 @@ import { ClientsComponent } from './clients/clients.component';
ChartsModule,
ProgressbarModule.forRoot()
],
declarations: [CephfsComponent, ClientsComponent],
declarations: [CephfsComponent, ClientsComponent, CephfsChartComponent],
providers: [CephfsService]
})
export class CephfsModule {}

View File

@ -48,15 +48,9 @@
</div>
<div class="row"
*ngFor="let mdsCounter of objectValues(mdsCounters)">
*ngFor="let mdsCounter of objectValues(mdsCounters); trackBy: trackByFn">
<div class="cold-md-12">
<div class="chart-container">
<canvas baseChart
[datasets]="mdsCounter.datasets"
[options]="mdsCounter.options"
[chartType]="mdsCounter.chartType">
</canvas>
</div>
<cd-cephfs-chart [mdsCounter]="mdsCounter"></cd-cephfs-chart>
</div>
</div>

View File

@ -1,10 +1,3 @@
.chart-container {
position: relative;
margin: auto;
height: 500px;
width: 100%;
}
.progress {
margin-bottom: 0px;
}

View File

@ -6,6 +6,7 @@ import { BsDropdownModule, ProgressbarModule } from 'ngx-bootstrap';
import { Observable } from 'rxjs/Observable';
import { SharedModule } from '../../../shared/shared.module';
import { CephfsChartComponent } from '../cephfs-chart/cephfs-chart.component';
import { CephfsService } from '../cephfs.service';
import { CephfsComponent } from './cephfs.component';
@ -36,7 +37,7 @@ describe('CephfsComponent', () => {
BsDropdownModule.forRoot(),
ProgressbarModule.forRoot()
],
declarations: [CephfsComponent],
declarations: [CephfsComponent, CephfsChartComponent],
providers: [
{ provide: CephfsService, useValue: fakeFilesystemService }
]

View File

@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/c
import { ActivatedRoute } from '@angular/router';
import * as _ from 'lodash';
import { Subscription } from 'rxjs/Subscription';
import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
@ -16,15 +17,10 @@ export class CephfsComponent implements OnInit, OnDestroy {
@ViewChild('poolProgressTmpl') poolProgressTmpl: TemplateRef<any>;
@ViewChild('activityTmpl') activityTmpl: TemplateRef<any>;
routeParamsSubscribe: any;
routeParamsSubscribe: Subscription;
objectValues = Object.values;
single: any[];
multi: any[];
view: any[] = [700, 400];
id: number;
name: string;
ranks: any;
@ -34,11 +30,6 @@ export class CephfsComponent implements OnInit, OnDestroy {
mdsCounters = {};
lhsCounter = 'mds.inodes';
rhsCounter = 'mds_server.handle_client_request';
charts = {};
interval: any;
constructor(
private route: ActivatedRoute,
private cephfsService: CephfsService,
@ -113,119 +104,23 @@ export class CephfsComponent implements OnInit, OnDestroy {
];
this.name = data.cephfs.name;
this.clientCount = data.cephfs.client_count;
this.draw_chart();
});
}
draw_chart() {
this.cephfsService.getMdsCounters(this.id).subscribe(data => {
const topChart = true;
_.each(this.mdsCounters, (value, key) => {
if (data[key] === undefined) {
delete this.mdsCounters[key];
}
});
_.each(data, (mdsData, mdsName) => {
const lhsData = this.convert_timeseries(mdsData[this.lhsCounter]);
const rhsData = this.delta_timeseries(mdsData[this.rhsCounter]);
if (this.mdsCounters[mdsName] === undefined) {
this.mdsCounters[mdsName] = {
datasets: [
{
label: this.lhsCounter,
yAxisID: 'LHS',
data: lhsData,
tension: 0.1
},
{
label: this.rhsCounter,
yAxisID: 'RHS',
data: rhsData,
tension: 0.1
}
],
options: {
responsive: true,
maintainAspectRatio: false,
legend: {
position: 'top',
display: topChart
},
scales: {
xAxes: [
{
position: 'top',
type: 'time',
display: topChart,
time: {
displayFormats: {
quarter: 'MMM YYYY'
}
}
}
],
yAxes: [
{
id: 'LHS',
type: 'linear',
position: 'left',
min: 0
},
{
id: 'RHS',
type: 'linear',
position: 'right',
min: 0
}
]
}
},
chartType: 'line'
};
} else {
this.mdsCounters[mdsName].datasets[0].data = lhsData;
this.mdsCounters[mdsName].datasets[1].data = rhsData;
}
_.each(data, (mdsData: any, mdsName) => {
mdsData.name = mdsName;
this.mdsCounters[mdsName] = mdsData;
});
});
}
// Convert ceph-mgr's time series format (list of 2-tuples
// with seconds-since-epoch timestamps) into what chart.js
// can handle (list of objects with millisecs-since-epoch
// timestamps)
convert_timeseries(sourceSeries) {
const data = [];
_.each(sourceSeries, dp => {
data.push({
x: dp[0] * 1000,
y: dp[1]
});
});
return data;
}
delta_timeseries(sourceSeries) {
let i;
let prev = sourceSeries[0];
const result = [];
for (i = 1; i < sourceSeries.length; i++) {
const cur = sourceSeries[i];
const tdelta = cur[0] - prev[0];
const vdelta = cur[1] - prev[1];
const rate = vdelta / tdelta;
result.push({
x: cur[0] * 1000,
y: rate
});
prev = cur;
}
return result;
trackByFn(index, item) {
return item.name;
}
}

View File

@ -8,6 +8,7 @@ import { TabsModule } from 'ngx-bootstrap/tabs';
import { SharedModule } from '../../shared/shared.module';
import { DashboardService } from './dashboard.service';
import { DashboardComponent } from './dashboard/dashboard.component';
import { HealthPieComponent } from './health-pie/health-pie.component';
import { HealthComponent } from './health/health.component';
import { LogColorPipe } from './log-color.pipe';
import { MdsSummaryPipe } from './mds-summary.pipe';
@ -28,7 +29,8 @@ import { PgStatusPipe } from './pg-status.pipe';
MgrSummaryPipe,
PgStatusPipe,
MdsSummaryPipe,
PgStatusStylePipe
PgStatusStylePipe,
HealthPieComponent
],
providers: [DashboardService]
})

View File

@ -0,0 +1,15 @@
<div class="chart-container">
<canvas baseChart
#chartCanvas
[datasets]="chart.dataset"
[chartType]="chart.chartType"
[options]="chart.options"
[labels]="chart.labels"
[colors]="chart.colors"
width="120"
height="120"></canvas>
<div class="chartjs-tooltip"
#chartTooltip>
<table></table>
</div>
</div>

View File

@ -0,0 +1 @@
@import '../../../../styles/chart-tooltip.scss';

View File

@ -0,0 +1,30 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChartsModule } from 'ng2-charts/ng2-charts';
import { SharedModule } from '../../../shared/shared.module';
import { HealthPieComponent } from './health-pie.component';
describe('HealthPieComponent', () => {
let component: HealthPieComponent;
let fixture: ComponentFixture<HealthPieComponent>;
beforeEach(
async(() => {
TestBed.configureTestingModule({
imports: [ChartsModule, SharedModule],
declarations: [HealthPieComponent]
}).compileComponents();
})
);
beforeEach(() => {
fixture = TestBed.createComponent(HealthPieComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,117 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
ViewChild
} from '@angular/core';
import * as Chart from 'chart.js';
import * as _ from 'lodash';
import { ChartTooltip } from '../../../shared/models/chart-tooltip';
import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
@Component({
selector: 'cd-health-pie',
templateUrl: './health-pie.component.html',
styleUrls: ['./health-pie.component.scss']
})
export class HealthPieComponent implements OnChanges, OnInit {
@ViewChild('chartCanvas') chartCanvasRef: ElementRef;
@ViewChild('chartTooltip') chartTooltipRef: ElementRef;
@Input() data: any;
@Input() tooltipFn: any;
@Output() prepareFn = new EventEmitter();
chart: any = {
chartType: 'doughnut',
dataset: [
{
label: null,
borderWidth: 0
}
],
options: {
responsive: true,
legend: { display: false },
animation: { duration: 0 },
tooltips: {
enabled: false
}
},
colors: [
{
borderColor: 'transparent'
}
]
};
constructor(private dimlessBinary: DimlessBinaryPipe) {}
ngOnInit() {
// An extension to Chart.js to enable rendering some
// text in the middle of a doughnut
Chart.pluginService.register({
beforeDraw: function(chart) {
if (!chart.options.center_text) {
return;
}
const width = chart.chart.width,
height = chart.chart.height,
ctx = chart.chart.ctx;
ctx.restore();
const fontSize = (height / 114).toFixed(2);
ctx.font = fontSize + 'em sans-serif';
ctx.textBaseline = 'middle';
const text = chart.options.center_text,
textX = Math.round((width - ctx.measureText(text).width) / 2),
textY = height / 2;
ctx.fillText(text, textX, textY);
ctx.save();
}
});
const getStyleTop = (tooltip, positionY) => {
return positionY + tooltip.caretY - tooltip.height - 10 + 'px';
};
const getStyleLeft = (tooltip, positionX) => {
return positionX + tooltip.caretX + 'px';
};
const getBody = (body) => {
const bodySplit = body[0].split(': ');
bodySplit[1] = this.dimlessBinary.transform(bodySplit[1]);
return bodySplit.join(': ');
};
const chartTooltip = new ChartTooltip(
this.chartCanvasRef,
this.chartTooltipRef,
getStyleLeft,
getStyleTop,
);
chartTooltip.getBody = getBody;
const self = this;
this.chart.options.tooltips.custom = (tooltip) => {
chartTooltip.customTooltips(tooltip);
};
this.prepareFn.emit([this.chart, this.data]);
}
ngOnChanges() {
this.prepareFn.emit([this.chart, this.data]);
}
}

View File

@ -27,7 +27,9 @@
</div>
<div class="media-body">
<span class="media-heading"
i18n="ceph monitors"><a routerLink="/monitor/">Monitors</a></span>
i18n="ceph monitors">
<a routerLink="/monitor/">Monitors</a>
</span>
<span class="media-text">{{ contentData.mon_status | monSummary }}</span>
</div>
</div>
@ -41,7 +43,9 @@
</div>
<div class="media-body">
<span class="media-heading"
i18n="ceph OSDs"><a routerLink="/osd/">OSDs</a></span>
i18n="ceph OSDs">
<a routerLink="/osd/">OSDs</a>
</span>
<span class="media-text">{{ contentData.osd_map | osdSummary }}</span>
</div>
</div>
@ -94,31 +98,15 @@
<span style="font-size: 45px;">{{ contentData.df.stats.total_objects | dimless }}</span>
</td>
<td>
<div class="center-block pie"
*ngIf="rawUsage.dataset">
<canvas baseChart
id="raw_usage_chart"
[datasets]="rawUsage.dataset"
[chartType]="rawUsage.chartType"
[options]="rawUsage.options"
[labels]="rawUsage.labels"
[colors]="rawUsage.colors"
width="120"
height="120"></canvas>
<div class="center-block pie">
<cd-health-pie [data]="contentData"
(prepareFn)="prepareRawUsage($event[0], $event[1])"></cd-health-pie>
</div>
</td>
<td>
<div class="center-block pie"
*ngIf="poolUsage.dataset">
<canvas baseChart
id="pool_usage_chart"
[datasets]="poolUsage.dataset"
[chartType]="poolUsage.chartType"
[options]="poolUsage.options"
[labels]="poolUsage.labels"
[colors]="poolUsage.colors"
width="120"
height="120"></canvas>
<div class="center-block pie">
<cd-health-pie [data]="contentData"
(prepareFn)="preparePoolUsage($event[0], $event[1])"></cd-health-pie>
</div>
</td>
</tr>

View File

@ -1,36 +1,28 @@
import { HttpClientModule } from '@angular/common/http';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChartsModule } from 'ng2-charts';
import { TabsModule } from 'ngx-bootstrap/tabs';
import { AppModule } from '../../../app.module';
import { SharedModule } from '../../../shared/shared.module';
import { DashboardService } from '../dashboard.service';
import { LogColorPipe } from '../log-color.pipe';
import { MdsSummaryPipe } from '../mds-summary.pipe';
import { MgrSummaryPipe } from '../mgr-summary.pipe';
import { MonSummaryPipe } from '../mon-summary.pipe';
import { OsdSummaryPipe } from '../osd-summary.pipe';
import { PgStatusStylePipe } from '../pg-status-style.pipe';
import { PgStatusPipe } from '../pg-status.pipe';
import { HealthComponent } from './health.component';
describe('HealthComponent', () => {
let component: HealthComponent;
let fixture: ComponentFixture<HealthComponent>;
const dashboardServiceStub = {
const fakeService = {
getHealth() {
return {};
}
};
beforeEach(
async(() => {
TestBed.configureTestingModule({
providers: [
{ provide: DashboardService, useValue: dashboardServiceStub }
],
imports: [AppModule]
providers: [{ provide: DashboardService, useValue: fakeService }],
imports: [SharedModule],
declarations: [HealthComponent]
}).compileComponents();
})
);

View File

@ -1,9 +1,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import * as Chart from 'chart.js';
import * as _ from 'lodash';
import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
import { DashboardService } from '../dashboard.service';
@Component({
@ -13,48 +11,13 @@ import { DashboardService } from '../dashboard.service';
})
export class HealthComponent implements OnInit, OnDestroy {
contentData: any;
interval: any;
poolUsage: any = {
chartType: 'doughnut'
};
rawUsage: any = {
chartType: 'doughnut',
center_text: 0
};
interval: number;
constructor(
private dimlessBinary: DimlessBinaryPipe,
private dashboardService: DashboardService
) {}
constructor(private dashboardService: DashboardService) {}
ngOnInit() {
// An extension to Chart.js to enable rendering some
// text in the middle of a doughnut
Chart.pluginService.register({
beforeDraw: function(chart) {
if (!chart.options.center_text) {
return;
}
const width = chart.chart.width,
height = chart.chart.height,
ctx = chart.chart.ctx;
ctx.restore();
const fontSize = (height / 114).toFixed(2);
ctx.font = fontSize + 'em sans-serif';
ctx.textBaseline = 'middle';
const text = chart.options.center_text,
textX = Math.round((width - ctx.measureText(text).width) / 2),
textY = height / 2;
ctx.fillText(text, textX, textY);
ctx.save();
}
});
this.getInfo();
this.interval = setInterval(() => {
this.interval = window.setInterval(() => {
this.getInfo();
}, 5000);
}
@ -66,80 +29,38 @@ export class HealthComponent implements OnInit, OnDestroy {
getInfo() {
this.dashboardService.getHealth().subscribe((data: any) => {
this.contentData = data;
this.draw_usage_charts();
});
}
draw_usage_charts() {
prepareRawUsage(chart, data) {
let rawUsageChartColor;
const rawUsageText =
Math.round(
100 *
(this.contentData.df.stats.total_used_bytes /
this.contentData.df.stats.total_bytes)
) + '%';
if (
this.contentData.df.stats.total_used_bytes /
this.contentData.df.stats.total_bytes >=
this.contentData.osd_map.full_ratio
) {
Math.round(100 * (data.df.stats.total_used_bytes / data.df.stats.total_bytes)) + '%';
if (data.df.stats.total_used_bytes / data.df.stats.total_bytes >= data.osd_map.full_ratio) {
rawUsageChartColor = '#ff0000';
} else if (
this.contentData.df.stats.total_used_bytes /
this.contentData.df.stats.total_bytes >=
this.contentData.osd_map.backfillfull_ratio
data.df.stats.total_used_bytes / data.df.stats.total_bytes >=
data.osd_map.backfillfull_ratio
) {
rawUsageChartColor = '#ff6600';
} else if (
this.contentData.df.stats.total_used_bytes /
this.contentData.df.stats.total_bytes >=
this.contentData.osd_map.nearfull_ratio
data.df.stats.total_used_bytes / data.df.stats.total_bytes >=
data.osd_map.nearfull_ratio
) {
rawUsageChartColor = '#ffc200';
} else {
rawUsageChartColor = '#00bb00';
}
this.rawUsage = {
chartType: 'doughnut',
dataset: [
{
label: null,
borderWidth: 0,
data: [
this.contentData.df.stats.total_used_bytes,
this.contentData.df.stats.total_avail_bytes
]
}
],
options: {
center_text: rawUsageText,
responsive: true,
legend: { display: false },
animation: { duration: 0 },
tooltips: {
callbacks: {
label: (tooltipItem, chart) => {
return (
chart.labels[tooltipItem.index] +
': ' +
this.dimlessBinary.transform(
chart.datasets[0].data[tooltipItem.index]
)
);
}
}
}
},
colors: [
{
backgroundColor: [rawUsageChartColor, '#424d52'],
borderColor: 'transparent'
}
],
labels: ['Raw Used', 'Raw Available']
};
chart.dataset[0].data = [data.df.stats.total_used_bytes, data.df.stats.total_avail_bytes];
chart.options.center_text = rawUsageText;
chart.colors = [{ backgroundColor: [rawUsageChartColor, '#424d52'] }];
chart.labels = ['Raw Used', 'Raw Available'];
}
preparePoolUsage(chart, data) {
const colors = [
'#3366CC',
'#109618',
@ -166,45 +87,13 @@ export class HealthComponent implements OnInit, OnDestroy {
const poolLabels = [];
const poolData = [];
_.each(this.contentData.df.pools, function(pool, i) {
_.each(data.df.pools, (pool, i) => {
poolLabels.push(pool['name']);
poolData.push(pool['stats']['bytes_used']);
});
this.poolUsage = {
chartType: 'doughnut',
dataset: [
{
label: null,
borderWidth: 0,
data: poolData
}
],
options: {
responsive: true,
legend: { display: false },
animation: { duration: 0 },
tooltips: {
callbacks: {
label: (tooltipItem, chart) => {
return (
chart.labels[tooltipItem.index] +
': ' +
this.dimlessBinary.transform(
chart.datasets[0].data[tooltipItem.index]
)
);
}
}
}
},
colors: [
{
backgroundColor: colors,
borderColor: 'transparent'
}
],
labels: poolLabels
};
chart.dataset[0].data = poolData;
chart.colors = [{ backgroundColor: colors }];
chart.labels = poolLabels;
}
}

View File

@ -1,10 +1,13 @@
<div class="chart-container"
[ngStyle]="style">
<canvas baseChart
<canvas baseChart #sparkCanvas
[labels]="labels"
[datasets]="datasets"
[options]="options"
[colors]="colors"
[chartType]="'line'">
</canvas>
<div class="chartjs-tooltip" #sparkTooltip>
<table></table>
</div>
</div>

View File

@ -1,4 +1,5 @@
@import '../../../../styles/chart-tooltip.scss';
.chart-container {
position: relative;
margin: auto;
position: static !important;
}

View File

@ -1,12 +1,17 @@
import { Component, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { Component, ElementRef, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import { Input } from '@angular/core';
import { ChartTooltip } from '../../../shared/models/chart-tooltip';
@Component({
selector: 'cd-sparkline',
templateUrl: './sparkline.component.html',
styleUrls: ['./sparkline.component.scss']
})
export class SparklineComponent implements OnInit, OnChanges {
@ViewChild('sparkCanvas') chartCanvasRef: ElementRef;
@ViewChild('sparkTooltip') chartTooltipRef: ElementRef;
@Input() data: any;
@Input()
style = {
@ -40,7 +45,10 @@ export class SparklineComponent implements OnInit, OnChanges {
}
},
tooltips: {
enabled: true
enabled: false,
mode: 'index',
intersect: false,
custom: undefined
},
scales: {
yAxes: [
@ -66,7 +74,31 @@ export class SparklineComponent implements OnInit, OnChanges {
constructor() {}
ngOnInit() {}
ngOnInit() {
const getStyleTop = (tooltip, positionY) => {
return (tooltip.caretY - tooltip.height - tooltip.yPadding - 5) + 'px';
};
const getStyleLeft = (tooltip, positionX) => {
return positionX + tooltip.caretX + 'px';
};
const chartTooltip = new ChartTooltip(
this.chartCanvasRef,
this.chartTooltipRef,
getStyleLeft,
getStyleTop
);
chartTooltip.customColors = {
backgroundColor: this.colors[0].pointBackgroundColor,
borderColor: this.colors[0].pointBorderColor
};
this.options.tooltips.custom = tooltip => {
chartTooltip.customTooltips(tooltip);
};
}
ngOnChanges(changes: SimpleChanges) {
this.datasets[0].data = changes['data'].currentValue;

View File

@ -0,0 +1,117 @@
import { ElementRef } from '@angular/core';
import * as _ from 'lodash';
export class ChartTooltip {
tooltipEl: any;
chartEl: any;
getStyleLeft: Function;
getStyleTop: Function;
customColors = {
backgroundColor: undefined,
borderColor: undefined
};
checkOffset = false;
/**
* Creates an instance of ChartTooltip.
* @param {ElementRef} chartCanvas Canvas Element
* @param {ElementRef} chartTooltip Tooltip Element
* @param {Function} getStyleLeft Function that calculates the value of Left
* @param {Function} getStyleTop Function that calculates the value of Top
* @memberof ChartTooltip
*/
constructor(
chartCanvas: ElementRef,
chartTooltip: ElementRef,
getStyleLeft: Function,
getStyleTop: Function
) {
this.chartEl = chartCanvas.nativeElement;
this.getStyleLeft = getStyleLeft;
this.getStyleTop = getStyleTop;
this.tooltipEl = chartTooltip.nativeElement;
}
/**
* Implementation of a ChartJS custom tooltip function.
*
* @param {any} tooltip
* @memberof ChartTooltip
*/
customTooltips(tooltip) {
// Hide if no tooltip
if (tooltip.opacity === 0) {
this.tooltipEl.style.opacity = 0;
return;
}
// Set caret Position
this.tooltipEl.classList.remove('above', 'below', 'no-transform');
if (tooltip.yAlign) {
this.tooltipEl.classList.add(tooltip.yAlign);
} else {
this.tooltipEl.classList.add('no-transform');
}
// Set Text
if (tooltip.body) {
const titleLines = tooltip.title || [];
const bodyLines = tooltip.body.map(bodyItem => {
return bodyItem.lines;
});
let innerHtml = '<thead>';
titleLines.forEach(title => {
innerHtml += '<tr><th>' + this.getTitle(title) + '</th></tr>';
});
innerHtml += '</thead><tbody>';
bodyLines.forEach((body, i) => {
const colors = tooltip.labelColors[i];
let style = 'background:' + (this.customColors.backgroundColor || colors.backgroundColor);
style += '; border-color:' + (this.customColors.borderColor || colors.borderColor);
style += '; border-width: 2px';
const span = '<span class="chartjs-tooltip-key" style="' + style + '"></span>';
innerHtml += '<tr><td nowrap>' + span + this.getBody(body) + '</td></tr>';
});
innerHtml += '</tbody>';
const tableRoot = this.tooltipEl.querySelector('table');
tableRoot.innerHTML = innerHtml;
}
const positionY = this.chartEl.offsetTop;
const positionX = this.chartEl.offsetLeft;
// Display, position, and set styles for font
if (this.checkOffset) {
const halfWidth = tooltip.width / 2;
this.tooltipEl.classList.remove('transform-left');
this.tooltipEl.classList.remove('transform-right');
if (tooltip.caretX - halfWidth < 0) {
this.tooltipEl.classList.add('transform-left');
} else if (tooltip.caretX + halfWidth > this.chartEl.width) {
this.tooltipEl.classList.add('transform-right');
}
}
this.tooltipEl.style.left = this.getStyleLeft(tooltip, positionX);
this.tooltipEl.style.top = this.getStyleTop(tooltip, positionY);
this.tooltipEl.style.opacity = 1;
this.tooltipEl.style.fontFamily = tooltip._fontFamily;
this.tooltipEl.style.fontSize = tooltip.fontSize;
this.tooltipEl.style.fontStyle = tooltip._fontStyle;
this.tooltipEl.style.padding = tooltip.yPadding + 'px ' + tooltip.xPadding + 'px';
}
getBody(body) {
return body;
}
getTitle(title) {
return title;
}
}

View File

@ -0,0 +1,62 @@
.chart-container {
position: absolute;
margin: auto;
cursor: pointer;
overflow: visible;
}
canvas {
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.chartjs-tooltip {
opacity: 0;
position: absolute;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 3px;
-webkit-transition: all 0.1s ease;
transition: all 0.1s ease;
pointer-events: none;
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif !important;
-webkit-transform: translate(-50%, 0);
transform: translate(-50%, 0);
&.transform-left {
transform: translate(-10%, 0);
&::after {
left: 10%;
}
}
&.transform-right {
transform: translate(-90%, 0);
&::after {
left: 90%;
}
}
}
.chartjs-tooltip::after {
content: ' ';
position: absolute;
top: 100%; /* At the bottom of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: black transparent transparent transparent;
}
::ng-deep .chartjs-tooltip-key {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 10px;
}