Repository: kmnhan/erlabpy (language: python)

Issue:
Port imagetool features
Port some Igor Pro ImageTool 4 & 5 features. See the linked pull request for details.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8536725b..07d59f61 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,11 +52,17 @@ jobs:
       - name: Test with pytest with coverage
         if: matrix.python-version == '3.12' && matrix.qt-api == 'pyqt6'
         run: |
-          pytest -v --cov erlab --cov-report xml
+          pytest -v --cov erlab --junitxml=junit.xml
 
       - name: Upload coverage to Codecov
         if: matrix.python-version == '3.12' && matrix.qt-api == 'pyqt6'
-        uses: codecov/codecov-action@v4.0.1
+        uses: codecov/codecov-action@v4
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+
+      - name: Upload test results to Codecov
+        if: ${{ matrix.python-version == '3.12' && matrix.qt-api == 'pyqt6' && !cancelled() }}
+        uses: codecov/test-results-action@v1
         with:
           token: ${{ secrets.CODECOV_TOKEN }}
 
@@ -93,6 +99,7 @@ jobs:
         with:
           github_token: ${{ secrets.GITHUB_TOKEN }}
           changelog_increment_filename: body.md
+          extra_requirements : "cz-changeup"
 
       - name: Build package distributions
         id: build
@@ -116,3 +123,4 @@ jobs:
           body_path: "body.md"
           tag_name: v${{ steps.cz.outputs.version }}
           token: ${{ secrets.GITHUB_TOKEN }}
