Drag one bar, the plan follows
Move a task and every task that depends on it slides into place while you are still dragging, skipping weekends and respecting lags, in well under a millisecond. The page calculates nothing: each date on the chart comes from plea-core, a Rust scheduling engine running in your browser as WebAssembly.
This is the moment that sells a planning tool. Users stop pressing “recalculate” because there is nothing to press: the plan is always consistent, and they see the consequence of a decision before they make it. It is also the part that is hard to build well. The moment you add working calendars, start-to-start links with lag and a milestone with two predecessors, “shift the successors by the same amount” stops being correct.
The chart only draws
The demo is written in Svelte, using the bars, links, and timeline from Svelte Planning Kit. It turns pointer movement into a date and draws the engine’s result. It does not know that Saturday is not a working day, what “start-to-start with one day of lag” means, or which tasks are on the critical path. All of that lives in plea-core, a scheduling engine written in Rust, compiled to a WebAssembly module of about 1 MB and exposed as a small Projectobject.
The plan is ten tasks of a website relaunch with twelve links: finish-to-start almost everywhere, two start-to-start links with lag, one milestone that waits for two branches, and a working calendar of Monday to Friday with a lunch break. Try switching “Skip weekends” off and dragging again: the same links, a different calendar, and the engine gives different dates.
Loading the plan
The engine is created once and handed the whole plan through solve. Every task is deliberately given the same start date: the plan you see on load is the engine’s answer, not something the page laid out.
import init, { Project } from "https://dhtmlx.com/docs/products/planning-engine/demos/codebase/plen-wasm/index.mjs";
await init(); // fetch and compile the .wasm once
const project = new Project({ dates: "date" }); // results come as Date values
const result = project.solve({
calendars: [{ id: "global", worktime: {
hours: ["8:00-12:00", "13:00-17:00"],
days: [0, 1, 1, 1, 1, 1, 0], // Sun..Sat
} }],
tasks: [
{ id: 1, start_date: "2026-08-10 00:00", duration: 2 },
{ id: 2, start_date: "2026-08-10 00:00", duration: 3 },
// …every task starts on day one; the engine places them
],
links: [
{ id: 1, source: 1, target: 2, type: "fs" },
{ id: 4, source: 3, target: 5, type: "ss", lag: 1 },
// …
],
}, { analytics: { criticalPath: true }, includeUnchanged: true });
baseId = result.baselineId; // the baseline every preview refers tosolve returns updates, the dates it decided, and abaselineId that names the accepted baseline. With includeUnchanged the first call lists every task, start and end included, so the chart never computes an end date itself. Later calls list only the tasks that moved, and the chart lays them over the map it keeps.
// Later calls report only what changed, so the chart keeps a map of dates
// and lays each update over a copy of it. The dates are already Date values:
// no parsing and no calendar math happens here.
function merge(source, updates) {
const map = new Map(source);
for (const u of updates) {
const prev = map.get(u.id) ?? {};
map.set(u.id, {
start: u.start_date ?? prev.start,
end: u.end_date ?? prev.end,
});
}
return map;
}Dragging is a preview
While the pointer moves, the demo calls preview with a single change: this task now starts on that day. The engine applies it to a temporary copy of the plan, reschedules whatever it affects and returns the tasks that differ from the baseline. The real project never changes, so a drag you abandon costs nothing.
// pointermove: ask "what if?" on a copy of the plan. Nothing is committed.
const result = project.preview(
{ tasks: { update: [{ id: drag.id, start_date: fmt(start) }] } },
{ baselineId: baseId, reasons: true, analytics: { criticalPath: true } },
);
view = merge(base, result.updates); // the accepted plan with the preview laid over it
// Svelte updates the bars, arrows and ghosts from `view`Two details are worth noticing when you drag. First, the engine is in charge, not the pointer: pull a task earlier than its predecessor allows and it stays where its links say it must, and the panel below the chart tells you it was held back. Second, the dashed outlines are the accepted plan, so you always see where each bar was and where it would go. The baselineId option pins the preview to a specific baseline; if the project has moved on since, the call fails with astale-baseline error instead of quietly answering about the wrong plan.
Dropping commits
On release the same change goes through apply, which edits the accepted baseline and marks it unsolved, and then solve(null) reschedules the plan the engine already holds and returns the new baseline id. Because the engine keeps the plan in memory between calls, only the change crosses the JavaScript boundary. That is what keeps a drag responsive on large plans: you send one small edit, not the project.
// pointerup: make it durable, then solve the new baseline.
project.apply({ tasks: { update: [{ id, start_date: fmt(start) }] } });
const result = project.solve(null, { analytics: { criticalPath: true } });
base = merge(view, result.updates); // the preview is what apply + solve produce; any difference is laid over it
baseId = result.baselineId; // later previews must name this baselineWhy did that move?
Passing reasons: true makes every update carry a cause: pushed by a predecessor through a given link, pulled by a successor, placed by its own constraint, or kept at its own date. The list under the chart is built straight from that. It costs the UI a few lines and it is what makes a plan explain itself instead of just changing.
// With { reasons: true } every update says why it moved.
const reasonText = (r) => {
if (r.kind === "asap") return `pushed by “${name(r.sourceId)}”`;
if (r.kind === "alap") return `pulled by “${name(r.targetId)}”`;
if (r.kind === "constraint") return "placed by its own constraint";
return "kept at its own date";
};The pink bars and arrows are the critical path, returned by asking foranalytics: { criticalPath: true } on the same calls. Drag a task that is not critical inside its slack and the path does not move; drag one that is, and the launch date moves with it. The “Plan” panel shows the effect on the launch milestone in days.
How far does it go?
Ten tasks are a toy, and the demo is honest about that. The point is the shape of the integration, and it does not change with size. Each drag step is one preview of a single edit to the plan the engine already holds, and the toolbar shows how long the last call took. To see how the same calls behave on plans of up to 10,000 tasks, run theengine speed demo: it timessolve, preview, leveling and evaluation in your browser.
What the demo leaves out on purpose: summary tasks and constraints, which the engine supports; resource leveling, which has its own demo; and the polish a production Gantt needs, like zooming, keyboard editing and touch handling. Those belong to the UI layer, and the engine works with any of them.