11import Foundation
22import Observation
33
4- /// 选择的时间范围
4+ /// 选择的时间范围(保留用于兼容粒度标签显示)
55enum 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/// 时间序列数据点
1894struct TimeSeriesPoint : Identifiable {
1995 let id : Date
@@ -46,9 +122,23 @@ struct TimeSeriesPoint: Identifiable {
46122/// Dashboard 主 ViewModel
47123@Observable
48124final 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+ }
0 commit comments