Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion brain-bar/Sources/BrainBar/BrainBarWindowRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ private struct BrainBarFlowLaneCard: View {
BrainBarHeroSparkline(
values: lane.values,
accentColor: lane.accentColor,
activityWindowMinutes: lane.activityWindowMinutes,
pulseRevision: pulseRevision
)
.frame(height: chartHeight)
Expand Down Expand Up @@ -1055,6 +1056,7 @@ private struct BrainBarFlowStatusPill: View {
private struct BrainBarHeroSparkline: View {
let values: [Int]
let accentColor: NSColor
let activityWindowMinutes: Int
let pulseRevision: Int

@Environment(\.accessibilityReduceMotion) private var reduceMotion
Expand All @@ -1076,7 +1078,8 @@ private struct BrainBarHeroSparkline: View {
SparklineChart(
presentation: SparklineChartPresentation(
label: "Recent activity sparkline",
values: values
values: values,
activityWindowMinutes: activityWindowMinutes
),
accentColor: accentColor,
compact: SparklineRenderer.isCompact(size: renderSize)
Expand Down
3 changes: 3 additions & 0 deletions brain-bar/Sources/BrainBar/Dashboard/PipelineState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ struct DashboardFlowLane: Sendable, Equatable {
let status: DashboardFlowLaneStatus
let statusText: String
let windowLabel: String
let activityWindowMinutes: Int
let rateText: String
let volumeText: String
let lastEventText: String
Expand Down Expand Up @@ -311,6 +312,7 @@ struct DashboardFlowSummary: Sendable, Equatable {
status: ingressStatus,
statusText: ingressStatus == .live ? "Ingress live now" : (ingressStatus == .recent ? "Recent writes in \(windowLabel.lowercased())" : "No recent writes"),
windowLabel: windowLabel,
activityWindowMinutes: stats.activityWindowMinutes,
rateText: DashboardMetricFormatter.rateString(
totalEvents: stats.recentWriteCount,
activityWindowMinutes: stats.activityWindowMinutes
Expand Down Expand Up @@ -361,6 +363,7 @@ struct DashboardFlowSummary: Sendable, Equatable {
status: enrichmentStatus,
statusText: enrichmentStatusText,
windowLabel: windowLabel,
activityWindowMinutes: stats.activityWindowMinutes,
rateText: DashboardMetricFormatter.rateString(
totalEvents: stats.recentEnrichmentCount,
activityWindowMinutes: stats.activityWindowMinutes
Expand Down
112 changes: 112 additions & 0 deletions brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ struct SparklineChartPoint: Identifiable, Equatable, Sendable {
struct SparklineChartPresentation: Equatable, Sendable {
let label: String
let values: [Int]
let activityWindowMinutes: Int
let latestBucketName: String

init(
label: String,
values: [Int],
activityWindowMinutes: Int = 30,
latestBucketName: String = "latest bucket count"
) {
self.label = label
self.values = values
self.activityWindowMinutes = activityWindowMinutes
self.latestBucketName = latestBucketName
}

Expand All @@ -43,6 +46,40 @@ struct SparklineChartPresentation: Equatable, Sendable {
max(values.max() ?? 0, 1)
}

func bucketLabel(for bucket: Int) -> String {
guard !values.isEmpty else { return "no bucket" }
let clampedBucket = min(max(bucket, 0), values.count - 1)
let totalSeconds = max(activityWindowMinutes * 60, 1)
let bucketWidthSeconds = max(1, Double(totalSeconds) / Double(values.count))
let bucketStart = Double(clampedBucket) * bucketWidthSeconds
let bucketEnd = min(Double(clampedBucket + 1) * bucketWidthSeconds, Double(totalSeconds))
let olderSecondsAgo = max(0, Int(round(Double(totalSeconds) - bucketStart)))
let newerSecondsAgo = max(0, Int(round(Double(totalSeconds) - bucketEnd)))

if newerSecondsAgo == 0 {
return "last \(Self.durationLabel(seconds: olderSecondsAgo))"
}
return "\(Self.durationLabel(seconds: olderSecondsAgo))-\(Self.durationLabel(seconds: newerSecondsAgo)) ago"
}

func tooltipText(forBucket bucket: Int) -> String {
let clampedBucket = min(max(bucket, 0), max(values.count - 1, 0))
let value = values.indices.contains(clampedBucket) ? values[clampedBucket] : 0
return "\(bucketLabel(for: clampedBucket)): \(value)"
}

private static func durationLabel(seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
if remainingSeconds == 0 {
return "\(minutes)m"
}
if minutes == 0 {
return "\(remainingSeconds)s"
}
return "\(minutes)m \(remainingSeconds)s"
}

private var trendDescription: String {
guard let last = values.last else {
return "no trend"
Expand All @@ -66,6 +103,8 @@ struct SparklineChart: View {
let presentation: SparklineChartPresentation
let accentColor: NSColor
let compact: Bool
@State private var hoveredBucket: Int?
@State private var hoverLocation: CGPoint?

init(
presentation: SparklineChartPresentation,
Expand Down Expand Up @@ -103,10 +142,83 @@ struct SparklineChart: View {
.background(Color.clear)
.padding(compact ? 2 : 10)
}
.chartOverlay { chartProxy in
GeometryReader { geometry in
if let plotAnchor = chartProxy.plotFrame {
let plotFrame = geometry[plotAnchor]

Rectangle()
.fill(.clear)
.contentShape(Rectangle())
.onContinuousHover { phase in
switch phase {
case .active(let location):
hoveredBucket = nearestBucket(
to: location,
plotFrame: plotFrame,
chartProxy: chartProxy
)
hoverLocation = location
case .ended:
hoveredBucket = nil
hoverLocation = nil
}
}

if let hoveredBucket,
let hoverLocation,
!compact {
sparklineTooltip(forBucket: hoveredBucket)
.position(tooltipPosition(near: hoverLocation, in: geometry.size))
.allowsHitTesting(false)
}
}
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(presentation.accessibilityLabel))
.accessibilityValue(Text(presentation.accessibilityValue))
}

private func nearestBucket(
to location: CGPoint,
plotFrame: CGRect,
chartProxy: ChartProxy
) -> Int? {
guard !presentation.points.isEmpty,
plotFrame.contains(location) else {
return nil
}

let plotX = location.x - plotFrame.minX
guard let bucket: Double = chartProxy.value(atX: plotX, as: Double.self) else {
return nil
}
return min(max(Int(bucket.rounded()), 0), presentation.points.count - 1)
}

private func tooltipPosition(near location: CGPoint, in size: CGSize) -> CGPoint {
let x = min(max(location.x, 58), max(size.width - 58, 58))
let y = max(location.y - 28, 16)
return CGPoint(x: x, y: y)
}

@ViewBuilder
private func sparklineTooltip(forBucket bucket: Int) -> some View {
Text(presentation.tooltipText(forBucket: bucket))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: true)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color(nsColor: accentColor).opacity(0.35), lineWidth: 1)
)
.shadow(color: .black.opacity(0.14), radius: 8, y: 3)
}
}

enum SparklineRenderer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,8 @@ final class StatusPopoverView: NSViewController {
sparklineChartView.rootView = SparklineChart(
presentation: SparklineChartPresentation(
label: "Recent activity sparkline",
values: summary.enrichment.values
values: summary.enrichment.values,
activityWindowMinutes: summary.enrichment.activityWindowMinutes
),
accentColor: collector.state.color
)
Expand Down
17 changes: 16 additions & 1 deletion brain-bar/Tests/BrainBarTests/DashboardTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,28 @@ final class DashboardTests: XCTestCase {
func testSparklineChartPresentationCarriesBucketsAndVoiceOverMetadata() {
let presentation = SparklineChartPresentation(
label: "Recent activity sparkline",
values: [0, 2, 5, 3]
values: [0, 2, 5, 3],
activityWindowMinutes: 20
)

XCTAssertEqual(presentation.points.map(\.bucket), [0, 1, 2, 3])
XCTAssertEqual(presentation.points.map(\.value), [0, 2, 5, 3])
XCTAssertEqual(presentation.accessibilityLabel, "Recent activity sparkline")
XCTAssertEqual(presentation.accessibilityValue, "latest bucket count 3, trending down")
XCTAssertEqual(presentation.bucketLabel(for: 0), "20m-15m ago")
XCTAssertEqual(presentation.bucketLabel(for: 3), "last 5m")
XCTAssertEqual(presentation.tooltipText(forBucket: 2), "10m-5m ago: 5")
}

func testSparklineChartPresentationLabelsPartialMinuteBucketsLikeDatabase() {
let presentation = SparklineChartPresentation(
label: "Recent activity sparkline",
values: Array(repeating: 0, count: 12),
activityWindowMinutes: 31
)

XCTAssertEqual(presentation.bucketLabel(for: 0), "31m-28m 25s ago")
XCTAssertEqual(presentation.bucketLabel(for: 11), "last 2m 35s")
}

func testSparklineRendererCompactClassificationMatchesEndpointAndChartPadding() {
Expand Down
Loading