Delete some components from standalone_app

This commit is contained in:
porink0424
2024-03-29 10:10:23 +09:00
parent bd09d8173a
commit 413ba0b6ef
8 changed files with 5 additions and 857 deletions
+1 -1
View File
@@ -61,6 +61,7 @@
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@mui/system": "^5.15.9",
"@optuna/storage": "../storage/",
"plotly.js-dist-min": "^2.30.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -69,7 +70,6 @@
},
"devDependencies": {
"@biomejs/biome": "1.5.3",
"@optuna/storage": "../storage/",
"@optuna/types": "../types/",
"@storybook/addon-essentials": "^8.0.4",
"@storybook/addon-interactions": "^8.0.4",
-383
View File
@@ -1,383 +0,0 @@
import { Clear } from "@mui/icons-material"
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"
import {
Collapse,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TableSortLabel,
useTheme,
} from "@mui/material"
import { styled } from "@mui/system"
import React from "react"
type Order = "asc" | "desc"
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
type Value = any
const defaultRowsPerPageOption = [10, 50, 100, { label: "All", value: -1 }]
interface DataGridColumn<T> {
field: keyof T
label: string
sortable?: boolean
less?: (a: T, b: T, ascending: boolean) => number
filterable?: boolean
toCellValue?: (rowIndex: number) => string | React.ReactNode
padding?: "normal" | "checkbox" | "none"
}
interface RowFilter {
columnIdx: number
value: Value
}
function DataGrid<T>(props: {
columns: DataGridColumn<T>[]
rows: T[]
keyField: keyof T
dense?: boolean
collapseBody?: (rowIndex: number) => React.ReactNode
initialRowsPerPage?: number
rowsPerPageOption?: Array<number | { value: number; label: string }>
defaultFilter?: (row: T) => boolean
}): React.ReactElement {
const { columns, rows, keyField, dense, collapseBody, defaultFilter } = props
let { initialRowsPerPage, rowsPerPageOption } = props
const [order, setOrder] = React.useState<Order>("asc")
const [orderBy, setOrderBy] = React.useState<number>(0) // index of columns
const [page, setPage] = React.useState(0)
const [filters, setFilters] = React.useState<RowFilter[]>([])
const getRowIndex = (row: T): number => {
return rows.findIndex((row2) => row[keyField] === row2[keyField])
}
// Pagination
rowsPerPageOption = rowsPerPageOption || defaultRowsPerPageOption
initialRowsPerPage = initialRowsPerPage // use first element as default
? initialRowsPerPage
: isNumber(rowsPerPageOption[0])
? rowsPerPageOption[0]
: rowsPerPageOption[0].value
const [rowsPerPage, setRowsPerPage] = React.useState(initialRowsPerPage)
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage)
}
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setRowsPerPage(parseInt(event.target.value, 10))
setPage(0)
}
// Filtering
const fieldAlreadyFiltered = (columnIdx: number): boolean =>
filters.some((f) => f.columnIdx === columnIdx)
const handleClickFilterCell = (columnIdx: number, value: Value) => {
if (fieldAlreadyFiltered(columnIdx)) {
return
}
const newFilters = [...filters, { columnIdx: columnIdx, value: value }]
setFilters(newFilters)
}
const clearFilter = (columnIdx: number): void => {
setFilters(filters.filter((f) => f.columnIdx !== columnIdx))
}
const filteredRows = rows.filter((row, rowIdx) => {
if (defaultFilter?.(row)) {
return false
}
return filters.length === 0
? true
: filters.some((f) => {
if (columns.length <= f.columnIdx) {
console.log(
`columnIdx=${f.columnIdx} must be smaller than columns.length=${columns.length}`
)
return true
}
const toCellValue = columns[f.columnIdx].toCellValue
if (toCellValue !== undefined) {
return toCellValue(rowIdx) === f.value
}
const field = columns[f.columnIdx].field
return row[field] === f.value
})
})
// Sorting
const createSortHandler = (columnId: number) => () => {
const isAsc = orderBy === columnId && order === "asc"
setOrder(isAsc ? "desc" : "asc")
setOrderBy(columnId)
}
const sortedRows = stableSort<T>(filteredRows, order, orderBy, columns)
const currentPageRows =
rowsPerPage > 0
? sortedRows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
: sortedRows
const emptyRows =
rowsPerPage - Math.min(rowsPerPage, sortedRows.length - page * rowsPerPage)
const RootDiv = styled("div")({
width: "100%",
})
const HiddenSpan = styled("span")({
border: 0,
clip: "rect(0 0 0 0)",
height: 1,
margin: -1,
overflow: "hidden",
padding: 0,
position: "absolute",
top: 20,
width: 1,
})
const TableHeaderCellSpan = styled("span")({
display: "inline-flex",
})
return (
<RootDiv>
<TableContainer>
<Table
aria-labelledby="tableTitle"
size={dense ? "small" : "medium"}
aria-label="data grid"
>
<TableHead>
<TableRow>
{collapseBody ? <TableCell /> : null}
{columns.map((column, columnIdx) => (
<TableCell
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={columnIdx}
padding={column.padding || "normal"}
sortDirection={orderBy === column.field ? order : false}
>
<TableHeaderCellSpan>
{column.sortable ? (
<TableSortLabel
active={orderBy === columnIdx}
direction={orderBy === columnIdx ? order : "asc"}
onClick={createSortHandler(columnIdx)}
>
{column.label}
{orderBy === column.field ? (
<HiddenSpan>
{order === "desc"
? "sorted descending"
: "sorted ascending"}
</HiddenSpan>
) : null}
</TableSortLabel>
) : (
column.label
)}
{column.filterable ? (
<IconButton
size={dense ? "small" : "medium"}
style={
fieldAlreadyFiltered(columnIdx)
? {}
: { visibility: "hidden" }
}
color="inherit"
onClick={() => {
clearFilter(columnIdx)
}}
>
<Clear />
</IconButton>
) : null}
</TableHeaderCellSpan>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{currentPageRows.map((row) => (
<DataGridRow<T>
columns={columns}
rowIndex={getRowIndex(row)}
row={row}
keyField={keyField}
collapseBody={collapseBody}
key={`${row[keyField]}`}
handleClickFilterCell={handleClickFilterCell}
/>
))}
{emptyRows > 0 && (
<TableRow style={{ height: (dense ? 33 : 53) * emptyRows }}>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={rowsPerPageOption}
component="div"
count={filteredRows.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</RootDiv>
)
}
function DataGridRow<T>(props: {
columns: DataGridColumn<T>[]
rowIndex: number
row: T
keyField: keyof T
collapseBody?: (rowIndex: number) => React.ReactNode
handleClickFilterCell: (columnIdx: number, value: Value) => void
}) {
const {
columns,
rowIndex,
row,
keyField,
collapseBody,
handleClickFilterCell,
} = props
const [open, setOpen] = React.useState(false)
const theme = useTheme()
const FilterableDiv = styled("div")({
color: theme.palette.primary.main,
textDecoration: "underline",
cursor: "pointer",
})
return (
<React.Fragment>
<TableRow hover tabIndex={-1}>
{collapseBody ? (
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
) : null}
{columns.map((column, columnIndex) => {
const cellItem = column.toCellValue
? column.toCellValue(rowIndex)
: // TODO(c-bata): Avoid this implicit type conversion.
(row[column.field] as number | string | null | undefined)
return column.filterable ? (
<TableCell
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
padding={column.padding || "normal"}
onClick={() => {
const value =
column.toCellValue !== undefined
? column.toCellValue(rowIndex)
: row[column.field]
handleClickFilterCell(columnIndex, value)
}}
>
<FilterableDiv>{cellItem}</FilterableDiv>
</TableCell>
) : (
<TableCell
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
padding={column.padding || "normal"}
>
{cellItem}
</TableCell>
)
})}
</TableRow>
{collapseBody ? (
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
{collapseBody(rowIndex)}
</Collapse>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
)
}
function getComparator<T>(
order: Order,
columns: DataGridColumn<T>[],
orderBy: number
): (a: T, b: T) => number {
return order === "desc"
? (a, b) => descendingComparator<T>(a, b, columns, orderBy)
: (a, b) => -descendingComparator<T>(a, b, columns, orderBy)
}
function descendingComparator<T>(
a: T,
b: T,
columns: DataGridColumn<T>[],
orderBy: number
): number {
const field = columns[orderBy].field
if (b[field] < a[field]) {
return -1
}
if (b[field] > a[field]) {
return 1
}
return 0
}
function stableSort<T>(
array: T[],
order: Order,
orderBy: number,
columns: DataGridColumn<T>[]
) {
// TODO(c-bata): Refactor here by implementing as the same comparator interface.
const less = columns[orderBy].less
const comparator = getComparator(order, columns, orderBy)
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
stabilizedThis.sort((a, b) => {
if (less) {
const ascending = order === "asc"
const result = ascending
? -less(a[0], b[0], ascending)
: less(a[0], b[0], ascending)
if (result !== 0) return result
} else {
const result = comparator(a[0], b[0])
if (result !== 0) return result
}
return a[1] - b[1]
})
return stabilizedThis.map((el) => el[0])
}
const isNumber = (
rowsPerPage: number | { value: number; label: string }
): rowsPerPage is number => {
return typeof rowsPerPage === "number"
}
export { DataGrid }
export type { DataGridColumn }
@@ -1,312 +0,0 @@
import {
Checkbox,
FormControl,
FormControlLabel,
FormLabel,
Grid,
MenuItem,
Radio,
RadioGroup,
Select,
SelectChangeEvent,
Switch,
Typography,
useTheme,
} from "@mui/material"
import * as plotly from "plotly.js-dist-min"
import React, { ChangeEvent, FC, useEffect, useState } from "react"
import { plotlyDarkTemplate } from "../PlotlyDarkMode"
const plotDomId = "plot-history"
export const PlotHistory: FC<{
study: Study | null
}> = ({ study = null }) => {
const theme = useTheme()
const [xAxis, setXAxis] = useState<string>("number")
const [objectiveId, setObjectiveId] = useState<number>(0)
const [logScale, setLogScale] = useState<boolean>(false)
const [filterCompleteTrial, setFilterCompleteTrial] = useState<boolean>(false)
const [filterPrunedTrial, setFilterPrunedTrial] = useState<boolean>(false)
const handleObjectiveChange = (event: SelectChangeEvent<number>) => {
setObjectiveId(event.target.value as number)
}
const handleXAxisChange = (e: ChangeEvent<HTMLInputElement>) => {
setXAxis(e.target.value)
}
const handleLogScaleChange = () => {
setLogScale(!logScale)
}
const handleFilterCompleteChange = () => {
setFilterCompleteTrial(!filterCompleteTrial)
}
const handleFilterPrunedChange = () => {
setFilterPrunedTrial(!filterPrunedTrial)
}
useEffect(() => {
if (study !== null) {
plotHistory(
study,
objectiveId,
xAxis,
logScale,
filterCompleteTrial,
filterPrunedTrial,
theme.palette.mode
)
}
}, [
study,
objectiveId,
logScale,
xAxis,
filterPrunedTrial,
filterCompleteTrial,
theme.palette.mode,
])
return (
<Grid container direction="row">
<Grid
item
xs={3}
container
direction="column"
sx={{ paddingRight: theme.spacing(2) }}
>
<Typography variant="h6" sx={{ margin: "1em 0", fontWeight: 600 }}>
History
</Typography>
{study !== null && study.directions.length !== 1 ? (
<FormControl
component="fieldset"
sx={{ marginBottom: theme.spacing(2) }}
>
<FormLabel component="legend">Objective ID:</FormLabel>
<Select value={objectiveId} onChange={handleObjectiveChange}>
{study.directions.map((d, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<MenuItem value={i} key={i}>
{i}
</MenuItem>
))}
</Select>
</FormControl>
) : null}
<FormControl
component="fieldset"
sx={{ marginBottom: theme.spacing(2) }}
>
<FormLabel component="legend">Log y scale:</FormLabel>
<Switch
checked={logScale}
onChange={handleLogScaleChange}
value="enable"
/>
</FormControl>
<FormControl
component="fieldset"
sx={{ marginBottom: theme.spacing(2) }}
>
<FormLabel component="legend">Filter state:</FormLabel>
<FormControlLabel
control={
<Checkbox
checked={!filterCompleteTrial}
onChange={handleFilterCompleteChange}
/>
}
label="Complete"
/>
<FormControlLabel
control={
<Checkbox
checked={!filterPrunedTrial}
onChange={handleFilterPrunedChange}
/>
}
label="Pruned"
/>
</FormControl>
<FormControl
component="fieldset"
sx={{ marginBottom: theme.spacing(2) }}
>
<FormLabel component="legend">X-axis:</FormLabel>
<RadioGroup
aria-label="gender"
name="gender1"
value={xAxis}
onChange={handleXAxisChange}
>
<FormControlLabel
value="number"
control={<Radio />}
label="Number"
/>
<FormControlLabel
value="datetime_start"
control={<Radio />}
label="Datetime start"
/>
<FormControlLabel
value="datetime_complete"
control={<Radio />}
label="Datetime complete"
/>
</RadioGroup>
</FormControl>
</Grid>
<Grid item xs={9}>
<div id={plotDomId} />
</Grid>
</Grid>
)
}
const filterFunc = (trial: Trial, objectiveId: number): boolean => {
if (trial.state !== "Complete" && trial.state !== "Pruned") {
return false
}
if (trial.values === undefined) {
return false
}
return (
trial.values.length > objectiveId &&
trial.values[objectiveId] !== Infinity &&
trial.values[objectiveId] !== -Infinity
)
}
const plotHistory = (
study: Study,
objectiveId: number,
xAxis: string,
logScale: boolean,
filterCompleteTrial: boolean,
filterPrunedTrial: boolean,
mode: string
) => {
if (document.getElementById(plotDomId) === null) {
return
}
const layout: Partial<plotly.Layout> = {
margin: {
l: 50,
t: 0,
r: 50,
b: 0,
},
yaxis: {
title: "Objective Value",
type: logScale ? "log" : "linear",
},
xaxis: {
title: xAxis === "number" ? "Trial" : "Time",
type: xAxis === "number" ? "linear" : "date",
},
showlegend: true,
template: mode === "dark" ? plotlyDarkTemplate : {},
}
let filteredTrials = study.trials.filter((t) => filterFunc(t, objectiveId))
if (filterCompleteTrial) {
filteredTrials = filteredTrials.filter((t) => t.state !== "Complete")
}
if (filterPrunedTrial) {
filteredTrials = filteredTrials.filter((t) => t.state !== "Pruned")
}
if (filteredTrials.length === 0) {
plotly.react(plotDomId, [], layout)
return
}
const getAxisX = (trial: Trial): number | Date => {
return xAxis === "number"
? trial.number
: xAxis === "datetime_start"
? trial.datetime_start ?? new Date()
: trial.datetime_complete ?? new Date()
}
const getValue = (trial: Trial, objectiveId: number): number | null => {
if (
objectiveId === null ||
trial.values === undefined ||
trial.values.length <= objectiveId
) {
return null
}
const value = trial.values[objectiveId]
if (value === Infinity || value === -Infinity) {
return null
}
return value
}
const xForLinePlot: (number | Date)[] = []
const yForLinePlot: number[] = []
let currentBest: number | null = null
for (let i = 0; i < filteredTrials.length; i++) {
const t = filteredTrials[i]
const v = getValue(t, objectiveId) as number
if (currentBest === null) {
currentBest = v
xForLinePlot.push(getAxisX(t))
yForLinePlot.push(v)
} else if (
study.directions[objectiveId] === "maximize" &&
v > currentBest
) {
const p = filteredTrials[i - 1]
if (!xForLinePlot.includes(getAxisX(p))) {
xForLinePlot.push(getAxisX(p))
yForLinePlot.push(currentBest)
}
currentBest = v
xForLinePlot.push(getAxisX(t))
yForLinePlot.push(v)
} else if (
study.directions[objectiveId] === "minimize" &&
v < currentBest
) {
const p = filteredTrials[i - 1]
if (!xForLinePlot.includes(getAxisX(p))) {
xForLinePlot.push(getAxisX(p))
yForLinePlot.push(currentBest)
}
currentBest = v
xForLinePlot.push(getAxisX(t))
yForLinePlot.push(v)
}
}
xForLinePlot.push(getAxisX(filteredTrials[filteredTrials.length - 1]))
yForLinePlot.push(yForLinePlot[yForLinePlot.length - 1])
const plotData: Partial<plotly.PlotData>[] = [
{
x: filteredTrials.map(getAxisX),
y: filteredTrials.map(
(t: Trial): number => getValue(t, objectiveId) as number
),
name: "Objective Value",
mode: "markers",
type: "scatter",
},
{
x: xForLinePlot,
y: yForLinePlot,
name: "Best Value",
mode: "lines",
type: "scatter",
},
]
plotly.react(plotDomId, plotData, layout)
}
@@ -15,11 +15,10 @@ import {
import Grid2 from "@mui/material/Unstable_Grid2"
import React, { FC, useContext, useState, useEffect } from "react"
import { Link, useParams } from "react-router-dom"
import { PlotHistory } from "./PlotHistory"
import { PlotImportance } from "./PlotImportance"
import { PlotIntermediateValues } from "./PlotIntermediateValues"
import { StorageContext } from "./StorageProvider"
import { TrialTable } from "./TrialTable"
import { PlotHistory, TrialTable } from "@optuna/storybook"
export const StudyDetail: FC<{
toggleColorMode: () => void
@@ -1,155 +0,0 @@
import React, { FC } from "react"
import { DataGrid } from "@optuna/storybook"
import { DataGridColumn } from "@optuna/storybook/types"
export const TrialTable: FC<{
study: Study
initialRowsPerPage?: number
}> = ({ study, initialRowsPerPage }) => {
const trials: Trial[] = study.trials
const columns: DataGridColumn<Trial>[] = [
{ field: "number", label: "Number", sortable: true, padding: "none" },
{
field: "state",
label: "State",
sortable: true,
filterable: true,
padding: "none",
toCellValue: (i) => trials[i].state.toString(),
},
]
if (study === null || study.directions.length === 1) {
columns.push({
field: "values",
label: "Value",
sortable: true,
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[0]
const secondVal = secondEl.values?.[0]
if (firstVal === secondVal) {
return 0
}
if (firstVal === undefined) {
return ascending ? -1 : 1
}
if (secondVal === undefined) {
return ascending ? 1 : -1
}
return firstVal < secondVal ? 1 : -1
},
toCellValue: (i) => {
if (trials[i].values === undefined) {
return null
}
return trials[i].values?.[0]
},
})
} else {
const objectiveColumns: DataGridColumn<Trial>[] = study.directions.map(
(s, objectiveId) => ({
field: "values",
label: `Objective ${objectiveId}`,
sortable: true,
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[objectiveId]
const secondVal = secondEl.values?.[objectiveId]
if (firstVal === secondVal) {
return 0
}
if (firstVal === undefined) {
return ascending ? -1 : 1
}
if (secondVal === undefined) {
return ascending ? 1 : -1
}
return firstVal < secondVal ? 1 : -1
},
toCellValue: (i) => {
if (trials[i].values === undefined) {
return null
}
return trials[i].values?.[objectiveId]
},
})
)
columns.push(...objectiveColumns)
}
// biome-ignore lint/complexity/noForEach: <explanation>
study.union_search_space.forEach((s) => {
columns.push({
field: "params",
label: `Param ${s.name}`,
toCellValue: (i) =>
trials[i].params.find((p) => p.name === s.name)?.param_external_value ??
null,
sortable: true,
filterable: false,
less: (firstEl, secondEl): number => {
const firstVal = firstEl.params.find(
(p) => p.name === s.name
)?.param_internal_value
const secondVal = secondEl.params.find(
(p) => p.name === s.name
)?.param_internal_value
if (firstVal === secondVal) {
return 0
}
if (firstVal && secondVal) {
return firstVal < secondVal ? 1 : -1
}
if (firstVal) {
return -1
}
return 1
},
})
})
// biome-ignore lint/complexity/noForEach: <explanation>
study.union_user_attrs.forEach((attr_spec) => {
columns.push({
field: "user_attrs",
label: `UserAttribute ${attr_spec.key}`,
toCellValue: (i) =>
trials[i].user_attrs.find((attr) => attr.key === attr_spec.key)
?.value || null,
sortable: attr_spec.sortable,
filterable: false,
less: (firstEl, secondEl): number => {
const firstVal = firstEl.user_attrs.find(
(attr) => attr.key === attr_spec.key
)?.value
const secondVal = secondEl.user_attrs.find(
(attr) => attr.key === attr_spec.key
)?.value
if (firstVal === secondVal) {
return 0
}
if (firstVal && secondVal) {
return firstVal < secondVal ? 1 : -1
}
if (firstVal) {
return -1
}
return 1
},
})
})
return (
<DataGrid<Trial>
columns={columns}
rows={trials}
keyField={"trial_id"}
dense={false}
initialRowsPerPage={initialRowsPerPage}
/>
)
}
+1 -1
View File
@@ -2,7 +2,7 @@ declare const IS_VSCODE: boolean
type TrialState = "Running" | "Complete" | "Pruned" | "Fail" | "Waiting"
type TrialStateFinished = "Complete" | "Fail" | "Pruned"
type StudyDirection = "maximize" | "minimize" | "not_set"
type StudyDirection = "maximize" | "minimize"
type OptunaStorage = {
getStudies: () => Promise<StudySummary[]>
+1 -2
View File
@@ -14,6 +14,7 @@
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@mui/system": "^5.15.9",
"@optuna/storage": "../storage/",
"plotly.js-dist-min": "^2.30.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -22,7 +23,6 @@
},
"devDependencies": {
"@biomejs/biome": "1.5.3",
"@optuna/storage": "../storage/",
"@optuna/types": "../types/",
"@storybook/addon-essentials": "^8.0.4",
"@storybook/addon-interactions": "^8.0.4",
@@ -43,7 +43,6 @@
"../storage": {
"name": "@optuna/storage",
"version": "0.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@sqlite.org/sqlite-wasm": "^3.45.1-build1"
+1 -1
View File
@@ -33,6 +33,7 @@
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@mui/system": "^5.15.9",
"@optuna/storage": "../storage/",
"plotly.js-dist-min": "^2.30.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -41,7 +42,6 @@
},
"devDependencies": {
"@biomejs/biome": "1.5.3",
"@optuna/storage": "../storage/",
"@optuna/types": "../types/",
"@storybook/addon-essentials": "^8.0.4",
"@storybook/addon-interactions": "^8.0.4",