Utilization heatmap
A support centre where each queue has a capacity and calls overlap through the day. The row background is tinted by how loaded the queue is each hour - light when quiet, hot near capacity, red when over. A glance shows where the pressure is. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Scheduler). See the SvGrid documentation for the full API.
About this example
A utilization heatmap in the Svelte 5 scheduler: a support centre where each queue has a capacity from its team size and calls overlap through the day. utilizationHeatmap tints the whole row background by how loaded the queue is each hour, light when quiet, hot near capacity and red when over, with collisionMode stack laying overlapping calls out, so a glance shows where the pressure is; the Table shows the same rows.
A support centre where each queue has a capacity (its team size) and calls overlap on it through the day. The row BACKGROUND is tinted by how loaded the queue is each hour - light when quiet, hot when near capacity, red when over. Distinct from the histogram: it colours the whole lane, so a glance shows where the pressure is. Toggle to the Table - same grid rows. Renderer: @svgrid/enterprise.
Imports, features and API used
Imports: @svgrid/grid, @svgrid/enterprise
Columns: title (Call), queue (Queue), start (Start), end (End)
Frequently asked questions
How is load computed per hour?
The scheduler counts overlapping events per resource per hour and divides by the resource's capacity from capacityField, mapping the ratio to the tint.
How is this different from the histogram?
The histogram is a bar chart under each row; the heatmap colours the row itself, so the whole timeline reads as a pressure map without extra height.
Can I change the colours?
Yes. utilizationHeatmap takes the colour stops for quiet, near capacity and over, and the tint is applied through the theme so it works in dark mode.
Related documentation
Related articles
- Conditional Formatting - Color Cells by Their Value - Four rule types, one prop - add heatmaps, data bars, icon sets, and threshold highlights to any SvGrid column without custom cell renderers.
Source code (394-scheduler-heatmap.svelte)
<script lang="ts">
/**
* 394. Utilization heatmap (Enterprise Scheduler Pro)
* ---------------------------------------------------
* A support centre where each queue has a capacity (its team size) and calls
* overlap on it through the day. The row BACKGROUND is tinted by how loaded the
* queue is each hour - light when quiet, hot when near capacity, red when over.
* Distinct from the histogram: it colours the whole lane, so a glance shows where
* the pressure is. Toggle to the Table - same grid rows. Renderer: @svgrid/enterprise.
*/
import { SvGrid, type ColumnDef, type SchedulerResource, type SchedulerEventMoveEvent, type SchedulerEventResizeEvent } from '@svgrid/grid'
import { enableSchedulerView, type SchedulerProConfig } from '@svgrid/enterprise'
enableSchedulerView()
type Queue = SchedulerResource & { cap: number }
const queues: Queue[] = [
{ id: 'front', title: 'Frontline (4)', color: '#4f46e5', cap: 4 },
{ id: 'billing', title: 'Billing (2)', color: '#0891b2', cap: 2 },
{ id: 'tech', title: 'Tech support (3)', color: '#16a34a', cap: 3 },
]
const queueColor = Object.fromEntries(queues.map((q) => [q.id, q.color!]))
type Call = { id: string; title: string; queue: string; start: string; end: string; color: string }
const pad = (n: number) => String(n).padStart(2, '0')
const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
const today = new Date(); today.setHours(0, 0, 0, 0)
const at = (h: number, m = 0) => { const d = new Date(today); d.setHours(h, m, 0, 0); return iso(d) }
let n = 0
const mk = (queue: string, sh: number, sm: number, eh: number, em: number): Call => ({ id: `c${++n}`, title: `Call ${n}`, queue, start: at(sh, sm), end: at(eh, em), color: queueColor[queue] ?? '#64748b' })
let rows = $state<Call[]>([
// Frontline: morning surge (5 calls touching 9-11 -> over its cap of 4), then eases.
mk('front', 9, 0, 10, 30), mk('front', 9, 0, 11, 0), mk('front', 9, 30, 11, 0), mk('front', 9, 30, 10, 30), mk('front', 9, 15, 10, 45),
mk('front', 10, 0, 11, 0),
mk('front', 13, 0, 14, 0), mk('front', 13, 30, 14, 30), mk('front', 15, 0, 16, 0),
// Billing: two overlapping late morning (at cap 2), one afternoon.
mk('billing', 10, 30, 12, 0), mk('billing', 11, 0, 12, 30), mk('billing', 14, 0, 15, 0),
// Tech: steady load around cap 3 midday.
mk('tech', 9, 30, 11, 0), mk('tech', 10, 0, 11, 30), mk('tech', 10, 30, 12, 0), mk('tech', 13, 0, 14, 30), mk('tech', 15, 0, 16, 30),
])
const columns: ColumnDef<any, Call>[] = [
{ field: 'title', header: 'Call', editorType: 'text', width: 130 },
{ field: 'queue', header: 'Queue', editorType: 'list', editorOptions: queues.map((q) => ({ value: q.id, label: q.title ?? q.id, color: q.color })), width: 150 },
{ field: 'start', header: 'Start', editorType: 'datetime', width: 150 },
{ field: 'end', header: 'End', editorType: 'datetime', width: 150 },
]
function onEventMove(e: SchedulerEventMoveEvent<Call>) { e.row.start = iso(e.start); e.row.end = iso(e.end); if (e.toResource != null) e.row.queue = e.toResource }
function onEventResize(e: SchedulerEventResizeEvent<Call>) { e.row.start = iso(e.start); e.row.end = iso(e.end) }
let view = $state<'timeline' | 'table'>('timeline')
// `utilizationHeatmap.capacityField` is declared as `keyof SchedulerResource`, so
// it does not know about a resource type's own extra fields. The renderer reads
// any numeric field off the resource, so widen the key to this demo's Queue.
type QueueSchedulerConfig = Omit<SchedulerProConfig<any, Call>, 'utilizationHeatmap'> & {
utilizationHeatmap?: boolean | { capacityField?: keyof Queue & string }
}
const schedulerCfg: QueueSchedulerConfig = {
startField: 'start', endField: 'end', titleField: 'title', colorField: 'color',
resourceField: 'queue', resources: queues,
utilizationHeatmap: { capacityField: 'cap' },
views: ['timelineDay'], initialView: 'timelineDay',
businessHours: { start: 8, end: 18 }, dayStartHour: 8, dayEndHour: 18, timelineTickMinWidth: 120, timelineLaneHeight: 22,
collisionMode: 'stack',
editable: true, tooltip: true, drawer: true,
onEventMove, onEventResize,
}
</script>
<section class="hm">
<header class="hm-head">
<div class="hm-title">
<strong>Support load</strong>
<span class="hm-sub">Row background shows each queue's utilization per hour - hot near capacity, red when over</span>
</div>
<div class="hm-seg" role="tablist" aria-label="View">
<button class="hm-seg-btn" role="tab" aria-selected={view === 'timeline'} class:hm-on={view === 'timeline'} onclick={() => (view = 'timeline')}>Timeline</button>
<button class="hm-seg-btn" role="tab" aria-selected={view === 'table'} class:hm-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>
</div>
</header>
<div class="hm-body">
{#if view === 'timeline'}
<SvGrid
columnResize data={rows} columns={columns} getRowId={(r) => r.id} containerHeight="100%" scheduler={schedulerCfg} />
{:else}
<SvGrid
columnResize data={rows} columns={columns} getRowId={(r) => r.id} containerHeight="100%" editable />
{/if}
</div>
</section>
<style>
.hm { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 12px; overflow: hidden; background: var(--sg-bg, #fff); }
.hm-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }
.hm-title { display: flex; flex-direction: column; gap: 2px; }
.hm-sub { font-size: 0.78rem; color: var(--sg-muted, #6b7280); }
.hm-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; overflow: hidden; }
.hm-seg-btn { border: 0; background: var(--sg-bg, #fff); color: var(--sg-muted, #6b7280); font: inherit; font-size: 0.82rem; padding: 5px 12px; cursor: pointer; }
.hm-seg-btn.hm-on { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); }
.hm-body { flex: 1 1 auto; min-height: 0; padding: 8px; }
</style>More Scheduler examples
- Scheduler / calendar mode - The same <SvGrid>, same data + columns, rendered as a full calendar by setting one `scheduler` prop: rows become events bucketed by time. Switch Month / Week / Day / Agenda in the toolbar; drag an event to reschedule, drag its edge to resize, click to edit in a drawer. onEventMove / onEventResize write the new times back onto your row. Toggle Calendar / Table to see it is one grid. The calendar renderer ships in @svgrid/enterprise.
- Timeline views (Day / Week) - A horizontal resource timeline: teams are rows, time runs left→right on a scrollable axis, work items are bars sized to real durations (half-day blocks up to multi-day tasks). Switch Day (hour ticks) or Week (day ticks); drag a bar to re-time it, to another team's row to reassign, or an edge to resize. Click / drag empty space to select a range (arrows move, Shift+arrows extend, Enter creates); Ctrl-click bars to multi-select. Bar fill = team, left strip = status.
- Selecting cells & events - Drag empty slots to create events (drag across days for a rectangle - one per day); Ctrl/Cmd-click events to multi-select (Shift-click a range), drag the set to move it together, or press Delete to remove. Enterprise scheduler view.
- Horizon - Outlook-style calendar app - A full calendar client on the Scheduler + SvDockManager: NavPane module rail + mini date-picker + calendar toggles, and a dockable workspace (drag / resize / float panes) with the calendar over an Upcoming grid and an Inbox. Recurring events with per-occurrence edits, a second time-zone ruler, iCal import/export and undo.
- Meridian Clinic - appointment scheduling - A front-desk appointment console: providers with their own hours as day columns, a drag-in patient waitlist, a utilization gauge, and a live patient-flow pipeline (Waiting / Checked in / In room / Completed) that updates as you work.