Skip to content

GanttChart Component Code

Dependencies

This component requires:

  • Vue 3 with Composition API
  • Pinia for state management
  • D3.js for SVG rendering (d3-selection, d3-scale, d3-axis, d3-time, d3-drag)
  • Moment.js for date manipulation
  • OMenu component for dropdown menus
  • Composables: useGanttZoomPan, useGanttDatePicker, useGanttContextMenu, useGanttLazyLoading
  • Store: ganttChart.store.js

Component Structure

The GanttChart component is organized into several files:

src/
├── components/
│   └── GanttChart/
│       └── GanttChart.vue       # Main component (5023 lines)
├── composables/
│   ├── useGanttZoomPan.js       # Zoom and pan functionality
│   ├── useGanttDatePicker.js    # Date picker management
│   ├── useGanttContextMenu.js   # Context menu operations
│   └── useGanttLazyLoading.js   # Lazy loading logic
└── stores/
    └── ganttChart.store.js      # Pinia store for state management

Main Component

The main GanttChart component is quite large (5023 lines). Here's the template structure:

vue
<template>
  <div class="gantt-wrapper">
    <!-- Header with timeline -->
    <div class="gantt-header" ref="headerRef">
      <svg class="chart-header-svg" ref="headerSvgRef"></svg>
    </div>

    <!-- Main content area with task bars -->
    <div
      class="gantt-content"
      ref="containerRef"
      @mousemove="handleMouseMoveGanttChart"
      @mouseleave="showCursorToolTip = false"
    >
      <svg ref="svgRef" class="gantt-chart-svg"></svg>
    </div>

    <!-- Add milestone tooltip -->
    <div
      v-if="showCursorToolTip"
      class="add-milestone-pointer-tooltip"
      :style="cursorTooltipPosition"
    >
      <svg><!-- Plus icon --></svg>
      Add Milestone
    </div>

    <!-- Zoom and pan controls -->
    <div class="zoom-controls-pan-view-button-wrapper">
      <!-- View mode selector -->
      <OMenu ref="viewModeMenu" placement="bottom-start">
        <template #trigger>
          <div class="button-wrapper view-mode-button">
            <span class="view-mode-text">{{ currentViewModeLabel }}</span>
          </div>
        </template>
        <template #content>
          <div class="view-mode-menu-content">
            <div
              v-for="mode in viewModes"
              :key="mode.value"
              class="view-mode-menu-item"
              @click="selectViewMode(mode.value)"
            >
              {{ mode.label }}
            </div>
          </div>
        </template>
      </OMenu>

      <!-- Zoom controls -->
      <div class="zoom-controls-wrapper">
        <div class="button-wrapper" @click="onClickZoomControl('minus')">
          <!-- Minus icon -->
        </div>
        <div class="button-wrapper" @click="onClickZoomControl('plus')">
          <!-- Plus icon -->
        </div>
      </div>

      <!-- Pan view button -->
      <div
        class="pan-view-button-wrapper"
        :class="{ 'is-active': isPanModeActive }"
        @click="onClickPanViewButton"
      >
        <!-- Hand icon -->
      </div>
    </div>

    <!-- Project start date button -->
    <div class="start-project-schedule-buttons-wrapper">
      <div class="button-wrapper" @click="onClickProjectScheduleDateButton">
        <!-- Arrow icon -->
      </div>
    </div>

    <!-- Date picker (invisible trigger positioned dynamically) -->
    <div
      ref="datePickerTrigger"
      class="date-picker-trigger"
      :style="datePickerTriggerStyle"
    >
      <OMenu
        ref="datePickerMenu"
        :placement="datePickerPlacement"
        :close-on-outside-click="true"
      >
        <template #trigger>
          <div style="width: 1px; height: 1px"></div>
        </template>
        <template #content>
          <!-- Custom date picker implementation -->
          <div class="date-picker-content">
            <!-- Date picker UI would go here -->
          </div>
        </template>
      </OMenu>
    </div>

    <!-- Context menu (invisible trigger positioned dynamically) -->
    <div
      ref="contextMenuTrigger"
      class="context-menu-trigger"
      :style="contextMenuTriggerStyle"
    >
      <OMenu
        ref="contextMenuMenu"
        :placement="contextMenuPlacement"
        :close-on-outside-click="true"
      >
        <template #trigger>
          <div style="width: 1px; height: 1px"></div>
        </template>
        <template #content>
          <div class="context-menu-content">
            <div
              v-for="item in contextMenuItems"
              :key="item._id"
              class="context-menu-item"
              @click="onContextMenuSelect(item)"
            >
              {{ item.name }}
            </div>
          </div>
        </template>
      </OMenu>
    </div>
  </div>
</template>

Script Setup Overview

vue
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { storeToRefs } from "pinia";
import * as d3 from "d3";
import moment from "moment";
import OMenu from "./OMenu.vue";

// Composables
import { useGanttZoomPan } from "@/composables/useGanttZoomPan";
import { useGanttDatePicker } from "@/composables/useGanttDatePicker";
import { useGanttContextMenu } from "@/composables/useGanttContextMenu";
import { useGanttLazyLoading } from "@/composables/useGanttLazyLoading";

// Store
import { useGanttChart } from "@/stores/ganttChart.store";

// Props
const props = defineProps({
  viewMode: {
    type: String,
    default: "daily",
    validator: (val) => ["daily", "weekly", "monthly"].includes(val),
  },
});

// Emits
const emit = defineEmits(["viewModeChanged"]);

// Store
const ganttChartStore = useGanttChart();
const { items, phasesList, ganttBoundaryStartDate, ganttBoundaryEndDate } =
  storeToRefs(ganttChartStore);

// Refs
const containerRef = ref(null);
const headerRef = ref(null);
const svgRef = ref(null);
const headerSvgRef = ref(null);

// Initialize composables
const zoomPanComposable = useGanttZoomPan({
  containerRef,
  onScroll: handleScroll,
  onZoomChange: handleZoomChange,
  shouldIgnoreKeyEvent: (e) => e.target.tagName === "INPUT",
});

const datePickerComposable = useGanttDatePicker();
const contextMenuComposable = useGanttContextMenu();
const lazyLoadingComposable = useGanttLazyLoading({
  containerRef,
  fetchItemsAPI: null, // Optional API function
});

// D3 scales, rendering logic, event handlers...
// (Full implementation in GanttChart/GanttChart.vue)

onMounted(() => {
  initializeChart();
  zoomPanComposable.setupContainerInteractions();
  zoomPanComposable.setupWindowListeners();
  lazyLoadingComposable.setupHorizontalObserver();
  lazyLoadingComposable.setInitialFetchedRange(
    ganttBoundaryStartDate.value,
    ganttBoundaryEndDate.value
  );
});

onUnmounted(() => {
  zoomPanComposable.cleanup();
  datePickerComposable.cleanup();
  lazyLoadingComposable.cleanup();
});
</script>

Composables

useGanttZoomPan.js

js
import { ref, nextTick, watch } from "vue";

const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_SENSITIVITY = 0.0012;

export const useGanttZoomPan = (options = {}) => {
  const { containerRef, onScroll, onZoomChange, shouldIgnoreKeyEvent } = options;

  const zoomFactor = ref(1);
  const isSpacePressed = ref(false);
  const isPanModeActive = ref(false);
  const isPanning = ref(false);

  // Pan and zoom logic...
  // (See full implementation in useGanttZoomPan.js)

  return {
    zoomFactor,
    isSpacePressed,
    isPanModeActive,
    isPanning,
    onClickZoomControl,
    onClickPanViewButton,
    setupContainerInteractions,
    teardownContainerInteractions,
    setupWindowListeners,
    cleanup,
  };
};

useGanttDatePicker.js

js
import { ref, computed, nextTick } from "vue";
import { useGanttChart } from "@/stores/ganttChart.store";
import moment from "moment";

const MENU_HEIGHT = 300;
const SPACING = 8;
const DATE_FORMAT = "YYYY-MM-DD";

export const useGanttDatePicker = () => {
  const ganttChartStore = useGanttChart();

  const datePickerMenu = ref(null);
  const datePickerTrigger = ref(null);
  const datePickerValue = ref(null);
  const currentEditingItem = ref(null);
  const currentDateType = ref(null);

  // Date picker logic...
  // (See full implementation in useGanttDatePicker.js)

  return {
    datePickerMenu,
    datePickerTrigger,
    datePickerValue,
    currentEditingItem,
    currentDateType,
    openDatePicker,
    closeDatePicker,
    handleDateChange,
    cleanup,
  };
};

useGanttContextMenu.js

js
import { ref, nextTick } from "vue";
import { useGanttChart } from "@/stores/ganttChart.store";

export const useGanttContextMenu = () => {
  const ganttChartStore = useGanttChart();

  const contextMenuMenu = ref(null);
  const contextMenuTrigger = ref(null);
  const contextMenuItem = ref(null);
  const contextMenuItems = ref([
    { _id: 1, name: "Edit" },
    { _id: 2, name: "Add Sub item" },
    { _id: 3, name: "Delete" },
    { _id: 4, name: "Duplicate Item" },
  ]);

  // Context menu logic...
  // (See full implementation in useGanttContextMenu.js)

  return {
    contextMenuMenu,
    contextMenuTrigger,
    contextMenuItem,
    contextMenuItems,
    openContextMenu,
    closeContextMenu,
    onContextMenuSelect,
  };
};

useGanttLazyLoading.js

js
import { ref, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import moment from "moment";
import { useGanttChart } from "@/stores/ganttChart.store";

const DAYS_TO_FETCH = 30;
const DATE_FORMAT = "YYYY-MM-DD";

export const useGanttLazyLoading = (options = {}) => {
  const { containerRef, fetchItemsAPI } = options;

  const ganttChartStore = useGanttChart();
  const { ganttBoundaryStartDate, ganttBoundaryEndDate, items } =
    storeToRefs(ganttChartStore);

  const isLoading = ref(false);
  const horizontalObserver = ref(null);
  const fetchedDateRanges = ref(new Set());

  // Lazy loading logic...
  // (See full implementation in useGanttLazyLoading.js)

  return {
    isLoading,
    setupHorizontalObserver,
    observeDateIntervals,
    setInitialFetchedRange,
    cleanup,
  };
};

Store (ganttChart.store.js)

js
import { defineStore } from "pinia";

export const useGanttChart = defineStore("ganttChart", {
  state: () => ({
    items: [],
    phasesList: [
      {
        _id: "1234567890",
        name: "Planning",
        colour: "#3098f2",
        textColor: "#ffffff",
      },
      {
        _id: "1234567891",
        name: "Design",
        colour: "#8B5CF6",
        textColor: "#ffffff",
      },
      {
        _id: "1234567892",
        name: "Development",
        colour: "#10b981",
        textColor: "#ffffff",
      },
      {
        _id: "1234567893",
        name: "Testing",
        colour: "#f59e0b",
        textColor: "#ffffff",
      },
      {
        _id: "1234567894",
        name: "Deployment",
        colour: "#ef4444",
        textColor: "#ffffff",
      },
    ],
    ganttBoundaryStartDate: null,
    ganttBoundaryEndDate: null,
    projectScheduleDate: null,
  }),

  actions: {
    updateItemTime(itemId, startDate, dueDate) {
      const item = this.items.find((i) => i._id === itemId);
      if (!item) return;

      if (startDate) item.startDate = new Date(startDate).toISOString();
      if (dueDate) item.dueDate = new Date(dueDate).toISOString();
    },

    // Additional actions...
  },
});

Full Component Files

Due to the size and complexity of the GanttChart component, the complete implementation is available in the source files:

  • Main Component: src/components/GanttChart/GanttChart.vue (5023 lines)
  • Zoom/Pan Composable: src/composables/useGanttZoomPan.js (290 lines)
  • Date Picker Composable: src/composables/useGanttDatePicker.js (159 lines)
  • Context Menu Composable: src/composables/useGanttContextMenu.js (120 lines)
  • Lazy Loading Composable: src/composables/useGanttLazyLoading.js (250 lines)
  • Store: src/stores/ganttChart.store.js (447 lines)

Key Implementation Details

D3.js Integration

The component uses D3.js for:

  • Time scales for X-axis positioning
  • SVG rendering and manipulation
  • Drag-and-drop interactions
  • Smooth transitions and animations

Date Handling

All dates are handled using Moment.js for:

  • Date formatting and parsing
  • Date range calculations
  • View mode granularity (daily/weekly/monthly)
  • Timeline interval generation

State Management

Pinia store manages:

  • Task items and their properties
  • Project phases and colors
  • Timeline boundaries
  • Shared state across components

Performance Optimizations

  • Lazy loading for large datasets
  • Virtual rendering of visible items only
  • Debounced drag operations
  • Efficient D3 data joins
  • IntersectionObserver for horizontal lazy loading

Accessibility

  • Keyboard navigation (Space, Escape)
  • ARIA labels on interactive elements
  • Focus management for popups
  • Screen reader support

Styling

The component uses SCSS for styling with:

  • BEM-like class naming
  • CSS variables for theming
  • Responsive design patterns
  • Custom scrollbar styling
  • Smooth transitions

Usage Example

vue
<template>
  <div class="app-container">
    <GanttChart :viewMode="currentViewMode" @viewModeChanged="handleViewModeChange" />
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
import GanttChart from "@/components/GanttChart/GanttChart.vue";
import { useGanttChart } from "@/stores/ganttChart.store";

const currentViewMode = ref("weekly");
const ganttStore = useGanttChart();

onMounted(() => {
  // Initialize store with data
  ganttStore.items = [
    // ... your task data
  ];
  ganttStore.ganttBoundaryStartDate = "2024-01-01";
  ganttStore.ganttBoundaryEndDate = "2024-12-31";
});

const handleViewModeChange = ({ mode }) => {
  currentViewMode.value = mode;
  console.log("View mode changed to:", mode);
};
</script>

<style scoped>
.app-container {
  width: 100%;
  height: 100vh;
  padding: 1rem;
}
</style>