diff --git a/doc/documentation.md b/doc/documentation.md index b6f2726c..6b5b10f3 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -1,51 +1,105 @@ -# General Scope -* The library is entirely client side, relying on the browser API. -* Time is measured using number, so any kind of x measure unit can be used. -* A function translating numbers into human readable form for labels and hovers can be provided by the library user. -* API for interaction with elements that will allow to register key and mouse listeners. -* The components are styleable. Those parts that are implemented in HTML allow CSS for styling. The timeline view\u2019s contents (gantt chart) provide an API for styling - -# Components -## Time Controller -* A central time controller that manages time-related properties, like the total time range, viewed range, time cursor positions. -* It provides API for setting, reading and syncing the individual properties - -## Time Cursor -* Time Cursor T1 and optionally T2 are displayed as a vertical line spanning all rows. Range between T1 and T2 will be highlighted. Styling is configurable through CSS. -* Time range selection (or range selection) has full read/write/listen API. -* Rendering is configurable through CSS and separated from the actual data. -Setting cursor T1 is doable by clicking anywhere in the main area. A mouse click while holding shift will set T2. -View Port positions (use to scroll and zoom) has full read/write/listen API. - -## Time Axis -A reusable time axis component, that can be used independently of the other components. It syncs with a time controller and allows the user to change the controllers values (zooming, scrolling, setting cursors) -* The time axis is separated and usable alone, such that other widgets can render below and sync with the same time axis. -* It is styleable through CSS. -* A time axis controller is used to sync any number of widgets, so that scrolling in any of the timeline charts or the time axis will scroll all others accordingly. -* Clicking and dragging on the time axis will increase/decrease the zoom -* The view is connected to a time controller instance and sync its viewport, zoom level cursors bi-directionally - -## Timeline View -* Shows data as a gantt chart on multiple rows. -* Optional labels can be shown in each state of each row. -* Optional marker symbols can be drawn over the states. -* An optional scrollbar on the bottom will allow to scroll on the x-axis. -* An optional scrollbar on the right will allow to scroll on the y-axis. -* Since implemented using [Pixi.js](https://www.pixijs.com/) (WebGL/Canvas) it will provide an API for styling. -* General possibilities to add additional graphical layers (e.g. markers or icons) is foreseen. -* The library provides with a pull hook, to lazily fetch data from any source. -* Selection of elements (single units) is accessible with a full read/write/listen API. -* Keystroke handling for navigation and time selection is supported. For a selected row, it's possible to use left/right arrow to go from one element's start time to end and vice versa, as well as skipping to the next/previous element on that row. Using shift + left mouse click will select a time range and the cursors T1 and T2 are drawn accordingly. -* WASD or IJKL keystrokes are available for zooming and navigation. The zooming is centered on the mouse cursor position. -* Horizontal zooming can be performed using Ctrl+mouse wheel. The zooming is centered on the mouse cursor position. -* Horizontal panning can be performed using the middle mouse button or Ctrl+left mouse button. -* Horizontal zooming selection can be performed using the right mouse button. -* Horizontal zooming can be cancelled by pressing the `esc` button while zooming using the right click + drag. -* The view is connected to a time controller instance and synchronizes its viewport, zoom level cursors bi-directionally. - -## Data Model -* Library user can configure a data model provider which gets asked for data lazily depending on the viewport. -* Data is prefetched for x and y dimensions to make scrolling smoother. -* Data can be fetched for a given resolution, so the provider can optimize the amount of data provided. -* The data model provider allows to provide an array of rows, containing an array of elements. An element has a start time and a length. Arbitrary additional data can be provided, which is then used for styling and registered handlers. E.g., the hover provider would look up certain fields to display hover text. -* The data model provider allows to provide an array of arrows, pointing from a point to another point. Here a point is a coordinate in the timeline graph consisting of a time and row number. Arrows can have arbitrary additional properties used for styling and registered handlers. +# General Scope +* The library is entirely client side, relying on the browser API. +* Time is measured using number, so any kind of x measure unit can be used. +* A function translating numbers into human readable form for labels and hovers can be provided by the library user. +* API for interaction with elements that will allow to register key and mouse listeners. +* The components are styleable. Those parts that are implemented in HTML allow CSS for styling. The timeline view\u2019s contents (gantt chart) provide an API for styling + +# Components +## Time Controller +* A central time controller that manages time-related properties, like the total time range, viewed range, time cursor positions. +* It provides API for setting, reading and syncing the individual properties + +## Time Cursor +* Time Cursor T1 and optionally T2 are displayed as a vertical line spanning all rows. Range between T1 and T2 will be highlighted. Styling is configurable through CSS. +* Time range selection (or range selection) has full read/write/listen API. +* Rendering is configurable through CSS and separated from the actual data. +Setting cursor T1 is doable by clicking anywhere in the main area. A mouse click while holding shift will set T2. +View Port positions (use to scroll and zoom) has full read/write/listen API. + +## Time Axis +A reusable time axis component, that can be used independently of the other components. It syncs with a time controller and allows the user to change the controllers values (zooming, scrolling, setting cursors) +* The time axis is separated and usable alone, such that other widgets can render below and sync with the same time axis. +* It is styleable through CSS. +* A time axis controller is used to sync any number of widgets, so that scrolling in any of the timeline charts or the time axis will scroll all others accordingly. +* Clicking and dragging on the time axis will increase/decrease the zoom +* The view is connected to a time controller instance and sync its viewport, zoom level cursors bi-directionally + +## Timeline View +* Shows data as a gantt chart on multiple rows. +* Optional labels can be shown in each state of each row. +* Optional marker symbols can be drawn over the states. +* An optional scrollbar on the bottom will allow to scroll on the x-axis. +* An optional scrollbar on the right will allow to scroll on the y-axis. +* Since implemented using [Pixi.js](https://www.pixijs.com/) (WebGL/Canvas) it will provide an API for styling. +* General possibilities to add additional graphical layers (e.g. markers or icons) is foreseen. +* The library provides with a pull hook, to lazily fetch data from any source. +* Selection of elements (single units) is accessible with a full read/write/listen API. +* Keystroke handling for navigation and time selection is supported. For a selected row, it's possible to use left/right arrow to go from one element's start time to end and vice versa, as well as skipping to the next/previous element on that row. Using shift + left mouse click will select a time range and the cursors T1 and T2 are drawn accordingly. +* WASD or IJKL keystrokes are available for zooming and navigation. The zooming is centered on the mouse cursor position. +* Horizontal zooming can be performed using Ctrl+mouse wheel. The zooming is centered on the mouse cursor position. +* Horizontal panning can be performed using the middle mouse button or Ctrl+left mouse button. +* Horizontal zooming selection can be performed using the right mouse button. +* Horizontal zooming can be cancelled by pressing the `esc` button while zooming using the right click + drag. +* The view is connected to a time controller instance and synchronizes its viewport, zoom level cursors bi-directionally. + +## Data Model +* Library user can configure a data model provider which gets asked for data lazily depending on the viewport. +* Data is prefetched for x and y dimensions to make scrolling smoother. +* Data can be fetched for a given resolution, so the provider can optimize the amount of data provided. +* The data model provider allows to provide an array of rows, containing an array of elements. An element has a start time and a length. Arbitrary additional data can be provided, which is then used for styling and registered handlers. E.g., the hover provider would look up certain fields to display hover text. +* The data model provider allows to provide an array of arrows, pointing from a point to another point. Here a point is a coordinate in the timeline graph consisting of a time and row number. Arrows can have arbitrary additional properties used for styling and registered handlers. + +## Performance improvements + +### Scaling + +Scaling of the timeline chart is done using the `scale factor` property of the state controller. + +* Any components that inherit from the `TimeGraphViewportLayer` can enable scaling using the `isScalable` property. +* The `TimeGraphViewportLayer` applies the `scale factor` value to the `PIXI.Container.scale.x` property to scale the chart. +* The `scale factor` is reset every time new data arrive from the server and the chart re-renders itself. A value of 1 means no scaling is applied. + +There are 2 ways to calculate this factor, which will be described below. + +#### On zoom + +The timeline chart clears all `row components` and re-render the states every time new data is fetched from the server. While waiting for the new data to arrive from the server, the timeline chart re-scales the states to make it look like the chart was zoomed in or out instantly the moment the view range changes. This section describes how the scaling is implemented to support view range changes triggered by zooming. + +When the view range is changed: + +* The `updateScaleFactor` function of the `state controller` of the timeline chart are triggered. It re-calculates the `scale factor` of the timeline chart. +* Once the `scale factor` is updated, it will trigger the `onScaleFactorChange` event, upon which any components that are registered to this event can trigger their own handler. +* In case of `view range` changes, the `scale factor` is calculated as `newScaleFactor = (oldViewRangeLength / newViewRangeLength) * oldScaleFactor`. The multiplication with the old scale factor is to support multiple zooming while waiting for data. + +Because the timeline chart requires the `oldViewRangeLength` to calculate the scale factor, the parameters of the `onViewRangeChanged` function has been updated: + +```text +Previously: (viewRange) +New implementation: (oldRange, newRange) +With viewRange=newRange +``` + +**Important**: The `onViewRangeChanged()` handlers need to be removed when we destroy the timeline chart so that subsequent view range change event will not cause the error `cannot read property of undefined` (because all PIXI objects are destroyed). + +#### On resize + +When the timeline chart is resized, the `scale factor` is also recalculated: + +```text +scaleFactor = canvasDisplayWidth / unscaledCanvasWidth; +where +canvasDisplayWidth = the current width of the canvas +unscaledCanvasWidth = the width of the canvas when no scaling is applied +``` + +The `unscaledCanvasWidth` is updated whenever the chart is reset. + +#### Label text + +When the timeline chart is scaled, the label text will also be scaled. This squishes the text when the chart shrinks, and stretches the text when the chart expands. Thus, the timeline chart rescales the texts using the function `scaleLabel()` of the `TimeGraphStateComponent` to re-render the labels with an appropriate scaling factor so that the text size stays consistent. + +#### Note + +* Some components, such as the selection cursors, do not need to be scaled when the timeline chart is zoomed/resized, but rather re-rendered. In the case of cursors, if scaling is applied, the timeline chart will display a thick vertical line, which is not the expected behavior. +* The `updateZoomingSelection()` function of the `TimeGraphChart` needs to undo the scaling to get the correct position of the user pointer. diff --git a/timeline-chart/src/components/time-graph-grid.ts b/timeline-chart/src/components/time-graph-grid.ts index 2a01ec68..8e8a6414 100644 --- a/timeline-chart/src/components/time-graph-grid.ts +++ b/timeline-chart/src/components/time-graph-grid.ts @@ -27,6 +27,6 @@ export class TimeGraphGrid extends TimeGraphAxisScale { } render(): void { - this.renderVerticalLines(false, this._options.lineColor || 0xdddddd, () => ({ lineHeight: this.stateController.canvasDisplayHeight }), true); + this.renderVerticalLines(false, this._options.lineColor || 0xdddddd, () => ({ lineHeight: this.stateController.canvasDisplayHeight })); } } \ No newline at end of file diff --git a/timeline-chart/src/components/time-graph-state.ts b/timeline-chart/src/components/time-graph-state.ts index d4809cc4..fa93214c 100644 --- a/timeline-chart/src/components/time-graph-state.ts +++ b/timeline-chart/src/components/time-graph-state.ts @@ -10,6 +10,7 @@ export interface TimeGraphStateStyle { height?: number borderWidth?: number borderColor?: number + scale?: number } /** @@ -71,7 +72,12 @@ export class TimeGraphStateComponent extends TimeGraphComponent this.update(); this.stateController.onWorldRender(this._updateHandler); + this.stateController.onScaleFactorChange(this._updateHandler); this.rowController.onVerticalOffsetChangedHandler(verticalOffset => { this.layer.position.y = -verticalOffset; diff --git a/timeline-chart/src/layer/time-graph-chart-cursors.ts b/timeline-chart/src/layer/time-graph-chart-cursors.ts index 4826d968..e521b378 100644 --- a/timeline-chart/src/layer/time-graph-chart-cursors.ts +++ b/timeline-chart/src/layer/time-graph-chart-cursors.ts @@ -27,6 +27,7 @@ export class TimeGraphChartCursors extends TimeGraphChartLayer { constructor(id: string, protected chartLayer: TimeGraphChart, protected rowController: TimeGraphRowController, style?: { color?: number }) { super(id, rowController); + this.isScalable = false; if (style && style.color) { this.color = style.color; } @@ -154,6 +155,7 @@ export class TimeGraphChartCursors extends TimeGraphChartLayer { this.onCanvasEvent('mousedown', this._mouseDownHandler); this.stateController.onWorldRender(this._updateHandler); this.unitController.onSelectionRangeChange(this._updateHandler); + this.stateController.onScaleFactorChange(this._updateHandler); this.update(); } @@ -232,6 +234,10 @@ export class TimeGraphChartCursors extends TimeGraphChartLayer { update() { if (this.unitController.selectionRange) { + /** + * When user selects a range on the timeline chart, the selection position must correspond to the cursor of the user, + * and not the timeline chart itself since scaling might be applied. + */ const firstCursorPosition = this.getWorldPixel(this.unitController.selectionRange.start); const secondCursorPosition = this.getWorldPixel(this.unitController.selectionRange.end); const firstCursorOptions = { diff --git a/timeline-chart/src/layer/time-graph-chart-grid.ts b/timeline-chart/src/layer/time-graph-chart-grid.ts index 30d0e369..d30c3969 100644 --- a/timeline-chart/src/layer/time-graph-chart-grid.ts +++ b/timeline-chart/src/layer/time-graph-chart-grid.ts @@ -1,9 +1,9 @@ -import { TimeGraphViewportLayer } from "./time-graph-viewport-layer"; import { TimeGraphGrid } from "../components/time-graph-grid"; import { TimelineChart } from "../time-graph-model"; import { TimeGraphAxisLayerOptions } from "./time-graph-axis"; +import { TimeGraphLayer } from "./time-graph-layer"; -export class TimeGraphChartGrid extends TimeGraphViewportLayer { +export class TimeGraphChartGrid extends TimeGraphLayer { protected gridComponent: TimeGraphGrid; private _updateHandler: { (): void; (viewRange: TimelineChart.TimeGraphRange): void; (viewRange: TimelineChart.TimeGraphRange): void; (selectionRange: TimelineChart.TimeGraphRange): void; };; @@ -22,6 +22,7 @@ export class TimeGraphChartGrid extends TimeGraphViewportLayer { this.addChild(this.gridComponent); this._updateHandler = (): void => this.update(); this.stateController.onWorldRender(this._updateHandler); + this.unitController.onViewRangeChanged(this._updateHandler); } update(opts?: TimeGraphAxisLayerOptions) { @@ -32,6 +33,7 @@ export class TimeGraphChartGrid extends TimeGraphViewportLayer { if (this.unitController) { this.stateController.removeWorldRenderHandler(this._updateHandler); this.unitController.removeSelectionRangeChangedHandler(this._updateHandler); + this.unitController.removeViewRangeChangedHandler(this._updateHandler); } super.destroy(); } diff --git a/timeline-chart/src/layer/time-graph-chart-selection-range.ts b/timeline-chart/src/layer/time-graph-chart-selection-range.ts index e50a2e2c..e5173d94 100644 --- a/timeline-chart/src/layer/time-graph-chart-selection-range.ts +++ b/timeline-chart/src/layer/time-graph-chart-selection-range.ts @@ -9,6 +9,7 @@ export class TimeGraphChartSelectionRange extends TimeGraphViewportLayer { constructor(id: string, style?: { color?: number }) { super(id); + this.isScalable = false; if (style && style.color) { this.color = style.color; } @@ -34,6 +35,7 @@ export class TimeGraphChartSelectionRange extends TimeGraphViewportLayer { this._updateHandler = (): void => this.update(); this.stateController.onWorldRender(this._updateHandler); this.unitController.onSelectionRangeChange(this._updateHandler); + this.stateController.onScaleFactorChange(this._updateHandler) this.update(); } @@ -44,6 +46,10 @@ export class TimeGraphChartSelectionRange extends TimeGraphViewportLayer { update() { if (this.unitController.selectionRange) { + /** + * When user selects a range on the timeline chart, the selection position must correspond to the cursor of the user, + * and not the timeline chart itself since scaling might be applied. + */ const firstCursorPosition = this.getWorldPixel(this.unitController.selectionRange.start); const secondCursorPosition = this.getWorldPixel(this.unitController.selectionRange.end); if (secondCursorPosition !== firstCursorPosition) { diff --git a/timeline-chart/src/layer/time-graph-chart.ts b/timeline-chart/src/layer/time-graph-chart.ts index cd0ed9b8..e6fc8768 100644 --- a/timeline-chart/src/layer/time-graph-chart.ts +++ b/timeline-chart/src/layer/time-graph-chart.ts @@ -1,6 +1,6 @@ import * as PIXI from "pixi.js-legacy"; -import { TimeGraphAnnotationComponent, TimeGraphAnnotationComponentOptions, TimeGraphAnnotationStyle } from "../components/time-graph-annotation"; -import { TimeGraphComponent, TimeGraphRect, TimeGraphStyledRect } from "../components/time-graph-component"; +import { TimeGraphAnnotationComponent, TimeGraphAnnotationStyle } from "../components/time-graph-annotation"; +import { TimeGraphComponent, TimeGraphStyledRect } from "../components/time-graph-component"; import { TimeGraphRectangle } from "../components/time-graph-rectangle"; import { TimeGraphRow, TimeGraphRowStyle } from "../components/time-graph-row"; import { TimeGraphStateComponent, TimeGraphStateStyle } from "../components/time-graph-state"; @@ -335,6 +335,7 @@ export class TimeGraphChart extends TimeGraphChartLayer { }); this._viewRangeChangedHandler = () => { + this.scaleStateLabels(); this.updateZoomingSelection(); this.ensureRowLinesFitViewWidth(); }; @@ -353,7 +354,6 @@ export class TimeGraphChart extends TimeGraphChartLayer { * Side effect - stage.width is always reset to renderer.width when this is called. */ this._zoomRangeChangedHandler = (zoomFactor) => { - this.updateScaleAndPosition(); this.ensureRowLinesFitViewWidth(); this.stateController.handleOnWorldRender(); }; @@ -372,7 +372,7 @@ export class TimeGraphChart extends TimeGraphChartLayer { } update() { - this.updateScaleAndPosition(); + this.scaleStateLabels(); this.ensureRowLinesFitViewWidth(); this._debouncedMaybeFetchNewData(); } @@ -382,8 +382,8 @@ export class TimeGraphChart extends TimeGraphChartLayer { this.removeChild(this.zoomingSelection); delete this.zoomingSelection; } else if (this.mouseZooming) { - - const x = this.getWorldPixel(this.mouseZoomingStart); + // The zooming selection should not be scaled. It should use the actual mouse coordinates. + const x = this.getWorldPixel(this.mouseZoomingStart) / this.stateController.scaleFactor; const options = { color: 0xbbbbbb, opacity: 0.2, @@ -392,7 +392,7 @@ export class TimeGraphChart extends TimeGraphChartLayer { y: 0 }, height: Math.max(this.stateController.canvasDisplayHeight, this.rowController.totalHeight), - width: this.mouseEndX - this.mouseStartX + width: (this.mouseEndX - this.mouseStartX) / this.stateController.scaleFactor }; if (!this.zoomingSelection) { @@ -533,6 +533,7 @@ export class TimeGraphChart extends TimeGraphChartLayer { if (!fine && this._coarseResolutionFactor !== FINE_RESOLUTION_FACTOR) { this._debouncedMaybeFetchNewDataFine(); } + this.stateController.resetScale(); } // After we build the new world from new data, execute render handlers. // Only execute after first, coarse render. Don't need to repeat after second, fine render. @@ -543,67 +544,6 @@ export class TimeGraphChart extends TimeGraphChartLayer { } } - protected updateScaleAndPosition() { - this.rowComponents.forEach((rowComponent) => { - const row = rowComponent.model; - if (rowComponent) { - const opts: TimeGraphRect = { - height: this.rowController.rowHeight, - position: { - x: 0, - y: rowComponent.position.y - }, - width: this.stateController.canvasDisplayWidth - } - rowComponent.update(opts); - } - let lastX: number | undefined; - let lastTime: bigint | undefined; - let lastBlank = false; - row?.states.forEach((state: TimelineChart.TimeGraphState, elementIndex: number) => { - const el = rowComponent.getStateById(state.id); - const start = state.range.start; - const xStart = this.getWorldPixel(start, true); - if (el) { - const end = state.range.end; - const xEnd = this.getWorldPixel(end, true); - const width = Math.max(1, xEnd - xStart); - const opts: TimeGraphStyledRect = { - height: el.height, - position: { - x: xStart, - y: el.position.y - }, - width, - displayWidth: width - }; - el.update(opts); - } - if (rowComponent && row.gapStyle) { - this.updateGap(state, rowComponent, row.gapStyle, xStart, lastX, lastTime, lastBlank); - } - // Does clamping xEnd effect lastX calculation in some way? - lastX = Math.max(xStart + 1, this.getWorldPixel(state.range.end)); - lastTime = state.range.end; - lastBlank = (state.data?.style === undefined); - }); - row?.annotations.forEach((annotation: TimelineChart.TimeGraphAnnotation, elementIndex: number) => { - const el = rowComponent.getAnnotationById(annotation.id); - if (el) { - // only handle ticks for now - const start = annotation.range.start; - const opts: TimeGraphAnnotationComponentOptions = { - position: { - x: this.getWorldPixel(start), - y: el.displayObject.y - } - } - el.update(opts); - } - }); - }); - } - protected handleSelectedStateChange() { this.selectedStateChangedHandler.forEach((handler) => handler(this.selectedStateModel)); } @@ -991,6 +931,7 @@ export class TimeGraphChart extends TimeGraphChartLayer { * This is a hacky solution that triggers every view range change. */ protected ensureRowLinesFitViewWidth = () => { + const newRowWidth = this.stateController.canvasDisplayWidth / this.stateController.scaleFactor; this.rowComponents.forEach(rowComponent => { rowComponent.update({ height: this.rowController.rowHeight, @@ -998,12 +939,24 @@ export class TimeGraphChart extends TimeGraphChartLayer { x: -this.stateController.positionOffset.x, y: rowComponent.position.y }, - width: this.stateController.canvasDisplayWidth, + width: newRowWidth, }); }) } + protected scaleStateLabels() { + this.rowComponents.forEach((rowComponent) => { + const row = rowComponent.model; + row?.states.forEach((state: TimelineChart.TimeGraphState, elementIndex: number) => { + const el = rowComponent.getStateById(state.id); + if (el) { + el.scaleLabel(this.stateController.scaleFactor); + } + }); + }); + } + get rowWidth(): number { return Number(this.unitController.worldRangeLength) * this.stateController.zoomFactor; } diff --git a/timeline-chart/src/layer/time-graph-viewport-layer.ts b/timeline-chart/src/layer/time-graph-viewport-layer.ts index 0fc570d0..eacf3806 100644 --- a/timeline-chart/src/layer/time-graph-viewport-layer.ts +++ b/timeline-chart/src/layer/time-graph-viewport-layer.ts @@ -4,6 +4,8 @@ import { TimeGraphStateController } from '../time-graph-state-controller'; import { TimeGraphUnitController } from '../time-graph-unit-controller'; export abstract class TimeGraphViewportLayer extends TimeGraphLayer { + // By default, scale this layer + protected isScalable: boolean = true; constructor(id: string) { super(id); @@ -31,6 +33,7 @@ export abstract class TimeGraphViewportLayer extends TimeGraphLayer { initializeLayer(canvas: HTMLCanvasElement, stage: PIXI.Container, stateController: TimeGraphStateController, unitController: TimeGraphUnitController) { super.initializeLayer(canvas, stage, stateController, unitController); this.stateController.onPositionChanged(this.shiftStage); + this.stateController.onScaleFactorChange(this.scaleStage); } protected shiftStage = () => { @@ -40,4 +43,10 @@ export abstract class TimeGraphViewportLayer extends TimeGraphLayer { this.layer.position.x = this.stateController.positionOffset.x; } + protected scaleStage = () => { + if (!this.isScalable || this.layer.scale === undefined || this.layer.scale === null) { + return; + } + this.layer.scale.x = this.stateController.scaleFactor; + } } diff --git a/timeline-chart/src/time-graph-container.ts b/timeline-chart/src/time-graph-container.ts index 5bdfb4fe..0fe8d1e6 100644 --- a/timeline-chart/src/time-graph-container.ts +++ b/timeline-chart/src/time-graph-container.ts @@ -122,6 +122,7 @@ export class TimeGraphContainer { destroy() { this.layers.forEach(l => l.destroy()); this.unitController.removeViewRangeChangedHandler(this.calculatePositionOffset); + this.stateController.removeHandlers(); this.application.destroy(true); } diff --git a/timeline-chart/src/time-graph-state-controller.ts b/timeline-chart/src/time-graph-state-controller.ts index a27fb3f1..9aceb5b3 100644 --- a/timeline-chart/src/time-graph-state-controller.ts +++ b/timeline-chart/src/time-graph-state-controller.ts @@ -11,9 +11,12 @@ export class TimeGraphStateController { protected ratio: number; + private _unscaledCanvasWidth: number; protected _canvasDisplayWidth: number; protected _canvasDisplayHeight: number; + private _scaleFactor: number; + protected _zoomFactor: number; protected _initialZoomFactor: number; protected _positionOffset: { @@ -23,9 +26,9 @@ export class TimeGraphStateController { private _worldRenderedHandlers: ((worldRange: TimelineChart.TimeGraphRange) => void)[] = []; protected zoomChangedHandlers: ((zoomFactor: number) => void)[] = []; + protected canvasWidthChangedHandlers: ((baseWidth: number) => void)[] = []; protected positionChangedHandlers: (() => void)[] = []; - protected canvasDisplayWidthChangedHandlers: (() => void)[] = []; - + protected scaleFactorChangedHandlers: ((newScaleFactor: number) => void)[] = []; constructor(protected canvas: HTMLCanvasElement, protected unitController: TimeGraphUnitController) { this.ratio = window.devicePixelRatio; @@ -36,7 +39,10 @@ export class TimeGraphStateController { this.oldPositionOffset = { x: 0, y: 0 }; this.snapped = false; + this._unscaledCanvasWidth = this._canvasDisplayWidth; + this._scaleFactor = 1; this.unitController.onViewRangeChanged(this.updateZoomFactor); + this.unitController.onViewRangeChanged(this.updateScaleFactor); } protected handleZoomChange(zoomFactor: number) { @@ -45,8 +51,8 @@ export class TimeGraphStateController { protected handlePositionChange() { this.positionChangedHandlers.forEach(handler => handler()); } - protected handleCanvasDisplayWidthChange() { - this.canvasDisplayWidthChangedHandlers.forEach(handler => handler()); + protected handleScaleFactorChange() { + this.scaleFactorChangedHandlers.forEach(handler => handler(this._scaleFactor)); } onZoomChanged(handler: (zoomFactor: number) => void) { @@ -55,8 +61,8 @@ export class TimeGraphStateController { onPositionChanged(handler: () => void) { this.positionChangedHandlers.push(handler); } - onCanvasDisplayWidthChanged(handler: () => void) { - this.canvasDisplayWidthChangedHandlers.push(handler); + onScaleFactorChange(handler: (newWidth: number) => void) { + this.scaleFactorChangedHandlers.push(handler); } removeOnZoomChanged(handler: (zoomFactor: number) => void) { @@ -75,6 +81,9 @@ export class TimeGraphStateController { updateDisplayWidth() { this._canvasDisplayWidth = this.canvas.width / this.ratio; + + // Adjust the scale factor if the display canvas width changes + this.scaleFactor = this._canvasDisplayWidth / this._unscaledCanvasWidth; } /** @@ -99,6 +108,29 @@ export class TimeGraphStateController { } } + // Adjust the scale factor if the view range changes + updateScaleFactor = (oldViewRange: TimelineChart.TimeGraphRange, newViewRange: TimelineChart.TimeGraphRange) => { + const oldViewRangeLength = oldViewRange.end - oldViewRange.start; + const newViewRangeLength = newViewRange.end - newViewRange.start; + + const newScaleFactor = Number(oldViewRangeLength) / Number(newViewRangeLength) * this._scaleFactor; + this.scaleFactor = newScaleFactor; + } + + resetScale() { + this._unscaledCanvasWidth = this._canvasDisplayWidth; + this.scaleFactor = 1; + } + + get scaleFactor(): number { + return this._scaleFactor; + } + + set scaleFactor(newScaleFactor: number) { + this._scaleFactor = newScaleFactor; + this.handleScaleFactorChange(); + } + get zoomFactor(): number { this.updateZoomFactor(); return this._zoomFactor; @@ -137,4 +169,8 @@ export class TimeGraphStateController { } } + removeHandlers() { + this.unitController.removeViewRangeChangedHandler(this.updateZoomFactor); + this.unitController.removeViewRangeChangedHandler(this.updateScaleFactor); + } } \ No newline at end of file diff --git a/timeline-chart/src/time-graph-unit-controller.ts b/timeline-chart/src/time-graph-unit-controller.ts index f8c52927..f0916257 100644 --- a/timeline-chart/src/time-graph-unit-controller.ts +++ b/timeline-chart/src/time-graph-unit-controller.ts @@ -5,7 +5,7 @@ import { TimeGraphRenderController } from "./time-graph-render-controller"; export class TimeGraphUnitController { - protected viewRangeChangedHandlers: ((newRange: TimelineChart.TimeGraphRange) => void)[]; + protected viewRangeChangedHandlers: ((oldRange: TimelineChart.TimeGraphRange, newRange: TimelineChart.TimeGraphRange) => void)[]; protected _viewRange: TimelineChart.TimeGraphRange; /** @@ -41,19 +41,19 @@ export class TimeGraphUnitController { this._renderer = new TimeGraphRenderController(); } - protected handleViewRangeChange() { - this.viewRangeChangedHandlers.forEach(handler => handler(this._viewRange)); + protected handleViewRangeChange(oldRange: TimelineChart.TimeGraphRange) { + this.viewRangeChangedHandlers.forEach(handler => handler(oldRange, this._viewRange)); } protected handleSelectionRangeChange() { this.selectionRangeChangedHandlers.forEach(handler => handler(this._selectionRange)); } - onViewRangeChanged(handler: (viewRange: TimelineChart.TimeGraphRange) => void) { + onViewRangeChanged(handler: (oldRange: TimelineChart.TimeGraphRange, viewRange: TimelineChart.TimeGraphRange) => void) { this.viewRangeChangedHandlers.push(handler); } - removeViewRangeChangedHandler(handler: (viewRange: TimelineChart.TimeGraphRange) => void) { + removeViewRangeChangedHandler(handler: (oldRange: TimelineChart.TimeGraphRange, viewRange: TimelineChart.TimeGraphRange) => void) { const index = this.viewRangeChangedHandlers.indexOf(handler); if (index > -1) { this.viewRangeChangedHandlers.splice(index, 1); @@ -82,6 +82,12 @@ export class TimeGraphUnitController { return this._viewRange; } set viewRange(newRange: TimelineChart.TimeGraphRange) { + // Making a deep copy + const oldRange = { + start: this._viewRange.start, + end: this._viewRange.end + }; + if (newRange.end > newRange.start) { this._viewRange = { start: newRange.start, end: newRange.end }; } @@ -91,7 +97,7 @@ export class TimeGraphUnitController { if (this._viewRange.end > this.absoluteRange) { this._viewRange.end = this.absoluteRange; } - this.handleViewRangeChange(); + this.handleViewRangeChange(oldRange); } get worldRange(): TimelineChart.TimeGraphRange {