Skip to content

Commit 62152ce

Browse files
committed
Refactor code to allow multiple views
1 parent d48624b commit 62152ce

18 files changed

+460
-508
lines changed

site/src/components/GanttChart/Bar.stories.tsx

Lines changed: 0 additions & 28 deletions
This file was deleted.

site/src/components/GanttChart/Label.stories.tsx

Lines changed: 0 additions & 33 deletions
This file was deleted.

site/src/components/GanttChart/Label.tsx

Lines changed: 0 additions & 39 deletions
This file was deleted.

site/src/components/GanttChart/XGrid.stories.tsx

Lines changed: 0 additions & 23 deletions
This file was deleted.

site/src/components/GanttChart/XValues.stories.tsx

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import { XGrid } from "./XGrid";
3+
import { XAxis } from "./XAxis";
4+
import type { FC } from "react";
5+
import { TimingBlocks } from "./TimingBlocks";
6+
import {
7+
YAxis,
8+
YAxisCaption,
9+
YAxisCaptionHeight,
10+
YAxisLabel,
11+
YAxisLabels,
12+
YAxisSection,
13+
} from "./YAxis";
14+
import {
15+
barsSpacing,
16+
columnWidth,
17+
contentSidePadding,
18+
intervalDimension,
19+
XAxisHeight,
20+
} from "./constants";
21+
import { Bar } from "./Bar";
22+
23+
export type ChartProps = {
24+
data: DataSection[];
25+
onBarClick: (label: string, section: string) => void;
26+
};
27+
28+
// This chart can split data into sections. Eg. display the provisioning timings
29+
// in one section and the scripting time in another
30+
type DataSection = {
31+
name: string;
32+
timings: Timing[];
33+
};
34+
35+
// Useful to perform chart operations without requiring additional information
36+
// such as labels or counts, which are only used for display purposes.
37+
export type Duration = {
38+
startedAt: Date;
39+
endedAt: Date;
40+
};
41+
42+
export type Timing = Duration & {
43+
/**
44+
* Label that will be displayed on the Y axis.
45+
*/
46+
label: string;
47+
/**
48+
* A timing can represent either a single time block or a group of time
49+
* blocks. When it represents a group, we display blocks within the bars to
50+
* clearly indicate to the user that the timing encompasses multiple time
51+
* blocks.
52+
*/
53+
count: number;
54+
};
55+
56+
export const Chart: FC<ChartProps> = ({ data, onBarClick }) => {
57+
const totalDuration = calcTotalDuration(data.flatMap((d) => d.timings));
58+
const intervals = createIntervals(totalDuration, intervalDimension);
59+
60+
return (
61+
<div css={styles.chart}>
62+
<YAxis>
63+
{data.map((section) => (
64+
<YAxisSection key={section.name}>
65+
<YAxisCaption>{section.name}</YAxisCaption>
66+
<YAxisLabels>
67+
{section.timings.map((t) => (
68+
<YAxisLabel key={t.label} id={`${t.label}-label`}>
69+
{t.label}
70+
</YAxisLabel>
71+
))}
72+
</YAxisLabels>
73+
</YAxisSection>
74+
))}
75+
</YAxis>
76+
77+
<div css={styles.main}>
78+
<XAxis labels={intervals.map(formatAsTimer)} />
79+
<div css={styles.content}>
80+
{data.map((section) => {
81+
return (
82+
<div key={section.name} css={styles.bars}>
83+
{section.timings.map((t) => {
84+
// The time this timing started relative to the initial timing
85+
const offset = diffInSeconds(
86+
t.startedAt,
87+
totalDuration.startedAt,
88+
);
89+
const size = secondsToPixel(durationToSeconds(t));
90+
return (
91+
<Bar
92+
key={t.label}
93+
x={secondsToPixel(offset)}
94+
width={size}
95+
afterLabel={`${durationToSeconds(t).toFixed(2)}s`}
96+
aria-labelledby={`${t.label}-label`}
97+
ref={applyBarHeightToLabel}
98+
disabled={t.count <= 1}
99+
onClick={() => {
100+
onBarClick(t.label, section.name);
101+
}}
102+
>
103+
{t.count > 1 && (
104+
<TimingBlocks size={size} count={t.count} />
105+
)}
106+
</Bar>
107+
);
108+
})}
109+
</div>
110+
);
111+
})}
112+
113+
<XGrid columns={intervals.length} />
114+
</div>
115+
</div>
116+
</div>
117+
);
118+
};
119+
120+
// Ensures the sidebar label remains vertically aligned with its corresponding bar.
121+
const applyBarHeightToLabel = (bar: HTMLDivElement | null) => {
122+
if (!bar) {
123+
return;
124+
}
125+
const labelId = bar.getAttribute("aria-labelledby");
126+
if (!labelId) {
127+
return;
128+
}
129+
// Selecting a label with special characters (e.g.,
130+
// #coder_metadata.container_info[0]) will fail because it is not a valid
131+
// selector. To handle this, we need to query by the id attribute and escape
132+
// it with quotes.
133+
const label = document.querySelector<HTMLSpanElement>(`[id="${labelId}"]`);
134+
if (!label) {
135+
return;
136+
}
137+
label.style.height = `${bar.clientHeight}px`;
138+
};
139+
140+
// Format a number in seconds to 00:00:00 format
141+
const formatAsTimer = (seconds: number): string => {
142+
const hours = Math.floor(seconds / 3600);
143+
const minutes = Math.floor((seconds % 3600) / 60);
144+
const remainingSeconds = seconds % 60;
145+
146+
return `${hours.toString().padStart(2, "0")}:${minutes
147+
.toString()
148+
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
149+
};
150+
151+
const durationToSeconds = (duration: Duration): number => {
152+
return (duration.endedAt.getTime() - duration.startedAt.getTime()) / 1000;
153+
};
154+
155+
// Create the intervals to be used in the XAxis
156+
const createIntervals = (duration: Duration, range: number): number[] => {
157+
const intervals = Math.ceil(durationToSeconds(duration) / range);
158+
return Array.from({ length: intervals }, (_, i) => i * range + range);
159+
};
160+
161+
const secondsToPixel = (seconds: number): number => {
162+
return (columnWidth * seconds) / intervalDimension;
163+
};
164+
165+
// Combine multiple durations into a single duration by using the initial start
166+
// time and the final end time.
167+
export const calcTotalDuration = (durations: readonly Duration[]): Duration => {
168+
const sortedDurations = durations
169+
.slice()
170+
.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime());
171+
const start = sortedDurations[0].startedAt;
172+
173+
const sortedEndDurations = durations
174+
.slice()
175+
.sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime());
176+
const end = sortedEndDurations[sortedEndDurations.length - 1].endedAt;
177+
return { startedAt: start, endedAt: end };
178+
};
179+
180+
const diffInSeconds = (b: Date, a: Date): number => {
181+
return (b.getTime() - a.getTime()) / 1000;
182+
};
183+
184+
const styles = {
185+
chart: {
186+
display: "flex",
187+
alignItems: "stretch",
188+
height: "100%",
189+
fontSize: 12,
190+
fontWeight: 500,
191+
},
192+
sidebar: {
193+
width: columnWidth,
194+
flexShrink: 0,
195+
padding: `${XAxisHeight}px 16px`,
196+
},
197+
caption: (theme) => ({
198+
height: YAxisCaptionHeight,
199+
display: "flex",
200+
alignItems: "center",
201+
fontSize: 10,
202+
fontWeight: 500,
203+
color: theme.palette.text.secondary,
204+
}),
205+
labels: {
206+
margin: 0,
207+
padding: 0,
208+
listStyle: "none",
209+
display: "flex",
210+
flexDirection: "column",
211+
gap: barsSpacing,
212+
textAlign: "right",
213+
},
214+
main: (theme) => ({
215+
display: "flex",
216+
flexDirection: "column",
217+
flex: 1,
218+
borderLeft: `1px solid ${theme.palette.divider}`,
219+
}),
220+
content: {
221+
flex: 1,
222+
position: "relative",
223+
},
224+
bars: {
225+
display: "flex",
226+
flexDirection: "column",
227+
gap: barsSpacing,
228+
padding: `${YAxisCaptionHeight}px ${contentSidePadding}px`,
229+
},
230+
} satisfies Record<string, Interpolation<Theme>>;

0 commit comments

Comments
 (0)