Skip to content
Open
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
2 changes: 1 addition & 1 deletion r/area-chart.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion r/bar-chart.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion r/pie-chart.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion r/radar-chart.json

Large diffs are not rendered by default.

26 changes: 24 additions & 2 deletions registry/dither-kit/bar-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function BarCanvas() {
}

let raf = 0
let visible = false
let animStart = 0
let lastProg = -1
let lastRevision = state.current.revision
Expand All @@ -138,6 +139,10 @@ export function BarCanvas() {
let lastHover: number | null | undefined = Symbol() as never

const draw = (now: number) => {
if (!visible) {
raf = 0
return
}
raf = requestAnimationFrame(draw)
const s = state.current
if (!s.ready) return
Expand Down Expand Up @@ -191,8 +196,25 @@ export function BarCanvas() {
needsFill = false
}

raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
if (typeof IntersectionObserver === "undefined") {
visible = true
raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
}
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting
if (visible) {
if (!raf) raf = requestAnimationFrame(draw)
} else if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
})
io.observe(canvas)
return () => {
io.disconnect()
cancelAnimationFrame(raf)
}
}, [cols, rows, width])

const bloomActive = ctx.bloomOnHover
Expand Down
26 changes: 24 additions & 2 deletions registry/dither-kit/cartesian-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function startCartesianLoop({
}

let raf = 0
let visible = false
let tick = 0
let last = 0
let animStart = 0
Expand All @@ -115,6 +116,10 @@ function startCartesianLoop({
let lastSelected: string | null | undefined = Symbol() as never

const draw = (now: number) => {
if (!visible) {
raf = 0
return
}
raf = requestAnimationFrame(draw)
const s = state.current
if (!s.ready) return
Expand Down Expand Up @@ -282,8 +287,25 @@ function startCartesianLoop({
}
}

raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
if (typeof IntersectionObserver === "undefined") {
visible = true
raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
}
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting
if (visible) {
if (!raf) raf = requestAnimationFrame(draw)
} else if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
})
io.observe(canvas)
return () => {
io.disconnect()
cancelAnimationFrame(raf)
}
}

/**
Expand Down
26 changes: 24 additions & 2 deletions registry/dither-kit/pie-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function PieCanvas() {
const animate = state.current.animate && !reduce
const duration = state.current.animationDuration
let raf = 0
let visible = false
let animStart = 0
let lastProg = -1
let lastRevision = state.current.revision
Expand Down Expand Up @@ -129,6 +130,10 @@ export function PieCanvas() {
}

const draw = (now: number) => {
if (!visible) {
raf = 0
return
}
raf = requestAnimationFrame(draw)
const s = state.current
if (!s.ready || !s.pie) return
Expand Down Expand Up @@ -187,8 +192,25 @@ export function PieCanvas() {
needsFill = false
}

raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
if (typeof IntersectionObserver === "undefined") {
visible = true
raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
}
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting
if (visible) {
if (!raf) raf = requestAnimationFrame(draw)
} else if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
})
io.observe(canvas)
return () => {
io.disconnect()
cancelAnimationFrame(raf)
}
}, [cols, rows, width, height])

const bloom = bloomLayerStyle(
Expand Down
26 changes: 24 additions & 2 deletions registry/dither-kit/radar-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function RadarCanvas() {
const animate = state.current.animate && !reduce
const duration = state.current.animationDuration
let raf = 0
let visible = false
let animStart = 0
let lastProg = -1
let lastRevision = state.current.revision
Expand Down Expand Up @@ -154,6 +155,10 @@ export function RadarCanvas() {
}

const draw = (now: number) => {
if (!visible) {
raf = 0
return
}
raf = requestAnimationFrame(draw)
const s = state.current
if (!s.ready || !s.radar) return
Expand Down Expand Up @@ -203,8 +208,25 @@ export function RadarCanvas() {
needsFill = false
}

raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
if (typeof IntersectionObserver === "undefined") {
visible = true
raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
}
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting
if (visible) {
if (!raf) raf = requestAnimationFrame(draw)
} else if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
})
io.observe(canvas)
return () => {
io.disconnect()
cancelAnimationFrame(raf)
}
}, [cols, rows, width, height])

const bloom = bloomLayerStyle(
Expand Down
100 changes: 100 additions & 0 deletions tests/offscreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { render } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

vi.mock("../registry/dither-kit/use-chart-dimensions", () => ({
useChartDimensions: () => ({
ref: { current: null },
size: { width: 600, height: 240 },
}),
}))

import { Area } from "../registry/dither-kit/area"
import { AreaChart } from "../registry/dither-kit/area-chart"

const data = [
{ m: "Jan", v: 1 },
{ m: "Feb", v: 2 },
]
const config = { v: { label: "V", color: "blue" as const } }

type IOInstance = {
cb: (entries: Array<{ isIntersecting: boolean }>) => void
targets: Element[]
}
let observers: IOInstance[] = []

describe("offscreen render pause", () => {
const orig: Record<string, unknown> = {}
let rafSpy: ReturnType<typeof vi.spyOn>
let cafSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
observers = []
orig.IO = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
orig.RO = (globalThis as { ResizeObserver?: unknown }).ResizeObserver
orig.getContext = HTMLCanvasElement.prototype.getContext
;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver =
class {
cb: IOInstance["cb"]
targets: Element[] = []
constructor(cb: IOInstance["cb"]) {
this.cb = cb
observers.push(this)
}
observe(el: Element) {
this.targets.push(el)
}
unobserve() {}
disconnect() {}
}
;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
HTMLCanvasElement.prototype.getContext = (() => ({})) as unknown as typeof HTMLCanvasElement.prototype.getContext
if (typeof globalThis.requestAnimationFrame === "undefined") {
globalThis.requestAnimationFrame = (() => 1) as typeof requestAnimationFrame
}
if (typeof globalThis.cancelAnimationFrame === "undefined") {
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
}
rafSpy = vi
.spyOn(globalThis, "requestAnimationFrame")
.mockReturnValue(1 as unknown as number)
cafSpy = vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {})
})

afterEach(() => {
rafSpy.mockRestore()
cafSpy.mockRestore()
;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = orig.IO
;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = orig.RO
HTMLCanvasElement.prototype.getContext =
orig.getContext as typeof HTMLCanvasElement.prototype.getContext
})

it("watches the canvas but doesn't start the loop until it is visible", () => {
render(
<AreaChart data={data} config={config}>
<Area dataKey="v" />
</AreaChart>
)
expect(observers.length).toBeGreaterThan(0)
expect(observers[0].targets.length).toBeGreaterThan(0)
expect(rafSpy).not.toHaveBeenCalled()
})

it("starts on intersect and stops when it leaves the viewport", () => {
render(
<AreaChart data={data} config={config}>
<Area dataKey="v" />
</AreaChart>
)
const io = observers[0]
io.cb([{ isIntersecting: true }])
expect(rafSpy).toHaveBeenCalledTimes(1)
io.cb([{ isIntersecting: false }])
expect(cafSpy).toHaveBeenCalled()
})
})