Lines 97.00% 97 / 100
Functions and Methods 80.00% 12 / 15
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
TimelineChart 97.00% 97 / 100 80.00% 12 / 15 50 0.00% 0 / 1
 __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
1<?php
2
3declare(strict_types=1);
4
5namespace App\Timeline;
6
7use App\Entity\AgentStat;
8use App\Entity\TimelineEvent;
9
10/**
11 * The timeline as a chart (agent_stat/_timeline_chart.html.twig, an SVG of WIDTH × HEIGHT): AP over
12 * time with a dot per upload, a flag per single-day event and a band per event over several days
13 * (a travel). Coordinates are in the SVG's units; the app draws the same (ui/TimelineChart.kt).
14 */
15final 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}