| /** |
| * @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 { tick, 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, |
| }; |
| } |
| } |
| |
| export 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; |
| |
| export 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; |
| } |
| |
| export 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. |
| export 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: tick; |
| xEnd: tick; |
| } |
| |
| /** |
| * 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 tick objects the same length as the values in lineData. */ |
| private labels: tick[] = []; |
| |
| /** |
| * 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. |
| |
| get scrollable(): boolean { |
| return this.hasAttribute('scrollable'); |
| } |
| |
| set scrollable(val: boolean) { |
| if (val) { |
| this.setAttribute('scrollable', ''); |
| } else { |
| this.removeAttribute('scrollable'); |
| } |
| } |
| |
| 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: MouseEvent) => { |
| // 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: MouseEvent) => { |
| e.preventDefault(); |
| 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', (e: MouseEvent) => { |
| e.preventDefault(); |
| if (this.inZoomDrag !== 'no-zoom') { |
| this.dispatchZoomEvent(); |
| } |
| if (this.inZoomDrag === 'details') { |
| this.doDetailsZoom(); |
| } |
| this.inZoomDrag = 'no-zoom'; |
| }); |
| |
| this.addEventListener('mouseleave', (e: MouseEvent) => { |
| e.preventDefault(); |
| if (this.inZoomDrag !== 'no-zoom') { |
| this.dispatchZoomEvent(); |
| } |
| if (this.inZoomDrag === 'details') { |
| this.doDetailsZoom(); |
| } |
| this.inZoomDrag = 'no-zoom'; |
| }); |
| |
| if (!this.scrollable) { |
| 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: tick[]): 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: tick[], 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 = []; |
| 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); |