diff --git a/packages/ag-charts-community/src/chart/cartesianChart.ts b/packages/ag-charts-community/src/chart/cartesianChart.ts index 9fe3a01031..3aa19656bf 100644 --- a/packages/ag-charts-community/src/chart/cartesianChart.ts +++ b/packages/ag-charts-community/src/chart/cartesianChart.ts @@ -66,7 +66,7 @@ export class CartesianChart extends Chart { this.syncAxisChanges(newValue, oldValue); if (this.ctx != null) { - this.ctx.zoomManager.updateAxes(newValue); + this.ctx.zoomManager.setAxes(newValue); } } diff --git a/packages/ag-charts-community/src/chart/interaction/zoomManager.ts b/packages/ag-charts-community/src/chart/interaction/zoomManager.ts index bdbf44f282..c1d692585f 100644 --- a/packages/ag-charts-community/src/chart/interaction/zoomManager.ts +++ b/packages/ag-charts-community/src/chart/interaction/zoomManager.ts @@ -1,6 +1,7 @@ import { type AxisID, type BoxBounds, + type DeepReadonly, Logger, type OptionsDefs, type RequireOptional, @@ -14,13 +15,19 @@ import { } from 'ag-charts-core'; import type { AgAutoScaledAxes, AgZoomEvent, AgZoomRange, AgZoomRatio } from 'ag-charts-types'; -import type { AxisZoomState, EventsHub, ZoomState } from '../../core/eventsHub'; +import type { + AxisZoomState, + EventsHub, + ZoomChangeRequestedEvent, + ZoomChangeType, + ZoomState, +} from '../../core/eventsHub'; import { ContinuousScale } from '../../scale/continuousScale'; import { DiscreteTimeScale } from '../../scale/discreteTimeScale'; import type { BBox } from '../../scene/bbox'; import { BaseManager } from '../../util/baseManager'; import { deepClone } from '../../util/json'; -import { objectsEqual } from '../../util/object'; +import { objectsEqual, strictObjectKeys } from '../../util/object'; import type { TypedEvent } from '../../util/observable'; import { calcPanToBBoxRatios } from '../../util/panToBBox'; import { NonNullableStateTracker } from '../../util/stateTracker'; @@ -33,6 +40,10 @@ export interface DefinedZoomState { y: ZoomState; } +type CoreZoomEntry = ZoomState & { direction: CartesianAxisDirection }; +export type CoreZoomState = Record; +export type CoreZoomStateSafeRetrieval = { readonly [K in AxisID]: CoreZoomEntry | undefined }; + export type ZoomMemento = { rangeX?: AgZoomRange; rangeY?: AgZoomRange; @@ -41,9 +52,12 @@ export type ZoomMemento = { autoScaledAxes?: AgAutoScaledAxes; }; -export type CartesianAxisLike = { +export type SimpleAxis = { id: AxisID; direction: CartesianAxisDirection; +}; + +export type CartesianAxisLike = SimpleAxis & { visibleRange: [number, number]; scale: Scale; range: [number, number]; @@ -65,6 +79,31 @@ const rangeValidator = (axis?: CartesianAxisLike) => return value < options.end; }, `to be less than end`); +function validateChanges(changes: UpdateZoomChanges): void { + for (const axisId of strictObjectKeys(changes)) { + const zoom = changes[axisId]; + if (!zoom) continue; + + const { min, max } = zoom; + + if (min < 0 || max > 1) { + Logger.warnOnce( + `Attempted to update axis (${axisId}) zoom to an invalid ratio of [{ min: ${min}, max: ${max} }], expecting a ratio of 0 to 1. Ignoring.` + ); + + // Remove invalid zoom state for this axis + delete changes[axisId]; + } + } +} + +type UpdateZoomChanges = Record; +type UpdateZoomParams = { + callerId: string; + changes: UpdateZoomChanges; + changeType: ZoomChangeType; +}; + /** * Manages the current zoom state for a chart. Tracks the requested zoom from distinct dependents * and handles conflicting zoom requests. @@ -72,14 +111,12 @@ const rangeValidator = (axis?: CartesianAxisLike) => export class ZoomManager extends BaseManager { public mementoOriginatorKey = 'zoom' as const; - private readonly axisZoomManagers = new Map(); - private readonly state = new NonNullableStateTracker({}, 'initial'); - + private readonly state = new NonNullableStateTracker({}, 'initial'); private readonly axes: CartesianAxisLike[] = []; private didLayoutAxes = false; private readonly autoScaleYAxis = new ZoomManagerAutoScaleAxis(); - private lastRestoredState: AxisZoomState | undefined = undefined; + private lastRestoredState: CoreZoomStateSafeRetrieval | undefined = undefined; private independentAxes = false; private navigatorModule = false; private zoomModule = false; @@ -112,11 +149,60 @@ export class ZoomManager extends BaseManager { this.restoreMemento(pendingMemento.version, pendingMemento.mementoVersion, pendingMemento.memento); } - this.autoScaleYZoom('zoom-manager'); + this.autoScaleYZoom('zoom-manager', 'layoutComplete'); }) ); } + private toCoreZoomState(axisZoom: DeepReadonly): CoreZoomState { + const result: CoreZoomState = {}; + let ids: AxisID[]; + const state = this.state.stateValue(); + + if (this.independentAxes) { + const xId = this.getPrimaryAxisId(ChartAxisDirection.X); + const yId = this.getPrimaryAxisId(ChartAxisDirection.Y); + ids = []; + if (xId) ids.push(xId); + if (yId) ids.push(yId); + } else { + ids = strictObjectKeys(state); + } + + for (const id of ids) { + const { direction } = state[id] ?? {}; + if (direction != undefined) { + const zoom = axisZoom[direction]; + if (zoom) { + const { min, max } = zoom; + result[id] = { min, max, direction }; + } + } + } + + return result; + } + + // FIXME: should be private + public toAxisZoomState(coreZoom: DeepReadonly): AxisZoomState | undefined { + let x: ZoomState | undefined; + let y: ZoomState | undefined; + + // Use the zoom on the primary (first) axis in each direction + for (const id of strictObjectKeys(coreZoom)) { + const { min, max, direction } = coreZoom[id]!; + if (direction === ChartAxisDirection.X) { + x ??= { min, max }; + } else if (direction === ChartAxisDirection.Y) { + y ??= { min, max }; + } + } + + if (x || y) { + return { x, y }; + } + } + public createMemento() { return this.getMementoRanges() as ZoomMemento; } @@ -147,8 +233,6 @@ export class ZoomManager extends BaseManager { } public restoreMemento(version: string, mementoVersion: string, memento: ZoomMemento | undefined) { - const { independentAxes } = this; - if (!this.axes || !this.didLayoutAxes) { this.pendingMemento = { version, mementoVersion, memento }; return; @@ -157,7 +241,7 @@ export class ZoomManager extends BaseManager { // Migration from older versions can be implemented here. - const zoom: AxisZoomState = this.getDefinedZoom(); + const zoom: DefinedZoomState & Pick = this.getDefinedZoom(); if (memento?.rangeX) { zoom.x = this.rangeToRatio(memento.rangeX, ChartAxisDirection.X) ?? { min: 0, max: 1 }; @@ -191,41 +275,32 @@ export class ZoomManager extends BaseManager { zoom.autoScaleYAxis = yAutoScale; } - this.lastRestoredState = zoom; - - if (independentAxes !== true) { - this.updateZoom('zoom-manager', zoom); - return; - } - - const primaryX = this.getPrimaryAxis(ChartAxisDirection.X); - const primaryY = this.getPrimaryAxis(ChartAxisDirection.Y); - - for (const axis of [primaryX, primaryY]) { - if (!axis) continue; - this.updateAxisZoom('zoom-manager', axis.id, zoom[axis.direction as keyof DefinedZoomState]); + const changes = this.toCoreZoomState(zoom); + this.lastRestoredState = changes; + if (zoom.autoScaleYAxis != undefined) { + this.autoScaleYAxis.manuallyAdjusted = !zoom.autoScaleYAxis; } + this.applyUpdateZoom({ callerId: 'zoom-manager', changeType: 'restoreMemento', changes }); } - public updateAxes(nextAxes: Array) { - const { axes, axisZoomManagers } = this; - - const existingZoomManagers = new Map(axisZoomManagers); - axisZoomManagers.clear(); - + public setAxes(nextAxes: Array | Array) { + const { axes } = this; axes.length = 0; for (const axis of nextAxes) { - if (typeof axis === 'string') { - axisZoomManagers.set(axis, existingZoomManagers.get(axis) ?? new AxisZoomManager(axis)); - } else { + if ('range' in axis) { axes.push(axis); - axisZoomManagers.set(axis.id, existingZoomManagers.get(axis.id) ?? new AxisZoomManager(axis)); } } - if (this.state.size > 0 && axes.length > 0) { - this.updateZoom(this.state.stateId(), this.state.stateValue()); + const callerId = this.state.stateId(); + const oldState = this.state.stateValue(); + const changes: CoreZoomState = {}; + for (const { id, direction } of nextAxes) { + const { min, max } = oldState[id] ?? { min: 0, max: 1 }; + changes[id] = { min, max, direction }; } + this.state.set(callerId, changes); + this.applyUpdateZoom({ callerId, changeType: 'setAxes', changes }); } public setIndependentAxes(independent = true) { @@ -237,7 +312,7 @@ export class ZoomManager extends BaseManager { this.autoScaleYAxis.padding = padding; if (enabled) { - this.autoScaleYZoom('toggle-auto-scale'); + this.autoScaleYZoom('toggle-auto-scale', 'update'); } } @@ -257,87 +332,83 @@ export class ZoomManager extends BaseManager { return this.zoomModule; } - public updateZoom(callerId: string, newZoom?: AxisZoomState) { - if (newZoom?.x && (newZoom.x.min < 0 || newZoom.x.max > 1)) { - Logger.warnOnce( - `Attempted to update x-axis zoom to an invalid ratio of [{ min: ${newZoom.x.min}, max: ${newZoom.x.max} }], expecting a ratio of 0 to 1, ignoring.` - ); - newZoom.x = undefined; - } + public updateZoom(callerId: string, newZoom?: AxisZoomState): boolean { + return this.updateChanges(callerId, this.toCoreZoomState(newZoom ?? {})); + } - if (newZoom?.y && (newZoom.y.min < 0 || newZoom.y.max > 1)) { - Logger.warnOnce( - `Attempted to update y-axis zoom to an invalid ratio of [{ min: ${newZoom.y.min}, max: ${newZoom.y.max} }], expecting a ratio of 0 to 1, ignoring.` - ); - newZoom.y = undefined; - } + public updateAxisZoom(callerId: string, axisId: AxisID, newZoom?: ZoomState): boolean { + const changes = { [axisId]: newZoom ?? this.getAxisZoom(axisId) }; + return this.updateChanges(callerId, changes); + } - if (this.axisZoomManagers.size === 0) { - const stateId = this.state.stateId(); - if (stateId === 'initial' || stateId === callerId) { - this.state.set(callerId, newZoom); + public updateChanges(callerId: string, changes: UpdateZoomChanges): boolean { + return this.applyUpdateZoom({ callerId, changeType: 'update', changes }); + } + + private computeChangedAxesIds(newState: UpdateZoomChanges): readonly AxisID[] { + const result: AxisID[] = []; + const oldState = this.state.stateValue(); + for (const id of strictObjectKeys(newState)) { + const newAxisState = newState[id]; + const oldAxisState = oldState[id]; + if ( + oldAxisState == undefined || + oldAxisState.min !== newAxisState.min || + oldAxisState.max !== newAxisState.max + ) { + result.push(id); } - return; } + return result; + } - this.state.set(callerId, newZoom); + private applyUpdateZoom({ callerId, changeType, changes }: UpdateZoomParams): boolean { + validateChanges(changes); - const autoScaleYAxis = newZoom?.autoScaleYAxis; - if (autoScaleYAxis != null) { - this.autoScaleYAxis.manuallyAdjusted = !autoScaleYAxis; + // TODO(olegat) move to enterprise + if (this.autoScaleYAxis.enabled && !this.autoScaleYAxis.manuallyAdjusted) { + changes = this.autoScaleYZoom(callerId, changeType, false, changes) ?? changes; } - for (const axis of this.axisZoomManagers.values()) { - axis.updateZoom(callerId, newZoom?.[axis.direction]); + const changedAxes = this.computeChangedAxesIds(changes); + const newState: CoreZoomStateSafeRetrieval = deepClone(this.state.stateValue()); + for (const id of changedAxes) { + const axis = newState[id]; + if (axis != undefined) { + axis.min = changes[id].min; + axis.max = changes[id].max; + } } + this.state.set(callerId, newState); - this.applyChanges(callerId); - } - - public updateAxisZoom(callerId: string, axisId: AxisID, newZoom?: ZoomState) { - this.axisZoomManagers.get(axisId)?.updateZoom(callerId, newZoom); - this.applyChanges(callerId); + return this.dispatch(callerId, changeType, changedAxes); } public resetZoom(callerId: string) { this.autoScaleYAxis.manuallyAdjusted = false; - - // TODO: Move `zoomUtils.ts` to community and use `definedZoomState()` here. - const zoom = this.getRestoredZoom(); - this.updateZoom(callerId, { - x: { min: zoom?.x?.min ?? 0, max: zoom?.x?.max ?? 1 }, - y: { min: zoom?.y?.min ?? 0, max: zoom?.y?.max ?? 1 }, - autoScaleYAxis: zoom?.autoScaleYAxis ?? true, - }); + const changes = this.toCoreZoomState(this.getRestoredZoom()); + this.applyUpdateZoom({ callerId, changeType: 'reset', changes }); } public resetAxisZoom(callerId: string, axisId: AxisID) { - const axisZoomManager = this.axisZoomManagers.get(axisId); - const direction = axisZoomManager?.direction; + const direction = this.state.stateValue()[axisId]?.direction; if (direction == null) return; const restoredZoom = this.getRestoredZoom(); + let lastAutoScaleYAxis: boolean | undefined; if (direction === ChartAxisDirection.Y) { - const autoScaleYAxis = restoredZoom?.autoScaleYAxis ?? true; - this.autoScaleYAxis.manuallyAdjusted = !autoScaleYAxis; - } - for (const axis of this.axes) { - if (axis.direction !== direction) continue; - this.updateAxisZoom(callerId, axis.id, restoredZoom?.[direction] ?? { min: 0, max: 1 }); + lastAutoScaleYAxis = restoredZoom?.autoScaleYAxis ?? true; + this.autoScaleYAxis.manuallyAdjusted = !lastAutoScaleYAxis; } + const changes = this.toCoreZoomState({ [direction]: restoredZoom[direction] }); + this.applyUpdateZoom({ callerId, changeType: 'reset', changes }); } public setAxisManuallyAdjusted(_callerId: string, axisId: AxisID) { - const direction = this.axisZoomManagers.get(axisId)?.direction; + const direction = this.state.stateValue()[axisId]?.direction; if (direction !== ChartAxisDirection.Y) return; this.autoScaleYAxis.manuallyAdjusted = true; } - public updatePrimaryAxisZoom(callerId: string, direction: CartesianAxisDirection, newZoom?: ZoomState) { - const primaryAxis = this.getPrimaryAxis(direction); - if (!primaryAxis) return; - this.updateAxisZoom(callerId, primaryAxis.id, newZoom); - } - public panToBBox(callerId: string, seriesRect: BBox, target: BoxBounds): boolean { if (!this.isZoomEnabled() && !this.isNavigatorEnabled()) return false; @@ -355,14 +426,8 @@ export class ZoomManager extends BaseManager { } const newZoom: AxisZoomState = calcPanToBBoxRatios(seriesRect, zoom, target); - - if (this.independentAxes) { - this.updatePrimaryAxisZoom(callerId, ChartAxisDirection.X, newZoom.x); - this.updatePrimaryAxisZoom(callerId, ChartAxisDirection.Y, newZoom.y); - } else { - this.updateZoom(callerId, newZoom); - } - return true; + const changes = this.toCoreZoomState(newZoom); + return this.applyUpdateZoom({ callerId, changeType: 'panToBBox', changes }); } // Fire this event to signal to listeners that the view is changing through a zoom and/or pan change. @@ -411,40 +476,26 @@ export class ZoomManager extends BaseManager { } public getZoom(): AxisZoomState | undefined { - let x: ZoomState | undefined; - let y: ZoomState | undefined; - - // Use the zoom on the primary (first) axis in each direction - for (const axis of this.axisZoomManagers.values()) { - if (axis.direction === ChartAxisDirection.X) { - x ??= axis.getZoom(); - } else if (axis.direction === ChartAxisDirection.Y) { - y ??= axis.getZoom(); - } - } - - if (x || y) { - return { x, y }; - } + return this.toAxisZoomState(this.state.stateValue()); } - public getAxisZoom(axisId: AxisID | CartesianAxisDirection): ZoomState { - return this.axisZoomManagers.get(axisId)?.getZoom() ?? { min: 0, max: 1 }; + public getAxisZoom(axisId: AxisID): ZoomState { + return this.state.stateValue()[axisId] ?? { min: 0, max: 1 }; } - public getAxisZooms(): Record { - const axes: Record = {}; - for (const [axisId, axis] of this.axisZoomManagers.entries()) { - axes[axisId] = { - direction: axis.direction, - zoom: axis.getZoom(), - }; - } - return axes; + public getAxisZooms(): CoreZoomStateSafeRetrieval { + return this.state.stateValue(); } - public getRestoredZoom(): AxisZoomState | undefined { - return this.lastRestoredState; + public getRestoredZoom() { + // TODO: Move `zoomUtils.ts` to community and use `definedZoomState()` here. + const zoom = this.toAxisZoomState(this.lastRestoredState ?? {}); + const newZoom = { + x: { min: zoom?.x?.min ?? 0, max: zoom?.x?.max ?? 1 }, + y: { min: zoom?.y?.min ?? 0, max: zoom?.y?.max ?? 1 }, + autoScaleYAxis: zoom?.autoScaleYAxis ?? true, + }; + return newZoom; } public getPrimaryAxisId(direction: CartesianAxisDirection) { @@ -569,47 +620,47 @@ export class ZoomManager extends BaseManager { } } - private autoScaleYZoom(callerId: string, applyChanges = true) { - const { independentAxes } = this; - + private autoScaleYZoom( + callerId: string, + changeType: ZoomChangeType | undefined, + apply = true, + changes?: UpdateZoomChanges + ) { const zoom = this.getZoom(); + if (zoom && changes) { + // The `zoom` is outdated, let's patch in the updates from `changes`. + const state = this.state.stateValue(); + for (const dir of [ChartAxisDirection.X, ChartAxisDirection.Y] as const) { + for (const id of strictObjectKeys(changes)) { + if (state[id]?.direction === dir) { + zoom[dir] = changes[id]; + break; + } + } + } + } if (zoom?.x == null) return; const zoomY = this.getAutoScaleYZoom(zoom.x); if (zoomY == null || objectsEqual(zoom.y, zoomY)) return; - if (independentAxes) { - const primaryAxis = this.getPrimaryAxis(ChartAxisDirection.Y); - const primaryAxisManager = primaryAxis == null ? undefined : this.axisZoomManagers.get(primaryAxis.id); - primaryAxisManager?.updateZoom('zoom-manager', zoomY); - } else { - for (const axisZoomManager of this.axisZoomManagers.values()) { - if (axisZoomManager.direction === ChartAxisDirection.Y) { - axisZoomManager.updateZoom('zoom-manager', zoomY); - } - } - } - - if (applyChanges) { - this.applyChanges(callerId); + changes = this.toCoreZoomState({ x: zoom.x, y: zoomY }); + if (changeType && apply) { + this.applyUpdateZoom({ callerId, changeType, changes }); } + return changes; } - private applyChanges(callerId: string) { - this.autoScaleYZoom(callerId, false); - - const changed = Array.from(this.axisZoomManagers.values(), (axis) => axis.applyChanges()).includes(true); - - if (!changed) { - return; - } - - const axes: Record = {}; - for (const [axisId, axis] of this.axisZoomManagers.entries()) { - axes[axisId] = axis.getZoom(); + private dispatch(callerId: string, changeType: ZoomChangeType, changedAxes: readonly AxisID[]): boolean { + if (changedAxes.length === 0) { + return false; } - this.eventsHub.emit('zoom:change-request', { ...this.getZoom(), axes, callerId }); + const { x, y } = this.getZoom() ?? {}; + const state = this.state.stateValue(); + const newEvent: ZoomChangeRequestedEvent = { callerId, changeType, changedAxes, state, x, y }; + this.eventsHub.emit('zoom:change-request', newEvent); + return true; } private getRangeDirection(ratio: ZoomState, direction: CartesianAxisDirection): AgZoomRange | undefined { @@ -857,45 +908,3 @@ export class ZoomManager extends BaseManager { return { min, max }; } } - -class AxisZoomManager { - public readonly direction: CartesianAxisDirection; - private currentZoom: ZoomState; - private readonly state: NonNullableStateTracker; - - constructor(axis: CartesianAxisDirection | CartesianAxisLike) { - let min: number; - let max: number; - if (typeof axis === 'string') { - this.direction = axis; - min = 0; - max = 1; - } else { - this.direction = axis.direction; - [min = 0, max = 1] = axis.visibleRange; - } - - this.state = new NonNullableStateTracker({ min, max }, 'initial'); - this.currentZoom = this.state.stateValue(); - } - - public updateZoom(callerId: string, newZoom?: ZoomState) { - this.state.set(callerId, newZoom); - } - - public getZoom() { - return deepClone(this.state.stateValue()); - } - - public hasChanges(): boolean { - const currentZoom = this.currentZoom; - const pendingZoom = this.state.stateValue(); - return currentZoom.min !== pendingZoom.min || currentZoom.max !== pendingZoom.max; - } - - public applyChanges(): boolean { - const hasChanges = this.hasChanges(); - this.currentZoom = this.state.stateValue(); - return hasChanges; - } -} diff --git a/packages/ag-charts-community/src/core/eventsHub.ts b/packages/ag-charts-community/src/core/eventsHub.ts index adfe5891c6..31522ca25a 100644 --- a/packages/ag-charts-community/src/core/eventsHub.ts +++ b/packages/ag-charts-community/src/core/eventsHub.ts @@ -1,4 +1,4 @@ -import type { Scale } from 'ag-charts-core'; +import type { AxisID, Scale } from 'ag-charts-core'; import { EventEmitter } from 'ag-charts-core'; import type { AgAnnotation, AgContextMenuItemShowOn, AgTimeInterval, AgTimeIntervalUnit } from 'ag-charts-types'; @@ -119,9 +119,13 @@ export interface SeriesKeyNavZoomEvent { readonly widgetEvent: KeyboardWidgetEvent<'keydown'>; } +export type ZoomChangeType = 'layoutComplete' | 'panToBBox' | 'reset' | 'restoreMemento' | 'update' | 'setAxes'; + export interface ZoomChangeRequestedEvent extends AxisZoomState { readonly callerId: string; - readonly axes: Record | undefined>; + readonly changeType: ZoomChangeType; + readonly changedAxes: readonly AxisID[]; + readonly state: { readonly [K in AxisID]: Readonly | undefined }; readonly x?: Readonly; readonly y?: Readonly; } diff --git a/packages/ag-charts-enterprise/src/charts/topologyChart.ts b/packages/ag-charts-enterprise/src/charts/topologyChart.ts index 912b2eeff3..c68a87b0ef 100644 --- a/packages/ag-charts-enterprise/src/charts/topologyChart.ts +++ b/packages/ag-charts-enterprise/src/charts/topologyChart.ts @@ -1,4 +1,5 @@ import { _ModuleSupport } from 'ag-charts-community'; +import { type AxisID, createId } from 'ag-charts-core'; import type { AgTopologyChartOptions } from 'ag-charts-types'; const { Chart, MercatorScale, ChartAxisDirection, Property } = _ModuleSupport; @@ -18,6 +19,8 @@ function isTopologySeries( export class TopologyChart extends Chart { static readonly className = 'TopologyChart'; static readonly type = 'topology' as const; + private readonly xAxis = { id: createId(_ModuleSupport.Axis), direction: ChartAxisDirection.X } as const; + private readonly yAxis = { id: createId(_ModuleSupport.Axis), direction: ChartAxisDirection.Y } as const; @Property topology?: _ModuleSupport.FeatureCollection; @@ -25,7 +28,7 @@ export class TopologyChart extends Chart { constructor(options: _ModuleSupport.ChartOptions, resources?: _ModuleSupport.TransferableResources) { super(options, resources); - this.ctx.zoomManager.updateAxes([ChartAxisDirection.X, ChartAxisDirection.Y]); + this.ctx.zoomManager.setAxes([this.xAxis, this.yAxis]); } override getChartType() { @@ -88,8 +91,8 @@ export class TopologyChart extends Chart { const x1 = viewBoxOriginX + viewBoxWidth; const y1 = viewBoxOriginY + viewBoxHeight; - const xZoom = this.ctx.zoomManager.getAxisZoom(ChartAxisDirection.X); - const yZoom = this.ctx.zoomManager.getAxisZoom(ChartAxisDirection.Y); + const xZoom = this.ctx.zoomManager.getAxisZoom(this.xAxis.id); + const yZoom = this.ctx.zoomManager.getAxisZoom(this.yAxis.id); const xSpan = (x1 - x0) / (xZoom.max - xZoom.min); const xStart = x0 - xSpan * xZoom.min; const ySpan = (y1 - y0) / (1 - yZoom.min - (1 - yZoom.max)); diff --git a/packages/ag-charts-enterprise/src/features/navigator/navigatorDOMProxy.ts b/packages/ag-charts-enterprise/src/features/navigator/navigatorDOMProxy.ts index 72f74e6b97..537136be09 100644 --- a/packages/ag-charts-enterprise/src/features/navigator/navigatorDOMProxy.ts +++ b/packages/ag-charts-enterprise/src/features/navigator/navigatorDOMProxy.ts @@ -93,7 +93,7 @@ export class NavigatorDOMProxy { const { _min: min, _max: max } = this; if (min == null || max == null) return; - return this.ctx.zoomManager.updateZoom('navigator', { x: { min, max } }); + this.ctx.zoomManager.updateZoom('navigator', { x: { min, max } }); } updateBounds(bounds: BoxBounds): void { diff --git a/packages/ag-charts-enterprise/src/features/zoom/zoom.ts b/packages/ag-charts-enterprise/src/features/zoom/zoom.ts index 233a03b68f..b4cdb97034 100644 --- a/packages/ag-charts-enterprise/src/features/zoom/zoom.ts +++ b/packages/ag-charts-enterprise/src/features/zoom/zoom.ts @@ -660,9 +660,7 @@ export class Zoom extends AbstractModuleInstance { event.sourceEvent.preventDefault(); const newZooms = scrollPanner.update(event, scrollingStep, seriesRect, zoomManager.getAxisZooms()); - for (const [axisId, { direction, zoom }] of entries(newZooms)) { - this.updateAxisZoom(axisId, direction as _ModuleSupport.CartesianAxisDirection, zoom); - } + this.updateChanges(newZooms); } private onWheelScrolling(event: _Widget.WheelWidgetEvent) { @@ -718,16 +716,12 @@ export class Zoom extends AbstractModuleInstance { if (enableIndependentAxes === true) { const newZooms = scroller.updateAxes(event, props, seriesRect, zoomManager.getAxisZooms()); - for (const [axisId, { direction, zoom: axisZoom }] of entries(newZooms)) { + for (const [axisId, { direction, min, max }] of entries(newZooms)) { const constrainedZoom = direction === ChartAxisDirection.X - ? this.constrainZoom({ x: axisZoom, y: { min: UNIT_MAX, max: UNIT_MAX } }).x - : axisZoom; - updated &&= this.updateAxisZoom( - axisId, - direction as _ModuleSupport.CartesianAxisDirection, - constrainedZoom - ); + ? this.constrainZoom({ x: { min, max }, y: { min: UNIT_MAX, max: UNIT_MAX } }).x + : { min, max }; + updated &&= this.updateAxisZoom(axisId, direction, constrainedZoom); } } else { const newZoom = scroller.update(event, props, seriesRect, this.getZoom()); @@ -829,11 +823,7 @@ export class Zoom extends AbstractModuleInstance { if (!seriesRect) return; const newZooms = panner.translateZooms(seriesRect, zoomManager.getAxisZooms(), event.deltaX, event.deltaY); - - for (const [axisId, { direction, zoom }] of entries(newZooms)) { - this.updateAxisZoom(axisId, direction as _ModuleSupport.CartesianAxisDirection, zoom); - } - + this.updateChanges(newZooms); tooltipManager.updateTooltip(TOOLTIP_ID); } @@ -964,6 +954,18 @@ export class Zoom extends AbstractModuleInstance { this.updateZoom(zoom); } + private updateChanges(changes: _ModuleSupport.CoreZoomState) { + // TODO: constrainZoom should operate on a partial CoreZoomState instead of DefinedZoomState. + // For compatibility, we calculate the final DefinedZoomState for constrainZoom to continue to work without + // breaking thebehaviour. + const partialZoom = this.ctx.zoomManager.toAxisZoomState(changes) ?? {}; + const currentZoom = definedZoomState(this.ctx.zoomManager.getZoom()); + this.updateZoom({ + x: partialZoom.x ?? currentZoom.x, + y: partialZoom.y ?? currentZoom.y, + }); + } + private updateZoom(zoom: DefinedZoomState) { if (this.enableIndependentAxes) { this.updatePrimaryAxisZooms(zoom); diff --git a/packages/ag-charts-enterprise/src/features/zoom/zoomPanner.ts b/packages/ag-charts-enterprise/src/features/zoom/zoomPanner.ts index 3ab000e7dc..b5c033def3 100644 --- a/packages/ag-charts-enterprise/src/features/zoom/zoomPanner.ts +++ b/packages/ag-charts-enterprise/src/features/zoom/zoomPanner.ts @@ -1,9 +1,12 @@ import { _ModuleSupport } from 'ag-charts-community'; import { entries, getWindow } from 'ag-charts-core'; -import type { AxisZoomStates, ZoomCoords } from './zoomTypes'; +import type { ZoomCoords } from './zoomTypes'; import { UNIT_MAX, UNIT_MIN, constrainZoom, definedZoomState, dx, dy, pointToRatio, translateZoom } from './zoomUtils'; +type State = _ModuleSupport.CoreZoomState; +type StateRetrieval = _ModuleSupport.CoreZoomStateSafeRetrieval; + export interface ZoomPanUpdate { type: 'update'; deltaX: number; @@ -163,24 +166,27 @@ export class ZoomPanner { return this.direction == null || this.direction === _ModuleSupport.ChartAxisDirection.Y; } - translateZooms(bbox: _ModuleSupport.BBox, currentZooms: AxisZoomStates, deltaX: number, deltaY: number) { + translateZooms(bbox: _ModuleSupport.BBox, currentZooms: StateRetrieval, deltaX: number, deltaY: number) { const offset = pointToRatio(bbox, bbox.x + Math.abs(deltaX), bbox.y + bbox.height - Math.abs(deltaY)); const offsetX = Math.sign(deltaX) * offset.x; const offsetY = -Math.sign(deltaY) * offset.y; - const newZooms: AxisZoomStates = {}; + const newZooms: State = {}; - for (const [axisId, { direction, zoom: currentZoom }] of entries(currentZooms)) { + for (const [axisId, currentZoom] of entries(currentZooms)) { + if (currentZoom == null) continue; // Skip panning axes that are fully zoomed out to prevent floating point issues - if (currentZoom && currentZoom.min === UNIT_MIN && currentZoom.max === UNIT_MAX) { + if (currentZoom.min === UNIT_MIN && currentZoom.max === UNIT_MAX) { continue; } + const { direction } = currentZoom; let zoom = definedZoomState({ [direction]: currentZoom }); zoom = constrainZoom(translateZoom(zoom, offsetX * dx(zoom), offsetY * dy(zoom))); - newZooms[axisId] = { direction, zoom: zoom[direction as _ModuleSupport.CartesianAxisDirection] }; + const { min, max } = zoom[direction]; + newZooms[axisId] = { direction, min, max }; } return newZooms; diff --git a/packages/ag-charts-enterprise/src/features/zoom/zoomScrollPanner.ts b/packages/ag-charts-enterprise/src/features/zoom/zoomScrollPanner.ts index 94306099bf..49f459d06a 100644 --- a/packages/ag-charts-enterprise/src/features/zoom/zoomScrollPanner.ts +++ b/packages/ag-charts-enterprise/src/features/zoom/zoomScrollPanner.ts @@ -1,29 +1,32 @@ import { _ModuleSupport } from 'ag-charts-community'; import { entries } from 'ag-charts-core'; -import type { AxisZoomStates } from './zoomTypes'; import { constrainZoom, definedZoomState, dx, pointToRatio, translateZoom } from './zoomUtils'; +type State = _ModuleSupport.CoreZoomState; +type StateRetrieval = _ModuleSupport.CoreZoomStateSafeRetrieval; + const DELTA_SCALE = 200; export class ZoomScrollPanner { - update(event: { deltaX: number }, step: number, bbox: _ModuleSupport.BBox, zooms: AxisZoomStates): AxisZoomStates { + update(event: { deltaX: number }, step: number, bbox: _ModuleSupport.BBox, zooms: StateRetrieval): State { const deltaX = event.deltaX * step * DELTA_SCALE; return this.translateZooms(bbox, zooms, deltaX); } - private translateZooms(bbox: _ModuleSupport.BBox, currentZooms: AxisZoomStates, deltaX: number) { - const newZooms: AxisZoomStates = {}; + private translateZooms(bbox: _ModuleSupport.BBox, currentZooms: StateRetrieval, deltaX: number) { + const newZooms: State = {}; const offset = pointToRatio(bbox, bbox.x + Math.abs(deltaX), 0); const offsetX = deltaX < 0 ? -offset.x : offset.x; - for (const [axisId, { direction, zoom: currentZoom }] of entries(currentZooms)) { - if (direction !== _ModuleSupport.ChartAxisDirection.X) continue; + for (const [axisId, value] of entries(currentZooms)) { + if (value?.direction !== _ModuleSupport.ChartAxisDirection.X) continue; + const { direction, min, max } = value; - let zoom = definedZoomState({ x: currentZoom }); + let zoom = definedZoomState({ x: { min, max } }); zoom = constrainZoom(translateZoom(zoom, offsetX * dx(zoom), 0)); - newZooms[axisId] = { direction, zoom: zoom.x }; + newZooms[axisId] = { direction, min: zoom.x.min, max: zoom.x.max }; } return newZooms; diff --git a/packages/ag-charts-enterprise/src/features/zoom/zoomScroller.ts b/packages/ag-charts-enterprise/src/features/zoom/zoomScroller.ts index e0ff563ea7..5da77d1c8a 100644 --- a/packages/ag-charts-enterprise/src/features/zoom/zoomScroller.ts +++ b/packages/ag-charts-enterprise/src/features/zoom/zoomScroller.ts @@ -1,7 +1,7 @@ import { _ModuleSupport, _Widget } from 'ag-charts-community'; import { entries } from 'ag-charts-core'; -import type { AxisZoomStates, DefinedZoomState, ZoomProperties } from './zoomTypes'; +import type { DefinedZoomState, ZoomProperties } from './zoomTypes'; import { constrainAxis, constrainZoom, @@ -12,15 +12,18 @@ import { scaleZoomAxisWithAnchor, } from './zoomUtils'; +type State = _ModuleSupport.CoreZoomState; +type StateRetrieval = _ModuleSupport.CoreZoomStateSafeRetrieval; + export class ZoomScroller { updateAxes( event: _Widget.WheelWidgetEvent, props: ZoomProperties, bbox: _ModuleSupport.BBox, - zooms: AxisZoomStates - ): AxisZoomStates { + zooms: StateRetrieval + ): State { const sourceEvent = event.sourceEvent; - const newZooms: AxisZoomStates = {}; + const newZooms: State = {}; const { anchorPointX, anchorPointY, isScalingX, isScalingY, scrollingStep } = props; // Convert the cursor position to coordinates as a ratio of 0 to 1 @@ -30,19 +33,20 @@ export class ZoomScroller { sourceEvent.offsetY ?? sourceEvent.clientY ); - for (const [axisId, { direction, zoom }] of entries(zooms)) { - if (zoom == null) continue; + for (const [axisId, value] of entries(zooms)) { + if (value == null) continue; + const { direction, min, max } = value; - let newZoom = { ...zoom }; + let newZoom = { min, max }; - const delta = scrollingStep * event.deltaY * (zoom.max - zoom.min); + const delta = scrollingStep * event.deltaY * (max - min); if (direction === _ModuleSupport.ChartAxisDirection.X && isScalingX) { newZoom.max += delta; - newZoom = scaleZoomAxisWithAnchor(newZoom, zoom, anchorPointX, origin.x); + newZoom = scaleZoomAxisWithAnchor(newZoom, value, anchorPointX, origin.x); } else if (direction === _ModuleSupport.ChartAxisDirection.Y && isScalingY) { newZoom.max += delta; - newZoom = scaleZoomAxisWithAnchor(newZoom, zoom, anchorPointY, origin.y); + newZoom = scaleZoomAxisWithAnchor(newZoom, value, anchorPointY, origin.y); } else { continue; } @@ -50,7 +54,8 @@ export class ZoomScroller { // @todo(AG-15397) - We don't have a way to normalize this zoom yet, so we'll just discard the zoom event if (newZoom.max < newZoom.min) continue; - newZooms[axisId] = { direction, zoom: constrainAxis(newZoom) }; + const constrained = constrainAxis(newZoom); + newZooms[axisId] = { direction, min: constrained.min, max: constrained.max }; } return newZooms; diff --git a/packages/ag-charts-enterprise/src/features/zoom/zoomToolbar.ts b/packages/ag-charts-enterprise/src/features/zoom/zoomToolbar.ts index 8127f4c250..0c4efa6b39 100644 --- a/packages/ag-charts-enterprise/src/features/zoom/zoomToolbar.ts +++ b/packages/ag-charts-enterprise/src/features/zoom/zoomToolbar.ts @@ -250,9 +250,10 @@ export class ZoomToolbar extends BaseProperties { if (props.independentAxes && button.value !== 'reset') { const axisZooms = this.ctx.zoomManager.getAxisZooms(); - for (const [axisId, { direction, zoom }] of entries(axisZooms)) { - if (zoom == null) continue; - this.onButtonPressAxis(button, props, axisId, direction, zoom); + for (const [axisId, value] of entries(axisZooms)) { + if (value == null) continue; + const { direction, min, max } = value; + this.onButtonPressAxis(button, props, axisId, direction, { min, max }); } } else { this.onButtonPressUnified(button, props); diff --git a/packages/ag-charts-enterprise/src/features/zoom/zoomTypes.ts b/packages/ag-charts-enterprise/src/features/zoom/zoomTypes.ts index c0651fb783..3b1179be06 100644 --- a/packages/ag-charts-enterprise/src/features/zoom/zoomTypes.ts +++ b/packages/ag-charts-enterprise/src/features/zoom/zoomTypes.ts @@ -1,5 +1,4 @@ import type { AgZoomAnchorPoint, _ModuleSupport } from 'ag-charts-community'; -import type { AxisID } from 'ag-charts-core'; export interface DefinedZoomState extends _ModuleSupport.AxisZoomState { x: _ModuleSupport.ZoomState; @@ -13,11 +12,6 @@ export type ZoomCoords = { y2: number; }; -export type AxisZoomStates = Record< - AxisID, - { direction: _ModuleSupport.ChartAxisDirection; zoom: _ModuleSupport.ZoomState } ->; - export interface ZoomProperties { anchorPointX: AgZoomAnchorPoint; anchorPointY: AgZoomAnchorPoint;