Optimizing Konva Performance with Viewport Culling

Introduction

In the previous article, I guided you on how to use Konva to create a simple WhiteBoard application; now we will continue to dive deeper into the performance issue when having to show too many elements on the UI to see how to solve it.

Here you need to understand that to draw a single element on the browser, including colors, shadows, animations, and enabling event triggers, etc., all consume a corresponding amount of CPU/GPU; if you only show a small number of elements it is negligible, but if this number is large enough and your machine's configuration is not sufficient to meet the demand, it will cause lagging or make the app unusable.

In this article, I will guide you on optimizing performance for a product to show 1000 elements on the UI while still operating smoothly using mathematical knowledge of Coordinate Transformation, also known as the Viewport Culling mechanism.

Prerequisites

If you don't know about Konva yet, please see the previous article to grasp some basic information about this package before continuing.


Detail

First, create the file type.ts to define the necessary types.

export interface ShapeData {
id: string
type: string
x: number
y: number
size: number
color: string
}

export interface ViewRect {
x1: number
y1: number
x2: number
y2: number
}

export interface ShapeItemProps {
item: ShapeData
image?: HTMLImageElement
onClick: (id: string) => void
}


Create the file utility.ts

import type {ShapeData} from './type'

export const WIDTH = window.innerWidth
export const HEIGHT = window.innerHeight
export const TOTAL_OBJECTS = 1000
export const CANVAS_VIRTUAL_SIZE = 5000
export const IMAGE_URL = 'https://konvajs.org/assets/lion.png'

export const generateData = (): ShapeData[] => {
const types = ['rect', 'square', 'diamond', 'circle', 'triangle', 'image']
return Array.from({length: TOTAL_OBJECTS}, (_, i) => ({
id: `obj-${i}`,
type: types[Math.floor(Math.random() * types.length)],
x: Math.random() * CANVAS_VIRTUAL_SIZE - CANVAS_VIRTUAL_SIZE / 2,
y: Math.random() * CANVAS_VIRTUAL_SIZE - CANVAS_VIRTUAL_SIZE / 2,
size: 40 + Math.random() * 40,
color: `hsl(${Math.random() * 360}, 70%, 60%)`,
}))
}

The code above is used to establish configuration constants for the application (screen size, object quantity) and the generateData function to create mock data for 1000 objects with random attributes of position, shape, and color within a virtual space with a size of 5000px.


Create the file ShapeItem.tsx

import {memo} from 'react'
import {Circle, Image, Rect, RegularPolygon} from 'react-konva'
import type {ShapeItemProps} from './type'

export const ShapeItem = memo(({item, image, onClick}: ShapeItemProps) => {
const {type, x, y, size, color, id} = item
const commonProps = {
x,
y,
id,
fill: color,
onClick: () => onClick(id),
perfectDrawEnabled: false,
shadowForStrokeEnabled: false,
}
switch (type) {
case 'rect':
return <Rect {...commonProps} width={size * 1.2} height={size} />
case 'square':
return <Rect {...commonProps} width={size} height={size} />
case 'circle':
return <Circle {...commonProps} radius={size / 2} />
case 'diamond':
return <RegularPolygon {...commonProps} sides={4} radius={size} />
case 'triangle':
return <RegularPolygon {...commonProps} sides={3} radius={size} />
case 'image':
return <Image {...commonProps} image={image} width={size} height={size} />
default:
return null
}
})

This is a sub-component wrapped in memo to avoid unnecessary re-renders. It is responsible for drawing specific shapes (rectangles, circles, polygons, images) based on the type attribute of the input data. At the same time, it optimizes performance by turning off costly drawing features like perfectDrawEnabled and shadowForStrokeEnabled.


Create the file OptimizedCanvas.tsx

import Konva from 'konva'
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Layer, Stage} from 'react-konva'
import useImage from 'use-image'
import {ShapeItem} from './ShapeItem'
import type {ShapeData, ViewRect} from './type'
import {generateData, HEIGHT, IMAGE_URL, TOTAL_OBJECTS, WIDTH} from './utility'

