blob: 11f5602be841822a87f6be57ef751bf9eda9ea50 [file] [log] [blame]
/**
* @module modules/plot-google-chart-sk
* @description <h2><code>plot-google-chart-sk</code></h2>
*
* @evt
*
* @attr
*
* @example
*/
import '@google-web-components/google-chart';
import '@material/web/progress/linear-progress';
import { GoogleChart } from '@google-web-components/google-chart';
import { consume } from '@lit/context';
import { html, css } from 'lit';
import { LitElement, PropertyValues } from 'lit';
import { ref, Ref, createRef } from 'lit/directives/ref.js';
import { property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import { define } from '../../../elements-sk/modules/define';
import { Anomaly, AnomalyMap } from '../json';
import { mainChartOptions } from '../common/plot-builder';
import {
dataframeAnomalyContext,
dataframeLoadingContext,
dataframeUserIssueContext,
DataTable,
dataTableContext,
UserIssueMap,
} from '../dataframe/dataframe_context';
import { isSingleTrace } from '../dataframe/traceset';
import { range } from '../dataframe/index';
import { VResizableBoxSk } from './v-resizable-box-sk';
import { SidePanelSk } from './side-panel-sk';
export interface AnomalyData {
x: number;
y: number;
anomaly: Anomaly;
highlight: boolean;
}
export interface PlotSelectionEventDetails {
value: range;
domain: 'commit' | 'date';
}
export interface PlotShowTooltipEventDetails {
row: number;
col: number;
}
export class PlotGoogleChartSk extends LitElement {
// TODO(b/362831653): Adjust height to 100% once plot-summary-sk is deprecated
static styles = css`
:host {
background-color: var(--plot-background-color-sk, var(--md-sys-color-background, 'white'));
}
slot {
display: none;
}
.container {
display: flex;
height: 100%;
}
.side {
max-width: 20%;
}
.plot {
height: 100%;
flex-grow: 1;
}
.anomaly {
position: absolute;
top: 0px;
left: 0px;
md-icon {
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
font-weight: bolder;
font-size: 24px;
color: darkblue;
}
md-icon.improvement {
background-color: rgba(3, 151, 3, 1);
font-weight: bolder;
border: black 1px solid;
}
md-icon.untriage {
background-color: yellow;
border: magenta 1px solid;
}
md-icon.regression {
background-color: violet;
border: orange 1px solid;
}
}
.userissue {
position: absolute;
top: 0px;
left: 0px;
md-icon {
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
font-weight: bolder;
font-size: 12px;
color: var(--primary);
}
md-icon.issue {
background: var(--background);
border: white 1px solid;
}
}
.delta {
position: absolute;
border: 1px solid purple;
background-color: rgba(255, 255, 0, 0.2); /* semi-transparent yellow */
top: 0px;
p {
position: relative;
top: 10px;
left: 10px;
}
}
md-linear-progress {
position: absolute;
width: 100%;
--md-linear-progress-active-indicator-height: 8px;
--md-linear-progress-track-height: 8px;
--md-linear-progress-track-shape: 8px;
}
`;
@consume({ context: dataframeLoadingContext, subscribe: true })
private loading = false;
@consume({ context: dataTableContext, subscribe: true })
@property({ attribute: false })
data: DataTable = null;
@property({})
selectedTraces: string[] | null = null;
@property({ reflect: true })
domain: 'commit' | 'date' = 'commit';
@property({ attribute: false })
selectedRange?: range;
@consume({ context: dataframeAnomalyContext, subscribe: true })
@property({ attribute: false })
private anomalyMap: AnomalyMap = {};
@consume({ context: dataframeUserIssueContext, subscribe: true })
@property({ attribute: false })
private userIssues: UserIssueMap = {};
// The slots to place in the templated icons for anomalies.
private slots = {
untriage: createRef<HTMLSlotElement>(),
regression: createRef<HTMLSlotElement>(),
improvement: createRef<HTMLSlotElement>(),
issue: createRef<HTMLSlotElement>(),
};
// Modes for chart interaction with mouse.
// We only have panning and deltaY for now.
// Default behavior is null.
// - panning (enabled by dragging) pans the chart to the left or right
// - deltaY (enabled with shift-click) calculates the delta on the
// y-axis between the start and end cursor.
@property({ attribute: false })
private navigationMode: 'pan' | 'deltaY' | null = null;
private lastMouse = { x: 0, y: 0 };
// The value distance when moving by 1px on the screen.
// Commits and dates operate on different scales, so adjust accordingly.
private valueDelta = { commit: 1, date: 1000 };
private cachedChartArea = { left: 0, top: 0, width: 0, height: 0 };
// Whether we are interacting with the chart that takes higher prioritiy than navigations.
private chartInteracting = false;
// cache the googleChart object within the module
private chart: google.visualization.CoreChartBase | null = null;
constructor() {
super();
this.addEventListeners();
}
// The div element that will host the plot on the summary.
private plotElement: Ref<GoogleChart> = createRef();
// The div container for anomaly overlays.
private anomalyDiv = createRef<HTMLDivElement>();
// The div container for user issue overlays.
private userIssueDiv = createRef<HTMLDivElement>();
// The div container for delta y selection range.
private deltaRangeBox = createRef<VResizableBoxSk>();
// The div container for the legend
private sidePanel = createRef<SidePanelSk>();
connectedCallback(): void {
super.connectedCallback();
const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
entries.forEach(() => {
// The google chart needs to redraw when it is resized.
this.plotElement.value?.redraw();
});
});
resizeObserver.observe(this);
}
protected render() {
// TODO(b/370804498): Break out plot panel into a separate module
// and create a new module that combines google chart and the
// tooltip module.
return html`
<div class="container">
<google-chart
${ref(this.plotElement)}
class="plot"
type="line"
.events=${['onmouseover', 'onmouseout']}
@mousedown=${this.onChartMouseDown}
@mouseup=${this.onChartMouseUp}
@google-chart-onmouseover=${this.onChartMouseOver}
@google-chart-onmouseout=${this.onChartMouseOut}
@google-chart-ready=${this.onChartReady}>
</google-chart>
${when(this.loading, () => html`<md-linear-progress indeterminate></md-linear-progress>`)}
<div class="anomaly" ${ref(this.anomalyDiv)}></div>
<div class="userissue" ${ref(this.userIssueDiv)}></div>
<v-resizable-box-sk ${ref(this.deltaRangeBox)}} @mouseup=${this.onChartMouseUp}>
</v-resizable-box-sk>
<div class="side" ?hidden=${isSingleTrace(this.data) ?? true}>
<side-panel-sk ${ref(this.sidePanel)} @side-panel-toggle=${this.onSidePanelToggle}>
</side-panel-sk>
</div>
</div>
<slot name="untriage" ${ref(this.slots.untriage)}></slot>
<slot name="regression" ${ref(this.slots.regression)}></slot>
<slot name="improvement" ${ref(this.slots.improvement)}></slot>
<slot name="issue" ${ref(this.slots.issue)}></slot>
`;
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('anomalyMap')) {
// If the anomalyMap is getting updated,
// trigger the chart to redraw and plot the anomaly.
this.plotElement.value?.redraw();
} else if (changedProperties.has('userIssues')) {
this.plotElement.value?.redraw();
} else if (changedProperties.has('selectedRange')) {
// If only the selectedRange is updated, then we only update the viewWindow.
this.updateOptions();
} else if (changedProperties.has('domain')) {
this.toggleSelectionRange();
this.updateDataView(this.data);
} else if (changedProperties.has('data')) {
this.updateDataView(this.data);
}
}
private async updateDataView(dt: DataTable) {
await this.updateComplete;
const plot = this.plotElement.value;
if (!plot || !dt) {
if (dt) {
console.warn('The datatable is not assigned because the element is not ready.');
}
return;
}
const view = new google.visualization.DataView(dt!);
const ncols = view.getNumberOfColumns();
// The first two columns are the commit position and the date.
const cols = [this.domain === 'commit' ? 0 : 1];
for (let index = 2; index < ncols; index++) {
cols.push(index);
}
view.setColumns(cols);
plot.view = view;
this.updateOptions();
}
// if new domain is commit, convert from date to commit and vice-versa
// the way this function works is imperfect. The ideal way to do this
// is for explore-simple-sk to be aware of the selection range from
// plot-summary-sk and can pass that to plot-google-chart. However,
// explore-simple-sk is not a lit module and toggling the domain using
// event listeners could create more complications
// Instead, we use the existing range and query the data to determine
// the new range
// TODO(b/362831653): Fix frame shifting from toggling domain
// Not sure the cause, could be the timezone?
private toggleSelectionRange() {
const newScale = this.domain === 'commit';
const currBegin = newScale
? (new Date(this.selectedRange!.begin! * 1000) as any)
: this.selectedRange!.begin!;
const currEnd = newScale
? (new Date(this.selectedRange!.end! * 1000) as any)
: this.selectedRange!.end!;
const fromCol = newScale ? 1 : 0;
const toCol = newScale ? 0 : 1;
const rows = this.data!.getFilteredRows([
{
column: fromCol,
minValue: currBegin,
maxValue: currEnd,
},
]);
const begin = this.data!.getValue(rows[0], toCol);
const end = this.data!.getValue(rows[rows.length - 1], toCol);
this.selectedRange = {
begin: newScale ? begin : (begin as any).getTime() / 1000,
end: newScale ? end : (end as any).getTime() / 1000,
};
}
private updateOptions() {
const plot = this.plotElement.value;
if (!plot) {
return;
}
const options = mainChartOptions(getComputedStyle(this), this.domain);
const begin = this.selectedRange?.begin;
const end = this.selectedRange?.end;
const commitScale = this.domain === 'commit';
options.hAxis!.viewWindow = {
min: commitScale ? begin : (new Date(begin! * 1000) as any),
max: commitScale ? end : (new Date(end! * 1000) as any),
};
plot.options = options;
}
// Add all the event listeners.
private addEventListeners(): void {
// If the user toggles the theme to/from darkmode then redraw.
document.addEventListener('theme-chooser-toggle', () => {
// Update the options to trigger the redraw.
if (this.plotElement.value) {
this.plotElement.value!.options = mainChartOptions(getComputedStyle(this), this.domain);
}
this.requestUpdate();
});
// We add listeners on the window so we can still track even the mouse is outside the chart
// area.
window.addEventListener('mousemove', (e) => {
this.onWindowMouseMove(e);
});
window.addEventListener('mouseup', () => {
this.onWindowMouseUp();
});
}
private onSidePanelToggle() {
this.plotElement.value?.redraw();
}
private onChartMouseDown(e: MouseEvent) {
// if user holds down shift-click, enable delta range calculation
if (e.shiftKey) {
e.preventDefault(); // disable system events
this.navigationMode = 'deltaY';
const layout = this.chart!.getChartLayoutInterface();
const area = layout.getChartAreaBoundingBox();
const deltaRangeBox = this.deltaRangeBox.value!;
deltaRangeBox.show(
{ top: area.top, left: area.left, width: area.width },
{ coord: e.offsetY, value: layout.getVAxisValue(e.offsetY) }
);
return;
}
// This disable system events like selecting texts.
e.preventDefault();
this.navigationMode = 'pan';
this.lastMouse = { x: e.x, y: e.y };
}
private onChartMouseOver(e: CustomEvent) {
if (this.navigationMode === 'deltaY') {
return;
}
this.chartInteracting = true;
this.dispatchEvent(
new CustomEvent<PlotShowTooltipEventDetails>('plot-data-mouseover', {
bubbles: true,
composed: true,
detail: {
row: e.detail.data.row,
col: e.detail.data.column,
},
})
);
}
private onChartMouseUp() {
this.chartInteracting = false;
this.navigationMode = null;
this.deltaRangeBox.value?.hide();
}
private onChartMouseOut() {
this.chartInteracting = false;
this.navigationMode = this.navigationMode === 'deltaY' ? 'deltaY' : null;
}
private onWindowMouseMove(e: MouseEvent) {
if (this.navigationMode === 'deltaY') {
e.preventDefault(); // disable system events
const layout = this.chart!.getChartLayoutInterface();
const deltaRangeBox = this.deltaRangeBox.value!;
deltaRangeBox.updateSelection({
coord: e.offsetY,
value: layout.getVAxisValue(e.offsetY),
});
return;
}
if (this.navigationMode !== 'pan') {
return;
}
const valueDelta = this.domain === 'commit' ? this.valueDelta.commit : this.valueDelta.date;
const deltaX = (this.lastMouse.x - e.x) * valueDelta;
this.lastMouse.x = e.x;
this.selectedRange!.begin += deltaX;
this.selectedRange!.end += deltaX;
this.updateOptions();
this.dispatchEvent(
new CustomEvent<PlotSelectionEventDetails>('selection-changing', {
bubbles: true,
composed: true,
detail: {
value: this.selectedRange!,
domain: this.domain,
},
})
);
}
private onWindowMouseUp() {
if (this.navigationMode === 'pan') {
this.dispatchEvent(
new CustomEvent<PlotSelectionEventDetails>('selection-changed', {
bubbles: true,
composed: true,
detail: {
value: this.selectedRange!,
domain: this.domain,
},
})
);
}
this.navigationMode = null;
this.chartInteracting = false;
}
private drawAnomaly(chart: google.visualization.CoreChartBase) {
const layout = chart.getChartLayoutInterface();
if (!this.anomalyMap) {
return;
}
const anomalyDiv = this.anomalyDiv.value;
if (!anomalyDiv) {
return;
}
const data = this.data!;
const traceKeys = this.selectedTraces ?? Object.keys(this.anomalyMap);
const chartRect = layout.getChartAreaBoundingBox();
const left = chartRect.left,
top = chartRect.top;
const right = left + chartRect.width,
bottom = top + chartRect.height;
const allDivs: Node[] = [];
// Clone from the given template icons in the named slots.
// Each anomaly will clone a new icon element from the template slots and be placed in the
// anomaly container.
const slots = this.slots;
const cloneSlot = (
name: 'untriage' | 'improvement' | 'regression',
traceKey: string,
commit: number
) => {
const assigned = slots[name].value!.assignedElements();
if (!assigned) {
console.warn(
'could not find anomaly template for commit',
`at ${commit} of ${traceKey} (${name}).`
);
return null;
}
if (assigned.length > 1) {
console.warn(
'multiple children found but only use the first one for commit',
`at ${commit} of ${traceKey} (${name}).`
);
}
const cloned = assigned[0].cloneNode(true) as HTMLElement;
cloned.className = `anomaly ${name}`;
return cloned;
};
traceKeys.forEach((key) => {
const anomalies = this.anomalyMap![key];
const traceCol = this.data!.getColumnIndex(key)!;
for (const cp in anomalies) {
const offset = Number(cp);
const rows = data.getFilteredRows([{ column: 0, value: offset }]);
if (rows.length < 0) {
console.warn('anomaly data is out of existing dataframe, ignored.');
continue;
}
const xValue =
this.domain === 'commit' ? data.getValue(rows[0], 0) : data.getValue(rows[0], 1);
const yValue = data.getValue(rows[0], traceCol);
const x = layout.getXLocation(xValue);
const y = layout.getYLocation(yValue);
// We only place the anomaly icons if they are within the chart boundary.
// p.s. the top and bottom are reversed-coordinated.
if (x < left || x > right || y < top || y > bottom) {
continue;
}
const anomaly = anomalies[offset];
let cloned: HTMLElement | null;
// TODO(b/377751454): add separate anomaly icon for "ignored"
if (anomaly.bug_id <= 0) {
// no bug assigned, untriaged anomaly
cloned = cloneSlot('untriage', key, offset);
} else if (anomaly.is_improvement) {
cloned = cloneSlot('improvement', key, offset);
} else {
cloned = cloneSlot('regression', key, offset);
}
if (cloned) {
cloned.style.top = `${y}px`;
cloned.style.left = `${x}px`;
allDivs.push(cloned);
}
}
});
// replaceChildren API could be already the most efficient API to replace all the children
// nodes. Alternatively, we could cache all the existing elements for each buckets and place
// them to the new locations, but the rendering internal may already do this for us.
// We should only do this optimization if we see a performance issue.
anomalyDiv.replaceChildren(...allDivs);
}
private drawUserIssues(chart: google.visualization.CoreChartBase) {
const layout = chart.getChartLayoutInterface();
if (!this.userIssues) {
return;
}
const userIssueDiv = this.userIssueDiv.value;
if (!userIssueDiv) {
return;
}
const data = this.data!;
const traceKeys = this.selectedTraces ?? Object.keys(this.userIssues);
const chartRect = layout.getChartAreaBoundingBox();
const left = chartRect.left,
top = chartRect.top;
const right = left + chartRect.width,
bottom = top + chartRect.height;
const allDivs: Node[] = [];
// Clone from the given template icons in the named slots.
const slots = this.slots;
const cloneSlot = (name: 'issue', traceKey: string, commit: number) => {
const assigned = slots[name].value!.assignedElements();
if (!assigned || assigned.length === 0) {
console.warn(
'could not find user issue template for commit',
`at ${commit} of ${traceKey} (${name}).`
);
return null;
}
if (assigned.length > 1) {
console.warn(
'multiple children found but only use the first one for commit',
`at ${commit} of ${traceKey} (${name}).`
);
}
const cloned = assigned[0].cloneNode(true) as HTMLElement;
cloned.className = `userissue ${name}`;
return cloned;
};
traceKeys.forEach((key) => {
const userIssues = this.userIssues![key];
const traceCol = this.data!.getColumnIndex(key)!;
for (const [cp, issueDetail] of Object.entries(userIssues)) {
const offset = Number(cp);
const anomaliesOnTraces = this.anomalyMap![key];
if (anomaliesOnTraces !== null && anomaliesOnTraces !== undefined) {
const a = anomaliesOnTraces[offset];
if (a !== null && a !== undefined) {
console.warn('user issue same as anomaly, ignored.');
continue;
}
}
const rows = data.getFilteredRows([{ column: 0, value: offset }]);
if (rows.length < 0) {
console.warn('user issue data is out of existing dataframe, ignored.');
continue;
}
if (issueDetail.bugId === 0) {
continue;
}
const xValue =
this.domain === 'commit' ? data.getValue(rows[0], 0) : data.getValue(rows[0], 1);
const yValue = data.getValue(rows[0], traceCol);
const x = layout.getXLocation(xValue);
const y = layout.getYLocation(yValue);
// We only place the user issue icons if they are within the chart boundary.
// p.s. the top and bottom are reversed-coordinated.
if (x < left || x > right || y < top || y > bottom) {
continue;
}
const cloned: HTMLElement | null = cloneSlot('issue', key, offset);
if (cloned) {
cloned.style.top = `${y}px`;
cloned.style.left = `${x}px`;
allDivs.push(cloned);
}
}
});
userIssueDiv.replaceChildren(...allDivs);
}
private onChartReady(e: CustomEvent) {
this.chart = e.detail.chart as google.visualization.CoreChartBase;
// Only draw the anomaly when the chart is ready.
this.drawAnomaly(this.chart);
this.drawUserIssues(this.chart);
const layout = this.chart.getChartLayoutInterface();
const area = layout.getChartAreaBoundingBox();
if (
area.left !== this.cachedChartArea.left ||
area.top !== this.cachedChartArea.top ||
area.height !== this.cachedChartArea.height ||
area.width !== this.cachedChartArea.width
) {
this.cachedChartArea = area;
}
}
// TODO(b/362831653): deprecate this, no longer needed
public updateChartData(_chartData: any) {}
/**
* Get the (x,y) position in the chart given row and column
* of the dataframe.
* @param index An index containing the row and column indexes.
*/
getPositionByIndex(index: { row: number; col: number }): { x: number; y: number } {
if (!this.chart) {
return { x: 0, y: 0 };
}
const domainColumn = this.domain === 'commit' ? 0 : 1;
const layout = (this.chart as google.visualization.LineChart).getChartLayoutInterface();
const xValue = this.data!.getValue(index.row, domainColumn);
const yValue = this.data!.getValue(index.row, index.col + 1);
return {
x: layout.getXLocation(xValue),
y: layout.getYLocation(yValue),
};
}
/**
* Get the commit position of a trace given the row index
* of the DataTable. The row index represents the x-position
* of the data. The commit position is always in the first
* in the first column.
* @param row The row index
*/
getCommitPosition(row: number) {
return this.data!.getValue(row + 1, 0);
}
/**
* Get the trace name of a trace given the column index
* of the DataTable. The trace name is always in the first
* row. This method makes no modifications to the trace name.
* @param col The col index
*/
getTraceName(col: number): string {
// TODO(b/370804498): Create another getTraceName method that
// returns a prettified version of the name. i.e.
// ,arch=x86,config=8888,test=decode,units=kb, becomes
// x86/8888/decode/kb.
// first two columns of DataTable are commit position and date
return this.data!.getColumnLabel(col + 1);
}
/**
* Get the Y value of a trace given the row and column index
* of the dataframe.
* @param index An index containing the row and column indexes.
*/
getYValue(index: { row: number; col: number }): number {
// first two columns of DataTable are commit position and date
return this.data!.getValue(index.row, index.col + 1);
}
/**
* Unselect all selections on the chart.
*/
unselectAll(): void {
this.chart!.setSelection([]);
}
}
define('plot-google-chart-sk', PlotGoogleChartSk);
declare global {
interface HTMLElementTagNameMap {
'plot-google-chart-sk': PlotGoogleChartSk;
}
interface GlobalEventHandlersEventMap {
'selection-changing': CustomEvent<PlotSelectionEventDetails>;
'selection-changed': CustomEvent<PlotSelectionEventDetails>;
'plot-data-mouseover': CustomEvent<PlotShowTooltipEventDetails>;
'plot-chart-mousedown': CustomEvent;
}
}