Skip to content

Commit dfa70a2

Browse files
committed
Add timeline zoom, pan & preset navigation for time series chart
Replace the fixed 3-segment TimeRangePicker with a continuous TimeWindow model that supports scroll-wheel zoom, drag-to-pan, preset buttons (1H/6H/1D/7D/30D), and a Live mode that auto-scrolls to now. Granularity (minute/hourly/daily) is derived automatically from window duration. Tooltip selection preserved via simultaneousGesture and hit-test-transparent scroll wheel overlay.
1 parent d7ab8e3 commit dfa70a2

5 files changed

Lines changed: 488 additions & 103 deletions

File tree

Sources/CCMonitor/Utilities/Constants.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,32 @@ enum Constants {
5858
// MARK: - 聚合
5959

6060
static let burnRateWindowMinutes: Int = 30
61+
62+
// MARK: - 时间窗口
63+
64+
/// 粒度切换阈值(秒)
65+
static let granularityMinuteThreshold: TimeInterval = 2 * 3600 // ≤ 2h → minute
66+
static let granularityHourlyThreshold: TimeInterval = 3 * 24 * 3600 // ≤ 3d → hourly, > 3d → daily
67+
68+
/// 窗口缩放极限(秒)
69+
static let timeWindowMinDuration: TimeInterval = 10 * 60 // 10 分钟
70+
static let timeWindowMaxDuration: TimeInterval = 30 * 24 * 3600 // 30 天
71+
72+
/// 默认窗口宽度(秒)
73+
static let timeWindowDefaultDuration: TimeInterval = 6 * 3600 // 6 小时
74+
75+
/// 缩放步进因子(滚轮 deltaY=1 时)
76+
static let zoomStepFactor: Double = 0.15
77+
78+
/// 滚轮灵敏度缩放系数
79+
static let scrollWheelSensitivity: Double = 1.0
80+
81+
/// 预设窗口列表 (label, 秒数)
82+
static let timeWindowPresets: [(label: String, duration: TimeInterval)] = [
83+
("1H", 3600),
84+
("6H", 6 * 3600),
85+
("1D", 24 * 3600),
86+
("7D", 7 * 24 * 3600),
87+
("30D", 30 * 24 * 3600),
88+
]
6189
}

Sources/CCMonitor/ViewModels/DashboardViewModel.swift

Lines changed: 189 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Foundation
22
import Observation
33

