Skip to content

Commit

Permalink
Merge pull request #1560 from ag-grid/AG-11148-annotations-save-restore
Browse files Browse the repository at this point in the history
AG-11148 Add saving and restoring of annotation mementos
  • Loading branch information
alantreadway authored May 10, 2024
2 parents 20f2113 + 7b1817f commit 375d831
Show file tree
Hide file tree
Showing 12 changed files with 984 additions and 31 deletions.
2 changes: 1 addition & 1 deletion packages/ag-charts-community/.dependency-cruiser.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ module.exports = {
comment: "Don't use top-level export bundles internally.",
severity: 'error',
from: { path: 'src/.*/' },
to: { path: 'src/[^/]*.ts' },
to: { path: 'src/[^/]*.ts', pathNot: 'src/version.ts' },
},
],
options: {
Expand Down
5 changes: 5 additions & 0 deletions packages/ag-charts-community/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { expect } from '@jest/globals';
import { CanvasRenderingContext2D } from 'canvas';
import { type MatchImageSnapshotOptions, toMatchImageSnapshot } from 'jest-image-snapshot';
import { Path2D, applyPath2DToCanvasRenderingContext } from 'path2d';
import { TextDecoder, TextEncoder } from 'util';

import { toMatchImage } from './src/chart/test/utils';

// @ts-expect-error
global.Path2D = Path2D;

// @ts-expect-error
global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;

applyPath2DToCanvasRenderingContext(CanvasRenderingContext2D);

declare module 'expect' {
Expand Down
26 changes: 26 additions & 0 deletions packages/ag-charts-community/src/chart/agChartV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { deepClone, jsonWalk } from '../util/json';
import { Logger } from '../util/logger';
import { mergeDefaults } from '../util/object';
import type { DeepPartial } from '../util/types';
import { VERSION } from '../version';
import { CartesianChart } from './cartesianChart';
import { Chart, type ChartExtendedOptions } from './chart';
import { AgChartInstanceProxy } from './chartProxy';
Expand All @@ -21,6 +22,7 @@ import {
isAgPolarChartOptions,
isAgTopologyChartOptions,
} from './mapping/types';
import { MementoCaretaker } from './memento';
import { PolarChart } from './polarChart';
import { TopologyChart } from './topologyChart';

Expand Down Expand Up @@ -142,9 +144,25 @@ export abstract class AgCharts {
}
return AgChartsInternal.getImageDataURL(chart, options);
}

public static saveAnnotations(chart: AgChartInstance) {
if (!(chart instanceof AgChartInstanceProxy)) {
throw new Error(AgCharts.INVALID_CHART_REF_MESSAGE);
}
return AgChartsInternal.saveAnnotations(chart);
}

public static restoreAnnotations(chart: AgChartInstance, blob: unknown) {
if (!(chart instanceof AgChartInstanceProxy)) {
throw new Error(AgCharts.INVALID_CHART_REF_MESSAGE);
}
return AgChartsInternal.restoreAnnotations(chart, blob);
}
}

