Skip to content

Commit f5aa9df

Browse files
author
Nils Bars
committed
Preserve scoreboard chart zoom and legend across polling re-renders
Add applyPreservedState() in chartSetup that copies dataZoom windows and legend selections from the live chart into the next option payload, and wire it into PointsOverTimeChart and ChallengePlot. ChallengePlot resets state when the selected challenge changes.
1 parent 4da7abb commit f5aa9df

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

spa-frontend/src/components/scoreboard/ChallengePlot.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
33
import type { EChartsOption } from 'echarts';
44
import {
5+
applyPreservedState,
56
buildCommonOptions,
67
formatTooltipDate,
78
getMarkLineColors,
@@ -31,6 +32,7 @@ type PlotPoint = {
3132
3233
const root = ref<HTMLDivElement | null>(null);
3334
let chart: ManagedChart | null = null;
35+
let lastRenderedChallenge: string | null = null;
3436
3537
function findBaseline(): number | null {
3638
for (const challenges of Object.values(props.assignments || {})) {
@@ -173,8 +175,17 @@ function buildOption(): EChartsOption {
173175
174176
function render() {
175177
if (!root.value) return;
178+
const firstMount = !chart;
176179
if (!chart) chart = mountChart(root.value);
177-
chart.chart.setOption(buildOption(), { notMerge: true });
180+
// Reset zoom/legend when the user switches to a different challenge — the
181+
// x-axis and team set are effectively a new chart. Preserve state only
182+
// across data polls for the same challenge.
183+
const sameChallenge = !firstMount && lastRenderedChallenge === props.challengeName;
184+
const option = sameChallenge
185+
? applyPreservedState(chart.chart, buildOption())
186+
: buildOption();
187+
chart.chart.setOption(option, { notMerge: true });
188+
lastRenderedChallenge = props.challengeName;
178189
}
179190
180191
let offTheme: (() => void) | null = null;

spa-frontend/src/components/scoreboard/PointsOverTimeChart.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
33
import type { EChartsOption } from 'echarts';
44
import {
5+
applyPreservedState,
56
buildCommonOptions,
67
formatTooltipDate,
78
getMarkLineColors,
@@ -135,8 +136,10 @@ function buildOption(): EChartsOption {
135136
136137
function render() {
137138
if (!root.value) return;
139+
const firstMount = !chart;
138140
if (!chart) chart = mountChart(root.value);
139-
chart.chart.setOption(buildOption(), { notMerge: true });
141+
const option = firstMount ? buildOption() : applyPreservedState(chart.chart, buildOption());
142+
chart.chart.setOption(option, { notMerge: true });
140143
}
141144
142145
let offTheme: (() => void) | null = null;

spa-frontend/src/components/scoreboard/chartSetup.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,58 @@ export function unmountChart(instance: ManagedChart | null) {
163163
instance.chart.dispose();
164164
}
165165

166+
type DataZoomEntry = {
167+
id?: string;
168+
start?: number;
169+
end?: number;
170+
startValue?: number | string;
171+
endValue?: number | string;
172+
};
173+
174+
type LegendEntry = {
175+
selected?: Record<string, boolean>;
176+
};
177+
178+
// Carry the user-interactive chart state (zoom window, legend toggles) from
179+
// the live chart into the next option payload. Without this, polling
180+
// re-renders with `notMerge: true` would wipe the user's view every few
181+
// seconds.
182+
export function applyPreservedState(
183+
chart: EChartsInstance,
184+
option: EChartsOption,
185+
): EChartsOption {
186+
const current = chart.getOption() as {
187+
dataZoom?: DataZoomEntry[];
188+
legend?: LegendEntry[];
189+
};
190+
191+
const nextDz = option.dataZoom;
192+
const prevDz = current.dataZoom;
193+
if (Array.isArray(nextDz) && Array.isArray(prevDz) && prevDz.length > 0) {
194+
option.dataZoom = nextDz.map((entry) => {
195+
const z = entry as DataZoomEntry;
196+
const prior = prevDz.find((p) => p.id === z.id);
197+
if (!prior) return entry;
198+
return {
199+
...entry,
200+
...(prior.start !== undefined ? { start: prior.start } : {}),
201+
...(prior.end !== undefined ? { end: prior.end } : {}),
202+
...(prior.startValue !== undefined ? { startValue: prior.startValue } : {}),
203+
...(prior.endValue !== undefined ? { endValue: prior.endValue } : {}),
204+
};
205+
});
206+
}
207+
208+
const prevLegend = current.legend?.[0]?.selected;
209+
if (prevLegend && option.legend) {
210+
const legendList = Array.isArray(option.legend) ? option.legend : [option.legend];
211+
legendList[0] = { ...legendList[0], selected: { ...prevLegend } };
212+
option.legend = Array.isArray(option.legend) ? legendList : legendList[0];
213+
}
214+
215+
return option;
216+
}
217+
166218
export function getTeamColor(team: string): string {
167219
let idx = teamIndices.get(team);
168220
if (idx === undefined) {

0 commit comments

Comments
 (0)