const OptimizedCanvas: React.FC = () => {
const [data] = useState<ShapeData[]>(generateData)
const [img] = useImage(IMAGE_URL)

const stageRef = useRef<Konva.Stage>(null)
const [viewRect, setViewRect] = useState<ViewRect>({
x1: -WIDTH,
y1: -HEIGHT,
x2: WIDTH,
y2: HEIGHT,
})

const updateViewport = useCallback(() => {
const stage = stageRef.current
if (!stage) return

const scale = stage.scaleX()
const x = stage.x()
const y = stage.y()

setViewRect({
x1: -x / scale,
y1: -y / scale,
x2: (-x + WIDTH) / scale,
y2: (-y + HEIGHT) / scale,
})
}, [])

useEffect(() => {
updateViewport()
}, [updateViewport])

const visibleItems = useMemo(() => {
const padding = 150
return data.filter(
item =>
item.x + padding > viewRect.x1 &&
item.x - padding < viewRect.x2 &&
item.y + padding > viewRect.y1 &&
item.y - padding < viewRect.y2
)
}, [data, viewRect])

const handleObjectClick = useCallback((id: string) => {
console.log('Clicked on object ID:', id)
}, [])

const handleWheel = useCallback(
(e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault()
const stage = stageRef.current
if (!stage) return

const oldScale = stage.scaleX()
const pointer = stage.getPointerPosition()
if (!pointer) return

const scaleBy = 1.1
const newScale =
e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy

stage.scale({x: newScale, y: newScale})

const newPos = {
x: pointer.x - (pointer.x - stage.x()) * (newScale / oldScale),
y: pointer.y - (pointer.y - stage.y()) * (newScale / oldScale),
}
stage.position(newPos)
updateViewport()
},
[updateViewport]
)

return (
<div className="relative w-full h-screen bg-[#121212] overflow-hidden select-none">
<Stage
width={WIDTH}
height={HEIGHT}
draggable
ref={stageRef}
onDragMove={updateViewport}
onWheel={handleWheel}
className="cursor-grab active:cursor-grabbing"
>
<Layer>
{visibleItems.map(item => (
<ShapeItem
key={item.id}
item={item}
image={img}
onClick={handleObjectClick}
/>
))}
</Layer>
</Stage>

<div className="absolute top-4 left-4 p-4 bg-black/70 backdrop-blur-md rounded-xl border border-white/10 pointer-events-none transition-all">
<h3 className="text-blue-400 font-bold text-xs uppercase tracking-wider mb-2">
Performance Monitor
</h3>
<div className="space-y-1 text-sm text-gray-300">
<div className="flex justify-between gap-8">
<span>Virtual Objects:</span>
<span className="font-mono text-white font-bold">
{TOTAL_OBJECTS.toLocaleString()}
</span>
</div>
<div className="flex justify-between gap-8">
<span>Active Rendered:</span>
<span className="font-mono text-green-400 font-bold">
{visibleItems.length}
</span>
</div>
<div className="flex justify-between gap-8">
<span>Current Zoom:</span>
<span className="font-mono text-yellow-400 font-bold">
{stageRef.current
? Math.round(stageRef.current.scaleX() * 100)
: 100}
%
</span>
</div>
</div>
<div className="mt-3 pt-3 border-t border-white/5 text-[10px] text-gray-500 italic">
Use Scroll to Zoom • Drag to Explore Space
</div>
</div>
</div>
)
}

export default OptimizedCanvas

This is the main component implementing the Viewport Culling mechanism. It calculates the space area currently displayed on the screen (viewport) through the updateViewport function whenever the user drags or zooms. Then, it uses useMemo to filter the data list, keeping and rendering only those objects within the visible range, significantly reducing the load on the GPU when only having to draw a small portion instead of all 1000 objects.


The result of use will be as follows when you zoom to show all elements.


When you zoom in smaller, it will only show those elements within the viewport and still be able to click on the elements for interaction.


Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Using Kafka with Docker and NodeJS

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Kubernetes Practice Series

NodeJS Practice Series

Sitemap