Popular features/S-curve
Loading live demo…from dhtmlxcode.com
Popular features
S-curve
Plot planned, actual, and forecast progress as an S-curve computed live from task effort and progress, drawn with no charting library.
gantt.plugins({ marker: true, tooltip: true });
gantt.config.date_format = "%Y-%m-%d";
gantt.config.auto_types = true;
gantt.config.open_tree_initially = true;
gantt.config.grid_resize = true;
gantt.config.work_time = true;
gantt.config.row_height = 32;
gantt.config.bar_height = 20;
const formatDayKey = gantt.date.date_to_str("%Y-%m-%d");
const todayDate = new Date(2026, 8, 14); // 14 Sep 2026 (demo "today")
let selectedMetric = "duration";
/* ---------------------------------------------------------------------------
* DATASET (actual slightly behind plan; a realistic, readable S-curve)
* ------------------------------------------------------------------------ */
const demoData = {
tasks: [
{ id: 1, text: "Plant commissioning", type: "project", open: true },
{ id: 10, text: "Civil & structural", type: "project", parent: 1, open: true },
{ id: 11, text: "Site preparation", parent: 10, start_date: "2026-08-03", duration: 8, progress: 1, cost: 22000 },
{ id: 12, text: "Foundations", parent: 10, start_date: "2026-08-13", duration: 12, progress: 0.95, cost: 48000 },
{ id: 13, text: "Steel erection", parent: 10, start_date: "2026-08-31", duration: 14, progress: 0.55, cost: 65000 },
{ id: 14, text: "Structural inspection",parent: 10, start_date: "2026-09-21", duration: 6, progress: 0, cost: 15000 },
{ id: 20, text: "Mechanical", type: "project", parent: 1, open: true },
{ id: 21, text: "Equipment install", parent: 20, start_date: "2026-09-14", duration: 16, progress: 0.2, cost: 90000 },
{ id: 22, text: "Piping", parent: 20, start_date: "2026-09-28", duration: 18, progress: 0, cost: 72000 },
{ id: 23, text: "HVAC ductwork", parent: 20, start_date: "2026-10-19", duration: 10, progress: 0, cost: 38000 },
{ id: 30, text: "Electrical & I&C", type: "project", parent: 1, open: true },
{ id: 31, text: "Cable trays & wiring", parent: 30, start_date: "2026-10-12", duration: 15, progress: 0, cost: 54000 },
{ id: 32, text: "Instrumentation", parent: 30, start_date: "2026-10-26", duration: 12, progress: 0, cost: 40000 },
{ id: 33, text: "Motor control centers",parent: 30, start_date: "2026-11-06", duration: 9, progress: 0, cost: 33000 },
{ id: 40, text: "Commissioning", type: "project", parent: 1, open: true },
{ id: 41, text: "Pre-commissioning", parent: 40, start_date: "2026-11-16", duration: 10, progress: 0, cost: 30000 },
{ id: 42, text: "Start-up & handover", parent: 40, start_date: "2026-11-30", duration: 8, progress: 0, cost: 25000 },
{ id: 43, text: "Handover", parent: 40, type: "milestone", start_date: "2026-12-10", progress: 0 }
],
links: [
{ id: 1, source: 11, target: 12, type: "0" }, { id: 2, source: 12, target: 13, type: "0" },
{ id: 3, source: 13, target: 14, type: "0" }, { id: 4, source: 14, target: 21, type: "0" },
{ id: 5, source: 21, target: 22, type: "0" }, { id: 6, source: 22, target: 23, type: "0" },
{ id: 7, source: 23, target: 31, type: "0" }, { id: 8, source: 31, target: 32, type: "0" },
{ id: 9, source: 32, target: 33, type: "0" }, { id: 10, source: 33, target: 41, type: "0" },
{ id: 11, source: 41, target: 42, type: "0" }, { id: 12, source: 42, target: 43, type: "0" }
]
};
/* ---------------------------------------------------------------------------
* GRID
* ------------------------------------------------------------------------ */
gantt.config.columns = [
{ name: "text", label: "Work package", tree: true, width: 220, resize: true },
{ name: "progress", label: "%", align: "center", width: 56, resize: true, template: function (task) {
if (task.type !== gantt.config.types.milestone && !gantt.isSummaryTask(task)){
return Math.round((task.progress || 0) * 100) + "%";
}
}},
{ name: "add", width: 40, resize: true }
];
gantt.config.grid_width = 330;
gantt.templates.tooltip_text = function (start, end, task) {
return "<b>" + task.text + "</b><br>" + Math.round((task.progress || 0) * 100) + "% complete";
};
/* ---------------------------------------------------------------------------
* ZOOM
* ------------------------------------------------------------------------ */
const zoomNames = ["week", "month"];
gantt.ext.zoom.init({
levels: [
{ name: "week", scale_height: 48, scales: [
{ unit: "month", step: 1, format: "%F %Y" },
{ unit: "week", step: 1, format: function (date) {
return "Wk " + gantt.date.date_to_str("%W")(date);
}}
] },
{ name: "month", scale_height: 48, scales: [
{ unit: "year", step: 1, format: "%Y" },
{ unit: "month", step: 1, format: "%M" }
] }
],
element: function () { return gantt.$root.querySelector(".gantt_task"); }
});
DHX.zoom.init(gantt, zoomNames, "zoom_seg", "footer_scale");
function setZoom(name) { DHX.zoom.set(name); }
/* ---------------------------------------------------------------------------
* S-CURVE COMPUTATION
* ------------------------------------------------------------------------ */
function projectRange() {
let earliestStartDate = null;
let latestEndDate = null;
gantt.eachTask(function (task) {
if (gantt.isSummaryTask(task) || !task.start_date) {
return;
}
if (!earliestStartDate || task.start_date < earliestStartDate) {
earliestStartDate = new Date(task.start_date);
}
const taskEndDate = task.end_date || task.start_date;
if (!latestEndDate || taskEndDate > latestEndDate) {
latestEndDate = new Date(taskEndDate);
}
});
return {
startDate: earliestStartDate,
endDate: latestEndDate
};
}
function taskWeight(task) {
if (selectedMetric === "cost") {
return Number(task.cost) || 0;
}
return Math.max(
gantt.calculateDuration({
start_date: task.start_date,
end_date: task.end_date
}) || task.duration || 0,
0
);
}
/* Daily planned and actual increments keyed by day. */
function buildSeries() {
const projectDateRange = projectRange();
if (!projectDateRange.startDate) {
return null;
}
const plannedIncrements = {};
const actualIncrements = {};
let totalWeight = 0;
gantt.eachTask(function (task) {
if (
gantt.isSummaryTask(task) ||
task.type === gantt.config.types.milestone ||
!task.start_date ||
!task.end_date
) {
return;
}
const taskWeightValue = taskWeight(task);
if (!taskWeightValue) {
return;
}
const workingDays = [];
let currentTaskDate = new Date(task.start_date);
while (currentTaskDate < task.end_date) {
if (gantt.isWorkTime(currentTaskDate, "day")) {
workingDays.push(new Date(currentTaskDate));
}
currentTaskDate = gantt.date.add(currentTaskDate, 1, "day");
}
if (!workingDays.length) {
return;
}
const dailyWeight = taskWeightValue / workingDays.length;
totalWeight += taskWeightValue;
workingDays.forEach(function (workingDay) {
const dateKey = formatDayKey(workingDay);
plannedIncrements[dateKey] = (plannedIncrements[dateKey] || 0) + dailyWeight;
if (workingDay <= todayDate) {
actualIncrements[dateKey] = (actualIncrements[dateKey] || 0) + dailyWeight * (task.progress || 0);
}
});
});
const curvePoints = [];
let cumulativePlanned = 0;
let cumulativeActual = 0;
let currentDate = new Date(projectDateRange.startDate);
const lastDate = new Date(projectDateRange.endDate);
let actualCumulativeAtToday = 0;
let actualWorkingDayCount = 0;
while (currentDate <= lastDate) {
const dateKey = formatDayKey(currentDate);
const isBeforeOrOnToday = currentDate <= todayDate;
cumulativePlanned += plannedIncrements[dateKey] || 0;
if (isBeforeOrOnToday) {
cumulativeActual += actualIncrements[dateKey] || 0;
}
curvePoints.push({
date: new Date(currentDate),
planned: cumulativePlanned,
actual: isBeforeOrOnToday ? cumulativeActual : null,
forecast: null
});
if (isBeforeOrOnToday && gantt.isWorkTime(currentDate, "day")) {
actualCumulativeAtToday = cumulativeActual;
actualWorkingDayCount++;
}
currentDate = gantt.date.add(currentDate, 1, "day");
}
if (!totalWeight) {
return null;
}
/* Forecast: extend from today's actual at the observed run-rate. */
const observedRate = actualWorkingDayCount ? actualCumulativeAtToday / actualWorkingDayCount : 0;
let forecastCumulative = actualCumulativeAtToday;
curvePoints.forEach(function (curvePoint) {
if (curvePoint.date < todayDate) {
return;
}
if (curvePoint.actual !== null) {
curvePoint.forecast = curvePoint.actual;
return;
}
if (gantt.isWorkTime(curvePoint.date, "day")) {
forecastCumulative += observedRate;
}
curvePoint.forecast = Math.min(forecastCumulative, totalWeight);
});
/* Normalise to percentages. */
curvePoints.forEach(function (curvePoint) {
curvePoint.planned = curvePoint.planned / totalWeight * 100;
if (curvePoint.actual !== null) {
curvePoint.actual = curvePoint.actual / totalWeight * 100;
}
if (curvePoint.forecast !== null) {
curvePoint.forecast = curvePoint.forecast / totalWeight * 100;
}
});
return {
points: curvePoints,
startDate: projectDateRange.startDate,
endDate: projectDateRange.endDate,
totalWeight: totalWeight
};
}
/* ---------------------------------------------------------------------------
* S-CURVE RENDERING
* ------------------------------------------------------------------------ */
const chartPadding = {
left: 38,
right: 14,
top: 12,
bottom: 22
};
function renderCurve() {
const svgElement = document.getElementById("scurve");
const curveData = buildSeries();
const chartWidth = svgElement.clientWidth || svgElement.parentNode.clientWidth;
const chartHeight = svgElement.clientHeight || svgElement.parentNode.clientHeight;
if (!curveData || !chartWidth || !chartHeight) {
svgElement.innerHTML = "";
return;
}
svgElement.setAttribute("viewBox", "0 0 " + chartWidth + " " + chartHeight);
const chartLeft = chartPadding.left;
const chartRight = chartWidth - chartPadding.right;
const chartBottom = chartHeight - chartPadding.bottom;
const chartTop = chartPadding.top;
const startTimestamp = curveData.startDate.valueOf();
const endTimestamp = curveData.endDate.valueOf();
const timelineSpan = endTimestamp - startTimestamp || 1;
function getXCoordinate(date) {
return chartLeft + (date.valueOf() - startTimestamp) / timelineSpan * (chartRight - chartLeft);
}
function getYCoordinate(percentage) {
return chartBottom - percentage / 100 * (chartBottom - chartTop);
}
const svgParts = [];
/* Horizontal gridlines and y-axis labels. */
[0, 25, 50, 75, 100].forEach(function (percentage) {
const yCoordinate = getYCoordinate(percentage);
svgParts.push(
`<line class='sc_grid' x1='${chartLeft}' y1='${yCoordinate}' x2='${chartRight}' y2='${yCoordinate}'/>`
);
svgParts.push(
`<text class='sc_label' x='${chartLeft - 6}' y='${yCoordinate + 3}' text-anchor='end'>${percentage}%</text>`
);
}
);
svgParts.push(
`<line class='sc_axis' x1='${chartLeft}' y1='${chartBottom}' x2='${chartRight}' y2='${chartBottom}'/>`
);
/* Month ticks on the x-axis. */
let monthDate = new Date(curveData.startDate.getFullYear(), curveData.startDate.getMonth(), 1);
const formatMonth = gantt.date.date_to_str("%M");
while (monthDate <= curveData.endDate) {
if (monthDate >= curveData.startDate) {
const xCoordinate = getXCoordinate(monthDate);
svgParts.push(
`<line class='sc_grid' x1='${xCoordinate}' y1='${chartTop}' x2='${xCoordinate}' y2='${chartBottom}'/>`
);
svgParts.push(
`<text class='sc_label' x='${xCoordinate + 3}' y='${chartBottom + 14}'>${formatMonth(monthDate)}</text>`
);
}
monthDate = gantt.date.add(monthDate, 1, "month");
}
function buildPath(seriesProperty) {
let pathData = "";
let hasStarted = false;
curveData.points.forEach(
function (curvePoint) {
const percentage = curvePoint[seriesProperty];
if (percentage == null) {
return;
}
pathData +=
(hasStarted ? "L" : "M") +
getXCoordinate(curvePoint.date).toFixed(1) + " " +
getYCoordinate(percentage).toFixed(1) + " ";
hasStarted = true;
}
);
return pathData.trim();
}
const plannedPath = buildPath("planned");
if (plannedPath) {
const areaPath =
plannedPath +
" L" + getXCoordinate(curveData.endDate).toFixed(1) + " " + chartBottom +
" L" + chartLeft + " " + chartBottom + " Z";
svgParts.push("<path class='sc_area' d='" + areaPath + "'/>");
svgParts.push("<path class='sc_planned' d='" + plannedPath +"'/>");
}
const forecastPath = buildPath("forecast");
if (forecastPath) {
svgParts.push("<path class='sc_fore' d='" + forecastPath + "'/>");
}
const actualPath = buildPath("actual");
if (actualPath) {
svgParts.push("<path class='sc_actual' d='" + actualPath + "'/>");
}
if (todayDate >= curveData.startDate && todayDate <= curveData.endDate) {
const todayXCoordinate = getXCoordinate(todayDate);
svgParts.push(
"<line class='sc_today' x1='" +
todayXCoordinate + "' y1='" +
chartTop + "' x2='" +
todayXCoordinate + "' y2='" +
chartBottom + "'/>"
);
svgParts.push("<text class='sc_today_t' x='" + (todayXCoordinate + 4) + "' y='" + (chartTop + 10) + "'>Today</text>");
}
svgElement.innerHTML = svgParts.join("");
renderKpis(curveData);
}
function valueAt(curveData, seriesProperty, targetDate) {
let nearestValue = null;
curveData.points.forEach(
function (curvePoint) {
if (curvePoint[seriesProperty] != null && curvePoint.date <= targetDate) {
nearestValue = curvePoint[seriesProperty];
}
}
);
return nearestValue;
}
function renderKpis(curveData) {
const plannedToday = valueAt(curveData, "planned", todayDate) || 0;
const actualToday = valueAt(curveData, "actual", todayDate) || 0;
const scheduleVariance = actualToday - plannedToday;
const varianceClass = scheduleVariance >= 0 ? "ahead" : "behind";
const varianceSign = scheduleVariance >= 0 ? "+" : "";
document.getElementById("chart_kpis").innerHTML =
"<span class='chart_kpi'><b>" +
Math.round(plannedToday) + "%</b><span>Planned</span></span>" + "<span class='chart_kpi'><b>" +
Math.round(actualToday) + "%</b><span>Actual</span></span>" + "<span class='chart_kpi'><b class='" +
varianceClass + "'>" +
varianceSign +
Math.round(scheduleVariance) + "%</b><span>Schedule var.</span></span>";
}
/* ---------------------------------------------------------------------------
* METRIC + THEME + WIRING
* ------------------------------------------------------------------------ */
function setMetric(metricName) {
selectedMetric = metricName;
DHX.ui.setActiveByAttribute("#metric_seg .dhx_btn", "data-metric", metricName, "active");
DHX.ui.setText("chart_title", metricName === "cost" ? "Cumulative cost (earned value)" : "Cumulative completion");
renderCurve();
}
let darkTheme = false;
function toggleTheme() {
darkTheme = !darkTheme;
document.getElementById("theme_sun").classList.toggle("dhx_hidden", darkTheme);
document.getElementById("theme_moon").classList.toggle("dhx_hidden", !darkTheme);
gantt.setSkin(darkTheme ? "dark" : "");
gantt.render();
renderCurve();
}
gantt.attachEvent("onAfterTaskUpdate", renderCurve);
gantt.attachEvent("onAfterTaskDrag", renderCurve);
gantt.attachEvent("onAfterTaskAdd", renderCurve);
gantt.attachEvent("onAfterTaskDelete", renderCurve);
window.addEventListener("resize", function () { renderCurve(); });
/* ---------------------------------------------------------------------------
* INIT
* ------------------------------------------------------------------------ */
gantt.init("gantt_here");
gantt.parse(demoData);
gantt.addMarker({
start_date: todayDate,
css: "today",
text: "Today",
title: "Today"
});
DHX.zoom.set("week");
renderCurve();<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<title>S-curve: planned vs actual progress | dhtmlxGantt</title>
<script src="../../codebase/dhtmlxgantt.js?v=10.0.0"></script>
<link rel="stylesheet" href="../../codebase/dhtmlxgantt.css?v=10.0.0">
<link rel="stylesheet" href="../common/demo-controls/dhx_controls.css">
<script src="../common/demo-controls/dhx_controls.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<style>
html,
body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
overflow: hidden;
}
.workspace {
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
}
.pane_gantt {
flex: 1.4 1 0;
min-height: 300px;
}
#gantt_here { width: 100%; height: 100%; }
.pane_chart {
flex: 1 1 0;
min-height: 230px;
background: var(--dhx-panel);
border-top: 1px solid var(--dhx-line);
display: flex;
flex-direction: column;
}
.chart_head {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 16px;
border-bottom: 1px solid var(--dhx-line-2);
flex-wrap: wrap;
}
.chart_title { font: 600 13px var(--dhx-font); color: var(--dhx-ink); }
.chart_legend { display: inline-flex; gap: 16px; }
.chart_legend .lg { display: inline-flex; align-items: center; gap: 6px; font: 500 12px var(--dhx-font); color: var(--dhx-ink-2); }
.chart_legend .ln { width: 18px; height: 0; border-top-width: 2.5px; border-top-style: solid; }
.chart_legend .ln.planned { border-color: #6366f1; }
.chart_legend .ln.actual { border-color: #16a34a; }
.chart_legend .ln.fore { border-top-style: dashed; border-color: #f59e0b; }
.chart_kpis { margin-left: auto; display: inline-flex; gap: 18px; }
.chart_kpi { text-align: right; }
.chart_kpi b { display: block; font: 600 15px var(--dhx-font); color: var(--dhx-ink); font-variant-numeric: tabular-nums; }
.chart_kpi span { font: 600 10px var(--dhx-font); letter-spacing: .04em; text-transform: uppercase; color: var(--dhx-muted); }
.chart_kpi b.ahead { color: #16a34a; } .chart_kpi b.behind { color: #dc2626; }
@media (max-width: 760px) {
.chart_kpis {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
}
.chart_body { flex: 1; min-height: 0; position: relative; }
#scurve { width: 100%; height: 100%; display: block; }
.sc_grid { stroke: var(--dhx-line-2); stroke-width: 1; }
.sc_axis { stroke: var(--dhx-line); stroke-width: 1; }
.sc_label { fill: var(--dhx-muted); font: 500 10px var(--dhx-font); }
.sc_today { stroke: #ef4444; stroke-width: 1.5; stroke-dasharray: 4 4; }
.sc_today_t{ fill: #ef4444; font: 600 10px var(--dhx-font); }
.sc_planned{ fill: none; stroke: #6366f1; stroke-width: 2.5; }
.sc_actual { fill: none; stroke: #16a34a; stroke-width: 2.5; }
.sc_fore { fill: none; stroke: #f59e0b; stroke-width: 2.5; stroke-dasharray: 5 6; }
.sc_area { fill: rgba(99,102,241,.08); stroke: none; }
</style>
</head>
<body>
<!-- Header -->
<div class="dhx_header">
<div class="dhx_brand dhx_brand--static">
<button class="dhx_brand__btn">
<div class="dhx_brand__logo" style="background:linear-gradient(140deg,#6366f1,#4338ca)">SC</div>
<div class="dhx_brand__titles">
<div class="dhx_brand__title-row"><span class="dhx_brand__title">Plant commissioning</span></div>
<div class="dhx_brand__meta"><span class="dhx_brand__subtitle">Earned-value S-curve</span></div>
</div>
</button>
</div>
<div class="dhx_header__center">
<div class="dhx_seg" id="metric_seg">
<button class="dhx_btn active" data-metric="duration" onclick="setMetric('duration')">By effort</button>
<button class="dhx_btn" data-metric="cost" onclick="setMetric('cost')">By cost</button>
</div>
</div>
<div class="dhx_header__right">
<div class="dhx_seg" id="zoom_seg">
<button class="dhx_btn" data-zoom="week" onclick="setZoom('week')">Week</button>
<button class="dhx_btn" data-zoom="month" onclick="setZoom('month')">Month</button>
</div>
<button class="dhx_btn dhx_btn--icon" title="Toggle theme" onclick="toggleTheme()">
<svg class="dhx_ic" id="theme_sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/><line x1="12" y1="2.5" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="21.5"/><line x1="2.5" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="21.5" y2="12"/><line x1="5.3" y1="5.3" x2="7" y2="7"/><line x1="17" y1="17" x2="18.7" y2="18.7"/><line x1="5.3" y1="18.7" x2="7" y2="17"/><line x1="17" y1="7" x2="18.7" y2="5.3"/></svg>
<svg class="dhx_ic dhx_hidden" id="theme_moon" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 0 1 9.5 4a7 7 0 1 0 10.5 10.5z"/></svg>
</button>
</div>
</div>
<!-- Workspace -->
<div class="workspace">
<div class="pane_gantt"><div id="gantt_here"></div></div>
<div class="pane_chart">
<div class="chart_head">
<span class="chart_title" id="chart_title">Cumulative completion</span>
<span class="chart_legend">
<span class="lg"><span class="ln planned"></span> Planned</span>
<span class="lg"><span class="ln actual"></span> Actual</span>
<span class="lg"><span class="ln fore"></span> Forecast</span>
</span>
<span class="chart_kpis" id="chart_kpis"></span>
</div>
<div class="chart_body"><svg id="scurve" preserveAspectRatio="none"></svg></div>
</div>
</div>
<!-- Footer -->
<div class="dhx_footer">
<span>Drag a task or change progress: the S-curve recomputes instantly</span>
<div class="dhx_footer__right"><span id="footer_scale">Week scale</span></div>
</div>
</body>
</html>