You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2020/06/29 17:31:21 UTC

[sis] 05/06: Provide Azimuthal Equidistant projections centered on arbitrary location.

This is an automated email from the ASF dual-hosted git repository.

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

commit 899f09b9202b575ae3f39462f85e04bc33b4e713
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Mon Jun 29 19:03:45 2020 +0200

    Provide Azimuthal Equidistant projections centered on arbitrary location.
---
 .../java/org/apache/sis/gui/coverage/Controls.java |  10 +-
 .../apache/sis/gui/coverage/CoverageCanvas.java    | 114 ++++++++++--
 .../apache/sis/gui/coverage/CoverageControls.java  |  58 +++----
 .../org/apache/sis/gui/referencing/MenuSync.java   |  20 ++-
 .../gui/referencing/PositionableProjection.java    | 191 +++++++++++++++++++++
 .../org/apache/sis/internal/gui/Resources.java     |  10 ++
 .../apache/sis/internal/gui/Resources.properties   |   2 +
 .../sis/internal/gui/Resources_fr.properties       |   2 +
 .../java/org/apache/sis/internal/gui/Styles.java   |   2 +-
 .../referencing/GeodeticObjectBuilder.java         |   9 +-
 10 files changed, 363 insertions(+), 55 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java
index e8999d6..5975ecd 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java
@@ -23,6 +23,7 @@ import javafx.scene.control.Label;
 import javafx.scene.layout.Region;
 import javafx.scene.text.Font;
 import javafx.scene.text.FontWeight;
+import org.apache.sis.internal.gui.Styles;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.util.resources.IndexedResourceBundle;
 
