diff --git a/NOTEPAD.md b/NOTEPAD.md index 5bb0b1a..e49a884 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -18,21 +18,19 @@ should reopen it. - INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian - INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure - BUG/UI mobile friendly support is degraded: 1) toolbar in the "Flows" viewport extend mobile viewport width 2) position of nodes should never be static; always adjust such that there are as few as possible overlaps (of nodes and edge labels) and direction is left to right (desktop) or top to bottom (mobile) with a minimal (but clean) overall edge length 3) Dashboard view is not mobile friendly at all; as dashboard design is infeasible on mobile, render all widgets in a vertically stacked order. This allows to inspect each widget and make changes. Layout changes are not a feature on mobile 4) the home view is not responsive; all items shown there should re-order on mobile such that no scrollbars appear. Make sure the mobile support is anchored in the design such that future work does not break it -- BUG/UI the codeberg icon should be replaced by a gitea icon - FEAT/UX mirror the "publish" system from the flow editor to the dashboard; i.e. dashboards are always editable (edit mode) when not in the "read-only wallpanel" mode. Changes are made effective by clicking "Publish" in the toolbar (identical to flows). - BUG/UI replace the search bar in flows/dashboard by a search icon which opens the search bar upon click. Also the create button should become a "+" icon button; both right aligned - FEAT/UI introduce an "Publish all" (checkmark icon button) to the flow and dashboard overview which publishs all changes without having to access the individual flows -- BUG/UI add a bit more vertical spacing between elements in the (node) panels - FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that - FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain) -- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a chat side panel) to explain the error(s) -- FEAT/UI trend graphs (in the home view table, but also nodes/edges view) should fade out towards the left end -- BUG/UI unify the trend graph view; adopt the style we use in the panels/ edges into the the trend graphs in the table of the home view -- BUG/UI ensure the content of the table (flow activity) in the home view uses the full width (e.g. we could right align the trend graphs, center the colums in the middle and left align the name of the flow) -- FEAT/UI introduce si unit style shortening of large values (like k, M, ..) globally +- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms" +- CHORE/UI `biome check ./src` reports an ineffective suppression at `FlowEditor.tsx:473` (`useExhaustiveDependencies` no longer fires there) - FEAT/UI text labels in the brain graph view should only show upon hovering. Edges/nodes which fire seldomly should dim out over time (up to a lower limit). Get inspiration on how "Obsidian" visualizes the graph view. Also make sure nodes shapes are visible on both light and dark themes (consider using fill color instead of shadows) -> web-search / research about visualization of large graphs and ensure high quality visual design - BUG/UI make sure the edges point towards the center of the nodes in the brain graph view (currently some of them seem a bit off-center) +- FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound +- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s) + ### Connector write paths Needs someone watching the real hardware, so it is not a background task. This diff --git a/frontend/src/components/Common/Sparkline.tsx b/frontend/src/components/Common/Sparkline.tsx new file mode 100644 index 0000000..7be9b9d --- /dev/null +++ b/frontend/src/components/Common/Sparkline.tsx @@ -0,0 +1,190 @@ +import { useId } from "react" + +import type { HistoryPoint } from "@/client" +import { cn, si } from "@/lib/utils" + +/** Room above and below the curve for the stroke, in viewBox units. */ +const PAD = 8 + +/** Above this ratio a linear series is all baseline and one spike. */ +const LOG_RATIO = 100 + +/** How far the left end fades over, in viewBox units. */ +const FADE = 40 + +/** Enough digits to tell two neighbouring readings apart. */ +const READOUT_DIGITS = 4 + +/** + * Curve and area for a series, in a 0–100 box. + * + * The awkward series are the point: one reading has no line to draw, a series + * that never moved has no span to divide by, and one spanning decades is only + * legible once the exponent is what varies. + */ +function shape(points: HistoryPoint[]) { + const values = points.map((point) => point.value) + const low = Math.min(...values) + const high = Math.max(...values) + // Logs need every reading on the same side of zero. + const logged = low > 0 && high / low >= LOG_RATIO + const project = (value: number) => (logged ? Math.log10(value) : value) + const floor = project(low) + const span = project(high) - floor + + const y = (value: number) => + span === 0 + ? 50 + : 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD) + const x = (index: number) => + points.length === 1 ? 100 : (index / (points.length - 1)) * 100 + + const line = points + .map( + (point, index) => + `${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`, + ) + .join(" ") + + return { + low, + high, + line, + area: `${line} L100,100 L0,100 Z`, + end: y(values[values.length - 1]), + } +} + +/** + * How a series has been moving — the one trend curve, drawn the same way in + * the node panel, on an edge and in the flow table. + * + * The left end fades out, so the oldest readings read as the tail they are + * rather than as an edge the series was cut at. The fade masks the curve and + * its area together; the live dot sits outside the mask, since the newest + * reading is the one thing that must stay solid. + * + * Needs at least one point: an empty series has no story, and what to say + * instead is the caller's to decide. + */ +export function Sparkline({ + points, + color = "var(--primary)", + height = "h-8", + dot = true, + readout = true, +}: { + points: HistoryPoint[] + /** The token the curve, its area and the dot are drawn in. */ + color?: string + /** Tailwind height of the curve's box. */ + height?: string + /** A dot on the newest reading; only honest where the series is live. */ + dot?: boolean + /** The current value and the range, beside the curve. */ + readout?: boolean +}) { + const id = useId() + const { low, high, line, area, end } = shape(points) + + return ( + // The gap leaves the live dot room to sit on the last reading without + // touching the labels. +
+
+ + {/* The newest reading is always the right edge, so the dot only needs to + know how high it sits. */} + {dot ? ( + <> + + + + ) : null} +
+ {/* A shared minimum width, so the curves all end on the same line. */} + {readout ? ( +
+
+ {si(points[points.length - 1].value, READOUT_DIGITS)} +
+ {/* A series that never moved has no range worth repeating. */} + {low === high ? null : ( +
+ {si(low, READOUT_DIGITS)}–{si(high, READOUT_DIGITS)} +
+ )} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index e50fba9..77c0071 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -4,7 +4,7 @@ import "uplot/dist/uPlot.min.css" import type { HistoryPoint } from "@/client" import { useTheme } from "@/components/theme-provider" -import { compact } from "@/lib/utils" +import { si } from "@/lib/utils" /** * How many lines one chart carries. @@ -29,17 +29,6 @@ function token(name: string): string { const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`) -/** - * An axis tick, kept short. - * - * The gutter the ticks are drawn in has a fixed width, so a grouped "15,000" - * is clipped to something that reads as a different number entirely. - */ -const tick = (value: number) => - Math.abs(value) >= 1000 - ? `${+(value / 1000).toPrecision(3)}k` - : compact(value) - /** The series joined onto one x axis, which is what uPlot draws. */ function table(plots: HistoryPoint[][]): uPlot.AlignedData { return uPlot.join( @@ -156,7 +145,10 @@ export function UplotChart({ { ...axis, size: 46, - values: (_self: uPlot, ticks: number[]) => ticks.map(tick), + // The gutter has a fixed width, so a grouped "15,000" would be + // clipped to something that reads as a different number entirely. + values: (_self: uPlot, ticks: number[]) => + ticks.map((value) => si(value)), }, ], series: [ @@ -168,9 +160,8 @@ export function UplotChart({ // rebuilt chart. stroke: () => seriesColor(index), // The cursor readout is what decides how wide the legend gets, so - // it is rounded here and the unit named in the card's title. - value: (_self: uPlot, raw: number) => - Number.isFinite(raw) ? compact(raw) : "--", + // it is shortened here and the unit named in the card's title. + value: (_self: uPlot, raw: number) => si(raw), // Series arrive on their own clocks; a joined table is mostly // holes, and a line with a hole per point is not a line. spanGaps: true, diff --git a/frontend/src/components/Flow/FlowPanel.tsx b/frontend/src/components/Flow/FlowPanel.tsx index df3fa2f..c638313 100644 --- a/frontend/src/components/Flow/FlowPanel.tsx +++ b/frontend/src/components/Flow/FlowPanel.tsx @@ -75,8 +75,8 @@ export function FlowPanel({ } > -
-
+
+
Running

@@ -94,7 +94,7 @@ export function FlowPanel({

-
+
Contents

{definition.name} namespaces @@ -106,7 +106,7 @@ export function FlowPanel({

{hasDraft ? ( -
+
Unpublished changes

The engine is still running the last published version of this diff --git a/frontend/src/components/Flow/MessageSparkline.tsx b/frontend/src/components/Flow/MessageSparkline.tsx index 6e971df..263ad8d 100644 --- a/frontend/src/components/Flow/MessageSparkline.tsx +++ b/frontend/src/components/Flow/MessageSparkline.tsx @@ -1,7 +1,8 @@ import { useQuery } from "@tanstack/react-query" -import { useEffect, useId, useState } from "react" +import { useEffect, useState } from "react" import type { HistoryPoint } from "@/client" +import { Sparkline } from "@/components/Common/Sparkline" import { qualify } from "./deriveEdges" import { useLiveValue } from "./liveStore" import { messageHistoryQueryOptions } from "./queries" @@ -9,20 +10,6 @@ import { messageHistoryQueryOptions } from "./queries" /** The same bound the server keeps, so the live tail cannot outgrow the window. */ const WINDOW = 120 -/** Room above and below the curve for the stroke, in viewBox units. */ -const PAD = 8 - -/** Above this ratio a linear series is all baseline and one spike. */ -const LOG_RATIO = 100 - -/** Short enough for a label, precise enough to tell two of them apart. */ -function compact(value: number): string { - const size = Math.abs(value) - if (size === 0) return "0" - if (size >= 1e6 || size < 1e-2) return value.toExponential(1) - return String(Number(value.toPrecision(4))) -} - /** How a value that cannot be plotted still reads. */ function describe(value: unknown): string { if (typeof value === "string") return value @@ -42,46 +29,6 @@ function useSettled(value: string): string { return settled } -/** - * Curve and area for a series, in a 0–100 box. - * - * The awkward series are the point: one reading has no line to draw, a series - * that never moved has no span to divide by, and one spanning decades is only - * legible once the exponent is what varies. - */ -export function shape(points: HistoryPoint[]) { - const values = points.map((point) => point.value) - const low = Math.min(...values) - const high = Math.max(...values) - // Logs need every reading on the same side of zero. - const logged = low > 0 && high / low >= LOG_RATIO - const project = (value: number) => (logged ? Math.log10(value) : value) - const floor = project(low) - const span = project(high) - floor - - const y = (value: number) => - span === 0 - ? 50 - : 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD) - const x = (index: number) => - points.length === 1 ? 100 : (index / (points.length - 1)) * 100 - - const line = points - .map( - (point, index) => - `${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`, - ) - .join(" ") - - return { - low, - high, - line, - area: `${line} L100,100 L0,100 Z`, - end: y(values[values.length - 1]), - } -} - /** * How one message has been moving, as a sparkline with a live end. * @@ -100,7 +47,6 @@ export function MessageSparkline({ const live = useLiveValue(qualify(flow, message)) const { data } = useQuery(messageHistoryQueryOptions(flow, message)) const [tail, setTail] = useState([]) - const gradient = useId() // Pointing the field at another message makes the collected tail meaningless. // biome-ignore lint/correctness/useExhaustiveDependencies: the name is what invalidates the tail, not anything the effect reads. @@ -147,64 +93,6 @@ export function MessageSparkline({ ) } - const { low, high, line, area, end } = shape(points) - - return ( - // The gap leaves the live dot room to sit on the last reading without - // touching the labels. -

-
- - {/* The newest reading is always the right edge, so the dot only needs to - know how high it sits. */} - - -
- {/* A shared minimum width, so the curves all end on the same line. */} -
-
- {compact(points[points.length - 1].value)} -
- {/* A series that never moved has no range worth repeating. */} - {low === high ? null : ( -
- {compact(low)}–{compact(high)} -
- )} -
-
- ) + // The value is live here, so the dot on the newest reading is earned. + return } diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 7876a4a..ee424bb 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -223,7 +223,7 @@ function PortList({ } return ( -
+
{title}