109 lines
4.9 KiB
PHP
109 lines
4.9 KiB
PHP
@props([
|
||
/** @var array<int, int|float|null> the series, in the order it happened */
|
||
'points' => [],
|
||
// muted where the figure is observed, accent where it can be acted on.
|
||
// Four accent charts in a row is how an accent stops meaning anything.
|
||
'tone' => 'accent',
|
||
'area' => false,
|
||
/*
|
||
| The scale. Left null, the line stretches over its own smallest and largest
|
||
| value — the classic sparkline, and the right choice when the question is
|
||
| "what shape did this take".
|
||
|
|
||
| It is the WRONG choice when the question is "how busy is this", and the
|
||
| host page proved it: a machine sitting at 0.2 % CPU, wobbling between 0.1
|
||
| and 0.3, drew a seismograph and flatly contradicted the figure printed
|
||
| beside it. Passing min=0 (and max=100 for a percentage) anchors the box so
|
||
| a calm host looks calm — the same rule the Chart.js version already
|
||
| carried as "an auto-scaled axis makes 3 % look like a wall of load".
|
||
*/
|
||
'min' => null,
|
||
'max' => null,
|
||
// Rendered size in px. The viewBox stays 96×34, so this only stretches.
|
||
'width' => 80,
|
||
'height' => 32,
|
||
])
|
||
@php
|
||
/*
|
||
| A missing sample BREAKS the line; it is not filtered out.
|
||
|
|
||
| This used to start with array_filter(…, 'is_numeric'), which threw the
|
||
| gaps away and joined their neighbours. Two things went wrong at once: the
|
||
| line claimed a measurement that was never taken, and every point after
|
||
| the gap slid left — an hour of samples with two gaps drew itself as
|
||
| fifty-eight minutes and put the newest reading somewhere in the middle.
|
||
|
|
||
| The x position is therefore taken from the point's place in the ORIGINAL
|
||
| series, and consecutive readings are collected into segments that are
|
||
| drawn as separate paths. A series with nothing missing yields exactly one
|
||
| segment and the same picture as before — which is what every existing
|
||
| caller passes.
|
||
*/
|
||
$values = array_values($points);
|
||
$numeric = array_values(array_filter($values, 'is_numeric'));
|
||
|
||
$low = $min ?? ($numeric ? min($numeric) : 0);
|
||
$high = $max ?? ($numeric ? max($numeric) : 0);
|
||
$span = max(0.0001, $high - $low);
|
||
$step = count($values) > 1 ? 96 / (count($values) - 1) : 96;
|
||
|
||
$segments = [];
|
||
$current = [];
|
||
|
||
foreach ($values as $i => $v) {
|
||
if (! is_numeric($v)) {
|
||
// A single reading on its own cannot be a line; it ends the segment
|
||
// without drawing one, rather than emitting a path of one point.
|
||
if (count($current) > 1) {
|
||
$segments[] = $current;
|
||
}
|
||
$current = [];
|
||
|
||
continue;
|
||
}
|
||
|
||
// Clamped: a value outside the given bounds belongs at the edge, not
|
||
// outside the box where it would be clipped into a straight line.
|
||
$ratio = min(1, max(0, ((float) $v - $low) / $span));
|
||
$current[] = [round($i * $step, 1), round(30 - $ratio * 26, 1)];
|
||
}
|
||
|
||
if (count($current) > 1) {
|
||
$segments[] = $current;
|
||
}
|
||
|
||
// Nur die Koordinaten, ohne Befehlsbuchstaben — der Aufrufer setzt das `M`
|
||
// oder das `L` davor. Vorher trug diese Funktion ihr `M` selbst, und im
|
||
// Flächenpfad stand damit `… L` unmittelbar vor einem `M`: gültig gelesen,
|
||
// nicht gezeichnet, und die Füllung verschwand ohne eine einzige Meldung.
|
||
$coords = fn (array $seg) => implode(' ', array_map(fn ($p) => $p[0].' '.$p[1], $seg));
|
||
|
||
// Own id per render: two gradients sharing one would be harmless, but a
|
||
// fill referring to a def that a later render replaced would not.
|
||
$fade = 'spark-fade-'.substr(md5($tone.serialize($values).$width), 0, 10);
|
||
@endphp
|
||
@if ($segments !== [])
|
||
<svg class="spark {{ $tone === 'accent' ? '' : 'muted' }}" viewBox="0 0 96 34" preserveAspectRatio="none"
|
||
style="width:{{ (int) $width }}px;height:{{ (int) $height }}px" aria-hidden="true">
|
||
@if ($area)
|
||
{{-- A fade rather than a flat tint: the fill carries the eye down to
|
||
the baseline instead of ending in a hard edge halfway up. --}}
|
||
<defs>
|
||
<linearGradient id="{{ $fade }}" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="currentColor" stop-opacity=".22" />
|
||
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
@endif
|
||
@foreach ($segments as $seg)
|
||
@if ($area)
|
||
{{-- Closed to the baseline under ITS OWN span, not under the whole
|
||
box: an area that ran from 0 to 96 would fill the gaps the
|
||
line above deliberately leaves open. --}}
|
||
<path class="area" fill="url(#{{ $fade }})" d="M{{ $seg[0][0] }} 34 L{{ $coords($seg) }} L{{ end($seg)[0] }} 34 Z" />
|
||
@endif
|
||
<path class="line" d="M{{ $coords($seg) }}" />
|
||
@endforeach
|
||
</svg>
|
||
@endif
|