@@ -48,11 +49,18 @@ abstract class Controls {
     private static final Insets NEXT_CAPTION_MARGIN = new Insets(30, 0, 6, 0);
 
     /**
-     * Margin for adding an indentation to a node.
+     * Margin for adding an indentation to a node when the node is inside a group
+     * created by {@link Styles#createControlGrid(int, Label...)}.
      */
     static final Insets INDENT = new Insets(0, 0, 0, 15);
 
     /**
+     * Margin for adding an indentation to a node when the node is outside a group
+     * created by {@link Styles#createControlGrid(int, Label...)}.
+     */
+    static final Insets INDENT_OUTSIDE = new Insets(0, 0, 0, 15 + Styles.FORM_INSETS.getLeft());
+
+    /**
      * The toolbar button for selecting this view.
      * This is initialized after construction.
      */
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
index 9f21407..50316e3 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
@@ -24,13 +24,17 @@ import java.awt.Rectangle;
 import java.awt.image.RenderedImage;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
 import javafx.scene.paint.Color;
 import javafx.scene.layout.Pane;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.Background;
 import javafx.scene.layout.BackgroundFill;
 import javafx.scene.control.Menu;
+import javafx.scene.control.RadioMenuItem;
 import javafx.scene.control.ContextMenu;
+import javafx.scene.control.ToggleGroup;
 import javafx.scene.input.MouseEvent;
 import javafx.event.EventHandler;
 import javafx.beans.DefaultProperty;
@@ -65,6 +69,7 @@ import org.apache.sis.image.Interpolation;
 import org.apache.sis.gui.map.MapCanvas;
 import org.apache.sis.gui.map.MapCanvasAWT;
 import org.apache.sis.gui.map.StatusBar;
+import org.apache.sis.gui.referencing.PositionableProjection;
 import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.internal.gui.GUIUtilities;
 import org.apache.sis.internal.gui.Resources;
@@ -177,34 +182,76 @@ public class CoverageCanvas extends MapCanvasAWT {
 
     /**
      * Creates and register a contextual menu.
+     *
+     * @todo Consider moving to {@link org.apache.sis.gui.map.MapCanvas}.
      */
     final ObjectProperty<ReferenceSystem> createContextMenu(final RecentReferenceSystems referenceSystems) {
+        final Resources resources = Resources.forLocale(getLocale());
         final MenuHandler handler = new MenuHandler();
+        final Menu  systemChoices = referenceSystems.createMenuItems(handler);
+        final Menu   localSystems = new Menu(resources.getString(Resources.Keys.CenteredProjection));
+        for (final PositionableProjection projection : PositionableProjection.values()) {
+            final RadioMenuItem item = new RadioMenuItem(projection.toString());
+            item.setToggleGroup(handler.positionables);
+            item.setOnAction((e) -> handler.createProjectedCRS(projection));
+            localSystems.getItems().add(item);
+        }
+        handler.menu.getItems().setAll(systemChoices, localSystems);
+        addPropertyChangeListener(OBJECTIVE_CRS_PROPERTY, handler);
         fixedPane.setOnMousePressed(handler);
-        final Menu systemChoices = referenceSystems.createMenuItems(handler);
-        handler.menu.getItems().setAll(systemChoices);
-        return RecentReferenceSystems.getSelectedProperty(systemChoices);
+        return handler.selectedProperty = RecentReferenceSystems.getSelectedProperty(systemChoices);
     }
 
     /**
-     * Shows or hides the contextual menu when right mouse button is clicked, with information about the
-     * location where the click occurred.
+     * Shows or hides the contextual menu when the right mouse button is clicked. This handler can determine
+     * the geographic location where the click occurred. This information is used for changing the projection
+     * while preserving approximately the location, scale and rotation of pixels around the mouse cursor.
      */
     @SuppressWarnings("serial")                                         // Not intended to be serialized.
     private final class MenuHandler extends DirectPosition2D
-            implements EventHandler<MouseEvent>, ChangeListener<ReferenceSystem>
+            implements EventHandler<MouseEvent>, ChangeListener<ReferenceSystem>, PropertyChangeListener
     {
-        /** The contextual menu to show or hide. */
+        /**
+         * The property to update if a change of CRS occurs in the enclosing canvas. This property is provided
+         * by {@link RecentReferenceSystems}, which listen to changes. Setting this property to a new value
+         * causes the "Referencing systems" radio menus to change the item where the check mark appear.
+         *
+         * <p>This field is initialized by {@link #createContextMenu(RecentReferenceSystems)} and should be
+         * considered final after initialization.</p>
+         */
+        ObjectProperty<ReferenceSystem> selectedProperty;
+
+        /**
+         * The group of {@link PositionableProjection} items for projections created on-the-fly at mouse position.
+         * Those items are not managed by {@link RecentReferenceSystems} so they need to be handled there.
+         */
+        final ToggleGroup positionables;
+
+        /**
+         * The contextual menu to show or hide when mouse button is clicked on the canvas.
+         */
         final ContextMenu menu;
 
-        /** Creates a new handler. */
+        /**
+         * {@code true} if we are in the process of setting a CRS generated by {@link PositionableProjection}.
+         */
+        private boolean isPositionableProjection;
+
+        /**
+         * Creates a new handler for contextual menu in enclosing canvas.
+         */
         MenuHandler() {
             super(getDisplayCRS());
             menu = new ContextMenu();
+            positionables = new ToggleGroup();
         }
 
-        /** Shows the menu on right mouse click, hide otherwise. */
-        @Override public void handle(final MouseEvent event) {
+        /**
+         * Invoked when the user click on the canvas.
+         * Shows the menu on right mouse click, hide otherwise.
+         */
+        @Override
+        public void handle(final MouseEvent event) {
             if (event.isSecondaryButtonDown()) {
                 x = event.getX();
                 y = event.getY();
@@ -215,14 +262,55 @@ public class CoverageCanvas extends MapCanvasAWT {
             }
         }
 
-        /** Invoked when user selected a new coordinate reference system among menu items. */
-        @Override public void changed(final ObservableValue<? extends ReferenceSystem> property,
-                                      final ReferenceSystem oldValue, final ReferenceSystem newValue)
+        /**
+         * Invoked when user selected a new coordinate reference system among the choices of predefined CRS.
+         * Those CRS are the ones managed by {@link RecentReferenceSystems}, not the ones created on-the-fly.
+         */
+        @Override
+        public void changed(final ObservableValue<? extends ReferenceSystem> property,
+                            final ReferenceSystem oldValue, final ReferenceSystem newValue)
         {
             if (newValue instanceof CoordinateReferenceSystem) {
                 setObjectiveCRS((CoordinateReferenceSystem) newValue, this, property);
             }
         }
+
+        /**
+         * Invoked when user selected a projection centered on mouse position. Those CRS are generated on-the-fly
+         * and are generally not on the list of CRS managed by {@link RecentReferenceSystems}.
+         */
+        final void createProjectedCRS(final PositionableProjection projection) {
+            try {
+                DirectPosition2D center = new DirectPosition2D();
+                center = (DirectPosition2D) objectiveToDisplay.inverseTransform(this, center);
+                center.setCoordinateReferenceSystem(getObjectiveCRS());
+                CoordinateReferenceSystem crs = projection.createProjectedCRS(center);
+                try {
+                    isPositionableProjection = true;
+                    setObjectiveCRS(crs, this, null);
+                } finally {
+                    isPositionableProjection = false;
+                }
+            } catch (NoninvertibleTransformException | FactoryException | TransformException e) {
+                ExceptionReporter.show(null, null, e);
+            }
+        }
+
+        /**
+         * Invoked when a canvas property changed, typically after a new coverage has been selected.
+         * The property of interest is {@value CoverageCanvas#OBJECTIVE_CRS_PROPERTY}.
+         * This method updates the CRS selected in the contextual menu.
+         */
+        @Override
+        public void propertyChange(final PropertyChangeEvent event) {
+            final Object value = event.getNewValue();
+            if (value instanceof CoordinateReferenceSystem) {
+                selectedProperty.set((CoordinateReferenceSystem) value);
+            }
+            if (!isPositionableProjection) {
+                positionables.selectToggle(null);
+            }
+        }
     }
 
     /**
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
index 6d707e3..eb8eadf 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
@@ -18,8 +18,6 @@ package org.apache.sis.gui.coverage;
 
 import java.util.Locale;
 import java.util.Objects;
-import java.beans.PropertyChangeEvent;
-import java.beans.PropertyChangeListener;
 import javafx.scene.control.Accordion;
 import javafx.scene.control.ColorPicker;
 import javafx.scene.control.Control;
@@ -27,6 +25,7 @@ import javafx.scene.control.TitledPane;
 import javafx.scene.layout.BorderPane;
 import javafx.scene.layout.GridPane;
 import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
 import javafx.beans.property.ObjectProperty;
 import javafx.beans.value.ChangeListener;
 import javafx.beans.value.ObservableValue;
@@ -38,8 +37,8 @@ import javafx.scene.paint.Color;
 import javafx.scene.text.Font;
 import javafx.util.StringConverter;
 import org.opengis.referencing.ReferenceSystem;
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.referencing.IdentifiedObjects;
 import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.gui.map.StatusBar;
 import org.apache.sis.image.Interpolation;
@@ -55,7 +54,7 @@ import org.apache.sis.util.resources.Vocabulary;
  * @since   1.1
  * @module
  */
-final class CoverageControls extends Controls implements PropertyChangeListener {
+final class CoverageControls extends Controls {
     /**
      * The component for showing sample values.
      */
@@ -72,12 +71,6 @@ final class CoverageControls extends Controls implements PropertyChangeListener
     private final BorderPane imageAndStatus;
 
     /**
-     * The selected reference system in the context menu, or {@code null} if there is no such property.
-     * This property is provided by {@link RecentReferenceSystems}.
-     */
-    private final ObjectProperty<ReferenceSystem> referenceSystem;
-
-    /**
      * Creates a new set of coverage controls.
      *
      * @param  vocabulary  localized set of words, provided in argument because often known by the caller.
@@ -94,26 +87,43 @@ final class CoverageControls extends Controls implements PropertyChangeListener
         view.statusBar = statusBar;
         imageAndStatus = new BorderPane(view.getView());
         imageAndStatus.setBottom(statusBar.getView());
-        referenceSystem = view.createContextMenu(referenceSystems);
+        final ObjectProperty<ReferenceSystem> referenceSystem = view.createContextMenu(referenceSystems);
+        final Locale locale = vocabulary.getLocale();
         /*
          * "Display" section with the following controls:
+         *    - Current CRS
          *    - Interpolation
          *    - Color stretching
          *    - Background color
          */
-        final GridPane displayPane;
+        final VBox displayPane;
         {   // Block for making variables locale to this scope.
+            final Font  font     = fontOfGroup();
+            final Label crsLabel = new Label(vocabulary.getString(Vocabulary.Keys.ReferenceSystem));
+            final Label crsShown = new Label();
+            crsLabel.setLabelFor(crsShown);
+            crsLabel.setFont(font);
+            crsLabel.setPadding(Styles.FORM_INSETS);
+            crsShown.setPadding(INDENT_OUTSIDE);
+            referenceSystem.addListener((p,o,n) -> {
+                crsShown.setText(IdentifiedObjects.getDisplayName(n, locale));
+            });
+            /*
+             * The pane containing controls will be divided in sections separated by labels:
+             * ones for values and one for colors.
+             */
             final int valuesHeader = 0;
             final int colorsHeader = 2;
-            displayPane = Styles.createControlGrid(valuesHeader + 1,
-                label(vocabulary, Vocabulary.Keys.Interpolation, createInterpolationButton(vocabulary.getLocale())),
+            final GridPane gp;
+            gp = Styles.createControlGrid(valuesHeader + 1,
+                label(vocabulary, Vocabulary.Keys.Interpolation, createInterpolationButton(locale)),
                 label(vocabulary, Vocabulary.Keys.Stretching, Stretching.createButton((p,o,n) -> view.setStyling(n))),
                 label(vocabulary, Vocabulary.Keys.Background, createBackgroundButton(background)));
             /*
-             * Insert space (one row) betweeb "interpolation" and "stretching"
+             * Insert space (one row) between "interpolation" and "stretching"
              * so we can insert the "colors" section header.
              */
-            final ObservableList<Node> items = displayPane.getChildren();
+            final ObservableList<Node> items = gp.getChildren();
             for (final Node item : items) {
                 if (GridPane.getColumnIndex(item) == 0) {
                     ((Label) item).setPadding(INDENT);
@@ -123,7 +133,6 @@ final class CoverageControls extends Controls implements PropertyChangeListener
                     GridPane.setRowIndex(item, row + 1);
                 }
             }
-            final Font font = fontOfGroup();
             final Label values = new Label(vocabulary.getString(Vocabulary.Keys.Values));
             final Label colors = new Label(vocabulary.getString(Vocabulary.Keys.Colors));
             values.setFont(font);
@@ -131,6 +140,7 @@ final class CoverageControls extends Controls implements PropertyChangeListener
             GridPane.setConstraints(values, 0, valuesHeader, 2, 1);    // Span 2 columns.
             GridPane.setConstraints(colors, 0, colorsHeader, 2, 1);
             items.addAll(values, colors);
+            displayPane = new VBox(crsLabel, crsShown, gp);
         }
         /*
          * Put all sections together and have the first one expanded by default.
@@ -141,7 +151,6 @@ final class CoverageControls extends Controls implements PropertyChangeListener
         controls = new Accordion(p1, p2);
         controls.setExpandedPane(p1);
         view.coverageProperty.bind(coverage);
-        view.addPropertyChangeListener(CoverageCanvas.OBJECTIVE_CRS_PROPERTY, this);
         p2.expandedProperty().addListener(new PropertyPaneCreator(view, p2));
     }
 
@@ -262,19 +271,6 @@ final class CoverageControls extends Controls implements PropertyChangeListener
     }
 
     /**
-     * Invoked when a canvas property changed, typically after a new coverage has been selected.
-     * The property of interest is {@value CoverageCanvas#OBJECTIVE_CRS_PROPERTY}.
-     * This method updates the CRS selected in the {@link ChoiceBox}.
-     */
-    @Override
-    public void propertyChange(final PropertyChangeEvent event) {
-        final Object value = event.getNewValue();
-        if (value instanceof CoordinateReferenceSystem) {
-            referenceSystem.set((CoordinateReferenceSystem) value);
-        }
-    }
-
-    /**
      * Returns the main component, which is showing coverage tabular data.
      */
     @Override
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/MenuSync.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/MenuSync.java
index 297cb8d..3f44241 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/MenuSync.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/MenuSync.java
@@ -33,6 +33,8 @@ import org.opengis.referencing.ReferenceSystem;
 import org.apache.sis.internal.gui.GUIUtilities;
 import org.apache.sis.referencing.IdentifiedObjects;
 import org.apache.sis.util.resources.Vocabulary;
+import org.apache.sis.util.ComparisonMode;
+import org.apache.sis.util.Utilities;
 
 
 /**
@@ -230,17 +232,23 @@ final class MenuSync extends SimpleObjectProperty<ReferenceSystem> implements Ev
      * to invoke the potentially costly {@link #action}.
      */
     @Override
-    public void set(final ReferenceSystem system) {
+    public void set(ReferenceSystem system) {
         final ReferenceSystem old = get();
         if (old != system) {
-            super.set(system);
+            final ComparisonMode mode = owner.duplicationCriterion.get();
             for (final MenuItem item : menus) {
-                if (item instanceof RadioMenuItem && item.getProperties().get(REFERENCE_SYSTEM_KEY) == system) {
-                    ((RadioMenuItem) item).setSelected(true);
-                    action.changed(this, old, system);
-                    return;
+                if (item instanceof RadioMenuItem) {
+                    final Object current = item.getProperties().get(REFERENCE_SYSTEM_KEY);
+                    if (Utilities.deepEquals(current, system, mode)) {
+                        system = (ReferenceSystem) current;
+                        super.set(system);
+                        ((RadioMenuItem) item).setSelected(true);
+                        action.changed(this, old, system);
+                        return;
+                    }
                 }
             }
+            super.set(system);
             group.selectToggle(null);
             /*
              * Do not invoke action.changed(…) since we have no non-null value to provide.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/PositionableProjection.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/PositionableProjection.java
new file mode 100644
index 0000000..2f8e6a7
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/PositionableProjection.java
@@ -0,0 +1,191 @@
+/*
+ * 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.gui.referencing;
+
+import java.util.List;
+import java.util.ArrayList;
+import org.opengis.util.CodeList;
+import org.opengis.util.FactoryException;
+import org.opengis.geometry.DirectPosition;
+import org.opengis.referencing.crs.GeographicCRS;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.crs.ProjectedCRS;
+import org.apache.sis.internal.gui.Resources;
+import org.apache.sis.internal.referencing.GeodeticObjectBuilder;
+import org.apache.sis.internal.referencing.ReferencingUtilities;
+import org.apache.sis.measure.AngleFormat;
+import org.apache.sis.measure.Latitude;
+import org.apache.sis.measure.Longitude;
+import org.apache.sis.measure.Units;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.referencing.CommonCRS;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Utilities;
+
+
+/**
+ * Provider of map projections centered on a point of interest.
+ * The point of interest is typically determined by mouse location.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+@SuppressWarnings("serial")         // We do not guarantee serialization compatibility.
+public abstract class PositionableProjection extends CodeList<PositionableProjection> {
+    /**
+     * List of all enumerations of this type.
+     * Must be declared before any enum declaration.
+     */
+    private static final List<PositionableProjection> VALUES = new ArrayList<>(1);
+
+    /**
+     * Provides <cite>Azimuthal Equidistant</cite> projection centered on a point of interest.
+     * For projection on the ellipsoid, this is valid only under 800 km of the point of interest.
+     *
+     * @see org.apache.sis.referencing.operation.projection.AzimuthalEquidistant
+     */
+    public static final PositionableProjection AZIMUTHAL_EQUIDISTANT =
+            new PositionableProjection("AZIMUTHAL_EQUIDISTANT", Resources.Keys.AzimuthalEquidistant)
+    {
+        @Override protected ProjectedCRS createProjectedCRS(final GeographicCRS baseCRS,
+                final double latitude, final double longitude) throws FactoryException
+        {
+            return newBuilder(latitude, longitude)
+                    .setConversionMethod("Azimuthal Equidistant (Spherical)")
+                    .setParameter("Latitude of natural origin",  latitude,  Units.DEGREE)
+                    .setParameter("Longitude of natural origin", longitude, Units.DEGREE)
+                    .createProjectedCRS(baseCRS, null);
+        }
+    };
+
+    /**
+     * The projection name as a {@link Resources} keys.
+     */
+    private final short nameKey;
+
+    /**
+     * Constructs an element of the given name. The new element is automatically added to the list
+     * returned by {@link #values()}. Subclasses shall ensure that only one instance is created for
+     * each value because there is no mechanism for removing previously created values.
+     *
+     * @param name  the name of the new element. This name shall not be in use by another element of this type.
+     */
+    protected PositionableProjection(final String name) {
+        super(name, VALUES);
+        nameKey = 0;
+    }
+
+    /**
+     * Creates a new enumeration.
+     */
+    private PositionableProjection(final String name, final short nameKey) {
+        super(name, VALUES);
+        this.nameKey = nameKey;
+    }
+
+    /**
+     * Returns the list of {@code PositionableProjection}s.
+     *
+     * @return the list of codes declared in the current JVM.
+     */
+    public static PositionableProjection[] values() {
+        synchronized (VALUES) {
+            return VALUES.toArray(new PositionableProjection[VALUES.size()]);
+        }
+    }
+
+    /**
+     * Returns the list of codes of the same kind than this code list element.
+     * Invoking this method is equivalent to invoking {@link #values()}, except that
+     * this method can be invoked on an instance of the parent {@code CodeList} class.
+     *
+     * @return all code {@linkplain #values() values} for this code list.
+     */
+    @Override
+    public PositionableProjection[] family() {
+        return values();
+    }
+
+    /**
+     * Returns a name for this enumeration which can be used in a user interface.
+     *
+     * @return a human-readable name for the projection created by this enumeration.
+     */
+    @Override
+    public String toString() {
+        return (nameKey != 0) ? Resources.format(nameKey) : name();
+    }
+
+    /**
+     * Creates a map projection centered on the given position. The position must have a coordinate reference system,
+     * but that CRS does not need to be geographic. The projection created by this method will use the same reference
+     * frame (datum) than the given position.
+     *
+     * <p>The default implementation converts the position to latitude and longitude values and delegates to
+     * {@link #createProjectedCRS(org.opengis.referencing.crs.GeographicCRS, double, double)}.</p>
+     *
+     * @param  center  the position at the center of the projection to create.
+     * @return projection centered on the given position.
+     * @throws FactoryException if an error occurred while creating the projection.
+     * @throws TransformException if an error occurred while converting the given position.
+     */
+    public ProjectedCRS createProjectedCRS(DirectPosition center) throws FactoryException, TransformException {
+        ArgumentChecks.ensureNonNull("center", center);
+        final CoordinateReferenceSystem inherit = center.getCoordinateReferenceSystem();
+        if (inherit == null) {
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.UnspecifiedCRS));
+        }
+        GeographicCRS normalizedCRS = ReferencingUtilities.toNormalizedGeographicCRS(inherit, true, false);
+        if (normalizedCRS == null) {
+            normalizedCRS = CommonCRS.WGS84.geographic();
+        }
+        if (!Utilities.equalsIgnoreMetadata(normalizedCRS, inherit)) {
+            center = CRS.findOperation(inherit, normalizedCRS, null).getMathTransform().transform(center, null);
+        }
+        return createProjectedCRS(normalizedCRS, center.getOrdinate(0), center.getOrdinate(1));
+    }
+
+    /**
+     * Creates a map projection centered on the given latitude and longitude.
+     *
+     * @param  baseCRS    the base CRS of the projection to create.
+     * @param  latitude   latitude of projection center.
+     * @param  longitude  longitude of projection center.
+     * @return projection centered on the given position.
+     * @throws FactoryException if an error occurred while creating the projection.
+     */
+    protected abstract ProjectedCRS createProjectedCRS(final GeographicCRS baseCRS,
+                        double latitude, double longitude) throws FactoryException;
+
+    /**
+     * Creates a new builder initialized to the projection name for the given coordinates.
+     */
+    final GeodeticObjectBuilder newBuilder(final double latitude, final double longitude) {
+        final AngleFormat  f = new AngleFormat("DD°MM′SS″");
+        final StringBuffer b = new StringBuffer();
+        synchronized (b) {
+            b.append(this).append(" @ ");
+            f.format(new Latitude (latitude),  b, null).append(' ');
+            f.format(new Longitude(longitude), b, null);
+            return new GeodeticObjectBuilder().addName(b.toString());
+        }
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java
index c76c6e4..eb04449 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java
@@ -66,6 +66,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short Along_1 = 35;
 
         /**
+         * Azimuthal equidistant
+         */
+        public static final short AzimuthalEquidistant = 42;
+
+        /**
          * Can not close “{0}”. Data may be lost.
          */
         public static final short CanNotClose_1 = 2;
@@ -106,6 +111,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short CanNotUseRefSys_1 = 9;
 
         /**
+         * Centered projection
+         */
+        public static final short CenteredProjection = 43;
+
+        /**
          * Close
          */
         public static final short Close = 10;
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties
index e1d9cae..12662a9 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties
@@ -22,6 +22,7 @@
 
 AllFiles               = All files
 Along_1                = Along {0}
+AzimuthalEquidistant   = Azimuthal equidistant
 CanNotFetchTile_2      = Can not fetch tile ({0}, {1}).
 CanNotReadFile_1       = Can not open \u201c{0}\u201d.
 CanNotClose_1          = Can not close \u201c{0}\u201d. Data may be lost.
@@ -30,6 +31,7 @@ CanNotCreateXML        = Can not create XML document.
 CanNotReadResource     = A resource contained in the file can not be read. The cause is given below.
 CanNotRender           = An error occurred while rendering the data.
 CanNotUseRefSys_1      = Can not use the \u201c{0}\u201d reference system.
+CenteredProjection     = Centered projection
 Close                  = Close
 Copy                   = Copy
 CopyAs                 = Copy as
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties
index 9d1faf5..4125597 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties
@@ -27,6 +27,7 @@
 
 AllFiles               = Tous les fichiers
 Along_1                = Selon {0}
+AzimuthalEquidistant   = Azimutal \u00e9quidistant
 CanNotFetchTile_2      = Ne peut pas obtenir la tuile ({0}, {1}).
 CanNotReadFile_1       = Ne peut pas ouvrir \u00ab\u202f{0}\u202f\u00bb.
 CanNotClose_1          = Ne peut pas fermer \u00ab\u202f{0}\u202f\u00bb. Il pourrait y avoir une perte de donn\u00e9es.
@@ -35,6 +36,7 @@ CanNotCreateXML        = Ne peut pas cr\u00e9er le document XML.
 CanNotReadResource     = Une ressource contenue dans le fichier ne peut pas \u00eatre lue. La cause est donn\u00e9e ci-dessous.
 CanNotRender           = Une erreur est survenue lors de l\u2019affichage des donn\u00e9es.
 CanNotUseRefSys_1      = Ne peut pas utiliser le syst\u00e8me de r\u00e9f\u00e9rence \u00ab\u202f{0}\u202f\u00bb.
+CenteredProjection     = Projection centr\u00e9e
 Close                  = Fermer
 Copy                   = Copier
 CopyAs                 = Copier comme
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
index 5625631..60e4ee0 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
@@ -155,7 +155,7 @@ public final class Styles extends Static {
     /**
      * Space between a group of controls and the border encompassing the group.
      */
-    private static final Insets FORM_INSETS = new Insets(12);
+    public static final Insets FORM_INSETS = new Insets(12);
 
     /**
      * Creates a grid pane of two columns and an arbitrary number of rows.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/GeodeticObjectBuilder.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/GeodeticObjectBuilder.java
index 22ad543..8dd8931 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/GeodeticObjectBuilder.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/GeodeticObjectBuilder.java
@@ -63,7 +63,7 @@ import org.apache.sis.referencing.IdentifiedObjects;
  * However this class may move in a public package later if we feel confident that its API is mature enough.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.1
  * @since   0.6
  * @module
  */
@@ -376,11 +376,11 @@ public final class GeodeticObjectBuilder extends Builder<GeodeticObjectBuilder>
      * </div>
      *
      * @param  baseCRS    coordinate reference system to base the derived CRS on.
-     * @param  derivedCS  the coordinate system for the derived CRS.
+     * @param  derivedCS  the coordinate system for the derived CRS, or {@code null} for the default.
      * @return the projected CRS.
      * @throws FactoryException if an error occurred while building the projected CRS.
      */
-    public ProjectedCRS createProjectedCRS(final GeographicCRS baseCRS, final CartesianCS derivedCS) throws FactoryException {
+    public ProjectedCRS createProjectedCRS(final GeographicCRS baseCRS, CartesianCS derivedCS) throws FactoryException {
         ensureConversionMethodSet();
         onCreate(false);
         try {
@@ -401,6 +401,9 @@ public final class GeodeticObjectBuilder extends Builder<GeodeticObjectBuilder>
             if (name != null) {
                 properties.put(Conversion.NAME_KEY, name);
             }
+            if (derivedCS == null) {
+                derivedCS = factories.getStandardProjectedCS();
+            }
             return factories.getCRSFactory().createProjectedCRS(properties, baseCRS, conversion, derivedCS);
         } finally {
             onCreate(true);