// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. /** * Plotly Gantt Chart template. * * One horizontal bar per task, `base: start`, `x: end − start` — Plotly's * `base` property natively floats a bar off zero, so no transparent-base * trick (unlike the ECharts template, which must stack a silent base series * to fake a floating interval bar). */ import { ChartTemplateDef } from '../../core/types'; import { coerceGanttEndpoint, formatGanttLabel, GANTT_PROPERTIES, isGanttTemporal, sortGanttRows, } from '../../chart-types/gantt'; import { getPlotlyPalette, getSeriesColor } from './utils'; export const plGanttChartDef: ChartTemplateDef = { chart: 'Gantt Chart', template: { mark: 'bar', encoding: {} }, channels: ['y', 'x', 'x2', 'color', 'detail', 'column', 'row'], markCognitiveChannel: 'position', declareLayoutMode: () => ({ axisFlags: { y: { banded: true } } }), instantiate: (spec, ctx) => { const { channelSemantics, table, chartProperties, semanticTypes } = ctx; const taskField = channelSemantics.y?.field; const startField = channelSemantics.x?.field; const endField = channelSemantics.x2?.field; const colorField = channelSemantics.color?.field; if (!taskField || !startField || !endField || table.length === 0) return; const temporal = isGanttTemporal(channelSemantics.x?.type, semanticTypes[startField]); const rows = sortGanttRows(table .map((r: any, inputIndex: number) => ({ task: String(r[taskField] ?? ''), start: coerceGanttEndpoint(r[startField], temporal), end: coerceGanttEndpoint(r[endField], temporal), group: colorField != null ? String(r[colorField] ?? '') : undefined, inputIndex, })) .filter((r) => r.task && Number.isFinite(r.start) && Number.isFinite(r.end))); if (rows.length === 0) return; const taskHeight = Number(chartProperties?.taskHeight ?? 70) / 100; const cornerRadius = Number(chartProperties?.cornerRadius ?? 2); const intervalLabels = chartProperties?.intervalLabels === true; const tasks = rows.map(r => r.task); const palette = getPlotlyPalette(ctx, 'color'); const groups = colorField ? Array.from(new Set(rows.map(r => r.group ?? ''))) : []; const groupColorIdx = new Map(groups.map((g, i) => [g, i])); const dateLabel = (ms: number) => temporal ? new Date(ms).toISOString().slice(0, 10) : String(ms); const traces: any[] = []; if (colorField && groups.length > 0) { for (const g of groups) { const groupRows = rows.filter(r => (r.group ?? '') === g); traces.push({ type: 'bar', name: g, orientation: 'h', base: groupRows.map(r => r.start), x: groupRows.map(r => r.end - r.start), y: groupRows.map(r => r.task), width: taskHeight, marker: { color: getSeriesColor(palette, groupColorIdx.get(g) ?? 0) }, text: intervalLabels ? groupRows.map(r => formatGanttLabel(r.start, r.end, temporal)) : undefined, textposition: intervalLabels ? 'outside' as const : undefined, customdata: groupRows.map(r => [dateLabel(r.start), dateLabel(r.end)]), }); } } else { traces.push({ type: 'bar', orientation: 'h', showlegend: false, base: rows.map(r => r.start), x: rows.map(r => r.end - r.start), y: tasks, width: taskHeight, marker: { color: getSeriesColor(palette, 0) }, text: intervalLabels ? rows.map(r => formatGanttLabel(r.start, r.end, temporal)) : undefined, textposition: intervalLabels ? 'outside' as const : undefined, customdata: rows.map(r => [dateLabel(r.start), dateLabel(r.end)]), }); } for (const t of traces) { t.hovertemplate = `%{y}
${startField}: %{customdata[0]}
${endField}: %{customdata[1]}`; t.marker.line = { width: 0 }; if (cornerRadius) t.marker.cornerradius = cornerRadius; } const xAxisSpec: any = { title: { text: temporal ? '' : startField } }; if (temporal) xAxisSpec.type = 'date'; Object.assign(spec, { data: traces, layout: { xaxis: xAxisSpec, yaxis: { type: 'category', categoryorder: 'array', categoryarray: tasks, autorange: 'reversed', title: { text: taskField } }, showlegend: !!colorField && groups.length > 1, }, }); delete spec.mark; delete spec.encoding; }, properties: GANTT_PROPERTIES, };