Skip to main content

ns-ui

Gantt ASCII Critical Path

An ASCII Gantt chart where a real CPM forward/backward pass decides which bars are critical: zero-float tasks draw heavy `━` in accent, the rest light `─`, and dragging a task re-runs the pass so the critical chain moves.

Usage

registry/core/gantt-ascii-critical-path/demo.tsx
"use client";

import { GanttAsciiCriticalPath } from "./component";

export default function GanttAsciiCriticalPathDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="flex w-full max-w-3xl flex-col items-center gap-5">
        <p className="self-start font-mono text-[11px] uppercase tracking-[0.18em] text-ns-muted">
          ns-ui / gantt-ascii-critical-path
        </p>
        <div className="flex w-full justify-center overflow-x-auto rounded-md border border-border bg-surface p-7">
          <GanttAsciiCriticalPath />
        </div>
        <p className="max-w-xl text-center text-xs leading-relaxed text-ns-muted">
          The heavy accented bars are the critical path. Drag one, or nudge it with the arrow keys,
          to reschedule.
        </p>
      </div>
    </main>
  );
}

Component

registry/core/gantt-ascii-critical-path/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// GanttAsciiCriticalPath — a project schedule whose critical path is COMPUTED,
// not annotated. A real CPM pass (Kahn topological order -> ES/EF forward ->
// LS/LF backward -> totalFloat = LS - ES) runs in one useMemo and decides one
// thing: a task with zero float draws the heavy accented set (┝━━┥), every
// other task the light set (├──┤). Dragging a bar re-runs the pass on the same
// frame, so handing slack to a different chain swaps the weights mid-gesture.
//
// No canvas, no rAF: every recompute is a direct response to pointer or key
// input. Empty timeline cells render nothing at all — the ink sits in the bars,
// the week ticks and the one `┊` today column.
//
// NO DEPENDENCY ARROWS. A connector lane anchored to the successor's row can
// place a predecessor's corner glyph at the right COLUMN but never at the right
// ROW, so every edge reads as leaving whatever task sits directly above. Doing
// it truthfully needs vertical routing across intervening rows — more apparatus
// than the edge earns: the bar WEIGHT already says which chain the ship date
// rides on, and that reads at rest with no pointer.
//
// PITCH: the timeline grid is laid out in `ch` units, not pixels. In a
// monospace face 1ch is exactly one advance width, so consecutive `─`/`━`
// glyphs butt up into a continuous rule at ANY font size and in any fallback
// font — a hardcoded px pitch dashes the bars the moment the two disagree.
// The drag handler needs a pixel figure, so it measures the row grid's real
// width / cols at pointerdown rather than assuming one.
// ---------------------------------------------------------------------------

export interface GanttTask {
  /** stable id, referenced by other tasks' deps */
  id: string;
  label: string;
  /** duration in days */
  duration: number;
  /** ids this task cannot start before */
  deps?: string[];
}

