blob: 529be208773e95c9352d62e25efaf9d78402767f [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 { html } from 'lit/html.js';
import * as d3Scale from 'd3-scale';
import { load } from '@google-web-components/google-chart/loader';
import { define } from '../../../elements-sk/modules/define';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { MousePosition, Point } from '../plot-simple-sk/plot-simple-sk';
import '@google-web-components/google-chart/';
import { ChartData, DrawSummaryChart } from '../common/plot-builder';
const ZOOM_RECT_COLOR = '#0007';
// 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;
}
export class PlotSummarySk extends ElementSk {
constructor() {
super(PlotSummarySk.template);
this._upgradeProperty('width');
this._upgradeProperty('height');
}
static get observedAttributes(): string[] {
return ['width', 'height'];
}
private overlayCtx: CanvasRenderingContext2D | null = null;
/** The window.devicePixelRatio. */
private scale: number = 1.0;
// This contains a mapping of the coordinates on the summary bar to
// the corresponding values. This helps us translate the position of
// the selected area to the corresponding values that they represent.
private valuesRangeCommit: d3Scale.ScaleLinear<number, number> | null = null;
private commitsStart: number = 0;
private commitsEnd: number = 0;
private valuesRangeDate: d3Scale.ScaleLinear<number, number> | null = null;
private dateStart: Date = new Date();
private dateEnd: Date = new Date();
private isCommitScale: boolean = false;
// 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 div element that will host the plot on the summary.
private plotElement: HTMLElement | null = null;
// The canvas element that is used for the selection overlay.
private overlayCanvas: HTMLCanvasElement | null = null;
// Keeps a track of the current chart data being displayed.
private currentChartData: ChartData | null = null;
private static template = (ele: PlotSummarySk) => html`
<div
id="plot"
class="plot"
width=${ele.width * window.devicePixelRatio}
height=${ele.height * window.devicePixelRatio}
style="transform-origin: 0 0;"></div>
<canvas
id="overlay"
class="overlay"
width=${ele.width * window.devicePixelRatio}
height=${ele.height * window.devicePixelRatio}
style="transform-origin: 0 0;"></canvas>
`;
async connectedCallback(): Promise<void> {
super.connectedCallback();
const resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
entries.forEach((entry) => {
this.width = entry.contentRect.width;
this.height = entry.contentRect.height;
if (this.currentChartData !== null) {
this.render();
}
});
}
);
resizeObserver.observe(this);
this.render();
window.requestAnimationFrame(this.raf.bind(this));
}
attributeChangedCallback(
_: string,
oldValue: string,
newValue: string
): void {
if (oldValue !== newValue) {
this.render();
}
}
render(): void {
this._render();
this.plotElement = this.querySelector<HTMLElement>('#plot')!;
this.overlayCanvas = this.querySelector<HTMLCanvasElement>('#overlay')!;
this.overlayCtx = this.overlayCanvas.getContext('2d');
// 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.addEventListeners();
this.drawSummaryRect();
}
private async _renderChart() {
if (this.isCommitScale) {
this.commitsStart = this.currentChartData!.start as number;
this.commitsEnd = this.currentChartData!.end as number;
this.valuesRangeCommit = d3Scale
.scaleLinear()
.domain([0, this.width])
.range([this.commitsStart, this.commitsEnd]);
} else {
this.dateStart = this.currentChartData!.start as Date;
this.dateEnd = this.currentChartData!.end as Date;
this.valuesRangeDate = d3Scale
.scaleLinear()
.domain([0, this.width])
.range([this.dateStart.getTime(), this.dateEnd.getTime()]);
}
await load({ packages: ['corechart'] });
DrawSummaryChart(
this.plotElement!,
this.currentChartData!,
this.width,
this.height,
getComputedStyle(this)
);
}
// Select the provided range on the plot-summary.
public Select(valueStart: number, valuesEnd: number | Date) {
if (this.isCommitScale) {
this.selectionRange = [
this.valuesRangeCommit!.invert(valueStart),
this.valuesRangeCommit!.invert(valuesEnd),
];
} else {
this.selectionRange = [
this.valuesRangeDate!.invert(valueStart),
this.valuesRangeDate!.invert(valuesEnd),
];
}
this.drawSelection();
}
// Display the chart data on the plot.
public async DisplayChartData(chartData: ChartData, isCommitScale: boolean) {
this.currentChartData = chartData;
this.isCommitScale = isCommitScale;
this._renderChart();
}
// Clear the current selection.
private clearSelection(): void {
this.overlayCtx!.clearRect(
0,
0,
this.overlayCanvas!.width,
this.overlayCanvas!.height
);
}
// Listener for the mouse down event.
private mouseDownListener(e: MouseEvent) {
e.preventDefault();
const point = this.eventToCanvasPt(e);
this.currentMousePosition = {
clientX: point.x,
clientY: point.y,
};
if (this.inSelectedArea(point)) {
// 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.x, point.x + 0.1];
this.isCurrentlySelecting = true;
}
}
// Listener for the mouse up event.
private mouseUpListener(e: MouseEvent) {
// Releasing the mouse means selection/dragging is done.
this.isCurrentlySelecting = false;
this.lockedSelectionDiffs = null;
this.summarySelected();
}
private summarySelected() {
if (this.selectionRange !== null) {
let start: number | Date;
let end: number | Date;
if (this.isCommitScale) {
start = this.valuesRangeCommit!(this.selectionRange[0]);
end = this.valuesRangeCommit!(this.selectionRange[1]);
} else {
start = new Date(this.valuesRangeDate!(this.selectionRange[0]));
end = new Date(this.valuesRangeDate!(this.selectionRange[1]));
}
this.dispatchEvent(
new CustomEvent<PlotSummarySkSelectionEventDetails>(
'summary_selected',
{
detail: {
start: this.selectionRange[0],
end: this.selectionRange[1],
valueStart: start,
valueEnd: end,
},
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 = {
clientX: point.x,
clientY: point.y,
};
}
// Add all the event listeners.
private addEventListeners(): void {
// If the user toggles the theme to/from darkmode then redraw.
document.addEventListener('theme-chooser-toggle', () => {
this.render();
this._renderChart();
});
this.addEventListener('mousedown', this.mouseDownListener);
this.addEventListener('mouseup', this.mouseUpListener);
this.addEventListener('mouseleave', this.mouseUpListener);
this.addEventListener('mousemove', this.mouseMoveListener);
}
// Draw the summary rectangle outline.
private drawSummaryRect(): void {
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.getAttribute('width') || '0');
}
set width(val: number) {
this.setAttribute('width', val.toString());
}
/** Mirrors the height attribute. */
get height(): number {
return +(this.getAttribute('height') || '0');
}
set height(val: number) {
this.setAttribute('height', val.toString());
}
/** Mirrors the highlight_color attribute. */
get highlightColor(): string {
return this.getAttribute('highlight_color') || ZOOM_RECT_COLOR;
}
set highlightColor(val: string) {
this.setAttribute('highlight_color', val);
}
/** Set's the hidden attribute. */
set hidden(val: boolean) {
super.hidden = val;
// Update the attribute to the child elements as well.
this.overlayCanvas!.hidden = val;
this.plotElement!.hidden = val;
}
// Converts an event to a specific point
private eventToCanvasPt(e: MousePosition) {
const clientRect = this.overlayCtx!.canvas.getBoundingClientRect();
return {
x: (e.clientX - clientRect.left) * this.scale,
y: (e.clientY - clientRect.top) * this.scale,
};
}
// Draws the selection area.
private drawSelection(): void {
this.clearSelection();
if (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 = this.highlightColor;
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);