Skip to content

Commit 3a41386

Browse files
Refactor RecentThroughputHeatmapWidget and HeatmapService for enhanced scalability and flexibility
- Updated caching key version to `v14` for invalidation logic. - Improved legend rendering with dynamic vertical padding and tick density scaling. - Refined tooltip logic for better differentiation of empty vs. feathered cells. - Optimized legend tick generation with adaptive intervals, precision formatting, and clamping logic. - Introduced adaptive tick label anchoring to ensure proper positioning within the legend bounds. - Enhanced layout styling for consistency and visual clarity.
1 parent 7971d58 commit 3a41386

File tree

3 files changed

+119
-31
lines changed

3 files changed

+119
-31
lines changed

app/Filament/Widgets/RecentThroughputHeatmapWidget.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function paletteOptions(): array
6060
protected function getViewData(): array
6161
{
6262
$cfgHash = md5(json_encode(\App\Services\Heatmap\HeatmapConfig::forCompute()));
63-
$cacheKey = sprintf('heatmap:composite:v12:%s:%s', $this->palette, $cfgHash);
63+
$cacheKey = sprintf('heatmap:composite:v14:%s:%s', $this->palette, $cfgHash);
6464

6565
$panels = Cache::remember($cacheKey, HeatmapConfig::cacheTtl(), function () {
6666
/** @var HeatmapService $svc */

app/Services/Heatmap/HeatmapService.php

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,34 @@ private function composePanel(
155155
float $clampMin,
156156
float $clampMax
157157
): array {
158+
// --- Auto-scale legend tick label density by available pixels ---
159+
// Legend column in Blade is set to 85% of the chart height.
160+
$legendPixelHeight = (int) floor($size * 0.85);
161+
162+
// Minimum vertical spacing (in px) between labels so they don’t overlap.
163+
// 16px works well for 11–12px font; tweak if your legend font changes.
164+
$minTickSpacingPx = 16;
165+
166+
// Convert available pixels to a reasonable number of intervals.
167+
// Clamp to avoid silly extremes (2..20 intervals -> 3..21 labels).
168+
$autoIntervals = max(2, min(20, (int) floor($legendPixelHeight / max($minTickSpacingPx, 1))));
169+
170+
// Allow config to override; when null/absent we use the auto value.
171+
// NOTE: HeatmapConfig::forCompute() should return 'legend_tick_intervals' as int|null.
172+
$tickIntervals = $cfg['legend_tick_intervals'] ?? null;
173+
if ($tickIntervals === null) {
174+
$tickIntervals = $autoIntervals;
175+
} else {
176+
$tickIntervals = max(1, (int) $tickIntervals);
177+
}
158178
return [
159179
'title' => $title,
160180
'xAxisTitle' => $xAxisTitle,
161181
'yAxisTitle' => $yAxisTitle,
162182
'xLabels' => $xLabels,
163183
'yLabels' => $yLabels,
164-
'xTickEvery' => $this->tickEvery($cols, 12),
165-
'yTickEvery' => $this->tickEvery($rows, 10),
184+
'xTickEvery' => $this->tickEvery($cols, $cfg['axis_x_tick_target']),
185+
'yTickEvery' => $this->tickEvery($rows, $cfg['axis_y_tick_target']),
166186
'width' => $size,
167187
'height' => $size,
168188
'gap' => $cfg['gap'],
@@ -172,7 +192,7 @@ private function composePanel(
172192
$cfg['tile'], $cfg['gap'], $cfg['color_empty']
173193
),
174194
'legendBar' => $this->legendBar($palette, $cfg['legend_steps']),
175-
'legendTicks' => $this->legendTicks($clampMin, $clampMax, 6),
195+
'legendTicks' => $this->legendTicks($clampMin, $clampMax, $tickIntervals),
176196
'tileStrokeColor' => $cfg['tile_stroke_color'],
177197
'tileStrokeWidth' => $cfg['tile_stroke_width'],
178198
];
@@ -505,6 +525,7 @@ private function buildTiles(
505525
bool $colorEmptyCells
506526
): array {
507527
$tiles = [];
528+
$epsilon = 1e-12;
508529

509530
$pitch = $tileSize + $tileGap;
510531

@@ -522,12 +543,16 @@ private function buildTiles(
522543
// we still color by feathered density but label as "feathered".
523544
if ($hasRawData) {
524545
$normalized = $this->scaleValue($densityValue, $legendMin, $legendMax, $valueScaleMode);
525-
$fillColor = HeatmapPalettes::colorAt($paletteName, $normalized);
526-
$tooltip = 'samples: '.(int) $rawCount;
527-
} elseif ($colorEmptyCells) {
546+
$fillColor = HeatmapPalettes::colorAt($paletteName, $normalized);
547+
$tooltip = 'samples: ' . (int) $rawCount;
548+
} elseif ($colorEmptyCells && $densityValue > $epsilon) {
528549
$normalized = $this->scaleValue($densityValue, $legendMin, $legendMax, $valueScaleMode);
529-
$fillColor = HeatmapPalettes::colorAt($paletteName, $normalized);
530-
$tooltip = 'feathered';
550+
$fillColor = HeatmapPalettes::colorAt($paletteName, $normalized);
551+
$tooltip = 'feathered';
552+
} else {
553+
// Force to lowest palette colour
554+
$fillColor = HeatmapPalettes::colorAt($paletteName, 0.0);
555+
$tooltip = 'no data';
531556
}
532557

533558
// Positioning:
@@ -599,28 +624,62 @@ private function legendBar(string $paletteName, int $stepCount): array
599624
* 'pos' => percentage from top (0..100), for CSS positioning,
600625
* 'value' => integer-rounded numeric value at the tick.
601626
*/
602-
private function legendTicks(float $domainMin, float $domainMax, int $legendTickIntervals): array
627+
private function legendTicks(float $domainMin, float $domainMax, int $targetIntervals): array
603628
{
629+
$span = max($domainMax - $domainMin, 1e-9);
630+
$step = $this->niceStep($span, $targetIntervals);
631+
632+
$start = $this->niceFloor($domainMin, $step);
633+
$end = $this->niceCeil($domainMax, $step);
634+
635+
$decimals = max(0, min(6, -(int) floor(log10(max($step, 1e-9)))));
636+
604637
$ticks = [];
638+
$lastLabel = null;
639+
640+
for ($v = $start; $v <= $end + 1e-9; $v += $step) {
641+
// 0..1 where 0=domainMin, 1=domainMax
642+
$t = ($v - $domainMin) / $span;
605643

606-
for ($i = 0; $i <= $legendTickIntervals; $i++) {
607-
// Fraction across the legend scale, 0..1 inclusive.
608-
$t = $i / max($legendTickIntervals, 1);
644+
// Clamp for layout; we’ll keep value/label unchanged for integrity.
645+
$tClamped = max(0.0, min(1.0, $t));
609646

610-
// Position in % from TOP:
611-
// Magic: (1 - t) * 100 flips so that larger values are at the top of a
612-
// vertical legend (CSS top origin). If your legend grows bottom→top, keep this.
613-
$positionPercentFromTop = 100 * (1 - $t);
647+
// Convert to top-from-percentage (UI has top=0 at legend's top).
648+
$posTop = 100 * (1 - $tClamped);
614649

615-
// Interpolate the numeric value for the tick.
616-
$valueAtTick = $domainMin + $t * ($domainMax - $domainMin);
650+
// Choose anchor to avoid overflow at extremes
651+
$anchor = 'middle';
652+
if ($t <= 0.0 + 1e-9) {
653+
$anchor = 'bottom'; // place label fully inside at top
654+
} elseif ($t >= 1.0 - 1e-9) {
655+
$anchor = 'top'; // place label fully inside at bottom
656+
}
657+
658+
// Format label
659+
$rounded = round($v, $decimals);
660+
if (abs($rounded) < pow(10, -$decimals)) {
661+
$rounded = 0.0;
662+
}
663+
$label = $decimals > 0 ? number_format($rounded, $decimals, '.', '') : (string) (int) round($rounded);
664+
665+
if ($label === $lastLabel) {
666+
continue;
667+
}
668+
$lastLabel = $label;
617669

618670
$ticks[] = [
619-
'pos' => $positionPercentFromTop,
620-
'value' => (int) round($valueAtTick),
671+
'pos' => $posTop, // 0..100
672+
'value' => $rounded, // numeric
673+
'label' => $label, // string
674+
'anchor' => $anchor, // 'top' | 'middle' | 'bottom'
621675
];
622676
}
623677

678+
if (!$ticks) {
679+
$ticks[] = ['pos' => 100, 'value' => $domainMin, 'label' => (string)$domainMin, 'anchor' => 'bottom'];
680+
$ticks[] = ['pos' => 0, 'value' => $domainMax, 'label' => (string)$domainMax, 'anchor' => 'top'];
681+
}
682+
624683
return $ticks;
625684
}
626685

@@ -674,7 +733,7 @@ private function emptyPanel(string $title, array $cfg): array
674733
// - Palette 'viridis' with 48 steps (smooth enough for placeholders).
675734
// - Legend ticks from 0..6 with 6 intervals → 7 ticks.
676735
'tiles' => $tiles,
677-
'legendBar' => $this->legendBar('viridis', 48),
736+
'legendBar' => $this->legendBar(HeatmapConfig::defaultPalette(), $cfg['legend_steps']),
678737
'legendTicks' => $this->legendTicks(0, 6, 6),
679738

680739
'tileStrokeColor' => $cfg['tile_stroke_color'],
@@ -1172,6 +1231,7 @@ private function legendClamp(array $densityGrid, string $scaleMode): array
11721231
case 'p99':
11731232
$legendMax = max(1.0, $p99Value);
11741233
break;
1234+
case 'adaptive':
11751235
default: // 'auto'
11761236
$legendMax = max(1.0, $p99Value);
11771237
// If there's an extreme outlier, allow expansion

resources/views/filament/widgets/recent-throughput-heatmap.blade.php

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,10 @@ class="hm-tip pointer-events-none absolute z-20 min-w-[180px] shadow-lg">
149149
</div>
150150

151151
<div class="w-16 sm:w-20 shrink-0 relative hm-legend">
152-
<div class="h-full relative" style="height: 85%;">
153-
<div class="absolute inset-y-0 left-0 w-4 sm:w-5 rounded-md overflow-hidden hm-legend-bar">
152+
<div class="h-full relative overflow-hidden hm-legend-wrap" style="--vpad:12px;">
153+
{{-- gradient bar with vertical padding --}}
154+
<div class="absolute left-0 rounded-md overflow-hidden hm-legend-bar"
155+
style="top: var(--vpad); bottom: var(--vpad); width: 1.25rem;">
154156
<div class="flex h-full w-full flex-col">
155157
@foreach ($legendBar as $color)
156158
<span style="background: {{ $color }}; height: {{ 100 / max(count($legendBar),1) }}%;"></span>
@@ -159,11 +161,29 @@ class="hm-tip pointer-events-none absolute z-20 min-w-[180px] shadow-lg">
159161
</div>
160162

161163
@foreach ($legendTicks as $tick)
162-
<div class="absolute flex items-center gap-2"
163-
style="left: calc(1.25rem + 4px); top: {{ $tick['pos'] }}%; transform: translateY(-50%);">
164-
<span class="hm-legend-line"></span>
165-
<span class="hm-legend-label">{{ $tick['value'] }}</span>
166-
</div>
164+
@php
165+
$anchor = $tick['anchor'] ?? 'middle';
166+
$translate = match($anchor) {
167+
'top' => 'translateY(0%)',
168+
'bottom' => 'translateY(-100%)',
169+
default => 'translateY(-50%)',
170+
};
171+
// keep the raw % from service (0..100 from top)
172+
$pos = (float) $tick['pos'];
173+
@endphp
174+
175+
{{-- 1px rule at the exact value, adjusted for vertical padding --}}
176+
<span class="hm-legend-line absolute"
177+
style="left: calc(1.25rem + 4px);
178+
top: calc(var(--vpad) + ({{ $pos }}% * (100% - (2 * var(--vpad))) / 100));"></span>
179+
180+
{{-- label at the same position, anchored to stay inside --}}
181+
<span class="hm-legend-label absolute"
182+
style="left: calc(1.25rem + 4px + 12px);
183+
top: calc(var(--vpad) + ({{ $pos }}% * (100% - (2 * var(--vpad))) / 100));
184+
transform: {{ $translate }};">
185+
{{ $tick['value'] }}
186+
</span>
167187
@endforeach
168188
</div>
169189

@@ -182,6 +202,15 @@ class="hm-tip pointer-events-none absolute z-20 min-w-[180px] shadow-lg">
182202
.vertical-text{ writing-mode: vertical-rl; text-orientation: mixed; }
183203
[x-cloak]{ display:none !important; }
184204
205+
.hm-legend-line{
206+
width: 8px;
207+
height: 1px;
208+
background-color: rgba(255,255,255,.5);
209+
display: inline-block;
210+
transform: translateY(-0.5px);
211+
}
212+
.hm-legend-label{ color:#fff; font-size:11px; white-space:nowrap; }
213+
185214
.hm-tip{
186215
font-size: var(--font-size, 12px);
187216
font-family: var(--ff, 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif);
@@ -200,6 +229,7 @@ class="hm-tip pointer-events-none absolute z-20 min-w-[180px] shadow-lg">
200229
--caret-size: 5px;
201230
--caret-pad: 2px;
202231
--caret-x: 12px;
232+
--vpad: 16px;
203233
background: var(--bg);
204234
color: var(--body-color);
205235
padding: var(--pad);
@@ -231,8 +261,6 @@ class="hm-tip pointer-events-none absolute z-20 min-w-[180px] shadow-lg">
231261
.hm-footer{ color: var(--footer-color); font-weight: 700; margin-top: var(--footer-mt); }
232262
233263
.hm-legend-bar{ border: 0 solid rgba(0,0,0,0); border-radius: 6px; }
234-
.hm-legend-line{ width: 8px; height: 1px; background-color: rgba(255,255,255,.5); display:inline-block; }
235-
.hm-legend-label{ color: #fff; font-size: 11px; }
236264
</style>
237265

238266
<script>

0 commit comments

Comments
 (0)