@props([ /** @var \App\Modules\Laboratory\DTOs\TrendSeries */ 'series', ]) @php /* | One analyte over time. | | Rendered as SVG on the server. A charting library for this would be | ~60 kB to draw a dozen points, and it would arrive after the paint — on | a screen a clinician opens to answer one question ("is this getting | better?") the chart being there on first paint is the whole point. | | ## What carries what | | Position carries in-or-out-of-range: the shaded band is the reference | range, so a point sitting above or below it *is* the finding. Colour is | therefore not spent on it — it is reserved for critical values, the one | state that needs interrupting. Critical points also get a ring and a | printed value, so the meaning never rests on colour alone. | | Both mark colours were checked against the panel surface in light and | dark: they clear the lightness band, the chroma floor, 3:1 contrast, and | separate under every simulated colour-vision deficiency. */ $points = $series->points; $count = count($points); // Plot box inside the viewBox, leaving room for the axis labels. Sizing the // box to exclude them is what produces a card with a tiny nested scrollbar. $w = 640.0; $h = 200.0; $padL = 52.0; $padR = 16.0; $padT = 14.0; $padB = 30.0; $plotW = $w - $padL - $padR; $plotH = $h - $padT - $padB; $values = array_map(static fn (array $p): float => $p['value'], $points); /* | The band is included in the extent so it is always visible. A patient | whose every reading sits above the range would otherwise get a chart | scaled to their values alone, with the "normal" zone off-screen — and | the one thing the chart exists to show is the distance from it. */ $candidates = $values; foreach ([$series->referenceLow, $series->referenceHigh] as $bound) { if ($bound !== null) { $candidates[] = $bound; } } $min = min($candidates); $max = max($candidates); $spread = $max - $min; // A flat series would divide by zero and draw on the floor; give it room // above and below so it reads as "unchanged" rather than "collapsed". if ($spread <= 0.0) { $pad = abs($max) > 0 ? abs($max) * 0.1 : 1.0; $min -= $pad; $max += $pad; $spread = $max - $min; } else { $headroom = $spread * 0.12; $min -= $headroom; $max += $headroom; $spread = $max - $min; } $y = static fn (float $value): float => $padT + $plotH - (($value - $min) / $spread) * $plotH; /* | Time-proportional, not index-proportional. Readings are taken when the | patient happens to attend: three in one week and then nothing for five | months is the actual clinical picture, and spacing the points evenly | would draw a steady decline that never happened. */ $firstTs = strtotime($points[0]['at']) ?: 0; $lastTs = strtotime($points[$count - 1]['at']) ?: 0; $span = max(1, $lastTs - $firstTs); $x = static fn (string $at): float => $padL + ((strtotime($at) ?: 0) - $firstTs) / $span * $plotW; $coords = []; foreach ($points as $point) { $coords[] = ['cx' => round($x($point['at']), 2), 'cy' => round($y($point['value']), 2)] + $point; } $line = implode(' ', array_map( static fn (array $c): string => $c['cx'].','.$c['cy'], $coords, )); $last = $coords[$count - 1]; $decimals = static function (float $value): int { return abs($value) >= 100 ? 0 : (abs($value) >= 10 ? 1 : 2); }; $fmt = static fn (float $value): string => number_format($value, $decimals($value)); $chartId = 'trend-'.$series->testId; // The band's top and bottom in plot coordinates, clamped to the box. $bandTop = $series->referenceHigh !== null ? $y($series->referenceHigh) : null; $bandBottom = $series->referenceLow !== null ? $y($series->referenceLow) : null; $summary = __('laboratory::messages.trends.chart_summary', [ 'test' => $series->name, 'count' => $count, 'first' => $fmt($points[0]['value']), 'last' => $fmt($last['value']), 'unit' => $series->unit ?? '', 'from' => $points[0]['label'], 'to' => $last['label'], ]); @endphp
{{-- One series, so no legend box: the title says what is plotted. --}}

{{ $series->name }} @if ($series->unit) {{ $series->unit }} @endif

@if ($series->referenceLow !== null || $series->referenceHigh !== null)

{{ __('laboratory::messages.trends.reference') }} {{ $series->referenceLow !== null ? $fmt($series->referenceLow) : '' }}–{{ $series->referenceHigh !== null ? $fmt($series->referenceHigh) : '' }}

@endif

{{ $fmt($last['value']) }} @if ($series->hasCritical()) {{ __('laboratory::messages.trends.critical') }} @endif

{{-- Chart and table both behind one toggle. Four of these to a row, and a clinician scanning the panel is answering "what are the numbers?" — the name, the range and the latest value do that, and they are what stays on screen. The chart answers the second question, "which way is it going?", and is asked for. A
, not a script: the CSP refuses inline handlers, and this is exactly the control the element exists for. It also means the chart is still in the page for print and for find-in-page. --}}
{{ __('laboratory::messages.trends.more') }} {{-- Chart and its axis labels travel together: open on a wide screen they become one column of a two-column layout, with the values table beside them. --}}
{{-- Scaled uniformly. Stretching the viewBox to a fixed height would turn every marker into an oval at any width whose aspect differs from the viewBox's — which is every width once these sit four-up in a grid. --}} {{-- Reference band: the normal zone, as a wash rather than an outline. --}} @if ($bandTop !== null || $bandBottom !== null) @php $top = $bandTop ?? $padT; $bottom = $bandBottom ?? ($padT + $plotH); $top = max($padT, min($top, $padT + $plotH)); $bottom = max($padT, min($bottom, $padT + $plotH)); @endphp @endif {{-- Hairline axes, solid. Dashed rules read as a threshold. --}} @foreach ($coords as $index => $c) {{-- Native tooltip on hover. It enhances; every value is also in the table below, so nothing is reachable only by pointer. --}} {{-- Each marker is a zero-length line with a round cap, not a circle. A circle's radius is in viewBox units and so shrinks with the chart — two-up in a grid it drops to about 3px, well under the 8px a pointer can reliably hit. A non-scaling stroke stays the width it is told to be at every size. The wider stroke underneath is the 2px surface ring, which keeps the marker legible where it crosses the line or another point. --}} {{ $c['label'] }} · {{ $fmt($c['value']) }} {{ $series->unit }} @endforeach {{-- Selective labels only: the latest reading, plus anything critical. A number beside every point is chaos and goes unread. --}} {{ $fmt($last['value']) }}
{{ $points[0]['label'] }} {{ $last['label'] }}
{{-- The table twin. Every chart has one: it is the WCAG-clean equivalent, and it is where the values live for anyone who cannot use the plot. No longer behind a second toggle of its own — opening the details is the one gesture, and a disclosure inside a disclosure is a click most people never make. --}}

{{ __('laboratory::messages.trends.show_values', ['count' => $count]) }}

@foreach ($points as $point) @endforeach
{{ __('laboratory::messages.trends.taken') }} {{ __('laboratory::messages.trends.value') }} {{ __('laboratory::messages.trends.flag') }} {{ __('laboratory::messages.trends.performed_by') }}
{{ $point['label'] }} {{ $fmt($point['value']) }} {{ $series->unit }} @if ($point['critical']) {{ __('laboratory::messages.trends.critical') }} @elseif ($point['flag'] && $point['flag'] !== 'normal') {{ __('laboratory::messages.flag.'.$point['flag']) }} @else — @endif {{-- A step can be a real change in the patient or a change of laboratory; the reader should be able to tell which. --}} @if ($point['lab'] !== null) {{ $point['lab'] !== '' ? $point['lab'] : __('laboratory::messages.source.external') }} @else {{ __('laboratory::messages.source.in_house') }} @endif