4-
/// 选择的时间范围
4+
/// 选择的时间范围(保留用于兼容粒度标签显示)
55
enum TimeRange: String, CaseIterable, Sendable {
66
case minutes = "Minutes"
77
case hours = "Hours"
@@ -14,6 +14,82 @@ enum ChartStyle: String, CaseIterable, Sendable {
1414
case line = "Line"
1515
}
1616

17+
/// 可见时间窗口模型
18+
struct TimeWindow: Equatable {
19+
var start: Date
20+
var end: Date
21+
22+
var duration: TimeInterval { end.timeIntervalSince(start) }
23+
24+
/// 根据窗口宽度自动判定粒度
25+
var granularity: AggregationGranularity {
26+
let d = duration
27+
if d <= Constants.granularityMinuteThreshold { return .minute }
28+
if d <= Constants.granularityHourlyThreshold { return .hourly }
29+
return .daily
30+
}
31+
32+
/// 对应的 TimeRange(用于 X 轴格式化等兼容场景)
33+
var timeRange: TimeRange {
34+
switch granularity {
35+
case .minute: return .minutes
36+
case .hourly: return .hours
37+
case .daily: return .days
38+
}
39+
}
40+
41+
/// 以中心为锚点缩放,clamp 到 [minDuration, maxDuration]
42+
func zoomed(by factor: Double) -> TimeWindow {
43+
let center = start.addingTimeInterval(duration / 2)
44+
let newDuration = (duration * factor).clamped(
45+
to: Constants.timeWindowMinDuration...Constants.timeWindowMaxDuration
46+
)
47+
let halfNew = newDuration / 2
48+
return TimeWindow(
49+
start: center.addingTimeInterval(-halfNew),
50+
end: center.addingTimeInterval(halfNew)
51+
)
52+
}
53+
54+
/// 平移指定秒数
55+
func shifted(by seconds: TimeInterval) -> TimeWindow {
56+
TimeWindow(
57+
start: start.addingTimeInterval(seconds),
58+
end: end.addingTimeInterval(seconds)
59+
)
60+
}
61+
62+
/// 限制在数据可用范围内
63+
func clamped(to bounds: ClosedRange<Date>) -> TimeWindow {
64+
let dur = duration
65+
var s = start
66+
var e = end
67+
68+
if s < bounds.lowerBound {
69+
s = bounds.lowerBound
70+
e = s.addingTimeInterval(dur)
71+
}
72+
if e > bounds.upperBound {
73+
e = bounds.upperBound
74+
s = e.addingTimeInterval(-dur)
75+
}
76+
// 再次确保 start 不越过 lower bound
77+
if s < bounds.lowerBound {
78+
s = bounds.lowerBound
79+
}
80+
return TimeWindow(start: s, end: e)
81+
}
82+
83+
/// 默认窗口:最近 N 秒
84+
static func defaultWindow() -> TimeWindow {
85+
let now = Date()
86+
return TimeWindow(
87+
start: now.addingTimeInterval(-Constants.timeWindowDefaultDuration),
88+
end: now
89+
)
90+
}
91+
}
92+
1793
/// 时间序列数据点
1894
struct TimeSeriesPoint: Identifiable {
1995
let id: Date
@@ -46,9 +122,23 @@ struct TimeSeriesPoint: Identifiable {
46122
/// Dashboard 主 ViewModel
47123
@Observable
48124
final class DashboardViewModel {
49-
var selectedTimeRange: TimeRange = .hours
125+
/// 可见时间窗口
126+
var timeWindow: TimeWindow = .defaultWindow()
127+
128+
/// 兼容属性:基于 timeWindow 自动推导
129+
var selectedTimeRange: TimeRange { timeWindow.timeRange }
130+
50131
var selectedChartStyle: ChartStyle = .bar
51132

133+
/// 实时模式:窗口右端跟随 now 自动滚动
134+
var isLive: Bool = true
135+
136+
/// 拖拽中标记(拖拽时忽略 tooltip selection)
137+
var isDragging: Bool = false
138+
139+
/// 全部数据的时间极值
140+
var dataBounds: ClosedRange<Date>?
141+
52142
// 摘要数据
53143
var todayCost: Double = 0
54144
var todayInputTokens: Int = 0
@@ -82,6 +172,55 @@ final class DashboardViewModel {
82172
// 项目列表
83173
var projects: [ProjectInfo] = []
84174

175+
// MARK: - 时间窗口操作
176+
177+
/// 缩放:factor > 1 缩小(看到更多数据),< 1 放大(看到更少数据)
178+
func zoom(by factor: Double) {
179+
isLive = false
180+
var newWindow = timeWindow.zoomed(by: factor)
181+
if let bounds = dataBounds {
182+
newWindow = newWindow.clamped(to: bounds)
183+
}
184+
timeWindow = newWindow
185+
}
186+
187+
/// 平移:正值向右(未来),负值向左(过去)
188+
func pan(by seconds: TimeInterval) {
189+
isLive = false
190+
var newWindow = timeWindow.shifted(by: seconds)
191+
if let bounds = dataBounds {
192+
newWindow = newWindow.clamped(to: bounds)
193+
}
194+
timeWindow = newWindow
195+
}
196+
197+
/// 应用预设窗口宽度
198+
func applyPreset(duration: TimeInterval) {
199+
let now = Date()
200+
timeWindow = TimeWindow(
201+
start: now.addingTimeInterval(-duration),
202+
end: now
203+
)
204+
if let bounds = dataBounds {
205+
timeWindow = timeWindow.clamped(to: bounds)
206+
}
207+
// 预设始终回到实时模式
208+
isLive = true
209+
}
210+
211+
/// 恢复实时跟随
212+
func goLive() {
213+
isLive = true
214+
let dur = timeWindow.duration
215+
let now = Date()
216+
timeWindow = TimeWindow(
217+
start: now.addingTimeInterval(-dur),
218+
end: now
219+
)
220+
}
221+
222+
// MARK: - 数据更新
223+
85224
/// 更新所有面板数据
86225
func update(from aggregator: UsageAggregator) {
87226
let today = aggregator.todayUsage
@@ -93,6 +232,19 @@ final class DashboardViewModel {
93232
activeSessions = aggregator.activeSessionCount
94233
burnRate = BurnRateCalculator.calculate(minuteUsage: aggregator.minuteUsage)
95234

235+
// 计算数据时间边界
236+
updateDataBounds(from: aggregator)
237+
238+
// isLive 时自动右移窗口
239+
if isLive {
240+
let dur = timeWindow.duration
241+
let now = Date()
242+
timeWindow = TimeWindow(
243+
start: now.addingTimeInterval(-dur),
244+
end: now
245+
)
246+
}
247+
96248
// 更新时间序列
97249
updateTimeSeries(from: aggregator)
98250

@@ -106,20 +258,41 @@ final class DashboardViewModel {
106258
.sorted { $0.totalCostUSD > $1.totalCostUSD }
107259
}
108260

261+
private func updateDataBounds(from aggregator: UsageAggregator) {
262+
// 收集所有数据源的时间极值
263+
var allDates: [Date] = []
264+
265+
for bucket in aggregator.minuteUsage.keys { allDates.append(bucket.date) }
266+
for bucket in aggregator.hourlyUsage.keys { allDates.append(bucket.date) }
267+
for bucket in aggregator.dailyUsage.keys { allDates.append(bucket.date) }
268+
269+
guard let minDate = allDates.min(), let maxDate = allDates.max() else {
270+
dataBounds = nil
271+
return
272+
}
273+
// 右边界扩展一点,确保最新数据点不被裁掉
274+
dataBounds = minDate...maxDate.addingTimeInterval(3600)
275+
}
276+
109277
private func updateTimeSeries(from aggregator: UsageAggregator) {
110278
let bucketData: [(DateBucket, UsageSummary)]
111279

112-
switch selectedTimeRange {
113-
case .minutes:
280+
switch timeWindow.granularity {
281+
case .minute:
114282
bucketData = aggregator.minuteUsage.sorted { $0.key < $1.key }
115-
case .hours:
283+
case .hourly:
116284
bucketData = aggregator.hourlyUsage.sorted { $0.key < $1.key }
117-
case .days:
285+
case .daily:
118286
bucketData = aggregator.dailyUsage.sorted { $0.key < $1.key }
119287
}
120288

121-
timeSeriesData = bucketData.map { bucket, summary in
122-
TimeSeriesPoint(
289+
// 按时间窗口过滤
290+
let windowStart = timeWindow.start
291+
let windowEnd = timeWindow.end
292+
293+
timeSeriesData = bucketData.compactMap { bucket, summary in
294+
guard bucket.date >= windowStart && bucket.date <= windowEnd else { return nil }
295+
return TimeSeriesPoint(
123296
date: bucket.date,
124297
cost: summary.totalCostUSD,
125298
inputTokens: summary.inputTokens,
@@ -130,3 +303,11 @@ final class DashboardViewModel {
130303
}
131304
}
132305
}
306+
307+
// MARK: - Comparable clamping helper
308+
309+
extension Comparable {
310+
func clamped(to range: ClosedRange<Self>) -> Self {
311+
min(max(self, range.lowerBound), range.upperBound)
312+
}
313+
}

Sources/CCMonitor/Views/Components/TimeRangePicker.swift

Lines changed: 0 additions & 18 deletions
This file was deleted.

Sources/CCMonitor/Views/Dashboard/DashboardView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,26 @@ struct DashboardView: View {
1616
// 时间序列图表
1717
TimeSeriesPanel()
1818
.frame(minHeight: 350)
19+
.clipped()
1920

2021
// 模型分布
2122
ModelDistributionPanel()
2223
.frame(minWidth: 300, maxWidth: 300, minHeight: 350)
24+
.clipped()
2325
}
26+
.fixedSize(horizontal: false, vertical: true)
2427

2528
// 底部: 项目 + 预算(横向排列)
2629
HStack(alignment: .top, spacing: 16) {
2730
ProjectPanel()
2831
.frame(minHeight: 250)
32+
.clipped()
2933

3034
BudgetPanel()
3135
.frame(minWidth: 300, maxWidth: 300, minHeight: 250)
36+
.clipped()
3237
}
38+
.fixedSize(horizontal: false, vertical: true)
3339
}
3440
.padding()
3541
}

0 commit comments

Comments
 (0)