@@ -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
0 commit comments