| import { html, LitElement, css, PropertyValues } from 'lit'; |
| import { property } from 'lit/decorators.js'; |
| import { createRef, ref } from 'lit/directives/ref.js'; |
| import { define } from '../../../elements-sk/modules/define'; |
| import { HResizableBoxSk } from '../plot-summary-sk/h_resizable_box_sk'; |
| import { TraceSeries, TraceRow } from './trace-types'; |
| import '@material/web/iconbutton/outlined-icon-button.js'; |
| import '@material/web/icon/icon.js'; |
| |
| // Ensure HResizableBoxSk is registered |
| if (!customElements.get('h-resizable-box-sk')) { |
| define('h-resizable-box-sk', HResizableBoxSk); |
| } |
| |
| /** |
| * @module modules/explore-multi-v2-sk/plot-summary-v2-sk |
| * @description Canvas rendering and decimation pipeline. |
| */ |
| export class PlotSummaryV2Sk extends LitElement { |
| private canvasRef = createRef<HTMLCanvasElement>(); |
| |
| private boxRef = createRef<HResizableBoxSk>(); |
| |
| private containerRef = createRef<HTMLDivElement>(); |
| |
| private resizeObserver: ResizeObserver | null = null; |
| |
| @property({ type: Array }) |
| series: TraceSeries[] = []; |
| |
| @property({ type: String }) |
| domain: 'commit' | 'date' = 'commit'; |
| |
| @property({ type: Number }) |
| viewportMinX: number | null = null; |
| |
| @property({ type: Number }) |
| viewportMaxX: number | null = null; |
| |
| @property({ type: Boolean }) |
| evenXAxisSpacing = false; |
| |
| @property({ type: Boolean }) |
| loading = false; |
| |
| static styles = css` |
| :host { |
| display: block; |
| margin-top: 12px; |
| } |
| |
| .plot-summary-layout { |
| display: flex; |
| flex-direction: row; |
| align-items: center; |
| width: 100%; |
| gap: 8px; |
| } |
| |
| .summary-container { |
| position: relative; |
| flex: 1; |
| height: 45px; |
| border: 1px solid var(--outline); |
| border-radius: 6px; |
| background: var(--background); |
| box-sizing: border-box; |
| overflow: hidden; |
| } |
| |
| md-outlined-icon-button { |
| --md-outlined-icon-button-container-width: 32px; |
| --md-outlined-icon-button-container-height: 32px; |
| --md-outlined-icon-button-icon-size: 20px; |
| } |
| |
| canvas { |
| display: block; |
| width: 100%; |
| height: 100%; |
| } |
| |
| h-resizable-box-sk { |
| position: absolute; |
| top: 0; |
| bottom: 0; |
| left: 0; |
| width: 100%; |
| } |
| |
| .overlay { |
| position: absolute; |
| inset: 0; |
| background: color-mix(in srgb, var(--background) 50%, transparent); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 10; |
| } |
| |
| .spinner { |
| width: 16px; |
| height: 16px; |
| border: 2px solid var(--primary); |
| border-top-color: transparent; |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| } |
| |
| @keyframes spin { |
| to { |
| transform: rotate(360deg); |
| } |
| } |
| `; |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| this.resizeObserver = new ResizeObserver(() => { |
| this.drawSummary(); |
| this.requestUpdate(); |
| }); |
| this.resizeObserver.observe(this); |
| } |
| |
| disconnectedCallback() { |
| if (this.resizeObserver) { |
| this.resizeObserver.disconnect(); |
| } |
| super.disconnectedCallback(); |
| } |
| |
| private sortedXCache: number[] | null = null; |
| |
| protected updated(changedProperties: PropertyValues) { |
| super.updated(changedProperties); |
| if (changedProperties.has('series') || changedProperties.has('domain')) { |
| this.sortedXCache = null; |
| } |
| if ( |
| changedProperties.has('series') || |
| changedProperties.has('domain') || |
| changedProperties.has('evenXAxisSpacing') |
| ) { |
| this.drawSummary(); |
| } |
| } |
| |
| private getX(r: TraceRow): number { |
| return this.domain === 'date' ? r.createdat : r.commit_number; |
| } |
| |
| private decimate(rows: TraceRow[]): TraceRow[] { |
| const maxPoints = 500; |
| if (rows.length <= maxPoints) { |
| return rows; |
| } |
| const bucketSize = Math.ceil((2 * rows.length) / maxPoints); |
| const decimated: TraceRow[] = []; |
| |
| for (let i = 0; i < rows.length; i += bucketSize) { |
| const end = Math.min(i + bucketSize, rows.length); |
| let minIdx = i; |
| let maxIdx = i; |
| |
| for (let j = i + 1; j < end; j++) { |
| if (rows[j].val < rows[minIdx].val) minIdx = j; |
| if (rows[j].val > rows[maxIdx].val) maxIdx = j; |
| } |
| |
| if (minIdx === maxIdx) { |
| decimated.push(rows[minIdx]); |
| } else if (minIdx < maxIdx) { |
| decimated.push(rows[minIdx]); |
| decimated.push(rows[maxIdx]); |
| } else { |
| decimated.push(rows[maxIdx]); |
| decimated.push(rows[minIdx]); |
| } |
| } |
| return decimated; |
| } |
| |
| private getSortedX(): number[] { |
| if (this.sortedXCache) { |
| return this.sortedXCache; |
| } |
| const uniqueX = new Set<number>(); |
| this.series |
| .filter((s) => !s.hidden) |
| .forEach((s) => { |
| if (s.rows) { |
| s.rows.forEach((r) => { |
| const x = this.getX(r); |
| if (x !== undefined && !isNaN(x)) { |
| uniqueX.add(x); |
| } |
| }); |
| } |
| }); |
| this.sortedXCache = Array.from(uniqueX).sort((a, b) => a - b); |
| return this.sortedXCache; |
| } |
| |
| private getVirtualIndex(arr: number[], val: number): number { |
| if (arr.length === 0) return 0; |
| if (arr.length === 1) return val - arr[0]; |
| |
| const n = arr.length; |
| if (val <= arr[0]) { |
| return (val - arr[0]) / (arr[1] - arr[0]); |
| } |
| if (val >= arr[n - 1]) { |
| return n - 1 + (val - arr[n - 1]) / (arr[n - 1] - arr[n - 2]); |
| } |
| |
| let low = 0; |
| let high = n - 1; |
| while (low <= high) { |
| const mid = Math.floor((low + high) / 2); |
| if (arr[mid] === val) return mid; |
| if (arr[mid] < val) low = mid + 1; |
| else high = mid - 1; |
| } |
| const i = low - 1; |
| return i + (val - arr[i]) / (arr[i + 1] - arr[i]); |
| } |
| |
| private getValueFromVirtualIndex(arr: number[], virtIdx: number): number { |
| if (arr.length === 0) return 0; |
| if (arr.length === 1) return arr[0] + virtIdx; |
| |
| const n = arr.length; |
| if (virtIdx <= 0) { |
| return arr[0] + virtIdx * (arr[1] - arr[0]); |
| } |
| if (virtIdx >= n - 1) { |
| return arr[n - 1] + (virtIdx - (n - 1)) * (arr[n - 1] - arr[n - 2]); |
| } |
| |
| const i = Math.floor(virtIdx); |
| const frac = virtIdx - i; |
| return arr[i] + frac * (arr[i + 1] - arr[i]); |
| } |
| |
| private getSeriesBounds(): { min: number; max: number } { |
| let minX = Infinity; |
| let maxX = -Infinity; |
| this.series |
| .filter((s) => !s.hidden) |
| .forEach((s) => { |
| if (s.rows) { |
| s.rows.forEach((r) => { |
| const xVal = this.getX(r); |
| if (xVal !== undefined && !isNaN(xVal)) { |
| if (xVal < minX) minX = xVal; |
| if (xVal > maxX) maxX = xVal; |
| } |
| }); |
| } |
| }); |
| |
| return { min: minX, max: maxX }; |
| } |
| |
| private convertToCoordsRange( |
| beginVal: number, |
| endVal: number, |
| width: number |
| ): { begin: number; end: number } | null { |
| if (width === 0) return null; |
| |
| if (this.evenXAxisSpacing) { |
| const sortedX = this.getSortedX(); |
| if (sortedX.length < 2) return null; |
| const minVirtIdx = this.getVirtualIndex(sortedX, beginVal); |
| const maxVirtIdx = this.getVirtualIndex(sortedX, endVal); |
| const maxIdx = sortedX.length - 1; |
| return { |
| begin: (minVirtIdx / maxIdx) * width, |
| end: (maxVirtIdx / maxIdx) * width, |
| }; |
| } |
| |
| const { min: minX, max: maxX } = this.getSeriesBounds(); |
| if (minX === Infinity || maxX === -Infinity || maxX === minX) return null; |
| |
| const mapVal = (v: number) => ((v - minX) / (maxX - minX)) * width; |
| return { |
| begin: mapVal(beginVal), |
| end: mapVal(endVal), |
| }; |
| } |
| |
| private convertToValueRange( |
| beginPx: number, |
| endPx: number, |
| width: number |
| ): { begin: number; end: number } | null { |
| if (width === 0) return null; |
| |
| if (this.evenXAxisSpacing) { |
| const sortedX = this.getSortedX(); |
| if (sortedX.length < 2) return null; |
| const maxIdx = sortedX.length - 1; |
| const minVirtIdx = (beginPx / width) * maxIdx; |
| const maxVirtIdx = (endPx / width) * maxIdx; |
| return { |
| begin: this.getValueFromVirtualIndex(sortedX, minVirtIdx), |
| end: this.getValueFromVirtualIndex(sortedX, maxVirtIdx), |
| }; |
| } |
| |
| const { min: minX, max: maxX } = this.getSeriesBounds(); |
| if (minX === Infinity || maxX === -Infinity || maxX === minX) return null; |
| |
| const mapPx = (px: number) => minX + (px / width) * (maxX - minX); |
| return { |
| begin: mapPx(beginPx), |
| end: mapPx(endPx), |
| }; |
| } |
| |
| private computeSummaryBounds(xToIndex: Map<number, number>): { |
| minX: number; |
| maxX: number; |
| minY: number; |
| maxY: number; |
| } { |
| let minX = Infinity; |
| let maxX = -Infinity; |
| let minY = Infinity; |
| let maxY = -Infinity; |
| |
| this.series.forEach((s) => { |
| if (s.hidden || !s.rows) return; |
| s.rows.forEach((r) => { |
| const rawX = this.getX(r); |
| if (rawX === undefined || isNaN(rawX)) return; |
| const xVal = this.evenXAxisSpacing ? xToIndex.get(rawX) : rawX; |
| if (xVal === undefined || isNaN(xVal)) return; |
| |
| if (xVal < minX) minX = xVal; |
| if (xVal > maxX) maxX = xVal; |
| if (r.val < minY) minY = r.val; |
| if (r.val > maxY) maxY = r.val; |
| }); |
| }); |
| |
| return { minX, maxX, minY, maxY }; |
| } |
| |
| private renderSeriesToCanvas( |
| ctx: CanvasRenderingContext2D, |
| xToIndex: Map<number, number>, |
| mapX: (x: number) => number, |
| mapY: (y: number) => number |
| ): void { |
| this.series.forEach((s) => { |
| if (s.hidden || !s.rows || s.rows.length === 0) return; |
| |
| const decimated = this.decimate(s.rows); |
| ctx.strokeStyle = s.color || '#1a73e8'; |
| ctx.globalAlpha = 0.7; |
| |
| ctx.beginPath(); |
| let isFirst = true; |
| decimated.forEach((r) => { |
| const rawX = this.getX(r); |
| if (rawX === undefined || isNaN(rawX)) return; |
| const xVal = this.evenXAxisSpacing ? xToIndex.get(rawX) : rawX; |
| if (xVal === undefined || isNaN(xVal)) return; |
| |
| const px = mapX(xVal); |
| const py = mapY(r.val); |
| if (isNaN(px) || isNaN(py)) return; |
| |
| if (isFirst) { |
| ctx.moveTo(px, py); |
| isFirst = false; |
| } else { |
| ctx.lineTo(px, py); |
| } |
| }); |
| ctx.stroke(); |
| }); |
| } |
| |
| private prepareCanvas(): { ctx: CanvasRenderingContext2D; width: number; height: number } | null { |
| const canvas = this.canvasRef.value; |
| if (!canvas) return null; |
| const rect = canvas.getBoundingClientRect(); |
| if (!rect.width || !rect.height) return null; |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) return null; |
| |
| const dpr = window.devicePixelRatio || 1; |
| canvas.width = rect.width * dpr; |
| canvas.height = rect.height * dpr; |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| ctx.clearRect(0, 0, rect.width, rect.height); |
| return { ctx, width: rect.width, height: rect.height }; |
| } |
| |
| private getSummaryIndexMap(): Map<number, number> { |
| const xToIndex = new Map<number, number>(); |
| if (this.evenXAxisSpacing) { |
| this.getSortedX().forEach((v, i) => xToIndex.set(v, i)); |
| } |
| return xToIndex; |
| } |
| |
| public drawSummary() { |
| if (!this.series || this.series.length === 0) return; |
| const prep = this.prepareCanvas(); |
| if (!prep) return; |
| |
| const xToIndex = this.getSummaryIndexMap(); |
| const { minX, maxX, minY, maxY } = this.computeSummaryBounds(xToIndex); |
| if (!isFinite(minX) || !isFinite(minY)) return; |
| |
| const yDelta = maxY - minY; |
| const adjustedMinY = yDelta === 0 ? minY - 1 : minY - yDelta * 0.05; |
| const adjustedMaxY = yDelta === 0 ? maxY + 1 : maxY + yDelta * 0.05; |
| |
| const paddingY = 4; |
| const drawableHeight = prep.height - 2 * paddingY; |
| const yRange = adjustedMaxY - adjustedMinY || 1; |
| const xRange = maxX - minX || 1; |
| |
| const mapX = (xVal: number) => ((xVal - minX) / xRange) * prep.width; |
| const mapY = (yVal: number) => |
| prep.height - paddingY - ((yVal - adjustedMinY) / yRange) * drawableHeight; |
| |
| prep.ctx.lineWidth = 1.5; |
| this.renderSeriesToCanvas(prep.ctx, xToIndex, mapX, mapY); |
| prep.ctx.globalAlpha = 1.0; |
| } |
| |
| protected render() { |
| const parentWidth = this.containerRef.value?.offsetWidth || 0; |
| let selectionRange = null; |
| if (this.viewportMinX !== null && this.viewportMaxX !== null) { |
| const coords = this.convertToCoordsRange(this.viewportMinX, this.viewportMaxX, parentWidth); |
| if (coords) { |
| selectionRange = { begin: coords.begin, end: coords.end }; |
| } |
| } |
| |
| return html` |
| <div class="plot-summary-layout"> |
| <md-outlined-icon-button ?disabled=${this.loading} @click=${() => this.loadMore('left')}> |
| <md-icon>chevron_left</md-icon> |
| </md-outlined-icon-button> |
| <div class="summary-container" ${ref(this.containerRef)}> |
| <canvas ${ref(this.canvasRef)}></canvas> |
| <h-resizable-box-sk |
| ${ref(this.boxRef)} |
| .selectionRange=${selectionRange} |
| @selection-changed=${this.handleBoxChanged}> |
| </h-resizable-box-sk> |
| ${this.loading |
| ? html` |
| <div class="overlay"> |
| <div class="spinner"></div> |
| </div> |
| ` |
| : ''} |
| </div> |
| <md-outlined-icon-button ?disabled=${this.loading} @click=${() => this.loadMore('right')}> |
| <md-icon>chevron_right</md-icon> |
| </md-outlined-icon-button> |
| </div> |
| `; |
| } |
| |
| private loadMore(side: 'left' | 'right') { |
| this.dispatchEvent( |
| new CustomEvent('load-more-click', { |
| detail: side, |
| bubbles: true, |
| composed: true, |
| }) |
| ); |
| } |
| |
| private handleBoxChanged(e: CustomEvent<{ begin: number; end: number } | null>) { |
| const detail = e.detail; |
| if (!detail) { |
| const { min: minX, max: maxX } = this.getSeriesBounds(); |
| this.dispatchEvent( |
| new CustomEvent('summary-range-selected', { |
| detail: { begin: minX, end: maxX }, |
| bubbles: true, |
| composed: true, |
| }) |
| ); |
| return; |
| } |
| |
| const parentWidth = this.containerRef.value?.offsetWidth || 0; |
| const valueRange = this.convertToValueRange(detail.begin, detail.end, parentWidth); |
| if (valueRange) { |
| this.dispatchEvent( |
| new CustomEvent('summary-range-selected', { |
| detail: { |
| begin: valueRange.begin, |
| end: valueRange.end, |
| }, |
| bubbles: true, |
| composed: true, |
| }) |
| ); |
| } |
| } |
| |
| /** |
| * Select programmatic positioning skeletal interface. |
| */ |
| Select(begin: number, end: number) { |
| console.log('Programmatic skeletal Select called:', begin, end); |
| } |
| } |
| |
| define('plot-summary-v2-sk', PlotSummaryV2Sk); |