+          files: dist/*
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7f20570c..211087f7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -46,5 +46,7 @@ repos:
     rev: v3.28.0
     hooks:
       - id: commitizen
+        additional_dependencies: [ cz-changeup ]
       - id: commitizen-branch
+        additional_dependencies: [ cz-changeup ]
         stages: [push]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08b0758c..416de596 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,531 +1,702 @@
+## Unreleased
+
+### Feat
+
+- **accessors.kspace**: add method argument ([204073e](https://github.com/kmnhan/erlabpy/commit/204073e9b748bf8c86fd0cd5b6aa98acfa86d3aa))
+
+  Momentum conversion through the `convert()` method of the kspace accessor now supports an additional keyword argument `method` that can be used to choose different interpolation methods supported by `scipy.interpolate.RegularGridInterpolator`. Note that methods other than `'linear'` will take much longer to execute.
+- **analysis.interpolate**: add solver args ([24be3b0](https://github.com/kmnhan/erlabpy/commit/24be3b0d86e4418010a5a69abf354a86987c0712))
+
+  `FastInterpolator` now supports `solver` and `solver_args` keyword arguments introduced in scipy 1.13.0.
+- **interactive.colors**: implement `BetterColorBarItem` limit editor ([7dd1477](https://github.com/kmnhan/erlabpy/commit/7dd1477d694b883326a0dfc8f8e552f2b905da06))
+
+  A new context menu in `BetterColorBarItem`'s viewbox enables manually editing color limits.
+- **analysis.interpolate**: implement slicing along a vector ([cba8567](https://github.com/kmnhan/erlabpy/commit/cba85675ea68925c39ed184f062f81aaded4d37b))
+
+  A new function `slice_along_vector` has been added which enables interpolating through a line defined by a vector and a point.
+- **interactive.imagetool**: add goldtool and dtool to menu ([33d5e35](https://github.com/kmnhan/erlabpy/commit/33d5e35dc7deb5c730da21cade55fd814a24268d))
+
+  The interactive tools goldtool and dtool are now directly accessible from the right-click menu of 2D images in ImageTool.
+- **accessors.general**: add option to `qsel.around` to disable averaging ([5aaed85](https://github.com/kmnhan/erlabpy/commit/5aaed856a1d3d71771a2bbf95d8c912c5b119e99))
+- **plotting.general**: add `NonUniformImage` functionality to `plot_array` ([86d8c1a](https://github.com/kmnhan/erlabpy/commit/86d8c1a45510a41d7d7c07a1cf417a0c45efbdb9))
+
+  `plot_array` can now plot data with unevenly spaced coordinates. It uses `matplotlib.image.NonUniformImage` with the default interpolation option set to 'nearest'. The resulting plot may be different from `xarray.DataArray.plot` which uses `pcolormesh` to generate image plots.
+- **interactive.imagetool**: add copy limits to colorbar menu ([29c37c4](https://github.com/kmnhan/erlabpy/commit/29c37c45f9de516b3764d4b4ed80282955ea1f3a))
+
+  Right-clicking on the colorbar will now show a menu which contains a button that copies the current color limits to the clipboard. This is useful when manually adjusting color limits.
+
+### Fix
+
+- **interactive**: fix compatibility issue with PySide6 ([da5f4af](https://github.com/kmnhan/erlabpy/commit/da5f4af139988e38f1c9b0534a5957644e01b9aa))
+- **interactive.imagetool**: do not copy code when unnecessary ([9131029](https://github.com/kmnhan/erlabpy/commit/91310295fb9ffab994b0b681c84a4646680583b0))
+- **accessors.general**: qshow now triggers hvplot properly for 1D data ([8a84813](https://github.com/kmnhan/erlabpy/commit/8a84813e561a52eaf5ef5fc7118b992e7537b1f6))
+- **interactive.imagetool**: make manager socket use default backlog ([0ac7f0b](https://github.com/kmnhan/erlabpy/commit/0ac7f0b35b3288a62708c1c5e2c54a483e563bd7))
+- **interactive.imagetool**: ensure proper socket termination in manager ([2cceb27](https://github.com/kmnhan/erlabpy/commit/2cceb27a7d0e9db80fc39aa65340df6612587206))
+
+### Refactor
+
+- **analysis.image**: add check for NaN in input ([095554f](https://github.com/kmnhan/erlabpy/commit/095554fcde5b1e2a8540e3a2626b7b7da3e8f181))
+
+  Derivative functions now check for NaNs in input data and raise a warning.
+
+  The interactive derivative tool automatically fills NaNs in the input data with zeros and shows a warning message.
+- remove unpacking inside `np.r_` ([6c27864](https://github.com/kmnhan/erlabpy/commit/6c278648eb46378227e49fc8ed01dd822ed52217))
+- improve initial import time ([f720973](https://github.com/kmnhan/erlabpy/commit/f7209735840355c9fa6ab799b578aecf65008d31))
+
 ## v2.8.5 (2024-07-31)
 
 ### Fix
 
-- **plotting.annotations**: properly pass keyword arguments in `mark_points_outside` ([`2136939`](https://github.com/kmnhan/erlabpy/commit/2136939e09656f921aed7204ca11cc6615605b7f))
-- **plotting.annotations**: expose property label generation to public api ([`545781d`](https://github.com/kmnhan/erlabpy/commit/545781d1aa5d04b5dd3bf6d0498821d104f837ac))
+- **plotting.annotations**: properly pass keyword arguments in `mark_points_outside` ([2136939](https://github.com/kmnhan/erlabpy/commit/2136939e09656f921aed7204ca11cc6615605b7f))
+- **plotting.annotations**: expose property label generation to public api ([545781d](https://github.com/kmnhan/erlabpy/commit/545781d1aa5d04b5dd3bf6d0498821d104f837ac))
+
+  A new `property_labels` function can be used to generate strings that are used by `label_subplot_properties` so that the labels can be used as titles easily through `eplt.set_titles`. Also, label generation now recognizes time, given as 't' with default unit seconds.
 
 ## v2.8.4 (2024-07-26)
 
 ### Fix
 
-- **erlab.plotting.general**: improve `plot_array` keyword versatility ([`1dc41cd`](https://github.com/kmnhan/erlabpy/commit/1dc41cd52f8d7f879cfe54f2adf3a512b78ac007))
-- **erlab.analysis.gold**: fix `quick_fit` attribute detection ([`3797f93`](https://github.com/kmnhan/erlabpy/commit/3797f93e1a578e356ce21e7b7c933341099ab156))
-- **interactive.imagetool**: retain window title upon archiving ([`b5d8aa4`](https://github.com/kmnhan/erlabpy/commit/b5d8aa4884562ba4b53351baf58d598e27a1e757))
+- **erlab.plotting.general**: improve `plot_array` keyword versatility ([1dc41cd](https://github.com/kmnhan/erlabpy/commit/1dc41cd52f8d7f879cfe54f2adf3a512b78ac007))
+
+  Enables additional kwargs with valid data dimensions as the key to be passed onto `qsel`.
+- **erlab.analysis.gold**: fix `quick_fit` attribute detection ([3797f93](https://github.com/kmnhan/erlabpy/commit/3797f93e1a578e356ce21e7b7c933341099ab156))
+- **interactive.imagetool**: retain window title upon archiving ([b5d8aa4](https://github.com/kmnhan/erlabpy/commit/b5d8aa4884562ba4b53351baf58d598e27a1e757))
 
 ### Refactor
 
-- **plotting.general**: remove `LabeledCursor` ([`912b4fb`](https://github.com/kmnhan/erlabpy/commit/912b4fb73f88e3a529d1e3880a2253e0cb26e7ae))
-- **accessors**: split submodule ([`6ed5c03`](https://github.com/kmnhan/erlabpy/commit/6ed5c039889624d3589d9ce71a75ed6047f4406f))
-- improve type annotations ([`b242f44`](https://github.com/kmnhan/erlabpy/commit/b242f44d49239e51b4bd9e4b1ae7fd952c59e2c2))
+- **plotting.general**: remove `LabeledCursor` ([912b4fb](https://github.com/kmnhan/erlabpy/commit/912b4fb73f88e3a529d1e3880a2253e0cb26e7ae))
+
+  We skip the deprecation step since nobody is likely to be using it anyway.
+- **accessors**: split submodule ([6ed5c03](https://github.com/kmnhan/erlabpy/commit/6ed5c039889624d3589d9ce71a75ed6047f4406f))
+
+  Accessors in `utils.py` has been moved to `general.py`, so that `utils.py` only contains utilities for creating accessors.
+- improve type annotations ([b242f44](https://github.com/kmnhan/erlabpy/commit/b242f44d49239e51b4bd9e4b1ae7fd952c59e2c2))
 
 ## v2.8.3 (2024-07-08)
 
 ### Fix
 
-- **interactive.imagetool**: various fixes related to manager ([`3d3f55e`](https://github.com/kmnhan/erlabpy/commit/3d3f55e84c2837dc86592bc2f5aa68282ca44fa5))
-- **plotting.plot3d**: temporarily disable broken monkey patch ([`220f23f`](https://github.com/kmnhan/erlabpy/commit/220f23fd078a4563f0eb33371af66d5b486d34cd))
-- replace broken signature for dynamic functions ([`39a3954`](https://github.com/kmnhan/erlabpy/commit/39a39549b074055bafb93238492dc2dd3ba3c834))
-- **interactive.imagetool**: fix broken binning controls on loading fron GUI ([`0ca5437`](https://github.com/kmnhan/erlabpy/commit/0ca5437481e4b7c269acde8bb1badec1070752e7))
+- **interactive.imagetool**: various fixes related to manager ([3d3f55e](https://github.com/kmnhan/erlabpy/commit/3d3f55e84c2837dc86592bc2f5aa68282ca44fa5))
+
+  This fix incorporates many changes to the ImageTool and ImageTool Manager.
+
+  First, the archiving function of the manager now works properly, and tries to clear memory eagerly.
+
+  When opening data from a file using the GUI, the name of the file will now be displayed in the title bar of the ImageTool. This file name is also propagated to the name displayed in the manager.
+
+  Furthermore, the archiving and show/hide functionality of the manager has been updated to restore the window geometry automatically. When the user shows or unarchives a hidden or archived window, the previous position of the window  is restored.
+
+  Some icons and the layout of the manager has been modified, and tooltips has been added to the buttons.
+
+  Also, some unexpected behavior regarding linking has been resolved.
+- **plotting.plot3d**: temporarily disable broken monkey patch ([220f23f](https://github.com/kmnhan/erlabpy/commit/220f23fd078a4563f0eb33371af66d5b486d34cd))
+- replace broken signature for dynamic functions ([39a3954](https://github.com/kmnhan/erlabpy/commit/39a39549b074055bafb93238492dc2dd3ba3c834))
+- **interactive.imagetool**: fix broken binning controls on loading fron GUI ([0ca5437](https://github.com/kmnhan/erlabpy/commit/0ca5437481e4b7c269acde8bb1badec1070752e7))
 
 ### Refactor
 
-- satisfy type checker ([`042a7b1`](https://github.com/kmnhan/erlabpy/commit/042a7b1f72a9a29b93736fe1eea61f18cc8ea49d))
-- **interactive.imagetool**: add batch close button to manager ([`efc6089`](https://github.com/kmnhan/erlabpy/commit/efc6089669d73ec5ba39acbbeb08720f0543fe3e))
+- satisfy type checker ([042a7b1](https://github.com/kmnhan/erlabpy/commit/042a7b1f72a9a29b93736fe1eea61f18cc8ea49d))
+- **interactive.imagetool**: add batch close button to manager ([efc6089](https://github.com/kmnhan/erlabpy/commit/efc6089669d73ec5ba39acbbeb08720f0543fe3e))
 
 ## v2.8.2 (2024-07-01)
 
 ### Fix
 
-- **interactive.imagetool**: fix crash while linking more than 3 tools ([`d5f8a30`](https://github.com/kmnhan/erlabpy/commit/d5f8a30224f72d7159216fa5638056569521f75f))
-- update resistance loader ([`6fcf2ab`](https://github.com/kmnhan/erlabpy/commit/6fcf2abe797313ee3c21fd3cd2f4daebf412225f))
+- **interactive.imagetool**: fix crash while linking more than 3 tools ([d5f8a30](https://github.com/kmnhan/erlabpy/commit/d5f8a30224f72d7159216fa5638056569521f75f))
+- update resistance loader ([6fcf2ab](https://github.com/kmnhan/erlabpy/commit/6fcf2abe797313ee3c21fd3cd2f4daebf412225f))
 
 ### Refactor
 
-- **interactive.imagetool**: show error message in GUI when opening file ([`287a7e8`](https://github.com/kmnhan/erlabpy/commit/287a7e84e5110ac08e17d9a852b0d2b0da830e42))
+- **interactive.imagetool**: show error message in GUI when opening file ([287a7e8](https://github.com/kmnhan/erlabpy/commit/287a7e84e5110ac08e17d9a852b0d2b0da830e42))
 
 ## v2.8.1 (2024-06-21)
 
 ### Fix
 
-- **interactive.imagetool**: properly implement caching and linking from GUI ([`ffacdce`](https://github.com/kmnhan/erlabpy/commit/ffacdce93d1ff89e1be823317a6d59a400a6dee2))
-- **plotting.general**: pass DataArray to `func` argument to `plot_array` ([`ed76e64`](https://github.com/kmnhan/erlabpy/commit/ed76e64e45eb3ea93fba61380bc0d63864446fd3))
+- **interactive.imagetool**: properly implement caching and linking from GUI ([ffacdce](https://github.com/kmnhan/erlabpy/commit/ffacdce93d1ff89e1be823317a6d59a400a6dee2))
+- **plotting.general**: pass DataArray to `func` argument to `plot_array` ([ed76e64](https://github.com/kmnhan/erlabpy/commit/ed76e64e45eb3ea93fba61380bc0d63864446fd3))
 
 ### Perf
 
-- **interactive.imagetool**: speedup file loading and saving ([`a6c869b`](https://github.com/kmnhan/erlabpy/commit/a6c869b7d6ce0419d84a46086004d451845c23e3))
+- **interactive.imagetool**: speedup file loading and saving ([a6c869b](https://github.com/kmnhan/erlabpy/commit/a6c869b7d6ce0419d84a46086004d451845c23e3))
+
+  Use pickle to save and load files instead of `erlab.io.load_hdf5` and `erlab.io.save_as_hdf5`.
 
 ## v2.8.0 (2024-06-17)
 
 ### Feat
 
-- **erlab.io.plugins.ssrl52**: changes to loader ([`512a89b`](https://github.com/kmnhan/erlabpy/commit/512a89b051911c88bafd59bdc9bd993ec727321a))
-- **erlab.io.dataloader**: reorder output coordinates ([`178edd2`](https://github.com/kmnhan/erlabpy/commit/178edd27f3e58387b12b7a7928a26e87766fa9be))
-- **interactive.imagetool**: add ImageTool window manager ([`b52d249`](https://github.com/kmnhan/erlabpy/commit/b52d2490ec61053b7b933e274a68a163761827ce))
-- **interactive.imagetool**: add undo and redo ([`e7e8213`](https://github.com/kmnhan/erlabpy/commit/e7e8213964c9739468b65e6a56dcc1a0d9d648e4))
-- **interactive.imagetool**: remember last used loader for each tool ([`eb0cd2f`](https://github.com/kmnhan/erlabpy/commit/eb0cd2f41992845988f5e500416ed98f5d078c14))
+- **erlab.io.plugins.ssrl52**: changes to loader ([512a89b](https://github.com/kmnhan/erlabpy/commit/512a89b051911c88bafd59bdc9bd993ec727321a))
+
+  The loader now promotes all attributes that varies during the scan to coordinates. Also, if the energy axis is given in kinetic energy and the work function is inferrable from the data attributes, the energy values are automatically converted to binding energy. This may require changes to existing code. This commit also includes a fix for hv-dependent swept cuts.
+- **erlab.io.dataloader**: reorder output coordinates ([178edd2](https://github.com/kmnhan/erlabpy/commit/178edd27f3e58387b12b7a7928a26e87766fa9be))
+
+  Coordinates on the loaded data will now respect the order given in `name_map` and `additional_coords`, improving readability.
+- **interactive.imagetool**: add ImageTool window manager ([b52d249](https://github.com/kmnhan/erlabpy/commit/b52d2490ec61053b7b933e274a68a163761827ce))
+
+  Start the manager with the cli command `itool-manager`. While running, all calls to `erlab.interactive.imagetool.itool` will make the ImageTool open in a separate process. The behavior can be controlled with a new keyword argument, `use_manager`.
+- **interactive.imagetool**: add undo and redo ([e7e8213](https://github.com/kmnhan/erlabpy/commit/e7e8213964c9739468b65e6a56dcc1a0d9d648e4))
+
+  Adjustments made in ImageTool can now be undone with Ctrl+Z. Virtually all actions except window size change and splitter position change should be undoable. Up to 1000 recent actions are stored in memory.
+- **interactive.imagetool**: remember last used loader for each tool ([eb0cd2f](https://github.com/kmnhan/erlabpy/commit/eb0cd2f41992845988f5e500416ed98f5d078c14))
 
 ### Fix
 
-- **interactive.imagetool**: fix code generation behaviour for non-uniform coordinates ([`3652a21`](https://github.com/kmnhan/erlabpy/commit/3652a21cf126ebcde015d5b7373bf5d5a675b177))
+- **interactive.imagetool**: fix code generation behaviour for non-uniform coordinates ([3652a21](https://github.com/kmnhan/erlabpy/commit/3652a21cf126ebcde015d5b7373bf5d5a675b177))
 
 ### Refactor
 
-- **interactive.imagetool**: preparation for saving and loading state ([`eca8262`](https://github.com/kmnhan/erlabpy/commit/eca8262defe8d135168ca7da115d947bda3c1040))
+- **interactive.imagetool**: preparation for saving and loading state ([eca8262](https://github.com/kmnhan/erlabpy/commit/eca8262defe8d135168ca7da115d947bda3c1040))
 
 ## v2.7.2 (2024-06-14)
 
 ### Fix
 
-- **erlab.io**: regression in handling getattr of dataloader ([`dd0a568`](https://github.com/kmnhan/erlabpy/commit/dd0a5680c6aed6e3b7ab391a10fbeb5c3cdc9c14))
+- **erlab.io**: regression in handling getattr of dataloader ([dd0a568](https://github.com/kmnhan/erlabpy/commit/dd0a5680c6aed6e3b7ab391a10fbeb5c3cdc9c14))
 
 ## v2.7.1 (2024-06-14)
 
 ### Fix
 
-- **interactive.imagetool**: Integrate data loaders to imagetool ([`7e7ea25`](https://github.com/kmnhan/erlabpy/commit/7e7ea25a8fbe3a43222fbc7baedaa04c6522e24d))
-- **accessors.kspace**: `hv_to_kz` now accepts iterables ([`36770d7`](https://github.com/kmnhan/erlabpy/commit/36770d723b1e3592bf83750f7559603026059bb1))
+- **interactive.imagetool**: Integrate data loaders to imagetool ([7e7ea25](https://github.com/kmnhan/erlabpy/commit/7e7ea25a8fbe3a43222fbc7baedaa04c6522e24d))
+
+  A new property called `file_dialog_methods` can be set in each loader which determines the method and name that is used in the file chooser window in imagetool.
+- **accessors.kspace**: `hv_to_kz` now accepts iterables ([36770d7](https://github.com/kmnhan/erlabpy/commit/36770d723b1e3592bf83750f7559603026059bb1))
 
 ## v2.7.0 (2024-06-09)
 
 ### Feat
 
-- **analysis.gold**: add function for quick resolution fitting ([`2fae1c3`](https://github.com/kmnhan/erlabpy/commit/2fae1c351f29b2fb1ceef39a69706b3f198e4659))
-- **analysis.fit**: Add background option to `MultiPeakModel` and `MultiPeakFunction` ([`2ccd8ad`](https://github.com/kmnhan/erlabpy/commit/2ccd8ad835cbc8de9764d2f8bbadda425babddb1))
+- **analysis.gold**: add function for quick resolution fitting ([2fae1c3](https://github.com/kmnhan/erlabpy/commit/2fae1c351f29b2fb1ceef39a69706b3f198e4659))
+- **analysis.fit**: Add background option to `MultiPeakModel` and `MultiPeakFunction` ([2ccd8ad](https://github.com/kmnhan/erlabpy/commit/2ccd8ad835cbc8de9764d2f8bbadda425babddb1))
 
 ### Fix
 
-- **erlab.io.plugins**: fix for hv-dependent data ([`d52152f`](https://github.com/kmnhan/erlabpy/commit/d52152f24807b9334ad5ffcc22c45a4af7a8d9ec))
+- **erlab.io.plugins**: fix for hv-dependent data ([d52152f](https://github.com/kmnhan/erlabpy/commit/d52152f24807b9334ad5ffcc22c45a4af7a8d9ec))
 
 ## v2.6.3 (2024-06-07)
 
 ### Fix
 
-- **erlab.io.plugins**: support SSRL hv dependent data ([`1529b6a`](https://github.com/kmnhan/erlabpy/commit/1529b6a0af43f09c51691ad8bebf9208d421940a))
+- **erlab.io.plugins**: support SSRL hv dependent data ([1529b6a](https://github.com/kmnhan/erlabpy/commit/1529b6a0af43f09c51691ad8bebf9208d421940a))
 
 ### Refactor
 
-- cleanup namespace ([`847fbbe`](https://github.com/kmnhan/erlabpy/commit/847fbbe4975b507905dc85ca5ae75fe16f5f887e))
+- cleanup namespace ([847fbbe](https://github.com/kmnhan/erlabpy/commit/847fbbe4975b507905dc85ca5ae75fe16f5f887e))
 
 ## v2.6.2 (2024-06-03)
 
 ### Fix
 
-- **interactive.imagetool**: fix regression with nonuniform data ([`67df972`](https://github.com/kmnhan/erlabpy/commit/67df9720193611816e2a562ce71d080fccbbec5e))
+- **interactive.imagetool**: fix regression with nonuniform data ([67df972](https://github.com/kmnhan/erlabpy/commit/67df9720193611816e2a562ce71d080fccbbec5e))
 
 ## v2.6.1 (2024-05-30)
 
 ### Fix
 
-- re-trigger due to CI failure ([`b6d69b5`](https://github.com/kmnhan/erlabpy/commit/b6d69b556e3d0dbe6d8d71596e32d9d7cfdc5267))
+- re-trigger due to CI failure ([b6d69b5](https://github.com/kmnhan/erlabpy/commit/b6d69b556e3d0dbe6d8d71596e32d9d7cfdc5267))
 
 ## v2.6.0 (2024-05-30)
 
 ### Feat
 
-- **interactive.imagetool**: add bin amount label to binning controls ([`7a7a692`](https://github.com/kmnhan/erlabpy/commit/7a7a692b881e4cc1bd49342f31f3fe50407d72b5))
-- add accessor for selecting around a point ([`aa24457`](https://github.com/kmnhan/erlabpy/commit/aa244576fcfa17f71be0a765be8f270a6ae28080))
-- **accessors.fit**: add support for background models ([`550be2d`](https://github.com/kmnhan/erlabpy/commit/550be2deebf54fab77bef591ccbe059b5b219937))
-- **io**: make the dataloader behavior more customizable ([`4824127`](https://github.com/kmnhan/erlabpy/commit/4824127181b4383788f6dbe5cbeae4b2060f1f4f))
+- **interactive.imagetool**: add bin amount label to binning controls ([7a7a692](https://github.com/kmnhan/erlabpy/commit/7a7a692b881e4cc1bd49342f31f3fe50407d72b5))
+- add accessor for selecting around a point ([aa24457](https://github.com/kmnhan/erlabpy/commit/aa244576fcfa17f71be0a765be8f270a6ae28080))
+- **accessors.fit**: add support for background models ([550be2d](https://github.com/kmnhan/erlabpy/commit/550be2deebf54fab77bef591ccbe059b5b219937))
+
+  If one coordinate is given but there are two independent variables are present in the model,  the second one will be treated as the data. This makes the accessor compatible with y-dependent background models, such as the Shirley background provided in `lmfitxps`.
+- **io**: make the dataloader behavior more customizable ([4824127](https://github.com/kmnhan/erlabpy/commit/4824127181b4383788f6dbe5cbeae4b2060f1f4f))
+
+  Now, a new `average_attrs` class attribute exists for attributes that would be averaged over multiple file scans. The current default just takes the attributes from the first file. This also works when you wish to demote a coordinate to an attribute while averaging over its values.
+
+  For more fine-grained control of the resulting data attributes, a new method `combine_attrs` can be overridden to take care of attributes for scans over multiple files. The default behavior is to just use the attributes from the first file.
 
 ### Fix
 
-- **plotting**: make `gradient_fill` keep axis scaling ([`51507dd`](https://github.com/kmnhan/erlabpy/commit/51507dd966a0ce2db4aabff2aac8222bee184cf8))
+- **plotting**: make `gradient_fill` keep axis scaling ([51507dd](https://github.com/kmnhan/erlabpy/commit/51507dd966a0ce2db4aabff2aac8222bee184cf8))
 
 ### Refactor
 
-- **analysis.image**: add check for 2D and uniform inputs ([`22bb02d`](https://github.com/kmnhan/erlabpy/commit/22bb02dd8dfbd5eb6b5d577abe9138a769a079b3))
-- try to fix synced itool garbage collection ([`932cc5a`](https://github.com/kmnhan/erlabpy/commit/932cc5a690dcebc92c65ea3f17081ac9f9c3ef8f))
-- create utils subpackage to host internal methods ([`3fa2873`](https://github.com/kmnhan/erlabpy/commit/3fa287386fc0e94e8a558e2f0e5520be869acb43))
-- add deprecation warnings for utilities ([`5d375b8`](https://github.com/kmnhan/erlabpy/commit/5d375b8fe0766ea3f2c5fe2421937ce7309e3da5))
-- rename `erlab.interactive.utilities` to `erlab.interactive.utils` ([`d9f1fb0`](https://github.com/kmnhan/erlabpy/commit/d9f1fb081be8d2e8710ec08421780f927341b71a))
-- rename `erlab.analysis.utilities` to `erlab.analysis.utils` ([`ed81b62`](https://github.com/kmnhan/erlabpy/commit/ed81b6234bd2960da785875e0aaaf2e9e5e48f15))
-- rename `erlab.io.utilities` to `erlab.io.utils` ([`6e0813d`](https://github.com/kmnhan/erlabpy/commit/6e0813d3873b09593ec9d539d72c7512fac77c70))
-- **io.plugins.merlin**: regard temperature as coordinate ([`2fda047`](https://github.com/kmnhan/erlabpy/commit/2fda04781961f2384c711a3b1c3c00ddaecaa617))
+- **analysis.image**: add check for 2D and uniform inputs ([22bb02d](https://github.com/kmnhan/erlabpy/commit/22bb02dd8dfbd5eb6b5d577abe9138a769a079b3))
+- try to fix synced itool garbage collection ([932cc5a](https://github.com/kmnhan/erlabpy/commit/932cc5a690dcebc92c65ea3f17081ac9f9c3ef8f))
+
+  This only happens in GH actions, and it doesn't happen every time so it's hard to debug.
+- create utils subpackage to host internal methods ([3fa2873](https://github.com/kmnhan/erlabpy/commit/3fa287386fc0e94e8a558e2f0e5520be869acb43))
+
+  The parallel module is now part of utils, without a compatibiliity layer or deprecation warning since nobody is using the functions from parallel anyway.
+- add deprecation warnings for utilities ([5d375b8](https://github.com/kmnhan/erlabpy/commit/5d375b8fe0766ea3f2c5fe2421937ce7309e3da5))
+
+  All submodules named `utilities.py` have been renamed to `utils.py` for consistency. The old call to `utilities.py` will still work but will raise a warning. The modules will be removed on 3.0 release.
+- rename `erlab.interactive.utilities` to `erlab.interactive.utils` ([d9f1fb0](https://github.com/kmnhan/erlabpy/commit/d9f1fb081be8d2e8710ec08421780f927341b71a))
+- rename `erlab.analysis.utilities` to `erlab.analysis.utils` ([ed81b62](https://github.com/kmnhan/erlabpy/commit/ed81b6234bd2960da785875e0aaaf2e9e5e48f15))
+- rename `erlab.io.utilities` to `erlab.io.utils` ([6e0813d](https://github.com/kmnhan/erlabpy/commit/6e0813d3873b09593ec9d539d72c7512fac77c70))
+- **io.plugins.merlin**: regard temperature as coordinate ([2fda047](https://github.com/kmnhan/erlabpy/commit/2fda04781961f2384c711a3b1c3c00ddaecaa617))
 
 ## v2.5.4 (2024-05-23)
 
 ### Fix
 
-- **io.plugins.maestro**: load correct beta for non deflector scans ([`5324c36`](https://github.com/kmnhan/erlabpy/commit/5324c362d7bdd1dcf0c21303f370fd2e77f12448))
+- **io.plugins.maestro**: load correct beta for non deflector scans ([5324c36](https://github.com/kmnhan/erlabpy/commit/5324c362d7bdd1dcf0c21303f370fd2e77f12448))
 
 ## v2.5.3 (2024-05-22)
 
 ### Fix
 
-- **io.utilities**: `get_files` now only list files, not directories ([`60f9230`](https://github.com/kmnhan/erlabpy/commit/60f92307f94484361e0ba11b10a52be4c4cc05a1))
-- **accessors.fit**: add `make_params` call before determining param names, closes #38 ([`f1d161d`](https://github.com/kmnhan/erlabpy/commit/f1d161de089b93e16b2947b126ac075764d98f75))
-- **analysis.fit**: make some models more robust to DataArray input ([`afe5ddd`](https://github.com/kmnhan/erlabpy/commit/afe5ddd9d1e6796ba0261a147c2733d607916d81))
+- **io.utilities**: `get_files` now only list files, not directories ([60f9230](https://github.com/kmnhan/erlabpy/commit/60f92307f94484361e0ba11b10a52be4c4cc05a1))
+- **accessors.fit**: add `make_params` call before determining param names, closes #38 ([f1d161d](https://github.com/kmnhan/erlabpy/commit/f1d161de089b93e16b2947b126ac075764d98f75))
+- **analysis.fit**: make some models more robust to DataArray input ([afe5ddd](https://github.com/kmnhan/erlabpy/commit/afe5ddd9d1e6796ba0261a147c2733d607916d81))
 
 ### Refactor
 
-- add loader for ALS BL7 MAESTRO `.h5` files ([`4f33402`](https://github.com/kmnhan/erlabpy/commit/4f3340228ae2e1cbd8baf57d5d426043f5e28688))
-- **interactive**: add informative error message for missing Qt bindings ([`560615b`](https://github.com/kmnhan/erlabpy/commit/560615bb89d2646965d1a2a967133f0df08e3f6e))
-- **io**: rename some internal variables and reorder ([`76fe284`](https://github.com/kmnhan/erlabpy/commit/76fe284b4bc9f1e0c3cb94857a65599b07ee04df))
+- add loader for ALS BL7 MAESTRO `.h5` files ([4f33402](https://github.com/kmnhan/erlabpy/commit/4f3340228ae2e1cbd8baf57d5d426043f5e28688))
+- **interactive**: add informative error message for missing Qt bindings ([560615b](https://github.com/kmnhan/erlabpy/commit/560615bb89d2646965d1a2a967133f0df08e3f6e))
+- **io**: rename some internal variables and reorder ([76fe284](https://github.com/kmnhan/erlabpy/commit/76fe284b4bc9f1e0c3cb94857a65599b07ee04df))
+
+  Also added a check for astropy in FITS file related utility.
 
 ## v2.5.2 (2024-05-16)
 
 ### Fix
 
-- make mathtext copy default to svg ([`2f6e0e5`](https://github.com/kmnhan/erlabpy/commit/2f6e0e558f251c846bc3dec39cd150391802460d))
-- resolve MemoryError in prominent color estimation ([`3bdcd03`](https://github.com/kmnhan/erlabpy/commit/3bdcd0341c41b424ebbcb565b7cda0db839e4cb8))
+- make mathtext copy default to svg ([2f6e0e5](https://github.com/kmnhan/erlabpy/commit/2f6e0e558f251c846bc3dec39cd150391802460d))
+- resolve MemoryError in prominent color estimation ([3bdcd03](https://github.com/kmnhan/erlabpy/commit/3bdcd0341c41b424ebbcb565b7cda0db839e4cb8))
+
+  Due to numpy/numpy/#11879 changed the auto method to sqrt. This should also improve memory usage and speed, with little to no impact on the end result.
 
 ## v2.5.1 (2024-05-15)
 
 ### Fix
 
-- **plotting**: fixes #35 ([`a67be68`](https://github.com/kmnhan/erlabpy/commit/a67be6869c2d25780f8a56794aad0386379202dd))
-- **fit.models**: wrong StepEdgeModel guess with DataArray input ([`6778c8d`](https://github.com/kmnhan/erlabpy/commit/6778c8dd2c048b0cab67c6d3668b25b3f79a71da))
+- **plotting**: fixes #35 ([a67be68](https://github.com/kmnhan/erlabpy/commit/a67be6869c2d25780f8a56794aad0386379202dd))
+
+  Gradient fill disappears upon adding labels
+- **fit.models**: wrong StepEdgeModel guess with DataArray input ([6778c8d](https://github.com/kmnhan/erlabpy/commit/6778c8dd2c048b0cab67c6d3668b25b3f79a71da))
 
 ### Refactor
 
-- **plotting**: code cleanup ([`aef10e4`](https://github.com/kmnhan/erlabpy/commit/aef10e472a3ebc935711253e91124cfd87beb9cc))
+- **plotting**: code cleanup ([aef10e4](https://github.com/kmnhan/erlabpy/commit/aef10e472a3ebc935711253e91124cfd87beb9cc))
 
 ## v2.5.0 (2024-05-13)
 
 ### Feat
 
-- extended interactive accessor ([`f6f19ab`](https://github.com/kmnhan/erlabpy/commit/f6f19abd8edfb33585b5e19040a2ebaff39b2b70))
-- **itool**: make itool accept Datasets ([`f77b699`](https://github.com/kmnhan/erlabpy/commit/f77b699abdf312a23832611052d67e8c4c8d4930))
-- **analysis.image**: add multidimensional Savitzky-Golay filter ([`131b32d`](https://github.com/kmnhan/erlabpy/commit/131b32d9e562693e98a2f9e45cf6db4635405b44))
+- extended interactive accessor ([f6f19ab](https://github.com/kmnhan/erlabpy/commit/f6f19abd8edfb33585b5e19040a2ebaff39b2b70))
+
+  The `qshow` accessor has been updated so that it calls `hvplot` (if installed) for data not supported by ImageTool.
+
+  Also, the `qshow` accessor has been introduced to Datasets. For valid fit result datasets produced by the `modelfit` accessor, calling `qshow` will now show an `hvplot`-based interactive visualization of the fit result.
+- **itool**: make itool accept Datasets ([f77b699](https://github.com/kmnhan/erlabpy/commit/f77b699abdf312a23832611052d67e8c4c8d4930))
+
+  When a Dataset is passed to `itool`, each data variable will be shown in a separate ImageTool window.
+- **analysis.image**: add multidimensional Savitzky-Golay filter ([131b32d](https://github.com/kmnhan/erlabpy/commit/131b32d9e562693e98a2f9e45cf6db4635405b44))
 
 ### Fix
 
-- **itool**: add input data dimension check ([`984f2db`](https://github.com/kmnhan/erlabpy/commit/984f2db0f69db2b5b99211728840447d9617f8bf))
-- **analysis.image**: correct argument order parsing in some filters ([`6043413`](https://github.com/kmnhan/erlabpy/commit/60434136224c0875ed8fba41d24e32fc6868127c))
-- **interactive**: improve formatting for code copied to clipboard ([`d8b6d91`](https://github.com/kmnhan/erlabpy/commit/d8b6d91a4d2688486886f2464426935fdf8cabc2))
+- **itool**: add input data dimension check ([984f2db](https://github.com/kmnhan/erlabpy/commit/984f2db0f69db2b5b99211728840447d9617f8bf))
+- **analysis.image**: correct argument order parsing in some filters ([6043413](https://github.com/kmnhan/erlabpy/commit/60434136224c0875ed8fba41d24e32fc6868127c))
+- **interactive**: improve formatting for code copied to clipboard ([d8b6d91](https://github.com/kmnhan/erlabpy/commit/d8b6d91a4d2688486886f2464426935fdf8cabc2))
 
 ### Refactor
 
-- **plotting**: update `clean_labels` to use `Axes.label_outer` ([`0c64756`](https://github.com/kmnhan/erlabpy/commit/0c647564c6027f5b60f9ff288f13019e0e5933b6))
+- **plotting**: update `clean_labels` to use `Axes.label_outer` ([0c64756](https://github.com/kmnhan/erlabpy/commit/0c647564c6027f5b60f9ff288f13019e0e5933b6))
 
 ## v2.4.2 (2024-05-07)
 
 ### Fix
 
-- **ktool**: resolve ktool initialization problem, closes #32 ([`e88a58e`](https://github.com/kmnhan/erlabpy/commit/e88a58e6aaed326af1a68aa33322d6ea9f0e800d))
-- **itool**: disable flag checking for non-numpy arrays ([`da6eb1d`](https://github.com/kmnhan/erlabpy/commit/da6eb1db9e81d51b52d4b361de938bcf7ba45e68))
+- **ktool**: resolve ktool initialization problem, closes #32 ([e88a58e](https://github.com/kmnhan/erlabpy/commit/e88a58e6aaed326af1a68aa33322d6ea9f0e800d))
+- **itool**: disable flag checking for non-numpy arrays ([da6eb1d](https://github.com/kmnhan/erlabpy/commit/da6eb1db9e81d51b52d4b361de938bcf7ba45e68))
 
 ## v2.4.1 (2024-05-03)
 
 ### Fix
 
-- **plotting**: fix wrong regex in `scale_units` ([`d7826d0`](https://github.com/kmnhan/erlabpy/commit/d7826d0269214dfd822a4d0293e42a9840015ce8))
-- fix bug in `modelfit` parameter concatenation ([`edaa556`](https://github.com/kmnhan/erlabpy/commit/edaa5566c6e3817e1d9220f7a96e8e731cf7eede))
-- **itool**: ensure DataArray is readable on load ([`5a0ff00`](https://github.com/kmnhan/erlabpy/commit/5a0ff002802cdf5bd3ceb34f9cddc53c9674e7bd))
+- **plotting**: fix wrong regex in `scale_units` ([d7826d0](https://github.com/kmnhan/erlabpy/commit/d7826d0269214dfd822a4d0293e42a9840015ce8))
+- fix bug in `modelfit` parameter concatenation ([edaa556](https://github.com/kmnhan/erlabpy/commit/edaa5566c6e3817e1d9220f7a96e8e731cf7eede))
+- **itool**: ensure DataArray is readable on load ([5a0ff00](https://github.com/kmnhan/erlabpy/commit/5a0ff002802cdf5bd3ceb34f9cddc53c9674e7bd))
 
 ## v2.4.0 (2024-05-02)
 
 ### Feat
 
-- **imagetool**: add method to update only the values ([`ca40fe4`](https://github.com/kmnhan/erlabpy/commit/ca40fe41a0320fd7843c86f95b68f8b6e19adca8))
-- add interpolation along a path ([`7366ec4`](https://github.com/kmnhan/erlabpy/commit/7366ec4db600617e585c724d05aafea387456cf2))
+- **imagetool**: add method to update only the values ([ca40fe4](https://github.com/kmnhan/erlabpy/commit/ca40fe41a0320fd7843c86f95b68f8b6e19adca8))
+- add interpolation along a path ([7366ec4](https://github.com/kmnhan/erlabpy/commit/7366ec4db600617e585c724d05aafea387456cf2))
+
+  The `slice_along_path` function has been added to `analysis.interpolate`, which enables easy interpolation along a evenly spaced path that is specified by its vertices and step size. The path can have an arbitrary number of dimensions and points.
 
 ### Fix
 
-- **io**: remove direct display call in interactive summary ([`d44b3a5`](https://github.com/kmnhan/erlabpy/commit/d44b3a56aecfb054a38d944c5c8b7f45d362cf3b))
-- **plotting**: add some validation checks to `plot_array` ([`2e0753c`](https://github.com/kmnhan/erlabpy/commit/2e0753c90ffbe6fdd05af210ac6a4dbfa9aba899))
-- **plotting**: increase default colorbar size ([`3208399`](https://github.com/kmnhan/erlabpy/commit/32083990e9e77df6e94b2b0836bc1f9764cfaaf7))
-- delay interactive imports until called ([`ad15910`](https://github.com/kmnhan/erlabpy/commit/ad15910f921cb5ffffc388e7a5e02832935f8547))
+- **io**: remove direct display call in interactive summary ([d44b3a5](https://github.com/kmnhan/erlabpy/commit/d44b3a56aecfb054a38d944c5c8b7f45d362cf3b))
+
+  This was causing duplicated plots.
+- **plotting**: add some validation checks to `plot_array` ([2e0753c](https://github.com/kmnhan/erlabpy/commit/2e0753c90ffbe6fdd05af210ac6a4dbfa9aba899))
+
+  The functions `plot_array` and `plot_array_2d` now checks if the input array coordinates are uniformly spaced. If they are not, a warning is issued and the user is informed that the plot may not be accurate.
+- **plotting**: increase default colorbar size ([3208399](https://github.com/kmnhan/erlabpy/commit/32083990e9e77df6e94b2b0836bc1f9764cfaaf7))
+
+  The default `width` argument to `nice_colorbar` is changed to 8 points. This ensures visibility in subplots, especially when constrained layout is used.
+- delay interactive imports until called ([ad15910](https://github.com/kmnhan/erlabpy/commit/ad15910f921cb5ffffc388e7a5e02832935f8547))
 
 ### Refactor
 
-- various cleanup ([`2b38397`](https://github.com/kmnhan/erlabpy/commit/2b383970b602507b6efedbf396f14d470db60d8f))
+- various cleanup ([2b38397](https://github.com/kmnhan/erlabpy/commit/2b383970b602507b6efedbf396f14d470db60d8f))
+
+  Improve docstring formatting and tweak linter settings
 
 ## v2.3.2 (2024-04-25)
 
 ### Fix
 
-- **io**: make summary caching togglable ([`99b8e22`](https://github.com/kmnhan/erlabpy/commit/99b8e221e75db73382bf599170c58d8a68ca049e))
-- **io**: data loader related fixes ([`da08e90`](https://github.com/kmnhan/erlabpy/commit/da08e9076e59895b35c393c8e2556c3592adf4a5))
+- **io**: make summary caching togglable ([99b8e22](https://github.com/kmnhan/erlabpy/commit/99b8e221e75db73382bf599170c58d8a68ca049e))
+
+  Also fixes a bug where interactive summary plots were duplicated
+- **io**: data loader related fixes ([da08e90](https://github.com/kmnhan/erlabpy/commit/da08e9076e59895b35c393c8e2556c3592adf4a5))
+
+  DA30 dataloader now preserves case for attribute names from zip files.
+
+  Post processing for datasets now works properly
 
 ## v2.3.1 (2024-04-25)
 
 ### Fix
 
-- **interactive**: keep pointer for imagetool, fix typing issues ([`c98c38e`](https://github.com/kmnhan/erlabpy/commit/c98c38ea11bce50ed9bfd8d374064bb2b1659d0c))
+- **interactive**: keep pointer for imagetool, fix typing issues ([c98c38e](https://github.com/kmnhan/erlabpy/commit/c98c38ea11bce50ed9bfd8d374064bb2b1659d0c))
 
 ### Refactor
 
-- move `characterization` to `io` ([`9c30f1b`](https://github.com/kmnhan/erlabpy/commit/9c30f1b7df51460f502dcbf999e3fac34be1cf99))
+- move `characterization` to `io` ([9c30f1b](https://github.com/kmnhan/erlabpy/commit/9c30f1b7df51460f502dcbf999e3fac34be1cf99))
 
 ## v2.3.0 (2024-04-22)
 
 ### Feat
 
-- **kspace**: rewrite conversion with `xarray.apply_ufunc` ([`156cef8`](https://github.com/kmnhan/erlabpy/commit/156cef830582e01dc378a7437a0c85f4c7efc077))
-- **exampledata**: enable specifying seed for noise rng ([`aa542e8`](https://github.com/kmnhan/erlabpy/commit/aa542e8c288ff1ca64820960f469b2c244ca5c95))
-- **interpolate**: enable fast interpolation for 1D arrays ([`ff333a0`](https://github.com/kmnhan/erlabpy/commit/ff333a05803d7079034e36f2e1dc3d22d0b686f7))
-- make both arguments optional for loader_context ([`6780197`](https://github.com/kmnhan/erlabpy/commit/6780197f68abfe7a9edbda951d804a9bc5ba56e9))
-- **kspace**: automatically detect kinetic energy axis and convert to binding ([`bbde447`](https://github.com/kmnhan/erlabpy/commit/bbde44717155d1dd9ffefbc286da32b0bfac2180))
-- add more output and parallelization to fit accessor ([`59b35f5`](https://github.com/kmnhan/erlabpy/commit/59b35f53f3ef7f518aec92e05854dba42ddba56f))
-- add callable fit accessor using apply_ufunc ([`11e3546`](https://github.com/kmnhan/erlabpy/commit/11e35466fec158e40d0e8e738dd81ed10225d83c))
-- add option to plot_array_2d so that users can pass non-normalized color array ([`74cf961`](https://github.com/kmnhan/erlabpy/commit/74cf961532a50d9c324189318460a9f840291a85))
-- **analysis.gold**: add option to normalize energy axis in fitting ([`3dffad6`](https://github.com/kmnhan/erlabpy/commit/3dffad65993520c4b9a9a3afd6be85671bac9d3a))
+- **kspace**: rewrite conversion with `xarray.apply_ufunc` ([156cef8](https://github.com/kmnhan/erlabpy/commit/156cef830582e01dc378a7437a0c85f4c7efc077))
+
+  Momentum conversion now relies on xarray broadcasting for all computations, and objects with extra dimensions such as temperature can be automatically broadcasted.
+
+  Dask arrays can also be converted.
+- **exampledata**: enable specifying seed for noise rng ([aa542e8](https://github.com/kmnhan/erlabpy/commit/aa542e8c288ff1ca64820960f469b2c244ca5c95))
+- **interpolate**: enable fast interpolation for 1D arrays ([ff333a0](https://github.com/kmnhan/erlabpy/commit/ff333a05803d7079034e36f2e1dc3d22d0b686f7))
+- make both arguments optional for loader_context ([6780197](https://github.com/kmnhan/erlabpy/commit/6780197f68abfe7a9edbda951d804a9bc5ba56e9))
+- **kspace**: automatically detect kinetic energy axis and convert to binding ([bbde447](https://github.com/kmnhan/erlabpy/commit/bbde44717155d1dd9ffefbc286da32b0bfac2180))
+- add more output and parallelization to fit accessor ([59b35f5](https://github.com/kmnhan/erlabpy/commit/59b35f53f3ef7f518aec92e05854dba42ddba56f))
+
+  Allows dictionary of `DataArray`s as parameter to fit accessor.
+
+  Now, the return `Dataset` contains the data and the best fit array. Relevant tests have been added.
+- add callable fit accessor using apply_ufunc ([11e3546](https://github.com/kmnhan/erlabpy/commit/11e35466fec158e40d0e8e738dd81ed10225d83c))
+
+  Add a `Dataset.modelfit` and `DataArray.modelfit` accessor with similar syntax and output as `Dataset.curvefit`. Closes #22
+- add option to plot_array_2d so that users can pass non-normalized color array ([74cf961](https://github.com/kmnhan/erlabpy/commit/74cf961532a50d9c324189318460a9f840291a85))
+- **analysis.gold**: add option to normalize energy axis in fitting ([3dffad6](https://github.com/kmnhan/erlabpy/commit/3dffad65993520c4b9a9a3afd6be85671bac9d3a))
+
+  This improves performance and results when eV is large like ~100eV.
 
 ### Fix
 
-- **kspace**: allow explicit coordinate kwargs ([`fe47efc`](https://github.com/kmnhan/erlabpy/commit/fe47efcde941767c02b582ce8b29d4b3678fd843))
-- **exampledata**: change noise generation parameters ([`b213f11`](https://github.com/kmnhan/erlabpy/commit/b213f1151ed2555fc80374e9ebe3fc0856a13948))
-- **fit**: make FermiEdge2dModel compatible with flattened meshgrid-like input arrays ([`c0dba26`](https://github.com/kmnhan/erlabpy/commit/c0dba261670774862f2dfae62c770bbab81aac2f))
-- fix progress bar for parallel objects that return generators ([`23d41b3`](https://github.com/kmnhan/erlabpy/commit/23d41b31a3f3ee6c7343d471f7cec34dc374bafa))
-- **plotting**: fix 2d colormaps ([`8299576`](https://github.com/kmnhan/erlabpy/commit/8299576ce3cbcbaec106bef952c6df148bb7ca18))
+- **kspace**: allow explicit coordinate kwargs ([fe47efc](https://github.com/kmnhan/erlabpy/commit/fe47efcde941767c02b582ce8b29d4b3678fd843))
+- **exampledata**: change noise generation parameters ([b213f11](https://github.com/kmnhan/erlabpy/commit/b213f1151ed2555fc80374e9ebe3fc0856a13948))
+- **fit**: make FermiEdge2dModel compatible with flattened meshgrid-like input arrays ([c0dba26](https://github.com/kmnhan/erlabpy/commit/c0dba261670774862f2dfae62c770bbab81aac2f))
+- fix progress bar for parallel objects that return generators ([23d41b3](https://github.com/kmnhan/erlabpy/commit/23d41b31a3f3ee6c7343d471f7cec34dc374bafa))
+
+  Tqdm imports are also simplified. We no longer handle `is_notebook` ourselves, but just import from `tqdm.auto`
+- **plotting**: fix 2d colormaps ([8299576](https://github.com/kmnhan/erlabpy/commit/8299576ce3cbcbaec106bef952c6df148bb7ca18))
+
+  Allow images including nan to be plotted with gen_2d_colormap, also handle plot_array_2d colorbar aspect
 
 ### Refactor
 
-- make zip strict (ruff B905) ([`78bf5f5`](https://github.com/kmnhan/erlabpy/commit/78bf5f5a2db52c14ccf5bfd3c83659ca53c4a408))
-- fix some type hints ([`2dfa5e1`](https://github.com/kmnhan/erlabpy/commit/2dfa5e1b4582e00d0631376ee32aa7d0b1b945b6))
-- **example**: move exampledata from interactive to io ([`1fc7e6c`](https://github.com/kmnhan/erlabpy/commit/1fc7e6c22ce477fe7ebbd8b0c6844d1a85df3fcf))
-- refactor accessors as submodule ([`9fc37bd`](https://github.com/kmnhan/erlabpy/commit/9fc37bd4825de519e4c4b6e30e9e32bf9392ed2d))
-- rewrite either_dict_or_kwargs with public api ([`34953d1`](https://github.com/kmnhan/erlabpy/commit/34953d10b6fd67720b1c29dbed1ab7a24e4d3060))
-- move correct_with_edge from era.utilities to era.gold ([`08a906f`](https://github.com/kmnhan/erlabpy/commit/08a906ff61a74febc0f47ed08ac24e7a4cd0977f))
-- qsel now raises a warning upon scalar indexing outside coordinate bounds ([`d6ed628`](https://github.com/kmnhan/erlabpy/commit/d6ed628111be8ac594d3a1b83cc2785a31e3f06e))
+- make zip strict (ruff B905) ([78bf5f5](https://github.com/kmnhan/erlabpy/commit/78bf5f5a2db52c14ccf5bfd3c83659ca53c4a408))
+- fix some type hints ([2dfa5e1](https://github.com/kmnhan/erlabpy/commit/2dfa5e1b4582e00d0631376ee32aa7d0b1b945b6))
+- **example**: move exampledata from interactive to io ([1fc7e6c](https://github.com/kmnhan/erlabpy/commit/1fc7e6c22ce477fe7ebbd8b0c6844d1a85df3fcf))
+
+  Also add sample data generation for fermi edge
+- refactor accessors as submodule ([9fc37bd](https://github.com/kmnhan/erlabpy/commit/9fc37bd4825de519e4c4b6e30e9e32bf9392ed2d))
+- rewrite either_dict_or_kwargs with public api ([34953d1](https://github.com/kmnhan/erlabpy/commit/34953d10b6fd67720b1c29dbed1ab7a24e4d3060))
+- move correct_with_edge from era.utilities to era.gold ([08a906f](https://github.com/kmnhan/erlabpy/commit/08a906ff61a74febc0f47ed08ac24e7a4cd0977f))
+
+  Calling from utilities will now raise a DeprecationWarning.
+
+  The erlab.analysis namespace is unchanged, so the affect will be minimal.
+- qsel now raises a warning upon scalar indexing outside coordinate bounds ([d6ed628](https://github.com/kmnhan/erlabpy/commit/d6ed628111be8ac594d3a1b83cc2785a31e3f06e))
 
 ## v2.2.2 (2024-04-15)
 
 ### Fix
 
-- **io**: unify call signature for summarize ([`e2782c8`](https://github.com/kmnhan/erlabpy/commit/e2782c898d5aaaa1443b2bc82bb61fb40a28d232))
-- resolve failing tests due to changes in sample data generation ([`80f0045`](https://github.com/kmnhan/erlabpy/commit/80f004574950834e42dbfa7677031d0f9f113bda))
-- **interactive.exampledata**: properly generate 2D data ([`825260c`](https://github.com/kmnhan/erlabpy/commit/825260c8ceb0a79b8c071750003529b91cda3573))
+- **io**: unify call signature for summarize ([e2782c8](https://github.com/kmnhan/erlabpy/commit/e2782c898d5aaaa1443b2bc82bb61fb40a28d232))
+- resolve failing tests due to changes in sample data generation ([80f0045](https://github.com/kmnhan/erlabpy/commit/80f004574950834e42dbfa7677031d0f9f113bda))
+- **interactive.exampledata**: properly generate 2D data ([825260c](https://github.com/kmnhan/erlabpy/commit/825260c8ceb0a79b8c071750003529b91cda3573))
 
 ### Refactor
 
-- **io**: allow for more complex setups ([`f67b2e4`](https://github.com/kmnhan/erlabpy/commit/f67b2e4c7b092b7ca2db00ce02a23647879c514b))
-- **io**: provide rich interactive summary ([`b075a9e`](https://github.com/kmnhan/erlabpy/commit/b075a9ee59b61892462fc475e78b036a54408099))
-- **io**: include "Path" column in ssrl loader summary ([`ae1d8ae`](https://github.com/kmnhan/erlabpy/commit/ae1d8aee051aa71563f6a6009ce9672e56edfae7))
-- **io**: improve array formatting in summary ([`1718529`](https://github.com/kmnhan/erlabpy/commit/171852957db7fe53ff6a5c5c5f843530078d4b46))
+- **io**: allow for more complex setups ([f67b2e4](https://github.com/kmnhan/erlabpy/commit/f67b2e4c7b092b7ca2db00ce02a23647879c514b))
+
+  LoaderBase.infer_index now returns a second argument, which is a dictionary containing optional keyword arguments to load.
+- **io**: provide rich interactive summary ([b075a9e](https://github.com/kmnhan/erlabpy/commit/b075a9ee59b61892462fc475e78b036a54408099))
+- **io**: include "Path" column in ssrl loader summary ([ae1d8ae](https://github.com/kmnhan/erlabpy/commit/ae1d8aee051aa71563f6a6009ce9672e56edfae7))
+- **io**: improve array formatting in summary ([1718529](https://github.com/kmnhan/erlabpy/commit/171852957db7fe53ff6a5c5c5f843530078d4b46))
 
 ### Perf
 
-- **io**: speedup merlin summary generation by excluding duplicates ([`d6b4253`](https://github.com/kmnhan/erlabpy/commit/d6b42537ce48232b5112daef8f31e5cf86ea921a))
+- **io**: speedup merlin summary generation by excluding duplicates ([d6b4253](https://github.com/kmnhan/erlabpy/commit/d6b42537ce48232b5112daef8f31e5cf86ea921a))
 
 ## v2.2.1 (2024-04-14)
 
 ### Fix
 
-- **fit**: add sigma and amplitude expressions to MultiPeakModel parameters ([`3f6ba5e`](https://github.com/kmnhan/erlabpy/commit/3f6ba5e84922129296183e02255506df73da0276))
-- **fit.minuit**: properly handle parameters constrained with expressions ([`d03f012`](https://github.com/kmnhan/erlabpy/commit/d03f012b4fde92f445a24657dca1fb5b3600fa45))
+- **fit**: add sigma and amplitude expressions to MultiPeakModel parameters ([3f6ba5e](https://github.com/kmnhan/erlabpy/commit/3f6ba5e84922129296183e02255506df73da0276))
+- **fit.minuit**: properly handle parameters constrained with expressions ([d03f012](https://github.com/kmnhan/erlabpy/commit/d03f012b4fde92f445a24657dca1fb5b3600fa45))
 
 ### Refactor
 
-- set informative model name for MultiPeakModel ([`d14ee9d`](https://github.com/kmnhan/erlabpy/commit/d14ee9d6ac7962207700de50039a5b7a858fea6a))
-- add gaussian and lorentzian for consistency ([`07c0dfb`](https://github.com/kmnhan/erlabpy/commit/07c0dfb9ecfb882e4f5f0ccfe942c1a835b613b2))
+- set informative model name for MultiPeakModel ([d14ee9d](https://github.com/kmnhan/erlabpy/commit/d14ee9d6ac7962207700de50039a5b7a858fea6a))
+- add gaussian and lorentzian for consistency ([07c0dfb](https://github.com/kmnhan/erlabpy/commit/07c0dfb9ecfb882e4f5f0ccfe942c1a835b613b2))
 
 ## v2.2.0 (2024-04-12)
 
 ### Feat
 
-- enable component evaluation for MultiPeakModel ([`8875b74`](https://github.com/kmnhan/erlabpy/commit/8875b7443d26313156fcdcc43586d40af4ff4f00))
-- **analysis.fit**: add BCS gap equation and Dynes formula ([`f862aa4`](https://github.com/kmnhan/erlabpy/commit/f862aa4af4d2ba470f1ea074fc90442d9b18b336))
+- enable component evaluation for MultiPeakModel ([8875b74](https://github.com/kmnhan/erlabpy/commit/8875b7443d26313156fcdcc43586d40af4ff4f00))
+- **analysis.fit**: add BCS gap equation and Dynes formula ([f862aa4](https://github.com/kmnhan/erlabpy/commit/f862aa4af4d2ba470f1ea074fc90442d9b18b336))
 
 ### Fix
 
-- curvefittingtool errors ([`9abb99c`](https://github.com/kmnhan/erlabpy/commit/9abb99c35633bc722469276d4837a2372c132042))
+- curvefittingtool errors ([9abb99c](https://github.com/kmnhan/erlabpy/commit/9abb99c35633bc722469276d4837a2372c132042))
 
 ### Refactor
 
-- cleanup fit namespace ([`906aa99`](https://github.com/kmnhan/erlabpy/commit/906aa99193f78577e705218b2d6c22378611f84b))
-- rename ExtendedAffineBroadenedFD to FermiEdgeModel ([`a98aa82`](https://github.com/kmnhan/erlabpy/commit/a98aa82bcbdf22ff8a156d800e336653f9afba07))
-- **interactive**: exclude bad colormaps ([`877c915`](https://github.com/kmnhan/erlabpy/commit/877c915def6eb3dddb3862d6ac64c8c70f456ad3))
+- cleanup fit namespace ([906aa99](https://github.com/kmnhan/erlabpy/commit/906aa99193f78577e705218b2d6c22378611f84b))
+- rename ExtendedAffineBroadenedFD to FermiEdgeModel ([a98aa82](https://github.com/kmnhan/erlabpy/commit/a98aa82bcbdf22ff8a156d800e336653f9afba07))
+- **interactive**: exclude bad colormaps ([877c915](https://github.com/kmnhan/erlabpy/commit/877c915def6eb3dddb3862d6ac64c8c70f456ad3))
 
 ## v2.1.3 (2024-04-11)
 
 ### Fix
 
-- **interactive**: update data load functions used in imagetool ([`c3abe35`](https://github.com/kmnhan/erlabpy/commit/c3abe3517046ed603a9221de38b22257322d3a51))
+- **interactive**: update data load functions used in imagetool ([c3abe35](https://github.com/kmnhan/erlabpy/commit/c3abe3517046ed603a9221de38b22257322d3a51))
 
 ## v2.1.2 (2024-04-11)
 
 ### Fix
 
-- **io**: prevent specifying invalid data_dir ([`701b011`](https://github.com/kmnhan/erlabpy/commit/701b011339ecba657a0f4a14e2fef19adeb4bf2b))
-- **io**: fixes merlin summary data type resolving ([`a91ad3d`](https://github.com/kmnhan/erlabpy/commit/a91ad3d4387a23d25ac1b208cba8217e67efbec0))
-- **io**: fix summary loading ([`a5dd84a`](https://github.com/kmnhan/erlabpy/commit/a5dd84af9eec0f835b3116bc7c470e57ef3f3e02))
+- **io**: prevent specifying invalid data_dir ([701b011](https://github.com/kmnhan/erlabpy/commit/701b011339ecba657a0f4a14e2fef19adeb4bf2b))
+- **io**: fixes merlin summary data type resolving ([a91ad3d](https://github.com/kmnhan/erlabpy/commit/a91ad3d4387a23d25ac1b208cba8217e67efbec0))
+- **io**: fix summary loading ([a5dd84a](https://github.com/kmnhan/erlabpy/commit/a5dd84af9eec0f835b3116bc7c470e57ef3f3e02))
 
 ## v2.1.1 (2024-04-10)
 
 ### Fix
 
-- **io**: enable specifying data_dir in loader context manager ([`37913b8`](https://github.com/kmnhan/erlabpy/commit/37913b80a1d7c6313a5b6cc4a3ab614565274c81))
-- **io**: allow loader_class aliases to be None ([`7eae2eb`](https://github.com/kmnhan/erlabpy/commit/7eae2ebf13f972d368ddb9922a71fd3bbed014e5))
+- **io**: enable specifying data_dir in loader context manager ([37913b8](https://github.com/kmnhan/erlabpy/commit/37913b80a1d7c6313a5b6cc4a3ab614565274c81))
+- **io**: allow loader_class aliases to be None ([7eae2eb](https://github.com/kmnhan/erlabpy/commit/7eae2ebf13f972d368ddb9922a71fd3bbed014e5))
 
 ### Refactor
 
-- remove igor2 import checking ([`b64d8f7`](https://github.com/kmnhan/erlabpy/commit/b64d8f7fe22ebc1c4818e26f93f864fd402bbd05))
-- **io**: default to always_single=True ([`007bb3b`](https://github.com/kmnhan/erlabpy/commit/007bb3b2703a647856c0a85e89075cf6572d263a))
+- remove igor2 import checking ([b64d8f7](https://github.com/kmnhan/erlabpy/commit/b64d8f7fe22ebc1c4818e26f93f864fd402bbd05))
+- **io**: default to always_single=True ([007bb3b](https://github.com/kmnhan/erlabpy/commit/007bb3b2703a647856c0a85e89075cf6572d263a))
 
 ## v2.1.0 (2024-04-09)
 
 ### Feat
 
-- **interactive**: overhaul dtool ([`8e5ec38`](https://github.com/kmnhan/erlabpy/commit/8e5ec3827dd2bd52475d454d5c5ef8aef7d665aa))
-- **interactive**: improve code generation ([`7cbe857`](https://github.com/kmnhan/erlabpy/commit/7cbe8572272f6c84a486599a990098ce8e3ff754))
-- **interactive**: extend xImageItem, add right-click menu to open imagetool ([`2b5bb2d`](https://github.com/kmnhan/erlabpy/commit/2b5bb2dfc3d4173d950135306b3b30a018c6d389))
+- **interactive**: overhaul dtool ([8e5ec38](https://github.com/kmnhan/erlabpy/commit/8e5ec3827dd2bd52475d454d5c5ef8aef7d665aa))
+
+  Now supports interpolation, copying code, opening in imagetool, and 2D laplacian method.
+- **interactive**: improve code generation ([7cbe857](https://github.com/kmnhan/erlabpy/commit/7cbe8572272f6c84a486599a990098ce8e3ff754))
+
+  Automatically shortens code and allows literals in kwargs
+- **interactive**: extend xImageItem, add right-click menu to open imagetool ([2b5bb2d](https://github.com/kmnhan/erlabpy/commit/2b5bb2dfc3d4173d950135306b3b30a018c6d389))
 
 ### Fix
 
-- sign error in minimum gradient ([`c45be0c`](https://github.com/kmnhan/erlabpy/commit/c45be0cf1a025c67e8af959ff83a9339cddbaaaa))
-- **analysis.image**: normalize data for mingrad output for numerical stability ([`0fc3711`](https://github.com/kmnhan/erlabpy/commit/0fc3711a521ffb0cbb4f5206c06d923eced1200c))
+- sign error in minimum gradient ([c45be0c](https://github.com/kmnhan/erlabpy/commit/c45be0cf1a025c67e8af959ff83a9339cddbaaaa))
+- **analysis.image**: normalize data for mingrad output for numerical stability ([0fc3711](https://github.com/kmnhan/erlabpy/commit/0fc3711a521ffb0cbb4f5206c06d923eced1200c))
 
 ### Refactor
 
-- **io**: validation now defaults to warning instead of raising an error ([`8867a07`](https://github.com/kmnhan/erlabpy/commit/8867a07304129beda749fa82d3909bf920fdb975))
+- **io**: validation now defaults to warning instead of raising an error ([8867a07](https://github.com/kmnhan/erlabpy/commit/8867a07304129beda749fa82d3909bf920fdb975))
 
 ## v2.0.0 (2024-04-08)
 
 ### BREAKING CHANGE
 
-- `PolyFunc` is now `PolynomialFunction`, and `FermiEdge2dFunc` is now `FermiEdge2dFunction`. The corresponding model names are unchanged. ([`20d784c`](https://github.com/kmnhan/erlabpy/commit/20d784c1d8fdcd786ab73b3ae03d3e331dc04df5))
-- This change disables the use of guess_fit. All fitting must be performed in the syntax recommended by lmfit. Addition of a accessor or a convenience function for coordinate-aware fitting is planned in the next release. ([`59163d5`](https://github.com/kmnhan/erlabpy/commit/59163d5f0e000d65aa53690a51b6db82df1ce5f1))
+- `PolyFunc` is now `PolynomialFunction`, and `FermiEdge2dFunc` is now `FermiEdge2dFunction`. The corresponding model names are unchanged. ([20d784c](https://github.com/kmnhan/erlabpy/commit/20d784c1d8fdcd786ab73b3ae03d3e331dc04df5))
+
+  BREAKING CHANGE: `PolyFunc` is now `PolynomialFunction`, and `FermiEdge2dFunc` is now `FermiEdge2dFunction`. The corresponding model names are unchanged.
+- This change disables the use of guess_fit. All fitting must be performed in the syntax recommended by lmfit. Addition of a accessor or a convenience function for coordinate-aware fitting is planned in the next release. ([59163d5](https://github.com/kmnhan/erlabpy/commit/59163d5f0e000d65aa53690a51b6db82df1ce5f1))
+
+  BREAKING CHANGE: This change disables the use of guess_fit. All fitting must be performed in the syntax recommended by lmfit. Addition of a accessor or a convenience function for coordinate-aware fitting is planned in the next release.
 
 ### Feat
 
-- **itool**: add copy code to PlotItem vb menu ([`7b4f30a`](https://github.com/kmnhan/erlabpy/commit/7b4f30ada21c5accc1d3824ad3d0f8097f9a99c1))
-- add 2D curvature, finally closes #14 ([`7fe95ff`](https://github.com/kmnhan/erlabpy/commit/7fe95ffcdf0531e456cfc97ae605467e4ae433c0))
-- **plotting**: add N argument to plot_array_2d ([`2cd79f7`](https://github.com/kmnhan/erlabpy/commit/2cd79f7ee007058da09aff244cd75748698444ee))
-- add scaled laplace ([`079e1d2`](https://github.com/kmnhan/erlabpy/commit/079e1d21201c7523877b06a0f04f7640027b0614))
-- add gaussian filter and laplacian ([`8628d33`](https://github.com/kmnhan/erlabpy/commit/8628d336ff5b4219e4fd382293736e4cbf026d56))
-- add derivative module with minimum gradient implementation ([`e0eabde`](https://github.com/kmnhan/erlabpy/commit/e0eabde60e6860c3827959b45be6d4f491918363))
-- **fit**: directly base models on lmfit.Model ([`59163d5`](https://github.com/kmnhan/erlabpy/commit/59163d5f0e000d65aa53690a51b6db82df1ce5f1))
+- **itool**: add copy code to PlotItem vb menu ([7b4f30a](https://github.com/kmnhan/erlabpy/commit/7b4f30ada21c5accc1d3824ad3d0f8097f9a99c1))
+
+  For each plot in imagetool, a new 'copy selection code' button has been added to the right-click menu that copies the code that can slice the data to recreate the data shown in the plot.
+- add 2D curvature, finally closes #14 ([7fe95ff](https://github.com/kmnhan/erlabpy/commit/7fe95ffcdf0531e456cfc97ae605467e4ae433c0))
+- **plotting**: add N argument to plot_array_2d ([2cd79f7](https://github.com/kmnhan/erlabpy/commit/2cd79f7ee007058da09aff244cd75748698444ee))
+- add scaled laplace ([079e1d2](https://github.com/kmnhan/erlabpy/commit/079e1d21201c7523877b06a0f04f7640027b0614))
+- add gaussian filter and laplacian ([8628d33](https://github.com/kmnhan/erlabpy/commit/8628d336ff5b4219e4fd382293736e4cbf026d56))
+- add derivative module with minimum gradient implementation ([e0eabde](https://github.com/kmnhan/erlabpy/commit/e0eabde60e6860c3827959b45be6d4f491918363))
+- **fit**: directly base models on lmfit.Model ([59163d5](https://github.com/kmnhan/erlabpy/commit/59163d5f0e000d65aa53690a51b6db82df1ce5f1))
+
+  BREAKING CHANGE: This change disables the use of guess_fit. All fitting must be performed in the syntax recommended by lmfit. Addition of a accessor or a convenience function for coordinate-aware fitting is planned in the next release.
 
 ### Fix
 
-- **dynamic**: properly broadcast xarray input ([`2f6672f`](https://github.com/kmnhan/erlabpy/commit/2f6672f3b003792ecd98b4fbc99fb11fcc0efb8b))
-- **fit.functions**: polynomial function now works for xarray input ([`3eb80de`](https://github.com/kmnhan/erlabpy/commit/3eb80dea31b6414fa9a694049b92b7334a4e10f5))
-- **analysis.image**: remove critical typo ([`fb7de0f`](https://github.com/kmnhan/erlabpy/commit/fb7de0fc3ba9049c488a90bef8ee3c4feb935341))
-- **analysis.image**: dtype safety of cfunc ([`b4f9b17`](https://github.com/kmnhan/erlabpy/commit/b4f9b17656c64be4cff876843ed0f3491d8310d4))
-- set autodownsample off for colorbar ([`256bf2d`](https://github.com/kmnhan/erlabpy/commit/256bf2dc8c368d093a3578d7f9279b1ee4653534))
-- disable itool downsample ([`e626bba`](https://github.com/kmnhan/erlabpy/commit/e626bba9fcd4fd31387ca3a07a9a33b7690f3645))
+- **dynamic**: properly broadcast xarray input ([2f6672f](https://github.com/kmnhan/erlabpy/commit/2f6672f3b003792ecd98b4fbc99fb11fcc0efb8b))
+- **fit.functions**: polynomial function now works for xarray input ([3eb80de](https://github.com/kmnhan/erlabpy/commit/3eb80dea31b6414fa9a694049b92b7334a4e10f5))
+- **analysis.image**: remove critical typo ([fb7de0f](https://github.com/kmnhan/erlabpy/commit/fb7de0fc3ba9049c488a90bef8ee3c4feb935341))
+- **analysis.image**: dtype safety of cfunc ([b4f9b17](https://github.com/kmnhan/erlabpy/commit/b4f9b17656c64be4cff876843ed0f3491d8310d4))
+- set autodownsample off for colorbar ([256bf2d](https://github.com/kmnhan/erlabpy/commit/256bf2dc8c368d093a3578d7f9279b1ee4653534))
+- disable itool downsample ([e626bba](https://github.com/kmnhan/erlabpy/commit/e626bba9fcd4fd31387ca3a07a9a33b7690f3645))
 
 ### Refactor
 
-- **fit**: unify dynamic function names ([`20d784c`](https://github.com/kmnhan/erlabpy/commit/20d784c1d8fdcd786ab73b3ae03d3e331dc04df5))
-- update dtool to use new functions ([`a6e46bb`](https://github.com/kmnhan/erlabpy/commit/a6e46bb8b19512e438291afbbd5e0e9a4eb4fe87))
-- **analysis.image**: add documentation and reorder functions ([`340665d`](https://github.com/kmnhan/erlabpy/commit/340665dc507a99acc7d56c46a2a2326fbb56b1e3))
-- rename module to image and add citation ([`b74a654`](https://github.com/kmnhan/erlabpy/commit/b74a654e07d9f4522cee2db0b897f1ffcdb86e94))
-- **dtool**: cleanup unused code ([`f4abd34`](https://github.com/kmnhan/erlabpy/commit/f4abd34bbf3130c0ec0fd2f9c830c8da43849f13))
+- **fit**: unify dynamic function names ([20d784c](https://github.com/kmnhan/erlabpy/commit/20d784c1d8fdcd786ab73b3ae03d3e331dc04df5))
+
+  BREAKING CHANGE: `PolyFunc` is now `PolynomialFunction`, and `FermiEdge2dFunc` is now `FermiEdge2dFunction`. The corresponding model names are unchanged.
+- update dtool to use new functions ([a6e46bb](https://github.com/kmnhan/erlabpy/commit/a6e46bb8b19512e438291afbbd5e0e9a4eb4fe87))
+- **analysis.image**: add documentation and reorder functions ([340665d](https://github.com/kmnhan/erlabpy/commit/340665dc507a99acc7d56c46a2a2326fbb56b1e3))
+- rename module to image and add citation ([b74a654](https://github.com/kmnhan/erlabpy/commit/b74a654e07d9f4522cee2db0b897f1ffcdb86e94))
+- **dtool**: cleanup unused code ([f4abd34](https://github.com/kmnhan/erlabpy/commit/f4abd34bbf3130c0ec0fd2f9c830c8da43849f13))
 
 ### Perf
 
-- **itool**: add explicit signatures to fastbinning ([`62e1d51`](https://github.com/kmnhan/erlabpy/commit/62e1d516f0260f661fe9cd8f1fae9cb81afbcabe))
+- **itool**: add explicit signatures to fastbinning ([62e1d51](https://github.com/kmnhan/erlabpy/commit/62e1d516f0260f661fe9cd8f1fae9cb81afbcabe))
+
+  Speedup initial binning by providing explicit signatures.
 
 ## v1.6.5 (2024-04-03)
 
 ### Fix
 
-- make imports work without optional pip dependencies ([`b8ac11d`](https://github.com/kmnhan/erlabpy/commit/b8ac11d8fb4379f70a39c817332382c352391a64))
+- make imports work without optional pip dependencies ([b8ac11d](https://github.com/kmnhan/erlabpy/commit/b8ac11d8fb4379f70a39c817332382c352391a64))
 
 ## v1.6.4 (2024-04-03)
 
 ### Fix
 
-- load colormaps only when igor2 is  available ([`7927c7d`](https://github.com/kmnhan/erlabpy/commit/7927c7db264bedb1a27b980d820d352f779b64c9))
+- load colormaps only when igor2 is  available ([7927c7d](https://github.com/kmnhan/erlabpy/commit/7927c7db264bedb1a27b980d820d352f779b64c9))
 
 ## v1.6.3 (2024-04-03)
 
 ### Fix
 
-- leave out type annotation for passing tests ([`eb25008`](https://github.com/kmnhan/erlabpy/commit/eb2500838820172529ee751b5d8a624c950f66d2))
+- leave out type annotation for passing tests ([eb25008](https://github.com/kmnhan/erlabpy/commit/eb2500838820172529ee751b5d8a624c950f66d2))
 
 ## v1.6.2 (2024-04-03)
 
 ### Fix
 
-- igor2 does not have to be installed on import time ([`186727a`](https://github.com/kmnhan/erlabpy/commit/186727ac8d50b662efeba8bee567cf1013ca936a))
+- igor2 does not have to be installed on import time ([186727a](https://github.com/kmnhan/erlabpy/commit/186727ac8d50b662efeba8bee567cf1013ca936a))
 
 ## v1.6.1 (2024-04-03)
 
 ### Fix
 
-- remove all pypi dependencies from pyproject.toml ([`1b2fd55`](https://github.com/kmnhan/erlabpy/commit/1b2fd5594f00bba8367419cd00919eba45cde5a7))
+- remove all pypi dependencies from pyproject.toml ([1b2fd55](https://github.com/kmnhan/erlabpy/commit/1b2fd5594f00bba8367419cd00919eba45cde5a7))
 
 ### Refactor
 
-- remove ktool_old ([`18ea072`](https://github.com/kmnhan/erlabpy/commit/18ea0723fdf538bdbf2789ca73b2b962839ca3e5))
+- remove ktool_old ([18ea072](https://github.com/kmnhan/erlabpy/commit/18ea0723fdf538bdbf2789ca73b2b962839ca3e5))
 
 ## v1.6.0 (2024-04-02)
 
 ### Feat
 
-- add mdctool ([`a4976f9`](https://github.com/kmnhan/erlabpy/commit/a4976f93cde51a41d667321a93dc2a90f23bddc3))
+- add mdctool ([a4976f9](https://github.com/kmnhan/erlabpy/commit/a4976f93cde51a41d667321a93dc2a90f23bddc3))
 
 ### Refactor
 
-- remove deprecated function and dependencies ([`4b9c7b1`](https://github.com/kmnhan/erlabpy/commit/4b9c7b1629d99fbf0108ca33791d1bfd59632199))
+- remove deprecated function and dependencies ([4b9c7b1](https://github.com/kmnhan/erlabpy/commit/4b9c7b1629d99fbf0108ca33791d1bfd59632199))
 
 ## v1.5.2 (2024-04-01)
 
 ### Fix
 
-- set values after setting bounds ([`ab6d682`](https://github.com/kmnhan/erlabpy/commit/ab6d682d0afafefcaec4c1ab6d673a39a75f40a6))
-- proper patch all interpolator selection functions ([`b91834e`](https://github.com/kmnhan/erlabpy/commit/b91834e1b0be200bafb86ed3581f08cf1a5d42ef))
-- make bz voronoi robust ([`8259760`](https://github.com/kmnhan/erlabpy/commit/8259760249be45892cd32f143b1b83aefe166c49))
+- set values after setting bounds ([ab6d682](https://github.com/kmnhan/erlabpy/commit/ab6d682d0afafefcaec4c1ab6d673a39a75f40a6))
+- proper patch all interpolator selection functions ([b91834e](https://github.com/kmnhan/erlabpy/commit/b91834e1b0be200bafb86ed3581f08cf1a5d42ef))
+- make bz voronoi robust ([8259760](https://github.com/kmnhan/erlabpy/commit/8259760249be45892cd32f143b1b83aefe166c49))
 
 ### Refactor
 
-- remove debug print statement in FastInterpolator class ([`712bd2c`](https://github.com/kmnhan/erlabpy/commit/712bd2ce90ad3534212d8a63c3fe10d780e243f5))
-- add edge correction ([`87adcef`](https://github.com/kmnhan/erlabpy/commit/87adceffda2364f404de0860bfe8bf36b4cc1394))
-- change variable name ([`b68949e`](https://github.com/kmnhan/erlabpy/commit/b68949ec59fd6bd7d7dad4ff9cc232b0e1ce4fba))
-- make rotation transformations try fast interpolator first ([`e0a7908`](https://github.com/kmnhan/erlabpy/commit/e0a790833025f0c7e952ad17d120f46de3100555))
-- update warning message ([`af67c1a`](https://github.com/kmnhan/erlabpy/commit/af67c1a507be35348b58862b6b51b92fac52781b))
-- add several new accessors ([`664e92a`](https://github.com/kmnhan/erlabpy/commit/664e92a3e171512be26ea957df945e84134c880a))
-- use new accessors and attrs ([`8e1dee2`](https://github.com/kmnhan/erlabpy/commit/8e1dee22d9d716f7e9bce29a1be3e68311494aa1))
-- add qplot accessor ([`cb9aa01`](https://github.com/kmnhan/erlabpy/commit/cb9aa017bebd2ee6661f4eb87b988509d28a37a5))
-- remove annotate_cuts ([`004ee80`](https://github.com/kmnhan/erlabpy/commit/004ee808dab13073cb3d2021d331767f6c28388a))
-- dataloader cleanup ([`fd97780`](https://github.com/kmnhan/erlabpy/commit/fd977800a504256afd6018e9991b2d1e996277df))
+- remove debug print statement in FastInterpolator class ([712bd2c](https://github.com/kmnhan/erlabpy/commit/712bd2ce90ad3534212d8a63c3fe10d780e243f5))
+- add edge correction ([87adcef](https://github.com/kmnhan/erlabpy/commit/87adceffda2364f404de0860bfe8bf36b4cc1394))
+- change variable name ([b68949e](https://github.com/kmnhan/erlabpy/commit/b68949ec59fd6bd7d7dad4ff9cc232b0e1ce4fba))
+- make rotation transformations try fast interpolator first ([e0a7908](https://github.com/kmnhan/erlabpy/commit/e0a790833025f0c7e952ad17d120f46de3100555))
+- update warning message ([af67c1a](https://github.com/kmnhan/erlabpy/commit/af67c1a507be35348b58862b6b51b92fac52781b))
+- add several new accessors ([664e92a](https://github.com/kmnhan/erlabpy/commit/664e92a3e171512be26ea957df945e84134c880a))
+- use new accessors and attrs ([8e1dee2](https://github.com/kmnhan/erlabpy/commit/8e1dee22d9d716f7e9bce29a1be3e68311494aa1))
+- add qplot accessor ([cb9aa01](https://github.com/kmnhan/erlabpy/commit/cb9aa017bebd2ee6661f4eb87b988509d28a37a5))
+- remove annotate_cuts ([004ee80](https://github.com/kmnhan/erlabpy/commit/004ee808dab13073cb3d2021d331767f6c28388a))
+- dataloader cleanup ([fd97780](https://github.com/kmnhan/erlabpy/commit/fd977800a504256afd6018e9991b2d1e996277df))
 
 ## v1.5.1 (2024-03-28)
 
 ### Fix
 
-- restore argname detection that was broken with namespace changes ([`863b702`](https://github.com/kmnhan/erlabpy/commit/863b702b6373f9a219a1e770aa49c71145371681))
-- namespace collision ([`10edcdc`](https://github.com/kmnhan/erlabpy/commit/10edcdc8b06425c380ca6caa2d3f5f2be5c13733))
-- followup namespace change ([`4c5222c`](https://github.com/kmnhan/erlabpy/commit/4c5222cc93196f0b6a75a0101107a37e73748eeb))
+- restore argname detection that was broken with namespace changes ([863b702](https://github.com/kmnhan/erlabpy/commit/863b702b6373f9a219a1e770aa49c71145371681))
+- namespace collision ([10edcdc](https://github.com/kmnhan/erlabpy/commit/10edcdc8b06425c380ca6caa2d3f5f2be5c13733))
+- followup namespace change ([4c5222c](https://github.com/kmnhan/erlabpy/commit/4c5222cc93196f0b6a75a0101107a37e73748eeb))
 
 ### Refactor
 
-- allow offsetview upate chaining ([`8d5ca4f`](https://github.com/kmnhan/erlabpy/commit/8d5ca4f5b12c7d7060ea444773a9851f23db9850))
-- improve consistency in accessors ([`9596fd7`](https://github.com/kmnhan/erlabpy/commit/9596fd723206f3e992fe00990f73364a61604cd6))
-- make prints consistent ([`0021302`](https://github.com/kmnhan/erlabpy/commit/002130224e3efc01615948a6443516e29d333cf5))
-- change module names to prevent conflict with function names ([`493a5aa`](https://github.com/kmnhan/erlabpy/commit/493a5aab19c0d66851ca068e286a6aec92131e33))
-- follow class naming conventions ([`efb9610`](https://github.com/kmnhan/erlabpy/commit/efb9610a864ef637f424c2f1b2871add7324b090))
+- allow offsetview upate chaining ([8d5ca4f](https://github.com/kmnhan/erlabpy/commit/8d5ca4f5b12c7d7060ea444773a9851f23db9850))
+
+  This also means that _repr_html_ is automatically displayed when update or reset is called.
+- improve consistency in accessors ([9596fd7](https://github.com/kmnhan/erlabpy/commit/9596fd723206f3e992fe00990f73364a61604cd6))
+
+  Added setter method for configuration too.
+- make prints consistent ([0021302](https://github.com/kmnhan/erlabpy/commit/002130224e3efc01615948a6443516e29d333cf5))
+- change module names to prevent conflict with function names ([493a5aa](https://github.com/kmnhan/erlabpy/commit/493a5aab19c0d66851ca068e286a6aec92131e33))
+
+  Cleanup erplot namespace and move tools to interactive.
+- follow class naming conventions ([efb9610](https://github.com/kmnhan/erlabpy/commit/efb9610a864ef637f424c2f1b2871add7324b090))
 
 ## v1.5.0 (2024-03-27)
 
 ### Feat
 
-- add interactive tool to kspace accessor ([`fb91cdb`](https://github.com/kmnhan/erlabpy/commit/fb91cdb50229154c070df8dfaa80cddc8520ae6d))
+- add interactive tool to kspace accessor ([fb91cdb](https://github.com/kmnhan/erlabpy/commit/fb91cdb50229154c070df8dfaa80cddc8520ae6d))
 
 ### Refactor
 
-- accessors are now registered upon package import ([`d79fee2`](https://github.com/kmnhan/erlabpy/commit/d79fee2a28dd5ee59bfc6bd1ce224a44c5f40a24))
+- accessors are now registered upon package import ([d79fee2](https://github.com/kmnhan/erlabpy/commit/d79fee2a28dd5ee59bfc6bd1ce224a44c5f40a24))
 
 ## v1.4.1 (2024-03-26)
 
 ### Fix
 
-- update package metadata ([`ecfb88f`](https://github.com/kmnhan/erlabpy/commit/ecfb88f2c23a7681e12d6f2dedcc316a28aa22c7))
+- update package metadata ([ecfb88f](https://github.com/kmnhan/erlabpy/commit/ecfb88f2c23a7681e12d6f2dedcc316a28aa22c7))
+
+  This should be classified as chore, but commiting as a fix to trigger CI
 
 ## v1.4.0 (2024-03-26)
 
 ### Feat
 
-- calculate kz in MomentumAccessor ([`46979f9`](https://github.com/kmnhan/erlabpy/commit/46979f907b120e5a4a88fdacd7d74a4b9dd41d6d))
-- make momentum conversion functions xarray compatible ([`a7aa34b`](https://github.com/kmnhan/erlabpy/commit/a7aa34ba983d3159c555ed66579d46eaf9e993aa))
+- calculate kz in MomentumAccessor ([46979f9](https://github.com/kmnhan/erlabpy/commit/46979f907b120e5a4a88fdacd7d74a4b9dd41d6d))
+
+  Add method that calculates kz array from given photon energy float
+- make momentum conversion functions xarray compatible ([a7aa34b](https://github.com/kmnhan/erlabpy/commit/a7aa34ba983d3159c555ed66579d46eaf9e993aa))
 
 ## v1.3.1 (2024-03-25)
 
 ### Fix
 
-- fixes #12 ([`02b49a1`](https://github.com/kmnhan/erlabpy/commit/02b49a1da7550ae2b07819e6ccde3dcf750fc527))
+- fixes #12 ([02b49a1](https://github.com/kmnhan/erlabpy/commit/02b49a1da7550ae2b07819e6ccde3dcf750fc527))
 
 ## v1.3.0 (2024-03-25)
 
 ### Feat
 
-- **io**: add new data loader plugin for DA30 + SES ([`7a27a2f`](https://github.com/kmnhan/erlabpy/commit/7a27a2f27d9658f1091aaa48bcc78dea562898d8))
+- **io**: add new data loader plugin for DA30 + SES ([7a27a2f](https://github.com/kmnhan/erlabpy/commit/7a27a2f27d9658f1091aaa48bcc78dea562898d8))
 
 ### Fix
 
-- **io**: properly handle registry getattr ([`499526f`](https://github.com/kmnhan/erlabpy/commit/499526fc1705bfbfbf8d3b80d50d65450dec7eae))
+- **io**: properly handle registry getattr ([499526f](https://github.com/kmnhan/erlabpy/commit/499526fc1705bfbfbf8d3b80d50d65450dec7eae))
+
+  This fixes an issue where _repr_html_ will fallback to __repr__.
+
+  Additionally, `get` will now raise a KeyError instead of a ValueError.
diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css
new file mode 100644
index 00000000..f088d8f3
--- /dev/null
+++ b/docs/source/_static/custom.css
@@ -0,0 +1,9 @@
+dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list>dt {
+  font-size: var(--font-size--small);
+  text-transform: uppercase;
+  background-color: var(--color-background-secondary);
+}
+
+span.default_value {
+  font-weight: normal;
+}
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f7625a98..e8476066 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -37,27 +37,27 @@
 extensions = [
     "sphinx.ext.autodoc",
     "sphinx.ext.autosummary",
+    "sphinx.ext.intersphinx",
+    "sphinx.ext.mathjax",
     "sphinx.ext.napoleon",
-    # "sphinx_autodoc_typehints",
+    "sphinx_autodoc_typehints",
+    # "IPython.sphinxext.ipython_directive",
+    # "IPython.sphinxext.ipython_console_highlighting",
+    # "sphinx.ext.inheritance_diagram",
+    "nbsphinx",
     "sphinx.ext.linkcode",
-    "sphinx.ext.mathjax",
-    "sphinx.ext.intersphinx",
+    "sphinx_copybutton",
+    "sphinx_design",
     "matplotlib.sphinxext.plot_directive",
     "matplotlib.sphinxext.figmpl_directive",
     "matplotlib.sphinxext.roles",
-    # "IPython.sphinxext.ipython_directive",
-    # "IPython.sphinxext.ipython_console_highlighting",
-    # "sphinx.ext.inheritance_diagram",
     "sphinxcontrib.bibtex",
     "sphinx_qt_documentation",
-    "sphinx_copybutton",
-    "nbsphinx",
-    "sphinx_design",
 ]
 
 
-templates_path = ["_templates"]
-exclude_patterns = []
+# templates_path = ["_templates"]
+# exclude_patterns = []
 
 default_role = "obj"
 
@@ -128,11 +128,15 @@ def linkcode_resolve(domain, info) -> str | None:
 }
 autodoc_typehints = "description"
 autodoc_typehints_description_target = "documented"
-# autodoc_type_aliases = {}
 autodoc_typehints_format = "short"
 autodoc_preserve_defaults = True
 autodoc_inherit_docstrings = False
 
+# -- sphinx_autodoc_typehints settings ---------------------------------------
+always_use_bars_union = True
+typehints_defaults = "comma"
+simplify_optional_unions = False
+typehints_document_rtype = False  # handled by napoleon
 
 # -- Napoleon settings -------------------------------------------------------
 
@@ -147,9 +151,8 @@ def linkcode_resolve(domain, info) -> str | None:
 napoleon_use_ivar = True
 napoleon_use_param = True
 napoleon_use_keyword = False
-# napoleon_use_rtype = True
+napoleon_use_rtype = False
 napoleon_preprocess_types = True
-
 napoleon_type_aliases = {
     "np.float32": "float32",
     "numpy.float32": "float32",
@@ -157,12 +160,10 @@ def linkcode_resolve(domain, info) -> str | None:
     "numpy.float64": "float64",
     "xr.DataArray": "xarray.DataArray",
     "array-like": "`array-like <numpy.typing.ArrayLike>`",
-    "array_like": "`array-like <numpy.typing.ArrayLike>`",
     "ColorType": "`ColorType <matplotlib.typing.ColorType>`",
     "RGBColorType": "`RGBColorType <matplotlib.typing.RGBColorType>`",
     "RGBAColorType": "`RGBAColorType <matplotlib.typing.RGBAColorType>`",
 }
-napoleon_attr_annotations = True
 napoleon_custom_sections = [("Signals", "params_style")]
 
 # -- nbsphinx options --------------------------------------------------------
@@ -450,7 +451,8 @@ def get_inproceedings_template(self, e):
 
 html_title = "ERLabPy"
 html_static_path = ["_static"]
-html_css_files = ["pied-piper-admonition.css"]
+html_css_files = ["custom.css"]
+
 html_theme_options: dict[str, object] = {
     "footer_icons": [
         {
diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst
index ec44f6e6..f39b41ed 100644
--- a/docs/source/getting-started.rst
+++ b/docs/source/getting-started.rst
@@ -164,8 +164,7 @@ For a full list of dependencies and optional dependencies, take a look at the
 Notes on compatibility
 ----------------------
 
-- ERLabPy is tested on Python 3.11 and 3.12. It is not guaranteed to work on older
-  versions of Python.
+- ERLabPy supports Python 3.11 and later.
 - There are some `known compatibility issues
   <https://github.com/kmnhan/erlabpy/issues/17>`_ with PyQt5 and PySide2, so it is
   recommended to use the newer PyQt6 or PySide6 if possible.
diff --git a/docs/source/images/dtool_dark.png b/docs/source/images/dtool_dark.png
new file mode 100644
index 00000000..4bdeafe1
Binary files /dev/null and b/docs/source/images/dtool_dark.png differ
diff --git a/docs/source/images/dtool_light.png b/docs/source/images/dtool_light.png
new file mode 100644
index 00000000..b4d0c163
Binary files /dev/null and b/docs/source/images/dtool_light.png differ
diff --git a/docs/source/user-guide/curve-fitting.ipynb b/docs/source/user-guide/curve-fitting.ipynb
index 9ba9e913..6e28ed48 100644
--- a/docs/source/user-guide/curve-fitting.ipynb
+++ b/docs/source/user-guide/curve-fitting.ipynb
@@ -37,7 +37,7 @@
                 "\n",
                 "In this section, we will cover basic curve fitting using `lmfit\n",
                 "<https://lmfit.github.io/lmfit-py/>`_. If you are familiar with the library, you can\n",
-                "skip this section."
+                ":ref:`skip to the next section <pre-defined-models>`."
             ]
         },
         {
@@ -320,6 +320,8 @@
                 "For more information, see the `lmfit documentation\n",
                 "<https://lmfit.github.io/lmfit-py/fitting.html>`_.\n",
                 "\n",
+                ".. _pre-defined-models:\n",
+                "\n",
                 "Fitting with pre-defined models\n",
                 "-------------------------------\n",
                 "\n",
@@ -490,10 +492,14 @@
                 "<erlab.accessors.fit.ModelFitDataArrayAccessor.__call__>` or\n",
                 ":meth:`xarray.Dataset.modelfit <erlab.accessors.fit.ModelFitDatasetAccessor.__call__>`,\n",
                 "depending on whether you want to fit a single DataArray or multiple DataArrays in a\n",
-                "Dataset. The syntax is similar to :meth:`xarray.DataArray.curvefit` and\n",
-                ":meth:`xarray.Dataset.curvefit`. The accessor returns a :class:`xarray.Dataset` with the\n",
-                "best-fit parameters and the fit statistics. The example below illustrates how to use the\n",
-                "accessor to conduct the same fit as in the previous example."
+                "Dataset. The accessor returns a :class:`xarray.Dataset` including the best-fit\n",
+                "parameters and the fit statistics. The example below illustrates how to use the accessor\n",
+                "to conduct the MDC fit demonstrated in the previous section.\n",
+                "\n",
+                ".. hint ::\n",
+                "\n",
+                "    The syntax of the accessors are similar to the xarray methods\n",
+                "    :meth:`xarray.DataArray.curvefit` and :meth:`xarray.Dataset.curvefit`."
             ]
         },
         {
@@ -520,11 +526,15 @@
                 }
             },
             "source": [
-                "``modelfit_results`` contains the underlying :class:`lmfit.model.ModelResult` objects.\n",
-                "The coefficients and their errors are stored in ``modelfit_coefficients`` and\n",
-                "``modelfit_stderr``, respectively. The `goodness-of-fit statistics\n",
-                "<https://lmfit.github.io/lmfit-py/fitting.html#goodness-of-fit-statistics>`_ are stored\n",
-                "in ``modelfit_stats``. \n",
+                "Let's take a closer look at data variables in the resulting Dataset.\n",
+                "\n",
+                "- ``modelfit_results`` contains the underlying :class:`lmfit.model.ModelResult` object from the fit.\n",
+                "- ``modelfit_coefficients`` and ``modelfit_stderr`` contain the best-fit coefficients and their errors, respectively.\n",
+                "- ``modelfit_stats`` contains the `goodness-of-fit statistics <https://lmfit.github.io/lmfit-py/fitting.html#goodness-of-fit-statistics>`_.\n",
+                "\n",
+                "It may not be immediately obvious how this is useful, but the true power of the accessor\n",
+                "comes from its ability to utilize xarray's powerful broadcasting capabilities described\n",
+                "in the next section.\n",
                 "\n",
                 "Fitting across multiple dimensions\n",
                 "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n",
@@ -534,9 +544,11 @@
                 "    There is a dedicated module for Fermi edge fitting and correction, described\n",
                 "    :ref:`here <fermi edge fitting>`. The following example is for illustrative purposes.\n",
                 "\n",
-                "The accessor comes in handy when you have to fit a single model to multiple data points\n",
-                "across arbitrary dimensions. Let's demonstrate this with a simulated cut that represents\n",
-                "a curved Fermi edge at 100 K, with an energy broadening of 20 meV."
+                "Suppose you have to fit a single model to multiple data points across some dimension, or\n",
+                "even multiple dimensions. The accessor can handle this with ease.\n",
+                "\n",
+                "Let's demonstrate this with a simulated cut that represents a curved Fermi edge at 100\n",
+                "K, with an energy broadening of 20 meV."
             ]
         },
         {
@@ -545,8 +557,8 @@
             "metadata": {},
             "outputs": [],
             "source": [
-                "from erlab.io.exampledata import generate_gold_edge\n",
                 "from erlab.analysis.fit.models import FermiEdgeModel\n",
+                "from erlab.io.exampledata import generate_gold_edge\n",
                 "\n",
                 "gold = generate_gold_edge(temp=100, Eres=0.02, count=5e5, seed=1)\n",
                 "gold.qplot(cmap=\"Greys\")"
@@ -568,10 +580,7 @@
             "source": [
                 "gold_selected = gold.sel(eV=slice(-0.2, 0.2))\n",
                 "result_ds = gold_selected.modelfit(\n",
-                "    coords=\"eV\",\n",
-                "    model=FermiEdgeModel(),\n",
-                "    params={\"temp\": {\"value\": 100.0, \"vary\": False}},\n",
-                "    guess=True,\n",
+                "    \"eV\", FermiEdgeModel(), params={\"temp\": {\"value\": 100.0, \"vary\": False}}, guess=True\n",
                 ")\n",
                 "result_ds"
             ]
@@ -580,7 +589,8 @@
             "cell_type": "markdown",
             "metadata": {},
             "source": [
-                "Let's plot the fitted parameters as a function of angle!"
+                "Notice how the data variables in the resulting Dataset now depend on the coordinate\n",
+                "`alpha`. Let's plot the center of the edge as a function of angle!"
             ]
         },
         {
@@ -612,9 +622,10 @@
                 }
             },
             "source": [
-                "We can also conduct a global fit to all the EDCs with a multidimensional model. First,\n",
-                "we normalize the data with the averaged intensity of each EDC and then fit the data to\n",
-                ":class:`FermiEdge2dModel <erlab.analysis.fit.models.FermiEdge2dModel>`."
+                "Fitting is not limited to 1D models. The following example demonstrates a global fit to\n",
+                "the cut with a multidimensional model. First, we normalize the data with the averaged\n",
+                "intensity of each EDC and then fit the data to :class:`FermiEdge2dModel\n",
+                "<erlab.analysis.fit.models.FermiEdge2dModel>`."
             ]
         },
         {
@@ -695,8 +706,8 @@
                 "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n",
                 "\n",
                 "You can also provide different initial guesses and bounds for different coordinates. To\n",
-                "demonstrate, let's create some data containing multiple MDCs, each with a different peak\n",
-                "position."
+                "demonstrate, let's create some data containing multiple Gaussian peaks, each with a\n",
+                "different center."
             ]
         },
         {
@@ -717,10 +728,10 @@
                 "\n",
                 "# Add some noise with fixed seed for reproducibility\n",
                 "rng = np.random.default_rng(5)\n",
-                "yerr = np.full_like(y, 0.1)\n",
+                "yerr = np.full_like(y, 0.05)\n",
                 "y = rng.normal(y, yerr)\n",
                 "\n",
-                "# Transform to DataArray\n",
+                "# Construct DataArray\n",
                 "darr = xr.DataArray(y, dims=[\"beta\", \"alpha\"], coords={\"beta\": beta, \"alpha\": alpha})\n",
                 "darr.qplot()"
             ]
@@ -991,7 +1002,7 @@
             "metadata": {},
             "outputs": [],
             "source": [
-                "era.correct_with_edge(gold, result).qplot(cmap=\"Greys\")\n",
+                "era.gold.correct_with_edge(gold, result).qplot(cmap=\"Greys\")\n",
                 "eplt.fermiline()"
             ]
         },
@@ -1121,7 +1132,7 @@
             "name": "python",
             "nbconvert_exporter": "python",
             "pygments_lexer": "ipython3",
-            "version": "3.11.9"
+            "version": "3.12.4"
         }
     },
     "nbformat": 4,
diff --git a/docs/source/user-guide/filtering.ipynb b/docs/source/user-guide/filtering.ipynb
new file mode 100644
index 00000000..a4a82d70
--- /dev/null
+++ b/docs/source/user-guide/filtering.ipynb
@@ -0,0 +1,291 @@
+{
+    "cells": [
+        {
+            "cell_type": "raw",
+            "id": "37312770",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": [
+                "Filtering\n",
+                "=========\n",
+                "\n",
+                "ERLabPy provides some filter functions in `scipy.ndimage` and `scipy.signal` extended to work with regularly spaced xarray DataArrays. Additionally, some techniques frequently used in ARPES data visualization are implemented.\n",
+                "\n",
+                "Image smoothing filters\n",
+                "-----------------------"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": null,
+            "id": "3ac5bc3b",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "import erlab.analysis as era"
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "fd4ee1a3",
+            "metadata": {},
+            "source": [
+                "First, let us generate some example data: a simple tight binding simulation of graphene."
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": null,
+            "id": "8633209c",
+            "metadata": {
+                "editable": true,
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": []
+            },
+            "outputs": [],
+            "source": [
+                "from erlab.io.exampledata import generate_data_angles\n",
+                "\n",
+                "cut = generate_data_angles(\n",
+                "    shape=(500, 1, 500),\n",
+                "    angrange={\"alpha\": (-15, 15), \"beta\": (-5, 5)},\n",
+                "    seed=1,\n",
+                "    bandshift=-0.2,\n",
+                ").T\n",
+                "cut.qplot()"
+            ]
+        },
+        {
+            "cell_type": "raw",
+            "id": "16477885",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": [
+                "Here, we apply a Gaussian filter in coordinate units using\n",
+                ":func:`erlab.analysis.image.gaussian_filter` to simulate an instrumental broadening of\n",
+                "10 meV and 0.2 degrees:"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": null,
+            "id": "82c2454f",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "cut_smooth = era.image.gaussian_filter(cut, sigma=dict(eV=0.01, alpha=0.2))\n",
+                "cut_smooth.qplot()"
+            ]
+        },
+        {
+            "cell_type": "raw",
+            "id": "9e68fdb4",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": [
+                "For all arguments and available filters, see the API reference at :mod:`erlab.analysis.image`.\n",
+                "\n",
+                "Visualizing dispersive features\n",
+                "-------------------------------\n",
+                "\n",
+                "There are several methods that are used to visualize dispersive features in ARPES data. To demonstrate, we first generate a synthetic ARPES cut with broad features:"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": null,
+            "id": "00d3b00f",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "from erlab.io.exampledata import generate_data_angles\n",
+                "\n",
+                "cut = generate_data_angles(\n",
+                "    shape=(500, 1, 500),\n",
+                "    angrange={\"alpha\": (-5, 5), \"beta\": (-10, 10)},\n",
+                "    temp=50.0,\n",
+                "    seed=1,\n",
+                "    bandshift=-0.15,\n",
+                "    Simag=0.1,\n",
+                "    count=1e11,\n",
+                ").T\n",
+                "cut.qplot()"
+            ]
+        },
+        {
+            "cell_type": "raw",
+            "id": "0d36f0c0",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": [
+                "The curvature can be calculated with :func:`erlab.analysis.image.curvature`:"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": null,
+            "id": "f2c428d9",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "result = era.image.curvature(cut, a0=1.0, factor=1.0)\n",
+                "\n",
+                "result.qplot(vmax=0, cmap=\"Greys_r\")"
+            ]
+        },
+        {
+            "cell_type": "raw",
+            "id": "626a74d6",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": [
+                "For different methods and arguments, see the API reference at :mod:`erlab.analysis.image`.\n",
+                "\n",
+                "Interactive differentiation\n",
+                "---------------------------\n",
+                "\n",
+                "A GUI for interactive smoothing and differentiation can be invoked with :func:`erlab.interactive.dtool`:\n",
+                "\n",
+                ".. code-block:: python\n",
+                "\n",
+                "    import erlab.interactive as eri\n",
+                "\n",
+                "    eri.dtool(cut)\n",
+                "\n",
+                ".. image:: ../images/dtool_light.png\n",
+                "    :align: center\n",
+                "    :alt: DerivativeTool\n",
+                "    :class: only-light\n",
+                "\n",
+                ".. only:: format_html\n",
+                "\n",
+                "    .. image:: ../images/dtool_dark.png\n",
+                "        :align: center\n",
+                "        :alt: DerivativeTool\n",
+                "        :class: only-dark\n",
+                "\n",
+                "- The first section interpolates the data to a grid prior to smoothing.\n",
+                "- The second section applies Gaussian filtering prior to differentiation.\n",
+                "- In the third section, selecting different tabs will apply different methods. Each tab contains parameters relevant to the corresponding method.\n",
+                "- Clicking the copy button will copy the code for differentiation to the clipboard.\n",
+                "- Both the data and the result can be opened in ImageTool from the right-click menu of each plot, where it can be exported to a file."
+            ]
+        },
+        {
+            "cell_type": "raw",
+            "id": "dfc0348b",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": []
+        },
+        {
+            "cell_type": "raw",
+            "id": "41bac675",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": []
+        },
+        {
+            "cell_type": "raw",
+            "id": "761eafa9",
+            "metadata": {
+                "editable": true,
+                "raw_mimetype": "text/restructuredtext",
+                "slideshow": {
+                    "slide_type": ""
+                },
+                "tags": [],
+                "vscode": {
+                    "languageId": "raw"
+                }
+            },
+            "source": []
+        }
+    ],
+    "metadata": {
+        "kernelspec": {
+            "display_name": "Python 3 (ipykernel)",
+            "language": "python",
+            "name": "python3"
+        },
+        "language_info": {
+            "codemirror_mode": {
+                "name": "ipython",
+                "version": 3
+            },
+            "file_extension": ".py",
+            "mimetype": "text/x-python",
+            "name": "python",
+            "nbconvert_exporter": "python",
+            "pygments_lexer": "ipython3",
+            "version": "3.12.4"
+        }
+    },
+    "nbformat": 4,
+    "nbformat_minor": 5
+}
diff --git a/docs/source/user-guide/imagetool.rst b/docs/source/user-guide/imagetool.rst
index 2ea8060e..a9b1854b 100644
--- a/docs/source/user-guide/imagetool.rst
+++ b/docs/source/user-guide/imagetool.rst
@@ -28,7 +28,8 @@ Features include:
 
 ImageTool can be used to display *image-like* :class:`xarray.DataArray`\ s ranging from
 2 to 4 dimensions. If a coordinate of the input data happens to be non-uniform, it will
-automatically be converted to an index array so that the data can be displayed.
+automatically be converted to an index array so that the data can be displayed as an
+image.
 
 There are two main ways to invoke the ImageTool. The first way is to call the
 :func:`itool <erlab.interactive.imagetool.itool>` convenience function, which will
@@ -86,24 +87,25 @@ related to 'shifting' a cursor usually involves holding :kbd:`Shift`.
 
 Using the ImageTool manager
 ---------------------------
-One drawback of using interactive tools with Jupyter notebooks is that the tool will be
-a blocking call. This means that you cannot run any other code while the tool is
-running. To get around this, you can use the :class:`ImageToolManager
+One drawback of using interactive tools inside Jupyter notebooks is that the tool
+execution is a blocking call. This means that you cannot run any other code while the
+tool is running. As a workaround, we provide :class:`ImageToolManager
 <erlab.interactive.imagetool.ImageToolManager>`.
 
 In the environment where ERLabPy installed, run ``itool-manager`` in a shell to start
-the manager application. Subsequent invocations with :func:`itool
+the manager application. Subsequent invocations of ImageTool from :func:`itool
 <erlab.interactive.imagetool.itool>` and :class:`qshow
-<erlab.accessors.general.InteractiveDataArrayAccessor>` will be handled by the manager.
+<erlab.accessors.general.InteractiveDataArrayAccessor>` will open the tool in the
+manager application, leaving the notebook free to run other code.
 
 .. note::
 
   - The manager is designed to be global, so you can only have one manager running on a
     single machine that will handle all ImageTool instances opened from different
-    notebooks.
+    sessions.
 
-  - Opening in the manager has a slight delay compared to opening directly from the
-    notebook. The delay is bigger for larger data. You can still open directly while the
-    manager is running by providing the `use_manager=False` argument to :func:`itool
+  - Opening in the manager has a slight overhead compared to opening directly from the
+    notebook. The overhead is more noticeable for larger data. You can still open
+    directly while the manager is running by passing `use_manager=False` to :func:`itool
     <erlab.interactive.imagetool.itool>` or :class:`qshow
     <erlab.accessors.general.InteractiveDataArrayAccessor>`.
diff --git a/docs/source/user-guide/index.rst b/docs/source/user-guide/index.rst
index ca9e435e..dcdf3442 100644
--- a/docs/source/user-guide/index.rst
+++ b/docs/source/user-guide/index.rst
@@ -26,6 +26,7 @@ If you are new to using ERLabPy, you may want to start with the `getting started
    plotting
    kconv
    curve-fitting
+   filtering
    imagetool
 
 Further reading
diff --git a/docs/source/user-guide/indexing.ipynb b/docs/source/user-guide/indexing.ipynb
index 0ea6dec5..1125744c 100644
--- a/docs/source/user-guide/indexing.ipynb
+++ b/docs/source/user-guide/indexing.ipynb
@@ -15,8 +15,8 @@
                 }
             },
             "source": [
-                "Selecting and indexing data\n",
-                "===========================\n",
+                "Selecting & indexing\n",
+                "====================\n",
                 "\n",
                 "In most cases, the powerful data manipulation and indexing methods provided by\n",
                 ":mod:`xarray` are sufficient. In the first section, we will briefly cover some\n",
@@ -474,7 +474,7 @@
             "name": "python",
             "nbconvert_exporter": "python",
             "pygments_lexer": "ipython3",
-            "version": "3.11.9"
+            "version": "3.12.4"
         }
     },
     "nbformat": 4,
diff --git a/docs/source/user-guide/io.ipynb b/docs/source/user-guide/io.ipynb
index 14ea68e5..679db078 100644
--- a/docs/source/user-guide/io.ipynb
+++ b/docs/source/user-guide/io.ipynb
@@ -14,8 +14,8 @@
                 }
             },
             "source": [
-                "Reading and writing data\n",
-                "========================\n",
+                "Reading & writing data\n",
+                "======================\n",
                 "\n",
                 "In ERLabPy, most data are represented as :class:`xarray.Dataset` objects or\n",
                 ":class:`xarray.DataArray` objects. :class:`xarray.DataArray` are similar to waves in\n",
@@ -365,7 +365,8 @@
                 "from this pattern, only two methods need to be implemented: :meth:`identify\n",
                 "<erlab.io.dataloader.LoaderBase.identify>` and :meth:`load_single\n",
                 "<erlab.io.dataloader.LoaderBase.load_single>`. The following flowchart shows the process\n",
-                "of loading data from a single scan, given either a file path or a sequence number:\n",
+                "of loading data from a single scan, given the path to the directory (``data_dir``) and\n",
+                "the sequence number or file name (``identifier``):\n",
                 "\n",
                 ".. image:: ../images/flowchart_single.pdf\n",
                 "   :align: center\n",
@@ -394,9 +395,9 @@
                 "of file paths along with a dictionary of corresponding coordinates.\n",
                 "\n",
                 "The method :meth:`infer_index <erlab.io.dataloader.LoaderBase.infer_index>` is given a\n",
-                "bare file name (without the extension and path) like and must return the sequence number\n",
-                "of the scan. For example, given the file name ``file_0003_0123``, the method should\n",
-                "return ``3``.\n",
+                "bare file name (without the extension and path) and must return the sequence number of\n",
+                "the scan. For example, given the file name ``file_0003_0123``, the method should return\n",
+                "``3``.\n",
                 "\n",
                 "Conventions\n",
                 "~~~~~~~~~~~\n",
@@ -1091,7 +1092,7 @@
             "name": "python",
             "nbconvert_exporter": "python",
             "pygments_lexer": "ipython3",
-            "version": "3.11.9"
+            "version": "3.12.4"
         }
     },
     "nbformat": 4,
diff --git a/docs/source/user-guide/kconv.ipynb b/docs/source/user-guide/kconv.ipynb
index aebc5199..c40c4e37 100644
--- a/docs/source/user-guide/kconv.ipynb
+++ b/docs/source/user-guide/kconv.ipynb
@@ -204,12 +204,14 @@
     "Parameters that are needed for momentum conversion are the information about the\n",
     "experimental configuration, the inner potential :math:`V_0` (for photon energy dependent\n",
     "data), work function, and angle offsets. These parameters are all stored as data\n",
-    "attributes. The ``kspace`` accessor provides a convenient way to access and modify these\n",
-    "parameters. See :meth:`configuration\n",
-    "<erlab.accessors.kspace.MomentumAccessor.configuration>`, :meth:`inner_potential\n",
-    "<erlab.accessors.kspace.MomentumAccessor.inner_potential>`, :meth:`work_function\n",
-    "<erlab.accessors.kspace.MomentumAccessor.work_function>`, and :meth:`offsets\n",
-    "<erlab.accessors.kspace.MomentumAccessor.offsets>` for more information.\n",
+    "attributes. The ``kspace`` accessor provides various ways to access and modify these\n",
+    "parameters.\n",
+    "\n",
+    "See :meth:`configuration <erlab.accessors.kspace.MomentumAccessor.configuration>`,\n",
+    ":meth:`inner_potential <erlab.accessors.kspace.MomentumAccessor.inner_potential>`,\n",
+    ":meth:`work_function <erlab.accessors.kspace.MomentumAccessor.work_function>`, and\n",
+    ":meth:`offsets <erlab.accessors.kspace.MomentumAccessor.offsets>` on how to access and\n",
+    "modify each of these parameters.\n",
     "\n",
     "First, let's check the angle offsets."
    ]
@@ -227,7 +229,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Since we haven't set any offsets, they are all zero. We will set the azimuthal angle to 60 degrees and the polar offset to 30 degrees and see what happens."
+    "Since we haven't set any offsets, they are all zero. We will set the azimuthal angle to\n",
+    "60 degrees and the polar offset to 30 degrees and see what happens."
    ]
   },
   {
@@ -286,6 +289,20 @@
     "\n",
     "For three dimensional momentum conversion like maps or photon energy scans, an interactive window can be opened where you can adjust the parameters and see the effect right away.\n",
     "\n",
+    "There are two ways to invoke the GUI. The first one is to call the ``interactive`` method on the accessor: \n",
+    "\n",
+    ".. code-block:: python\n",
+    "\n",
+    "    data.kspace.interactive()\n",
+    "\n",
+    "The second option is to invoke the GUI directly with :func:`erlab.interactive.ktool`. The difference is that the latter will automatically determine the name of the input data and apply it to the generated code that is copied to the clipboard.\n",
+    "\n",
+    ".. code-block:: python\n",
+    "\n",
+    "    import erlab.interactive as eri\n",
+    "\n",
+    "    eri.ktool(data)\n",
+    "\n",
     "The GUI is divided into two tabs.\n",
     "\n",
     ".. image:: ../images/ktool_1_light.png\n",
@@ -314,21 +331,7 @@
     "        :alt: KspaceTool 2\n",
     "        :class: only-dark\n",
     "\n",
-    "The second tab provides visualization options. You can overlay Brillouin zones and high symmetry points on the result, adjust colors, and apply binning.\n",
-    "\n",
-    "There are two ways to invoke the GUI. The first one is to call the ``interactive`` method on the accessor: \n",
-    "\n",
-    ".. code-block:: python\n",
-    "\n",
-    "    data.kspace.interactive()\n",
-    "\n",
-    "The second option is to invoke the GUI directly with the ``erlab.interactive.ktool`` function. The difference is that the latter will automatically determine the name of the input data and apply it to the generated code that is copied to the clipboard.\n",
-    "\n",
-    ".. code-block:: python\n",
-    "\n",
-    "    import erlab.interactive as eri\n",
-    "\n",
-    "    eri.ktool(data)"
+    "The second tab provides visualization options. You can overlay Brillouin zones and high symmetry points on the result, adjust colors, and apply binning."
    ]
   },
   {
@@ -363,7 +366,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.9"
+   "version": "3.12.4"
   }
  },
  "nbformat": 4,
diff --git a/docs/source/user-guide/plotting.ipynb b/docs/source/user-guide/plotting.ipynb
index 89f353ee..9cdaf8da 100644
--- a/docs/source/user-guide/plotting.ipynb
+++ b/docs/source/user-guide/plotting.ipynb
@@ -739,7 +739,7 @@
             "name": "python",
             "nbconvert_exporter": "python",
             "pygments_lexer": "ipython3",
-            "version": "3.11.9"
+            "version": "3.12.4"
         }
     },
     "nbformat": 4,
diff --git a/environment.yml b/environment.yml
index 65abdaaf..5863733f 100644
--- a/environment.yml
+++ b/environment.yml
@@ -18,7 +18,7 @@ dependencies:
   - python>=3.11
   - qtawesome>=1.3.1
   - qtpy>=2.4.1
-  - scipy>=1.12.0
+  - scipy>=1.13.0
   - tqdm>=4.66.2
   - uncertainties>=3.1.4
   - varname>=0.13.0
diff --git a/pyproject.toml b/pyproject.toml
index 97a6aa1d..c76ad550 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,7 +34,7 @@ dependencies = [
     "pyqtgraph>=0.13.1",
     "qtawesome>=1.3.1",
     "qtpy>=2.4.1",
-    "scipy>=1.12.0",
+    "scipy>=1.13.0",
     "tqdm>=4.66.2",
     "uncertainties>=3.1.4",
     "varname>=0.13.0",
@@ -54,6 +54,7 @@ dev = [
     "pytest-xdist",
     "pytest",
     "commitizen",
+    "cz-changeup",
     "ruff",
 ]
 docs = [
@@ -81,9 +82,14 @@ Changelog = "https://github.com/kmnhan/erlabpy/blob/main/CHANGELOG.md"
 version_provider = "pep621"
 update_changelog_on_bump = true
 tag_format = "v$version"
-template = "./templates/CHANGELOG.md.j2"
 changelog_start_rev = "v1.2.1"
 changelog_merge_prerelease = true
+name = 'cz_changeup'
+
+# cz-changeup configuration
+changeup_repo_base_url = "https://github.com/kmnhan/erlabpy"
+changeup_show_body = true
+changeup_body_indent = 2
 
 [tool.setuptools]
 package-dir = { "" = "src" }
diff --git a/src/erlab/accessors/fit.py b/src/erlab/accessors/fit.py
index a30cf61f..9a76fdbf 100644
--- a/src/erlab/accessors/fit.py
+++ b/src/erlab/accessors/fit.py
@@ -12,8 +12,8 @@
 import copy
 import itertools
 import warnings
-from collections.abc import Hashable, Iterable, Mapping, Sequence
-from typing import TYPE_CHECKING, Any, Literal, cast
+from collections.abc import Collection, Hashable, Iterable, Mapping, Sequence
+from typing import Any, Literal, cast
 
 import joblib
 import lmfit
@@ -28,9 +28,6 @@
 )
 from erlab.utils.parallel import joblib_progress
 
-if TYPE_CHECKING:
-    from xarray.core.types import Dims
-
 
 def _nested_dict_vals(d):
     for v in d.values():
@@ -126,7 +123,7 @@ def __call__(
         self,
         coords: str | xr.DataArray | Iterable[str | xr.DataArray],
         model: lmfit.Model,
-        reduce_dims: Dims = None,
+        reduce_dims: str | Collection[Hashable] | None = None,
         skipna: bool = True,
         params: lmfit.Parameters
         | dict[str, float | dict[str, Any]]
diff --git a/src/erlab/accessors/general.py b/src/erlab/accessors/general.py
index e3db84da..3668a184 100644
--- a/src/erlab/accessors/general.py
+++ b/src/erlab/accessors/general.py
@@ -76,8 +76,7 @@ def __call__(self, *args, **kwargs):
         if self._obj.ndim >= 2 and self._obj.ndim <= 4:
             return self.itool(*args, **kwargs)
         if importlib.util.find_spec("hvplot"):
-            self.hvplot(*args, **kwargs)
-            return None
+            return self.hvplot(*args, **kwargs)
         raise ValueError("Data must have at least two dimensions.")
 
     def itool(self, *args, **kwargs):
@@ -413,7 +412,9 @@ def __call__(
 
         return out
 
-    def around(self, radius: float | dict[Hashable, float], **sel_kw) -> xr.DataArray:
+    def around(
+        self, radius: float | dict[Hashable, float], *, average: bool = True, **sel_kw
+    ) -> xr.DataArray:
         """
         Average data within a specified radius of a specified point.
 
@@ -427,7 +428,11 @@ def around(self, radius: float | dict[Hashable, float], **sel_kw) -> xr.DataArra
         ----------
         radius
             The radius of the region. If a single number, the same radius is used for
-            all dimensions. If a dictionary, each value
+            all dimensions. If a dictionary, keys must be valid dimension names and the
+            values are the radii for the corresponding dimensions.
+        average
+            If `True`, return the mean value of the data within the region. If `False`,
+            return the masked data.
         **sel_kw
             The center of the spherical region. Must be a mapping of valid dimension
             names to coordinate values.
@@ -447,6 +452,10 @@ def around(self, radius: float | dict[Hashable, float], **sel_kw) -> xr.DataArra
         """
         import erlab.analysis
 
-        return self._obj.where(
-            erlab.analysis.mask.spherical_mask(self._obj, radius, **sel_kw), drop=True
-        ).mean(sel_kw.keys())
+        masked = self._obj.where(
+            erlab.analysis.mask.spherical_mask(self._obj, radius, **sel_kw),
+            drop=average,
+        )
+        if average:
+            return masked.mean(sel_kw.keys())
+        return masked
diff --git a/src/erlab/accessors/kspace.py b/src/erlab/accessors/kspace.py
index 553e3552..2b2ebf36 100644
--- a/src/erlab/accessors/kspace.py
+++ b/src/erlab/accessors/kspace.py
@@ -680,6 +680,7 @@ def convert(
         bounds: dict[str, tuple[float, float]] | None = None,
         resolution: dict[str, float] | None = None,
         *,
+        method: str = "linear",
         silent: bool = False,
         **coords,
     ) -> xr.DataArray:
@@ -702,6 +703,10 @@ def convert(
             and the other is estimated as the inverse of the photoelectron inelastic
             mean free path given by the universal curve. The resolution is estimated as
             the smaller of the two values.
+        method
+            The interpolation method to use, passed to
+            :func:`erlab.analysis.interpolate.interpn`. Using methods other than
+            ``'linear'`` will result in slower performance.
         silent
             If `True`, suppresses printing, by default `False`.
         **coords
@@ -729,8 +734,7 @@ def convert(
         .. code-block:: python
 
             data.kspace.offsets = {"delta": 0.1, "xi": 0.0, "beta": 0.3}
-            data.kspace.work_function = 4.3
-            data.kspace.inner_potential = 12.0
+            data.kspace.work_function = 4.3 data.kspace.inner_potential = 12.0
             converted_data = data.kspace.convert()
 
 
@@ -738,9 +742,8 @@ def convert(
 
         .. code-block:: python
 
-            bounds = {"kx": (0.0, 1.0), "ky": (-1.0, 1.0)}
-            resolution = {"kx": 0.01, "ky": 0.01}
-            converted_data = data.kspace.convert(bounds, resolution)
+            bounds = {"kx": (0.0, 1.0), "ky": (-1.0, 1.0)} resolution = {"kx": 0.01,
+            "ky": 0.01} converted_data = data.kspace.convert(bounds, resolution)
 
         """
         if bounds is None:
@@ -825,7 +828,7 @@ def convert(
 
         def _wrap_interpn(arr, *args):
             points, xi = args[: arr.ndim], args[arr.ndim :]
-            return interpn(points, arr, xi, bounds_error=False).squeeze()
+            return interpn(points, arr, xi, method=method, bounds_error=False).squeeze()
 
         input_core_dims = [input_dims]
         input_core_dims.extend([(d,) for d in input_dims])
diff --git a/src/erlab/analysis/__init__.py b/src/erlab/analysis/__init__.py
index 5bae9ed6..5badf939 100644
--- a/src/erlab/analysis/__init__.py
+++ b/src/erlab/analysis/__init__.py
@@ -17,13 +17,45 @@
    interpolate
    kspace
    transform
-   utils
 
 """
 
-__all__ = ["correct_with_edge", "quick_resolution", "shift", "slice_along_path"]
+import warnings
 
 from erlab.analysis import fit, gold, image, interpolate, mask, transform  # noqa: F401
-from erlab.analysis.gold import correct_with_edge, quick_resolution
-from erlab.analysis.interpolate import slice_along_path
-from erlab.analysis.utils import shift
+
+
+def correct_with_edge(*args, **kwargs):
+    from erlab.analysis.gold import correct_with_edge
+
+    warnings.warn(
+        "importing as erlab.analysis.correct_with_edge is deprecated, "
+        "use erlab.analysis.gold.correct_with_edge instead",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return correct_with_edge(*args, **kwargs)
+
+
+def quick_resolution(*args, **kwargs):
+    from erlab.analysis.gold import quick_resolution
+
+    warnings.warn(
+        "importing as erlab.analysis.quick_resolution is deprecated, "
+        "use erlab.analysis.gold.quick_resolution instead",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return quick_resolution(*args, **kwargs)
+
+
+def slice_along_path(*args, **kwargs):
+    from erlab.analysis.interpolate import slice_along_path
+
+    warnings.warn(
+        "importing as erlab.analysis.slice_along_path is deprecated, "
+        "use erlab.analysis.interpolate.slice_along_path instead",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return slice_along_path(*args, **kwargs)
diff --git a/src/erlab/analysis/correlation.py b/src/erlab/analysis/correlation.py
index a166c823..ad84bedc 100644
--- a/src/erlab/analysis/correlation.py
+++ b/src/erlab/analysis/correlation.py
@@ -47,7 +47,7 @@ def nanacf(arr, *args, **kwargs):
 
 
 def acf2(arr, mode: str = "full", method: str = "fft"):
-    """Calculate the autocorrelation function (ACF) of a 2D array including nans.
+    """Calculate the autocorrelation function (ACF) of a 2D array including NaNs.
 
     Parameters
     ----------
diff --git a/src/erlab/analysis/gold.py b/src/erlab/analysis/gold.py
index 574a8858..1e1f6eaf 100644
--- a/src/erlab/analysis/gold.py
+++ b/src/erlab/analysis/gold.py
@@ -36,7 +36,7 @@
     PolynomialModel,
     StepEdgeModel,
 )
-from erlab.analysis.utils import shift
+from erlab.analysis.transform import shift
 from erlab.plotting.colors import proportional_colorbar
 from erlab.plotting.general import autoscale_to, figwh, plot_array
 from erlab.utils.parallel import joblib_progress
@@ -72,7 +72,7 @@ def correct_with_edge(
     plot_kw
         Additional keyword arguments for the plot. Defaults to `None`.
     **shift_kwargs
-        Additional keyword arguments to `erlab.analysis.utils.shift`.
+        Additional keyword arguments to :func:`erlab.analysis.transform.shift`.
 
     Returns
     -------
diff --git a/src/erlab/analysis/image.py b/src/erlab/analysis/image.py
index b13d1abe..5fcd5959 100644
--- a/src/erlab/analysis/image.py
+++ b/src/erlab/analysis/image.py
@@ -13,6 +13,17 @@
 
 """
 
+__all__ = [
+    "gaussian_filter",
+    "gaussian_laplace",
+    "laplace",
+    "ndsavgol",
+    "scaled_laplace",
+    "curvature",
+    "minimum_gradient",
+    "gradient_magnitude",
+]
+
 import itertools
 import math
 from collections.abc import Collection, Hashable, Mapping, Sequence
@@ -27,6 +38,7 @@
 
 from erlab.utils.array import (
     check_arg_2d_darr,
+    check_arg_has_no_nans,
     check_arg_uniform_dims,
     is_uniform_spaced,
 )
@@ -212,7 +224,7 @@ def gaussian_filter(
 def gaussian_laplace(
     darr: xr.DataArray,
     sigma: float | Collection[float] | Mapping[Hashable, float],
-    mode: str | Sequence[str] | Mapping[str, str] = "nearest",
+    mode: str | Sequence[str] | Mapping[Hashable, str] = "nearest",
     cval: float = 0.0,
     **kwargs,
 ) -> xr.DataArray:
@@ -267,7 +279,7 @@ def gaussian_laplace(
     )
 
     # Convert mode to tuple acceptable by scipy
-    if isinstance(mode, dict):
+    if isinstance(mode, Mapping):
         mode = tuple(mode[d] for d in sigma_dict)
 
     # Calculate sigma in pixels
@@ -609,13 +621,14 @@ def laplace(
     --------
     :func:`scipy.ndimage.laplace` : The underlying function used to apply the filter.
     """
-    if isinstance(mode, dict):
+    if isinstance(mode, Mapping):
         mode = tuple(mode[d] for d in darr.dims)
     return darr.copy(data=scipy.ndimage.laplace(darr.values, mode=mode, cval=cval))
 
 
 @check_arg_2d_darr
 @check_arg_uniform_dims
+@check_arg_has_no_nans
 def minimum_gradient(
     darr: xr.DataArray, mode: str = "nearest", cval: float = 0.0
 ) -> xr.DataArray:
@@ -665,6 +678,7 @@ def minimum_gradient(
 
 @check_arg_2d_darr
 @check_arg_uniform_dims
+@check_arg_has_no_nans
 def scaled_laplace(
     darr,
     factor: float = 1.0,
@@ -726,7 +740,7 @@ def scaled_laplace(
     elif factor < 0:
         weight /= abs(factor)
 
-    if isinstance(mode, dict):
+    if isinstance(mode, Mapping):
         mode = tuple(mode[d] for d in darr.dims)
 
     def d2_scaled(arr, axis, output, mode, cval):
@@ -742,6 +756,7 @@ def d2_scaled(arr, axis, output, mode, cval):
 
 @check_arg_2d_darr
 @check_arg_uniform_dims
+@check_arg_has_no_nans
 def curvature(darr: xr.DataArray, a0: float = 1.0, factor: float = 1.0) -> xr.DataArray:
     """2D curvature method for detecting dispersive features.
 
diff --git a/src/erlab/analysis/interpolate.py b/src/erlab/analysis/interpolate.py
index c2ee0507..9e12acad 100644
--- a/src/erlab/analysis/interpolate.py
+++ b/src/erlab/analysis/interpolate.py
@@ -1,11 +1,11 @@
 """Utilities for interpolation."""
 
-__all__ = ["FastInterpolator", "interpn", "slice_along_path"]
+__all__ = ["FastInterpolator", "interpn", "slice_along_path", "slice_along_vector"]
 
 import itertools
 import math
 import warnings
-from collections.abc import Callable, Hashable, Mapping, Sequence
+from collections.abc import Callable, Hashable, Iterable, Mapping, Sequence
 from typing import cast
 
 import numba
@@ -56,6 +56,9 @@ def __init__(
         method="linear",
         bounds_error=False,
         fill_value=np.nan,
+        *,
+        solver=None,
+        solver_args=None,
     ) -> None:
         super().__init__(
             points,
@@ -63,6 +66,8 @@ def __init__(
             method=method,
             bounds_error=bounds_error,
             fill_value=fill_value,
+            solver=solver,
+            solver_args=solver_args,
         )
 
         self.uneven_dims = tuple(
@@ -175,7 +180,6 @@ def interpn(
     values_x : numpy.ndarray
         Interpolated values at input coordinates.
 
-
     Note
     ----
     This optimized version of linear interpolation can be used with the `xarray`
@@ -326,7 +330,7 @@ def _check_even(arr) -> bool:
 
 def slice_along_path(
     darr: xr.DataArray,
-    vertices: Mapping[Hashable, Sequence],
+    vertices: Mapping[Hashable, Sequence[float]],
     step_size: float | None = None,
     dim_name: str = "path",
     interp_kwargs: dict | None = None,
@@ -449,6 +453,81 @@ def slice_along_path(
     )
 
 
+def slice_along_vector(
+    data: xr.DataArray,
+    center: dict[str, float],
+    direction: dict[str, float],
+    stretch: float | tuple[float, float],
+    **kwargs,
+):
+    """Slice along a vector defined by the center point, direction vector, and lengths.
+
+    Parameters
+    ----------
+    data
+        The data to be sliced.
+    center
+        The center point of the vector given as a dictionary. The keys should correspond
+        to dimensions in `data`.
+    direction
+        The direction vector given as a dictionary with the same keys as `center`. The
+        direction is normalized before slicing.
+    stretch
+        The length of the vector in both directions in data units. If a single value is
+        given, the vector is stretched in both directions by the same amount. If a tuple
+        is given, the vector is stretched by different amounts in the negative and
+        positive directions.
+    **kwargs
+        Additional keyword arguments to be passed to `slice_along_path`.
+
+    Returns
+    -------
+    xr.DataArray
+        The sliced data array.
+
+    Examples
+    --------
+    ::
+
+        import erlab.analysis as era
+        import numpy as np
+        from erlab.io.exampledata import generate_data
+
+        # Generate some example data: a 3D kx-ky-eV ARPES map.
+        data = generate_data()
+
+        # Slice along a vector centered at (kx, ky) = (-0.5, -0.3).
+        # The direction of the vector is defined by (kx, ky) = (sqrt(3), 1.0).
+        # The slice starts from -0.1 inverse Å and ends at 0.2 inverse Å.
+
+        era.interpolate.slice_along_vector(
+            data,
+            center=dict(kx=-0.5, ky=-0.3),
+            direction=dict(kx=np.sqrt(3), ky=1.0),
+            stretch=(0.1, 0.3),
+        )
+
+    See Also
+    --------
+    :func:`erlab.analysis.interpolate.slice_along_path`
+
+    """
+    direction: dict[str, float] = {
+        k: float(v / np.linalg.norm(list(direction.values())))
+        for k, v in direction.items()
+    }
+    if isinstance(stretch, Iterable):
+        lm, lp = stretch
+    else:
+        lm = lp = stretch
+
+    vert: Mapping[Hashable, Sequence[float]] = {
+        k: (center[k] - lm * direction[k], center[k] + lp * direction[k])
+        for k in center
+    }
+    return slice_along_path(data, vertices=vert, **kwargs)
+
+
 # Monkey patch xarray to make fast interpolator available
 
 _get_interpolator_nd_original = xarray.core.missing._get_interpolator_nd
diff --git a/src/erlab/analysis/transform.py b/src/erlab/analysis/transform.py
index e7dc9db9..288f881c 100644
--- a/src/erlab/analysis/transform.py
+++ b/src/erlab/analysis/transform.py
@@ -1,12 +1,361 @@
 """Transformations."""
 
-__all__ = ["rotateinplane", "rotatestackinplane"]
+__all__ = ["rotate", "shift"]
+
+import itertools
+import warnings
+from collections.abc import Hashable, Iterable, Mapping
+from typing import cast
 
 import numpy as np
+import scipy.ndimage
+import scipy.special
 import xarray as xr
 
+from erlab.utils.array import trim_na, uniform_dims
+
+
+def rotate(
+    darr: xr.DataArray,
+    angle: float,
+    axes: tuple[int, int] | tuple[Hashable, Hashable] = (0, 1),
+    center: tuple[float, float] | Mapping[Hashable, float] = (0.0, 0.0),
+    reshape: bool = True,
+    order: int = 1,
+    mode="constant",
+    cval=np.nan,
+    prefilter: bool = True,
+):
+    """Rotate an array in the plane defined by the two axes.
+
+    Parameters
+    ----------
+    darr
+        The array to rotate.
+    angle
+        The rotation angle in degrees.
+    axes : tuple of 2 ints or strings, optional
+        The two axes that define the plane of rotation. Default is the first two axes.
+        If strings are provided, they must be valid dimension names in the input array.
+    center : tuple of 2 floats or dict, optional
+        The center of rotation in data coordinates. If a tuple, it is given as values
+        along the dimensions specified in `axes`. If a dict, it must have keys that
+        correspond to `axes`. Default is (0, 0).
+    reshape
+        If `reshape` is true, the output shape is adapted so that the input array is
+        contained completely in the output. Default is True.
+    order
+        The order of the spline interpolation, default is 1. The order has to be in the
+        range 0-5.
+    mode, cval, prefilter
+        Passed to :func:`scipy.ndimage.affine_transform`. See the scipy documentation
+        for more information.
+
+    Returns
+    -------
+    darr : xarray.DataArray
+        The rotated array.
+
+    See Also
+    --------
+    scipy.ndimage.affine_transform
+        The function that performs the affine transformation on the input array.
+    scipy.ndimage.rotate
+        Similar function that rotates a numpy array.
+
+    """
+    input_arr = darr.values
+    ndim = input_arr.ndim
+
+    if ndim < 2:
+        raise ValueError("input array should be at least 2D")
+
+    if isinstance(axes[0], int):
+        axes_dims: list[Hashable] = [darr.dims[a] for a in cast(tuple[int, int], axes)]
+    else:
+        axes_dims = list(axes)
+    axes = list(darr.get_axis_num(axes_dims))
+
+    if isinstance(center, Mapping):
+        if set(center.keys()) != set(axes_dims):
+            raise ValueError("center must have keys that match axes")
+        centers = [center[dim] for dim in axes_dims]
+    else:
+        centers = list(center)
+
+    if not uniform_dims(darr).issuperset(axes_dims):
+        raise ValueError("all coordinates along axes must be evenly spaced")
+
+    # Sort with respect to axis index
+    axes, axes_dims, centers = map(
+        list, zip(*sorted(zip(axes, axes_dims, centers, strict=True)), strict=True)
+    )
+
+    # Get pixel sizes
+    ycoords, xcoords = darr[axes_dims[0]].values, darr[axes_dims[1]].values
+    dy, dx = ycoords[1] - ycoords[0], xcoords[1] - xcoords[0]
+    pixel_ratio = np.abs(dy / dx)  # pixel aspect ratio
+
+    # Center in coordinate space
+    center_y, center_x = centers
+
+    # Rotation center in pixel space before transformation
+    in_pixel_center = np.array(
+        [(center_y - ycoords[0]) / dy, (center_x - xcoords[0]) / dx, 1.0]
+    )
+
+    # Build rotation matrix
+    c, s = scipy.special.cosdg(angle), scipy.special.sindg(angle)
+    matrix = np.array([[c, s, 0], [-s, c, 0], [0, 0, 1]])
+
+    # Since rotation is applied in pixel space, scale for non-square pixels
+    scale_matrix = np.diag([1.0 / pixel_ratio, 1.0, 1.0])
+    scale_matrix_inverse = np.diag([pixel_ratio, 1.0, 1.0])
+    matrix = scale_matrix @ matrix @ scale_matrix_inverse
+
+    img_shape = np.asarray(input_arr.shape)
+    in_plane_shape = img_shape[axes]
+
+    if reshape:
+        iy, ix = in_plane_shape
+        out_bounds = matrix @ [[0, 0, iy, iy], [0, ix, 0, ix], [1, 1, 1, 1]]
+        out_plane_shape = (np.ptp(out_bounds, axis=1) + 0.5).astype(int)
+
+        out_center = (matrix @ ((out_plane_shape - 1) / 2))[:2]
+        out_plane_shape = out_plane_shape[:2]
+        in_center = (in_plane_shape - 1) / 2
+
+    else:
+        out_plane_shape = img_shape[axes]
+        out_center = (matrix @ in_pixel_center)[:2]
+        in_center = in_pixel_center[:2]
+
+    offset = in_center - out_center
+
+    # Build translation matrix
+    translation_matrix = np.array([[1, 0, offset[0]], [0, 1, offset[1]], [0, 0, 1]])
+    matrix = translation_matrix @ matrix
+
+    output_shape = img_shape
+    output_shape[axes] = out_plane_shape
+    output_shape = tuple(output_shape)
+
+    if np.iscomplexobj(input_arr):
+        output = np.zeros(
+            output_shape, dtype=np.promote_types(input_arr.dtype, np.complex64)
+        )
+    else:
+        output = np.zeros(output_shape, dtype=input_arr.dtype.name)
+
+    if ndim <= 2:
+        scipy.ndimage.affine_transform(
+            input_arr, matrix, 0.0, output_shape, output, order, mode, cval, prefilter
+        )
+    else:
+        # If ndim > 2, the rotation is applied over all the planes parallel to axes
+        planes_coord = itertools.product(
+            *[
+                [slice(None)] if ax in axes else range(img_shape[ax])
+                for ax in range(ndim)
+            ]
+        )
+
+        for coordinates in cast(Iterable[tuple[slice | int, ...]], planes_coord):
+            ia = input_arr[coordinates]
+            oa = output[coordinates]
+            scipy.ndimage.affine_transform(
+                ia,
+                matrix,
+                0.0,
+                tuple(out_plane_shape),
+                oa,
+                order,
+                mode,
+                cval,
+                prefilter,
+            )
+
+    shape_diff = out_plane_shape - in_plane_shape
+
+    # Coords associated with rotated axes are meaningless after rotation
+    for dim, coord in dict(darr.coords).items():
+        if dim in axes_dims:
+            continue
+        for ax in axes_dims:
+            if ax in coord.dims:
+                darr = darr.copy().drop_vars((dim,))
+                break
+
+    if reshape:
+        # Adjust DataArray shape to match the output shape
+        for i, diff in zip(axes, shape_diff, strict=True):
+            if diff < 0:
+                darr = darr.isel({axes_dims[i]: slice(None, diff)})
+            elif diff > 0:
+                darr = darr.copy(deep=True).pad({axes_dims[i]: (0, diff)})
+
+        # Rotation center in pixel space after transformation
+        out_pixel_center = np.linalg.lstsq(matrix, in_pixel_center, rcond=None)[0][:2]
+
+        start0 = -out_pixel_center[0] * dy + center_y
+        end0 = start0 + (out_plane_shape[0] - 1) * dy
+
+        start1 = -out_pixel_center[1] * dx + center_x
+        end1 = start1 + (out_plane_shape[1] - 1) * dx
+
+        darr = darr.assign_coords(
+            {
+                axes_dims[0]: np.linspace(start0, end0, out_plane_shape[0]),
+                axes_dims[1]: np.linspace(start1, end1, out_plane_shape[1]),
+            }
+        )
+
+    darr = darr.copy(data=output)
+
+    if reshape:
+        darr = trim_na(darr, axes_dims)
+
+    return darr
+
+
+def shift(
+    darr: xr.DataArray,
+    shift: float | xr.DataArray,
+    along: str,
+    shift_coords: bool = False,
+    **shift_kwargs,
+) -> xr.DataArray:
+    """Shifts the values of a DataArray along a single dimension.
+
+    The shift is applied using `scipy.ndimage.shift` which uses spline interpolation. By
+    default, the spline is of order 1 (linear interpolation).
+
+    Parameters
+    ----------
+    darr
+        The array to shift.
+    shift
+        The amount of shift to be applied along the specified dimension. If
+        :code:`shift` is a DataArray, different shifts can be applied to different
+        coordinates. The dimensions of :code:`shift` must be a subset of the dimensions
+        of `darr`. For more information, see the note below. If :code:`shift` is a
+        `float`, the same shift is applied to all values along dimension `along`. This
+        is equivalent to providing a 0-dimensional DataArray.
+    along
+        Name of the dimension along which the shift is applied.
+    shift_coords
+        If `True`, the coordinates of the output data will be changed so that the output
+        contains all the values of the original data. If `False`, the coordinates and
+        shape of the original data will be retained, and only the data will be shifted.
+        Defaults to `False`.
+    **shift_kwargs
+        Additional keyword arguments passed onto `scipy.ndimage.shift`. Default values
+        of `cval` and `order` are set to `np.nan` and `1` respectively.
+
+    Returns
+    -------
+    xarray.DataArray
+        The shifted DataArray.
+
+    Note
+    ----
+    - All dimensions in :code:`shift` must be a dimension in `darr`.
+    - The :code:`shift` array values are divided by the step size along the `along`
+      dimension.
+    - NaN values in :code:`shift` are treated as zero.
+
+    Example
+    -------
+
+    >>> import xarray as xr
+    >>> import erlab.analysis as era
+    >>> darr = xr.DataArray(
+    ...     np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]).astype(float), dims=["x", "y"]
+    ... )
+    >>> shift_arr = xr.DataArray([1, 0, 2], dims=["x"])
+    >>> shifted = era.transform.shift(darr, shift_arr, along="y")
+    >>> print(shifted)
+    <xarray.DataArray (x: 3, y: 3)> Size: 72B
+    nan 1.0 2.0 4.0 5.0 6.0 nan nan 7.0
+    Dimensions without coordinates: x, y
+    """
+    shift_kwargs.setdefault("order", 1)
+    shift_kwargs.setdefault("mode", "constant")
+    if shift_kwargs["mode"] == "constant":
+        shift_kwargs.setdefault("cval", np.nan)
+
+    if not isinstance(shift, xr.DataArray):
+        shift = xr.DataArray(float(shift))
+
+    for dim in shift.dims:
+        if dim not in darr.dims:
+            raise ValueError(f"Dimension {dim} in shift array not found in input array")
+        if darr[dim].size != shift[dim].size:
+            raise ValueError(
+                f"Dimension {dim} in shift array has different size than input array"
+            )
+
+    domain_indices: tuple[int, ...] = darr.get_axis_num(shift.dims)
+
+    # `along` must be evenly spaced and monotonic increasing
+    out = darr.sortby(along).copy()
+
+    # Normalize shift values
+    along_step: float = out[along].values[1] - out[along].values[0]
+    shift = (shift.copy() / along_step).fillna(0.0)
+
+    if shift_coords:
+        # We first apply the integer part of the average shift to the coords
+        rigid_shift: float = float(np.round(shift.values.mean()))
+        shift = shift - rigid_shift
+
+        # Apply coordinate shift
+        out = out.assign_coords({along: out[along].values + rigid_shift * along_step})
+
+        # The bounds of the remaining shift values are used to pad the data
+        nshift_min, nshift_max = shift.values.min(), shift.values.max()
+        pads: tuple[int, int] = min(0, round(nshift_min)), max(0, round(nshift_max))
+
+        # Construct new coordinate array
+        new_along = np.linspace(
+            out[along].values[0] + pads[0] * along_step,
+            out[along].values[-1] + pads[1] * along_step,
+            out[along].size + sum(np.abs(pads)),
+        )
+
+        # Pad the data and assign new coordinates
+        out = out.pad(
+            {along: tuple(np.abs(pads))}, mode="constant", constant_values=np.nan
+        )
+        out = out.assign_coords({along: new_along})
+
+    for idxs in itertools.product(*[range(darr.shape[i]) for i in domain_indices]):
+        # Construct slices for indexing
+        _slices: list[slice | int] = [slice(None)] * darr.ndim
+        for domain_index, i in zip(domain_indices, idxs, strict=True):
+            _slices[domain_index] = i
+
+        slices: tuple[slice | int, ...] = tuple(_slices)
+
+        # Initialize arguments to `scipy.ndimage.shift`
+        arr = out[slices]
+        shifts: list[float] = [0.0] * arr.ndim
+        shift_val: float = float(shift.isel(dict(zip(shift.dims, idxs, strict=True))))
+        shifts[cast(int, arr.get_axis_num(along))] = shift_val
+
+        # Apply shift
+        out[slices] = scipy.ndimage.shift(arr.values, shifts, **shift_kwargs)
+
+    return out
+
 
 def rotateinplane(data: xr.DataArray, rotate, **interp_kwargs):
+    warnings.warn(
+        "erlab.analysis.transform.rotateinplane is deprecated, "
+        "use erlab.analysis.transform.rotate instead",
+        DeprecationWarning,
+        stacklevel=1,
+    )
     interp_kwargs.setdefault("method", "linearfast")
 
     theta = np.radians(rotate)
@@ -17,6 +366,12 @@ def rotateinplane(data: xr.DataArray, rotate, **interp_kwargs):
 
 
 def rotatestackinplane(data: xr.DataArray, rotate, **interp_kwargs):
+    warnings.warn(
+        "erlab.analysis.transform.rotateinplane is deprecated, "
+        "use erlab.analysis.transform.rotate instead",
+        DeprecationWarning,
+        stacklevel=1,
+    )
     interp_kwargs.setdefault("method", "linearfast")
 
     theta = np.radians(rotate)
diff --git a/src/erlab/analysis/utilities.py b/src/erlab/analysis/utilities.py
deleted file mode 100644
index 2735e956..00000000
--- a/src/erlab/analysis/utilities.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import warnings
-
-from erlab.analysis.utils import correct_with_edge, shift  # noqa: F401
-
-warnings.warn(
-    "`erlab.analysis.utilities` has been moved to `erlab.analysis.utils` "
-    "and will be removed in a future release",
-    DeprecationWarning,
-    stacklevel=2,
-)
diff --git a/src/erlab/analysis/utils.py b/src/erlab/analysis/utils.py
index 54df19fc..88355093 100644
--- a/src/erlab/analysis/utils.py
+++ b/src/erlab/analysis/utils.py
@@ -1,142 +1,18 @@
-__all__ = ["shift"]
-
-import itertools
 import warnings
-from typing import cast
 
-import numpy as np
-import scipy.ndimage
 import xarray as xr
 
 
-def shift(
-    darr: xr.DataArray,
-    shift: float | xr.DataArray,
-    along: str,
-    shift_coords: bool = False,
-    **shift_kwargs,
-) -> xr.DataArray:
-    """Shifts the values of a DataArray along a single dimension.
-
-    The shift is applied using `scipy.ndimage.shift` with the specified keyword
-    arguments. Linear interpolation is used by default.
-
-    Parameters
-    ----------
-    darr
-        The array to shift.
-    shift
-        The amount of shift to be applied along the specified dimension. If
-        :code:`shift` is a DataArray, different shifts can be applied to different
-        coordinates. The dimensions of :code:`shift` must be a subset of the dimensions
-        of `darr`. For more information, see the note below. If :code:`shift` is a
-        `float`, the same shift is applied to all values along dimension `along`. This
-        is equivalent to providing a 0-dimensional DataArray.
-    along
-        Name of the dimension along which the shift is applied.
-    shift_coords
-        If `True`, the coordinates of the output data will be changed so that the output
-        contains all the values of the original data. If `False`, the coordinates and
-        shape of the original data will be retained, and only the data will be shifted.
-        Defaults to `False`.
-    **shift_kwargs
-        Additional keyword arguments passed onto `scipy.ndimage.shift`. Default values
-        of `cval` and `order` are set to `np.nan` and `1` respectively.
-
-    Returns
-    -------
-    xarray.DataArray
-        The shifted DataArray.
-
-    Note
-    ----
-    - All dimensions in :code:`shift` must be a dimension in `darr`.
-    - The :code:`shift` array values are divided by the step size along the `along`
-      dimension.
-    - NaN values in :code:`shift` are treated as zero.
-
-    Example
-    -------
-
-    >>> import xarray as xr
-    >>> darr = xr.DataArray(
-    ...     np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]).astype(float), dims=["x", "y"]
-    ... )
-    >>> shift_arr = xr.DataArray([1, 0, 2], dims=["x"])
-    >>> shifted = erlab.analysis.utils.shift(darr, shift_arr, along="y")
-    >>> print(shifted)
-    <xarray.DataArray (x: 3, y: 3)> Size: 72B
-    nan 1.0 2.0 4.0 5.0 6.0 nan nan 7.0
-    Dimensions without coordinates: x, y
-    """
-    shift_kwargs.setdefault("order", 1)
-    shift_kwargs.setdefault("mode", "constant")
-    if shift_kwargs["mode"] == "constant":
-        shift_kwargs.setdefault("cval", np.nan)
-
-    if not isinstance(shift, xr.DataArray):
-        shift = xr.DataArray(float(shift))
-
-    for dim in shift.dims:
-        if dim not in darr.dims:
-            raise ValueError(f"Dimension {dim} in shift array not found in input array")
-        if darr[dim].size != shift[dim].size:
-            raise ValueError(
-                f"Dimension {dim} in shift array has different size than input array"
-            )
-
-    domain_indices: tuple[int, ...] = darr.get_axis_num(shift.dims)
-
-    # `along` must be evenly spaced and monotonic increasing
-    out = darr.sortby(along).copy()
-
-    # Normalize shift values
-    along_step: float = out[along].values[1] - out[along].values[0]
-    shift = (shift.copy() / along_step).fillna(0.0)
-
-    if shift_coords:
-        # We first apply the integer part of the average shift to the coords
-        rigid_shift: float = float(np.round(shift.values.mean()))
-        shift = shift - rigid_shift
+def shift(*args, **kwargs) -> xr.DataArray:
+    from erlab.analysis.transform import shift as shift_func
 
-        # Apply coordinate shift
-        out = out.assign_coords({along: out[along].values + rigid_shift * along_step})
-
-        # The bounds of the remaining shift values are used to pad the data
-        nshift_min, nshift_max = shift.values.min(), shift.values.max()
-        pads: tuple[int, int] = min(0, round(nshift_min)), max(0, round(nshift_max))
-
-        # Construct new coordinate array
-        new_along = np.linspace(
-            out[along].values[0] + pads[0] * along_step,
-            out[along].values[-1] + pads[1] * along_step,
-            out[along].size + sum(np.abs(pads)),
-        )
-
-        # Pad the data and assign new coordinates
-        out = out.pad(
-            {along: tuple(np.abs(pads))}, mode="constant", constant_values=np.nan
-        )
-        out = out.assign_coords({along: new_along})
-
-    for idxs in itertools.product(*[range(darr.shape[i]) for i in domain_indices]):
-        # Construct slices for indexing
-        _slices: list[slice | int] = [slice(None)] * darr.ndim
-        for domain_index, i in zip(domain_indices, idxs, strict=True):
-            _slices[domain_index] = i
-
-        slices: tuple[slice | int, ...] = tuple(_slices)
-
-        # Initialize arguments to `scipy.ndimage.shift`
-        arr = out[slices]
-        shifts: list[float] = [0.0] * arr.ndim
-        shift_val: float = float(shift.isel(dict(zip(shift.dims, idxs, strict=True))))
-        shifts[cast(int, arr.get_axis_num(along))] = shift_val
-
-        # Apply shift
-        out[slices] = scipy.ndimage.shift(arr.values, shifts, **shift_kwargs)
-
-    return out
+    warnings.warn(
+        "erlab.analysis.utils.shift is deprecated, "
+        "use erlab.analysis.gold.correct_with_edge instead",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return shift_func(*args, **kwargs)
 
 
 def correct_with_edge(*args, **kwargs):
@@ -146,6 +22,6 @@ def correct_with_edge(*args, **kwargs):
         "erlab.analysis.utils.correct_with_edge is deprecated, "
         "use erlab.analysis.gold.correct_with_edge instead",
         DeprecationWarning,
-        stacklevel=1,
+        stacklevel=2,
     )
     return correct_with_edge(*args, **kwargs)
diff --git a/src/erlab/interactive/colors.py b/src/erlab/interactive/colors.py
index 3cab54ad..5135fbcd 100644
--- a/src/erlab/interactive/colors.py
+++ b/src/erlab/interactive/colors.py
@@ -316,6 +316,65 @@ def mouseDragEvent(self, ev) -> None:
             self.sigRegionChanged.emit(self)
 
 
+class _ColorBarLimitWidget(QtWidgets.QWidget):
+    def __init__(self, colorbar: BetterColorBarItem) -> None:
+        super().__init__()
+        self.setGeometry(QtCore.QRect(0, 640, 242, 182))
+        self.cb = colorbar
+
+        layout = QtWidgets.QFormLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setVerticalSpacing(0)
+        self.setLayout(layout)
+
+        self.max_spin = pg.SpinBox(dec=True, compactHeight=False, finite=False)
+        self.min_spin = pg.SpinBox(dec=True, compactHeight=False, finite=False)
+        self.rst_btn = QtWidgets.QPushButton("Reset")
+
+        self.max_spin.setObjectName("_vmax_spin")
+        self.min_spin.setObjectName("_vmin_spin")
+        self.rst_btn.setObjectName("_vlim_reset_btn")
+
+        layout.addRow("Max", self.max_spin)
+        layout.addRow("Min", self.min_spin)
+        layout.addRow(self.rst_btn)
+
+        self.cb._span.sigRegionChanged.connect(self.region_changed)
+        self.min_spin.sigValueChanged.connect(self.update_region)
+        self.max_spin.sigValueChanged.connect(self.update_region)
+        self.rst_btn.clicked.connect(self.reset)
+
+    def _set_spin_values(self, mn: float, mx: float) -> None:
+        self.max_spin.blockSignals(True)
+        self.min_spin.blockSignals(True)
+        self.max_spin.setValue(mx)
+        self.min_spin.setValue(mn)
+        self.max_spin.blockSignals(False)
+        self.min_spin.blockSignals(False)
+
+    @QtCore.Slot()
+    def region_changed(self):
+        mn, mx = self.cb._span.getRegion()
+        self._set_spin_values(mn, mx)
+
+    @QtCore.Slot()
+    def reset(self):
+        self._set_spin_values(-np.inf, np.inf)
+        self.update_region()
+
+    @QtCore.Slot()
+    def update_region(self):
+        # Trigger region change start and finish signals to simulate drag
+        self.cb._span.sigRegionChangeStarted.emit(self.cb._span)
+        self.cb.setSpanRegion((self.min_spin.value(), self.max_spin.value()))
+        self.cb._span.sigRegionChangeFinished.emit(self.cb._span)
+
+    def setVisible(self, visible: bool) -> None:
+        super().setVisible(visible)
+        if visible:
+            self.region_changed()
+
+
 class BetterColorBarItem(pg.PlotItem):
     def __init__(
         self,
@@ -332,7 +391,7 @@ def __init__(
 
         self.setDefaultPadding(0)
         # self.hideButtons()
-        self.setMenuEnabled(False)
+        self.setMenuEnabled(False, enableViewBoxMenu=True)
         self.vb.setMouseEnabled(x=False, y=True)
 
         self._colorbar = pg.ImageItem(
@@ -361,6 +420,15 @@ def __init__(
         self.setAutoLevels(autoLevels)
         self._images: set[weakref.ref[BetterImageItem]] = set()
         self._primary_image: weakref.ref[BetterImageItem] | None = None
+
+        # Add colorbar limit editor to context menu
+        self.vb.menu.addSeparator()
+        self._clim_menu: QtWidgets.QMenu = self.vb.menu.addMenu("Edit color limits")
+        clw = _ColorBarLimitWidget(self)
+        act = QtWidgets.QWidgetAction(self._clim_menu)
+        act.setDefaultWidget(clw)
+        self._clim_menu.addAction(act)
+
         if image is not None:
             self.setImageItem(image)
         if limits is not None:
diff --git a/src/erlab/interactive/derivative.py b/src/erlab/interactive/derivative.py
index 3c714c2e..e574517f 100644
--- a/src/erlab/interactive/derivative.py
+++ b/src/erlab/interactive/derivative.py
@@ -21,7 +21,7 @@
 )
 from erlab.interactive.utils import (
     copy_to_clipboard,
-    gen_function_code,
+    generate_code,
     parse_data,
     xImageItem,
 )
@@ -50,6 +50,15 @@ def __init__(self, data: xr.DataArray, *, data_name: str | None = None) -> None:
         self.setupUi(self)
         self.setWindowTitle("")
 
+        if data.isnull().any():
+            self.show()
+            QtWidgets.QMessageBox.warning(
+                self,
+                "Warning",
+                "Input DataArray contains NaN values. These will be filled with zeros.",
+            )
+            data = data.fillna(0.0)
+
         if data.ndim != 2:
             raise ValueError("Input DataArray must be 2D")
 
@@ -232,9 +241,14 @@ def copy_code(self) -> str:
                     strict=True,
                 )
             }
+
             lines.append(
-                gen_function_code(
-                    copy=False, **{f"_processed = {data_name}.interp": [arg_dict]}
+                generate_code(
+                    xr.DataArray.interp,
+                    [],
+                    arg_dict,
+                    module=data_name,
+                    assign="_processed",
                 )
             )
             data_name = "_processed"
@@ -258,17 +272,16 @@ def copy_code(self) -> str:
                 (
                     f"for _ in range({self.sn_spin.value()}):",
                     "\t"
-                    + gen_function_code(
-                        copy=False,
-                        **{
-                            "_processed = era.image.gaussian_filter": [
-                                f"|{data_name}|",
-                                arg_dict,
-                            ]
-                        },
+                    + generate_code(
+                        gaussian_filter,
+                        [f"|{data_name}|"],
+                        arg_dict,
+                        module="era.image",
+                        assign="_processed",
                     ),
                 )
             )
+
             data_name = "_processed"
 
         if self.tab_widget.currentIndex() == 0:
@@ -279,7 +292,7 @@ def copy_code(self) -> str:
         else:
             match self.tab_widget.currentIndex():
                 case 1:
-                    fname = "era.image.scaled_laplace"
+                    func = scaled_laplace
                     arg_dict = {
                         "factor": float(
                             np.round(
@@ -289,7 +302,7 @@ def copy_code(self) -> str:
                         )
                     }
                 case 2:
-                    fname = "era.image.curvature"
+                    func = curvature
                     arg_dict = {
                         "a0": float(
                             np.round(
@@ -304,12 +317,17 @@ def copy_code(self) -> str:
                         ),
                     }
                 case 3:
-                    fname = "era.image.minimum_gradient"
+                    func = minimum_gradient
                     arg_dict = {}
 
             lines.append(
-                gen_function_code(
-                    copy=False, **{f"result = {fname}": [f"|{data_name}|", arg_dict]}
+                generate_code(
+                    func,
+                    [f"|{data_name}|"],
+                    arg_dict,
+                    module="era.image",
+                    assign="result",
+                    remove_defaults=False,
                 )
             )
 
diff --git a/src/erlab/interactive/fermiedge.py b/src/erlab/interactive/fermiedge.py
index b8c19f11..78a4017d 100644
--- a/src/erlab/interactive/fermiedge.py
+++ b/src/erlab/interactive/fermiedge.py
@@ -18,7 +18,8 @@
     ParameterGroup,
     ROIControls,
     _coverage_resolve_trace,
-    gen_function_code,
+    copy_to_clipboard,
+    generate_code,
     xImageItem,
 )
 from erlab.utils.parallel import joblib_progress_qt
@@ -449,7 +450,7 @@ def _perform_poly_fit(self):
             scale_covar=params["Scale cov"],
         )
         target = self.data if self.data_corr is None else self.data_corr
-        self.corrected = erlab.analysis.correct_with_edge(
+        self.corrected = erlab.analysis.gold.correct_with_edge(
             target, self.result, plot=False, shift_coords=params["Shift coords"]
         )
         return lambda x: self.result.eval(self.result.params, x=x)
@@ -465,7 +466,7 @@ def _perform_spline_fit(self):
         )
 
         target = self.data if self.data_corr is None else self.data_corr
-        self.corrected = erlab.analysis.correct_with_edge(
+        self.corrected = erlab.analysis.gold.correct_with_edge(
             target, self.result, plot=False, shift_coords=params["Shift coords"]
         )
         return self.result
@@ -493,10 +494,10 @@ def gen_code(self, mode: str) -> None:
 
         match mode:
             case "poly":
-                fname = "poly"
+                func = erlab.analysis.gold.poly
                 arg_dict["degree"] = p1["Degree"]
             case "spl":
-                fname = "spline"
+                func = erlab.analysis.gold.spline
                 if p1["Auto"]:
                     arg_dict["lam"] = None
                 else:
@@ -514,32 +515,22 @@ def gen_code(self, mode: str) -> None:
         if mode == "poly" and not p1["Scale cov"]:
             arg_dict["scale_covar"] = False
 
-        if self.data_corr is None:
-            gen_function_code(
-                copy=True,
-                **{
-                    f"modelresult = era.gold.{fname}": [
-                        f"|{self._argnames['data']}|",
-                        arg_dict,
-                    ]
-                },
-            )
-        else:
-            arg_dict["correct"] = True
-            gen_function_code(
-                copy=True,
-                **{
-                    f"modelresult = era.gold.{fname}": [
-                        f"|{self._argnames['data']}|",
-                        arg_dict,
-                    ],
-                    "corrected = era.correct_with_edge": [
-                        f"|{self._argnames['data_corr']}|",
-                        "|modelresult|",
-                        {"shift_coords": p1["Shift coords"]},
-                    ],
-                },
+        code_str = generate_code(
+            func,
+            [f"|{self._argnames['data']}|"],
+            arg_dict,
+            module="era.gold",
+            assign="modelresult",
+        )
+        if self.data_corr is not None:
+            code_str += "\n" + generate_code(
+                erlab.analysis.gold.correct_with_edge,
+                [f"|{self._argnames['data_corr']}|", "|modelresult|"],
+                {"shift_coords": p1["Shift coords"]},
+                module="era.gold",
+                assign="corrected",
             )
+        copy_to_clipboard(code_str)
 
 
 def goldtool(
diff --git a/src/erlab/interactive/imagetool/__init__.py b/src/erlab/interactive/imagetool/__init__.py
index 28ff84eb..75bfc7c1 100644
--- a/src/erlab/interactive/imagetool/__init__.py
+++ b/src/erlab/interactive/imagetool/__init__.py
@@ -38,7 +38,7 @@
     ItoolCrosshairControls,
 )
 from erlab.interactive.imagetool.core import ImageSlicerArea, SlicerLinkProxy
-from erlab.interactive.utils import DictMenuBar, copy_to_clipboard
+from erlab.interactive.utils import DictMenuBar, copy_to_clipboard, generate_code
 
 if TYPE_CHECKING:
     from collections.abc import Callable, Collection
@@ -133,7 +133,8 @@ def itool(
         or a list of ImageTool windows depending on the input data.
 
         The function will also return `None` if the windows are opened in the
-        :class:`ImageToolManager <erlab.interactive.imagetool.manager.ImageToolManager>`
+        :class:`ImageToolManager
+        <erlab.interactive.imagetool.manager.ImageToolManager>`.
 
     Examples
     --------
@@ -359,6 +360,11 @@ def widget_box(self, widget: QtWidgets.QWidget, **kwargs) -> QtWidgets.QGroupBox
         group_layout.addWidget(widget)
         return group
 
+    def closeEvent(self, evt: QtGui.QCloseEvent | None) -> None:
+        for ax in self.slicer_area.images:
+            ax.close_associated_windows()
+        super().closeEvent(evt)
+
 
 class ImageTool(BaseImageTool):
     """The ImageTool window class.
@@ -446,6 +452,7 @@ def colorAct(self):
         )
 
     def _generate_menu_kwargs(self) -> dict:
+        _guideline_actions = self.slicer_area.main_image._guideline_actions
         menu_kwargs: dict[str, Any] = {
             "fileMenu": {
                 "title": "&File",
@@ -537,12 +544,19 @@ def _generate_menu_kwargs(self) -> dict:
                         "triggered": self._copy_cursor_idx,
                         "sep_after": True,
                     },
+                    "Rotate": {"triggered": self._rotate},
+                    "Rotation Guidelines": {
+                        "actions": {
+                            f"guide{i}": act for i, act in enumerate(_guideline_actions)
+                        },
+                        "sep_after": True,
+                    },
                 },
             },
             "helpMenu": {
                 "title": "&Help",
                 "actions": {
-                    "helpAction": {"text": "DataSlicer Help (WIP)"},
+                    "helpAction": {"text": "Help (WIP)"},
                     "shortcutsAction": {
                         "text": "Keyboard Shortcuts Reference (WIP)",
                         "sep_before": True,
@@ -653,6 +667,11 @@ def refreshEditMenus(self) -> None:
         self.action_dict["undoAct"].setEnabled(self.slicer_area.undoable)
         self.action_dict["redoAct"].setEnabled(self.slicer_area.redoable)
 
+    @QtCore.Slot()
+    def _rotate(self) -> None:
+        dialog = RotationDialog(self.slicer_area)
+        dialog.exec()
+
     def _set_colormap_options(self) -> None:
         self.slicer_area.set_colormap(
             reverse=self.colorAct[0].isChecked(),
@@ -734,3 +753,154 @@ def _export_file(self) -> None:
             files = dialog.selectedFiles()
             fn, kargs = valid_savers[dialog.selectedNameFilter()]
             fn(self.slicer_area._data, files[0], **kargs)
+
+
+class _DataEditDialog(QtWidgets.QDialog):
+    title: str | None = None
+    prefix: str = ""
+    suffix: str = ""
+    show_copy_button: bool = False
+
+    def __init__(self, slicer_area: ImageSlicerArea) -> None:
+        super().__init__()
+        if self.title is not None:
+            self.setWindowTitle(self.title)
+
+        self.slicer_area = slicer_area
+
+        self._layout = QtWidgets.QFormLayout()
+        self.setLayout(self._layout)
+
+        self.setup_widgets()
+
+        self.new_window_check = QtWidgets.QCheckBox("Open in New Window")
+        self.new_window_check.setChecked(True)
+        self.layout_.addRow(self.new_window_check)
+
+        self.buttonBox = QtWidgets.QDialogButtonBox(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+            | QtWidgets.QDialogButtonBox.StandardButton.Cancel
+        )
+        if self.show_copy_button:
+            self.copy_button = QtWidgets.QPushButton("Copy Code")
+            self.copy_button.clicked.connect(
+                lambda: copy_to_clipboard(self.make_code())
+            )
+            self.buttonBox.addButton(
+                self.copy_button, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole
+            )
+
+        self.buttonBox.accepted.connect(self.accept)
+        self.buttonBox.rejected.connect(self.reject)
+        self.layout_.addRow(self.buttonBox)
+
+    @property
+    def layout_(self) -> QtWidgets.QFormLayout:
+        return self._layout
+
+    @property
+    def array_slicer(self) -> ArraySlicer:
+        return self.slicer_area.array_slicer
+
+    @QtCore.Slot()
+    def accept(self) -> None:
+        if self.slicer_area.data.name is not None:
+            new_name = f"{self.prefix}{self.slicer_area.data.name}{self.suffix}"
+        else:
+            new_name = None
+
+        if self.new_window_check.isChecked():
+            itool(self.process_data().rename(new_name))
+        else:
+            self.slicer_area.set_data(self.process_data().rename(new_name))
+        super().accept()
+
+    def setup_widgets(self) -> None:
+        # Overridden by subclasses
+        pass
+
+    def process_data(self) -> xr.DataArray:
+        # Overridden by subclasses
+        return self.slicer_area.data
+
+    def make_code(self) -> str:
+        # Overridden by subclasses
+        return ""
+
+
+class RotationDialog(_DataEditDialog):
+    suffix = " Rotated"
+    show_copy_button = True
+
+    @property
+    def _rotate_params(self) -> dict[str, Any]:
+        return {
+            "angle": self.angle_spin.value(),
+            "axes": cast(tuple[str, str], tuple(self.slicer_area.main_image.axis_dims)),
+            "center": cast(
+                tuple[float, float], tuple(spin.value() for spin in self.center_spins)
+            ),
+            "reshape": self.reshape_check.isChecked(),
+            "order": self.order_spin.value(),
+        }
+
+    def setup_widgets(self) -> None:
+        main_image = self.slicer_area.main_image
+        self.angle_spin = QtWidgets.QDoubleSpinBox()
+        self.angle_spin.setRange(-360, 360)
+        self.angle_spin.setSingleStep(1)
+        self.angle_spin.setValue(0)
+        self.angle_spin.setDecimals(2)
+        self.angle_spin.setSuffix("°")
+        self.layout_.addRow("Angle", self.angle_spin)
+
+        self.center_spins = (QtWidgets.QDoubleSpinBox(), QtWidgets.QDoubleSpinBox())
+        for i in range(2):
+            axis: int = main_image.display_axis[i]
+            dim: str = str(main_image.axis_dims[i])
+
+            self.center_spins[i].setRange(*map(float, self.array_slicer.lims[i]))
+            self.center_spins[i].setSingleStep(float(self.array_slicer.incs[i]))
+            self.center_spins[i].setValue(0.0)
+            self.center_spins[i].setDecimals(self.array_slicer.get_significant(i))
+
+            self.layout_.addRow(f"Center {dim}", self.center_spins[i])
+            self.array_slicer.get_significant(axis)
+
+        self.order_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox()
+        self.order_spin.setRange(0, 5)
+        self.order_spin.setValue(1)
+        self.layout_.addRow("Spline Order", self.order_spin)
+
+        self.reshape_check = QtWidgets.QCheckBox("Reshape")
+        self.reshape_check.setChecked(True)
+        self.layout_.addRow(self.reshape_check)
+
+        if main_image.is_guidelines_visible:
+            # Fill values from guideline
+            self.angle_spin.setValue(-main_image._guideline_angle)
+            for spin, val in zip(
+                self.center_spins, main_image._guideline_offset, strict=True
+            ):
+                spin.setValue(val)
+
+    def process_data(self) -> xr.DataArray:
+        from erlab.analysis.transform import rotate
+
+        return rotate(self.slicer_area.data, **self._rotate_params)
+
+    def make_code(self) -> str:
+        from erlab.analysis.transform import rotate
+
+        placeholder = " "
+        params = dict(self._rotate_params)
+
+        for k, v in params.items():
+            if isinstance(v, tuple):
+                params[k] = f"({', '.join(map(str, v))})"
+            else:
+                params[k] = str(v)
+
+        return generate_code(
+            rotate, [f"|{placeholder}|"], self._rotate_params, module="era.transform"
+        )
diff --git a/src/erlab/interactive/imagetool/controls.py b/src/erlab/interactive/imagetool/controls.py
index f0df5bd1..17a4480f 100644
--- a/src/erlab/interactive/imagetool/controls.py
+++ b/src/erlab/interactive/imagetool/controls.py
@@ -445,6 +445,7 @@ def update_content(self) -> None:
             self.spin_val[i].setRange(*self.array_slicer.lims[i])
             self.spin_val[i].setSingleStep(self.array_slicer.incs[i])
             self.spin_val[i].setValue(self.slicer_area.get_current_value(i))
+            self.spin_val[i].setDecimals(self.array_slicer.get_significant(i))
 
             self.label_dim[i].blockSignals(False)
             self.spin_idx[i].blockSignals(False)
diff --git a/src/erlab/interactive/imagetool/core.py b/src/erlab/interactive/imagetool/core.py
index 07de011f..42fdff19 100644
--- a/src/erlab/interactive/imagetool/core.py
+++ b/src/erlab/interactive/imagetool/core.py
@@ -28,7 +28,7 @@
     pg_colormap_powernorm,
 )
 from erlab.interactive.imagetool.slicer import ArraySlicer
-from erlab.interactive.utils import BetterAxisItem, copy_to_clipboard
+from erlab.interactive.utils import BetterAxisItem, copy_to_clipboard, make_crosshairs
 
 if TYPE_CHECKING:
     from collections.abc import Callable, Iterable, Sequence
@@ -241,10 +241,10 @@ def children(self) -> set[ImageSlicerArea]:
 
     @property
     def num_children(self) -> int:
-        return len(self._children)
+        return len(self.children)
 
     def unlink_all(self) -> None:
-        for s in self._children:
+        for s in self.children:
             s._linking_proxy = None
         self._children.clear()
 
@@ -288,7 +288,7 @@ def sync(
         """
         if color and not self.link_colors:
             return
-        for target in self._children.difference({source}):
+        for target in self.children.difference({source}):
             getattr(target, funcname)(
                 **self.convert_args(source, target, arguments, indices, steps)
             )
@@ -812,8 +812,10 @@ def refresh_current(self, axes: tuple[int, ...] | None = None) -> None:
     def refresh(self, cursor: int, axes: tuple[int, ...] | None = None) -> None:
         self.sigIndexChanged.emit(cursor, axes)
 
+    @QtCore.Slot()
     def view_all(self) -> None:
-        for ax in self.axes:
+        for ax in reversed(self.axes):
+            # Updating linked axes before the main image to prevent fixed limits
             ax.vb.enableAutoRange()
             ax.vb.updateAutoRange()
 
@@ -1530,6 +1532,15 @@ def __init__(
         copy_code_action = self.vb.menu.addAction("Copy selection code")
         copy_code_action.triggered.connect(self.copy_selection_code)
 
+        if image:
+            goldtool_action = self.vb.menu.addAction("Open in goldtool")
+            self._goldtool: None | QtWidgets.QWidget = None
+            goldtool_action.triggered.connect(self.open_in_goldtool)
+
+            dtool_action = self.vb.menu.addAction("Open in dtool")
+            self._dtool: None | QtWidgets.QWidget = None
+            dtool_action.triggered.connect(self.open_in_dtool)
+
         for i in (0, 1):
             self.getViewBoxMenu().ctrl[i].linkCombo.setVisible(False)
             self.getViewBoxMenu().ctrl[i].label.setVisible(False)
@@ -1563,6 +1574,27 @@ def __init__(
             self._single_queue = collections.deque([0.0], maxlen=9)
             self._next_queue = collections.deque([0.0], maxlen=9)
 
+        if image:
+            # Rotatable alignment guidelines
+            self._guidelines_items: list[pg.GraphicsObject] = []
+            self._guideline_actions: list[QtWidgets.QAction] = []
+            self._action_group = QtWidgets.QActionGroup(self)
+
+            self._guideline_angle: float = 0.0
+            self._guideline_offset: list[float] = [0.0, 0.0]
+
+            for i, text in enumerate(["None", "C2", "C4", "C6"]):
+                qact = QtWidgets.QAction(text)
+                qact.setCheckable(True)
+                qact.toggled.connect(
+                    lambda b, idx=i: self._set_guidelines(idx) if b else None
+                )
+                qact.setActionGroup(self._action_group)
+                self._guideline_actions.append(qact)
+            self._guideline_actions[0].setChecked(True)
+
+            self._rotate_action = QtWidgets.QAction("Apply Rotation")
+
     @property
     def axis_dims(self) -> list[str | None]:
         dim_list: list[str | None] = [
@@ -1579,6 +1611,66 @@ def axis_dims(self) -> list[str | None]:
     def is_independent(self) -> bool:
         return self.vb.state["linkedViews"] == [None, None]
 
+    @property
+    def current_data(self) -> xr.DataArray:
+        return self.slicer_data_items[self.slicer_area.current_cursor].sliced_data
+
+    @property
+    def selection_code(self) -> str:
+        return self.array_slicer.qsel_code(
+            self.slicer_area.current_cursor, self.display_axis
+        )
+
+    @property
+    def is_guidelines_visible(self) -> bool:
+        return len(self._guidelines_items) != 0
+
+    @QtCore.Slot()
+    def close_associated_windows(self) -> None:
+        if self.is_image:
+            if self._goldtool is not None:
+                self._goldtool.close()
+            if self._dtool is not None:
+                self._dtool.close()
+
+    @QtCore.Slot()
+    def open_in_goldtool(self) -> None:
+        if self.is_image:
+            data = self.current_data
+
+            if "alpha" not in data.dims:
+                QtWidgets.QMessageBox.critical(
+                    self,
+                    "Error",
+                    "Data must have an 'alpha' dimension to be opened in GoldTool",
+                )
+                return
+
+            from erlab.interactive.fermiedge import GoldTool
+
+            if self._goldtool is not None:
+                self._goldtool.close()
+                self._goldtool = None
+
+            self._goldtool = GoldTool(
+                data, data_name="data" + self.selection_code, execute=False
+            )
+            self._goldtool.show()
+
+    @QtCore.Slot()
+    def open_in_dtool(self) -> None:
+        if self.is_image:
+            from erlab.interactive.derivative import DerivativeTool
+
+            if self._dtool is not None:
+                self._dtool.close()
+                self._dtool = None
+
+            self._dtool = DerivativeTool(
+                self.current_data, data_name="data" + self.selection_code
+            )
+            self._dtool.show()
+
     def refresh_manual_range(self) -> None:
         if self.is_independent:
             return
@@ -1753,7 +1845,7 @@ def add_cursor(self, update: bool = True) -> None:
                 lambda v, *, line=c, axis=ax: self.line_drag(line, v.temp_value, axis)
             )
             c.sigClicked.connect(lambda *, line=c: self.line_click(line))
-            c.sigDragStarted.connect(self.slicer_area.sigWriteHistory.emit)
+            c.sigDragStarted.connect(lambda: self.slicer_area.sigWriteHistory.emit())
 
         if update:
             self.refresh_cursor(new_cursor)
@@ -1810,16 +1902,86 @@ def refresh_cursor(self, cursor: int) -> None:
                 self.array_slicer.span_bounds(cursor, ax)
             )
 
+    @QtCore.Slot(int)
+    def set_guidelines(self, n: Literal[0, 1, 2, 3]) -> None:
+        """Show rotating crosshairs for alignment."""
+        if not self.is_image:
+            return
+        self._guideline_actions[n].setChecked(True)
+
+    @QtCore.Slot()
+    def remove_guidelines(self) -> None:
+        """Hide rotating crosshairs."""
+        self.set_guidelines(0)
+
+    @QtCore.Slot(int)
+    def _set_guidelines(self, n: Literal[0, 1, 2, 3]) -> None:
+        if not self.is_image:
+            return
+        if n == 0:
+            self._remove_guidelines()
+            return
+
+        old_pos: pg.Point | None = None
+        if len(self._guidelines_items) != n + 1 and self.is_guidelines_visible:
+            old_pos = self._guidelines_items[0].pos()
+            old_angle = float(
+                self._guidelines_items[0].angle - self._guidelines_items[0].offset
+            )
+            self._remove_guidelines()
+
+        for w in make_crosshairs(n):
+            self.addItem(w)
+            self._guidelines_items.append(w)
+
+        line = self._guidelines_items[0]
+
+        if old_pos is not None:
+            line.setPos(old_pos)
+            line.setAngle(old_angle)
+
+        def _print_angle():
+            line_pos = line.pos()
+            self._guideline_angle = line.angle_effective
+            self._guideline_offset = [line_pos.x(), line_pos.y()]
+            for i in range(2):
+                self._guideline_offset[i] = np.round(
+                    self._guideline_offset[i],
+                    self.array_slicer.get_significant(self.display_axis[i]),
+                )
+
+            self.setTitle(
+                f"{self._guideline_angle}° "
+                + str(tuple(self._guideline_offset)).replace("-", "−")
+            )
+
+        line.sigAngleChanged.connect(lambda: _print_angle())
+        line.sigPositionChanged.connect(lambda: _print_angle())
+        _print_angle()
+
+    @QtCore.Slot()
+    def _remove_guidelines(self) -> None:
+        if not self.is_image:
+            return
+        for item in list(self._guidelines_items):
+            self.removeItem(item)
+            self._guidelines_items.remove(item)
+        self._guideline_angle = 0.0
+        self._guideline_offset = [0.0, 0.0]
+        self.setTitle(None)
+
     def connect_signals(self) -> None:
         self._slicer_area.sigIndexChanged.connect(self.refresh_items_data)
         self._slicer_area.sigBinChanged.connect(self.refresh_items_data)
         self._slicer_area.sigShapeChanged.connect(self.update_manual_range)
+        self._slicer_area.sigShapeChanged.connect(self.remove_guidelines)
         self.vb.sigRangeChanged.connect(self.refresh_manual_range)
 
     def disconnect_signals(self) -> None:
         self._slicer_area.sigIndexChanged.disconnect(self.refresh_items_data)
         self._slicer_area.sigBinChanged.disconnect(self.refresh_items_data)
         self._slicer_area.sigShapeChanged.disconnect(self.update_manual_range)
+        self._slicer_area.sigShapeChanged.disconnect(self.remove_guidelines)
         self.vb.sigRangeChanged.disconnect(self.refresh_manual_range)
 
     @QtCore.Slot(int, object)
@@ -1902,18 +2064,16 @@ def save_current_data(self, fileName=None) -> None:
 
         import erlab.io
 
-        erlab.io.save_as_hdf5(
-            self.slicer_data_items[self.slicer_area.current_cursor].sliced_data,
-            fileName,
-        )
+        erlab.io.save_as_hdf5(self.current_data, fileName)
 
     @QtCore.Slot()
     def copy_selection_code(self) -> None:
-        copy_to_clipboard(
-            self.array_slicer.qsel_code(
-                self.slicer_area.current_cursor, self.display_axis
+        if self.selection_code == "":
+            QtWidgets.QMessageBox.critical(
+                self, "Error", "Selection code is undefined for main image of 2D data."
             )
-        )
+            return
+        copy_to_clipboard(self.selection_code)
 
     @property
     def display_axis(self) -> tuple[int, ...]:
@@ -1945,17 +2105,24 @@ def __init__(self, slicer_area: ImageSlicerArea, **kwargs) -> None:
         )
         super().__init__(**kwargs)
 
+        copy_action = self.vb.menu.addAction("Copy color limits to clipboard")
+        copy_action.triggered.connect(self._copy_limits)
+
     @property
     def slicer_area(self) -> ImageSlicerArea:
         return self._slicer_area
 
     @property
     def images(self):
-        return [weakref.ref(x) for x in self._slicer_area._imageitems]
+        return [weakref.ref(x) for x in self.slicer_area._imageitems]
 
     @property
     def primary_image(self):
-        return weakref.ref(self._slicer_area.main_image.slicer_data_items[0])
+        return weakref.ref(self.slicer_area.main_image.slicer_data_items[0])
+
+    @QtCore.Slot()
+    def _copy_limits(self) -> str:
+        return copy_to_clipboard(str(self.slicer_area.levels))
 
     def setImageItem(self, *args, **kwargs) -> None:
         self.slicer_area.sigViewOptionChanged.connect(self.limit_changed)
@@ -1964,7 +2131,7 @@ def setImageItem(self, *args, **kwargs) -> None:
         self._span.setRegion(self.limits)
         self._span.blockSignals(False)
         self._span.sigRegionChangeStarted.connect(
-            self._slicer_area.sigWriteHistory.emit
+            lambda: self.slicer_area.sigWriteHistory.emit()
         )
         self._span.sigRegionChanged.connect(self.level_change)
         self._span.sigRegionChangeFinished.connect(self.level_change_fin)
diff --git a/src/erlab/interactive/imagetool/fastbinning.py b/src/erlab/interactive/imagetool/fastbinning.py
index 081bec36..bccccc89 100644
--- a/src/erlab/interactive/imagetool/fastbinning.py
+++ b/src/erlab/interactive/imagetool/fastbinning.py
@@ -321,7 +321,13 @@ def _nanmean_4_123(a: npt.NDArray[np.float32 | np.float64]) -> npt.NDArray[np.fl
         frozenset({0, 2, 3}): _nanmean_4_023,
         frozenset({1, 2, 3}): _nanmean_4_123,
     },
-}  #: Mapping from array dimensions to axis combinations to corresponding functions.
+}
+"""
+A dictionary that maps the number of dimensions of the input array to a dictionary of
+functions that calculate the mean along specific axes. The keys of the inner dictionary
+are integers or frozensets of integers that represent the axes along which the mean is
+to be calculated.
+"""
 
 
 def fast_nanmean(
@@ -382,8 +388,8 @@ def fast_nanmean_skipcheck(
     """Compute the mean for specific floating point arrays while ignoring NaNs.
 
     This is a version of `fast_nanmean` with near-zero overhead meant for internal use.
-    Strict assumptions on the input parameters allow skipping some checks. Failure to
-    meet these assumptions may lead to undefined behavior.
+    Strict assumptions on the input parameters allow skipping some checks. See the
+    parameter descriptions for allowed input values.
 
     Parameters
     ----------
@@ -399,6 +405,11 @@ def fast_nanmean_skipcheck(
     numpy.ndarray or float
         The calculated mean. The output array is always C-contiguous.
 
+    Note
+    ----
+    This function does not check for the validity of the input parameters. Invalid input
+    may lead to obscure errors or silently produce incorrect results.
+
     """
     if isinstance(axis, Collection):
         if len(axis) == a.ndim:
diff --git a/src/erlab/interactive/imagetool/manager.py b/src/erlab/interactive/imagetool/manager.py
index 0b303dd6..53df47e3 100644
--- a/src/erlab/interactive/imagetool/manager.py
+++ b/src/erlab/interactive/imagetool/manager.py
@@ -108,7 +108,7 @@ def run(self) -> None:
         soc = socket.socket()
         soc.bind(("127.0.0.1", PORT))
         soc.setblocking(False)
-        soc.listen(1)
+        soc.listen()
         print("Server is listening...")
 
         while not self.stopped.is_set():
@@ -145,6 +145,8 @@ def run(self) -> None:
 
             conn.close()
 
+        soc.close()
+
 
 class _QHLine(QtWidgets.QFrame):
     """Horizontal line widget."""
@@ -188,6 +190,7 @@ def tool(self, value: ImageTool | None) -> None:
             self._tool.slicer_area.unlink()
             self._tool.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)
             self._tool.removeEventFilter(self)
+            self._tool.sigTitleChanged.disconnect(self.update_title)
             self._tool.slicer_area.set_data(
                 xr.DataArray(np.zeros((2, 2)), name=self._tool.slicer_area.data.name)
             )
@@ -678,6 +681,9 @@ def closeEvent(self, event: QtGui.QCloseEvent | None) -> None:
             for tool in list(self.tool_options.keys()):
                 self.remove_tool(tool)
 
+        # Clean up temporary directory
+        self._tmp_dir.cleanup()
+
         # Clean up shared memory
         self._shm.close()
         self._shm.unlink()
@@ -803,9 +809,7 @@ def show_in_manager(
 
     # Send the size of the data first
     client_socket.sendall(struct.pack(">L", len(kwargs)))
-
     client_socket.sendall(kwargs)
-
     client_socket.close()
 
 
diff --git a/src/erlab/interactive/imagetool/slicer.py b/src/erlab/interactive/imagetool/slicer.py
index 8b651fb0..69a4866e 100644
--- a/src/erlab/interactive/imagetool/slicer.py
+++ b/src/erlab/interactive/imagetool/slicer.py
@@ -369,6 +369,11 @@ def values_of_dim(self, dim: Hashable) -> npt.NDArray[np.float32]:
         """
         return self._obj._coords[dim]._data.array._data  # type: ignore[union-attr]
 
+    def get_significant(self, axis: int) -> int:
+        """Return the number of significant digits for a given axis."""
+        step = self.incs[axis]
+        return int(np.clip(np.ceil(-np.log10(abs(step)) + 1), 0, None))
+
     def add_cursor(self, like_cursor: int = -1, update: bool = True) -> None:
         self._bins.append(list(self.get_bins(like_cursor)))
         new_ind = self.get_indices(like_cursor)
@@ -647,11 +652,16 @@ def qsel_code(self, cursor: int, disp: Sequence[int]) -> str:
             qsel_kw = self.qsel_args(cursor, disp)
         except ValueError:
             return self.isel_code(cursor, disp)
-
-        return f".qsel({format_kwargs(qsel_kw)})"
+        kwargs_str = format_kwargs(qsel_kw)
+        if kwargs_str:
+            return f".qsel({kwargs_str})"
+        return ""
 
     def isel_code(self, cursor: int, disp: Sequence[int]) -> str:
-        return f".isel({format_kwargs(self.isel_args(cursor, disp, int_if_one=True))})"
+        kwargs_str = format_kwargs(self.isel_args(cursor, disp, int_if_one=True))
+        if kwargs_str:
+            return f".isel({kwargs_str})"
+        return ""
 
     def xslice(self, cursor: int, disp: Sequence[int]) -> xr.DataArray:
         isel_kw = self.isel_args(cursor, disp, int_if_one=False)
diff --git a/src/erlab/interactive/kspace.py b/src/erlab/interactive/kspace.py
index 59db6c9a..e0c1b52a 100644
--- a/src/erlab/interactive/kspace.py
+++ b/src/erlab/interactive/kspace.py
@@ -20,7 +20,7 @@
     ColorMapGammaWidget,  # noqa: F401
 )
 from erlab.interactive.imagetool import ImageTool
-from erlab.interactive.utils import copy_to_clipboard, gen_function_code, xImageItem
+from erlab.interactive.utils import copy_to_clipboard, generate_code, xImageItem
 from erlab.plotting.bz import get_bz_edge
 
 
@@ -265,12 +265,18 @@ def copy_code(self) -> str:
             )
 
         offset_dict_repr = str(self.offset_dict).replace("'", '"')
+
+        from erlab.accessors.kspace import MomentumAccessor
+
         out_lines.extend(
             (
                 f"{input_name}.kspace.offsets = {offset_dict_repr}",
-                gen_function_code(
-                    copy=False,
-                    **{f"{input_name}_kconv = {input_name}.kspace.convert": [arg_dict]},
+                generate_code(
+                    MomentumAccessor.convert,
+                    [],
+                    arg_dict,
+                    module=f"{input_name}.kspace",
+                    assign=f"{input_name}_kconv",
                 ),
             )
         )
diff --git a/src/erlab/interactive/utils.py b/src/erlab/interactive/utils.py
index be1deecb..399bcb65 100644
--- a/src/erlab/interactive/utils.py
+++ b/src/erlab/interactive/utils.py
@@ -3,12 +3,14 @@
 from __future__ import annotations
 
 import functools
+import inspect
+import itertools
 import re
 import sys
 import threading
 import types
 import warnings
-from typing import TYPE_CHECKING, Any, Literal, cast, no_type_check
+from typing import TYPE_CHECKING, Any, Literal, Self, cast, no_type_check
 
 import numpy as np
 import numpy.typing as npt
@@ -20,7 +22,7 @@
 from erlab.interactive.colors import BetterImageItem, pg_colormap_powernorm
 
 if TYPE_CHECKING:
-    from collections.abc import Mapping
+    from collections.abc import Callable, Collection, Mapping
 
     from pyqtgraph.GraphicsScene.mouseEvents import MouseDragEvent
 
@@ -31,9 +33,11 @@
     "BetterSpinBox",
     "DictMenuBar",
     "ParameterGroup",
+    "RotatableLine",
     "copy_to_clipboard",
-    "gen_function_code",
-    "gen_single_function_code",
+    "format_kwargs",
+    "generate_code",
+    "make_crosshairs",
     "parse_data",
     "xImageItem",
 ]
@@ -80,7 +84,8 @@ def copy_to_clipboard(content: str | list[str]) -> str:
     Parameters
     ----------
     content
-        The content to be copied.
+        The content to be copied. If a list of strings is passed, the strings are joined
+        by newlines.
 
     Returns
     -------
@@ -93,6 +98,24 @@ def copy_to_clipboard(content: str | list[str]) -> str:
     return content
 
 
+def format_kwargs(d: dict[str, Any]) -> str:
+    """Format a dictionary of keyword arguments for a function call.
+
+    If the keys are valid Python identifiers, the output will be formatted as keyword
+    arguments. Otherwise, the output will be formatted as a dictionary.
+
+    Parameters
+    ----------
+    d
+        Dictionary of keyword arguments.
+
+    """
+    if all(s.isidentifier() for s in d):
+        return ", ".join(f"{k}={_parse_single_arg(v)!s}" for k, v in d.items())
+    out = ", ".join(f'"{k}": {_parse_single_arg(v)!s}' for k, v in d.items())
+    return "{" + out + "}"
+
+
 def _parse_single_arg(arg):
     if isinstance(arg, str):
         # If the string is surrounded by vertical bars, remove them
@@ -106,7 +129,118 @@ def _parse_single_arg(arg):
     return arg
 
 
-def gen_single_function_code(funcname: str, *args: tuple, **kwargs):
+def generate_code(
+    func: Callable,
+    args: Collection[Any],
+    kwargs: Mapping[str, Any],
+    module: str | None = None,
+    name: str | None = None,
+    assign: str | None = None,
+    prefix: str | None = None,
+    remove_defaults: bool = True,
+    line_length: int = 88,
+    copy: bool = False,
+) -> str:
+    r"""Generate Python code for a function call.
+
+    The result can be copied to your clipboard in a form that can be pasted into an
+    interactive Python session or Jupyter notebook cell.
+
+    Parameters
+    ----------
+    func
+        Function to generate code for.
+    args
+        Positional arguments passed onto the function. Non-string arguments are
+        converted to strings. If given a string, quotes are added. If surrounded by
+        vertical bars (``|``), the string is not quoted.
+    kwargs
+        Keyword arguments passed onto the function.
+    module
+        Prefix to add to the function name. For example, ``"scipy.ndimage"``.
+    name
+        Name of the function. If `None`, `func.__name__` is used.
+    assign
+        If provided, the return value will be assigned to this variable.
+    prefix
+        Prefix to add to the generated string.
+    remove_defaults
+        If `True`, keyword arguments that have values identical to the defaults are
+        removed.
+    line_length
+        Maximum length of the line. If the line is longer than this, it will be split
+        into multiple lines.
+    copy
+        If `True`, the result is copied to the clipboard.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from erlab.interactive.utils import generate_code
+    >>> generate_code(
+    >>>     np.linalg.norm,
+    >>>     args=["|np.array([1, 2, 3])|"],
+    >>>     kwargs=dict(ord=2, keepdims=False),
+    >>>     module="np.linalg",
+    >>>     assign="value",
+    >>> )
+    'value = numpy.linalg.norm(numpy.array([1, 2, 3]), ord=2)'
+
+    """
+    if remove_defaults:
+        kwargs = _remove_default_kwargs(func, dict(kwargs))
+
+    module = "" if module is None else f"{module.strip(' .')}."
+
+    if name is None:
+        name = func.__name__
+    if assign is not None:
+        module = f"{assign} = {module}"
+    if prefix is not None:
+        module = f"{prefix}{module}"
+
+    code_str = _gen_single_function_code(
+        f"{module}{name}", args, kwargs, line_length=line_length
+    )
+
+    if copy:
+        return copy_to_clipboard(code_str)
+    return code_str
+
+
+def _remove_default_kwargs(func: Callable, kwargs: dict[str, Any]) -> dict[str, Any]:
+    """Clean up keyword arguments for a function.
+
+    Given a function and a dictionary of keyword arguments, remove any keys that already
+    have the default value in the function signature.
+
+    Parameters
+    ----------
+    func
+        Function to clean up keyword arguments for.
+    kwargs
+        Dictionary of keyword arguments.
+
+    Returns
+    -------
+    dict
+        Cleaned up dictionary of keyword arguments.
+    """
+    params = inspect.signature(func).parameters
+
+    for k, v in dict(kwargs).items():
+        if params[k].default is not inspect.Parameter.empty and v == params[k].default:
+            kwargs.pop(k)
+    return kwargs
+
+
+def _gen_single_function_code(
+    funcname: str,
+    args: Collection[Any],
+    kwargs: Mapping[str, Any],
+    *,
+    line_length: int = 88,
+) -> str:
     """Generate the string for a Python function call.
 
     The first argument is the name of the function, and subsequent arguments are passed
@@ -118,10 +252,13 @@ def gen_single_function_code(funcname: str, *args: tuple, **kwargs):
     ----------
     funcname
         Name of the function.
-    *args
+    args
         Mandatory arguments passed onto the function.
-    **kwargs
+    kwargs
         Keyword arguments passed onto the function.
+    line_length
+        Maximum length of the line. If the line is longer than this, it will be split
+        into multiple lines.
 
     Returns
     -------
@@ -149,7 +286,7 @@ def gen_single_function_code(funcname: str, *args: tuple, **kwargs):
     # Add closing parenthesis
     code += ")"
 
-    if len(code.replace("\n", "")) <= 88:
+    if len(code.replace("\n", "")) <= line_length:
         # If code fits in one line, remove newlines
         code = " ".join([s.strip() for s in code.split("\n")])
         # Remove trailing comma and space
@@ -158,53 +295,6 @@ def gen_single_function_code(funcname: str, *args: tuple, **kwargs):
     return code
 
 
-def gen_function_code(copy: bool = True, **kwargs):
-    r"""Copy the Python code for function calls to the clipboard.
-
-    The result can be copied to your clipboard in a form that can be pasted into an
-    interactive Python session or Jupyter notebook cell.
-
-    Parameters
-    ----------
-    copy
-        If `True`, the code string is copied.
-    **kwargs
-        Dictionary where the keys are the string of the function call and the values are
-        a list of function arguments. The last item, if a dictionary, is interpreted as
-        keyword arguments.
-
-    """
-    code_list = []
-    for fname, fargs in kwargs.items():
-        if not isinstance(fargs[-1], dict):
-            fargs.append({})
-        code_list.append(gen_single_function_code(fname, *fargs[:-1], **fargs[-1]))
-
-    code_str = "\n".join(code_list)
-
-    if copy:
-        return copy_to_clipboard(code_str)
-    return code_str
-
-
-def format_kwargs(d: dict[str, Any]) -> str:
-    """Format a dictionary of keyword arguments for a function call.
-
-    If the keys are valid Python identifiers, the output will be formatted as keyword
-    arguments. Otherwise, the output will be formatted as a dictionary.
-
-    Parameters
-    ----------
-    d
-        Dictionary of keyword arguments.
-
-    """
-    if all(s.isidentifier() for s in d):
-        return ", ".join(f"{k}={_parse_single_arg(v)!s}" for k, v in d.items())
-    out = ", ".join(f'"{k}": {_parse_single_arg(v)!s}' for k, v in d.items())
-    return "{" + out + "}"
-
-
 class BetterSpinBox(QtWidgets.QAbstractSpinBox):
     """An improved spinbox.
 
@@ -266,7 +356,7 @@ def __init__(
         self._is_discrete = discrete
         self._is_scientific = scientific
         self._decimal_significant = significant
-        self.setDecimals(decimals)
+        self._decimals = decimals
 
         self._value = value
         self._lastvalue: float | None = None
@@ -312,6 +402,7 @@ def prefix(self) -> str:
     @QtCore.Slot(int)
     def setDecimals(self, decimals: int) -> None:
         self._decimals = decimals
+        self._updateWidth()
 
     def decimals(self) -> int:
         return self._decimals
@@ -324,7 +415,7 @@ def setRange(self, mn, mx) -> None:
     def widthFromText(self, text: str) -> int:
         return QtGui.QFontMetrics(self.font()).boundingRect(text).width()
 
-    def widthFromValue(self, value):
+    def widthFromValue(self, value) -> int:
         return self.widthFromText(self.textFromValue(value))
 
     def setMaximum(self, mx) -> None:
@@ -659,7 +750,7 @@ def __init__(
         layout.addWidget(self.check)
 
         for spin in (self.spin_value, self.spin_lb, self.spin_ub):
-            spin.valueChanged.connect(self.sigParamChanged.emit)
+            spin.valueChanged.connect(lambda: self.sigParamChanged.emit())
         self.spin_lb.valueChanged.connect(self._refresh_bounds)
         self.spin_ub.valueChanged.connect(self._refresh_bounds)
         self.check.stateChanged.connect(self.setFixed)
@@ -1672,6 +1763,144 @@ def parse_action(actopts: dict):
         return action
 
 
+class RotatableLine(pg.InfiniteLine):
+    """:class:`pyqtgraph.InfiniteLine` that rotates under drag.
+
+    The position can be changed by providing a :class:`pyqtgraph.TargetItem` which will
+    drag the line along with it.
+
+    Using the constructor function :func:`make_crosshairs` is recommended for creating
+    several lines at once.
+
+    Parameters
+    ----------
+    offset
+        Offset angle in degrees.
+    target
+        Target item to link the position of the line to.
+    **kwargs
+        Additional keyword arguments to pass to :class:`pyqtgraph.InfiniteLine`.
+
+    Signals
+    -------
+    sigAngleChanged(float)
+        Emitted when the angle of the line is changed.
+    """
+
+    sigAngleChanged = QtCore.Signal(float)  #: :meta private:
+    _sigAngleChangeStarted = QtCore.Signal(float)  #: :meta private:
+
+    def __init__(
+        self, offset: float = 0.0, target: pg.TargetItem | None = None, **kwargs
+    ) -> None:
+        self.offset: float = offset
+        kwargs.setdefault("movable", True)
+        super().__init__(**kwargs)
+
+        if target is not None:
+            self.set_target(target)
+
+    @property
+    def angle_effective(self) -> float:
+        """The angle of the line relative to the initial angle."""
+        return np.round(self.angle - self.offset - 90.0, 2)
+
+    def link(self, other: Self) -> None:
+        """Link with another :class:`RotatableLine`.
+
+        Providing another :class:`RotatableLine` will link the angles of both lines.
+        """
+
+        def _on_angle_changed_self(angle) -> None:
+            self.blockSignals(True)
+            self.setAngle(angle)
+            self.blockSignals(False)
+            self.sigAngleChanged.emit(angle)
+
+        def _on_angle_changed_other(angle) -> None:
+            other.blockSignals(True)
+            other.setAngle(angle)
+            other.blockSignals(False)
+            other.sigAngleChanged.emit(angle)
+
+        self._sigAngleChangeStarted.connect(_on_angle_changed_other)
+        other._sigAngleChangeStarted.connect(_on_angle_changed_self)
+
+    def setAngle(self, angle: float) -> None:
+        self.viewTransformChanged()
+        angle = angle % 180
+        super().setAngle(np.round(angle + self.offset, 2))
+        self._sigAngleChangeStarted.emit(angle)
+        self.sigAngleChanged.emit(angle)
+
+    def set_target(self, target: pg.TargetItem) -> None:
+        target.sigPositionChanged.connect(lambda: self._target_moved(target))
+        self.sigPositionChanged.connect(lambda: self._sync_target(target))
+
+    def _sync_target(self, target: pg.TargetItem) -> None:
+        target.blockSignals(True)
+        target.setPos(self.pos())
+        target.blockSignals(False)
+
+    def _target_moved(self, target: pg.TargetItem) -> None:
+        self.setPos(target.pos())
+
+    def mouseDragEvent(self, ev) -> None:
+        if self.movable and ev.button() == QtCore.Qt.MouseButton.LeftButton:
+            if ev.isStart():
+                self.moving = True
+                self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos())
+                self.startPosition = self.pos()
+            ev.accept()
+
+            if not self.moving:
+                return
+
+            lastpos = self.mapToParent(ev.pos())
+            dx = lastpos.x() - self.startPosition.x()
+            dy = lastpos.y() - self.startPosition.y()
+            self.setAngle(np.rad2deg(np.arctan2(dy, dx)) - self.offset)
+
+            self.sigDragged.emit(self)
+            if ev.isFinish():
+                self.moving = False
+
+
+def make_crosshairs(n: Literal[1, 2, 3] = 1) -> list[pg.TargetItem | RotatableLine]:
+    """Create a `pyqtgraph.TargetItem` and associated :class:`RotatableLine`s.
+
+    Parameters
+    ----------
+    n
+        Number of lines to create. Must be 1, 2, or 3. If 1, a single line is created.
+        If 2, two lines are created at 0 and 90 degrees. If 3, three lines are created
+        at 0, 120, and 240 degrees.
+
+    Returns
+    -------
+    list[pg.TargetItem | RotatableLine]
+        List of the created items. Add these to a
+        :class:`pyqtgraph.GraphicsLayoutWidget` or :class:`pyqtgraph.PlotItem`.
+    """
+    if n == 1:
+        angles: tuple[int, ...] = (0,)
+    elif n == 2:
+        angles = (0, 90)
+    else:
+        angles = (0, 120, 240)
+
+    target = pg.TargetItem()
+    lines = [RotatableLine(a, target, movable=True) for a in angles]
+
+    for ln in lines:
+        ln.set_target(target)
+
+    for l0, l1 in itertools.combinations(lines, 2):
+        l0.link(l1)
+
+    return [*lines, target]
+
+
 if __name__ == "__main__":
     from scipy.ndimage import gaussian_filter  # , uniform_filter
 
diff --git a/src/erlab/io/exampledata.py b/src/erlab/io/exampledata.py
index 217a948e..8e8ba0eb 100644
--- a/src/erlab/io/exampledata.py
+++ b/src/erlab/io/exampledata.py
@@ -1,6 +1,6 @@
-"""Generates simple simulated ARPES data for testing purposes."""
+"""Generates simple simulated ARPES data for testing and demonstration."""
 
-__all__ = ["generate_data", "generate_data_angles"]
+__all__ = ["generate_data", "generate_data_angles", "generate_gold_edge"]
 
 from collections.abc import Hashable, Sequence
 from typing import cast
@@ -14,7 +14,7 @@
 from erlab.constants import kb_eV
 
 
-def func(kvec, a):
+def _func(kvec, a):
     n1 = np.array([a / np.sqrt(3), 0])
     n2 = np.array([-a / 2 / np.sqrt(3), a / 2])
     n3 = np.array([-a / 2 / np.sqrt(3), -a / 2])
@@ -25,26 +25,29 @@ def func(kvec, a):
     )
 
 
-def fermi_dirac(E, T):
+def _fermi_dirac(E, T):
     return 1 / (np.exp(E / (8.61733e-5 * T)) + 1)
 
 
-def band(kvec, t, a):
-    return -t * np.abs(func(kvec, a))
+def _band(kvec, t, a):
+    return -t * np.abs(_func(kvec, a))
 
 
-def spectral_function(w, bareband, Sreal, Simag):
+def _spectral_function(w, bareband, Sreal, Simag):
     return Simag / (np.pi * ((w - bareband - Sreal) ** 2 + Simag**2))
 
 
-def add_fd_norm(image, eV, temp=30, efermi=0, count=1e7) -> None:
+def _add_fd_norm(image, eV, temp=30, efermi=0, count=1e7) -> None:
     if temp != 0:
-        image *= fermi_dirac(eV - efermi, temp)[None, None, :]
+        image *= _fermi_dirac(eV - efermi, temp)[None, None, :]
     image += 0.1e-2
     image /= image.sum()
     image *= count
 
 
+# Changing default values may break tests
+
+
 def generate_data(
     shape: tuple[int, int, int] = (250, 250, 300),
     krange: float | tuple[float, float] | dict[str, tuple[float, float]] = 0.89,
@@ -122,17 +125,17 @@ def generate_data(
 
     point_iter = np.array(np.meshgrid(kx, ky)).T.reshape(-1, 2)
 
-    Eij = band(point_iter, t, a).reshape(*shape[:-1], 1)
+    Eij = _band(point_iter, t, a).reshape(*shape[:-1], 1)
 
-    Akw_p = spectral_function(eV[None, None, :], Eij + bandshift, Sreal, Simag)
-    Akw_m = spectral_function(eV[None, None, :], -Eij + bandshift, Sreal, Simag)
+    Akw_p = _spectral_function(eV[None, None, :], Eij + bandshift, Sreal, Simag)
+    Akw_m = _spectral_function(eV[None, None, :], -Eij + bandshift, Sreal, Simag)
 
-    phase = np.angle(func(point_iter, a)).reshape(shape[0], shape[1])
+    phase = np.angle(_func(point_iter, a)).reshape(shape[0], shape[1])
     Akw_p *= (1 + np.cos(phase))[:, :, None]
     Akw_m *= (1 - np.cos(phase))[:, :, None]
     out = Akw_p + Akw_m
 
-    add_fd_norm(out, eV, efermi=0, temp=temp, count=count)
+    _add_fd_norm(out, eV, efermi=0, temp=temp, count=count)
 
     if noise:
         rng = np.random.default_rng(seed)
@@ -251,17 +254,17 @@ def generate_data_angles(
     kxv, kyv = forward_func(a_mesh[:, :, None], b_mesh[:, :, None])
     point_iter = np.stack([kxv, kyv], axis=3)
 
-    Eij = band(point_iter, t, a)
+    Eij = _band(point_iter, t, a)
 
-    Akw_p = spectral_function(eV, Eij + bandshift, Sreal, Simag)
-    Akw_m = spectral_function(eV, -Eij + bandshift, Sreal, Simag)
+    Akw_p = _spectral_function(eV, Eij + bandshift, Sreal, Simag)
+    Akw_m = _spectral_function(eV, -Eij + bandshift, Sreal, Simag)
 
-    phase = np.angle(func(point_iter, a))
+    phase = np.angle(_func(point_iter, a))
     Akw_p *= 1 + np.cos(phase)
     Akw_m *= 1 - np.cos(phase)
     out = Akw_p + Akw_m
 
-    add_fd_norm(out, eV, efermi=0, temp=temp, count=count)
+    _add_fd_norm(out, eV, efermi=0, temp=temp, count=count)
 
     if noise:
         rng = np.random.default_rng(seed)
diff --git a/src/erlab/plotting/atoms.py b/src/erlab/plotting/atoms.py
index 0833a5ff..d1c144f4 100644
--- a/src/erlab/plotting/atoms.py
+++ b/src/erlab/plotting/atoms.py
@@ -422,7 +422,7 @@ def _size_array(self) -> npt.NDArray[np.float64]:
 
     @property
     def _atom_pos_array(self) -> npt.NDArray[np.float64]:
-        return np.r_[*list(self.atom_pos.values())]
+        return np.concatenate(list(self.atom_pos.values()))
 
     def clear_bonds(self) -> None:
         self.segments.clear()
diff --git a/src/erlab/plotting/bz.py b/src/erlab/plotting/bz.py
index d58bbbbc..38463c19 100644
--- a/src/erlab/plotting/bz.py
+++ b/src/erlab/plotting/bz.py
@@ -94,7 +94,8 @@ def get_bz_edge(
                 for line_new in lines_new
             ):
                 lines_new.append(line[i : i + 2])
-    for v in np.r_[*vertices]:
+
+    for v in np.concatenate(vertices):
         if not any(np.allclose(v, vn) for vn in vertices_new):
             vertices_new.append(v)
 
@@ -109,8 +110,8 @@ def get_bz_edge(
             displacement = np.dot(vals, basis)
             additional_lines.append(lines_arr + displacement)
             additional_verts.append(vertices_arr + displacement)
-    lines_arr = np.r_[lines_arr, *additional_lines]
-    vertices_arr = np.r_[vertices_arr, *additional_verts]
+    lines_arr = np.concatenate((lines_arr, *additional_lines))
+    vertices_arr = np.concatenate((vertices_arr, *additional_verts))
 
     return lines_arr, vertices_arr
 
diff --git a/src/erlab/plotting/general.py b/src/erlab/plotting/general.py
index 8a929749..1cf8c6ec 100644
--- a/src/erlab/plotting/general.py
+++ b/src/erlab/plotting/general.py
@@ -40,6 +40,7 @@
     gen_2d_colormap,
     nice_colorbar,
 )
+from erlab.utils.array import is_dims_uniform
 
 if TYPE_CHECKING:
     from collections.abc import Callable, Collection, Sequence
@@ -170,6 +171,56 @@ def array_extent(
     return x0, x1, y0, y1
 
 
+def _imshow_nonuniform(
+    ax,
+    x,
+    y,
+    A,
+    cmap=None,
+    norm=None,
+    *,
+    aspect=None,
+    interpolation=None,
+    alpha=None,
+    vmin=None,
+    vmax=None,
+    url=None,
+    **kwargs,
+):
+    """Display data as an image on a non-uniform grid.
+
+    :func:`matplotlib.pyplot.imshow` creates a :class:`matplotlib.image.AxesImage`, but
+    this function creates a :class:`matplotlib.image.NonUniformImage` instead.
+
+    """
+    im = matplotlib.image.NonUniformImage(
+        ax,
+        cmap=cmap,
+        norm=norm,
+        interpolation=interpolation,
+        **kwargs,
+    )
+
+    if aspect is None and not (
+        im.is_transform_set() and not im.get_transform().contains_branch(ax.transData)
+    ):
+        aspect = matplotlib.rcParams["image.aspect"]
+    if aspect is not None:
+        ax.set_aspect(aspect)
+
+    im.set_data(x, y, A)
+    im.set_alpha(alpha)
+    if im.get_clip_path() is None:
+        im.set_clip_path(ax.patch)
+    im._scale_norm(norm, vmin, vmax)
+    im.set_url(url)
+
+    im.set_extent(im.get_extent())
+
+    ax.add_image(im)
+    return im
+
+
 def plot_array(
     arr: xr.DataArray,
     ax: matplotlib.axes.Axes | None = None,
@@ -186,10 +237,14 @@ def plot_array(
     func_args: dict | None = None,
     rtol: float = 1.0e-5,
     atol: float = 1.0e-8,
+    rasterized: bool = True,
     **improps,
 ) -> matplotlib.image.AxesImage:
     """Plot a 2D :class:`xarray.DataArray` using :func:`matplotlib.pyplot.imshow`.
 
+    If the input array is detected to have non-evenly spaced coordinates, it is plotted
+    as a :class:`matplotlib.image.NonUniformImage`.
+
     Parameters
     ----------
     arr
@@ -214,16 +269,30 @@ def plot_array(
     func_args
         Keyword arguments passed onto `func`.
     rtol, atol
-        By default, the input array is checked for evenly spaced coordinates. ``rtol``
-        and ``atol`` are the tolerances for the coordinates to be considered evenly
-        spaced. The default values are consistent with `numpy.isclose`.
+        By default, the input array is checked for evenly spaced coordinates. If it is
+        not evenly spaced, it is plotted as a :class:`matplotlib.image.NonUniformImage`
+        instead of a :class:`matplotlib.image.AxesImage`. ``rtol`` and ``atol`` are the
+        tolerances for the coordinates to be considered evenly spaced. The default
+        values are consistent with `numpy.isclose`.
+    rasterized
+        Force rasterized output.
     **improps
-        Keyword arguments passed onto :func:`matplotlib.axes.Axes.imshow`.
+        Keyword arguments passed onto :func:`matplotlib.pyplot.imshow`.
 
     Returns
     -------
     matplotlib.image.AxesImage
 
+    Notes
+    -----
+    Some keyword arguments have different default behavior compared to matplotlib.
+
+    - `interpolation` is set to ``'none'`` for evenly spaced data and ``'nearest'`` for
+      nonuniform data.
+    - `aspect` is set to ``'auto'``.
+    - `origin` is set to ``'lower'``.
+    - The image is rasterized by default.
+
     """
     if colorbar_kw is None:
         colorbar_kw = {}
@@ -232,6 +301,8 @@ def plot_array(
 
     if isinstance(arr, np.ndarray):
         arr = xr.DataArray(arr)
+    if arr.ndim != 2:
+        raise ValueError("Input array must be 2D")
 
     if ax is None:
         ax = plt.gca()
@@ -266,13 +337,7 @@ def plot_array(
     if norm is None:
         norm = copy.deepcopy(matplotlib.colors.PowerNorm(gamma, **norm_kw))
 
-    improps_default = {
-        "interpolation": "none",
-        "extent": array_extent(arr, rtol, atol),
-        "aspect": "auto",
-        "origin": "lower",
-        "rasterized": True,
-    }
+    improps_default = {"aspect": "auto", "origin": "lower", "rasterized": rasterized}
     for k, v in improps_default.items():
         improps.setdefault(k, v)
 
@@ -283,9 +348,23 @@ def plot_array(
             arr = arr.copy(deep=True).sel({arr.dims[0]: slice(*ylim)})
 
     if func is not None:
-        img = ax.imshow(func(arr, **func_args), norm=norm, **improps)
+        arr = func(arr.copy(deep=True), **func_args)
+
+    if is_dims_uniform(arr, rtol=rtol, atol=atol):
+        improps.setdefault("interpolation", "none")
+        img = ax.imshow(
+            arr.values, norm=norm, extent=array_extent(arr, rtol, atol), **improps
+        )
     else:
-        img = ax.imshow(arr.values, norm=norm, **improps)
+        improps.setdefault("interpolation", "nearest")
+        img = _imshow_nonuniform(
+            ax,
+            x=arr[arr.dims[1]].values,
+            y=arr[arr.dims[0]].values,
+            A=arr.values,
+            norm=norm,
+            **improps,
+        )
 
     ax.set_xlabel(str(arr.dims[1]))
     ax.set_ylabel(str(arr.dims[0]))
diff --git a/src/erlab/utils/array.py b/src/erlab/utils/array.py
index 5a644558..25c9947c 100644
--- a/src/erlab/utils/array.py
+++ b/src/erlab/utils/array.py
@@ -1,6 +1,13 @@
 """Utility functions for working with numpy and xarray."""
 
-__all__ = ["is_dims_uniform", "is_monotonic", "is_uniform_spaced", "uniform_dims"]
+__all__ = [
+    "check_arg_has_no_nans",
+    "is_dims_uniform",
+    "is_monotonic",
+    "is_uniform_spaced",
+    "trim_na",
+    "uniform_dims",
+]
 
 import functools
 from collections.abc import Callable, Hashable, Iterable
@@ -137,3 +144,61 @@ def _wrapper(*args, **kwargs):
     if func is not None:
         return _decorator(func)
     return _decorator
+
+
+def check_arg_has_no_nans(func: Callable | None = None):
+    """Decorate a function to check if the first argument has no NaNs."""
+
+    def _decorator(func):
+        @functools.wraps(func)
+        def _wrapper(*args, **kwargs):
+            if np.isnan(args[0]).any():
+                raise ValueError("Input must not contain any NaN values")
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    if func is not None:
+        return _decorator(func)
+    return _decorator
+
+
+def trim_na(darr: xr.DataArray, dims: Iterable[Hashable] | None = None) -> xr.DataArray:
+    """Drop all-NaN coordinates from the edges of a DataArray.
+
+    Parameters
+    ----------
+    darr
+        The DataArray to trim.
+    dims
+        The dimensions along which to trim. If not provided, the data will be trimmed
+        along all dimensions.
+
+    Returns
+    -------
+    darr : xarray.DataArray
+        The trimmed DataArray.
+    """
+    if dims is None:
+        dims = darr.dims
+    for dim in dims:
+        darr = _trim_na_edges(darr, dim)
+    return darr
+
+
+def _trim_na_edges(darr: xr.DataArray, dim: Hashable) -> xr.DataArray:
+    return _trim_na_trailing_edge(_trim_na_leading_edge(darr, dim), dim)
+
+
+def _trim_na_leading_edge(darr: xr.DataArray, dim: Hashable) -> xr.DataArray:
+    if darr[dim].size > 0 and np.isnan(darr.isel({dim: 0}).values).all():
+        darr = darr.isel({dim: slice(1, None)})
+        return _trim_na_leading_edge(darr, dim)
+    return darr
+
+
+def _trim_na_trailing_edge(darr: xr.DataArray, dim: Hashable) -> xr.DataArray:
+    if darr[dim].size > 0 and np.isnan(darr.isel({dim: -1}).values).all():
+        darr = darr.isel({dim: slice(None, -1)})
+        return _trim_na_trailing_edge(darr, dim)
+    return darr
diff --git a/templates/CHANGELOG.md.j2 b/templates/CHANGELOG.md.j2
deleted file mode 100644
index a591590f..00000000
--- a/templates/CHANGELOG.md.j2
+++ /dev/null
@@ -1,19 +0,0 @@
-{% for entry in tree %}
-
-## {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %}
-
-{% for change_key, changes in entry.changes.items() %}
-
-{% if change_key %}
-### {{ change_key }}
-{% endif %}
-
-{% for change in changes %}
-{% if change.scope %}
-- **{{ change.scope }}**: {{ change.message }} ([`{{ change.sha1[:7] }}`](https://github.com/kmnhan/erlabpy/commit/{{ change.sha1 }}))
-{% elif change.message %}
-- {{ change.message }} ([`{{ change.sha1[:7] }}`](https://github.com/kmnhan/erlabpy/commit/{{ change.sha1 }}))
-{% endif %}
-{% endfor %}
-{% endfor %}
-{% endfor %}


Test patch:
diff --git a/tests/accessors/test_fit.py b/tests/accessors/test_fit.py
index 5fda0f59..c7ede0c3 100644
--- a/tests/accessors/test_fit.py
+++ b/tests/accessors/test_fit.py
@@ -5,31 +5,10 @@
 import xarray as xr
 
 
-def exp_decay(t, n0, tau=1):
-    return n0 * np.exp(-t / tau)
-
-
 def power(t, a):
     return np.power(t, a)
 
 
-@pytest.fixture()
-def exp_decay_model():
-    return lmfit.Model(exp_decay)
-
-
-@pytest.fixture()
-def fit_test_darr():
-    t = np.arange(0, 5, 0.5)
-    da = xr.DataArray(
-        np.stack([exp_decay(t, 3, 3), exp_decay(t, 5, 4), np.nan * t], axis=-1),
-        dims=("t", "x"),
-        coords={"t": t, "x": [0, 1, 2]},
-    )
-    da[0, 0] = np.nan
-    return da
-
-
 @pytest.fixture()
 def fit_expected_darr():
     return xr.DataArray(
diff --git a/tests/accessors/test_general.py b/tests/accessors/test_general.py
new file mode 100644
index 00000000..126aaee2
--- /dev/null
+++ b/tests/accessors/test_general.py
@@ -0,0 +1,57 @@
+import erlab.accessors  # noqa: F401
+import holoviews.element.chart
+import lmfit
+import matplotlib.image
+import matplotlib.lines
+import numpy as np
+import panel.layout.base
+import pytest
+import xarray as xr
+
+
+def test_da_qplot():
+    dat = xr.DataArray(np.arange(25).reshape(5, 5), dims=("x", "y"))
+
+    assert isinstance(dat.qplot(), matplotlib.image.AxesImage)
+    assert isinstance(dat[0].qplot()[0], matplotlib.lines.Line2D)
+
+
+def test_da_qshow_hvplot():
+    # Imagetool from 2D data is tested in interactive/test_imagetool.py
+    # Hvplot curve from 1D data is tested here
+    dat = xr.DataArray(
+        np.arange(25) ** 2.0, coords={"x": np.arange(25) * 1.0}, name="1D"
+    )
+    assert isinstance(dat.qshow(), holoviews.element.chart.Curve)
+
+
+@pytest.mark.parametrize("plot_components", [True, False])
+def test_ds_qshow_fit(plot_components: bool):
+    # Define angle coordinates for 2D data
+    alpha = np.linspace(-5.0, 5.0, 100)
+    beta = np.linspace(-1.0, 1.0, 3)
+
+    # Center of the peaks along beta
+    center = np.array([-2.0, 0.0, 2.0])[:, np.newaxis]
+
+    # Gaussian peak on a linear background
+    y = -0.1 * alpha + 2 + 3 * np.exp(-((alpha - center) ** 2) / (2 * 1**2))
+
+    # Add some noise with fixed seed for reproducibility
+    rng = np.random.default_rng(5)
+    yerr = np.full_like(y, 0.05)
+    y = rng.normal(y, yerr)
+
+    # Construct DataArray
+    darr = xr.DataArray(
+        y, dims=["beta", "alpha"], coords={"beta": beta, "alpha": alpha}
+    )
+
+    result_ds = darr.modelfit(
+        coords="alpha",
+        model=lmfit.models.GaussianModel() + lmfit.models.LinearModel(),
+        params={"center": xr.DataArray([-2, 0, 2], coords=[darr.beta]), "slope": -0.1},
+    )
+    assert isinstance(
+        result_ds.qshow(plot_components=plot_components), panel.layout.base.Column
+    )
diff --git a/tests/analysis/test_interpolate.py b/tests/analysis/test_interpolate.py
index 48c459c9..5fb05c40 100644
--- a/tests/analysis/test_interpolate.py
+++ b/tests/analysis/test_interpolate.py
@@ -1,7 +1,7 @@
 import numpy as np
 import scipy.interpolate
 import xarray as xr
-from erlab.analysis.interpolate import interpn, slice_along_path
+from erlab.analysis.interpolate import interpn, slice_along_path, slice_along_vector
 
 
 def value_func_1d(x):
@@ -122,3 +122,71 @@ def test_slice_along_path():
             ]
         ),
     )
+
+
+def test_slice_along_vector():
+    x = np.linspace(0, 1, 5)
+    y = np.linspace(0, 1, 5)
+    z = np.linspace(0, 1, 5)
+
+    darr = xr.DataArray(
+        np.random.default_rng(1).random((5, 5, 5)),
+        coords={"x": x, "y": y, "z": z},
+        dims=["x", "y", "z"],
+    )
+
+    # Test with tuple stretch
+    interp = slice_along_vector(
+        darr,
+        center={"x": 0.5, "y": 0.3},
+        direction={"x": np.sqrt(3), "y": 1.0},
+        stretch=(0.1, 0.3),
+    )
+
+    np.testing.assert_allclose(
+        interp.values,
+        np.array(
+            [
+                [0.23207336, 0.59910469, 0.77890933, 0.84194666, 0.52078597],
+                [0.18583922, 0.68822936, 0.28374755, 0.36078411, 0.42699386],
+            ]
+        ),
+    )
+    np.testing.assert_allclose(interp.x.values, np.array([0.41339746, 0.75980762]))
+    np.testing.assert_allclose(interp.y.values, np.array([0.25, 0.45]))
+    np.testing.assert_allclose(interp.z.values, z)
+    np.testing.assert_allclose(interp.path.values, np.array([0.0, 0.4]))
+
+    # Test with scalar stretch
+
+    interp = slice_along_vector(
+        darr,
+        center={"x": 0.5, "y": 0.3},
+        direction={"x": np.sqrt(3), "y": 1.0},
+        stretch=0.2,
+    )
+    np.testing.assert_allclose(
+        interp.values,
+        np.array(
+            [
+                [0.4484835, 0.39773655, 0.60736742, 0.71743957, 0.60188075],
+                [0.26960418, 0.5175887, 0.50620098, 0.45499338, 0.57402158],
+            ]
+        ),
+    )
+    np.testing.assert_allclose(interp.x.values, np.array([0.32679492, 0.67320508]))
+    np.testing.assert_allclose(interp.y.values, np.array([0.2, 0.4]))
+    np.testing.assert_allclose(interp.z.values, z)
+    np.testing.assert_allclose(interp.path.values, np.array([0.0, 0.4]))
+
+    # Test with 3D input
+    interp = slice_along_vector(
+        darr,
+        center={"x": 0.5, "y": 0.3, "z": 0.5},
+        direction={"x": np.sqrt(3), "y": 1.0, "z": 1.0},
+        stretch=0.2,
+    )
+    np.testing.assert_allclose(interp.values, np.array([0.56640928, 0.52730286]))
+    np.testing.assert_allclose(interp.x.values, np.array([0.34508067, 0.65491933]))
+    np.testing.assert_allclose(interp.y.values, np.array([0.21055728, 0.38944272]))
+    np.testing.assert_allclose(interp.path.values, np.array([0.0, 0.4]))
diff --git a/tests/analysis/test_transform.py b/tests/analysis/test_transform.py
new file mode 100644
index 00000000..fd564bf9
--- /dev/null
+++ b/tests/analysis/test_transform.py
@@ -0,0 +1,141 @@
+import numpy as np
+import pytest
+import xarray as xr
+import xarray.testing
+from erlab.analysis.transform import rotate, shift
+
+
+def test_rotate():
+    input_arr = xr.DataArray(
+        np.arange(12).reshape((3, 4)).astype(float),
+        dims=("y", "x"),
+        coords={"y": [0.0, 1.0, 2.0], "x": [0.0, 1.0, 2.0, 3.0]},
+    )
+    expected_output = xr.DataArray(
+        np.array([[3, 7, 11], [2, 6, 10], [1, 5, 9], [0, 4, 8]], dtype=float),
+        dims=("y", "x"),
+        coords={"y": [-3.0, -2.0, -1.0, 0.0], "x": [0.0, 1.0, 2.0]},
+    )
+
+    xarray.testing.assert_allclose(
+        rotate(input_arr, 90, reshape=True, order=1), expected_output
+    )
+    xarray.testing.assert_allclose(
+        rotate(
+            input_arr,
+            90,
+            axes=("y", "x"),
+            center={"x": 0, "y": 0},
+            reshape=True,
+            order=1,
+        ),
+        expected_output,
+    )
+    xarray.testing.assert_allclose(
+        rotate(input_arr, 90, center={"x": 3, "y": 2}, reshape=True, order=1),
+        xr.DataArray(
+            np.array([[3, 7, 11], [2, 6, 10], [1, 5, 9], [0, 4, 8]], dtype=float),
+            dims=("y", "x"),
+            coords={"y": [2.0, 3.0, 4.0, 5.0], "x": [1.0, 2.0, 3.0]},
+        ),
+    )
+
+    xarray.testing.assert_allclose(
+        rotate(input_arr, 90, reshape=False, order=1),
+        xr.DataArray(
+            np.array(
+                [
+                    [0, 4, 8, np.nan],
+                    [np.nan, np.nan, np.nan, np.nan],
+                    [np.nan, np.nan, np.nan, np.nan],
+                ],
+                dtype=float,
+            ),
+            dims=("y", "x"),
+            coords={"y": [0.0, 1.0, 2.0], "x": [0.0, 1.0, 2.0, 3.0]},
+        ),
+    )
+
+    # Higher dimensional array
+    input_arr = xr.DataArray(
+        np.arange(24).reshape((3, 4, 2)).astype(float),
+        dims=("y", "x", "z"),
+        coords={"y": [0.0, 1.0, 2.0], "x": [0.0, 1.0, 2.0, 3.0], "z": [0.0, 1.0]},
+    )
+    xarray.testing.assert_allclose(
+        rotate(input_arr, 90, reshape=True, order=1),
+        xr.DataArray(
+            np.array(
+                [
+                    [[6, 7], [14, 15], [22, 23]],
+                    [[4, 5], [12, 13], [20, 21]],
+                    [[2, 3], [10, 11], [18, 19]],
+                    [[0, 1], [8, 9], [16, 17]],
+                ],
+                dtype=float,
+            ),
+            dims=("y", "x", "z"),
+            coords={
+                "y": [-3.0, -2.0, -1.0, 0.0],
+                "x": [0.0, 1.0, 2.0],
+                "z": [0.0, 1.0],
+            },
+        ),
+    )
+
+    # Test with associated coordinates
+    input_arr = xr.DataArray(
+        np.arange(12).reshape((3, 4)).astype(float),
+        dims=("y", "x"),
+        coords={
+            "y": [0.0, 1.0, 2.0],
+            "x": [0.0, 1.0, 2.0, 3.0],
+            "yy": ("y", [0.0, 1.0, 2.0]),
+        },
+    )
+    expected_output = xr.DataArray(
+        np.array([[3, 7, 11], [2, 6, 10], [1, 5, 9], [0, 4, 8]], dtype=float),
+        dims=("y", "x"),
+        coords={"y": [-3.0, -2.0, -1.0, 0.0], "x": [0.0, 1.0, 2.0]},
+    )
+
+    # Catch exceptions
+    with pytest.raises(ValueError, match="input array should be at least 2D"):
+        rotate(xr.DataArray(np.arange(5)), 90)
+
+    with pytest.raises(ValueError, match="center must have keys that match axes"):
+        rotate(input_arr, 90, center={"x": 0, "z": 0})
+
+    with pytest.raises(
+        ValueError, match="all coordinates along axes must be evenly spaced"
+    ):
+        rotate(
+            xr.DataArray(
+                np.arange(12).reshape((3, 4)).astype(float),
+                dims=("y", "x"),
+                coords={"y": [0.0, 1.0, 3.0], "x": [0.0, 1.0, 2.0, 3.0]},
+            ),
+            90,
+        )
+
+
+def test_shift():
+    # Create a test input DataArray
+    darr = xr.DataArray(
+        np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]).astype(float), dims=["x", "y"]
+    )
+
+    # Create a test shift DataArray
+    shift_arr = xr.DataArray([1, 0, 2], dims=["x"])
+
+    # Perform the shift operation
+    shifted = shift(darr, shift_arr, along="y")
+
+    # Define the expected result
+    expected = xr.DataArray(
+        np.array([[np.nan, 1.0, 2.0], [4.0, 5.0, 6.0], [np.nan, np.nan, 7.0]]),
+        dims=["x", "y"],
+    )
+
+    # Check if the shifted array matches the expected result
+    assert np.allclose(shifted, expected, equal_nan=True)
diff --git a/tests/analysis/test_utils.py b/tests/analysis/test_utils.py
deleted file mode 100644
index f3d5bd1c..00000000
--- a/tests/analysis/test_utils.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import numpy as np
-import xarray as xr
-from erlab.analysis.utils import shift
-
-
-def test_shift():
-    # Create a test input DataArray
-    darr = xr.DataArray(
-        np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]).astype(float), dims=["x", "y"]
-    )
-
-    # Create a test shift DataArray
-    shift_arr = xr.DataArray([1, 0, 2], dims=["x"])
-
-    # Perform the shift operation
-    shifted = shift(darr, shift_arr, along="y")
-
-    # Define the expected result
-    expected = xr.DataArray(
-        np.array([[np.nan, 1.0, 2.0], [4.0, 5.0, 6.0], [np.nan, np.nan, 7.0]]),
-        dims=["x", "y"],
-    )
-
-    # Check if the shifted array matches the expected result
-    assert np.allclose(shifted, expected, equal_nan=True)
diff --git a/tests/conftest.py b/tests/conftest.py
index 7d9edf0e..e62fe432 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,31 @@
+import lmfit
+import numpy as np
 import pytest
+import xarray as xr
 from erlab.io.exampledata import generate_data_angles, generate_gold_edge
 
 
+def exp_decay(t, n0, tau=1):
+    return n0 * np.exp(-t / tau)
+
+
+@pytest.fixture()
+def exp_decay_model():
+    return lmfit.Model(exp_decay)
+
+
+@pytest.fixture()
+def fit_test_darr():
+    t = np.arange(0, 5, 0.5)
+    da = xr.DataArray(
+        np.stack([exp_decay(t, 3, 3), exp_decay(t, 5, 4), np.nan * t], axis=-1),
+        dims=("t", "x"),
+        coords={"t": t, "x": [0, 1, 2]},
+    )
+    da[0, 0] = np.nan
+    return da
+
+
 @pytest.fixture()
 def anglemap():
     return generate_data_angles(shape=(10, 10, 10), assign_attributes=True)
diff --git a/tests/interactive/test_imagetool.py b/tests/interactive/test_imagetool.py
index 4e32f65a..a925c8bd 100644
--- a/tests/interactive/test_imagetool.py
+++ b/tests/interactive/test_imagetool.py
@@ -1,16 +1,21 @@
 import time
 from collections.abc import Callable
 
+import erlab.analysis.transform
 import numpy as np
+import pyperclip
 import pytest
 import xarray as xr
+import xarray.testing
 from erlab.interactive.imagetool import itool
 from erlab.interactive.imagetool.manager import ImageToolManager
 from numpy.testing import assert_almost_equal
 from qtpy import QtCore, QtWidgets
 
 
-def accept_dialog(dialog_trigger: Callable, time_out: int = 5) -> QtWidgets.QDialog:
+def accept_dialog(
+    dialog_trigger: Callable, time_out: int = 5, pre_call: Callable | None = None
+) -> QtWidgets.QDialog:
     """Accept a dialog during testing.
 
     If there is no dialog, it waits until one is created for a maximum of 5 seconds (by
@@ -23,6 +28,9 @@ def accept_dialog(dialog_trigger: Callable, time_out: int = 5) -> QtWidgets.QDia
         Callable that triggers the dialog creation.
     time_out
         Maximum time (seconds) to wait for the dialog creation.
+    pre_call
+        Callable that takes the dialog as a single argument. If provided, it is executed
+        before calling ``.accept()`` on the dialog.
     """
     dialog = None
     start_time = time.time()
@@ -36,6 +44,8 @@ def dialog_creation():
 
         # Avoid errors when dialog is not created
         if isinstance(dialog, QtWidgets.QDialog):
+            if pre_call is not None:
+                pre_call(dialog)
             if (
                 isinstance(dialog, QtWidgets.QMessageBox)
                 and dialog.defaultButton() is not None
@@ -117,6 +127,20 @@ def test_itool(qtbot):
 
     # Lock levels
     win.slicer_area.lock_levels(True)
+    win.slicer_area.levels = (1.0, 23.0)
+    assert win.slicer_area._colorbar.cb._copy_limits() == str((1.0, 23.0))
+
+    # Test color limits editor
+    clw = win.slicer_area._colorbar.cb._clim_menu.actions()[0].defaultWidget()
+    assert clw.min_spin.value() == win.slicer_area.levels[0]
+    assert clw.max_spin.value() == win.slicer_area.levels[1]
+    clw.min_spin.setValue(1.0)
+    assert clw.min_spin.value() == 1.0
+    clw.max_spin.setValue(2.0)
+    assert clw.max_spin.value() == 2.0
+    clw.rst_btn.click()
+    assert win.slicer_area.levels == (0.0, 24.0)
+    win.slicer_area.levels = (1.0, 23.0)
     win.slicer_area.lock_levels(False)
 
     # Undo and redo
@@ -172,6 +196,29 @@ def test_itool(qtbot):
     win.close()
 
 
+def test_itool_tools(qtbot, gold):
+    win = itool(gold, execute=False)
+    qtbot.addWidget(win)
+
+    with qtbot.waitExposed(win):
+        win.show()
+        win.activateWindow()
+        win.raise_()
+
+    # Test code generation
+    assert win.slicer_area.images[0].selection_code == ""
+
+    # Open goldtool from main image
+    win.slicer_area.images[0].open_in_goldtool()
+    assert isinstance(win.slicer_area.images[0]._goldtool, QtWidgets.QWidget)
+
+    # Open dtool from main image
+    win.slicer_area.images[0].open_in_dtool()
+    assert isinstance(win.slicer_area.images[0]._dtool, QtWidgets.QWidget)
+
+    win.close()
+
+
 def test_itool_ds(qtbot):
     # If no 2D to 4D data is present in given Dataset, ValueError is raised
     with pytest.raises(
@@ -354,3 +401,58 @@ def test_manager(qtbot):
     qtbot.waitUntil(lambda: win.ntools == 0, timeout=2000)
 
     win.close()
+
+
+def test_itool_rotate(qtbot):
+    data = xr.DataArray(np.arange(25).reshape((5, 5)).astype(float), dims=["x", "y"])
+    win = itool(data, execute=False)
+    qtbot.addWidget(win)
+
+    # Test dialog
+    def _set_dialog_params(dialog):
+        dialog.angle_spin.setValue(60.0)
+        dialog.reshape_check.setChecked(True)
+        dialog.new_window_check.setChecked(False)
+
+    accept_dialog(win.mnb._rotate, pre_call=_set_dialog_params)
+
+    # Check if the data is rotated
+    xarray.testing.assert_allclose(
+        win.slicer_area._data,
+        erlab.analysis.transform.rotate(data, angle=60.0, reshape=True),
+    )
+
+    # Test guidelines
+    win.slicer_area.set_data(data)
+    win.slicer_area.main_image.set_guidelines(3)
+    assert win.slicer_area.main_image.is_guidelines_visible
+
+    win.slicer_area.main_image._guidelines_items[0].setAngle(90.0 - 30.0)
+    win.slicer_area.main_image._guidelines_items[-1].setPos((3.0, 3.1))
+
+    def _set_dialog_params(dialog):
+        assert dialog.angle_spin.value() == 30.0
+        assert dialog.center_spins[0].value() == 3.0
+        assert dialog.center_spins[1].value() == 3.1
+        dialog.copy_button.click()
+        dialog.reshape_check.setChecked(True)
+        dialog.new_window_check.setChecked(False)
+
+    accept_dialog(win.mnb._rotate, pre_call=_set_dialog_params)
+
+    # Check if the data is rotated
+    xarray.testing.assert_allclose(
+        win.slicer_area._data,
+        erlab.analysis.transform.rotate(
+            data, angle=30.0, center=(3.0, 3.1), reshape=True
+        ),
+    )
+
+    # Test copy button
+    assert pyperclip.paste().startswith("era.transform.rotate")
+
+    # Transpose should remove guidelines
+    qtbot.keyClick(win, QtCore.Qt.Key.Key_T)
+    assert ~win.slicer_area.main_image.is_guidelines_visible
+
+    win.close()
diff --git a/tests/interactive/test_tools.py b/tests/interactive/test_tools.py
index d2abd67c..358fd87a 100644
--- a/tests/interactive/test_tools.py
+++ b/tests/interactive/test_tools.py
@@ -29,9 +29,6 @@ def test_goldtool(qtbot, gold):
     gold,
     angle_range=(-13.5, 13.5),
     eV_range=(-0.204, 0.276),
-    bin_size=(1, 1),
-    method="leastsq",
-    degree=4,
     fast=True,
 )"""
     )
diff --git a/tests/io/test_dataloader.py b/tests/io/test_dataloader.py
index f26abfd7..71eb7e91 100644
--- a/tests/io/test_dataloader.py
+++ b/tests/io/test_dataloader.py
@@ -306,3 +306,6 @@ def generate_summary(self, data_dir):
 
     # Interactive summary
     erlab.io.loaders.current_loader.isummarize(df)
+
+    # Cleanup the temporary directory
+    tmp_dir.cleanup()
diff --git a/tests/io/test_exampledata.py b/tests/io/test_exampledata.py
new file mode 100644
index 00000000..9aeed2ce
--- /dev/null
+++ b/tests/io/test_exampledata.py
@@ -0,0 +1,95 @@
+import numpy as np
+from erlab.io.exampledata import generate_data, generate_data_angles, generate_gold_edge
+
+
+def test_generate_data():
+    data = generate_data((3, 3, 3), seed=1)
+
+    np.testing.assert_allclose(
+        data.values,
+        np.array(
+            [
+                [
+                    [9010277.53158856, 4458397.04074018, 809908.96443774],
+                    [4698266.35922851, 2578636.07354791, 528809.26472864],
+                    [9008422.18238725, 4456859.98787036, 809438.41409181],
+                ],
+                [
+                    [5463001.22757439, 3986865.31769739, 864834.81723129],
+                    [3594377.58855508, 2508454.42140497, 566255.6149856],
+                    [5460569.85001597, 3984435.69921373, 864115.77259181],
+                ],
+                [
+                    [9012950.87657523, 4459198.12053694, 809868.93308102],
+                    [4700173.71802403, 2577709.04540753, 528257.25001757],
+                    [9012753.85992549, 4458417.57349542, 809627.4950456],
+                ],
+            ]
+        ),
+    )
+
+    np.testing.assert_allclose(data.kx.values, np.array([-0.89, 0.0, 0.89]))
+    np.testing.assert_allclose(data.ky.values, np.array([-0.89, 0.0, 0.89]))
+
+
+def test_generate_data_angles():
+    data = generate_data_angles(
+        (3, 3, 3),
+        hv=50.0,
+        configuration=1,
+        temp=20.0,
+        seed=1,
+        assign_attributes=True,
+    )
+
+    np.testing.assert_allclose(
+        data.values,
+        np.array(
+            [
+                [
+                    [10584103.6542194, 5093835.27779442, 896123.30180953],
+                    [1709763.89081559, 1250221.69563657, 324333.8997436],
+                    [10581859.86894855, 5091979.3893089, 895557.22568712],
+                ],
+                [
+                    [6101271.24393742, 4502715.52572828, 966803.28944403],
+                    [2240475.87672943, 1433784.37846328, 346174.48478143],
+                    [6098365.34359401, 4499774.3417057, 965935.18855541],
+                ],
+                [
+                    [10587162.63852743, 5095269.99906888, 896271.06947063],
+                    [1710576.94066684, 1247915.58065668, 323377.25250659],
+                    [10586937.5332778, 5094328.47440016, 895980.63452237],
+                ],
+            ]
+        ),
+    )
+    np.testing.assert_allclose(data.alpha.values, np.array([-15.0, 0.0, 15.0]))
+    np.testing.assert_allclose(data.beta.values, np.array([-15.0, 0.0, 15.0]))
+
+    np.testing.assert_allclose(data.xi.values, 0.0)
+    np.testing.assert_allclose(data.delta.values, 0.0)
+    np.testing.assert_allclose(data.hv.values, 50.0)
+
+    assert data.attrs["temp_sample"] == 20.0
+    assert data.attrs["configuration"] == 1
+
+
+def test_generate_gold_edge():
+    data = generate_gold_edge(nx=3, ny=5, seed=1)
+
+    np.testing.assert_allclose(
+        data.values,
+        np.array(
+            [
+                [88861.71360087, 120239.1523542, 89026.19170356],
+                [79988.11645269, 107963.33789627, 79549.67219177],
+                [68850.81086526, 92741.26836792, 68413.28599851],
+                [50556.8598757, 68031.55342743, 50174.42004713],
+                [10742.14847358, 14218.90842119, 10640.56032391],
+            ]
+        ),
+    )
+
+    np.testing.assert_allclose(data.eV.values, np.array([-1.3, -0.9, -0.5, -0.1, 0.3]))
+    np.testing.assert_allclose(data.alpha.values, np.array([-15.0, 0.0, 15.0]))
diff --git a/tests/plotting/test_general.py b/tests/plotting/test_general.py
index c64c44cb..7644461b 100644
--- a/tests/plotting/test_general.py
+++ b/tests/plotting/test_general.py
@@ -142,13 +142,22 @@ def test_plot_slices():
 @pytest.mark.parametrize("ylim", [None, 0.5, (-0.3, 0.7)])
 @pytest.mark.parametrize("xlim", [None, 0.5, (-0.3, 0.7)])
 @pytest.mark.parametrize("colorbar", [False, True])
-def test_plot_array(colorbar, xlim, ylim, crop, kwargs):
-    data = xr.DataArray(
-        np.random.default_rng(0).random((11, 11)),
-        coords=[np.linspace(-1, 1, 11), np.linspace(-1, 1, 11)],
-        dims=["x", "y"],
-    )
-
+@pytest.mark.parametrize(
+    "data",
+    [
+        xr.DataArray(
+            np.random.default_rng(0).random((11, 11)),
+            coords=[np.linspace(-1, 1, 11), np.linspace(-1, 1, 11)],
+            dims=["x", "y"],
+        ),
+        xr.DataArray(
+            np.random.default_rng(0).random((11, 11)),
+            coords=[np.linspace(-1, 1, 11) ** 3, np.linspace(-1, 1, 11)],
+            dims=["x", "y"],
+        ),
+    ],
+)
+def test_plot_array(data, colorbar, xlim, ylim, crop, kwargs):
     _, ax = plt.subplots()
     plot_array(data, colorbar=colorbar, xlim=xlim, ylim=ylim, crop=crop, **kwargs)
     assert ax.get_xlabel() == "y"
diff --git a/tests/utils/test_array.py b/tests/utils/test_array.py
index 749cc7c3..ec1f308b 100644
--- a/tests/utils/test_array.py
+++ b/tests/utils/test_array.py
@@ -1,9 +1,13 @@
 import numpy as np
+import pytest
 import xarray as xr
+import xarray.testing
 from erlab.utils.array import (
+    check_arg_has_no_nans,
     is_dims_uniform,
     is_monotonic,
     is_uniform_spaced,
+    trim_na,
     uniform_dims,
 )
 
@@ -60,3 +64,66 @@ def test_uniform():
     darr = xr.DataArray(np.array(1), dims=(), coords={})
     assert uniform_dims(darr) == set()
     assert is_dims_uniform(darr)
+
+
+def test_check_arg_has_no_nans():
+    @check_arg_has_no_nans
+    def decorated_func(arr):
+        pass
+
+    # Test case 1: No NaN values
+    arr = np.array([1, 2, 3])
+    decorated_func(arr)
+
+    # Test case 2: NaN values present
+    arr = np.array([1, np.nan, 3])
+    with pytest.raises(ValueError, match="Input must not contain any NaN values"):
+        decorated_func(arr)
+
+    # Test case 3: NaN values present, DataArray
+    arr = xr.DataArray([1, np.nan, 3])
+    with pytest.raises(ValueError, match="Input must not contain any NaN values"):
+        decorated_func(arr)
+
+    # Test case 4: Empty array
+    arr = np.array([])
+    assert decorated_func(arr) is None
+
+
+def test_trim_na():
+    # Test case 1: Trim along all dimensions
+    darr = xr.DataArray(
+        np.array([[np.nan, 2, 3], [np.nan, 5, 6], [np.nan, np.nan, np.nan]]),
+        dims=("x", "y"),
+        coords={"x": [1, 2, 3], "y": [1, 2, 3]},
+    )
+    expected_result = xr.DataArray(
+        np.array([[2, 3], [5, 6]]),
+        dims=("x", "y"),
+        coords={"x": [1, 2], "y": [2, 3]},
+    )
+    xarray.testing.assert_identical(trim_na(darr), expected_result)
+
+    darr = xr.DataArray(
+        np.array([[np.nan, 2, 3], [np.nan, 5, 6], [np.nan, 8, 9]]),
+        dims=("x", "y"),
+        coords={"x": [1, 2, 3], "y": [1, 2, 3]},
+    )
+    expected_result = xr.DataArray(
+        np.array([[2, 3], [5, 6], [8, 9]]),
+        dims=("x", "y"),
+        coords={"x": [1, 2, 3], "y": [2, 3]},
+    )
+    xarray.testing.assert_identical(trim_na(darr), expected_result)
+
+    # Test case 2: Trim along specific dimension
+    xarray.testing.assert_identical(trim_na(darr, dims=("x",)), darr)
+
+    # Test case 3: No NaN values
+    xarray.testing.assert_identical(trim_na(darr.fillna(0)), darr.fillna(0))
+
+    # Test case 4: Size 1 and size 0 arrays
+    darr1 = xr.DataArray(np.array([np.nan]), dims=("x",), coords={"x": [1]})
+    darr0 = xr.DataArray(np.array([]), dims=("x",), coords={"x": []})
+    xarray.testing.assert_identical(trim_na(darr1), darr0)
+    xarray.testing.assert_identical(trim_na(darr0), darr0)