export interface GanttAsciiCriticalPathProps {
  /** the scheduled tasks */
  tasks?: GanttTask[];
  /** timeline cells drawn per row */
  cols?: number;
  /** day index marked by the `┊` column. A fixed prop, never new Date() */
  today?: number;
  /** week number printed at the first tick */
  startWeek?: number;
  /** heading above the chart */
  title?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const LABEL_CHARS = 16;

const DEFAULT_TASKS: GanttTask[] = [
  { id: "discovery", label: "Discovery interviews", duration: 4, deps: [] },
  { id: "wireframes", label: "Wireframe review", duration: 3, deps: ["discovery"] },
  { id: "design-system", label: "Design system", duration: 5, deps: ["wireframes"] },
  { id: "api-contract", label: "API contract", duration: 3, deps: ["discovery"] },
  { id: "integrate-payments", label: "Integrate payments", duration: 7, deps: ["api-contract"] },
  { id: "checkout-ui", label: "Checkout UI build", duration: 6, deps: ["design-system"] },
  { id: "launch-comms", label: "Launch comms", duration: 2, deps: ["design-system"] },
  {
    id: "qa-regression",
    label: "QA regression pass",
    duration: 4,
    deps: ["checkout-ui", "integrate-payments"],
  },
  { id: "ship", label: "Production rollout", duration: 3, deps: ["qa-regression", "launch-comms"] },
];

type Computed = {
  es: number;
  ef: number;
  ls: number;
  lf: number;
  float: number;
  critical: boolean;
  cyclic: boolean;
};

function padLabel(label: string): string {
  if (label.length <= LABEL_CHARS) return label.padEnd(LABEL_CHARS, " ");
  return `${label.slice(0, LABEL_CHARS - 1)}…`;
}

export function GanttAsciiCriticalPath({
  tasks = DEFAULT_TASKS,
  cols = 32,
  today = 9,
  startWeek = 12,
  title = "Checkout replatform",
  className = "",
}: GanttAsciiCriticalPathProps) {
  const [offsets, setOffsets] = useState<Record<string, number>>({});
  const [hovered, setHovered] = useState<string | null>(null);
  const [focused, setFocused] = useState<string | null>(null);
  const [dragId, setDragId] = useState<string | null>(null);
  const dragRef = useRef<{
    id: string;
    startX: number;
    startOffset: number;
    prev: number;
    cellPx: number;
  } | null>(null);

  // --- CPM: Kahn topological order, forward pass, backward pass -------------
  const { plan, projectEnd, scale, bases } = useMemo(() => {
    const byId = new Map(tasks.map((t) => [t.id, t]));
    const indeg = new Map<string, number>();
    const succ = new Map<string, string[]>();
    for (const t of tasks) {
      const deps = (t.deps ?? []).filter((d) => byId.has(d) && d !== t.id);
      indeg.set(t.id, deps.length);
      for (const d of deps) succ.set(d, [...(succ.get(d) ?? []), t.id]);
    }
    const queue = tasks.filter((t) => (indeg.get(t.id) ?? 0) === 0).map((t) => t.id);
    const order: string[] = [];
    while (queue.length) {
      const id = queue.shift() as string;
      order.push(id);
      for (const s of succ.get(id) ?? []) {
        const n = (indeg.get(s) ?? 0) - 1;
        indeg.set(s, n);
        if (n === 0) queue.push(s);
      }
    }
    // Anything Kahn never drained sits on a cycle: excluded, never hung on.
    const inOrder = new Set(order);

    // FORWARD: ES = max(0, max(EF of deps) + manualOffset), EF = ES + duration
    const es = new Map<string, number>();
    const ef = new Map<string, number>();
    // earliest day a task could start on dependencies alone, before its manual
    // offset — the floor an ArrowLeft nudge must not drift below.
    const base0 = new Map<string, number>();
    for (const id of order) {
      const t = byId.get(id) as GanttTask;
      let base = 0;
      for (const d of t.deps ?? []) if (inOrder.has(d)) base = Math.max(base, ef.get(d) ?? 0);
      base0.set(id, base);
      const start = Math.max(0, base + (offsets[id] ?? 0));
      es.set(id, start);
      ef.set(id, start + Math.max(1, t.duration));
    }
    const end = order.reduce((m, id) => Math.max(m, ef.get(id) ?? 0), 1);

    // BACKWARD, reverse topological: LF = min(LS of successors) or projectEnd
    const ls = new Map<string, number>();
    const lf = new Map<string, number>();
    for (let i = order.length - 1; i >= 0; i--) {
      const id = order[i];
      const t = byId.get(id) as GanttTask;
      const outs = (succ.get(id) ?? []).filter((s) => inOrder.has(s));
      const late = outs.length ? Math.min(...outs.map((s) => ls.get(s) ?? end)) : end;
      lf.set(id, late);
      ls.set(id, late - Math.max(1, t.duration));
    }

    const map = new Map<string, Computed>();
    for (const t of tasks) {
      if (!inOrder.has(t.id)) {
        map.set(t.id, { es: 0, ef: 0, ls: 0, lf: 0, float: 0, critical: false, cyclic: true });
        continue;
      }
      const f = (ls.get(t.id) as number) - (es.get(t.id) as number);
      map.set(t.id, {
        es: es.get(t.id) as number,
        ef: ef.get(t.id) as number,
        ls: ls.get(t.id) as number,
        lf: lf.get(t.id) as number,
        float: f,
        // <= rather than ===: a drag can push a task to negative float
        // mid-gesture, and that row is still the tight one, not a slack one.
        critical: f <= 0,
        cyclic: false,
      });
    }
    return { plan: map, projectEnd: end, bases: base0, scale: Math.max(1, Math.ceil(end / cols)) };
  }, [tasks, offsets, cols]);

  // --- drag: pixel dx -> whole cells -> manualOffset, CPM re-runs live ------
  useEffect(() => {
    if (!dragId) return;
    const move = (e: PointerEvent) => {
      const d = dragRef.current;
      if (!d) return;
      const cells = Math.round((e.clientX - d.startX) / d.cellPx);
      setOffsets((o) => ({ ...o, [d.id]: d.startOffset + cells * scale }));
    };
    const end = () => {
      dragRef.current = null;
      setDragId(null);
    };
    const key = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      const d = dragRef.current;
      if (d) setOffsets((o) => ({ ...o, [d.id]: d.prev }));
      dragRef.current = null;
      setDragId(null);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", end);
    window.addEventListener("pointercancel", end);
    window.addEventListener("keydown", key);
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", end);
      window.removeEventListener("pointercancel", end);
      window.removeEventListener("keydown", key);
    };
  }, [dragId, scale]);

