Skip to content

Commit 02ccc2e

Browse files
Add PlotTimeline in tslib
1 parent 9c13b76 commit 02ccc2e

File tree

3 files changed

+208
-179
lines changed

3 files changed

+208
-179
lines changed
Lines changed: 7 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
1-
import { Card, CardContent, Grid, Typography, useTheme } from "@mui/material"
2-
import * as Optuna from "@optuna/types"
1+
import { Grid, useTheme } from "@mui/material"
2+
import { PlotTimeline } from "@optuna/react"
33
import * as plotly from "plotly.js-dist-min"
44
import React, { FC, useEffect } from "react"
5-
import { StudyDetail, Trial } from "ts/types/optuna"
5+
import { StudyDetail } from "ts/types/optuna"
66
import { PlotType } from "../apiClient"
7-
import { makeHovertext } from "../graphUtil"
87
import { usePlot } from "../hooks/usePlot"
98
import { usePlotlyColorTheme } from "../state"
109
import { useBackendRender } from "../state"
1110

1211
const plotDomId = "graph-timeline"
13-
const maxBars = 100
1412

1513
export const GraphTimeline: FC<{
1614
study: StudyDetail | null
1715
}> = ({ study }) => {
16+
const theme = useTheme()
17+
const colorTheme = usePlotlyColorTheme(theme.palette.mode)
18+
1819
if (useBackendRender()) {
1920
return <GraphTimelineBackend study={study} />
2021
} else {
21-
return <GraphTimelineFrontend study={study} />
22+
return <PlotTimeline study={study} colorTheme={colorTheme} />
2223
}
2324
}
2425

