Skip to content

Commit 1a1d22a

Browse files
authored
Merge pull request #951 from keisuke-umezawa/feature/add-plottimeline
Move `PlotTimeline` from `optuna_dashboard/ts` to `tslib/react`
2 parents 81fa76b + 09c514e commit 1a1d22a

File tree

8 files changed

+320
-194
lines changed

8 files changed

+320
-194
lines changed
Lines changed: 10 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
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"
7+
import { studyDetailToStudy } from "../graphUtil"
88
import { usePlot } from "../hooks/usePlot"
99
import { usePlotlyColorTheme } from "../state"
1010
import { useBackendRender } from "../state"
1111

1212
const plotDomId = "graph-timeline"
13-
const maxBars = 100
1413

1514
export const GraphTimeline: FC<{
1615
study: StudyDetail | null
1716
}> = ({ study }) => {
17+
const theme = useTheme()
18+
const colorTheme = usePlotlyColorTheme(theme.palette.mode)
19+
1820
if (useBackendRender()) {
1921
return <GraphTimelineBackend study={study} />
2022
} else {
21-
return <GraphTimelineFrontend study={study} />
23+
return (
24+
<PlotTimeline study={studyDetailToStudy(study)} colorTheme={colorTheme} />
25+
)
2226
}
2327
}
2428

@@ -51,176 +55,3 @@ const GraphTimelineBackend: FC<{
5155
</Grid>
5256
)
5357
}
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-
}

optuna_dashboard/ts/components/StudyDetail.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import { Link, useParams } from "react-router-dom"
1616
import { useRecoilValue } from "recoil"
1717

1818
import { TrialTable } from "@optuna/react"
19-
import * as Optuna from "@optuna/types"
2019
import { actionCreator } from "../action"
2120
import { useConstants } from "../constantsProvider"
21+
import { studyDetailToStudy } from "../graphUtil"
2222
import {
2323
reloadIntervalState,
2424
useStudyDetailValue,
@@ -62,19 +62,7 @@ export const StudyDetail: FC<{
6262
const reloadInterval = useRecoilValue<number>(reloadIntervalState)
6363
const studyName = useStudyName(studyId)
6464
const isPreferential = useStudyIsPreferential(studyId)
65-
const study: Optuna.Study | null = studyDetail
66-
? {
67-
id: studyDetail.id,
68-
name: studyDetail.name,
69-
directions: studyDetail.directions,
70-
union_search_space: studyDetail.union_search_space,
71-
intersection_search_space: studyDetail.intersection_search_space,
72-
union_user_attrs: studyDetail.union_user_attrs,
73-
datetime_start: studyDetail.datetime_start,
74-
trials: studyDetail.trials,
75-
metric_names: studyDetail.metric_names,
76-
}
77-
: null
65+
const study = studyDetailToStudy(studyDetail)
7866

7967
const title =
8068
studyName !== null ? `${studyName} (id=${studyId})` : `Study #${studyId}`

optuna_dashboard/ts/graphUtil.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Optuna from "@optuna/types"
2-
import { SearchSpaceItem, Trial } from "./types/optuna"
2+
import { SearchSpaceItem, StudyDetail, Trial } from "./types/optuna"
33

44
const PADDING_RATIO = 0.05
55

@@ -115,3 +115,23 @@ export const makeHovertext = (trial: Trial): string => {
115115
" "
116116
).replace(/\n/g, "<br>")
117117
}
118+
119+
export const studyDetailToStudy = (
120+
studyDetail: StudyDetail | null
121+
): Optuna.Study | null => {
122+
const study: Optuna.Study | null = studyDetail
123+
? {
124+
id: studyDetail.id,
125+
name: studyDetail.name,
126+
directions: studyDetail.directions,
127+
union_search_space: studyDetail.union_search_space,
128+
intersection_search_space: studyDetail.intersection_search_space,
129+
union_user_attrs: studyDetail.union_user_attrs,
130+
datetime_start: studyDetail.datetime_start,
131+
trials: studyDetail.trials,
132+
metric_names: studyDetail.metric_names,
133+
}
134+
: null
135+
136+
return study
137+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { CssBaseline, ThemeProvider } from "@mui/material"
2+
import { Meta, StoryObj } from "@storybook/react"
3+
import React from "react"
4+
import { useMockStudy } from "../MockStudies"
5+
import { darkTheme } from "../styles/darkTheme"
6+
import { lightTheme } from "../styles/lightTheme"
7+
import { PlotTimeline } from "./PlotTimeline"
8+
9+
const meta: Meta<typeof PlotTimeline> = {
10+
component: PlotTimeline,
11+
title: "Plot/Timeline",
12+
tags: ["autodocs"],
13+
decorators: [
14+
(Story, storyContext) => {
15+
const { study } = useMockStudy(storyContext.parameters?.studyId)
16+
if (!study) return <p>loading...</p>
17+
return (
18+
<ThemeProvider theme={storyContext.parameters?.theme}>
19+
<CssBaseline />
20+
<Story
21+
args={{
22+
study,
23+
}}
24+
/>
25+
</ThemeProvider>
26+
)
27+
},
28+
],
29+
}
30+
31+
export default meta
32+
type Story = StoryObj<typeof PlotTimeline>
33+
34+
export const LightTheme: Story = {
35+
parameters: {
36+
studyId: 1,
37+
theme: lightTheme,
38+
},
39+
}
40+
41+
export const DarkTheme: Story = {
42+
parameters: {
43+
studyId: 1,
44+
theme: darkTheme,
45+
},
46+
}
47+
48+
// TODO(c-bata): Add a story for multi objective study.
49+
// export const MultiObjective: Story = {
50+
// parameters: {
51+
// ...
52+
// },
53+
// }

0 commit comments

Comments
 (0)