diff --git a/.changeset/drawer-focus-refs-nullable.md b/.changeset/drawer-focus-refs-nullable.md new file mode 100644 index 00000000000..a3239b461d9 --- /dev/null +++ b/.changeset/drawer-focus-refs-nullable.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +`Drawer` accepts `initialFocusRef` and `returnFocusRef` refs that start `null`, as `Popover` does. diff --git a/.changeset/ds-slider-plain-variant.md b/.changeset/ds-slider-plain-variant.md new file mode 100644 index 00000000000..dc47c0c780b --- /dev/null +++ b/.changeset/ds-slider-plain-variant.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +`Slider` gains a `plain` variant: a small round thumb of one fixed size that never swells while dragged, for dense control rows. The rail now spans the slider's root whatever aligns the root's children. diff --git a/.changeset/simulate-drawer-frame.md b/.changeset/simulate-drawer-frame.md new file mode 100644 index 00000000000..7eee8b7253c --- /dev/null +++ b/.changeset/simulate-drawer-frame.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +The Simulate drawers and the full optimization view share one frame: a one-line title with the status, runs, time and compute badge in a header that condenses as the body scrolls, the metric cards beside or above the parameter controls and the surface by the drawer's width, and every card at a fixed height so nothing moves while results stream. The Summary section is gone; its content is the header. diff --git a/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx b/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx index 3ccab0c7867..2fb28d0b135 100644 --- a/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx +++ b/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx @@ -285,8 +285,8 @@ const DrawerRoot = ({ shouldCloseOn?: OverlayShouldCloseOn; loading?: boolean; onClose?: () => void; - initialFocusRef?: React.RefObject; - returnFocusRef?: React.RefObject; + initialFocusRef?: React.RefObject; + returnFocusRef?: React.RefObject; } & React.AriaAttributes) => { const portalContainerRef = usePortalContainerRef(); diff --git a/libs/@hashintel/ds-components/src/components/Slider/slider.tsx b/libs/@hashintel/ds-components/src/components/Slider/slider.tsx index e5d2e1bf2b6..fe6490315ae 100644 --- a/libs/@hashintel/ds-components/src/components/Slider/slider.tsx +++ b/libs/@hashintel/ds-components/src/components/Slider/slider.tsx @@ -6,6 +6,29 @@ const THUMB_WIDTH = 18; const THUMB_HEIGHT = 16; const THUMB_RADIUS = THUMB_HEIGHT / 2; const THUMB_ACTIVE_SCALE = 2.2; +/** The plain variant's round thumb, in pixels; it never scales. */ +const PLAIN_THUMB_SIZE = 12; + +/** + * How the thumb draws. `default` is the pill thumb that swells while dragged; + * `plain` is a small round thumb of one fixed size, for dense control rows + * where a swelling thumb would cover its neighbours. + */ +export type SliderVariant = "default" | "plain"; + +const plainThumbStyles = css({ + outline: "none", + display: "block", + width: `[${PLAIN_THUMB_SIZE}px]`, + height: `[${PLAIN_THUMB_SIZE}px]`, + borderRadius: "full", + border: "[1px solid rgba(255,255,255,0.6)]", + backgroundColor: "blue.s90", + boxShadow: "[0 1px 3px rgba(37,99,235,0.3)]", + "&[data-focus]": { + boxShadow: "[0 0 0 3px rgba(59,130,246,0.3)]", + }, +}); const thumbInnerStyles = css({ display: "block", @@ -28,6 +51,7 @@ export interface SliderProps { defaultValue?: number; label?: string; showValueText?: boolean; + variant?: SliderVariant; /** Shows the value without letting the pointer or keyboard move it. */ disabled?: boolean; onChange?: (value: number) => void; @@ -45,15 +69,24 @@ export const Slider: React.FC = ({ defaultValue, label, showValueText = false, + variant = "default", disabled, onChange, onChangeEnd, }) => { + const plain = variant === "plain"; return ( = ({ position: "relative", display: "flex", alignItems: "center", + // The rail spans the root whatever aligns the root's children; a + // centred root would otherwise shrink the control to the thumb. + width: "full", + "&[data-variant=plain]": { + height: `[${PLAIN_THUMB_SIZE + 4}px]`, + }, })} + data-variant={variant} > = ({ /> - div": { - background: - "[linear-gradient(180deg, rgba(59,130,246,0.95) 0%, rgba(37,99,235,0.98) 100%)]", - transformOrigin: "center", - transition: - "[transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), background 0.2s ease]", - }, - "&[data-dragging] > div": { - transform: `scale(${THUMB_ACTIVE_SCALE})`, - background: "[rgba(255,255,255,0.2)]", - shadow: - "[0 2px 4px rgba(0,0,0,0.1), inset 0 1px 3px rgba(0,0,0,0.1), inset 0 -1px 3px rgba(255,255,255,0.1)]", - }, - })} - > -
- - + {plain ? ( + + + + ) : ( + div": { + background: + "[linear-gradient(180deg, rgba(59,130,246,0.95) 0%, rgba(37,99,235,0.98) 100%)]", + transformOrigin: "center", + transition: + "[transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), background 0.2s ease]", + }, + "&[data-dragging] > div": { + transform: `scale(${THUMB_ACTIVE_SCALE})`, + background: "[rgba(255,255,255,0.2)]", + shadow: + "[0 2px 4px rgba(0,0,0,0.1), inset 0 1px 3px rgba(0,0,0,0.1), inset 0 -1px 3px rgba(255,255,255,0.1)]", + }, + })} + > +
+ + + )} ); diff --git a/libs/@hashintel/ds-components/src/main.ts b/libs/@hashintel/ds-components/src/main.ts index 33e97f34859..16a94401fa2 100644 --- a/libs/@hashintel/ds-components/src/main.ts +++ b/libs/@hashintel/ds-components/src/main.ts @@ -74,7 +74,11 @@ export { Select, type SelectItem, } from "./components/Select/select"; -export { Slider, type SliderProps } from "./components/Slider/slider"; +export { + Slider, + type SliderProps, + type SliderVariant, +} from "./components/Slider/slider"; export { TextArea } from "./components/TextArea/text-area"; export { TextInput } from "./components/TextInput/text-input"; export { TextMark } from "./components/TextMark/text-mark"; diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 5f9e1b793c1..e9d6f5cca4b 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -63,7 +63,7 @@ Parameter sweeps are experimental and off by default. Turn on **Parameter sweeps Flip **Sweep** on any numeric scenario parameter to explore an interval of values instead of one. Set the minimum and the maximum — that is all a sweep declares. Petrinaut quantizes the interval finely (about fifty steps; integer parameters step by whole numbers) so a selection has a stable identity and revisiting one restores its results. With [ad-hoc scenarios](ad-hoc-scenarios.md) enabled, the same toggle sits on every numeric value of the ad-hoc form -- a token count, a cell, a variable, a parameter override -- and each selection sweeps as a generated parameter named after the value, shown in the navigator under the value's path. -A sweep computes **what you have selected**. The results drawer grows a **Parameters** strip — pinned while you scroll — with one slider per swept parameter. Each slider selects a range on its interval, and starts spanning the whole of it: +A sweep computes **what you have selected**. The results drawer grows a **Parameters** band across the top of its body, with one slider per swept parameter; the chevron before its title folds the sliders away and back without losing their positions. Each slider selects a range on its interval, and starts spanning the whole of it: - **Range** (the default): Petrinaut runs **one stochastic simulation over the ranges** — every run draws its own value for each ranged parameter, spread across the selected interval — and the metric charts stream the live distribution **over the region**, sharpening exactly like a plain experiment's. Resize a range from either end to focus; compute restarts on the new selection. Range selections run on the GPU when the net qualifies — each run's parameter draw is uploaded alongside its state — and otherwise on the CPU at full parallelism; an initial state that a scenario derives from a ranged parameter holds at the range's midpoint, while the simulation itself reads each run's own value. - **Point**: switch a parameter's control to Point and its slider collapses to a single value. A point refines in escalating batches (8, 25, 100, … up to your run budget), exactly like a plain experiment at that value — including on the GPU. @@ -74,9 +74,9 @@ Every selection uses the same seed sequence (common random numbers), and a run's #### The surface view -A sweep with two or more swept parameters grows a **Surface** section between the parameter strip and the metric charts: a card holding a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The **X**, **Y** and **Metric** pickers sit in the row under the plot, and the line under the card's title reads the sampling progress (or, mid-drag, the values under the pointer). The plot fills in live and coarse-first — the four corners, then ever finer subdivisions, each level a complete picture (8 runs per point) — and **the surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release both shown parameters collapse to a point there, which then refines with more runs. An orange ring marks where the navigator currently sits. Your selected point always computes first: the metric charts start streaming before surface sampling begins, and after every slider move the surface waits for the new selection's first frames before continuing. Every metric is measured on the same samples, so switching the shown metric repaints instantly from what was already computed; changing the fixed parameters or the axes restarts the fill for the new slice. +A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** band: a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The **X** and **Y** pickers sit in the row under the plot and the **Metric** picker in the row beneath them, and the line under the card's title reads the sampling progress (or, mid-drag, the values under the pointer). The plot fills in live and coarse-first — the four corners, then ever finer subdivisions, each level a complete picture (8 runs per point) — and **the surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release both shown parameters collapse to a point there, which then refines with more runs. An orange ring marks where the navigator currently sits. Your selected point always computes first: the metric charts start streaming before surface sampling begins, and after every slider move the surface waits for the new selection's first frames before continuing. Every metric is measured on the same samples, so switching the shown metric repaints instantly from what was already computed; changing the fixed parameters or the axes restarts the fill for the new slice. -The summary, the parameter strip, and the surface hold still at the top of the drawer; the metric charts scroll on their own below them, so the graphs stay in view while you browse the charts. +The drawer arranges its parts by its width. The **Parameters** band spans the body under the header. Beneath it, at the drawer's full width and in the full-size presentation, the **Surface** sits on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower drawer the metric cards come first, then **Surface**, so the charts you watch are at the top either way. A sweep with one swept parameter has no surface, and its cards take the whole width. Every card keeps a fixed height, and only the body scrolls, under the header. ### Compute backend (experimental) @@ -101,24 +101,28 @@ Run count has no ceiling of its own: runs beyond what your GPU can hold at once Two things to know before comparing results: -- **The same seed gives different numbers on the two backends.** They deliberately use different random number generators, so the trajectories differ while the distributions agree. On the built-in SIR example the two backends' mean token counts agree to within half a percent. The badge in each experiment's summary records which backend ran it, so results stay attributable after the fact. +- **The same seed gives different numbers on the two backends.** They deliberately use different random number generators, so the trajectories differ while the distributions agree. On the built-in SIR example the two backends' mean token counts agree to within half a percent. The badge in each experiment's header records which backend ran it, so results stay attributable after the fact. - Continuous dynamics are integrated with a **more accurate method** (Runge-Kutta 4) than the CPU's, so a model with differential equations may show slightly different — better — values, not just different noise. - The GPU steps every run to the configured max time, while the CPU stops a run as soon as it can no longer fire anything. So a net that finishes early reports a **higher frame count and simulated time** on the GPU for the same results. Nothing is wrong with either; they just stop counting at different points. -### Reading the summary +### Reading the header -Open an experiment's drawer and its **Summary** section reports: +Open an experiment's drawer and its header names the experiment in one line: the name, the scenario (or **Default scenario**), the run count and the time step, for example **SIR transmission sweep · Seasonal Flu · 100 runs · dt 1**. Beneath it, a strip of labelled columns divided by hairlines: -| Field | Meaning | -| ------------ | ----------------------------------------------------------------------------------------------------------------------- | -| **Status** | One of the five statuses above. | -| **Scenario** | The scenario the experiment runs, or `Default`. | -| **Runs** | How many runs are in flight, and how many have finished. | -| **Errors** | How many individual runs errored — shown only when at least one has. An experiment can complete with some runs errored. | -| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. | -| **Elapsed** | Clock time the experiment has been simulating. Once it stops, this becomes **Duration** and holds the total it took. | +| Column | Meaning | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Status** | One of the five statuses above, as a pill with a coloured dot. | +| **Runs** | How many runs are in flight, and how many have finished. | +| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. | +| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. | +| **Elapsed** | Clock time the experiment has been simulating; it stops with the experiment and holds the total it took. | +| **Selection** | For a sweep: the selected combination's runs sampled over the run budget. | +| **Activity** | The **N computing** chip: how many batches run right now, **0 computing** when nothing does. Click it while something runs to list them. | +| **Compute** | Whether the run uses the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, it names the requirement the net did not meet. | -A badge beside the **Summary** heading shows whether the run used the **CPU** or the **GPU**, and stays visible when the section is collapsed. Hover it for detail — on a CPU-backed experiment that asked for the GPU, the badge explains which requirement the net did not meet. +A progress bar runs along the header's bottom edge: the selected combination's runs for a sweep, simulated time otherwise. If the experiment failed, the error reads in the line under the header. + +Once the drawer's body has scrolled, the header condenses to one line, with the columns folded in as compact chips beside the title, the compute badge and the computing chip still among them; move the pointer over it and it grows back. Nothing in the header moves when a status changes, a count goes to zero or a number grows a digit: every column is as wide as its widest value, and every card in the body keeps its height. **Elapsed** and **Duration** measure simulating only. Compiling the net's user code and starting the workers (or acquiring the GPU device and compiling the shader) happens before the clock starts, so the number is comparable between the two backends. An experiment that fails before it starts simulating shows `—` rather than a duration. @@ -153,7 +157,7 @@ A confirmation prompt blocks browser/tab close while any experiment is initializ ### Notifications -The Summary's progress bar tracks the selected combination (runs sampled over the run budget). While anything computes, a **"N computing"** chip appears beside it — a sweep runs several simulations in parallel (the selection's own batches, surface chunks, cell refinements) — and clicking the chip expands a compact list with each batch's kind and progress. Selection batches are the priority work and sort first. +The **N computing** chip in the header's **Activity** column counts the batches running right now — a sweep runs several simulations in parallel (the selection's own batches, surface chunks, cell refinements) — and clicking it opens a compact list with each batch's kind and progress; it reads **0 computing** while nothing runs. Selection batches are the priority work and sort first. A small toast appears when an experiment **completes** or **errors**, even if its drawer isn't open. The top-bar **Active experiments** popover (see below) lets you jump to any in-flight experiment from anywhere in the app. diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index 9cd55345598..ebfd75e5490 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -120,30 +120,46 @@ records can be removed from their result drawer; a paused record's **Remove** sits in the **More actions** menu at the left of its footer. The drawer's footer offers **Open full view**, which gives the whole -Optimizations section to the study: the same summary, controls, charts and -steps, spread over the section's width so the three chart cards sit side by -side. Its top bar holds **Back to list**, which returns to the list, and -**Show in drawer**, which shows the same study in the drawer again. Both +Optimizations section to the study: the same header, controls, charts and +steps, spread over the section's width so the controls and the surface sit on +the left and the chart cards on the right. Its header starts with **Back to +list**, which returns to the list, and its footer holds **Show in drawer**, +which shows the same study in the drawer again. Both presentations are places in the app, so the browser's Back button undoes the switch. Once you are in the full presentation, opening another row from the list opens it full too. -Every study opens with a summary band that holds still while the rest -scrolls beneath it. Its first line says where the study is: **Step 17 of 30 · -best step so far: step 12 (650.5)** while it runs, or **Stopped after 17 of -30 steps** (or **Finished**, **Cancelled**, **Failed**) once it is over. +Every study opens under a header that holds still while the body scrolls +beneath it. Its title is one line: the study's name, the scenario and the +objective, **Maximize profit · Rich stock · Maximize Adjusted profit**. At the +right of the title a line says where the study is: **Step 17 of 30 · best +step so far: step 12 (650.5)** while it runs, or **Stopped after 17 of 30 +steps** (or **Finished**, **Cancelled**, **Failed**) once it is over. While it runs, a chip beside the line says whether the study is still finding better steps: **Still improving** when the best moved within the last few completed steps (a tenth of the requested steps, five at least), **Converging** when that many steps passed without a better one, and **Too early to say** before one such window has completed. -Beneath the line, the band shows the status, the steps finished over the -steps requested (with the runs per step when above one), and **Best step so -far**, the best value seen so far (hover it for the best step's parameters), -with a progress bar for the steps beneath. The value is named for what it -is: the best of the steps tried, not a confirmed result at that -configuration. +Beneath the title, the header's strip of labelled columns shows the status, +the steps finished over the steps requested (with the runs per step when above +one), and **Best step so far**, the best value seen so far (hover it for the +best step's parameters). A progress bar for the steps runs along the header's +bottom edge. The value is named for what it is: the best of the steps tried, +not a confirmed result at that configuration. Every column is as wide as its +widest value, so nothing in the header moves as the numbers change. Once the +body has scrolled the header condenses to one line, the columns folded in as +compact chips; move the pointer over it and it grows back. The line under the +header is reserved for a note (the error when a study failed, the resume note +while it is paused), so nothing moves when one appears. + +The body arranges its parts by its width. The **Parameters** band spans the +body under the header; the chevron before its title folds the controls away +and back without losing their positions. Beneath it, in the drawer at its full +width and in the full view, the **Objective surface** sits on the left and the +other chart cards on the right, two to a row; in a narrower drawer the chart +cards come first, then the surface. The steps table follows at a fixed height +and scrolls on its own. Every card keeps its height whatever it shows. The **Objective by step** card draws every step's objective value as a dot over the step number, with the best so far as a line stepping up (or down, @@ -153,7 +169,7 @@ The line under the title counts the completed steps. The steps table sits at the bottom, newest steps first, each with its parameters, objective value and a state mark (complete, pruned or failed). It scrolls on its own, and a long study shows its newest 200 steps while the -strip keeps the totals and the best. A study with +header keeps the totals and the best. A study with [constraints](#constraints) run in the browser adds a **Runs passed** column (`52 / 60 · 87%`, the constraint with the fewest passing runs when there are several; hover the mark for the rest) and greys the rows of infeasible steps, @@ -166,8 +182,8 @@ in the view claims to still be following a step. ### On the optimization service -A study run on the optimization service adds a **Best parameters** section -between the strip and the steps: the best step's value for every scenario +A study run on the optimization service adds a **Best parameters** band +beside the Objective by step card: the best step's value for every scenario parameter. **Cancel** ends the run on the server, and its status reads **Cancelled**. @@ -180,17 +196,17 @@ kept, and a **Retry** action starts a fresh run with the same settings. A study that runs in the browser (see [Running in the browser](#running-in-the-browser)) shows more, because the machine computing -it is yours, and lays it out so the strip, the controls and the plots stay in -view on a laptop screen while the study streams: - -- The summary band also shows the parallel steps when above one, and, at the - right of its first line, a badge saying where the steps run. It reads - **CPU**, because the GPU backend cannot - compute an expression objective (see step 4 of - [Creating an optimization](#creating-an-optimization)). Under the steps bar, - a thinner bar tracks the runs of the step in flight, and an **N computing** - chip appears while anything computes (the steps in flight and the picked - point's refinement), expanding into one row per batch with its own progress. +it is yours, and lays it out so the header, the controls and the plots stay +in view on a laptop screen while the study streams: + +- The header's strip also shows the parallel steps when above one, an + **Activity** column with the **N computing** chip, and a **Compute** column + saying where the steps run. The badge reads **CPU**, because the GPU backend + cannot compute an expression objective (see step 4 of + [Creating an optimization](#creating-an-optimization)); hover it for the + reason. The chip counts the batches running right now (the steps in flight + and the picked point's refinement), **0 computing** when nothing does, and + opens a compact list with one row per batch and its own progress. - A **Parameters** band with one slider per optimized numeric parameter and a switch per optimized boolean parameter, two to a row when they fit. Its heading carries the state line and the **Follow steps** switch. While the @@ -239,7 +255,7 @@ view on a laptop screen while the study streams: the latest step's verdict (clear, limited or infeasible) with the runs that passed its tightest constraint, and one bar per state constraint shows the share of steps it passed, with a dashed mark at the threshold. The same - headline sits in the summary band as **Steps clear**. Infeasible draws are + headline sits in the header's stats line as **Steps clear**. Infeasible draws are grey dots on the Objective by step chart and hollow grey rings on the surface, and the best step so far is never one of them. - The **Sensitivity analysis** card, last in the row, ranks the optimized diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx index 24cf6a6231f..8caaad36ae6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx @@ -4,8 +4,8 @@ * parameter's quantized interval — the whole interval by default, * collapsible to a single point — and committing a move reports the new * selection so the owner can redirect compute to it. In the experiment - * drawer this strip lives in the section's sticky band and stays visible - * while the charts scroll. + * drawer this strip is the Parameters band across the body, under the + * header. * * Purely presentational: selection and sampling progress come in as props, * and the only output is `onSelectionChange`. Slider moves commit live — @@ -83,16 +83,25 @@ const sliderStyle = css({ flex: "1", }); +// One line whatever the band's width, so a change of wording never moves +// what follows the band. const statusStyle = css({ display: "flex", alignItems: "center", gap: "[6px]", // Aligns under the sliders: the 140px name column plus the row gap. paddingLeft: "[148px]", + minWidth: "[0]", fontSize: "xs", color: "neutral.s80", fontVariantNumeric: "tabular-nums", - minHeight: "[16px]", + whiteSpace: "nowrap", + height: "[16px]", + "& > span:last-child": { + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", + }, }); const spinnerSlotStyle = css({ @@ -148,6 +157,7 @@ const AxisControl = ({ // thumbs trap the drag on the upper one, which cannot move left. = ({ className, @@ -66,6 +64,7 @@ export const RangeSlider: React.FC = ({ step={step} value={[value[0], value[1]]} minStepsBetweenThumbs={0} + thumbSize={{ width: THUMB_SIZE, height: THUMB_SIZE }} disabled={disabled} aria-label={ ariaLabel ? [`${ariaLabel} minimum`, `${ariaLabel} maximum`] : undefined @@ -93,6 +92,7 @@ export const RangeSlider: React.FC = ({ position: "relative", display: "flex", alignItems: "center", + width: "full", height: `[${THUMB_SIZE + 4}px]`, })} > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx index e016d30a3b2..95879c2e0df 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx @@ -25,6 +25,7 @@ import { ContourSurface } from "../../../../../components/contour-surface"; import { formatAxisValue } from "../shared/format-axis-value"; import { describeSurfaceSampling, + SURFACE_FOOTER_TWO_ROW_HEIGHT, SURFACE_PLOT_HEIGHT, SurfaceAxisControls, surfaceCaption, @@ -215,6 +216,8 @@ export const SweepSurface = ({ }), })} bodyHeight={SURFACE_PLOT_HEIGHT} + // The axis selects on one row, the metric select on the next. + footerHeight={SURFACE_FOOTER_TWO_ROW_HEIGHT} footer={ ), }; + +/** The two-axis sweep with four metrics: the common case the drawer must show without scrolling. */ +const fourMetricSweep = (): ExperimentRecord => { + const sweep = makeParameterSweepExperiment(); + const infected = sweep.metricSpecs[0]!; + return { + ...sweep, + metricSpecs: [ + infected, + { ...infected, id: "susceptible", label: "Susceptible" }, + { ...infected, id: "recovered", label: "Recovered" }, + { ...infected, id: "hospitalised", label: "Hospitalised" }, + ], + }; +}; + +export const FourMetrics: Story = { + name: "Sweep, four metrics", + render: () => ( + + + + ), +}; + +/** A plain experiment: no parameters, no surface, the metric cards alone under the header. */ +const plainExperiment = ( + status: ExperimentRecord["status"], +): ExperimentRecord => + makeExperiment(1, { + name: "SIR Monte Carlo", + status, + metricSpecs: makeParameterSweepExperiment().metricSpecs, + metricFrames: makeParameterSweepExperiment().metricFrames, + }); + +export const Running: Story = { + name: "Plain experiment, running", + render: () => ( + + + + ), +}; + +export const Complete: Story = { + name: "Plain experiment, complete", + render: () => ( + + + + ), +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx new file mode 100644 index 00000000000..1746757dc23 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -0,0 +1,239 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, + frameLayoutSignature, +} from "../shared/drawer-frame"; +import { + frameHeader, + scrollFrameBody, +} from "../shared/drawer-frame/frame-test-helpers"; +import { + makeExperiment, + makeParameterSweepExperiment, +} from "./experiments-story-fixtures"; +import { ViewExperimentDrawer } from "./view-experiment-drawer"; + +import type { ExperimentRecord } from "../../../../../../react/experiments/context"; +import type { ReactNode } from "react"; + +vi.mock("@hashintel/ds-components", async (importOriginal) => { + const actual = + await importOriginal(); + const Drawer = Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + Header: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Body: ({ children }: { children: ReactNode }) =>
{children}
, + Footer: ({ actions }: { actions: ReactNode }) => ( +
{actions}
+ ), + }, + ); + const Tooltip = ({ children }: { children: ReactNode }) => <>{children}; + type FlatItem = { + id?: string; + text?: ReactNode; + onClick?: (id: string) => void; + }; + type MenuEntry = FlatItem & { items?: MenuEntry[] }; + const flatten = (entries: MenuEntry[]): FlatItem[] => + entries.flatMap((entry) => (entry.items ? flatten(entry.items) : [entry])); + // The Ark menu positions itself with a ResizeObserver jsdom lacks; this one + // lists the items as buttons. + const Menu = ({ + items, + trigger, + }: { + items: MenuEntry[]; + trigger: ReactNode; + }) => ( + <> + {trigger} +
+ {flatten(items).map((item, index) => ( + + ))} +
+ + ); + return { ...actual, Drawer, Menu, Tooltip }; +}); + +// The contour surface draws on a canvas jsdom cannot host; the card around +// it is real so its box counts. +vi.mock("./sweep-surface", async () => { + const chartCard = await vi.importActual< + typeof import("../shared/chart-card") + >("../shared/chart-card"); + return { + SweepSurface: () => ( + X · Y} + footerHeight={24} + > +
+ + ), + }; +}); + +// uPlot cannot mount in jsdom; the menu and the subtitle are real. +vi.mock("./experiment-metric-timeline", async () => { + const [menu, describeView, viewState] = await Promise.all([ + vi.importActual< + typeof import("./experiment-metric-timeline/metric-view-menu") + >("./experiment-metric-timeline/metric-view-menu"), + vi.importActual< + typeof import("./experiment-metric-timeline/describe-metric-view") + >("./experiment-metric-timeline/describe-metric-view"), + vi.importActual( + "./experiment-metric-timeline/view-state", + ), + ]); + return { + MetricViewMenu: menu.MetricViewMenu, + describeMetricView: describeView.describeMetricView, + DEFAULT_METRIC_VIEW_SETTINGS: viewState.DEFAULT_METRIC_VIEW_SETTINGS, + ExperimentMetricTimeline: ({ plotHeight }: { plotHeight: number }) => ( +
+ ), + }; +}); + +// The navigator's Ark sliders measure themselves with a ResizeObserver jsdom +// lacks; nothing here depends on a measurement. +class ObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = ObserverStub as unknown as typeof ResizeObserver; + +afterEach(cleanup); + +const renderDrawer = (experiment: ExperimentRecord) => + render( + {}} experiment={experiment} />, + ); + +const sweep = makeParameterSweepExperiment(); + +/** The sweep in each state a drawer can show it. */ +const sweepIn = (status: ExperimentRecord["status"]): ExperimentRecord => ({ + ...sweep, + status, + finishedAt: status === "running" ? null : Date.now(), + error: status === "error" ? "worker crashed" : null, + progress: + status === "running" + ? sweep.progress + : { ...sweep.progress!, activeRuns: 0, allFinished: true }, +}); + +describe("ViewExperimentDrawer in the frame", () => { + it("titles the drawer in one line and puts the stats and the badge in the header", () => { + renderDrawer(sweep); + + expect( + screen.getByText( + /^SIR transmission sweep · Seasonal Flu · 100 runs · dt 1$/u, + ), + ).toBeTruthy(); + expect(screen.getByText("Running")).toBeTruthy(); + expect( + screen + .getByText("Runs") + .nextElementSibling?.querySelector("[data-frame-stat-value]") + ?.textContent, + ).toMatch(/^\d+ active, \d+ complete$/u); + expect(screen.getByText("Selection")).toBeTruthy(); + expect(screen.getByText("CPU")).toBeTruthy(); + expect(screen.queryByText("Summary")).toBeNull(); + expect(document.querySelector("[data-frame-progress]")).toBeTruthy(); + expect(screen.getByText("Parameters")).toBeTruthy(); + expect(screen.getByTestId("sweep-surface")).toBeTruthy(); + }); + + it("condenses the header once the body scrolls, with nothing else involved", () => { + renderDrawer(sweep); + + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + expect(frameHeader().dataset.condensed).toBe("true"); + }); + + it("keeps the header, the note row and every card at one height across running, complete, cancelled and error", () => { + const signatures = ( + ["running", "complete", "cancelled", "error"] as const + ).map((status) => { + const view = renderDrawer(sweepIn(status)); + const signature = frameLayoutSignature(view.container); + view.unmount(); + return signature; + }); + + expect(signatures[0]!.header).toBe(`${FRAME_HEADER_HEIGHT}px`); + expect(signatures[0]!.note).toBe("20px"); + expect(signatures[0]!.cards.length).toBeGreaterThan(1); + for (const signature of signatures.slice(1)) { + expect(signature).toEqual(signatures[0]); + } + }); + + it("shows the error in the reserved note row without adding a row", () => { + renderDrawer(sweepIn("error")); + + const note = document.querySelector("[data-frame-note]")!; + expect(note.textContent).toBe("worker crashed"); + expect(note.dataset.tone).toBe("error"); + expect(note.style.height).toBe("20px"); + }); + + it("leaves a metric card's height alone when its aggregation changes", () => { + const view = renderDrawer(sweep); + const before = frameLayoutSignature(view.container); + + fireEvent.click( + screen.getAllByRole("button", { name: "Chart options" })[0]!, + ); + fireEvent.click(screen.getAllByRole("menuitem", { name: "Median" })[0]!); + + expect(screen.getAllByText(/^median over runs/u).length).toBeGreaterThan(0); + expect(frameLayoutSignature(view.container)).toEqual(before); + }); + + it("shows a plain experiment's metric cards alone, with no parameters and no surface", () => { + renderDrawer( + makeExperiment(1, { + metricSpecs: sweep.metricSpecs, + metricFrames: sweep.metricFrames, + }), + ); + + expect(screen.queryByText("Parameters")).toBeNull(); + expect(screen.queryByTestId("sweep-surface")).toBeNull(); + expect(screen.queryByText("Selection")).toBeNull(); + expect(screen.getAllByTestId("metric-timeline").length).toBe( + sweep.metricSpecs.length, + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx index 0f45662e661..60a92601b85 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx @@ -1,39 +1,25 @@ +/** + * One experiment in a drawer over the Experiments list, in the shared frame: + * the one-line title and the stats in the header, the Parameters band across + * the body, then the surface and the metric cards arranged by the drawer's + * width, with the actions in the footer. + */ import { use } from "react"; -import { Button, Drawer, Icon } from "@hashintel/ds-components"; +import { Button, Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { ExperimentsActionsContext, type ExperimentRecord, } from "../../../../../../react/experiments/context"; -import { Section, SectionList } from "../../../../../components/section"; +import { experimentProgressPercent } from "../../../shared/experiment-progress"; import { ComputeBackendBadge } from "../shared/compute-backend-badge"; +import { DrawerFrame, FrameBand, FrameColumns } from "../shared/drawer-frame"; import { SweepNavigator } from "./sweep-navigator"; import { SweepSurface } from "./sweep-surface"; import { ExperimentMetrics } from "./view-experiment-drawer/experiment-metrics"; -import { ExperimentSummary } from "./view-experiment-drawer/experiment-summary"; - -// The drawer body is a column: the summary, the navigator, and the surface -// hold still at the top, and the metric charts alone scroll below them. -const drawerBodyStyle = css({ - paddingTop: "[0]", - display: "flex", - flexDirection: "column", - // The overlay body scrolls by default; here only the metric list may. - overflow: "hidden", -}); - -const fixedSectionStyle = css({ - flexShrink: "0", -}); - -const metricsScrollStyle = css({ - flex: "[1]", - minHeight: "[160px]", - overflowY: "auto", - scrollbarWidth: "[thin]", -}); +import { ExperimentStats } from "./view-experiment-drawer/experiment-stats"; // Keeps its footprint when a run can no longer be cancelled, so Remove and // Close do not slide when a run finishes. @@ -42,6 +28,18 @@ const cancelSlotStyle = css({ "&[data-hidden=true]": { visibility: "hidden" }, }); +const PARAMETERS_HELP = + "Only the selected combination computes. Move a control and compute follows it; results for visited combinations are kept."; + +/** The frame's one-line title: `SIR transmission sweep · Seasonal Flu · 100 runs · dt 1`. */ +export const describeExperiment = ( + experiment: Pick< + ExperimentRecord, + "name" | "scenarioName" | "runCount" | "dt" + >, +): string => + `${experiment.name} · ${experiment.scenarioName ?? "Default scenario"} · ${experiment.runCount.toLocaleString("en-US")} runs · dt ${experiment.dt}`; + export const ViewExperimentDrawer = ({ open, onClose, @@ -61,128 +59,92 @@ export const ViewExperimentDrawer = ({ const canCancel = experiment.status === "initializing" || experiment.status === "running"; + const { sweep } = experiment; return ( - - - - -
( - - )} + } + badge={} + progress={experimentProgressPercent(experiment)} + note={ + experiment.error === null + ? null + : { content: experiment.error, tone: "error" } + } + footer={ + <> + + - -
- {experiment.sweep ? ( -
- experiment.sweep ? ( - - setSweepSelection(experiment.id, selection) - } - /> - ) : null - } - > - {null} -
- ) : null} - {experiment.sweep && experiment.parameterAxes.length >= 2 ? ( -
- {/* Keyed so the axis and metric pickers never carry one - experiment's identifiers into another when the drawer swaps - records in place. */} - -
- ) : null} - {experiment.metricSpecs.length > 0 ? ( -
-
- {/* Keyed so faded previous pictures and size choices never - leak from one experiment into another when the drawer - swaps records in place. */} - -
-
- ) : null} -
-
- - - - - - + + + + } + > + {sweep ? ( + + + setSweepSelection(experiment.id, selection) + } + /> + + ) : null} + = 2 ? ( + // Keyed so the axis and metric pickers never carry one + // experiment's identifiers into another when the drawer swaps + // records in place. + + ) : undefined + } + secondary={ + experiment.metricSpecs.length > 0 ? ( + // Keyed so faded previous pictures and view choices never leak + // from one experiment into another when the drawer swaps records + // in place. + + ) : undefined } /> -
+ ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx new file mode 100644 index 00000000000..14412fef407 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx @@ -0,0 +1,153 @@ +/** + * The experiment frame's stat columns: the status pill, the runs, the errors, + * the simulated time, the wall-clock time, the selection's sampling for a + * sweep, and the chip listing what computes. Every column is as wide as its + * widest value. + */ +import { useEffect, useState } from "react"; + +import { + type ExperimentRecord, + getExperimentElapsedMs, + isExperimentActive, + type SweepBatchStatus, +} from "../../../../../../../react/experiments/context"; +import { + type ComputeBatch, + ComputeBatchesChip, + FrameStat, + FrameStatusPill, + type FrameStatusTone, +} from "../../shared/drawer-frame"; +import { formatFixed } from "../../shared/format-value"; +import { formatDurationMs } from "../format-duration"; + +const STATUS_DISPLAY: Record< + ExperimentRecord["status"], + { label: string; tone: FrameStatusTone } +> = { + initializing: { label: "Initializing", tone: "active" }, + running: { label: "Running", tone: "active" }, + idle: { label: "Idle", tone: "neutral" }, + complete: { label: "Complete", tone: "done" }, + error: { label: "Error", tone: "error" }, + cancelled: { label: "Cancelled", tone: "neutral" }, +}; + +/** The longest status label, so the pill keeps its width as the status changes. */ +const WIDEST_STATUS = Object.values(STATUS_DISPLAY) + .map((entry) => entry.label) + .reduce((widest, label) => (label.length > widest.length ? label : widest)); + +/** The widest wall-clock readout `formatDurationMs` prints. */ +const WIDEST_DURATION = "59m 59s"; + +/** + * A clock that advances while `active`, so an elapsed-time readout keeps + * moving even when a stalled run stops publishing progress. + */ +const useNow = (active: boolean): number => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!active) { + return; + } + const update = () => setNow(Date.now()); + update(); + const intervalId = window.setInterval(update, 250); + return () => window.clearInterval(intervalId); + }, [active]); + + return now; +}; + +/** + * Simulated time to show when no batch is publishing progress: an idle sweep + * or a complete run has taken every run to the end. + */ +const settledTime = (experiment: ExperimentRecord): number => + experiment.status === "idle" || experiment.status === "complete" + ? experiment.maxTime + : 0; + +/** + * "selection" is the navigator's own ladder, the priority work; "surface" + * is a contour chunk; "refine" is a single cell brought up to depth. + */ +const BATCH_KIND_META: Record< + SweepBatchStatus["kind"], + Pick +> = { + selection: { label: "Selection", tone: "priority" }, + surface: { label: "Surface", tone: "background" }, + refine: { label: "Refine", tone: "background" }, +}; + +/** The sweep's batches as the computing list shows them. */ +export const experimentComputeBatches = ( + sweepBatches: readonly SweepBatchStatus[], +): ComputeBatch[] => + sweepBatches.map((batch) => ({ + id: String(batch.id), + ...BATCH_KIND_META[batch.kind], + runCount: batch.runCount, + completedRuns: batch.completedRuns, + })); + +const formatCount = (value: number): string => value.toLocaleString("en-US"); + +export const ExperimentStats = ({ + experiment, +}: { + experiment: ExperimentRecord; +}) => { + const progress = experiment.progress; + const now = useNow(isExperimentActive(experiment)); + const elapsedMs = getExperimentElapsedMs(experiment, now); + const status = STATUS_DISPLAY[experiment.status]; + const runCount = formatCount(experiment.runCount); + const maxTime = formatFixed(experiment.maxTime); + // The widest time readout: a fraction just under the maximum, which prints + // its three decimals, over the maximum. + const widestTime = `${formatFixed(Math.max(0, experiment.maxTime - 0.001))} / ${maxTime}`; + + return ( + <> + + + {status.label} + + + + {progress + ? `${formatCount(progress.activeRuns)} active, ${formatCount(progress.completedRuns)} complete` + : runCount} + + + {formatCount(progress?.erroredRuns ?? 0)} + + + {formatFixed(progress?.time ?? settledTime(experiment))} / {maxTime} + + {/* Wall-clock, as distinct from the simulated time; it stops once the + experiment finishes and is dashed out when stepping never began. */} + + {elapsedMs === null ? "—" : formatDurationMs(elapsedMs)} + + {experiment.sweep ? ( + + {formatCount(experiment.sweep.runsSampled)} / {runCount} runs + + ) : null} + + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-summary.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-summary.tsx deleted file mode 100644 index c69ff9d05ef..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-summary.tsx +++ /dev/null @@ -1,190 +0,0 @@ -/** - * The drawer's summary: a strip of stats with a status dot, the compute - * activity underneath, and the error text when the experiment failed. - */ -import { useEffect, useState } from "react"; - -import { css } from "@hashintel/ds-helpers/css"; - -import { - type ExperimentRecord, - getExperimentElapsedMs, - isExperimentActive, - type SweepBatchStatus, -} from "../../../../../../../react/experiments/context"; -import { experimentProgressPercent } from "../../../../shared/experiment-progress"; -import { - ComputeActivity, - type ComputeActivityBatch, -} from "../../shared/compute-activity"; -import { formatFixed } from "../../shared/format-value"; -import { - SummaryStat, - SummaryStatusDot, - type SummaryStatusTone, - SummaryStrip, -} from "../../shared/summary-strip"; -import { formatDurationMs } from "../format-duration"; - -const summaryStyle = css({ - marginTop: "-1", - marginBottom: "3", -}); - -const activityStyle = css({ - marginTop: "2", -}); - -const errorStyle = css({ - fontSize: "sm", - color: "red.s100", - whiteSpace: "pre-wrap", -}); - -const STATUS_DISPLAY: Record< - ExperimentRecord["status"], - { label: string; tone: SummaryStatusTone } -> = { - initializing: { label: "Initializing", tone: "active" }, - running: { label: "Running", tone: "active" }, - idle: { label: "Idle", tone: "neutral" }, - complete: { label: "Complete", tone: "done" }, - error: { label: "Error", tone: "error" }, - cancelled: { label: "Cancelled", tone: "neutral" }, -}; - -/** - * A clock that advances while `active`, so an elapsed-time readout keeps - * moving even when a stalled run stops publishing progress. - */ -const useNow = (active: boolean): number => { - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - if (!active) { - return; - } - const update = () => setNow(Date.now()); - update(); - const intervalId = window.setInterval(update, 250); - return () => window.clearInterval(intervalId); - }, [active]); - - return now; -}; - -/** Longest status label, so the strip never reflows as the status changes. */ -/** - * Simulated time to show when no batch is publishing progress: an idle sweep - * or a complete run has taken every run to the end. - */ -const settledTime = (experiment: ExperimentRecord): number => - experiment.status === "idle" || experiment.status === "complete" - ? experiment.maxTime - : 0; - -const STATUS_CHARS = - Math.max( - ...Object.values(STATUS_DISPLAY).map((entry) => entry.label.length), - ) + 2; - -/** - * "selection" is the navigator's own ladder — the priority work; "surface" - * is a contour chunk; "refine" is a single cell brought up to depth. - */ -const BATCH_KIND_META: Record< - SweepBatchStatus["kind"], - Pick -> = { - selection: { label: "Selection", tone: "priority" }, - surface: { label: "Surface", tone: "background" }, - refine: { label: "Refine", tone: "background" }, -}; - -/** The sweep's batches as the activity list shows them. */ -const activityBatches = ( - sweepBatches: readonly SweepBatchStatus[], -): ComputeActivityBatch[] => - sweepBatches.map((batch) => ({ - id: String(batch.id), - ...BATCH_KIND_META[batch.kind], - runCount: batch.runCount, - completedRuns: batch.completedRuns, - })); - -/** The bar under the stats: the selection's runs for a sweep, simulated time otherwise. */ -const activityBar = (experiment: ExperimentRecord) => ({ - percent: experimentProgressPercent(experiment), - label: experiment.sweep - ? `Selection · ${experiment.sweep.runsSampled.toLocaleString("en-US")} / ${experiment.runCount.toLocaleString("en-US")} runs` - : `Time · ${(experiment.progress?.time ?? 0).toLocaleString("en-US")} / ${experiment.maxTime.toLocaleString("en-US")}`, -}); - -export const ExperimentSummary = ({ - experiment, -}: { - experiment: ExperimentRecord; -}) => { - const progress = experiment.progress; - const now = useNow(isExperimentActive(experiment)); - const elapsedMs = getExperimentElapsedMs(experiment, now); - const status = STATUS_DISPLAY[experiment.status]; - - return ( -
- - - - {status.label} - - - {experiment.scenarioName ?? "Default"} - - - {progress - ? `${progress.activeRuns} active, ${progress.completedRuns} complete` - : experiment.runCount} - - - {progress?.erroredRuns ?? 0} - - - {formatFixed(progress?.time ?? settledTime(experiment))} /{" "} - {formatFixed(experiment.maxTime)} - - {/* Wall-clock, as distinct from the simulated time; dashed out - when stepping never began. */} - - {elapsedMs === null ? "—" : formatDurationMs(elapsedMs)} - - -
- -
- {experiment.error ? ( - {experiment.error} - ) : null} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx index 523bf8dc080..76d83a6ffb4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx @@ -7,6 +7,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { OptimizationsContext } from "../../../../../../react/optimizations/context"; import { EditorContext } from "../../../../../../react/state/editor-context"; +import { FRAME_HEADER_CONDENSED_HEIGHT } from "../shared/drawer-frame"; +import { + frameHeader, + scrollFrameBody, +} from "../shared/drawer-frame/frame-test-helpers"; import { OptimizationFullView } from "./optimization-full-view"; import { makeConnectedStudyState, @@ -148,7 +153,7 @@ describe("OptimizationFullView", () => { setSimulatePresentation, }); - expect(screen.getByText(input.name)).toBeTruthy(); + expect(screen.getByText(new RegExp(`^${input.name} · `, "u"))).toBeTruthy(); expect(screen.getByText(/best step so far/u)).toBeTruthy(); expect(screen.getByText("Objective by step")).toBeTruthy(); expect(screen.getByText("Objective at the step in flight")).toBeTruthy(); @@ -164,6 +169,16 @@ describe("OptimizationFullView", () => { expect(setSimulatePresentation).toHaveBeenCalledWith("drawer"); }); + it("condenses the header once the body scrolls", () => { + renderFullView(running); + + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + expect(frameHeader().dataset.condensed).toBe("true"); + }); + it("shows the results once the study settled: no verdict, no live titles", () => { const setSelectedOptimizationId = vi.fn(); const removeOptimization = vi.fn(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx index f7ab4660b40..a26c8951f3e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx @@ -1,7 +1,7 @@ /** - * The Optimizations section given over to one study: a top bar with Back to - * list, the study's name and its actions, then the study body spread over - * the section's width. The drawer shows the same body stacked. + * The Optimizations section given over to one study: the study frame filling + * the section, with Back to list before the title and the actions in the + * footer. The drawer shows the same frame over the list. */ import { use } from "react"; @@ -9,76 +9,15 @@ import { Button, Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { OptimizationsContext } from "../../../../../../react/optimizations/context"; -import { EditorContext } from "../../../../../../react/state/editor-context"; -import { describeStudy, StudyActions, StudyBody } from "./study-view"; +import { StudyFrame } from "./study-view"; import type { OptimizationRecord } from "../../../../../../react/optimizations/context"; -const frameStyle = css({ +const sectionStyle = css({ display: "flex", - flexDirection: "column", flex: "1", minWidth: "[0]", height: "full", - backgroundColor: "neutral.s00", -}); - -// The same bar the section list has, so the switch between the two reads as -// one place changing what it shows. -const topBarStyle = css({ - display: "flex", - alignItems: "center", - gap: "3", - minHeight: "[52px]", - paddingLeft: "[12px]", - paddingRight: "[20px]", - paddingY: "[8px]", - borderBottomWidth: "[1px]", - borderBottomStyle: "solid", - borderBottomColor: "neutral.s40", - flexShrink: 0, -}); - -const titleBlockStyle = css({ - display: "flex", - flexDirection: "column", - flex: "1", - minWidth: "[0]", -}); - -const titleStyle = css({ - fontSize: "sm", - fontWeight: "semibold", - color: "neutral.s120", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const descriptionStyle = css({ - fontSize: "xs", - color: "neutral.s80", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const actionsStyle = css({ - display: "flex", - alignItems: "center", - gap: "2", - flexShrink: 0, -}); - -// Never scrolls itself: the study body keeps its summary band still and -// scrolls the region beneath it. -const bodyStyle = css({ - flex: "1", - display: "flex", - flexDirection: "column", - minHeight: "[0]", - overflow: "hidden", - paddingX: "5", }); export const OptimizationFullView = ({ @@ -86,39 +25,27 @@ export const OptimizationFullView = ({ }: { optimization: OptimizationRecord; }) => { - const { setSimulatePresentation } = use(EditorContext); const { setSelectedOptimizationId } = use(OptimizationsContext); + const backToList = () => setSelectedOptimizationId(null); return ( -
-
- -
- {optimization.input.name} - - {describeStudy(optimization)} - -
-
- setSelectedOptimizationId(null)} - /> -
-
-
- -
+
+ } + onClick={backToList} + > + Back to list + + } + onClose={backToList} + />
); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx index 592f0224361..4f9e78c2f05 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx @@ -1,14 +1,15 @@ /** - * The body of a study, shared by the drawer and the full view: a fixed - * summary band (the header line, the strip, the progress bar) above one - * scrolling region holding the navigator band, the surface, the objective at - * the selected point, the objective by step and the steps table. The two - * surfaces arrange the same pieces; `layout` says how. + * One study in the shared frame, for the drawer and the full view alike: the + * one-line title with the progress line beside it, the stats and the compute + * badge, the steps bar; the Parameters band across the body; then, arranged + * by the frame's width, the surface, the chart cards (the objective at the + * point, the objective by step, Constraints when the study declares any, + * Sensitivity analysis) and the steps table; and the actions in the footer. */ -import { use } from "react"; +import { use, type ReactNode } from "react"; import { Button, HelpTooltip, Icon } from "@hashintel/ds-components"; -import { css, cx } from "@hashintel/ds-helpers/css"; +import { css } from "@hashintel/ds-helpers/css"; import { type ConnectedStudyState, @@ -18,9 +19,22 @@ import { type OptimizationRecord, OptimizationsContext, } from "../../../../../../react/optimizations/context"; +import { EditorContext } from "../../../../../../react/state/editor-context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; -import { Section, SectionList } from "../../../../../components/section"; -import { ChartCardMenu, type ChartCardTone } from "../shared/chart-card"; +import { + CHART_CARD_MIN_WIDTH, + ChartCardGrid, + chartCardHeight, + ChartCardMenu, + type ChartCardTone, +} from "../shared/chart-card"; +import { ComputeBackendBadge } from "../shared/compute-backend-badge"; +import { + DrawerFrame, + FrameBand, + FrameColumns, + type FrameNote, +} from "../shared/drawer-frame"; import { formatScalar } from "../shared/format-value"; import { NavigatedOptimizationSurface, @@ -38,43 +52,31 @@ import { OptimizationMetrics, } from "./study-view/optimization-metrics"; import { ParameterImportancePanel } from "./study-view/parameter-importance-panel"; +import { stepsProgressPercent } from "./study-view/shared/study-progress"; import { StepsTable } from "./study-view/steps-table"; +import { StudyHeader } from "./study-view/study-header"; import { type StudyPhase, studyPhase } from "./study-view/study-phase"; -import { StudySummaryBand } from "./study-view/study-summary-strip"; +import { StudyStats } from "./study-view/study-stats"; import type { PetrinautSimulatePresentation } from "../../../../../../react/state/editor-context"; export { studyPhase, type StudyPhase } from "./study-view/study-phase"; export { describeStudyProgress } from "./study-view/study-header"; -/** How a study body is arranged: stacked for the drawer, or spread over the section's width. */ -export type StudyLayout = "drawer" | "full"; - -// The band holds still; only the region beneath it scrolls, so condensing -// or growing the band never moves the body's scroll offset. -const bodyStyle = css({ - display: "flex", - flexDirection: "column", - flex: "[1]", - minHeight: "[0]", - overflow: "hidden", +/** Every chart card of a study is this tall; the surface card, with its footer, comes to the same. */ +const STUDY_CARD_HEIGHT = chartCardHeight({ + bodyHeight: OBJECTIVE_PLOT_HEIGHT, }); +/** The steps table's fixed height in pixels; the steps scroll inside it. */ +const STEPS_TABLE_HEIGHT = 320; -const scrollRegionStyle = css({ +const stepsStyle = css({ display: "flex", flexDirection: "column", - flex: "[1]", - minHeight: "[0]", - overflowY: "auto", - scrollbarWidth: "[thin]", -}); - -const fixedSectionStyle = css({ - flexShrink: "0", + gap: "1", }); const stepsScrollStyle = css({ - flex: "[1]", overflowY: "auto", scrollbarWidth: "[thin]", borderWidth: "[1px]", @@ -91,54 +93,6 @@ const stepsScrollStyle = css({ }, }); -const remoteStepsHeightStyle = css({ - minHeight: "[160px]", -}); - -// A connected study's steps get whatever height the panes above leave. The -// table's own header names the columns, so no section title precedes it. -const connectedStepsStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - flex: "[1]", - minHeight: "[0]", - paddingTop: "3", - paddingBottom: "3", -}); - -const connectedStepsHeightStyle = css({ - minHeight: "[160px]", -}); - -const fullStepsHeightStyle = css({ - minHeight: "[240px]", -}); - -// The chart cards side by side, all the same height; a lone card on the -// last row of the drawer takes the whole row rather than half of it. -const panesStyle = css({ - display: "grid", - alignItems: "stretch", - gap: "5", - paddingTop: "2.5", - paddingBottom: "2", -}); - -const drawerPanesStyle = css({ - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 400px), 1fr))", - "& > :last-child:nth-child(odd)": { gridColumn: "[1 / -1]" }, -}); - -const fullPanesStyle = css({ - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 380px), 1fr))", -}); - -const historyBlockStyle = css({ - paddingTop: "2.5", - paddingBottom: "3", -}); - const bestParametersStyle = css({ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(14rem, 1fr))", @@ -182,7 +136,10 @@ const bestParameterValueStyle = css({ const SURFACE_HELP = "The objective over two optimized parameters, drawn from the study's own steps: each step is a dot, the best emphasized, pruned steps hollow, and the field is interpolated between them. The ringed dot is the step being evaluated, filling in as it runs; once the study is over, or Follow steps is off, click or drag the plot to refine a point."; -/** The header's second line: the scenario and the objective. */ +const REMOTE_SURFACE_HELP = + "The objective over two optimized parameters, computed locally on this machine; the study's own trials appear as rings. Move the sliders or click the plot to recompute elsewhere."; + +/** The scenario and the objective, the title's second and third parts. */ export const describeStudy = (optimization: OptimizationRecord): string => { const { input } = optimization; const scenario = input.model.definition.scenarios?.find( @@ -196,6 +153,10 @@ export const describeStudy = (optimization: OptimizationRecord): string => { return `${scenario?.name ?? input.scenario.id} · ${direction} ${metric?.name ?? input.objective.metricId}`; }; +/** The frame's one-line title: `Supply chain · Base scenario · Maximize Profit`. */ +export const studyTitle = (optimization: OptimizationRecord): string => + `${optimization.input.name} · ${describeStudy(optimization)}`; + /** * What the objective's timeline describes: the step in flight while a live * study is followed, otherwise the point the navigation holds. A settled @@ -211,18 +172,34 @@ export const objectiveAtPointTitle = ( ? "Objective at the step in flight" : "Objective at the selected point"; -const BestParametersSection = ({ +/** The frame's note row: the error when the study failed, else the resume note while paused. */ +export const studyNote = ( + optimization: OptimizationRecord, +): FrameNote | null => { + if (optimization.error) { + return { content: optimization.error, tone: "error" }; + } + if (optimization.status === "paused" && optimization.connected) { + return { + content: ( + + Resuming continues the study's history; it does not reproduce the + draws an uninterrupted run would have made. + + ), + tone: "muted", + }; + } + return null; +}; + +const BestParametersBand = ({ optimization, }: { optimization: OptimizationRecord; }) => optimization.best ? ( -
+
{Object.entries(optimization.best.parameters).map( ([identifier, value]) => ( @@ -235,78 +212,83 @@ const BestParametersSection = ({ ), )}
-
+ ) : null; -/** A study run elsewhere: the summary, the results, the objective by step and the experimental surface. */ +const StudySteps = ({ + optimization, + bestTrial, +}: { + optimization: OptimizationRecord; + bestTrial: number | null; +}) => ( +
+ +
+); + +/** A study run elsewhere: the best parameters, the objective by step, the experimental surface and the steps. */ const RemoteStudyBody = ({ optimization, - layout, }: { optimization: OptimizationRecord; - layout: StudyLayout; }) => { const { enableOptimizationSurface } = use(UserSettingsContext); const surfaceEligible = enableOptimizationSurface && optimization.axes.length >= 2; return ( -
- - -
- -
- -
- {surfaceEligible ? ( -
+ <> + + -
- ) : null} - {optimization.trials.length > 0 ? ( -
- -
- ) : null} -
-
-
+ + ) : undefined + } + secondary={ + + + + } + after={ + optimization.trials.length > 0 ? ( + + ) : undefined + } + /> + ); }; /** - * A study evaluated in this browser: the header and the summary, the - * parameter controls with their state line, the chart cards (the surface, the - * objective at the point, the objective by step, Constraints when the study - * declares any, and Sensitivity analysis), and the steps filling what is - * left. The navigation drives the surface and the objective's timeline, - * following each step while the study runs. + * A study evaluated in this browser: the parameter controls with their state + * line across the body, then the surface on one side, the chart cards on the + * other, the steps beneath. The navigation drives the surface and the + * objective's timeline, following each step while the study runs. */ const ConnectedStudyBody = ({ optimization, connected, - layout, }: { optimization: OptimizationRecord; connected: ConnectedStudyState; - layout: StudyLayout; }) => { const { setOptimizationNavigation } = use(OptimizationsContext); const onNavigationChange = (patch: Partial) => @@ -317,22 +299,16 @@ const ConnectedStudyBody = ({ optimization.status === "paused" ? "paused" : "default"; return ( -
- -
- -
- {optimization.axes.length >= 2 ? ( + <> + + = 2 ? ( } tone={tone} /> - ) : null} - {/* Keyed so faded previous pictures never leak from one study into - another when the surface swaps records. */} - - - {(optimization.input.constraints ?? []).length > 0 ? ( - + {/* Keyed so faded previous pictures never leak from one study into + another when the surface swaps records. */} + + - ) : null} - {/* Only a study evaluated here receives importances; a remote study - has no panel rather than an empty one. */} - -
-
- 0 ? ( + + ) : null} + {/* Only a study evaluated here receives importances; a remote study + has no panel rather than an empty one. */} + + + } + after={ + -
-
-
+ } + /> + ); }; -/** - * The whole body of a study in either surface: the fixed summary band, then - * the scrolling region. The surface around it must not scroll on its own. - */ +/** The body of a study in either surface, arranged by the frame's width. */ export const StudyBody = ({ optimization, - layout, }: { optimization: OptimizationRecord; - /** `drawer` stacks for the overlay; `full` spreads the chart cards over the section's width. */ - layout: StudyLayout; }) => optimization.connected ? ( ) : ( - + ); /** The button that moves the navigation to the best step and computes there. */ @@ -611,3 +581,59 @@ export const StudyActions = ({ ); }; + +/** + * The whole study in the shared frame. `drawer` puts it in a ds Drawer over + * the list; without it the frame fills the section, with `leading` (Back to + * list) before the title. `onClose` leaves the record: after Remove, and + * from the drawer's Close button. + */ +export const StudyFrame = ({ + optimization, + presentation, + drawer, + leading, + onClose, +}: { + optimization: OptimizationRecord; + presentation: PetrinautSimulatePresentation; + drawer?: { onClose: () => void; swapKey: string }; + leading?: ReactNode; + onClose: () => void; +}) => { + const { setSimulatePresentation } = use(EditorContext); + const { connected } = optimization; + + return ( + } + stats={} + badge={ + connected ? ( + + ) : undefined + } + progress={stepsProgressPercent(optimization)} + note={studyNote(optimization)} + footer={ + + } + > + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx index 6637e1bee6d..eb2c108e783 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx @@ -1,13 +1,10 @@ /** - * The Parameters band of a connected study's drawer: a title row with the - * help tooltip and the navigator's status line, then the parameter controls. - * It holds still above the plots; the drawer keeps it out of the scrolling - * region, so there is no section body beneath it. + * The Parameters band of a connected study: the collapsible frame band with + * the help tooltip and the navigator's status line in its title row, then the + * parameter controls across the body. */ -import { HelpTooltip } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - import { optimizationBooleanIdentifiers } from "../../../../../../../react/optimizations/surface-grid"; +import { FrameBand } from "../../shared/drawer-frame"; import { OptimizationNavigator, OptimizationNavigatorStatus, @@ -19,35 +16,6 @@ import type { OptimizationRecord, } from "../../../../../../../react/optimizations/context"; -const bandStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - paddingTop: "3", - paddingBottom: "2", - flexShrink: "0", -}); - -const headerRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "2", -}); - -const headerLeftStyle = css({ - display: "flex", - alignItems: "center", - gap: "1", -}); - -const titleStyle = css({ - fontWeight: "semibold", - fontSize: "sm", - lineHeight: "[14px]", - color: "neutral.fg.body", -}); - const PARAMETERS_HELP = "The chart beside the surface shows the objective at this point. While the study runs and Follow steps is on, the point follows each step as it is evaluated and the controls only show it; turn Follow steps off, or wait for the study to finish, to move them and look elsewhere."; @@ -63,19 +31,19 @@ export const NavigatorBand = ({ running: boolean; onNavigationChange: (patch: Partial) => void; }) => ( -
-
-
- Parameters - -
+ -
+ } + > -
+ ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx index d0e8c9daa50..3c4ff71505d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx @@ -64,10 +64,11 @@ const axisStepAt = (axis: OptimizationSurfaceAxis, position: number): number => ) / 2; // Two columns of controls when the band is wide enough for two readable -// sliders, so several parameters cost one row per pair. +// sliders, so several parameters cost one row per pair; one column, never +// wider than the band, otherwise. const navigatorStyle = css({ display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(400px, 1fr))", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 400px), 1fr))", columnGap: "8", rowGap: "[6px]", }); @@ -89,7 +90,15 @@ const nameStyle = css({ whiteSpace: "nowrap", }); +// The slider's root: it stretches across the row so the rail is drawn at +// full width; centring its children would shrink the rail to the thumb. const controlStyle = css({ + flex: "1", + minWidth: "[0]", + justifyContent: "center", +}); + +const toggleSlotStyle = css({ flex: "1", display: "flex", alignItems: "center", @@ -104,14 +113,18 @@ const readoutStyle = css({ textAlign: "right", }); +// One line whatever the band's width: the text clips with an ellipsis rather +// than wrapping, so a status change never changes the row's height. const statusStyle = css({ display: "flex", alignItems: "center", gap: "2", + minWidth: "[0]", fontSize: "xs", color: "neutral.s80", fontVariantNumeric: "tabular-nums", - minHeight: "[24px]", + whiteSpace: "nowrap", + height: "[24px]", }); const spinnerSlotStyle = css({ @@ -120,14 +133,17 @@ const spinnerSlotStyle = css({ }); const statusTextStyle = css({ + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", "&[data-tone=error]": { color: "red.s100", - whiteSpace: "pre-wrap", }, }); const followStyle = css({ marginLeft: "2", + flexShrink: "0", fontSize: "xs", color: "neutral.s100", }); @@ -168,6 +184,7 @@ export const OptimizationNavigator = ({ {identifier} - + {describeSelection(selection, running)} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts index b02bc655973..689894cfd7b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts @@ -1,20 +1,16 @@ /** - * A study's progress as the summary's bars and activity list show it: steps - * finished over steps requested, the followed step's runs over the runs each - * step gets, and the batches computing right now, each named. + * A study's progress as the frame's header shows it: steps finished over + * steps requested for the bar, and the batches computing right now, each + * named, for the computing chip. */ import { type ConnectedStudyState, - followedTrial, type OptimizationBatchStatus, type OptimizationRecord, } from "../../../../../../../../react/optimizations/context"; import { formatParameters } from "../../../shared/format-value"; -import type { - ComputeActivityBar, - ComputeActivityBatch, -} from "../../../shared/compute-activity"; +import type { ComputeBatch } from "../../../shared/drawer-frame"; export const finishedStepCount = ( optimization: Pick< @@ -26,56 +22,30 @@ export const finishedStepCount = ( optimization.prunedTrials + optimization.failedTrials; -/** The main bar: steps finished over steps requested. */ -export const stepsBar = ( +/** The header bar: steps finished over steps requested, 0 to 100. */ +export const stepsProgressPercent = ( optimization: Pick< OptimizationRecord, "completedTrials" | "prunedTrials" | "failedTrials" | "requestedTrials" >, - label?: string, -): ComputeActivityBar => { - const finished = finishedStepCount(optimization); - return { - percent: - optimization.requestedTrials > 0 - ? Math.min(100, (finished / optimization.requestedTrials) * 100) - : 0, - label, - }; -}; - -/** - * The thinner bar beneath: the followed step's runs over the runs each step - * gets, while a step is being followed. - */ -export const followedStepBar = ( - optimization: Pick, -): ComputeActivityBar | null => { - const selection = optimization.connected?.selection ?? null; - if (selection === null || !selection.computing) { - return null; - } - const trial = followedTrial(selection.key); - if (trial === null) { - return null; - } - const runsPerStep = optimization.input.execution.seedsPerTrial ?? 1; - return { - percent: Math.min(100, (selection.runsCompleted / runsPerStep) * 100), - label: `Step ${trial + 1} · ${selection.runsCompleted} / ${runsPerStep} runs`, - }; -}; +): number => + optimization.requestedTrials > 0 + ? Math.min( + 100, + (finishedStepCount(optimization) / optimization.requestedTrials) * 100, + ) + : 0; -/** A batch as the activity list names it: "Step 4", or "Refining population=1850, infected_ratio=0.36". */ +/** A batch as the computing list names it: "Step 4", or "Refining population=1850, infected_ratio=0.36". */ export const describeBatch = (batch: OptimizationBatchStatus): string => batch.kind === "trial" ? `Step ${batch.trial + 1}` : `Refining ${formatParameters(batch.values)}`; -/** The study's batches as the activity list shows them; steps are the priority work. */ +/** The study's batches as the computing list shows them; steps are the priority work. */ export const activityBatches = ( connected: ConnectedStudyState | null, -): ComputeActivityBatch[] => +): ComputeBatch[] => (connected?.activity ?? []).map((batch) => ({ id: String(batch.id), label: describeBatch(batch), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx index c664eab5b73..5a562735696 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx @@ -26,7 +26,10 @@ type StepState = Step["state"]; const INFEASIBLE_MARK_COLOR = "#cccccc"; const stepHintStyle = css({ + display: "block", + height: "[16px]", fontSize: "xs", + lineHeight: "[16px]", color: "neutral.s80", }); @@ -200,23 +203,26 @@ export const StepsTable = ({ optimization, bestTrial, className, + height, }: { optimization: OptimizationRecord; /** The step to star; null marks none. */ bestTrial: number | null; className: string; + /** The table box's fixed height in pixels; the steps scroll inside it. */ + height: number; }) => { const displayedSteps = optimization.trials.slice(-DISPLAYED_STEPS).reverse(); return ( <> - {optimization.trials.length > DISPLAYED_STEPS ? ( - - Showing the latest {DISPLAYED_STEPS} of {optimization.trials.length}{" "} - received steps. - - ) : null} -
+ {/* The hint's row is reserved, so the 201st step moves nothing. */} + + {optimization.trials.length > DISPLAYED_STEPS + ? `Showing the latest ${DISPLAYED_STEPS} of ${optimization.trials.length} received steps.` + : ""} + +
= { + initializing: "active", + running: "active", + paused: "neutral", + complete: "done", + error: "error", + cancelled: "neutral", +}; + +/** The longest status word, so the pill never reflows as it changes. */ +const WIDEST_STATUS = "Reconnecting"; + +/** The widest objective `formatNumber` prints: a sign, six significant digits and an exponent. */ +const WIDEST_OBJECTIVE = "-0.00000e+00"; + +/** "4 / 30 · 3 runs each · 2 at once", with the parts that are 1 left out. */ +export const describeStepProgress = ( + optimization: Pick< + OptimizationRecord, + | "completedTrials" + | "prunedTrials" + | "failedTrials" + | "requestedTrials" + | "connected" + | "input" + >, +): string => { + const runsPerStep = optimization.input.execution.seedsPerTrial ?? 1; + const parallelism = optimization.connected?.parallelism ?? 1; + return [ + `${finishedStepCount(optimization)} / ${optimization.requestedTrials}`, + ...(runsPerStep > 1 ? [`${runsPerStep} runs each`] : []), + ...(parallelism > 1 ? [`${parallelism} at once`] : []), + ].join(" · "); +}; + +/** The status word; a run whose event stream is being re-established says so instead. */ +export const describeStudyStatus = ( + optimization: Pick< + OptimizationRecord, + "status" | "connected" | "connectionState" + >, +): string => + optimization.connectionState === "reconnecting" + ? "Reconnecting" + : describeOptimizationStatus(optimization); + +export const StudyStats = ({ + optimization, +}: { + optimization: OptimizationRecord; +}) => { + const { connected } = optimization; + const constrained = (optimization.input.constraints ?? []).length > 0; + const rates = constrained + ? studyConstraintRates( + optimization.trials, + constraintAlpha(optimization.input), + ) + : null; + + return ( + <> + + + {describeStudyStatus(optimization)} + + + + {describeStepProgress(optimization)} + + {rates === null ? null : ( + + {formatRate(rates.stepsClear, rates.stepsSimulated)} + + )} + + {optimization.best ? ( + + {formatNumber(optimization.best.objective)} + + ) : ( + "—" + )} + + {connected ? ( + + + + ) : null} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-summary-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-summary-strip.tsx deleted file mode 100644 index f291d82cf99..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-summary-strip.tsx +++ /dev/null @@ -1,199 +0,0 @@ -/** - * A study's summary as one fixed band at the top of its surface, outside the - * scrolling body: the header line (where the study is, the best step so far, - * the verdict chip) with the backend badge at its right for a connected - * study, then status, steps finished over requested and the best value so - * far in a strip, with the steps bar beneath and the error when there is - * one. A connected study adds the followed step's runs under the steps bar, - * the "N computing" chip and the fallback note. A study with constraints - * adds the steps clear across the study to the strip. The band has no title - * of its own: it is the drawer's header continued, not a section. - */ -import { Tooltip } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - -import { - constraintAlpha, - formatRate, - studyConstraintRates, -} from "../../../../../../../react/optimizations/constraint-rates"; -import { ComputeActivity } from "../../shared/compute-activity"; -import { ComputeBackendBadge } from "../../shared/compute-backend-badge"; -import { formatNumber, formatParameters } from "../../shared/format-value"; -import { - SummaryStat, - SummaryStatusDot, - type SummaryStatusTone, - SummaryStrip, -} from "../../shared/summary-strip"; -import { describeOptimizationStatus } from "../optimization-status"; -import { - activityBatches, - finishedStepCount, - followedStepBar, - stepsBar, -} from "./shared/study-progress"; -import { StudyHeader } from "./study-header"; - -import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; - -const bandStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - flexShrink: "0", - paddingTop: "2", - paddingBottom: "3", - borderBottomWidth: "[1px]", - borderBottomStyle: "solid", - borderBottomColor: "neutral.bd.subtle", -}); - -const headerRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "3", -}); - -const noteStyle = css({ - fontSize: "xs", - color: "neutral.s80", -}); - -const errorStyle = css({ - fontSize: "sm", - color: "red.s100", - whiteSpace: "pre-wrap", -}); - -const STATUS_TONE: Record = { - initializing: "active", - running: "active", - paused: "neutral", - complete: "done", - error: "error", - cancelled: "neutral", -}; - -/** Longest status label plus the dot, so the strip never reflows as it changes. */ -const STATUS_CHARS = "Initializing (reconnecting…)".length; - -/** "4 / 30 · 3 runs each · 2 at once", with the parts that are 1 left out. */ -export const describeStepProgress = ( - optimization: Pick< - OptimizationRecord, - | "completedTrials" - | "prunedTrials" - | "failedTrials" - | "requestedTrials" - | "connected" - | "input" - >, -): string => { - const runsPerStep = optimization.input.execution.seedsPerTrial ?? 1; - const parallelism = optimization.connected?.parallelism ?? 1; - return [ - `${finishedStepCount(optimization)} / ${optimization.requestedTrials}`, - ...(runsPerStep > 1 ? [`${runsPerStep} runs each`] : []), - ...(parallelism > 1 ? [`${parallelism} at once`] : []), - ].join(" · "); -}; - -export const StudySummaryBand = ({ - optimization, -}: { - optimization: OptimizationRecord; -}) => { - const { connected } = optimization; - const status = describeOptimizationStatus(optimization); - const fallbackReason = connected?.computeBackendFallbackReason ?? null; - const constrained = (optimization.input.constraints ?? []).length > 0; - const rates = constrained - ? studyConstraintRates( - optimization.trials, - constraintAlpha(optimization.input), - ) - : null; - - return ( -
-
- - {connected ? ( - - ) : null} -
- - - - {status} - {optimization.connectionState === "reconnecting" - ? " (reconnecting…)" - : ""} - - - {describeStepProgress(optimization)} - - {rates === null ? null : ( - - {formatRate(rates.stepsClear, rates.stepsSimulated)} - - )} - - {optimization.best ? ( - - {formatNumber(optimization.best.objective)} - - ) : ( - "—" - )} - - - - {fallbackReason === null ? null : ( - Ran on the CPU: {fallbackReason} - )} - {optimization.status === "paused" && connected ? ( - - Resuming continues the study's history; it does not reproduce the - draws an uninterrupted run would have made. - - ) : null} - {optimization.error ? ( - {optimization.error} - ) : null} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx index c141e14ad23..74aa7855bae 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx @@ -1,7 +1,13 @@ /** * @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { cloneElement, use, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -12,6 +18,15 @@ import { type OptimizationsContextValue, } from "../../../../../../react/optimizations/context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; +import { + FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, + frameLayoutSignature, +} from "../shared/drawer-frame"; +import { + frameHeader, + scrollFrameBody, +} from "../shared/drawer-frame/frame-test-helpers"; import { fakeConstrainedStudyInput, fakeConstrainedStudyTrials, @@ -37,17 +52,8 @@ vi.mock("@hashintel/ds-components", async (importOriginal) => { const Drawer = Object.assign( ({ children }: { children: ReactNode }) =>
{children}
, { - Header: ({ - title, - description, - }: { - title: ReactNode; - description?: ReactNode; - }) => ( -
- {title} -

{description}

-
+ Header: ({ children }: { children: ReactNode }) => ( +
{children}
), Body: ({ children }: { children: ReactNode }) =>
{children}
, Footer: ({ actions }: { actions: ReactNode }) => ( @@ -111,7 +117,18 @@ vi.mock("@hashintel/ds-components", async (importOriginal) => { ); }; - return { ...actual, Drawer, Menu, Slider, Tooltip }; + // The Ark popover positions itself against a trigger jsdom cannot lay out; + // this one renders its panel in place. + const Popover = Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + Container: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + }, + ); + + return { ...actual, Drawer, Menu, Popover, Slider, Tooltip }; }); vi.mock("./optimization-surface", () => ({ @@ -275,7 +292,10 @@ describe("ViewOptimizationDrawer for a remote study", () => { expect(screen.getByText("Complete")).toBeTruthy(); expect(screen.getByText("5 / 30")).toBeTruthy(); expect( - screen.getByText("Best step so far").nextElementSibling?.textContent, + screen + .getByText("Best step so far") + .nextElementSibling?.querySelector("[data-frame-stat-value]") + ?.textContent, ).toBe(formatObjective(best!.objective)); expect(screen.getByText("Best parameters")).toBeTruthy(); expect(screen.getByRole("table")).toBeTruthy(); @@ -286,7 +306,7 @@ describe("ViewOptimizationDrawer for a remote study", () => { expect(screen.queryByTitle("Best step")).toBeNull(); }); - it("names the scenario and the objective under the title", () => { + it("names the scenario and the objective in the one-line title", () => { renderDrawer(remote); const metric = input.model.definition.metrics![0]!; @@ -400,7 +420,10 @@ describe("ViewOptimizationDrawer for a connected study", () => { expect(screen.getByText("Running")).toBeTruthy(); expect(screen.getByText("3 / 30")).toBeTruthy(); expect( - screen.getByText("Best step so far").nextElementSibling?.textContent, + screen + .getByText("Best step so far") + .nextElementSibling?.querySelector("[data-frame-stat-value]") + ?.textContent, ).toBe(formatObjective(trials[2]!.best!.objective)); // The table lists the newest step first; the header row is row 1. const bestTrial = trials[2]!.best!.trial; @@ -516,13 +539,9 @@ describe("ViewOptimizationDrawer for a connected study", () => { }, }); + // The reason lives in the badge's tooltip, which the mock does not render. expect(screen.getByText("CPU")).toBeTruthy(); expect(screen.queryByText("GPU")).toBeNull(); - expect( - screen.getByText( - "Ran on the CPU: the GPU cannot compute expression metrics", - ), - ).toBeTruthy(); }); it("badges a study that ran on the GPU", () => { @@ -566,7 +585,66 @@ describe("ViewOptimizationDrawer for a connected study", () => { expect(extendOptimization).toHaveBeenCalledWith(stopped.id, 4); }); - it("shows the followed step's runs under the steps bar and lists the batches computing", () => { + it("condenses the header once the body scrolls, also after the focused computing chip went idle", () => { + const view = renderDrawer(connected); + + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + scrollFrameBody(0); + + // Focus lands on the chip while a batch computes, then the batch ends and + // the chip is disabled under the focus without a blur. + const computing = { + ...connected, + connected: { + ...following, + activity: [ + { + id: 3, + kind: "trial" as const, + trial: 2, + runCount: 1, + completedRuns: 0, + }, + ], + }, + }; + view.rerender( + + + {}} + optimization={computing} + /> + + , + ); + act(() => screen.getByRole("button", { name: /1 computing/ }).focus()); + view.rerender( + + + {}} + optimization={connected} + /> + + , + ); + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + }); + + it("lists the batches computing from the computing chip", () => { renderDrawer({ ...connected, connected: { @@ -585,7 +663,6 @@ describe("ViewOptimizationDrawer for a connected study", () => { }); expect(screen.getByText("3 / 30")).toBeTruthy(); - expect(screen.getByText("Step 3 · 1 / 1 runs")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /2 computing/ })); expect(screen.getByText("Step 3")).toBeTruthy(); expect(screen.getByText("0 / 1 runs")).toBeTruthy(); @@ -902,3 +979,101 @@ describe("ViewOptimizationDrawer's Sensitivity analysis card", () => { expect(screen.queryByText("Sensitivity analysis")).toBeNull(); }); }); + +describe("ViewOptimizationDrawer holds every box still across states", () => { + const navigation = navigationAtTrial(input, trials[2]!, true); + const base = { + input, + trials: trials.slice(0, 3), + best: trials[2]!.best, + }; + const states: OptimizationRecord[] = [ + makeOptimizationRecord({ + ...base, + status: "running", + connected: makeConnectedStudyState(input, { + navigation, + selection: makeSelectionStream({ + input, + navigation, + followedTrial: 2, + runsCompleted: 1, + computing: true, + frameCount: 4, + }), + }), + }), + makeOptimizationRecord({ + ...base, + status: "paused", + connected: makeConnectedStudyState(input, { + navigation: navigationAtTrial(input, trials[2]!, false), + selection: null, + resumable: true, + }), + }), + makeOptimizationRecord({ + ...base, + status: "cancelled", + connected: makeConnectedStudyState(input, { + navigation: navigationAtTrial(input, trials[2]!, false), + resumable: true, + }), + }), + makeOptimizationRecord({ + ...base, + status: "complete", + connected: makeConnectedStudyState(input, { + navigation: navigationAtTrial(input, trials[2]!, false), + resumable: true, + }), + }), + ]; + + it("gives the header, the note row, every card and the steps table one height in running, paused, stopped and complete", () => { + const signatures = states.map((state) => { + const view = renderDrawer(state); + const signature = frameLayoutSignature(view.container); + view.unmount(); + return signature; + }); + + expect(signatures[0]!.header).toBe(`${FRAME_HEADER_HEIGHT}px`); + expect(signatures[0]!.note).toBe("20px"); + expect(signatures[0]!.steps).toBe("320px"); + expect(signatures[0]!.cards.map(([title]) => title)).toEqual([ + "Objective at the step in flight", + "Objective by step", + "Sensitivity analysis", + ]); + for (const signature of signatures.slice(1)) { + // A settled study titles the objective card for the point it shows; + // the boxes are the same. + expect({ + ...signature, + cards: signature.cards.map(([, height]) => height), + }).toEqual({ + ...signatures[0], + cards: signatures[0]!.cards.map(([, height]) => height), + }); + } + }); + + it("puts the resume note in the reserved row while paused", () => { + renderDrawer(states[1]!); + + const note = document.querySelector("[data-frame-note]")!; + expect(note.style.height).toBe("20px"); + expect(note.querySelector("[data-resume-note]")).toBeTruthy(); + }); + + it("leaves the objective card's height alone when its aggregation changes", () => { + const view = renderDrawer(states[0]!); + const before = frameLayoutSignature(view.container); + + fireEvent.click(screen.getByRole("button", { name: "Chart options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Median" })); + + expect(frameLayoutSignature(view.container)).toEqual(before); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx index 9c7f922a35d..c5d0c418b16 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx @@ -1,27 +1,12 @@ /** - * One study in a drawer over the Optimizations list: the study body stacked - * for the overlay's width, with the actions in the footer. The full view - * shows the same body spread over the section; "Open full view" switches. + * One study in a drawer over the Optimizations list: the study frame in a ds + * Drawer. The full view shows the same frame over the section; "Open full + * view" in the footer switches. */ -import { use } from "react"; - -import { Drawer } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - -import { EditorContext } from "../../../../../../react/state/editor-context"; -import { describeStudy, StudyActions, StudyBody } from "./study-view"; +import { StudyFrame } from "./study-view"; import type { OptimizationRecord } from "../../../../../../react/optimizations/context"; -// The body is a column that never scrolls itself: the study body keeps its -// summary band still and scrolls the region beneath it. -const drawerBodyStyle = css({ - paddingTop: "[0]", - display: "flex", - flexDirection: "column", - overflow: "hidden", -}); - export const ViewOptimizationDrawer = ({ open, onClose, @@ -31,35 +16,16 @@ export const ViewOptimizationDrawer = ({ onClose: () => void; optimization: OptimizationRecord | undefined; }) => { - const { setSimulatePresentation } = use(EditorContext); - if (!open || !optimization) { return null; } return ( - - - - - - - } - /> - + /> ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx index 67a626cf70c..0bd6ed0fc06 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx @@ -244,6 +244,12 @@ export const ChartCardMenu = ({ label, items }: ChartCardMenuProps) => ( /> ); +/** + * The narrowest a chart card gets in a grid, in pixels: two fit side by side + * in the frame's secondary column from its minimum width up. + */ +export const CHART_CARD_MIN_WIDTH = 320; + const gridStyle = css({ display: "grid", alignItems: "stretch", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-activity.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-activity.tsx deleted file mode 100644 index 71c8139a967..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-activity.tsx +++ /dev/null @@ -1,273 +0,0 @@ -/** - * A summary's compute readout: the progress bar for the thing the drawer - * shows, an optional thinner bar for the batch feeding it, and a compact, - * toggleable list of every batch computing right now — a sweep runs the - * selection's ladder, surface chunks and cell refinements in parallel, a - * study its steps and the navigated point's refinement — so the list shows - * that parallelism. Collapsed, it is one line ("N computing"); the toggle - * hides when nothing computes. - */ -import { useState } from "react"; - -import { Icon } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - -/** One computing batch, for the expanded list. */ -export type ComputeActivityBatch = { - id: string; - label: string; - /** Priority work draws in blue; background work in grey. */ - tone: "priority" | "background"; - runCount: number; - completedRuns: number; -}; - -/** A progress bar's fill and the label under it; no label leaves the slot empty. */ -export type ComputeActivityBar = { - percent: number; - label?: string; -}; - -const barTrackStyle = css({ - height: "[6px]", - width: "full", - backgroundColor: "neutral.s30", - borderRadius: "full", - overflow: "hidden", -}); - -const barFillStyle = css({ - height: "full", - borderRadius: "full", - backgroundColor: "neutral.s120", - transition: "[width 160ms ease-out]", -}); - -const secondaryTrackStyle = css({ - height: "[3px]", - width: "full", - marginTop: "[3px]", - backgroundColor: "neutral.s20", - borderRadius: "full", - overflow: "hidden", -}); - -const secondaryFillStyle = css({ - height: "full", - borderRadius: "full", - backgroundColor: "blue.s100", - transition: "[width 160ms ease-out]", -}); - -const metaRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "2", - marginTop: "1", -}); - -const metaLabelsStyle = css({ - display: "flex", - alignItems: "baseline", - gap: "2", - minWidth: "[0]", -}); - -// The toggle stays in the row while nothing computes so the row keeps its -// height, and the sections below hold still when batches start and finish. -const toggleSlotStyle = css({ - display: "inline-flex", - "&[data-idle=true]": { visibility: "hidden" }, -}); - -const metaLabelStyle = css({ - fontSize: "[11px]", - color: "neutral.s80", - fontVariantNumeric: "tabular-nums", -}); - -const secondaryLabelStyle = css({ - fontSize: "[11px]", - color: "blue.s100", - fontVariantNumeric: "tabular-nums", - whiteSpace: "nowrap", -}); - -const toggleStyle = css({ - display: "inline-flex", - alignItems: "center", - gap: "1", - paddingX: "1.5", - paddingY: "[2px]", - borderRadius: "sm", - borderWidth: "[0]", - fontSize: "[11px]", - fontWeight: "medium", - color: "neutral.s100", - backgroundColor: "neutral.s10", - cursor: "pointer", - _hover: { backgroundColor: "neutral.s20" }, -}); - -const computingDotStyle = css({ - width: "[6px]", - height: "[6px]", - borderRadius: "full", - backgroundColor: "blue.s100", -}); - -const batchListStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[3px]", - marginTop: "1", - padding: "1.5", - borderWidth: "[1px]", - borderStyle: "solid", - borderColor: "neutral.bd.subtle", - borderRadius: "sm", - backgroundColor: "neutral.s10", -}); - -const batchRowStyle = css({ - display: "grid", - gridTemplateColumns: "[minmax(76px, auto) minmax(0, 1fr) 88px]", - alignItems: "center", - gap: "2", - minHeight: "[16px]", -}); - -const batchLabelStyle = css({ - display: "inline-flex", - alignItems: "center", - gap: "1", - fontSize: "[11px]", - color: "neutral.s100", - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - maxWidth: "[220px]", -}); - -const batchDotStyle = css({ - width: "[6px]", - height: "[6px]", - borderRadius: "full", - flexShrink: "0", - backgroundColor: "neutral.s60", - "&[data-tone=priority]": { backgroundColor: "blue.s100" }, -}); - -const batchTrackStyle = css({ - height: "[4px]", - borderRadius: "full", - backgroundColor: "neutral.s30", - overflow: "hidden", -}); - -const batchFillStyle = css({ - height: "full", - borderRadius: "full", - backgroundColor: "neutral.s90", - transition: "[width 160ms ease-out]", - "&[data-tone=priority]": { backgroundColor: "blue.s100" }, -}); - -const batchCountStyle = css({ - fontSize: "[11px]", - color: "neutral.s80", - fontVariantNumeric: "tabular-nums", - textAlign: "right", - whiteSpace: "nowrap", -}); - -const BatchRow = ({ batch }: { batch: ComputeActivityBatch }) => { - const percent = - batch.runCount > 0 - ? Math.min(100, (batch.completedRuns / batch.runCount) * 100) - : 0; - - return ( -
- - - {batch.label} - -
-
-
- - {batch.completedRuns.toLocaleString("en-US")} /{" "} - {batch.runCount.toLocaleString("en-US")} runs - -
- ); -}; - -export const ComputeActivity = ({ - bar, - secondaryBar = null, - batches, -}: { - bar: ComputeActivityBar; - /** A thinner bar beneath the main one; null hides it. */ - secondaryBar?: ComputeActivityBar | null; - batches: readonly ComputeActivityBatch[]; -}) => { - const [expanded, setExpanded] = useState(false); - - return ( -
-
-
-
- {secondaryBar ? ( -
-
-
- ) : null} -
- - {bar.label === undefined ? null : ( - {bar.label} - )} - {secondaryBar ? ( - {secondaryBar.label} - ) : null} - - - - -
- {expanded && batches.length > 0 ? ( -
- {batches.map((batch) => ( - - ))} -
- ) : null} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx index af3e070d3f3..892f222fdd3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx @@ -12,14 +12,17 @@ export type ComputeBackendSummary = Pick< // Local rather than the design system's `Badge`, whose `brand` scheme puts // #5EB1EF on a near-white #FBFDFF — about 2.3:1, below the 4.5:1 WCAG AA // needs for text this size. +// 18px tall, like the status pill and the computing chip it shares the +// header's strip with. const badgeStyle = css({ display: "inline-flex", alignItems: "center", gap: "1", paddingX: "1.5", - paddingY: "[2px]", + height: "[18px]", borderRadius: "sm", fontSize: "xs", + lineHeight: "[18px]", fontWeight: "medium", color: "neutral.s110", backgroundColor: "neutral.s10", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.stories.tsx new file mode 100644 index 00000000000..c7f26201fc1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.stories.tsx @@ -0,0 +1,161 @@ +/** + * The frame on its own, with placeholder cards: at rest, the header shows + * the title line, the strip of stat columns and the bar; once the body has + * scrolled it condenses to one line of compact chips and grows back under the + * pointer. The Parameters band spans the body and collapses; the surface and + * the cards share the columns beneath it. + */ +import { useEffect } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { ChartCard, ChartCardGrid, chartCardHeight } from "./chart-card"; +import { + ComputeBatchesChip, + DrawerFrame, + FrameBand, + FrameColumns, + FrameStat, + FrameStatusPill, +} from "./drawer-frame"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Simulate / DrawerFrame", + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const sectionStyle = css({ + display: "flex", + height: "[100vh]", + width: "full", +}); + +const placeholderStyle = css({ + height: "full", + borderRadius: "md", + backgroundColor: "neutral.s10", +}); + +const PLOT_HEIGHT = 220; +const CARD_HEIGHT = chartCardHeight({ bodyHeight: PLOT_HEIGHT }); + +const Stats = () => ( + <> + + + Running + + + + 900 active, 100 complete + + + 0 + + + 45 / 180 + + + 4m 02s + + + 61 / 100 runs + + + + + +); + +const Cards = ({ count }: { count: number }) => ( + + {Array.from({ length: count }, (_, index) => ( + +
+ + ))} + +); + +const Frame = ({ cards }: { cards: number }) => ( +
+ } + badge={CPU} + progress={61} + footer={Actions} + > + +
+ + X · Y · Metric} + footerHeight={24} + > +
+ + } + secondary={} + /> + +
+); + +export const AtRest: Story = { + name: "At rest", + render: () => , +}; + +/** Scrolls the body once mounted, so the story opens on the condensed header. */ +const ScrolledFrame = () => { + useEffect(() => { + document.querySelector("[data-frame-body]")?.scrollTo(0, 160); + }, []); + return ; +}; + +export const Condensed: Story = { + parameters: { + docs: { + description: { + story: + "The body has scrolled: the stat columns folded into the title line as compact chips and the header is 36px tall. Move the pointer over it to see it grow back.", + }, + }, + }, + render: () => , +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx new file mode 100644 index 00000000000..194e0b6f0e6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx @@ -0,0 +1,256 @@ +/** + * @vitest-environment jsdom + */ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { CHART_CARD_MIN_WIDTH } from "./chart-card"; +import { + ComputeBatchesChip, + DrawerFrame, + FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, + FRAME_SECONDARY_MIN_WIDTH, + FRAME_TWO_COLUMN_MIN_WIDTH, + FrameBand, + FrameColumns, + FrameStat, + FrameStatusPill, +} from "./drawer-frame"; +import { + frameHeader as header, + scrollFrameBody as scrollBodyTo, +} from "./drawer-frame/frame-test-helpers"; + +import type { ReactNode } from "react"; + +afterEach(cleanup); + +/** The computing chip's shape: a button in the stats line, disabled once nothing computes. */ +const computingChip = (computing: boolean) => ( + +); + +const frame = ({ + note = null, + stats, +}: { + note?: { content: string; tone: "error" } | null; + stats?: ReactNode; +} = {}) => ( + Step 3 of 30} + stats={ + <> + + + Running + + + + 100 complete + + {stats} + + } + badge={CPU} + progress={40} + note={note} + footer={} + > +
+ +); + +const renderFrame = (note: { content: string; tone: "error" } | null = null) => + render(frame({ note })); + +describe("DrawerFrame", () => { + it("renders the title, the stat columns sized by their widest value, the badge column, the bar and the footer at rest", () => { + renderFrame(); + + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + expect(header().dataset.condensed).toBe("false"); + expect( + screen.getByText( + "SIR transmission sweep · Seasonal Flu · 100 runs · dt 1", + ), + ).toBeTruthy(); + expect(screen.getByText("Step 3 of 30")).toBeTruthy(); + const runs = screen.getByText("Runs").nextElementSibling!; + expect(runs.querySelector("[data-frame-stat-value]")?.textContent).toBe( + "100 complete", + ); + expect(runs.querySelector("[data-frame-stat-sizer]")?.textContent).toBe( + "1,000 complete", + ); + // The stats are labelled columns; the badge is the last of them. + const columns = [ + ...document.querySelectorAll("[data-frame-stats] > [data-frame-stat]"), + ]; + expect( + columns.map((column) => column.querySelector("span")?.textContent), + ).toEqual(["Status", "Runs", "Compute"]); + expect(columns.at(-1)?.getAttribute("data-trailing")).toBe("true"); + expect(screen.getByText("CPU")).toBeTruthy(); + expect( + document.querySelector("[data-frame-progress] > div")?.style + .width, + ).toBe("40%"); + expect(screen.getByRole("button", { name: "Close" })).toBeTruthy(); + }); + + it("condenses once the body has scrolled, folding the stats into the title line, and grows back under the pointer", () => { + renderFrame(); + + scrollBodyTo(48); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + expect(header().dataset.condensed).toBe("true"); + // The compact copy sits in the title line; the stats line is folded away. + const compact = document.querySelector("[data-frame-compact-stats]")!; + expect(compact.textContent).toContain("Running"); + expect(compact.textContent).toContain("CPU"); + expect( + [...compact.querySelectorAll("[data-frame-stat]")].map((stat) => + stat.getAttribute("title"), + ), + ).toEqual(["Status", "Runs", "Compute"]); + expect( + document.querySelector("[data-frame-stats]")?.getAttribute("aria-hidden"), + ).toBe("true"); + + fireEvent.pointerEnter(header()); + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + expect(document.querySelector("[data-frame-compact-stats]")).toBeNull(); + + fireEvent.pointerLeave(header()); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + + scrollBodyTo(0); + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + }); + + it("holds its height while a control inside it has the focus, and condenses once that control lost it without a blur", () => { + const view = render(frame({ stats: computingChip(true) })); + const chip = screen.getByRole("button", { name: "1 computing" }); + act(() => chip.focus()); + + // Keyboard focus on a header control holds the header open. + scrollBodyTo(48); + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + + // The chip goes idle under the focus: disabled, so it fires no blur. + view.rerender(frame({ stats: computingChip(false) })); + scrollBodyTo(60); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + expect(header().dataset.condensed).toBe("true"); + view.unmount(); + + // The same when the focused control is unmounted. + const removed = render(frame({ stats: computingChip(true) })); + act(() => screen.getByRole("button", { name: "1 computing" }).focus()); + removed.rerender(frame()); + scrollBodyTo(60); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + }); + + it("gives the secondary column room for two chart cards and their gap from the two-column width up", () => { + // The grid's gap is the `3` spacing token, 12px. + expect(FRAME_SECONDARY_MIN_WIDTH).toBe(2 * CHART_CARD_MIN_WIDTH + 12); + // The extra-large drawer's body content box is 1010px, 995px beside a + // classic scrollbar; both are two-column widths. + expect(FRAME_TWO_COLUMN_MIN_WIDTH).toBeLessThanOrEqual(995); + expect(FRAME_TWO_COLUMN_MIN_WIDTH).toBeGreaterThan( + FRAME_SECONDARY_MIN_WIDTH, + ); + }); + + it("keeps the note row mounted at one height whether or not there is a note", () => { + const empty = renderFrame(); + const emptyRow = document.querySelector("[data-frame-note]")!; + expect(emptyRow.style.height).toBe("20px"); + expect(emptyRow.textContent).toBe(""); + empty.unmount(); + + renderFrame({ content: "metric__profit: Unexpected token", tone: "error" }); + const row = document.querySelector("[data-frame-note]")!; + expect(row.style.height).toBe("20px"); + expect(row.dataset.tone).toBe("error"); + expect(row.textContent).toBe("metric__profit: Unexpected token"); + }); + + it("draws the computing chip at zero, disabled, with room for a three-digit count", () => { + render(); + + const chip = screen.getByRole("button", { name: "0 computing" }); + expect(chip).toHaveProperty("disabled", true); + expect( + chip.closest("[data-compute-batches]")?.getAttribute("data-idle"), + ).toBe("true"); + expect(chip.textContent).toContain("000 computing"); + }); + + it("folds a collapsible band's controls away without unmounting them", () => { + render( + + + , + ); + + const toggle = screen.getByRole("button", { name: "Collapse Parameters" }); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(toggle); + + const content = document.querySelector( + "[data-frame-band-content]", + )!; + expect( + document + .querySelector("[data-frame-band]") + ?.getAttribute("data-collapsed"), + ).toBe("true"); + expect(content.getAttribute("aria-hidden")).toBe("true"); + expect(content.hasAttribute("inert")).toBe(true); + expect(content.querySelector("input")?.value).toBe("42"); + expect( + screen + .getByRole("button", { name: "Expand Parameters" }) + .getAttribute("aria-expanded"), + ).toBe("false"); + + fireEvent.click(screen.getByRole("button", { name: "Expand Parameters" })); + expect(content.hasAttribute("inert")).toBe(false); + expect(screen.getByRole("textbox", { name: "population" })).toBeTruthy(); + }); + + it("gives the secondary column the whole width when there is no primary", () => { + const view = render(cards
} />); + expect( + view.container + .querySelector("[data-frame-columns]") + ?.getAttribute("data-primary"), + ).toBe("false"); + view.unmount(); + + render( + surface
} + secondary={
cards
} + />, + ); + expect( + document + .querySelector("[data-frame-columns]") + ?.getAttribute("data-primary"), + ).toBe("true"); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx new file mode 100644 index 00000000000..db129587be3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx @@ -0,0 +1,250 @@ +/** + * @layerRoot ui.views.editor.drawer-frame + * @role The chrome every Simulate drawer and the full study view share: a header that condenses once the body scrolls, a body of fixed-height cards, a footer of actions + * + * The header sits outside the body's scroll container, so condensing it + * changes the body's available height and never its scroll offset. The body + * is a size container: a `note` row always mounted, empty when there is + * nothing to say, so an error or a resume note appearing moves nothing; then + * the adopter's parameter band across the width, then `FrameColumns`, which + * arranges the surface and the cards by the body's width. In a drawer the + * body takes the opening focus, so wheel and arrow keys scroll it at once and + * no control in the header holds the header open. + */ +import { type ReactNode, use, useRef } from "react"; + +import { Drawer } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; +import { FrameAnimateContext } from "./drawer-frame/frame-animate-context"; +import { FrameHeader } from "./drawer-frame/frame-header"; +import { useBodyScrolled } from "./drawer-frame/use-body-scrolled"; +import { useHeaderEngaged } from "./drawer-frame/use-header-engaged"; +import { usePrefersReducedMotion } from "./drawer-frame/use-prefers-reduced-motion"; + +export { + FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, + FrameStat, + FrameStatusPill, + type FrameStatusTone, +} from "./drawer-frame/frame-header"; +export { + FRAME_SECONDARY_MIN_WIDTH, + FRAME_TWO_COLUMN_MIN_WIDTH, + FrameColumns, +} from "./drawer-frame/frame-columns"; +export { FrameBand } from "./drawer-frame/frame-band"; +export { + type FrameLayoutSignature, + frameLayoutSignature, +} from "./drawer-frame/frame-layout-signature"; +export { + type ComputeBatch, + ComputeBatchesChip, +} from "./drawer-frame/compute-batches-chip"; + +/** The reserved row under the header: a note in the muted or the error tone. */ +export type FrameNote = { + content: ReactNode; + tone: "muted" | "error"; +}; + +/** The height of the note row in pixels; reserved whether or not a note shows. */ +export const FRAME_NOTE_HEIGHT = 20; + +// The ds close button is 28px wide with a 20px right gutter. +const DRAWER_CLOSE_GUTTER = 52; + +const sectionFrameStyle = css({ + display: "flex", + flexDirection: "column", + flex: "1", + minWidth: "[0]", + minHeight: "[0]", + height: "full", + backgroundColor: "neutral.s00", +}); + +const sectionHeaderStyle = css({ + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.s40", + flexShrink: "0", +}); + +const sectionFooterStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: "2", + flexShrink: "0", + paddingX: "5", + paddingY: "3", + borderTopWidth: "[1px]", + borderTopStyle: "solid", + borderTopColor: "neutral.s40", +}); + +// The ds header's own padding goes; the frame header brings its own and sets +// its height. The close button the ds header draws floats over the frame +// header's right gutter. +const drawerHeaderStyle = css({ + display: "block", + padding: "[0 !important]", + position: "relative", + "& > div:first-child": { minWidth: "[0]" }, + "& > button": { + position: "absolute", + top: "[4px]", + right: "[20px]", + margin: "[0]", + }, +}); + +const drawerBodyStyle = css({ + display: "flex", + flexDirection: "column", + overflow: "hidden", +}); + +const bodyStyle = css({ + display: "flex", + flexDirection: "column", + gap: "3", + flex: "[1]", + minHeight: "[0]", + minWidth: "[0]", + overflowY: "auto", + overflowX: "hidden", + outline: "none", + scrollbarWidth: "[thin]", + scrollbarGutter: "stable", + paddingX: "5", + paddingBottom: "4", + containerType: "inline-size", + containerName: "drawer-frame-body", +}); + +const noteRowStyle = css({ + display: "flex", + alignItems: "center", + flexShrink: "0", + minWidth: "[0]", + fontSize: "xs", + lineHeight: "[16px]", + color: "neutral.s80", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + "&[data-tone=error]": { color: "red.s100" }, +}); + +export type DrawerFrameProps = { + /** One line: `SIR transmission sweep · Seasonal Flu · 100 runs · dt 1`. */ + title: string; + /** Before the title: a Back button in the full view. */ + leading?: ReactNode; + /** The title line's right side while at rest: the study's progress line. */ + headline?: ReactNode; + /** The stat columns: `FrameStat`s holding the status pill, the counts, the computing chip. */ + stats: ReactNode; + /** The strip's last column, pinned right: the compute badge. */ + badge?: ReactNode; + /** The bar along the header's bottom edge, 0 to 100. */ + progress: number; + /** The reserved row at the top of the body; null keeps the row empty. */ + note?: FrameNote | null; + /** The footer's actions. */ + footer: ReactNode; + /** Given, the frame renders inside a ds `Drawer`; otherwise it fills its section. */ + drawer?: { onClose: () => void; swapKey: string }; + children: ReactNode; +}; + +export const DrawerFrame = ({ + title, + leading, + headline, + stats, + badge, + progress, + note = null, + footer, + drawer, + children, +}: DrawerFrameProps) => { + const { showAnimations } = use(UserSettingsContext); + const reducedMotion = usePrefersReducedMotion(); + const { scrolled, onScroll } = useBodyScrolled(); + const { engaged, engagement, settleFocus } = useHeaderEngaged(); + const bodyRef = useRef(null); + const condensed = scrolled && !engaged; + const animate = showAnimations && !reducedMotion; + + const header = ( + + ); + + const body = ( +
{ + onScroll(event); + settleFocus(); + }} + > +
+ {note?.content} +
+ {children} +
+ ); + + if (drawer === undefined) { + return ( +
+
{header}
+ {body} +
{footer}
+
+ ); + } + + return ( + + {header} + + {body} + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx new file mode 100644 index 00000000000..ae67c19f9ac --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx @@ -0,0 +1,209 @@ +/** + * The "N computing" chip on the frame's stats line and the list it opens: one + * row per batch computing right now with its own progress. A sweep runs the + * selection's ladder, surface chunks and cell refinements in parallel, a + * study its steps and the navigated point's refinement, so the list shows + * that parallelism. The list is a popover, so opening it moves nothing. The + * chip is always drawn, `0 computing` and disabled while nothing runs, and is + * as wide as a three-digit count, so the strip never moves around it. + */ +import { useRef, useState } from "react"; + +import { Icon, Popover } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +/** One computing batch, for the list. */ +export type ComputeBatch = { + id: string; + label: string; + /** Priority work draws in blue; background work in grey. */ + tone: "priority" | "background"; + runCount: number; + completedRuns: number; +}; + +/** The widest count the chip reserves room for. */ +const WIDEST_COUNT = "000"; + +const slotStyle = css({ + display: "inline-flex", + flexShrink: "0", +}); + +const chipStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1", + paddingX: "1.5", + height: "[18px]", + borderRadius: "sm", + borderWidth: "[0]", + fontSize: "[11px]", + fontWeight: "medium", + fontVariantNumeric: "tabular-nums", + color: "neutral.s100", + backgroundColor: "neutral.s10", + cursor: "pointer", + whiteSpace: "nowrap", + _hover: { backgroundColor: "neutral.s20" }, + "&[data-idle=true]": { + color: "neutral.s70", + cursor: "default", + _hover: { backgroundColor: "neutral.s10" }, + }, +}); + +// The live text and the widest text share one grid cell, so the chip is as +// wide as `000 computing` whatever the count. +const chipTextStyle = css({ + display: "grid", + "& > *": { gridArea: "[1 / 1]" }, +}); + +const sizerStyle = css({ + visibility: "hidden", +}); + +const computingDotStyle = css({ + width: "[6px]", + height: "[6px]", + borderRadius: "full", + backgroundColor: "blue.s100", + "[data-idle=true] > &": { backgroundColor: "neutral.s50" }, +}); + +const listStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[3px]", + minWidth: "[320px]", + padding: "2", +}); + +const rowStyle = css({ + display: "grid", + gridTemplateColumns: "[minmax(76px, auto) minmax(0, 1fr) 88px]", + alignItems: "center", + gap: "2", + minHeight: "[16px]", +}); + +const labelStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1", + fontSize: "[11px]", + color: "neutral.s100", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + maxWidth: "[220px]", +}); + +const dotStyle = css({ + width: "[6px]", + height: "[6px]", + borderRadius: "full", + flexShrink: "0", + backgroundColor: "neutral.s60", + "&[data-tone=priority]": { backgroundColor: "blue.s100" }, +}); + +const trackStyle = css({ + height: "[4px]", + borderRadius: "full", + backgroundColor: "neutral.s30", + overflow: "hidden", +}); + +const fillStyle = css({ + height: "full", + borderRadius: "full", + backgroundColor: "neutral.s90", + transition: "[width 160ms ease-out]", + "&[data-tone=priority]": { backgroundColor: "blue.s100" }, +}); + +const countStyle = css({ + fontSize: "[11px]", + color: "neutral.s80", + fontVariantNumeric: "tabular-nums", + textAlign: "right", + whiteSpace: "nowrap", +}); + +const BatchRow = ({ batch }: { batch: ComputeBatch }) => { + const percent = + batch.runCount > 0 + ? Math.min(100, (batch.completedRuns / batch.runCount) * 100) + : 0; + + return ( +
+ + + {batch.label} + +
+
+
+ + {batch.completedRuns.toLocaleString("en-US")} /{" "} + {batch.runCount.toLocaleString("en-US")} runs + +
+ ); +}; + +export const ComputeBatchesChip = ({ + batches, +}: { + batches: readonly ComputeBatch[]; +}) => { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const idle = batches.length === 0; + const showing = open && !idle; + + return ( + + + {showing ? ( + setOpen(false)} + > + +
+ {batches.map((batch) => ( + + ))} +
+
+
+ ) : null} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-animate-context.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-animate-context.ts new file mode 100644 index 00000000000..d5a2a08eebb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-animate-context.ts @@ -0,0 +1,8 @@ +/** + * Whether the frame's transitions run: the frame reads the animations setting + * and the OS's reduced-motion preference once and hands the answer down to + * the header and the bands. Outside a frame the transitions run. + */ +import { createContext } from "react"; + +export const FrameAnimateContext = createContext(true); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx new file mode 100644 index 00000000000..da17e602e3d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx @@ -0,0 +1,137 @@ +/** + * A titled band in the frame body: a title row with an optional help tooltip + * and a trailing readout, then the band's controls. Bands hold parameter + * controls and span the body's width; they have no card border. A + * collapsible band folds its controls away behind a chevron, keeping them + * mounted so their state survives; the fold animates the band's height + * alone, and whatever follows the band moves as one block. + */ +import { use, useState, type ReactNode } from "react"; + +import { Button, HelpTooltip } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { FrameAnimateContext } from "./frame-animate-context"; + +const bandStyle = css({ + display: "flex", + flexDirection: "column", + minWidth: "[0]", +}); + +// The row is one line at a fixed height: whatever the trailing readout says, +// the controls below it never move. +const headerRowStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "2", + height: "[24px]", +}); + +const headerLeftStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + flexShrink: "0", +}); + +const titleStyle = css({ + fontWeight: "semibold", + fontSize: "sm", + lineHeight: "[20px]", + color: "neutral.fg.body", +}); + +const trailingStyle = css({ + display: "flex", + alignItems: "center", + minWidth: "[0]", + overflow: "hidden", + whiteSpace: "nowrap", +}); + +// The fold: a one-row grid whose row goes from `1fr` to `0fr`, so the +// content's own height is what animates and the content stays mounted. +const foldStyle = css({ + display: "grid", + gridTemplateRows: "[1fr]", + "&[data-animate=true]": { + transition: "[grid-template-rows 160ms ease-out, visibility 0s]", + }, + "&[data-collapsed=true]": { + gridTemplateRows: "[0fr]", + visibility: "hidden", + transitionDelay: "[0s, 160ms]", + }, +}); + +const contentStyle = css({ + minHeight: "[0]", + minWidth: "[0]", + overflow: "hidden", +}); + +const contentInnerStyle = css({ + paddingTop: "2", +}); + +export const FrameBand = ({ + title, + help, + trailing, + collapsible = false, + children, +}: { + title: string; + help?: string; + /** The title row's right side: a state line, a switch. */ + trailing?: ReactNode; + /** A chevron before the title folds the controls away; they stay mounted. */ + collapsible?: boolean; + children: ReactNode; +}) => { + const animate = use(FrameAnimateContext); + const [collapsed, setCollapsed] = useState(false); + const contentId = `frame-band-${title.replace(/\s+/gu, "-").toLowerCase()}`; + + return ( +
+
+
+ {collapsible ? ( +
+ {trailing === undefined ? null : ( +
{trailing}
+ )} +
+
+
+
{children}
+
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx new file mode 100644 index 00000000000..22b847e3e98 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx @@ -0,0 +1,87 @@ +/** + * The frame body's arrangement under the Parameters band. At the extra-large + * drawer's width and above, two columns: the surface on the left, the metric + * cards on the right, the right column never narrower than two cards. In a + * narrower drawer, one column with the metric cards first, then the surface, + * so the thing being watched is at the top either way. Without a surface the + * cards take the whole width. Whatever follows (a steps table) spans the + * width beneath. + */ +import { css } from "@hashintel/ds-helpers/css"; + +import type { ReactNode } from "react"; + +/** + * The body width, in pixels, from which the two-column arrangement applies. + * The extra-large ds drawer is 1060px wide and its body's content box 1010px, + * 995px beside a classic scrollbar; the threshold sits under that with room + * for a viewport that clamps the drawer a little. + */ +export const FRAME_TWO_COLUMN_MIN_WIDTH = 960; + +/** + * The secondary column's minimum width in pixels: two chart cards at + * `CHART_CARD_MIN_WIDTH` and the grid's 12px gap between them. The primary + * column yields until the body is wide enough for the 3:5 split. + */ +export const FRAME_SECONDARY_MIN_WIDTH = 652; + +// The body is the `drawer-frame-body` size container (declared in +// drawer-frame.tsx); Panda extracts the query statically, so the threshold +// and the column minimum are written out here and mirrored by the constants +// above. +const columnsStyle = css({ + display: "grid", + gap: "4", + alignItems: "start", + gridTemplateColumns: "minmax(0, 1fr)", + gridTemplateAreas: '"secondary" "primary" "after"', + "@container drawer-frame-body (min-width: 960px)": { + "&[data-primary=true]": { + gridTemplateColumns: "minmax(0, 3fr) minmax(652px, 5fr)", + gridTemplateAreas: '"primary secondary" "after after"', + }, + }, +}); + +const areaStyle = css({ + display: "flex", + flexDirection: "column", + gap: "4", + minWidth: "[0]", +}); + +export const FrameColumns = ({ + primary, + secondary, + after, +}: { + /** The surface card; absent, the secondary column takes the width. */ + primary?: ReactNode; + /** The metric cards grid. */ + secondary?: ReactNode; + /** Full width beneath both columns: a steps table. */ + after?: ReactNode; +}) => ( +
+ {primary === undefined ? null : ( +
+ {primary} +
+ )} + {secondary === undefined ? null : ( +
+ {secondary} +
+ )} + {after === undefined ? null : ( +
+ {after} +
+ )} +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx new file mode 100644 index 00000000000..890e004af61 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx @@ -0,0 +1,386 @@ +/** + * The frame's header: one title line, one strip of labelled stat columns + * divided by hairlines with the compute badge as its last column, and the + * progress bar along the bottom edge. It sits outside the body's scroll + * container. Once the body has scrolled it condenses: the strip folds into + * the title line as compact chips, and the header grows back while the + * pointer or focus is on it. + * + * Every column is exactly as wide as its widest value: the value cell lays + * an invisible copy of that widest text under the live one, so a number + * growing a digit, a status changing word or a count going to zero moves + * nothing. + */ +import { createContext, use, type ReactNode } from "react"; + +import { css, cx } from "@hashintel/ds-helpers/css"; + +import type { FrameHeaderEngagement } from "./use-header-engaged"; + +/** The header's height in pixels at rest: the title line, the stat strip and the bar. */ +export const FRAME_HEADER_HEIGHT = 68; +/** The header's height in pixels once the body has scrolled: one line and the bar. */ +export const FRAME_HEADER_CONDENSED_HEIGHT = 36; + +/** How the stats render: as labelled columns on their own line, or as compact chips beside the title. */ +export type FrameStatsDensity = "full" | "compact"; + +const FrameStatsDensityContext = createContext("full"); + +const rootStyle = css({ + position: "relative", + display: "flex", + flexDirection: "column", + flexShrink: "0", + boxSizing: "border-box", + minWidth: "[0]", + overflow: "hidden", + paddingTop: "1.5", + paddingLeft: "5", + paddingRight: "5", + backgroundColor: "neutral.s00", + "&[data-animate=true]": { + transition: "[height 160ms ease-out]", + }, +}); + +const titleRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "3", + height: "[24px]", + minWidth: "[0]", + flexShrink: "0", +}); + +// The title yields to the compact chips: it may shrink to a few characters, +// the chips never shrink at all. +const titleStyle = css({ + fontSize: "sm", + fontWeight: "semibold", + lineHeight: "[20px]", + color: "neutral.s120", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + flexShrink: "1", + minWidth: "[48px]", +}); + +// The headline reads to the right of the title while the header is at rest; +// condensed, the compact chips take that room and the headline steps aside. +const headlineStyle = css({ + display: "flex", + alignItems: "center", + marginLeft: "auto", + minWidth: "[0]", + overflow: "hidden", + "[data-condensed=true] &": { display: "none" }, +}); + +const compactRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + marginLeft: "auto", + flexShrink: "0", + whiteSpace: "nowrap", + "[data-animate=true] &": { + animationName: "[dialogBackdropIn]", + animationDuration: "[160ms]", + animationTimingFunction: "ease-out", + }, +}); + +// The strip's height is the label line, its gap and the value line; it +// folds to nothing while condensed. +const statsRowStyle = css({ + display: "flex", + alignItems: "stretch", + height: "[32px]", + minWidth: "[0]", + overflow: "hidden", + whiteSpace: "nowrap", + opacity: "[1]", + "[data-animate=true] &": { + transition: + "[height 160ms ease-out, opacity 120ms ease-out, visibility 0s]", + }, + "[data-condensed=true] &": { + height: "[0]", + opacity: "[0]", + visibility: "hidden", + transitionDelay: "[0s, 0s, 160ms]", + }, +}); + +const progressTrackStyle = css({ + position: "absolute", + left: "[0]", + right: "[0]", + bottom: "[0]", + height: "[3px]", + backgroundColor: "neutral.s30", +}); + +const progressFillStyle = css({ + height: "full", + backgroundColor: "neutral.s120", + "[data-animate=true] &": { + transition: "[width 160ms ease-out]", + }, +}); + +// A column: the label over the value, a hairline on its left from the second +// column on. Compact, the label goes (its text becomes the tooltip) and the +// column is one chip with no rule. +const statStyle = css({ + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + flexShrink: "0", + minWidth: "[0]", + paddingX: "3", + fontSize: "xs", + lineHeight: "[18px]", + color: "neutral.s120", + "&[data-density=full]:first-child": { paddingLeft: "[0]" }, + "&[data-density=full] + &[data-density=full]": { + borderLeftWidth: "[1px]", + borderLeftStyle: "solid", + borderLeftColor: "neutral.bd.subtle", + }, + "&[data-align=end]": { alignItems: "flex-end" }, + "&[data-density=compact]": { + flexDirection: "row", + alignItems: "center", + padding: "[0]", + }, + "&[data-trailing=true]": { marginLeft: "auto" }, +}); + +const statLabelStyle = css({ + fontSize: "[10px]", + lineHeight: "[12px]", + fontWeight: "medium", + letterSpacing: "[0.04em]", + textTransform: "uppercase", + color: "neutral.s70", + "&[data-density=compact]": { display: "none" }, +}); + +// The live value and the invisible widest value share one grid cell, so the +// cell is as wide as the widest and the live text sits inside it. +const statValueStyle = css({ + display: "grid", + alignItems: "center", + fontWeight: "medium", + fontVariantNumeric: "tabular-nums", + whiteSpace: "nowrap", + "& > *": { gridArea: "[1 / 1]" }, + "&[data-align=end]": { justifyItems: "end" }, +}); + +const sizerStyle = css({ + visibility: "hidden", + pointerEvents: "none", +}); + +const valueTextStyle = css({ + display: "inline-flex", + alignItems: "center", + minWidth: "[0]", +}); + +const pillStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1.5", + flexShrink: "0", + paddingX: "2", + height: "[18px]", + borderRadius: "full", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s110", + backgroundColor: "neutral.s10", + fontVariantNumeric: "tabular-nums", + whiteSpace: "nowrap", + "&[data-tone=active]": { color: "blue.s100", backgroundColor: "blue.s10" }, + "&[data-tone=done]": { color: "green.s100", backgroundColor: "green.s10" }, + "&[data-tone=error]": { color: "red.s100", backgroundColor: "red.s10" }, +}); + +const pillTextStyle = css({ + display: "grid", + "& > *": { gridArea: "[1 / 1]" }, +}); + +const pillDotStyle = css({ + width: "[6px]", + height: "[6px]", + borderRadius: "full", + flexShrink: "0", + backgroundColor: "neutral.s60", + "[data-tone=active] > &": { backgroundColor: "blue.s100" }, + "[data-tone=done] > &": { backgroundColor: "green.s90" }, + "[data-tone=error] > &": { backgroundColor: "red.s100" }, +}); + +/** + * One column of the strip: a small uppercase label over its value. The + * column is as wide as `widest`, the longest text the value can be, so a + * changing value never moves its neighbours. Numbers align to the end. + * Compact (while the header is condensed) the label becomes a tooltip. + */ +export const FrameStat = ({ + label, + widest, + align = "end", + trailing = false, + children, + className, +}: { + label: string; + /** The widest text the value can show; it sizes the column invisibly. */ + widest: string; + /** Where the value sits in its column: numbers at the end, words at the start. */ + align?: "start" | "end"; + /** Pinned to the strip's right edge. */ + trailing?: boolean; + children: ReactNode; + className?: string; +}) => { + const density = use(FrameStatsDensityContext); + return ( + + + {label} + + + + {widest} + + + {children} + + + + ); +}; + +export type FrameStatusTone = "active" | "done" | "error" | "neutral"; + +/** The status pill: a dot in the status's colour and the status word, as wide as the longest word. */ +export const FrameStatusPill = ({ + tone, + widest, + children, +}: { + tone: FrameStatusTone; + /** The longest status word, so the pill keeps its width across statuses. */ + widest: string; + children: ReactNode; +}) => ( + + + + + {widest} + + {children} + + +); + +export type FrameHeaderProps = { + /** One line, ellipsized when narrow: `SIR transmission sweep · Seasonal Flu · 100 runs · dt 1`. */ + title: string; + /** Before the title: a Back button in the full view. */ + leading?: ReactNode; + /** The title line's right side while at rest: a live readout such as the study's progress line. */ + headline?: ReactNode; + /** The strip: `FrameStat` columns. Rendered again as compact chips while condensed. */ + stats: ReactNode; + /** The strip's last column, pinned right: the compute badge. */ + badge?: ReactNode; + /** The bar along the bottom edge, 0 to 100. Always drawn. */ + progress: number; + condensed: boolean; + /** Whether the height and opacity changes animate: off under reduced motion or the animations setting. */ + animate: boolean; + /** Room kept clear on the right for a close button the surrounding chrome draws. */ + closeGutter?: number; + /** The pointer and focus handlers that hold the header open while the body is scrolled. */ + engagement: FrameHeaderEngagement; +}; + +const BadgeColumn = ({ badge }: { badge: ReactNode }) => ( + + {badge} + +); + +export const FrameHeader = ({ + title, + leading, + headline, + stats, + badge, + progress, + condensed, + animate, + closeGutter = 0, + engagement, +}: FrameHeaderProps) => ( +
0 ? closeGutter : undefined, + }} + {...engagement} + > +
+ {leading === undefined ? null : leading} + + {title} + + {condensed ? ( + +
+ {stats} + {badge === undefined ? null : } +
+
+ ) : null} + {headline === undefined ? null : ( +
{headline}
+ )} +
+
+ {stats} + {badge === undefined ? null : } +
+
+
+
+
+); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-layout-signature.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-layout-signature.ts new file mode 100644 index 00000000000..d1f39280092 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-layout-signature.ts @@ -0,0 +1,33 @@ +/** + * The boxes the frame promises to hold still, read from the DOM: the header + * and note row heights, every card's title and body height, every grid's row + * height and the steps table's height. Two renders of one drawer in two + * states must give the same signature; tests and probes compare them. + */ +export type FrameLayoutSignature = { + header: string; + note: string; + cards: [title: string, bodyHeight: string][]; + gridRows: string[]; + steps: string | null; +}; + +const styleHeight = (element: Element | null): string => + element instanceof HTMLElement ? element.style.height : ""; + +export const frameLayoutSignature = ( + root: ParentNode, +): FrameLayoutSignature => ({ + header: styleHeight(root.querySelector("[data-frame-header]")), + note: styleHeight(root.querySelector("[data-frame-note]")), + cards: [...root.querySelectorAll("[data-chart-card]")].map((card) => [ + card.querySelector("span")?.textContent ?? "", + styleHeight(card.querySelector("[data-chart-card-body]")), + ]), + gridRows: [...root.querySelectorAll("[data-chart-card-grid]")].map((grid) => + grid instanceof HTMLElement ? grid.style.gridAutoRows : "", + ), + steps: root.querySelector("[data-steps-table]") + ? styleHeight(root.querySelector("[data-steps-table]")) + : null, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-test-helpers.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-test-helpers.ts new file mode 100644 index 00000000000..51d3359473b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-test-helpers.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "@testing-library/react"; + +/** The frame header's element, for tests that read its height and state. */ +export const frameHeader = (): HTMLElement => + document.querySelector("[data-frame-header]")!; + +/** The frame body's scroll container. */ +export const frameBody = (): HTMLElement => + document.querySelector("[data-frame-body]")!; + +/** + * Scrolls the frame body to `top` the way a wheel would: jsdom lays nothing + * out, so the offset is set on the element and its scroll event fired. + */ +export const scrollFrameBody = (top: number): void => { + Object.defineProperty(frameBody(), "scrollTop", { + configurable: true, + value: top, + }); + fireEvent.scroll(frameBody()); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-body-scrolled.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-body-scrolled.ts new file mode 100644 index 00000000000..a2d7ff6eb8c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-body-scrolled.ts @@ -0,0 +1,17 @@ +import { useState, type UIEvent } from "react"; + +/** + * Whether a scroll container has moved off its top. Read from the element's + * scroll events alone: the frame's header condenses on `scrollTop > 0` and + * nothing else, so no observer is needed. + */ +export const useBodyScrolled = (): { + scrolled: boolean; + onScroll: (event: UIEvent) => void; +} => { + const [scrolled, setScrolled] = useState(false); + return { + scrolled, + onScroll: (event) => setScrolled(event.currentTarget.scrollTop > 0), + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-header-engaged.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-header-engaged.ts new file mode 100644 index 00000000000..b89cc2611d6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-header-engaged.ts @@ -0,0 +1,51 @@ +import { type FocusEvent, useState } from "react"; + +/** + * The handlers the header's root element takes so the pointer and the + * keyboard focus can hold it at its full height while the body is scrolled. + */ +export type FrameHeaderEngagement = { + onPointerEnter: () => void; + onPointerLeave: () => void; + onFocus: (event: FocusEvent) => void; + onBlur: () => void; +}; + +/** + * Whether the pointer or the keyboard focus is on the header. The pointer + * is tracked by its enter and leave; focus is remembered as the element + * that took it, because an element disabled or removed while focused fires + * no blur and would otherwise hold the header open for good. `settleFocus`, + * called when the body scrolls, drops a remembered focus that element no + * longer holds. + */ +export const useHeaderEngaged = (): { + engaged: boolean; + engagement: FrameHeaderEngagement; + settleFocus: () => void; +} => { + const [hovered, setHovered] = useState(false); + const [focusedElement, setFocusedElement] = useState(null); + + return { + engaged: hovered || focusedElement !== null, + engagement: { + onPointerEnter: () => setHovered(true), + onPointerLeave: () => setHovered(false), + onFocus: (event) => setFocusedElement(event.target), + onBlur: () => setFocusedElement(null), + }, + settleFocus: () => { + if (focusedElement === null) { + return; + } + const holdsFocus = + focusedElement.isConnected && + document.activeElement === focusedElement && + !focusedElement.matches(":disabled"); + if (!holdsFocus) { + setFocusedElement(null); + } + }, + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-prefers-reduced-motion.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-prefers-reduced-motion.ts new file mode 100644 index 00000000000..343c40e8461 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-prefers-reduced-motion.ts @@ -0,0 +1,19 @@ +import { useSyncExternalStore } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; + +const subscribe = (onChange: () => void): (() => void) => { + if (typeof window.matchMedia !== "function") { + return () => {}; + } + const media = window.matchMedia(QUERY); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +}; + +const getSnapshot = (): boolean => + typeof window.matchMedia === "function" && window.matchMedia(QUERY).matches; + +/** Whether the viewer asked the system for reduced motion; follows the setting live. */ +export const usePrefersReducedMotion = (): boolean => + useSyncExternalStore(subscribe, getSnapshot, () => false); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx index 19b9a573270..22ea35b51cd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx @@ -13,7 +13,12 @@ import { MetricViewMenu, type MetricViewSettings, } from "../experiments/experiment-metric-timeline"; -import { ChartCard, ChartCardGrid, chartCardHeight } from "./chart-card"; +import { + CHART_CARD_MIN_WIDTH, + ChartCard, + ChartCardGrid, + chartCardHeight, +} from "./chart-card"; import type { MonteCarloUserDefinedMetricFrame } from "@hashintel/petrinaut-core"; @@ -30,8 +35,6 @@ export const METRIC_PLOT_HEIGHT = 220; export const METRIC_CARD_HEIGHT = chartCardHeight({ bodyHeight: METRIC_PLOT_HEIGHT, }); -/** Two cards per row in an extra-large drawer; one when the drawer is narrower. */ -const METRIC_CARD_MIN_WIDTH = 360; export const MetricTiles = ({ tiles, @@ -53,7 +56,7 @@ export const MetricTiles = ({ return ( {tiles.map((tile) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/summary-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/summary-strip.tsx deleted file mode 100644 index ee8025383c2..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/summary-strip.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * A drawer summary's strip: stats side by side, each a small uppercase label - * over its value, divided by hairlines, wrapping when the drawer is narrow. - */ -import { css } from "@hashintel/ds-helpers/css"; - -import type { ReactNode } from "react"; - -// Every stat carries its own leading hairline, and the strip shifts left by -// exactly one divider-plus-gap so each row's first divider lands outside the -// clipping wrapper — wrapped rows therefore start flush, not with a floating -// rule (a sibling selector cannot see flex line breaks). -const stripClipStyle = css({ - overflow: "hidden", -}); - -const stripStyle = css({ - display: "flex", - flexWrap: "wrap", - alignItems: "center", - rowGap: "2", - marginLeft: "[-17px]", -}); - -const statStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[1px]", - minWidth: "[0]", - paddingLeft: "4", - marginLeft: "[1px]", - borderLeftWidth: "[1px]", - borderLeftStyle: "solid", - borderLeftColor: "neutral.bd.subtle", - paddingRight: "4", -}); - -const statLabelStyle = css({ - fontSize: "[10px]", - fontWeight: "medium", - letterSpacing: "[0.04em]", - textTransform: "uppercase", - color: "neutral.s70", -}); - -const statValueStyle = css({ - fontSize: "sm", - fontWeight: "medium", - color: "neutral.s120", - fontVariantNumeric: "tabular-nums", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -// Inline-block inside the value span, so a long value still ellipsizes (a -// flex value container turns its text into an item ellipsis cannot reach). -const statusDotStyle = css({ - display: "inline-block", - width: "[7px]", - height: "[7px]", - borderRadius: "full", - marginRight: "1.5", - verticalAlign: "[1px]", - backgroundColor: "neutral.s60", - "&[data-tone=active]": { backgroundColor: "blue.s100" }, - "&[data-tone=done]": { backgroundColor: "green.s90" }, - "&[data-tone=error]": { backgroundColor: "red.s100" }, -}); - -const trailingStyle = css({ - display: "inline-flex", - alignItems: "center", - marginLeft: "auto", - paddingLeft: "4", -}); - -export type SummaryStatusTone = "active" | "done" | "error" | "neutral"; - -export const SummaryStrip = ({ - children, - trailing, -}: { - children: ReactNode; - /** Pinned to the strip's right edge, outside the stats' hairlines. */ - trailing?: ReactNode; -}) => ( -
-
- {children} - {trailing === undefined ? null : ( - {trailing} - )} -
-
-); - -export const SummaryStat = ({ - label, - minChars, - children, -}: { - label: string; - /** Reserve this many characters so a changing value never reflows the strip. */ - minChars?: number; - children: ReactNode; -}) => ( -
- {label} - - {children} - -
-); - -export const SummaryStatusDot = ({ tone }: { tone: SummaryStatusTone }) => ( - -); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/surface-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/surface-frame.tsx index 6248aa95c2c..5060252db86 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/surface-frame.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/surface-frame.tsx @@ -1,7 +1,10 @@ /** * The card both surface views share: the plot in the body, the state line - * (or the drag readout) in the subtitle, and the X/Y axis selects, with - * whatever else the view controls, in the footer. + * (or the drag readout) in the subtitle, and the X/Y axis selects in the + * footer, with whatever else the view controls on a second footer row. The + * footer is a grid of label and select pairs: the selects share the row's + * width, so the footer fits the card's narrowest column without overflowing, + * and a view with further controls reserves its second row at all times. */ import { Select } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; @@ -12,18 +15,22 @@ import type { ReactNode } from "react"; /** The plot's height in pixels inside a surface card. */ export const SURFACE_PLOT_HEIGHT = 280; -/** The footer's content height: an extra-small Select. */ +/** The footer's content height for one row of controls: an extra-small Select. */ export const SURFACE_FOOTER_HEIGHT = 24; +/** The footer's content height for two rows of controls and the gap between them. */ +export const SURFACE_FOOTER_TWO_ROW_HEIGHT = SURFACE_FOOTER_HEIGHT * 2 + 6; +// Two label-and-select pairs per row; each select takes its share of the row +// and ellipsizes a long option name rather than pushing the next label. const controlsStyle = css({ - display: "flex", + display: "grid", + gridTemplateColumns: "[auto minmax(0, 1fr) auto minmax(0, 1fr)]", alignItems: "center", - gap: "2", + columnGap: "2", + rowGap: "[6px]", + width: "full", minWidth: "[0]", - // Compact inline controls; the ds Select otherwise stretches to the row. - "& [data-scope='select']": { width: "[170px]" }, - // The Select's root insists on min-content width, which overflows the - // 170px box over the next label; a long option name fits by ellipsis. + "& [data-scope='select']": { width: "full", minWidth: "[0]" }, "& > div > div": { minWidth: "[0]" }, }); @@ -40,6 +47,7 @@ export const SurfaceFrame = ({ actions, bodyHeight, footer, + footerHeight = SURFACE_FOOTER_HEIGHT, tone, children, }: { @@ -52,6 +60,8 @@ export const SurfaceFrame = ({ bodyHeight?: number; /** The axis selects and whatever else the view controls. */ footer: ReactNode; + /** The footer's content height; two rows when the view adds controls to the axis selects. */ + footerHeight?: number; tone?: ChartCardTone; children: ReactNode; }) => ( @@ -61,7 +71,7 @@ export const SurfaceFrame = ({ actions={actions} bodyHeight={bodyHeight} footer={footer} - footerHeight={SURFACE_FOOTER_HEIGHT} + footerHeight={footerHeight} tone={tone} > {children} @@ -72,7 +82,7 @@ export const SurfaceControlLabel = ({ children }: { children: ReactNode }) => ( {children} ); -/** The X and Y axis selects; `children` adds further controls to the row. */ +/** The X and Y axis selects on one row; `children` adds further controls on the row beneath. */ export const SurfaceAxisControls = ({ axes, xAxisId,