diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py index cc9314d..a049e01 100644 --- a/backend/app/flow/dashboards.py +++ b/backend/app/flow/dashboards.py @@ -30,6 +30,12 @@ DASHBOARD_DIR = "_dashboards" #: A chart cannot ask for an unbounded series; this is the ceiling. HISTORY_CAP = 5000 +#: How many readings a bar may nest inside its own. The limit is contrast, not +#: layout: the segments share one fill token, because no slot of the chart ramp +#: clears 3:1 against the outer one, and a fourth could not be told from its +#: neighbour. Mirrored in the client (``BarWidget.tsx``). +BAR_SEGMENTS = 3 + #: Resolved out here on purpose: the store has a ``list`` method, which #: shadows the builtin for any annotation written inside the class. Bindings = list[dict[str, Any]] @@ -119,6 +125,23 @@ class WidgetDef(BaseModel): """A chart that asks a flow for its series instead of reading the ring.""" return self.type == "chart" and self.config.get("source") == "query" + @property + def inner_bindings(self) -> Bindings: + """A bar's nested readings, in either shape a document may carry them. + + One binding beside ``inner_dtype``, as a bar was written before it + stacked, or an ordered list of ``{message, dtype}`` — so an older + dashboard keeps drawing without being migrated first. + """ + inner = self.config.get("inner") + if isinstance(inner, list): + return [s for s in inner[:BAR_SEGMENTS] if isinstance(s, dict)] + dtype = self.config.get("inner_dtype") + # A recorded type with nothing bound is still a type to be held to. + if inner or dtype: + return [{"message": inner or "", "dtype": dtype}] + return [] + @property def messages(self) -> list[str]: """Every message name this widget reads.""" @@ -132,8 +155,9 @@ class WidgetDef(BaseModel): if series.get("message") ] name = self.config.get("message") - inner = self.config.get("inner") # only a bar nests a second reading - return [str(value) for value in (name, inner) if value] + # Only a bar nests further readings inside the one it draws. + nested = [s.get("message") for s in self.inner_bindings] + return [str(value) for value in (name, *nested) if value] @property def target(self) -> str: @@ -170,7 +194,10 @@ class WidgetDef(BaseModel): str(series.get("dtype") or "") for series in self.config.get("series") or [] ] - return [str(self.config.get(key) or "") for key in ("dtype", "inner_dtype")] + return [ + str(self.config.get("dtype") or ""), + *(str(s.get("dtype") or "") for s in self.inner_bindings), + ] @model_validator(mode="after") def _check_binding(self) -> WidgetDef: @@ -184,6 +211,10 @@ class WidgetDef(BaseModel): ) return self + inner = self.config.get("inner") + if isinstance(inner, list) and len(inner) > BAR_SEGMENTS: + raise ValueError(f"a bar nests at most {BAR_SEGMENTS} readings") + allowed = WIDGET_DTYPES.get(self.type) if not allowed: return self @@ -510,6 +541,7 @@ def default_dashboard(name: str) -> DashboardDef: __all__ = [ + "BAR_SEGMENTS", "DASHBOARD_DIR", "HISTORY_CAP", "INPUT_WIDGETS", diff --git a/frontend/src/components/Common/RangePicker.tsx b/frontend/src/components/Common/RangePicker.tsx index 8985f28..8b90a7a 100644 --- a/frontend/src/components/Common/RangePicker.tsx +++ b/frontend/src/components/Common/RangePicker.tsx @@ -1,3 +1,7 @@ +// The segmented shape's thumb transition lives beside the dashboard's own +// widgets, and CSS is chunked per entry — so the rule is pulled in wherever +// this picker is used, or the two copies of one shape would move differently. +import "@/components/Dashboard/dashboard.css" import { cn } from "@/lib/utils" /** @@ -48,22 +52,41 @@ export function RangePicker({ value: Range onChange: (range: Range) => void }) { + const chosen = RANGES.findIndex((range) => range.hours === value.hours) return ( + // A `fieldset` carries `min-inline-size: min-content` from the UA sheet, + // which no width utility overrides. Equal tracks and no gap put the + // sliding thumb at its share of the padded box without measuring — a grid + // rather than a flex row because `flex-1` under `w-fit` sizes the segments + // to a share of the widest label instead of to the label itself.