class AgChartsInternal {
private static readonly caretaker = new MementoCaretaker(VERSION);

static getInstance(element: HTMLElement): AgChartInstanceProxy | undefined {
const chart = Chart.getInstance(element);
return chart ? AgChartInstanceProxy.chartInstances.get(chart) : undefined;
Expand Down Expand Up @@ -241,6 +259,14 @@ class AgChartsInternal {
return result;
}

static saveAnnotations(proxy: AgChartInstanceProxy) {
return AgChartsInternal.caretaker.save(proxy.chart.ctx.annotationManager);
}

static restoreAnnotations(proxy: AgChartInstanceProxy, blob: unknown) {
return AgChartsInternal.caretaker.restore(proxy.chart.ctx.annotationManager, blob);
}

private static async prepareResizedChart({ chart }: AgChartInstanceProxy, opts: DownloadOptions = {}) {
const width: number = opts.width ?? chart.width ?? chart.ctx.scene.canvas.width;
const height: number = opts.height ?? chart.height ?? chart.ctx.scene.canvas.height;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,57 @@
import type { AgAnnotationsThemeableOptions } from '../../options/chart/annotationsOptions';
import type { Group } from '../../scene/group';
import type { Node } from '../../scene/node';
import { isPlainObject } from '../../util/type-guards';
import { BaseManager } from '../baseManager';
import type { Memento, MementoOriginator } from '../memento';

export class AnnotationManager {
export interface AnnotationsRestoreEvent {
type: 'restore-annotations';
annotations: AnnotationsMemento['annotations'];
}

export class AnnotationsMemento implements Memento {
type = 'annotations';

constructor(
public readonly version: string,
public readonly annotations?: any
) {}
}

export class AnnotationManager
extends BaseManager<AnnotationsRestoreEvent['type'], AnnotationsRestoreEvent>
implements MementoOriginator
{
public mementoOriginatorName = 'Annotations';

private annotations?: any;
private styles?: AgAnnotationsThemeableOptions;

constructor(private readonly annotationRoot: Group) {}
constructor(private readonly annotationRoot: Group) {
super();
}

public createMemento(version: string) {
return new AnnotationsMemento(version, this.annotations);
}

public guardMemento(blob: unknown): blob is AnnotationsMemento {
return isPlainObject(blob) && 'type' in blob && blob.type === 'annotations';
}

public restoreMemento(memento: AnnotationsMemento) {
// Migration from older versions can be implemented here.

this.listeners.dispatch('restore-annotations', {
type: 'restore-annotations',
annotations: memento.annotations,
});
}

public updateData(annotations?: any) {
this.annotations = annotations;
}

public attachNode(node: Node) {
this.annotationRoot.append(node);
Expand Down
99 changes: 99 additions & 0 deletions packages/ag-charts-community/src/chart/memento.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from '@jest/globals';

import { isPlainObject } from '../util/type-guards';
import { Memento, MementoCaretaker, MementoOriginator } from './memento';
import { expectWarning, setupMockConsole } from './test/utils';

describe('Memento Caretaker', () => {
setupMockConsole();

class TestMemento implements Memento {
type = 'test';
constructor(
public readonly version: string,
public readonly data?: any
) {}
}

class TestOriginator implements MementoOriginator {
mementoOriginatorName = 'TestOriginator';
data?: object;
restored?: object;

createMemento(version: string) {
return new TestMemento(version, this.data);
}

guardMemento(blob: unknown): boolean {
return isPlainObject(blob) && 'type' in blob && blob.type === 'test';
}

restoreMemento(blob: TestMemento): void {
this.restored = blob.data;
}
}

let originator: TestOriginator;
let caretaker: MementoCaretaker;

beforeEach(() => {
originator = new TestOriginator();
caretaker = new MementoCaretaker('10.0.0');
});

it('should save and restore data', () => {
originator.data = { hello: 'world' };

const blob = caretaker.save(originator);
caretaker.restore(originator, blob);

expect(blob).toBe('eyJ2ZXJzaW9uIjoiMTAuMC4wIiwiZGF0YSI6eyJoZWxsbyI6IndvcmxkIn0sInR5cGUiOiJ0ZXN0In0=');
expect(originator.restored).toEqual({ hello: 'world' });
});

it('should save and restore data with unicode strings', () => {
originator.data = { hello: '🌍' };

const blob = caretaker.save(originator);
caretaker.restore(originator, blob);

expect(blob).toBe('eyJ2ZXJzaW9uIjoiMTAuMC4wIiwiZGF0YSI6eyJoZWxsbyI6IvCfjI0ifSwidHlwZSI6InRlc3QifQ==');
expect(originator.restored).toEqual({ hello: '🌍' });
});

it('should guard an incorrect memento', () => {
class OtherTestMemento extends TestMemento {
override type = 'other-test';
}

class OtherTestOriginator extends TestOriginator {
override mementoOriginatorName = 'OtherTestOriginator';

override createMemento(version: string) {
return new OtherTestMemento(version, this.data);
}

override guardMemento(blob: unknown): boolean {
return isPlainObject(blob) && 'type' in blob && blob.type === 'other-test';
}
}

const otherOriginator = new OtherTestOriginator();
otherOriginator.data = { hello: 'world' };

const blob = caretaker.save(otherOriginator);
caretaker.restore(originator, blob);

expectWarning('AG Charts - TestOriginator - Could not restore data, memento was invalid, ignoring.', {
data: otherOriginator.data,
type: 'other-test',
version: '10.0.0',
});
expect(originator.restored).toBeUndefined();
});

it('should handle an invalid memento', () => {
expect(() => caretaker.restore(originator, 'some nonsense')).toThrow();
expect(originator.restored).toBeUndefined();
});
});
98 changes: 98 additions & 0 deletions packages/ag-charts-community/src/chart/memento.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Logger } from '../util/logger';
import { isDate, isPlainObject } from '../util/type-guards';

export interface Memento {
type: string;
version: string;
}

export interface MementoOriginator {
mementoOriginatorName: string;
createMemento(version: string): Memento;
guardMemento(blob: unknown): boolean;
restoreMemento(blob: Memento): void;
}

/**
* The caretaker manages the encoding and decoding of mementos from originators, ensuring they can be provided to and
* received from external systems. A memento encapsulates the state of an originator at a point in time. A memento
* is also versioned to ensure it can be migrated to newer versions of the originator.
*/
export class MementoCaretaker {
private readonly version: string;

constructor(version: string) {
// Strip out version suffixes, e.g. `-beta`
this.version = version.split('-')[0];
}

save(originator: MementoOriginator) {
return this.encode(originator, originator.createMemento(this.version));
}

restore(originator: MementoOriginator, blob: unknown) {
if (typeof blob !== 'string') {
Logger.warnOnce(
`${originator.mementoOriginatorName} - Could not restore data of type [${typeof blob}], expecting a string, ignoring.`
);
return;
}

const memento = this.decode(originator, blob);

if (!originator.guardMemento(memento)) {
Logger.warnOnce(
`${originator.mementoOriginatorName} - Could not restore data, memento was invalid, ignoring.`,
memento
);
return;
}

originator.restoreMemento(memento);
}

/**
* Encode a memento as a base64 string. Base64 encoding only supports utf-8, so multi-byte unicode characters are
* converted into multiple single-byte utf-8 characters.
*/
private encode(originator: MementoOriginator, memento: Memento) {
try {
const mementoString = JSON.stringify(memento, this.encodeTypes);
const bytes = new TextEncoder().encode(mementoString);
const binaryString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join('');
return btoa(binaryString);
} catch (error) {
throw new Error(`${originator.mementoOriginatorName} - Failed to encode memento [${error}].`, {
cause: error,
});
}
}

/**
* Decode an encoded memento, translating multiple single-byte uft-8 characters back into multi-byte unicode.
*/
private decode(originator: MementoOriginator, encoded: string) {
try {
const binaryString = atob(encoded);
const bytes = Uint8Array.from(binaryString, (m) => m.codePointAt(0) ?? 0);
const blobString = new TextDecoder().decode(bytes);
return JSON.parse(blobString, this.decodeTypes);
} catch (error) {
throw new Error(`${originator.mementoOriginatorName} - Failed to decode memento [${error}].`, {
cause: error,
});
}
}

private encodeTypes(this: any, key: any, value: any) {
if (isDate(this[key])) return { __type: 'date', value: String(this[key]) };
return value;
}

private decodeTypes(this: any, key: any, value: any) {
if (isPlainObject(this[key]) && '__type' in this[key] && this[key].__type === 'date') {
return new Date(this[key].value);
}
return value;
}
}
5 changes: 5 additions & 0 deletions packages/ag-charts-community/src/scale/timeScale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import timeYear from '../util/time/year';
import { buildFormatter } from '../util/timeFormat';
import { dateToNumber, defaultTimeTickFormat } from '../util/timeFormatDefaults';
import { ContinuousScale } from './continuousScale';
import type { ScaleConvertParams } from './scale';

export class TimeScale extends ContinuousScale<Date, TimeInterval | number> {
readonly type = 'time';
Expand Down Expand Up @@ -120,6 +121,10 @@ export class TimeScale extends ContinuousScale<Date, TimeInterval | number> {
return i;
}

override convert(x: Date, opts?: ScaleConvertParams | undefined): number {
return super.convert(new Date(x), opts);
}

override invert(y: number): Date {
return new Date(super.invert(y));
}
Expand Down
Loading

0 comments on commit 375d831

Please sign in to comment.