diff --git a/AGENTS.md b/AGENTS.md index 3e344b7..cee8ffc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,20 @@ are no component-level AGENTS.md files. measurements of the same kind are not two directions of one flow. A group that declares a direction must declare its opposite too, checked against the real registry in `MonitorSourcesTests`. +- **A metric declares whether it is a slice of a whole.** + `MetricDescriptor.composition` is `.part`, `.aggregate` or nil. Memory (app, + wired, compressed, cached, free) and CPU (user, system) are slices; Memory + Used and CPU Total are the sums of them; Memory Swap is neither, being on + disk. **An aggregate must never be stacked** — Used is app plus wired plus + compressed, so a band for it counts those three twice and puts the top of the + card at nearly twice the machine's RAM. It keeps a line, which lands on top of + the bands it sums. Lines on a stacked card are **dashed**, with a hollow + legend swatch, because a solid stroke among bands reads as one more slice. A + card stacks only when it draws two or more slices, and its y-axis is bounded + by the **summed** height rather than the tallest band. `MonitorSourcesTests` + holds the claim against the real machine: the slices account for 85–102% of + physical RAM, so they do not overlap. Nothing is both a slice and a direction, + and a registry test proves it. - **Paired charts can be mirrored, and only the picture flips.** Inbound draws above the baseline and outbound below it when **Mirror paired charts** is on in the Charts tab. The stored sample stays positive — a rate is never diff --git a/Sources/MonitorCore/ChartPreferences.swift b/Sources/MonitorCore/ChartPreferences.swift index 4a46232..e770276 100644 --- a/Sources/MonitorCore/ChartPreferences.swift +++ b/Sources/MonitorCore/ChartPreferences.swift @@ -45,6 +45,28 @@ public enum ChartMirror { } } +/// Which cards are a whole divided into slices. +/// +/// No table here either. A metric declares its own `composition`, so a card can +/// be stacked when it draws two or more slices — and a source added later gets +/// it without this file being told it exists. +public enum ChartStack { + /// The slices a card draws, in the order it draws them, or empty when it + /// has nothing to stack. + /// + /// **Two or more.** A single band is an area chart with extra steps, and it + /// says nothing a line does not. + /// + /// Everything else on the card keeps its line — an aggregate especially. + /// Memory Used is app plus wired plus compressed, so stacking it would + /// count those three twice; drawn as a line it lands exactly on top of the + /// bands it sums, which reads as a check rather than a contradiction. + public static func parts(of descriptors: [MetricDescriptor]) -> [MetricID] { + let parts = descriptors.filter { $0.composition == .part } + return parts.count >= 2 ? parts.map(\.id) : [] + } +} + /// How the charts are drawn, as opposed to which ones there are. /// /// Its own type beside `LayoutPreferences` rather than a field on it, because @@ -60,8 +82,15 @@ public struct ChartPreferences: Codable, Equatable, Sendable { /// they have to switch on. public var mirrorsPairs: Bool - public init(mirrorsPairs: Bool = false) { + /// Draw a card's slices as stacked bands rather than as lines from zero. + /// + /// Off by default, for the same reason mirroring is: a chart that changes + /// shape under somebody on upgrade is worse than one they switch on. + public var stacksParts: Bool + + public init(mirrorsPairs: Bool = false, stacksParts: Bool = false) { self.mirrorsPairs = mirrorsPairs + self.stacksParts = stacksParts } public static let `default` = ChartPreferences() @@ -73,15 +102,27 @@ public struct ChartPreferences: Codable, Equatable, Sendable { return ChartMirror.pair(for: descriptors) } + /// The slices this card should stack, given the setting: empty when + /// stacking is off, and empty when the card has nothing to stack. + public func stack(for descriptors: [MetricDescriptor]) -> [MetricID] { + guard stacksParts else { return [] } + return ChartStack.parts(of: descriptors) + } + private enum CodingKeys: String, CodingKey { case mirrorsPairs + case stacksParts } /// `decodeIfPresent`, so a value written before a setting existed still /// decodes rather than resetting the whole struct to its defaults. public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let stored = try container.decodeIfPresent(Bool.self, forKey: .mirrorsPairs) - self.init(mirrorsPairs: stored ?? ChartPreferences.default.mirrorsPairs) + let mirrors = try container.decodeIfPresent(Bool.self, forKey: .mirrorsPairs) + let stacks = try container.decodeIfPresent(Bool.self, forKey: .stacksParts) + self.init( + mirrorsPairs: mirrors ?? ChartPreferences.default.mirrorsPairs, + stacksParts: stacks ?? ChartPreferences.default.stacksParts + ) } } diff --git a/Sources/MonitorCore/Metric.swift b/Sources/MonitorCore/Metric.swift index 49e3907..a58527c 100644 --- a/Sources/MonitorCore/Metric.swift +++ b/Sources/MonitorCore/Metric.swift @@ -63,6 +63,27 @@ public enum MetricDirection: String, Sendable, Codable { case outbound } +/// How a metric relates to the others in its group. +/// +/// Most have no relation: two temperature sensors are two readings, and neither +/// is a slice or a sum of the other. Some groups are a whole divided up — +/// memory is app, wired, compressed, cached and free — and there stacking the +/// slices shows the total as well as the split, which lines drawn from zero +/// cannot. +/// +/// A fact about the metric, like `direction`. Whether the parts are then +/// *drawn* stacked is a preference in `ChartPreferences`. +public enum MetricComposition: String, Sendable, Codable { + /// One slice of the group's whole. Slices do not overlap, so they can be + /// stacked. + case part + /// A sum of other metrics in the same group: memory Used is app plus wired + /// plus compressed, CPU Total is user plus system. **Never stacked** — it + /// would count its own parts a second time. Drawn as a line over the bands, + /// where it lands exactly on top of the slices it sums. + case aggregate +} + /// Everything the UI needs to draw a series without knowing where it came from. public struct MetricDescriptor: Hashable, Sendable, Codable { public let id: MetricID @@ -78,6 +99,9 @@ public struct MetricDescriptor: Hashable, Sendable, Codable { /// Which way this one runs, when it is one direction of a flow. Nil for /// everything that is not. public let direction: MetricDirection? + /// Whether this one is a slice of its group's whole, or a sum of those + /// slices. Nil for the metrics that are neither. + public let composition: MetricComposition? public init( id: MetricID, @@ -86,7 +110,8 @@ public struct MetricDescriptor: Hashable, Sendable, Codable { unit: MetricUnit, kind: MetricKind = .gauge, nominalMaximum: Double? = nil, - direction: MetricDirection? = nil + direction: MetricDirection? = nil, + composition: MetricComposition? = nil ) { self.id = id self.name = name @@ -95,5 +120,6 @@ public struct MetricDescriptor: Hashable, Sendable, Codable { self.kind = kind self.nominalMaximum = nominalMaximum self.direction = direction + self.composition = composition } } diff --git a/Sources/MonitorSources/CPUSource.swift b/Sources/MonitorSources/CPUSource.swift index 9dcfb2a..8ba1d6d 100644 --- a/Sources/MonitorSources/CPUSource.swift +++ b/Sources/MonitorSources/CPUSource.swift @@ -51,14 +51,15 @@ public final class CPUSource: MetricSource, @unchecked Sendable { var result = [ MetricDescriptor( id: Self.total, name: "Total", group: "CPU", unit: .fraction, - nominalMaximum: 1 + nominalMaximum: 1, composition: .aggregate ), MetricDescriptor( - id: Self.user, name: "User", group: "CPU", unit: .fraction, nominalMaximum: 1 + id: Self.user, name: "User", group: "CPU", unit: .fraction, nominalMaximum: 1, + composition: .part ), MetricDescriptor( id: Self.system, name: "System", group: "CPU", unit: .fraction, - nominalMaximum: 1 + nominalMaximum: 1, composition: .part ), ] for cluster in clusters { diff --git a/Sources/MonitorSources/MemorySource.swift b/Sources/MonitorSources/MemorySource.swift index f4dc7d2..c9659b1 100644 --- a/Sources/MonitorSources/MemorySource.swift +++ b/Sources/MonitorSources/MemorySource.swift @@ -34,27 +34,27 @@ public final class MemorySource: MetricSource, @unchecked Sendable { return [ MetricDescriptor( id: Self.used, name: "Used", group: "Memory", unit: .bytes, - nominalMaximum: ceiling + nominalMaximum: ceiling, composition: .aggregate ), MetricDescriptor( id: Self.app, name: "App", group: "Memory", unit: .bytes, - nominalMaximum: ceiling + nominalMaximum: ceiling, composition: .part ), MetricDescriptor( id: Self.wired, name: "Wired", group: "Memory", unit: .bytes, - nominalMaximum: ceiling + nominalMaximum: ceiling, composition: .part ), MetricDescriptor( id: Self.compressed, name: "Compressed", group: "Memory", unit: .bytes, - nominalMaximum: ceiling + nominalMaximum: ceiling, composition: .part ), MetricDescriptor( id: Self.cached, name: "Cached", group: "Memory", unit: .bytes, - nominalMaximum: ceiling + nominalMaximum: ceiling, composition: .part ), MetricDescriptor( id: Self.free, name: "Free", group: "Memory", unit: .bytes, - nominalMaximum: ceiling + nominalMaximum: ceiling, composition: .part ), MetricDescriptor(id: Self.swapUsed, name: "Swap", group: "Memory", unit: .bytes), MetricDescriptor( diff --git a/Sources/MonitorUI/ChartCard.swift b/Sources/MonitorUI/ChartCard.swift index 1b7cf07..a271b45 100644 --- a/Sources/MonitorUI/ChartCard.swift +++ b/Sources/MonitorUI/ChartCard.swift @@ -23,6 +23,12 @@ public struct ChartCard: View { /// Passed in rather than worked out here, because whether to mirror is a /// preference and a card does not read preferences. public var mirror: MetricPair? + /// The series to draw as stacked bands rather than as lines. Empty is the + /// ordinary card. + /// + /// Passed in rather than worked out here, for the same reason `mirror` is: + /// whether to stack is a preference, and a card does not read preferences. + public var stacked: [MetricID] = [] public init( title: String, @@ -30,7 +36,8 @@ public struct ChartCard: View { window: TimeInterval, isUnavailable: Bool = false, plotHeight: Double = Theme.Layout.chartMinHeight, - mirror: MetricPair? = nil + mirror: MetricPair? = nil, + stacked: [MetricID] = [] ) { self.title = title self.series = series @@ -38,6 +45,10 @@ public struct ChartCard: View { self.isUnavailable = isUnavailable self.plotHeight = plotHeight self.mirror = mirror + // A card is one or the other. Two directions of a flow are not slices + // of a whole, so nothing declares both — but drawing bands below a + // baseline would be nonsense, so it cannot happen by accident either. + self.stacked = mirror == nil ? stacked : [] } public var body: some View { @@ -131,9 +142,16 @@ public struct ChartCard: View { _ descriptor: MetricDescriptor, index: Int, latest: Sample ) -> some View { HStack(spacing: 4) { - Circle() - .fill(Theme.seriesColor(index)) - .frame(width: 7, height: 7) + // Filled for a band, hollow for a line — the same distinction the + // chart makes, so the key does not have to be guessed at. + Group { + if stacked.isEmpty || isBand(descriptor) { + Circle().fill(Theme.seriesColor(index)) + } else { + Circle().strokeBorder(Theme.seriesColor(index), lineWidth: 1.5) + } + } + .frame(width: 7, height: 7) Text(descriptor.name) .foregroundStyle(Theme.label) .lineLimit(1) @@ -197,12 +215,26 @@ public struct ChartCard: View { } ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in ForEach(entry.points, id: \.timestamp) { sample in - LineMark( - x: .value("Time", Date(timeIntervalSince1970: sample.timestamp)), - y: .value("Value", plotted(sample.value, of: entry.descriptor)) + let x = PlottableValue.value( + "Time", Date(timeIntervalSince1970: sample.timestamp) + ) + let y = PlottableValue.value( + "Value", plotted(sample.value, of: entry.descriptor) ) - .foregroundStyle(Theme.seriesColor(index)) - .interpolationMethod(.monotone) + // Bands for the slices, lines for everything else on the + // card. Swift Charts stacks area marks that share an x and + // differ by series, and leaves the line marks alone, so the + // two kinds sit on one chart without arguing. + if stacked.contains(entry.descriptor.id) { + AreaMark(x: x, y: y) + .foregroundStyle(Theme.seriesColor(index).opacity(0.85)) + .interpolationMethod(.monotone) + } else { + LineMark(x: x, y: y) + .foregroundStyle(Theme.seriesColor(index)) + .interpolationMethod(.monotone) + .lineStyle(lineStyle) + } } .foregroundStyle(by: .value("Series", entry.descriptor.name)) } @@ -330,7 +362,44 @@ public struct ChartCard: View { // Scaled to what is on screen, not to the whole buffer. Otherwise a // spike eight minutes off the left of a two-minute window flattens // everything you can actually see. - let peak = visible.flatMap { $0.points.map(\.value) }.max() ?? 1 + // + // A stack is measured by its total height, not by its tallest band. Its + // top is what the eye reads, and scaling to one band would push the + // stack out through the top of the card. + let peak = max( + visible.flatMap { $0.points.map(\.value) }.max() ?? 1, stackedPeak ?? 0 + ) return max(peak * 1.15, .leastNonzeroMagnitude) } + + /// Solid on an ordinary card, dashed on a stacked one. + /// + /// A line among bands is a different kind of statement and has to look like + /// one. Memory Used is a *sum* of three of the bands under it and Swap is + /// not in the machine's RAM at all, so drawing them in the same solid + /// stroke the card uses elsewhere invites reading them as one more slice. + private var lineStyle: StrokeStyle { + stacked.isEmpty + ? StrokeStyle(lineWidth: 2) + : StrokeStyle(lineWidth: 2, dash: [4, 3]) + } + + /// Whether this series is drawn as a band. The legend asks so its swatch + /// can say the same thing the chart does. + private func isBand(_ descriptor: MetricDescriptor) -> Bool { + stacked.contains(descriptor.id) + } + + /// The tallest the stack gets inside the window: the parts summed per + /// timestamp, then the largest of those sums. + private var stackedPeak: Double? { + guard !stacked.isEmpty else { return nil } + var totals: [TimeInterval: Double] = [:] + for entry in visible where stacked.contains(entry.descriptor.id) { + for sample in entry.points { + totals[sample.timestamp, default: 0] += sample.value + } + } + return totals.values.max() + } } diff --git a/Sources/MonitorUI/DashboardView.swift b/Sources/MonitorUI/DashboardView.swift index d2c98b4..8b3ab19 100644 --- a/Sources/MonitorUI/DashboardView.swift +++ b/Sources/MonitorUI/DashboardView.swift @@ -292,7 +292,8 @@ public struct DashboardView: View { // The metrics the card is *drawing*, not the group's whole // membership: switch one direction off and the card stops // being a pair. - mirror: model.charts.mirror(for: series.map(\.descriptor)) + mirror: model.charts.mirror(for: series.map(\.descriptor)), + stacked: model.charts.stack(for: series.map(\.descriptor)) ) card .copyable { diff --git a/Sources/MonitorUI/PreferencesView.swift b/Sources/MonitorUI/PreferencesView.swift index 4ed537e..bea8af1 100644 --- a/Sources/MonitorUI/PreferencesView.swift +++ b/Sources/MonitorUI/PreferencesView.swift @@ -189,6 +189,23 @@ struct ChartsTab: View { var body: some View { Form { + Section { + Toggle("Stack the parts of a whole", isOn: $model.charts.stacksParts) + } footer: { + Text( + "Memory is app, wired, compressed, cached and free — five " + + "slices of one machine's RAM — and CPU is user plus " + + "system. Stacked, the bands show the split and the " + + "total at once, which lines all climbing from zero " + + "cannot. A total such as Memory Used keeps its line " + + "and lands on top of the bands it sums, because " + + "stacking it would count them twice." + ) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Section { Toggle("Mirror paired charts", isOn: $model.charts.mirrorsPairs) } footer: { diff --git a/Tests/MonitorCoreTests/ChartPreferencesTests.swift b/Tests/MonitorCoreTests/ChartPreferencesTests.swift index ab8d7d8..e2525be 100644 --- a/Tests/MonitorCoreTests/ChartPreferencesTests.swift +++ b/Tests/MonitorCoreTests/ChartPreferencesTests.swift @@ -3,14 +3,22 @@ import Foundation import Testing private func metric( - _ id: String, _ name: String, group: String, direction: MetricDirection? = nil + _ id: String, _ name: String, group: String, + direction: MetricDirection? = nil, + composition: MetricComposition? = nil ) -> MetricDescriptor { MetricDescriptor( id: MetricID(id), name: name, group: group, unit: .bitsPerSecond, - direction: direction + direction: direction, composition: composition ) } +private let app = metric("memory.app", "App", group: "Memory", composition: .part) +private let wired = metric("memory.wired", "Wired", group: "Memory", composition: .part) +private let free = metric("memory.free", "Free", group: "Memory", composition: .part) +private let used = metric("memory.used", "Used", group: "Memory", composition: .aggregate) +private let swap = metric("memory.swap.used", "Swap", group: "Memory") + private let netIn = metric("net.bits.in", "In", group: "Network", direction: .inbound) private let netOut = metric("net.bits.out", "Out", group: "Network", direction: .outbound) private let diskRead = metric("disk.bytes.read", "Read", group: "Disk", direction: .inbound) @@ -77,12 +85,76 @@ struct ChartMirrorTests { } } +@Suite("ChartStack") +struct ChartStackTests { + @Test("The slices of a whole stack") + func stacksParts() { + #expect(ChartStack.parts(of: [app, wired, free]).count == 3) + } + + @Test("A sum of the slices never stacks") + func aggregateStaysOut() { + // Memory Used is app plus wired plus compressed. Stacked, it would + // count those three a second time and put the top of the card at nearly + // twice the RAM in the machine. + let stack = ChartStack.parts(of: [app, wired, free, used]) + #expect(!stack.contains(used.id)) + #expect(stack.count == 3) + } + + @Test("A metric that is neither never stacks") + func unrelatedStaysOut() { + // Swap is on disk. It shares the card, and it is not a slice of the + // machine's RAM. + #expect(!ChartStack.parts(of: [app, wired, swap]).contains(swap.id)) + } + + @Test("One slice on its own is not a stack") + func singlePart() { + // A single band is an area chart with extra steps. + #expect(ChartStack.parts(of: [app]).isEmpty) + #expect(ChartStack.parts(of: [app, used, swap]).isEmpty) + #expect(ChartStack.parts(of: []).isEmpty) + } + + @Test("The slices keep the order the card draws them in") + func keepsOrder() { + // The stack is drawn bottom-up in this order, and the order is the + // reader's own layout order. + #expect(ChartStack.parts(of: [free, app, wired]) == [free.id, app.id, wired.id]) + } + + @Test("A card cannot be both a stack and a pair") + func stacksAndPairsAreExclusive() { + // Two directions of a flow are not slices of a whole. Nothing declares + // both, and a band drawn below a baseline would be nonsense. + #expect(ChartStack.parts(of: [netIn, netOut]).isEmpty) + #expect(ChartMirror.pair(for: [app, wired]) == nil) + } +} + @Suite("ChartPreferences") struct ChartPreferencesTests { - @Test("Mirroring is off until it is switched on") + @Test("Both settings are off until they are switched on") func offByDefault() { #expect(ChartPreferences.default.mirrorsPairs == false) + #expect(ChartPreferences.default.stacksParts == false) #expect(ChartPreferences.default.mirror(for: [netIn, netOut]) == nil) + #expect(ChartPreferences.default.stack(for: [app, wired, free]).isEmpty) + } + + @Test("Switched on, a card of slices stacks and everything else does not") + func onlyPartsStack() { + let preferences = ChartPreferences(stacksParts: true) + #expect(preferences.stack(for: [app, wired, free]).count == 3) + #expect(preferences.stack(for: [netIn, netOut]).isEmpty) + } + + @Test("The two settings are independent") + func settingsAreIndependent() { + let stacking = ChartPreferences(mirrorsPairs: false, stacksParts: true) + #expect(stacking.mirror(for: [netIn, netOut]) == nil) + #expect(!stacking.stack(for: [app, wired]).isEmpty) } @Test("Switched on, a pair mirrors and everything else does not") @@ -94,7 +166,7 @@ struct ChartPreferencesTests { @Test("Round-trips through Codable") func codable() throws { - let preferences = ChartPreferences(mirrorsPairs: true) + let preferences = ChartPreferences(mirrorsPairs: true, stacksParts: true) let data = try JSONEncoder().encode(preferences) #expect(try JSONDecoder().decode(ChartPreferences.self, from: data) == preferences) } diff --git a/Tests/MonitorSourcesTests/SourceTests.swift b/Tests/MonitorSourcesTests/SourceTests.swift index 9338c94..38cbca6 100644 --- a/Tests/MonitorSourcesTests/SourceTests.swift +++ b/Tests/MonitorSourcesTests/SourceTests.swift @@ -98,6 +98,72 @@ struct SourceTests { #expect(value <= Double(source.physicalMemory)) } + @Test("the memory slices account for the machine's RAM, and Used does not") + func memoryPartsSumToTheWhole() throws { + // The claim stacking rests on. If the slices overlapped, the stack + // would climb past the top of the card, and if Used were one of them it + // would count app, wired and compressed a second time. + let source = MemorySource() + let batch = try source.read(at: 0) + let values = Dictionary( + batch.samples.map { ($0.metric, $0.value) }, uniquingKeysWith: { first, _ in first } + ) + let parts = source.descriptors + .filter { $0.composition == .part } + .compactMap { values[$0.id] } + #expect(parts.count >= 2) + + let total = Double(source.physicalMemory) + let summed = parts.reduce(0, +) + // Not exact: macOS keeps page classes this does not name, so the slices + // account for most of the RAM rather than all of it. What matters is + // that they never exceed it, which overlapping slices would. + #expect(summed <= total * 1.02, "the slices overlap; a stack would overflow") + #expect(summed >= total * 0.85, "the slices leave too much unaccounted for") + + let used = try #require(values[MemorySource.used]) + #expect(used < summed, "Used is a sum of slices, not a slice") + } + + @Test("CPU Total is exactly its slices") + func cpuPartsSumToTotal() throws { + let source = CPUSource() + _ = try source.read(at: 0) + Thread.sleep(forTimeInterval: 0.2) + let samples = try source.read(at: 0.2).samples + let values = Dictionary( + samples.map { ($0.metric, $0.value) }, uniquingKeysWith: { first, _ in first } + ) + let user = try #require(values[CPUSource.user]) + let system = try #require(values[CPUSource.system]) + let total = try #require(values[CPUSource.total]) + #expect(abs(user + system - total) < 0.0001) + } + + @Test("a group that declares slices declares at least two of them") + func slicesComeInTwos() { + // One band is an area chart with extra steps, so a lone `.part` is a + // declaration somebody half finished. + let sliced = SourceRegistry.allDescriptors.filter { $0.composition == .part } + #expect(!sliced.isEmpty) + for group in Set(sliced.map(\.group)) { + let members = sliced.filter { $0.group == group } + #expect(members.count >= 2, "\(group) declares one slice and no other") + } + } + + @Test("nothing is both a slice and a direction") + func compositionAndDirectionAreExclusive() { + // Two directions of a flow are not slices of a whole, and a band drawn + // below a baseline would be nonsense. + for descriptor in SourceRegistry.allDescriptors { + #expect( + descriptor.direction == nil || descriptor.composition == nil, + "\(descriptor.id.rawValue) claims to be both" + ) + } + } + @Test("disk counters differentiate into non-negative rates") func disk() throws { let source = DiskSource() diff --git a/docs/ui.md b/docs/ui.md index 4026b98..2aa53aa 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -247,6 +247,69 @@ checkbox has no gesture to end. Off by default. Mirroring is the clearer way to read throughput, but a chart that changes shape under somebody on upgrade is worse than one they switch on. +## Stacked cards + +Memory is not seven independent readings. App, wired, compressed, cached and +free are five slices of one machine's RAM, and drawn as five lines from zero +they answer "how big is each part" while hiding the question you opened the card +for: how full is the machine. Stacked, the bands answer both — each band is a +part, the top of the stack is the total. + +Switch **Stack the parts of a whole** on in the Charts tab. Today that is Memory +and CPU; anything declared later joins them. + +### A metric says whether it is a slice + +`MetricDescriptor.composition` is `.part`, `.aggregate`, or nil. Same shape as +`direction`, and for the same reason: it is a fact about the metric, so a source +added later gets stacking without anything else being told. + +- **`.part`** — one slice of the group's whole. Slices do not overlap. +- **`.aggregate`** — a sum of slices in the same group. Memory Used is app plus + wired plus compressed; CPU Total is user plus system. +- **nil** — neither. Memory Swap shares the card and is not in the machine's RAM + at all, and two temperature sensors are two readings rather than a division of + anything. + +A card stacks when it draws **two or more** slices. One band is an area chart +with extra steps. + +### An aggregate must never be a band + +This is the trap the whole feature turns on. Memory Used is app plus wired plus +compressed, so stacking it counts those three a second time and puts the top of +the card at nearly twice the RAM in the machine. It keeps its line instead, +where it lands exactly on top of the bands it sums — which reads as a check +rather than a contradiction. + +`MonitorSourcesTests` holds the claim against the real machine: the slices +account for between 85% and 102% of physical RAM, so they cannot be overlapping, +and Used is strictly larger than any one of them. CPU is an exact identity — +user plus system *is* total, to four decimal places. + +### Lines among bands are dashed + +A line on a stacked card is a different kind of statement and has to look like +one. Solid strokes are what the card uses for slices, so an aggregate or an +unrelated series drawn solid invites reading it as one more slice. Dashed, with +a hollow legend swatch instead of a filled one, so the key says what the chart +says. + +### The scale is the stack's height + +A stacked card's y-axis is bounded by the summed height per timestamp, not by +the tallest single band. The top of the stack is what the eye reads, and scaling +to one band would push the stack out through the top of the card. Memory has a +`nominalMaximum` of physical RAM so it was already bounded; a stacked group +without one would not be. + +### Stacked and mirrored are exclusive + +Two directions of a flow are not slices of a whole, so nothing declares both and +a registry test proves it. `ChartCard` drops the stack when a mirror is set as +well, because a band drawn below a baseline would be nonsense however it got +there. + ## The time axis Three things have to be true of the labels along the bottom, and the first two