Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/app/common/loader/reference-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface TextReferenceFromKHIFileBinary extends TextReference {
* Convert the raw KHIFileTextReference to TextReferenceFromKHIFileBinary.
*/
export function ToTextReferenceFromKHIFileBinary(
reference: KHIFileTextReference | null,
reference: KHIFileTextReference | null = null,
): TextReference {
if (reference === null) {
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { HorizontalScrollCalculator } from './horizontal-scroll-calculator';

describe('HorizontalScrollCalculator', () => {
describe('totalWidth', () => {
it('returns total needed width for rendering without offset & margin', () => {
const calculator = new HorizontalScrollCalculator(0, 1000, 0);
// 1000ms & 1px/ms => 1000 px
expect(calculator.totalWidth(1)).toBeCloseTo(1000);
expect(calculator.totalWidth(3)).toBeCloseTo(3000);
});

it('returns total needed width for rendering with offset', () => {
const calculator = new HorizontalScrollCalculator(1000, 2000, 0);
expect(calculator.totalWidth(1)).toBeCloseTo(1000);
expect(calculator.totalWidth(3)).toBeCloseTo(3000);
});

it('returns total needed width for rendering with offset & margin', () => {
const calculator = new HorizontalScrollCalculator(1000, 2000, 300);
// 1000ms & 1px/ms => 1000 px
// 300px margin on both sides
expect(calculator.totalWidth(1)).toBeCloseTo(1000 + 300 * 2);
expect(calculator.totalWidth(3)).toBeCloseTo(3000 + 300 * 2);
});
});

describe('totalRenderWidth', () => {
it('returns viewport width + extra offset width', () => {
const calculator = new HorizontalScrollCalculator(0, 1000, 300);
expect(calculator.totalRenderWidth(1000)).toBeCloseTo(1000 + 300 * 2);
});
});

describe('leftDrawAreaTimeMS', () => {
it('returns aligned time based on tickTimeMS', () => {
const calculator = new HorizontalScrollCalculator(0, 1000, 300);
// tickTimeMS = 100
// extraOffsetTimeMS (at 1px/ms) = 300ms
// viewportLeftTimeMS = 550
// (550 - 300) / 100 = 2.5 -> floor -> 2 -> 200
expect(calculator.leftDrawAreaTimeMS(550, 100, 1)).toBeCloseTo(200);
});

it('returns aligned time based on tickTimeMS with different pixelsPerMs', () => {
const calculator = new HorizontalScrollCalculator(0, 1000, 300);
// tickTimeMS = 100
// extraOffsetTimeMS (at 10px/ms) = 300/10 = 30ms
// viewportLeftTimeMS = 155
// (155 - 30) / 100 = 1.25 -> floor -> 1 -> 100
expect(calculator.leftDrawAreaTimeMS(155, 100, 10)).toBeCloseTo(100);
});
});

describe('calculateZoomScrollLeft', () => {
it('calculates correct new scroll position when zooming in without extra offset', () => {
const calculator = new HorizontalScrollCalculator(1000, 2000, 0);
const currentPpm = 1;
const newPpm = 2;
const mousePos = 100;
const currentScrollLeft = 0;

// Initial state:
// minScrollableTime = 1000
// viewportLeftTime = 1000
// mouseTime = 1000 + 100/1 = 1100

// Expected state:
// minScrollableTime = 1000
// mouseTime = 1100
// newViewportLeftTime = 1100 - 100/2 = 1050
// newScrollLeft = (1050 - 1000) * 2 = 100

const newScrollLeft = calculator.calculateZoomScrollLeft(
currentPpm,
newPpm,
mousePos,
currentScrollLeft,
);
expect(newScrollLeft).toBeCloseTo(100);
});

it('calculates correct new scroll position when zooming in with extra offset', () => {
const calculator = new HorizontalScrollCalculator(1000, 2000, 300);
const currentPpm = 1;
const newPpm = 2;
const mousePos = 100;
// Let's assume scrolled a bit
// minScrollableTime(1) = 700
// scrollLeft=100 => viewportLeftTime = 700 + 100 = 800
const currentScrollLeft = 100;

// mouseTime = 800 + 100/1 = 900

// New state:
// minScrollableTime(2) = 1000 - 300/2 = 850
// mouseTime should be 900
// newViewportLeftTime = 900 - 100/2 = 850
// newScrollLeft = (850 - 850) * 2 = 0

const newScrollLeft = calculator.calculateZoomScrollLeft(
currentPpm,
newPpm,
mousePos,
currentScrollLeft,
);
expect(newScrollLeft).toBeCloseTo(0);
});

it('keeps the time at mouse position constant', () => {
const calculator = new HorizontalScrollCalculator(1000, 5000, 300);
const currentPpm = 2;
const newPpm = 5.5; // Arbitrary zoom
const mousePos = 453; // Arbitrary mouse position
const currentScrollLeft = 1500; // Arbitrary scroll

const prevTimeAtMouse =
calculator.scrollToViewportLeftTime(currentScrollLeft, currentPpm) +
mousePos / currentPpm;

const newScrollLeft = calculator.calculateZoomScrollLeft(
currentPpm,
newPpm,
mousePos,
currentScrollLeft,
);

const newTimeAtMouse =
calculator.scrollToViewportLeftTime(newScrollLeft, newPpm) +
mousePos / newPpm;

expect(newTimeAtMouse).toBeCloseTo(prevTimeAtMouse);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* HorizontalScrollCalculator calculates horizontal virtual scrolling for the timeline.
* It manages the conversion between time (ms) and pixel coordinates, taking into account
* zoom levels (pixels per millisecond) and extra buffer offsets.
*/
export class HorizontalScrollCalculator {
/**
* @param minTimeMs The minimum timestamp (start time) of the log data.
* @param maxTimeMs The maximum timestamp (end time) of the log data.
* @param extraOffsetWidthInPx Extra width in pixels to render outside the viewport for buffering. Defaults to 300.
*/
constructor(
readonly minTimeMs: number,
readonly maxTimeMs: number,
readonly extraOffsetWidthInPx: number = 300,
) {}

/**
* The total width of the scrollable area in pixels.
*
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns Total width in pixels.
*/
totalWidth(pixelsPerMs: number): number {
const timeSpan =
this.maxScrollableTimeMS(pixelsPerMs) -
this.minScrollableTimeMS(pixelsPerMs);
return timeSpan * pixelsPerMs;
}

/**
* Calculates the total width to be rendered, including the visible viewport and buffers.
*
* @param viewportWidth The width of the visible part of the scroll container (px).
* @returns Total efficient render width (px).
*/
totalRenderWidth(viewportWidth: number): number {
return viewportWidth + this.extraOffsetWidthInPx * 2;
}

/**
* Calculates the starting time (ms) for the content to be rendered based on the viewport's position.
* It aligns the start time to the nearest tick interval.
*
* @param viewportLeftTimeMS The time at the left edge of the visible viewport.
* @param tickTimeMS The current tick interval in milliseconds.
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns The aligned start time (ms) for rendering.
*/
leftDrawAreaTimeMS(
viewportLeftTimeMS: number,
tickTimeMS: number,
pixelsPerMs: number,
): number {
return (
Math.floor(
(viewportLeftTimeMS - this.extraOffsetTimeMS(pixelsPerMs)) / tickTimeMS,
) * tickTimeMS
);
}

/**
* Calculates the CSS `left` offset for the rendered content area.
* This aligns the content with the virtual scroll position.
*
* @param viewportLeftTimeMS The time at the left edge of the visible viewport.
* @param tickTimeMS The current tick interval in milliseconds.
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns The pixel offset for the left edge of the rendered content.
*/
leftDrawAreaOffset(
viewportLeftTimeMS: number,
tickTimeMS: number,
pixelsPerMs: number,
): number {
const vpLeftToDrawAreaLeftInPx =
(viewportLeftTimeMS -
this.leftDrawAreaTimeMS(viewportLeftTimeMS, tickTimeMS, pixelsPerMs)) *
pixelsPerMs;
return (
(viewportLeftTimeMS - this.minScrollableTimeMS(pixelsPerMs)) *
pixelsPerMs -
vpLeftToDrawAreaLeftInPx
);
}

/**
* Calculates the minimum zoom level (pixels per ms) required to fit the entire timeline in the viewport.
*
* @param viewportWidth The width of the viewport (px).
* @returns The minimum pixels per millisecond.
*/
minPixelPerMs(viewportWidth: number): number {
const logTimeMS = this.maxTimeMs - this.minTimeMs;
return viewportWidth / logTimeMS;
}

/**
* Returns the maximum allowed zoom level.
*
* @returns The maximum pixels per millisecond.
*/
maxPixelPerMs(): number {
return 10;
}

/**
* Calculates the minimum scrollable time (start of the scrollable area), including the buffer.
*
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns The minimum scrollable time in milliseconds.
*/
minScrollableTimeMS(pixelsPerMs: number): number {
return this.minTimeMs - this.extraOffsetTimeMS(pixelsPerMs);
}

/**
* Calculates the maximum scrollable time (end of the scrollable area), including the buffer.
*
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns The maximum scrollable time in milliseconds.
*/
maxScrollableTimeMS(pixelsPerMs: number): number {
return this.maxTimeMs + this.extraOffsetTimeMS(pixelsPerMs);
}

/**
* Converts a timestamp to its left offset in pixels relative to the start of the scrollable area.
*
* @param time The timestamp to convert (ms).
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns The left offset in pixels.
*/
timeMSToOffsetLeft(time: number, pixelsPerMs: number): number {
return (time - this.minScrollableTimeMS(pixelsPerMs)) * pixelsPerMs;
}

/**
* Calculates the maximum possible horizontal scroll position (scrollLeft).
*
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @param viewportWidth The width of the viewport (px).
* @returns The maximum scrollLeft value (px).
*/
maxScrollLeft(pixelsPerMs: number, viewportWidth: number): number {
return (
(this.maxTimeMs -
(viewportWidth - this.extraOffsetWidthInPx) / pixelsPerMs -
this.minTimeMs) *
pixelsPerMs
);
}

/**
* Calculates the time duration corresponding to the extra buffer offset.
*
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns The buffer duration in milliseconds.
*/
extraOffsetTimeMS(pixelsPerMs: number): number {
return this.extraOffsetWidthInPx / pixelsPerMs;
}

/**
* Converts the horizontal scroll position (scrollLeft) to the corresponding time at the left edge.
*
* @param scrollX The horizontal scroll position (px).
* @param pixelsPerMs The current zoom level in pixels per millisecond.
* @returns The time at the left edge of the visible area (ms).
*/
scrollToViewportLeftTime(scrollX: number, pixelsPerMs: number): number {
return scrollX / pixelsPerMs + this.minScrollableTimeMS(pixelsPerMs);
}

/**
* Calculates the scroll position keeping mouse position time fixed after a zoom operation.
*
* @param currentPixelsPerMs The current zoom level in pixels per millisecond.
* @param newPixelsPerMs The new zoom level in pixels per millisecond.
* @param viewportRelativeMousePosition The relative position of the mouse within the viewport.
* @param currentScrollLeft The current scroll position.
* @returns The new scroll position after the zoom operation.
*/
calculateZoomScrollLeft(
currentPixelsPerMs: number,
newPixelsPerMs: number,
viewportRelativeMousePosition: number,
currentScrollLeft: number,
): number {
const cMinSc = this.minScrollableTimeMS(currentPixelsPerMs);
const nMinSc = this.minScrollableTimeMS(newPixelsPerMs);
return (
newPixelsPerMs * (cMinSc - nMinSc) +
(newPixelsPerMs / currentPixelsPerMs) *
(currentScrollLeft + viewportRelativeMousePosition) -
viewportRelativeMousePosition
);
}
}
Loading