sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] branch geoapi-4.0 updated: When formatting projected coordinates, give also the axis direction. Example: "-100 m E 300 m N". Maybe a future version should replace "E" by "W" when the value is negative, but current version is enough for resolving the ambiguity problem.
Date Sun, 26 Apr 2020 23:37:06 GMT
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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 71da3a6  When formatting projected coordinates, give also the axis direction. Example: "-100 m E 300 m N". Maybe a future version should replace "E" by "W" when the value is negative, but current version is enough for resolving the ambiguity problem.
71da3a6 is described below

commit 71da3a69b230e0d486fc1dbcc25bf490c70b0254
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Mon Apr 27 01:29:42 2020 +0200

    When formatting projected coordinates, give also the axis direction. Example: "-100 m E 300 m N".
    Maybe a future version should replace "E" by "W" when the value is negative, but current version
    is enough for resolving the ambiguity problem.
    
    Add an `setGroundAccuracy(Quantity)` method for specifying a text like "± 1 m" to append after coordinate values.
    This accuracy is shown only if the precision of coordinate values (i.e. the number of fraction digits) is smaller
    than the specified accuracy.
    
    Those changes are applied only on the `CoordinateFormat.format(…)` method. The `CoordinateFormat.parse(…)` method
    is not yet updated accordingly.
---
 .../java/org/apache/sis/gui/map/StatusBar.java     |  31 +-
 .../org/apache/sis/geometry/CoordinateFormat.java  | 572 ++++++++++++++++-----
 .../apache/sis/geometry/CoordinateFormatTest.java  |  37 +-
 .../java/org/apache/sis/io/CompoundFormat.java     |   7 +-
 .../org/apache/sis/measure/QuantityFormat.java     | 146 ++++++
 .../main/java/org/apache/sis/measure/Scalar.java   |   4 +-
 .../java/org/apache/sis/measure/UnitFormat.java    |   2 +-
 .../java/org/apache/sis/measure/ScalarTest.java    |  10 +-
 8 files changed, 661 insertions(+), 148 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/StatusBar.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/StatusBar.java
index 36ed7ce..44716aa 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/StatusBar.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/StatusBar.java
@@ -44,6 +44,7 @@ import javafx.beans.property.SimpleObjectProperty;
 import javafx.beans.value.ChangeListener;
 import javafx.collections.ListChangeListener;
 import javafx.concurrent.Task;
+import javax.measure.quantity.Length;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.MismatchedDimensionException;
 import org.opengis.util.FactoryException;
@@ -75,6 +76,7 @@ import org.apache.sis.util.logging.Logging;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.gui.Widget;
 import org.apache.sis.gui.referencing.RecentReferenceSystems;
+import org.apache.sis.internal.referencing.Formulas;
 import org.apache.sis.internal.gui.BackgroundThreads;
 import org.apache.sis.internal.gui.ExceptionReporter;
 import org.apache.sis.internal.gui.Resources;
