Repository: napari/napari

Issue:
Scale bar extends beyond the size of canvas when zooming in
## 🐛 Bug

When you zoom in past a scale bar size of `1` the scale bar keeps extending beyond the size of the canvas. 

## To Reproduce

https://github.com/napari/napari/assets/400105/1070264d-f82c-4bfc-991d-b507cdc704d2
Caption: Screen recording of zooming in and the scale bar keeps extending once it reaches 1 instead of resizing. 

Steps to reproduce the behavior:

1. Open an image
2. View > Scale Bar > Scale Bar Visible [check]
3. Zoom in

## Expected behavior

The scale bar unit should continue getting smaller than 1. I think @andy-sweet showed a demo with units enabled and the napari-metadata plugin?

## Environment

On a feature branch that is updated to `902f5197a9e6f4752c0c673a0f4e06b01b407081` on main (~main as of June 1).






Discussion:
I can reproduce on main.
If you just enable using the menu, then you get the described behavior.
However, if you set units, e.g.:
`viewer.scale_bar.unit = "um"`
(this can only be done programmatically as far as I know and lacks documentation, see https://github.com/napari/docs/issues/38)
then it works as expected with the scale bar staying approx the same length but units changing.


Aha! Thank you @psobolewskiPhD. I saw it in the napari-metadata demo, but I remembered Andy saying it was functionality that previously existed.

In my case the data is unit-less synthetic data, so although setting the units will probably help my case for demo purposes, eventually I'd hope for something more like scientific notation in this case.
Here's the source of the issue:
https://github.com/napari/napari/blob/af7d46918a67084e6c8e927251936e417ee6d5cc/napari/utils/_units.py#L5-L21
Without units, the `1` is the smallest value possible.

I think as a good fix, we could have the default unit to be `pixel`, rather than `None`:
https://github.com/hgrecco/pint/blob/3fb63af49df803e7bcad1fe9e6bdaab04021f4cc/pint/default_en.txt#L666

Then it works fairly elegantly I think:
<img width="1269" alt="image" src="https://github.com/napari/napari/assets/76622105/16258786-0778-4c27-b5f5-07448e222d5a">

<img width="1269" alt="image" src="https://github.com/napari/napari/assets/76622105/6309624a-b151-448e-8634-434eb9a1e0d1">

I like the simplicity of @psobolewskiPhD's `pixel` default unit suggestion. 
Now it does break down if the user sets a scale for the layer:
<img width="1269" alt="image" src="https://github.com/napari/napari/assets/76622105/78f8a080-10eb-49ad-984d-4cddbf0f2620">

...but in theory then they should provide a unit because pixels are then not the relevant length?
Don't feel too strongly, but I have a mild preference for a default none-unit/dimensionless quantity over pixel because pixel is only defined with respect to a single image reference space, whereas napari could be displaying multiple images with difference reference spaces (i.e. pixel spacing) or only non-image layers.

I haven't looked too closely at `VispyScaleBarOverlay._calculate_best_length`, but I wonder if we could just skip most of the logic there when `_unit` is `None` or just have some simple calculation to get scientific notation.
I agree with @andy-sweet on this. However, since `pixel` is also used as a convention in ImageJ *and* using `pixel` as the unit solves the problem in this case, I would give this a low priority. 

However, I'm going to label it as a good first issue, because I think @andy-sweet's implementation tips could be enough information for someone to solve this and I think this can be a small effort.
Hi, gave this a check, maybe adding a validation like:

```python
        if new_value > new_quantity.magnitude:
            new_value = new_quantity.magnitude
```

After the `new_value = PREFERRED_VALUES[index]` definition at:

https://github.com/napari/napari/blob/5ce6fc9a6beb45cc819a6f4c8a9ccebdef801667/napari/_vispy/overlays/scale_bar.py#L74

Could be enough here?

With this, seems like the scale bar value is able to be lower than 1:

![scale_bar](https://github.com/napari/napari/assets/16781833/0332183a-c2aa-450a-90f0-36e113142f63)

@dalthviz oh cool! I suspect we want to use less precision on the floating points, but I'm not sure if we have a convention for numerical precision in napari.
Maybe we could just have that discussion when reviewing the PR?

Resolving patch:
diff --git a/napari/_vispy/overlays/scale_bar.py b/napari/_vispy/overlays/scale_bar.py
--- a/napari/_vispy/overlays/scale_bar.py
+++ b/napari/_vispy/overlays/scale_bar.py
@@ -1,4 +1,6 @@
 import bisect
+from decimal import Decimal
+from math import floor, log
 
 import numpy as np
 
@@ -65,13 +67,26 @@ def _calculate_best_length(self, desired_length: float):
         # that might have occurred (e.g. um -> cm)
         factor = current_quantity / new_quantity
 
-        # select value closest to one of our preferred values
-        index = bisect.bisect_left(PREFERRED_VALUES, new_quantity.magnitude)
+        # select value closest to one of our preferred values and also
+        # validate if quantity is dimensionless and lower than 1 to prevent
+        # the scale bar to extend beyond the canvas when zooming.
+        # If the value falls in those conditions, we use the corresponding
+        # prefered value but scaled to take into account the actual value
+        # magnitude. See https://github.com/napari/napari/issues/5914
+        magnitude_1000 = floor(log(new_quantity.magnitude, 1000))
+        scaled_magnitude = new_quantity.magnitude * 1000 ** (-magnitude_1000)
+        index = bisect.bisect_left(PREFERRED_VALUES, scaled_magnitude)
         if index > 0:
             # When we get the lowest index of the list, removing -1 will
             # return the last index.
             index -= 1
         new_value = PREFERRED_VALUES[index]
+        if new_quantity.dimensionless and new_quantity.magnitude < 1:
+            # using Decimal is necessary to avoid `4.999999e-6`
+            # at really small scale.
+            new_value = float(
+                Decimal(new_value) * Decimal(1000) ** magnitude_1000
+            )
 
         # get the new pixel length utilizing the user-specified units
         new_length = ((new_value * factor) / self._unit.magnitude).magnitude


Test patch: