├── .github └── workflows │ ├── deploy.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── lib ├── components │ ├── Error.tsx │ ├── Pert │ │ ├── ChartSVG.tsx │ │ ├── Pert.tsx │ │ └── PertChart.tsx │ ├── arrow │ │ ├── Arrows.tsx │ │ └── arrow.module.css │ ├── grid │ │ └── Grid.tsx │ └── task │ │ ├── Metrics.tsx │ │ ├── TaskLevel.tsx │ │ ├── TaskNode.tsx │ │ ├── Tasks.tsx │ │ └── task.module.css ├── constants │ ├── font.constants.ts │ └── pert.constants.ts ├── context │ └── pertContext.tsx ├── hooks │ └── usePertDimensions.ts ├── main.ts ├── types │ ├── global.types.ts │ ├── pert.types.ts │ └── task.types.ts └── utlis │ ├── Pert.ts │ └── generateKey.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.ts ├── public ├── cover.jpg ├── demo.jpg └── pert.svg ├── release.config.cjs ├── src ├── App.tsx ├── components │ ├── Color.tsx │ ├── CopyDrawer.tsx │ ├── PertChart.tsx │ ├── PertDetails.tsx │ ├── Sidebar.tsx │ ├── StyleOptions.tsx │ ├── TasksDrawer.tsx │ ├── mode-toggle.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── drawer.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── slider.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ └── textarea.tsx ├── constants │ └── initStates.ts ├── index.css ├── lib │ └── utils.ts ├── main.tsx └── vite-env.d.ts ├── tailwind.config.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - beta 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: "lts/*" 22 | 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: 9 27 | 28 | - name: Checkout repo 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Node 32 | uses: actions/setup-node@v4 33 | 34 | - name: Install dependencies 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: Build project 38 | run: pnpm build --mode app 39 | 40 | - name: Upload production-ready build files 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: production-files 44 | path: ./dist 45 | 46 | deploy: 47 | name: Deploy 48 | needs: build 49 | runs-on: ubuntu-latest 50 | if: github.ref == 'refs/heads/main' 51 | 52 | steps: 53 | - name: Download artifact 54 | uses: actions/download-artifact@v4 55 | with: 56 | name: production-files 57 | path: ./dist 58 | 59 | - name: Deploy to GitHub Pages 60 | uses: peaceiris/actions-gh-pages@v4 61 | with: 62 | github_token: ${{ secrets.GITHUB_TOKEN }} 63 | publish_dir: ./dist 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - beta 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | issues: write 18 | pull-requests: write 19 | id-token: write 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: "lts/*" 29 | 30 | - name: Install pnpm 31 | uses: pnpm/action-setup@v4 32 | with: 33 | version: 9 34 | 35 | - name: Install dependencies 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Build 39 | run: pnpm build --mode lib 40 | 41 | - name: Audit 42 | run: npm audit signatures 43 | 44 | - name: Release 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | run: npx semantic-release 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | *.tsbuildinfo 16 | # Editor directories and files 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 90 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Youcef Hammadi [@ucfx](https://github.com/ucfx) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-pert 2 | 3 | 4 | 5 | [![-](https://img.shields.io/github/stars/ucfx/react-pert?style=for-the-badge&colorA=000000&colorB=6868ff)](https://github.com/ucfx/react-pert/stargazers) 6 | [![-](https://img.shields.io/npm/v/react-pert?style=for-the-badge&colorA=000000&colorB=6868ff)](https://www.npmjs.com/package/react-pert) 7 | [![Build Size](https://img.shields.io/bundlephobia/minzip/react-pert?label=bundle%20size&style=for-the-badge&colorA=000000&colorB=6868ff)](https://bundlephobia.com/result?p=react-pert) 8 | [![Downloads](https://img.shields.io/npm/dt/react-pert.svg?style=for-the-badge&colorA=000000&colorB=6868ff)](https://www.npmjs.com/package/react-pert) 9 | 10 | ## :star2: Overview 11 | 12 | This package provides tools to calculate and visualize a PERT (Program Evaluation and Review Technique) diagram. It includes the following components and utilities: 13 | 14 | - **`Pert`**: A component to render the PERT diagram. 15 | - **`usePert`**: A custom hook to retrieve PERT results. 16 | - **Chart Interaction**: Allows interaction with the diagram, enabling the selection of tasks and displaying only the path related to the selected task. 17 | 18 | ### :rocket: Progress 19 | 20 | - :white_check_mark: **PERT Calculator**: Fully functional. 21 | - :white_check_mark: **PERT Diagram**: Fully functional. 22 | 23 | ### :computer: Demo 24 | 25 | Check out the live demo [here](https://ucfx.github.io/react-pert/). 26 | 27 | 28 | 29 | --- 30 | 31 | ## :clipboard: Installation 32 | 33 | Install the package via npm: 34 | 35 | ```bash 36 | npm install react-pert 37 | ``` 38 | 39 | --- 40 | 41 | ## :book: Usage 42 | 43 | ### Using `Pert` Component 44 | 45 | ```jsx 46 | import { Pert, type TaskInput } from "react-pert"; 47 | 48 | const App = () => { 49 | const tasks: TaskInput[] = [ 50 | { key: "1", duration: 5, text: "A" }, 51 | { key: "2", duration: 4, text: "B" }, 52 | { key: "3", duration: 2, text: "C", dependsOn: ["1"] }, 53 | { key: "4", duration: 3, text: "D", dependsOn: ["2"] }, 54 | //... 55 | ]; 56 | 57 | return ; 58 | }; 59 | ``` 60 | 61 | ### Using `usePert` Hook 62 | 63 | You need to wrap your application with `PertProvider` to use the `usePert` hook. Here is an example of how you can use it: 64 | 65 | - **Note**: You should put the `Pert` component and `usePert` hook in the same `PertProvider` to ensure that the PERT data is available to both. 66 | 67 | ```jsx 68 | import { PertProvider, usePert, type TaskInput } from "react-pert"; 69 | 70 | const App = () => { 71 | const tasks: TaskInput[] = [ 72 | { key: "1", duration: 5, text: "A" }, 73 | { key: "2", duration: 4, text: "B" }, 74 | { key: "3", duration: 2, text: "C", dependsOn: ["1"] }, 75 | { key: "4", duration: 3, text: "D", dependsOn: ["2"] }, 76 | //... 77 | ]; 78 | return ( 79 | 80 | 81 | 82 | 83 | ); 84 | }; 85 | ``` 86 | 87 | ```jsx 88 | import { usePert } from "react-pert"; 89 | 90 | const PertDetails = () => { 91 | const { projectDuration, criticalPaths } = usePert(); 92 | 93 | return ( 94 |
95 |

Project Duration : {projectDuration}

96 |

Critical Paths:

