From d18c3413520516e6a64f6edf4a7bb0c50453b09a Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sat, 24 Jan 2026 13:53:12 -0700 Subject: [PATCH] feat(gantt): add zoom controls and pan mode (WIP) zoom in/out buttons and ctrl+scroll work. pan mode toggle added (pointer vs grab) but vertical panning still broken - needs different approach for navigating the chart body independently of the sticky header. --- CLAUDE.md | 10 +++ src/components/schedule/gantt-chart.tsx | 89 +++++++++++++++++-- src/components/schedule/gantt.css | 8 ++ .../schedule/schedule-gantt-view.tsx | 57 +++++++++++- 4 files changed, 153 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6999869..d815219 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,3 +66,13 @@ key bindings: - `ASSETS` - static asset serving - `IMAGES` - cloudflare image optimization - `WORKER_SELF_REFERENCE` - self-reference for caching + +known issues (WIP) +--- + +- gantt chart pan/zoom: zoom controls (+/-) and ctrl+scroll work. pan mode + toggle (pointer/grab) exists but vertical panning does not work correctly + yet - the scroll-based approach conflicts with how frappe-gantt sizes its + container. horizontal panning works. needs a different approach for + vertical navigation (possibly a custom viewport with transform-based + rendering for the body while keeping the header fixed separately). diff --git a/src/components/schedule/gantt-chart.tsx b/src/components/schedule/gantt-chart.tsx index cae6917..51afbdb 100755 --- a/src/components/schedule/gantt-chart.tsx +++ b/src/components/schedule/gantt-chart.tsx @@ -1,6 +1,6 @@ "use client" -import { useRef, useEffect, useState } from "react" +import { useRef, useEffect, useState, useCallback } from "react" import type { FrappeTask } from "@/lib/schedule/gantt-transform" import "./gantt.css" @@ -9,24 +9,85 @@ type ViewMode = "Day" | "Week" | "Month" interface GanttChartProps { tasks: FrappeTask[] viewMode: ViewMode + columnWidth?: number + panMode?: boolean onDateChange?: ( task: FrappeTask, start: Date, end: Date ) => void onProgressChange?: (task: FrappeTask, progress: number) => void + onZoom?: (direction: "in" | "out") => void } export function GanttChart({ tasks, viewMode, + columnWidth, + panMode = false, onDateChange, onProgressChange, + onZoom, }: GanttChartProps) { const containerRef = useRef(null) + const wrapperRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const ganttRef = useRef(null) const [loaded, setLoaded] = useState(false) + // pan state - scrolls the .gantt-container directly + const isPanning = useRef(false) + const panStartX = useRef(0) + const panStartY = useRef(0) + const panScrollLeft = useRef(0) + const panScrollTop = useRef(0) + const ganttContainerRef = useRef(null) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + const canPan = e.button === 1 || (panMode && e.button === 0) + if (!canPan) return + e.preventDefault() + const gc = ganttContainerRef.current + if (!gc) return + isPanning.current = true + panStartX.current = e.clientX + panStartY.current = e.clientY + panScrollLeft.current = gc.scrollLeft + panScrollTop.current = gc.scrollTop + const wrapper = wrapperRef.current + if (wrapper) wrapper.style.cursor = "grabbing" + }, [panMode]) + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isPanning.current) return + const gc = ganttContainerRef.current + if (!gc) return + gc.scrollLeft = panScrollLeft.current - (e.clientX - panStartX.current) + gc.scrollTop = panScrollTop.current - (e.clientY - panStartY.current) + }, []) + + const handleMouseUp = useCallback(() => { + if (!isPanning.current) return + isPanning.current = false + const wrapper = wrapperRef.current + if (wrapper) wrapper.style.cursor = "" + }, []) + + // ctrl+scroll zoom + useEffect(() => { + const wrapper = wrapperRef.current + if (!wrapper || !onZoom) return + + const handleWheel = (e: WheelEvent) => { + if (!e.ctrlKey) return + e.preventDefault() + onZoom(e.deltaY < 0 ? "in" : "out") + } + + wrapper.addEventListener("wheel", handleWheel, { passive: false }) + return () => wrapper.removeEventListener("wheel", handleWheel) + }, [onZoom]) + useEffect(() => { if (!containerRef.current || tasks.length === 0) return @@ -53,13 +114,14 @@ export function GanttChart({ ganttRef.current = new Gantt(containerRef.current, ganttTasks, { view_mode: viewMode, - on_date_change: (task: any, start: Date, end: Date) => { + ...(columnWidth ? { column_width: columnWidth } : {}), + on_date_change: (task: { id: string }, start: Date, end: Date) => { if (onDateChange) { const original = tasks.find((t) => t.id === task.id) if (original) onDateChange(original, start, end) } }, - on_progress_change: (task: any, progress: number) => { + on_progress_change: (task: { id: string }, progress: number) => { if (onProgressChange) { const original = tasks.find((t) => t.id === task.id) if (original) onProgressChange(original, progress) @@ -67,13 +129,14 @@ export function GanttChart({ }, }) - // remove overflow from inner gantt-container so popup isn't clipped - // the parent wrapper handles horizontal scrolling instead + // constrain gantt-container to wrapper height so content overflows + // this enables scroll-based panning while keeping the header sticky const ganttContainer = containerRef.current.querySelector( ".gantt-container" ) as HTMLElement | null if (ganttContainer) { - ganttContainer.style.overflow = "visible" + ganttContainer.style.height = "100%" + ganttContainerRef.current = ganttContainer } setLoaded(true) @@ -81,7 +144,7 @@ export function GanttChart({ initGantt() return () => { cancelled = true } - }, [tasks, viewMode, onDateChange, onProgressChange]) + }, [tasks, viewMode, columnWidth, onDateChange, onProgressChange]) useEffect(() => { if (ganttRef.current && loaded) { @@ -98,8 +161,16 @@ export function GanttChart({ } return ( -
-
+
+
) } diff --git a/src/components/schedule/gantt.css b/src/components/schedule/gantt.css index f682b29..0b36205 100755 --- a/src/components/schedule/gantt.css +++ b/src/components/schedule/gantt.css @@ -1,3 +1,11 @@ +/* hide scrollbars on gantt-container (panning handles navigation) */ +.gantt-container { + scrollbar-width: none; +} +.gantt-container::-webkit-scrollbar { + display: none; +} + /* frappe-gantt base styles (vendored from frappe-gantt/dist/frappe-gantt.css) */ :root{--g-arrow-color: #1f2937;--g-bar-color: #fff;--g-bar-border: #fff;--g-tick-color-thick: #ededed;--g-tick-color: #f3f3f3;--g-actions-background: #f3f3f3;--g-border-color: #ebeff2;--g-text-muted: #7c7c7c;--g-text-light: #fff;--g-text-dark: #171717;--g-progress-color: #dbdbdb;--g-handle-color: #37352f;--g-weekend-label-color: #dcdce4;--g-expected-progress: #c4c4e9;--g-header-background: #fff;--g-row-color: #fdfdfd;--g-row-border-color: #c7c7c7;--g-today-highlight: #37352f;--g-popup-actions: #ebeff2;--g-weekend-highlight-color: #f7f7f7} .gantt-container{line-height:14.5px;position:relative;isolation:isolate;overflow:auto;font-size:12px;height:var(--gv-grid-height);width:100%;border-radius:8px} diff --git a/src/components/schedule/schedule-gantt-view.tsx b/src/components/schedule/schedule-gantt-view.tsx index b836d91..51899d5 100755 --- a/src/components/schedule/schedule-gantt-view.tsx +++ b/src/components/schedule/schedule-gantt-view.tsx @@ -22,6 +22,10 @@ import { IconChevronRight, IconChevronDown, IconUsers, + IconZoomIn, + IconZoomOut, + IconPointer, + IconHandGrab, } from "@tabler/icons-react" import { GanttChart } from "./gantt-chart" import { TaskFormDialog } from "./task-form-dialog" @@ -65,6 +69,25 @@ export function ScheduleGanttView({ null ) + const [panMode, setPanMode] = useState(false) + + const defaultWidths: Record = { + Day: 38, Week: 140, Month: 120, + } + const [columnWidth, setColumnWidth] = useState(defaultWidths[viewMode]) + + const handleZoom = useCallback((direction: "in" | "out") => { + setColumnWidth((prev) => { + const next = direction === "in" ? prev * 1.3 : prev / 1.3 + return Math.round(Math.min(300, Math.max(20, next))) + }) + }, []) + + const handleViewModeChange = (mode: ViewMode) => { + setViewMode(mode) + setColumnWidth(defaultWidths[mode]) + } + const filteredTasks = showCriticalPath ? tasks.filter((t) => t.isCriticalPath) : tasks @@ -136,7 +159,7 @@ export function ScheduleGanttView({ key={mode} size="sm" variant={viewMode === mode ? "default" : "outline"} - onClick={() => setViewMode(mode)} + onClick={() => handleViewModeChange(mode)} > {mode} @@ -148,6 +171,33 @@ export function ScheduleGanttView({ > Today + + +
@@ -299,11 +349,14 @@ export function ScheduleGanttView({ -
+