sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] 01/02: Swing components as helper tools for visual tests of some Apache SIS classes. Those widgets are not part of main Apache SIS code base. We do not intent to build any application on top of them (we use JavaFX instead).
Date Fri, 18 Dec 2020 15:55:53 GMT
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch visual-test
in repository https://gitbox.apache.org/repos/asf/sis.git

commit dc2533b8c79ad5916f801950a0946267779b9cda
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Fri Dec 18 15:30:01 2020 +0100

    Swing components as helper tools for visual tests of some Apache SIS classes.
    Those widgets are not part of main Apache SIS code base. We do not intent to
    build any application on top of them (we use JavaFX instead).
    
    Those classes may move to a module dedicated to tests in a future version,
    but we will wait for migration to JUnit 5 before in order to have a better
    idea about how to reorganize tests.
    
    https://issues.apache.org/jira/browse/SIS-507
---
 .gitattributes                                     |   44 +
 .gitignore                                         |   26 +
 LICENSE                                            |  202 ++
 README.md                                          |    5 +
 pom.xml                                            |   87 +
 .../org/apache/sis/swing/DeformableViewer.java     |   51 +
 src/main/java/org/apache/sis/swing/Dialog.java     |   62 +
 .../org/apache/sis/swing/DoubleDimension2D.java    |  135 ++
 .../org/apache/sis/swing/ExceptionMonitor.java     |  320 +++
 .../apache/sis/swing/InternalWindowListener.java   |  174 ++
 .../org/apache/sis/swing/MouseReshapeTracker.java  | 1568 +++++++++++++
 .../apache/sis/swing/MouseSelectionTracker.java    |  366 +++
 .../java/org/apache/sis/swing/SwingUtilities.java  |  420 ++++
 src/main/java/org/apache/sis/swing/Window.java     |  149 ++
 .../java/org/apache/sis/swing/WindowCreator.java   |  330 +++
 .../java/org/apache/sis/swing/ZoomChangeEvent.java |   75 +
 .../org/apache/sis/swing/ZoomChangeListener.java   |   37 +
 src/main/java/org/apache/sis/swing/ZoomPane.java   | 2377 ++++++++++++++++++++
 .../org/apache/sis/swing/doc-files/ZoomPane.png    |  Bin 0 -> 4312 bytes
 .../org/apache/sis/swing/internal/Resources.java   |  183 ++
 .../apache/sis/swing/internal/Resources.properties |   37 +
 .../sis/swing/internal/Resources_fr.properties     |   37 +
 .../apache/sis/swing/internal/package-info.java    |   25 +
 .../java/org/apache/sis/swing/package-info.java    |   26 +
 .../org/apache/sis/test/visual/DesktopPane.java    |  257 +++
 .../org/apache/sis/test/visual/Visualization.java  |   75 +
 src/test/java/org/apache/sis/swing/TestCase.java   |  121 +
 .../java/org/apache/sis/swing/ZoomPaneTest.java    |   80 +
 28 files changed, 7269 insertions(+)

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..9ec5bba
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,44 @@
+# Convert line endings to native line endings on checkout.
+# Files for source code:
+*.c          text
+*.h          text
+*.idl        text
+*.java       text
+*.jsp        text
+*.prj        text
+*.properties text
+*.sql        text
+
+# XML files:
+*.fxml  text
+*.gml   text
+*.sld   text
+*.xml   text
+*.xsd   text
+*.xsl   text
+
+# Text or web files:
+*.css   text
+*.html  text
+*.md    text
+*.txt   text
+*.xhtml text
+
+# Batch files (system-dependent):
+*.sh    text eol=lf
+*.bat   text eol=crlf
+
+# Denote files that are truly binary and should not be modified.
+*.bmp  binary
+*.dbf  binary
+*.gif  binary
+*.jpg  binary
+*.jpeg binary
+*.png  binary
+*.shp  binary
+*.shx  binary
+*.so   binary
+*.tif  binary
+*.tiff binary
+*.utf  binary
+*.zip  binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2d5d4d4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+ide-project/NetBeans/nbproject/private/
+ide-project/NetBeans/build/
+ide-project/NetBeans/dist/
+ide-project/local-src/
+ide-project/workspace/
+target/
+*.tmp
+*.log
+.svn/
+
+# Generated by NetBeans IDE
+nbactions.xml
+
+# Generated by Eclipse IDE
+.classpath
+.project
+.settings/
+
+# Generated by IntelliJ IDE
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# Generated by MacOS
+.DS_Store
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5405b38
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# Visual tests for Apache SIS
+
+This branch provides helper classes for testing some Apache SIS features.
+It is not intended to be used as an application; the `sis-javafx` module
+can be used for that purpose.
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..b5d6a81
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<project xmlns              = "http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi          = "http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation = "http://maven.apache.org/POM/4.0.0
+                               http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.apache.sis</groupId>
+    <artifactId>core</artifactId>
+    <version>2.0-SNAPSHOT</version>
+  </parent>
+
+
+  <!-- ===========================================================
+           Module Description
+       =========================================================== -->
+  <groupId>org.apache.sis.core</groupId>
+  <artifactId>sis-visual-test</artifactId>
+  <name>Apache SIS visual test</name>
+  <description>
+    Provides helper classes for testing some Apache SIS features.
+    It is not intended to be used as an application.
+  </description>
+
+
+  <!-- ===========================================================
+           Developers and Contributors
+       =========================================================== -->
+  <developers>
+    <developer>
+      <name>Martin Desruisseaux</name>
+      <id>desruisseaux</id>
+      <email>desruisseaux@apache.org</email>
+      <organization>Geomatys</organization>
+      <organizationUrl>http://www.geomatys.com</organizationUrl>
+      <timezone>+1</timezone>
+      <roles>
+        <role>committer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Johann Sorel</name>
+      <id>jsorel</id>
+      <email>johann.sorel@geomatys.com</email>
+      <organization>Geomatys</organization>
+      <organizationUrl>http://www.geomatys.com</organizationUrl>
+      <timezone>+1</timezone>
+      <roles>
+        <role>committer</role>
+      </roles>
+    </developer>
+  </developers>
+
+
+  <!-- ===========================================================
+           Dependencies
+       =========================================================== -->
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.sis.core</groupId>
+      <artifactId>sis-referencing</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+  </dependencies>
+
+</project>
diff --git a/src/main/java/org/apache/sis/swing/DeformableViewer.java b/src/main/java/org/apache/sis/swing/DeformableViewer.java
new file mode 100644
index 0000000..d2cc0d2
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/DeformableViewer.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.geom.Point2D;
+
+
+/**
+ * An interface for viewers that may be deformed by some artefacts. For example the {@link ZoomPane}
+ * viewer is capable to show a {@linkplain ZoomPane#setMagnifierVisible magnifying glass} on top of
+ * the usual content. The presence of a magnifying glass deforms the viewer in that the apparent
+ * position of pixels within the glass are moved. This interface allows for corrections of apparent
+ * pixel position in order to get the position we would have if no deformations existed.
+ *
+ * @author  Martin Desruisseaux (MPO, IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public interface DeformableViewer {
+    /**
+     * Corrects a pixel coordinates for removing the effect of the magnifying glass. Without this
+     * method, transformations from pixels to geographic coordinates would not give accurate results
+     * for pixels inside the magnifying glass because the glass moves the apparent pixel position.
+     * Invoking this method removes deformation effects using the following steps:
+     *
+     * <ul>
+     *   <li>If the given pixel coordinates are outside the magnifying glass,
+     *       then this method do nothing.</li>
+     *   <li>Otherwise, this method update {@code point} in such a way that it contains the position
+     *       that the same pixel would have in the absence of magnifying glass.</li>
+     * </ul>
+     *
+     * @param point  on input, a pixel coordinates as it appears on the screen. On output, the
+     *        coordinates that the same pixel would have if the magnifying glass was not presents.
+     */
+    void correctApparentPixelPosition(Point2D point);
+}
diff --git a/src/main/java/org/apache/sis/swing/Dialog.java b/src/main/java/org/apache/sis/swing/Dialog.java
new file mode 100644
index 0000000..2f8e7e6
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/Dialog.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Component;
+import java.text.ParseException;
+
+
+/**
+ * Interface for widgets that can be used as a dialog box.
+ *
+ * @author  Martin Desruisseaux (MPO, IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public interface Dialog {
+    /**
+     * Shows a dialog box requesting input from the user. The dialog box will be parented to {@code owner}.
+     * If {@code owner} is contained into a {@link javax.swing.JDesktopPane}, the dialog box will appears
+     * as an internal frame.
+     *
+     * <h4>Multi-threading</h4>
+     * Apache SIS implementations allow this method to be invoked from any thread. If the caller
+     * thread is not the <cite>Swing</cite> thread, then the execution of this method will be
+     * registered in the AWT Event Queue and the caller thread will block until completion.
+     *
+     * @param  owner  the parent component for the dialog box, or {@code null} if there is no parent.
+     * @param  title  the dialog box title.
+     * @return {@code true} if user pressed the "<cite>Ok</cite>" button, or {@code false} otherwise
+     *         (e.g. pressing "<cite>Cancel</cite>" or closing the dialog box from the title bar).
+     */
+    boolean showDialog(Component owner, String title);
+
+    /**
+     * Forces the current value to be taken from the editable fields and set them as the current values.
+     * If this operation fails for at least one field, this method will set the focus on the offending
+     * field before to throw the exception.
+     *
+     * <p>This method is typically invoked after {@link #showDialog(Component, String)}
+     * returned {@code true} and before to read the values from the dialog widget.</p>
+     *
+     * @throws ParseException if at least one values couldn't be committed.
+     *
+     * @see javax.swing.JFormattedTextField#commitEdit()
+     * @see javax.swing.JSpinner#commitEdit()
+     */
+    void commitEdit() throws ParseException;
+}
diff --git a/src/main/java/org/apache/sis/swing/DoubleDimension2D.java b/src/main/java/org/apache/sis/swing/DoubleDimension2D.java
new file mode 100644
index 0000000..d8f1ea3
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/DoubleDimension2D.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.io.Serializable;
+import java.awt.geom.Dimension2D;
+import static java.lang.Double.doubleToLongBits;
+import org.apache.sis.internal.util.Numerics;
+
+
+/**
+ * Implements {@link Dimension2D} using double-precision floating point values.
+ *
+ * @author  Martin Desruisseaux (MPO, IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+final class DoubleDimension2D extends Dimension2D implements Serializable {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 3603763914115376884L;
+
+    /**
+     * The width.
+     */
+    public double width;
+
+    /**
+     * The height.
+     */
+    public double height;
+
+    /**
+     * Constructs a new dimension initialized to (0,0).
+     */
+    public DoubleDimension2D() {
+    }
+
+    /**
+     * Constructs a new dimension initialized to the given dimension.
+     *
+     * @param  dimension  the dimension to copy.
+     */
+    public DoubleDimension2D(final Dimension2D dimension) {
+        width  = dimension.getWidth();
+        height = dimension.getHeight();
+    }
+
+    /**
+     * Constructs a new dimension with the specified values.
+     *
+     * @param  w  the width.
+     * @param  h  the height.
+     */
+    public DoubleDimension2D(final double w, final double h) {
+        width  = w;
+        height = h;
+    }
+
+    /**
+     * Sets width and height for this dimension.
+     *
+     * @param  w  the width.
+     * @param  h  the height.
+     */
+    @Override
+    public void setSize(final double w, final double h) {
+        width  = w;
+        height = h;
+    }
+
+    /**
+     * Returns the width.
+     */
+    @Override
+    public double getWidth() {
+        return width;
+    }
+
+    /**
+     * Returns the height.
+     */
+    @Override
+    public double getHeight() {
+        return height;
+    }
+
+    /**
+     * Returns a hash code value for this dimension.
+     */
+    @Override
+    public int hashCode() {
+        final long code = doubleToLongBits(width) + 31*doubleToLongBits(height);
+        return (int) code ^ (int) (code >>> 32) ^ (int) serialVersionUID;
+    }
+
+    /**
+     * Compares this dimension with the given object for equality.
+     *
+     * @param  object  the object to compare with.
+     * @return {@code true} if this dimension is equal to the given object.
+     */
+    @Override
+    public boolean equals(final Object object) {
+        if (object instanceof DoubleDimension2D) {
+            final DoubleDimension2D that = (DoubleDimension2D) object;
+            return Numerics.equals(width,  that.width) &&
+                   Numerics.equals(height, that.height);
+        }
+        return false;
+    }
+
+    /**
+     * Returns a string representation of this dimension.
+     */
+    @Override
+    public String toString() {
+        return "Dimension2D[" + width + ", " + height + ']';
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/ExceptionMonitor.java b/src/main/java/org/apache/sis/swing/ExceptionMonitor.java
new file mode 100644
index 0000000..7457b6e
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/ExceptionMonitor.java
@@ -0,0 +1,320 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Dimension;
+import java.awt.BorderLayout;
+import java.awt.Container;
+import java.awt.Component;
+import java.awt.Dialog;
+import java.awt.Window;
+import javax.swing.Box;
+import javax.swing.JPanel;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JTextArea;
+import javax.swing.JComponent;
+import javax.swing.JOptionPane;
+import javax.swing.JScrollPane;
+import javax.swing.JTabbedPane;
+import javax.swing.JDesktopPane;
+import javax.swing.JInternalFrame;
+import javax.swing.AbstractButton;
+import java.awt.event.ActionListener;
+import java.awt.event.ActionEvent;
+import java.awt.EventQueue;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import org.apache.sis.util.Classes;
+import org.apache.sis.io.LineAppender;
+import org.apache.sis.swing.internal.Resources;
+
+
+/**
+ * Dialog box for exception messages and eventually their traces.
+ * The message will appear in a dialog box or in an internal window, depending on the parent.
+ * <strong>Note:</strong> All methods in this class must be invoked in the same thread as the
+ * <cite>Swing</cite> thread.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+@SuppressWarnings("serial")
+final class ExceptionMonitor extends JOptionPane implements ActionListener {
+    /**
+     * Number of spaces to leave between each tab.
+     */
+    private static final int TAB_WIDTH = 4;
+
+    /**
+     * Width and height (in pixels) of the dialog box when it also shows the stack trace.
+     */
+    private static final int WIDTH = 600, HEIGHT = 400;
+
+    /**
+     * Dialog box made visible. This is an instance of {@link JDialog} or {@link JInternalFrame}.
+     */
+    private final Component dialog;
+
+    /**
+     * Exception to show in the dialog box. The {@link Throwable#getLocalizedMessage()} method
+     * will be invoked to obtain the message to show.
+     */
+    private final Throwable exception;
+
+    /**
+     * Box which will contain the "message" part of the constructed dialog box.
+     * This box will be expanded if the user asks to see the exception trace.
+     * It will arrange the components using {@link BorderLayout}.
+     */
+    private final Container message;
+
+    /**
+     * Component showing the exception stack trace. Initially null.
+     * Will be created if the stack trace is requested by the user.
+     */
+    private Container trace;
+
+    /**
+     * Indicates whether the trace is currently visible. This field value
+     * will be inverted each time the user presses the button "trace".
+     */
+    private boolean traceVisible;
+
+    /**
+     * Button which makes the trace appear or disappear.
+     */
+    private final AbstractButton traceButton;
+
+    /**
+     * Initial size of the dialog box {@link #dialog}. This information will be used to
+     * return the box to its initial size when the trace disappears.
+     */
+    private final Dimension initialSize;
+
+    /**
+     * Resources in the user's language.
+     */
+    private final Resources resources;
+
+    /**
+     * Constructs a pane which will show the specified error message.
+     *
+     * @param owner      parent Component of the dialog box to be created.
+     * @param exception  exception we want to report.
+     * @param message    message to show.
+     * @param buttons    buttons to place under the message. These buttons should be in the order "Debug", "Close".
+     * @param resources  resources in the user's language.
+     */
+    @SuppressWarnings("ThisEscapedInObjectConstruction")
+    private ExceptionMonitor(final Component owner,   final Throwable exception,
+                             final Container message, final AbstractButton[] buttons,
+                             final Resources resources)
+    {
+        super(message, ERROR_MESSAGE, OK_CANCEL_OPTION, null, buttons);
+        this.exception   = exception;
+        this.message     = message;
+        this.resources   = resources;
+        this.traceButton = buttons[0];
+        buttons[0].addActionListener(this);
+        buttons[1].addActionListener(this);
+        /*
+         * Constructs the dialog box.  Automatically detects if we can use InternalFrame or if
+         * we should be happy with JDialog. The exception trace will not be written immediately.
+         */
+        final String classname = Classes.getShortClassName(exception);
+        final String title = resources.getLabel(Resources.Keys.Error) + ' ' + classname;
+        final JDesktopPane desktop = getDesktopPaneForComponent(owner);
+        if (desktop != null) {
+            final JInternalFrame dialog = createInternalFrame(desktop, title);
+            desktop.setLayer(dialog, JDesktopPane.MODAL_LAYER);
+            dialog.setDefaultCloseOperation(JInternalFrame.DISPOSE_ON_CLOSE);
+            dialog.setResizable(false);
+            dialog.pack();
+            this.dialog = dialog;
+        } else {
+            final JDialog dialog = createDialog(owner, title);
+            dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
+            dialog.setResizable(false);
+            dialog.pack();
+            this.dialog = dialog;
+        }
+        initialSize = dialog.getSize();
+    }
+
+    /**
+     * Shows a dialog box which notifies the user that an exception has been produced.
+     * This method should be invoked in the same thread as the Swing thread.
+     */
+    private static void showUnsafe(final Component owner, final Throwable exception, String message) {
+        final Resources resources = Resources.forLocale((owner != null) ? owner.getLocale() : null);
+        if (message == null) {
+            message = exception.getLocalizedMessage();
+            if (message == null) {
+                final String classname = Classes.getShortClassName(exception);
+                message = resources.getString(Resources.Keys.NoDetails_1, classname);
+            }
+        }
+        final JTextArea textArea = new JTextArea(message);
+        textArea.setLineWrap(true);
+        final JComponent messageBox = new JPanel(new BorderLayout());
+        messageBox.add(textArea, BorderLayout.NORTH);
+        final ExceptionMonitor pane = new ExceptionMonitor(owner, exception, messageBox, new AbstractButton[] {
+                new JButton(resources.getString(Resources.Keys.Debug)),
+                new JButton(resources.getString(Resources.Keys.Close))
+        }, resources);
+        pane.dialog.setVisible(true);
+    }
+
+    /**
+     * Shows an error message for the specified exception. Note that this method can
+     * be invoked from any thread (not necessarily the <cite>Swing</cite> thread).
+     *
+     * @param  owner      component in which the exception occurred, or {@code null} if unknown.
+     * @param  exception  exception which has been thrown and is to be reported to the user.
+     * @param  message    message to show. If this parameter is null, then the message will be provided by
+     *                    {@link Exception#getLocalizedMessage()}.
+     */
+    public static void show(final Component owner, final Throwable exception, final String message) {
+        if (EventQueue.isDispatchThread()) {
+            showUnsafe(owner, exception, message);
+        } else {
+            SwingUtilities.invokeAndWait(() -> showUnsafe(owner, exception, message));
+        }
+    }
+
+    /**
+     * Shows an error message for the specified exception. Note that this method can
+     * be invoked from any thread (not necessarily the <cite>Swing</cite> thread).
+     *
+     * @param  owner      component in which the exception occurred, or {@code null} if unknown.
+     * @param  exception  exception which has been thrown and is to be reported to the user.
+     */
+    public static void show(final Component owner, final Throwable exception) {
+        show(owner, exception, null);
+    }
+
+    /**
+     * Shows the exception stack trace below the message. This method is invoked when the dialog
+     * box "Debug" button is pressed. If the exception trace still has not been written yet,
+     * this method will construct the necessary components.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void actionPerformed(final ActionEvent event) {
+        if (event.getSource() != traceButton) {
+            dispose();
+            return;
+        }
+        /*
+         * Constructs the exception trace if it hasn't already been constructed.
+         */
+        if (trace == null) {
+            JComponent traceComponent = null;
+            for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
+                final JTextArea text = new JTextArea();
+                text.setTabSize(4);
+                text.setText(formatStackTrace(cause));
+                text.setEditable(false);
+                text.setCaretPosition(0);
+                final JScrollPane scroll = new JScrollPane(text);
+                if (traceComponent != null) {
+                    if (!(traceComponent instanceof JTabbedPane)) {
+                        traceComponent.setOpaque(false);
+                        String classname = Classes.getShortClassName(exception);
+                        JTabbedPane tabs = new JTabbedPane(JTabbedPane.TOP, JTabbedPane.SCROLL_TAB_LAYOUT);
+                        tabs.addTab(classname, traceComponent);
+                        traceComponent = tabs;
+                    }
+                    String classname = Classes.getShortClassName(cause);
+                    ((JTabbedPane) traceComponent).addTab(classname, scroll);
+                } else {
+                    traceComponent = scroll;
+                }
+            }
+            if (traceComponent == null) {
+                // Should not happen
+                return;
+            }
+            trace = Box.createVerticalBox();
+            trace.add(Box.createVerticalStrut(12));
+            trace.add(traceComponent);
+        }
+        /*
+         * Inserts or hides the exception trace. Even if the trace is hidden,
+         * it will not be destroyed in case the user want to show it again.
+         */
+        traceButton.setText(resources.getString(traceVisible ? Resources.Keys.Debug : Resources.Keys.Hide));
+        traceVisible = !traceVisible;
+        if (dialog instanceof Dialog) {
+            ((Dialog) dialog).setResizable(traceVisible);
+        } else {
+            ((JInternalFrame) dialog).setResizable(traceVisible);
+        }
+        int dx = dialog.getWidth();
+        int dy = dialog.getHeight();
+        if (traceVisible) {
+            message.add(trace, BorderLayout.CENTER);
+            dialog.setSize(WIDTH, HEIGHT);
+        } else {
+            message.remove(trace);
+            dialog.setSize(initialSize);
+        }
+        dx -= dialog.getWidth();
+        dy -= dialog.getHeight();
+        dialog.setLocation(Math.max(0, dialog.getX() + dx/2),
+                           Math.max(0, dialog.getY() + dy/2));
+        dialog.validate();
+    }
+
+    /**
+     * Returns the exception trace as a string. This method get the stack trace using the
+     * {@link Throwable#printStackTrace(PrintWriter)} method, then replaces the tabulation
+     * characters by 4 white spaces.
+     *
+     * @param  exception  the exception to format.
+     * @return a string representation of the given exception.
+     */
+    private static String formatStackTrace(final Throwable exception) {
+        final StringWriter writer = new StringWriter();
+        exception.printStackTrace(new PrintWriter(writer));
+        final StringBuilder buffer = new StringBuilder();
+        final LineAppender formatter = new LineAppender(buffer);
+        formatter.setTabulationWidth(TAB_WIDTH);
+        try {
+            formatter.append(writer.toString());
+        } catch (IOException e) {
+            throw new AssertionError(e);
+        }
+        return buffer.toString();
+    }
+
+    /**
+     * Frees up the resources used by this dialog box. This method is invoked when the
+     * user closes the dialog box which reported the exception.
+     */
+    private void dispose() {
+        if (dialog instanceof Window) {
+            ((Window) dialog).dispose();
+        } else {
+            ((JInternalFrame) dialog).dispose();
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/InternalWindowListener.java b/src/main/java/org/apache/sis/swing/InternalWindowListener.java
new file mode 100644
index 0000000..b7715d3
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/InternalWindowListener.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Component;
+import java.awt.Window;
+import java.awt.event.WindowEvent;
+import java.awt.event.WindowListener;
+import javax.swing.JInternalFrame;
+import javax.swing.event.InternalFrameEvent;
+import javax.swing.event.InternalFrameListener;
+
+
+/**
+ * Wraps a {@link WindowListener} into an {@link InternalFrameListener}.
+ * This is used by {@link SwingUtilities} in order to have the same methods
+ * working seemless on both {@link java.awt.Frame} and {@link javax.swing.JInternalFrame}.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+final class InternalWindowListener implements InternalFrameListener {
+    /**
+     * The underlying {@link WindowListener}.
+     */
+    private final WindowListener listener;
+
+    /**
+     * Constructs a new {@link InternalFrameListener}
+     * wrapping the specified {@link WindowListener}.
+     */
+    private InternalWindowListener(final WindowListener listener) {
+        this.listener = listener;
+    }
+
+    /**
+     * Wraps the specified {@link WindowListener} into an {@link InternalFrameListener}.
+     * If the specified object is already an {@link InternalFrameListener}, then it is
+     * returned as-is.
+     *
+     * @param  listener  the window listener.
+     * @return the internal frame listener.
+     */
+    public static InternalFrameListener wrap(final WindowListener listener) {
+        if (listener == null) {
+            return null;
+        }
+        if (listener instanceof InternalFrameListener) {
+            return (InternalFrameListener) listener;
+        }
+        return new InternalWindowListener(listener);
+    }
+
+    /**
+     * Wraps the given internal frame event into a window event.
+     */
+    private static WindowEvent wrap(final InternalFrameEvent event) {
+        /*
+         * Don't use javax.swing.SwingUtilities.getWindowAncestor
+         * because we want the check to include event.getSource().
+         */
+        Component c = (Component) event.getSource();
+        while (c != null) {
+            if (c instanceof Window) {
+                return new WindowEvent((Window) c, event.getID());
+            }
+            c = c.getParent();
+        }
+        return null;            // We can't create a WindowEvent with a null source.
+    }
+
+    /**
+     * Invoked when a internal frame has been opened.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void internalFrameOpened(InternalFrameEvent event) {
+        listener.windowOpened(wrap(event));
+    }
+
+    /**
+     * Invoked when an internal frame is in the process of being closed.
+     * The close operation can be overridden at this point.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void internalFrameClosing(InternalFrameEvent event) {
+        listener.windowClosing(wrap(event));
+    }
+
+    /**
+     * Invoked when an internal frame has been closed.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void internalFrameClosed(InternalFrameEvent event) {
+        listener.windowClosed(wrap(event));
+    }
+
+    /**
+     * Invoked when an internal frame is iconified.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void internalFrameIconified(InternalFrameEvent event) {
+        listener.windowIconified(wrap(event));
+    }
+
+    /**
+     * Invoked when an internal frame is de-iconified.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void internalFrameDeiconified(InternalFrameEvent event) {
+        listener.windowDeiconified(wrap(event));
+    }
+
+    /**
+     * Invoked when an internal frame is activated.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void internalFrameActivated(InternalFrameEvent event) {
+        listener.windowActivated(wrap(event));
+    }
+
+    /**
+     * Invoked when an internal frame is de-activated.
+     *
+     * @param  event  the event.
+     */
+    @Override
+    public void internalFrameDeactivated(InternalFrameEvent event) {
+        listener.windowDeactivated(wrap(event));
+    }
+
+    /**
+     * Removes the given window listener from the given internal frame. This method will look
+     * for instances of {@code InternalWindowListener} and unwrap the listener if needed.
+     *
+     * @param  frame     the frame from which to remove the listener.
+     * @param  listener  the listener to remove.
+     */
+    public static void removeWindowListener(final JInternalFrame frame, final WindowListener listener) {
+        for (final InternalFrameListener candidate : frame.getInternalFrameListeners()) {
+            if (candidate instanceof InternalWindowListener &&
+                    ((InternalWindowListener) candidate).listener.equals(listener))
+            {
+                frame.removeInternalFrameListener(candidate);
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/MouseReshapeTracker.java b/src/main/java/org/apache/sis/swing/MouseReshapeTracker.java
new file mode 100644
index 0000000..8eefebb
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/MouseReshapeTracker.java
@@ -0,0 +1,1568 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Shape;
+import java.awt.Rectangle;
+import java.awt.geom.Point2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.geom.PathIterator;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.RectangularShape;
+import java.awt.geom.NoninvertibleTransformException;
+import java.awt.Cursor;
+import java.awt.Insets;
+import java.awt.Component;
+import javax.swing.JSpinner;
+import javax.swing.JComponent;
+import javax.swing.JTextField;
+import javax.swing.SwingConstants;
+import javax.swing.SpinnerDateModel;
+import javax.swing.SpinnerNumberModel;
+import javax.swing.JFormattedTextField;
+import java.awt.event.MouseEvent;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import javax.swing.event.MouseInputAdapter;
+
+import java.util.Date;
+import java.util.Arrays;
+import java.text.Format;
+import java.text.DateFormat;
+import java.text.DecimalFormat;
+import java.text.SimpleDateFormat;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.util.Classes;
+import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
+
+
+/**
+ * Controls the position and size of a rectangle which the user can move
+ * with their mouse. For example, this class can be used as follows:
+ *
+ * {@preformat java
+ *     public class MyClass extends JPanel {
+ *         private final MouseReshapeTracker slider = new MouseReshapeTracker() {
+ *             protected void clipChangeRequested(double xmin, double xmax, double ymin, double ymax) {
+ *                 // Indicates what must be done if the user tries to move the
+ *                 // rectangle outside the permitted limits.
+ *                 // This method is optional.
+ *             }
+ *
+ *             protected void stateChanged(boolean isAdjusting) {
+ *                 // Method automatically invoked each time the user
+ *                 // changes the position of the rectangle.
+ *                 // Code here what it should do in this case.
+ *             }
+ *         };
+ *
+ *         private final AffineTransform transform = AffineTransform.getScaleInstance(10, 10);
+ *
+ *         public MyClass() {
+ *             slider.setFrame(0, 0, 1, 1);
+ *             slider.setClip(0, 100, 0, 1);
+ *             slider.setTransform(transform);
+ *             addMouseMotionListener(slider);
+ *             addMouseListener(slider);
+ *         }
+ *
+ *         public void paintComponent(Graphics graphics) {
+ *             AffineTransform tr=...
+ *             Graphics2D g = (Graphics2D) graphics;
+ *             g.transform(transform);
+ *             g.setColor(new Color(128, 64, 92, 64));
+ *             g.fill(slider);
+ *         }
+ *     }
+ * }
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+class MouseReshapeTracker extends MouseInputAdapter implements Shape {
+    /**
+     * Minimum width the rectangle should have, in pixels.
+     */
+    private static final int MIN_WIDTH = 12;
+
+    /**
+     * Minimum height the rectangle should have, in pixels.
+     */
+    private static final int MIN_HEIGHT = 12;
+
+    /**
+     * If the user moves the mouse by less than RESIZE_POS, then we assume the
+     * user wants to resize rather than move the rectangle. This distance is
+     * measured in pixels from one of the rectangle's edges.
+     */
+    private static final int RESIZE_POS = 4;
+
+    /**
+     * Minimum value of the <code>(clipped rectangle size)/(full rectangle size)</code> ratio.
+     * This minimum value will only be taken into account when the user modifies the rectangle's
+     * position using the values entered in the fields. This number must be greater than or equal to 1.
+     */
+    private static final double MINSIZE_RATIO = 1.25;
+
+    /**
+     * Minimum <var>x</var> coordinate permitted for the rectangle.
+     * The default value is {@link java.lang.Double#NEGATIVE_INFINITY}.
+     */
+    private double xmin = Double.NEGATIVE_INFINITY;
+
+    /**
+     * Minimum <var>y</var> coordinate permitted for the rectangle.
+     * The default value is {@link java.lang.Double#NEGATIVE_INFINITY}.
+     */
+    private double ymin = Double.NEGATIVE_INFINITY;
+
+    /**
+     * Maximum <var>x</var> coordinate permitted for the rectangle.
+     * The default value is {@link java.lang.Double#POSITIVE_INFINITY}.
+     */
+    private double xmax = Double.POSITIVE_INFINITY;
+
+    /**
+     * Maximum <var>y</var> coordinate permitted for the rectangle.
+     * The default value is {@link java.lang.Double#POSITIVE_INFINITY}.
+     */
+    private double ymax = Double.POSITIVE_INFINITY;
+
+    /**
+     * The rectangle to control.  The coordinates of this rectangle must be logical coordinates
+     * (for example, coordinates in metres), and not screen pixel coordinates.
+     * An empty rectangle means that no region is currently selected.
+     */
+    private final RectangularShape logicalShape;
+
+    /**
+     * Rectangle to be drawn in the component. This rectangle can be different to {@link #logicalShape}
+     * and the latter is so small that it is preferable to draw it a little bit bigger than the user has requested.
+     * In this case, {@code drawnShape} will serve as a temporary rectangle with extended coordinates.
+     *
+     * <p><b>Note:</b> this rectangle should be read only, except in the case
+     * of {@link #update} which is the only method permitted to update it.</p>
+     */
+    private transient RectangularShape drawnShape;
+
+    /**
+     * Affine transform which changes logical coordinates into pixel coordinates.
+     * It is guaranteed that no method except {@link #setTransform} will modify this transformation.
+     */
+    private final AffineTransform transform = new AffineTransform();
+
+    /**
+     * Last <em>relative</em> mouse coordinates. This information is expressed in logical
+     * coordinates (according to the {@link #transform} inverse affine transform).
+     * The coordinates are relative to (<var>x</var>,<var>y</var>) corner of the rectangle.
+     */
+    private transient double mouseDX, mouseDY;
+
+    /**
+     * {@code x}, {@code y}, {@code width} and {@code height} coordinates of a box which completely
+     * encloses {@link #drawnShape}. These coordinates must be expressed in <strong>pixels</strong>.
+     * If need be, the affine transform {@link #transform} can be used to change pixel coordinates
+     * into logical coordinates and vice versa.
+     */
+    private transient int x, y, width, height;
+
+    /**
+     * Indicates whether the mouse pointer is over the rectangle.
+     */
+    private transient boolean mouseOverRect;
+
+    /**
+     * Point used internally by certain calculations in order to avoid
+     * the frequent creation of several temporary {@link Point2D} objects.
+     */
+    private final transient Point2D.Double tmp = new Point2D.Double();
+
+    /**
+     * Indicates if the user is currently dragging the rectangle.
+     * For this field to become {@code true}, the mouse must have
+     * been over the rectangle as the user pressed the mouse button.
+     */
+    private transient boolean isDragging;
+
+    /**
+     * Indicates which edges the user is currently adjusting with the mouse.
+     * This field is often identical to {@link #adjustingSides}.
+     * However, unlike {@link #adjustingSides}, it designates an edge of the shape {@link #logicalShape}
+     * and not an edge of the shape in pixels appearing on the screen. It is different, for example,
+     * if the affine transform {@link #transform} contains a 90° rotation.
+     */
+    private transient int adjustingLogicalSides;
+
+    /**
+     * Indicates which edges the user is currently adjusting with the mouse.
+     * Permitted values are binary combinations of {@link #NORTH}, {@link #SOUTH}, {@link #EAST} and {@link #WEST}.
+     */
+    private transient int adjustingSides;
+
+    /**
+     * Indicates which edges are allowed to be adjusted.
+     * Permitted values are binary combinations of {@link #NORTH}, {@link #SOUTH}, {@link #EAST} and {@link #WEST}.
+     */
+    private int adjustableSides;
+
+    /**
+     * Indicates if the geometric shape can be moved.
+     */
+    private boolean moveable = true;
+
+    /**
+     * When the position of the left or right-hand edge of the rectangle is manually edited,
+     * this indicates whether the position of the opposite edge should be automatically adjusted.
+     * The default value is {@code false}.
+     */
+    private boolean synchronizeX;
+
+    /**
+     * When the position of the top or bottom edge of the rectangle is manually edited,
+     * this indicates whether the position of the opposite edge should be automatically adjusted.
+     * The default value is {@code false}.
+     */
+    private boolean synchronizeY;
+
+    /** Bit representing north. */ private static final int NORTH = 1;
+    /** Bit representing south. */ private static final int SOUTH = 2;
+    /** Bit representing east.  */ private static final int EAST  = 4;
+    /** Bit representing west.  */ private static final int WEST  = 8;
+
+    /**
+     * Cursor codes corresponding to a given {@link adjustingSides} value.
+     */
+    private static final int[] CURSORS = new int[] {
+        Cursor.     MOVE_CURSOR,     // 0000 =       |      |       |
+        Cursor. N_RESIZE_CURSOR,     // 0001 =       |      |       | NORTH
+        Cursor. S_RESIZE_CURSOR,     // 0010 =       |      | SOUTH |
+        Cursor.  DEFAULT_CURSOR,     // 0011 =       |      | SOUTH | NORTH
+        Cursor. E_RESIZE_CURSOR,     // 0100 =       | EAST |       |
+        Cursor.NE_RESIZE_CURSOR,     // 0101 =       | EAST |       | NORTH
+        Cursor.SE_RESIZE_CURSOR,     // 0110 =       | EAST | SOUTH |
+        Cursor.  DEFAULT_CURSOR,     // 0111 =       | EAST | SOUTH | NORTH
+        Cursor. W_RESIZE_CURSOR,     // 1000 =  WEST |      |       |
+        Cursor.NW_RESIZE_CURSOR,     // 1001 =  WEST |      |       | NORTH
+        Cursor.SW_RESIZE_CURSOR      // 1010 =  WEST |      | SOUTH |
+    };
+
+    /**
+     * Lookup table which converts <i>Swing</i> constants into combinations of {@link #NORTH},
+     * {@link #SOUTH}, {@link #EAST} and {@link #WEST} constants. We cannot use <i>Swing</i>
+     * constants directly because, unfortunately, they do not correspond to the binary
+     * combinations of the four cardinal corners.
+     */
+    private static final int[] SWING_TO_CUSTOM = new int[] {
+        SwingConstants.NORTH,      NORTH,
+        SwingConstants.SOUTH,      SOUTH,
+        SwingConstants.EAST,       EAST,
+        SwingConstants.WEST,       WEST,
+        SwingConstants.NORTH_EAST, NORTH | EAST,
+        SwingConstants.SOUTH_EAST, SOUTH | EAST,
+        SwingConstants.NORTH_WEST, NORTH | WEST,
+        SwingConstants.SOUTH_WEST, SOUTH | WEST
+    };
+
+    /**
+     * List of text fields which represent the coordinates of the rectangle's edges.
+     */
+    private Control[] editors;
+
+    /**
+     * Constructs an object capable of moving and resizing a rectangular shape through mouse movements.
+     * The rectangle will be positioned, by default at the coordinates (0,0).
+     * Its width and height will be null.
+     */
+    public MouseReshapeTracker() {
+        this(new Rectangle2D.Double());
+    }
+
+    /**
+     * Constructs an object capable of moving and resizing a rectangular shape through mouse movements.
+     * The given shape does not have to be a rectangle. It could, for example, be a circle.
+     * The coordinates of this shape will be the initial coordinates of the visor.
+     * They are logical coordinates and not pixel coordinates.
+     *
+     * <p>Note that the constructor retains a direct reference to this shape, without creating a clone.
+     * As a consequence, any modification carried out on the geometric shape will have repercussions
+     * for this {@code MouseReshapeTracker} object and vice versa.</p>
+     *
+     * @param  shape  rectangular geometric shape to be resized by mouse movements.
+     */
+    public MouseReshapeTracker(final RectangularShape shape) {
+        this.logicalShape = shape;
+        this.drawnShape   = shape;
+        update();
+    }
+
+    /**
+     * Invoked after reading this object in order to finish the construction of certain fields.
+     */
+    private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+        in.defaultReadObject();
+        drawnShape = logicalShape;
+        update();
+    }
+
+    /**
+     * Updates the internal fields of this object. The adjusted fields will be:
+     *
+     * <ul>
+     *   <li>{@link #drawnShape} for the rectangle to be drawn.</li>
+     *   <li>{@link #x}, {@link #y}, {@link #width} and {@link #height}
+     *       for the pixel coordinates of {@link #drawnShape}.</li>
+     * </ul>
+     */
+    private void update() {
+        /*
+         * Takes into account cases where the affine transform
+         * contains a rotation of 90° or any other.
+         */
+        adjustingLogicalSides = inverseTransform(adjustingSides);
+        /*
+         * Obtains the geometric shape to draw. Normally it will be a {@link #logicalShape},
+         * except if the latter is so small that we have considered it preferable to create
+         * a temporary shape which will be slightly bigger.
+         */
+        tmp.x = logicalShape.getWidth();
+        tmp.y = logicalShape.getHeight();
+        transform.deltaTransform(tmp, tmp);
+        if (Math.abs(tmp.x) < MIN_WIDTH || Math.abs(tmp.y) < MIN_HEIGHT) {
+            if (Math.abs(tmp.x) < MIN_WIDTH ) tmp.x = (tmp.x < 0) ? -MIN_WIDTH  : MIN_WIDTH;
+            if (Math.abs(tmp.y) < MIN_HEIGHT) tmp.y = (tmp.y < 0) ? -MIN_HEIGHT : MIN_HEIGHT;
+            try {
+                AffineTransforms2D.inverseDeltaTransform(transform, tmp, tmp);
+                double x = logicalShape.getX();
+                double y = logicalShape.getY();
+                if ((adjustingLogicalSides & WEST) != 0) {
+                    x += logicalShape.getWidth() - tmp.x;
+                }
+                if ((adjustingLogicalSides & NORTH) != 0) {
+                    y += logicalShape.getHeight() - tmp.y;
+                }
+                if (drawnShape == logicalShape) {
+                    drawnShape = (RectangularShape) logicalShape.clone();
+                }
+                drawnShape.setFrame(x, y, tmp.x, tmp.y);
+            } catch (NoninvertibleTransformException exception) {
+                drawnShape = logicalShape;
+            }
+        } else {
+            drawnShape = logicalShape;
+        }
+        /*
+         * NOTE: the condition 'drawnShape==logicalShape' indicates that it has not been necessary
+         *       to modify the shape. The method `mouseDragged` will use this information.
+         *
+         * Now retains the pixel coordinates of the new position of the rectangle.
+         */
+        double xmin = Double.POSITIVE_INFINITY;
+        double ymin = Double.POSITIVE_INFINITY;
+        double xmax = Double.NEGATIVE_INFINITY;
+        double ymax = Double.NEGATIVE_INFINITY;
+        for (int i = 0; i < 4; i++) {
+            tmp.x = (i&1) == 0 ? drawnShape.getMinX() : drawnShape.getMaxX();
+            tmp.y = (i&2) == 0 ? drawnShape.getMinY() : drawnShape.getMaxY();
+            transform.transform(tmp, tmp);
+            if (tmp.x < xmin) xmin = tmp.x;
+            if (tmp.x > xmax) xmax = tmp.x;
+            if (tmp.y < ymin) ymin = tmp.y;
+            if (tmp.y > ymax) ymax = tmp.y;
+        }
+        x      = (int) Math.floor(xmin)      -1;
+        y      = (int) Math.floor(ymin)      -1;
+        width  = (int) Math.ceil (xmax-xmin) +2;
+        height = (int) Math.ceil (ymax-ymin) +2;
+    }
+
+    /**
+     * Returns the transform of {@code adjusting}.
+     *
+     * @param  adjusting  flags to transform (generally {@link #adjustingSides}).
+     */
+    private int inverseTransform(int adjusting) {
+        switch (adjusting & (WEST | EAST)) {
+            case WEST: tmp.x=-1; break;
+            case EAST: tmp.x=+1; break;
+            default  : tmp.x= 0; break;
+        }
+        switch (adjusting & (NORTH | SOUTH)) {
+            case NORTH: tmp.y=-1; break;
+            case SOUTH: tmp.y=+1; break;
+            default   : tmp.y= 0; break;
+        }
+        try {
+            AffineTransforms2D.inverseDeltaTransform(transform, tmp, tmp);
+            final double normalize = 0.25 * Math.hypot(tmp.x, tmp.y);
+            tmp.x /= normalize;
+            tmp.y /= normalize;
+            adjusting = 0;
+            switch ((int) Math.signum(Math.rint(tmp.x))) {
+                case -1: adjusting |= WEST; break;
+                case +1: adjusting |= EAST; break;
+            }
+            switch ((int) Math.signum(Math.rint(tmp.y))) {
+                case -1: adjusting |= NORTH; break;
+                case +1: adjusting |= SOUTH; break;
+            }
+            return adjusting;
+        } catch (NoninvertibleTransformException exception) {
+            return adjusting;
+        }
+    }
+
+    /**
+     * Declares the affine transform which will transform the logical coordinates into pixel coordinates.
+     * This is the affine transform specified in {@link java.awt.Graphics2D#transform} at the moment that
+     * {@code this} is drawn. The information contained in this affine transform is necessary for several
+     * of this class's methods to work. It is the programmer's responsibility to ensure that this information
+     * is always up-to-date.  By default, {@code MouseReshapeTracker} assumes an identity transform.
+     *
+     * @param  newValue  the transform from logical to pixel coordinates.
+     */
+    public void setTransform(final AffineTransform newValue) {
+        if (!transform.equals(newValue)) {
+            fireStateWillChange();
+            transform.setTransform(newValue);
+            update();
+            fireStateChanged();
+        }
+    }
+
+    /**
+     * Returns the position and the size of the rectangular shape. These bounds can be slightly
+     * bigger than those returned by {@link #getFrame} since {@code getBounds2D()} returns the
+     * bounds of the rectangle visible on screen, which may have a minimal size.
+     *
+     * @return the position and size of the rectangular shape, in logical units.
+     */
+    @Override
+    public Rectangle getBounds() {
+        return drawnShape.getBounds();
+    }
+
+    /**
+     * Returns the position and the size of the rectangular shape. These bounds can be slightly
+     * bigger than those returned by {@link #getFrame} since {@code getBounds2D()} returns the
+     * bounds of the rectangle visible on screen, which may have a minimal size.
+     *
+     * @return the position and size of the rectangular shape, in logical units.
+     */
+    @Override
+    public Rectangle2D getBounds2D() {
+        return drawnShape.getBounds2D();
+    }
+
+    /**
+     * Returns the position and the size of the rectangular shape.
+     * This information is expressed in logical coordinates.
+     *
+     * @return the position and size of the rectangular shape, in logical units.
+     *
+     * @see #getCenterX()
+     * @see #getCenterY()
+     * @see #getMinX()
+     * @see #getMaxX()
+     * @see #getMinY()
+     * @see #getMaxY()
+     */
+    public Rectangle2D getFrame() {
+        return logicalShape.getFrame();
+    }
+
+    /**
+     * Defines a new position and bounds for the rectangular shape. The coordinates passed to this
+     * method should be logical coordinates rather than pixel coordinates. If the range of values
+     * covered by the rectangular shape is limited by a call to {@link #setClip}, then the shape
+     * will be moved and resized as needed to fit into the permitted region.
+     *
+     * @param  frame  the new position and size of the rectangular shape.
+     * @return {@code true} if the rectangle's coordinates have changed.
+     *
+     * @see #getFrame()
+     */
+    public final boolean setFrame(final Rectangle2D frame) {
+        return setFrame(frame.getX(), frame.getY(), frame.getWidth(), frame.getHeight());
+    }
+
+    /**
+     * Defines a new position and bounds for the rectangular shape. The coordinates passed to this
+     * method should be logical coordinates rather than pixel coordinates. If the range of values
+     * covered by the rectangular shape is limited by a call to {@link #setClip}, then the shape
+     * will be moved and resized as needed to fit into the permitted region.
+     *
+     * @param  x       the new horizontal position.
+     * @param  y       the new vertical position.
+     * @param  width   the new width.
+     * @param  height  the new height.
+     * @return {@code true} if the rectangle's coordinates have changed.
+     *
+     * @see #setX(double, double)
+     * @see #setY(double, double)
+     */
+    public boolean setFrame(double x, double y, double width, double height) {
+        final double oldX = logicalShape.getX();
+        final double oldY = logicalShape.getY();
+        final double oldW = logicalShape.getWidth();
+        final double oldH = logicalShape.getHeight();
+        if (x < xmin) x = xmin;
+        if (y < ymin) y = ymin;
+        if (x + width > xmax) {
+            x = Math.max(xmin, xmax - width);
+            width = xmax - x;
+        }
+        if (y + height > ymax) {
+            y = Math.max(ymin, ymax - height);
+            height = ymax - y;
+        }
+        fireStateWillChange();
+        logicalShape.setFrame(x, y, width, height);
+        if (oldX != logicalShape.getX()     ||
+            oldY != logicalShape.getY()     ||
+            oldW != logicalShape.getWidth() ||
+            oldH != logicalShape.getHeight())
+        {
+            update();
+            fireStateChanged();
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Defines the new range of values covered by the rectangle according to the <var>x</var> axis.
+     * The values covered along the <var>y</var> axis will not be changed.
+     * The values must be expressed in logical coordinates.
+     *
+     * @param  min  the new minimal value.
+     * @param  max  the new maximal value.
+     *
+     * @see #getMinX()
+     * @see #getMaxX()
+     * @see #getCenterX()
+     */
+    public final void setX(final double min, final double max) {
+        setFrame(Math.min(min,max), logicalShape.getY(),
+                 Math.abs(max-min), logicalShape.getHeight());
+    }
+
+    /**
+     * Defines the new range of values covered by the rectangle according to the <var>y</var> axis.
+     * The values covered along the <var>x</var> axis will not be changed.
+     * The values must be expressed in logical coordinates.
+     *
+     * @param  min  the new minimal value.
+     * @param  max  the new maximal value.
+     *
+     * @see #getMinY()
+     * @see #getMaxY()
+     * @see #getCenterY()
+     */
+    public final void setY(final double min, final double max) {
+        setFrame(logicalShape.getX(), Math.min(min, max),
+                 logicalShape.getWidth(), Math.abs(max - min));
+    }
+
+    /**
+     * Returns the minimum <var>x</var> coordinate of the rectangle
+     * (the logical coordinate, not the pixel coordinate).
+     *
+     * @return the current minimal value on the horizontal axis.
+     */
+    public double getMinX() {
+        return logicalShape.getMinX();
+    }
+
+    /**
+     * Returns the minimum <var>y</var> coordinate of the rectangle
+     * (the logical coordinate, not the pixel coordinate).
+     *
+     * @return the current minimal value on the vertical axis.
+     */
+    public double getMinY() {
+        return logicalShape.getMinY();
+    }
+
+    /**
+     * Returns the maximum <var>x</var> coordinate of the rectangle
+     * (the logical coordinate, not the pixel coordinate).
+     *
+     * @return the current maximal value on the horizontal axis.
+     */
+    public double getMaxX() {
+        return logicalShape.getMaxX();
+    }
+
+    /**
+     * Returns the maximum <var>y</var> coordinate of the rectangle
+     * (the logical coordinate, not the pixel coordinate).
+     *
+     * @return the current maximal value on the vertical axis.
+     */
+    public double getMaxY() {
+        return logicalShape.getMaxY();
+    }
+
+    /**
+     * Returns the width of the rectangle.
+     * This width is expressed in logical coordinates, not pixel coordinates.
+     *
+     * @return the current width.
+     */
+    public double getWidth() {
+        return logicalShape.getWidth();
+    }
+
+    /**
+     * Returns the height of the rectangle.
+     * This height is expressed in logical coordinates, not pixel coordinates.
+     *
+     * @return the current height.
+     */
+    public double getHeight() {
+        return logicalShape.getHeight();
+    }
+
+    /**
+     * Returns the <var>x</var> coordinate of the centre of the rectangle
+     * (logical coordinate, not pixel coordinate).
+     *
+     * @return the current median value on the horizontal axis.
+     */
+    public double getCenterX() {
+        return logicalShape.getCenterX();
+    }
+
+    /**
+     * Returns the <var>y</var> coordinate of the centre of the rectangle
+     * (logical coordinate, not pixel coordinate).
+     *
+     * @return the current median value on the vertical axis.
+     */
+    public double getCenterY() {
+        return logicalShape.getCenterY();
+    }
+
+    /**
+     * Indicates whether the rectangle is empty.
+     * This will be the case if the width and / or height is null.
+     *
+     * @return {@code true} if the rectangular shape is empty.
+     */
+    public boolean isEmpty() {
+        return logicalShape.isEmpty();
+    }
+
+    /**
+     * Indicates whether the rectangular shape contains the specified point.
+     * This point should be expressed in logical coordinates.
+     *
+     * @param  point  the point to test for inclusion.
+     * @return {@code true} if the given point is included in the rectangular shape.
+     */
+    @Override
+    public boolean contains(final Point2D point) {
+        return logicalShape.contains(point);
+    }
+
+    /**
+     * Indicates whether the rectangular shape contains the specified point.
+     * This point should be expressed in logical coordinates.
+     *
+     * @param  x  the <var>x</var> value of the point to test for inclusion.
+     * @param  y  the <var>y</var> value of the point to test for inclusion.
+     * @return {@code true} if the given point is included in the rectangular shape.
+     */
+    @Override
+    public boolean contains(final double x, final double y) {
+        return logicalShape.contains(x, y);
+    }
+
+    /**
+     * Indicates whether the rectangular shape contains the specified rectangle.
+     * This rectangle should be expressed in logical coordinates. This method may
+     * conservatively return {@code false} as permitted by the {@link Shape} specification.
+     *
+     * @param rect  the rectangle to test for inclusion.
+     * @return {@code true} if the given rectangle is included in the rectangular shape.
+     */
+    @Override
+    public boolean contains(final Rectangle2D rect) {
+        return logicalShape.contains(rect);
+    }
+
+    /**
+     * Indicates whether the rectangular shape contains the specified rectangle.
+     * This rectangle must be expressed in logical coordinates. This method may
+     * conservatively return {@code false} as permitted by the {@link Shape} specification.
+     *
+     * @param  x       the <var>x</var> location of the rectangle to test for inclusion.
+     * @param  y       the <var>y</var> location of the rectangle to test for inclusion.
+     * @param  width   the width of the rectangle to test for inclusion.
+     * @param  height  the height of the rectangle to test for inclusion.
+     * @return {@code true} if the given rectangle is included in the rectangular shape.
+     */
+    @Override
+    public boolean contains(double x, double y, double width, double height) {
+        return logicalShape.contains(x, y, width, height);
+    }
+
+    /**
+     * Indicates whether the rectangular shape intersects the specified rectangle.
+     * This rectangle must be expressed in logical coordinates. This method may
+     * conservatively return {@code true} as permitted by the {@link Shape} specification.
+     *
+     * @param  rect  the rectangle to test for intersection.
+     * @return {@code true} if the given rectangle intersects the rectangular shape.
+     */
+    @Override
+    public boolean intersects(final Rectangle2D rect) {
+        return drawnShape.intersects(rect);
+    }
+
+    /**
+     * Indicates whether the rectangular shape intersects the specified rectangle.
+     * This rectangle must be expressed in logical coordinates. This method may
+     * conservatively return {@code true} as permitted by the {@link Shape} specification.
+     *
+     * @param  x The <var>x</var> location of the rectangle to test for intersection.
+     * @param  y The <var>y</var> location of the rectangle to test for intersection.
+     * @param  width The width of the rectangle to test for intersection.
+     * @param  height The height of the rectangle to test for intersection.
+     * @return {@code true} if the given rectangle intersects the rectangular shape.
+     */
+    @Override
+    public boolean intersects(double x, double y, double width, double height) {
+        return drawnShape.intersects(x, y, width, height);
+    }
+
+    /**
+     * Returns a path iterator for the rectangular shape to be drawn.
+     *
+     * @param  transform  the transform to be applied on coordinates to be returned by the iterator.
+     * @return An iterator over the coordinates of the rectangular shape.
+     */
+    @Override
+    public PathIterator getPathIterator(final AffineTransform transform) {
+        return drawnShape.getPathIterator(transform);
+    }
+
+    /**
+     * Returns a path iterator for the rectangular shape to be drawn.
+     *
+     * @param  transform  the transform to be applied on coordinates to be returned by the iterator.
+     * @param  flatness   the flateness factor for converting curves to straight lines.
+     * @return An iterator over the coordinates of the rectangular shape.
+     */
+    @Override
+    public PathIterator getPathIterator(final AffineTransform transform, final double flatness) {
+        return drawnShape.getPathIterator(transform, flatness);
+    }
+
+    /**
+     * Returns the bounds between which the rectangular shape can move.
+     * These bounds are specified in logical coordinates.
+     *
+     * @return the current clip area.
+     */
+    public Rectangle2D getClip() {
+        return new Rectangle2D.Double(xmin, ymin, xmax - xmin, ymax - ymin);
+    }
+
+    /**
+     * Defines the bounds between which the rectangular shape can move. This method manages
+     * infinities correctly if the specified rectangle has redefined its {@code getMaxX()}
+     * and {@code getMaxY()} methods correctly.
+     *
+     * @param  rect  the new clip area.
+     *
+     * @see #setClipMinMax(double, double, double, double)
+     */
+    public final void setClip(final Rectangle2D rect) {
+        setClipMinMax(rect.getMinX(), rect.getMaxX(), rect.getMinY(), rect.getMaxY());
+    }
+
+    /**
+     * Defines the bounds between which the rectangular shape can move. This method simply calls
+     * {@link #setClipMinMax setClipMinMax(...)} with the appropriate parameters.
+     * It is defined in order to avoid confusion among programmers used to <i>Java2D</i> conventions.
+     * If you want to specify infinite values (in order to widen the visor's bounds to all possible values along
+     * certain axes), you <u>must</u> use {@link #setClipMinMax setClipMinMax(...)} rather than {@code setClip(...)}.
+     *
+     * @param  x       the <var>x</var> location of the clip area.
+     * @param  y       the <var>y</var> location of the clip area.
+     * @param  width   the width of the clip area.
+     * @param  height  the height of the clip area.
+     */
+    public final void setClip(final double x, final double y, final double width, final double height) {
+        setClipMinMax(x, x + width, y, y + height);
+    }
+
+    /**
+     * Defines the bounds between which the rectangle can move. This method's arguments define the
+     * minimum and maximum values that the logical coordinates of the rectangle can take. The values
+     * {@link java.lang.Double#NEGATIVE_INFINITY} and {@link java.lang.Double#POSITIVE_INFINITY} are
+     * valid for indicating that the visor can extend across all values according to certain axes.
+     * The value {@link java.lang.Double#NaN} for a given argument indicates that we want to keep the old value.
+     *
+     * <p>If the visor doesn't fit completely within the new bounds,
+     * it will be moved and resized as needed in order to make it fit.</p>
+     */
+    public void setClipMinMax(double xmin, double xmax, double ymin, double ymax) {
+        if (xmin > xmax) {
+            final double tmp = xmin;
+            xmin = xmax; xmax = tmp;
+        }
+        if (ymin > ymax) {
+            final double tmp = ymin;
+            ymin = ymax; ymax = tmp;
+        }
+        if (!Double.isNaN(xmin)) this.xmin = xmin;
+        if (!Double.isNaN(xmax)) this.xmax = xmax;
+        if (!Double.isNaN(ymin)) this.ymin = ymin;
+        if (!Double.isNaN(ymax)) this.ymax = ymax;
+        setFrame(logicalShape.getX(), logicalShape.getY(), logicalShape.getWidth(), logicalShape.getHeight());
+    }
+
+    /**
+     * Invoked when a change in the clip is required. This method can be invoked,
+     * for example, when the user manually edits the position of the rectangle in a text field, and
+     * the new position falls outside the current clip.  This method does <u>not</u> have to accept
+     * a clip change. It can do nothing, which is the same as refusing any change. It can also
+     * unconditionally accept any change by calling {@link #setClipMinMax}. Finally, it can reach a
+     * compromise solution by imposing certain conditions on the changes. The default implementation
+     * does nothing, which means that no automatic change in the clip will be authorized.
+     */
+    protected void clipChangeRequested(double xmin, double xmax, double ymin, double ymax) {
+    }
+
+    /**
+     * Indicates whether the rectangle can be moved with the mouse.
+     * By default, it can be moved but not resized.
+     */
+    public boolean isMoveable() {
+        return moveable;
+    }
+
+    /**
+     * Specifies whether the rectangle can be moved with the mouse.
+     * The value {@code false} indicates that the rectangle cannot be moved, but can still
+     * be resized if {@link #setAdjustable} has been invoked with the appropriate parameters.
+     */
+    public void setMoveable(final boolean moveable) {
+        this.moveable = moveable;
+    }
+
+    /**
+     * Indicates whether the size of a rectangle can be modified using a specified edge.
+     * The specified edge must be one of the following constants:
+     *
+     * <table class="sis">
+     *   <tr>
+     *     <td>{@link SwingConstants#NORTH_WEST}</td>
+     *     <td>{@link SwingConstants#NORTH}</td>
+     *     <td>{@link SwingConstants#NORTH_EAST}</td>
+     *   </tr><tr>
+     *     <td>{@link SwingConstants#WEST}</td>
+     *     <td></td>
+     *     <td>{@link SwingConstants#EAST}</td>
+     *   </tr><tr>
+     *     <td>{@link SwingConstants#SOUTH_WEST}</td>
+     *     <td>{@link SwingConstants#SOUTH}</td>
+     *     <td>{@link SwingConstants#SOUTH_EAST}</td>
+     *   </tr>
+     * </table>
+     *
+     * These constants designate the edge which is visible on screen. For example, {@code NORTH} always designates
+     * the top edge on the screen. However, this could correspond to another edge of the logical shape {@code this}
+     * depending on the affine transform which was specified during the last call to {@link #setTransform(jAffineTransform)}.
+     * For example, {@code AffineTransform.getScaleInstance(+1,-1)} has the effect of inverting the <var>y</var> axis so
+     * that the <var>y</var><sub>max</sub> values appear to the North rather than the <var>y</var><sub>min</sub> values.
+     */
+    public boolean isAdjustable(int side) {
+        side = convertSwingConstant(side);
+        return (adjustableSides & side) == side;
+    }
+
+    /**
+     * Specifies whether the size of the rectangle can be modified using the specified edge.
+     * The specified edge must be one of the following constants:
+     *
+     * <table class="sis">
+     *   <tr>
+     *     <td>{@link SwingConstants#NORTH_WEST}</td>
+     *     <td>{@link SwingConstants#NORTH}</td>
+     *     <td>{@link SwingConstants#NORTH_EAST}</td>
+     *   </tr><tr>
+     *     <td>{@link SwingConstants#WEST}</td>
+     *     <td></td>
+     *     <td>{@link SwingConstants#EAST}</td>
+     *   </tr><tr>
+     *     <td>{@link SwingConstants#SOUTH_WEST}</td>
+     *     <td>{@link SwingConstants#SOUTH}</td>
+     *     <td>{@link SwingConstants#SOUTH_EAST}</td>
+     *   </tr>
+     * </table>
+     *
+     * These constants designate the edge which is visible on screen. For example, {@code NORTH} always designates
+     * the top edge on the screen. However, this could correspond to another edge of the logical shape {@code this}
+     * depending on the affine transform which was specified during the last call to {@link #setTransform(AffineTransform)}.
+     * For example, {@code AffineTransform.getScaleInstance(+1,-1)} has the effect of inverting the <var>y</var> axis so
+     * that the <var>y</var><sub>max</sub> values appear to the North rather than the <var>y</var><sub>min</sub> values.
+     */
+    public void setAdjustable(int side, final boolean adjustable) {
+        side = convertSwingConstant(side);
+        if (adjustable) {
+            adjustableSides |=  side;
+        } else {
+            adjustableSides &= ~side;
+        }
+    }
+
+    /**
+     * Converts a Swing edge constant to system used by this package. We cannot use <i>Swing</i>
+     * constants directly because, unfortunately, they do not correspond to the binary combinations
+     * of the four cardinal corners.
+     */
+    private int convertSwingConstant(final int side) {
+        for (int i = 0; i < SWING_TO_CUSTOM.length; i += 2) {
+            if (SWING_TO_CUSTOM[i] == side) {
+                return SWING_TO_CUSTOM[i + 1];
+            }
+        }
+        throw new IllegalArgumentException(String.valueOf(side));
+    }
+
+    /**
+     * Invoked during mouse movements. The default implementation checks whether the cursor
+     * is inside the rectangle or on one of its edges, and adjusts the mouse pointer icon accordingly.
+     */
+    @Override
+    public void mouseMoved(final MouseEvent event) {
+        if (!isDragging) {
+            final Component source=event.getComponent();
+            if (source != null) {
+                int x = event.getX(); tmp.x = x;
+                int y = event.getY(); tmp.y = y;
+                final boolean mouseOverRect;
+                try {
+                    mouseOverRect = drawnShape.contains(transform.inverseTransform(tmp, tmp));
+                } catch (NoninvertibleTransformException exception) {
+                    // Ignore this exception.
+                    return;
+                }
+                final boolean mouseOverRectChanged = (mouseOverRect != this.mouseOverRect);
+                if (mouseOverRect) {
+                    /*
+                     * We do not use "adjustingLogicalSides" because we are working
+                     * with pixel coordinates and not logical coordinates.
+                     */
+                    final int old = adjustingSides;
+                    adjustingSides = 0;
+                    if (Math.abs(x -= this.x)<=RESIZE_POS){
+                        adjustingSides |= WEST;
+                    }
+                    if (Math.abs(y -= this.y)<=RESIZE_POS){
+                        adjustingSides |= NORTH;
+                    }
+                    if (Math.abs(x - this.width)<=RESIZE_POS) {
+                        adjustingSides |= EAST;
+                    }
+                    if (Math.abs(y - this.height)<=RESIZE_POS) {
+                        adjustingSides |= SOUTH;
+                    }
+                    adjustingSides &= adjustableSides;
+                    if (adjustingSides != old || mouseOverRectChanged) {
+                        if (adjustingSides == 0 && !moveable) {
+                            source.setCursor(null);
+                        } else {
+                            adjustingLogicalSides = inverseTransform(adjustingSides);
+                            source.setCursor(Cursor.getPredefinedCursor(adjustingSides < CURSORS.length
+                                    ? CURSORS[adjustingSides] : Cursor.DEFAULT_CURSOR));
+                        }
+                    }
+                    if (mouseOverRectChanged) {
+                        /*
+                         * Adding and removing listeners worked well, but had the disadvantage
+                         * of changing the order of the listeners. This caused problems when
+                         * the order was important.
+                         */
+                        //source.addMouseListener(this);
+                        this.mouseOverRect = mouseOverRect;
+                    }
+                } else if (mouseOverRectChanged) {
+                    adjustingSides = 0;
+                    source.setCursor(null);
+                    //source.removeMouseListener(this);
+                    this.mouseOverRect = mouseOverRect;
+                }
+            }
+        }
+    }
+
+    /**
+     * Invoked when the user presses a mouse button anywhere within the component.
+     * The default implementation checks if the button was pressed whilst the mouse cursor was within the rectangle.
+     * If so, this object will track the mouse drags to move or resize the rectangle.
+     */
+    @Override
+    public void mousePressed(final MouseEvent e) {
+        if (!e.isConsumed() && (e.getModifiersEx() & MouseEvent.BUTTON1_DOWN_MASK)!= 0) {
+            if (adjustingSides != 0 || moveable) {
+                tmp.x = e.getX();
+                tmp.y = e.getY();
+                try {
+                    if (drawnShape.contains(transform.inverseTransform(tmp, tmp))) {
+                        mouseDX = tmp.x - drawnShape.getX();
+                        mouseDY = tmp.y - drawnShape.getY();
+                        isDragging = true;
+                        e.consume();
+                    }
+                } catch (NoninvertibleTransformException exception) {
+                    // Ignore this exception.
+                }
+            }
+        }
+    }
+
+    /**
+     * Invoked during mouse drags. The default implementation applies the mouse movement
+     * to the rectangle and notifies the component where the event which it needs to redraw, at least in part,
+     * came from.
+     */
+    @Override
+    public void mouseDragged(final MouseEvent e) {
+        if (isDragging) {
+            final int adjustingLogicalSides = this.adjustingLogicalSides;
+            final Component source = e.getComponent();
+            if (source != null) try {
+                tmp.x = e.getX();
+                tmp.y = e.getY();
+                transform.inverseTransform(tmp, tmp);
+                /*
+                 * Calculates the (x0,y0) coordinates of the corner of the rectangle. The (mouseDX, mouseDY)
+                 * coordinates represent the position of the mouse at the moment the button is pressed and
+                 * don't normally change (except during certain adjustments). In determining (mouseDX, mouseDY),
+                 * they is calculated as if the user began to drag the rectangle at the very corner,
+                 * though in reality they could have clicked anywhere.
+                 */
+                double x0 = tmp.x - mouseDX;
+                double y0 = tmp.y - mouseDY;
+                double dx = drawnShape.getWidth();
+                double dy = drawnShape.getHeight();
+                final double oldWidth  = dx;
+                final double oldHeight = dy;
+                /*
+                 * Deals with cases where, instead of dragging the rectangle,
+                 * the user is in the process of resizing it.
+                 */
+                switch (adjustingLogicalSides & (EAST | WEST)) {
+                    case WEST: {
+                        if (x0 < xmin) {
+                            x0 = xmin;
+                        }
+                        dx += drawnShape.getX() - x0;
+                        if (!(dx > 0)) {
+                            dx = drawnShape.getWidth();
+                            x0 = drawnShape.getX();
+                        }
+                        break;
+                    }
+                    case EAST: {
+                        dx += x0 - (x0 = drawnShape.getX());
+                        final double limit = xmax - x0;
+                        if (dx > limit) {
+                            dx = limit;
+                        }
+                        if (!(dx > 0)) {
+                            dx = drawnShape.getWidth();
+                            x0 = drawnShape.getX();
+                        }
+                        break;
+                    }
+                }
+                switch (adjustingLogicalSides & (NORTH | SOUTH)) {
+                    case NORTH: {
+                        if (y0 < ymin) {
+                            y0 = ymin;
+                        }
+                        dy += drawnShape.getY() - y0;
+                        if (!(dy > 0)) {
+                            dy = drawnShape.getHeight();
+                            y0 = drawnShape.getY();
+                        }
+                        break;
+                    }
+                    case SOUTH: {
+                        dy += y0 - (y0 = drawnShape.getY());
+                        final double limit = ymax - y0;
+                        if (dy > limit) dy = limit;
+                        if (!(dy > 0)) {
+                            dy = drawnShape.getHeight();
+                            y0 = drawnShape.getY();
+                        }
+                        break;
+                    }
+                }
+                /*
+                 * The (x0, y0, dx, dy) coordinates now give the new position and size of the
+                 * rectangle. But, before making the change, check whether only one edge was
+                 * being adjusted. If so, we cancel the changes with respect to the other edge
+                 * (if not, the user could move the rectangle vertically at the same time as
+                 * adjusting its right or left edge, which is not at all practical...)
+                 */
+                if ((adjustingLogicalSides & (NORTH | SOUTH)) != 0 &&
+                    (adjustingLogicalSides & (EAST  |  WEST)) == 0)
+                {
+                    x0 = drawnShape.getX();
+                    dx = drawnShape.getWidth();
+                }
+                if ((adjustingLogicalSides & (NORTH | SOUTH)) == 0 &&
+                    (adjustingLogicalSides & (EAST  |  WEST)) != 0)
+                {
+                    y0 = drawnShape.getY();
+                    dy = drawnShape.getHeight();
+                }
+                /*
+                 * If the user didn't adjusted any side, then make sure that the logical size
+                 * is conserved (i.e. discard the "drawing" size if it was different).
+                 */
+                if (adjustingLogicalSides == 0) {
+                    final double old_dx = logicalShape.getWidth();
+                    final double old_dy = logicalShape.getHeight();
+                    x0 += (dx - old_dx)/2;
+                    y0 += (dy - old_dy)/2;
+                    dx = old_dx;
+                    dy = old_dy;
+                }
+                /*
+                 * Modifies the rectangle coordinates and notifies that the component needs redrawing.
+                 * Note: `repaint` should be invoked before and after `setFrame` because the coordinates changed.
+                 */
+                source.repaint(x, y, width, height);
+                try {
+                    setFrame(x0, y0, dx, dy);
+                } catch (RuntimeException exception) {
+                    Logging.unexpectedException(null, MouseReshapeTracker.class, "mouseDragged", exception);
+                }
+                source.repaint(x, y, width, height);
+                /*
+                 * Adjustment for special cases.
+                 */
+                if ((adjustingLogicalSides & EAST) != 0) {
+                    mouseDX += (drawnShape.getWidth() - oldWidth);
+                }
+                if ((adjustingLogicalSides & SOUTH) != 0) {
+                    mouseDY += (drawnShape.getHeight() - oldHeight);
+                }
+            } catch (NoninvertibleTransformException exception) {
+                // Ignore.
+            }
+        }
+    }
+
+    /**
+     * Invoked when the user releases the mouse button. The default
+     * implementation calls {@link #stateChanged} with the argument {@code false}, in
+     * order to inform the derived classes that the changes are finished.
+     */
+    @Override
+    public void mouseReleased(final MouseEvent event) {
+        if (isDragging && (event.getModifiersEx() & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+            isDragging = false;
+            final Component source = event.getComponent();
+            try {
+                tmp.x = event.getX();
+                tmp.y = event.getY();
+                mouseOverRect = drawnShape.contains(transform.inverseTransform(tmp, tmp));
+                if (!mouseOverRect && source != null) source.setCursor(null);
+                event.consume();
+            } catch (NoninvertibleTransformException exception) {
+                // Ignore this exception.
+            } try {
+                // It is essential that `isDragging == false`.
+                fireStateChanged();
+            } catch (RuntimeException exception) {
+                ExceptionMonitor.show(source, exception);
+            }
+        }
+    }
+
+    /**
+     * Invoked <strong>before</strong> the position or the size of the visor has changed.
+     * A call to {@code stateWillChange} is normally followed by a call to {@link #stateChanged}, <u>except</u>
+     * if the expected change didn't ultimately occur. The derived classes can redefine this method to take the
+     * necessary actions when a change is on the point of being actioned. They must not, however, call any method
+     * which risks modifying the state of this object.  The default implementation does nothing.
+     *
+     * @param isAdjusting {@code true} if the user is still modifying the position of the visor,
+     *        {@code false} if they have released the mouse button.
+     */
+    protected void stateWillChange(final boolean isAdjusting) {
+    }
+
+    /**
+     * Invoked <strong>after</strong> the position and size of the visor has changed.
+     * The call to {@code stateChanged} must have been preceded by a call to {@link #stateWillChange}.
+     * The derived classes can redefine this method to take the necessary actions when a change has just been actioned.
+     * They must not, however, call any method which risks modifying the state of this object.
+     * The default implementation does nothing.
+     *
+     * @param isAdjusting {@code true} if the user is still modifying the position of the visor,
+     *        {@code false} if they have released the mouse button.
+     */
+    protected void stateChanged(final boolean isAdjusting) {
+    }
+
+    /**
+     * Invoked before the position or the size of the visor has changed.
+     */
+    private void fireStateWillChange() {
+        stateWillChange(isDragging);
+    }
+
+    /**
+     * Invoked after the position or the size of the visor has changed.
+     */
+    private void fireStateChanged() {
+        updateEditors();
+        stateChanged(isDragging);
+    }
+
+    /**
+     * Updates the text in the editors. Each editor added by the method {@link #addEditor} will have its text
+     * reformatted. This method can be invoked, for example, after changing the format used by the editors.
+     * It is not necessary to call this method each time the mouse moves; it is done automatically.
+     */
+    public void updateEditors() {
+        if (editors != null) {
+            for (int i = 0; i < editors.length; i++) {
+                editors[i].updateText();
+            }
+        }
+    }
+
+    /**
+     * Adds an editor in which the user can explicitly specify the coordinates of one of the edges of the rectangle.
+     * Each time the user drags the rectangle, the text appearing in this editor will automatically be updated.
+     * If the user explicitly enters a new value in this editor, the position of the rectangle will be adjusted.
+     *
+     * @param  format  format to use for parsing and formatting the values in the editor.
+     * @param  side    edge of the rectangle whose coordinates will be controlled by the editor.
+     *                 It should be one of the following constants:
+     *
+     * <table class="sis">
+     *   <tr>
+     *     <td>{@link SwingConstants#NORTH_WEST}</td>
+     *     <td>{@link SwingConstants#NORTH}</td>
+     *     <td>{@link SwingConstants#NORTH_EAST}</td>
+     *   </tr><tr>
+     *     <td>{@link SwingConstants#WEST}</td>
+     *     <td></td>
+     *     <td>{@link SwingConstants#EAST}</td>
+     *   </tr><tr>
+     *     <td>{@link SwingConstants#SOUTH_WEST}</td>
+     *     <td>{@link SwingConstants#SOUTH}</td>
+     *     <td>{@link SwingConstants#SOUTH_EAST}</td>
+     *   </tr></table>
+     *
+     * @param  toRepaint  component to repaint after a field has been edited, or {@code null} if there isn't one.
+     * @return an editor in which the user can specify the position of one of the edges of the geometric shape.
+     * @throws IllegalArgumentException if {@code side} isn't one of the recognized codes.
+     */
+    public synchronized JComponent addEditor(final Format format, final int side, Component toRepaint)
+            throws IllegalArgumentException
+    {
+        final JComponent       component;
+        final JFormattedTextField editor;
+        if (format instanceof DecimalFormat) {
+            final SpinnerNumberModel   model = new SpinnerNumberModel();
+            final JSpinner           spinner = new JSpinner(model);
+            final JSpinner.NumberEditor sedt = (JSpinner.NumberEditor) spinner.getEditor();
+            final DecimalFormat targetFormat = sedt.getFormat();
+            final DecimalFormat sourceFormat = (DecimalFormat) format;
+            // TODO: Next line would be much more efficient if only we had a
+            // NumberEditor.setFormat(NumberFormat) method (See RFE #4520587)
+            targetFormat.setDecimalFormatSymbols(sourceFormat.getDecimalFormatSymbols());
+            targetFormat.applyPattern(sourceFormat.toPattern());
+            editor = sedt.getTextField();
+            component = spinner;
+        } else if (format instanceof SimpleDateFormat) {
+            final SpinnerDateModel        model = new SpinnerDateModel();
+            final JSpinner              spinner = new JSpinner(model);
+            final JSpinner.DateEditor      sedt = (JSpinner.DateEditor) spinner.getEditor();
+            final SimpleDateFormat targetFormat = sedt.getFormat();
+            final SimpleDateFormat sourceFormat = (SimpleDateFormat) format;
+            // TODO: Next line would be much more efficient if only we had a
+            // DateEditor.setFormat(DateFormat) method... (See RFE #4520587)
+            targetFormat.setDateFormatSymbols(sourceFormat.getDateFormatSymbols());
+            targetFormat.applyPattern(sourceFormat.toPattern());
+            editor = sedt.getTextField();
+            component = spinner;
+        } else {
+            component = editor = new JFormattedTextField(format);
+        }
+        /*
+         * "9" is the default width of text fields. These widths are expressed in number of columns.
+         * Swing does not appear to measure these widths very accurately; it seems to provide more
+         * than requested. For that reason, we specify a narrower width.
+         */
+        editor.setColumns(5);
+        editor.setHorizontalAlignment(JTextField.RIGHT);
+        Insets insets = editor.getMargin();
+        insets.right += 2;
+        editor.setMargin(insets);
+        /*
+         * Adds the editor to the list of editors to control. Increasing the `editors` array length
+         * each time is not a very efficient strategy, but it will do because it is unlikely that we
+         * will ever add more than 4 editors.
+         */
+        final Control control = new Control(editor, (format instanceof DateFormat),
+                                            convertSwingConstant(side), toRepaint);
+        if (editors == null) {
+            editors = new Control[1];
+        } else {
+            editors = Arrays.copyOf(editors, editors.length + 1);
+        }
+        editors[editors.length - 1] = control;
+        return component;
+    }
+
+    /**
+     * Removes an editor from the list of component showing coordinates.
+     *
+     * @param  editor  editor to remove.
+     */
+    public synchronized void removeEditor(final JComponent editor) {
+        if (editors != null) {
+            for (int i = 0; i < editors.length; i++) {
+                if (editors[i].editor == editor) {
+                    editors = ArraysExt.remove(editors, i, 1);
+                    /*
+                     * In principle, there should be no more objects to remove from the table.
+                     * But we let the loop continue anyway, just in case.
+                     */
+                }
+            }
+            if (editors.length == 0) {
+                editors = null;
+            }
+        }
+    }
+
+    /**
+     * When the position of one of the rectangle's edges is edited manually, specifies whether
+     * the opposite edge should also be adjusted. By default, the edges are not synchronized.
+     *
+     * @param  axis {@link SwingConstants#HORIZONTAL} to change the synchronization of the left
+     *         and right edges, or {@link SwingConstants#VERTICAL} to change the synchronization
+     *         of the top and bottom edges.
+     * @param  state {@code true} to synchronize the edges, or {@code false} to desynchronize.
+     * @throws IllegalArgumentException if {@code axis} isn't one of the valid codes.
+     */
+    public void setEditorsSynchronized(final int axis, final boolean state)
+            throws IllegalArgumentException
+    {
+        switch (axis) {
+            case SwingConstants.HORIZONTAL: synchronizeX = state; break;
+            case SwingConstants.VERTICAL:   synchronizeY = state; break;
+            default: throw new IllegalArgumentException();
+        }
+    }
+
+    /**
+     * When the position of one of the rectangle's edges is edited manually, specifies whether
+     * the opposite edge should also be adjusted. By default, the edges are not synchronized.
+     *
+     * @param  axis {@link SwingConstants#HORIZONTAL} to determine the synchronization of the left
+     *         and right edges, or {@link SwingConstants#VERTICAL} to determine the synchronization
+     *         of the top and bottom edges.
+     * @return {@code true} if the specified edges are synchronized, or {@code false} if not
+     * @throws IllegalArgumentException if {@code axis} isn't one of the valid codes.
+     */
+    public boolean isEditorsSynchronized(final int axis) throws IllegalArgumentException {
+        switch (axis) {
+            case SwingConstants.HORIZONTAL: return synchronizeX;
+            case SwingConstants.VERTICAL:   return synchronizeY;
+            default: throw new IllegalArgumentException();
+        }
+    }
+
+    /**
+     * Returns a character string representing this object.
+     */
+    @Override
+    public String toString() {
+        return Classes.getShortClassName(this) + '[' + Classes.getShortClassName(logicalShape) + ']';
+    }
+
+    /**
+     * Synchronizes one of the rectangle's edges with a text field. Each time the visor moves, the text will be
+     * updated. If, on the contrary, it is the text which is manually edited, the visor will be repositioned.
+     */
+    private final class Control implements PropertyChangeListener {
+        /**
+         * Text field representing the coordinate of one of the visor's edges.
+         */
+        public final JFormattedTextField editor;
+
+        /**
+         * {@code true} if the field {@link #editor} formats dates,
+         * or {@code false} if it formats numbers.
+         */
+        private final boolean isDate;
+
+        /**
+         * Side of the rectangle to be controlled. This field designates the edge which is visible on screen.
+         * For example, {@code NORTH} always designates the top edge on the screen. However, this could correspond
+         * to another edge of the logical shape {@link MouseReshapeTracker} depending on the affine transform that
+         * was specified during the last call to {@link MouseReshapeTracker#setTransform}.
+         * For example, {@code AffineTransform.getScaleInstance(+1,-1)} has the effect of inverting
+         * the <var>y</var> axis so that the <var>y</var><sub>max</sub> values appear to the North
+         * rather than the <var>y</var><sub>min</sub> values.
+         */
+        private final int side;
+
+        /**
+         * Component to repaint after the field is edited, or {@code null} if there isn't one.
+         */
+        private final Component toRepaint;
+
+        /**
+         * Constructs an object which will control one of the rectangle's edges.
+         *
+         * @param editor     field which will contain the coordinate of the rectangle's edge.
+         * @param isDate     {@code true} if the field {@link #editor} formats dates, or {@code false} if it formats numbers.
+         * @param side       edge of the rectangle to control. This argument designates the edge visible on screen.
+         * @param toRepaint  component to repaint after the field has been edited, or {@code null} if there isn't one.
+         */
+        public Control(final JFormattedTextField editor, final boolean isDate,
+                       final int side, final Component toRepaint)
+        {
+            this.editor    = editor;
+            this.isDate    = isDate;
+            this.side      = side;
+            this.toRepaint = toRepaint;
+            updateText(editor);
+            editor.addPropertyChangeListener("value", this);
+        }
+
+        /**
+         * Invoked each time the value in the editor changes.
+         */
+        @Override
+        public void propertyChange(final PropertyChangeEvent event) {
+            final Object source = event.getSource();
+            if (source instanceof JFormattedTextField) {
+                final JFormattedTextField editor = (JFormattedTextField) source;
+                final Object value = editor.getValue();
+                if (value != null) {
+                    final double v = (value instanceof Date)       ?
+                                     ((Date) value).getTime()      :
+                                     ((Number) value).doubleValue();
+                    if (!Double.isNaN(v)) {
+                        /*
+                         * Obtains the new coordinates of the rectangle, taking into account the coordinates
+                         * changed by the user as well as the old coordinates which have not changed.
+                         */
+                        final int side = inverseTransform(this.side);
+                        double Vxmin = (side &  WEST) == 0 ? logicalShape.getMinX() : v;
+                        double Vxmax = (side &  EAST) == 0 ? logicalShape.getMaxX() : v;
+                        double Vymin = (side & NORTH) == 0 ? logicalShape.getMinY() : v;
+                        double Vymax = (side & SOUTH) == 0 ? logicalShape.getMaxY() : v;
+                        if (synchronizeX || Vxmin > Vxmax) {
+                            final double dx = logicalShape.getWidth();
+                            if ((side & WEST) != 0) Vxmax = Vxmin + dx;
+                            if ((side & EAST) != 0) Vxmin = Vxmax - dx;
+                        }
+                        if (synchronizeY || Vymin > Vymax) {
+                            final double dy = logicalShape.getHeight();
+                            if ((side & NORTH) != 0) Vymax = Vymin + dy;
+                            if ((side & SOUTH) != 0) Vymin = Vymax - dy;
+                        }
+                        /*
+                         * Checks whether the new coordinates need a clip adjustment. If so, we ask
+                         * the method clipChangeRequested(...) to make the change. That method doesn't
+                         * have to accept the change. The rest of the code will be correct even if
+                         * the clip hasn't changed - in that case the position of the rectangle will
+                         * still be adjusted by setFrame(...).
+                         */
+                        if (Vxmin < xmin) {
+                            final double dx = Math.max(xmax - xmin, MINSIZE_RATIO * (Vxmax - Vxmin));
+                            final double margin = Vxmax + dx * ((MINSIZE_RATIO - 1) * 0.5);
+                            clipChangeRequested(margin - dx, margin, ymin, ymax);
+                        } else if (Vxmax > xmax) {
+                            final double dx = Math.max(xmax - xmin, MINSIZE_RATIO * (Vxmax - Vxmin));
+                            final double margin = Vxmin-dx * ((MINSIZE_RATIO - 1) * 0.5);
+                            clipChangeRequested(margin, margin + dx, ymin, ymax);
+                        }
+                        if (Vymin < ymin) {
+                            final double dy = Math.max(ymax - ymin, MINSIZE_RATIO * (Vymax - Vymin));
+                            final double margin = Vymax + dy * ((MINSIZE_RATIO - 1) * 0.5);
+                            clipChangeRequested(xmin, xmax, margin - dy, margin);
+                        } else if (Vymax > ymax) {
+                            final double dy = Math.max(ymax - ymin, MINSIZE_RATIO * (Vymax - Vymin));
+                            final double margin = Vymin - dy * ((MINSIZE_RATIO - 1) * 0.5);
+                            clipChangeRequested(xmin, xmax, margin, margin + dy);
+                        }
+                        /*
+                         * Repositions the rectangle based on the new coordinates.
+                         */
+                        if (setFrame(Vxmin, Vymin, Vxmax - Vxmin, Vymax - Vymin)) {
+                            if (toRepaint != null) toRepaint.repaint();
+                        }
+                    }
+                }
+                updateText(editor);
+            }
+        }
+
+        /**
+         * Invoked when the rectangle position is adjusted. This method adjusts
+         * the value shown in the text field based on the position of the rectangle.
+         */
+        private void updateText(final JFormattedTextField editor) {
+            String text;
+            if (!logicalShape.isEmpty() ||
+                ((text = editor.getText()) != null && !text.trim().isEmpty()))
+            {
+                double value;
+                switch (inverseTransform(side)) {
+                    case NORTH: value = logicalShape.getMinY(); break;
+                    case SOUTH: value = logicalShape.getMaxY(); break;
+                    case  WEST: value = logicalShape.getMinX(); break;
+                    case  EAST: value = logicalShape.getMaxX(); break;
+                    default   : return;
+                }
+                editor.setValue(isDate ? new Date(Math.round(value)) : Double.valueOf(value));
+            }
+        }
+
+        /**
+         * Updates the text which appears in {@link #editor}
+         * based on the current position of the rectangle.
+         */
+        public void updateText() {
+            updateText(editor);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/MouseSelectionTracker.java b/src/main/java/org/apache/sis/swing/MouseSelectionTracker.java
new file mode 100644
index 0000000..96754c2
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/MouseSelectionTracker.java
@@ -0,0 +1,366 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Shape;
+import java.awt.Rectangle;
+import java.awt.geom.Line2D;
+import java.awt.geom.Point2D;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.geom.RoundRectangle2D;
+import java.awt.geom.RectangularShape;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.NoninvertibleTransformException;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics2D;
+import java.awt.event.MouseEvent;
+import javax.swing.event.MouseInputAdapter;
+
+
+/**
+ * Controller which allows the user to select a region of a component.
+ * The user must click on a point in the component, then drag the mouse pointer whilst keeping the button pressed.
+ * During the dragging, the shape which is drawn is usually a rectangle. But other shapes can be used such as,
+ * for example, an ellipse.
+ * To use this class, it is necessary to create a derived class which defines the following methods:
+ *
+ * <ul>
+ *   <li>{@link #selectionPerformed(int, int, int, int)} (mandatory)</li>
+ *   <li>{@link #getModel} (optional)</li>
+ * </ul>
+ *
+ * This controller should then be registered with one, and only one, component using the following syntax:
+ *
+ * {@preformat java
+ *     Component component = ...
+ *     MouseSelectionTracker control = ...
+ *     component.addMouseListener(control);
+ * }
+ *
+ * @author  Martin Desruisseaux (MPO, IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public abstract class MouseSelectionTracker extends MouseInputAdapter {
+    /**
+     * Stippled rectangle representing the region which the user is currently selecting.
+     * This rectangle can be empty. These coordinates are only significant in the period
+     * between the user pressing the mouse button and then releasing it to outline a region.
+     * Conventionally, the {@code null} value indicates that a line should be used instead
+     * of a rectangular shape. The coordinates are always expressed in pixels.
+     */
+    private transient RectangularShape mouseSelectedArea;
+
+    /**
+     * Color to replace during XOR drawings on a graphic.
+     * This color is specified in {@link Graphics2D#setColor(Color)}.
+     */
+    private Color backXORColor = Color.white;
+
+    /**
+     * Color to replace with during the XOR drawings on a graphic.
+     * This color is specified in {@link Graphics2D#setXORMode(Color)}.
+     */
+    private Color lineXORColor = Color.black;
+
+    /**
+     * <var>x</var> coordinate of the mouse when the button is pressed.
+     */
+    private transient int ox;
+
+    /**
+     * <var>y</var> coordinate of the mouse when the button is pressed.
+     */
+    private transient int oy;
+
+    /**
+     * <var>x</var> coordinate of the mouse during the last drag.
+     */
+    private transient int px;
+
+    /**
+     * <var>y</var> coordinate of the mouse during the last drag.
+     */
+    private transient int py;
+
+    /**
+     * Indicates whether a selection is underway.
+     */
+    private transient boolean isDragging;
+
+    /**
+     * Constructs an object which will allow rectangular regions to be selected using the mouse.
+     */
+    public MouseSelectionTracker() {
+    }
+
+    /**
+     * Specifies the colors to be used for drawing the outline of a box when the user selects a region.
+     * All {@code a} colors will be replaced by {@code b} colors and vice versa.
+     *
+     * @param  a  the color to be replaced by color <var>b</var>.
+     * @param  b  the color replacing the color <var>a</var>.
+     */
+    public void setXORColors(final Color a, final Color b) {
+        backXORColor = a;
+        lineXORColor = b;
+    }
+
+    /**
+     * Returns the geometric shape to use for marking the boundaries of a region. The shape is usually a
+     * rectangle but could also be an ellipse or other {@linkplain RectangularShape rectangular shapes}.
+     * The coordinates of the returned shape will not be taken into account. In fact, these coordinates
+     * will regularly be discarded. Only the class of the returned shape matter (for example,
+     * {@link Ellipse2D} vs {@link Rectangle2D}) and their parameters which are not related to their position
+     * (for example, the {@linkplain RoundRectangle2D#getArcWidth() arc size} of a rectangle with rounded corners).
+     *
+     * <p>The shape returned will usually be an instance of a class derived from {@link RectangularShape},
+     * but could also be an instance of the {@link Line2D} class.
+     * <strong>Any other class risks throwing a {@link ClassCastException} when executed</strong>.</p>
+     *
+     * <p>The default implementation always returns an instance of {@link Rectangle}.</p>
+     *
+     * @param  event  mouse coordinate when the button is pressed. This information can be used by subclasses
+     *         overriding this method if the mouse location is relevant to the choice of a geometric shape.
+     * @return shape from the class {@link RectangularShape} or {@link Line2D}, or {@code null}
+     *         to indicate that we do not want to make a selection.
+     */
+    protected Shape getModel(final MouseEvent event) {
+        return new Rectangle();
+    }
+
+    /**
+     * Method which is automatically invoked after the user selects a region with the mouse.
+     * All coordinates passed in as parameters are expressed in pixels.
+     *
+     * @param  ox  <var>x</var> coordinate of the mouse when the user pressed the mouse button.
+     * @param  oy  <var>y</var> coordinate of the mouse when the user pressed the mouse button.
+     * @param  px  <var>x</var> coordinate of the mouse when the user released the mouse button.
+     * @param  py  <var>y</var> coordinate of the mouse when the user released the mouse button.
+     */
+    protected abstract void selectionPerformed(int ox, int oy, int px, int py);
+
+    /**
+     * Returns the geometric shape surrounding the last region to be selected by the user.
+     * An optional affine transform can be specified to convert the region selected by the user
+     * into logical coordinates. The class of the shape returned depends on the model returned
+     * by {@link #getModel}:
+     *
+     * <ul>
+     *   <li>If the model is an instance of {@link Line2D} (which means that this
+     *       {@code MouseSelectionTracker} only draws a line between points),
+     *       the object returned will belong to the {@link Line2D} class.</li>
+     *   <li>Otherwise the object returned is usually (but not necessarily) an instance of the same class,
+     *       usually {@link Rectangle2D}. There could be situations where the returned object is an instance
+     *       of an another class, for example if the affine transform performs a rotation.</li>
+     * </ul>
+     *
+     * @param  transform  affine transform which converts logical coordinates into pixel coordinates.
+     *         This is usually the same transform than the one used for drawing in a {@link java.awt.Graphics2D} object.
+     * @return a geometric shape enclosing the last region to be selected by the user,
+     *         or {@code null} if no selection has yet been made.
+     * @throws NoninvertibleTransformException if the affine transform can not be inverted.
+     */
+    public Shape getSelectedArea(final AffineTransform transform) throws NoninvertibleTransformException {
+        if (ox == px && oy == py) {
+            return null;
+        }
+        RectangularShape shape = mouseSelectedArea;
+        if (transform != null && !transform.isIdentity()) {
+            if (shape == null) {
+                final Point2D.Float po = new Point2D.Float(ox, oy);
+                final Point2D.Float pp = new Point2D.Float(px, py);
+                transform.inverseTransform(po, po);
+                transform.inverseTransform(pp, pp);
+                return new Line2D.Float(po, pp);
+            } else {
+                if (canReshape(shape, transform)) {
+                    final Point2D.Double point = new Point2D.Double();
+                    double xmin = Double.POSITIVE_INFINITY;
+                    double ymin = Double.POSITIVE_INFINITY;
+                    double xmax = Double.NEGATIVE_INFINITY;
+                    double ymax = Double.NEGATIVE_INFINITY;
+                    for (int i = 0; i < 4; i++) {
+                        point.x = (i&1) == 0 ? shape.getMinX() : shape.getMaxX();
+                        point.y = (i&2) == 0 ? shape.getMinY() : shape.getMaxY();
+                        transform.inverseTransform(point, point);
+                        if (point.x < xmin) xmin = point.x;
+                        if (point.x > xmax) xmax = point.x;
+                        if (point.y < ymin) ymin = point.y;
+                        if (point.y > ymax) ymax = point.y;
+                    }
+                    if (shape instanceof Rectangle) {
+                        return new Rectangle2D.Float((float) xmin,
+                                                     (float) ymin,
+                                                     (float) (xmax - xmin),
+                                                     (float) (ymax - ymin));
+                    } else {
+                        shape = (RectangularShape) shape.clone();
+                        shape.setFrame(xmin, ymin, xmax - xmin, ymax - ymin);
+                        return shape;
+                    }
+                } else {
+                    return transform.createInverse().createTransformedShape(shape);
+                }
+            }
+        } else {
+            return (shape != null) ? (Shape) shape.clone() : new Line2D.Float(ox, oy, px, py);
+        }
+    }
+
+    /**
+     * Indicates whether we can transform {@code shape} simply by calling its
+     * {@code shape.setFrame(...)} method rather than by using the heavy artillery
+     * that is the {@code transform.createTransformedShape(shape)} method.
+     */
+    private static boolean canReshape(final RectangularShape shape, final AffineTransform transform) {
+        final int type=transform.getType();
+        if ((type & AffineTransform.TYPE_GENERAL_TRANSFORM) != 0) return false;
+        if ((type & AffineTransform.TYPE_MASK_ROTATION)     != 0) return false;
+        if ((type & AffineTransform.TYPE_FLIP)              != 0) {
+            if (shape instanceof Rectangle2D)      return true;
+            if (shape instanceof Ellipse2D)        return true;
+            if (shape instanceof RoundRectangle2D) return true;
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Returns a {@link Graphics2D} object to be used for drawing in the specified component.
+     * We must not forget to call {@link Graphics2D#dispose} when the graphics object is no longer needed.
+     */
+    private Graphics2D getGraphics(final Component c) {
+        final Graphics2D graphics = (Graphics2D) c.getGraphics();
+        graphics.setXORMode(lineXORColor);
+        graphics.setColor  (backXORColor);
+        return graphics;
+    }
+
+    /**
+     * Notifies this controller that the mouse button has been pressed. The default implementation
+     * memorizes the mouse coordinate (which will become one of the corners of the future rectangle
+     * to be drawn) and prepares this {@code MouseSelectionTracker} to observe the mouse movements.
+     *
+     * @param  event  contains mouse coordinates where the button has been pressed.
+     * @throws ClassCastException if {@link #getModel} doesn't return a shape
+     *         from the class {@link RectangularShape} or {@link Line2D}.
+     */
+    @Override
+    public void mousePressed(final MouseEvent event) throws ClassCastException {
+        if (!event.isConsumed() && (event.getModifiersEx() & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+            final Component source = event.getComponent();
+            if (source != null) {
+                Shape model = getModel(event);
+                if (model != null) {
+                    isDragging = true;
+                    ox = px = event.getX();
+                    oy = py = event.getY();
+                    if (model instanceof Line2D) {
+                        model = null;
+                    }
+                    mouseSelectedArea = (RectangularShape) model;
+                    if (mouseSelectedArea != null) {
+                        mouseSelectedArea.setFrame(ox, oy, 0, 0);
+                    }
+                    source.addMouseMotionListener(this);
+                }
+                source.requestFocus();
+                event.consume();
+            }
+        }
+    }
+
+    /**
+     * Notifies this controller that the mouse has been dragged. The default implementation moves
+     * a corner of the rectangle used to select the region. The other corner remains fixed at the
+     * point where the mouse was at the moment it was {@linkplain #mousePressed pressed}.
+     *
+     * @param  event  contains mouse coordinates when the cursor is being dragged.
+     */
+    @Override
+    public void mouseDragged(final MouseEvent event) {
+        if (isDragging) {
+            final Graphics2D graphics = getGraphics(event.getComponent());
+            if (mouseSelectedArea == null) {
+                graphics.drawLine(ox, oy, px, py);
+                px = event.getX();
+                py = event.getY();
+                graphics.drawLine(ox, oy, px, py);
+            } else {
+                graphics.draw(mouseSelectedArea);
+                int xmin = this.ox;
+                int ymin = this.oy;
+                int xmax = px = event.getX();
+                int ymax = py = event.getY();
+                if (xmin > xmax) {
+                    final int xtmp = xmin;
+                    xmin = xmax; xmax = xtmp;
+                }
+                if (ymin > ymax) {
+                    final int ytmp = ymin;
+                    ymin = ymax; ymax = ytmp;
+                }
+                mouseSelectedArea.setFrame(xmin, ymin, xmax - xmin, ymax - ymin);
+                graphics.draw(mouseSelectedArea);
+            }
+            graphics.dispose();
+            event.consume();
+        }
+    }
+
+    /**
+     * Notifies this controller that the mouse button has been released. The default implementation invokes
+     * {@link #selectionPerformed(int, int, int, int)} with the bounds of the selected region as parameters.
+     *
+     * @param  event  contains mouse coordinates where the button has been released.
+     */
+    @Override
+    public void mouseReleased(final MouseEvent event) {
+        if (isDragging && (event.getButton() == MouseEvent.BUTTON1)) {
+            isDragging = false;
+            final Component component = event.getComponent();
+            component.removeMouseMotionListener(this);
+
+            final Graphics2D graphics = getGraphics(event.getComponent());
+            if (mouseSelectedArea == null) {
+                graphics.drawLine(ox, oy, px, py);
+            } else {
+                graphics.draw(mouseSelectedArea);
+            }
+            graphics.dispose();
+            px = event.getX();
+            py = event.getY();
+            selectionPerformed(ox, oy, px, py);
+            event.consume();
+        }
+    }
+
+    /**
+     * Notifies this controller that the mouse has been moved but not as a result of the user selecting a region.
+     * The default implementation notifies the source component that this {@code MouseSelectionTracker} is no
+     * longer interested in being informed about mouse movements.
+     *
+     * @param  event  contains mouse coordinates when the cursor is being moved.
+     */
+    @Override
+    public void mouseMoved(final MouseEvent event) {
+        event.getComponent().removeMouseMotionListener(this);
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/SwingUtilities.java b/src/main/java/org/apache/sis/swing/SwingUtilities.java
new file mode 100644
index 0000000..2acef9a
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/SwingUtilities.java
@@ -0,0 +1,420 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.UndeclaredThrowableException;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.EventQueue;
+import java.awt.SystemColor;
+import java.awt.event.ActionListener;
+import java.awt.event.WindowListener;
+import javax.swing.Action;
+import javax.swing.DefaultListModel;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+import javax.swing.JInternalFrame;
+import javax.swing.JOptionPane;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import javax.swing.UIManager;
+import javax.swing.table.TableColumn;
+import javax.swing.table.JTableHeader;
+import javax.swing.table.TableCellRenderer;
+import javax.swing.table.DefaultTableCellRenderer;
+import org.apache.sis.swing.internal.Resources;
+import org.apache.sis.util.Static;
+
+
+/**
+ * A collection of utility methods for Swing. Every {@code show*} methods delegate
+ * their work to the corresponding method in {@link JOptionPane}, with two differences:
+ *
+ * <ul>
+ *   <li>{@code SwingUtilities}'s method may be invoked from any thread.
+ *       If they are invoked from a non-Swing thread, execution will be delegate
+ *       to the Swing thread and the calling thread will block until completion.</li>
+ *   <li>If a parent component is a {@link javax.swing.JDesktopPane},
+ *       dialog will be rendered as internal frames instead of frames.</li>
+ * </ul>
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+final class SwingUtilities extends Static {
+    /**
+     * Do not allow any instance of this class to be created.
+     */
+    private SwingUtilities() {
+    }
+
+    /**
+     * Shows the given component in a {@link JFrame}.
+     * This is used (indirectly) mostly for debugging purpose.
+     *
+     * @param  panel  the panel to show.
+     * @param  title  the frame title.
+     */
+    public static void show(final JComponent panel, final String title) {
+        final JFrame frame = new JFrame(title);
+        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+        frame.add(panel);
+        frame.pack();
+        frame.setLocationRelativeTo(null);
+        frame.setVisible(true);
+    }
+
+    /**
+     * Brings up a "Ok/Cancel" dialog with no icon, using the installed window handler.
+     * In the default configuration, this will result in a call to the
+     * {@link #showOptionDialog(Component, Object, String)} method just below.
+     *
+     * @param  owner   the parent component. Dialog will apears on top of this owner.
+     * @param  dialog  the dialog content to show.
+     * @param  title   the title string for the dialog.
+     * @return {@code true} if user clicked "Ok", {@code false} otherwise.
+     */
+    public static boolean showDialog(final Component owner, final Component dialog, final String title) {
+        final WindowCreator.Handler handler;
+        if (owner instanceof WindowCreator) {
+            handler = ((WindowCreator) owner).getWindowHandler();
+        } else {
+            handler = WindowCreator.getDefaultWindowHandler();
+        }
+        return handler.showDialog(owner, dialog, title);
+    }
+
+    /**
+     * Brings up a "Ok/Cancel" dialog with no icon. This method can be invoked
+     * from any thread and blocks until the user click on "Ok" or "Cancel".
+     *
+     * @param  owner   the parent component. Dialog will apears on top of this owner.
+     * @param  dialog  the dialog content to show.
+     * @param  title   the title string for the dialog.
+     * @return {@code true} if user clicked "Ok", {@code false} otherwise.
+     */
+    public static boolean showOptionDialog(final Component owner, final Object dialog, final String title) {
+        return showOptionDialog(owner, dialog, title, null);
+    }
+
+    /**
+     * Brings up a "Ok/Cancel/Reset" dialog with no icon. This method can be invoked
+     * from any thread and blocks until the user click on "Ok" or "Cancel".
+     *
+     * @param  owner   the parent component. Dialog will apears on top of this owner.
+     * @param  dialog  the dialog content to show.
+     * @param  title   the title string for the dialog.
+     * @param  reset   action to execute when user press "Reset", or {@code null} if there is no "Reset" button.
+     *                 If {@code reset} is an instance of {@link Action}, the button label will be set according
+     *                 the action's properties.
+     * @return {@code true} if user clicked "Ok", {@code false} otherwise.
+     */
+    public static boolean showOptionDialog(final Component owner, final Object dialog, final String title, final ActionListener reset) {
+        /*
+         * Delegates to Swing thread if this method is invoked from an other thread.
+         */
+        if (!EventQueue.isDispatchThread()) {
+            final boolean[] result = new boolean[1];
+            invokeAndWait(() -> result[0] = showOptionDialog(owner, dialog, title, reset));
+            return result[0];
+        }
+        /*
+         * Constructs the buttons bar.
+         */
+        Object[]    options = null;
+        Object initialValue = null;
+        int okChoice = JOptionPane.OK_OPTION;
+        if (reset != null) {
+            final Resources resources = Resources.forLocale(owner!=null ? owner.getLocale() : null);
+            final JButton button;
+            if (reset instanceof Action) {
+                button = new JButton((Action)reset);
+            } else {
+                button = new JButton(resources.getString(Resources.Keys.Reset));
+                button.addActionListener(reset);
+            }
+            options = new Object[] {
+                resources.getString(Resources.Keys.Ok),
+                resources.getString(Resources.Keys.Cancel),
+                button
+            };
+            initialValue = options[okChoice=0];
+        }
+        /*
+         * Brings ups the dialog box.
+         */
+        final int choice;
+        if (JOptionPane.getDesktopPaneForComponent(owner)!=null) {
+            choice = JOptionPane.showInternalOptionDialog(
+                    owner,                         // Parent component
+                    dialog,                        // Message
+                    title,                         // Title of dialog box
+                    JOptionPane.OK_CANCEL_OPTION,  // Button to shown
+                    JOptionPane.PLAIN_MESSAGE,     // Message type
+                    null,                          // Icon
+                    options,                       // Button list
+                    initialValue);                 // Default button
+        } else {
+            choice = JOptionPane.showOptionDialog(
+                    owner,                         // Parent component
+                    dialog,                        // Message
+                    title,                         // Title of dialog box
+                    JOptionPane.OK_CANCEL_OPTION,  // Button to shown
+                    JOptionPane.PLAIN_MESSAGE,     // Message type
+                    null,                          // Icon
+                    options,                       // Button list
+                    initialValue);                 // Default button
+        }
+        return choice == okChoice;
+    }
+
+    /**
+     * Brings up a message dialog with a "Ok" button. This method can be invoked
+     * from any thread and blocks until the user click on "Ok".
+     *
+     * @param  owner    the parent component. Dialog will apears on top of this owner.
+     * @param  message  the dialog content to show.
+     * @param  title    the title string for the dialog.
+     * @param  type     the message type
+     *                ({@link JOptionPane#ERROR_MESSAGE},
+     *                 {@link JOptionPane#INFORMATION_MESSAGE},
+     *                 {@link JOptionPane#WARNING_MESSAGE},
+     *                 {@link JOptionPane#QUESTION_MESSAGE} or
+     *                 {@link JOptionPane#PLAIN_MESSAGE}).
+     */
+    public static void showMessageDialog(final Component owner, final Object message, final String title, final int type) {
+        if (!EventQueue.isDispatchThread()) {
+            invokeAndWait(() -> showMessageDialog(owner, message, title, type));
+            return;
+        }
+        if (JOptionPane.getDesktopPaneForComponent(owner)!=null) {
+            JOptionPane.showInternalMessageDialog(
+                    owner,     // Parent component
+                    message,   // Message
+                    title,     // Title of dialog box
+                    type);     // Message type
+        } else {
+            JOptionPane.showMessageDialog(
+                    owner,     // Parent component
+                    message,   // Message
+                    title,     // Title of dialog box
+                    type);     // Message type
+        }
+    }
+
+    /**
+     * Brings up a confirmation dialog with "Yes/No" buttons. This method can be
+     * invoked from any thread and blocks until the user click on "Yes" or "No".
+     *
+     * @param  owner    the parent component. Dialog will apears on top of this owner.
+     * @param  message  the dialog content to show.
+     * @param  title    the title string for the dialog.
+     * @param  type     the message type
+     *                ({@link JOptionPane#ERROR_MESSAGE},
+     *                 {@link JOptionPane#INFORMATION_MESSAGE},
+     *                 {@link JOptionPane#WARNING_MESSAGE},
+     *                 {@link JOptionPane#QUESTION_MESSAGE} or
+     *                 {@link JOptionPane#PLAIN_MESSAGE}).
+     * @return {@code true} if user clicked on "Yes", {@code false} otherwise.
+     */
+    public static boolean showConfirmDialog(final Component owner, final Object message, final String title, final int type) {
+        if (!EventQueue.isDispatchThread()) {
+            final boolean[] result = new boolean[1];
+            invokeAndWait(() -> result[0] = showConfirmDialog(owner, message, title, type));
+            return result[0];
+        }
+        final int choice;
+        if (JOptionPane.getDesktopPaneForComponent(owner)!=null) {
+            choice = JOptionPane.showInternalConfirmDialog(
+                    owner,                           // Parent component
+                    message,                         // Message
+                    title,                           // Title of dialog box
+                    JOptionPane.YES_NO_OPTION,       // Button to shown
+                    type);                           // Message type
+        } else {
+            choice = JOptionPane.showConfirmDialog(
+                    owner,                           // Parent component
+                    message,                         // Message
+                    title,                           // Title of dialog box
+                    JOptionPane.YES_NO_OPTION,       // Button to shown
+                    type);                           // Message type
+        }
+        return choice == JOptionPane.YES_OPTION;
+    }
+
+    /**
+     * Setups the given table for usage as row-header.
+     * This method setups the background color to the same one than the column headers.
+     *
+     * <div class="note"><b>Note:</b> in a previous version, we were assigning to the row headers
+     * the same cell renderer than the one created by <cite>Swing</cite> for the column headers.
+     * But it produced strange effects when the L&amp;F uses a vertical gradient instead than a uniform color.</div>
+     *
+     * @param  table  the table to setup as row headers.
+     * @return the renderer which has been assigned to the table.
+     */
+    public static TableCellRenderer setupAsRowHeader(final JTable table) {
+        final JTableHeader header = table.getTableHeader();
+        Color background = header.getBackground();
+        Color foreground = header.getForeground();
+        if (background == null || background.equals(table.getBackground())) {
+            if (!SystemColor.control.equals(background)) {
+                background = SystemColor.control;
+                foreground = SystemColor.controlText;
+            } else {
+                final Locale locale = table.getLocale();
+                background = UIManager.getColor("Label.background", locale);
+                foreground = UIManager.getColor("Label.foreground", locale);
+            }
+        }
+        final DefaultTableCellRenderer renderer = new DefaultTableCellRenderer();
+        renderer.setBackground(background);
+        renderer.setForeground(foreground);
+        renderer.setHorizontalAlignment(DefaultTableCellRenderer.RIGHT);
+        final TableColumn column = table.getColumnModel().getColumn(0);
+        column.setCellRenderer(renderer);
+        column.setPreferredWidth(60);
+        table.setPreferredScrollableViewportSize(table.getPreferredSize());
+        table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+        table.setCellSelectionEnabled(false);
+        return renderer;
+    }
+
+    /**
+     * Removes the given elements from the given list. This method tries to use
+     * {@link DefaultListModel#removeRange} when possible in order to group events together.
+     *
+     * <p><strong>Warning:</strong> this method override the given {@code indices} array.</p>
+     *
+     * @param  list     the list from which to remove elements.
+     * @param  indices  the index of elements to remove.
+     */
+    public static void remove(final DefaultListModel<?> list, final int[] indices) {
+        /*
+         * We must iterate in reverse order, because the
+         * index after the removed elements will change.
+         */
+        int i = indices.length;
+        if (i != 0) {
+            Arrays.sort(indices);
+            int upper = indices[--i];
+            int lower = upper;
+            while (i != 0) {
+                int previous = indices[--i];
+                if (previous != lower - 1) {
+                    if (lower == upper) {
+                        list.remove(lower);
+                    } else {
+                        list.removeRange(lower, upper);
+                    }
+                    upper = previous;
+                }
+                lower = previous;
+            }
+            if (lower == upper) {
+                list.remove(lower);
+            } else {
+                list.removeRange(lower, upper);
+            }
+        }
+    }
+
+    /**
+     * Adds the given listener to the first window ancestor found in the hierarchy.
+     * If an {@link JInternalFrame} is found in the hierarchy, it the listener will
+     * be wrapped in an adapter before to be given to that internal frame.
+     *
+     * @param  component  the component for which to search for a window ancestor.
+     * @param  listener   the listener to register.
+     */
+    public static void addWindowListener(Component component, final WindowListener listener) {
+        while (component != null) {
+            if (component instanceof org.apache.sis.swing.Window) {
+                ((org.apache.sis.swing.Window) component).addWindowListener(listener);
+                break;
+            }
+            if (component instanceof Window) {
+                ((Window) component).addWindowListener(listener);
+                break;
+            }
+            if (component instanceof JInternalFrame) {
+                ((JInternalFrame) component).addInternalFrameListener(InternalWindowListener.wrap(listener));
+                break;
+            }
+            component = component.getParent();
+        }
+    }
+
+    /**
+     * Removes the given listener from the first window ancestor found in the hierarchy.
+     * This method is the converse of {@code addWindowListener(listener, component)}.
+     *
+     * @param  component  the component for which to search for a window ancestor.
+     * @param  listener   the listener to unregister.
+     */
+    public static void removeWindowListener(Component component, final WindowListener listener) {
+        while (component != null) {
+            if (component instanceof org.apache.sis.swing.Window) {
+                ((org.apache.sis.swing.Window) component).removeWindowListener(listener);
+                break;
+            }
+            if (component instanceof Window) {
+                ((Window) component).removeWindowListener(listener);
+                break;
+            }
+            if (component instanceof JInternalFrame) {
+                InternalWindowListener.removeWindowListener((JInternalFrame) component, listener);
+                break;
+            }
+            component = component.getParent();
+        }
+    }
+
+    /**
+     * Causes runnable to have its run method invoked in the dispatch thread of the event queue.
+     * This will happen after all pending events are processed.
+     * The call blocks until this has happened.
+     *
+     * @param  runnable  the task to run in the dispatch thread.
+     */
+    public static void invokeAndWait(final Runnable runnable) {
+        if (EventQueue.isDispatchThread()) {
+            runnable.run();
+        } else {
+            try {
+                EventQueue.invokeAndWait(runnable);
+            } catch (InterruptedException exception) {
+                // Someone don't want to let us sleep. Go back to work.
+            } catch (InvocationTargetException target) {
+                final Throwable exception = target.getTargetException();
+                if (exception instanceof RuntimeException) {
+                    throw (RuntimeException) exception;
+                }
+                if (exception instanceof Error) {
+                    throw (Error) exception;
+                }
+                // Should not happen since `Runnable.run()` does not allow checked exception.
+                throw new UndeclaredThrowableException(exception, exception.getLocalizedMessage());
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/Window.java b/src/main/java/org/apache/sis/swing/Window.java
new file mode 100644
index 0000000..3079718
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/Window.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Component;
+import java.awt.event.WindowListener;
+import javax.swing.JDialog;
+import javax.swing.JFrame;
+import javax.swing.JInternalFrame;
+import javax.swing.WindowConstants;
+
+
+/**
+ * Interfaces for windows created by {@link WindowCreator}. This interface is typically implemented
+ * by {@link JDialog}, {@link JFrame} or {@link JInternalFrame} subclasses, but users can provide other
+ * implementation. For example an application developed on top of the <cite>NetBeans platform</cite>
+ * may need to provide their own implementation for better integration with their platform.
+ *
+ * <p>Instances of {@code Window} are created by
+ * {@link WindowCreator.Handler#createWindow(Component, Component, String)}.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public interface Window extends WindowConstants {
+    /**
+     * Return {@code true} if this window is visible. The default value on
+     * {@linkplain WindowCreator.Handler#createWindow window creation} is {@code false}.
+     *
+     * @return {@code true} if this window is visible.
+     *
+     * @see Component#isVisible()
+     */
+    boolean isVisible();
+
+    /**
+     * Sets whatever this window should be visible. New windows created by {@link WindowCreator.Handler}
+     * needs an explicit call to this method in order to be visible.
+     *
+     * @param  visible  {@code true} for showing this window, or {@code false} for hiding it.
+     *
+     * @see Component#setVisible(boolean)
+     */
+    void setVisible(boolean visible);
+
+    /**
+     * Sets the window size.
+     *
+     * @param  width   the width in pixels.
+     * @param  height  the height in pixels.
+     *
+     * @see Component#setSize(int, int)
+     */
+    void setSize(int width, int height);
+
+    /**
+     * Returns the current title, or {@code null} if none.
+     *
+     * @return the current title, or {@code null} if none.
+     *
+     * @see java.awt.Frame#getTitle()
+     * @see JInternalFrame#getTitle()
+     */
+    String getTitle();
+
+    /**
+     * Sets the window title.
+     *
+     * @param  title  the new title, or {@code null} if none.
+     *
+     * @see java.awt.Frame#setTitle(String)
+     * @see JInternalFrame#setTitle(String)
+     */
+    void setTitle(String title);
+
+    /**
+     * Adds the specified window listener to receive window events from this window.
+     * If {@code listener} is null, no exception is thrown and no action is performed.
+     *
+     * <p>The listener given to this method shall be tolerant to null {@code WindowEvent}
+     * argument value, since it is not guaranteed that the events fired by the actual
+     * window implementation can be converted to {@code WindowEvent} in every cases.</p>
+     *
+     * @param  listener  the window listener to add.
+     *
+     * @see java.awt.Window#addWindowListener(WindowListener)
+     */
+    void addWindowListener(WindowListener listener);
+
+    /**
+     * Removes the specified window listener.
+     *
+     * @param  listener  the window listener to remove.
+     *
+     * @see java.awt.Window#removeWindowListener(WindowListener)
+     */
+    void removeWindowListener(WindowListener listener);
+
+    /**
+     * Returns the default operation that occurs when the user initiates a "close" on this window.
+     * The default value on {@linkplain WindowCreator.Handler#createWindow window creation} is
+     * {@link WindowConstants#DISPOSE_ON_CLOSE}.
+     *
+     * @return the operation that will occur when the user closes the window,
+     *         as one of the {@link WindowConstants}.
+     *
+     * @see JFrame#getDefaultCloseOperation()
+     * @see JInternalFrame#getDefaultCloseOperation()
+     */
+    int getDefaultCloseOperation();
+
+    /**
+     * Sets the default operation that occurs when the user initiates a "close" on this window.
+     * This method may be invoked after {@linkplain WindowCreator.Handler#createWindow window creation}
+     * in order to set the default close operation to {@link WindowConstants#HIDE_ON_CLOSE}.
+     * In such case, the caller is responsible for {@linkplain #dispose() disposing} this window
+     * when it is no longer needed.
+     *
+     * @param  operation  the operation that will occur when the user closes the window,
+     *         as one of the {@link WindowConstants}.
+     *
+     * @see JFrame#setDefaultCloseOperation(int)
+     * @see JInternalFrame#setDefaultCloseOperation(int)
+     */
+    void setDefaultCloseOperation(int operation);
+
+    /**
+     * Releases the resources used by this window.
+     *
+     * @see java.awt.Window#dispose()
+     * @see JInternalFrame#dispose()
+     */
+    void dispose();
+}
diff --git a/src/main/java/org/apache/sis/swing/WindowCreator.java b/src/main/java/org/apache/sis/swing/WindowCreator.java
new file mode 100644
index 0000000..16b5449
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/WindowCreator.java
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.text.ParseException;
+import java.awt.Component;
+import java.awt.event.WindowListener;
+import javax.swing.JFrame;
+import javax.swing.JDialog;
+import javax.swing.JComponent;
+import javax.swing.JDesktopPane;
+import javax.swing.JInternalFrame;
+import javax.swing.JOptionPane;
+import javax.swing.UIManager;
+import org.apache.sis.util.Workaround;
+import org.apache.sis.swing.internal.Resources;
+
+
+/**
+ * Base class of <cite>Swing</cite> components which may create new windows. For example
+ * an {@code AuthorityCodesComboBox} widget may have an information button which popup a
+ * window providing information about the selected CRS.
+ *
+ * <p>By default the new windows are instances of either {@link JDialog}, {@link JFrame} or
+ * {@link JInternalFrame} - the later case occurs if and only if this {@code WindowCreator}
+ * has a {@link JDesktopPane} ancestor. However this class provides a
+ * {@link #setWindowHandler(Handler)} method allowing users to plugin their own mechanism,
+ * for example in order to integrate the widget in the NetBeans platform.</p>
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+@SuppressWarnings("serial")
+public abstract class WindowCreator extends JComponent {
+    /**
+     * The system-wide {@link Handler} to use when none is explicitly set.
+     */
+    private static Handler defaultWindowHandler;
+
+    /**
+     * The handler for creating new windows, or {@code null} if not yet initialized.
+     */
+    private Handler windowHandler;
+
+    /**
+     * Creates a new {@code WindowCreator} with the default handler.
+     */
+    protected WindowCreator() {
+    }
+
+    /**
+     * Returns the current handler for creating new windows.
+     * The default value is {@link #getDefaultWindowHandler()}.
+     *
+     * @return the current window handler.
+     */
+    public Handler getWindowHandler() {
+        if (windowHandler == null) {
+            windowHandler = getDefaultWindowHandler();
+        }
+        return windowHandler;
+    }
+
+    /**
+     * Sets the new handler for creating windows. A {@code null} value resets the
+     * {@linkplain #getDefaultWindowHandler() default handler}.
+     *
+     * @param  handler  the new window handler, or {@code null} for the default one.
+     */
+    public void setWindowHandler(Handler handler) {
+        if (handler == null) {
+            handler = getDefaultWindowHandler();
+        }
+        final Handler old = getWindowHandler();
+        windowHandler = handler;
+        firePropertyChange("windowHandler", old, handler);
+    }
+
+    /**
+     * Returns the system-wide default handler. Every {@link WindowCreator} instance will use this handler,
+     * unless {@link #setWindowHandler(Handler)} has been explicitly invoked.
+     *
+     * <p>This method returns {@link Handler#DEFAULT}, unless a different default handler
+     * has been given to the {@link #setDefaultWindowHandler(Handler)} method.</p>
+     *
+     * @return the default handler for all {@link WindowCreator} instances.
+     */
+    public static synchronized Handler getDefaultWindowHandler() {
+        if (defaultWindowHandler == null) {
+            defaultWindowHandler = Handler.DEFAULT;
+        }
+        return defaultWindowHandler;
+    }
+
+    /**
+     * Sets the system-wide default handler. Applications will typically invoke this method
+     * at startup time if they want windows of some other kind than {@link JDialog},
+     * {@link JFrame} or {@link JInternalFrame}.
+     *
+     * <p>Invoking this method has no effect on the existing {@code WindowCreator} instances
+     * on which the {@link #setWindowHandler(Handler)} method has already been invoked.</p>
+     *
+     * @param  handler  the new default window handler, or {@code null} for the default one.
+     */
+    public static synchronized void setDefaultWindowHandler(Handler handler) {
+        if (handler == null) {
+            handler = Handler.DEFAULT;
+        }
+        defaultWindowHandler = handler;
+    }
+
+    /**
+     * Creates new {@linkplain Window Windows} for the purpose of widgets extending {@link WindowCreator}.
+     * The widget will typically use this handler as below:
+     *
+     * {@preformat java
+     *     public class Widget extends WindowCreator {
+     *         private JPanel accessoryContent = ...;
+     *         private Window accessoryWindow;
+     *
+     *         void showAccessoryInformation() {
+     *             accessoryContent... // Do some update here
+     *
+     *             if (accessoryWindow == null) {
+     *                 accessoryWindow = getWindowHandler().createWindow(this, accessoryContent, title);
+     *             }
+     *             accessoryWindow.setVisible(true);
+     *         }
+     *     }
+     * }
+     *
+     * The {@linkplain #DEFAULT default handler} will create new windows of kind
+     * {@link JDialog}, {@link JFrame} or {@link JInternalFrame}. However users can provide
+     * a different handler to {@link WindowCreator}, for example in order to integrate the
+     * windows with the NetBeans platform.
+     */
+    public interface Handler {
+        /**
+         * The default implementation. The kind of window created by this implementation depends
+         * on the parent of the {@code owner} argument:
+         *
+         * <ul>
+         *   <li>If a parent is a {@link JDesktopPane}, then the content is added into a
+         *       {@link JInternalFrame}.</li>
+         *   <li>If a parent is a {@link JFrame} or a {@link JDialog}, then the content is
+         *       added into a {@link JDialog}.</li>
+         *   <li>Otherwise, the content is added into a {@link JFrame}.</li>
+         * </ul>
+         */
+        Handler DEFAULT = new DefaultHandler();
+
+        /**
+         * Invoked when the given {@code owner} needs to create a new window for the given {@code content}.
+         * This method shall create new windows initialized as below:
+         *
+         * <ul>
+         *   <li>The window is initially hidden. Callers need to invoke
+         *       {@link Window#setVisible(boolean)} in order to make it visible.</li>
+         *   <li>The {@linkplain Window#setDefaultCloseOperation(int) default close
+         *       operation} shall be {@link JFrame#DISPOSE_ON_CLOSE DISPOSE_ON_CLOSE}.</li>
+         * </ul>
+         *
+         * @param  owner    the {@link WindowCreator} which need to create a new window.
+         * @param  content  the content to put in the window.
+         * @param  title    the window title.
+         * @return the new window.
+         */
+        Window createWindow(Component owner, Component content, String title);
+
+        /**
+         * Shows the given content in a modal dialog with "<cite>Ok</cite>" and "<cite>Cancel</cite>" buttons,
+         * and waits for the user to close the dialog.
+         *
+         * @param  owner    the {@link WindowCreator} which need to show a dialog window.
+         * @param  content  the content to put in the dialog window.
+         * @param  title    the dialog title.
+         * @return {@code true} if the user clicked on the "<cite>Ok</cite>" button, or {@code false} otherwise.
+         */
+        boolean showDialog(Component owner, Component content, String title);
+
+        /**
+         * Shows an error message in a modal dialog with "<cite>Ok</cite>" button,
+         * and waits for the user to close the dialog.
+         *
+         * @param  owner    the {@link WindowCreator} which need to show a dialog window.
+         * @param  content  the content to put in the dialog window.
+         * @param  title    the dialog title.
+         */
+        void showError(Component owner, Component content, String title);
+    }
+
+    /**
+     * The default implementation of {@link Handler}.
+     * This is the type of {@link Handler#DEFAULT}.
+     */
+    private static final class DefaultHandler implements Handler {
+        /**
+         * Creates a {@link JDialog}, {@link JFrame} or {@link JInternalFrame}
+         *  depending on the {@code owner} ancestor.
+         */
+        @Override
+        public Window createWindow(final Component owner, final Component content, final String title) {
+            java.awt.Window window = null;
+            Component parent = owner;
+            while ((parent = parent.getParent()) != null) {
+                if (parent instanceof JDesktopPane) {
+                    final InternalFrame frame = new InternalFrame(title);
+                    ((JDesktopPane) parent).add(frame);
+                    frame.add(content);
+                    frame.pack();
+                    return frame;
+                }
+                if (parent instanceof java.awt.Frame) {
+                    window = new Dialog((java.awt.Frame) parent, title);
+                    break;
+                } else if (parent instanceof java.awt.Dialog) {
+                    window = new Dialog((java.awt.Dialog) parent, title);
+                    break;
+                }
+            }
+            if (window == null) {
+                window = new Frame(title);
+            }
+            window.add(content);
+            window.pack();
+            window.setLocationRelativeTo(owner);
+            ((Window) window).setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
+            return (Window) window;
+        }
+
+        /**
+         * Shows a dialog box as a {@link JDialog} or {@link JInternalFrame}
+         * depending on the {@code owner} ancestor.
+         */
+        @Override
+        @Workaround(library="MacOS", version="10.5")
+        public boolean showDialog(Component owner, final Component content, final String title) {
+            /*
+             * Workaround for the Mac L&F, where the internal dialog box has no border
+             * and can not be moved. We will use a native dialog window instead.
+             */
+            if (UIManager.getLookAndFeel().getName().equalsIgnoreCase("Mac OS X")) {
+                if (!(owner instanceof java.awt.Window)) {
+                    owner = javax.swing.SwingUtilities.getWindowAncestor(owner);
+                }
+            }
+            while (SwingUtilities.showOptionDialog(owner, content, title)) {
+                if (!(content instanceof org.apache.sis.swing.Dialog)) {
+                    return true;
+                }
+                try {
+                    ((org.apache.sis.swing.Dialog) content).commitEdit();
+                    return true;
+                } catch (ParseException exception) {
+                    SwingUtilities.showMessageDialog(owner, exception.getLocalizedMessage(),
+                            Resources.forLocale(content.getLocale()).getString(Resources.Keys.IllegalEntry),
+                            JOptionPane.ERROR_MESSAGE);
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Shows a dialog box as a {@link JDialog} or {@link JInternalFrame}
+         * depending on the {@code owner} ancestor.
+         */
+        @Override
+        public void showError(final Component owner, final Component content, String title) {
+            if (title == null) {
+                title = Resources.forLocale(owner.getLocale()).getString(Resources.Keys.Error);
+            }
+            JOptionPane.showMessageDialog(owner, content, title, JOptionPane.ERROR_MESSAGE);
+        }
+    }
+
+    /**
+     * A {@link JInternalFrame} which implement the {@link Window} interface.
+     * This is one of the types of windows created by {@link DefaultHandler}.
+     */
+    @SuppressWarnings("serial")
+    private static final class InternalFrame extends JInternalFrame implements Window {
+        InternalFrame(final String title) {
+            super(title, true, true, true, true);
+        }
+
+        @Override
+        public void addWindowListener(final WindowListener listener) {
+            addInternalFrameListener(InternalWindowListener.wrap(listener));
+        }
+
+        @Override
+        public void removeWindowListener(final WindowListener listener) {
+            InternalWindowListener.removeWindowListener(this, listener);
+        }
+    }
+
+    /**
+     * A {@link JFrame} which implement the {@link Window} interface.
+     * This is one of the types of windows created by {@link DefaultHandler}.
+     */
+    @SuppressWarnings("serial")
+    private static final class Frame extends JFrame implements Window {
+        Frame(final String title) {super(title);}
+    }
+
+    /**
+     * A {@link JDialog} which implement the {@link Window} interface.
+     * This is one of the types of windows created by {@link DefaultHandler}.
+     */
+    @SuppressWarnings("serial")
+    private static final class Dialog extends JDialog implements Window {
+        Dialog(final java.awt.Frame  owner, final String title) {super(owner, title);}
+        Dialog(final java.awt.Dialog owner, final String title) {super(owner, title);}
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/ZoomChangeEvent.java b/src/main/java/org/apache/sis/swing/ZoomChangeEvent.java
new file mode 100644
index 0000000..54d91c4
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/ZoomChangeEvent.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.util.EventObject;
+import java.awt.geom.AffineTransform;
+
+
+/**
+ * An event which indicates that a zoom occurred in a component.
+ * This event is fired by {@link ZoomPane}.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public class ZoomChangeEvent extends EventObject {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 5063317286699888858L;
+
+    /**
+     * An affine transform indicating the zoom change. If {@code oldZoom} and {@code newZoom}
+     * are the affine transforms before and after the change respectively, then the following
+     * relation must hold (within the limits of rounding error):
+     *
+     * {@preformat java
+     *     newZoom = oldZoom.concatenate(change)
+     * }
+     */
+    private final AffineTransform change;
+
+    /**
+     * Constructs a new event. If {@code oldZoom} and {@code newZoom} are the affine transforms
+     * before and after the change respectively, then the following relation must hold (within
+     * the limits of rounding error):
+     *
+     * {@preformat java
+     *     newZoom = oldZoom.concatenate(change)
+     * }
+     *
+     * @param  source  the event source.
+     * @param  change  an affine transform indicating the zoom change.
+     */
+    public ZoomChangeEvent(final ZoomPane source, final AffineTransform change) {
+        super(source);
+        this.change = change;
+    }
+
+    /**
+     * Returns the affine transform indicating the zoom change.
+     * <strong>Note:</strong> for performance reasons, this method does not clone
+     * the returned transform. Do not change!
+     *
+     * @return the zoom change as an affine transform (<strong>not</strong> cloned).
+     */
+    public AffineTransform getChange() {
+        return change;
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/ZoomChangeListener.java b/src/main/java/org/apache/sis/swing/ZoomChangeListener.java
new file mode 100644
index 0000000..723bbcd
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/ZoomChangeListener.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.util.EventListener;
+
+
+/**
+ * Listener for zoom change events.
+ * Zoom changes are described by an {@link java.awt.geom.AffineTransform}.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public interface ZoomChangeListener extends EventListener {
+    /**
+     * Invoked when a zoom changed.
+     *
+     * @param  event  the change in zoom scale, translation, rotation, <i>etc.</i>
+     */
+    void zoomChanged(ZoomChangeEvent event);
+}
diff --git a/src/main/java/org/apache/sis/swing/ZoomPane.java b/src/main/java/org/apache/sis/swing/ZoomPane.java
new file mode 100644
index 0000000..070ff44
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/ZoomPane.java
@@ -0,0 +1,2377 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.util.EventListener;
+import java.io.Serializable;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Container;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.GridBagConstraints;
+import java.awt.Window;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.Paint;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.Shape;
+import java.awt.Stroke;
+import java.awt.Toolkit;
+import java.awt.event.ActionEvent;
+import java.awt.event.ComponentEvent;
+import java.awt.event.ComponentListener;
+import java.awt.event.KeyEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.awt.event.MouseWheelEvent;
+import java.awt.event.MouseWheelListener;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Dimension2D;
+import java.awt.geom.NoninvertibleTransformException;
+import java.awt.geom.Point2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.geom.RoundRectangle2D;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+
+import javax.swing.AbstractAction;
+import javax.swing.AbstractButton;
+import javax.swing.Action;
+import javax.swing.ActionMap;
+import javax.swing.BoundedRangeModel;
+import javax.swing.InputMap;
+import javax.swing.JComponent;
+import javax.swing.JMenu;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JScrollBar;
+import javax.swing.KeyStroke;
+import javax.swing.SwingConstants;
+import javax.swing.SwingUtilities;
+import javax.swing.plaf.ComponentUI;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.util.NullArgumentException;
+import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.swing.internal.Resources;
+
+import static java.lang.Math.abs;
+import static java.lang.Math.rint;
+
+
+/**
+ * Base class for widget with a zoomable content. User can perform zooms using keyboard, menu or mouse.
+ * Subclasses must provide the content to be paint with the following methods, which need to be overridden:
+ *
+ * <ul class="verbose">
+ *   <li>{@link #getArea()}, which must return a bounding box for the content to paint.
+ *   This area can be expressed in arbitrary units. For example, an object wanting to display
+ *   a geographic map with a content ranging from 10° to 15°E and 40° to 45°N should override
+ *   this method as follows:
+ *
+ * {@preformat java
+ *     public Rectangle2D getArea() {
+ *         return new Rectangle2D.Double(10, 40, 15-10, 45-40);
+ *     }
+ * }</li>
+ *
+ *   <li>{@link #paintComponent(Graphics2D)}, which must paint the widget content. Implementations
+ *   must invoke <code>graphics.transform({@linkplain #zoom})</code> somewhere in their code in order
+ *   to perform the zoom. Note that, by default, the {@linkplain #zoom} is initialized in such a way
+ *   that the <var>y</var> axis points upwards, like the convention in geometry. This is opposed to
+ *   the default Java2D axis orientation, where the <var>y</var> axis points downwards. The Java2D
+ *   convention is appropriate for text rendering - consequently implementations wanting to paint
+ *   text should use the default transform (the one provided by {@link Graphics2D}) for that purpose.
+ *   Example:
+ *
+ * {@preformat java
+ *     protected void paintComponent(final Graphics2D graphics) {
+ *         graphics.clip(getZoomableBounds(null));
+ *         final AffineTransform textTr = graphics.getTransform();
+ *         graphics.transform(zoom);
+ *         // Paint the widget here, using logical coordinates.
+ *         // The coordinate system is the same as getArea()'s one.
+ *         graphics.setTransform(textTr);
+ *         // Paint any text here, in pixel coordinates.
+ *     }
+ * }</li>
+ *
+ *   <li>{@link #reset()}, which sets up the initial {@linkplain #zoom}.
+ *   Overriding this method is optional since the default implementation is appropriate in many cases.
+ *   This default implementation setups the initial zoom in such a way that the following relation
+ *   approximately hold: <cite>Logical coordinates provided by {@link #getPreferredArea()},
+ *   after an affine transform described by {@link #zoom}, match pixel coordinates provided
+ *   by {@link #getZoomableBounds(Rectangle)}.</cite></li>
+ * </ul>
+ *
+ * The "preferred area" is initially the same as {@link #getArea()}.
+ * The user can specify a different preferred area with {@link #setPreferredArea(Rectangle2D)}.
+ * The user can also reduce zoomable bounds by inserting an empty border around the widget, e.g.:
+ *
+ * {@preformat java
+ *     setBorder(BorderFactory.createEmptyBorder(top, left, bottom, right));
+ * }
+ *
+ * <h2>Zoom actions</h2>
+ * Whatever action is performed by the user, all zoom commands are translated as calls to
+ * {@link #transform(AffineTransform)}. Derived classes can redefine this method if they want
+ * to take particular actions during zooms, for example, modifying the minimum and maximum of
+ * a graph's axes. The table below shows the keyboard presses assigned to each zoom:
+ *
+ * <table class="sis">
+ *   <caption>Key events</caption>
+ *   <tr><th>Key</th>             <th>Purpose</th>                 <th>{@link Action} name</th></tr>
+ *   <tr><td>↑ (up)</td>          <td>Scroll up</td>               <td>{@code "Up"}</td></tr>
+ *   <tr><td>↓ (down)</td>        <td>Scroll down</td>             <td>{@code "Down"}</td></tr>
+ *   <tr><td>← (left)</td>        <td>Scroll left</td>             <td>{@code "Left"}</td></tr>
+ *   <tr><td>→ (right)</td>       <td>Scroll right</td>            <td>{@code "Right"}</td></tr>
+ *   <tr><td>⎘ (page down)</td>   <td>Zoom in</td>                 <td>{@code "ZoomIn"}</td></tr>
+ *   <tr><td>⎗ (page up)</td>     <td>Zoom out</td>                <td>{@code "ZoomOut"}</td></tr>
+ *   <tr><td>end</td>             <td>Maximal zoom</td>            <td>{@code "Zoom"}</td></tr>
+ *   <tr><td>home</td>            <td>Default zoom</td>            <td>{@code "Reset"}</td></tr>
+ *   <tr><td>Ctrl + left</td>     <td>Anti-clockwise rotation</td> <td>{@code "RotateLeft"}</td></tr>
+ *   <tr><td>Ctrl + right</td>    <td>Clockwise rotation</td>      <td>{@code "RotateRight"}</td></tr>
+ * </table>
+ *
+ * In above table, the last column gives the {@link String}s that identify the different actions
+ * which manage the zooms. For example to get action for zoom in, we can write
+ * <code>{@linkplain #getActionMap() getActionMap()}.get("ZoomIn")</code>.
+ *
+ * <h2>Scroll pane</h2>
+ * <strong>{@link javax.swing.JScrollPane} objects are not suitable for adding scrollbars
+ * to a {@code ZoomPane} object.</strong> Instead, use {@link #createScrollPane()}.
+ * Like other actions, all movements performed by user through the scrollbars
+ * will be translated in calls to {@link #transform(AffineTransform)}.
+ *
+ * <img src="doc-files/ZoomPane.png" alt="ZoomPane screenshot">
+ *
+ * @author  Martin Desruisseaux (MPO, IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+@SuppressWarnings("serial")
+public abstract class ZoomPane extends JComponent implements DeformableViewer {
+    /**
+     * Whether to print debug messages.
+     *
+     * @see #debug(String, Rectangle2D)
+     */
+    private static final boolean DEBUG = false;
+
+    /**
+     * Minimum width and height of this component.
+     */
+    private static final int MINIMUM_SIZE = 10;
+
+    /**
+     * Default width and height of this component.
+     */
+    private static final int DEFAULT_SIZE = 400;
+
+    /**
+     * Default width and height of the magnifying glass.
+     */
+    private static final int DEFAULT_MAGNIFIER_SIZE = 150;
+
+    /**
+     * Default color with which to tint magnifying glass.
+     */
+    private static final Paint DEFAULT_MAGNIFIER_GLASS = new Color(209, 225, 243);
+
+    /**
+     * Default color of the magnifying glass border.
+     */
+    private static final Paint DEFAULT_MAGNIFIER_BORDER = new Color(110, 129, 177);
+
+    /**
+     * Small number for floating point comparisons.
+     */
+    private static final double EPS = 1E-6;
+
+    /**
+     * Constant indicating scale changes on the <var>x</var> axis.
+     */
+    public static final int SCALE_X = 1;
+
+    /**
+     * Constant indicating scale changes on the <var>y</var> axis.
+     */
+    public static final int SCALE_Y = (1 << 1);
+
+    /**
+     * Constant indicating scale changes by the same value on both the <var>x</var> and <var>y</var> axes.
+     * This flag combines {@link #SCALE_X} and {@link #SCALE_Y}.
+     * <b>Note:</b> the converse (<code>{@linkplain #SCALE_X}|{@linkplain #SCALE_Y}</code>)
+     * does not necessarily imply {@code UNIFORM_SCALE}.
+     */
+    public static final int UNIFORM_SCALE = SCALE_X | SCALE_Y | (1 << 2);
+
+    /**
+     * Constant indicating translations on the <var>x</var> axis.
+     */
+    public static final int TRANSLATE_X = (1 << 3);
+
+    /**
+     * Constant indicating translations on the <var>y</var> axis.
+     */
+    public static final int TRANSLATE_Y = (1 << 4);
+
+    /**
+     * Constant indicating rotations.
+     */
+    public static final int ROTATE  = (1 << 5);
+
+    /**
+     * Constant indicating the resetting of scale, rotation and translation to default values.
+     * Those default values ensure that the content is fully contained in the window.
+     * This action is implemented by a call to {@link #reset()}.
+     */
+    public static final int RESET = (1 << 6);
+
+    /**
+     * Constant indicating default zoom close to the maximum permitted zoom.
+     * This zoom should allow details of the graphic to be seen without being overly big.
+     */
+    public static final int DEFAULT_ZOOM = (1 << 7);
+
+    /**
+     * Combination of all permitted flags.
+     */
+    private static final int MASK = SCALE_X | SCALE_Y | UNIFORM_SCALE | TRANSLATE_X | TRANSLATE_Y |
+                                    ROTATE | RESET | DEFAULT_ZOOM;
+
+    /**
+     * Number of pixels by which to move the {@code ZoomPane} content during translations.
+     */
+    private static final double AMOUNT_TRANSLATE = 10;
+
+    /**
+     * Zoom factor (must be greater than 1).
+     */
+    private static final double AMOUNT_SCALE = 1.03125;
+
+    /**
+     * Rotation angle in radians.
+     */
+    private static final double AMOUNT_ROTATE = Math.PI / 90;
+
+    /**
+     * Multiplication factor to apply on {@link #ACTION_AMOUNT} numbers when the "Shift" key is kept pressed.
+     */
+    private static final double ENHANCEMENT_FACTOR = 7.5;
+
+    /**
+     * Enumeration value indicating that a paint is in progress.
+     *
+     * @see #renderingType
+     */
+    private static final int IS_PAINTING = 0;
+
+    /**
+     * Enumeration value indicating that a paint of the magnifying glass is in progress.
+     *
+     * @see #renderingType
+     */
+    private static final int IS_PAINTING_MAGNIFIER = 1;
+
+    /**
+     * Enumeration value indicating that a print is in progress.
+     *
+     * @see #renderingType
+     */
+    private static final int IS_PRINTING = 2;
+
+    /**
+     * List of keys identifying zoom actions.
+     */
+    private static final String[] ACTION_ID = {
+        /*[0] Left        */ "Left",
+        /*[1] Right       */ "Right",
+        /*[2] Up          */ "Up",
+        /*[3] Down        */ "Down",
+        /*[4] ZoomIn      */ "ZoomIn",
+        /*[5] ZoomOut     */ "ZoomOut",
+        /*[6] ZoomMax     */ "ZoomMax",
+        /*[7] Reset       */ "Reset",
+        /*[8] RotateLeft  */ "RotateLeft",
+        /*[9] RotateRight */ "RotateRight"
+    };
+
+    /**
+     * List of resource keys for building menus in user's language.
+     * Must be in same order than {@link #ACTION_ID}.
+     */
+    private static final short[] RESOURCE_ID = {
+        /*[0] Left        */ Resources.Keys.Left,
+        /*[1] Right       */ Resources.Keys.Right,
+        /*[2] Up          */ Resources.Keys.Up,
+        /*[3] Down        */ Resources.Keys.Down,
+        /*[4] ZoomIn      */ Resources.Keys.ZoomIn,
+        /*[5] ZoomOut     */ Resources.Keys.ZoomOut,
+        /*[6] ZoomMax     */ Resources.Keys.ZoomMax,
+        /*[7] Reset       */ Resources.Keys.Reset,
+        /*[8] RotateLeft  */ Resources.Keys.RotateLeft,
+        /*[9] RotateRight */ Resources.Keys.RotateRight
+    };
+
+    /**
+     * List of default keystrokes performing zooms. Elements in this table go in pairs:
+     * elements at even indices are keystroke whilst elements at odd indices are modifier
+     * (CTRL or SHIFT). To obtain the {@link KeyStroke} object for action <var>i</var>,
+     * we can use the following code:
+     *
+     * {@preformat java
+     *     final int key = DEFAULT_KEYBOARD[(i << 1)+0];
+     *     final int mdf = DEFAULT_KEYBOARD[(i << 1)+1];
+     *     KeyStroke stroke = KeyStroke.getKeyStroke(key, mdf);
+     * }
+     */
+    private static final int[] ACTION_KEY = {
+        /*[0] Left        */ KeyEvent.VK_LEFT,      0,
+        /*[1] Right       */ KeyEvent.VK_RIGHT,     0,
+        /*[2] Up          */ KeyEvent.VK_UP,        0,
+        /*[3] Down        */ KeyEvent.VK_DOWN,      0,
+        /*[4] ZoomIn      */ KeyEvent.VK_PAGE_UP,   0,
+        /*[5] ZoomOut     */ KeyEvent.VK_PAGE_DOWN, 0,
+        /*[6] ZoomMax     */ KeyEvent.VK_END,       0,
+        /*[7] Reset       */ KeyEvent.VK_HOME,      0,
+        /*[8] RotateLeft  */ KeyEvent.VK_LEFT,      KeyEvent.CTRL_DOWN_MASK,
+        /*[9] RotateRight */ KeyEvent.VK_RIGHT,     KeyEvent.CTRL_DOWN_MASK
+    };
+
+    /**
+     * Constants indicating the type of action to apply: translation, zoom or rotation.
+     */
+    private static final short[] ACTION_TYPE = {
+        /*[0] Left        */ (short) TRANSLATE_X,
+        /*[1] Right       */ (short) TRANSLATE_X,
+        /*[2] Up          */ (short) TRANSLATE_Y,
+        /*[3] Down        */ (short) TRANSLATE_Y,
+        /*[4] ZoomIn      */ (short) SCALE_X | SCALE_Y,
+        /*[5] ZoomOut     */ (short) SCALE_X | SCALE_Y,
+        /*[6] ZoomMax     */ (short) DEFAULT_ZOOM,
+        /*[7] Reset       */ (short) RESET,
+        /*[8] RotateLeft  */ (short) ROTATE,
+        /*[9] RotateRight */ (short) ROTATE
+    };
+
+    /**
+     * Amounts by which to translate, zoom or rotate the window content.
+     */
+    private static final double[] ACTION_AMOUNT = {
+        /*[0] Left        */  +AMOUNT_TRANSLATE,
+        /*[1] Right       */  -AMOUNT_TRANSLATE,
+        /*[2] Up          */  +AMOUNT_TRANSLATE,
+        /*[3] Down        */  -AMOUNT_TRANSLATE,
+        /*[4] ZoomIn      */   AMOUNT_SCALE,
+        /*[5] ZoomOut     */ 1/AMOUNT_SCALE,
+        /*[6] ZoomMax     */   Double.NaN,
+        /*[7] Reset       */   Double.NaN,
+        /*[8] RotateLeft  */  -AMOUNT_ROTATE,
+        /*[9] RotateRight */  +AMOUNT_ROTATE
+    };
+
+    /**
+     * List of operation types forming a group in the contextual menu.
+     * Group will be separated by a menu separator.
+     */
+    private static final int[] GROUP = {
+        TRANSLATE_X | TRANSLATE_Y,
+        SCALE_X | SCALE_Y | DEFAULT_ZOOM | RESET,
+        ROTATE
+    };
+
+    /**
+     * {@code ComponentUI} object in charge of obtaining the preferred
+     * size of a {@code ZoomPane} object as well as drawing it.
+     */
+    private static final ComponentUI UI = new ComponentUI() {
+        /**
+         * Returns a default minimum size.
+         */
+        @Override
+        public Dimension getMinimumSize(final JComponent c) {
+            return new Dimension(MINIMUM_SIZE, MINIMUM_SIZE);
+        }
+
+        /**
+         * Returns the maximum size. We use the preferred size as a default maximum size.
+         */
+        @Override
+        public Dimension getMaximumSize(final JComponent c) {
+            return getPreferredSize(c);
+        }
+
+        /**
+         * Returns the default preferred size. User can override this preferred size
+         * by invoking {@link JComponent#setPreferredSize(Dimension)}.
+         */
+        @Override
+        public Dimension getPreferredSize(final JComponent c) {
+            return ((ZoomPane) c).getDefaultSize();
+        }
+
+        /**
+         * Overrides in order to handle painting of magnifying glass, which is a special case.
+         * Since the magnifying glass is painted just after the normal component, we do not want
+         * to clear the background before painting it.
+         */
+        @Override
+        public void update(final Graphics g, final JComponent c) {
+            switch (((ZoomPane) c).renderingType) {
+                case IS_PAINTING_MAGNIFIER: paint(g, c); break;     // Avoid background clearing
+                default: super.update(g, c); break;
+            }
+        }
+
+        /**
+         * Paints the component. This method basically delegates the
+         * work to {@link ZoomPane#paintComponent(Graphics2D)}.
+         */
+        @Override
+        public void paint(final Graphics g, final JComponent c) {
+            final ZoomPane pane = (ZoomPane)   c;
+            final Graphics2D gr = (Graphics2D) g;
+            switch (pane.renderingType) {
+                case IS_PAINTING:           pane.paintComponent(gr); break;
+                case IS_PAINTING_MAGNIFIER: pane.paintMagnifier(gr); break;
+                case IS_PRINTING:           pane.printComponent(gr); break;
+                default: throw new IllegalStateException(Integer.toString(pane.renderingType));
+            }
+        }
+    };
+
+    /**
+     * Object in charge of drawing a box representing the user's selection.
+     */
+    private final MouseListener mouseSelectionTracker = new MouseSelectionTracker() {
+        /**
+         * Returns the selection shape. This is usually a rectangle, but could also be an ellipse or other kind
+         * of geometric shape. This method gets the shape from {@link ZoomPane#getMouseSelectionShape(Point2D)}.
+         */
+        @Override
+        protected Shape getModel(final MouseEvent event) {
+            final Point2D point = new Point2D.Double(event.getX(), event.getY());
+            if (getZoomableBounds().contains(point)) try {
+                return getMouseSelectionShape(zoom.inverseTransform(point, point));
+            } catch (NoninvertibleTransformException exception) {
+                unexpectedException("getModel", exception);
+            }
+            return null;
+        }
+
+        /**
+         * Invoked when the user finished his/her selection. This method delegates the action to
+         * {@link ZoomPane#mouseSelectionPerformed(Shape)}, which default implementation performs a zoom.
+         */
+        @Override
+        protected void selectionPerformed(int ox, int oy, int px, int py) {
+            try {
+                final Shape selection = getSelectedArea(zoom);
+                if (selection != null) {
+                    mouseSelectionPerformed(selection);
+                }
+            } catch (NoninvertibleTransformException exception) {
+                unexpectedException("selectionPerformed", exception);
+            }
+        }
+    };
+
+    /**
+     * Group of listeners for various events of interest for {@link ZoomPane}.
+     * Its includes mouse clicks in order to eventually claim focus or show contextual menu.
+     * Also listen for changes of component size (to adjust the zoom), <i>etc.</i>
+     */
+    @SuppressWarnings("serial")
+    private final class Listeners extends MouseAdapter implements MouseWheelListener, ComponentListener, Serializable {
+        @Override public void mouseWheelMoved (final MouseWheelEvent event) {ZoomPane.this.mouseWheelMoved (event);}
+        @Override public void mousePressed    (final MouseEvent      event) {ZoomPane.this.mayShowPopupMenu(event);}
+        @Override public void mouseReleased   (final MouseEvent      event) {ZoomPane.this.mayShowPopupMenu(event);}
+        @Override public void componentResized(final ComponentEvent  event) {ZoomPane.this.processSizeEvent(event);}
+        @Override public void componentMoved  (final ComponentEvent  event) {}
+        @Override public void componentShown  (final ComponentEvent  event) {}
+        @Override public void componentHidden (final ComponentEvent  event) {}
+    }
+
+    /**
+     * Affine transform containing zoom factors, translations and rotations.
+     * During component painting, this affine transform should be applied with a call to
+     * <code>{@linkplain Graphics2D#transform(AffineTransform) Graphics2D.transform}(zoom)</code>.
+     */
+    protected final AffineTransform zoom = new AffineTransform();
+
+    /**
+     * Indicates whether the zoom is the result of a {@link #reset()} operation.
+     * This is used in order to determine which behavior to replicate when the widget is resized.
+     */
+    private boolean zoomIsReset = true;
+
+    /**
+     * {@code true} if calls to {@link #repaint()} should be temporarily disabled.
+     */
+    private boolean disableRepaint;
+
+    /**
+     * Types of zoom permitted. Values can be combinations of {@link #SCALE_X}, {@link #SCALE_Y},
+     * {@link #TRANSLATE_X}, {@link #TRANSLATE_Y}, {@link #ROTATE}, {@link #RESET} or {@link #DEFAULT_ZOOM}.
+     */
+    private final int allowedActions;
+
+    /**
+     * Controls how to calculate the initial affine transform. The {@code true} value specifies that
+     * content should fill the entire panel, even if it implies losing some content close to the edges.
+     * The {@code false} value specifies to display the entire content, even if it means leaving blank
+     * spaces in the panel.
+     */
+    private boolean fillPanel;
+
+    /**
+     * Logical coordinates of visible region. This information is used for keeping the same region when
+     * the component size or position changes. This rectangle is initially empty and get values only when
+     * {@link #reset()} is invoked while {@link #getPreferredArea()} and {@link #getZoomableBounds()} can
+     * both return valid coordinates.
+     *
+     * @see #getVisibleArea()
+     * @see #setVisibleArea(Rectangle2D)
+     */
+    private final Rectangle2D visibleArea = new Rectangle2D.Double();
+
+    /**
+     * Logical coordinates of the initial region to display, the first time that the window is shown.
+     * A {@code null} value indicates a call to {@link #getArea()}.
+     *
+     * @see #getPreferredArea()
+     * @see #setPreferredArea(Rectangle2D)
+     */
+    private Rectangle2D preferredArea;
+
+    /**
+     * Menu to show on mouse right click. This menu will contain navigation options.
+     *
+     * @see #getPopupMenu(MouseEvent)
+     */
+    private transient PointPopupMenu navigationPopupMenu;
+
+    /**
+     * Enumeration value indicating which kind of painting is in progress. Permitted values are
+     * {@link #IS_PAINTING}, {@link #IS_PAINTING_MAGNIFIER} and {@link #IS_PRINTING}.
+     */
+    private transient int renderingType;
+
+    /**
+     * Indicates if this {@code ZoomPane} should be repainted when the user adjusts the scrollbars.
+     * The default value is {@code false}, which means that {@code ZoomPane} will wait until user
+     * has released the scrollbar before repainting the component.
+     *
+     * @see #isPaintingWhileAdjusting()
+     * @see #setPaintingWhileAdjusting(boolean)
+     */
+    private boolean paintingWhileAdjusting;
+
+    /**
+     * Object in which to write coordinates computed by {@link #getZoomableBounds()}.
+     * Used for reducing the amount of object allocations.
+     */
+    private transient Rectangle cachedBounds;
+
+    /**
+     * Object in which to write values computed by {@link #getInsets()}.
+     * Used for reducing the amount of object allocations.
+     */
+    private transient Insets cachedInsets;
+
+    /**
+     * Indicates whether the user is authorized to show the magnifying glass.
+     * The default value is {@code true}.
+     */
+    private boolean magnifierEnabled = true;
+
+    /**
+     * Magnification factor inside the magnifying glass. This factor must be greater than 1.
+     */
+    private double magnifierPower = 4;
+
+    /**
+     * Boundaries of the region to magnify. Coordinates of this shape are in pixels.
+     * The {@code null} value means that no magnifying glass is drawn.
+     */
+    private transient MouseReshapeTracker magnifier;
+
+    /**
+     * Color with which to tint magnifying glass interior.
+     */
+    private Paint magnifierGlass = DEFAULT_MAGNIFIER_GLASS;
+
+    /**
+     * Color of the magnifying glass border.
+     */
+    private Paint magnifierBorder = DEFAULT_MAGNIFIER_BORDER;
+
+    /**
+     * Constructs a {@code ZoomPane}.
+     *
+     * @param allowedActions
+     *             allowed zoom actions. It can be a bitwise combination of the following constants:
+     *             {@link #SCALE_X}, {@link #SCALE_Y}, {@link #UNIFORM_SCALE}, {@link #TRANSLATE_X},
+     *             {@link #TRANSLATE_Y}, {@link #ROTATE}, {@link #RESET} and {@link #DEFAULT_ZOOM}.
+     * @throws IllegalArgumentException if {@code type} is invalid.
+     */
+    public ZoomPane(final int allowedActions) throws IllegalArgumentException {
+        if ((allowedActions & ~MASK) != 0) {
+            throw new IllegalArgumentException();
+        }
+        this.allowedActions = allowedActions;
+        final Resources resources = Resources.forLocale(null);
+        final InputMap   inputMap = super.getInputMap();
+        final ActionMap actionMap = super.getActionMap();
+        for (int i = 0; i < ACTION_ID.length; i++) {
+            final short actionType = ACTION_TYPE[i];
+            if ((actionType & allowedActions) != 0) {
+                final String  actionID = ACTION_ID[i];
+                final double    amount = ACTION_AMOUNT[i];
+                final int     keyboard = ACTION_KEY[(i << 1) + 0];
+                final int     modifier = ACTION_KEY[(i << 1) + 1];
+                final KeyStroke stroke = KeyStroke.getKeyStroke(keyboard, modifier);
+                final Action    action = new AbstractAction() {
+                    /*
+                     * Action to perform when a key has been pressed or the mouse clicked.
+                     */
+                    @Override
+                    public void actionPerformed(final ActionEvent event) {
+                        Point point = null;
+                        final Object  source = event.getSource();
+                        final boolean button = (source instanceof AbstractButton);
+                        if (button) {
+                            for (Container c = (Container) source; c != null; c = c.getParent()) {
+                                if (c instanceof PointPopupMenu) {
+                                    point = ((PointPopupMenu) c).point;
+                                    break;
+                                }
+                            }
+                        }
+                        double m = amount;
+                        if (button || (event.getModifiers() & ActionEvent.SHIFT_MASK) != 0) {
+                            if ((actionType & UNIFORM_SCALE) != 0) {
+                                m = (m >= 1) ? 2.0 : 0.5;
+                            }
+                            else {
+                                m *= ENHANCEMENT_FACTOR;
+                            }
+                        }
+                        transform(actionType & allowedActions, m, point);
+                    }
+                };
+                action.putValue(Action.NAME, resources.getString(RESOURCE_ID[i]));
+                action.putValue(Action.ACTION_COMMAND_KEY, actionID);
+                action.putValue(Action.ACCELERATOR_KEY, stroke);
+                actionMap.put(actionID, action);
+                inputMap .put(stroke, actionID);
+                inputMap .put(KeyStroke.getKeyStroke(keyboard, modifier | KeyEvent.SHIFT_DOWN_MASK), actionID);
+            }
+        }
+        /*
+         * Adds a listeners for mouse clicks and resizing events.
+         */
+        final Listeners listeners = new Listeners();
+        super.addComponentListener(listeners);
+        super.addMouseListener(listeners);
+        if ((allowedActions & (SCALE_X | SCALE_Y)) != 0) {
+            super.addMouseWheelListener(listeners);
+        }
+        super.addMouseListener(mouseSelectionTracker);
+        super.setBackground(Color.WHITE);
+        super.setAutoscrolls(true);
+        super.setFocusable(true);
+        super.setOpaque(true);
+        super.setUI(UI);
+    }
+
+    /**
+     * Reinitializes the {@link #zoom} affine transform in order to cancel any zoom, rotation or translation.
+     * Default implementation makes the <var>y</var> axis orientation upwards and makes the entire content to be
+     * visible in the {@link #getPreferredArea()} logical coordinates.
+     *
+     * <div class="note"><b>Note:</b>
+     * {@code reset()} is <u>the only</u> {@code ZoomPane} method which does not delegate
+     * to {@link #transform(AffineTransform)} method for modifying the zoom.
+     * This exception is necessary for avoiding an infinite loop.</div>
+     */
+    public void reset() {
+        reset(getZoomableBounds(), true);
+    }
+
+    /**
+     * Reinitializes the {@link #zoom} affine transform in order to cancel any zoom, rotation or translation.
+     * The {@code yAxisUpward} argument indicates whether the <var>y</var> axis should point upwards.
+     * A {@code false} value lets it point downwards. This is a convenience method for subclasses which want
+     * to override {@link #reset()}.
+     *
+     * @param zoomableBounds  pixel coordinates of the region where to draw. Typical value is
+     *        <code>{@linkplain #getZoomableBounds(Rectangle) getZoomableBounds}(null)</code>.
+     * @param yAxisUpward {@code true} if the <var>y</var> axis should point upwards rather than downwards.
+     */
+    protected void reset(final Rectangle zoomableBounds, final boolean yAxisUpward) {
+        if (!zoomableBounds.isEmpty()) {
+            final Rectangle2D area = getPreferredArea();
+            if (isValid(area)) {
+                final AffineTransform change;
+                try {
+                    change = zoom.createInverse();
+                } catch (NoninvertibleTransformException exception) {
+                    unexpectedException("reset", exception);
+                    return;
+                }
+                if (yAxisUpward) {
+                    zoom.setToScale(+1, -1);
+                } else {
+                    zoom.setToIdentity();
+                }
+                final AffineTransform transform = setVisibleArea(area, zoomableBounds,
+                                        SCALE_X | SCALE_Y | TRANSLATE_X | TRANSLATE_Y);
+                change.concatenate(zoom);
+                zoom  .concatenate(transform);
+                change.concatenate(transform);
+                getVisibleArea(zoomableBounds);         // Force update of `visibleArea`
+                /*
+                 * The three private versions `fireZoomPane0`, `getVisibleArea` and `setVisibleArea`
+                 * avoid invoking other `ZoomPane` methods in order to avoid an infinite loop.
+                 */
+                if (!change.isIdentity()) {
+                    fireZoomChanged0(change);
+                    if (!disableRepaint) {
+                        repaint(zoomableBounds);
+                    }
+                }
+                zoomIsReset = true;
+                debug("reset", visibleArea);
+            }
+        }
+    }
+
+    /**
+     * Indicates whether the zoom is the result of a {@link #reset()} operation.
+     */
+    final boolean zoomIsReset() {
+        return zoomIsReset;
+    }
+
+    /**
+     * Sets the policy for the zoom when the content is initially drawn or when the user resets the zoom.
+     * Value {@code true} means that the panel should initially be completely filled, even if the content
+     * partially falls outside the panel's bounds. Value {@code false} means that the full content should
+     * appear in the panel, even if some space is not used. Default value is {@code false}.
+     *
+     * @param fill {@code true} if the panel should be initially completely filled.
+     */
+    protected void setResetPolicy(final boolean fill) {
+        fillPanel = fill;
+    }
+
+    /**
+     * Returns a bounding box that contains the logical coordinates of all data that may be displayed
+     * in this {@code ZoomPane}. For example, if this {@code ZoomPane} is to display a geographic map,
+     * then this method should return the map's bounds in degrees of latitude and longitude (if the
+     * underlying CRS is {@linkplain org.opengis.referencing.crs.GeographicCRS geographic}), in metres
+     * (if the underlying CRS is {@linkplain org.opengis.referencing.crs.ProjectedCRS projected}) or
+     * some other geodetic units. This bounding box is completely independent of any current zoom
+     * setting and will change only if the content changes.
+     *
+     * @return a bounding box for the logical coordinates of all contents that are going to be
+     *         drawn in this {@code ZoomPane}. If this bounding box is unknown, then this method
+     *         can return {@code null} (but this is not recommended).
+     */
+    public abstract Rectangle2D getArea();
+
+    /**
+     * Indicates whether the logical coordinates of a region have been defined. This method returns
+     * {@code true} if {@link #setPreferredArea(Rectangle2D)} has been invoked with a non null argument.
+     *
+     * @return {@code true} if a preferred area has been set.
+     */
+    public final boolean hasPreferredArea() {
+        return preferredArea != null;
+    }
+
+    /**
+     * Returns the logical coordinates of the region to display the first time that {@code ZoomPane} is shown.
+     * This region will also be displayed each time the method {@link #reset()} is invoked.
+     * The default implementation goes as follows:
+     *
+     * <ul>
+     *   <li>If a region has already been defined by a call to {@link #setPreferredArea(Rectangle2D)},
+     *       this region will be returned.</li>
+     *   <li>If not, the whole region {@link #getArea()} will be returned.</li>
+     * </ul>
+     *
+     * @return the logical coordinates of the region to be initially displayed,
+     *         or {@code null} if these coordinates are unknown.
+     */
+    public final Rectangle2D getPreferredArea() {
+        return (preferredArea != null) ? (Rectangle2D) preferredArea.clone() : getArea();
+    }
+
+    /**
+     * Specifies the logical coordinates of the region to display the first time that {@code ZoomPane} is shown.
+     * This region will also be displayed when {@link #reset()} method is invoked.
+     *
+     * @param  area  the logical coordinates of the region to be initially displayed, or {@code null}.
+     */
+    public final void setPreferredArea(final Rectangle2D area) {
+        if (area == null) {
+            preferredArea = null;
+        } else if (isValid(area)) {
+            final Object oldArea;
+            if (preferredArea == null) {
+                oldArea = null;
+                preferredArea = new Rectangle2D.Double();
+            }
+            else oldArea = preferredArea.clone();
+            preferredArea.setRect(area);
+            firePropertyChange("preferredArea", oldArea, area);
+            debug("setPreferredArea", area);
+        } else {
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.EmptyArgument_1, "area"));
+        }
+    }
+
+    /**
+     * Returns the logical coordinates of the region currently shown. In the case of a map,
+     * the logical coordinates can be expressed in degrees of latitude/longitude or in metres
+     * if a cartographic projection has been applied.
+     *
+     * @return the region currently shown, in logical coordinates.
+     */
+    public final Rectangle2D getVisibleArea() {
+        return getVisibleArea(getZoomableBounds());
+    }
+
+    /**
+     * Implementation of {@link #getVisibleArea()}.
+     */
+    private Rectangle2D getVisibleArea(final Rectangle zoomableBounds) {
+        if (zoomableBounds.isEmpty()) {
+            return (Rectangle2D) visibleArea.clone();
+        }
+        Rectangle2D visible;
+        try {
+            visible = AffineTransforms2D.inverseTransform(zoom, zoomableBounds, null);
+        } catch (NoninvertibleTransformException exception) {
+            unexpectedException("getVisibleArea", exception);
+            visible = new Rectangle2D.Double(zoomableBounds.getCenterX(),
+                                             zoomableBounds.getCenterY(), 0, 0);
+        }
+        visibleArea.setRect(visible);
+        return visible;
+    }
+
+    /**
+     * Zooms to a given region specified in logical coordinates.
+     * This method modifies the zoom and the translation in order to display the specified region.
+     * If {@link #zoom} contains a rotation, this rotation will not be modified.
+     *
+     * @param  logicalBounds  logical coordinates of the region to be shown.
+     * @throws IllegalArgumentException if {@code source} is empty.
+     */
+    public void setVisibleArea(final Rectangle2D logicalBounds) throws IllegalArgumentException {
+        debug("setVisibleArea", logicalBounds);
+        transform(setVisibleArea(logicalBounds, getZoomableBounds(), 0));
+    }
+
+    /**
+     * Implementation of {@link #setVisibleArea(Rectangle2D)}.
+     *
+     * @param  source  logical coordinates of the region to be shown.
+     * @param  dest    pixel coordinates of the window region where to draw (usually {@link #getZoomableBounds()}).
+     * @param  mask    a mask to combine with the {@link #allowedActions} for determining which transformations are
+     *                 allowed. The {@link #allowedActions} is not modified.
+     * @return change to apply to the {@link #zoom} affine transform.
+     * @throws IllegalArgumentException if {@code source} is empty.
+     */
+    private AffineTransform setVisibleArea(Rectangle2D source, Rectangle2D dest, int mask) throws IllegalArgumentException {
+        /*
+         * Reject invalid source rectangle, but be more flexible for destination
+         * rectangle because the window could have been resized by the user.
+         */
+        if (!isValid(source)) {
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.EmptyArgument_1, "source"));
+        }
+        if (!isValid(dest)) {
+            return new AffineTransform();
+        }
+        /*
+         * Converts the destination into logical coordinates, then apply
+         * a zoom and a translation mapping `source` into `dest`.
+         */
+        try {
+            dest = AffineTransforms2D.inverseTransform(zoom, dest, null);
+        } catch (NoninvertibleTransformException exception) {
+            unexpectedException("setVisibleArea", exception);
+            return new AffineTransform();
+        }
+        final double sourceWidth  = source.getWidth ();
+        final double sourceHeight = source.getHeight();
+        final double destWidth    =   dest.getWidth ();
+        final double destHeight   =   dest.getHeight();
+        double sx = destWidth / sourceWidth;
+        double sy = destHeight / sourceHeight;
+        /*
+         * Uniformize the horizontal and vertical scales,
+         * if such a uniformization has been requested.
+         */
+        mask |= allowedActions;
+        if ((mask & UNIFORM_SCALE) == UNIFORM_SCALE) {
+            if (fillPanel) {
+                if (sy * sourceWidth  > destWidth ) {
+                    sx = sy;
+                } else if (sx * sourceHeight > destHeight) {
+                    sy = sx;
+                }
+            } else {
+                if (sy * sourceWidth  < destWidth ) {
+                    sx = sy;
+                } else if (sx * sourceHeight < destHeight) {
+                    sy = sx;
+                }
+            }
+        }
+        final AffineTransform change = AffineTransform.getTranslateInstance(
+                         (mask & TRANSLATE_X) != 0 ? dest.getCenterX()    : 0,
+                         (mask & TRANSLATE_Y) != 0 ? dest.getCenterY()    : 0);
+        change.scale    ((mask & SCALE_X    ) != 0 ? sx                   : 1,
+                         (mask & SCALE_Y    ) != 0 ? sy                   : 1);
+        change.translate((mask & TRANSLATE_X) != 0 ? -source.getCenterX() : 0,
+                         (mask & TRANSLATE_Y) != 0 ? -source.getCenterY() : 0);
+        roundIfAlmostInteger(change);
+        return change;
+    }
+
+    /**
+     * Returns the bounding box (in pixel coordinates) of the zoomable area.
+     * <strong>For performance reasons, this method reuses an internal cache.
+     * Never modify the returned rectangle!</strong>. This internal method is
+     * invoked by every method looking for the {@code ZoomPane} dimension.
+     *
+     * @return the bounding box of the zoomable area, in pixel coordinates relative to this {@code ZoomPane} widget.
+     *         <strong>Do not change the returned rectangle!</strong>
+     */
+    private Rectangle getZoomableBounds() {
+        return cachedBounds = getZoomableBounds(cachedBounds);
+    }
+
+    /**
+     * Returns the bounding box (in pixel coordinates) of the zoomable area. This method is similar
+     * to {@link #getBounds(Rectangle)}, except that the zoomable area may be smaller than the whole
+     * widget area. For example, a chart needs to keep some space for axes around the zoomable area.
+     * Another difference is that pixel coordinates are relative to the widget, i.e. the (0,0)
+     * coordinate lies on the {@code ZoomPane} upper left corner, no matter the location on screen.
+     *
+     * <p>{@code ZoomPane} invokes {@code getZoomableBounds(…)} when it needs to set up an initial {@link #zoom} value.
+     * Subclasses should also set the clip area to this bounding box in their {@link #paintComponent(Graphics2D)}
+     * method <em>before</em> setting the graphics transform. For example:</p>
+     *
+     * {@preformat java
+     *     graphics.clip(getZoomableBounds(null));
+     *     graphics.transform(zoom);
+     * }
+     *
+     * @param  bounds  an optional pre-allocated rectangle, or {@code null} to create a new one.
+     * @return the bounding box of the zoomable area, in pixel coordinates relative to this {@code ZoomPane} widget.
+     */
+    protected Rectangle getZoomableBounds(Rectangle bounds) {
+        Insets insets;
+        bounds = getBounds(bounds); insets = cachedInsets;
+        insets = getInsets(insets); cachedInsets = insets;
+        if (bounds.isEmpty()) {
+            final Dimension size = getPreferredSize();
+            bounds.width  = size.width;
+            bounds.height = size.height;
+        }
+        bounds.x       =  insets.left;
+        bounds.y       =  insets.top;
+        bounds.width  -= (insets.left + insets.right);
+        bounds.height -= (insets.top + insets.bottom);
+        return bounds;
+    }
+
+    /**
+     * Returns the default size for this component. This is the size returned by {@link #getPreferredSize()}
+     * if no preferred size has been explicitly set with {@link #setPreferredSize(Dimension)}.
+     *
+     * @return the default size for this component.
+     */
+    protected Dimension getDefaultSize() {
+        return getViewSize();
+    }
+
+    /**
+     * Returns the preferred pixel size for a close zoom. For image rendering, the preferred pixel size
+     * is the image's pixel size in logical units. For other kinds of rendering, this "pixel" size should
+     * be some reasonable resolution. The default implementation computes a default value from {@link #getArea()}.
+     *
+     * @return the preferred pixel size for a close zoom, in logical units.
+     */
+    protected Dimension2D getPreferredPixelSize() {
+        final Rectangle2D area = getArea();
+        if (isValid(area)) {
+            final double sx = area.getWidth () / (10 * getWidth ());
+            final double sy = area.getHeight() / (10 * getHeight());
+            return new DoubleDimension2D(sx, sy);
+        } else {
+            return new Dimension(1, 1);
+        }
+    }
+
+    /**
+     * Returns the current {@link #zoom} scale factor. For example a value of 1/100 means that 100 metres are
+     * displayed as 1 pixel (assuming that the logical coordinates of {@link #getArea()} are expressed in metres).
+     *
+     * <p>This method combines scale along both axes, which is correct if this {@code ZoomPane} has
+     * been constructed with the {@link #UNIFORM_SCALE} type.</p>
+     *
+     * @return the current scale factor calculated from the {@link #zoom} affine transform.
+     */
+    public double getScaleFactor() {
+        return AffineTransforms2D.getScale(zoom);
+    }
+
+    /**
+     * Returns a clone of the current {@link #zoom} transform.
+     *
+     * @return a clone of the current transform.
+     */
+    public AffineTransform getTransform() {
+        return new AffineTransform(zoom);
+    }
+
+    /**
+     * Sets the {@link #zoom} transform to the given value. The default implementation computes an affine transform
+     * which is the change needed for going from the current {@linkplain #zoom} to the given transform, then calls
+     * {@link #transform(AffineTransform)} with that change. This is done that way for giving listeners a chance to
+     * track the changes.
+     *
+     * @param  tr  the new transform.
+     */
+    public void setTransform(final AffineTransform tr) {
+        final AffineTransform change;
+        try {
+            change = zoom.createInverse();
+        } catch (NoninvertibleTransformException exception) {
+            /*
+             * Invoke the static method because we will not be able to invoke fireZoomChanged(…).
+             * This is because we can not compute the change.
+             */
+            unexpectedException("setTransform", (Exception) exception);
+            zoom.setTransform(tr);
+            return;
+        }
+        change.concatenate(tr);
+        roundIfAlmostInteger(change);
+        transform(change);
+    }
+
+    /**
+     * Changes the {@linkplain #zoom} by applying an affine transform. The {@code change} transform
+     * must express a change in logical units, for example, a translation in metres.
+     * This method is conceptually similar to the following code:
+     *
+     * {@preformat java
+     *     zoom.concatenate(change);
+     *     fireZoomChanged(change);
+     *     repaint(getZoomableBounds(null));
+     * }
+     *
+     * If {@code change} is the identity transform, then this method does nothing and listeners are not notified.
+     *
+     * @param  change  the zoom change as an affine transform in logical coordinates.
+     */
+    public void transform(final AffineTransform change) {
+        if (!change.isIdentity()) {
+            zoom.concatenate(change);
+            roundIfAlmostInteger(zoom);
+            fireZoomChanged(change);
+            if (!disableRepaint) {
+                repaint(getZoomableBounds());
+            }
+            zoomIsReset = false;
+        }
+    }
+
+    /**
+     * Changes the {@linkplain #zoom} by applying an affine transform. The {@code change} transform
+     * must express a change in pixel units, for example a scrolling of 6 pixels toward right.
+     * This method is conceptually similar to the following code:
+     *
+     * {@preformat java
+     *     zoom.preConcatenate(change);
+     *     // Converts the change from pixel to logical units
+     *     AffineTransform logical = zoom.createInverse();
+     *     logical.concatenate(change);
+     *     logical.concatenate(zoom);
+     *     fireZoomChanged(logical);
+     *     repaint(getZoomableBounds(null));
+     * }
+     *
+     * If {@code change} is the identity transform, then this method does nothing and listeners are not notified.
+     *
+     * @param  change  the zoom change, as an affine transform in pixel coordinates.
+     */
+    public void transformPixels(final AffineTransform change) {
+        if (!change.isIdentity()) {
+            final AffineTransform logical;
+            try {
+                logical = zoom.createInverse();
+            } catch (NoninvertibleTransformException exception) {
+                throw new IllegalStateException(exception);
+            }
+            logical.concatenate(change);
+            logical.concatenate(zoom);
+            roundIfAlmostInteger(logical);
+            transform(logical);
+        }
+    }
+
+    /**
+     * Applies a zoom, translation or rotation on the {@code ZoomPane} content.
+     * The type of operation depends on the {@code operation} argument:
+     *
+     * <ul>
+     *   <li>{@link #TRANSLATE_X} applies a translation along the <var>x</var> axis.
+     *       The {@code amount} argument specifies the translation in number of pixels.
+     *       A negative value moves to the left whilst a positive value moves to the right.</li>
+     *   <li>{@link #TRANSLATE_Y} applies a translation along the <var>y</var> axis.
+     *       The {@code amount} argument specifies the translation in number of pixels.
+     *       A negative value moves upwards whilst a positive value moves downwards.</li>
+     *   <li>{@link #UNIFORM_SCALE} applies a zoom. The {@code amount} argument specifies the
+     *       type of zoom to perform. A value greater than 1 will perform a zoom in whilst a value
+     *       between 0 and 1 will perform a zoom out.</li>
+     *   <li>{@link #ROTATE} carries out a rotation.
+     *       The {@code amount} argument specifies the rotation angle in radians.</li>
+     *   <li>{@link #RESET} restore the zoom to a default scale, rotation and translation.
+     *       This operation displays all, or almost all, the contents of {@code ZoomPane}.</li>
+     *   <li>{@link #DEFAULT_ZOOM} applies a default zoom, close to the maximum zoom, which shows
+     *       the details of the contents of {@code ZoomPane} but without enlarging them too much.</li>
+     * </ul>
+     *
+     * @param  operation  type of operation to perform.
+     * @param  amount     ({@link #TRANSLATE_X} and {@link #TRANSLATE_Y}) translation in pixels,
+     *                    ({@link #SCALE_X} and {@link #SCALE_Y}) scale factor or
+     *                    ({@link #ROTATE}) rotation angle in radians.
+     *                    In other cases, this argument is ignored and can be {@link Double#NaN}.
+     * @param  center     zoom center ({@link #SCALE_X} and {@link #SCALE_Y}) or
+     *                    rotation center ({@link #ROTATE}), in pixel coordinates.
+     *                    The {@code null} value indicates a default value, more often the window center.
+     * @throws UnsupportedOperationException if the {@code operation} argument is not recognized.
+     */
+    private void transform(final int operation, final double amount, final Point2D center)
+            throws UnsupportedOperationException
+    {
+        if ((operation & (RESET)) != 0) {
+            /////////////////////
+            ////    RESET    ////
+            /////////////////////
+            if ((operation & ~(RESET)) != 0) {
+                throw new UnsupportedOperationException();
+            }
+            reset();
+            return;
+        }
+        final AffineTransform change;
+        try {
+            change = zoom.createInverse();
+        } catch (NoninvertibleTransformException exception) {
+            unexpectedException("transform", exception);
+            return;
+        }
+        if ((operation & (TRANSLATE_X | TRANSLATE_Y)) != 0) {
+            /////////////////////////
+            ////    TRANSLATE    ////
+            /////////////////////////
+            if ((operation & ~(TRANSLATE_X | TRANSLATE_Y)) != 0) {
+                throw new UnsupportedOperationException();
+            }
+            change.translate(((operation & TRANSLATE_X) != 0) ? amount : 0,
+                             ((operation & TRANSLATE_Y) != 0) ? amount : 0);
+        } else {
+            /*
+             * Gets the coordinates (in pixels) of the rotation or zoom center.
+             */
+            final double centerX;
+            final double centerY;
+            if (center != null) {
+                centerX = center.getX();
+                centerY = center.getY();
+            } else {
+                final Rectangle bounds = getZoomableBounds();
+                if (bounds.width >= 0 && bounds.height >= 0) {
+                    centerX = bounds.getCenterX();
+                    centerY = bounds.getCenterY();
+                } else {
+                    return;
+                }
+                /*
+                 * Zero lengths and widths are accepted. however if the rectangle is not valid
+                 * (negative length or width) then the method will end without doing anything.
+                 * No zoom will be performed.
+                 */
+            }
+            if ((operation & (ROTATE)) != 0) {
+                //////////////////////
+                ////    ROTATE    ////
+                //////////////////////
+                if ((operation & ~(ROTATE)) != 0) {
+                    throw new UnsupportedOperationException();
+                }
+                change.rotate(amount, centerX, centerY);
+            } else if ((operation & (SCALE_X | SCALE_Y)) != 0) {
+                /////////////////////
+                ////    SCALE    ////
+                /////////////////////
+                if ((operation & ~(UNIFORM_SCALE)) != 0) {
+                    throw new UnsupportedOperationException();
+                }
+                change.translate(+centerX, +centerY);
+                change.scale(((operation & SCALE_X) != 0) ? amount : 1,
+                             ((operation & SCALE_Y) != 0) ? amount : 1);
+                change.translate(-centerX, -centerY);
+            } else if ((operation & (DEFAULT_ZOOM)) != 0) {
+                ////////////////////////////
+                ////    DEFAULT_ZOOM    ////
+                ////////////////////////////
+                if ((operation & ~(DEFAULT_ZOOM)) != 0) {
+                    throw new UnsupportedOperationException();
+                }
+                final Dimension2D size = getPreferredPixelSize();
+                double sx = 1 / (size.getWidth()  * AffineTransforms2D.getScaleX0(zoom));
+                double sy = 1 / (size.getHeight() * AffineTransforms2D.getScaleY0(zoom));
+                if ((allowedActions & UNIFORM_SCALE) == UNIFORM_SCALE) {
+                    if (sx > sy) sx = sy;
+                    if (sy > sx) sy = sx;
+                }
+                if ((allowedActions & SCALE_X) == 0) sx = 1;
+                if ((allowedActions & SCALE_Y) == 0) sy = 1;
+                change.translate(+centerX, +centerY);
+                change.scale    ( sx     ,  sy     );
+                change.translate(-centerX, -centerY);
+            } else {
+                throw new UnsupportedOperationException();
+            }
+        }
+        change.concatenate(zoom);
+        roundIfAlmostInteger(change);
+        transform(change);
+    }
+
+    /**
+     * Adds an object to the list of objects interested in being notified about zoom changes.
+     *
+     * @param  listener  the change listener to add.
+     */
+    public void addZoomChangeListener(final ZoomChangeListener listener) {
+        listenerList.add(ZoomChangeListener.class, listener);
+    }
+
+    /**
+     * Removes an object from the list of objects interested in being notified about zoom changes.
+     *
+     * @param  listener  the change listener to remove.
+     */
+    public void removeZoomChangeListener(final ZoomChangeListener listener) {
+        listenerList.remove(ZoomChangeListener.class, listener);
+    }
+
+    /**
+     * Adds an object to the list of objects interested in being notified about mouse events.
+     *
+     * @param  listener  the mouse listener to add.
+     */
+    @Override
+    public void addMouseListener(final MouseListener listener) {
+        super.removeMouseListener(mouseSelectionTracker);
+        super.addMouseListener   (listener);
+        super.addMouseListener   (mouseSelectionTracker);               // MUST be last!
+    }
+
+    /**
+     * Notifies all registered {@code ZoomListener}s that a zoom change occurred.
+     * If {@code oldZoom} and {@code newZoom} are the affine transforms of the old and new zoom respectively,
+     * the change can be computed in such a way that the following relation hold within rounding errors:
+     *
+     * {@preformat java
+     *     newZoom = oldZoom.concatenate(change)
+     * }
+     *
+     * <strong>Note: This method may modify the given {@code change} transform</strong> to combine several
+     * consecutive {@code fireZoomChanged(…)} calls in a single transformation.
+     *
+     * @param change  affine transform which represents the change in the zoom.
+     *                The given instance may be modified by this method call.
+     */
+    protected void fireZoomChanged(final AffineTransform change) {
+        visibleArea.setRect(getVisibleArea());
+        fireZoomChanged0(change);
+    }
+
+    /**
+     * Notifies all registered {@code ZoomListener}s that a zoom change occurred.
+     * Unlike the protected {@link #fireZoomChanged(AffineTransform)} method, this private method does not modify any
+     * internal field and does not attempt to call other {@code ZoomPane} methods such as {@link #getVisibleArea()}.
+     * This restriction avoid an infinite loop when this method is invoked by {@link #reset()}.
+     */
+    private void fireZoomChanged0(final AffineTransform change) {
+        /*
+         * Note: the event must be fired even if the transformation is the identity matrix,
+         *       because some classes use it for updating scrollbars.
+         */
+        if (change == null) {
+            throw new NullArgumentException();
+        }
+        ZoomChangeEvent event = null;
+        final Object[] listeners = listenerList.getListenerList();
+        for (int i = listeners.length; (i -= 2) >= 0;) {
+            if (listeners[i] == ZoomChangeListener.class) {
+                if (event == null) {
+                    event = new ZoomChangeEvent(this, change);
+                }
+                try {
+                    ((ZoomChangeListener) listeners[i+1]).zoomChanged(event);
+                } catch (RuntimeException exception) {
+                    unexpectedException("fireZoomChanged", exception);
+                }
+            }
+        }
+    }
+
+    /**
+     * Invoked when user selected an area with the mouse.
+     * The default implementation zooms to the selected {@code area}.
+     * Subclasses can override this method in order to perform another action.
+     *
+     * @param  area  area selected by the user, in logical coordinates.
+     */
+    protected void mouseSelectionPerformed(final Shape area) {
+        final Rectangle2D rect = (area instanceof Rectangle2D) ? (Rectangle2D) area : area.getBounds2D();
+        if (isValid(rect)) {
+            setVisibleArea(rect);
+        }
+    }
+
+    /**
+     * Returns the geometric shape to draw when user is delimitating an area. The shape is often a {@link Rectangle2D}
+     * but could also be an {@link java.awt.geom.Ellipse2D} or some other kinds of shape. The important aspect is the
+     * shape class and parameters not related to its position (e.g. arc size in a {@link RoundRectangle2D}).
+     * The width, height, <var>x</var> and <var>y</var> coordinates will be ignored and overwritten.
+     *
+     * <p>The returned shape should be either an instance of {@link java.awt.geom.RectangularShape} or
+     * {@link java.awt.geom.Line2D}. Other classes may cause a {@link ClassCastException} to be thrown.</p>
+     *
+     * <p>The default implementation returns a {@link Rectangle2D} instance.</p>
+     *
+     * @param  point  logical coordinates of the mouse at the moment the button is pressed.
+     * @return shape as an instance of {@link java.awt.geom.RectangularShape} or {@link java.awt.geom.Line2D},
+     *         or {@code null} for disabling selection by area.
+     */
+    protected Shape getMouseSelectionShape(final Point2D point) {
+        return new Rectangle2D.Float();
+    }
+
+    /**
+     * Indicates whether the magnifying glass is allowed to be shown on this component.
+     * By default, it is allowed.
+     *
+     * @return {@code true} if the magnifying glass is allowed to be shown.
+     */
+    public boolean isMagnifierEnabled() {
+        return magnifierEnabled;
+    }
+
+    /**
+     * Specifies whether the magnifying glass is allowed to be shown on this component.
+     * A {@code false} value hides the magnifying glass, removes the "Display magnifying glass"
+     * choice from the contextual menu and causes
+     * <code>{@linkplain #setMagnifierVisible setMagnifierVisible}(true)</code>
+     * calls to be ignored.
+     *
+     * @param  enabled  whether magnifying glass is allowed to be show.
+     */
+    public void setMagnifierEnabled(final boolean enabled) {
+        magnifierEnabled = enabled;
+        navigationPopupMenu = null;
+        if (!enabled) {
+            setMagnifierVisible(false);
+        }
+    }
+
+    /**
+     * Indicates whether the magnifying glass is currently shown. By default, it is not visible.
+     * Invoke {@link #setMagnifierVisible(boolean)} to make it appear.
+     *
+     * @return whether the magnifying glass is currently shown.
+     */
+    public boolean isMagnifierVisible() {
+        return magnifier != null;
+    }
+
+    /**
+     * Shows or hides the magnifying glass. If the magnifying glass is not yet shown and this method is invoked
+     * with the {@code true} argument value, then the magnifying glass will appear at the window center.
+     *
+     * @param  visible  whether to show the magnifying glass.
+     */
+    public void setMagnifierVisible(final boolean visible) {
+        setMagnifierVisible(visible, null);
+    }
+
+    /**
+     * Returns the color with which to tint magnifying glass interior.
+     *
+     * @return the current color of magnifying glass interior.
+     */
+    public Paint getMagnifierGlass() {
+        return magnifierGlass;
+    }
+
+    /**
+     * Sets the color with which to tint magnifying glass interior.
+     *
+     * @param  color  the new color of magnifying glass interior.
+     */
+    public void setMagnifierGlass(final Paint color) {
+        final Paint old = magnifierGlass;
+        magnifierGlass = color;
+        firePropertyChange("magnifierGlass", old, color);
+    }
+
+    /**
+     * Returns the color of the magnifying glass border.
+     *
+     * @return the current color of the magnifying glass border.
+     */
+    public Paint getMagnifierBorder() {
+        return magnifierBorder;
+    }
+
+    /**
+     * Sets the color of the magnifying glass border.
+     *
+     * @param  color  the new color of the magnifying glass border.
+     */
+    public void setMagnifierBorder(final Paint color) {
+        final Paint old = magnifierBorder;
+        magnifierBorder = color;
+        firePropertyChange("magnifierBorder", old, color);
+    }
+
+    /**
+     * Returns the scale factor that has been applied on the {@link Graphics2D} before invoking
+     * {@link #paintComponent(Graphics2D)}. This is always 1, except when painting the content
+     * inside magnifier glass.
+     */
+    final double getGraphicsScale() {
+        return (renderingType == IS_PAINTING_MAGNIFIER) ? magnifierPower : 1;
+    }
+
+    /**
+     * Corrects a pixel coordinates for removing the effect of the magnifying glass. Without this
+     * method, transformations from pixels to geographic coordinates would not give accurate results
+     * for pixels inside the magnifying glass because the glass moves the apparent pixel position.
+     * Invoking this method removes deformation effects using the following steps:
+     *
+     * <ul>
+     *   <li>If the given pixel coordinates are outside the magnifying glass,
+     *       then this method do nothing.</li>
+     *   <li>Otherwise, this method update {@code point} in such a way that it contains the position
+     *       that the same pixel would have in the absence of magnifying glass.</li>
+     * </ul>
+     *
+     * @param point  on input, a pixel coordinates as it appears on the screen. On output, the
+     *        coordinates that the same pixel would have if the magnifying glass was not presents.
+     */
+    @Override
+    public void correctApparentPixelPosition(final Point2D point) {
+        if (magnifier != null && magnifier.contains(point)) {
+            final double centerX = magnifier.getCenterX();
+            final double centerY = magnifier.getCenterY();
+            /*
+             * The following code is equivalent to the following transformations, which
+             * must be identical to those which are applied in paintMagnifier(...).
+             *
+             *     translate(+centerX, +centerY);
+             *     scale    (magnifierPower, magnifierPower);
+             *     translate(-centerX, -centerY);
+             *     inverseTransform(point, point);
+             */
+            point.setLocation((point.getX() - centerX) / magnifierPower + centerX,
+                              (point.getY() - centerY) / magnifierPower + centerY);
+        }
+    }
+
+    /**
+     * Shows or hides the magnifying glass. The magnifying glass will be shown centered on the
+     * specified coordinate if non-null, or in the screen center if {@code center} is null.
+     *
+     * @param visible  {@code true} to show the magnifying glass or {@code false} to hide it.
+     * @param center   central coordinate for the magnifying glass.
+     */
+    private void setMagnifierVisible(final boolean visible, final Point center) {
+        MouseReshapeTracker magnifier = this.magnifier;
+        if (visible && magnifierEnabled) {
+            if (magnifier == null) {
+                Rectangle bounds = getZoomableBounds(); // Do not modify the Rectangle!
+                if (bounds.isEmpty()) bounds = new Rectangle(0, 0, DEFAULT_SIZE, DEFAULT_SIZE);
+                final int size = Math.min(Math.min(bounds.width, bounds.height), DEFAULT_MAGNIFIER_SIZE);
+                final int x, y;
+                if (center != null) {
+                    x = center.x - size / 2;
+                    y = center.y - size / 2;
+                } else {
+                    x = bounds.x + (bounds.width - size) / 2;
+                    y = bounds.y + (bounds.height - size) / 2;
+                }
+                this.magnifier = magnifier = new MouseReshapeTracker(new RoundRectangle2D.Float(x, y, size, size, 24, 24)) {
+                    @Override protected void stateWillChange(final boolean isAdjusting) {repaintMagnifier();}
+                    @Override protected void stateChanged   (final boolean isAdjusting) {repaintMagnifier();}
+                };
+                magnifier.setClip(bounds);
+                magnifier.setAdjustable(SwingConstants.NORTH, true);
+                magnifier.setAdjustable(SwingConstants.SOUTH, true);
+                magnifier.setAdjustable(SwingConstants.EAST , true);
+                magnifier.setAdjustable(SwingConstants.WEST , true);
+
+                addMouseListener      (magnifier);
+                addMouseMotionListener(magnifier);
+                firePropertyChange("magnifierVisible", Boolean.FALSE, Boolean.TRUE);
+                repaintMagnifier();
+            } else if (center != null) {
+                final Rectangle2D frame = magnifier.getFrame();
+                final double width  = frame.getWidth();
+                final double height = frame.getHeight();
+                magnifier.setFrame(center.x - 0.5 * width,
+                                   center.y - 0.5 * height, width, height);
+            }
+        } else if (magnifier != null) {
+            repaintMagnifier();
+            removeMouseMotionListener(magnifier);
+            removeMouseListener      (magnifier);
+            setCursor(null);
+            this.magnifier = null;
+            firePropertyChange("magnifierVisible", Boolean.TRUE, Boolean.FALSE);
+        }
+    }
+
+    /**
+     * Inserts navigation options to the specified menu. Default implementation adds menu items
+     * such as "Zoom in" and "Zoom out" together with associated short-cut keys.
+     *
+     * @param  menu  the menu in which to add navigation options.
+     */
+    public void buildNavigationMenu(final JMenu menu) {
+        buildNavigationMenu(menu, null);
+    }
+
+    /**
+     * Implementation of {@link #buildNavigationMenu(JMenu)}.
+     */
+    private void buildNavigationMenu(final JMenu menu, final JPopupMenu popup) {
+        int groupIndex = 0;
+        boolean firstMenu = true;
+        final ActionMap actionMap = getActionMap();
+        for (int i=0; i<ACTION_ID.length; i++) {
+            final Action action = actionMap.get(ACTION_ID[i]);
+            if (action!=null && action.getValue(Action.NAME)!=null) {
+                /*
+                 * Checks whether the next item belongs to a new group.
+                 * If this is the case, it will be necessary to add a separator before the next menu.
+                 */
+                final int lastGroupIndex = groupIndex;
+                while ((ACTION_TYPE[i] & GROUP[groupIndex]) == 0) {
+                    groupIndex = (groupIndex+1) % GROUP.length;
+                    if (groupIndex == lastGroupIndex) {
+                        break;
+                    }
+                }
+                /*
+                 * Adds an item to the menu.
+                 */
+                if (menu != null) {
+                    if (groupIndex!=lastGroupIndex && !firstMenu) {
+                        menu.addSeparator();
+                    }
+                    final JMenuItem item = new JMenuItem(action);
+                    item.setAccelerator((KeyStroke) action.getValue(Action.ACCELERATOR_KEY));
+                    menu.add(item);
+                }
+                if (popup != null) {
+                    if (groupIndex!=lastGroupIndex && !firstMenu) {
+                        popup.addSeparator();
+                    }
+                    final JMenuItem item = new JMenuItem(action);
+                    item.setAccelerator((KeyStroke) action.getValue(Action.ACCELERATOR_KEY));
+                    popup.add(item);
+                }
+                firstMenu = false;
+            }
+        }
+    }
+
+    /**
+     * Menu with mouse coordinates where user clicked when this menu has been shown.
+     */
+    @SuppressWarnings("serial")
+    private static final class PointPopupMenu extends JPopupMenu {
+        /**
+         * Coordinates of the point where user clicked.
+         */
+        public final Point point;
+
+        /**
+         * Creates a menu associated to the given mouse coordinates.
+         */
+        public PointPopupMenu(final Point point) {
+            this.point = point;
+        }
+    }
+
+    /**
+     * Invoked when user clicks on the right mouse button.
+     * The default implementation shows a contextual menu containing navigation options.
+     *
+     * @param  event  mouse event containing mouse coordinates in geographic coordinates
+     *                together with pixel coordinates.
+     * @return the contextual menu to show, or {@code null} if none.
+     */
+    protected JPopupMenu getPopupMenu(final MouseEvent event) {
+        if (getZoomableBounds().contains(event.getX(), event.getY())) {
+            if (navigationPopupMenu == null) {
+                navigationPopupMenu = new PointPopupMenu(event.getPoint());
+                if (magnifierEnabled) {
+                    final Resources resources = Resources.forLocale(getLocale());
+                    final JMenuItem item = new JMenuItem(
+                            resources.getString(Resources.Keys.ShowMagnifier));
+                    item.addActionListener((final ActionEvent event1) ->
+                            setMagnifierVisible(true, navigationPopupMenu.point));
+                    navigationPopupMenu.add(item);
+                    navigationPopupMenu.addSeparator();
+                }
+                buildNavigationMenu(null, navigationPopupMenu);
+            } else {
+                navigationPopupMenu.point.x = event.getX();
+                navigationPopupMenu.point.y = event.getY();
+            }
+            return navigationPopupMenu;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Invoked when user clicks on the right mouse button inside the magnifying glass.
+     * The default implementation shows a contextual menu which contains magnifying glass options.
+     *
+     * @param  event  mouse event containing mouse coordinates in geographic coordinates
+     *                together with pixel coordinates.
+     * @return the contextual menu to show, or {@code null} if none.
+     */
+    protected JPopupMenu getMagnifierMenu(final MouseEvent event) {
+        final Resources resources = Resources.forLocale(getLocale());
+        final JPopupMenu menu = new JPopupMenu(resources.getString(Resources.Keys.Magnifier));
+        final JMenuItem  item = new JMenuItem (resources.getString(Resources.Keys.Hide));
+        item.addActionListener((final ActionEvent event1) -> setMagnifierVisible(false));
+        menu.add(item);
+        return menu;
+    }
+
+    /**
+     * Shows the navigation contextual menu, provided the mouse event described the expected action.
+     */
+    private void mayShowPopupMenu(final MouseEvent event) {
+        if (event.getID() == MouseEvent.MOUSE_PRESSED &&
+                (event.getModifiersEx() & MouseEvent.BUTTON1_DOWN_MASK) != 0)
+        {
+            requestFocus();
+        }
+        if (event.isPopupTrigger()) {
+            final Point point      = event.getPoint();
+            final JPopupMenu popup = (magnifier != null && magnifier.contains(point)) ?
+                    getMagnifierMenu(event) : getPopupMenu(event);
+            if (popup != null) {
+                final Component source  = event.getComponent();
+                final Window    window  = SwingUtilities.getWindowAncestor(source);
+                if (window != null) {
+                    final Toolkit   toolkit = source.getToolkit();
+                    final Insets    insets  = toolkit.getScreenInsets(window.getGraphicsConfiguration());
+                    final Dimension screen  = toolkit.getScreenSize();
+                    final Dimension size    = popup.getPreferredSize();
+                    SwingUtilities.convertPointToScreen(point, source);
+                    screen.width  -= (size.width  + insets.right);
+                    screen.height -= (size.height + insets.bottom);
+                    if (point.x > screen.width)  point.x = screen.width;
+                    if (point.y > screen.height) point.y = screen.height;
+                    if (point.x < insets.left)   point.x = insets.left;
+                    if (point.y < insets.top)    point.y = insets.top;
+                    SwingUtilities.convertPointFromScreen(point, source);
+                    popup.show(source, point.x, point.y);
+                }
+            }
+        }
+    }
+
+    /**
+     * Invoked when user moves the mouse wheel.
+     * This method performs a zoom centered on the mouse position.
+     */
+    private void mouseWheelMoved(final MouseWheelEvent event) {
+        if (event.getScrollType() == MouseWheelEvent.WHEEL_UNIT_SCROLL) {
+            int rotation  = event.getUnitsToScroll();
+            double scale  = 1 + (AMOUNT_SCALE - 1) * Math.abs(rotation);
+            Point2D point = new Point2D.Double(event.getX(), event.getY());
+            if (rotation > 0) {
+                scale = 1 / scale;
+            }
+            if (magnifier != null && magnifier.contains(point)) {
+                magnifierPower *= scale;
+                repaintMagnifier();
+            } else {
+                correctApparentPixelPosition(point);
+                transform(UNIFORM_SCALE & allowedActions, scale, point);
+            }
+            event.consume();
+        }
+    }
+
+    /**
+     * Invoked when component size or position changed.
+     * The {@link #repaint()} method is not invoked because there is already a repaint command in the queue.
+     * The {@link #transform(AffineTransform)} method is not invoked neither because the zoom has not really
+     * changed; we have only uncovered a part of the window previously hidden.
+     * However, we still need to adjust the scrollbars.
+     */
+    private void processSizeEvent(final ComponentEvent event) {
+        if (zoomIsReset || !isValid(visibleArea)) {
+            disableRepaint = true;
+            try {
+                reset();
+            } finally {
+                disableRepaint = false;
+            }
+        }
+        if (magnifier != null) {
+            magnifier.setClip(getZoomableBounds());
+        }
+        final Object[] listeners = listenerList.getListenerList();
+        for (int i = listeners.length; (i-=2) >= 0;) {
+            if (listeners[i] == ZoomChangeListener.class) {
+                if (listeners[i + 1] instanceof Synchronizer) try {
+                    ((ZoomChangeListener) listeners[i + 1]).zoomChanged(null);
+                } catch (RuntimeException exception) {
+                    unexpectedException("processSizeEvent", exception);
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns an {@code ZoomPane} embedded in a component with scrollbars.
+     *
+     * @return a swing component showing this {@code ZoomPane} together with scrollbars.
+     */
+    public JComponent createScrollPane() {
+        return new ScrollPane();
+    }
+
+    /**
+     * Convenience method for getting a scrollbar model. Should actually be declared inside {@link ScrollPane},
+     * but we are not allowed to declare static methods in non-static inner classes.
+     */
+    private static BoundedRangeModel getModel(final JScrollBar bar) {
+        return (bar != null) ? bar.getModel() : null;
+    }
+
+    /**
+     * The scroll panel for {@link ZoomPane}. The standard {@link javax.swing.JScrollPane}
+     * class is not used because it is difficult to get {@link javax.swing.JViewport} to
+     * interact with transformations already handled by {@link ZoomPane#zoom}.
+     */
+    @SuppressWarnings("serial")
+    private final class ScrollPane extends JComponent implements PropertyChangeListener {
+        /**
+         * The horizontal scrollbar, or {@code null} if none.
+         */
+        private final JScrollBar scrollbarX;
+
+        /**
+         * The vertical scrollbar, or {@code null} if none.
+         */
+        private final JScrollBar scrollbarY;
+
+        /**
+         * Creates a scroll pane for the enclosing {@link ZoomPane}.
+         */
+        public ScrollPane() {
+            setOpaque(false);
+            setLayout(new GridBagLayout());
+            /*
+             * Sets up the scrollbars.
+             */
+            if ((allowedActions & TRANSLATE_X) != 0) {
+                scrollbarX = new JScrollBar(JScrollBar.HORIZONTAL);
+                scrollbarX.setUnitIncrement ((int) (AMOUNT_TRANSLATE));
+                scrollbarX.setBlockIncrement((int) (AMOUNT_TRANSLATE * ENHANCEMENT_FACTOR));
+            } else {
+                scrollbarX  = null;
+            }
+            if ((allowedActions & TRANSLATE_Y) != 0) {
+                scrollbarY = new JScrollBar(JScrollBar.VERTICAL);
+                scrollbarY.setUnitIncrement ((int) (AMOUNT_TRANSLATE));
+                scrollbarY.setBlockIncrement((int) (AMOUNT_TRANSLATE * ENHANCEMENT_FACTOR));
+            } else {
+                scrollbarY  = null;
+            }
+            /*
+             * Adds the scrollbars in the scroll pane.
+             */
+            final GridBagConstraints c = new GridBagConstraints();
+            if (scrollbarX != null) {
+                c.gridx = 0; c.weightx = 1;
+                c.gridy = 1; c.weighty = 0;
+                c.fill = GridBagConstraints.HORIZONTAL;
+                add(scrollbarX, c);
+            }
+            if (scrollbarY != null) {
+                c.gridx = 1; c.weightx = 0;
+                c.gridy = 0; c.weighty = 1;
+                c.fill = GridBagConstraints.VERTICAL;
+                add(scrollbarY, c);
+            }
+            if (scrollbarX != null && scrollbarY != null) {
+                final JComponent corner = new JPanel(false);
+                c.gridx = 1; c.weightx = 0;
+                c.gridy = 1; c.weighty = 0;
+                c.fill = GridBagConstraints.BOTH;
+                add(corner, c);
+            }
+            c.fill = GridBagConstraints.BOTH;
+            c.gridx = 0; c.weightx = 1;
+            c.gridy = 0; c.weighty = 1;
+            add(ZoomPane.this, c);
+        }
+
+        /**
+         * Invoked when this {@code ScrollPane} is added in a {@link Container}.
+         * This method registers all required listeners.
+         */
+        @Override
+        public void addNotify() {
+            super.addNotify();
+            tieModels(getModel(scrollbarX), getModel(scrollbarY));
+            ZoomPane.this.addPropertyChangeListener("zoom.insets", this);
+        }
+
+        /**
+         * Invoked when this {@code ScrollPane} is removed from a {@link Container}.
+         * This method unregisters all listeners.
+         */
+        @Override
+        public void removeNotify() {
+            ZoomPane.this.removePropertyChangeListener("zoom.insets", this);
+            untieModels(getModel(scrollbarX), getModel(scrollbarY));
+            super.removeNotify();
+        }
+
+        /**
+         * Invoked when the zoomable area changes.
+         * This method adjust scrollbar insets for keeping scrollbars aligned with zoomable area.
+         */
+        @Override
+        public void propertyChange(final PropertyChangeEvent event) {
+            final Insets old    = (Insets) event.getOldValue();
+            final Insets insets = (Insets) event.getNewValue();
+            final GridBagLayout layout = (GridBagLayout) getLayout();
+            if (scrollbarX != null && (old.left != insets.left || old.right != insets.right)) {
+                final GridBagConstraints c = layout.getConstraints(scrollbarX);
+                c.insets.left  = insets.left;
+                c.insets.right = insets.right;
+                layout.setConstraints(scrollbarX, c);
+                scrollbarX.invalidate();
+            }
+            if (scrollbarY != null && (old.top != insets.top || old.bottom != insets.bottom)) {
+                final GridBagConstraints c = layout.getConstraints(scrollbarY);
+                c.insets.top    = insets.top;
+                c.insets.bottom = insets.bottom;
+                layout.setConstraints(scrollbarY, c);
+                scrollbarY.invalidate();
+            }
+        }
+    }
+
+    /**
+     * Synchronizes the position and range of given models with the zoom position.
+     * The <var>x</var> and <var>y</var> models are associated with horizontal and vertical scrollbars.
+     * When a scrollbar position is adjusted, the zoom is adjusted accordingly.
+     * Conversely when the zoom is modified, the scrollbars position and range are adjusted accordingly.
+     *
+     * @param  x  model of the horizontal scrollbar or {@code null} if none.
+     * @param  y  model of the vertical scrollbar or {@code null} if none.
+     */
+    public void tieModels(final BoundedRangeModel x, final BoundedRangeModel y) {
+        if (x != null || y != null) {
+            final Synchronizer listener = new Synchronizer(x, y);
+            addZoomChangeListener(listener);
+            if (x != null) x.addChangeListener(listener);
+            if (y != null) y.addChangeListener(listener);
+        }
+    }
+
+    /**
+     * Removes synchronization between specified <var>x</var> and <var>y</var> models and enclosing {@code ZoomPane}.
+     * The {@link ChangeListener} and {@link ZoomChangeListener} objects that were created are deleted.
+     *
+     * @param  x  model of the horizontal scrollbar or {@code null} if none.
+     * @param  y  model of the vertical scrollbar or {@code null} if none.
+     */
+    public void untieModels(final BoundedRangeModel x, final BoundedRangeModel y) {
+        final EventListener[] listeners = getListeners(ZoomChangeListener.class);
+        for (int i = 0; i < listeners.length; i++) {
+            if (listeners[i] instanceof Synchronizer) {
+                final Synchronizer s = (Synchronizer) listeners[i];
+                if (s.xm == x && s.ym == y) {
+                    removeZoomChangeListener(s);
+                    if (x != null) x.removeChangeListener(s);
+                    if (y != null) y.removeChangeListener(s);
+                }
+            }
+        }
+    }
+
+    /**
+     * Object responsible for synchronizing a {@link javax.swing.JScrollPane} object with scrollbars.
+     * Whilst not generally useful, it would be possible to synchronize several pairs of
+     * {@link BoundedRangeModel} objects on one {@code ZoomPane} object.
+     */
+    private final class Synchronizer implements ChangeListener, ZoomChangeListener {
+        /**
+         * Model to synchronize with {@link ZoomPane}.
+         */
+        public final BoundedRangeModel xm, ym;
+
+        /**
+         * Indicates whether the scrollbars are being adjusted in response to {@link #zoomChanged}.
+         * If this is the case, {@link #stateChanged} must not make any other adjustments.
+         */
+        private transient boolean isAdjusting;
+
+        /**
+         * Cached {@code ZoomPane} bounds. Used in order to avoid too many object allocations on the heap.
+         */
+        private transient Rectangle bounds;
+
+        /**
+         * Constructs an object which synchronizes a pair of {@link BoundedRangeModel} with {@link ZoomPane}.
+         */
+        public Synchronizer(final BoundedRangeModel xm, final BoundedRangeModel ym) {
+            this.xm = xm;
+            this.ym = ym;
+        }
+
+        /**
+         * Invoked when the position of a scrollbars changed.
+         */
+        @Override
+        public void stateChanged(final ChangeEvent event) {
+            if (!isAdjusting) {
+                final boolean valueIsAdjusting = ((BoundedRangeModel) event.getSource()).getValueIsAdjusting();
+                if (paintingWhileAdjusting || !valueIsAdjusting) {
+                    /*
+                     * Scroll view coordinates are computed using the following steps:
+                     *
+                     *   1) Get the logical coordinates for the whole area.
+                     *   2) Transform to pixel space using current zoom.
+                     *   3) Clip to the scrollbar's position (in pixels).
+                     *   4) Transform back to the logical space.
+                     *   5) Set the visible area to the resulting rectangle.
+                     */
+                    Rectangle2D area = getArea();
+                    if (isValid(area)) {
+                        area = AffineTransforms2D.transform(zoom, area, null);
+                        double x = area.getX();
+                        double y = area.getY();
+                        double width, height;
+                        if (xm != null) {
+                            x    += xm.getValue();
+                            width = xm.getExtent();
+                        } else {
+                            width = area.getWidth();
+                        }
+                        if (ym != null) {
+                            y     += ym.getValue();
+                            height = ym.getExtent();
+                        } else {
+                            height = area.getHeight();
+                        }
+                        area.setRect(x, y, width, height);
+                        bounds = getBounds(bounds);
+                        try {
+                            area = AffineTransforms2D.inverseTransform(zoom, area, area);
+                            try {
+                                isAdjusting = true;
+                                transform(setVisibleArea(area, bounds=getBounds(bounds), 0));
+                            } finally {
+                                isAdjusting = false;
+                            }
+                        } catch (NoninvertibleTransformException exception) {
+                            unexpectedException("stateChanged", exception);
+                        }
+                    }
+                }
+                if (!valueIsAdjusting) {
+                    zoomChanged(null);
+                }
+            }
+        }
+
+        /**
+         * Invoked when the zoom changes.
+         *
+         * @param  change  ignored. Can be null.
+         */
+        @Override
+        public void zoomChanged(final ZoomChangeEvent change) {
+            if (!isAdjusting) {
+                Rectangle2D area = getArea();
+                if (isValid(area)) {
+                    area = AffineTransforms2D.transform(zoom, area, null);
+                    try {
+                        isAdjusting = true;
+                        setRangeProperties(xm, area.getX(), getWidth(),  area.getWidth());
+                        setRangeProperties(ym, area.getY(), getHeight(), area.getHeight());
+                    }
+                    finally {
+                        isAdjusting = false;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Adjusts the values of a model. The minimums and maximum values are adjusted as needed in order to include
+     * the given value and its range. This adjustment is necessary for avoiding chaotic behavior when suer drags
+     * the shape whilst a part of the graphic is outside the region initially specified by {@link #getArea()}.
+     */
+    private static void setRangeProperties(final BoundedRangeModel model,
+            final double value, final int extent, final double max)
+    {
+        if (model != null) {
+            final int pos = (int) Math.round(-value);
+            model.setRangeProperties(pos, extent, Math.min(0, pos),
+                    Math.max((int) Math.round(max), pos + extent), false);
+        }
+    }
+
+    /**
+     * Modifies the position in pixels of the {@code ZoomPane} visible part. {@code viewSize} is the size (in pixels)
+     * that {@code ZoomPane} would have if its visible area covered the whole region given by {@link #getArea()} with
+     * current zoom (Note: {@code viewSize} can be obtained by {@link #getPreferredSize()}
+     * if {@link #setPreferredSize(Dimension)} has not been invoked with a non-null value).
+     * Therefore, by definition, the conversion in pixel space of the region given by {@link #getArea()}
+     * would be <code>bounds = Rectangle(0, 0, viewSize.width, viewSize.height)</code>.
+     *
+     * <p>This {@code scrollRectToVisible(…)} method allows us to define the {@code bounds} sub-region
+     * to show in the {@code ZoomPane} window.</p>
+     *
+     * @param  rect  the region to be made visible.
+     */
+    @Override
+    public void scrollRectToVisible(final Rectangle rect) {
+        Rectangle2D area = getArea();
+        if (isValid(area)) {
+            area = AffineTransforms2D.transform(zoom, area, null);
+            area.setRect(area.getX() + rect.getX(), area.getY() + rect.getY(),
+                         rect.getWidth(), rect.getHeight());
+            try {
+                setVisibleArea(AffineTransforms2D.inverseTransform(zoom, area, area));
+            } catch (NoninvertibleTransformException exception) {
+                unexpectedException("scrollRectToVisible", exception);
+            }
+        }
+    }
+
+    /**
+     * Indicates whether this {@code ZoomPane} should be repainted when the user is still adjusting scrollbar slider.
+     * The scrollbars (or other models) are those which have been synchronized with this {@code ZoomPane} object by a
+     * call to the {@link #tieModels(BoundedRangeModel, BoundedRangeModel)} method. The default value is {@code false},
+     * which means that {@code ZoomPane} will wait until the user releases the slider before repainting.
+     *
+     * @return {@code true} if the zoom pane is painted while the user is scrolling.
+     */
+    public boolean isPaintingWhileAdjusting() {
+        return paintingWhileAdjusting;
+    }
+
+    /**
+     * Defines whether this {@code ZoomPane} should repaint its content when the user moves the scrollbar slider.
+     * A fast computer is recommended if this flag is to be set to {@code true}.
+     *
+     * @param  flag  {@code true} if the zoom pane should be painted while the user is scrolling.
+     */
+    public void setPaintingWhileAdjusting(final boolean flag) {
+        paintingWhileAdjusting = flag;
+    }
+
+    /**
+     * Notifies that a part of this pane needs to be repainted. This method overrides the method
+     * of the parent class for taking in account the case where the magnifying glass is shown.
+     */
+    @Override
+    public void repaint(final long tm, final int x, final int y, final int width, final int height) {
+        super.repaint(tm, x, y, width, height);
+        if (magnifier != null && magnifier.intersects(x, y, width, height)) {
+            /*
+             * If the part to paint is inside the magnifying glass, the zoom applied by the
+             * glass implies that we have to repaint a little more than that was requested.
+             */
+            repaintMagnifier();
+        }
+    }
+
+    /**
+     * Notifies that the magnifying glass needs to be repainted. A {@link #repaint()} action is performed
+     * with the bounds of the magnifying glass as coordinates (taking into account its outline).
+     */
+    private void repaintMagnifier() {
+        final Rectangle bounds = magnifier.getBounds();
+        bounds.x      -= 4;
+        bounds.y      -= 4;
+        bounds.width  += 8;
+        bounds.height += 8;
+        super.repaint(0, bounds.x, bounds.y, bounds.width, bounds.height);
+    }
+
+    /**
+     * Paints the magnifying glass. This method is invoked after {@link #paintComponent(Graphics2D)}
+     * if a magnifying glass is visible.
+     *
+     * @param  graphics  the graphics where to paint the magnifying glass.
+     */
+    protected void paintMagnifier(final Graphics2D graphics) {
+        final double centerX = magnifier.getCenterX();
+        final double centerY = magnifier.getCenterY();
+        final Stroke  stroke =  graphics.getStroke();
+        final Paint    paint =  graphics.getPaint();
+        graphics.setStroke(new BasicStroke(6));
+        graphics.setPaint (magnifierBorder);
+        graphics.draw     (magnifier);
+        graphics.setStroke(stroke);
+        graphics.clip     (magnifier); // Coordinates in pixels!
+        graphics.setPaint (magnifierGlass);
+        graphics.fill     (magnifier.getBounds2D());
+        graphics.setPaint (paint);
+        graphics.translate(+centerX, +centerY);
+        graphics.scale    (magnifierPower, magnifierPower);
+        graphics.translate(-centerX, -centerY);
+        // Note: the transformations performed here must be identical to those
+        //       performed in pixelToLogical(...).
+        paintComponent(graphics);
+    }
+
+    /**
+     * Paints this component. Subclass must override this method in order to draw the {@code ZoomPane} content.
+     * For most implementations, the first line in this method will be <code>graphics.transform({@linkplain #zoom})</code>.
+     *
+     * @param  graphics  the graphics where to paint this component.
+     */
+    protected abstract void paintComponent(final Graphics2D graphics);
+
+    /**
+     * Prints this component. The default implementation invokes {@link #paintComponent(Graphics2D)}.
+     *
+     * @param  graphics  the graphics where to print this component.
+     */
+    protected void printComponent(final Graphics2D graphics) {
+        paintComponent(graphics);
+    }
+
+    /**
+     * Paints this component. This method is declared final in order to avoid unintentional overriding.
+     * Override {@link #paintComponent(Graphics2D)} instead.
+     *
+     * @param  graphics  the graphics where to paint this component.
+     */
+    @Override
+    protected final void paintComponent(final Graphics graphics) {
+        renderingType = IS_PAINTING;
+        super.paintComponent(graphics);
+        /*
+         * The JComponent.paintComponent(…) method creates a temporary Graphics2D object,
+         * then calls ComponentUI.update(…) with that graphic as a parameter. This method
+         * clears the screen background then calls ComponentUI.paint(…). This last method
+         * has been redefined above (our {@link #UI} object) in such a way that it calls
+         * itself paintComponent(Graphics2D).
+         */
+        if (magnifier != null) {
+            renderingType = IS_PAINTING_MAGNIFIER;
+            super.paintComponent(graphics);
+        }
+    }
+
+    /**
+     * Prints this component. This method is declared final in order to avoid unintentional overriding.
+     * Override {@link #printComponent(Graphics2D)} instead.
+     *
+     * @param  graphics  the graphics where to print this component.
+     */
+    @Override
+    protected final void printComponent(final Graphics graphics) {
+        renderingType = IS_PRINTING;
+        super.paintComponent(graphics);
+        /*
+         * Do not invoke `super.printComponent` because we don't want
+         * above `paintComponent(…)` to be invoked.
+         */
+    }
+
+    /**
+     * Returns the size (in pixels) that {@code ZoomPane} would have if it was showing the whole region
+     * specified by {@link #getArea()} with the current zoom ({@link #zoom}). This method is useful for
+     * determining the maximum values to assign to the scrollbars.
+     * For example, the horizontal bar could cover the {@code [0..viewSize.width]} range
+     * whilst the vertical bar could cover the {@code [0..viewSize.height]} range.
+     */
+    private Dimension getViewSize() {
+        if (!visibleArea.isEmpty()) {
+            Rectangle2D area = getArea();
+            if (isValid(area)) {
+                area = AffineTransforms2D.transform(zoom, area, null);
+                return new Dimension((int) Math.rint(area.getWidth()),
+                                     (int) Math.rint(area.getHeight()));
+            }
+            return getSize();
+        }
+        return new Dimension(DEFAULT_SIZE, DEFAULT_SIZE);
+    }
+
+    /**
+     * Returns the insets of this component.
+     * If different insets are desired, override {@link #getInsets(Insets)} instead than this method.
+     */
+    @Override
+    public final Insets getInsets() {
+        return getInsets(null);
+    }
+
+    /**
+     * Notifies this {@code ZoomPane} that the GUI has changed. Users should not call this method directly.
+     */
+    @Override
+    public void updateUI() {
+        navigationPopupMenu = null;
+        super.updateUI();
+        setUI(UI);
+    }
+
+    /**
+     * Invoked when an affine transform can not be inverted.
+     * Current implementation logs the stack trace and resets the zoom.
+     *
+     * @param  methodName  the caller method name.
+     * @param  exception   the exception to log.
+     */
+    private void unexpectedException(String methodName, NoninvertibleTransformException exception) {
+        zoom.setToIdentity();
+        unexpectedException(methodName, (Exception) exception);
+    }
+
+    /**
+     * Invoked when an unexpected exception occurs.
+     * Current implementation logs the stack trace.
+     *
+     * @param  methodName  the caller's method name.
+     * @param  exception   the exception to log.
+     */
+    private static void unexpectedException(String methodName, Exception exception) {
+        Logging.unexpectedException(Logging.getLogger("org.apache.sis.swing"), ZoomPane.class, methodName, exception);
+    }
+
+    /**
+     * Prints a message saying "Area:" with coordinates of given rectangle.
+     * This is used for debugging purposes only.
+     */
+    private static void debug(final String methodName, final Rectangle2D area) {
+        if (DEBUG) {
+            System.out.println(methodName + " area: "
+                    + "x=[" + area.getMinX() + " … " + area.getMaxX() + "], "
+                    + "y=[" + area.getMinY() + " … " + area.getMaxY() + ']');
+        }
+    }
+
+    /**
+     * Verifies whether the given rectangle is valid. The rectangle is considered invalid if
+     * its length or width is less than or equals to 0, or if a coordinate is infinite or NaN.
+     */
+    private static boolean isValid(final Rectangle2D rect) {
+        if (rect == null) {
+            return false;
+        }
+        final double x = rect.getX();
+        final double y = rect.getY();
+        final double w = rect.getWidth();
+        final double h = rect.getHeight();
+        return (x > Double.NEGATIVE_INFINITY && x < Double.POSITIVE_INFINITY &&
+                y > Double.NEGATIVE_INFINITY && y < Double.POSITIVE_INFINITY &&
+                w > 0                        && w < Double.POSITIVE_INFINITY &&
+                h > 0                        && h < Double.POSITIVE_INFINITY);
+    }
+
+    /**
+     * If scale and shear coefficients are close to integers, replaces their current values by their rounded values.
+     * The scale and shear coefficients are handled in a "all or nothing" way; either all of them or none are rounded.
+     * The translation terms are handled separately, provided that the scale and shear coefficients have been rounded.
+     *
+     * <p>This rounding up is useful for example in order to speedup image renderings.</p>
+     *
+     * @param  tr  the transform to round. Rounding will be applied in place.
+     */
+    private static void roundIfAlmostInteger(final AffineTransform tr) {
+        double r;
+        final double m00, m01, m10, m11;
+        if (abs((m00 = rint(r=tr.getScaleX())) - r) <= EPS &&
+            abs((m01 = rint(r=tr.getShearX())) - r) <= EPS &&
+            abs((m11 = rint(r=tr.getScaleY())) - r) <= EPS &&
+            abs((m10 = rint(r=tr.getShearY())) - r) <= EPS)
+        {
+            /*
+             * At this point the scale and shear coefficients can been rounded to integers.
+             * Continue only if this rounding does not make the transform non-invertible.
+             */
+            if ((m00!=0 || m01!=0) && (m10!=0 || m11!=0)) {
+                double m02, m12;
+                if (abs((r = rint(m02=tr.getTranslateX())) - m02) <= EPS) m02=r;
+                if (abs((r = rint(m12=tr.getTranslateY())) - m12) <= EPS) m12=r;
+                tr.setTransform(m00, m10, m01, m11, m02, m12);
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/doc-files/ZoomPane.png b/src/main/java/org/apache/sis/swing/doc-files/ZoomPane.png
new file mode 100644
index 0000000..912e70e
Binary files /dev/null and b/src/main/java/org/apache/sis/swing/doc-files/ZoomPane.png differ
diff --git a/src/main/java/org/apache/sis/swing/internal/Resources.java b/src/main/java/org/apache/sis/swing/internal/Resources.java
new file mode 100644
index 0000000..132c2f3
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/internal/Resources.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing.internal;
+
+import java.net.URL;
+import java.util.Locale;
+import java.util.MissingResourceException;
+import org.apache.sis.util.resources.KeyConstants;
+import org.apache.sis.util.resources.IndexedResourceBundle;
+
+
+/**
+ * Resources for the Swing widgets.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public final class Resources extends IndexedResourceBundle {
+    /**
+     * Resource keys. This class is used when compiling sources, but no dependencies to
+     * {@code Keys} should appear in any resulting class files. Since the Java compiler
+     * inlines final integer values, using long identifiers will not bloat the constant
+     * pools of compiled classes.
+     */
+    public static final class Keys extends KeyConstants {
+        /**
+         * The unique instance of key constants handler.
+         */
+        static final Keys INSTANCE = new Keys();
+
+        /**
+         * For {@link #INSTANCE} creation only.
+         */
+        private Keys() {
+        }
+
+        /**
+         * Cancel
+         */
+        public static final short Cancel = 16;
+
+        /**
+         * Close
+         */
+        public static final short Close = 14;
+
+        /**
+         * Debug
+         */
+        public static final short Debug = 13;
+
+        /**
+         * Down
+         */
+        public static final short Down = 4;
+
+        /**
+         * Error
+         */
+        public static final short Error = 11;
+
+        /**
+         * Hide
+         */
+        public static final short Hide = 18;
+
+        /**
+         * Bad entry.
+         */
+        public static final short IllegalEntry = 15;
+
+        /**
+         * Left
+         */
+        public static final short Left = 1;
+
+        /**
+         * Magnifier
+         */
+        public static final short Magnifier = 20;
+
+        /**
+         * {0} (no details)
+         */
+        public static final short NoDetails_1 = 12;
+
+        /**
+         * Ok
+         */
+        public static final short Ok = 17;
+
+        /**
+         * Reset
+         */
+        public static final short Reset = 5;
+
+        /**
+         * Right
+         */
+        public static final short Right = 2;
+
+        /**
+         * Rotate left
+         */
+        public static final short RotateLeft = 6;
+
+        /**
+         * Rotate right
+         */
+        public static final short RotateRight = 7;
+
+        /**
+         * Show magnifier
+         */
+        public static final short ShowMagnifier = 19;
+
+        /**
+         * Up
+         */
+        public static final short Up = 3;
+
+        /**
+         * Zoom in
+         */
+        public static final short ZoomIn = 8;
+
+        /**
+         * Close zoom
+         */
+        public static final short ZoomMax = 9;
+
+        /**
+         * Zoom out
+         */
+        public static final short ZoomOut = 10;
+    }
+
+    /**
+     * Constructs a new resource bundle loading data from the given UTF file.
+     *
+     * @param resources  the path of the binary file containing resources, or {@code null} if
+     *        there is no resources. The resources may be a file or an entry in a JAR file.
+     */
+    public Resources(final URL resources) {
+        super(resources);
+    }
+
+    /**
+     * Returns the handle for the {@code Keys} constants.
+     *
+     * @return a handler for the constants declared in the inner {@code Keys} class.
+     */
+    @Override
+    protected KeyConstants getKeyConstants() {
+        return Keys.INSTANCE;
+    }
+
+    /**
+     * Returns resources in the given locale.
+     *
+     * @param  locale  the locale, or {@code null} for the default locale.
+     * @return resources in the given locale.
+     * @throws MissingResourceException if resources can not be found.
+     */
+    public static Resources forLocale(final Locale locale) throws MissingResourceException {
+        return getBundle(Resources.class, locale);
+    }
+}
diff --git a/src/main/java/org/apache/sis/swing/internal/Resources.properties b/src/main/java/org/apache/sis/swing/internal/Resources.properties
new file mode 100644
index 0000000..a8b5d76
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/internal/Resources.properties
@@ -0,0 +1,37 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+Cancel        = Cancel
+Close         = Close
+Debug         = Debug
+Down          = Down
+Error         = Error
+Hide          = Hide
+IllegalEntry  = Bad entry.
+Left          = Left
+Magnifier     = Magnifier
+NoDetails_1   = {0} (no details)
+Ok            = Ok
+Reset         = Reset
+Right         = Right
+RotateLeft    = Rotate left
+RotateRight   = Rotate right
+ShowMagnifier = Show magnifier
+Up            = Up
+ZoomIn        = Zoom in
+ZoomMax       = Close zoom
+ZoomOut       = Zoom out
diff --git a/src/main/java/org/apache/sis/swing/internal/Resources_fr.properties b/src/main/java/org/apache/sis/swing/internal/Resources_fr.properties
new file mode 100644
index 0000000..12dfa41
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/internal/Resources_fr.properties
@@ -0,0 +1,37 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+Cancel        = Annuler
+Close         = Fermer
+Debug         = D\u00e9bug
+Down          = Bas
+Error         = Erreur
+Hide          = Cacher
+IllegalEntry  = Saisie invalide.
+Left          = Gauche
+Magnifier     = Loupe
+NoDetails_1   = {0} (il n\u2019y a pas d\u2019autre information sur cette erreur).
+Ok            = Ok
+Reset         = R\u00e9tablir
+Right         = Droite
+RotateLeft    = Rotation gauche
+RotateRight   = Rotation droite
+ShowMagnifier = Afficher la loupe
+Up            = Haut
+ZoomIn        = Zoom avant
+ZoomMax       = Zoom rapproch\u00e9
+ZoomOut       = Zoom arri\u00e8re
diff --git a/src/main/java/org/apache/sis/swing/internal/package-info.java b/src/main/java/org/apache/sis/swing/internal/package-info.java
new file mode 100644
index 0000000..f0e59d9
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/internal/package-info.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Resources for the {@code org.apache.sis.swing} package.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+package org.apache.sis.swing.internal;
diff --git a/src/main/java/org/apache/sis/swing/package-info.java b/src/main/java/org/apache/sis/swing/package-info.java
new file mode 100644
index 0000000..98ed7c8
--- /dev/null
+++ b/src/main/java/org/apache/sis/swing/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Swing widgets for testing purposes. For now those widgets are provided only for testing.
+ * In the future, we could move this package to a {@code sis-swing} module if there is a use for it.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+package org.apache.sis.swing;
diff --git a/src/main/java/org/apache/sis/test/visual/DesktopPane.java b/src/main/java/org/apache/sis/test/visual/DesktopPane.java
new file mode 100644
index 0000000..d46136f
--- /dev/null
+++ b/src/main/java/org/apache/sis/test/visual/DesktopPane.java
@@ -0,0 +1,257 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.test.visual;
+
+import java.io.File;
+import java.io.IOException;
+import javax.imageio.ImageIO;
+import java.util.prefs.Preferences;
+import java.awt.Desktop;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Graphics2D;
+import java.awt.event.ActionEvent;
+import java.awt.image.BufferedImage;
+import java.beans.PropertyVetoException;
+import javax.swing.AbstractAction;
+import javax.swing.JComponent;
+import javax.swing.JDesktopPane;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JInternalFrame;
+import javax.swing.JList;
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JOptionPane;
+import javax.swing.JScrollPane;
+import javax.swing.UIManager;
+import javax.swing.event.InternalFrameEvent;
+import javax.swing.event.InternalFrameAdapter;
+import org.apache.sis.util.Classes;
+import org.apache.sis.util.logging.Logging;
+
+
+/**
+ * The desktop pane where to put the widgets to be tested.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+@SuppressWarnings("serial")
+final class DesktopPane extends JDesktopPane {
+    /**
+     * The key for screenshot directory in the user preferences.
+     */
+    private static final String SCREENSHOT_DIRECTORY_PREFS = "Screenshots";
+
+    static {
+        try {
+            UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
+        } catch (Exception e) {
+            Logging.unexpectedException(null, DesktopPane.class, "<init>", e);
+        }
+    }
+    /**
+     * The desktop which contain the internal frame for each widget.
+     */
+    static final DesktopPane INSTANCE = new DesktopPane();
+
+    /**
+     * The menu for creating new windows.
+     */
+    private final JMenu newMenu;
+
+    /**
+     * The last active component.
+     */
+    private JComponent active;
+
+    /**
+     * Creates the desktop, creates its frame and makes it visible.
+     */
+    private DesktopPane() {
+        final JMenuBar menuBar = new JMenuBar();
+        newMenu = new JMenu("New");
+        menuBar.add(newMenu);
+        {   // For keeping variables locale.
+            final JMenu menu = new JMenu("Tools");
+            menu.add(new AbstractAction("Screenshot") {
+                @Override public void actionPerformed(final ActionEvent event) {screenshot();}
+            });
+            menu.add(new AbstractAction("Preferences") {
+                @Override public void actionPerformed(final ActionEvent event) {preferences();}
+            });
+            menuBar.add(menu);
+        }
+        {   // For keeping variables locale.
+            final JMenu menu = new JMenu("Windows");
+            menu.add(new AbstractAction("List") {
+                @Override public void actionPerformed(final ActionEvent event) {listWindows();}
+            });
+            menuBar.add(menu);
+        }
+        final JFrame frame = new JFrame("Widget tests");
+        frame.setJMenuBar(menuBar);
+        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+        frame.setContentPane(this);
+        frame.setSize(1000, 600);
+        frame.setLocationRelativeTo(null);                          // Put at screen center.
+        frame.setVisible(true);
+    }
+
+    /**
+     * Adds a test case to be shown in the "New" menu and show the widget immediately.
+     * This method shall be invoked in Swing thread.
+     *
+     * @param  testCase  the test case for which the component is added.
+     */
+    final void addAndShow(final Visualization testCase) {
+        newMenu.add(new AbstractAction(Classes.getShortName(testCase.testing)) {
+            @Override public void actionPerformed(final ActionEvent event) {
+                show(testCase);
+            }
+        });
+        show(testCase);
+    }
+
+    /**
+     * Shows the widget created by the given test case.
+     */
+    private void show(final Visualization testCase) {
+        final int n = testCase.numTests;
+        for (int i=0; i<n; i++) {
+            show(testCase.create(i), i, n);
+        }
+    }
+
+    /**
+     * Shows the given component in a frame.
+     *
+     * @param  component  the component to show.
+     */
+    private void show(final JComponent component, final int index, final int numTests) {
+        String title = Classes.getShortClassName(component);
+        if (numTests != 1) {
+            title = title + " (" + index + ')';
+        }
+        final JInternalFrame frame = new JInternalFrame(title, true, true, true, true);
+        frame.addInternalFrameListener(new InternalFrameAdapter() {
+            @Override public void internalFrameActivated(final InternalFrameEvent event) {
+                active = component;
+            }
+
+            @Override public void internalFrameClosed(final InternalFrameEvent event) {
+                if (active == component) {
+                    active = null;
+                }
+            }
+        });
+        frame.add(component);
+        frame.pack();
+        final Dimension size = frame.getMinimumSize();
+        if (size != null) {
+            frame.setSize(Math.max(frame.getWidth(),  size.width),
+                          Math.max(frame.getHeight(), size.height));
+        }
+        final int numCols = (int) Math.ceil(Math.sqrt(numTests));
+        final int numRows = (numTests + numCols - 1) / numCols;
+        final int deltaX  = getWidth()  / numCols;
+        final int deltaY  = getHeight() / numRows;
+        frame.setLocation(deltaX * (index % numRows) + (deltaX - frame.getWidth())  / 2,
+                          deltaY * (index / numRows) + (deltaY - frame.getHeight()) / 2);
+        frame.setVisible(true);
+        add(frame);
+        try {
+            frame.setSelected(true);
+        } catch (PropertyVetoException e) {
+            Logging.unexpectedException(null, DesktopPane.class, "show", e);
+        }
+    }
+
+    /**
+     * List windows known to this desktop.
+     */
+    private void listWindows() {
+        final Component[] components = getComponents();
+        final String[] titles = new String[components.length];
+        for (int i=0; i<components.length; i++) {
+            Component c = components[i];
+            String title = String.valueOf(c.getName());
+            if (c instanceof JInternalFrame) {
+                final JInternalFrame ci = (JInternalFrame) c;
+                title = String.valueOf(ci.getTitle());
+                c = ci.getRootPane().getComponent(0);
+            }
+            final Dimension size = c.getSize();
+            titles[i] = title + " : " + c.getClass().getSimpleName() +
+                        '[' + size.width + " × " + size.height + ']';
+        }
+        final JInternalFrame frame = new JInternalFrame("Windows", true, true, true, true);
+        frame.add(new JScrollPane(new JList<>(titles)));
+        frame.pack();
+        frame.setVisible(true);
+        add(frame);
+    }
+
+    /**
+     * Popups a dialog box for setting the preferences.
+     */
+    private void preferences() {
+        final Preferences prefs = Preferences.userNodeForPackage(DesktopPane.class);
+        final JFileChooser chooser = new JFileChooser(prefs.get(SCREENSHOT_DIRECTORY_PREFS, null));
+        chooser.setDialogTitle("Output directory for screenshots");
+        chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
+        switch (chooser.showOpenDialog(this)) {
+            case JFileChooser.APPROVE_OPTION: {
+                final File directory = chooser.getSelectedFile();
+                if (directory != null) {
+                    prefs.put(SCREENSHOT_DIRECTORY_PREFS, directory.getPath());
+                }
+                break;
+            }
+        }
+    }
+
+    /**
+     * Takes a screenshot of the currently active component.
+     */
+    private void screenshot() {
+        final JComponent c = active;
+        if (c != null && c.isValid()) {
+            final BufferedImage image = new BufferedImage(c.getWidth(), c.getHeight(), BufferedImage.TYPE_INT_RGB);
+            final Graphics2D handler = image.createGraphics();
+            c.print(handler);
+            handler.dispose();
+            File file = new File(Preferences.userNodeForPackage(DesktopPane.class).get(SCREENSHOT_DIRECTORY_PREFS, "."));
+            file = new File(file, Classes.getShortClassName(c) + ".png");
+            try {
+                if (ImageIO.write(image, "png", file)) {
+                    file = file.getParentFile();
+                    Desktop.getDesktop().open(file);
+                } else {
+                    JOptionPane.showInternalMessageDialog(this, "No PNG writer.", "Screenshot", JOptionPane.WARNING_MESSAGE);
+                }
+            } catch (IOException e) {
+                JOptionPane.showInternalMessageDialog(c, e.getLocalizedMessage(),
+                        e.getClass().getSimpleName(), JOptionPane.ERROR_MESSAGE);
+            }
+        } else {
+            JOptionPane.showInternalMessageDialog(this, "No active window.", "Screenshot", JOptionPane.WARNING_MESSAGE);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sis/test/visual/Visualization.java b/src/main/java/org/apache/sis/test/visual/Visualization.java
new file mode 100644
index 0000000..310928a
--- /dev/null
+++ b/src/main/java/org/apache/sis/test/visual/Visualization.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.test.visual;
+
+import javax.swing.JComponent;
+import javax.swing.SwingUtilities;
+
+
+/**
+ * Base class for tests on widgets.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public abstract class Visualization {
+    /**
+     * The type of the object being tested.
+     */
+    final Class<?> testing;
+
+    /**
+     * Number of invocation of {@link #create(int)} to perform.
+     */
+    final int numTests;
+
+    /**
+     * Creates a new instance of {@code Visualization} which will invoke {@link #create(int)} only once.
+     *
+     * @param  testing  the type of object to be tested.
+     */
+    protected Visualization(final Class<?> testing) {
+        this(testing, 1);
+    }
+
+    /**
+     * Creates a new instance of {@code Visualization}.
+     *
+     * @param  testing   the type of object to be tested.
+     * @param  numTests  number of invocation of {@link #create(int)} to perform.
+     */
+    protected Visualization(final Class<?> testing, final int numTests) {
+        this.testing  = testing;
+        this.numTests = numTests;
+    }
+
+    /**
+     * Creates a widget showing the object to test.
+     *
+     * @param  index  index of test occurrence, from 0 inclusive to the value given at construction time, exclusive.
+     * @return a widget showing the object to test.
+     */
+    protected abstract JComponent create(int index);
+
+    /**
+     * Creates and shows a widget visualizing the object to test.
+     */
+    public final void show() {
+        SwingUtilities.invokeLater(() -> DesktopPane.INSTANCE.addAndShow(this));
+    }
+}
diff --git a/src/test/java/org/apache/sis/swing/TestCase.java b/src/test/java/org/apache/sis/swing/TestCase.java
new file mode 100644
index 0000000..ef57f5b
--- /dev/null
+++ b/src/test/java/org/apache/sis/swing/TestCase.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import javax.swing.JComponent;
+import org.apache.sis.test.visual.Visualization;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Base class for tests on widgets. When executed as a JUnit test, this class displays nothing.
+ * It merely:
+ *
+ * <ul>
+ *   <li>Ensure that no exception is thrown while creating the widget.</li>
+ *   <li>Ensure that no exception is thrown while painting in a buffered image.</li>
+ * </ul>
+ *
+ * However subclasses provide a main method for running also the test as a classical application,
+ * in which case the widget is shown. Note that {@code TestCase} is only for "testing the tests",
+ * i.e. tests the widgets used for testing Apache SIS. We do not distribute those widgets,
+ * so tests of the {@code org.apache.sis.swing} package do not need to be extensive.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public abstract strictfp class TestCase {
+    /**
+     * The type of the widget being tested.
+     */
+    private final Class<?> testing;
+
+    /**
+     * Number of invocation of {@link #create(int)} to perform.
+     */
+    private final int numTests;
+
+    /**
+     * Creates a new instance of {@code TestCase} which will invoke {@link #create(int)} only once.
+     *
+     * @param  testing  the class of widget being tested.
+     */
+    protected TestCase(final Class<? extends JComponent> testing) {
+        this(testing, 1);
+    }
+
+    /**
+     * Creates a new instance of {@code TestCase}.
+     *
+     * @param  testing   the class of widget being tested.
+     * @param  numTests  number of invocation of {@link #create(int)} to perform.
+     */
+    protected TestCase(final Class<? extends JComponent> testing, final int numTests) {
+        assertTrue(JComponent.class.isAssignableFrom(testing));
+        assertTrue(numTests >= 1);
+        this.testing  = testing;
+        this.numTests = numTests;
+    }
+
+    /**
+     * Creates the widget.
+     *
+     * @param  index  widget index from 0 inclusive to the value given at construction time, exclusive.
+     * @return the widget to show.
+     */
+    protected abstract JComponent create(int index);
+
+    /**
+     * Creates the widget and paints in a buffered image. This test is useful only for widget
+     * implementations that override their {@link JComponent#paint(Graphics)} method.
+     */
+    @Test
+    public final void testPaint() {
+        assertTrue(testing.desiredAssertionStatus());
+        for (int i=0; i<numTests; i++) {
+            final JComponent component = create(i);
+            component.setSize(component.getPreferredSize());
+            component.setVisible(true);
+            final int width  = component.getWidth();
+            final int height = component.getHeight();
+            final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+            final Graphics2D gr = image.createGraphics();
+            try {
+                component.print(gr);
+            } finally {
+                gr.dispose();
+            }
+        }
+    }
+
+    /**
+     * Shows the widget. This method can be invoked from the {@code main} method of subclasses.
+     */
+    public final void show() {
+        new Visualization(testing) {
+            @Override protected JComponent create(int index) {
+                return TestCase.this.create(index);
+            }
+        }.show();
+    }
+}
diff --git a/src/test/java/org/apache/sis/swing/ZoomPaneTest.java b/src/test/java/org/apache/sis/swing/ZoomPaneTest.java
new file mode 100644
index 0000000..f0becba
--- /dev/null
+++ b/src/test/java/org/apache/sis/swing/ZoomPaneTest.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.swing;
+
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.Polygon;
+import java.awt.Rectangle;
+import java.awt.geom.Rectangle2D;
+import javax.swing.JComponent;
+
+
+/**
+ * Tests the {@link ZoomPane}.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public final strictfp class ZoomPaneTest extends TestCase {
+    /**
+     * Creates the test case.
+     */
+    public ZoomPaneTest() {
+        super(ZoomPane.class);
+    }
+
+    /**
+     * Shows a {@link ZoomPane}.
+     *
+     * @param  args  ignored.
+     */
+    public static void main(String[] args) {
+        new ZoomPaneTest().show();
+    }
+
+    /**
+     * Creates the widget.
+     */
+    @Override
+    @SuppressWarnings("serial")
+    protected JComponent create(final int index) {
+        final Rectangle rect = new Rectangle(100,200,100,93);
+        final Polygon   poly = new Polygon(new int[] {125,175,150}, new int[] {225,225,268}, 3);
+        final ZoomPane  pane = new ZoomPane(
+                ZoomPane.UNIFORM_SCALE | ZoomPane.ROTATE      |
+                ZoomPane.TRANSLATE_X   | ZoomPane.TRANSLATE_Y |
+                ZoomPane.RESET         | ZoomPane.DEFAULT_ZOOM)
+        {
+            @Override public Rectangle2D getArea() {
+                return rect;
+            }
+
+            @Override protected void paintComponent(final Graphics2D graphics) {
+                graphics.transform(zoom);
+                graphics.setColor(Color.RED);
+                graphics.fill(poly);
+                graphics.setColor(Color.BLUE);
+                graphics.draw(poly);
+                graphics.draw(rect);
+            }
+        };
+        pane.setPaintingWhileAdjusting(true);
+        return pane.createScrollPane();
+    }
+}


Mime
View raw message