["onChartReady"]>
- >(
- (instance) => {
- firstInstanceDoneRef.current = true
- const zoom = zoomWindowRef.current
- if (zoom.start > 0 || zoom.end < 100) {
- instance.dispatchAction({
- type: "dataZoom",
- start: zoom.start,
- end: zoom.end,
- })
- }
- if (animateEntryRef.current) {
- const onFinished = () => {
- instance.off("finished", onFinished)
- settleChartEntryAnimation(bufferId)
- }
- instance.on("finished", onFinished)
- }
- },
- [bufferId],
- )
-
- const key = useMemo(() => structuralKey(option), [option])
-
- const suppressEntryAnimation =
- !animateEntryRef.current && !firstInstanceDoneRef.current
- const renderOption = useMemo(
- () =>
- suppressEntryAnimation ? { ...option, animationDuration: 0 } : option,
- [option, suppressEntryAnimation],
- )
+ // Without a slider or a wheel nothing can move the window back, so a
+ // window that outlives its controls resets and the owner hears it go.
+ useEffect(() => {
+ if (zoomDensity === null || !zoomControlsHidden(zoomDensity)) return
+ if (!isZoomed(zoomWindowRef.current)) return
+ resetZoom()
+ onZoomChangeRef.current?.(0, 100)
+ }, [zoomDensity, resetZoom])
- const events = useMemo(() => {
- if (!onZoomChange) return undefined
- return {
- datazoom: (evt: unknown) => {
- const e = evt as DataZoomEvent
- const first = e.batch?.[0] ?? e
- if (
- typeof first.start === "number" &&
- typeof first.end === "number"
- ) {
- onZoomChange(first.start, first.end)
- }
- },
- }
- }, [onZoomChange])
+ useImperativeHandle(ref, () => ({ resetZoom }), [resetZoom])
return (
(
height: typeof height === "number" ? `${height}px` : height,
}}
>
-
+ {zoomDensity !== null && (
+
+ )}
)
},
diff --git a/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.test.ts b/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.test.ts
index 6f617e453..dcd82a630 100644
--- a/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.test.ts
+++ b/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.test.ts
@@ -370,3 +370,23 @@ describe("buildEchartsOption — timestamps render in UTC, like the result grid"
expect(opt.useUTC).toBe(true)
})
})
+
+describe("buildEchartsOption — axis labels", () => {
+ it("hides overlapping x-axis labels so a narrow chart thins them instead of colliding", () => {
+ // Given a temporal x column with more ticks than a narrow chart can fit
+ const columns = [col("ts", "TIMESTAMP"), col("v", "DOUBLE")]
+ const dataset = Array.from({ length: 15 }, (_, i) => [i * 120_000, i])
+ // When the option builds
+ const opt = buildEchartsOption({ xColumn: "ts" }, [
+ resolved({
+ columns,
+ dataset,
+ xColumn: "ts",
+ type: "bar",
+ yColumns: ["v"],
+ }),
+ ])
+ // Then the x-axis label config asks ECharts to hide overlaps
+ expect(firstAxis(opt.xAxis).axisLabel).toMatchObject({ hideOverlap: true })
+ })
+})
diff --git a/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts b/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts
index 294324c05..293c29569 100644
--- a/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts
+++ b/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts
@@ -1,4 +1,5 @@
import type { EChartsOption } from "echarts"
+import type { ChartZoomDensity } from "./chartDensity"
import type { ColumnDefinition } from "../../../../utils/questdb/types"
import type { ChartConfig, ChartType, SeriesAxis } from "./chartTypes"
import { MAX_PARTITION_SERIES, classifyColumn } from "./inferChartConfig"
@@ -32,8 +33,6 @@ type SeriesContext = {
categoryUnion: string[] | null // set only when overlaying series onto a shared category axis; null for a single positional query
}
-const DATAZOOM_THRESHOLD = 200
-
const LEGEND_BOTTOM = 3
const SLIDER_HEIGHT = 18
const SLIDER_BOTTOM = 40
@@ -357,7 +356,7 @@ export const buildEchartsOption = (
queries: ResolvedQuery[],
): EChartsOption => {
const chartText = { fontSize: CHART_FONT_SIZE }
- const axisLabel = { fontSize: CHART_FONT_SIZE }
+ const axisLabel = { fontSize: CHART_FONT_SIZE, hideOverlap: true }
const axisName = { fontSize: CHART_FONT_SIZE }
const baseLegend = {
type: "scroll" as const,
@@ -459,22 +458,13 @@ export const buildEchartsOption = (
? (rightQueries[0].name ?? rightQueries[0].yColumns[0] ?? "")
: "")
- const maxRows = queries.reduce((m, q) => Math.max(m, q.dataset.length), 0)
- const hasZoom = maxRows > DATAZOOM_THRESHOLD
- const sliderZoom = {
- type: "slider" as const,
- height: SLIDER_HEIGHT,
- bottom: SLIDER_BOTTOM,
- textStyle: chartText,
- }
-
const rightPadding = 36
const grid: EChartsOption["grid"] = {
left: 24,
right: rightPadding,
top: 40,
- bottom: hasZoom ? GRID_BOTTOM_WITH_ZOOM : GRID_BOTTOM_NO_ZOOM,
+ bottom: GRID_BOTTOM_NO_ZOOM,
containLabel: true,
}
@@ -548,7 +538,36 @@ export const buildEchartsOption = (
grid,
xAxis,
yAxis,
- dataZoom: hasZoom ? [{ type: "inside" }, sliderZoom] : undefined,
series: series as EChartsOption["series"],
}
}
+
+// Every cartesian chart embeds both zoom components, and density only toggles
+// their visibility — never the option's structure, which would remount the
+// chart through its structural key. Wheel zoom arms while marks are merely
+// tight (it costs no plot space); the slider costs plot height, so it waits
+// until marks fall under the readable floor. The renderer measures the density
+// from its own width.
+export const withZoomDensity = (
+ option: EChartsOption,
+ density: ChartZoomDensity,
+): EChartsOption => {
+ if (option.xAxis == null) return option
+ return {
+ ...option,
+ grid: {
+ ...(option.grid as object),
+ bottom: density.slider ? GRID_BOTTOM_WITH_ZOOM : GRID_BOTTOM_NO_ZOOM,
+ },
+ dataZoom: [
+ { type: "inside", disabled: !density.wheel },
+ {
+ type: "slider",
+ show: density.slider,
+ height: SLIDER_HEIGHT,
+ bottom: SLIDER_BOTTOM,
+ textStyle: { fontSize: CHART_FONT_SIZE },
+ },
+ ],
+ }
+}
diff --git a/src/scenes/Editor/Notebook/CellChart/chartDensity.test.ts b/src/scenes/Editor/Notebook/CellChart/chartDensity.test.ts
new file mode 100644
index 000000000..e8b16a84d
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/chartDensity.test.ts
@@ -0,0 +1,271 @@
+import { describe, it, expect } from "vitest"
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+import type { EChartsOption } from "echarts"
+import {
+ buildEchartsOption,
+ withZoomDensity,
+ type ResolvedQuery,
+} from "./buildEchartsOption"
+import {
+ chartZoomDensity,
+ MIN_MARK_PX,
+ MIN_POINT_PX,
+ WHEEL_ZOOM_HEADROOM,
+} from "./chartDensity"
+
+const needsZoomSlider = (option: EChartsOption, widthPx: number) =>
+ chartZoomDensity(option, widthPx).slider
+
+const needsWheelZoom = (option: EChartsOption, widthPx: number) =>
+ chartZoomDensity(option, widthPx).wheel
+
+const withMeasuredZoom = (option: EChartsOption, widthPx: number) =>
+ withZoomDensity(option, chartZoomDensity(option, widthPx))
+
+const col = (name: string, type: string): ColumnDefinition => ({ name, type })
+
+const resolved = (over: Partial): ResolvedQuery => ({
+ index: 0,
+ columns: [],
+ dataset: [],
+ xColumn: null,
+ type: "line",
+ yColumns: [],
+ axis: "left",
+ ...over,
+})
+
+// 800px container, 24 + 36 grid padding, 48px of y-axis labels: 692px plot.
+const CONTAINER_PX = 800
+const PLOT_PX = 692
+const WIDE_CONTAINER_PX = 1600
+
+// The most marks the plot can show at the readable floor, per mark type.
+const readableSlots = (fillRatio: number, minPx: number) =>
+ Math.floor((PLOT_PX * fillRatio) / minPx)
+const singleBarFill = 0.8
+const threeBarFill = 0.8 / (1.2 * 3 - 0.2)
+const candleFill = 0.5
+
+const rows = (count: number, valuesPerRow: number): number[][] =>
+ Array.from({ length: count }, (_, i) => [
+ i * 60_000,
+ ...Array.from({ length: valuesPerRow }, (_, j) => i + j),
+ ])
+
+const bars = (count: number, seriesCount: number) => {
+ const yColumns = Array.from({ length: seriesCount }, (_, j) => `y${j}`)
+ return buildEchartsOption({ xColumn: "ts" }, [
+ resolved({
+ columns: [
+ col("ts", "TIMESTAMP"),
+ ...yColumns.map((y) => col(y, "DOUBLE")),
+ ],
+ dataset: rows(count, seriesCount),
+ xColumn: "ts",
+ type: "bar",
+ yColumns,
+ }),
+ ])
+}
+
+const candles = (count: number) =>
+ buildEchartsOption({ xColumn: "ts" }, [
+ resolved({
+ columns: [
+ col("ts", "TIMESTAMP"),
+ col("open", "DOUBLE"),
+ col("high", "DOUBLE"),
+ col("low", "DOUBLE"),
+ col("close", "DOUBLE"),
+ ],
+ dataset: rows(count, 4),
+ xColumn: "ts",
+ type: "candlestick",
+ yColumns: [],
+ ohlc: { open: "open", high: "high", low: "low", close: "close" },
+ }),
+ ])
+
+const line = (count: number) =>
+ buildEchartsOption({ xColumn: "ts" }, [
+ resolved({
+ columns: [col("ts", "TIMESTAMP"), col("v", "DOUBLE")],
+ dataset: rows(count, 1),
+ xColumn: "ts",
+ type: "line",
+ yColumns: ["v"],
+ }),
+ ])
+
+describe("chartZoomDensity slider", () => {
+ it("asks for a slider only once single-series bars fall under the readable width", () => {
+ // Given the most single bars the plot can show at the floor
+ const limit = readableSlots(singleBarFill, MIN_MARK_PX)
+
+ // When the density is checked at the limit, past it, and in a wider container
+ const atLimit = needsZoomSlider(bars(limit, 1), CONTAINER_PX)
+ const pastLimit = needsZoomSlider(bars(limit + 1, 1), CONTAINER_PX)
+ const pastLimitWide = needsZoomSlider(bars(limit + 1, 1), WIDE_CONTAINER_PX)
+
+ // Then that many need no slider and one more does
+ expect(atLimit).toBe(false)
+ expect(pastLimit).toBe(true)
+ // And the same bars in a container twice as wide are readable again
+ expect(pastLimitWide).toBe(false)
+ })
+
+ it("counts grouped bar series against the same band", () => {
+ // Given three series sharing every band
+ const limit = readableSlots(threeBarFill, MIN_MARK_PX)
+
+ // When the density is checked at the limit and one band past it
+ const atLimit = needsZoomSlider(bars(limit, 3), CONTAINER_PX)
+ const pastLimit = needsZoomSlider(bars(limit + 1, 3), CONTAINER_PX)
+
+ // Then the band count that keeps a column readable is a third of the single-series one
+ expect(limit).toBeLessThan(readableSlots(singleBarFill, MIN_MARK_PX) / 3)
+ expect(atLimit).toBe(false)
+ expect(pastLimit).toBe(true)
+ })
+
+ it("uses the half-band candle body as the readable mark", () => {
+ // Given the most candles whose bodies stay at the floor
+ const limit = readableSlots(candleFill, MIN_MARK_PX)
+
+ // When the density is checked at the limit and one candle past it
+ const atLimit = needsZoomSlider(candles(limit), CONTAINER_PX)
+ const pastLimit = needsZoomSlider(candles(limit + 1), CONTAINER_PX)
+
+ // Then that many need no slider and one more does
+ expect(atLimit).toBe(false)
+ expect(pastLimit).toBe(true)
+ })
+
+ it("asks for a slider on a line only once points outnumber pixels", () => {
+ // Given a point per readable pixel
+ const limit = readableSlots(1, MIN_POINT_PX)
+
+ // When the density is checked at the limit and one point past it
+ const atLimit = needsZoomSlider(line(limit), CONTAINER_PX)
+ const pastLimit = needsZoomSlider(line(limit + 1), CONTAINER_PX)
+
+ // Then that many need no slider and one more does
+ expect(atLimit).toBe(false)
+ expect(pastLimit).toBe(true)
+ })
+
+ it("never asks for a slider before the container is measured", () => {
+ // Given a very dense line in a container with no width yet
+ const unmeasured = line(5000)
+
+ // When the density is checked at width zero
+ const needsSlider = needsZoomSlider(unmeasured, 0)
+
+ // Then no slider is requested
+ expect(needsSlider).toBe(false)
+ })
+
+ it("uses the exact width instead of rounding a narrow chart up", () => {
+ // Given 35 single bars
+ const chart = bars(35, 1)
+
+ // When the density is checked at 351px and at 400px
+ const narrow = needsZoomSlider(chart, 351)
+ const wide = needsZoomSlider(chart, 400)
+
+ // Then the narrow width needs a slider and the wide one does not
+ expect(narrow).toBe(true)
+ expect(wide).toBe(false)
+ })
+})
+
+describe("chartZoomDensity wheel", () => {
+ it("arms the wheel at the headroom multiple of the readable floor", () => {
+ // Given the most single bars that stay at the wheel threshold (3× the floor)
+ const limit = readableSlots(
+ singleBarFill,
+ MIN_MARK_PX * WHEEL_ZOOM_HEADROOM,
+ )
+
+ // When the wheel is checked at the limit and one bar past it
+ const atLimit = needsWheelZoom(bars(limit, 1), CONTAINER_PX)
+ const pastLimit = needsWheelZoom(bars(limit + 1, 1), CONTAINER_PX)
+
+ // Then that many need no wheel and one more does
+ expect(atLimit).toBe(false)
+ expect(pastLimit).toBe(true)
+ })
+
+ it("offers the wheel without the slider while marks are tight but readable", () => {
+ // Given a line denser than the wheel threshold but sparser than the floor
+ const points = Math.floor(PLOT_PX / 2)
+
+ // When both zoom modes are checked
+ const needsWheel = needsWheelZoom(line(points), CONTAINER_PX)
+ const needsSlider = needsZoomSlider(line(points), CONTAINER_PX)
+
+ // Then the invisible wheel zoom arms while the space-costing slider waits
+ expect(needsWheel).toBe(true)
+ expect(needsSlider).toBe(false)
+ })
+})
+
+type ZoomComponents = [
+ { type: string; disabled: boolean },
+ { type: string; show: boolean },
+]
+
+describe("withZoomDensity", () => {
+ it("embeds both zoom components inert on a sparse chart", () => {
+ // Given a sparse chart
+ const sparse = bars(15, 1)
+
+ // When the zoom components are embedded
+ const option = withMeasuredZoom(sparse, CONTAINER_PX)
+
+ // Then the components exist but neither is active, so density changes can
+ // never alter the option's structure, and the grid keeps its margin
+ const [inside, slider] = option.dataZoom as ZoomComponents
+ expect(inside).toMatchObject({ type: "inside", disabled: true })
+ expect(slider).toMatchObject({ type: "slider", show: false })
+ expect((option.grid as { bottom: number }).bottom).toBe(56)
+ })
+
+ it("activates the wheel alone on a tight chart", () => {
+ // Given a line tight enough for the wheel but readable without a slider
+ const tight = line(Math.floor(PLOT_PX / 2))
+
+ // When the zoom components are embedded
+ const option = withMeasuredZoom(tight, CONTAINER_PX)
+
+ // Then only the wheel is active and the grid keeps its margin
+ const [inside, slider] = option.dataZoom as ZoomComponents
+ expect(inside).toMatchObject({ disabled: false })
+ expect(slider).toMatchObject({ show: false })
+ expect((option.grid as { bottom: number }).bottom).toBe(56)
+ })
+
+ it("activates the wheel and the slider together on a dense chart", () => {
+ // Given a dense chart
+ const dense = line(PLOT_PX * 2)
+
+ // When the zoom components are embedded
+ const option = withMeasuredZoom(dense, CONTAINER_PX)
+
+ // Then both zooms are active and the grid grows at the bottom
+ const [inside, slider] = option.dataZoom as ZoomComponents
+ expect(inside).toMatchObject({ type: "inside", disabled: false })
+ expect(slider).toMatchObject({ type: "slider", show: true })
+ expect((option.grid as { bottom: number }).bottom).toBe(86)
+ })
+
+ it("leaves an axis-less chart without zoom components", () => {
+ // Given a pie option (no xAxis to zoom)
+ // When the zoom components are embedded
+ const option = withMeasuredZoom({ series: [{ type: "pie" }] }, CONTAINER_PX)
+
+ // Then no zoom component is added
+ expect(option.dataZoom).toBeUndefined()
+ })
+})
diff --git a/src/scenes/Editor/Notebook/CellChart/chartDensity.ts b/src/scenes/Editor/Notebook/CellChart/chartDensity.ts
new file mode 100644
index 000000000..00fa874c4
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/chartDensity.ts
@@ -0,0 +1,93 @@
+import type { EChartsOption } from "echarts"
+
+// A bar or candle body narrower than this reads as a line, not a shape.
+export const MIN_MARK_PX = 6
+// Lines and scatter downsample to the pixel grid, so detail is lost only once
+// points outnumber pixels.
+export const MIN_POINT_PX = 1
+const Y_AXIS_LABEL_WIDTH_PX = 48
+// ECharts keeps 20% of a band as the category gap and 20% of a bar between
+// grouped columns, so one column is 0.8 / (1.2k - 0.2) of its band. A candle
+// body is half its band.
+const CANDLE_FILL_RATIO = 0.5
+
+type SeriesSpec = { type?: string; stack?: string; data?: unknown[] }
+
+const seriesOf = (option: EChartsOption): SeriesSpec[] => {
+ const raw = option.series
+ return (Array.isArray(raw) ? raw : raw ? [raw] : []) as SeriesSpec[]
+}
+
+const firstOf = (value: T | T[] | undefined): T | undefined =>
+ Array.isArray(value) ? value[0] : value
+
+const dataLength = (series: SeriesSpec): number =>
+ Array.isArray(series.data) ? series.data.length : 0
+
+const maxDataLength = (list: SeriesSpec[]): number =>
+ list.reduce((max, series) => Math.max(max, dataLength(series)), 0)
+
+const barFillRatio = (columns: number): number => 0.8 / (1.2 * columns - 0.2)
+
+const barColumns = (bars: SeriesSpec[]): number => {
+ const stacks = new Set(bars.map((s) => s.stack).filter(Boolean))
+ return bars.filter((s) => !s.stack).length + stacks.size
+}
+
+const plotWidthOf = (
+ option: EChartsOption,
+ containerWidthPx: number,
+): number => {
+ const grid = firstOf(option.grid) as
+ | { left?: number; right?: number }
+ | undefined
+ const yAxisCount = Array.isArray(option.yAxis) ? option.yAxis.length : 1
+ return (
+ containerWidthPx -
+ (grid?.left ?? 0) -
+ (grid?.right ?? 0) -
+ Y_AXIS_LABEL_WIDTH_PX * yAxisCount
+ )
+}
+
+// Wheel zoom arms while marks are merely tight — this many times the readable
+// floor — since it costs no plot space; the slider waits for the floor itself.
+export const WHEEL_ZOOM_HEADROOM = 3
+
+export type ChartZoomDensity = {
+ slider: boolean
+ wheel: boolean
+}
+
+export const chartZoomDensity = (
+ option: EChartsOption,
+ containerWidthPx: number,
+): ChartZoomDensity => {
+ const plotWidth = plotWidthOf(option, containerWidthPx)
+ if (plotWidth <= 0) return { slider: false, wheel: false }
+
+ const series = seriesOf(option)
+ const bars = series.filter((s) => s.type === "bar")
+ const candles = series.filter((s) => s.type === "candlestick")
+ const points = series.filter((s) => s.type === "line" || s.type === "scatter")
+ const xAxis = firstOf(option.xAxis) as { data?: unknown[] } | undefined
+ const categorySlots = Array.isArray(xAxis?.data) ? xAxis.data.length : 0
+ const bandSlots = (list: SeriesSpec[]) =>
+ Math.max(categorySlots, maxDataLength(list))
+
+ const density = { slider: false, wheel: false }
+ const include = (slots: number, fillRatio: number, minPx: number) => {
+ if (slots <= 0) return
+ const markPx = (plotWidth / slots) * fillRatio
+ density.slider ||= markPx < minPx
+ density.wheel ||= markPx < minPx * WHEEL_ZOOM_HEADROOM
+ }
+ if (bars.length > 0) {
+ include(bandSlots(bars), barFillRatio(barColumns(bars)), MIN_MARK_PX)
+ }
+ if (candles.length > 0) {
+ include(bandSlots(candles), CANDLE_FILL_RATIO, MIN_MARK_PX)
+ }
+ include(maxDataLength(points), 1, MIN_POINT_PX)
+ return density
+}
diff --git a/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts
index fc05af3c3..bbf5ba702 100644
--- a/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts
+++ b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts
@@ -95,6 +95,19 @@ describe("resultsEquivalent", () => {
expect(resultsEquivalent(a, b)).toBe(true)
})
+ it("ignores surrounding whitespace but not inner whitespace or keyword casing", () => {
+ // Given results whose queries differ only around the statement, and
+ // results whose queries differ inside it
+ const base = [dql([{ name: "x", type: "INT" }], [[1]], "select 1 as x")]
+ const padded = [dql([{ name: "x", type: "INT" }], [[1]], " select 1 as x;")]
+ const recased = [dql([{ name: "x", type: "INT" }], [[1]], "SELECT 1 AS x")]
+
+ // When they are compared
+ // Then only the padded query is the same statement
+ expect(resultsEquivalent(base, padded)).toBe(true)
+ expect(resultsEquivalent(base, recased)).toBe(false)
+ })
+
it("returns false when result counts differ", () => {
const a = dql([{ name: "x", type: "INT" }], [[1]])
const b = { ...a, count: 2 }
@@ -246,6 +259,15 @@ describe("resultMatchesQueries", () => {
).toBe(true)
})
+ it("rejects a change of internal whitespace or keyword casing", () => {
+ // Given a result produced by the statement in one presentation
+ // When the cell now carries the same statement reformatted
+ // Then it does not match — the chart re-fetches
+ expect(
+ resultMatchesQueries(cellResult(["select 1\nas x"]), ["SELECT 1 AS x"]),
+ ).toBe(false)
+ })
+
it("rejects on statement-count mismatch", () => {
// Given a single-statement result
const result = cellResult(["select a"])
@@ -392,13 +414,18 @@ describe("toChartResult", () => {
expect(toChartResult(running, ["select 1"])).toEqual({ kind: "pending" })
})
- it("settles a matching result with its chartable rows and timestamp", () => {
- const settled = toChartResult(settledResult("select 1"), ["select 1"])
+ it("settles a matching result with its chartable rows", () => {
+ // Given a settled result whose statement matches the cell
+ const result = settledResult("select 1")
+
+ // When it is converted for the chart
+ const settled = toChartResult(result, ["select 1"])
+
+ // Then it settles with one chartable row set and no error
expect(settled.kind).toBe("settled")
if (settled.kind === "settled") {
expect(settled.results).toHaveLength(1)
expect(settled.hadError).toBe(false)
- expect(settled.timestamp).toBe(42)
}
})
diff --git a/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts
index d8eb35ec9..dc8e76e63 100644
--- a/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts
+++ b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts
@@ -1,7 +1,7 @@
import type { QueryExecResult } from "../../../../hooks/useQueryExecution"
import type { CellResult, SingleQueryResult } from "../../../../store/notebook"
import type { ColumnDefinition } from "../../../../utils/questdb/types"
-import { hasPendingResult } from "../notebookUtils"
+import { hasPendingResult } from "../statementIdentity"
import { normalizeQueryText } from "../../Monaco/utils"
import type { ChartConfig, QueryChart } from "../CellChart/chartTypes"
import type {
@@ -84,7 +84,6 @@ export type ChartResult =
kind: "settled"
results: QueryExecResult[]
hadError: boolean
- timestamp: number
}
export const toChartResult = (
@@ -98,7 +97,6 @@ export const toChartResult = (
kind: "settled",
results: successResults(result.results.map(toExecResult)),
hadError: result.results.some((r) => r.type === "error"),
- timestamp: result.timestamp,
}
}
diff --git a/src/scenes/Editor/Notebook/DrawCanvas/index.tsx b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx
index d4a6dc761..4fde51a4d 100644
--- a/src/scenes/Editor/Notebook/DrawCanvas/index.tsx
+++ b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx
@@ -10,16 +10,20 @@ import {
} from "../CellChart/ChartRenderer"
import { ChartSettingsDrawer } from "../CellChart/ChartSettingsDrawer"
import { resolveDraw, toChartResult } from "./drawCanvasUtils"
+import { Button } from "../../../../components/Button"
import { toast } from "../../../../components/Toast"
import { CircleNotchSpinner } from "../../Monaco/icons"
import { eventBus } from "../../../../modules/EventBus"
import { EventType } from "../../../../modules/EventBus/types"
-import { useCellFetchState } from "../cellRefresh/CellRefreshContext"
import {
- deriveChartLoading,
+ shallowEqual,
+ useCellFetchSelector,
+} from "../cellRefresh/CellRefreshContext"
+import {
pendingCellFetchState,
+ type CellFetchState,
} from "../cellRefresh/cellRefreshEngine"
-import { useCellResultStatus } from "../resultHydration/CellResultHydrationContext"
+import { useChartLoading } from "../cells/useChartLoading"
import {
getChartZoom,
setChartZoom,
@@ -27,9 +31,30 @@ import {
import { trackEvent } from "../../../../modules/ConsoleEventTracker"
import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events"
import type { ChartSettingsTelemetry } from "../CellChart/chartSettingsTelemetry"
+import { signalUserEdit } from "../../../../utils/notebooks/notebookAIBridge"
+import { useNotebookBufferId } from "../NotebookProvider"
+import { PaneEmptyState } from "../PaneEmptyState"
const NO_RESULTS: QueryExecResult[] = []
+type DrawState = Pick<
+ CellFetchState,
+ "queries" | "queriesKey" | "settledKey" | "classifyBlock" | "fetchCancelled"
+>
+
+// Leaves out `fetching`: the canvas reads it only through `loading`, so a
+// refresh of a drawn chart does not re-render it.
+const selectDrawState = (
+ state: CellFetchState | undefined,
+): DrawState | undefined =>
+ state && {
+ queries: state.queries,
+ queriesKey: state.queriesKey,
+ settledKey: state.settledKey,
+ classifyBlock: state.classifyBlock,
+ fetchCancelled: state.fetchCancelled,
+ }
+
const notebookChartSettingsTelemetry: ChartSettingsTelemetry = {
onCancel: (method) => {
void trackEvent(ConsoleEvent.NOTEBOOK_CHART_SETTINGS_CANCEL, { method })
@@ -65,13 +90,9 @@ const Canvas = styled.div`
background: ${({ theme }) => theme.color.surfaceInset};
`
-const EmptyState = styled.div`
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: ${({ theme }) => theme.color.contentSecondary};
- font-size: ${({ theme }) => theme.fontSize.sm};
+const CancelledState = styled(PaneEmptyState)`
+ flex-direction: column;
+ gap: 1rem;
`
// Announces loading/empty transitions. Stays mounted with only its text
@@ -90,13 +111,19 @@ type Props = {
cell: NotebookCell
isFocused: boolean
onConfigChange: (config: ChartConfig) => void
+ onRetryUnmountWhileFocused: () => void
}
export const DrawCanvas: React.FC = ({
cell,
isFocused,
onConfigChange,
+ onRetryUnmountWhileFocused,
}) => {
+ const bufferId = useNotebookBufferId()
+ const drawState = useCellFetchSelector(cell.id, selectDrawState, shallowEqual)
+ const { loading } = useChartLoading(cell)
+
const [settingsOpen, setSettingsOpen] = useState(false)
const [zoomStart, setZoomStart] = useState(
() => getChartZoom(cell.id)?.start ?? 0,
@@ -108,11 +135,9 @@ export const DrawCanvas: React.FC = ({
const configAtSettingsOpenRef = useRef(undefined)
const chartRendererRef = useRef(null)
- const fetchState = useCellFetchState(cell.id)
- const resultStatus = useCellResultStatus(cell.id)
- const state = useMemo(
- () => fetchState ?? pendingCellFetchState(cell.value),
- [fetchState, cell.value],
+ const state: DrawState = useMemo(
+ () => drawState ?? pendingCellFetchState(cell.value),
+ [drawState, cell.value],
)
const { queries, queriesKey, settledKey, classifyBlock } = state
const chartResult = useMemo(
@@ -149,6 +174,15 @@ export const DrawCanvas: React.FC = ({
setSettingsOpen(true)
}, [cell.chartConfig])
+ // The refresh replaces the cancelled state, and Retry with it; a focused
+ // Retry hands its focus on first, like the Stop button does on unmount.
+ const handleRetry = (e: React.MouseEvent) => {
+ if (e.currentTarget.matches(":focus-visible")) onRetryUnmountWhileFocused()
+ signalUserEdit(bufferId)
+ void trackEvent(ConsoleEvent.NOTEBOOK_CELL_DRAW)
+ eventBus.publish(EventType.NOTEBOOK_CELL_REFRESH_CHART, { cellId: cell.id })
+ }
+
const option = useMemo(
() => buildEchartsOption(resolution.chart, resolution.renderQueries),
[resolution],
@@ -157,13 +191,7 @@ export const DrawCanvas: React.FC = ({
const empty =
classifyBlock !== null || queries.length === 0 || results.length === 0
const settledForCurrentQueries = settledKey === queriesKey
- // Initial load (snapshot hydration or first fetch) with nothing to show yet:
- // a spinner replaces the chart area until data lands.
- const { loading } = deriveChartLoading(
- state,
- chartResult,
- resultStatus === "loading",
- )
+ const cancelled = state.fetchCancelled && results.length === 0 && !loading
let emptyMessage: string
if (classifyBlock?.kind === "write") {
emptyMessage = `Cannot draw a write query ('${classifyBlock.queryType}'). Switch to Run mode to execute this SQL.`
@@ -207,16 +235,34 @@ export const DrawCanvas: React.FC = ({
return (
- {loading ? "Loading chart data" : empty ? emptyMessage : ""}
+ {loading
+ ? "Loading chart data"
+ : cancelled
+ ? "Chart loading was cancelled"
+ : empty
+ ? emptyMessage
+ : ""}
{loading ? (
-
+
-
+
+ ) : cancelled ? (
+
+ Chart loading was cancelled.
+
+
) : empty ? (
- {emptyMessage}
+ {emptyMessage}
) : (
-