construction-gantt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +5 -0
- package/dist/export/index.cjs +1 -0
- package/dist/export/index.d.cts +112 -0
- package/dist/export/index.d.cts.map +1 -0
- package/dist/export/index.d.ts +112 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +1 -0
- package/dist/index.cjs +3492 -0
- package/dist/index.d.cts +628 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +628 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3461 -0
- package/dist/pdf-CAQDrX0w.cjs +120 -0
- package/dist/pdf-CBaoJRTI.js +120 -0
- package/dist/png-C8t74695.cjs +88 -0
- package/dist/png-DKZeKnRh.js +88 -0
- package/dist/xlsx-5FRPFck7.js +89 -0
- package/dist/xlsx-Gh5L_NL3.cjs +111 -0
- package/package.json +86 -0
- package/src/Gantt.css +23 -0
- package/src/Gantt.tsx +636 -0
- package/src/SpikeGantt.tsx +114 -0
- package/src/analysis.ts +83 -0
- package/src/baseline.ts +119 -0
- package/src/calendars/canterbury-table.ts +44 -0
- package/src/calendars/internal/computus.ts +25 -0
- package/src/calendars/internal/date-utils.ts +13 -0
- package/src/calendars/internal/mondayisation.ts +46 -0
- package/src/calendars/internal/month-rules.ts +65 -0
- package/src/calendars/matariki-table.ts +63 -0
- package/src/calendars/nz-holidays.ts +214 -0
- package/src/editing/command-history.ts +78 -0
- package/src/editing/commands.ts +327 -0
- package/src/editing/composite-command.ts +64 -0
- package/src/editing/draft-project.ts +59 -0
- package/src/editing/errors.ts +14 -0
- package/src/editing/factories.ts +92 -0
- package/src/editing/use-editable-project.ts +122 -0
- package/src/export/index.ts +12 -0
- package/src/export/offscreen.tsx +89 -0
- package/src/export/pdf-dimensions.ts +64 -0
- package/src/export/pdf.ts +68 -0
- package/src/export/png.ts +48 -0
- package/src/export/types.ts +42 -0
- package/src/export/xlsx.ts +70 -0
- package/src/index.ts +89 -0
- package/src/mspdi/parse.ts +820 -0
- package/src/mspdi/serialize.ts +352 -0
- package/src/mspdi/types.ts +53 -0
- package/src/schedule.ts +470 -0
- package/src/topological-sort.ts +51 -0
- package/src/types.ts +254 -0
- package/src/visibility.ts +35 -0
- package/src/working-time.ts +235 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Project } from '../types.js';
|
|
2
|
+
import type { EditCommand } from './commands.js';
|
|
3
|
+
import { CompositeCommand } from './composite-command.js';
|
|
4
|
+
import { EditError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Overlay over a committed Project. Pending commands are applied
|
|
8
|
+
* left-to-right to produce the `effective` Project that the renderer
|
|
9
|
+
* reads. Commit promotes pending into a single history entry; cancel
|
|
10
|
+
* discards them.
|
|
11
|
+
*
|
|
12
|
+
* Immutable value type — all operations return new instances.
|
|
13
|
+
*/
|
|
14
|
+
export interface DraftProject {
|
|
15
|
+
readonly base: Project;
|
|
16
|
+
readonly pending: ReadonlyArray<EditCommand>;
|
|
17
|
+
readonly effective: Project;
|
|
18
|
+
readonly isDirty: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function newDraft(base: Project): DraftProject {
|
|
22
|
+
return { base, pending: [], effective: base, isDirty: false };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function enqueue(draft: DraftProject, command: EditCommand): DraftProject {
|
|
26
|
+
const effective = command.apply(draft.effective);
|
|
27
|
+
return {
|
|
28
|
+
base: draft.base,
|
|
29
|
+
pending: [...draft.pending, command],
|
|
30
|
+
effective,
|
|
31
|
+
isDirty: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CommitResult {
|
|
36
|
+
/** The new committed Project (becomes the next base). */
|
|
37
|
+
readonly newBase: Project;
|
|
38
|
+
/** Single command (if pending was 1) or CompositeCommand wrapping the pending list. */
|
|
39
|
+
readonly compound: EditCommand;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function commit(draft: DraftProject, label: string = 'Edit'): CommitResult {
|
|
43
|
+
if (draft.pending.length === 0) {
|
|
44
|
+
throw new EditError('cannot commit an empty draft', 'commit');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const compound =
|
|
48
|
+
draft.pending.length === 1 ? draft.pending[0] : new CompositeCommand(draft.pending, label);
|
|
49
|
+
|
|
50
|
+
if (!compound) {
|
|
51
|
+
throw new EditError('unreachable: empty pending', 'commit');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { newBase: draft.effective, compound };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function cancel(draft: DraftProject): DraftProject {
|
|
58
|
+
return newDraft(draft.base);
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when an EditCommand's pre-conditions fail (e.g. target task
|
|
3
|
+
* doesn't exist, FS-link-to-self, malformed patch). Caught at the hook
|
|
4
|
+
* boundary so consumers see a typed error rather than a generic Error.
|
|
5
|
+
*/
|
|
6
|
+
export class EditError extends Error {
|
|
7
|
+
readonly commandKind: string;
|
|
8
|
+
|
|
9
|
+
constructor(message: string, commandKind: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'EditError';
|
|
12
|
+
this.commandKind = commandKind;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { DependencyType, Link, LinkId, Task, TaskId } from '../types.js';
|
|
2
|
+
import {
|
|
3
|
+
CreateLinkCommand,
|
|
4
|
+
CreateTaskCommand,
|
|
5
|
+
DeleteLinkCommand,
|
|
6
|
+
DeleteTaskCommand,
|
|
7
|
+
type EditCommand,
|
|
8
|
+
UpdateLinkCommand,
|
|
9
|
+
UpdateTaskCommand,
|
|
10
|
+
} from './commands.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ergonomic factory functions for constructing edit commands with
|
|
14
|
+
* descriptive labels. Consumers call these instead of `new
|
|
15
|
+
* UpdateTaskCommand(...)` so undo UI shows "Rename task to 'Foundation'"
|
|
16
|
+
* rather than the generic "Update task 'a' (text)".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function renameTask(id: TaskId, text: string): EditCommand {
|
|
20
|
+
return new UpdateTaskCommand(id, { text }, `Rename task to "${text}"`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setTaskStart(id: TaskId, start: Date): EditCommand {
|
|
24
|
+
return new UpdateTaskCommand(id, { start }, `Move task "${String(id)}" (Start)`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function setTaskDuration(id: TaskId, minutes: number): EditCommand {
|
|
28
|
+
return new UpdateTaskCommand(
|
|
29
|
+
id,
|
|
30
|
+
{ duration: minutes },
|
|
31
|
+
`Change duration of task "${String(id)}"`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setTaskProgress(id: TaskId, percent: number): EditCommand {
|
|
36
|
+
return new UpdateTaskCommand(
|
|
37
|
+
id,
|
|
38
|
+
{ progress: percent },
|
|
39
|
+
`Update progress of task "${String(id)}" to ${percent}%`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function updateTask(id: TaskId, patch: Partial<Task>): EditCommand {
|
|
44
|
+
return new UpdateTaskCommand(id, patch);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createTask(task: Task, _parent?: TaskId, _insertAfter?: TaskId): EditCommand {
|
|
48
|
+
// _parent and _insertAfter are accepted at the public surface but not
|
|
49
|
+
// wired into the underlying CreateTaskCommand in v0.4 foundation —
|
|
50
|
+
// hierarchy reordering and parent-changes are downstream concerns.
|
|
51
|
+
return new CreateTaskCommand(task);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function deleteTask(id: TaskId): EditCommand {
|
|
55
|
+
return new DeleteTaskCommand(id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates a CreateLinkCommand with a **deterministic** link id derived
|
|
60
|
+
* from `${source}->${target}`. This means calling `linkTasks('a', 'b')`
|
|
61
|
+
* twice produces the same id — and the second `apply()` will throw
|
|
62
|
+
* `EditError: duplicate link id`. By design: duplicate enqueues are a
|
|
63
|
+
* consumer bug we surface loudly rather than silently coalesce.
|
|
64
|
+
*
|
|
65
|
+
* Consumers who legitimately need to re-add a previously-deleted link
|
|
66
|
+
* (e.g. delete A→B, then re-create it without bringing the deleted one
|
|
67
|
+
* back via undo) should use `new CreateLinkCommand({ id: customId, … })`
|
|
68
|
+
* directly with a fresh id.
|
|
69
|
+
*/
|
|
70
|
+
export function linkTasks(
|
|
71
|
+
source: TaskId,
|
|
72
|
+
target: TaskId,
|
|
73
|
+
type: DependencyType = 'FS',
|
|
74
|
+
lag = 0,
|
|
75
|
+
): EditCommand {
|
|
76
|
+
const link: Link = {
|
|
77
|
+
id: `${String(source)}->${String(target)}` as LinkId,
|
|
78
|
+
source,
|
|
79
|
+
target,
|
|
80
|
+
type,
|
|
81
|
+
lag,
|
|
82
|
+
};
|
|
83
|
+
return new CreateLinkCommand(link);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function updateLink(id: LinkId, patch: Partial<Link>): EditCommand {
|
|
87
|
+
return new UpdateLinkCommand(id, patch);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function deleteLink(id: LinkId): EditCommand {
|
|
91
|
+
return new DeleteLinkCommand(id);
|
|
92
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useMemo, useReducer, useRef } from 'react';
|
|
2
|
+
import { schedule } from '../schedule.js';
|
|
3
|
+
import type { Project } from '../types.js';
|
|
4
|
+
import {
|
|
5
|
+
type CommandHistory,
|
|
6
|
+
redo as historyRedo,
|
|
7
|
+
undo as historyUndo,
|
|
8
|
+
newHistory,
|
|
9
|
+
pushCommand,
|
|
10
|
+
} from './command-history.js';
|
|
11
|
+
import type { EditCommand } from './commands.js';
|
|
12
|
+
import {
|
|
13
|
+
type DraftProject,
|
|
14
|
+
cancel as draftCancel,
|
|
15
|
+
commit as draftCommit,
|
|
16
|
+
enqueue as draftEnqueue,
|
|
17
|
+
newDraft,
|
|
18
|
+
} from './draft-project.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Consumer-facing return shape. The renderer reads `project`
|
|
22
|
+
* unconditionally — it's always the scheduled, effective state.
|
|
23
|
+
*/
|
|
24
|
+
export interface EditableProject {
|
|
25
|
+
readonly project: Project;
|
|
26
|
+
readonly isDirty: boolean;
|
|
27
|
+
readonly canUndo: boolean;
|
|
28
|
+
readonly canRedo: boolean;
|
|
29
|
+
enqueue(command: EditCommand): void;
|
|
30
|
+
commit(label?: string): void;
|
|
31
|
+
cancel(): void;
|
|
32
|
+
undo(): void;
|
|
33
|
+
redo(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EditingState {
|
|
37
|
+
draft: DraftProject;
|
|
38
|
+
history: CommandHistory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type EditingAction =
|
|
42
|
+
| { type: 'enqueue'; command: EditCommand }
|
|
43
|
+
| { type: 'commit'; label: string }
|
|
44
|
+
| { type: 'cancel' }
|
|
45
|
+
| { type: 'undo' }
|
|
46
|
+
| { type: 'redo' };
|
|
47
|
+
|
|
48
|
+
function reducer(state: EditingState, action: EditingAction): EditingState {
|
|
49
|
+
switch (action.type) {
|
|
50
|
+
case 'enqueue':
|
|
51
|
+
return { ...state, draft: draftEnqueue(state.draft, action.command) };
|
|
52
|
+
|
|
53
|
+
case 'commit': {
|
|
54
|
+
if (state.draft.pending.length === 0) return state;
|
|
55
|
+
const { newBase, compound } = draftCommit(state.draft, action.label);
|
|
56
|
+
return {
|
|
57
|
+
draft: newDraft(newBase),
|
|
58
|
+
history: pushCommand(state.history, compound),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case 'cancel':
|
|
63
|
+
return { ...state, draft: draftCancel(state.draft) };
|
|
64
|
+
|
|
65
|
+
case 'undo': {
|
|
66
|
+
// If dirty, cancel pending first (matches VS Code / Figma).
|
|
67
|
+
const cleaned = state.draft.isDirty ? draftCancel(state.draft) : state.draft;
|
|
68
|
+
const result = historyUndo(state.history, cleaned.base);
|
|
69
|
+
if (!result) return { ...state, draft: cleaned };
|
|
70
|
+
return {
|
|
71
|
+
draft: newDraft(result.nextProject),
|
|
72
|
+
history: result.nextHistory,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'redo': {
|
|
77
|
+
const cleaned = state.draft.isDirty ? draftCancel(state.draft) : state.draft;
|
|
78
|
+
const result = historyRedo(state.history, cleaned.base);
|
|
79
|
+
if (!result) return { ...state, draft: cleaned };
|
|
80
|
+
return {
|
|
81
|
+
draft: newDraft(result.nextProject),
|
|
82
|
+
history: result.nextHistory,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The single hook entry point for v0.4 editing. Captures `initial` once
|
|
90
|
+
* on first mount (subsequent renders with a different `initial` are
|
|
91
|
+
* ignored — consumers reset by remounting via `key={projectId}`).
|
|
92
|
+
*
|
|
93
|
+
* Every effective state change is run through `schedule()` so the
|
|
94
|
+
* returned `project` always has fresh CPM data. Per ADR-005 (engine-first).
|
|
95
|
+
*/
|
|
96
|
+
export function useEditableProject(initial: Project): EditableProject {
|
|
97
|
+
// Capture initial once. useRef freezes the value across re-renders.
|
|
98
|
+
const initialRef = useRef(initial);
|
|
99
|
+
|
|
100
|
+
const [state, dispatch] = useReducer(
|
|
101
|
+
reducer,
|
|
102
|
+
undefined,
|
|
103
|
+
(): EditingState => ({
|
|
104
|
+
draft: newDraft(initialRef.current),
|
|
105
|
+
history: newHistory(),
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const scheduled = useMemo(() => schedule(state.draft.effective), [state.draft.effective]);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
project: scheduled,
|
|
113
|
+
isDirty: state.draft.isDirty,
|
|
114
|
+
canUndo: state.history.canUndo,
|
|
115
|
+
canRedo: state.history.canRedo,
|
|
116
|
+
enqueue: (command) => dispatch({ type: 'enqueue', command }),
|
|
117
|
+
commit: (label = 'Edit') => dispatch({ type: 'commit', label }),
|
|
118
|
+
cancel: () => dispatch({ type: 'cancel' }),
|
|
119
|
+
undo: () => dispatch({ type: 'undo' }),
|
|
120
|
+
redo: () => dispatch({ type: 'redo' }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Subpath entry: construction-gantt/export
|
|
2
|
+
// Re-exports types only. Implementation modules (xlsx.ts, png.ts, pdf.ts)
|
|
3
|
+
// are lazy-imported from Gantt.tsx's handle methods so consumers who
|
|
4
|
+
// never call an export method don't pay the bundle cost.
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
GanttHandle,
|
|
8
|
+
PdfExportOptions,
|
|
9
|
+
PngExportOptions,
|
|
10
|
+
XlsxColumn,
|
|
11
|
+
XlsxExportOptions,
|
|
12
|
+
} from './types.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Mounts a second <Gantt> instance in a detached, off-screen container so
|
|
2
|
+
// the export pipeline can snapshot the full project regardless of the
|
|
3
|
+
// on-screen viewport's scroll position. Positioned far off-screen rather
|
|
4
|
+
// than display:none because hidden containers don't compute layout, which
|
|
5
|
+
// breaks DOM-to-image's bounding-rect reads.
|
|
6
|
+
|
|
7
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
8
|
+
import { Gantt, type GanttProps } from '../Gantt.js';
|
|
9
|
+
import type { Project } from '../types.js';
|
|
10
|
+
|
|
11
|
+
export type OffscreenGanttProps = Pick<
|
|
12
|
+
GanttProps,
|
|
13
|
+
| 'cellWidth'
|
|
14
|
+
| 'cellHeight'
|
|
15
|
+
| 'markers'
|
|
16
|
+
| 'baselineIndex'
|
|
17
|
+
| 'showBaselineBars'
|
|
18
|
+
| 'columns'
|
|
19
|
+
| 'height'
|
|
20
|
+
| 'visibleTaskIds'
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
export interface OffscreenHandle {
|
|
24
|
+
/** The host DOM element. The rendered Gantt is mounted inside this. */
|
|
25
|
+
container: HTMLDivElement;
|
|
26
|
+
/** Unmount + remove the container from the DOM. */
|
|
27
|
+
dispose: () => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function renderOffscreen(args: {
|
|
31
|
+
scheduled: Project;
|
|
32
|
+
ganttProps: OffscreenGanttProps;
|
|
33
|
+
}): Promise<OffscreenHandle> {
|
|
34
|
+
const { scheduled, ganttProps } = args;
|
|
35
|
+
|
|
36
|
+
const container = document.createElement('div');
|
|
37
|
+
container.style.position = 'absolute';
|
|
38
|
+
container.style.left = '-99999px';
|
|
39
|
+
container.style.top = '0';
|
|
40
|
+
container.style.width = 'max-content';
|
|
41
|
+
container.style.background = '#ffffff';
|
|
42
|
+
document.body.appendChild(container);
|
|
43
|
+
|
|
44
|
+
const root: Root = createRoot(container);
|
|
45
|
+
root.render(
|
|
46
|
+
<Gantt
|
|
47
|
+
project={scheduled}
|
|
48
|
+
preScheduled
|
|
49
|
+
cellWidth={ganttProps.cellWidth}
|
|
50
|
+
cellHeight={ganttProps.cellHeight}
|
|
51
|
+
markers={ganttProps.markers}
|
|
52
|
+
baselineIndex={ganttProps.baselineIndex}
|
|
53
|
+
showBaselineBars={ganttProps.showBaselineBars}
|
|
54
|
+
columns={ganttProps.columns}
|
|
55
|
+
visibleTaskIds={ganttProps.visibleTaskIds}
|
|
56
|
+
height={computeFullHeight(scheduled, ganttProps.cellHeight ?? 42)}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Two animation frames + font load to give SVAR time to lay out and
|
|
61
|
+
// any custom fonts time to be ready before snapshot.
|
|
62
|
+
await waitForRender();
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
container,
|
|
66
|
+
async dispose() {
|
|
67
|
+
root.unmount();
|
|
68
|
+
if (container.parentNode) {
|
|
69
|
+
container.parentNode.removeChild(container);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function computeFullHeight(project: Project, cellHeight: number): number {
|
|
76
|
+
// Header band + one row per task. Heuristic; the off-screen container's
|
|
77
|
+
// width:max-content + the height being a lower bound mean an underestimate
|
|
78
|
+
// is harmless — content expands rather than clipping.
|
|
79
|
+
return 80 + project.tasks.length * cellHeight;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function waitForRender(): Promise<void> {
|
|
83
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
84
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
85
|
+
const fonts = (document as { fonts?: { ready?: Promise<unknown> } }).fonts;
|
|
86
|
+
if (fonts?.ready) {
|
|
87
|
+
await fonts.ready;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Pure-function image-fit math. Used by the PDF exporter to scale the
|
|
2
|
+
// captured PNG onto a chosen page format with a uniform margin while
|
|
3
|
+
// preserving aspect ratio. Kept separate from pdf.ts so it can be
|
|
4
|
+
// Vitest-covered without instantiating jsPDF.
|
|
5
|
+
|
|
6
|
+
export interface PageDimensions {
|
|
7
|
+
/** Width in millimetres. */
|
|
8
|
+
width: number;
|
|
9
|
+
/** Height in millimetres. */
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ImageFit {
|
|
14
|
+
/** Image render width in millimetres. */
|
|
15
|
+
width: number;
|
|
16
|
+
/** Image render height in millimetres. */
|
|
17
|
+
height: number;
|
|
18
|
+
/** Image origin X (from top-left) in millimetres. */
|
|
19
|
+
x: number;
|
|
20
|
+
/** Image origin Y (from top-left) in millimetres. */
|
|
21
|
+
y: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Standard print dimensions (ISO 216 + ANSI Letter), in millimetres.
|
|
25
|
+
export const PAGE_DIMENSIONS_MM: Record<
|
|
26
|
+
'a4' | 'a3' | 'letter',
|
|
27
|
+
{ landscape: PageDimensions; portrait: PageDimensions }
|
|
28
|
+
> = {
|
|
29
|
+
a4: {
|
|
30
|
+
portrait: { width: 210, height: 297 },
|
|
31
|
+
landscape: { width: 297, height: 210 },
|
|
32
|
+
},
|
|
33
|
+
a3: {
|
|
34
|
+
portrait: { width: 297, height: 420 },
|
|
35
|
+
landscape: { width: 420, height: 297 },
|
|
36
|
+
},
|
|
37
|
+
letter: {
|
|
38
|
+
portrait: { width: 215.9, height: 279.4 },
|
|
39
|
+
landscape: { width: 279.4, height: 215.9 },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function computeImageFit(args: {
|
|
44
|
+
pageWidth: number;
|
|
45
|
+
pageHeight: number;
|
|
46
|
+
margin: number;
|
|
47
|
+
pngPxWidth: number;
|
|
48
|
+
pngPxHeight: number;
|
|
49
|
+
}): ImageFit {
|
|
50
|
+
const { pageWidth, pageHeight, margin, pngPxWidth, pngPxHeight } = args;
|
|
51
|
+
const availableWidth = pageWidth - 2 * margin;
|
|
52
|
+
const availableHeight = pageHeight - 2 * margin;
|
|
53
|
+
|
|
54
|
+
const widthScale = availableWidth / pngPxWidth;
|
|
55
|
+
const heightScale = availableHeight / pngPxHeight;
|
|
56
|
+
const scale = Math.min(widthScale, heightScale);
|
|
57
|
+
|
|
58
|
+
const width = pngPxWidth * scale;
|
|
59
|
+
const height = pngPxHeight * scale;
|
|
60
|
+
const x = (pageWidth - width) / 2;
|
|
61
|
+
const y = (pageHeight - height) / 2;
|
|
62
|
+
|
|
63
|
+
return { width, height, x, y };
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsPDF } from 'jspdf';
|
|
2
|
+
import type { GanttProps } from '../Gantt.js';
|
|
3
|
+
import type { Project } from '../types.js';
|
|
4
|
+
import { computeImageFit, PAGE_DIMENSIONS_MM } from './pdf-dimensions.js';
|
|
5
|
+
import { exportPNG } from './png.js';
|
|
6
|
+
import type { PdfExportOptions } from './types.js';
|
|
7
|
+
|
|
8
|
+
export async function exportPDF(args: {
|
|
9
|
+
scheduled: Project;
|
|
10
|
+
ganttProps: Pick<
|
|
11
|
+
GanttProps,
|
|
12
|
+
| 'cellWidth'
|
|
13
|
+
| 'cellHeight'
|
|
14
|
+
| 'markers'
|
|
15
|
+
| 'baselineIndex'
|
|
16
|
+
| 'showBaselineBars'
|
|
17
|
+
| 'columns'
|
|
18
|
+
| 'height'
|
|
19
|
+
| 'visibleTaskIds'
|
|
20
|
+
>;
|
|
21
|
+
options: PdfExportOptions;
|
|
22
|
+
}): Promise<Blob> {
|
|
23
|
+
const { scheduled, ganttProps, options } = args;
|
|
24
|
+
const orientation = options.orientation ?? 'landscape';
|
|
25
|
+
const format = options.format ?? 'a3';
|
|
26
|
+
const margin = options.margin ?? 10;
|
|
27
|
+
const backgroundColor = options.backgroundColor ?? '#ffffff';
|
|
28
|
+
|
|
29
|
+
const pngBlob = await exportPNG({
|
|
30
|
+
scheduled,
|
|
31
|
+
ganttProps,
|
|
32
|
+
options: { backgroundColor },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const dataUrl = await blobToDataUrl(pngBlob);
|
|
36
|
+
const { width: pngPxWidth, height: pngPxHeight } = await probeImageDimensions(dataUrl);
|
|
37
|
+
|
|
38
|
+
const page = PAGE_DIMENSIONS_MM[format][orientation];
|
|
39
|
+
const fit = computeImageFit({
|
|
40
|
+
pageWidth: page.width,
|
|
41
|
+
pageHeight: page.height,
|
|
42
|
+
margin,
|
|
43
|
+
pngPxWidth,
|
|
44
|
+
pngPxHeight,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const pdf = new jsPDF({ orientation, unit: 'mm', format });
|
|
48
|
+
pdf.addImage(dataUrl, 'PNG', fit.x, fit.y, fit.width, fit.height);
|
|
49
|
+
return pdf.output('blob');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function blobToDataUrl(blob: Blob): Promise<string> {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const reader = new FileReader();
|
|
55
|
+
reader.onload = () => resolve(String(reader.result));
|
|
56
|
+
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
|
|
57
|
+
reader.readAsDataURL(blob);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function probeImageDimensions(dataUrl: string): Promise<{ width: number; height: number }> {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const img = new Image();
|
|
64
|
+
img.onload = () => resolve({ width: img.width, height: img.height });
|
|
65
|
+
img.onerror = () => reject(new Error('Failed to probe PNG dimensions'));
|
|
66
|
+
img.src = dataUrl;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { toBlob } from 'html-to-image';
|
|
2
|
+
import type { GanttProps } from '../Gantt.js';
|
|
3
|
+
import type { Project } from '../types.js';
|
|
4
|
+
import { type OffscreenGanttProps, renderOffscreen } from './offscreen.js';
|
|
5
|
+
import type { PngExportOptions } from './types.js';
|
|
6
|
+
|
|
7
|
+
export async function exportPNG(args: {
|
|
8
|
+
scheduled: Project;
|
|
9
|
+
ganttProps: Pick<
|
|
10
|
+
GanttProps,
|
|
11
|
+
| 'cellWidth'
|
|
12
|
+
| 'cellHeight'
|
|
13
|
+
| 'markers'
|
|
14
|
+
| 'baselineIndex'
|
|
15
|
+
| 'showBaselineBars'
|
|
16
|
+
| 'columns'
|
|
17
|
+
| 'height'
|
|
18
|
+
| 'visibleTaskIds'
|
|
19
|
+
>;
|
|
20
|
+
options: PngExportOptions;
|
|
21
|
+
}): Promise<Blob> {
|
|
22
|
+
const { scheduled, ganttProps, options } = args;
|
|
23
|
+
|
|
24
|
+
const pixelRatio =
|
|
25
|
+
options.pixelRatio ??
|
|
26
|
+
(typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 2);
|
|
27
|
+
const backgroundColor = options.backgroundColor ?? '#ffffff';
|
|
28
|
+
|
|
29
|
+
const offscreen = await renderOffscreen({
|
|
30
|
+
scheduled,
|
|
31
|
+
ganttProps: ganttProps as OffscreenGanttProps,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const blob = await toBlob(offscreen.container, {
|
|
36
|
+
backgroundColor,
|
|
37
|
+
pixelRatio,
|
|
38
|
+
cacheBust: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!blob) {
|
|
42
|
+
throw new Error('exportPNG: html-to-image returned null');
|
|
43
|
+
}
|
|
44
|
+
return blob;
|
|
45
|
+
} finally {
|
|
46
|
+
await offscreen.dispose();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Public types for the construction-gantt/export subpath.
|
|
2
|
+
// Imported by the core <Gantt> for its ref handle, and re-exported
|
|
3
|
+
// from the subpath entry for direct consumer use.
|
|
4
|
+
|
|
5
|
+
import type { Task } from '../types.js';
|
|
6
|
+
|
|
7
|
+
export interface PngExportOptions {
|
|
8
|
+
/** Background colour for the captured canvas. Defaults to '#ffffff'. */
|
|
9
|
+
backgroundColor?: string;
|
|
10
|
+
/** Pixel ratio. Defaults to window.devicePixelRatio || 2. */
|
|
11
|
+
pixelRatio?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PdfExportOptions {
|
|
15
|
+
/** Page orientation. Defaults to 'landscape' (Gantt charts are wide). */
|
|
16
|
+
orientation?: 'landscape' | 'portrait';
|
|
17
|
+
/** Page size. Defaults to 'a3'. */
|
|
18
|
+
format?: 'a4' | 'a3' | 'letter';
|
|
19
|
+
/** Margin in mm. Defaults to 10. */
|
|
20
|
+
margin?: number;
|
|
21
|
+
/** Background colour for the underlying PNG. Defaults to '#ffffff'. */
|
|
22
|
+
backgroundColor?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface XlsxColumn {
|
|
26
|
+
header: string;
|
|
27
|
+
/** A key on Task, or a function returning the cell value. */
|
|
28
|
+
value: keyof Task | ((task: Task) => string | number | Date | undefined);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface XlsxExportOptions {
|
|
32
|
+
/** Sheet name. Defaults to 'Programme'. */
|
|
33
|
+
sheetName?: string;
|
|
34
|
+
/** Override the default column set. */
|
|
35
|
+
columns?: XlsxColumn[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GanttHandle {
|
|
39
|
+
exportPNG(options?: PngExportOptions): Promise<Blob>;
|
|
40
|
+
exportPDF(options?: PdfExportOptions): Promise<Blob>;
|
|
41
|
+
exportXLSX(options?: XlsxExportOptions): Promise<Blob>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx';
|
|
2
|
+
import type { Project, Task } from '../types.js';
|
|
3
|
+
import type { XlsxColumn, XlsxExportOptions } from './types.js';
|
|
4
|
+
|
|
5
|
+
export type SheetCell = string | number | Date;
|
|
6
|
+
export type SheetRow = SheetCell[];
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_XLSX_COLUMNS: XlsxColumn[] = [
|
|
9
|
+
{ header: 'ID', value: 'id' },
|
|
10
|
+
{ header: 'Name', value: 'text' },
|
|
11
|
+
{ header: 'Start', value: 'start' },
|
|
12
|
+
{ header: 'End', value: 'end' },
|
|
13
|
+
{ header: 'Duration (working minutes)', value: 'duration' },
|
|
14
|
+
{
|
|
15
|
+
header: 'Critical',
|
|
16
|
+
value: (t: Task) => (t.computed?.isCritical ? 'Y' : 'N'),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
header: 'Total slack (working minutes)',
|
|
20
|
+
value: (t: Task) => t.computed?.totalSlack ?? 0,
|
|
21
|
+
},
|
|
22
|
+
{ header: 'Progress (%)', value: 'progress' },
|
|
23
|
+
{
|
|
24
|
+
header: 'Parent',
|
|
25
|
+
value: (t: Task) => t.parent ?? '',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function buildSheetRows(project: Project, columns: XlsxColumn[]): SheetRow[] {
|
|
30
|
+
const header: SheetRow = columns.map((c) => c.header);
|
|
31
|
+
const dataRows: SheetRow[] = project.tasks.map((task) => columns.map((c) => readCell(task, c)));
|
|
32
|
+
return [header, ...dataRows];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readCell(task: Task, column: XlsxColumn): SheetCell {
|
|
36
|
+
if (typeof column.value === 'function') {
|
|
37
|
+
const raw = column.value(task);
|
|
38
|
+
return raw ?? '';
|
|
39
|
+
}
|
|
40
|
+
const raw = task[column.value];
|
|
41
|
+
if (raw === undefined || raw === null) return '';
|
|
42
|
+
if (raw instanceof Date) return raw;
|
|
43
|
+
if (typeof raw === 'number' || typeof raw === 'string') return raw;
|
|
44
|
+
return String(raw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function exportXLSX({
|
|
48
|
+
scheduled,
|
|
49
|
+
options,
|
|
50
|
+
}: {
|
|
51
|
+
scheduled: Project;
|
|
52
|
+
options: XlsxExportOptions;
|
|
53
|
+
}): Promise<Blob> {
|
|
54
|
+
const columns = options.columns ?? DEFAULT_XLSX_COLUMNS;
|
|
55
|
+
const sheetName = options.sheetName ?? 'Programme';
|
|
56
|
+
|
|
57
|
+
const rows = buildSheetRows(scheduled, columns);
|
|
58
|
+
const sheet = XLSX.utils.aoa_to_sheet(rows, { cellDates: true });
|
|
59
|
+
const workbook = XLSX.utils.book_new();
|
|
60
|
+
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
|
|
61
|
+
|
|
62
|
+
const arrayBuffer = XLSX.write(workbook, {
|
|
63
|
+
type: 'array',
|
|
64
|
+
bookType: 'xlsx',
|
|
65
|
+
}) as ArrayBuffer;
|
|
66
|
+
|
|
67
|
+
return new Blob([arrayBuffer], {
|
|
68
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
69
|
+
});
|
|
70
|
+
}
|