fix(editor): stop the behaviour chart flickering during playback

The playhead was a canvas line redrawn by redrawing the whole uPlot chart on
every frame, which flickers. Draw it as a DOM overlay that slides over the
canvas instead, so the canvas is only redrawn when the data actually changes.
This commit is contained in:
Julien Calixte
2026-06-20 14:47:08 +02:00
parent dc30e5f890
commit d5cda95c3e

View File

@@ -43,6 +43,25 @@ let observer: ResizeObserver | undefined
/** Signature of the current plot's tracks; a change means the shape changed. */ /** Signature of the current plot's tracks; a change means the shape changed. */
let builtSig = "" let builtSig = ""
// Playhead as a DOM overlay (CSS px), not a canvas line: a moving line drawn by
// redrawing the whole uPlot canvas every frame flickers, so the line slides over
// the canvas instead and the canvas is only redrawn when the data changes.
const markerX = ref<number | null>(null)
const markerTop = ref(0)
const markerHeight = ref(0)
function syncMarker(): void {
if (!plot || props.marker === null) {
markerX.value = null
return
}
// bbox is in device pixels; the overlay is laid out in CSS pixels.
const ratio = window.devicePixelRatio || 1
markerX.value = plot.valToPos(props.marker, "x")
markerTop.value = plot.bbox.top / ratio
markerHeight.value = plot.bbox.height / ratio
}
/** Compact axis/legend numbers (7039.99 → "7040", 0.42 → "0.42", idle → "--"). */ /** Compact axis/legend numbers (7039.99 → "7040", 0.42 → "0.42", idle → "--"). */
function fmt(value: number | null): string { function fmt(value: number | null): string {
if (value === null) return "--" if (value === null) return "--"
@@ -76,26 +95,6 @@ function options(width: number): uPlot.Options {
height: props.height, height: props.height,
cursor: { y: false }, cursor: { y: false },
scales: { x: { time: false } }, scales: { x: { time: false } },
// Playhead: a vertical line at the current simulation time, so the chart and
// the animating canvas read off the same instant. Drawn after the series, and
// redrawn whenever `marker` changes (see the watch below).
hooks: {
draw: [
(u: uPlot): void => {
if (props.marker === null) return
const x = Math.round(u.valToPos(props.marker, "x", true))
const { ctx } = u
ctx.save()
ctx.beginPath()
ctx.lineWidth = 1
ctx.strokeStyle = themeColor("--color-primary", "#2563eb")
ctx.moveTo(x, u.bbox.top)
ctx.lineTo(x, u.bbox.top + u.bbox.height)
ctx.stroke()
ctx.restore()
},
],
},
series: [ series: [
{}, {},
...props.series.map( ...props.series.map(
@@ -123,17 +122,25 @@ function build(): void {
function render(): void { function render(): void {
if (!plot || trackSig() !== builtSig) build() if (!plot || trackSig() !== builtSig) build()
else plot.setData(props.data) else plot.setData(props.data)
syncMarker() // the plot area may have shifted; keep the overlay aligned
} }
onMounted(() => { onMounted(() => {
build() build()
observer = new ResizeObserver(() => { observer = new ResizeObserver(() => {
if (plot && root.value) plot.setSize({ width: root.value.clientWidth, height: props.height }) if (plot && root.value) {
plot.setSize({ width: root.value.clientWidth, height: props.height })
syncMarker()
}
}) })
if (root.value) observer.observe(root.value) if (root.value) observer.observe(root.value)
syncMarker()
// The canvas paints before the web font loads and won't repaint on its own, so // The canvas paints before the web font loads and won't repaint on its own, so
// axis labels would stick to the fallback. Redraw once the font is ready. // axis labels would stick to the fallback. Redraw once the font is ready.
document.fonts?.ready.then(() => plot?.redraw()) document.fonts?.ready.then(() => {
plot?.redraw()
syncMarker()
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -142,15 +149,18 @@ onBeforeUnmount(() => {
}) })
watch(() => props.data, render) watch(() => props.data, render)
// The playhead moves without the data changing, so nudge a redraw on its own. // The playhead just slides the overlay — no canvas redraw, so playback stays smooth.
watch( watch(() => props.marker, syncMarker)
() => props.marker,
() => plot?.redraw(),
)
</script> </script>
<template> <template>
<div ref="root" class="w-full" /> <div ref="root" class="relative w-full">
<div
v-if="markerX !== null"
class="pointer-events-none absolute w-px bg-primary"
:style="{ left: `${markerX}px`, top: `${markerTop}px`, height: `${markerHeight}px` }"
/>
</div>
</template> </template>
<style scoped> <style scoped>