I wish to have a native Gantt chart

I’m working on what should be a pretty basic visualization: plotting events on the y-axis and time on the x-axis, with horizontal bars showing the duration from start to end time. Sounds like a textbook use case for a Gantt chart, right?

Well, I’ve tried everything:

  • Time series panel — not built for this.
  • Bar gauges — nope.
  • State timeline — close, but not reusable in the way the old Gantt plugin was.

The marcusolsson-gantt-panel plugin used to do exactly what I needed, but it’s no longer maintained. The author recommends exploring the state timeline panel instead — but after trying it myself and reading many similar posts, I can confidently say: it’s not the same. It doesn’t offer the same flexibility or clarity for visualizing event durations.

If nothing existing can be reused or repurposed for this, then why not build a core Gantt chart panel into Grafana? This kind of visualization is essential for many use cases - monitoring pipelines, job scheduling, process tracking, etc.

I really hope someone on the team is working on this. Thanks for listening and sorry for the rant!

I never had to use Gantt chart in Grafana, nor that I maintain any of the plugins. Could you detail a bit more what’s missing in State timeline compared to Marcus’ plugin ?

Thanks for jumping in! I totally get that the State Timeline panel is designed for a different use case, but here’s where I ran into trouble trying to use it for event duration visualization:

  1. Padding with nulls: To show durations that occur at specific times (e.g., a task from 04:00 to 04:11), I had to pad the data with nulls or dummy values for all the times when that event wasn’t active. This gets messy fast, especially when events are sparse or irregular.
  2. Infinite end times: The State Timeline assumes that the last state continues indefinitely. That makes sense for monitoring stateful systems, but it’s a problem when I want to show discrete durations—like a task that ended 10 minutes ago and isn’t currently active. There’s no clean way to say “this event ended and nothing followed.”

So while I understand the design philosophy behind State Timeline, it’s a bit too rigid for use cases where I just want to show event durations at various points in time—especially when:

  • Events don’t happen continuously.
  • Some events overlap.
  • Some events are in the past and not currently active.

Have you ever needed to visualize discrete event durations like this? If so, how did you approach it?

I played a bit with State timeline, and I believe the main issue is about the data format expected. I would be easier to use as a Gantt with a table formated like:

| Start time | End time | Task | State     |
|------------|----------|------|-----------|
| 09:00      | 09:05    | A    | Preparing |
| 09:00      | 09:10    | B    | Preparing |
| 09:15      | 09:20    | C    | Working   |

This would allow for simple SQL queries (as I don’t expect calendar events to be stored in a TSDB)

State timeline can display discrete events when using start / end / state format.

But it’s cumbersome to use as it has been designed for timeseries. This following example is kind-of-incorrect™:

Beside start/end columns, it expects one column for each task. On line 1, I defined task A to start at 9:00 and end at 9:06. On line 2, I defined task A (again, because the column exists) to be nulled from 9:03 to 9:04. Result: task starts at 9:00, ends at 9:03, doesn’t start again from 9:04 to 9:06

I am providing contradicting informations to a perfectly working plugin. That’s why I believe adding a newly supported format of table would makes State timeline a good option for Gantt.

Would it make sense ? It’s not a promise to implement anything as I am not managing any of the plugins, just personal interest and curiousity.

Yes, exactly. The State Timeline panel works well for continuous state tracking, but it’s not ideal when repurposed as a Gantt chart. It expects time series data with one column per task and continuous states, which makes it hard to represent discrete event durations - especially when events overlap.

A Gantt chart and a state timeline serve different purposes. I’d really like to see a core Gantt panel in Grafana that supports start/end durations, overlap handling, and a simple table format. Repurposing State Timeline for this feels like a workaround, not a solution.

currently there is no such core plugin.

I don’t get why adding the ability to read a new table format in State Timeline would be a workaround and not a solution. What would be missing ?

I have the same issue with State Timeline. My use case is that I want to display scheduled taks, in my case MSSQL Agent jobs, and their duration, have overlapping timestamps which seems to confuse State Timline, there is also another issue where it makes another misstake (on that I don’t really understand) where tasks that runs very often seems to be continuous over multiple executions. But the main problem I get from State Timeline is the overlapping timestamps. I’ve written about it in more detail here:

In addition to the complex data preparation mentioned above, I loved to easily add additional information in the mouse-over of the former Gantt panel. I use the Gantt panel to show the duration of errors on several machines. Adding the error description and other information to the mouse-over massively increased the value of the panel. Otherwise, you could only see, yes, there was an error, but have no idea what exactly was going on.

In regard of Tooltip options, State Timeline can’t replace the old Gantt chart at all.

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}">&#9632;</span> ${status} &middot; ${group}<br/>` +
        `Owner: <b>${owner}</b><br/>` +
        `${fmtDate(s)} &rarr; ${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() }],
      }
    },
  ],
};

It renders a proper Gantt with overlapping bars, progress fill, and grouped rows, which is exactly what State Timeline couldn’t do.

It depends on the Business Charts (volkovlabs-echarts-panel) plugin being installed, and the JS needs retouching if the query’s field names or frame shape change. So it’s a real fix for teams comfortable maintaining a bit of panel level JS, but it’s still per dashboard code rather than a config driven panel and a native Gantt would remove that maintenance step.
This is the way to go for anyone landing here needing a Gantt now.