97 |
98 | {criticalPaths.map((cp, index) => ( 99 |
100 | {cp.map((p, i) => ( 101 | 102 | {p.text} 103 | {i < cp.length - 1 && " → "} 104 | 105 | ))} 106 |
107 | ))} 108 |
109 |
110 | ); 111 | }; 112 | ``` 113 | 114 | --- 115 | 116 | ## :bulb: Examples 117 | 118 | - ### setSelectedTask 119 | 120 | You can use the `setSelectedTask` function to select a task in the PERT diagram. This function is available when you import `setSelectedTask` from `react-pert`. 121 | 122 | ```typescript 123 | setSelectedTask: (taskKey: string | null) => void; 124 | ``` 125 | 126 | ```jsx 127 | import { Pert, PertProvider, setSelectedTask } from "react-pert"; 128 | 129 | const App = () => { 130 | const tasks = [ 131 | { key: "1", duration: 5, text: "A" }, 132 | //... 133 | ]; 134 | 135 | const handleClick = () => { 136 | setSelectedTask("1"); 137 | }; 138 | const handleClear = () => { 139 | setSelectedTask(null); 140 | }; 141 | 142 | return ( 143 | // PertProvider is optional if you are using only setSelectedTask 144 | 145 | 146 | 147 | 148 | 149 | ); 150 | }; 151 | ``` 152 | 153 | - ### onSelect 154 | 155 | You can use the `onSelect` prop to get the selected task when a task is selected in the PERT diagram. 156 | 157 | ```typescript 158 | onSelect?: (task: Task) => void; 159 | ``` 160 | 161 | ```jsx 162 | import { Pert } from "react-pert"; 163 | 164 | const App = () => { 165 | const tasks = [/*...*/]; 166 | const handleSelect = (task) => { 167 | console.log("Selected Task:", task); 168 | }; 169 | 170 | return ; 171 | }; 172 | ``` 173 | 174 | - ### `usePert` with `PertOptions` 175 | 176 | You can pass options to the `usePert` hook to customize the output data. 177 | 178 | ```typescript 179 | const results = usePert({ bounds: true }); 180 | ``` 181 | - Default: `true` 182 | 183 | The `usePert` hook can be customized using the `bounds` option to include or exclude boundary tasks (Start and Finish) in the returned tasks. 184 | 185 | #### Input 186 | 187 | ```jsx 188 | const input: TaskInput[] = [ 189 | { key: "1", duration: 5, text: "A" }, 190 | { key: "2", duration: 4, text: "B", dependsOn: ["1"] }, 191 | { key: "3", duration: 2, text: "C", dependsOn: ["2"] }, 192 | ]; 193 | ``` 194 | 195 | #### Output with `bounds = true` 196 | 197 | When `bounds` is set to `true`, the Start and Finish tasks are included: 198 | 199 | ```jsx 200 | const output: Task[] = [ 201 | { key: "Start", text: "Start", duration: 0, dependsOn: [] }, 202 | { key: "1", text: "A", duration: 5, dependsOn: ["Start"], ...rest /* other properties */ }, 203 | { key: "2", text: "B", duration: 4, dependsOn: ["1"], ...rest /* other properties */ }, 204 | { key: "3", text: "C", duration: 2, dependsOn: ["2"], ...rest /* other properties */ }, 205 | { key: "Finish", text: "Finish", duration: 0, dependsOn: ["3"] }, 206 | ]; 207 | ``` 208 | 209 | #### Output with `bounds = false` 210 | 211 | When `bounds` is set to `false`, the Start and Finish tasks are excluded: 212 | 213 | ```jsx 214 | const output: Task[] = [ 215 | { key: "1", text: "A", duration: 5, dependsOn: [], ...rest /* other properties */,}, 216 | { key: "2", text: "B", duration: 4, dependsOn: ["1"], ...rest /* other properties */ }, 217 | { key: "3", text: "C", duration: 2, dependsOn: ["2"], ...rest /* other properties */ }, 218 | ]; 219 | ``` 220 | 221 | ## :books: API Reference 222 | 223 | ### `PertProps` 224 | 225 | | Attribute | Type | Description | 226 | | ----------- | --------------------------- | --------------------------------------------------------------- | 227 | | `tasks` | [`TaskInput[]`](#taskinput) | Array of tasks to be used for the calculation and PERT diagram. | 228 | | `styles?` | [`PertStyles`](#pertstyles) | Optional styles configuration for the PERT chart. | 229 | | `onSelect?` | `(task: `[`Task`](#task)`) => void` | Optional callback invoked when a task is selected. | 230 | 231 | ### `Pert` 232 | 233 | A visual representation of the PERT diagram (currently in development). 234 | 235 | --- 236 | 237 | ### `usePert` 238 | 239 | #### Options: 240 | 241 | ### `PertOptions` 242 | 243 | | Attribute | Type | Description | 244 | | --------- | --------- | ------------------------------------------------------------------------------------------------------------------ | 245 | | `bounds` | `boolean` | Determines whether the boundary tasks (Start and Finish) should be included in the returned tasks. Default: `true` | 246 | 247 | - If `true`, the Start and Finish tasks will be included. 248 | - If `false`, the Start and Finish tasks will be excluded. 249 | - Default: `true` 250 | 251 | #### Returns: 252 | 253 | - **`PertDataType`**: Contains all PERT data including tasks, levels, links, critical paths, and project duration. 254 | 255 | ### `PertDataType` 256 | 257 | | Attribute | Type | Description | 258 | | ----------------- | --------------------------------- | ----------------------------------------------------------- | 259 | | `tasks` | [`Task[]`](#task) | Array of tasks with PERT calculation results. | 260 | | `levels` | [`LevelType`](#leveltype) | Mapping of task keys to their respective levels. | 261 | | `links` | [`LinkType[]`](#linktype) | Array of links representing the dependencies between tasks. | 262 | | `criticalPaths` | [`CriticalPath[]`](#criticalpath) | Array of critical paths in the project. | 263 | | `projectDuration` | `number` | Total duration of the project. | 264 | | `error?` | `string \| null` | Current error message, if any. | 265 | 266 | ### `TaskInput` 267 | 268 | Represents a task with input data for PERT calculation. 269 | 270 | | Attribute | Type | Description | 271 | | ------------ | ---------- | --------------------------------------------------------------- | 272 | | `key` | `string` | Unique identifier for the task. | 273 | | `text` | `string` | Description or name of the task. | 274 | | `duration` | `number` | Duration of the task in some unit (e.g., days). | 275 | | `dependsOn?` | `string[]` | Array of task keys that the current task depends on (optional). | 276 | 277 | ### `Task` 278 | 279 | Represents a task with PERT calculation results. 280 | 281 | | Attribute | Type | Description | 282 | | ------------- | ---------- | -------------------------------------------- | 283 | | `key` | `string` | Unique identifier for the task. | 284 | | `text` | `string` | Description or name of the task. | 285 | | `duration` | `number` | Duration of the task. | 286 | | `dependsOn?` | `string[]` | Array of task keys the task depends on. | 287 | | `earlyStart` | `number` | The earliest start time for the task. | 288 | | `earlyFinish` | `number` | The earliest finish time for the task. | 289 | | `lateStart` | `number` | The latest start time for the task. | 290 | | `lateFinish` | `number` | The latest finish time for the task. | 291 | | `level` | `number` | The level of the task in the PERT diagram. | 292 | | `critical` | `boolean` | Indicates if the task is on a critical path. | 293 | | `freeFloat` | `number` | The free float time of the task. | 294 | | `totalFloat` | `number` | The total float time of the task. | 295 | | `index` | `number` | Index of the task in the sequence. | 296 | 297 | ### `PertStyles` 298 | 299 | Styles configuration for the PERT chart. 300 | 301 | | Attribute | Type | Description | 302 | | ---------------------- | ---------------------------- | -------------------------------------------------------- | 303 | | `disableGrid?` | `boolean` | Whether to disable grid lines in the chart. | 304 | | `taskSize?` | `number` | Size of the task node in pixels. | 305 | | `fontFamily?` | `string` | Font family for the text in the task nodes. | 306 | | `fontSize?` | [`FontSize`](#fontsize) | Font size for the text in the task nodes. | 307 | | `textColor?` | `string` | Color of the text inside the task nodes. | 308 | | `chartBackground?` | `string` | Background color of the entire chart. | 309 | | `taskBackground?` | `string` | Background color of the task nodes. | 310 | | `gridColor?` | `string` | Color of the grid lines in the chart. | 311 | | `borderWidth?` | `number` | Width of the border for task nodes. | 312 | | `selectedBorderWidth?` | `number` | Width of the border for selected task nodes. | 313 | | `hoverBorderWidth?` | `number` | Width of the border when hovering over task nodes. | 314 | | `borderColor?` | `string` | Color of the border for task nodes. | 315 | | `selectedBorderColor?` | `string` | Color of the border for selected task nodes. | 316 | | `criticalColor?` | `string` | Color for critical path elements (e.g., tasks or links). | 317 | | `arrowColor?` | `string` | Color of the arrows (links) between task nodes. | 318 | | `arrowWidth?` | `number` | Width of the arrows (links) between task nodes. | 319 | | `gap?` | `{ x?: number; y?: number }` | Gap between task nodes in the chart. | 320 | 321 | ### `FontSize` 322 | 323 | | Type | Description | 324 | | ------------------------------------------------------------------------- | --------------------------------------------- | 325 | | `"sm"`, `"md"`, `"lg"`, `"xl"`, `"2xl"`, `"3xl"`, `string & {}`, `number` | Font size options for text in the task nodes. | 326 | 327 | ### `LevelType` 328 | 329 | Represents a mapping of task keys to their respective levels in the PERT diagram. 330 | 331 | | Attribute | Type | Description | 332 | | --------- | ---------- | ----------------------------------------- | 333 | | `key` | `number` | The level number in the PERT diagram. | 334 | | `value` | `string[]` | Array of task keys at the specific level. | 335 | 336 | ### `LinkType` 337 | 338 | Represents a link between two tasks in the PERT diagram. 339 | 340 | | Attribute | Type | Description | 341 | | ---------- | --------- | ------------------------------------------------- | 342 | | `from` | `string` | The task key from which the link originates. | 343 | | `to` | `string` | The task key to which the link leads. | 344 | | `critical` | `boolean` | Indicates if the link is part of a critical path. | 345 | 346 | ### `CriticalPath` 347 | 348 | Represents a critical path in the PERT diagram. 349 | 350 | | | Type | Description | 351 | | -------------- | -------------------------- | ------------------------------------------------------------------------- | 352 | | `CriticalPath` | [`PathItem[]`](#pathitem) | An array of tasks (`PathItem`) forming the critical path. | 353 | 354 | ### `PathItem` 355 | 356 | | Attribute | Type | Description | 357 | | --------- | -------- | ---------------------------------- | 358 | | `text` | `string` | Description or name of the task. | 359 | | `key` | `string` | Task key identifier. | 360 | 361 | --- 362 | 363 | ## :handshake: Contributing 364 | 365 | We welcome contributions! If you encounter any bugs or have feature requests, please open an issue or submit a pull request. 366 | 367 | --- 368 | 369 | ## :page_with_curl: License 370 | 371 | This package is open-source and licensed under the [MIT License](LICENSE.md). 372 | 373 | Enjoy building with **PERT Diagram**! :relaxed: 374 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | react-pert Demo 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/components/Error.tsx: -------------------------------------------------------------------------------- 1 | export default function Error({ error }: { error: string }) { 2 | return ( 3 |
12 | {error} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /lib/components/Pert/ChartSVG.tsx: -------------------------------------------------------------------------------- 1 | import { Arrows } from "../arrow/Arrows"; 2 | import Tasks from "../task/Tasks"; 3 | import Grid from "../grid/Grid"; 4 | import { InternalPertResultType, PertStyles } from "../../types/pert.types"; 5 | import { 6 | DEFAULT_CHART_STYLES, 7 | DEFAULT_COLORS, 8 | DEFAULT_WIDTH, 9 | } from "../../constants/pert.constants"; 10 | import { ChartDimensions } from "../../types/task.types"; 11 | 12 | export interface ChartSVGProps { 13 | size: ChartDimensions; 14 | pertData: InternalPertResultType; 15 | selectedTask: string | null; 16 | hoveredTask: string | null; 17 | setHoveredTask: (taskKey: string | null) => void; 18 | styles: Omit & { 19 | gap: { 20 | x: number; 21 | y: number; 22 | }; 23 | taskSize: number; 24 | fontSize: string; 25 | }; 26 | } 27 | 28 | export const ChartSVG: React.FC = ({ 29 | size, 30 | pertData, 31 | selectedTask, 32 | setHoveredTask, 33 | hoveredTask, 34 | styles: { 35 | fontSize, 36 | taskSize, 37 | gap, 38 | disableGrid, 39 | taskBackground, 40 | selectedBorderColor, 41 | borderColor, 42 | criticalColor, 43 | arrowColor, 44 | arrowWidth: arrowWidth, 45 | chartBackground, 46 | textColor, 47 | fontFamily, 48 | borderWidth, 49 | hoverBorderWidth, 50 | selectedBorderWidth, 51 | gridColor, 52 | }, 53 | }) => { 54 | const taskStyles = { 55 | "--task-bg": taskBackground ?? DEFAULT_COLORS.TASK_BG, 56 | "--task-bg-critical": criticalColor ?? DEFAULT_COLORS.CRITICAL, 57 | "--task-stroke-color": borderColor ?? DEFAULT_COLORS.STROKE, 58 | "--task-stroke-width": borderWidth ?? DEFAULT_WIDTH.BORDER, 59 | "--task-stroke-hover-width": hoverBorderWidth ?? DEFAULT_WIDTH.HOVER_BORDER, 60 | "--task-selected-stroke-width": selectedBorderWidth ?? DEFAULT_WIDTH.SELECTED_BORDER, 61 | "--task-selected-stroke-color": selectedBorderColor ?? DEFAULT_COLORS.SELECTED, 62 | "--task-text-color": textColor ?? DEFAULT_COLORS.TEXT, 63 | "--task-font-family": fontFamily ?? "inherit", 64 | } as React.CSSProperties; 65 | 66 | const arrowStyles = { 67 | "--arrow-stroke-color": arrowColor ?? DEFAULT_COLORS.STROKE, 68 | "--arrow-critical-stroke-color": criticalColor ?? DEFAULT_COLORS.CRITICAL, 69 | "--arrow-stroke-width": arrowWidth ?? 2, 70 | } as React.CSSProperties; 71 | 72 | const chartStyles: React.CSSProperties = { 73 | ...DEFAULT_CHART_STYLES, 74 | fontSize, 75 | backgroundColor: chartBackground, 76 | }; 77 | 78 | return ( 79 | 88 | 89 | {!disableGrid && } 90 | 101 | 111 | 112 | 113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /lib/components/Pert/Pert.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { InternalPertProvider } from "../../context/pertContext"; 3 | import { PertProps, PertStyles } from "../../types/pert.types"; 4 | import PertChart from "./PertChart"; 5 | 6 | export const Pert: React.FunctionComponent = ({ styles, ...rest }) => ( 7 | 8 |
15 | 16 |
17 |
18 | ); 19 | -------------------------------------------------------------------------------- /lib/components/Pert/PertChart.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 | import { usePertContext } from "../../context/pertContext"; 3 | import { ChartSVG } from "./ChartSVG"; 4 | import Error from "../Error"; 5 | import { PertStyles } from "../../types/pert.types"; 6 | import { DEFAULT_ERROR_DIMENSIONS } from "../../constants/pert.constants"; 7 | import { usePertDimensions } from "../../hooks/usePertDimensions"; 8 | import { FONT_SIZES } from "../../constants/font.constants"; 9 | import { Task, TaskInput } from "../../types/global.types"; 10 | 11 | export interface PertChartProps { 12 | tasks: TaskInput[]; 13 | styles: PertStyles; 14 | taskSize?: number; 15 | onSelect?: (task: Task) => void; 16 | } 17 | 18 | let externalSetSelectedTask: { 19 | setter: React.Dispatch>; 20 | onSelect: ((task: any) => void) | undefined; 21 | getTask: ((task: string) => Task | undefined) | undefined; 22 | } | null = null; 23 | 24 | export const setSelectedTask = (taskKey: string | null) => { 25 | if (!externalSetSelectedTask) return; 26 | 27 | const { setter, onSelect, getTask } = externalSetSelectedTask; 28 | 29 | if (!taskKey) { 30 | setter(null); 31 | onSelect?.(null); 32 | return; 33 | } 34 | 35 | const task = getTask?.(taskKey) ?? null; 36 | onSelect?.(task); 37 | setter(task ? taskKey : null); 38 | }; 39 | 40 | export const PertChart: React.FC = ({ tasks, onSelect, styles }) => { 41 | const { pertData, calculatePertResults, error } = usePertContext(); 42 | const tasksHashRef = useRef(); 43 | const [selectedTask, setSelectedTaskState] = useState(null); 44 | const [hoveredTask, setHoveredTaskState] = useState(null); 45 | 46 | const getTask = (taskkey: string): Task | undefined => { 47 | return pertData.tasks.get(taskkey); 48 | }; 49 | 50 | const setterRef = useRef(setSelectedTaskState); 51 | externalSetSelectedTask = { setter: setterRef.current, getTask, onSelect }; 52 | 53 | useEffect(() => { 54 | const tasksHash = JSON.stringify(tasks); 55 | if (tasksHashRef.current !== tasksHash) { 56 | calculatePertResults(tasks); 57 | setSelectedTask(null); 58 | tasksHashRef.current = tasksHash; 59 | } 60 | }, [tasks, calculatePertResults]); 61 | 62 | const setHoveredTask = useCallback((taskKey: string | null) => { 63 | setHoveredTaskState(taskKey); 64 | }, []); 65 | 66 | const { gap: customGap, taskSize, fontSize } = styles; 67 | 68 | const customTaskSize = Math.max(taskSize ?? 100, 70); 69 | 70 | const gap = useMemo( 71 | () => ({ 72 | x: Math.max(customTaskSize, customGap?.x ?? 0), 73 | y: Math.max(customTaskSize, customGap?.y ?? 0), 74 | }), 75 | [customGap?.x, customGap?.y, customTaskSize] 76 | ); 77 | 78 | const size = usePertDimensions({ taskSize: customTaskSize, gap }); 79 | 80 | const customFontSize = useMemo(() => { 81 | if (!fontSize) return FONT_SIZES.default; 82 | if (typeof fontSize === "number") return `${fontSize}px`; 83 | return FONT_SIZES[fontSize as keyof typeof FONT_SIZES] || fontSize; 84 | }, [fontSize]); 85 | 86 | if (error) { 87 | return ; 88 | } 89 | 90 | return ( 91 | 99 | ); 100 | }; 101 | 102 | export default PertChart; 103 | -------------------------------------------------------------------------------- /lib/components/arrow/Arrows.tsx: -------------------------------------------------------------------------------- 1 | import { LevelType, LinkType } from "../../types/global.types"; 2 | import { InternalTaskResultType } from "../../types/pert.types"; 3 | import styles from "./arrow.module.css"; 4 | 5 | interface ArrowsProps { 6 | arrows: LinkType[]; 7 | levels: LevelType; 8 | taskSize: number; 9 | size: { width: number; height: number }; 10 | gap: { 11 | x: number; 12 | y: number; 13 | }; 14 | tasks: InternalTaskResultType; 15 | selectedTask: string | null; 16 | hoveredTask: string | null; 17 | arrowStyles: React.CSSProperties; 18 | } 19 | export const Arrows: React.FC = ({ 20 | arrows, 21 | taskSize, 22 | levels, 23 | size, 24 | gap, 25 | tasks, 26 | selectedTask, 27 | hoveredTask, 28 | arrowStyles, 29 | }) => { 30 | const isArrowVisible = (fromTaskKey: string, toTaskKey: string) => { 31 | return ( 32 | selectedTask === fromTaskKey || 33 | selectedTask === toTaskKey || 34 | hoveredTask === fromTaskKey || 35 | hoveredTask === toTaskKey 36 | ); 37 | }; 38 | 39 | const getTaskPosition = (taskKey: string, isFrom = false) => { 40 | const task = tasks.get(taskKey)!; 41 | const level = task.level; 42 | const index = levels.get(level)!.findIndex((t) => t === taskKey); 43 | const arrLength = levels.get(level)!.length; 44 | const totalHeight = arrLength * taskSize + (arrLength - 1) * gap.y; 45 | const offset = (size.height - totalHeight) / 2 + taskSize / 2; 46 | const x = level * (taskSize + gap.x) + (isFrom ? taskSize * (3 / 2) : taskSize / 2); 47 | const y = index * (taskSize + gap.y) + offset; 48 | return { x, y, level, task }; 49 | }; 50 | 51 | const className = `${styles.arrows} ${selectedTask ? styles.onSelect : ""} ${ 52 | hoveredTask ? styles.onHover : "" 53 | }`; 54 | 55 | return ( 56 | 57 | {arrows.map((arrow, key) => { 58 | const { 59 | x: fromX, 60 | y: fromY, 61 | level: fromLevel, 62 | task: fromTask, 63 | } = getTaskPosition(arrow.from, true); 64 | const { 65 | x: toX, 66 | y: toY, 67 | level: toLevel, 68 | task: toTask, 69 | } = getTaskPosition(arrow.to); 70 | 71 | const className = `${styles.arrow} ${ 72 | isArrowVisible(fromTask.key, toTask.key) ? styles.visible : "" 73 | } ${arrow.critical ? styles.critical : ""}`; 74 | 75 | return ( 76 | 86 | ); 87 | })} 88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /lib/components/arrow/arrow.module.css: -------------------------------------------------------------------------------- 1 | .arrows { 2 | .arrow { 3 | fill: transparent; 4 | stroke: var(--arrow-stroke-color); 5 | stroke-width: var(--arrow-stroke-width); 6 | transition: stroke .2s, stroke-width 0.2s, opacity .2s; 7 | 8 | &.critical { 9 | stroke: var(--arrow-critical-stroke-color); 10 | } 11 | } 12 | 13 | &.onHover .arrow { 14 | opacity: .3; 15 | } 16 | 17 | &.onSelect .arrow { 18 | opacity: 0; 19 | } 20 | 21 | &.onSelect .arrow, 22 | &.onHover .arrow { 23 | 24 | &.hover, 25 | &.visible { 26 | stroke-width: calc(var(--arrow-stroke-width) + 1); 27 | opacity: 1; 28 | } 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /lib/components/grid/Grid.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_COLORS } from "../../constants/pert.constants"; 2 | 3 | interface GridProps { 4 | size: { width: number; height: number }; 5 | taskSize: number; 6 | strokeColor: string | undefined; 7 | } 8 | 9 | export default function Grid({ size, taskSize, strokeColor }: GridProps) { 10 | const rowCount = Math.ceil(size.height / taskSize) + 1; 11 | const colCount = Math.ceil(size.width / taskSize) + 1; 12 | 13 | const renderLines = (count: number, isVertical: boolean, totalLength: number) => 14 | Array.from({ length: count }, (_, i) => { 15 | const pos = i * taskSize; 16 | return ( 17 | 27 | ); 28 | }); 29 | 30 | return ( 31 | 32 | {renderLines(rowCount, false, size.width)} 33 | {renderLines(colCount, true, size.height)} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /lib/components/task/Metrics.tsx: -------------------------------------------------------------------------------- 1 | import { Task } from "../../types/global.types"; 2 | import { TaskDimensions } from "../../types/task.types"; 3 | 4 | interface TaskMetricsProps { 5 | task: Task; 6 | x: number; 7 | y: number; 8 | dimensions: TaskDimensions; 9 | } 10 | 11 | const Metrics: React.FC = ({ task, x, y, dimensions }) => { 12 | const { halfRadius, twoThirdsRadius } = dimensions; 13 | return ( 14 | <> 15 | 16 | {task.earlyStart} 17 | 18 | 19 | {task.earlyFinish} 20 | 21 | 22 | {task.lateStart} 23 | 24 | 25 | {task.lateFinish} 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default Metrics; 32 | -------------------------------------------------------------------------------- /lib/components/task/TaskLevel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Task } from "../../types/global.types"; 3 | import { TaskDimensions } from "../../types/task.types"; 4 | import TaskNode from "./TaskNode"; 5 | 6 | interface TaskLevelProps { 7 | level: number; 8 | keyList: string[]; 9 | tasks: Map; 10 | taskSize: number; 11 | gap: { 12 | x: number; 13 | y: number; 14 | }; 15 | offset: number; 16 | dimensions: TaskDimensions; 17 | selectedTask: string | null; 18 | onHover: (key: string, isHovering: boolean) => void; 19 | onTaskClick: (taskKey: string) => void; 20 | taskStyles: React.CSSProperties; 21 | } 22 | 23 | const TaskLevel: React.FC = ({ 24 | level, 25 | keyList, 26 | tasks, 27 | taskSize, 28 | gap, 29 | offset, 30 | dimensions, 31 | selectedTask, 32 | onHover, 33 | onTaskClick, 34 | taskStyles, 35 | }) => { 36 | return ( 37 | 38 | {keyList.map((key, index) => { 39 | const task = tasks.get(key); 40 | if (!task) return null; 41 | 42 | const x = level * taskSize + taskSize + gap.x * level; 43 | const y = index * (taskSize + gap.y) + offset; 44 | const isVisible = Boolean( 45 | selectedTask && 46 | (task.dependsOn?.includes(selectedTask) || 47 | tasks.get(selectedTask)?.dependsOn?.includes(task.key)) 48 | ); 49 | 50 | const isSelected = task.key === selectedTask; 51 | return ( 52 | 64 | ); 65 | })} 66 | 67 | ); 68 | }; 69 | 70 | export default React.memo(TaskLevel); 71 | -------------------------------------------------------------------------------- /lib/components/task/TaskNode.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { Task } from "../../types/global.types"; 3 | import styles from "./task.module.css"; 4 | import Metrics from "./Metrics"; 5 | import { TaskDimensions } from "../../types/task.types"; 6 | 7 | interface TaskNodeProps { 8 | task: Task; 9 | x: number; 10 | y: number; 11 | dimensions: TaskDimensions; 12 | onHover: (key: string, isHovering: boolean) => void; 13 | onTaskClick: (taskKey: string) => void; 14 | isVisible: boolean; 15 | isSelected: boolean; 16 | taskStyles: React.CSSProperties; 17 | } 18 | 19 | const TaskNode: React.FC = ({ 20 | task, 21 | x, 22 | y, 23 | dimensions, 24 | onHover, 25 | onTaskClick, 26 | isVisible, 27 | isSelected, 28 | taskStyles, 29 | }) => { 30 | const { radius } = dimensions; 31 | const pathData = useMemo( 32 | () => ` 33 | M ${x - radius} ${y - radius / 3} 34 | L ${x + radius} ${y - radius / 3} 35 | M ${x - radius} ${y + radius / 3} 36 | L ${x + radius} ${y + radius / 3} 37 | M ${x} ${y + radius / 3} 38 | L ${x} ${y + radius} 39 | M ${x} ${y - radius} 40 | L ${x} ${y - radius / 3} 41 | `, 42 | [x, y, radius] 43 | ); 44 | 45 | return ( 46 | onHover(task.key, true)} 51 | onMouseLeave={() => onHover(task.key, false)} 52 | onClick={() => onTaskClick(task.key)} 53 | style={taskStyles} 54 | > 55 | 63 | 64 | {task.text} 65 | 66 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default React.memo(TaskNode); 73 | -------------------------------------------------------------------------------- /lib/components/task/Tasks.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useCallback } from "react"; 2 | import styles from "./task.module.css"; 3 | import { setSelectedTask } from "../../components/Pert/PertChart"; 4 | import TaskLevel from "./TaskLevel"; 5 | import { Task } from "../../types/global.types"; 6 | 7 | export interface TasksProps { 8 | tasks: Map; 9 | levels: Map; 10 | taskSize: number; 11 | size: { 12 | width: number; 13 | height: number; 14 | }; 15 | gap: { 16 | x: number; 17 | y: number; 18 | }; 19 | selectedTask: string | null; 20 | setHoveredTask: (taskKey: string | null) => void; 21 | taskStyles: React.CSSProperties; 22 | } 23 | 24 | const Tasks: React.FC = ({ 25 | tasks, 26 | levels, 27 | taskSize, 28 | size, 29 | gap, 30 | selectedTask, 31 | setHoveredTask, 32 | taskStyles, 33 | }) => { 34 | const dimensions = useMemo( 35 | () => ({ 36 | radius: taskSize / 2, 37 | halfRadius: taskSize / 4, 38 | twoThirdsRadius: (taskSize / 6) * 2, 39 | }), 40 | [taskSize] 41 | ); 42 | 43 | const handleHover = useCallback( 44 | (key: string, isHovering: boolean) => { 45 | setHoveredTask(isHovering ? key : null); 46 | }, 47 | [setHoveredTask] 48 | ); 49 | 50 | const handleTaskClick = useCallback( 51 | (taskKey: string) => { 52 | setSelectedTask(selectedTask !== taskKey ? taskKey : null); 53 | }, 54 | [selectedTask] 55 | ); 56 | 57 | const renderedLevels = useMemo(() => { 58 | return Array.from(levels.entries()).map(([levelNum, keyList]) => { 59 | const totalHeight = keyList.length * taskSize + (keyList.length - 1) * gap.y; 60 | const offset = (size.height - totalHeight) / 2 + dimensions.radius; 61 | 62 | return ( 63 | 77 | ); 78 | }); 79 | }, [ 80 | levels, 81 | tasks, 82 | taskSize, 83 | gap, 84 | size.height, 85 | dimensions, 86 | selectedTask, 87 | handleHover, 88 | handleTaskClick, 89 | taskStyles, 90 | ]); 91 | 92 | return {renderedLevels}; 93 | }; 94 | 95 | export default React.memo(Tasks); 96 | -------------------------------------------------------------------------------- /lib/components/task/task.module.css: -------------------------------------------------------------------------------- 1 | .tasks { 2 | .task { 3 | user-select: none; 4 | transition: filter .2s, opacity .2s; 5 | 6 | text { 7 | fill: var(--task-text-color); 8 | font-family: var(--task-font-family); 9 | } 10 | 11 | rect { 12 | transition: stroke-width .2s; 13 | stroke: var(--task-stroke-color); 14 | stroke-width: var(--task-stroke-width); 15 | fill: var(--task-bg); 16 | 17 | &~path { 18 | stroke: var(--task-stroke-color); 19 | } 20 | } 21 | 22 | &.critical rect { 23 | fill: var(--task-bg-critical); 24 | } 25 | 26 | &:hover { 27 | cursor: pointer; 28 | filter: brightness(.9); 29 | 30 | rect { 31 | stroke-width: var(--task-stroke-hover-width); 32 | } 33 | } 34 | 35 | &.selected { 36 | rect { 37 | stroke: var(--task-selected-stroke-color); 38 | stroke-width: var(--task-selected-stroke-width); 39 | } 40 | } 41 | } 42 | 43 | &:has(.task.selected) { 44 | .task:not(.selected):not(.visible) { 45 | opacity: 0.7; 46 | filter: brightness(0.6); 47 | 48 | &:hover { 49 | cursor: pointer; 50 | filter: brightness(.9); 51 | opacity: 1; 52 | 53 | rect { 54 | stroke: var(--task-stroke-color); 55 | stroke-linejoin: bevel; 56 | } 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /lib/constants/font.constants.ts: -------------------------------------------------------------------------------- 1 | export const FONT_SIZES = { 2 | sm: "0.75rem", 3 | md: "1rem", 4 | lg: "1.25rem", 5 | xl: "1.5rem", 6 | "2xl": "1.75rem", 7 | "3xl": "2rem", 8 | default: "1rem", 9 | min: "0.75rem", 10 | max: "2rem", 11 | } as const; 12 | -------------------------------------------------------------------------------- /lib/constants/pert.constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_ERROR_DIMENSIONS = { 2 | width: 300, 3 | height: 200, 4 | } as const; 5 | 6 | export const DEFAULT_CHART_STYLES = { 7 | display: "block", 8 | boxSizing: "border-box", 9 | transition: "width 0.3s ease, height 0.3s ease", 10 | } as const; 11 | 12 | export const DEFAULT_COLORS = { 13 | CRITICAL: "#ff9147", 14 | TASK_BG: "#aaaeff", 15 | CHART_BG: "#fff", 16 | GRID: "#83838350", 17 | TEXT: "#000", 18 | SELECTED: "#6868ff", 19 | STROKE: "#615f77", 20 | } as const; 21 | 22 | export const DEFAULT_WIDTH = { 23 | BORDER: 1, 24 | HOVER_BORDER: 2, 25 | SELECTED_BORDER: 3, 26 | } as const; 27 | -------------------------------------------------------------------------------- /lib/context/pertContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useCallback, useContext, useState } from "react"; 2 | import { TaskInput, PertOptions } from "../types/global.types.ts"; 3 | import { 4 | InternalPertResultType, 5 | InternalPertContextType, 6 | PertDataType, 7 | } from "../types/pert.types.ts"; 8 | import PertCalculator from "../utlis/Pert.ts"; 9 | 10 | const EMPTY_RESULT: InternalPertResultType = { 11 | tasks: new Map(), 12 | levels: new Map(), 13 | links: [], 14 | criticalPaths: [], 15 | projectDuration: 0, 16 | }; 17 | 18 | const pertContext = createContext(null); 19 | const internalPertContext = createContext(null); 20 | 21 | const createPertProvider = (Context: React.Context) => { 22 | return ({ children }: { children: ReactNode }) => { 23 | const [pertData, setPertResults] = useState(EMPTY_RESULT); 24 | const [error, setError] = useState(null); 25 | 26 | const calculatePertResults = useCallback((data: TaskInput[]) => { 27 | try { 28 | const results: InternalPertResultType = new PertCalculator(data).solve(); 29 | setPertResults(results); 30 | setError(null); 31 | } catch (err: any) { 32 | console.error(err); 33 | setPertResults(EMPTY_RESULT); 34 | setError(err.message); 35 | } 36 | }, []); 37 | 38 | return ( 39 | 46 | {children} 47 | 48 | ); 49 | }; 50 | }; 51 | 52 | export const PertProvider = createPertProvider(pertContext); 53 | export const InternalPertProvider = createPertProvider(internalPertContext); 54 | 55 | export const usePert = (options: PertOptions = { bounds: true }) => { 56 | const context = useContext(pertContext); 57 | if (!context) { 58 | throw new Error("usePertData must be used within a PertProvider"); 59 | } 60 | const tasksArr = Array.from(context.pertData.tasks.values()); 61 | const tasks = options?.bounds 62 | ? tasksArr.slice(1, -1).map((task) => ({ 63 | ...task, 64 | dependsOn: task.dependsOn?.includes(tasksArr[0].key) ? [] : task.dependsOn, 65 | })) 66 | : tasksArr; 67 | 68 | return { 69 | ...context.pertData, 70 | tasks, 71 | error: context.error, 72 | projectDuration: context.pertData.projectDuration, 73 | } as PertDataType; 74 | }; 75 | 76 | export const usePertContext = () => { 77 | const context = useContext(pertContext); 78 | if (!context) { 79 | const internalContext = useContext(internalPertContext); 80 | if (!internalContext) { 81 | throw new Error( 82 | "usePertData must be used within a PertProvider or InternalPertProvider" 83 | ); 84 | } 85 | return internalContext; 86 | } 87 | return context; 88 | }; 89 | -------------------------------------------------------------------------------- /lib/hooks/usePertDimensions.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { ChartDimensions } from "../types/task.types"; 3 | import { usePertContext } from "../context/pertContext"; 4 | 5 | interface UsePertDimensionsProps { 6 | taskSize: number; 7 | gap: { x: number; y: number }; 8 | } 9 | 10 | export const usePertDimensions = ({ 11 | taskSize, 12 | gap, 13 | }: UsePertDimensionsProps): ChartDimensions => { 14 | const { pertData } = usePertContext(); 15 | 16 | return useMemo(() => { 17 | const levelValues = Array.from(pertData.levels.values()); 18 | if (!levelValues.length) { 19 | return { width: 0, height: 0 }; 20 | } 21 | 22 | const maxNodesPerLevel = Math.max(...levelValues.map((nodes) => nodes.length)); 23 | 24 | return { 25 | width: (levelValues.length + 1) * taskSize + (levelValues.length - 1) * gap.x, 26 | height: (maxNodesPerLevel + 1) * taskSize + (maxNodesPerLevel - 1) * gap.y, 27 | }; 28 | }, [pertData.levels, taskSize, gap]); 29 | }; 30 | -------------------------------------------------------------------------------- /lib/main.ts: -------------------------------------------------------------------------------- 1 | export { Pert } from "./components/Pert/Pert"; 2 | export { setSelectedTask } from "./components/Pert/PertChart"; 3 | export type * from "./types/global.types"; 4 | export type { PertStyles, PertProps, PertDataType, FontSize } from "./types/pert.types"; 5 | export { PertProvider, usePert } from "./context/pertContext"; 6 | -------------------------------------------------------------------------------- /lib/types/global.types.ts: -------------------------------------------------------------------------------- 1 | export type TaskInput = { 2 | key: string; 3 | text: string; 4 | duration: number; 5 | dependsOn?: string[]; 6 | }; 7 | 8 | export type Task = TaskInput & { 9 | earlyStart: number; 10 | earlyFinish: number; 11 | lateStart: number; 12 | lateFinish: number; 13 | level: number; 14 | critical: boolean; 15 | freeFloat: number; 16 | totalFloat: number; 17 | index: number; 18 | }; 19 | 20 | export type LevelType = Map; 21 | 22 | export type LinkType = { 23 | from: string; 24 | to: string; 25 | critical: boolean; 26 | }; 27 | 28 | type PathItem = { 29 | text: string; 30 | key: string; 31 | }; 32 | 33 | export type CriticalPath = PathItem[]; 34 | 35 | export interface PertOptions { 36 | /** 37 | * Determines whether the boundary tasks (Start and Finish) should be included in the returned tasks. 38 | * - If `true`, the Start and Finish tasks will be included. 39 | * - If `false`, the Start and Finish tasks will be excluded. 40 | * @type {boolean} 41 | * @default true 42 | */ 43 | bounds?: boolean; 44 | } 45 | -------------------------------------------------------------------------------- /lib/types/pert.types.ts: -------------------------------------------------------------------------------- 1 | import { CriticalPath, LevelType, LinkType, Task } from "./global.types"; 2 | import { TaskInput } from "./global.types"; 3 | 4 | // dont care about this type 5 | export type InternalTaskResultType = Map; 6 | 7 | // dont care about this type 8 | interface ResultType { 9 | /** 10 | * Levels of tasks. 11 | */ 12 | levels: LevelType; 13 | /** 14 | * Links between tasks. 15 | */ 16 | links: LinkType[]; 17 | /** 18 | * Critical paths. 19 | */ 20 | criticalPaths: CriticalPath[]; 21 | /** 22 | * Project Duration. 23 | */ 24 | projectDuration: number; 25 | } 26 | 27 | // dont care about this type 28 | interface ErrorType { 29 | /** Current error message, if any */ 30 | error: string | null; 31 | } 32 | 33 | // dont care about this type 34 | export interface InternalPertResultType extends ResultType { 35 | tasks: InternalTaskResultType; 36 | } 37 | 38 | // dont care about this type 39 | export interface InternalPertContextType extends ErrorType { 40 | /** Current PERT calculation results */ 41 | pertData: InternalPertResultType; 42 | /** 43 | * Calculates PERT results based on input tasks 44 | * @param data - Array of tasks to process 45 | * @example 46 | * calculatePertResults([ 47 | * { key: "1", duration: 5, text: "A", dependsOn: ["8", "10", "11"] }, 48 | * { key: "2", duration: 4, text: "B", dependsOn: ["10", "11"] }, 49 | * ... 50 | * ]); 51 | */ 52 | calculatePertResults: (data: TaskInput[]) => void; 53 | } 54 | 55 | export interface PertDataType extends ResultType, ErrorType { 56 | /** 57 | * Task with metrics. 58 | */ 59 | tasks: Task[]; 60 | } 61 | 62 | export type FontSize = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | (string & {}) | number; 63 | 64 | interface EventOption { 65 | /** 66 | * Invokes on task select. 67 | */ 68 | onSelect?: (task: Task) => void; 69 | } 70 | 71 | export interface PertStyles { 72 | /** 73 | * Whether to disable grid lines in the chart. 74 | * @default false 75 | */ 76 | disableGrid?: boolean; 77 | 78 | /** 79 | * Size of the task node in pixels. 80 | * @default 100 81 | * @min 70 82 | * @max 200 83 | */ 84 | taskSize?: number; 85 | 86 | /** 87 | * Font family for the text in the task nodes. 88 | * @default "inherit" 89 | * @example 90 | * "Arial" | "Roboto" | "sans-serif" 91 | */ 92 | fontFamily?: string; 93 | 94 | /** 95 | * Font size for the text in the task nodes. 96 | * @default "md" 97 | * @example 98 | * "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | 24 | "1.5rem" 99 | */ 100 | fontSize?: FontSize; 101 | 102 | /** 103 | * Color of the text inside the task nodes. 104 | * @default "#000" 105 | */ 106 | textColor?: string; 107 | 108 | /** 109 | * Background color of the entire chart. 110 | * @default "#fff" 111 | */ 112 | chartBackground?: string; 113 | 114 | /** 115 | * Background color of the task nodes. 116 | * @default "#aaaeff" 117 | */ 118 | taskBackground?: string; 119 | 120 | /** 121 | * Color of the grid lines in the chart. 122 | * @default "#83838350" 123 | */ 124 | gridColor?: string; 125 | 126 | /** 127 | * Width of the border for task nodes. 128 | * @default 1 129 | */ 130 | borderWidth?: number; 131 | 132 | /** 133 | * Width of the border for selected task nodes. 134 | * @default 3 135 | */ 136 | selectedBorderWidth?: number; 137 | 138 | /** 139 | * Width of the border when hovering over task nodes. 140 | * @default 2 141 | */ 142 | hoverBorderWidth?: number; 143 | 144 | /** 145 | * Color of the border for task nodes. 146 | * @default "#615f77" 147 | */ 148 | borderColor?: string; 149 | 150 | /** 151 | * Color of the border for selected task nodes. 152 | * @default "#6868ff" 153 | */ 154 | selectedBorderColor?: string; 155 | 156 | /** 157 | * Color for critical path elements (e.g., tasks or links). 158 | * @default "#ff9147" 159 | */ 160 | criticalColor?: string; 161 | 162 | /** 163 | * Color of the arrows (links) between task nodes. 164 | * @default "#615f77" 165 | */ 166 | arrowColor?: string; 167 | 168 | /** 169 | * Width of the arrows (links) between task nodes. 170 | * @default 2 171 | */ 172 | arrowWidth?: number; 173 | 174 | /** 175 | * Gap between task nodes in the chart. 176 | * @default { x: TaskSize ?? 100, y: TaskSize ?? 100 } 177 | */ 178 | gap?: { x?: number; y?: number }; 179 | } 180 | 181 | export interface PertProps extends EventOption { 182 | tasks: TaskInput[]; 183 | styles?: PertStyles; 184 | } 185 | -------------------------------------------------------------------------------- /lib/types/task.types.ts: -------------------------------------------------------------------------------- 1 | export interface TaskDimensions { 2 | radius: number; 3 | halfRadius: number; 4 | twoThirdsRadius: number; 5 | } 6 | export interface ChartDimensions { 7 | width: number; 8 | height: number; 9 | } 10 | -------------------------------------------------------------------------------- /lib/utlis/Pert.ts: -------------------------------------------------------------------------------- 1 | import { generateKey } from "./generateKey"; 2 | import { 3 | TaskInput, 4 | Task, 5 | LevelType, 6 | LinkType, 7 | CriticalPath, 8 | } from "../types/global.types"; 9 | 10 | class Pert { 11 | private levels: LevelType; 12 | private tasksMap: Map; 13 | private initialData: TaskInput[]; 14 | private links: LinkType[]; 15 | private criticalPaths: CriticalPath[]; 16 | private lastTaskKey: string; 17 | private startTaskKey: string; 18 | private projectDuration: number; 19 | 20 | constructor(data: TaskInput[]) { 21 | this.initialData = data; 22 | this.tasksMap = new Map(); 23 | this.lastTaskKey = `Finish-${generateKey()}`; 24 | this.startTaskKey = `Start-${generateKey()}`; 25 | this.links = []; 26 | this.criticalPaths = []; 27 | this.levels = new Map(); 28 | this.projectDuration = 0; 29 | } 30 | 31 | private convertDataToMap() { 32 | this.tasksMap = new Map(); 33 | this.tasksMap.set(this.startTaskKey, { 34 | key: this.startTaskKey, 35 | duration: 0, 36 | text: "Start", 37 | } as Task); 38 | this.initialData.forEach((task, index) => { 39 | if (this.tasksMap.has(task.key)) throw Error(`Duplicate keys found ${task.key}`); 40 | this.tasksMap.set( 41 | task.key, 42 | (!task.dependsOn || task.dependsOn.length === 0 43 | ? { ...task, dependsOn: [this.startTaskKey], index } 44 | : { ...task, index }) as Task 45 | ); 46 | }); 47 | } 48 | 49 | private calculatePERT() { 50 | this.convertDataToMap(); 51 | this.calcLevels(); 52 | 53 | this.levels.forEach((indices) => { 54 | indices.forEach((index) => { 55 | this.calculateEarlyTimes(index); 56 | }); 57 | }); 58 | 59 | let lastTask = Array.from(this.tasksMap.values()).reduce((prev, current) => 60 | prev.earlyFinish > current.earlyFinish ? prev : current 61 | ); 62 | lastTask.lateFinish = lastTask.earlyFinish; 63 | lastTask.critical = true; 64 | 65 | this.tasksMap.set(this.lastTaskKey, { 66 | key: this.lastTaskKey, 67 | text: "Finish", 68 | dependsOn: [lastTask.key], 69 | duration: 0, 70 | earlyStart: lastTask.earlyFinish, 71 | earlyFinish: lastTask.earlyFinish, 72 | lateFinish: lastTask.earlyFinish, 73 | lateStart: lastTask.earlyFinish, 74 | level: this.levels.size, 75 | critical: true, 76 | freeFloat: 0, 77 | totalFloat: 0, 78 | index: this.tasksMap.size - 1, 79 | }); 80 | this.levels.set(this.levels.size, [this.lastTaskKey]); 81 | 82 | this.projectDuration = lastTask.lateFinish; 83 | 84 | for (const [, indices] of [...this.levels.entries()].reverse()) { 85 | indices.forEach((index) => { 86 | this.calculateLateTimes(index, lastTask.lateFinish); 87 | }); 88 | } 89 | 90 | this.levels.forEach((indices, key) => { 91 | this.levels.set( 92 | key, 93 | indices.sort((a, b) => this.tasksMap.get(a)!.index - this.tasksMap.get(b)!.index) 94 | ); 95 | }); 96 | } 97 | 98 | private getSuccessors(task: Task): Task[] { 99 | return Array.from(this.tasksMap.values()).filter( 100 | (t) => t.dependsOn && t.dependsOn.includes(task.key) 101 | ); 102 | } 103 | 104 | private calculateEarlyTimes(k: string) { 105 | const task = this.tasksMap.get(k)!; 106 | if (!task.dependsOn) { 107 | task.earlyStart = 0; 108 | } else { 109 | let maxFinishTime = 0; 110 | task.dependsOn.forEach((dependency) => { 111 | const dependencyTask = this.tasksMap.get(dependency)!; 112 | 113 | maxFinishTime = Math.max( 114 | maxFinishTime, 115 | dependencyTask.earlyStart + dependencyTask.duration 116 | ); 117 | }); 118 | task.earlyStart = maxFinishTime; 119 | } 120 | task.earlyFinish = task.earlyStart + task.duration; 121 | } 122 | 123 | private calculateLateTimes(k: string, projectDuration: number) { 124 | const task = this.tasksMap.get(k)!; 125 | const successors = this.getSuccessors(task); 126 | if (task.key !== this.lastTaskKey && successors.length === 0) 127 | this.tasksMap.get(this.lastTaskKey)!.dependsOn?.push(k); 128 | 129 | let lateFinish = 130 | successors.length === 0 131 | ? projectDuration 132 | : Math.min(...successors.map((s) => s.lateFinish - s.duration)); 133 | 134 | task.lateFinish = lateFinish; 135 | task.lateStart = task.lateFinish - task.duration; 136 | task.critical = task.earlyFinish === task.lateFinish; 137 | 138 | if (!task.critical) { 139 | task.freeFloat = 140 | (successors.length === 0 141 | ? projectDuration 142 | : Math.min(...successors.map((s) => s.earlyStart))) - task.earlyFinish; 143 | task.totalFloat = task.lateFinish - task.earlyFinish; 144 | } else { 145 | task.freeFloat = 0; 146 | task.totalFloat = 0; 147 | } 148 | } 149 | 150 | private calcLevels() { 151 | this.levels.clear(); 152 | 153 | let arr: string[] = []; // To detect circular dependencies 154 | 155 | const calcLevel = (task: Task) => { 156 | if (arr.includes(task.key)) { 157 | throw new Error("Circular dependency detected"); 158 | } 159 | if (!task.dependsOn || task.dependsOn.length === 0) { 160 | task.level = 0; 161 | } else { 162 | let maxLevel = 0; 163 | task.dependsOn.forEach((dependency) => { 164 | const dependencyTask = this.tasksMap.get(dependency)!; 165 | if (!dependencyTask) { 166 | throw new Error(`Task with KEY '${dependency}' was not found.`); 167 | } 168 | if (dependencyTask.level === undefined) { 169 | arr.push(task.key); 170 | calcLevel(dependencyTask); 171 | } 172 | maxLevel = Math.max(maxLevel, dependencyTask.level + 1); 173 | }); 174 | task.level = maxLevel; 175 | } 176 | 177 | if (!this.levels.has(task.level)) { 178 | this.levels.set(task.level, []); 179 | } 180 | 181 | const levelArray = this.levels.get(task.level)!; 182 | 183 | if (!levelArray.includes(task.key)) { 184 | levelArray.push(task.key); 185 | } 186 | }; 187 | 188 | this.tasksMap.forEach((task) => { 189 | if (task.level === undefined) { 190 | arr = []; 191 | calcLevel(task); 192 | } 193 | }); 194 | } 195 | 196 | private calcNodeLinks() { 197 | const linkData: LinkType[] = []; 198 | const dependencies: string[] = []; 199 | 200 | this.tasksMap.forEach((task, key) => { 201 | if (task.dependsOn) { 202 | task.dependsOn.forEach((dependency) => { 203 | dependencies.push(dependency); 204 | linkData.push({ 205 | from: dependency, 206 | to: task.key, 207 | critical: task.critical && this.tasksMap.get(dependency)!.critical, 208 | }); 209 | }); 210 | } else if (key !== this.startTaskKey) { 211 | linkData.push({ 212 | from: this.startTaskKey, 213 | to: task.key, 214 | critical: task.critical, 215 | }); 216 | } 217 | }); 218 | 219 | this.tasksMap.forEach((task) => { 220 | if (!dependencies.includes(task.key) && task.key !== this.lastTaskKey) { 221 | linkData.push({ 222 | from: task.key, 223 | to: this.lastTaskKey, 224 | critical: task.critical, 225 | }); 226 | } 227 | }); 228 | 229 | this.links = linkData; 230 | } 231 | 232 | private calcCriticalPaths() { 233 | this.calcNodeLinks(); 234 | 235 | const criticalPaths: CriticalPath[] = []; 236 | const startNodes = this.links.filter( 237 | (link) => 238 | link.critical && link.from === this.startTaskKey && link.to !== this.lastTaskKey 239 | ); 240 | 241 | startNodes.forEach((startNode) => { 242 | const path: LinkType[] = [startNode]; 243 | 244 | const findPath = (node: LinkType) => { 245 | const nodeLinks = this.links.filter( 246 | (link) => link.critical && link.from === node.to && link.to !== this.lastTaskKey 247 | ); 248 | if (nodeLinks.length === 0) { 249 | criticalPaths.push( 250 | path.map((p) => ({ 251 | text: this.tasksMap.get(p.to)!.text, 252 | key: p.to, 253 | })) 254 | ); 255 | } else { 256 | nodeLinks.forEach((nodeLink) => { 257 | path.push(nodeLink); 258 | findPath(nodeLink); 259 | path.pop(); 260 | }); 261 | } 262 | }; 263 | 264 | findPath(startNode); 265 | }); 266 | 267 | this.criticalPaths = criticalPaths; 268 | } 269 | 270 | getTasks(): Map { 271 | this.calculatePERT(); 272 | return this.tasksMap; 273 | } 274 | 275 | getCriticalPaths(): CriticalPath[] { 276 | if (this.criticalPaths.length === 0) this.calcCriticalPaths(); 277 | return this.criticalPaths; 278 | } 279 | 280 | getLevels(): Map { 281 | return this.levels; 282 | } 283 | 284 | getNodeLinks(): LinkType[] { 285 | return this.links; 286 | } 287 | 288 | getProjectDuration(): number { 289 | return this.projectDuration; 290 | } 291 | 292 | solve() { 293 | return { 294 | tasks: this.getTasks(), 295 | levels: this.getLevels(), 296 | criticalPaths: this.getCriticalPaths(), 297 | links: this.getNodeLinks(), 298 | projectDuration: this.getProjectDuration(), 299 | }; 300 | } 301 | } 302 | 303 | export default Pert; 304 | -------------------------------------------------------------------------------- /lib/utlis/generateKey.ts: -------------------------------------------------------------------------------- 1 | export const generateKey = () => Math.random().toString(36).substring(7); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-pert", 3 | "version": "0.0.0-development", 4 | "type": "module", 5 | "license": "MIT", 6 | "description": "Interactive PERT chart component for React", 7 | "files": [ 8 | "dist", 9 | "README.md", 10 | "LICENSE.md" 11 | ], 12 | "main": "./dist/index.umd.cjs", 13 | "module": "./dist/index.js", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.umd.cjs" 18 | } 19 | }, 20 | "types": "./dist/index.d.ts", 21 | "sideEffects": false, 22 | "publishConfig": { 23 | "access": "public" 24 | }, 25 | "homepage": "https://github.com/ucfx/react-pert#readme", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/ucfx/react-pert" 29 | }, 30 | "author": { 31 | "name": "Youcef Hammadi", 32 | "email": "ucefhammadi@gmail.com" 33 | }, 34 | "scripts": { 35 | "dev": "vite", 36 | "prebuild": "rimraf dist", 37 | "build": "tsc -b && vite build", 38 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 39 | "semantic-release": "semantic-release" 40 | }, 41 | "peerDependencies": { 42 | "react": "*", 43 | "react-dom": "*" 44 | }, 45 | "devDependencies": { 46 | "@eslint/js": "^9.19.0", 47 | "@radix-ui/react-label": "^2.1.2", 48 | "@radix-ui/react-popover": "^1.1.6", 49 | "@radix-ui/react-scroll-area": "^1.2.3", 50 | "@radix-ui/react-select": "^2.1.6", 51 | "@radix-ui/react-separator": "^1.1.2", 52 | "@radix-ui/react-slider": "^1.2.3", 53 | "@radix-ui/react-slot": "^1.1.2", 54 | "@radix-ui/react-switch": "^1.1.3", 55 | "@radix-ui/react-tabs": "^1.1.3", 56 | "@types/node": "^22.13.1", 57 | "@types/react": "^18.3.18", 58 | "@types/react-dom": "^18.3.5", 59 | "@vitejs/plugin-react": "^4.3.4", 60 | "autoprefixer": "^10.4.20", 61 | "class-variance-authority": "^0.7.1", 62 | "clsx": "^2.1.1", 63 | "eslint": "^9.19.0", 64 | "eslint-plugin-react-hooks": "^5.1.0", 65 | "eslint-plugin-react-refresh": "^0.4.18", 66 | "globals": "^15.14.0", 67 | "lucide-react": "^0.475.0", 68 | "postcss": "^8.5.1", 69 | "react-colorful": "^5.6.1", 70 | "rimraf": "^6.0.1", 71 | "semantic-release": "^24.2.1", 72 | "tailwind-merge": "^3.0.1", 73 | "tailwindcss": "3", 74 | "tailwindcss-animate": "^1.0.7", 75 | "typescript": "^5.7.3", 76 | "typescript-eslint": "^8.23.0", 77 | "vaul": "^1.1.2", 78 | "vite": "^6.1.0", 79 | "vite-plugin-dts": "^4.5.0", 80 | "vite-plugin-lib-inject-css": "^2.2.1", 81 | "vite-tsconfig-paths": "^5.1.4", 82 | "react": "^18.3.1", 83 | "react-dom": "^18.3.1" 84 | }, 85 | "keywords": [ 86 | "react", 87 | "pert", 88 | "pert chart", 89 | "react-pert", 90 | "react pert", 91 | "graph", 92 | "diagram", 93 | "chart" 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /postcss.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ucfx/react-pert/3816c8b4c161d5317a2ac82bef6c65a7885a113a/public/cover.jpg -------------------------------------------------------------------------------- /public/demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ucfx/react-pert/3816c8b4c161d5317a2ac82bef6c65a7885a113a/public/demo.jpg -------------------------------------------------------------------------------- /public/pert.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ["main", { name: "beta", prerelease: true }], 3 | }; 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { ChevronRight, ChevronLeft } from "lucide-react"; 3 | import { cn } from "@/lib/utils"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Sidebar } from "@/components/Sidebar"; 6 | import { PertChart } from "./components/PertChart"; 7 | import { ScrollArea, ScrollBar } from "./components/ui/scroll-area"; 8 | import { ModeToggle } from "./components/mode-toggle"; 9 | import { TasksDrawer } from "./components/TasksDrawer"; 10 | import { PertStyles, Task, TaskInput } from "@@/lib/main"; 11 | import { initialStyles, initTasks } from "./constants/initStates"; 12 | import "./index.css"; 13 | 14 | function App() { 15 | const [styles, setStyles] = useState(initialStyles); 16 | const [isSidebarOpen, setIsSidebarOpen] = useState(true); 17 | const [tasks, setTasks] = useState(initTasks); 18 | const [selectedTask, setSelectedTask] = useState(null); 19 | 20 | const handleSetStyles = useCallback((key: keyof PertStyles, value: any) => { 21 | setStyles((prev) => ({ ...prev, [key]: value })); 22 | }, []); 23 | 24 | const handleSetTasks = useCallback((newTasks: TaskInput[]) => { 25 | setTasks(newTasks); 26 | }, []); 27 | 28 | const handleSelect = useCallback((Task: Task) => { 29 | console.log("selected task", Task); 30 | setSelectedTask(Task); 31 | }, []); 32 | 33 | return ( 34 |
35 |
41 |
47 | 52 |
53 | 65 |
66 |
72 |
73 |
74 |

PERT Chart

75 |

76 | {isSidebarOpen 77 | ? "Customize the appearance using the sidebar" 78 | : "Click the arrow to show style options"} 79 |

80 |
81 |
82 | 94 | 106 | 107 | 108 |
109 |
110 |
111 | 112 | 113 | 114 | 115 |
116 |
117 |
118 | ); 119 | } 120 | 121 | export default App; 122 | -------------------------------------------------------------------------------- /src/components/Color.tsx: -------------------------------------------------------------------------------- 1 | import { HexAlphaColorPicker } from "react-colorful"; 2 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; 3 | import { PertStyles } from "@@/lib/main"; 4 | import { memo } from "react"; 5 | import { Input } from "./ui/input"; 6 | 7 | interface ColorProps { 8 | color?: string; 9 | colorName: keyof PertStyles; 10 | handleChange: (key: keyof PertStyles, value: any) => void; 11 | } 12 | 13 | export const Color = memo(function Color({ color, colorName, handleChange }: ColorProps) { 14 | return ( 15 | 16 | 17 | 21 | 22 | 23 | handleChange(colorName, color)} 26 | /> 27 | handleChange(colorName, e.target.value)} 30 | className="mt-2 border rounded-md p-1" 31 | /> 32 | 33 | 34 | ); 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/CopyDrawer.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { 3 | Drawer, 4 | DrawerContent, 5 | DrawerDescription, 6 | DrawerHeader, 7 | DrawerTitle, 8 | DrawerTrigger, 9 | } from "@/components/ui/drawer"; 10 | import { Button } from "@/components/ui/button"; 11 | import { Textarea } from "@/components/ui/textarea"; 12 | import { PertStyles } from "@@/lib/main"; 13 | import { Check, Copy } from "lucide-react"; 14 | 15 | interface CopyDrawerProps { 16 | styles: PertStyles; 17 | } 18 | 19 | export const CopyDrawer = memo(({ styles }: CopyDrawerProps) => { 20 | const jsonInput = JSON.stringify(styles, null, 2); 21 | 22 | const handleCopyStyles = async (e: React.MouseEvent) => { 23 | const button = e.currentTarget; 24 | try { 25 | const input = document.createElement("textarea"); 26 | input.value = JSON.stringify(styles, null, 2); 27 | document.body.appendChild(input); 28 | input.select(); 29 | input.setSelectionRange(0, 99999); 30 | 31 | if (navigator.clipboard) { 32 | await navigator.clipboard.writeText(input.value); 33 | button.classList.add("success"); 34 | setTimeout(() => { 35 | button.classList.remove("success"); 36 | }, 2000); 37 | } 38 | 39 | document.body.removeChild(input); 40 | } catch (error) { 41 | console.error("Copy failed", error); 42 | } 43 | }; 44 | return ( 45 |
46 | 47 | 48 | 49 | 50 | 51 |
52 | 53 | Copy Styles 54 | 55 | 56 |
57 | 67 |