  // Clamped both ways against the task's dependency-earliest start: at −base so
  // repeated ArrowLeft on a task already sitting at day 0 cannot accumulate
  // invisible negative offset that a later ArrowRight has to spend itself
  // unwinding, and at cols*scale − base so held ArrowRight cannot run ES past
  // aria-valuemax while the bar saturates at the last column. aria-valuenow
  // (the clamped ES) therefore stays in step with the stored offset.
  const nudge = (id: string, dir: number) =>
    setOffsets((o) => {
      const base = bases.get(id) ?? 0;
      const next = (o[id] ?? 0) + dir * scale;
      return { ...o, [id]: Math.min(cols * scale - base, Math.max(-base, next)) };
    });

  const colOf = (day: number) => Math.floor(day / scale);
  const todayCol = colOf(today);

  const geom = (id: string) => {
    const c = plan.get(id) as Computed;
    const start = Math.min(cols - 2, Math.max(0, colOf(c.es)));
    const len = Math.max(2, Math.min(cols - start, Math.round((c.ef - c.es) / scale)));
    return { start, len, c };
  };

  const active = hovered ?? focused;

  const readout = (() => {
    if (!active) {
      const crit = tasks.filter((t) => plan.get(t.id)?.critical && !plan.get(t.id)?.cyclic).length;
      const slack = tasks.length - crit;
      return `${projectEnd} days  critical chain ${crit} tasks  ${slack} tasks carry float`;
    }
    const t = tasks.find((x) => x.id === active) as GanttTask;
    const c = plan.get(active) as Computed;
    if (c.cyclic) return `${t.label}  ×  circular dependency, excluded from the pass`;
    return `${t.label}  ES ${c.es}  EF ${c.ef}  LS ${c.ls}  LF ${c.lf}  float ${c.float} day${
      c.float === 1 ? "" : "s"
    }`;
  })();

  // 1ch === one monospace advance, so `─` runs join with no seam at any size.
  const cell = "inline-block text-center";
  const cellStyle = { width: "1ch" } as const;
  const labelStyle = { width: `${LABEL_CHARS}ch` } as const;
  const trackStyle = { gridTemplateColumns: `repeat(${cols}, 1ch)` } as const;

