Skip to main content
Back to examples
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>