Lines
97.00%
97 / 100
Methods
80.00%
12 / 15
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| dense | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| path | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 2 | ||
| build | 100.00% | 35 / 35 | 100.00% | 1 / 1 | 9 | ||
| date | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 3 | ||
| within | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 4 | ||
| day | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| startOf | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| timeRange | 90.90% | 10 / 11 | 0.00% | 0 / 1 | 6.03 | ||
| flag | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| band | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 2 | ||
| niceRange | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 6 | ||
| compact | 80.00% | 4 / 5 | 0.00% | 0 / 1 | 4.13 | ||
| decimals | 66.66% | 2 / 3 | 0.00% | 0 / 1 | 3.33 | ||
| monthTicks | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 6 | ||
| 15 | final readonly class TimelineChart | |
| 16 | { | |
| 17 | public const int WIDTH = 800; | |
| 18 | ||
| 19 | public const int HEIGHT = 240; | |
| 20 | ||
| 21 | /** The plot inside the axis labels. */ | |
| 22 | public const int LEFT = 52; | |
| 23 | ||
| 24 | public const int RIGHT = 784; | |
| 25 | ||
| 26 | public const int TOP = 28; | |
| 27 | ||
| 28 | public const int BOTTOM = 212; | |
| 29 | ||
| 30 | /** A day of the date filter (Y-m-d), as a regular expression for the query parameters. */ | |
| 31 | public const string DAY = '/^\d{4}-\d{2}-\d{2}$/'; | |
| 32 | ||
| 33 | /** With more uploads the dots would run together: the line alone, a dot on hover. */ | |
| 34 | public const int MAX_DOTS = 20; | |
| 35 | ||
| 36 | /** | |
| 37 | * @param list<array{x: float, y: float, title: string}> $points the uploads, oldest first | |
| 38 | * @param list<array{x: float, title: string}> $flags single-day events | |
| 39 | * @param list<array{x: float, width: float, title: string, label: string}> $bands events over several days | |
| 40 | * @param list<array{y: float, label: string}> $yTicks | |
| 41 | * @param list<array{x: float, label: string}> $xTicks | |
| 42 | */ | |
| 43 | public function __construct( | |
| 44 | public array $points, | |
| 45 | public array $flags, | |
| 46 | public array $bands, | |
| 47 | public array $yTicks, | |
| 48 | public array $xTicks, | |
| 49 | ) { | |
| 50 | } | |
| 51 | ||
| 52 | public function dense(): bool | |
| 53 | { | |
| 54 | return \count($this->points) > self::MAX_DOTS; | |
| 55 | } | |
| 56 | ||
| 57 | /** The AP line through the uploads, as an SVG path. */ | |
| 58 | public function path(): string | |
| 59 | { | |
| 60 | return \implode(' ', \array_map( | |
| 61 | static fn (array $point, int $index): string => \sprintf('%s%.1f,%.1f', 0 === $index ? 'M' : 'L', $point['x'], $point['y']), | |
| 62 | $this->points, | |
| 63 | \array_keys($this->points), | |
| 64 | )); | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Null when there is nothing to show. Zoomed to the days from $from to $to (both included, null: | |
| 69 | * no limit) as the app's: only the uploads and events on them, the AP axis for those uploads. | |
| 70 | * | |
| 71 | * @param list<AgentStat> $stats any order | |
| 72 | * @param list<TimelineEvent> $events | |
| 73 | */ | |
| 74 | public static function build(array $stats, array $events, string $locale, ?\DateTimeImmutable $from = null, ?\DateTimeImmutable $to = null): ?self | |
| 75 | { | |
| 76 | $first = $from instanceof \DateTimeImmutable ? self::day($from->setTime(0, 0)) : null; | |
| 77 | // To the end of the last day | |
| 78 | $last = $to instanceof \DateTimeImmutable ? self::day($to->setTime(0, 0)) + 1 : null; | |
| 79 | $stats = \array_values(\array_filter($stats, static fn (AgentStat $stat): bool => self::within(self::day($stat->datetime), self::day($stat->datetime), $first, $last))); | |
| 80 | $events = \array_values(\array_filter($events, static fn (TimelineEvent $event): bool => self::within( | |
| 81 | self::day(self::startOf($event)), | |
| 82 | // The start of its last day: on the first day shown or later | |
| 83 | self::day($event->endDate ?? self::startOf($event)), | |
| 84 | $first, | |
| 85 | $last, | |
| 86 | ))); | |
| 87 | ||
| 88 | if ([] === $stats && [] === $events) { | |
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | [$start, $end] = self::timeRange($stats, $events, $first, $last); | |
| 93 | $x = static fn (float $time): float => self::LEFT + ($time - $start) / ($end - $start) * (self::RIGHT - self::LEFT); | |
| 94 | ||
| 95 | $aps = \array_map(static fn (AgentStat $stat): int => $stat->ap, $stats); | |
| 96 | [$low, $high, $step] = self::niceRange([] === $aps ? 0 : \min($aps), [] === $aps ? 1 : \max($aps)); | |
| 97 | $y = static fn (float $ap): float => self::BOTTOM - ($ap - $low) / ($high - $low) * (self::BOTTOM - self::TOP); | |
| 98 | ||
| 99 | $sorted = $stats; | |
| 100 | \usort($sorted, static fn (AgentStat $a, AgentStat $b): int => $a->datetime <=> $b->datetime); | |
| 101 | ||
| 102 | $points = \array_map(static fn (AgentStat $stat): array => [ | |
| 103 | 'x' => $x(self::day($stat->datetime)), | |
| 104 | 'y' => $y($stat->ap), | |
| 105 | 'title' => \sprintf('%s · L%s · %s AP', $stat->datetime->format('Y-m-d H:i'), $stat->level ?? '–', \number_format($stat->ap)), | |
| 106 | ], $sorted); | |
| 107 | ||
| 108 | // No AP axis without uploads (e.g. zoomed to the days of a travel only) | |
| 109 | $yTicks = []; | |
| 110 | for ($ap = $low; [] !== $stats && $ap <= $high + $step / 2; $ap += $step) { | |
| 111 | $yTicks[] = ['y' => $y($ap), 'label' => self::compact($ap, $step)]; | |
| 112 | } | |
| 113 | ||
| 114 | $single = \array_values(\array_filter($events, static fn (TimelineEvent $event): bool => !$event->endDate instanceof \DateTimeImmutable)); | |
| 115 | $travels = \array_values(\array_filter($events, static fn (TimelineEvent $event): bool => $event->endDate instanceof \DateTimeImmutable)); | |
| 116 | ||
| 117 | return new self( | |
| 118 | $points, | |
| 119 | \array_map(static fn (TimelineEvent $event): array => self::flag($event, $x), $single), | |
| 120 | \array_map(static fn (TimelineEvent $event): array => self::band($event, $x), $travels), | |
| 121 | $yTicks, | |
| 122 | self::monthTicks($start, $end, $x, $locale), | |
| 123 | ); | |
| 124 | } | |
| 125 | ||
| 126 | /** A day of the date filter (Y-m-d, see DAY); null for none. */ | |
| 127 | public static function date(?string $day): ?\DateTimeImmutable | |
| 128 | { | |
| 129 | return null === $day || '' === $day ? null : new \DateTimeImmutable($day.' 00:00:00', new \DateTimeZone('UTC')); | |
| 130 | } | |
| 131 | ||
| 132 | /** Whether the time from $start to $end overlaps the days from $first to $last (null: no limit). */ | |
| 133 | private static function within(float $start, float $end, ?float $first, ?float $last): bool | |
| 134 | { | |
| 135 | return (null === $first || $end >= $first) && (null === $last || $start < $last); | |
| 136 | } | |
| 137 | ||
| 138 | /** Days since the epoch: a date and the time of an upload on the same scale. */ | |
| 139 | private static function day(\DateTimeImmutable $date): float | |
| 140 | { | |
| 141 | return $date->getTimestamp() / 86400; | |
| 142 | } | |
| 143 | ||
| 144 | private static function startOf(TimelineEvent $event): \DateTimeImmutable | |
| 145 | { | |
| 146 | return $event->date ?? $event->createdAt; | |
| 147 | } | |
| 148 | ||
| 149 | /** | |
| 150 | * The days the chart shows: from $first to $last when given, else every upload and event, at | |
| 151 | * least 30 days; with a little room on both sides for the dots and flags. | |
| 152 | * | |
| 153 | * @param list<AgentStat> $stats | |
| 154 | * @param list<TimelineEvent> $events | |
| 155 | * | |
| 156 | * @return array{float, float} | |
| 157 | */ | |
| 158 | private static function timeRange(array $stats, array $events, ?float $first = null, ?float $last = null): array | |
| 159 | { | |
| 160 | $times = \array_map(static fn (AgentStat $stat): float => self::day($stat->datetime), $stats); | |
| 161 | foreach ($events as $event) { | |
| 162 | $times[] = self::day(self::startOf($event)); | |
| 163 | // A band ends after its last day | |
| 164 | $times[] = self::day($event->endDate ?? self::startOf($event)) + 1; | |
| 165 | } | |
| 166 | ||
| 167 | if ([] === $times) { | |
| 168 | // Not from build(): it has an upload or an event | |
| 169 | $times = [self::day(new \DateTimeImmutable())]; | |
| 170 | } | |
| 171 | ||
| 172 | [$start, $end] = [$first ?? \min($times), $last ?? \max($times)]; | |
| 173 | if (null === $first && null === $last && $end - $start < 30) { | |
| 174 | [$start, $end] = [($start + $end) / 2 - 15, ($start + $end) / 2 + 15]; | |
| 175 | } | |
| 176 | ||
| 177 | $padding = ($end - $start) * 0.03; | |
| 178 | ||
| 179 | return [$start - $padding, $end + $padding]; | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * A single-day event, in the middle of its day. | |
| 184 | * | |
| 185 | * @param \Closure(float): float $x | |
| 186 | * | |
| 187 | * @return array{x: float, title: string} | |
| 188 | */ | |
| 189 | private static function flag(TimelineEvent $event, \Closure $x): array | |
| 190 | { | |
| 191 | $date = self::startOf($event); | |
| 192 | ||
| 193 | return ['x' => $x(self::day($date) + 0.5), 'title' => $date->format('Y-m-d').': '.$event->text]; | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * An event over several days (a travel), from its first to the end of its last day. | |
| 198 | * | |
| 199 | * @param \Closure(float): float $x | |
| 200 | * | |
| 201 | * @return array{x: float, width: float, title: string, label: string} | |
| 202 | */ | |
| 203 | private static function band(TimelineEvent $event, \Closure $x): array | |
| 204 | { | |
| 205 | $date = self::startOf($event); | |
| 206 | $endDate = $event->endDate ?? $date; | |
| 207 | // Cut to the plot: a zoomed chart can start or end within a travel | |
| 208 | $left = \max((float) self::LEFT, $x(self::day($date))); | |
| 209 | $right = \min((float) self::RIGHT, $x(self::day($endDate) + 1)); | |
| 210 | ||
| 211 | return [ | |
| 212 | 'x' => $left, | |
| 213 | 'width' => \max(2.0, $right - $left), | |
| 214 | 'title' => \sprintf('%s – %s: %s', $date->format('Y-m-d'), $endDate->format('Y-m-d'), $event->text), | |
| 215 | // The first line | |
| 216 | 'label' => \mb_strimwidth(\strtok($event->text, "\n") ?: '', 0, 40, '…'), | |
| 217 | ]; | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Rounded bounds and a step for about 4 ticks; at least a step of room around a single value. | |
| 222 | * | |
| 223 | * @return array{float, float, float} | |
| 224 | */ | |
| 225 | private static function niceRange(float $min, float $max): array | |
| 226 | { | |
| 227 | if ($max - $min < 1) { | |
| 228 | [$min, $max] = [$min - \max(1.0, $min * 0.05), $max + \max(1.0, $max * 0.05)]; | |
| 229 | } | |
| 230 | ||
| 231 | $rough = ($max - $min) / 4; | |
| 232 | $magnitude = 10 ** \floor(\log10($rough)); | |
| 233 | $step = $magnitude * match (true) { | |
| 234 | $rough / $magnitude <= 1 => 1, | |
| 235 | $rough / $magnitude <= 2 => 2, | |
| 236 | $rough / $magnitude <= 5 => 5, | |
| 237 | default => 10, | |
| 238 | }; | |
| 239 | ||
| 240 | return [\floor($min / $step) * $step, \ceil($max / $step) * $step, $step]; | |
| 241 | } | |
| 242 | ||
| 243 | /** | |
| 244 | * 243962582 → 244M, 5400 → 5.4k: short enough for the axis in every language. With the ticks' | |
| 245 | * $step as many decimals as it needs, so that close ticks differ (243.6M, 243.8M); trailing | |
| 246 | * zeros dropped (1k, 1.5k). | |
| 247 | */ | |
| 248 | private static function compact(float $value, ?float $step = null): string | |
| 249 | { | |
| 250 | foreach ([[1e9, 'B'], [1e6, 'M'], [1e3, 'k']] as [$unit, $suffix]) { | |
| 251 | if (\abs($value) >= $unit) { | |
| 252 | $text = \number_format($value / $unit, self::decimals($value / $unit, $unit, $step), '.', ''); | |
| 253 | ||
| 254 | return (\str_contains($text, '.') ? \rtrim(\rtrim($text, '0'), '.') : $text).$suffix; | |
| 255 | } | |
| 256 | } | |
| 257 | ||
| 258 | return \number_format($value, 0, '.', ''); | |
| 259 | } | |
| 260 | ||
| 261 | /** For a step of 0.1M one decimal of M; without a step one below 10 (2.5M, but 250M). */ | |
| 262 | private static function decimals(float $short, float $unit, ?float $step): int | |
| 263 | { | |
| 264 | if (null === $step) { | |
| 265 | return \abs($short) < 10 ? 1 : 0; | |
| 266 | } | |
| 267 | ||
| 268 | return \max(0, (int) \ceil(-\log10($step / $unit) - 1e-9)); | |
| 269 | } | |
| 270 | ||
| 271 | /** | |
| 272 | * A tick at the start of every month, or of every 2, 3, 6, 12, 24, 60 or 120 months for at most 6 | |
| 273 | * ticks; the month in the language of the page, with the year ("Sept. 26" would read as a day), or | |
| 274 | * only the year with ticks a year or more apart. | |
| 275 | * | |
| 276 | * @param \Closure(float): float $x | |
| 277 | * | |
| 278 | * @return list<array{x: float, label: string}> | |
| 279 | */ | |
| 280 | private static function monthTicks(float $start, float $end, \Closure $x, string $locale): array | |
| 281 | { | |
| 282 | $first = new \DateTimeImmutable('@'.(int) ($start * 86400))->modify('first day of next month midnight'); | |
| 283 | $last = new \DateTimeImmutable('@'.(int) ($end * 86400)); | |
| 284 | $months = (((int) $last->format('Y')) - ((int) $first->format('Y'))) * 12 + ((int) $last->format('n')) - ((int) $first->format('n')) + 1; | |
| 285 | ||
| 286 | $every = 1; | |
| 287 | foreach ([1, 2, 3, 6, 12, 24, 60, 120] as $every) { | |
| 288 | if ($months / $every <= 6) { | |
| 289 | break; | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | // On months that divide by the step, counted from year 0: January, April, July and October for 3, even years for 24 | |
| 294 | while (0 !== (((int) $first->format('Y')) * 12 + ((int) $first->format('n')) - 1) % $every) { | |
| 295 | $first = $first->modify('+1 month'); | |
| 296 | } | |
| 297 | ||
| 298 | $format = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', null, $every >= 12 ? 'yyyy' : 'MMM yyyy'); | |
| 299 | $ticks = []; | |
| 300 | for ($month = $first; $month <= $last; $month = $month->modify('+'.$every.' months')) { | |
| 301 | $ticks[] = ['x' => $x($month->getTimestamp() / 86400), 'label' => (string) $format->format($month)]; | |
| 302 | } | |
| 303 | ||
| 304 | return $ticks; | |
| 305 | } | |
| 306 | } |