  return (
    <section
      data-gantt
      aria-label={`${title} — Gantt chart with computed critical path`}
      className={`inline-flex select-none flex-col gap-3 font-mono text-[15px] leading-none ${className}`}
    >
      <div className="flex items-baseline justify-between gap-6">
        <span className="text-[11px] uppercase tracking-[0.18em] text-ns-muted">{title}</span>
        <span className="text-[10px] uppercase tracking-[0.18em] text-ns-muted">
          <span className="text-ns-accent">━</span> critical &nbsp; ─ has float
        </span>
      </div>

      <div className="flex flex-col gap-[3px]">
        {/* week labels */}
        <div className="flex items-center">
          <span className="shrink-0 whitespace-pre" style={labelStyle} />
          <span className={cell} style={cellStyle} />
          <div className="grid" style={trackStyle}>
            {Array.from({ length: cols }, (_, i) => {
              const day = i * scale;
              if (day % 7 !== 0) return null;
              return (
                <span
                  key={i}
                  className="whitespace-pre text-[10px] leading-none text-ns-muted"
                  style={{ gridColumn: `${i + 1} / span 4` }}
                >
                  {`W${startWeek + day / 7}`}
                </span>
              );
            })}
          </div>
        </div>

        {/* ruler: `┬` every 7 days over a `─` rule, `┊` at today */}
        <div className="flex items-center">
          <span className="shrink-0 whitespace-pre" style={labelStyle} />
          <span className={`${cell} text-border`} style={cellStyle}>
            ┌
          </span>
          <div className="flex">
            {Array.from({ length: cols }, (_, i) => {
              const day = i * scale;
              const tick = day % 7 === 0;
              const isToday = i === todayCol;
              return (
                <span
                  key={i}
                  className={`${cell} ${
                    isToday ? "text-foreground" : tick ? "text-ns-muted" : "text-ns-muted/40"
                  }`}
                  style={cellStyle}
                >
                  {isToday ? "┊" : tick ? "┬" : "─"}
                </span>
              );
            })}
          </div>
        </div>

        {tasks.map((t) => {
          const { start, len, c } = geom(t.id);
          const isActive = active === t.id;
          const barGlyphs = c.critical
            ? `┝${"━".repeat(Math.max(0, len - 2))}┥`
            : `├${"─".repeat(Math.max(0, len - 2))}┤`;
          const todayFree = todayCol < start || todayCol >= start + len;

          return (
            <div
              key={t.id}
              data-task-row={t.id}
              className="flex items-center"
              onPointerEnter={() => setHovered(t.id)}
              onPointerLeave={() => setHovered((h) => (h === t.id ? null : h))}
            >
              <span
                className={`shrink-0 whitespace-pre text-[13px] transition-colors duration-[140ms] motion-reduce:transition-none ${
                  isActive || c.critical ? "text-foreground" : "text-ns-muted"
                }`}
                style={labelStyle}
              >
                {padLabel(t.label)}
              </span>
              <span className={`${cell} text-border`} style={cellStyle}>
                │
              </span>

              {c.cyclic ? (
                <span className="whitespace-pre pl-1 text-[13px] text-ns-muted">
                  ×  cycle — excluded
                </span>
              ) : (
                <div className="relative grid" style={trackStyle}>
                  <button
                    type="button"
                    role="slider"
                    aria-orientation="horizontal"
                    aria-valuemin={0}
                    aria-valuemax={cols * scale}
                    aria-valuenow={c.es}
                    aria-valuetext={`starts day ${c.es}, ends day ${c.ef}, ${
                      c.critical ? "on the critical path, no float" : `${c.float} days of float`
                    }`}
                    aria-label={`${t.label} schedule. Drag or use arrow keys to reschedule.`}
                    className="flex cursor-ew-resize items-center rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ns-accent"
                    style={{ gridColumn: `${start + 1} / span ${len}`, gridRow: 1 }}
                    onFocus={() => setFocused(t.id)}
                    onBlur={() => setFocused((f) => (f === t.id ? null : f))}
                    onPointerDown={(e) => {
                      // Measure the real pitch off the laid-out track instead
                      // of trusting a px constant to match the font metric.
                      const track = e.currentTarget.parentElement;
                      const w = track ? track.getBoundingClientRect().width : 0;
                      dragRef.current = {
                        id: t.id,
                        startX: e.clientX,
                        startOffset: offsets[t.id] ?? 0,
                        prev: offsets[t.id] ?? 0,
                        cellPx: w > 0 ? w / cols : 9,
                      };
                      setDragId(t.id);
                    }}
                    onKeyDown={(e) => {
                      if (e.key === "ArrowLeft") {
                        e.preventDefault();
                        nudge(t.id, -1);
                      } else if (e.key === "ArrowRight") {
                        e.preventDefault();
                        nudge(t.id, 1);
                      } else if (e.key === "Escape" && !dragId) {
                        // Guarded on !dragId: mid-drag the window keydown
                        // listener owns Escape (it restores the pre-drag
                        // offset) and both firing would fight.
                        e.preventDefault();
                        setOffsets((o) => ({ ...o, [t.id]: 0 }));
                      }
                    }}
                  >
                    {barGlyphs.split("").map((ch, k) => (
                      <span
                        key={k}
                        className={`${cell} ${c.critical ? "text-ns-accent" : "text-ns-muted"}`}
                        style={cellStyle}
                      >
                        {ch}
                      </span>
                    ))}
                  </button>

                  {todayFree && todayCol >= 0 && todayCol < cols && (
                    <span
                      aria-hidden
                      className={`${cell} text-foreground`}
                      style={{
                        gridColumn: `${todayCol + 1} / span 1`,
                        gridRow: 1,
                        width: "1ch",
                      }}
                    >
                      ┊
                    </span>
                  )}
                </div>
              )}
            </div>
          );
        })}
      </div>

      <p
        aria-live="polite"
        className="rounded-sm border border-border bg-background px-3 py-2 text-[11px] tabular-nums text-ns-muted"
      >
        {readout}
      </p>
    </section>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/gantt-ascii-critical-path.tsx, so point the import there.

Use when Pick gantt-ascii-critical-path when the point is which chain the delivery date rides on, and the reader should be able to drag a task and watch criticality move to a different chain. schedule-ascii-freebusy is for finding a free slot across several people's calendars rather than a dependency-scheduled plan; timeline-agent-lanes is for concurrent runtime activity in swimlanes; timeline-changelog-wave is for a chronological event feed with no dependency math.

Install

npx shadcn add https://design.helpmarq.com/r/gantt-ascii-critical-path.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <GanttAsciiCriticalPath tasks? cols? today? startWeek? title? className?> as pure DOM box-drawing text — no canvas, no rAF, every recompute a direct response to pointer or keyboard input. MECHANISM: a genuine Critical Path Method pass, computed in ONE useMemo keyed on tasks + manualOffset + cols. Topological order comes from Kahn's algorithm over the dependency graph (indegree map, zero-indegree queue, self-edges and dangling dep ids filtered out first); any task Kahn never drains sits on a cycle, so it is excluded from the pass and renders its row as `× cycle — excluded` rather than hanging. FORWARD pass in topological order: ES = max(0, max(EF of deps) + manualOffset[id]), EF = ES + duration; projectEnd = max(EF). BACKWARD pass in REVERSE topological order: LF = min(LS of successors), or projectEnd when a task has none; LS = LF − duration; totalFloat = LS − ES; critical ⇔ totalFloat <= 0 (the <= rather than === is what keeps a row well-formed when a drag pushes a task into negative float mid-gesture — it is still the tight chain, not a slack one). ROW: a 16-char label column truncated with `…`, a `│` rule, then `cols` timeline cells (default 32) at scale = max(1, ceil(projectEnd / cols)) days per cell. BAR: non-critical = `├` + `─`×(len−2) + `┤` in text-ns-muted; critical = `┝` + `━`×(len−2) + `┥` in text-ns-accent — the WEIGHT change is the primary encoding, so the critical chain still reads in a pure-monochrome screenshot and hue is only reinforcement. Nothing else is drawn on the row: no slack run, no float glyphs — bar weight is the only encoding, and float stays a number in the readout. PITCH (the constant that makes or breaks the drawing): the timeline track is `grid-template-columns: repeat(cols, 1ch)` and every glyph cell is `width: 1ch`, NOT a hardcoded pixel figure. In a monospace face 1ch is exactly one advance width, so consecutive `─`/`━` glyphs butt into a continuous rule at any font size and under any fallback font; a px constant that disagrees with the font metric by even a pixel draws every bar as spaced dashes, which is the single most common way this component is got wrong. The drag handler is the one place a pixel number is needed, and it MEASURES it — `track.getBoundingClientRect().width / cols` read off the laid-out row at pointerdown — instead of assuming one. HEADER: `W12`-style labels computed from DAYS (startWeek + day/7, so they stay truthful at any scale) over a rule of `─` with `┬` at every 7-day tick; today is a fixed day-index prop (default 9, never derived from new Date(), so the frame is deterministic for a screenshot gate) drawn as a `┊` column in text-foreground repeated down every row, and drawn only where no bar already occupies that cell so glyphs never collide. Empty timeline cells render literally nothing — the ink concentrates in the bars, the ticks and the one `┊` instead of washing the frame in low-contrast filler. NO DEPENDENCY ARROWS ARE DRAWN, deliberately: a connector lane anchored to the successor's row can only place a predecessor's corner glyph at the right COLUMN, never at the right ROW, so every edge reads as leaving whichever task happens to sit directly above the successor; drawing edges truthfully needs vertical routing across intervening rows, which is more apparatus than the edge earns here. Dependencies stay in the data, where they drive ES/EF/LS/LF — the bar WEIGHT is what tells the reader which chain the ship date rides on, and that reads at rest with no pointer. INTERACTION: pointerdown on a bar records startX, the current offset and the measured cell pitch, then attaches window pointermove/pointerup/keydown listeners (window listeners, not setPointerCapture, which a synthetic pointerId would throw on); pointermove converts pixel dx to whole cells via round(dx / cellPx) and writes manualOffset[id], re-running the CPM memo on the same frame — which is the whole point: a task you drag can hand criticality to a different chain and you watch the `━`/`─` weights swap live. Escape during a drag restores the previous offset; Escape on a focused bar clears its manual offset entirely, returning the task to its computed schedule; ArrowLeft/ArrowRight nudge the focused task ±1 cell through the identical code path, clamped to [−base, cols*scale − base] where base is the task's dependency-earliest start: the lower clamp stops a left nudge on a task already sitting at day 0 from banking invisible negative offset that the next right nudge would spend unwinding, the upper stops a held right nudge from running ES past aria-valuemax while the bar saturates at the last column — every keypress moves the bar, and aria-valuenow stays in step with the stored offset. Hovering or focusing a task brightens its label and prints `Integrate payments ES 7 EF 14 LS 11 LF 18 float 4 days` into an aria-live=polite readout, which at rest prints the project summary (`25 days critical chain 6 tasks 3 tasks carry float`) instead. THEME: every colour is a token class (text-ns-accent, text-ns-muted, text-ns-muted/40, text-foreground, text-border, border-border, bg-background) — no hex anywhere, so light and dark both render correctly. --border is reserved for the structural `│` and `┌` rules, which may safely recede. REDUCED MOTION: there is no animation loop at all; the only transition is the 140ms colour ease on the row label, carrying motion-reduce:transition-none so prefers-reduced-motion snaps instead of easing and nothing is lost. A11Y: each bar is a real <button> carrying role=slider with aria-orientation=horizontal, aria-valuemin=0, aria-valuemax=cols*scale, aria-valuenow=ES and an aria-valuetext naming its day range and float — the arrow keys are then the role's own expected operation rather than an undocumented extra on a button — plus an aria-label naming the task and the available action, a visible focus-visible:ring-2 ring-inset ring-ns-accent on every bar, and full arrow-key operation; the readout is aria-live=polite so a screen reader hears the recomputed float. Props: tasks ({id,label,duration,deps}[], default a 9-task checkout replatform plan whose zero-float chain is discovery → wireframes → design-system → checkout-ui → qa-regression → ship, with api-contract carrying 4 days of float, integrate-payments 4 and launch-comms 8), cols (default 32), today (day index, default 9), startWeek (default 12), title, className.

Props

PropTypeDefaultDescription
tasks?GanttTask[]DEFAULT_TASKSthe scheduled tasks
cols?number32timeline cells drawn per row
today?number9day index marked by the `┊` column. A fixed prop, never new Date()
startWeek?number12week number printed at the first tick
title?string"Checkout replatform"heading above the chart
className?string—extra classes merged onto the rendered root element