Skip to content

[IMP] chart: add zoom interactivity #6046

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
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
38 changes: 24 additions & 14 deletions src/components/figures/chart/chartJs/chartjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ export class ChartJsComponent extends Component<Props, SpreadsheetChildEnv> {
isFullScreen: { type: Boolean, optional: true },
};

private canvas = useRef("graphContainer");
private chart?: Chart;
private currentRuntime!: ChartJSRuntime;
private animationStore: Store<ChartAnimationStore> | undefined;
protected canvas = useRef("graphContainer");
protected chart?: Chart;
protected currentRuntime!: ChartJSRuntime;
protected animationStore: Store<ChartAnimationStore> | undefined;

private currentDevicePixelRatio = window.devicePixelRatio;

Expand Down Expand Up @@ -103,28 +103,37 @@ export class ChartJsComponent extends Component<Props, SpreadsheetChildEnv> {
const runtime = this.chartRuntime;
this.currentRuntime = runtime;
// Note: chartJS modify the runtime in place, so it's important to give it a copy
this.createChart(deepCopy(runtime.chartJsConfig));
this.createChart(deepCopy(runtime));
});
onWillUnmount(() => this.chart?.destroy());
onWillUnmount(this.unmount.bind(this));
useEffect(() => {
const runtime = this.chartRuntime;
if (runtime !== this.currentRuntime) {
if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
this.chart?.destroy();
this.createChart(deepCopy(runtime.chartJsConfig));
this.createChart(deepCopy(runtime));
} else {
this.updateChartJs(deepCopy(runtime.chartJsConfig));
this.updateChartJs(deepCopy(runtime));
}
this.currentRuntime = runtime;
} else if (this.currentDevicePixelRatio !== window.devicePixelRatio) {
this.currentDevicePixelRatio = window.devicePixelRatio;
this.updateChartJs(deepCopy(this.currentRuntime.chartJsConfig));
this.updateChartJs(deepCopy(this.currentRuntime));
}
});
}

