import Foundation import CoreGraphics /// Die Geometrie eines Programmrasters: Zeit in Punkte. /// /// Eigener Typ statt Rechnungen in der Ansicht, weil hier alles schiefgehen /// kann, was man nicht sieht: eine Sendung, die vor dem Fenster begann, säße /// bei negativem Versatz außerhalb; eine Kurzmeldung wäre zwei Punkte breit /// und unlesbar; ein leeres Raster hätte die Breite null. Geprüft werden /// diese Fälle nur, wenn sie außerhalb einer `View` stehen. public struct GuideLayout: Sendable, Equatable { /// Der Anfang des Fensters — die angebrochene halbe Stunde vor „jetzt". /// /// Nicht „jetzt" selbst: dann trüge die Achse krumme Zeiten und die /// laufende Sendung wäre immer angeschnitten. public let start: Date public let end: Date public let pointsPerMinute: CGFloat /// Schmaler wird kein Block, egal wie kurz die Sendung ist. public let minimumBlockWidth: CGFloat private static let halfHour: TimeInterval = 30 * 60 public init( now: Date, latestEnd: Date? = nil, pointsPerMinute: CGFloat = 4.4, minimumBlockWidth: CGFloat = 36, minimumHours: Double = 3, maximumHours: Double = 14 ) { let anchor = (now.timeIntervalSince1970 / Self.halfHour).rounded(.down) * Self.halfHour let start = Date(timeIntervalSince1970: anchor) self.start = start // Unten begrenzt, damit ein Raster mit einer einzigen bekannten // Sendung nicht zur Briefmarke wird; oben, damit es bei einem // Anbieter mit zwei Wochen Vorschau nicht endlos wird. let bounded = min( max(latestEnd ?? start, start.addingTimeInterval(minimumHours * 3_600)), start.addingTimeInterval(maximumHours * 3_600) ) // Auch das Ende auf die halbe Stunde, nach oben. Sonst endet die // Achse mit einem Stummel, dessen Beschriftung keine Spalte mehr hat. self.end = Date( timeIntervalSince1970: (bounded.timeIntervalSince1970 / Self.halfHour).rounded(.up) * Self.halfHour ) self.pointsPerMinute = pointsPerMinute self.minimumBlockWidth = minimumBlockWidth } public var totalWidth: CGFloat { CGFloat(end.timeIntervalSince(start) / 60) * pointsPerMinute } /// Der waagerechte Versatz eines Zeitpunkts. /// /// Nie negativ: was vor dem Fenster liegt, klebt an dessen linkem Rand. public func x(for date: Date) -> CGFloat { max(0, CGFloat(date.timeIntervalSince(start) / 60) * pointsPerMinute) } /// Die Breite einer Sendung, auf das Fenster beschnitten. public func width(from begin: Date, to finish: Date) -> CGFloat { let visibleStart = max(begin, start) let visibleEnd = min(finish, end) let minutes = max(0, visibleEnd.timeIntervalSince(visibleStart) / 60) return max(CGFloat(minutes) * pointsPerMinute, minimumBlockWidth) } /// Die Beschriftungen der Zeitachse, halbstündlich. public var ticks: [Date] { stride(from: 0, to: end.timeIntervalSince(start), by: Self.halfHour) .map { start.addingTimeInterval($0) } } /// Wie breit eine Achsenbeschriftung ist. public var tickWidth: CGFloat { CGFloat(Self.halfHour / 60) * pointsPerMinute } }