@@ -304,7 +306,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> {
      * The {@link #position} text to show when the mouse is outside the canvas area.
      * This text is set to the axis abbreviations, for example "(φ, λ)".
      *
-     * @see #setFormatCRS(CoordinateReferenceSystem)
+     * @see #setFormatCRS(CoordinateReferenceSystem, Length)
      */
     private String outsideText;
 
@@ -585,7 +587,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> {
             // Keep the format CRS unchanged since we made `localToPositionCRS` consistent with its value.
         } else {
             objectiveToPositionCRS = null;
-            setFormatCRS(crs);                                      // Should be invoked before to set precision.
+            setFormatCRS(crs, null);                                // Should be invoked before to set precision.
             crs = setReplaceablePositionCRS(crs);                   // May invoke later setFormatCRS(…) again.
         }
         format.setGroundPrecision(Quantities.create(resolution, unit));
@@ -729,10 +731,19 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> {
      * @param  operation  the new value to assign to {@link #objectiveToPositionCRS}
      */
     private void setPositionCRS(final CoordinateReferenceSystem crs, final CoordinateOperation operation) {
-        setFormatCRS(crs);
+        Length accuracy = null;
+        double a = CRS.getLinearAccuracy(operation);
+        if (a > 0) {
+            final Unit<Length> unit;
+            if      (a < 1)    unit = Units.CENTIMETRE;
+            else if (a < 1000) unit = Units.METRE;
+            else               unit = Units.KILOMETRE;
+            a = Units.METRE.getConverterTo(unit).convert(Math.max(a, Formulas.LINEAR_TOLERANCE));
+            accuracy = Quantities.create(a, unit);
+        }
+        setFormatCRS(crs, accuracy);
         objectiveToPositionCRS = operation;
         updateLocalToPositionCRS();
-//      TODO: CRS.getLinearAccuracy(op);
         position.setTextFill(Styles.NORMAL_TEXT);
         position.setMinWidth(0);
         setErrorMessage(null, null);
@@ -752,12 +763,14 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> {
      * For the method that apply required changes on transforms before to set the format CRS,
      * see {@link #setPositionCRS(CoordinateReferenceSystem)}.
      *
-     * @param  crs  the new {@link #format} reference system.
+     * @param  crs       the new {@link #format} reference system.
+     * @param  accuracy  positional accuracy in the given CRS, or {@code null} if none.
      *
      * @see #positionReferenceSystem
      */
-    private void setFormatCRS(final CoordinateReferenceSystem crs) {
+    private void setFormatCRS(final CoordinateReferenceSystem crs, final Length accuracy) {
         format.setDefaultCRS(crs);
+        format.setGroundAccuracy(accuracy);
         String text = IdentifiedObjects.getDisplayName(crs, format.getLocale(Locale.Category.DISPLAY));
         Tooltip tp = null;
         if (text != null) {
@@ -792,7 +805,9 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> {
                         }
                         b.append('?');
                     }
-                    text = b.append(')').toString();
+                    b.append(')');
+                    format.getGroundAccuracyText().ifPresent(b::append);
+                    text = b.toString();
                 }
             }
         }
@@ -820,7 +835,7 @@ public class StatusBar extends Widget implements EventHandler<MouseEvent> {
     private void resetPositionCRS(final Color textFill) {
         objectiveToPositionCRS = null;
         localToPositionCRS = localToObjectiveCRS.get();
-        setFormatCRS(objectiveCRS);
+        setFormatCRS(objectiveCRS, null);
         position.setTextFill(textFill);
     }
 
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/geometry/CoordinateFormat.java b/core/sis-referencing/src/main/java/org/apache/sis/geometry/CoordinateFormat.java
index e3ae13d..f872b91 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/geometry/CoordinateFormat.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/geometry/CoordinateFormat.java
@@ -24,16 +24,19 @@ import java.text.DecimalFormat;
 import java.text.FieldPosition;
 import java.text.ParsePosition;
 import java.text.ParseException;
+import java.util.Optional;
 import java.util.Arrays;
 import java.util.Date;
 import java.util.Locale;
 import java.util.TimeZone;
 import java.io.IOException;
+import java.io.ObjectInputStream;
 import java.io.UncheckedIOException;
 import javax.measure.Unit;
 import javax.measure.UnitConverter;
 import javax.measure.Quantity;
 import javax.measure.quantity.Time;
+import javax.measure.quantity.Length;
 import javax.measure.IncommensurableException;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.referencing.cs.AxisDirection;
@@ -43,6 +46,7 @@ import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.opengis.referencing.crs.TemporalCRS;
 import org.opengis.referencing.datum.Ellipsoid;
 import org.apache.sis.internal.referencing.Formulas;
+import org.apache.sis.internal.referencing.AxisDirections;
 import org.apache.sis.internal.referencing.ReferencingUtilities;
 import org.apache.sis.internal.system.Loggers;
 import org.apache.sis.internal.util.LocalizedParseException;
@@ -60,8 +64,10 @@ import org.apache.sis.measure.Latitude;
 import org.apache.sis.measure.Longitude;
 import org.apache.sis.measure.Units;
 import org.apache.sis.measure.Quantities;
-import org.apache.sis.referencing.CRS;
+import org.apache.sis.measure.QuantityFormat;
+import org.apache.sis.measure.UnitFormat;
 import org.apache.sis.io.CompoundFormat;
+import org.apache.sis.referencing.CRS;
 
 
 /**
@@ -105,7 +111,7 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
     /**
      * Serial number for cross-version compatibility.
      */
-    private static final long serialVersionUID = 8324486673169133932L;
+//    private static final long serialVersionUID = -6813397580614005734L;
 
     /**
      * Maximal number of characters to convert to {@link String} if the text to parse is not a string instance.
@@ -122,7 +128,7 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
 
     /**
      * The separator between each coordinate values to be formatted.
-     * The default value is a space.
+     * The default value is a EM space space (U+2003).
      *
      * @see #getSeparator()
      * @see #setSeparator(String)
@@ -130,18 +136,61 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
     private String separator;
 
     /**
-     * The separator without spaces, used at parsing time.
+     * The separator without spaces, or an empty string if the separator contains only white spaces.
+     * This is used at parsing time only.
      */
-    private String parseSeparator;
+    private transient String parseSeparator;
 
     /**
      * The desired ground precision, or {@code null} if unspecified.
+     * This precision may not apply to all axes. The "ground axes" dimensions
+     * are identified by the bits set in the {@link #groundDimensions} bitmask.
      *
+     * @see #groundDimensions
      * @see #setGroundPrecision(Quantity)
      */
     private Quantity<?> groundPrecision;
 
     /**
+     * The declared accuracy on ground, or {@code null} if unspecified. The accuracy applies to the same axes
+     * than {@link #groundPrecision}. But contrarily to {@code groundPrecision}, the accuracy does not change
+     * the number of fraction digits used by {@link NumberFormat}. Instead it causes a text such as "± 30 m"
+     * to be appended after the coordinates.
+     *
+     * @see #accuracyText
+     * @see #groundDimensions
+     * @see #accuracyThreshold
+     * @see #setGroundAccuracy(Quantity)
+     */
+    private Quantity<?> groundAccuracy;
+
+    /**
+     * Value of {@link #desiredPrecisions} which cause {@link #accuracyText} to be shown.
+     * For each dimension identified by {@link #groundDimensions}, if the corresponding
+     * value in {@link #desiredPrecisions} is equals or smaller to this threshold, then
+     * {@link #accuracyText} will be appended after the formatted coordinates.
+     *
+     * @see #desiredPrecisions
+     * @see #isAccuracyVisible
+     */
+    private transient double accuracyThreshold;
+
+    /**
+     * The dimensions on which {@link #groundPrecision} applies, specified as a bitmask.
+     * This bitmask is computed by {@link #applyGroundPrecision(CoordinateReferenceSystem)}
+     * when first needed. The current heuristic rules are:
+     * <ul>
+     *   <li>All axes having a {@link AxisDirections#isCompass(AxisDirection) compass direction}
+     *       if at least one of those axes uses an unit of measurement compatible with the unit
+     *       of {@link #groundPrecision} (possibly after conversion between linear and angular
+     *       units on a sphere).</li>
+     *   <li>Otherwise all axes using compatible units, regardless their direction and without
+     *       conversion between linear and angular units.</li>
+     * </ul>
+     */
+    private transient long groundDimensions;
+
+    /**
      * The desired precisions for each coordinate, or {@code null} if unspecified.
      * The length of this array does not need to be equal to the number of dimensions;
      * extraneous values are ignored and missing values are assumed equal to 0.
@@ -156,17 +205,32 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
     private double[] desiredPrecisions;
 
     /**
-     * Whether the {@link Format} instances have been configured for the precision specified by
-     * {@link #groundPrecision} and {@link #desiredPrecisions}. We use a field separated from
-     * {@link #lastCRS} because precision needs to be set only for formatting, not for parsing.
+     * Whether this {@code CoordinateFormat} instance has been configured for the precision and accuracy
+     * specified by {@link #groundPrecision}, {@link #desiredPrecisions} and {@link #groundAccuracy}.
+     * We use a field separated from {@link #lastCRS} because precision and accuracy threshold need
+     * to be set only for formatting, not for parsing.
      *
      * @see #setPrecisions(double...)
      * @see #setGroundPrecision(Quantity)
+     * @see #setGroundAccuracy(Quantity)
      * @see #configure(CoordinateReferenceSystem)
      */
     private transient boolean isPrecisionApplied;
 
     /**
+     * Whether to append the accuracy after coordinate values. This flag is {@code true}
+     * if {@link #accuracyText} is non-null and one of the following conditions is true:
+     *
+     * <ul>
+     *   <li>{@link #desiredPrecisions} is null, in which case the accuracy is unconditionally shown.</li>
+     *   <li>At least one {@link #desiredPrecisions} value is below {@link #accuracyThreshold}.</li>
+     * </ul>
+     *
+     * This flag is valid only if {@link #isPrecisionApplied} is {@code true}.
+     */
+    private transient boolean isAccuracyVisible;
+
+    /**
      * The coordinate reference system to assume if no CRS is attached to the position to format.
      * May be {@code null}.
      *
@@ -191,6 +255,9 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
      * The type for each value in the {@code formats} array, or {@code null} if not yet computed.
      * Types are: 0=number, 1=longitude, 2=latitude, 3=other angle, 4=date, 5=elapsed time.
      *
+     * <p>This array is created by {@link #createFormats(CoordinateReferenceSystem)}, which is invoked before
+     * parsing or formatting in a different CRS than last operation, and stay unmodified after creation.</p>
+     *
      * @see #createFormats(CoordinateReferenceSystem)
      */
     private transient byte[] types;
@@ -217,22 +284,45 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
     /**
      * The units for each dimension to be formatted as number.
      * We do not store this information for dimensions to be formatted as angle or date.
+     *
+     * <p>This array is created by {@link #createFormats(CoordinateReferenceSystem)}, which is invoked before
+     * parsing or formatting in a different CRS than last operation, and stay unmodified after creation.</p>
      */
     private transient Unit<?>[] units;
 
     /**
      * Conversions from arbitrary units to the unit used by formatter, or {@code null} if none.
      * For example in the case of dates, this is the conversions from temporal axis units to milliseconds.
+     *
+     * <p>This array is created by {@link #createFormats(CoordinateReferenceSystem)}, which is invoked before
+     * parsing or formatting in a different CRS than last operation, and stay unmodified after creation.</p>
      */
     private transient UnitConverter[] toFormatUnit;
 
     /**
-     * Units symbols. Used only for coordinate to be formatted as ordinary numbers.
-     * Non-null only if at least one coordinate is to be formatted that way.
+     * Units symbols to append after coordinate values for each dimension, including leading space.
+     * This is used only for coordinates to be formatted as ordinary numbers with {@link NumberFormat}.
+     * This array is non-null only if at least one dimension needs to format its coordinates that way.
+     * The unit symbol may be followed by axis direction symbol used for axes on the ground
+     * ("E", "N", "SW", <i>etc.</i>) so the complete symbol may be for example "km E".
+     *
+     * <p>This array is created by {@link #createFormats(CoordinateReferenceSystem)}, which is invoked before
+     * parsing or formatting in a different CRS than last operation, and stay unmodified after creation.</p>
      */
     private transient String[] unitSymbols;
 
     /**
+     * Text to append to the coordinate values for giving an indication about accuracy, or {@code null} if none.
+     * Example: " ± 1 m" (note the leading space). This is determined by the {@link #groundAccuracy} value.
+     * If {@link #desiredPrecisions} array is non-null, then accuracy is shown only if a precision is smaller.
+     *
+     * @see #groundAccuracy
+     * @see #accuracyThreshold
+     * @see #setGroundAccuracy(Quantity)
+     */
+    private transient String accuracyText;
+
+    /**
      * Flags the coordinate values that need to be inverted before to be formatted.
      * This is needed for example if the axis is oriented toward past instead than future,
      * or toward west instead than east.
@@ -243,6 +333,9 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
 
     /**
      * The time epochs. Non-null only if the at least on coordinate is to be formatted as a date.
+     *
+     * <p>This array is created by {@link #createFormats(CoordinateReferenceSystem)}, which is invoked before
+     * parsing or formatting in a different CRS than last operation, and stay unmodified after creation.</p>
      */
     private transient long[] epochs;
 
@@ -272,7 +365,8 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
      */
     public CoordinateFormat(final Locale locale, final TimeZone timezone) {
         super(locale, timezone);
-        parseSeparator = separator = " ";
+        separator = "\u2003";       // EM space.
+        parseSeparator = "";
     }
 
     /**
@@ -295,9 +389,6 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
         ArgumentChecks.ensureNonEmpty("separator", separator);
         this.separator = separator;
         parseSeparator = CharSequences.trimWhitespaces(separator);
-        if (parseSeparator.isEmpty()) {
-            parseSeparator = separator;
-        }
     }
 
     /**
@@ -348,6 +439,7 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
         /*
          * If no CRS were specified, we will format everything as numbers. Working with null CRS
          * is sometime useful because null CRS are allowed in DirectPosition according ISO 19107.
+         * Note that the caller may have replaced `crs` by `defaultCRS` if the CRS was null.
          */
         if (crs == null) {
             return;
@@ -358,6 +450,14 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
         }
         /*
          * Otherwise (if a CRS is given), infer the format subclasses from the axes.
+         * Prepare also related information such as the unit of measurement and the
+         * axis direction ("E", "N", etc.) that may need to be formatted.
+         * The loop handles the following cases:
+         *
+         *    - case 0: no axis         — use default NumberFormat
+         *    - case 1: angular unit    — use AngleFormat
+         *    - case 2: temporal unit   — use DateFormat unless no TemporalCRS is found
+         *    - case 3: all other unit  — use NumberFormat + UnitFormat + [axis direction]
          */
         final int      dimension = cs.getDimension();
         final byte[]   types     = new byte  [dimension];
@@ -368,25 +468,25 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
                 formats[i] = getDefaultFormat();
                 continue;
             }
+            final AxisDirection direction = axis.getDirection();
             final Unit<?> unit = axis.getUnit();
             /*
-             * Formatter for angular units. Target unit is DEGREE_ANGLE.
+             * CASE 1: Formatter for angular units. Target unit is DEGREE_ANGLE.
              * Type is LONGITUDE, LATITUDE or ANGLE depending on axis direction.
              */
             if (Units.isAngular(unit)) {
                 byte type = ANGLE;
-                final AxisDirection dir = axis.getDirection();
-                if      (AxisDirection.NORTH.equals(dir)) {type = LATITUDE;}
-                else if (AxisDirection.EAST .equals(dir)) {type = LONGITUDE;}
-                else if (AxisDirection.SOUTH.equals(dir)) {type = LATITUDE;  negate(i);}
-                else if (AxisDirection.WEST .equals(dir)) {type = LONGITUDE; negate(i);}
+                if      (AxisDirection.NORTH.equals(direction)) {type = LATITUDE;}
+                else if (AxisDirection.EAST .equals(direction)) {type = LONGITUDE;}
+                else if (AxisDirection.SOUTH.equals(direction)) {type = LATITUDE;  negate(i);}
+                else if (AxisDirection.WEST .equals(direction)) {type = LONGITUDE; negate(i);}
                 types  [i] = type;
                 formats[i] = getFormat(Angle.class);
                 setConverter(dimension, i, unit.asType(javax.measure.quantity.Angle.class).getConverterTo(Units.DEGREE));
                 continue;
             }
             /*
-             * Formatter for temporal units. Target unit is MILLISECONDS.
+             * CASE 2: Formatter for temporal units. Target unit is MILLISECONDS.
              * Type is DATE.
              */
             if (Units.isTemporal(unit)) {
@@ -399,36 +499,45 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
                     formats[i] = getFormat(Date.class);
                     epochs [i] = ((TemporalCRS) t).getDatum().getOrigin().getTime();
                     setConverter(dimension, i, unit.asType(Time.class).getConverterTo(Units.MILLISECOND));
-                    if (AxisDirection.PAST.equals(axis.getDirection())) {
+                    if (AxisDirection.PAST.equals(direction)) {
                         negate(i);
                     }
                     continue;
                 }
                 types[i] = TIME;
-                // Fallthrough: formatted as number.
+                // Fallthrough: format as number (can not compute epoch because no TemporalCRS found).
             }
             /*
-             * Formatter for all other units. Do NOT set types[i] since it may have been set
-             * to a non-zero value by previous case. If not, the default value (zero) is the
-             * one we want.
+             * CASE 3: Formatter for all other units. Do NOT set types[i] since it may have been set to
+             * a non-zero value by previous case. If not, the default value (zero) is the one we want.
              */
             formats[i] = getFormat(Number.class);
+            StringBuilder symbol = null;
             if (unit != null) {
                 if (units == null) {
                     units = new Unit<?>[dimension];
                 }
                 units[i] = unit;
-                final String symbol = getFormat(Unit.class).format(unit);
-                if (!symbol.isEmpty()) {
-                    if (unitSymbols == null) {
-                        unitSymbols = new String[dimension];
-                    }
-                    unitSymbols[i] = symbol;
+                final String s = getFormat(Unit.class).format(unit);
+                if (!s.isEmpty()) {
+                    symbol = new StringBuilder().append(QuantityFormat.SEPARATOR).append(s);
+                }
+            }
+            if (AxisDirections.isCompass(direction)) {
+                if (symbol == null) {
+                    symbol = new StringBuilder();
                 }
+                symbol.append(Characters.NO_BREAK_SPACE).append(CharSequences.camelCaseToAcronym(direction.identifier()));
+            }
+            if (symbol != null) {
+                if (unitSymbols == null) {
+                    unitSymbols = new String[dimension];
+                }
+                unitSymbols[i] = symbol.toString();
             }
         }
-        this.types    = types;          // Assign only on success.
-        this.formats  = formats;
+        this.types    = types;
+        this.formats  = formats;        // Assign only on success because no element can be null.
         sharedFormats = formats;        // `getFormatClone(int)` will separate arrays later if needed.
     }
 
@@ -582,6 +691,7 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
                 }
             }
         }
+        updateAccuracyVisibility();
     }
 
     /**
@@ -621,7 +731,7 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
      */
     private void configure(final CoordinateReferenceSystem crs) {
         if (lastCRS != crs) {
-            createFormats(crs);             // This method sets the `lastCRS` field.
+            createFormats(crs);                 // This method sets the `lastCRS` field.
         }
         if (!isPrecisionApplied) {
             if (groundPrecision != null) {
@@ -638,6 +748,8 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
                     applyPrecision(i);          // Will clone Format instances if needed.
                 }
             }
+            applyGroundAccuracy(crs);
+            updateAccuracyVisibility();
             isPrecisionApplied = true;
         }
     }
@@ -685,7 +797,6 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
      *
      * @since 1.1
      */
-    @SuppressWarnings("null")
     public void setGroundPrecision(final Quantity<?> precision) {
         ArgumentChecks.ensureNonNull("precision", precision);
         groundPrecision = precision;
@@ -695,66 +806,175 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
     }
 
     /**
+     * Specifies an uncertainty to append as "± <var>accuracy</var>" after the coordinate values.
+     * If no {@linkplain #setPrecisions(double...) precisions} have been specified, the accuracy
+     * will be always shown. But if precisions have been specified, then the accuracy will be
+     * shown only if equals or greater than the precision.
+     *
+     * @param  accuracy  the accuracy to append after the coordinate values, or {@code null} if none.
+     *
+     * @see #getGroundAccuracy()
+     * @see #getGroundAccuracyText()
+     * @see Quantities#create(double, Unit)
+     *
+     * @since 1.1
+     */
+    public void setGroundAccuracy(final Quantity<?> accuracy) {
+        accuracyText = null;
+        groundAccuracy = accuracy;
+        if (accuracy != null) {
+            final NumberFormat nf = NumberFormat.getInstance(getLocale(Locale.Category.FORMAT));
+            final QuantityFormat f = new QuantityFormat(nf, (UnitFormat) getFormat(Unit.class));
+            if (buffer == null) buffer = new StringBuffer();
+            buffer.setLength(0);
+            accuracyText = f.format(accuracy, buffer.append("\u2003\u00B1\u00A0"), dummy).toString();
+        }
+        if (isPrecisionApplied) {
+            applyGroundAccuracy(lastCRS);
+            updateAccuracyVisibility();
+        }
+    }
+
+    /**
      * Configures the formats for {@link #groundPrecision} value. Contrarily to {@link #applyPrecision(int)},
      * this method modifies the default formats provided by {@link #getFormat(Class)}. They are the formats
      * stored in the {@link #sharedFormats} array. Those formats are used as fallback when the {@link #formats}
      * array does not provide more specific format.
      *
+     * <p>It is caller responsibility to ensure that {@link #groundPrecision} is non-null before to invoke this
+     * method.</p>
+     *
      * @param  crs  the target CRS in the conversion from ground units to CRS units.
      */
+    @SuppressWarnings("null")
     private void applyGroundPrecision(final CoordinateReferenceSystem crs) {
-        final double resolution = Math.abs(groundPrecision.getValue().doubleValue());
-        final Unit<?> unit = groundPrecision.getUnit();
-        if (Units.isTemporal(unit)) {
-            return;                                 // Setting temporal resolution is not yet implemented.
-        }
         /*
          * If the given resolution is linear (for example in metres), compute an equivalent resolution in degrees
          * assuming a sphere of radius computed from the CRS.  Conversely if the resolution is angular (typically
          * in degrees), computes an equivalent linear resolution. For all other kind of units, do nothing.
          */
-        Resolution specified = new Resolution(resolution, unit, Units.isAngular(unit));
-        Resolution related   = null;
-        IncommensurableException error = null;
-        if (specified.isAngular || Units.isLinear(unit)) try {
-            related = specified.related(ReferencingUtilities.getEllipsoid(crs));
-        } catch (IncommensurableException e) {
-            error = e;
+        final Resolution specified = new Resolution(groundPrecision);
+        Resolution derived;
+        IncommensurableException error;
+        try {
+            derived = specified.derived(crs);
+            error   = null;
+        } catch (IncommensurableException e) {      // Should not happen. If happen anyway, use `specified` only.
+            derived = null;
+            error   = e;
         }
         /*
-         * We now have the requested resolution in both linear and angular units, if equivalence has been established.
-         * Convert those resolutions to the units actually used by the CRS. If some axes use different units, keep the
-         * units which result in the finest resolution.
+         * We now have the requested resolution in both linear and angular units. Convert those resolutions
+         * to the unit actually used by CRS axes.  If the units are not the same for all axes, use the unit
+         * which result in the smallest resolution value after conversion. Current implementation considers
+         * only compass directions (East, North, South-East, etc.) but we may revisit in the future.
          */
-        boolean relatedUsed = false;
-        if (crs != null) {
-            final CoordinateSystem cs = crs.getCoordinateSystem();
-            if (cs != null) {                                                   // Paranoiac check (should never be null).
-                final int dimension = cs.getDimension();
+        groundDimensions     = 0L;
+        boolean useSpecified = false;
+        boolean useDerived   = false;
+        final CoordinateSystem cs;
+        if (crs != null && (cs = crs.getCoordinateSystem()) != null) {
+            final int dimension = cs.getDimension();
+            /*
+             * The following loop will be executed exactly one or two times. The first execution checks
+             * only axes having compass direction (East, North, South-East, etc.) and compatible units.
+             * If no such axis is found, second execution checks all axes regardless their direction.
+             */
+            for (boolean useAllAxes = false; ; useAllAxes = true) {
                 for (int i=0; i<dimension; i++) {
                     final CoordinateSystemAxis axis = cs.getAxis(i);
-                    if (axis != null) {                                         // Paranoiac check.
+                    if (axis == null) continue;                         // Paranoiac check (should never be null).
+                    final AxisDirection direction = axis.getDirection();
+                    if (useAllAxes || AxisDirections.isCompass(direction)) {
+                        specified.findMaxValue(axis);
                         final Unit<?> axisUnit = axis.getUnit();
                         if (axisUnit != null) try {
-                            final double maxValue = Math.max(Math.abs(axis.getMinimumValue()),
-                                                             Math.abs(axis.getMaximumValue()));
-                            if (!specified.forAxis(maxValue, axisUnit) && related != null) {
-                                relatedUsed |= related.forAxis(maxValue, axisUnit);
+                            boolean done;
+                            useSpecified |= (done = specified.findMinResolution(axisUnit, useSpecified));
+                            if (!done && derived != null) {
+                                useDerived |= (done = derived.findMinResolution(axisUnit, useDerived));
+                            }
+                            if (done) {
+                                groundDimensions |= Numerics.bitmask(i);
                             }
                         } catch (IncommensurableException e) {
-                            if (error == null) error = e;
+                            if (error == null) error = e;       // Should not happen. If happen anyway, skip axis.
                             else error.addSuppressed(e);
                         }
                     }
                 }
+                if (useSpecified | useDerived) {
+                    break;
+                }
+                if (useAllAxes) {
+                    useSpecified = true;
+                    derived      = null;
+                    break;
+                }
             }
         }
+        if (useSpecified) specified.setPrecision(this);
+        if (useDerived)   derived  .setPrecision(this);
         if (error != null) {
-            Logging.unexpectedException(Logging.getLogger(Loggers.MEASURE), CoordinateFormat.class, "setGroundPrecision", error);
+            unexpectedException("setGroundPrecision", error);
         }
-        specified.setPrecision(this);
-        if (relatedUsed) {
-            related.setPrecision(this);
+    }
+
+    /**
+     * Updates the {@link #accuracyThreshold} for the current {@link #groundAccuracy} value
+     * (which may be null) and the given coordinate reference system.
+     */
+    private void applyGroundAccuracy(final CoordinateReferenceSystem crs) {
+        long dimensions = groundDimensions;
+abort:  if (dimensions != 0 && groundAccuracy != null) try {
+            final Resolution specified = new Resolution(groundAccuracy);
+            final Resolution derived   = specified.derived(crs);            // May be null.
+            final CoordinateSystem cs  = crs.getCoordinateSystem();
+            accuracyThreshold = 0;
+            do {
+                final int i = Long.numberOfTrailingZeros(dimensions);
+                final Unit<?> unit = cs.getAxis(i).getUnit();
+                final double accuracy;
+                if (unit.isCompatible(specified.unit)) {
+                    accuracy = specified.resolution(unit);
+                } else if (derived != null && unit.isCompatible(derived.unit)) {
+                    accuracy = derived.resolution(unit);
+                } else {
+                    break abort;
+                }
+                if (accuracy > accuracyThreshold) {
+                    accuracyThreshold = accuracy;
+                }
+                dimensions &= ~(1L << i);
+            } while (dimensions != 0);
+            return;
+        } catch (IncommensurableException e) {
+            // Should not happen because `groundDimensions` bits were set only on successful axes.
+            unexpectedException("setGroundAccuracy", e);
+        }
+        accuracyThreshold = Double.POSITIVE_INFINITY;
+    }
+
+    /**
+     * Updates the {@link #isAccuracyVisible} flag according current values of {@link #accuracyText},
+     * {@link #accuracyThreshold} and {@link #desiredPrecisions}.
+     */
+    private void updateAccuracyVisibility() {
+        isAccuracyVisible = (accuracyText != null);
+        if (isAccuracyVisible && desiredPrecisions != null) {
+            long dimensions = groundDimensions;
+            if (dimensions != 0) {
+                isAccuracyVisible = false;
+                do {
+                    final int i = Long.numberOfTrailingZeros(dimensions);
+                    final double precision = desiredPrecisions[i];
+                    if (precision > 0 && precision <= accuracyThreshold) {
+                        isAccuracyVisible = true;
+                        break;
+                    }
+                    dimensions &= ~(1L << i);
+                } while (dimensions != 0);
+            }
         }
     }
 
@@ -765,23 +985,48 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
      * and another one for the resolution in degrees.
      */
     private static final class Resolution {
+        /** Maximal absolute value that we may format, regardless unit of measurement. */
+        private double magnitude;
+
         /** The desired resolution in the unit of measurement given by {@link #unit}. */
         private double resolution;
 
-        /** Maximal absolute value that we may format in unit of measurement given by {@link #unit}. */
-        private double magnitude;
-
-        /** Unit of measurement of {@link #resolution} or {@link #magnitude}. */
+        /** Unit of measurement of {@link #resolution}. */
         private Unit<?> unit;
 
         /** Whether {@link #unit} is an angular unit. */
         final boolean isAngular;
 
-        /** Creates a new instance initialized to the given resolution. */
-        Resolution(final double resolution, final Unit<?> unit, final boolean isAngular) {
-            this.resolution = resolution;
-            this.unit       = unit;
-            this.isAngular  = isAngular;
+        /** Creates a new instance initialized to the given precision. */
+        Resolution(final Quantity<?> groundPrecision) {
+            resolution = Math.abs(groundPrecision.getValue().doubleValue());
+            unit       = groundPrecision.getUnit();
+            isAngular  = Units.isAngular(unit);
+        }
+
+        /**
+         * Creates a new instance derived from the given angular or linear resolution.
+         * This constructor computes an angular resolution from a linear one, or conversely.
+         * If is caller responsibility to ensure that the specified resolution is either linear or angular.
+         *
+         * @param  specified  the linear or angular resolution specified by the user.
+         * @param  radius     authalic radius of CRS ellipsoid.
+         * @param  axisUnit   {@code radius} unit of measurement, which is also ellipsoid axes unit.
+         * @throws IncommensurableException should not happen if {@code specified} is either linear or angular.
+         */
+        private Resolution(final Resolution specified, final double radius, final Unit<Length> axisUnit)
+                throws IncommensurableException
+        {
+            isAngular = !specified.isAngular;
+            if (isAngular) {
+                // Angular resolution in radians  =  linear resolution  /  radius
+                resolution = Math.toDegrees(specified.resolution(axisUnit) / radius);
+                unit       = Units.DEGREE;
+            } else {
+                // Linear resolution  =  angular resolution in radians  ×  radius.
+                resolution = specified.resolution(Units.RADIAN) * radius;
+                unit       = axisUnit;
+            }
         }
 
         /**
@@ -789,53 +1034,65 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
          * is in degrees, returns an equivalent resolution in metres. Other linear and angular units are accepted too;
          * they will be converted as needed.
          *
-         * @param  ellipsoid  the ellipsoid, or {@code null} if none.
-         * @return the related resolution, or {@code null} if none.
+         * @param  crs  the CRS for which to derive an equivalent resolution, or {@code null} if none.
+         * @return the derived resolution, or {@code null} if none.
+         * @throws IncommensurableException should never happen since this method verifies unit compatibility.
          */
-        Resolution related(final Ellipsoid ellipsoid) throws IncommensurableException {
-            final double radius = Formulas.getAuthalicRadius(ellipsoid);
-            if (radius > 0) {                                       // Indirectly filter null ellipsoid.
-                Unit<?> relatedUnit = ellipsoid.getAxisUnit();      // Angular if `unit` is linear, or linear if `unit` is angular.
-                if (relatedUnit != null) {                          // Paranoiac check (should never be null).
-                    double related;
-                    if (isAngular) {
-                        // Linear resolution  =  angular resolution in radians  ×  radius.
-                        related = unit.getConverterToAny(Units.RADIAN).convert(resolution) * radius;
-                    } else {
-                        // Angular resolution in radians  =  linear resolution  /  radius
-                        related = Math.toDegrees(unit.getConverterToAny(relatedUnit).convert(resolution) / radius);
-                        relatedUnit = Units.DEGREE;
+        Resolution derived(final CoordinateReferenceSystem crs) throws IncommensurableException {
+            if (isAngular || Units.isLinear(unit)) {
+                final Ellipsoid ellipsoid = ReferencingUtilities.getEllipsoid(crs);
+                final double radius = Formulas.getAuthalicRadius(ellipsoid);
+                if (radius > 0) {                                       // Indirectly filter null ellipsoid.
+                    Unit<Length> axisUnit = ellipsoid.getAxisUnit();
+                    if (axisUnit != null) {                             // Paranoiac check (should never be null).
+                        return new Resolution(this, radius, axisUnit);
                     }
-                    return new Resolution(related, relatedUnit, !isAngular);
                 }
             }
             return null;
         }
 
         /**
+         * Returns the resolution converted to the specified unit as an absolute value.
+         *
+         * @throws IncommensurableException if the specified unit is not compatible with {@link #unit}.
+         */
+        private double resolution(final Unit<?> target) throws IncommensurableException {
+            return Math.abs(unit.getConverterToAny(target).convert(resolution));
+        }
+
+        /**
          * Adjusts the resolution units for the given coordinate system axis. This methods select the units which
          * result in the smallest absolute value of {@link #resolution}.
          *
-         * @param  maxValue  the maximal absolute value that a coordinate on the axis may have.
-         * @param  axisUnit  {@link CoordinateSystemAxis#getUnit()}.
+         * @param  axisUnit     {@link CoordinateSystemAxis#getUnit()}.
+         * @param  hasPrevious  whether this method has been successfully applied on another axis before.
          * @return whether the given axis unit is compatible with the expected unit.
+         * @throws IncommensurableException should never happen since this method verifies unit compatibility.
          */
-        boolean forAxis(double maxValue, final Unit<?> axisUnit) throws IncommensurableException {
+        boolean findMinResolution(final Unit<?> axisUnit, final boolean hasPrevious) throws IncommensurableException {
             if (!axisUnit.isCompatible(unit)) {
                 return false;
             }
-            final UnitConverter c = unit.getConverterToAny(axisUnit);
-            final double r = Math.abs(c.convert(resolution));
-            if (r < resolution) {
-                resolution = r;                                         // To units producing the smallest value.
+            final double r = resolution(axisUnit);
+            if (!hasPrevious || r < resolution) {
+                resolution = r;                         // To units producing the smallest value.
                 unit = axisUnit;
-            } else {
-                maxValue = Math.abs(c.inverse().convert(maxValue));     // From axis units to selected units.
             }
+            return true;
+        }
+
+        /**
+         * Adjusts the maximal magnitude value, ignoring unit conversion. We do not apply unit conversion because
+         * the axis minimum and maximum values are already in the units of the coordinates that will be formatted.
+         * Even if different axes use different units, we want the largest value that {@link NumberFormat} may see.
+         */
+        final void findMaxValue(final CoordinateSystemAxis axis) {
+            final double maxValue = Math.max(Math.abs(axis.getMinimumValue()),
+                                             Math.abs(axis.getMaximumValue()));
             if (maxValue > magnitude) {
                 magnitude = maxValue;
             }
-            return true;
         }
 
         /**
@@ -844,6 +1101,9 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
          * {@link #getFormat(Class)}. They are the formats stored in the {@link #sharedFormats} array.
          */
         void setPrecision(final CoordinateFormat owner) {
+            if (Units.isTemporal(unit)) {
+                return;                         // Setting temporal resolution is not yet implemented.
+            }
             final Format format = owner.getFormat(isAngular ? Angle.class : Number.class);
             if (format instanceof DecimalFormat) {
                 /*
@@ -863,6 +1123,30 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
     }
 
     /**
+     * Returns the current ground accuracy value, or {@code null} if none.
+     * This is the value given to the last call to {@link #setGroundAccuracy(Quantity)}.
+     *
+     * @return the current ground accuracy value, or {@code null} if none.
+     *
+     * @see #setGroundAccuracy(Quantity)
+     */
+    public Quantity<?> getGroundAccuracy() {
+        return groundAccuracy;
+    }
+
+    /**
+     * Returns the textual representation of the current ground accuracy.
+     * Example: " ± 3 m" (note the leading space).
+     *
+     * @return textual representation of current ground accuracy.
+     *
+     * @see #setGroundAccuracy(Quantity)
+     */
+    public Optional<String> getGroundAccuracyText() {
+        return Optional.ofNullable(accuracyText);
+    }
+
+    /**
      * Returns the pattern for number, angle or date fields. The given {@code valueType} should be
      * {@code Number.class}, {@code Angle.class}, {@code Date.class} or a sub-type of the above.
      * This method may return {@code null} if the underlying format can not provide a pattern.
@@ -1057,10 +1341,17 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
             if (unitSymbols != null && i < unitSymbols.length) {
                 final String symbol = unitSymbols[i];
                 if (symbol != null) {
-                    toAppendTo.append(Characters.NO_BREAK_SPACE).append(symbol);
+                    toAppendTo.append(symbol);
                 }
             }
         }
+        /*
+         * Finished to format the all coordinate values. Appends the accuracy if
+         * there is one and if the precision is at least as small as the accuracy.
+         */
+        if (isAccuracyVisible) {
+            toAppendTo.append(accuracyText);
+        }
     }
 
     /**
@@ -1124,27 +1415,49 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
          * end ahead of time. We currently allow that only for coordinate without CRS.
          */
         for (int i=0; i < coordinates.length; i++) {
-            if (i != 0) {
-                final int end = subPos.getIndex();
+skipSep:    if (i != 0) {
+                final int end = subPos.getIndex();          // End of previous coordinate.
                 int index = offset + end;
-                while (!CharSequences.regionMatches(text, index, parseSeparator)) {
-                    if (index < length) {
-                        final int c = Character.codePointAt(text, index);
-                        if (Character.isSpaceChar(c)) {
-                            index += Character.charCount(c);
-                            continue;
+                while (index < length) {
+                    if (parseSeparator.isEmpty()) {
+                        final int next = CharSequences.skipLeadingWhitespaces(text, index, length);
+                        if (next > index) {
+                            subPos.setIndex(next - offset);
+                            break skipSep;
+                        }
+                    } else {
+                        if (CharSequences.regionMatches(text, index, parseSeparator)) {
+                            subPos.setIndex(index + parseSeparator.length() - offset);
+                            break skipSep;
                         }
                     }
-                    if (formats == null) {
-                        pos.setIndex(index);
-                        return new GeneralDirectPosition(Arrays.copyOf(coordinates, i));
-                    }
-                    pos.setIndex(start);
-                    pos.setErrorIndex(index);
-                    throw new LocalizedParseException(getLocale(), Errors.Keys.UnexpectedCharactersAfter_2,
-                            new CharSequence[] {text.subSequence(start, end), CharSequences.token(text, index)}, index);
+                    final int c = Character.codePointAt(text, index);
+                    if (!Character.isSpaceChar(c)) break;
+                    index += Character.charCount(c);
                 }
-                subPos.setIndex(index + parseSeparator.length() - offset);
+                /*
+                 * No separator found. If no CRS was specified (in which case we don't know how many coordinates
+                 * were expected), then stop parsing and return whatever amount of coordinates we got. Otherwise
+                 * (another coordinate was expected) consider we have a too short string or unexpected characters.
+                 */
+                if (formats == null) {
+                    pos.setIndex(index);
+                    return new GeneralDirectPosition(Arrays.copyOf(coordinates, i));
+                }
+                pos.setIndex(start);
+                pos.setErrorIndex(index);
+                final CharSequence previous = text.subSequence(start, end);
+                final CharSequence found = CharSequences.token(text, index);
+                final short key;
+                final CharSequence[] args;
+                if (found.length() != 0) {
+                    key = Errors.Keys.UnexpectedCharactersAfter_2;
+                    args = new CharSequence[] {previous, found};
+                } else {
+                    key = Errors.Keys.UnexpectedEndOfString_1;
+                    args = new CharSequence[] {previous};
+                }
+                throw new LocalizedParseException(getLocale(), key, args, index);
             }
             /*
              * At this point 'subPos' is set to the beginning of the next coordinate to parse in 'asString'.
@@ -1242,6 +1555,16 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
     }
 
     /**
+     * Invoked when an expected error occurred but continuation is still possible.
+     *
+     * @param  method  the public method to report as the source of the log record.
+     * @param  error   the error that occurred.
+     */
+    private static void unexpectedException(final String method, final Exception error) {
+        Logging.unexpectedException(Logging.getLogger(Loggers.MEASURE), CoordinateFormat.class, method, error);
+    }
+
+    /**
      * Returns a clone of this format.
      *
      * @return a clone of this format.
@@ -1257,4 +1580,15 @@ public class CoordinateFormat extends CompoundFormat<DirectPosition> {
         }
         return clone;
     }
+
+    /**
+     * Invoked on deserialization for restoring some transient fields.
+     *
+     * @param  in  the input stream from which to deserialize a coordinate format
+     * @throws IOException if an I/O error occurred while reading or if the stream contains invalid data.
+     * @throws ClassNotFoundException if the class serialized on the stream is not on the classpath.
+     */
+    private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+        parseSeparator = CharSequences.trimWhitespaces(separator);
+    }
 }
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/geometry/CoordinateFormatTest.java b/core/sis-referencing/src/test/java/org/apache/sis/geometry/CoordinateFormatTest.java
index 60b1e38..b08f26c 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/geometry/CoordinateFormatTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/geometry/CoordinateFormatTest.java
@@ -26,6 +26,7 @@ import org.opengis.geometry.DirectPosition;
 import org.apache.sis.measure.Angle;
 import org.apache.sis.measure.Quantities;
 import org.apache.sis.measure.Units;
+import org.apache.sis.referencing.operation.HardCodedConversions;
 import org.apache.sis.referencing.crs.HardCodedCRS;
 import org.apache.sis.test.mock.VerticalCRSMock;
 import org.apache.sis.test.DependsOnMethod;
@@ -57,13 +58,13 @@ public final strictfp class CoordinateFormatTest extends TestCase {
     public void testFormatUnknownCRS() {
         final CoordinateFormat format = new CoordinateFormat(null, null);
         GeneralDirectPosition position = new GeneralDirectPosition(23.78, -12.74, 127.9, 3.25);
-        assertEquals("23.78 -12.74 127.9 3.25", format.format(position));
+        assertEquals("23.78 -12.74 127.9 3.25", format.format(position));
         /*
          * Try another point having a different number of position
          * for verifying that no cached values are causing problem.
          */
         position = new GeneralDirectPosition(4.64, 10.25, -3.12);
-        assertEquals("4.64 10.25 -3.12", format.format(position));
+        assertEquals("4.64 10.25 -3.12", format.format(position));
         /*
          * Try again with a different separator.
          */
@@ -116,13 +117,25 @@ public final strictfp class CoordinateFormatTest extends TestCase {
         final CoordinateFormat format = new CoordinateFormat(Locale.US, null);
         format.setDefaultCRS(VerticalCRSMock.HEIGHT);
         DirectPosition1D position = new DirectPosition1D(100);
-        assertEquals("100 m", format.format(position));
+        assertEquals("100 m", format.format(position));
 
         position.setCoordinateReferenceSystem(VerticalCRSMock.HEIGHT_ft);
-        assertEquals("100 ft", format.format(position));
+        assertEquals("100 ft", format.format(position));
 
         position.setCoordinateReferenceSystem(VerticalCRSMock.DEPTH);
-        assertEquals("100 m", format.format(position));
+        assertEquals("100 m", format.format(position));
+    }
+
+    /**
+     * Tests formatting a 2-dimensional projected coordinate.
+     */
+    @Test
+    @DependsOnMethod("testFormatUnknownCRS")
+    public void testFormatProjected() {
+        final CoordinateFormat format = new CoordinateFormat(Locale.US, null);
+        format.setDefaultCRS(HardCodedConversions.mercator());
+        DirectPosition2D position = new DirectPosition2D(-100, 300);
+        assertEquals("-100 m E 300 m N", format.format(position));
     }
 
     /**
@@ -145,14 +158,14 @@ public final strictfp class CoordinateFormatTest extends TestCase {
         assertEquals("getPattern(Date)",   datePattern, format.getPattern(Date .class));
         final GeneralDirectPosition position = new GeneralDirectPosition(23.78, -12.74, 127.9, 54000.25);
         position.setCoordinateReferenceSystem(HardCodedCRS.GEOID_4D);
-        assertEquals("23°46,8′E 12°44,4′S 127,9 m 22-09-2006 07:00", format.format(position));
+        assertEquals("23°46,8′E 12°44,4′S 127,9 m 22-09-2006 07:00", format.format(position));
         /*
          * Try a null CRS. Should format everything as numbers.
          */
         position.setCoordinateReferenceSystem(null);
         assertEquals("getPattern(Angle)", anglePattern, format.getPattern(Angle.class));
         assertEquals("getPattern(Date)",   datePattern, format.getPattern(Date .class));
-        assertEquals("23,78 -12,74 127,9 54 000,25",    format.format(position));
+        assertEquals("23,78 -12,74 127,9 54 000,25",    format.format(position));
         /*
          * Try again with the original CRS, but different separator.
          */
@@ -161,7 +174,7 @@ public final strictfp class CoordinateFormatTest extends TestCase {
         position.setCoordinateReferenceSystem(HardCodedCRS.GEOID_4D);
         assertEquals("getPattern(Angle)", anglePattern, format.getPattern(Angle.class));
         assertEquals("getPattern(Date)",   datePattern, format.getPattern(Date .class));
-        assertEquals("23°46,8′E; 12°44,4′S; 127,9 m; 22-09-2006 07:00", format.format(position));
+        assertEquals("23°46,8′E; 12°44,4′S; 127,9 m; 22-09-2006 07:00", format.format(position));
     }
 
     /**
@@ -266,9 +279,9 @@ public final strictfp class CoordinateFormatTest extends TestCase {
         final DirectPosition2D pos = new DirectPosition2D(40.123456789, 9.87654321);
         format.setDefaultCRS(HardCodedCRS.WGS84_φλ);
         format.setGroundPrecision(Quantities.create(0.01, Units.GRAD));
-        assertEquals("40°07,4′N 9°52,6′E", format.format(pos));
+        assertEquals("40°07,4′N 9°52,6′E", format.format(pos));
         format.setGroundPrecision(Quantities.create(0.01, Units.METRE));
-        assertEquals("40°07′24,4444″N 9°52′35,5556″E", format.format(pos));
+        assertEquals("40°07′24,4444″N 9°52′35,5556″E", format.format(pos));
     }
 
     /**
@@ -281,11 +294,11 @@ public final strictfp class CoordinateFormatTest extends TestCase {
         final DirectPosition2D pos = new DirectPosition2D(40.123456789, 9.87654321);
         format.setDefaultCRS(HardCodedCRS.WGS84_φλ);
         format.setPrecisions(0.05, 0.0001);
-        assertEquals("40°07′N 9°52′35,6″E", format.format(pos));
+        assertEquals("40°07′N 9°52′35,6″E", format.format(pos));
         assertArrayEquals("precisions", new double[] {1.0/60, 0.1/3600}, format.getPrecisions(), 1E-15);
 
         format.setPrecisions(0.0005, 0.01);
-        assertEquals("40°07′24″N 9°52,6′E", format.format(pos));
+        assertEquals("40°07′24″N 9°52,6′E", format.format(pos));
         assertArrayEquals("precisions", new double[] {1.0/3600, 0.1/60}, format.getPrecisions(), 1E-15);
     }
 
diff --git a/core/sis-utility/src/main/java/org/apache/sis/io/CompoundFormat.java b/core/sis-utility/src/main/java/org/apache/sis/io/CompoundFormat.java
index 02b107d..78f919d 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/io/CompoundFormat.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/io/CompoundFormat.java
@@ -30,6 +30,7 @@ import java.text.FieldPosition;
 import java.text.ParsePosition;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import javax.measure.Quantity;
 import javax.measure.Unit;
 
 import org.opengis.referencing.IdentifiedObject;
@@ -39,6 +40,7 @@ import org.apache.sis.measure.AngleFormat;
 import org.apache.sis.measure.Range;
 import org.apache.sis.measure.RangeFormat;
 import org.apache.sis.measure.UnitFormat;
+import org.apache.sis.measure.QuantityFormat;
 import org.apache.sis.util.Numbers;
 import org.apache.sis.util.Classes;
 import org.apache.sis.util.Localized;
@@ -94,7 +96,7 @@ import static org.apache.sis.internal.util.StandardDateFormat.UTC;
  * in case of error.</div>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.1
  *
  * @param <T>  the base type of objects parsed and formatted by this class.
  *
@@ -439,6 +441,7 @@ public abstract class CompoundFormat<T> extends Format implements Localized {
      *   <tr><td>{@link Date}</td>            <td>{@link DateFormat}</td></tr>
      *   <tr><td>{@link Number}</td>          <td>{@link NumberFormat}</td></tr>
      *   <tr><td>{@link Unit}</td>            <td>{@link UnitFormat}</td></tr>
+     *   <tr><td>{@link Quantity}</td>        <td>{@link QuantityFormat}</td></tr>
      *   <tr><td>{@link Range}</td>           <td>{@link RangeFormat}</td></tr>
      *   <tr><td>{@link Class}</td>           <td>(internal)</td></tr>
      * </table>
@@ -483,6 +486,8 @@ public abstract class CompoundFormat<T> extends Format implements Localized {
             return AngleFormat.getInstance(locale);
         } else if (valueType == Unit.class) {
             return new UnitFormat(locale);
+        } else if (valueType == Quantity.class) {
+            return new QuantityFormat(locale);
         } else if (valueType == Range.class) {
             return new RangeFormat(locale);
         } else if (valueType == DirectPosition.class) {
diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/QuantityFormat.java b/core/sis-utility/src/main/java/org/apache/sis/measure/QuantityFormat.java
new file mode 100644
index 0000000..9060ccf
--- /dev/null
+++ b/core/sis-utility/src/main/java/org/apache/sis/measure/QuantityFormat.java
@@ -0,0 +1,146 @@
+/*
+ * 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.measure;
+
+import java.util.Locale;
+import java.text.Format;
+import java.text.FieldPosition;
+import java.text.NumberFormat;
+import java.text.ParsePosition;
+import javax.measure.Quantity;
+import javax.measure.Unit;
+import org.apache.sis.util.ArgumentChecks;
+
+
+/**
+ * Parses and formats numbers with units of measurement.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ *
+ * @see NumberFormat
+ * @see UnitFormat
+ *
+ * @since 1.1
+ * @module
+ */
+public class QuantityFormat extends Format {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 1014042719969477503L;
+
+    /**
+     * The default separator used between numerical value and its unit of measurement.
+     * Current value is narrow no-break space (U+202F).
+     */
+    public static final char SEPARATOR = '\u202F';
+
+    /**
+     * The format for parsing and formatting the number part.
+     */
+    protected final NumberFormat numberFormat;
+
+    /**
+     * The format for parsing and formatting the unit of measurement part.
+     */
+    protected final UnitFormat unitFormat;
+
+    /**
+     * Creates a new instance for the given locale.
+     *
+     * @param  locale  the locale for the quantity format.
+     */
+    public QuantityFormat(final Locale locale) {
+        ArgumentChecks.ensureNonNull("locale", locale);
+        numberFormat = NumberFormat.getNumberInstance(locale);
+        unitFormat   = new UnitFormat(locale);
+    }
+
+    /**
+     * Creates a new instance using the given number and unit formats.
+     *
+     * @param  numberFormat  the format for parsing and formatting the number part.
+     * @param  unitFormat    the format for parsing and formatting the unit of measurement part.
+     */
+    public QuantityFormat(final NumberFormat numberFormat, final UnitFormat unitFormat) {
+        ArgumentChecks.ensureNonNull("numberFormat", numberFormat);
+        ArgumentChecks.ensureNonNull("unitFormat",   unitFormat);
+        this.numberFormat = numberFormat;
+        this.unitFormat   = unitFormat;
+    }
+
+    /**
+     * Formats the specified quantity in the given buffer.
+     * The given object shall be an {@link Quantity} instance.
+     *
+     * @param  quantity    the quantity to format.
+     * @param  toAppendTo  where to format the quantity.
+     * @param  pos         where to store the position of a formatted field, or {@code null} if none.
+     * @return the given {@code toAppendTo} argument, for method calls chaining.
+     */
+    @Override
+    public StringBuffer format(final Object quantity, StringBuffer toAppendTo, FieldPosition pos) {
+        final Quantity<?> q = (Quantity<?>) quantity;
+        if (pos == null) pos = new FieldPosition(0);
+        toAppendTo = numberFormat.format(q.getValue(), toAppendTo, pos).append(SEPARATOR);   // Narrow no-break space.
+        toAppendTo = unitFormat.format(q.getUnit(), toAppendTo, pos);
+        return toAppendTo;
+    }
+
+    /**
+     * Parses text from a string to produce a quantity, or returns {@code null} if the parsing failed.
+     *
+     * @param  source  the text, part of which should be parsed.
+     * @param  pos     index and error index information.
+     * @return a unit parsed from the string, or {@code null} in case of error.
+     */
+    @Override
+    public Object parseObject(final String source, final ParsePosition pos) {
+        final Number value = numberFormat.parse(source, pos);
+        if (value != null) {
+            final Unit<?> unit = unitFormat.parse(source, pos);
+            if (unit != null) {
+                return Quantities.create(value.doubleValue(), unit);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns a clone of this format.
+     *
+     * @return a clone of this format.
+     */
+    @Override
+    public QuantityFormat clone() {
+        final QuantityFormat clone = (QuantityFormat) super.clone();
+        try {
+            java.lang.reflect.Field field;
+            field = QuantityFormat.class.getField("numberFormat");
+            field.setAccessible(true);
+            field.set(clone, numberFormat.clone());
+
+            field = QuantityFormat.class.getField("unitFormat");
+            field.setAccessible(true);
+            field.set(clone, unitFormat.clone());
+        } catch (ReflectiveOperationException e) {
+            throw new AssertionError(e);
+        }
+        return clone;
+    }
+}
diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/Scalar.java b/core/sis-utility/src/main/java/org/apache/sis/measure/Scalar.java
index a425cb7..d0e9873 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/measure/Scalar.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/measure/Scalar.java
@@ -32,7 +32,7 @@ import org.apache.sis.internal.util.Numerics;
  * Instances of this class are unmodifiable.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.1
  *
  * @param <Q>  the concrete subtype.
  *
@@ -344,7 +344,7 @@ abstract class Scalar<Q extends Quantity<Q>> extends Number implements Quantity<
         StringBuilders.trimFractionalPart(buffer);
         final String symbol = getUnit().toString();
         if (symbol != null && !symbol.isEmpty()) {
-            buffer.append(' ').append(symbol);
+            buffer.append(QuantityFormat.SEPARATOR).append(symbol);
         }
         return buffer.toString();
     }
diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/UnitFormat.java b/core/sis-utility/src/main/java/org/apache/sis/measure/UnitFormat.java
index 002cf2f..2ee18bc 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/measure/UnitFormat.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/measure/UnitFormat.java
@@ -890,7 +890,7 @@ appPow: if (unit == null) {
      *
      * @param  unit        the unit to format.
      * @param  toAppendTo  where to format the unit.
-     * @param  pos         where to store the position of a formatted field.
+     * @param  pos         where to store the position of a formatted field, or {@code null} if none.
      * @return the given {@code toAppendTo} argument, for method calls chaining.
      */
     @Override
diff --git a/core/sis-utility/src/test/java/org/apache/sis/measure/ScalarTest.java b/core/sis-utility/src/test/java/org/apache/sis/measure/ScalarTest.java
index 1108317..7768f2b 100644
--- a/core/sis-utility/src/test/java/org/apache/sis/measure/ScalarTest.java
+++ b/core/sis-utility/src/test/java/org/apache/sis/measure/ScalarTest.java
@@ -34,7 +34,7 @@ import static org.apache.sis.test.Assert.*;
  * Tests {@link Scalar} and its subclasses.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.1
  * @since   0.8
  * @module
  */
@@ -110,8 +110,8 @@ public final strictfp class ScalarTest extends TestCase {
      */
     @Test
     public void testToString() {
-        assertEquals("toString()", "24 km",   new Scalar.Length       (24.00, Units.KILOMETRE).toString());
-        assertEquals("toString()", "10.25 h", new Scalar.Time         (10.25, Units.HOUR)     .toString());
+        assertEquals("toString()", "24 km",   new Scalar.Length       (24.00, Units.KILOMETRE).toString());
+        assertEquals("toString()", "10.25 h", new Scalar.Time         (10.25, Units.HOUR)     .toString());
         assertEquals("toString()", "0.25",    new Scalar.Dimensionless( 0.25, Units.UNITY)    .toString());
     }
 
@@ -153,7 +153,7 @@ public final strictfp class ScalarTest extends TestCase {
         assertInstanceOf("Dynamic proxy", Length.class, q1);
         assertSame  ("unit", Units.KILOMETRE, q1.getUnit());
         assertEquals("value", 24, q1.getValue().doubleValue(), STRICT);
-        assertEquals("toString()", "24 km", q1.toString());
+        assertEquals("toString()", "24 km", q1.toString());
 
         final Quantity<Length> q2 = ScalarFallback.factory(24, Units.KILOMETRE, Length.class);
         assertEquals("hashCode()", q1.hashCode(), q2.hashCode());
@@ -165,7 +165,7 @@ public final strictfp class ScalarTest extends TestCase {
         assertInstanceOf("Dynamic proxy", Length.class, q4);
         assertSame  ("unit", Units.KILOMETRE, q4.getUnit());
         assertEquals("value", 25.5, q4.getValue().doubleValue(), STRICT);
-        assertEquals("toString()", "25.5 km", q4.toString());
+        assertEquals("toString()", "25.5 km", q4.toString());
 
         final Quantity<Length> q5 = q1.multiply(q3).divide(q2).asType(Length.class);
         assertSame  ("unit", Units.METRE, q5.getUnit());


Mime
View raw message