Are we not in the position to DIY and build your own Gantt chart?
Give Business Charts or Business Text a try - you can pretty much customise however you like with HTML, JS, HBS, and CSS. All codes are on the panel itself - skips all the traditional SDLC lifecycle: no compiling, no shipping, no hosting required. Perfect for ad-hoc, lightweight DIY solutions like you’re use-case here?
With LLM this shouldn’t be impossible to anyone I feel. Here’s a 10 minute session with Claude as a sample in Grafana Play:
Here’s the smaple code blocks if anyone wants it:
Grafana TestData - CSV Content
task,group,status,start,end,progress
Stakeholder interviews,Discovery,Complete,2026-06-01T00:00:00Z,2026-06-19T00:00:00Z,100
Current-state assessment,Discovery,Complete,2026-06-08T00:00:00Z,2026-06-26T00:00:00Z,100
Requirements workshop,Discovery,Complete,2026-06-22T00:00:00Z,2026-07-03T00:00:00Z,100
Success criteria sign-off,Discovery,Complete,2026-07-06T00:00:00Z,2026-07-10T00:00:00Z,100
Vendor shortlist,Discovery,Complete,2026-07-06T00:00:00Z,2026-07-24T00:00:00Z,100
Solution architecture draft,Design,Complete,2026-07-13T00:00:00Z,2026-08-07T00:00:00Z,100
Data model design,Design,Complete,2026-07-20T00:00:00Z,2026-08-14T00:00:00Z,100
Interface specification,Design,Complete,2026-08-03T00:00:00Z,2026-08-21T00:00:00Z,100
Non-functional requirements,Design,Complete,2026-08-10T00:00:00Z,2026-08-28T00:00:00Z,100
Architecture review board,Design,In Progress,2026-08-24T00:00:00Z,2026-09-11T00:00:00Z,65
Design sign-off,Design,In Progress,2026-09-01T00:00:00Z,2026-09-18T00:00:00Z,30
Environment provisioning,Platform,Complete,2026-08-03T00:00:00Z,2026-08-28T00:00:00Z,100
Network and firewall changes,Platform,In Progress,2026-08-17T00:00:00Z,2026-09-11T00:00:00Z,80
Identity provider setup,Platform,In Progress,2026-08-24T00:00:00Z,2026-09-18T00:00:00Z,55
Base image hardening,Platform,In Progress,2026-09-01T00:00:00Z,2026-09-25T00:00:00Z,35
Backup and restore config,Platform,Planned,2026-09-21T00:00:00Z,2026-10-09T00:00:00Z,0
Monitoring agents rollout,Platform,Planned,2026-09-28T00:00:00Z,2026-10-23T00:00:00Z,0
Capacity baseline,Platform,Planned,2026-10-12T00:00:00Z,2026-10-30T00:00:00Z,0
Source system inventory,Data,Complete,2026-08-10T00:00:00Z,2026-08-28T00:00:00Z,100
Extract pipeline build,Data,In Progress,2026-08-24T00:00:00Z,2026-09-25T00:00:00Z,45
Transform rules mapping,Data,At Risk,2026-09-07T00:00:00Z,2026-10-09T00:00:00Z,15
Reference data cleanup,Data,Blocked,2026-09-01T00:00:00Z,2026-09-30T00:00:00Z,20
Historical load - trial,Data,Planned,2026-10-05T00:00:00Z,2026-10-30T00:00:00Z,0
Reconciliation reporting,Data,Planned,2026-10-19T00:00:00Z,2026-11-13T00:00:00Z,0
Historical load - final,Data,Planned,2026-11-16T00:00:00Z,2026-12-04T00:00:00Z,0
API gateway configuration,Integration,In Progress,2026-08-31T00:00:00Z,2026-09-25T00:00:00Z,40
Ticketing system connector,Integration,Planned,2026-09-21T00:00:00Z,2026-10-23T00:00:00Z,0
Notification service hookup,Integration,Planned,2026-10-05T00:00:00Z,2026-10-30T00:00:00Z,0
Single sign-on integration,Integration,Blocked,2026-09-14T00:00:00Z,2026-10-09T00:00:00Z,10
Legacy adapter retirement,Integration,Planned,2026-11-30T00:00:00Z,2026-12-18T00:00:00Z,0
Core service scaffold,Build,Complete,2026-08-17T00:00:00Z,2026-09-02T00:00:00Z,100
Reporting module,Build,In Progress,2026-08-31T00:00:00Z,2026-10-09T00:00:00Z,35
Alerting rules engine,Build,At Risk,2026-09-07T00:00:00Z,2026-10-16T00:00:00Z,20
Self-service portal,Build,Planned,2026-09-28T00:00:00Z,2026-11-13T00:00:00Z,0
Mobile responsive views,Build,Planned,2026-10-26T00:00:00Z,2026-11-27T00:00:00Z,0
Bulk export tooling,Build,Planned,2026-11-09T00:00:00Z,2026-12-04T00:00:00Z,0
Test strategy,Testing,In Progress,2026-09-01T00:00:00Z,2026-09-18T00:00:00Z,50
Unit test coverage uplift,Testing,Planned,2026-09-21T00:00:00Z,2026-10-30T00:00:00Z,0
System integration testing,Testing,Planned,2026-11-02T00:00:00Z,2026-11-27T00:00:00Z,0
Performance and load testing,Testing,Planned,2026-11-16T00:00:00Z,2026-12-11T00:00:00Z,0
Failover and DR test,Testing,Planned,2026-12-07T00:00:00Z,2026-12-18T00:00:00Z,0
User acceptance testing,Testing,Planned,2027-01-05T00:00:00Z,2027-01-29T00:00:00Z,0
Defect triage and fixes,Testing,Planned,2026-11-09T00:00:00Z,2027-02-05T00:00:00Z,0
Threat model workshop,Security,Complete,2026-08-17T00:00:00Z,2026-08-28T00:00:00Z,100
Access control matrix,Security,In Progress,2026-09-01T00:00:00Z,2026-09-25T00:00:00Z,25
Penetration test,Security,Planned,2026-12-07T00:00:00Z,2026-12-18T00:00:00Z,0
Remediation window,Security,Planned,2027-01-04T00:00:00Z,2027-01-22T00:00:00Z,0
Compliance evidence pack,Security,Planned,2027-01-25T00:00:00Z,2027-02-12T00:00:00Z,0
Training material authoring,Enablement,Planned,2026-11-02T00:00:00Z,2026-12-04T00:00:00Z,0
Runbook and SOP drafting,Enablement,Planned,2026-11-30T00:00:00Z,2027-01-08T00:00:00Z,0
Admin training delivery,Enablement,Planned,2027-01-11T00:00:00Z,2027-01-22T00:00:00Z,0
End-user training delivery,Enablement,Planned,2027-01-25T00:00:00Z,2027-02-12T00:00:00Z,0
Pilot group go-live,Rollout,Planned,2027-02-15T00:00:00Z,2027-02-26T00:00:00Z,0
Hypercare period,Rollout,Planned,2027-02-15T00:00:00Z,2027-04-16T00:00:00Z,0
Wave 1 rollout,Rollout,Planned,2027-03-01T00:00:00Z,2027-03-19T00:00:00Z,0
Wave 2 rollout,Rollout,Planned,2027-03-22T00:00:00Z,2027-04-09T00:00:00Z,0
Legacy decommission,Closeout,Planned,2027-04-12T00:00:00Z,2027-04-30T00:00:00Z,0
Benefits review,Closeout,Planned,2027-05-03T00:00:00Z,2027-05-14T00:00:00Z,0
Project closure report,Closeout,Planned,2027-05-17T00:00:00Z,2027-05-28T00:00:00Z,0
Business Charts - Function (JS)
// ============================================================================
// Gantt chart — Business Charts (volkovlabs-echarts-panel)
// Expects columns: task, group, status, start, end, progress (owner optional)
// ============================================================================
// --- Data frame resolution: context.panel.data (v5+) vs data (older) ---
const series =
context?.panel?.data?.series?.[0] ||
(typeof data !== 'undefined' ? data?.series?.[0] : null);
const bail = (msg) => ({
backgroundColor: '#FFFFFF',
title: { text: msg, left: 'center', top: 'middle', textStyle: { color: '#C62828', fontSize: 13 } },
});
if (!series?.fields?.length) return bail('No data frame — check the query');
const col = (name) => {
const f = series.fields.find((x) => x.name === name);
if (!f) return null;
return typeof f.values?.toArray === 'function' ? f.values.toArray() : (f.values ?? []);
};
const tasks = col('task');
if (!tasks?.length) {
return bail('Missing "task" column. Found: ' + series.fields.map((f) => f.name).join(', '));
}
const groups = col('group') ?? tasks.map(() => '');
const statuses = col('status') ?? tasks.map(() => 'Planned');
const progress = col('progress') ?? tasks.map(() => 0);
// TestData may hand back epoch ms (auto-detected time field) or ISO strings.
const toMs = (v) => (typeof v === 'number' ? v : Date.parse(v));
const starts = (col('start') ?? []).map(toMs);
const ends = (col('end') ?? []).map(toMs);
if (!starts.length || Number.isNaN(starts[0])) return bail('start column missing or unparseable');
// Owner: real 'owner' column wins; otherwise fall back to block ownership by group.
// Delete this map once the query supplies per-task assignees.
const OWNER_BY_GROUP = {
'Discovery': 'A. Patel',
'Design': 'R. Okafor',
'Platform': 'M. Lindqvist',
'Data': 'S. Haruna',
'Integration': 'T. Bergman',
'Build': 'L. Moreau',
'Testing': 'K. Ito',
'Security': 'D. Novak',
'Enablement': 'J. Whitfield',
'Rollout': 'C. Ferreira',
'Closeout': 'A. Patel',
};
const ownerCol = col('owner');
const owners = tasks.map((_, i) => ownerCol?.[i] ?? OWNER_BY_GROUP[groups[i]] ?? '—');
const days = tasks.map((_, i) => Math.round((ends[i] - starts[i]) / 86400000));
// --- Light-mode palette ---
const COLORS = {
'Complete': '#2E7D32',
'In Progress': '#1565C0',
'At Risk': '#ED6C02',
'Blocked': '#C62828',
'Planned': '#607D8B',
};
const FALLBACK = '#607D8B';
const INK = '#2B333D';
const INK_MUTED = '#5A6675';
const INK_FAINT = '#7C8896';
const LINE_MAJOR = '#A9B4C0'; // month gridlines
const LINE_MINOR = '#D5DDE5'; // week gridlines
const LINE_ROW = '#C3CCD6'; // row separators
const BORDER = '#7E8B99'; // outer frame + table rules
const BAND = '#F1F5F9'; // alternating group shade
const HEADER_BG = '#EDF1F5';
const FONT_BODY = '11px Inter, "Segoe UI", Roboto, sans-serif';
const FONT_HEAD = '600 11px Inter, "Segoe UI", Roboto, sans-serif';
// --- Left table. Add/remove entries here; grid.left and all rules recompute. ---
const PAD = 10;
const GAP = 12;
const COLUMNS = [
{ key: 'task', label: 'Task', width: 168, align: 'left', color: INK, font: FONT_BODY },
{ key: 'owner', label: 'Owner', width: 92, align: 'left', color: INK_MUTED, font: FONT_BODY },
{ key: 'days', label: 'Days', width: 36, align: 'right', color: INK_FAINT, font: FONT_BODY },
];
let cursor = PAD;
COLUMNS.forEach((c) => { c.x = cursor; cursor += c.width + GAP; });
const LEFT = cursor - GAP + PAD;
const GRID_TOP = 64;
// Category axis runs bottom-up, so reverse to read top-down in source order.
const categories = tasks.slice().reverse();
const rowOf = (i) => categories.indexOf(tasks[i]);
// Row index -> cell values. Any COLUMNS key must exist here.
const rowMeta = [];
tasks.forEach((t, i) => {
rowMeta[rowOf(i)] = { task: t, owner: owners[i], days: String(days[i]) };
});
// value: [rowIdx, start, end, progress, task, group, status, owner, days]
const barData = tasks.map((t, i) => ({
value: [rowOf(i), starts[i], ends[i], progress[i], t, groups[i], statuses[i], owners[i], days[i]],
itemStyle: { color: COLORS[statuses[i]] || FALLBACK },
}));
// --- Group bands: y-only data, so x-zoom can never filter them out ---
const bandData = [];
{
const seen = [];
groups.forEach((g) => { if (!seen.includes(g)) seen.push(g); });
seen.forEach((g, n) => {
if (n % 2 === 1) return; // shade every other group
const rows = groups.map((v, i) => (v === g ? rowOf(i) : null)).filter((v) => v !== null);
bandData.push([Math.min(...rows), Math.max(...rows)]);
});
}
const bandRenderItem = (params, api) => {
const cs = params.coordSys;
const h = api.size([0, 1])[1];
const yA = api.coord([cs.x, api.value(0)])[1];
const yB = api.coord([cs.x, api.value(1)])[1];
const top = Math.max(Math.min(yA, yB) - h / 2, cs.y);
const bot = Math.min(Math.max(yA, yB) + h / 2, cs.y + cs.height);
if (bot <= top) return;
return {
type: 'rect',
shape: { x: cs.x, y: top, width: cs.width, height: bot - top },
style: { fill: BAND },
silent: true,
};
};
// --- Left table rows. y-only data; the x arg to api.coord is arbitrary. ---
const tableRenderItem = (params, api) => {
const cs = params.coordSys;
const row = api.value(0);
const meta = rowMeta[row];
if (!meta) return;
const yc = api.coord([cs.x, row])[1];
const h = api.size([0, 1])[1];
const top = yc - h / 2;
const bot = yc + h / 2;
// Skip rows scrolled outside the window or text bleeds past the frame.
if (bot < cs.y || top > cs.y + cs.height) return;
const children = COLUMNS.map((c) => ({
type: 'text',
style: {
text: meta[c.key] ?? '',
x: c.align === 'right' ? c.x + c.width : c.x,
y: yc,
fill: c.color,
font: c.font,
width: c.width,
overflow: 'truncate',
align: c.align,
textAlign: c.align, // zrender 4 fallback
verticalAlign: 'middle',
textVerticalAlign: 'middle', // zrender 4 fallback
},
silent: true,
}));
children.push({
type: 'line',
shape: { x1: 0, y1: bot, x2: LEFT, y2: bot },
style: { stroke: LINE_ROW, lineWidth: 1 },
silent: true,
});
COLUMNS.slice(1).forEach((c) => {
children.push({
type: 'line',
shape: { x1: c.x - GAP / 2, y1: top, x2: c.x - GAP / 2, y2: bot },
style: { stroke: LINE_ROW, lineWidth: 1 },
silent: true,
});
});
return { type: 'group', children };
};
// --- Bars. Manual clipping avoids any dependency on the echarts global. ---
const makeRenderItem = (fraction, heightRatio) =>
function (params, api) {
const row = api.value(0);
const s = api.value(1);
const e = api.value(2);
const drawnEnd = fraction ? s + (e - s) * (api.value(3) / 100) : e;
const p1 = api.coord([s, row]);
const p2 = api.coord([drawnEnd, row]);
const h = api.size([0, 1])[1] * heightRatio;
const cs = params.coordSys;
const x1 = Math.max(p1[0], cs.x);
const x2 = Math.min(Math.max(p2[0], p1[0] + 1), cs.x + cs.width);
const y1 = Math.max(p1[1] - h / 2, cs.y);
const y2 = Math.min(p1[1] + h / 2, cs.y + cs.height);
if (x2 <= x1 || y2 <= y1) return;
// Track = tinted outline, progress = solid fill on top. Reads like an Excel bar.
const base = api.style().fill;
const style = fraction
? { fill: base }
: { fill: base, opacity: 0.16, stroke: base, lineWidth: 1.25 };
return {
type: 'rect',
shape: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 },
style,
silent: !!fraction,
};
};
const fmtDate = (t) =>
new Date(t).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
// --- Static table header ---
const graphic = [
{
type: 'rect', left: 0, top: 0, z: 10,
shape: { x: 0, y: 0, width: LEFT, height: GRID_TOP - 1 },
style: { fill: HEADER_BG }, silent: true
},
...COLUMNS.map((c) => ({
type: 'text', z: 11,
left: c.align === 'right' ? c.x + c.width : c.x,
top: GRID_TOP - 22,
style: { text: c.label, fill: INK, font: FONT_HEAD, align: c.align, textAlign: c.align },
silent: true,
})),
{
type: 'rect', left: 0, top: GRID_TOP - 1.5, z: 11,
shape: { x: 0, y: 0, width: LEFT, height: 1.5 },
style: { fill: BORDER }, silent: true
},
];
return {
backgroundColor: '#FFFFFF',
animation: false,
graphic,
tooltip: {
trigger: 'item',
backgroundColor: '#FFFFFF',
borderColor: '#9AA6B4',
borderWidth: 1,
textStyle: { color: INK, fontSize: 12 },
extraCssText: 'box-shadow: 0 2px 10px rgba(0,0,0,0.18);',
formatter: (p) => {
const [, s, e, prog, task, group, status, owner, d] = p.value;
const c = COLORS[status] || FALLBACK;
return (
`<b>${task}</b><br/>` +
`<span style="color:${c}">■</span> ${status} · ${group}<br/>` +
`Owner: <b>${owner}</b><br/>` +
`${fmtDate(s)} → ${fmtDate(e)} (${d}d)<br/>` +
`Progress: <b>${prog}%</b>`
);
},
},
grid: {
left: LEFT,
right: 44,
top: GRID_TOP,
bottom: 60,
show: true,
backgroundColor: '#FFFFFF',
borderColor: BORDER,
borderWidth: 1.5,
},
xAxis: {
type: 'time',
position: 'top',
axisLine: { show: true, lineStyle: { color: BORDER, width: 1.5 } },
axisTick: { show: true, length: 5, lineStyle: { color: BORDER, width: 1.5 } },
axisLabel: { color: INK, fontSize: 11, fontWeight: 600, margin: 10 },
splitLine: { show: true, lineStyle: { color: LINE_MAJOR, width: 1.25 } },
minorTick: { show: true, splitNumber: 4, lineStyle: { color: LINE_MINOR } },
minorSplitLine: { show: true, lineStyle: { color: LINE_MINOR, width: 1 } },
},
yAxis: {
type: 'category',
data: categories,
axisLine: { show: true, lineStyle: { color: BORDER, width: 1.5 } },
axisTick: { show: false },
axisLabel: { show: false }, // replaced by the custom table series
splitLine: { show: true, interval: 0, lineStyle: { color: LINE_ROW, width: 1 } },
},
// filterMode 'none' throughout: renderItem clips manually, so letting ECharts drop
// out-of-window points only hides bars and rows that span the visible range.
dataZoom: [
{
type: 'slider', xAxisIndex: 0, filterMode: 'none', height: 18, bottom: 18,
left: LEFT, right: 44,
borderColor: '#A9B4C0', fillerColor: 'rgba(21,101,192,0.12)',
handleStyle: { color: '#FFFFFF', borderColor: '#7E8B99' },
textStyle: { color: INK_MUTED, fontSize: 10 }
},
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
{
type: 'slider', yAxisIndex: 0, filterMode: 'none', width: 12, right: 12,
top: GRID_TOP, bottom: 60, startValue: 0, endValue: 17, showDetail: false,
borderColor: '#A9B4C0', fillerColor: 'rgba(21,101,192,0.12)',
handleStyle: { color: '#FFFFFF', borderColor: '#7E8B99' }
},
{ type: 'inside', yAxisIndex: 0, filterMode: 'none', zoomOnMouseWheel: false, moveOnMouseWheel: true },
],
series: [
{
type: 'custom', name: 'Group band', renderItem: bandRenderItem,
encode: { y: [0, 1] }, data: bandData,
z: 1, silent: true, tooltip: { show: false }
},
{
type: 'custom', name: 'Row labels', renderItem: tableRenderItem,
encode: { y: 0 }, data: tasks.map((t, i) => [rowOf(i)]),
z: 6, silent: true, tooltip: { show: false }
},
{
type: 'custom', name: 'Planned', renderItem: makeRenderItem(false, 0.58),
encode: { x: [1, 2], y: 0 }, data: barData, z: 3
},
{
type: 'custom', name: 'Actual', renderItem: makeRenderItem(true, 0.58),
encode: { x: [1, 2], y: 0 }, data: barData, z: 4,
markLine: {
symbol: 'none', silent: true,
lineStyle: { color: '#C62828', type: 'dashed', width: 1.5 },
label: { formatter: 'Today', color: '#C62828', fontSize: 10, position: 'insideEndTop' },
data: [{ xAxis: Date.now() }],
}
},
],
};