@@ -51,176 +52,3 @@ const GraphTimelineBackend: FC<{
5152
</Grid>
5253
)
5354
}
54-
55-
const GraphTimelineFrontend: FC<{
56-
study: StudyDetail | null
57-
}> = ({ study }) => {
58-
const theme = useTheme()
59-
const colorTheme = usePlotlyColorTheme(theme.palette.mode)
60-
61-
const trials = study?.trials ?? []
62-
63-
useEffect(() => {
64-
if (study !== null) {
65-
plotTimeline(trials, colorTheme)
66-
}
67-
}, [trials, colorTheme])
68-
69-
return (
70-
<Card>
71-
<CardContent>
72-
<Typography
73-
variant="h6"
74-
sx={{ margin: "1em 0", fontWeight: theme.typography.fontWeightBold }}
75-
>
76-
Timeline
77-
</Typography>
78-
<Grid item xs={9}>
79-
<div id={plotDomId} />
80-
</Grid>
81-
</CardContent>
82-
</Card>
83-
)
84-
}
85-
86-
const plotTimeline = (
87-
trials: Trial[],
88-
colorTheme: Partial<Plotly.Template>
89-
) => {
90-
if (document.getElementById(plotDomId) === null) {
91-
return
92-
}
93-
94-
if (trials.length === 0) {
95-
plotly.react(plotDomId, [], {
96-
template: colorTheme,
97-
})
98-
return
99-
}
100-
101-
const cm: Record<Optuna.TrialState, string> = {
102-
Complete: "blue",
103-
Fail: "red",
104-
Pruned: "orange",
105-
Running: "green",
106-
Waiting: "gray",
107-
}
108-
const runningKey = "Running"
109-
110-
const lastTrials = trials.slice(-maxBars) // To only show last elements
111-
const minDatetime = new Date(
112-
Math.min(
113-
...lastTrials.map(
114-
(t) => t.datetime_start?.getTime() ?? new Date().getTime()
115-
)
116-
)
117-
)
118-
const maxRunDuration = Math.max(
119-
...trials.map((t) => {
120-
return t.datetime_start === undefined || t.datetime_complete === undefined
121-
? -Infinity
122-
: t.datetime_complete.getTime() - t.datetime_start.getTime()
123-
})
124-
)
125-
const hasRunning =
126-
(maxRunDuration === -Infinity &&
127-
trials.some((t) => t.state === runningKey)) ||
128-
trials.some((t) => {
129-
if (t.state !== runningKey) {
130-
return false
131-
}
132-
const now = new Date().getTime()
133-
const start = t.datetime_start?.getTime() ?? now
134-
// This is an ad-hoc handling to check if the trial is running.
135-
// We do not check via `trialState` because some trials may have state=RUNNING,
136-
// even if they are not running because of unexpected job kills.
137-
// In this case, we would like to ensure that these trials will not squash the timeline plot
138-
// for the other trials.
139-
return now - start < maxRunDuration * 5
140-
})
141-
const maxDatetime = hasRunning
142-
? new Date()
143-
: new Date(
144-
Math.max(
145-
...lastTrials.map(
146-
(t) => t.datetime_complete?.getTime() ?? minDatetime.getTime()
147-
)
148-
)
149-
)
150-
const layout: Partial<plotly.Layout> = {
151-
margin: {
152-
l: 50,
153-
t: 0,
154-
r: 50,
155-
b: 0,
156-
},
157-
xaxis: {
158-
title: "Datetime",
159-
type: "date",
160-
range: [minDatetime, maxDatetime],
161-
},
162-
yaxis: {
163-
title: "Trial",
164-
range: [lastTrials[0].number, lastTrials[0].number + lastTrials.length],
165-
},
166-
uirevision: "true",
167-
template: colorTheme,
168-
legend: {
169-
x: 1.0,
170-
y: 0.95,
171-
},
172-
}
173-
174-
const makeTrace = (bars: Trial[], state: string, color: string) => {
175-
const isRunning = state === runningKey
176-
// Waiting trials should not squash other trials, so use `maxDatetime` instead of `new Date()`.
177-
const starts = bars.map((b) => b.datetime_start ?? maxDatetime)
178-
const runDurations = bars.map((b, i) => {
179-
const startTime = starts[i].getTime()
180-
const completeTime = isRunning
181-
? maxDatetime.getTime()
182-
: b.datetime_complete?.getTime() ?? startTime
183-
// By using 1 as the min value, we can recognize these bars at least when zooming in.
184-
return Math.max(1, completeTime - startTime)
185-
})
186-
const trace: Partial<plotly.PlotData> = {
187-
type: "bar",
188-
x: runDurations,
189-
y: bars.map((b) => b.number),
190-
// @ts-ignore: To suppress ts(2322)
191-
base: starts,
192-
name: state,
193-
text: bars.map((b) => makeHovertext(b)),
194-
hovertemplate: "%{text}<extra>" + state + "</extra>",
195-
orientation: "h",
196-
marker: { color: color },
197-
textposition: "none", // Avoid drawing hovertext in a bar.
198-
}
199-
return trace
200-
}
201-
202-
const traces: Partial<plotly.PlotData>[] = []
203-
for (const [state, color] of Object.entries(cm)) {
204-
const bars = trials.filter((t) => t.state === state)
205-
if (bars.length === 0) {
206-
continue
207-
}
208-
if (state === "Complete") {
209-
const feasibleTrials = bars.filter((t) =>
210-
t.constraints.every((c) => c <= 0)
211-
)
212-
const infeasibleTrials = bars.filter((t) =>
213-
t.constraints.some((c) => c > 0)
214-
)
215-
if (feasibleTrials.length > 0) {
216-
traces.push(makeTrace(feasibleTrials, "Complete", color))
217-
}
218-
if (infeasibleTrials.length > 0) {
219-
traces.push(makeTrace(infeasibleTrials, "Infeasible", "#cccccc"))
220-
}
221-
} else {
222-
traces.push(makeTrace(bars, state, color))
223-
}
224-
}
225-
plotly.react(plotDomId, traces, layout)
226-
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Card, CardContent, Grid, Typography, useTheme } from "@mui/material"
2+
import * as Optuna from "@optuna/types"
3+
import * as plotly from "plotly.js-dist-min"
4+
import { FC, useEffect } from "react"
5+
import { makeHovertext } from "../utils/graphUtil"
6+
import { plotlyDarkTemplate } from "./PlotlyDarkMode"
7+
8+
const plotDomId = "graph-timeline"
9+
const maxBars = 100
10+
11+
export const PlotTimeline: FC<{
12+
study: Optuna.Study
13+
colorTheme?: Partial<Plotly.Template>
14+
}> = ({ study, colorTheme }) => {
15+
const theme = useTheme()
16+
const colorThemeUsed =
17+
colorTheme ?? (theme.palette.mode === "dark" ? plotlyDarkTemplate : {})
18+
const trials = study?.trials ?? []
19+
20+
useEffect(() => {
21+
if (study !== null) {
22+
plotTimeline(trials, colorThemeUsed)
23+
}
24+
}, [study, trials, colorThemeUsed])
25+
26+
return (
27+
<Card>
28+
<CardContent>
29+
<Typography
30+
variant="h6"
31+
sx={{ margin: "1em 0", fontWeight: theme.typography.fontWeightBold }}
32+
>
33+
Timeline
34+
</Typography>
35+
<Grid item xs={9}>
36+
<div id={plotDomId} />
37+
</Grid>
38+
</CardContent>
39+
</Card>
40+
)
41+
}
42+
43+
const plotTimeline = (
44+
trials: Optuna.Trial[],
45+
colorTheme: Partial<Plotly.Template>
46+
) => {
47+
if (document.getElementById(plotDomId) === null) {
48+
return
49+
}
50+
51+
if (trials.length === 0) {
52+
plotly.react(plotDomId, [], {
53+
template: colorTheme,
54+
})
55+
return
56+
}
57+
58+
const cm: Record<Optuna.TrialState, string> = {
59+
Complete: "blue",
60+
Fail: "red",
61+
Pruned: "orange",
62+
Running: "green",
63+
Waiting: "gray",
64+
}
65+
const runningKey = "Running"
66+
67+
const lastTrials = trials.slice(-maxBars) // To only show last elements
68+
const minDatetime = new Date(
69+
Math.min(
70+
...lastTrials.map(
71+
(t) => t.datetime_start?.getTime() ?? new Date().getTime()
72+
)
73+
)
74+
)
75+
const maxRunDuration = Math.max(
76+
...trials.map((t) => {
77+
return t.datetime_start === undefined || t.datetime_complete === undefined
78+
? -Infinity
79+
: t.datetime_complete.getTime() - t.datetime_start.getTime()
80+
})
81+
)
82+
const hasRunning =
83+
(maxRunDuration === -Infinity &&
84+
trials.some((t) => t.state === runningKey)) ||
85+
trials.some((t) => {
86+
if (t.state !== runningKey) {
87+
return false
88+
}
89+
const now = new Date().getTime()
90+
const start = t.datetime_start?.getTime() ?? now
91+
// This is an ad-hoc handling to check if the trial is running.
92+
// We do not check via `trialState` because some trials may have state=RUNNING,
93+
// even if they are not running because of unexpected job kills.
94+
// In this case, we would like to ensure that these trials will not squash the timeline plot
95+
// for the other trials.
96+
return now - start < maxRunDuration * 5
97+
})
98+
const maxDatetime = hasRunning
99+
? new Date()
100+
: new Date(
101+
Math.max(
102+
...lastTrials.map(
103+
(t) => t.datetime_complete?.getTime() ?? minDatetime.getTime()
104+
)
105+
)
106+
)
107+
const layout: Partial<plotly.Layout> = {
108+
margin: {
109+
l: 50,
110+
t: 0,
111+
r: 50,
112+
b: 0,
113+
},
114+
xaxis: {
115+
title: "Datetime",
116+
type: "date",
117+
range: [minDatetime, maxDatetime],
118+
},
119+
yaxis: {
120+
title: "Trial",
121+
range: [lastTrials[0].number, lastTrials[0].number + lastTrials.length],
122+
},
123+
uirevision: "true",
124+
template: colorTheme,
125+
legend: {
126+
x: 1.0,
127+
y: 0.95,
128+
},
129+
}
130+
131+
const makeTrace = (bars: Optuna.Trial[], state: string, color: string) => {
132+
const isRunning = state === runningKey
133+
// Waiting trials should not squash other trials, so use `maxDatetime` instead of `new Date()`.
134+
const starts = bars.map((b) => b.datetime_start ?? maxDatetime)
135+
const runDurations = bars.map((b, i) => {
136+
const startTime = starts[i].getTime()
137+
const completeTime = isRunning
138+
? maxDatetime.getTime()
139+
: b.datetime_complete?.getTime() ?? startTime
140+
// By using 1 as the min value, we can recognize these bars at least when zooming in.
141+
return Math.max(1, completeTime - startTime)
142+
})
143+
const trace: Partial<plotly.PlotData> = {
144+
type: "bar",
145+
x: runDurations,
146+
y: bars.map((b) => b.number),
147+
// @ts-ignore: To suppress ts(2322)
148+
base: starts,
149+
name: state,
150+
text: bars.map((b) => makeHovertext(b)),
151+
hovertemplate: `%{text}<extra>${state}</extra>`,
152+
orientation: "h",
153+
marker: { color: color },
154+
textposition: "none", // Avoid drawing hovertext in a bar.
155+
}
156+
return trace
157+
}
158+
159+
const traces: Partial<plotly.PlotData>[] = []
160+
for (const [state, color] of Object.entries(cm)) {
161+
const bars = trials.filter((t) => t.state === state)
162+
if (bars.length === 0) {
163+
continue
164+
}
165+
if (state === "Complete") {
166+
const feasibleTrials = bars.filter((t) =>
167+
t.constraints.every((c) => c <= 0)
168+
)
169+
const infeasibleTrials = bars.filter((t) =>
170+
t.constraints.some((c) => c > 0)
171+
)
172+
if (feasibleTrials.length > 0) {
173+
traces.push(makeTrace(feasibleTrials, "Complete", color))
174+
}
175+
if (infeasibleTrials.length > 0) {
176+
traces.push(makeTrace(infeasibleTrials, "Infeasible", "#cccccc"))
177+
}
178+
} else {
179+
traces.push(makeTrace(bars, state, color))
180+
}
181+
}
182+
plotly.react(plotDomId, traces, layout)
183+
}

0 commit comments

Comments
 (0)