Cap the long lists, and wrap a wide rank on a phone
Two things the first pass got wrong. The Changes and "Given up on" lists had no cap, so they ran as long as the audit trail did — and worse, a truncating flex row still offers its whole unwrapped line as a min-content contribution, so the card sized itself to the longest entry and took the page sideways with it. Both now use the same capped, scrolling box the runs and failures lists already had, which fixes the length and the width together. Running downwards, a rank of eight nodes was two thousand pixels across because a node box is landscape: siblings cost a rank four times as much across as they do down it. A rank wider than two now wraps onto the ranks below, settling over a few passes since wrapping one rank pushes what was under it up into the room that freed. The value chip on an edge no longer reserves its width there either — that width is spent across the screen rather than along the flow, and the rank gap it would widen is already wider than the chip is tall. The same flow that laid out 2040x216 is now 803x722; a chain and a diamond are unchanged, and so is every desktop layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
This commit is contained in:
@@ -21,22 +21,44 @@ const NODE_H = 56
|
||||
* Room for the live value an edge carries (`LiveEdge`'s chip is
|
||||
* `max-w-[140px]`). Reserved on the edge itself, so dagre routes nodes around
|
||||
* the chip rather than through it.
|
||||
*
|
||||
* Only its height is reserved when the graph runs downwards. The chip's width
|
||||
* is then spent across the screen rather than along the flow, and a phone has
|
||||
* none to spare — while the rank gap it would otherwise widen is already 80px,
|
||||
* more than the chip is tall. Sibling edges are a node's width apart there, so
|
||||
* the chips clear each other without being asked to.
|
||||
*/
|
||||
const LABEL_W = 150
|
||||
const LABEL_H = 24
|
||||
|
||||
/**
|
||||
* Lay the graph out and return each node's top-left corner.
|
||||
* How many nodes may sit abreast when the graph runs downwards.
|
||||
*
|
||||
* ponytail: every node is treated as 220×56 rather than measured. Measuring
|
||||
* would feed the result back into the layout and oscillate; if nodes ever grow
|
||||
* past that box, take the sizes from `node.measured` once they have settled.
|
||||
* A node box is landscape, so siblings cost a rank four times as much across
|
||||
* as they do down it: eight of them side by side is two thousand pixels, which
|
||||
* a phone can only show by shrinking the labels out of existence. Two abreast
|
||||
* is 480px — the fit still reads at 390 — and anything beyond that wraps onto
|
||||
* the ranks below, so a wide fan-out grows the way the screen does.
|
||||
*/
|
||||
export function layoutGraph(
|
||||
const ABREAST = 2
|
||||
|
||||
/**
|
||||
* How many times to let the wrapping settle before taking what it has.
|
||||
*
|
||||
* A bound rather than a fixed point: each pass moves nodes strictly downwards,
|
||||
* so it does converge, but a pathological graph should not be allowed to
|
||||
* relayout itself twenty times on a phone.
|
||||
*/
|
||||
const WRAP_PASSES = 6
|
||||
|
||||
type Wrap = [string, string]
|
||||
|
||||
function build(
|
||||
ids: string[],
|
||||
edges: { source: string; target: string }[],
|
||||
direction: Direction,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
wrap: Wrap[],
|
||||
) {
|
||||
const graph = new dagre.graphlib.Graph()
|
||||
graph.setDefaultEdgeLabel(() => ({}))
|
||||
graph.setGraph({
|
||||
@@ -57,13 +79,72 @@ export function layoutGraph(
|
||||
for (const edge of edges) {
|
||||
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue
|
||||
graph.setEdge(edge.source, edge.target, {
|
||||
width: LABEL_W,
|
||||
width: direction === "LR" ? LABEL_W : 0,
|
||||
height: LABEL_H,
|
||||
labelpos: "c",
|
||||
})
|
||||
}
|
||||
// Nothing draws these: they only say "put this one a rank further down".
|
||||
for (const [from, to] of wrap) graph.setEdge(from, to, { weight: 2 })
|
||||
|
||||
dagre.layout(graph)
|
||||
return graph
|
||||
}
|
||||
|
||||
/**
|
||||
* Which nodes have to move down a rank for the graph to stay narrow.
|
||||
*
|
||||
* Two nodes of the same rank never have an edge between them, so chaining the
|
||||
* third to the first — and the fourth to the second, and so on — cannot make a
|
||||
* cycle. It lays the rank out in rows of `ABREAST`, in the order the document
|
||||
* lists them, so the wrap reads the way the flow is written.
|
||||
*/
|
||||
function wrapWideRanks(graph: ReturnType<typeof build>, ids: string[]): Wrap[] {
|
||||
const ranks = new Map<number, string[]>()
|
||||
for (const id of ids) {
|
||||
const node = graph.node(id) as { rank?: number } | undefined
|
||||
if (node?.rank === undefined) continue
|
||||
ranks.set(node.rank, [...(ranks.get(node.rank) ?? []), id])
|
||||
}
|
||||
|
||||
const wrap: Wrap[] = []
|
||||
for (const rank of ranks.values()) {
|
||||
if (rank.length <= ABREAST) continue
|
||||
for (let i = ABREAST; i < rank.length; i += 1) {
|
||||
wrap.push([rank[i - ABREAST], rank[i]])
|
||||
}
|
||||
}
|
||||
return wrap
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the graph out and return each node's top-left corner.
|
||||
*
|
||||
* ponytail: every node is treated as 220×56 rather than measured. Measuring
|
||||
* would feed the result back into the layout and oscillate; if nodes ever grow
|
||||
* past that box, take the sizes from `node.measured` once they have settled.
|
||||
*/
|
||||
export function layoutGraph(
|
||||
ids: string[],
|
||||
edges: { source: string; target: string }[],
|
||||
direction: Direction,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
let graph = build(ids, edges, direction, [])
|
||||
|
||||
// Running downwards, a rank wider than the screen is the one thing the
|
||||
// layout can still do something about. Wrapping one rank pushes whatever was
|
||||
// below it up into the room that freed, which can overfill a rank that was
|
||||
// fine — so it settles rather than being done once. A handful of passes over
|
||||
// a graph of a few dozen nodes, and none at all when nothing is too wide.
|
||||
if (direction === "TB") {
|
||||
const wrap: Wrap[] = []
|
||||
for (let pass = 0; pass < WRAP_PASSES; pass += 1) {
|
||||
const more = wrapWideRanks(graph, ids)
|
||||
if (!more.length) break
|
||||
wrap.push(...more)
|
||||
graph = build(ids, edges, direction, wrap)
|
||||
}
|
||||
}
|
||||
|
||||
// dagre places centres; React Flow wants top-left corners.
|
||||
return new Map(
|
||||
|
||||
@@ -28,6 +28,18 @@ import {
|
||||
const RUNS_SHOWN = 15
|
||||
const FAILURES_SHOWN = 25
|
||||
|
||||
/**
|
||||
* A list of rows in a card: capped and scrolled rather than as long as it
|
||||
* happens to be.
|
||||
*
|
||||
* The cap is the visible reason; the scroll container is the structural one. A
|
||||
* row is `min-w-0 flex-1 truncate`, and a truncating flex item still offers its
|
||||
* whole unwrapped line as a min-content contribution — so without this the card
|
||||
* sizes to the longest entry it holds and takes the page sideways with it. See
|
||||
* DESIGN-GUIDELINES.md → Responsive.
|
||||
*/
|
||||
const LIST = "max-h-96 overflow-y-auto"
|
||||
|
||||
/**
|
||||
* A moment picked off a chart.
|
||||
*
|
||||
@@ -275,9 +287,7 @@ export function HealthActivity({ range }: { range: Range }) {
|
||||
<div
|
||||
className={cn(
|
||||
CARD,
|
||||
// Capped rather than as long as it happens to be: the two lists
|
||||
// sit side by side, and a filtered one is meant to be scrolled.
|
||||
"max-h-96 overflow-y-auto",
|
||||
LIST,
|
||||
runsAt.pinned !== null && "border-primary",
|
||||
)}
|
||||
data-testid="recent-runs"
|
||||
@@ -334,7 +344,7 @@ export function HealthActivity({ range }: { range: Range }) {
|
||||
<div
|
||||
className={cn(
|
||||
CARD,
|
||||
"max-h-96 overflow-y-auto",
|
||||
LIST,
|
||||
failuresAt.pinned !== null && "border-primary",
|
||||
)}
|
||||
data-testid="failures"
|
||||
@@ -359,7 +369,7 @@ export function HealthActivity({ range }: { range: Range }) {
|
||||
{dead?.length ? (
|
||||
<section className="grid gap-3">
|
||||
<h2 className={PANEL_SECTION}>Given up on</h2>
|
||||
<div className={CARD}>
|
||||
<div className={cn(CARD, LIST)}>
|
||||
{dead.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -382,7 +392,7 @@ export function HealthActivity({ range }: { range: Range }) {
|
||||
|
||||
<section className="grid gap-3">
|
||||
<h2 className={PANEL_SECTION}>Changes</h2>
|
||||
<div className={CARD}>
|
||||
<div className={cn(CARD, LIST)}>
|
||||
{audit?.length ? (
|
||||
audit.map((event) => (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user