
Multi-team resource scheduling lanes displaying overlapping task intervals.
components/tanstack-charts/bar/scrollable-resource-lanes.tsx
import { type ChartPoint, defineChart, rect } from "@tanstack/charts";
import { motion } from "@tanstack/charts/motion";
import { tooltip } from "@tanstack/charts/tooltip";
import { Chart } from "@tanstack/react-charts/core";
import { useMemo, useState } from "react";
import { scaleBand, scaleUtc } from "d3-scale";
import { GanttChartSquare } from "lucide-react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
interface ResourceTask {
id: string;
resource: string;
label: string;
status: "completed" | "in-progress" | "planned";
start: Date;
end: Date;
}
const motionRenderer = motion({
initial: "always",
transition: { type: "spring", stiffness: 170, damping: 18, mass: 1 },
});
const resourceLanes = ["DevOps Team", "Frontend Team", "Backend Team", "Design Team", "QA Security"];
const statuses = ["completed", "in-progress", "planned"] as const;
const statusColors: Record<string, string> = {
completed: "var(--chart-1)",
"in-progress": "var(--chart-2)",
planned: "var(--chart-3)",
};
function generateDemoData(): ResourceTask[] {
const tasks: ResourceTask[] = [];
const baseDate = new Date(Date.UTC(2024, 8, 1)).getTime();
const dayMs = 24 * 60 * 60 * 1000;
resourceLanes.forEach((lane, lIdx) => {
for (let t = 0; t < 3; t++) {
const startDay = lIdx * 3 + t * 8 + Math.floor(Math.random() * 2);
const duration = 4 + Math.floor(Math.random() * 5);
tasks.push({
id: `${lane}-${t}`,
resource: lane,
label: `Sprint Task ${lIdx + 1}.${t + 1}`,
status: statuses[(lIdx + t) % 3],
start: new Date(baseDate + startDay * dayMs),
end: new Date(baseDate + (startDay + duration) * dayMs),
});
}
});
return tasks;
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
if (points.length === 0) return { title: "", rows: [] };
const d = points[0].datum as ResourceTask;
if (!d) return { title: "", rows: [] };
const fmt = (dt: any) => {
if (!dt) return "";
const dateObj = dt instanceof Date ? dt : new Date(dt);
return isNaN(dateObj.getTime())
? ""
: dateObj.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
};
return {
title: `${d.resource}: ${d.label}`,
rows: [
{ label: "Status", value: d.status.toUpperCase(), color: statusColors[d.status] },
{ label: "Schedule Range", value: `${fmt(d.start)} - ${fmt(d.end)}`, color: "var(--foreground)" },
],
};
}
export function ScrollableResourceLanes() {
const data = useMemo(() => generateDemoData(), []);
const [focusedTask, setFocusedTask] = useState<ResourceTask | null>(null);
const fullDomain = useMemo(() => {
return [new Date(Date.UTC(2024, 8, 1)), new Date(Date.UTC(2024, 9, 15))];
}, []);
const definition = useMemo(
() =>
defineChart(
{
renderer: motionRenderer,
marks: [
rect(data, {
key: "id",
x1: "start",
x2: "end",
y: "resource",
color: "status",
inset: 4,
radius: 4,
stroke: "var(--background)",
strokeWidth: 1.5,
}),
],
scales: {
x: {
scale: scaleUtc().domain(fullDomain),
grid: true,
axis: {
ticks: {
format: (v: any) => {
const dateObj = v instanceof Date ? v : new Date(v);
return isNaN(dateObj.getTime())
? ""
: dateObj.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
},
},
},
},
y: {
scale: scaleBand<string>().domain(resourceLanes).paddingInner(0.15),
grid: false,
axis: false,
},
},
color: {
domain: ["completed", "in-progress", "planned"],
range: ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"],
},
margin: { top: 16, right: 24, bottom: 32, left: 110 },
theme: {
foreground: "var(--muted-foreground)",
grid: "var(--border)",
background: "transparent",
},
},
{
keyboard: true,
tooltip: {
use: tooltip,
className:
"font-sans shadow-none rounded-md bg-popover text-popover-foreground border p-2 text-xs",
anchor: "pointer",
placement: "auto",
content: (points) => {
if (points[0]?.datum) setFocusedTask(points[0].datum as ResourceTask);
return shadcnTooltipContent(points);
},
},
},
),
[data, fullDomain],
);
return (
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Horizontal Gantt Resource Timeline Lanes</CardTitle>
<CardDescription>
Multi-team resource scheduling lanes displaying task duration spans and status codes
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[260px] w-full overflow-x-auto">
<div className="h-full min-w-[500px]">
<Chart
definition={definition}
renderer={motionRenderer}
height={260}
ariaLabel="Horizontal Gantt Resource Timeline Lanes Chart"
/>
</div>
</div>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
{focusedTask
? `Selected: ${focusedTask.resource} - ${focusedTask.label}`
: "Hover task blocks to reveal detailed sprint timelines"}{" "}
<GanttChartSquare className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-muted-foreground leading-none">
Band scale Y coordinates pair with UTC timeline X ranges for Gantt task rendering
</div>
</CardFooter>
</Card>
);
}
export default ScrollableResourceLanes;