private createChart(chartData: ChartConfiguration<any>) {
if (this.env.model.getters.isDashboard() && this.animationStore) {
protected unmount() {
this.chart?.destroy();
}

protected get shouldAnimate() {
return this.env.model.getters.isDashboard();
}

protected createChart(chartRuntime: ChartJSRuntime) {
let chartData = chartRuntime.chartJsConfig as ChartConfiguration<any>;
if (this.shouldAnimate && this.animationStore) {
const chartType = this.env.model.getters.getChart(this.props.chartId)?.type;
if (chartType && this.animationStore.animationPlayed[this.animationFigureId] !== chartType) {
chartData = this.enableAnimationInChartData(chartData);
Expand All @@ -137,8 +146,9 @@ export class ChartJsComponent extends Component<Props, SpreadsheetChildEnv> {
this.chart = new window.Chart(ctx, chartData);
}

private updateChartJs(chartData: ChartConfiguration<any>) {
if (this.env.model.getters.isDashboard()) {
protected updateChartJs(chartRuntime: ChartJSRuntime) {
let chartData = chartRuntime.chartJsConfig as ChartConfiguration<any>;
if (this.shouldAnimate) {
const chartType = this.env.model.getters.getChart(this.props.chartId)?.type;
if (chartType && this.hasChartDataChanged() && this.animationStore) {
chartData = this.enableAnimationInChartData(chartData);
Expand All @@ -165,7 +175,7 @@ export class ChartJsComponent extends Component<Props, SpreadsheetChildEnv> {
);
}

private enableAnimationInChartData(chartData: ChartConfiguration<any>) {
protected enableAnimationInChartData(chartData: ChartConfiguration<any>) {
return {
...chartData,
options: { ...chartData.options, animation: { animateRotate: true } },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Command, UID } from "../../../../..";
import {
MOVING_AVERAGE_TREND_LINE_XAXIS_ID,
TREND_LINE_XAXIS_ID,
} from "../../../../../helpers/figures/charts/chart_common";
import { SpreadsheetStore } from "../../../../../stores";

const TREND_LINE_AXES_IDS = [TREND_LINE_XAXIS_ID, MOVING_AVERAGE_TREND_LINE_XAXIS_ID] as const;
const ZOOMABLE_AXIS_IDS = ["x", ...TREND_LINE_AXES_IDS] as const;
export type AxisId = (typeof ZOOMABLE_AXIS_IDS)[number];
export type AxesLimits = {
[chartId: UID]: { [axisId in AxisId]?: { min: number; max: number } | undefined };
};
export class ZoomableChartStore extends SpreadsheetStore {
mutators = [
"resetAxisLimits",
"updateAxisLimits",
"updateTrendLineConfiguration",
"clearAxisLimits",
] as const;

originalAxisLimits: AxesLimits = {};
currentAxesLimits: AxesLimits = {};
private idConversion: Record<UID, Set<UID>> = {};

handle(cmd: Command) {
switch (cmd.type) {
case "DELETE_FIGURE":
if (cmd.figureId && this.idConversion[cmd.figureId]) {
for (const chartId of this.idConversion[cmd.figureId]) {
delete this.originalAxisLimits[chartId];
delete this.currentAxesLimits[chartId];
}
}
break;
case "UPDATE_CHART":
const type = cmd.definition.type;
const chartId = `${type}-${cmd.figureId}`;
if (!this.idConversion[cmd.figureId]) {
this.idConversion[cmd.figureId] = new Set<UID>();
}
this.idConversion[cmd.figureId].add(chartId);
if (!("zoomable" in cmd.definition && cmd.definition.zoomable)) {
this.clearAxisLimits(chartId);
}
break;
}
}

clearAxisLimits(chartId: UID) {
delete this.originalAxisLimits[chartId];
delete this.currentAxesLimits[chartId];
return "noStateChange";
}

resetAxisLimits(
chartId: UID,
limits: { [key: string]: { min: number; max: number } | undefined } | undefined
) {
for (const axisId of ZOOMABLE_AXIS_IDS) {
if (limits?.[axisId]) {
if (!this.originalAxisLimits[chartId]?.[axisId]) {
this.originalAxisLimits[chartId] = {
...this.originalAxisLimits[chartId],
[axisId]: {},
};
}
this.originalAxisLimits[chartId][axisId]!["min"] = limits[axisId].min;
this.originalAxisLimits[chartId][axisId]!["max"] = limits[axisId].max;
} else {
if (this.originalAxisLimits[chartId]?.[axisId]) {
delete this.originalAxisLimits[chartId][axisId];
}
}
}
return "noStateChange";
}

updateAxisLimits(chartId: UID, limits?: { min: number; max: number } | undefined) {
if (limits === undefined) {
delete this.currentAxesLimits[chartId];
return "noStateChange";
}
let { min, max } = limits;
if (min > max) {
[min, max] = [max, min];
}
this.currentAxesLimits[chartId] = { x: { min, max } };
return "noStateChange";
}

/* Update the trend line axis configuration based on the current axis limits.
* This function calculates the new limits for the trend line axes based on the current x-axis
* limits and the original limits of the trend line axes.
* It assumes that the origininal trend line axes are linear transformations of the original x-axis
* limits and applies the same transformation to the current x-axis limits to get the new limits
* for the current trend line axes.
*/
updateTrendLineConfiguration(chartId: UID) {
if (!this.originalAxisLimits[chartId]) {
return "noStateChange";
}
const chartLimits = this.originalAxisLimits[chartId].x;
if (chartLimits === undefined) {
return "noStateChange";
}
for (const axisId of TREND_LINE_AXES_IDS) {
if (!this.originalAxisLimits[chartId][axisId]) {
continue;
}
if (!this.currentAxesLimits[chartId]?.[axisId]) {
this.currentAxesLimits[chartId] = {
...this.currentAxesLimits[chartId],
[axisId]: {},
};
}
if (this.currentAxesLimits[chartId]?.x === undefined) {
return "noStateChange";
}
const realRange = chartLimits.max - chartLimits.min;
const trendingLimits = this.originalAxisLimits[chartId][axisId];
const trendingRange = trendingLimits.max! - trendingLimits.min!;
const slope = trendingRange / realRange;
const intercept = trendingLimits.min! - chartLimits.min * slope;
const newXMin = this.currentAxesLimits[chartId].x.min;
const newXMax = this.currentAxesLimits[chartId].x.max;
this.currentAxesLimits[chartId][axisId]!.min = newXMin * slope + intercept;
this.currentAxesLimits[chartId][axisId]!.max = newXMax * slope + intercept;
}
return "noStateChange";
}
}
Loading