blob: 037a53dd0f47a13f42ed1e51af19028f79224a63 [file]
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);