|  | /** | 
|  | * @module modules/plot-simple-sk | 
|  | * @description <h2><code>plot-simple-sk</code></h2> | 
|  | * | 
|  | *  A custom element for plotting x,y graphs. | 
|  | * | 
|  | *  The canvas is broken into two areas, the summary and the details. The | 
|  | *  summary is always SUMMARY_HEIGHT pixels high. Also note that we use | 
|  | *  window.devicePixelRatio to decide the actual number of pixels to use, and | 
|  | *  then use CSS transform to squash the canvas back down to the desired size. | 
|  | * | 
|  | *    +----------------------------------------------------+ | 
|  | *    |                                                    | | 
|  | *    |                   MARGIN                           | | 
|  | *    |                                                    | | 
|  | *    |   +--------------------------------------------+   | | 
|  | *    |   |           Summary                          |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   +--------------------------------------------+   | | 
|  | *    |                                                    | | 
|  | *    |                   MARGIN                           | | 
|  | *    |                                                    | | 
|  | *    |   +--------------------------------------------+   | | 
|  | *    |   |          Details                           |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   |                                            |   | | 
|  | *    |   +--------------------------------------------+   | | 
|  | *    |                                                    | | 
|  | *    |                   MARGIN                           | | 
|  | *    |                                                    | | 
|  | *    +----------------------------------------------------+ | 
|  | * | 
|  | * To keep rendering quick the traces will be written into Path2D objects to be | 
|  | * used for quick rendering. | 
|  | * | 
|  | * We also use a k-d Tree for quick lookup for clicking and mouse movement over | 
|  | * the traces. | 
|  | * | 
|  | * There are actually two canvas's in play, the trace canvas is below the | 
|  | * overlay canvas. The trace canvas contains all the traces in the summary and | 
|  | * details along with their axes. The overlay canvas contains everything that | 
|  | * changes quickly, such as the crosshairs, the x-bar, etc. | 
|  | * | 
|  | * This element knows about elements-sk/themes and uses those CSS variables if | 
|  | * present. | 
|  | * | 
|  | * Listens for "theme-chooser-toggle" event on the document and redraws with | 
|  | * updated computed style colors. | 
|  | * | 
|  | * @evt trace_selected - Event produced when the user clicks on a line. The | 
|  | *     e.detail contains the id of the line and the index of the point in the | 
|  | *     line closest to the mouse, and the [x, y] value of the point in 'pt'. | 
|  | * | 
|  | *     <pre> | 
|  | *     { | 
|  | *        x: x, | 
|  | *        y: y, | 
|  | *        name: name, | 
|  | *      } | 
|  | *     </pre> | 
|  | * | 
|  | * @evt trace_focused - Event produced when the user moves the mouse close to a | 
|  | *     line. The e.detail contains the id of the line and the index of the point | 
|  | *     in the line closest to the mouse. | 
|  | * | 
|  | *     <pre> | 
|  | *     { | 
|  | *        x: x, | 
|  | *        y: y, | 
|  | *        name: name, | 
|  | *      } | 
|  | *     </pre> | 
|  | * | 
|  | * @evt zoom - Event produced when the user has zoomed into a region by | 
|  | *      dragging. The detail is of the form: | 
|  | * | 
|  | *      { | 
|  | *        xBegin: new Date(), | 
|  | *        xEnd: new Date(), | 
|  | *      } | 
|  | * | 
|  | * @attr width - The width of the element in px. | 
|  | * | 
|  | * @attr height - The height of the element in px. | 
|  | * | 
|  | * @attr summary {Boolean} - If present then display the summary bar. | 
|  | */ | 
|  | import { html } from 'lit-html'; | 
|  | import * as d3Scale from 'd3-scale'; | 
|  | import * as d3Array from 'd3-array'; | 
|  | import { Anomaly } from '../json'; | 
|  | import { define } from '../../../elements-sk/modules/define'; | 
|  | import { ElementSk } from '../../../infra-sk/modules/ElementSk'; | 
|  | import { KDTree, KDPoint } from './kd'; | 
|  | import { ticks } from './ticks'; | 
|  | import { MISSING_DATA_SENTINEL } from '../const/const'; | 
|  |  | 
|  | //  Prefix for trace ids that are not real traces, such as special_zero. Special | 
|  | //  traces never receive focus and can't be clicked on. | 
|  | const SPECIAL = 'special'; | 
|  |  | 
|  | const NUM_Y_TICKS = 4; | 
|  |  | 
|  | const HOVER_COLOR = '#8887'; // Note the alpha value. | 
|  |  | 
|  | const ZOOM_RECT_COLOR = '#0007'; // Note the alpha value. | 
|  |  | 
|  | const SUMMARY_LINE_WIDTH = 1; // px | 
|  |  | 
|  | const DETAIL_LINE_WIDTH = 1; // px | 
|  |  | 
|  | const AXIS_LINE_WIDTH = 1; // px | 
|  |  | 
|  | const MIN_MOUSE_MOVE_FOR_ZOOM = 5; // px | 
|  |  | 
|  | /** | 
|  | * @constant {Array} - Colors used for traces. | 
|  | */ | 
|  | const COLORS = [ | 
|  | '#000000', | 
|  | '#1B9E77', | 
|  | '#D95F02', | 
|  | '#7570B3', | 
|  | '#E7298A', | 
|  | '#66A61E', | 
|  | '#E6AB02', | 
|  | '#A6761D', | 
|  | '#666666', | 
|  | ]; | 
|  |  | 
|  | // Contains linear scales to convert from source coordinates into | 
|  | // device/destination coordinates. | 
|  | export interface Range { | 
|  | x: d3Scale.ScaleLinear<number, number>; | 
|  | y: d3Scale.ScaleLinear<number, number>; | 
|  | } | 
|  |  | 
|  | // A trace is drawn as a set of lines overdrawn with dots at each measurement. | 
|  | interface TracePaths { | 
|  | linePath: Path2D | null; | 
|  | dotsPath: Path2D | null; | 
|  | } | 
|  |  | 
|  | /** @class Builds the Path2D objects that describe the trace and the dots for a given | 
|  | *   set of scales. | 
|  | */ | 
|  | class PathBuilder { | 
|  | // TODO(jcgregorio) Change to TracePaths. | 
|  | private linePath: Path2D; | 
|  |  | 
|  | private dotsPath: Path2D; | 
|  |  | 
|  | // TODO(jcgregorio) Change to Range. | 
|  | private xRange: d3Scale.ScaleLinear<number, number>; | 
|  |  | 
|  | private yRange: d3Scale.ScaleLinear<number, number>; | 
|  |  | 
|  | private radius: number; | 
|  |  | 
|  | constructor( | 
|  | xRange: d3Scale.ScaleLinear<number, number>, | 
|  | yRange: d3Scale.ScaleLinear<number, number>, | 
|  | radius: number | 
|  | ) { | 
|  | this.xRange = xRange; | 
|  | this.yRange = yRange; | 
|  | this.radius = radius; | 
|  | this.linePath = new Path2D(); | 
|  | this.dotsPath = new Path2D(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Add a point to plot to the path. | 
|  | * | 
|  | * @param {Number} x - X coordinate in source coordinates. | 
|  | * @param {Number} y - Y coordinate in source coordinates. | 
|  | */ | 
|  | add(x: number, y: number) { | 
|  | // Convert source coord into canvas coords. | 
|  | const cx = this.xRange(x); | 
|  | const cy = this.yRange(y); | 
|  |  | 
|  | if (x === 0) { | 
|  | this.linePath.moveTo(cx, cy); | 
|  | } else { | 
|  | this.linePath.lineTo(cx, cy); | 
|  | } | 
|  | this.dotsPath.moveTo(cx + this.radius, cy); | 
|  | this.dotsPath.arc(cx, cy, this.radius, 0, 2 * Math.PI); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Returns the Arrays of Path2D objects that represent all the traces. | 
|  | * | 
|  | * @returns {Object} | 
|  | */ | 
|  | paths(): TracePaths { | 
|  | return { | 
|  | linePath: this.linePath, | 
|  | dotsPath: this.dotsPath, | 
|  | }; | 
|  | } | 
|  | } | 
|  |  | 
|  | interface Point { | 
|  | x: number; | 
|  | y: number; | 
|  | } | 
|  |  | 
|  | const invalidPoint: Point = { | 
|  | x: Number.MIN_SAFE_INTEGER, | 
|  | y: Number.MIN_SAFE_INTEGER, | 
|  | }; | 
|  |  | 
|  | const pointIsValid = (p: Point): boolean => p.x !== invalidPoint.x; | 
|  |  | 
|  | interface Rect extends Point { | 
|  | width: number; | 
|  | height: number; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Convert rect in domain units into canvas coordinates using the given range. | 
|  | * | 
|  | * Presumes the rect was previously gotten from rectFromRangeInvert, so we don't | 
|  | * need to flip top and bottom. | 
|  | */ | 
|  | export const rectFromRange = (range: Range, rect: Rect): Rect => { | 
|  | const cleft = range.x(rect.x); | 
|  | const ctop = range.y(rect.y); | 
|  | const cright = range.x(rect.x + rect.width); | 
|  | const cbottom = range.y(rect.y + rect.height); | 
|  | return { | 
|  | x: cleft, | 
|  | y: ctop, | 
|  | width: cright - cleft, | 
|  | height: cbottom - ctop, | 
|  | }; | 
|  | }; | 
|  |  | 
|  | /** | 
|  | * Convert rect in canvas units into domain units given the given range. | 
|  | * | 
|  | * Presumes this comes from a dragged out mouse region, which could be backwards | 
|  | * and/or upside down, so corrections are done to the corners. | 
|  | */ | 
|  | export const rectFromRangeInvert = (range: Range, rect: Rect): Rect => { | 
|  | let left = rect.x; | 
|  | let top = rect.y; | 
|  | let right = rect.x + rect.width; | 
|  | let bottom = rect.y + rect.height; | 
|  | if (right < left) { | 
|  | [left, right] = [right, left]; | 
|  | } | 
|  | // We do this backwards since range.y then does a second direction reversal, | 
|  | // i.e. Canvas y-axis is in the opposite direction of the y-axis we use in | 
|  | // the data units. | 
|  | if (top < bottom) { | 
|  | [bottom, top] = [top, bottom]; | 
|  | } | 
|  |  | 
|  | return { | 
|  | x: range.x.invert(left), | 
|  | y: range.y.invert(top), | 
|  | width: range.x.invert(right) - range.x.invert(left), | 
|  | height: range.y.invert(bottom) - range.y.invert(top), | 
|  | }; | 
|  | }; | 
|  |  | 
|  | const defaultRect: Rect = { | 
|  | x: 0, | 
|  | y: 0, | 
|  | width: 0, | 
|  | height: 0, | 
|  | }; | 
|  |  | 
|  | interface SearchPoint extends Point { | 
|  | // Source coordinates. | 
|  | sx: number; | 
|  | sy: number; | 
|  | name: string; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @class Builds a kdTree for searcing for nearest points to the mouse. | 
|  | */ | 
|  | class SearchBuilder { | 
|  | // TODO(jcgregorio) Change to Range. | 
|  | private xRange: d3Scale.ScaleLinear<number, number>; | 
|  |  | 
|  | private yRange: d3Scale.ScaleLinear<number, number>; | 
|  |  | 
|  | private points: SearchPoint[]; | 
|  |  | 
|  | constructor( | 
|  | xRange: d3Scale.ScaleLinear<number, number>, | 
|  | yRange: d3Scale.ScaleLinear<number, number> | 
|  | ) { | 
|  | this.xRange = xRange; | 
|  | this.yRange = yRange; | 
|  | this.points = []; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Add a point to the kdTree. | 
|  | * | 
|  | * Note that add() stores the x and y coords as 'sx' and 'sy' in the KDTree nodes, | 
|  | * and the canvas coords, which are computed from sx and sy are stored as 'x' | 
|  | * and 'y' in the KDTree nodes. | 
|  | * | 
|  | * @param {Number} x - X coordinate in source coordinates. | 
|  | * @param {Number} y - Y coordinate in source coordinates. | 
|  | * @param {String} name - The trace name. | 
|  | */ | 
|  | add(x: number, y: number, name: string) { | 
|  | if (name.startsWith(SPECIAL)) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | // Convert source coord into canvas coords. | 
|  | const cx = this.xRange(x); | 
|  | const cy = this.yRange(y); | 
|  |  | 
|  | this.points.push({ | 
|  | x: cx, | 
|  | y: cy, | 
|  | sx: x, | 
|  | sy: y, | 
|  | name, | 
|  | }); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Returns a kdTree that contains all the points being plotted. | 
|  | * | 
|  | * @returns {KDTree} | 
|  | */ | 
|  | kdTree() { | 
|  | const distance = (a: KDPoint, b: KDPoint) => { | 
|  | const dx = a.x - b.x; | 
|  | const dy = a.y - b.y; | 
|  | return dx * dx + dy * dy; | 
|  | }; | 
|  |  | 
|  | return new KDTree(this.points, distance, ['x', 'y']); | 
|  | } | 
|  | } | 
|  |  | 
|  | // Returns true if pt is in rect. | 
|  | function inRect(pt: Point, rect: Rect): boolean { | 
|  | return ( | 
|  | pt.x >= rect.x && | 
|  | pt.x < rect.x + rect.width && | 
|  | pt.y >= rect.y && | 
|  | pt.y < rect.y + rect.height | 
|  | ); | 
|  | } | 
|  |  | 
|  | // Restricts pt to rect. | 
|  | function clampToRect(pt: Point, rect: Rect) { | 
|  | if (pt.x < rect.x) { | 
|  | pt.x = rect.x; | 
|  | } else if (pt.x > rect.x + rect.width) { | 
|  | pt.x = rect.x + rect.width; | 
|  | } | 
|  | if (pt.y < rect.y) { | 
|  | pt.y = rect.y; | 
|  | } else if (pt.y > rect.y + rect.height) { | 
|  | pt.y = rect.y + rect.height; | 
|  | } | 
|  | } | 
|  |  | 
|  | // Clip the given Canvas2D context to the given rect. | 
|  | function clipToRect(ctx: CanvasRenderingContext2D, rect: Rect) { | 
|  | ctx.beginPath(); | 
|  | ctx.rect(rect.x, rect.y, rect.width, rect.height); | 
|  | ctx.clip(); | 
|  | } | 
|  |  | 
|  | // All the data for a single trace. | 
|  | interface LineData { | 
|  | name: string; | 
|  | values: number[]; | 
|  | color: string; | 
|  | detail: TracePaths; | 
|  | summary: TracePaths; | 
|  | } | 
|  |  | 
|  | export interface AnomalyData { | 
|  | x: number; | 
|  | y: number; | 
|  | anomaly: Anomaly; | 
|  | } | 
|  |  | 
|  | interface MousePosition { | 
|  | clientX: number; | 
|  | clientY: number; | 
|  | } | 
|  |  | 
|  | interface MouseMoveRaw extends MousePosition { | 
|  | // Is the shift key being pressed as the mouse moves. | 
|  | shiftKey: boolean; | 
|  | } | 
|  |  | 
|  | interface HoverPoint extends Point { | 
|  | // The trace id. | 
|  | name: string; | 
|  | } | 
|  |  | 
|  | interface Label extends Point { | 
|  | // The text value of the label. | 
|  | text: string; | 
|  | } | 
|  |  | 
|  | interface CrosshairPoint extends Point { | 
|  | shift: boolean; | 
|  | } | 
|  |  | 
|  | // Common information for both the Summary and Detail display areas. | 
|  | interface Area { | 
|  | rect: Rect; | 
|  | axis: { | 
|  | path: Path2D; | 
|  | labels: Label[]; | 
|  | }; | 
|  | range: Range; | 
|  | } | 
|  |  | 
|  | type SummaryArea = Area; | 
|  |  | 
|  | interface DetailArea extends Area { | 
|  | yaxis: { | 
|  | path: Path2D; | 
|  | labels: Label[]; | 
|  | }; | 
|  | } | 
|  |  | 
|  | // Describes the zoom in terms of x-axis source values. | 
|  | export type ZoomRange = [number, number] | null; | 
|  |  | 
|  | // Used for both the trace_selected and trace_focused events. | 
|  | export interface PlotSimpleSkTraceEventDetails { | 
|  | x: number; | 
|  | y: number; | 
|  |  | 
|  | // The trace id. | 
|  | name: string; | 
|  | } | 
|  |  | 
|  | export interface PlotSimpleSkZoomEventDetails { | 
|  | xBegin: Date; | 
|  | xEnd: Date; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * The type of zoom being done, or 'no-zoom' if no zoom is currently being done. | 
|  | */ | 
|  | export type ZoomDragType = 'no-zoom' | 'details' | 'summary'; | 
|  |  | 
|  | export class PlotSimpleSk extends ElementSk { | 
|  | /** The location of the XBar. See the xbar property. */ | 
|  | private _xbar: number = -1; | 
|  |  | 
|  | /** If true then draw dots on the traces. */ | 
|  | private _dots: boolean = true; | 
|  |  | 
|  | /** The locations of the background bands. See bands property. */ | 
|  | private _bands: number[] = []; | 
|  |  | 
|  | private _anomalyDataMap: { [key: string]: AnomalyData[] } = {}; | 
|  |  | 
|  | /** A map of trace names to 'true' of traces that are highlighted. */ | 
|  | private highlighted: { [key: string]: boolean } = {}; | 
|  |  | 
|  | /** | 
|  | *  The data we are plotting. | 
|  | *  An array of objects of this form: | 
|  | * | 
|  | *   { | 
|  | *     name: key, | 
|  | *     values: [1.0, 1.1, 0.9, ...], | 
|  | *     detail: { | 
|  | *       linePath: Path2D, | 
|  | *       dotsPath: Path2D, | 
|  | *     }, | 
|  | *     summary: { | 
|  | *       linePath: Path2D, | 
|  | *       dotsPath: Path2D, | 
|  | *     }, | 
|  | *   } | 
|  | */ | 
|  | private lineData: LineData[] = []; | 
|  |  | 
|  | /** An array of Date()'s the same length as the values in lineData. */ | 
|  | private labels: Date[] = []; | 
|  |  | 
|  | /** | 
|  | * The current zoom, either null or an array of two values in source x | 
|  | * coordinates, e.g. [1, 12]. | 
|  | */ | 
|  | private _zoom: ZoomRange = null; | 
|  |  | 
|  | /** The source coordinate where a zoom started. */ | 
|  | private zoomBegin: number = 0; | 
|  |  | 
|  | /** The zoom rectangle on the details region. Stored in destination units. */ | 
|  | private zoomRect: Rect = defaultRect; | 
|  |  | 
|  | /** | 
|  | * detailsZoomRangesStack and inactiveZoomRangesStack work together to manage | 
|  | * a stack of zoom levels. As new zoom ranges are dragged out they are pushed | 
|  | * onto detailsZoomRangesStack, and as the mouse wheel is turned we push/pop | 
|  | * zoom ranges between detailsZoomRangesStack and inactiveZoomRangesStack. | 
|  | * Scroll up means to zoom in, so we pop a rect off the inactive stack and | 
|  | * push it on the active stack. Scrolling down means to zoom out, so we do the | 
|  | * reverse, pop of the active stack and push it onto the inactive stack. | 
|  | * | 
|  | * When plotting we only look at the top of the active stack, i.e. the end of | 
|  | * detailsZoomRangesStack. | 
|  | * | 
|  | * detailsZoomRangesStack is a stack of zoom ranges, each zoom range in the | 
|  | * stack represents a smaller area than the zoom range below it on the stack. | 
|  | * Zoom ranges are stored in domain units. | 
|  | */ | 
|  | private detailsZoomRangesStack: Rect[] = []; | 
|  |  | 
|  | /** | 
|  | * As we use the mouse wheel to move through zooms we store the ones we've | 
|  | * popped off of detailsZoomRangesStack here. | 
|  | * | 
|  | * Zoom ranges are stored in domain units. | 
|  | */ | 
|  | private inactiveDetailsZoomRangesStack: Rect[] = []; | 
|  |  | 
|  | /** | 
|  | * True if we are currently drag zooming, i.e. the mouse is pressed and moving | 
|  | * over the summary. | 
|  | */ | 
|  | private inZoomDrag: ZoomDragType = 'no-zoom'; | 
|  |  | 
|  | /** The Canvas 2D context of the traces canvas. */ | 
|  | private ctx: CanvasRenderingContext2D | null = null; | 
|  |  | 
|  | /** The Canvas 2D context of the overlay canvas. */ | 
|  | private overlayCtx: CanvasRenderingContext2D | null = null; | 
|  |  | 
|  | /** The window.devicePixelRatio. */ | 
|  | private scale: number = 1.0; | 
|  |  | 
|  | /** | 
|  | * A copy of the clientX, clientY, and shiftKey values of mousemove events, | 
|  | * or null if a mousemove event hasn't occurred since the last time it was | 
|  | * processed. | 
|  | */ | 
|  | private mouseMoveRaw: MouseMoveRaw | null = null; | 
|  |  | 
|  | /** | 
|  | * A kdTree for all the points being displayed, in source coordinates. Is | 
|  | * null if no traces are being displayed. | 
|  | */ | 
|  | private pointSearch: KDTree<SearchPoint> | null = null; | 
|  |  | 
|  | /** | 
|  | * The closest trace point to the mouse. May be {} if no traces are | 
|  | * displayed or the mouse hasn't moved over the canvas yet. Has the form: | 
|  | *   { | 
|  | *     x: x, | 
|  | *     y: y, | 
|  | *     name: String, // name of trace | 
|  | *   } | 
|  | */ | 
|  | private hoverPt: HoverPoint = { | 
|  | x: -1, | 
|  | y: -1, | 
|  | name: '', | 
|  | }; | 
|  |  | 
|  | /** | 
|  | * The location of the crosshair in canvas coordinates. Of the form: | 
|  | *   { | 
|  | *     x: x, | 
|  | *     y: y, | 
|  | *     shift: Boolean, | 
|  | *   } | 
|  | * | 
|  | * The value of shift is true of the shift key is being pressed while the | 
|  | * mouse moves. | 
|  | * } | 
|  | */ | 
|  | private crosshair: CrosshairPoint = { | 
|  | x: -1, | 
|  | y: -1, | 
|  | shift: false, | 
|  | }; | 
|  |  | 
|  | /** All the info we need about the summary area. */ | 
|  | private summaryArea: SummaryArea = { | 
|  | rect: { | 
|  | x: 0, | 
|  | y: 0, | 
|  | width: 0, | 
|  | height: 0, | 
|  | }, | 
|  | axis: { | 
|  | path: new Path2D(), // Path2D. | 
|  | labels: [], // The labels and locations to draw them. {x, y, text}. | 
|  | }, | 
|  | range: { | 
|  | x: d3Scale.scaleLinear(), | 
|  | y: d3Scale.scaleLinear(), | 
|  | }, | 
|  | }; | 
|  |  | 
|  | /** All the info we need about the details area. */ | 
|  | private detailArea: DetailArea = { | 
|  | rect: { | 
|  | x: 0, | 
|  | y: 0, | 
|  | width: 0, | 
|  | height: 0, | 
|  | }, | 
|  | axis: { | 
|  | path: new Path2D(), // Path2D. | 
|  | labels: [], // The labels and locations to draw them. {x, y, text}. | 
|  | }, | 
|  | yaxis: { | 
|  | path: new Path2D(), | 
|  | labels: [], // The labels and locations to draw them. {x, y, text}. | 
|  | }, | 
|  | range: { | 
|  | x: d3Scale.scaleLinear(), | 
|  | y: d3Scale.scaleLinear(), | 
|  | }, | 
|  | }; | 
|  |  | 
|  | /** | 
|  | * A task to rebuild the k-d search tree used for finding the closest point | 
|  | * to the mouse. The actual value is a window.setTimer timerId or zero if no | 
|  | * task is scheduled. | 
|  | * | 
|  | * See https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ | 
|  | * for details on tasks vs microtasks. | 
|  | */ | 
|  | private recalcSearchTask: number = 0; | 
|  |  | 
|  | /** | 
|  | * A task to do the actual re-draw work of a zoom. The actual value is a | 
|  | * window.setTimer timerId or zero if no task is scheduled. | 
|  | * | 
|  | * See https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ | 
|  | * for details on tasks vs microtasks. | 
|  | */ | 
|  | private zoomTask: number = 0; | 
|  |  | 
|  | /** | 
|  | * A formatter that prints numbers nicely, such as adding commas. Used when | 
|  | * display the hover text. | 
|  | */ | 
|  | private numberFormatter: Intl.NumberFormat = new Intl.NumberFormat(); | 
|  |  | 
|  | private SUMMARY_HEIGHT!: number; // px | 
|  |  | 
|  | private SUMMARY_BAR_WIDTH!: number; // px | 
|  |  | 
|  | private DETAIL_BAR_WIDTH!: number; // px | 
|  |  | 
|  | private SUMMARY_HIGHLIGHT_LINE_WIDTH!: number; // px | 
|  |  | 
|  | private DETAIL_RADIUS!: number; // px | 
|  |  | 
|  | private SUMMARY_RADIUS!: number; // The radius of points in the summary area. (px) | 
|  |  | 
|  | private MARGIN!: number; // The margin around the details and summary areas. (px) | 
|  |  | 
|  | private LEFT_MARGIN!: number; // px | 
|  |  | 
|  | private Y_AXIS_TICK_LENGTH!: number; // px | 
|  |  | 
|  | private ANOMALY_BACKGROUND!: string; // CSS color. | 
|  |  | 
|  | private REGRESSION_COLOR!: string; // CSS color. | 
|  |  | 
|  | private IMPROVEMENT_COLOR!: string; // CSS color. | 
|  |  | 
|  | private ANOMALY_RADIUS!: number; // px | 
|  |  | 
|  | private ANOMALY_FONT_SIZE!: number; // px | 
|  |  | 
|  | private ANOMALY_FONT!: string; // CSS font string. | 
|  |  | 
|  | private LABEL_FONT_SIZE!: number; // px | 
|  |  | 
|  | private LABEL_MARGIN!: number; // px | 
|  |  | 
|  | private LABEL_FONT!: string; // CSS font string. | 
|  |  | 
|  | private ZOOM_BAR_LINE_WIDTH!: number; // px | 
|  |  | 
|  | private HOVER_LINE_WIDTH!: number; // px | 
|  |  | 
|  | private LABEL_COLOR!: string; // CSS color. | 
|  |  | 
|  | private LABEL_BACKGROUND!: string; // CSS color. | 
|  |  | 
|  | private CROSSHAIR_COLOR!: string; // CSS color. | 
|  |  | 
|  | private BAND_COLOR!: string; // CSS color. | 
|  |  | 
|  | constructor() { | 
|  | super(PlotSimpleSk.template); | 
|  |  | 
|  | this._upgradeProperty('width'); | 
|  | this._upgradeProperty('height'); | 
|  | this._upgradeProperty('bands'); | 
|  | this._upgradeProperty('xbar'); | 
|  | this._upgradeProperty('hightlight'); | 
|  | this._upgradeProperty('zoom'); | 
|  |  | 
|  | this.updateScaledMeasurements(); | 
|  | } | 
|  |  | 
|  | // Note that in both of the canvas elements we are setting a CSS transform that | 
|  | // takes into account window.devicePixelRatio, that is, we are drawing to a | 
|  | // scale that matches the displays native resolution and then scaling that back | 
|  | // to fit on the page. Also see updateScaledMeasurements for how the device | 
|  | // pixel ratio affects all of our pixel calculations. | 
|  | private static template = (ele: PlotSimpleSk) => html` | 
|  | <canvas | 
|  | class="traces" | 
|  | width=${ele.width * window.devicePixelRatio} | 
|  | height=${ele.height * window.devicePixelRatio} | 
|  | style="transform-origin: 0 0; transform: scale(${1 / | 
|  | window.devicePixelRatio});"></canvas> | 
|  | <canvas | 
|  | class="overlay" | 
|  | width=${ele.width * window.devicePixelRatio} | 
|  | height=${ele.height * window.devicePixelRatio} | 
|  | style="transform-origin: 0 0; transform: scale(${1 / | 
|  | window.devicePixelRatio});"></canvas> | 
|  | `; | 
|  |  | 
|  | connectedCallback(): void { | 
|  | super.connectedCallback(); | 
|  |  | 
|  | this.render(); | 
|  |  | 
|  | // We need to dynamically resize the canvas elements since they don't do | 
|  | // that themselves. | 
|  | const resizeObserver = new ResizeObserver( | 
|  | (entries: ResizeObserverEntry[]) => { | 
|  | entries.forEach((entry) => { | 
|  | this.width = entry.contentRect.width; | 
|  | this.height = entry.contentRect.height; | 
|  | }); | 
|  | } | 
|  | ); | 
|  | resizeObserver.observe(this); | 
|  |  | 
|  | this.addEventListener('mousemove', (e) => { | 
|  | // Do as little as possible here. The raf() function will periodically | 
|  | // check if the mouse has moved and trigger the appropriate redraws. | 
|  | this.mouseMoveRaw = { | 
|  | clientX: e.clientX, | 
|  | clientY: e.clientY, | 
|  | shiftKey: e.shiftKey, | 
|  | }; | 
|  | }); | 
|  |  | 
|  | this.addEventListener('mousedown', (e) => { | 
|  | const pt = this.eventToCanvasPt(e); | 
|  | // If you click in the summary area then begin zooming via drag. | 
|  | if (inRect(pt, this.summaryArea.rect!)) { | 
|  | const zx = this.summaryArea.range.x.invert(pt.x); | 
|  | this.inZoomDrag = 'summary'; | 
|  | this.zoomBegin = zx; | 
|  | this.zoom = [zx, zx + 0.01]; // Add a smidge to the second zx to avoid a degenerate detail plot. | 
|  |  | 
|  | // Zooming via the summary area clears all details area zooms. | 
|  | this.detailsZoomRangesStack = []; | 
|  | this.inactiveDetailsZoomRangesStack = []; | 
|  | } | 
|  | if (inRect(pt, this.detailArea.rect!)) { | 
|  | this.inZoomDrag = 'details'; | 
|  | this.zoomRect = { | 
|  | x: pt.x, | 
|  | y: pt.y, | 
|  | width: 0, | 
|  | height: 0, | 
|  | }; | 
|  | } | 
|  | }); | 
|  |  | 
|  | this.addEventListener('mouseup', () => { | 
|  | if (this.inZoomDrag !== 'no-zoom') { | 
|  | this.dispatchZoomEvent(); | 
|  | } | 
|  | if (this.inZoomDrag === 'details') { | 
|  | this.doDetailsZoom(); | 
|  | } | 
|  | this.inZoomDrag = 'no-zoom'; | 
|  | }); | 
|  |  | 
|  | this.addEventListener('mouseleave', () => { | 
|  | if (this.inZoomDrag !== 'no-zoom') { | 
|  | this.dispatchZoomEvent(); | 
|  | } | 
|  | if (this.inZoomDrag === 'details') { | 
|  | this.doDetailsZoom(); | 
|  | } | 
|  | this.inZoomDrag = 'no-zoom'; | 
|  | }); | 
|  |  | 
|  | this.addEventListener('wheel', (e: WheelEvent) => { | 
|  | e.stopPropagation(); | 
|  | e.preventDefault(); | 
|  | // If the wheel is spun while we are zoomed then move through the stack of | 
|  | // zoom ranges. | 
|  | if (this.detailsZoomRangesStack) { | 
|  | // Scrolling up on the scroll wheel gives e.deltaY a negative value. Up | 
|  | // means to scroll in, which means we want to take a rect from | 
|  | // inactiveDetailsZoomRangesStack and make it active by pushing it on | 
|  | // detailsZoomRangesStack. Down reverses the push/pop direction. | 
|  | if (e.deltaY < 0) { | 
|  | if (this.inactiveDetailsZoomRangesStack.length === 0) { | 
|  | return; | 
|  | } | 
|  | this.detailsZoomRangesStack.push( | 
|  | this.inactiveDetailsZoomRangesStack.pop()! | 
|  | ); | 
|  | } else { | 
|  | if (this.detailsZoomRangesStack.length === 0) { | 
|  | return; | 
|  | } | 
|  | this.inactiveDetailsZoomRangesStack.push( | 
|  | this.detailsZoomRangesStack.pop()! | 
|  | ); | 
|  | } | 
|  | this._zoomImpl(); | 
|  | } | 
|  | }); | 
|  |  | 
|  | this.addEventListener('click', (e) => { | 
|  | const pt = this.eventToCanvasPt(e); | 
|  | if (!inRect(pt, this.detailArea.rect)) { | 
|  | return; | 
|  | } | 
|  | if (!this.pointSearch) { | 
|  | return; | 
|  | } | 
|  | const closest = this.pointSearch.nearest(pt); | 
|  | const detail = { | 
|  | x: closest.sx, | 
|  | y: closest.sy, | 
|  | name: closest.name, | 
|  | }; | 
|  | this.dispatchEvent( | 
|  | new CustomEvent<PlotSimpleSkTraceEventDetails>('trace_selected', { | 
|  | detail, | 
|  | bubbles: true, | 
|  | }) | 
|  | ); | 
|  | }); | 
|  |  | 
|  | // If the user toggles the theme to/from darkmode then redraw. | 
|  | document.addEventListener('theme-chooser-toggle', () => { | 
|  | this.render(); | 
|  | }); | 
|  |  | 
|  | window.requestAnimationFrame(this.raf.bind(this)); | 
|  | } | 
|  |  | 
|  | attributeChangedCallback( | 
|  | _: string, | 
|  | oldValue: string, | 
|  | newValue: string | 
|  | ): void { | 
|  | if (oldValue !== newValue) { | 
|  | this.render(); | 
|  | } | 
|  | } | 
|  |  | 
|  | // Call this when the width or height attrs have changed. | 
|  | render(): void { | 
|  | this._render(); | 
|  | const canvas = this.querySelector<HTMLCanvasElement>('canvas.traces')!; | 
|  | const overlayCanvas = | 
|  | this.querySelector<HTMLCanvasElement>('canvas.overlay')!; | 
|  | if (canvas) { | 
|  | this.ctx = canvas.getContext('2d'); | 
|  | this.overlayCtx = overlayCanvas.getContext('2d'); | 
|  | this.scale = window.devicePixelRatio; | 
|  | this.updateScaledMeasurements(); | 
|  | this.updateScaleRanges(); | 
|  | this.recalcDetailPaths(); | 
|  | this.recalcSummaryPaths(); | 
|  | this.drawTracesCanvas(); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Adds lines to be displayed. | 
|  | * | 
|  | * Any line id that begins with 'special' will be treated specially, | 
|  | * i.e. it will be presented as a dashed black line that doesn't | 
|  | * generate events. This may be useful for adding a line at y=0, | 
|  | * or a reference trace. | 
|  | * | 
|  | * @param {Object} lines - A map from trace id to arrays of y values. | 
|  | * @param {Array} labels - An array of Date objects the same length as the values. | 
|  | * | 
|  | */ | 
|  | addLines(lines: { [key: string]: number[] | null }, labels: Date[]): void { | 
|  | const keys = Object.keys(lines); | 
|  | if (keys.length === 0) { | 
|  | return; | 
|  | } | 
|  | const startedEmpty = this._zoom === null && this.lineData.length === 0; | 
|  | if (labels) { | 
|  | this.labels = labels; | 
|  | } | 
|  |  | 
|  | // Convert into the format we will eventually expect. | 
|  | keys.forEach((key) => { | 
|  | // You can't encode NaN in JSON, so convert sentinel values to NaN here so | 
|  | // that dsArray functions will operate correctly. Make a copy so the NaN's | 
|  | // don't migrate out of plot-simple. | 
|  | const values = [...lines[key]!]; | 
|  | values.forEach((x, i) => { | 
|  | if (x === MISSING_DATA_SENTINEL) { | 
|  | values[i] = NaN; | 
|  | } | 
|  | }); | 
|  |  | 
|  | this.lineData.push({ | 
|  | name: key, | 
|  | values, | 
|  | color: 'black', | 
|  | detail: { | 
|  | linePath: null, | 
|  | dotsPath: null, | 
|  | }, | 
|  | summary: { | 
|  | linePath: null, | 
|  | dotsPath: null, | 
|  | }, | 
|  | }); | 
|  | }); | 
|  |  | 
|  | // Set the zoom if we just added data for the first time. | 
|  | if (startedEmpty && this.lineData.length > 0) { | 
|  | this._zoom = [0, this.lineData[0].values.length - 1]; | 
|  | } | 
|  |  | 
|  | this.updateScaleDomains(); | 
|  | this.recalcSummaryPaths(); | 
|  | this.recalcDetailPaths(); | 
|  | this.drawTracesCanvas(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Delete all the lines whose ids are in 'ids' from being plotted. | 
|  | * | 
|  | * @param {Array<string>} ids - The trace ids to remove. | 
|  | */ | 
|  | deleteLines(ids: string[]): void { | 
|  | this.lineData = this.lineData.filter( | 
|  | (line) => ids.indexOf(line.name) === -1 | 
|  | ); | 
|  | if (this.anomalyDataMap !== null) { | 
|  | ids.forEach((id) => { | 
|  | if (id in this.anomalyDataMap) { | 
|  | delete this.anomalyDataMap[id]; | 
|  | } | 
|  | }); | 
|  | } | 
|  | const onlySpecialLinesRemaining = this.lineData.every((line) => | 
|  | line.name.startsWith(SPECIAL) | 
|  | ); | 
|  | if (onlySpecialLinesRemaining) { | 
|  | this.removeAll(); | 
|  | } else { | 
|  | this.updateScaleDomains(); | 
|  | this.recalcSummaryPaths(); | 
|  | this.recalcDetailPaths(); | 
|  | this.drawTracesCanvas(); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Remove all lines from plot. | 
|  | */ | 
|  | removeAll(): void { | 
|  | this.lineData = []; | 
|  | this.anomalyDataMap = {}; | 
|  | this.labels = []; | 
|  | this.highlight = []; | 
|  | this.hoverPt = { | 
|  | x: -1, | 
|  | y: -1, | 
|  | name: '', | 
|  | }; | 
|  | this.pointSearch = null; | 
|  | this.crosshair = { | 
|  | x: -1, | 
|  | y: -1, | 
|  | shift: false, | 
|  | }; | 
|  | this.mouseMoveRaw = null; | 
|  | this.highlighted = {}; | 
|  | this._xbar = -1; | 
|  | this._zoom = null; | 
|  | this.inZoomDrag = 'no-zoom'; | 
|  | this.detailsZoomRangesStack = []; | 
|  | this.inactiveDetailsZoomRangesStack = []; | 
|  | this.drawTracesCanvas(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Return the names of all the lines being plotted, not including SPECIAL | 
|  | * names. | 
|  | * */ | 
|  | getLineNames(): string[] { | 
|  | const ret: string[] = []; | 
|  | this.lineData.forEach((line) => { | 
|  | if (line.name.startsWith(SPECIAL)) { | 
|  | return; | 
|  | } | 
|  | ret.push(line.name); | 
|  | }); | 
|  | return ret; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Update all the things that look like constants, but are really dependent on | 
|  | * window.devicePixelRatio or the current CSS styling. | 
|  | */ | 
|  | private updateScaledMeasurements() { | 
|  | // The height of the summary area. | 
|  | if (this.summary) { | 
|  | this.SUMMARY_HEIGHT = 50 * this.scale; // px | 
|  | } else { | 
|  | this.SUMMARY_HEIGHT = 0; | 
|  | } | 
|  |  | 
|  | this.SUMMARY_BAR_WIDTH = 2 * this.scale; // px | 
|  |  | 
|  | this.DETAIL_BAR_WIDTH = 3 * this.scale; // px | 
|  |  | 
|  | this.SUMMARY_HIGHLIGHT_LINE_WIDTH = 3 * this.scale; | 
|  |  | 
|  | // The radius of points in the details area. | 
|  | this.DETAIL_RADIUS = 3 * this.scale; // px | 
|  |  | 
|  | // The radius of points in the summary area. | 
|  | this.SUMMARY_RADIUS = 2 * this.scale; // px | 
|  |  | 
|  | // The margin around the details and summary areas. | 
|  | this.MARGIN = 32 * this.scale; // px | 
|  |  | 
|  | this.LEFT_MARGIN = 2 * this.MARGIN; // px | 
|  |  | 
|  | this.Y_AXIS_TICK_LENGTH = this.MARGIN / 4; // px | 
|  |  | 
|  | this.ANOMALY_RADIUS = 12 * this.scale; // px | 
|  |  | 
|  | this.ANOMALY_FONT_SIZE = 26 * this.scale; | 
|  |  | 
|  | this.ANOMALY_FONT = `${this.ANOMALY_FONT_SIZE}px Material Icons`; | 
|  |  | 
|  | this.LABEL_FONT_SIZE = 14 * this.scale; // px | 
|  |  | 
|  | this.LABEL_MARGIN = 6 * this.scale; // px | 
|  |  | 
|  | this.LABEL_FONT = `${this.LABEL_FONT_SIZE}px Roboto,Helvetica,Arial,Bitstream Vera Sans,sans-serif`; | 
|  |  | 
|  | this.ZOOM_BAR_LINE_WIDTH = 3 * this.scale; // px | 
|  |  | 
|  | this.HOVER_LINE_WIDTH = 1 * this.scale; // px | 
|  |  | 
|  | this.CROSSHAIR_COLOR = '#f00'; | 
|  |  | 
|  | this.BAND_COLOR = '#888'; | 
|  |  | 
|  | // Pull out the computed colors. | 
|  | const style = getComputedStyle(this); | 
|  |  | 
|  | // Start by using the computed colors. | 
|  | this.LABEL_COLOR = style.color; | 
|  | this.LABEL_BACKGROUND = style.backgroundColor; | 
|  |  | 
|  | this.ANOMALY_BACKGROUND = style.getPropertyValue('--on-surface'); | 
|  | this.IMPROVEMENT_COLOR = style.getPropertyValue('--success'); | 
|  | this.REGRESSION_COLOR = style.getPropertyValue('--failure'); | 
|  |  | 
|  | // Now override with CSS variables if they are present. | 
|  | const onBackground = style.getPropertyValue('--on-backgroud'); | 
|  | if (onBackground !== '') { | 
|  | this.LABEL_COLOR = onBackground; | 
|  | } | 
|  |  | 
|  | const background = style.getPropertyValue('--backgroud'); | 
|  | if (background !== '') { | 
|  | this.LABEL_BACKGROUND = background; | 
|  | } | 
|  |  | 
|  | const errorColor = style.getPropertyValue('--error'); | 
|  | if (errorColor !== '') { | 
|  | this.CROSSHAIR_COLOR = errorColor; | 
|  | } | 
|  |  | 
|  | const secondaryColor = style.getPropertyValue('--secondary'); | 
|  | if (secondaryColor !== '') { | 
|  | this.BAND_COLOR = secondaryColor; | 
|  | } | 
|  | } | 
|  |  | 
|  | private dispatchZoomEvent() { | 
|  | if (!this._zoom) { | 
|  | return; | 
|  | } | 
|  | let beginIndex = Math.floor(this._zoom[0] - 0.1); | 
|  | if (beginIndex < 0) { | 
|  | beginIndex = 0; | 
|  | } | 
|  | let endIndex = Math.ceil(this._zoom[1] + 0.1); | 
|  | if (endIndex > this.labels.length - 1) { | 
|  | endIndex = this.labels.length - 1; | 
|  | } | 
|  | const detail = { | 
|  | xBegin: this.labels[beginIndex], | 
|  | xEnd: this.labels[endIndex], | 
|  | }; | 
|  | this.dispatchEvent( | 
|  | new CustomEvent<PlotSimpleSkZoomEventDetails>('zoom', { | 
|  | detail, | 
|  | bubbles: true, | 
|  | }) | 
|  | ); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Convert mouse event coordinates to a canvas point. | 
|  | * | 
|  | * @param {Object} e - A mouse event or an object that has the coords stored | 
|  | * in clientX and clientY. | 
|  | */ | 
|  | private eventToCanvasPt(e: MouseMoveRaw) { | 
|  | const clientRect = this.ctx!.canvas.getBoundingClientRect(); | 
|  | return { | 
|  | x: (e.clientX - clientRect.left) * this.scale, | 
|  | y: (e.clientY - clientRect.top) * this.scale, | 
|  | }; | 
|  | } | 
|  |  | 
|  | // Handles requestAnimationFrame callbacks. | 
|  | private raf() { | 
|  | // Always queue up our next raf first. | 
|  | window.requestAnimationFrame(() => this.raf()); | 
|  |  | 
|  | // Bail out early if the mouse hasn't moved. | 
|  | if (this.mouseMoveRaw === null) { | 
|  | return; | 
|  | } | 
|  | if (this.inZoomDrag === 'no-zoom') { | 
|  | const pt = this.eventToCanvasPt(this.mouseMoveRaw); | 
|  |  | 
|  | // Update _hoverPt if needed. | 
|  | if (this.pointSearch) { | 
|  | const closest = this.pointSearch.nearest(pt); | 
|  | const detail = { | 
|  | x: closest.sx, | 
|  | y: closest.sy, | 
|  | name: closest.name, | 
|  | }; | 
|  | if (detail.x !== this.hoverPt.x || detail.y !== this.hoverPt.y) { | 
|  | this.hoverPt = detail; | 
|  | this.dispatchEvent( | 
|  | new CustomEvent<PlotSimpleSkTraceEventDetails>('trace_focused', { | 
|  | detail, | 
|  | bubbles: true, | 
|  | }) | 
|  | ); | 
|  | } | 
|  | } | 
|  |  | 
|  | // Update crosshair. | 
|  | if (this.mouseMoveRaw.shiftKey && this.pointSearch) { | 
|  | this.crosshair = { | 
|  | x: pt.x, | 
|  | y: pt.y, | 
|  | shift: false, | 
|  | }; | 
|  | clampToRect(this.crosshair, this.detailArea.rect); | 
|  | } else { | 
|  | this.crosshair = { | 
|  | x: this.detailArea.range.x(this.hoverPt.x), | 
|  | y: this.detailArea.range.y(this.hoverPt.y), | 
|  | shift: true, | 
|  | }; | 
|  | } | 
|  | this.drawOverlayCanvas(); | 
|  | } else { | 
|  | // We are zooming. | 
|  | const pt = this.eventToCanvasPt(this.mouseMoveRaw); | 
|  |  | 
|  | if (this.inZoomDrag === 'summary') { | 
|  | clampToRect(pt, this.summaryArea.rect); | 
|  |  | 
|  | // x in source coordinates. | 
|  | const sx = this.summaryArea.range.x.invert(pt.x); | 
|  |  | 
|  | // Set zoom, always making sure we go from lowest to highest. | 
|  | let zoom: ZoomRange = [this.zoomBegin, sx]; | 
|  | if (this.zoomBegin > sx) { | 
|  | zoom = [sx, this.zoomBegin]; | 
|  | } | 
|  | this.zoom = zoom; | 
|  | } else if (this.inZoomDrag === 'details') { | 
|  | clampToRect(pt, this.detailArea.rect); | 
|  | this.zoomRect.width = pt.x - this.zoomRect.x; | 
|  | this.zoomRect.height = pt.y - this.zoomRect.y; | 
|  | this.drawOverlayCanvas(); | 
|  | } | 
|  | } | 
|  | this.mouseMoveRaw = null; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * This is a super simple hash (h = h * 31 + x_i) currently used | 
|  | * for things like assigning colors to graphs based on trace ids. It | 
|  | * shouldn't be used for anything more serious than that. | 
|  | * | 
|  | * @param {String} s - A string to hash. | 
|  | * @return {Number} A 32 bit hash for the given string. | 
|  | */ | 
|  | private hashString(s: string) { | 
|  | let hash = 0; | 
|  | for (let i = s.length - 1; i >= 0; i--) { | 
|  | // eslint-disable-next-line no-bitwise | 
|  | hash = (hash << 5) - hash + s.charCodeAt(i); | 
|  | // eslint-disable-next-line no-bitwise | 
|  | hash |= 0; | 
|  | } | 
|  | return Math.abs(hash); | 
|  | } | 
|  |  | 
|  | // Rebuilds our cache of Path2D objects we use for quick rendering. | 
|  | private recalcSummaryPaths() { | 
|  | this.lineData.forEach((line) => { | 
|  | // Need to pass in the x and y ranges, and the dot radius. | 
|  | if (line.name.startsWith(SPECIAL)) { | 
|  | line.color = this.LABEL_COLOR; | 
|  | } else { | 
|  | line.color = COLORS[(this.hashString(line.name) % 8) + 1]; | 
|  | } | 
|  |  | 
|  | const summaryBuilder = new PathBuilder( | 
|  | this.summaryArea.range.x, | 
|  | this.summaryArea.range.y, | 
|  | this.SUMMARY_RADIUS | 
|  | ); | 
|  |  | 
|  | line.values.forEach((y, x) => { | 
|  | if (Number.isNaN(y)) { | 
|  | return; | 
|  | } | 
|  | summaryBuilder.add(x, y); | 
|  | }); | 
|  | line.summary = summaryBuilder.paths(); | 
|  | }); | 
|  |  | 
|  | // Build summary x-axis. | 
|  | this.recalcXAxis(this.summaryArea, this.labels, 0); | 
|  | } | 
|  |  | 
|  | // Rebuilds our cache of Path2D objects we use for quick rendering. | 
|  | private recalcDetailPaths() { | 
|  | const domain = this.detailArea.range.x.domain(); | 
|  | domain[0] = Math.floor(domain[0] - 0.1); | 
|  | domain[1] = Math.ceil(domain[1] + 0.1); | 
|  | this.lineData.forEach((line) => { | 
|  | // Need to pass in the x and y ranges, and the dot radius. | 
|  | if (line.name.startsWith(SPECIAL)) { | 
|  | line.color = this.LABEL_COLOR; | 
|  | } else { | 
|  | line.color = COLORS[(this.hashString(line.name) % 8) + 1]; | 
|  | } | 
|  |  | 
|  | const detailBuilder = new PathBuilder( | 
|  | this.detailArea.range.x, | 
|  | this.detailArea.range.y, | 
|  | this.DETAIL_RADIUS | 
|  | ); | 
|  |  | 
|  | let previousPoint: Point = invalidPoint; | 
|  | let addedPointFromBeforeTheDomain = false; | 
|  | let addedPointFromAfterTheDomain = false; | 
|  | line.values.forEach((y, x) => { | 
|  | if (Number.isNaN(y)) { | 
|  | return; | 
|  | } | 
|  | // Always add in one point after the domain so we draw all visible line | 
|  | // segments. | 
|  | if (x > domain[1] && !addedPointFromAfterTheDomain) { | 
|  | detailBuilder.add(x, y); | 
|  | addedPointFromAfterTheDomain = true; | 
|  | return; | 
|  | } | 
|  | if (x < domain[0] || x > domain[1]) { | 
|  | previousPoint = { x: x, y: y }; | 
|  | return; | 
|  | } | 
|  | // Always add in one point before the domain so we draw all visible line | 
|  | // segments. | 
|  | if (!addedPointFromBeforeTheDomain) { | 
|  | if (pointIsValid(previousPoint)) { | 
|  | detailBuilder.add(previousPoint.x, previousPoint.y); | 
|  | } | 
|  | addedPointFromBeforeTheDomain = true; | 
|  | } | 
|  | detailBuilder.add(x, y); | 
|  | }); | 
|  | line.detail = detailBuilder.paths(); | 
|  | }); | 
|  |  | 
|  | // Build detail x-axis. | 
|  | const detailDomain = this.detailArea.range.x.domain(); | 
|  | const labelOffset = Math.ceil(detailDomain[0]); | 
|  | const detailLabels = this.labels.slice( | 
|  | Math.ceil(detailDomain[0]), | 
|  | Math.floor(detailDomain[1] + 1) | 
|  | ); | 
|  | this.recalcXAxis(this.detailArea, detailLabels, labelOffset); | 
|  |  | 
|  | // Build detail y-axis. | 
|  | this.recalcYAxis(this.detailArea); | 
|  | this.recalcSearch(); | 
|  | } | 
|  |  | 
|  | // Recalculates the y-axis info. | 
|  | private recalcYAxis(area: DetailArea) { | 
|  | const yAxisPath = new Path2D(); | 
|  | const thinX = Math.floor(this.detailArea.rect.x) + 0.5; // Make sure we get a thin line. https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Applying_styles_and_colors#A_lineWidth_example | 
|  | yAxisPath.moveTo(thinX, this.detailArea.rect.y); | 
|  | yAxisPath.lineTo( | 
|  | thinX, | 
|  | this.detailArea.rect.y + this.detailArea.rect.height | 
|  | ); | 
|  | area.yaxis.labels = []; | 
|  | area.range.y.ticks(NUM_Y_TICKS).forEach((t) => { | 
|  | const label = { | 
|  | x: 0, | 
|  | y: Math.floor(area.range.y(t)) + 0.5, | 
|  | text: `${this.numberFormatter.format(t)}`, | 
|  | }; | 
|  | area.yaxis.labels.push(label); | 
|  | yAxisPath.moveTo(thinX, label.y); | 
|  | yAxisPath.lineTo(thinX - this.Y_AXIS_TICK_LENGTH, label.y); | 
|  | }); | 
|  | area.yaxis.path = yAxisPath; | 
|  | } | 
|  |  | 
|  | // Recalculates the x-axis info. | 
|  | private recalcXAxis(area: Area, labels: Date[], labelOffset: number) { | 
|  | const xAxisPath = new Path2D(); | 
|  | const thinY = Math.floor(area.rect.y) + 0.5; // Make sure we get a thin line. | 
|  | xAxisPath.moveTo(area.rect.x + 0.5, thinY); | 
|  | xAxisPath.lineTo(area.rect.x + 0.5 + area.rect.width, thinY); | 
|  | area.axis.labels = []; | 
|  | ticks(labels).forEach((tick) => { | 
|  | const label = { | 
|  | x: Math.floor(area.range.x(tick.x + labelOffset)) + 0.5, | 
|  | y: area.rect.y - this.MARGIN / 2, | 
|  | text: tick.text, | 
|  | }; | 
|  | area.axis.labels.push(label); | 
|  | xAxisPath.moveTo(label.x, area.rect.y); | 
|  | xAxisPath.lineTo(label.x, area.rect.y - this.MARGIN / 2); | 
|  | }); | 
|  | area.axis.path = xAxisPath; | 
|  | } | 
|  |  | 
|  | // Rebuilds the kdTree we use to look up closest points. | 
|  | private recalcSearch() { | 
|  | if (this.recalcSearchTask) { | 
|  | return; | 
|  | } | 
|  | this.recalcSearchTask = window.setTimeout(() => this.recalcSearchImpl()); | 
|  | } | 
|  |  | 
|  | private recalcSearchImpl() { | 
|  | if (this.zoomTask) { | 
|  | // If there is a pending zoom task then let that complete first since zooming | 
|  | // invalidates the search tree and it needs to be built again. | 
|  | this.recalcSearchTask = window.setTimeout(() => this.recalcSearchImpl()); | 
|  | return; | 
|  | } | 
|  | const domain = this.detailArea.range.x.domain(); | 
|  | domain[0] = Math.floor(domain[0] - 0.1); | 
|  | domain[1] = Math.ceil(domain[1] + 0.1); | 
|  | const searchBuilder = new SearchBuilder( | 
|  | this.detailArea.range.x, | 
|  | this.detailArea.range.y | 
|  | ); | 
|  | this.lineData.forEach((line) => { | 
|  | line.values.forEach((y, x) => { | 
|  | if (Number.isNaN(y)) { | 
|  | return; | 
|  | } | 
|  | if (x < domain[0] || x > domain[1]) { | 
|  | return; | 
|  | } | 
|  | searchBuilder.add(x, y, line.name); | 
|  | }); | 
|  | }); | 
|  | this.pointSearch = searchBuilder.kdTree(); | 
|  | this.recalcSearchTask = 0; | 
|  | } | 
|  |  | 
|  | // Updates all of our d3Scale domains. | 
|  | private updateScaleDomains() { | 
|  | let domainEnd = 1; | 
|  | if (this.lineData && this.lineData.length) { | 
|  | domainEnd = this.lineData[0].values.length - 1; | 
|  | } | 
|  | if (this._zoom) { | 
|  | this.detailArea.range.x = this.detailArea.range.x.domain(this._zoom); | 
|  | } else { | 
|  | this.detailArea.range.x = this.detailArea.range.x.domain([0, domainEnd]); | 
|  | } | 
|  |  | 
|  | this.summaryArea.range.x = this.summaryArea.range.x.domain([0, domainEnd]); | 
|  |  | 
|  | const domain = [ | 
|  | d3Array.min(this.lineData, (line) => d3Array.min(line.values))!, | 
|  | d3Array.max(this.lineData, (line) => d3Array.max(line.values))!, | 
|  | ]; | 
|  |  | 
|  | this.detailArea.range.y = this.detailArea.range.y.domain(domain).nice(); | 
|  |  | 
|  | this.summaryArea.range.y = this.summaryArea.range.y.domain(domain); | 
|  |  | 
|  | // If detailsZoomRangeStacks is not empty then it overrides the detail | 
|  | // range. | 
|  | if (this.detailsZoomRangesStack.length > 0) { | 
|  | const zoom = | 
|  | this.detailsZoomRangesStack[this.detailsZoomRangesStack.length - 1]; | 
|  | this.detailArea.range.x = this.detailArea.range.x.domain([ | 
|  | zoom.x, | 
|  | zoom.x + zoom.width, | 
|  | ]); | 
|  | this.detailArea.range.y = this.detailArea.range.y.domain([ | 
|  | zoom.y, | 
|  | zoom.y + zoom.height, | 
|  | ]); | 
|  | } | 
|  | } | 
|  |  | 
|  | // Updates all of our d3Scale ranges. Also updates detail and summary rects. | 
|  | private updateScaleRanges() { | 
|  | const width = this.ctx!.canvas.width; | 
|  | const height = this.ctx!.canvas.height; | 
|  |  | 
|  | this.summaryArea.range.x = this.summaryArea.range.x.range([ | 
|  | this.LEFT_MARGIN, | 
|  | width - this.MARGIN, | 
|  | ]); | 
|  |  | 
|  | this.summaryArea.range.y = this.summaryArea.range.y.range([ | 
|  | this.SUMMARY_HEIGHT + this.MARGIN, | 
|  | this.MARGIN, | 
|  | ]); | 
|  |  | 
|  | this.detailArea.range.x = this.detailArea.range.x.range([ | 
|  | this.LEFT_MARGIN, | 
|  | width - this.MARGIN, | 
|  | ]); | 
|  |  | 
|  | this.detailArea.range.y = this.detailArea.range.y.range([ | 
|  | height - this.MARGIN, | 
|  | this.SUMMARY_HEIGHT + 2 * this.MARGIN, | 
|  | ]); | 
|  |  | 
|  | this.summaryArea.rect = { | 
|  | x: this.LEFT_MARGIN, | 
|  | y: this.MARGIN, | 
|  | width: width - this.MARGIN - this.LEFT_MARGIN, | 
|  | height: this.SUMMARY_HEIGHT, | 
|  | }; | 
|  |  | 
|  | this.detailArea.rect = { | 
|  | x: this.LEFT_MARGIN, | 
|  | y: this.SUMMARY_HEIGHT + 2 * this.MARGIN, | 
|  | width: width - this.MARGIN - this.LEFT_MARGIN, | 
|  | height: height - this.SUMMARY_HEIGHT - 3 * this.MARGIN, | 
|  | }; | 
|  | } | 
|  |  | 
|  | private doDetailsZoom() { | 
|  | // Don't actually do the zoom if the box isn't big enough. | 
|  | if ( | 
|  | Math.abs(this.zoomRect.width) < MIN_MOUSE_MOVE_FOR_ZOOM || | 
|  | Math.abs(this.zoomRect.height) < MIN_MOUSE_MOVE_FOR_ZOOM | 
|  | ) { | 
|  | return; | 
|  | } | 
|  | this.detailsZoomRangesStack.push( | 
|  | rectFromRangeInvert(this.detailArea.range, this.zoomRect) | 
|  | ); | 
|  |  | 
|  | // We added a new zoom range, which means all the inactive zoom ranges are | 
|  | // no longer valid. | 
|  | this.inactiveDetailsZoomRangesStack = []; | 
|  | this.inZoomDrag = 'no-zoom'; | 
|  | this._zoomImpl(); | 
|  | } | 
|  |  | 
|  | // Draw the contents of the overlay canvas. | 
|  | private drawOverlayCanvas() { | 
|  | // Always start by clearing the overlay. | 
|  | const width = this.overlayCtx!.canvas.width; | 
|  | const height = this.overlayCtx!.canvas.height; | 
|  | const ctx = this.overlayCtx!; | 
|  |  | 
|  | ctx.clearRect(0, 0, width, height); | 
|  |  | 
|  | if (this.summary) { | 
|  | // First clip to the summary region. | 
|  | ctx.save(); | 
|  | { | 
|  | // Block to scope save/restore. | 
|  | clipToRect(ctx, this.summaryArea.rect); | 
|  |  | 
|  | // Draw the xbar. | 
|  | this.drawXBar(ctx, this.summaryArea, this.SUMMARY_BAR_WIDTH); | 
|  |  | 
|  | // Draw the bands. | 
|  | this.drawBands(ctx, this.summaryArea, this.SUMMARY_BAR_WIDTH); | 
|  |  | 
|  | // If detailsZoomRangeStacks is not empty then draw a box to indicate | 
|  | // the zoomed region. | 
|  | if (this.detailsZoomRangesStack.length > 0) { | 
|  | const zoom = | 
|  | this.detailsZoomRangesStack[this.detailsZoomRangesStack.length - 1]; | 
|  | this.drawZoomRect(ctx, rectFromRange(this.summaryArea.range, zoom)); | 
|  | } | 
|  |  | 
|  | // Draw the zoom on the summary. | 
|  | if (this._zoom !== null) { | 
|  | ctx.lineWidth = this.ZOOM_BAR_LINE_WIDTH; | 
|  | ctx.strokeStyle = this.LABEL_COLOR; | 
|  |  | 
|  | // Draw left bar. | 
|  | const leftx = this.summaryArea.range.x(this._zoom[0]); | 
|  | ctx.beginPath(); | 
|  | ctx.moveTo(leftx, this.summaryArea.rect.y); | 
|  | ctx.lineTo( | 
|  | leftx, | 
|  | this.summaryArea.rect.y + this.summaryArea.rect.height | 
|  | ); | 
|  |  | 
|  | // Draw right bar. | 
|  | const rightx = this.summaryArea.range.x(this._zoom[1]); | 
|  | ctx.moveTo(rightx, this.summaryArea.rect.y); | 
|  | ctx.lineTo( | 
|  | rightx, | 
|  | this.summaryArea.rect.y + this.summaryArea.rect.height | 
|  | ); | 
|  | ctx.stroke(); | 
|  |  | 
|  | // Draw gray boxes. | 
|  | ctx.fillStyle = ZOOM_RECT_COLOR; | 
|  | ctx.rect( | 
|  | this.summaryArea.rect.x, | 
|  | this.summaryArea.rect.y, | 
|  | leftx - this.summaryArea.rect.x, | 
|  | this.summaryArea.rect.height | 
|  | ); | 
|  | ctx.rect( | 
|  | rightx, | 
|  | this.summaryArea.rect.y, | 
|  | this.summaryArea.rect.x + this.summaryArea.rect.width - rightx, | 
|  | this.summaryArea.rect.height | 
|  | ); | 
|  |  | 
|  | ctx.fill(); | 
|  | } | 
|  | } | 
|  | ctx.restore(); | 
|  | } | 
|  |  | 
|  | // Now clip to the detail region. | 
|  | ctx.save(); | 
|  | { | 
|  | // Block to scope save/restore. | 
|  | clipToRect(ctx, this.detailArea.rect); | 
|  |  | 
|  | // Draw the xbar. | 
|  | this.drawXBar(ctx, this.detailArea, this.DETAIL_BAR_WIDTH); | 
|  |  | 
|  | // Draw the bands. | 
|  | this.drawBands(ctx, this.detailArea, this.DETAIL_BAR_WIDTH); | 
|  |  | 
|  | // Draw highlighted lines. | 
|  | this.lineData.forEach((highlightedLine) => { | 
|  | if (!(highlightedLine.name in this.highlighted)) { | 
|  | return; | 
|  | } | 
|  | ctx.strokeStyle = highlightedLine.color; | 
|  | ctx.fillStyle = this.LABEL_BACKGROUND; | 
|  | ctx.lineWidth = this.SUMMARY_HIGHLIGHT_LINE_WIDTH; | 
|  |  | 
|  | ctx.stroke(highlightedLine.detail.linePath!); | 
|  | if (this.dots) { | 
|  | ctx.fill(highlightedLine.detail.dotsPath!); | 
|  | ctx.stroke(highlightedLine.detail.dotsPath!); | 
|  | } | 
|  | }); | 
|  |  | 
|  | // Find the line currently hovered over. | 
|  | let line = null; | 
|  | for (let i = 0; i < this.lineData.length; i++) { | 
|  | if (this.lineData[i].name === this.hoverPt.name) { | 
|  | line = this.lineData[i]; | 
|  | break; | 
|  | } | 
|  | } | 
|  | if (line !== null) { | 
|  | // Draw the hovered line and dots in a different color. | 
|  | ctx.strokeStyle = HOVER_COLOR; | 
|  | ctx.fillStyle = HOVER_COLOR; | 
|  | ctx.lineWidth = this.HOVER_LINE_WIDTH; | 
|  |  | 
|  | // Just draw the dots, not the line. | 
|  | if (this.dots) { | 
|  | ctx.fill(line.detail.dotsPath!); | 
|  | ctx.stroke(line.detail.dotsPath!); | 
|  | } | 
|  | } | 
|  |  | 
|  | // Draw the anomalies. | 
|  | this.drawAnomalies(ctx, this.detailArea); | 
|  |  | 
|  | if (this.inZoomDrag === 'details') { | 
|  | this.drawZoomRect(ctx, this.zoomRect); | 
|  | } else if (this.inZoomDrag === 'no-zoom') { | 
|  | // Draw the crosshairs. | 
|  | ctx.strokeStyle = this.CROSSHAIR_COLOR; | 
|  | ctx.lineWidth = AXIS_LINE_WIDTH; | 
|  | ctx.beginPath(); | 
|  | const thinX = Math.floor(this.crosshair.x) + 0.5; // Make sure we get a thin line. | 
|  | const thinY = Math.floor(this.crosshair.y) + 0.5; // Make sure we get a thin line. | 
|  | ctx.moveTo(this.detailArea.rect.x, thinY); | 
|  | ctx.lineTo(this.detailArea.rect.x + this.detailArea.rect.width, thinY); | 
|  | ctx.moveTo(thinX, this.detailArea.rect.y); | 
|  | ctx.lineTo(thinX, this.detailArea.rect.y + this.detailArea.rect.height); | 
|  | ctx.stroke(); | 
|  |  | 
|  | // Y label at crosshair if shift is pressed. | 
|  | if (this.crosshair.shift) { | 
|  | // Draw the label offset from the crosshair. | 
|  | ctx.font = this.LABEL_FONT; | 
|  | ctx.textBaseline = 'bottom'; | 
|  | ctx.textAlign = 'start'; | 
|  | const label = this.numberFormatter.format(this.hoverPt.y); | 
|  | let x = this.crosshair.x + this.MARGIN; | 
|  | let y = this.crosshair.y - this.MARGIN; | 
|  |  | 
|  | // First draw a white backdrop. | 
|  | ctx.fillStyle = this.LABEL_BACKGROUND; | 
|  | const meas = ctx.measureText(label); | 
|  | const labelHeight = this.LABEL_FONT_SIZE + 2 * this.LABEL_MARGIN; | 
|  | const labelWidth = meas.width + this.LABEL_MARGIN * 2; | 
|  |  | 
|  | // Bump the text to different quadrants so it is always visible. | 
|  | if (y < this.detailArea.rect.y + this.detailArea.rect.height / 2) { | 
|  | y = this.crosshair.y + this.MARGIN; | 
|  | } | 
|  | if (x > this.detailArea.rect.x + this.detailArea.rect.width / 2) { | 
|  | x = x - labelWidth - 2 * this.MARGIN; | 
|  | } | 
|  |  | 
|  | ctx.beginPath(); | 
|  | ctx.rect( | 
|  | x - this.LABEL_MARGIN, | 
|  | y + this.LABEL_MARGIN, | 
|  | labelWidth, | 
|  | -labelHeight | 
|  | ); | 
|  | ctx.fill(); | 
|  | ctx.strokeStyle = this.LABEL_COLOR; | 
|  | ctx.beginPath(); | 
|  | ctx.rect( | 
|  | x - this.LABEL_MARGIN, | 
|  | y + this.LABEL_MARGIN, | 
|  | labelWidth, | 
|  | -labelHeight | 
|  | ); | 
|  | ctx.stroke(); | 
|  |  | 
|  | // Now draw text on top. | 
|  | ctx.fillStyle = this.LABEL_COLOR; | 
|  | ctx.fillText(label, x, y); | 
|  | } | 
|  | } | 
|  | } | 
|  | ctx.restore(); | 
|  | } | 
|  |  | 
|  | // Draw a dashed rectangle for the details zoom. | 
|  | private drawZoomRect(ctx: CanvasRenderingContext2D, rect: Rect) { | 
|  | ctx.strokeStyle = this.LABEL_COLOR; | 
|  | ctx.lineWidth = 1; | 
|  | ctx.setLineDash([2, 2]); | 
|  | ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); | 
|  | ctx.setLineDash([]); | 
|  | } | 
|  |  | 
|  | // Draw the xbar in the given area with the given width. | 
|  | private drawXBar(ctx: CanvasRenderingContext2D, area: Area, width: number) { | 
|  | if (this.xbar === -1) { | 
|  | return; | 
|  | } | 
|  | ctx.lineWidth = width; | 
|  | ctx.strokeStyle = this.CROSSHAIR_COLOR; | 
|  | const bx = area.range.x(this._xbar); | 
|  | ctx.beginPath(); | 
|  | ctx.moveTo(bx, area.rect.y); | 
|  | ctx.lineTo(bx, area.rect.y + area.rect.height); | 
|  | ctx.stroke(); | 
|  | } | 
|  |  | 
|  | // Draw the bands in the given area with the given width. | 
|  | private drawBands(ctx: CanvasRenderingContext2D, area: Area, width: number) { | 
|  | ctx.lineWidth = width; | 
|  | ctx.strokeStyle = this.BAND_COLOR; | 
|  | ctx.setLineDash([width, width]); | 
|  | ctx.beginPath(); | 
|  | this._bands.forEach((band) => { | 
|  | const bx = area.range.x(band); | 
|  | ctx.moveTo(bx, area.rect.y); | 
|  | ctx.lineTo(bx, area.rect.y + area.rect.height); | 
|  | }); | 
|  | ctx.stroke(); | 
|  | ctx.setLineDash([]); | 
|  | } | 
|  |  | 
|  | // Draw all anomalies in the given area. | 
|  | private drawAnomalies(ctx: CanvasRenderingContext2D, area: Area) { | 
|  | const keys = Object.keys(this._anomalyDataMap); | 
|  | keys.forEach((key) => { | 
|  | this._anomalyDataMap[key].forEach((anomalyData) => { | 
|  | const anomaly = anomalyData.anomaly; | 
|  | const cx = area.range.x(anomalyData.x); | 
|  | const cy = area.range.y(anomalyData.y); | 
|  | const anomalyPath = new Path2D(); | 
|  |  | 
|  | // Draw white circle background of anomaly icon. | 
|  | anomalyPath.moveTo(cx + this.ANOMALY_RADIUS, cy); | 
|  | anomalyPath.arc(cx, cy, this.ANOMALY_RADIUS, 0, 2 * Math.PI); | 
|  | ctx.fillStyle = this.ANOMALY_BACKGROUND; | 
|  |  | 
|  | ctx.fill(anomalyPath); | 
|  | let symbol = ''; | 
|  | if (anomaly.is_improvement) { | 
|  | ctx.fillStyle = this.IMPROVEMENT_COLOR; | 
|  | symbol = String.fromCharCode(0xe86c); | 
|  | } else { | 
|  | ctx.fillStyle = this.REGRESSION_COLOR; | 
|  | symbol = String.fromCharCode(0xe000); | 
|  | } | 
|  |  | 
|  | // Draw anomaly icon. | 
|  | ctx.textAlign = 'center'; | 
|  | ctx.textBaseline = 'middle'; | 
|  | ctx.font = this.ANOMALY_FONT; | 
|  | ctx.fillText(symbol, cx, cy); | 
|  | }); | 
|  | }); | 
|  | } | 
|  |  | 
|  | // Draw everything on the trace canvas. | 
|  | // | 
|  | // Well, not quite everything, if we are drag zooming then we only redraw the | 
|  | // details and not the summary. | 
|  | private drawTracesCanvas() { | 
|  | const width = this.ctx!.canvas.width; | 
|  | const height = this.ctx!.canvas.height; | 
|  | const ctx = this.ctx!; | 
|  |  | 
|  | if (this.inZoomDrag !== 'no-zoom') { | 
|  | ctx.clearRect( | 
|  | this.detailArea.rect.x - this.MARGIN, | 
|  | this.detailArea.rect.y - this.MARGIN, | 
|  | this.detailArea.rect.width + 2 * this.MARGIN, | 
|  | this.detailArea.rect.height + 2 * this.MARGIN | 
|  | ); | 
|  | } else { | 
|  | ctx.clearRect(0, 0, width, height); | 
|  | } | 
|  | ctx.fillStyle = this.LABEL_BACKGROUND; | 
|  |  | 
|  | // Draw the detail. | 
|  | ctx.save(); | 
|  | { | 
|  | // Block to scope save/restore. | 
|  | clipToRect(ctx, this.detailArea.rect); | 
|  | this.drawXAxis(ctx, this.detailArea); | 
|  | ctx.fillStyle = this.LABEL_BACKGROUND; | 
|  |  | 
|  | this.lineData.forEach((line) => { | 
|  | ctx.strokeStyle = line.color; | 
|  | ctx.lineWidth = DETAIL_LINE_WIDTH; | 
|  | ctx.stroke(line.detail.linePath!); | 
|  | if (this.dots) { | 
|  | ctx.fill(line.detail.dotsPath!); | 
|  | ctx.stroke(line.detail.dotsPath!); | 
|  | } | 
|  | }); | 
|  | } | 
|  | ctx.restore(); | 
|  | this.drawXAxis(ctx, this.detailArea); | 
|  |  | 
|  | if (this.inZoomDrag === 'no-zoom' && this.summary) { | 
|  | // Draw the summary. | 
|  | ctx.save(); | 
|  | { | 
|  | // Block to scope save/restore. | 
|  | clipToRect(ctx, this.summaryArea.rect); | 
|  | this.lineData.forEach((line) => { | 
|  | ctx.fillStyle = this.LABEL_BACKGROUND; | 
|  | ctx.strokeStyle = line.color; | 
|  | ctx.lineWidth = SUMMARY_LINE_WIDTH; | 
|  | ctx.stroke(line.summary.linePath!); | 
|  | if (this.dots) { | 
|  | ctx.fill(line.summary.dotsPath!); | 
|  | ctx.stroke(line.summary.dotsPath!); | 
|  | } | 
|  | }); | 
|  | } | 
|  | ctx.restore(); | 
|  | this.drawXAxis(ctx, this.summaryArea); | 
|  | } | 
|  | // Draw y-Axes. | 
|  | this.drawYAxis(ctx, this.detailArea); | 
|  |  | 
|  | this.drawOverlayCanvas(); | 
|  | } | 
|  |  | 
|  | // Draw a y-axis using the given context in the given area. | 
|  | private drawYAxis(ctx: CanvasRenderingContext2D, area: DetailArea) { | 
|  | ctx.strokeStyle = this.LABEL_COLOR; | 
|  | ctx.fillStyle = this.LABEL_COLOR; | 
|  | ctx.font = this.LABEL_FONT; | 
|  | ctx.textBaseline = 'middle'; | 
|  | ctx.lineWidth = AXIS_LINE_WIDTH; | 
|  | ctx.textAlign = 'right'; | 
|  | ctx.stroke(area.yaxis.path); | 
|  | const labelWidth = (3 * this.LEFT_MARGIN) / 4; | 
|  | area.yaxis.labels.forEach((label) => { | 
|  | ctx.fillText(label.text, label.x + labelWidth, label.y, labelWidth); | 
|  | }); | 
|  | } | 
|  |  | 
|  | // Draw a x-axis using the given context in the given area. | 
|  | private drawXAxis(ctx: CanvasRenderingContext2D, area: Area) { | 
|  | ctx.strokeStyle = this.LABEL_COLOR; | 
|  | ctx.fillStyle = this.LABEL_COLOR; | 
|  | ctx.font = this.LABEL_FONT; | 
|  | ctx.textBaseline = 'middle'; | 
|  | ctx.lineWidth = AXIS_LINE_WIDTH; | 
|  | ctx.stroke(area.axis.path); | 
|  | area.axis.labels.forEach((label) => { | 
|  | ctx.fillText(label.text, label.x - 2, label.y); | 
|  | }); | 
|  | } | 
|  |  | 
|  | /** | 
|  | *  An array of trace ids to highlight. Set to [] to remove all highlighting. | 
|  | */ | 
|  | get highlight(): string[] { | 
|  | return Object.keys(this.highlighted); | 
|  | } | 
|  |  | 
|  | set highlight(ids: string[]) { | 
|  | this.highlighted = {}; | 
|  | ids.forEach((name) => { | 
|  | this.highlighted[name] = true; | 
|  | }); | 
|  | this.drawOverlayCanvas(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Location to put a vertical marking bar on the graph. Can be set to -1 to | 
|  | * not display any bar. | 
|  | */ | 
|  | get xbar(): number { | 
|  | return this._xbar; | 
|  | } | 
|  |  | 
|  | set xbar(value: number) { | 
|  | this._xbar = value; | 
|  | this.drawOverlayCanvas(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * A list of x source offsets to place vertical markers. into labels. Can be | 
|  | *   set to [] to remove all bands. | 
|  | */ | 
|  | get bands(): number[] { | 
|  | return this._bands; | 
|  | } | 
|  |  | 
|  | set bands(bands: number[]) { | 
|  | if (!bands) { | 
|  | this._bands = []; | 
|  | } else { | 
|  | this._bands = bands; | 
|  | } | 
|  | this.drawOverlayCanvas(); | 
|  | } | 
|  |  | 
|  | get anomalyDataMap(): { [key: string]: AnomalyData[] } { | 
|  | return this._anomalyDataMap; | 
|  | } | 
|  |  | 
|  | set anomalyDataMap(anomalyDataMap: { [key: string]: AnomalyData[] }) { | 
|  | this._anomalyDataMap = anomalyDataMap; | 
|  | this.drawOverlayCanvas(); | 
|  | } | 
|  |  | 
|  | /** The zoom range, an array of two values in source x units. Can be set to | 
|  | * null to have no zoom. | 
|  | */ | 
|  | get zoom(): ZoomRange { | 
|  | return this._zoom; | 
|  | } | 
|  |  | 
|  | set zoom(range: ZoomRange) { | 
|  | this._zoom = range; | 
|  | if (this.zoomTask) { | 
|  | return; | 
|  | } | 
|  | this.zoomTask = window.setTimeout(() => this._zoomImpl()); | 
|  | } | 
|  |  | 
|  | private _zoomImpl() { | 
|  | this.updateScaleDomains(); | 
|  | this.recalcDetailPaths(); | 
|  | this.drawTracesCanvas(); | 
|  | this.zoomTask = 0; | 
|  | } | 
|  |  | 
|  | static get observedAttributes(): string[] { | 
|  | return ['width', 'height', 'summary']; | 
|  | } | 
|  |  | 
|  | /** 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()); | 
|  | } | 
|  |  | 
|  | /** @prop summary {string} Mirrors the summary attribute. */ | 
|  | get summary(): boolean { | 
|  | return this.hasAttribute('summary'); | 
|  | } | 
|  |  | 
|  | set summary(val: boolean) { | 
|  | if (val) { | 
|  | this.setAttribute('summary', val.toString()); | 
|  | } else { | 
|  | this.removeAttribute('summary'); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** @prop nodots {boolean} Mirrors the nodots attribute.  */ | 
|  | get dots(): boolean { | 
|  | return this._dots; | 
|  | } | 
|  |  | 
|  | set dots(val: boolean) { | 
|  | this._dots = val; | 
|  | this.drawTracesCanvas(); | 
|  | } | 
|  | } | 
|  |  | 
|  | define('plot-simple-sk', PlotSimpleSk); |