blob: f877c33082f3b7870407520294482e54a414be27 [file] [log] [blame]
/**
* @module modules/plot-summary-sk
* @description <h2><code>plot-summary-sk</code></h2>
*
* @evt summary_selected - Event produced when the user selects a section on the summary.
*
* @attr
*
* @example
*/
import '@google-web-components/google-chart';
import '../dataframe/dataframe_context';
import { GoogleChart } from '@google-web-components/google-chart';
import { html, LitElement, PropertyValues } from 'lit';
import { ref, createRef } from 'lit/directives/ref.js';
import { define } from '../../../elements-sk/modules/define';
import { MousePosition, Point } from '../plot-simple-sk/plot-simple-sk';
import {
SummaryChartOptions,
convertFromDataframe,
} from '../common/plot-builder';
import { ColumnHeader, DataFrame } from '../json';
import { property } from 'lit/decorators.js';
import { style } from './plot-summary-sk.css';
// Describes the zoom in terms of x-axis source values.
export type ZoomRange = [number, number] | null;
export interface PlotSummarySkSelectionEventDetails {
start: number;
end: number;
valueStart: number | Date;
valueEnd: number | Date;
domain: 'commit' | 'date';
}
export class PlotSummarySk extends LitElement {
static styles = style;
@property({ reflect: true })
domain: 'commit' | 'date' = 'commit';
@property({ attribute: false })
dataframe?: DataFrame;
@property({ attribute: true })
selectedTrace: string | null = null;
// The current selected value saved for re-adjusting selection box.
// The google chart reloads and redraws themselves asynchronously, we need to
// save the selection range and apply it after we received the `ready` event.
private _selectedValueRange: {
begin: ColumnHeader;
end: ColumnHeader;
} | null = null;
constructor() {
super();
this.addEventListeners();
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has('dataframe') ||
changedProperties.has('selectedTrace') ||
changedProperties.has('domain')
) {
this.updateDataframe(this.dataframe!, this.selectedTrace);
}
}
private async updateDataframe(df: DataFrame | null, trace: string | null) {
const plot = this.plotElement.value;
if (!plot) {
if (df) {
console.warn(
'The dataframe is not assigned because the element is not ready. Try call `await this.updateComplete` first.'
);
}
return;
}
const rows = convertFromDataframe(
df,
this.domain,
trace || Object.keys(df?.traceset || {})[0]
);
if (rows) {
plot.data = rows;
plot.options = SummaryChartOptions(getComputedStyle(this), this.domain);
}
}
// The div element that will host the plot on the summary.
private plotElement = createRef<GoogleChart>();
protected render() {
return html`
<div class="container">
<google-chart
${ref(this.plotElement)}
class="plot"
type="line"
@google-chart-ready=${this.onGoogleChartReady}>
</google-chart>
</google-chart>
<canvas
${ref(this.overlayCanvas)}
class="overlay"
@mousedown=${this.mouseDownListener}
@mousemove=${this.mouseMoveListener}
@mouseleave=${this.mouseUpListener}
@mouseup=${this.mouseUpListener}>
</canvas>
</div>
`;
}
connectedCallback() {
super.connectedCallback();
const resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
entries.forEach(() => {
// The google chart needs to redraw when it is resized.
this.plotElement.value?.redraw();
this.requestUpdate();
});
}
);
resizeObserver.observe(this);
}
private onGoogleChartReady() {
// Update the selectionBox because the chart might get updated.
if (!this._selectedValueRange) {
this.clearSelection();
return;
}
this.Select(this._selectedValueRange.begin, this._selectedValueRange.end);
}
protected firstUpdated(_changedProperties: PropertyValues): void {
if (!this.overlayCanvas.value) {
return;
}
const resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.target !== this.overlayCanvas.value) {
return;
}
// We need to resize the canvas after its bounding rect is changed,
const boundingRect = entry.contentRect;
this.overlayCanvas.value!.width = boundingRect!.width;
this.overlayCanvas.value!.height = boundingRect!.height;
this.overlayCtx!.globalAlpha = 0.5;
});
}
);
resizeObserver.observe(this.overlayCanvas.value!);
// globalAlpha denotes the transparency of the fill. Setting this to 50%
// allows us to show the highlight plus the portion of the plot highlighted.
this.overlayCtx!.globalAlpha = 0.5;
this.drawSummaryRect();
requestAnimationFrame(() => this.raf());
}
// Select the provided range on the plot-summary.
public Select(begin: ColumnHeader, end: ColumnHeader) {
const isCommitScale = this.domain === 'commit';
const col = isCommitScale ? 'offset' : 'timestamp';
const chart = this.chartLayout;
const startX = chart?.getXLocation(
isCommitScale ? begin[col] : (new Date(begin[col] * 1000) as any)
);
const endX = chart?.getXLocation(
isCommitScale ? end[col] : (new Date(end[col] * 1000) as any)
);
this.selectionRange = [startX || 0, endX || 0];
this._selectedValueRange = { begin, end };
this.drawSelection();
}
// Get the underlying ChartLayoutInterface.
// This provides API to inspect the traces and coordinates.
private get chartLayout(): google.visualization.ChartLayoutInterface | null {
const gchart = this.plotElement.value;
if (!gchart) {
return null;
}
const wrapper = gchart['chartWrapper'] as google.visualization.ChartWrapper;
if (!wrapper) {
return null;
}
const chart = wrapper.getChart();
return (
chart &&
(chart as google.visualization.CoreChartBase).getChartLayoutInterface()
);
}
// 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.dataframe) {
this.plotElement.value!.options = SummaryChartOptions(
getComputedStyle(this),
this.domain
);
}
this.requestUpdate();
});
}
// ======== Selection using Canvas =========
// Below is using the canvas to track and draw user selections.
// This gives us ability to draw our own stylish UIs and also requires to
// manage user interaction ourselves. The canvas does require a bit more
// frequent update and draws than native HTML elements w/o optimizations.
private get overlayCtx() {
return this.overlayCanvas.value?.getContext('2d');
}
// Array denoting the start and end points of the current selection.
private selectionRange: ZoomRange = null;
// Array denoting the diffs between the currently clicked point and
// the start and end points of the current selection. This is used
// to maintain the size of the selection when user drags it left/right.
private lockedSelectionDiffs: [number, number] | null = null;
// Denotes if the user is currently making a selection.
private isCurrentlySelecting: boolean = false;
// Tracks the mouse position.
private currentMousePosition: MousePosition | null = null;
// The canvas element that is used for the selection overlay.
private overlayCanvas = createRef<HTMLCanvasElement>();
// Clear the current selection.
private clearSelection(): void {
this.overlayCtx?.clearRect(
0,
0,
this.overlayCanvas.value?.width || 0,
this.overlayCanvas.value?.height || 0
);
}
// Listener for the mouse down event.
private mouseDownListener(e: MouseEvent) {
e.preventDefault();
const point = this.eventToCanvasPt(e);
this.currentMousePosition = point;
if (this.inSelectedArea({ x: point.clientX, y: point.clientY })) {
// Implement the drag functionality to drag the selected area around.
// This means the user is dragging the current selection around.
// Let's get the left and right diffs between the current mouse position
// and the ends of the selection range. As the mouse moves, we will update
// the selection range so that these diffs remain constant.
const leftDiff =
this.currentMousePosition.clientX - this.selectionRange![0];
const rightDiff =
this.selectionRange![1] - this.currentMousePosition.clientX;
this.lockedSelectionDiffs = [leftDiff, rightDiff];
} else {
// User is starting a new selection.
this.selectionRange = [point.clientX, point.clientX + 0.1];
this.isCurrentlySelecting = true;
}
}
// Listener for the mouse up event.
private mouseUpListener() {
// Releasing the mouse means selection/dragging is done.
this.isCurrentlySelecting = false;
this.lockedSelectionDiffs = null;
this.summarySelected();
}
private summarySelected() {
if (this.selectionRange !== null) {
const start =
this.chartLayout?.getHAxisValue(this.selectionRange[0]) || 0;
const end = this.chartLayout?.getHAxisValue(this.selectionRange[1]) || 0;
this.dispatchEvent(
new CustomEvent<PlotSummarySkSelectionEventDetails>(
'summary_selected',
{
detail: {
start: this.selectionRange[0],
end: this.selectionRange[1],
valueStart:
this.domain === 'date'
? (start as any).getTime() / 1000
: start,
valueEnd:
this.domain === 'date' ? (end as any).getTime() / 1000 : end,
domain: this.domain,
},
bubbles: true,
}
)
);
}
}
// Listener for the mouse move event.
private mouseMoveListener(e: MouseEvent) {
// Keep track of the user's mouse movements.
const point = this.eventToCanvasPt(e);
this.currentMousePosition = point;
}
// Draw the summary rectangle outline.
private drawSummaryRect(): void {
if (!this.overlayCanvas) {
return;
}
const style = getComputedStyle(this);
this.overlayCtx!.lineWidth = 3.0;
this.overlayCtx!.strokeStyle = style.color;
this.overlayCtx!.strokeRect(0, 0, this.width, this.height);
this.overlayCtx!.save();
}
/** Mirrors the width attribute. */
get width(): number {
return this.getBoundingClientRect().width;
}
/** Mirrors the height attribute. */
get height(): number {
return this.getBoundingClientRect().height;
}
// Converts an event to a specific point
private eventToCanvasPt(e: MousePosition) {
const clientRect = this.plotElement.value!.getBoundingClientRect();
return {
clientX: e.clientX - clientRect!.left,
clientY: e.clientY - clientRect!.top,
};
}
// Draws the selection area.
private drawSelection(): void {
this.clearSelection();
if (this.overlayCanvas.value && this.selectionRange !== null) {
// Draw left line.
const startx = this.selectionRange[0];
this.drawVerticalLineAtPosition(startx);
// Draw the right line.
const endx = this.selectionRange[1];
this.drawVerticalLineAtPosition(endx);
// Shade the selected section.
this.overlayCtx!.beginPath();
this.overlayCtx!.fillStyle = getComputedStyle(this).getPropertyValue(
'--sk-summary-highlight'
);
this.overlayCtx!.rect(startx, 0, endx - startx, this.height);
this.overlayCtx!.fill();
}
}
// Draw a vertical line at the given position
private drawVerticalLineAtPosition(x: number) {
// Start at the bottom of the rectangle
this.overlayCtx!.moveTo(x, 0);
this.overlayCtx!.lineTo(x, this.height);
this.overlayCtx!.stroke();
}
// Handles the animation depending on the current state.
private raf() {
// Always queue up our next raf first.
window.requestAnimationFrame(() => this.raf());
// Exit early if there is no ongoing activity.
if (this.currentMousePosition === null) {
return;
}
if (this.isCurrentlySelecting) {
// If the user is currently selecting an area, update the selection range
// array based on the current mouse position.
let startx = this.selectionRange![0];
let endx = this.selectionRange![1];
const currentx = this.currentMousePosition.clientX;
// Figure out the closest end of the selection to the current position.
// This tells us the direction in which the user is highlighting.
const isMovingOnLeft =
Math.abs(currentx - startx) < Math.abs(currentx - endx);
if (isMovingOnLeft) {
// If the mouse is towards the left side, we update the start values.
startx = currentx;
} else {
// If the mouse is towards the right, we update the end values.
endx = currentx;
}
this.selectionRange![0] = startx;
this.selectionRange![1] = endx;
} else if (this.lockedSelectionDiffs !== null) {
// User is dragging the current selection around.
// Update the selectionRange with the values that keeps these
// diffs constant wrt the current mouse position. Also account for the fact
// that the user may drag the selected area beyond the bounds of the rendered rect.
this.selectionRange![0] = Math.max(
0,
this.currentMousePosition.clientX - this.lockedSelectionDiffs![0]
);
this.selectionRange![1] = Math.min(
this.width,
this.currentMousePosition.clientX + this.lockedSelectionDiffs![1]
);
}
this.drawSelection();
}
// Checks if a point is inside the selected area.
private inSelectedArea(pt: Point): boolean {
if (this.selectionRange === null) {
return false;
}
return pt.x >= this.selectionRange[0] && pt.x < this.selectionRange[1];
}
}
define('plot-summary-sk', PlotSummarySk);
declare global {
interface HTMLElementTagNameMap {
'plot-summary-sk': PlotSummarySk;
}
interface GlobalEventHandlersEventMap {
summary_selected: CustomEvent<PlotSummarySkSelectionEventDetails>;
}
}