/*
 * 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.portrayal;

import java.util.ArrayList;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalDouble;
import org.opengis.geometry.Envelope;
import org.opengis.geometry.DirectPosition;
import org.opengis.geometry.MismatchedDimensionException;
import org.opengis.metadata.extent.GeographicBoundingBox;
import org.opengis.metadata.spatial.DimensionNameType;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.crs.EngineeringCRS;
import org.opengis.referencing.crs.GeographicCRS;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.TransformException;
import org.opengis.referencing.operation.CoordinateOperation;
import org.opengis.referencing.datum.PixelInCell;
import org.opengis.util.FactoryException;
import org.apache.sis.util.Utilities;
import org.apache.sis.util.Localized;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.geometry.GeneralDirectPosition;
import org.apache.sis.geometry.GeneralEnvelope;
import org.apache.sis.measure.Units;
import org.apache.sis.referencing.CRS;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.referencing.operation.DefaultCoordinateOperationFactory;
import org.apache.sis.referencing.operation.matrix.Matrices;
import org.apache.sis.referencing.operation.matrix.MatrixSIS;
import org.apache.sis.referencing.operation.transform.MathTransforms;
import org.apache.sis.referencing.operation.transform.LinearTransform;
import org.apache.sis.referencing.operation.transform.TransformSeparator;
import org.apache.sis.referencing.util.ReferencingUtilities;
import org.apache.sis.referencing.util.DirectPositionView;
import org.apache.sis.referencing.util.WraparoundApplicator;
import org.apache.sis.util.internal.DoubleDouble;
import org.apache.sis.coverage.grid.IncompleteGridGeometryException;
import org.apache.sis.coverage.grid.GridGeometry;
import org.apache.sis.coverage.grid.GridExtent;

// Specific to the main branch:
import org.apache.sis.geometry.MismatchedReferenceSystemException;
import org.apache.sis.coverage.CannotEvaluateException;


/**
 * Common abstraction for implementations that manage the display and user manipulation
 * of spatial graphic elements. This base class makes no assumption about the geometry
 * of the display device (e.g. flat video monitor using Cartesian coordinate system,
 * or planetarium dome using spherical coordinate system).
 *
 * <p>This {@code Canvas} base class does not draw anything by itself.
 * Subclasses are responsible for drawing graphic elements.
 * The visual contents are usually geographic located symbols, features or images,
 * but some implementations can also manage non-geographic elements like a map scale.</p>
 *
 * <p>A {@code Canvas} manages four fundamental properties:</p>
 * <ul>
 *   <li>The coordinate reference system to use for displaying data.</li>
 *   <li>The location of data to display in all dimensions, including the dimensions
 *       not shown by the display device (for example time).</li>
 *   <li>The size of the display device, in units of the display coordinate system (typically pixels).</li>
 *   <li>The conversion from the Coordinate Reference System to the display coordinate system.</li>
 * </ul>
 *
 * Those properties are explained in more details below. Other information, for example the
 * geographic bounding box of the data shown on screen, are inferred from above properties.
 *
 * <h2>Coordinate Reference Systems</h2>
 * There is three {@linkplain CoordinateReferenceSystem Coordinate Reference Systems}
 * involved in the rendering of geospatial data:
 *
 * <ol class="verbose">
 *   <li>The <cite>data CRS</cite> is specific to the data to be displayed.
 *       It may be anything convertible to the <cite>objective CRS</cite>.
 *       Different graphic elements may use different data CRS,
 *       potentially with a different number of dimensions.</li>
 *   <li>The {@linkplain #getObjectiveCRS objective CRS} is the common CRS in which all data
 *       are converted before to be displayed. If the objective CRS involves a map projection,
 *       it determines the deformation of shapes that user will see on the display device.
 *       The objective CRS should have the same number of dimensions than the display device
 *       (often 2). Its domain of validity should be wide enough for encompassing all data.
 *       The {@link CRS#suggestCommonTarget CRS.suggestCommonTarget(…)} method may be helpful
 *       for choosing an objective CRS from a set of data CRS.</li>
 *   <li>The {@linkplain #getDisplayCRS display CRS} is the coordinate system of the display device.
 *       The {@linkplain #getObjectiveToDisplay() conversion from objective CRS to display CRS}
 *       should be an affine transform with a scale, a translation and optionally a rotation.
 *       This conversion changes every time that the user zooms or scrolls on viewed data.</li>
 * </ol>
 *
 * <h2>Location of data to display</h2>
 * In addition of above-cited Coordinate Reference Systems, a {@code Canvas} also contains a point of interest.
 * The point of interest is often, but not necessarily, at the center of display area.
 * It defines the position where {@linkplain #getSpatialResolution() resolutions} will be computed,
 * and the position to keep fixed when scales and rotations are applied.
 *
 * <p>The point of interest can be expressed in any CRS;
 * it does not need to be the objective CRS or the CRS of any data.
 * However, the CRS of that point must have enough dimensions for being convertible to the CRS of all data.
 * This rule implies that the number of dimensions of the point of interest is equal or greater than
 * the highest number of dimensions found in data. The purpose is not only to specify which point to show in
 * (typically) the center of the display area, but also to specify which slice to select in all dimensions
 * not shown by the display device.</p>
 *
 * <h3>Example</h3>
 * If some data have (<var>x</var>,<var>y</var>,<var>z</var>) dimensions and
 * other data have (<var>x</var>,<var>y</var>,<var>t</var>) dimensions, then the point of interest shall contain
 * coordinate values for at least all of the (<var>x</var>,<var>y</var>,<var>z</var>,<var>t</var>) dimensions
 * (i.e. it must be 4-dimensional, even if all data in this example are 3-dimensional). If the display device
 * is a two-dimensional screen showing map in the (<var>x</var>,<var>y</var>) dimensions (horizontal plane),
 * then the point of interest defines the <var>z</var> value (elevation or depth) and the <var>t</var> value
 * (date and time) of the slice to show.
 *
 * <h2>Display device size</h2>
 * The geographic extent of data to be rendered is constrained by the zoom level and the display device size.
 * The display size is given by {@link #getDisplayBounds()} as an envelope having the number of dimensions of
 * the display device. The display bounds is usually given in {@linkplain Units#PIXEL pixel units}, but other
 * units such as {@link Units#POINT} are also authorized.
 * The zoom level is given indirectly by the {@link #getObjectiveToDisplay()} transform.
 * The display device may have a wraparound axis, for example in the spherical coordinate system of a planetarium.
 *
 * <h2>Multi-threading</h2>
 * {@code Canvas} is not thread-safe. Synchronization, if desired, must be done by the caller.
 * Another common strategy is to interact with {@code Canvas} from a single thread,
 * for example the Swing or JavaFX event queue.
 *
 * @author  Johann Sorel (Geomatys)
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.4
 * @since   1.1
 */
public class Canvas extends Observable implements Localized {
    /**
     * The {@value} property name, used for notifications about changes in objective CRS.
     * The objective CRS is the Coordinate Reference System in which all data are transformed before displaying.
     * Its number of dimension is the determined by the display device (two for flat screens).
     * Associated values are instances of {@link CoordinateReferenceSystem}.
     *
     * @see #getObjectiveCRS()
     * @see #setObjectiveCRS(CoordinateReferenceSystem, DirectPosition)
     * @see #addPropertyChangeListener(String, PropertyChangeListener)
     */
    public static final String OBJECTIVE_CRS_PROPERTY = "objectiveCRS";

    /**
     * The {@value} property name, used for notifications about changes in <cite>objective to display</cite> conversion.
     * This conversion maps coordinates in the {@linkplain #getObjectiveCRS() objective CRS} to coordinates in the
     * {@linkplain #getDisplayCRS() display CRS}. Associated values are instances of {@link LinearTransform}.
     * The event class is the {@link TransformChangeEvent} specialization.
     *
     * @see #getObjectiveToDisplay()
     * @see #setObjectiveToDisplay(LinearTransform)
     * @see #addPropertyChangeListener(String, PropertyChangeListener)
     */
    public static final String OBJECTIVE_TO_DISPLAY_PROPERTY = "objectiveToDisplay";

    /**
     * The {@value} property name, used for notifications about changes in bounds of display device.
     * It may be for example changes in the size of the window were data are shown.
     * Associated values are instances of {@link Envelope}.
     *
     * @see #getDisplayBounds()
     * @see #setDisplayBounds(Envelope)
     * @see #addPropertyChangeListener(String, PropertyChangeListener)
     */
    public static final String DISPLAY_BOUNDS_PROPERTY = "displayBounds";

    /**
     * The {@value} property name, used for notifications about changes in point of interest.
     * The point of interest defines the location of a representative point,
     * typically (but not necessarily) in the center of the data bounding box.
     * It defines also the slice coordinate values in all dimensions beyond the ones shown by the device.
     * Associated values are instances of {@link DirectPosition}.
     *
     * @see #getPointOfInterest(boolean)
     * @see #setPointOfInterest(DirectPosition)
     * @see #addPropertyChangeListener(String, PropertyChangeListener)
     */
    public static final String POINT_OF_INTEREST_PROPERTY = "pointOfInterest";

    /**
     * The {@value} property name.
     * The grid geometry is a synthetic property computed from other properties when requested.
     * The computed grid geometry may change every time that a {@value #OBJECTIVE_CRS_PROPERTY},
     * {@value #OBJECTIVE_TO_DISPLAY_PROPERTY}, {@value #DISPLAY_BOUNDS_PROPERTY} or
     * {@value #POINT_OF_INTEREST_PROPERTY} property is changed. We do not (at this time) fire
     * {@value} change events because computing a new grid geometry for every changes of above-cited
     * properties would be costly. An alternative approach could be to fire {@value} event only when
     * {@link #setGridGeometry(GridGeometry)} is explicitly invoked, but it could be misleading if
     * it gives the false impression that the grid geometry did not changed because a listener did
     * not received an {@value} event.
     *
     * @see #getGridGeometry()
     * @see #setGridGeometry(GridGeometry)
     */
    private static final String GRID_GEOMETRY_PROPERTY = "gridGeometry";

    /**
     * The {@value} property name. The geographic area is a synthetic property computed
     * from {@value #DISPLAY_BOUNDS_PROPERTY}, {@value #OBJECTIVE_TO_DISPLAY_PROPERTY}
     * and {@value #OBJECTIVE_CRS_PROPERTY}. There are no events fired for this property.
     *
     * @see #getGeographicArea()
     */
    private static final String GEOGRAPHIC_AREA_PROPERTY = "geographicArea";

    /**
     * The {@value} property name. The resolution is a synthetic property computed from
     * {@value #POINT_OF_INTEREST_PROPERTY}, {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} and
     * {@value #OBJECTIVE_CRS_PROPERTY}. There are no events fired for this property.
     *
     * @see #getSpatialResolution()
     */
    private static final String SPATIAL_RESOLUTION_PROPERTY = "spatialResolution";

    /**
     * The coordinate reference system in which to transform all data before displaying.
     * If {@code null}, then no transformation is applied and data coordinates are used directly
     * as display coordinates, regardless the data CRS (even if different data use different CRS).
     *
     * @see #OBJECTIVE_CRS_PROPERTY
     * @see #getObjectiveCRS()
     * @see #setObjectiveCRS(CoordinateReferenceSystem, DirectPosition)
     * @see #augmentedObjectiveCRS
     */
    private CoordinateReferenceSystem objectiveCRS;

    /**
     * The conversion from {@linkplain #getObjectiveCRS() objective CRS} to the display coordinate system.
     * Conceptually this conversion should never be null (its initial value is the identity conversion).
     * However, subclasses may use a more specialized type such as {@link java.awt.geom.AffineTransform}
     * and set this field to {@code null} for recomputing it from the specialized type when requested.
     *
     * @see #OBJECTIVE_TO_DISPLAY_PROPERTY
     * @see #getObjectiveToDisplay()
     * @see #setObjectiveToDisplay(LinearTransform)
     */
    private LinearTransform objectiveToDisplay;

    /**
     * The size and location of the output device, modified in-place if the size change.
     * The CRS of this envelope is the display CRS. Coordinate values are initially NaN.
     *
     * @see #DISPLAY_BOUNDS_PROPERTY
     * @see #getDisplayBounds()
     * @see #getDisplayCRS()
     * @see #setDisplayBounds(Envelope)
     */
    final GeneralEnvelope displayBounds;

    /**
     * A point (in display coordinates) considered representative of the data.
     * This is the default location where Jacobian matrices are computed when needed.
     * This is typically (but not necessarily) the center of data bounding box.
     * May become outside the viewing area after zooms or translations have been applied.
     *
     * Also used for selecting a slice in all supplemental dimensions.
     * If {@code null}, then calculations that depend on a point of interest are skipped.
     *
     * @see #POINT_OF_INTEREST_PROPERTY
     * @see #getPointOfInterest(boolean)
     * @see #setPointOfInterest(DirectPosition)
     */
    private GeneralDirectPosition pointOfInterest;

    /**
     * The point of interest transformed to the objective CRS, or {@code null} if {@link #pointOfInterest}
     * has not yet been provided. This point shall be updated immediately when {@link #pointOfInterest} is
     * updated, as a way to verify that the point is valid.
     *
     * <p>There is no setter method for this property. It is computed from {@link #pointOfInterest}
     * and {@link #objectiveCRS} (indirectly, through {@link #multidimToObjective}) and should be
     * recomputed when any of those properties changed.</p>
     *
     * @see #getObjectivePOI()
     * @see #getGridGeometry()
     */
    private DirectPosition objectivePOI;

    /**
     * The transform from the multi-dimensional CRS of {@link #pointOfInterest} to the objective CRS.
     * This is the transform used for computing {@link #objectivePOI}. This transform may reduce the
     * number of dimensions.
     *
     * <p>There is no setter method for this property. It is computed from {@link #pointOfInterest}
     * and {@link #objectiveCRS} and should be recomputed when any of those properties changed.</p>
     *
     * <p>In current implementation, this transform may depend on the zoom level and viewed geographic
     * area at the time this transform has been computed. The transform could be slightly different if
     * it has been computed at the time a different geographic area was viewed. Those variations may exist
     * because {@link #findTransform(CoordinateReferenceSystem, CoordinateReferenceSystem, boolean)} takes
     * in account the current viewing conditions. We may need to revisit this behavior in the future if
     * it appears to be a problem.</p>
     *
     * @see #getGridGeometry()
     */
    private MathTransform multidimToObjective;

    /**
     * The {@link #objectiveCRS} augmented with additional dimensions found in {@link #pointOfInterest}.
     * This field is initially {@code null} and computed only if needed. It may be reset to {@code null}
     * at any time if the properties used for computing this value changed.
     *
     * <p>If the point of interest has no supplemental dimension, then this CRS is {@link #objectiveCRS}.
     * Otherwise {@linkplain #supplementalDimensions supplemental dimensions} are added on a best effort
     * basis: some supplemental dimensions may be missing if we have not been able to separate components
     * from the Point Of Interest CRS.</p>
     *
     * @see #getGridGeometry()
     */
    private CoordinateReferenceSystem augmentedObjectiveCRS;

    /**
     * The dimensions in the Point Of Interest CRS that are not in the {@link #objectiveCRS}.
     * Those dimensions are encoded as a bitmask: if dimension <var>n</var> is a supplemental
     * dimension, then the bit {@code 1L << n} is set to 1. This encoding implies that we can
     * not handle more than {@value Long#SIZE} dimensions at this time.
     *
     * <p>The value of this field is invalid if {@link #augmentedObjectiveCRS} is {@code null}.
     * Those two fields are computed together.</p>
     *
     * @see #getGridGeometry()
     */
    private long supplementalDimensions;

    /**
     * Type of each grid axis (column, row, vertical, temporal, …) or {@code null} if unspecified.
     * This is only a help for debugging purpose, by providing more information to the developers.
     * Those types should not be used for any "real" work.
     *
     * @see #getGridGeometry()
     */
    private DimensionNameType[] axisTypes;

    /**
     * The grid geometry, computed when first needed and reset to {@code null} when invalidated.
     * This is invalidated when any {@link Canvas} property is modified. In particular, this is
     * invalidated every time that the {@link #objectiveToDisplay} transform changes. Note that
     * "objective to display" changes happen much more often than changes in other properties.
     *
     * <p>The {@link #augmentedObjectiveCRS}, {@link #supplementalDimensions}, {@link #multidimToObjective},
     * {@link #objectivePOI} and {@link #axisTypes} objects are intermediate calculations with typically a
     * longer lifetime than {@code gridGeometry}. They are saved for faster recomputation of grid geometry
     * when only the {@link #objectiveToDisplay} transform has changed.</p>
     */
    private GridGeometry gridGeometry;

    /**
     * The context (geographic area and desired resolution) for selecting a coordinate operation.
     * The information contained in this object can opportunistically be used for providing the
     * geographic area and spatial resolution of this canvas.
     *
     * @see #getGeographicArea()
     * @see #getSpatialResolution()
     * @see #findTransform(CoordinateReferenceSystem, CoordinateReferenceSystem, boolean)
     */
    private final CanvasContext operationContext;

    /**
     * The factory to use for creating coordinate operations. This factory allow us to specify the area
     * of interest (the geographic region shown by this {@code Canvas}) and the desired resolution.
     *
     * @see #findTransform(CoordinateReferenceSystem, CoordinateReferenceSystem, boolean)
     */
    private final DefaultCoordinateOperationFactory coordinateOperationFactory;

    /**
     * The locale for labels or error messages, or {@code null} for the default.
     */
    private final Locale locale;

    /**
     * Creates a new canvas for a display device using the given coordinate reference system.
     * The display CRS of a canvas cannot be changed after construction.
     * Its coordinate system depends on the display device shape
     * (for example a two-dimensional Cartesian coordinate system for flat screens,
     * or a polar or spherical coordinate system for planetarium domes).
     * The axis units of measurement are typically (but not necessarily) {@link Units#PIXEL}
     * for Cartesian coordinate systems, with {@link Units#DEGREE} in polar, cylindrical or
     * spherical coordinate systems.
     *
     * @param  displayCRS  the coordinate system of the display device.
     * @param  locale      the locale to use for labels and some messages, or {@code null} for default.
     */
    protected Canvas(final EngineeringCRS displayCRS, final Locale locale) {
        this.locale = locale;
        ArgumentChecks.ensureNonNull("displayCRS", displayCRS);
        displayBounds = new GeneralEnvelope(displayCRS);
        displayBounds.setToNaN();
        coordinateOperationFactory = DefaultCoordinateOperationFactory.provider();
        operationContext = new CanvasContext();
    }

    /**
     * Returns the locale used for texts or for producing some error messages.
     * May be {@code null} if no locale has been specified, in which case
     * the {@linkplain Locale#getDefault() system default} should be used.
     *
     * @return the locale for messages, or {@code null} if not explicitly defined.
     */
    @Override
    public Locale getLocale() {
        return locale;
    }

    /**
     * Returns the number of dimensions of the display device.
     * Subclasses may override for a little bit more efficiency.
     */
    int getDisplayDimensions() {
        return ReferencingUtilities.getDimension(getDisplayCRS());
    }

    /**
     * Gets the name of display axes and stores them in the given array. Those display axis names
     * are used for debugging purposes only, as an additional information provided to developers.
     * Those names should not be used for any "real" work. The default implementation does nothing
     * since this base {@link Canvas} class does not know well the geometry of the display device.
     * It is okay to leave elements to {@code null}.
     *
     * @param  axisTypes  where to store the name of display axes. The array length will be
     *                    at least {@link #getDisplayDimensions()} (it will often be longer).
     */
    void getDisplayAxes(final DimensionNameType[] axisTypes) {
    }

    /**
     * Returns the Coordinate Reference System of the display device.
     * The axis units of measurement are typically (but not necessarily) {@link Units#PIXEL}
     * for Cartesian coordinate systems, with {@link Units#DEGREE} in polar, cylindrical or
     * spherical coordinate systems. The coordinate system may have a wraparound axis for
     * some "exotic" display devices (e.g. planetarium dome).
     *
     * <p>Note that the {@link CRS#findOperation CRS.findOperation(…)} static method can generally
     * not handle this display CRS. To apply coordinate operations on display coordinates,
     * {@link #getObjectiveToDisplay()} transform must be inverted and used.</p>
     *
     * <h4>Usage note</h4>
     * Invoking this method is rarely needed.
     * It is sufficient to said that a display CRS exists at least conceptually,
     * and that we define a conversion from the objective CRS to that display CRS.
     * This method may be useful when the subclasses may be something else than {@link PlanarCanvas},
     * in which case the caller may want more information about the geometry of the display device.
     *
     * @return the Coordinate Reference System of the display device.
     *
     * @see #getObjectiveCRS()
     * @see #getObjectiveToDisplay()
     */
    public final EngineeringCRS getDisplayCRS() {
        return (EngineeringCRS) displayBounds.getCoordinateReferenceSystem();
    }

    /**
     * Returns the Coordinate Reference System in which all data are transformed before displaying.
     * After conversion to this CRS, coordinates should be related to the display device coordinates
     * with only a final scale, a translation and optionally a rotation remaining to apply.
     *
     * <p>This value may be {@code null} on newly created {@code Canvas}, before data are added and canvas
     * is configured. It should not be {@code null} anymore once a {@code Canvas} is ready for displaying.</p>
     *
     * @return the Coordinate Reference System in which to transform all data before displaying.
     *
     * @see #OBJECTIVE_CRS_PROPERTY
     * @see #getDisplayCRS()
     * @see #getObjectiveToDisplay()
     */
    public CoordinateReferenceSystem getObjectiveCRS() {
        return objectiveCRS;
    }

    /**
     * Sets the Coordinate Reference System in which all data are transformed before displaying.
     * The new CRS must be compatible with the previous CRS, i.e. a coordinate operation between
     * the two CRSs shall exist. If this is not the case (e.g. for rendering completely new data),
     * use {@link #setGridGeometry(GridGeometry)} instead.
     *
     * <p>The given CRS should have a domain of validity wide enough for encompassing all data
     * (the {@link CRS#suggestCommonTarget CRS.suggestCommonTarget(…)} method may be helpful
     * for choosing an objective CRS from a set of data CRS).
     * If the given value is different than the previous value, then a change event is sent to
     * all listeners registered for the {@value #OBJECTIVE_CRS_PROPERTY} property.</p>
     *
     * <p>If the transform between old and new CRS is not identity, then this method recomputes the
     * <cite>objective to display</cite> conversion in a way preserving the display coordinates of the given anchor,
     * together with the scales and orientations of features in close neighborhood of that point.
     * This calculation may cause {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} property change event
     * with the {@link TransformChangeEvent.Reason#CRS_CHANGE} reason to be sent to listeners.
     * That event is sent after the above-cited {@value #OBJECTIVE_CRS_PROPERTY} event
     * (note that {@value #POINT_OF_INTEREST_PROPERTY} stay unchanged).
     * All those change events are sent only after all property values have been updated to their new values.</p>
     *
     * @param  newValue  the new Coordinate Reference System in which to transform all data before displaying.
     * @param  anchor    the point to keep at fixed display coordinates, expressed in any compatible CRS.
     *                   If {@code null}, defaults to {@linkplain #getPointOfInterest(boolean) point of interest}.
     *                   If non-null, the anchor must be associated to a CRS.
     * @throws NullPointerException if the given CRS is null.
     * @throws MismatchedDimensionException if the given CRS does not have the number of dimensions of the display device.
     * @throws RenderException if the objective CRS cannot be set to the given value for another reason.
     */
    public void setObjectiveCRS(final CoordinateReferenceSystem newValue, DirectPosition anchor) throws RenderException {
        ArgumentChecks.ensureNonNull(OBJECTIVE_CRS_PROPERTY, newValue);
        ArgumentChecks.ensureDimensionMatches(OBJECTIVE_CRS_PROPERTY, getDisplayDimensions(), newValue);
        final CoordinateReferenceSystem oldValue = objectiveCRS;
        if (!newValue.equals(oldValue)) try {
            final CoordinateOperation newToGeo = objectiveToGeographic(newValue);
            LinearTransform oldObjectiveToDisplay = null;
            LinearTransform newObjectiveToDisplay = null;
            if (oldValue != null) {
                /*
                 * Compute the change unconditionally as a way to verify that the new CRS is compatible with
                 * data currently shown. Another reason is that checking identity transform is more reliable
                 * than the `compareIgnoreMetadata(oldValue, newValue)` check.
                 *
                 * Note: we are invoking `findTransform(…)` with a CoordinateOperationContext computed from
                 * the old CRS. But it is okay because the context information are geographic area (degrees)
                 * and approximate resolution (metres), which should not change a lot since we will continue
                 * to view the same area after the CRS change. Those information only need to be approximate
                 * anyway, and in many cases will be totally ignored by `findTransform(…)`.
                 */
                final MathTransform newToOld = findTransform(newValue, oldValue, false);
                if (pointOfInterest != null && !newToOld.isIdentity()) {
                    final CoordinateReferenceSystem poiCRS = pointOfInterest.getCoordinateReferenceSystem();
                    final MathTransform  poiToNew = findTransform(poiCRS, newValue, false);
                    final DirectPosition poiInNew = poiToNew.transform(pointOfInterest, allocatePosition());
                    /*
                     * We need anchor in new CRS. If no anchor was specified, `poiInNew` is already what we need.
                     * Otherwise convert the anchor to coordinates in the new CRS. There is good chances that the
                     * anchor CRS is the objective CRS, so we can reuse `poiToNew`.
                     */
                    if (anchor == null) {
                        anchor = poiInNew;
                    } else {
                        final CoordinateReferenceSystem crs = anchor.getCoordinateReferenceSystem();
                        ArgumentChecks.ensureNonNull("anchor.CRS", crs);
                        if (!Utilities.equalsIgnoreMetadata(crs, newValue)) {
                            MathTransform anchorToNew = poiToNew;
                            if (!Utilities.equalsIgnoreMetadata(crs, poiCRS)) {
                                anchorToNew = findTransform(crs, newValue, true);
                            }
                            anchor = anchorToNew.transform(anchor, allocatePosition());
                        }
                    }
                    /*
                     * We want pixel coordinates of the Point Of Interest (POI) to be unaffected by the change of CRS,
                     * and the Jacobian matrix around POI to be approximately the same. Conceptually, this is as if we
                     * wanted to convert from new CRS to old CRS before to apply the old `objectiveToCRS` transform.
                     * We get this effect by pre-concatenating a linear approximation of "new to old CRS" transform
                     * before `objectiveToCRS`. That approximation contains only uniform scale, rotation or axis flips
                     * in order to preserve pixel ratios (otherwise the map projection would appear deformed).
                     */
                    oldObjectiveToDisplay = getObjectiveToDisplay();
                    final WraparoundApplicator wp = new WraparoundApplicator(null, objectivePOI, oldValue.getCoordinateSystem());
                    final MathTransform change = orthogonalTangent(wp.forDomainOfUse(newToOld), anchor.getCoordinate());
                    final MathTransform result = MathTransforms.concatenate(change, oldObjectiveToDisplay);
                    /*
                     * The result is the new `objectiveToTransform` such as the display is unchanged around POI.
                     * That transform should be an instance of `LinearTransform` because the two concatenated
                     * transforms were linear, but we nevertheless invoke `tangent(…)` as a safety;
                     * normally it should just return the `result` as-is.
                     */
                    newObjectiveToDisplay = MathTransforms.tangent(result, poiInNew);
                    setObjectiveToDisplayImpl(newObjectiveToDisplay);
                    objectivePOI          = poiInNew;               // Set only after everything else succeeded.
                    multidimToObjective   = poiToNew;
                    augmentedObjectiveCRS = null;                   // Will be recomputed when first needed.
                    axisTypes             = null;
                    gridGeometry          = null;
                }
            }
            objectiveCRS = newValue;                                // Set only after everything else succeeded.
            operationContext.setObjectiveToGeographic(newToGeo);
            firePropertyChange(OBJECTIVE_CRS_PROPERTY, oldValue, newValue);
            fireIfChanged(oldObjectiveToDisplay, newObjectiveToDisplay, false);     // Shall be after CRS change event.
        } catch (FactoryException | TransformException e) {
            throw new RenderException(errors().getString(Errors.Keys.CanNotSetPropertyValue_1, OBJECTIVE_CRS_PROPERTY), e);
        }
    }

    /**
     * Computes the approximate change from a new {@link #objectiveToDisplay} to the old one for keeping the
     * Point Of Interest (POI) at the same location. The given {@code newToOld} argument is the change as a
     * potentially non-linear transform. The transform returned by this method is a linear approximation of
     * {@code newToOld} {@linkplain MathTransforms#tangent tangent} at the POI, but with orthogonal vectors.
     * In other words, the returned transform may apply a uniform scale, a rotation or flip axes, but no shear.
     *
     * @param  newToOld  the change as a potentially non-linear transform.
     * @param  poiInNew  point of interest in the coordinates of the new objective CRS.
     * @return an approximation of {@code newToOld} with only uniform scale, rotation and axis flips.
     *
     * @see MathTransforms#tangent(MathTransform, DirectPosition)
     */
    private static MathTransform orthogonalTangent(final MathTransform newToOld, final double[] poiInNew)
            throws TransformException, RenderException
    {
        final double[]  poiInOld   = new double[newToOld.getTargetDimensions()];
        final MatrixSIS derivative = MatrixSIS.castOrCopy(MathTransforms.derivativeAndTransform(newToOld, poiInNew, 0, poiInOld, 0));
        final MatrixSIS magnitudes = derivative.normalizeColumns();
        final MatrixSIS affine     = Matrices.createAffine(derivative, new DirectPositionView.Double(poiInOld));
        final int       srcDim     = magnitudes.getNumCol();
        DoubleDouble    scale      = DoubleDouble.ZERO;             // Will be set to average magnitude value.
        for (int i=0; i<srcDim; i++) {
            scale = scale.add(magnitudes.getNumber(0, i), false);
        }
        scale = scale.divide(srcDim);
        /*
         * Following code assumes a two-dimensional rotation matrix. We have not yet explored how
         * to generalize to n-dimensional case (Gram–Schmidt process may be a path to explore).
         * We want:
         *           ┌          ┐     ┌                 ┐
         *           │ m₀₀  m₀₁ │  ≈  │ cos(θ)  −sin(θ) │
         *           │ m₁₀  m₁₁ │     │ sin(θ)   cos(θ) │
         *           └          ┘     └                 ┘
         *
         * We want some "average" value for |cos(θ)| (the sign will be adjusted later).
         * The root mean square (RMS) is convenient because of  cos²(θ) = 1 − sin²(θ):
         *
         *     |cos(θ)|  ≈  √((m₀₀² + (1 − m₀₁²) + (1 − m₁₀²) + m₁₁²) / 4)
         */
        if (srcDim == PlanarCanvas.BIDIMENSIONAL && poiInOld.length == PlanarCanvas.BIDIMENSIONAL) {
            final double ms  = Math.max(0, Math.min(1, (cps(affine, 0) + cps(affine, 1) + 2) / 4));
            final double sin = Math.sqrt(1 - ms);
            final double cos = Math.sqrt(    ms);
            for (int row = 0; row <= 1; row++) {
                final int sor = row ^ 1;
                affine.setElement(row, row, Math.copySign(cos, affine.getElement(row, row)));
                affine.setElement(row, sor, Math.copySign(sin, affine.getElement(row, sor)));
            }
        } else {
            throw new RenderException(Errors.format(Errors.Keys.UnsupportedCoordinateSystem_1, "3D"));
        }
        for (int i=0; i<srcDim; i++) {
            affine.convertBefore(i, scale, null);           // Use same scale factor for all coordinates.
            affine.convertBefore(i, null, -poiInNew[i]);
        }
        return MathTransforms.linear(affine);
    }

    /**
     * Computes cos(θ)² − sin²(θ) on the given matrix row. Caller needs to add 1 for getting the sum
     * of squares of cosine values. That addition should be done last for reducing rounding errors.
     */
    private static double cps(final MatrixSIS affine, final int row) {
        final double cos = affine.getElement(row, row);
        final double sin = affine.getElement(row, row ^ 1);
        return cos*cos - sin*sin;
    }

    /**
     * Returns the (usually affine) conversion from objective CRS to display coordinate system.
     * The source coordinates shall be in the CRS given by {@link #getObjectiveCRS()} and the
     * converted coordinates will be in the CRS given by {@link #getDisplayCRS()}.
     *
     * <p>The <cite>objective to display</cite> conversion changes every time that user zooms
     * or scrolls on viewed data. However, the transform returned by this method is a snapshot
     * taken at the time this method is invoked; subsequent changes in the <cite>objective to
     * display</cite> conversion are not reflected in the returned transform.</p>
     *
     * @return snapshot of the (usually affine) conversion from objective CRS
     *         to display coordinate system (never {@code null}).
     *
     * @see #OBJECTIVE_CRS_PROPERTY
     * @see #getObjectiveCRS()
     * @see #getDisplayCRS()
     */
    public LinearTransform getObjectiveToDisplay() {
        if (objectiveToDisplay == null) {
            objectiveToDisplay = createObjectiveToDisplay();
        }
        return objectiveToDisplay;
    }

    /**
     * Returns the current <cite>objective to display</cite> conversion managed by the subclass.
     * This method is invoked only if {@link #objectiveToDisplay} is {@code null}, which may
     * happen either at initialization time or if the subclass uses its own specialized field
     * instead of {@link #objectiveToDisplay} for managing changes in the zooms or viewed area.
     * This method needs to be overridden only by subclasses using such specialization.
     *
     * @return objective to display conversion created from current value managed by subclass.
     *
     * @see #setObjectiveToDisplayImpl(LinearTransform)
     */
    LinearTransform createObjectiveToDisplay() {
        return MathTransforms.identity(getDisplayDimensions());
    }

    /**
     * Sets the conversion from objective CRS to display coordinate system.
     * If the given value is different than the previous value, then a change event is sent
     * to all listeners registered for the {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} property.
     * The event reason is {@link TransformChangeEvent.Reason#ASSIGNMENT}.
     *
     * <p>Invoking this method has the effect of changing the viewed area, the zoom level or the rotation of the map.
     * It does not update the {@value #POINT_OF_INTEREST_PROPERTY} property however. The point of interest may move
     * outside the view area as a result of this method call.</p>
     *
     * @param  newValue  the new <cite>objective to display</cite> conversion.
     * @throws IllegalArgumentException if given the transform does not have the expected number of dimensions or is not affine.
     * @throws RenderException if the <cite>objective to display</cite> transform cannot be set to the given value for another reason.
     */
    public void setObjectiveToDisplay(final LinearTransform newValue) throws RenderException {
        ArgumentChecks.ensureNonNull(OBJECTIVE_TO_DISPLAY_PROPERTY, newValue);
        final int expected = getDisplayDimensions();
        int actual = newValue.getSourceDimensions();
        if (actual == expected) {
            actual = newValue.getTargetDimensions();
            if (actual == expected) {
                LinearTransform oldValue = objectiveToDisplay;      // Do not invoke user-overridable method.
                if (oldValue == null) {
                    oldValue = createObjectiveToDisplay();
                }
                if (!oldValue.equals(newValue)) {
                    setObjectiveToDisplayImpl(newValue);
                    firePropertyChange(new TransformChangeEvent(this, oldValue, newValue,
                                           TransformChangeEvent.Reason.ASSIGNMENT));
                }
                return;
            }
        }
        throw new MismatchedDimensionException(errors().getString(
                Errors.Keys.MismatchedDimension_3, OBJECTIVE_TO_DISPLAY_PROPERTY, expected, actual));
    }

    /**
     * Actually sets the conversion from objective CRS to display coordinate system.
     * Contrarily to other setter methods, this method does not notify listeners about that change;
     * it is caller responsibility to fire a {@link TransformChangeEvent} after all fields are updated.
     * This design choice is because this method is usually invoked as part of a larger set of changes.
     *
     * <p>If the new value is {@code null}, then this method only declares that the {@link #objectiveToDisplay}
     * transform became invalid and will need to be recomputed. It is subclasses responsibility to recompute the
     * transform in their {@link #createObjectiveToDisplay()}.</p>
     *
     * @param  newValue  the new "objective to display" transform, or {@code null} if it will be computed later
     *          by {@link #createObjectiveToDisplay()}. A null value is okay only when invoked by subclasses that
     *          overrode {@link #createObjectiveToDisplay()}.
     *
     * @see #createObjectiveToDisplay()
     */
    void setObjectiveToDisplayImpl(final LinearTransform newValue) {
        objectiveToDisplay = newValue;
        gridGeometry       = null;
        operationContext.clear();
    }

    /**
     * Returns the size and location of the display device.
     * The unit of measurement is typically (but not necessarily) pixels.
     * The coordinate values are often integers, but this is not mandatory.
     * The coordinate reference system is given by {@link #getDisplayCRS()}.
     *
     * <p>This value may be {@code null} on newly created {@code Canvas}, before data are added and canvas
     * is configured. It should not be {@code null} anymore once a {@code Canvas} is ready for displaying.</p>
     *
     * @return size and location of the display device.
     *
     * @see #DISPLAY_BOUNDS_PROPERTY
     * @see #getGeographicArea()
     */
    public Envelope getDisplayBounds() {
        return displayBounds.isAllNaN() ? null : new GeneralEnvelope(displayBounds);
    }

    /**
     * Sets the size and location of the display device. The envelope CRS shall be either the
     * {@linkplain #getDisplayCRS() display CRS} or unspecified, in which case the display CRS
     * is assumed. Unit of measurement is typically (but not necessarily) {@link Units#PIXEL}.
     * If the given value is different than the previous value, then a change event is sent to
     * all listeners registered for the {@value #DISPLAY_BOUNDS_PROPERTY} property.
     *
     * @param  newValue  the new display bounds.
     * @throws IllegalArgumentException if the given envelope does not have the expected CRS or number of dimensions.
     * @throws RenderException if the display bounds cannot be set to the given value for another reason.
     */
    public void setDisplayBounds(final Envelope newValue) throws RenderException {
        ArgumentChecks.ensureNonNull(DISPLAY_BOUNDS_PROPERTY, newValue);
        final CoordinateReferenceSystem crs = newValue.getCoordinateReferenceSystem();
        if (crs != null && !Utilities.equalsIgnoreMetadata(getDisplayCRS(), crs)) {
            throw new MismatchedReferenceSystemException(errors().getString(
                    Errors.Keys.IllegalCoordinateSystem_1, IdentifiedObjects.getDisplayName(crs, getLocale())));
        }
        final GeneralEnvelope oldValue = new GeneralEnvelope(displayBounds);
        displayBounds.setEnvelope(newValue);
        displayBounds.setCoordinateReferenceSystem(oldValue.getCoordinateReferenceSystem());
        if (displayBounds.isEmpty()) {
            displayBounds.setEnvelope(oldValue);
            throw new IllegalArgumentException(errors().getString(Errors.Keys.EmptyProperty_1, DISPLAY_BOUNDS_PROPERTY));
        }
        if (!oldValue.equals(displayBounds)) {
            gridGeometry = null;
            operationContext.partialClear(false);                               // Resolution is still valid.
            firePropertyChange(DISPLAY_BOUNDS_PROPERTY, oldValue, newValue);    // Do not publish reference to `displayBounds`.
        }
    }

    /**
     * Returns the coordinates of a point considered representative of the data.
     * This is typically (but not necessarily) the center of data bounding box.
     * This point is used for example as the default location where to compute resolution
     * (the resolution may vary at each pixel because of map projection deformations).
     * This position may become outside the viewing area after zooms or translations have been applied.
     *
     * <p>The coordinates can be given in their original CRS or in the {@linkplain #getObjectiveCRS() objective CRS}.
     * If {@code objective} is {@code false}, then the returned position can be expressed in any CRS convertible to
     * data or objective CRS. If that CRS has more dimensions than the {@linkplain #getObjectiveCRS() objective CRS},
     * then the supplemental dimensions specify which slice to show
     * (for example the depth of the horizontal plane to display, or the date of the dynamic phenomenon to display.
     * See {@linkplain Canvas class javadoc} for more discussion.)
     * If {@code objective} is {@code true}, then the position is transformed to the objective CRS.</p>
     *
     * <p>This value is initially {@code null}. A value should be specified either by invoking
     * {@link #setPointOfInterest(DirectPosition)} or {@link #setGridGeometry(GridGeometry)}.</p>
     *
     * @param  objective  whether to return a position transformed to {@linkplain #getObjectiveCRS() objective CRS}.
     * @return coordinates of a representative point, or {@code null} if unspecified.
     *
     * @see #POINT_OF_INTEREST_PROPERTY
     */
    public DirectPosition getPointOfInterest(final boolean objective) {
        final DirectPosition poi = objective ? objectivePOI : pointOfInterest;
        return (poi != null) ? new GeneralDirectPosition(poi) : null;
    }

    /**
     * Sets the coordinates of a representative point inside the data bounding box.
     * If the given value is different than the previous value, then a change event is sent to all listeners
     * registered for the {@value #POINT_OF_INTEREST_PROPERTY} property.
     *
     * @param  newValue  the new coordinates of a representative point.
     * @throws NullPointerException if the given position is null.
     * @throws IllegalArgumentException if the given position does not have a CRS.
     * @throws RenderException if the point of interest cannot be set to the given value.
     */
    public void setPointOfInterest(final DirectPosition newValue) throws RenderException {
        ArgumentChecks.ensureNonNull(POINT_OF_INTEREST_PROPERTY, newValue);
        final GeneralDirectPosition copy = new GeneralDirectPosition(newValue);
        final CoordinateReferenceSystem crs = copy.getCoordinateReferenceSystem();
        if (crs == null) {
            throw new IllegalArgumentException(errors().getString(Errors.Keys.UnspecifiedCRS));
        }
        final GeneralDirectPosition oldValue = pointOfInterest;
        if (!copy.equals(oldValue)) try {
            /*
             * If the user has not yet specified an objective CRS, takes the Point Of Interest CRS
             * (only the number of dimensions that the display device can show).
             */
            if (objectiveCRS == null) {
                final CoordinateReferenceSystem newObjectiveCRS = CRS.getComponentAt(crs, 0, getDisplayDimensions());
                if (newObjectiveCRS == null) {
                    throw new IllegalArgumentException("Cannot infer objective CRS.");
                    // Message not localized yet because we should probably try harder.
                }
                operationContext.setObjectiveToGeographic(objectiveToGeographic(newObjectiveCRS));
                objectiveCRS = newObjectiveCRS;                            // Set only on success.
            }
            /*
             * Transform the Point Of Interest to the objective CRS as a way to test its validity.
             * All canvas fields will be updated only if this operation succeeds.
             *
             * Note 1: in the CoordinateOperationContext used for selecting a MathTransform, the geographic area is
             * still the same but the spatial resolution could be slightly different because computed at a new point
             * of interest. But we cannot use the new point of interest now, because we need the MathTransform for
             * computing it. However, in practice the resolution is often ignored, or does not vary a lot in regions
             * where it matter. So we assume it is okay to keep the CoordinateOperationContext with old resolution
             * in the following call to `findTransform(…)` or usage of `multidimToObjective`.
             *
             * Note 2: `oldValue` cannot be null if `multidimToObjective` is non-null.
             */
            MathTransform mt = multidimToObjective;
            if (mt == null || !Utilities.equalsIgnoreMetadata(crs, oldValue.getCoordinateReferenceSystem())) {
                mt = findTransform(crs, objectiveCRS, false);
            }
            objectivePOI          = mt.transform(copy, allocatePosition());
            pointOfInterest       = copy;                                           // Set only after transform succeeded.
            multidimToObjective   = mt;
            augmentedObjectiveCRS = null;                                           // Will be recomputed when first needed.
            axisTypes             = null;
            gridGeometry          = null;
            operationContext.partialClear(true);                                    // Geographic area is still valid.
            firePropertyChange(POINT_OF_INTEREST_PROPERTY, oldValue, newValue);     // Do not publish reference to `copy`.
        } catch (FactoryException | TransformException e) {
            throw new RenderException(errors().getString(Errors.Keys.CanNotSetPropertyValue_1, POINT_OF_INTEREST_PROPERTY), e);
        }
    }

    /**
     * Returns the coordinate values of the Point Of Interest (POI) in objective CRS.
     * The array length should be equal to {@link #getDisplayDimensions()}.
     * May be {@code null} if the point of interest is unknown.
     */
    final double[] getObjectivePOI() {
        return (objectivePOI != null) ? objectivePOI.getCoordinate() : null;
    }

    /**
     * Returns canvas properties (CRS, display bounds, conversion) encapsulated in a grid geometry.
     * This is a convenience method for interoperability with grid coverage API.
     * If {@link #setGridGeometry(GridGeometry)} has been invoked with a non-null value and no other
     * {@code Canvas} property changed since that method call, then this method returns that value.
     * Otherwise this method computes a grid geometry as described below.
     *
     * <p>The set of {@link GridGeometry} dimensions includes all the dimensions of the objective CRS,
     * augmented with all (if possible) or some supplemental dimensions found in the point of interest.
     * For example if the canvas manages only (<var>x</var>,<var>y</var>) coordinates but the point of
     * interest includes also a <var>t</var> coordinate, then a third dimension (which we call the
     * <cite>supplemental dimension</cite>) for <var>t</var> is added to the CRS, {@link GridExtent}
     * and "grid to CRS" transform of the returned grid geometry.</p>
     *
     * <table class="sis">
     *   <caption>Canvas properties → grid geometry properties</caption>
     *   <tr>
     *     <th>Grid geometry element</th>
     *     <th>Display dimensions</th>
     *     <th>Supplemental dimensions</th>
     *   </tr><tr>
     *     <td>{@link GridGeometry#getCoordinateReferenceSystem()}</td>
     *     <td>{@link #getObjectiveCRS()}.</td>
     *     <td>Some of <code>{@linkplain #getPointOfInterest(boolean)
     *         getPointOfInterest}(false).getCoordinateReferenceSystem()</code></td>
     *   </tr><tr>
     *     <td>{@link GridGeometry#getExtent()}</td>
     *     <td>{@link #getDisplayBounds()} rounded to enclosing (floor and ceil) integers</td>
     *     <td>[0 … 0]</td>
     *   </tr><tr>
     *     <td>{@link GridGeometry#getGridToCRS(PixelInCell)}</td>
     *     <td>Inverse of {@link #getObjectiveToDisplay()}</td>
     *     <td>Some {@linkplain #getPointOfInterest(boolean) point of interest} coordinates as translation terms</td>
     *   </tr>
     * </table>
     *
     * The {@link GridGeometry#getGridToCRS(PixelInCell)} transform built by this method is always a {@link LinearTransform}.
     * This linearity implies that the grid geometry CRS cannot be the Point Of Interest (POI) CRS, unless conversion
     * from POI CRS to objective CRS is linear.
     *
     * @return a grid geometry encapsulating canvas properties, including supplemental dimensions if possible.
     * @throws RenderException if the grid geometry cannot be computed.
     */
    public GridGeometry getGridGeometry() throws RenderException {
        if (gridGeometry == null) try {
            /*
             * If not already done, create a multi-dimensional CRS composed of `objectiveCRS`
             * with supplemental dimensions appended. This CRS needs to be recreated only if
             * the Point of Interest and/or the objective CRS changed since last call.
             */
            if (augmentedObjectiveCRS == null) {
                if (pointOfInterest != null && objectiveCRS != null) {
                    final CoordinateReferenceSystem crs = pointOfInterest.getCoordinateReferenceSystem();
                    final ArrayList<CoordinateReferenceSystem> components = new ArrayList<>(4);
                    components.add(objectiveCRS);
                    /*
                     * `findSupplementalDimensions(…)` tries to complete the `components` list on a best effort basis.
                     * We have no guarantees that all supplemental dimensions will be included. The set of dimensions
                     * actually appended is encoded in `supplementalDimensions` bits.
                     */
                    supplementalDimensions = CanvasExtent.findSupplementalDimensions(crs,
                            multidimToObjective.derivative(pointOfInterest), components);
                    augmentedObjectiveCRS = CRS.compound(components.toArray(CoordinateReferenceSystem[]::new));
                    if (Utilities.equalsIgnoreMetadata(augmentedObjectiveCRS, crs)) {
                        augmentedObjectiveCRS = crs;
                    }
                } else {
                    augmentedObjectiveCRS = objectiveCRS;
                }
                /*
                 * The axis types are for information purposes only, for making debugging easier.
                 * It will typically contains the (column, row) names, maybe completed with up or
                 * time names.
                 */
                axisTypes = CanvasExtent.suggestAxisTypes(augmentedObjectiveCRS, getDisplayDimensions());
                getDisplayAxes(axisTypes);
            }
            /*
             * Create the `gridToCRS` transform using the "display to objective" transform augmented with POI
             * coordinate values in supplemental dimensions. Those coordinate values will be stored in the
             * translation terms of the `gridToCRS` matrix.
             */
            if (objectiveToDisplay == null) {
                objectiveToDisplay = createObjectiveToDisplay();
            }
            LinearTransform gridToCRS = objectiveToDisplay.inverse();
            if (supplementalDimensions != 0) {
                gridToCRS = CanvasExtent.createGridToCRS(gridToCRS.getMatrix(), pointOfInterest, supplementalDimensions);
            }
            /*
             * Create the grid extent with a number of dimensions that include the supplemental dimensions.
             * The cell indices range of all supplemental dimensions is [0 … 0]. If a point of interest is
             * available, the `GridExtent` will contain the grid coordinates of that point.
             */
            final GridExtent extent;
            if (displayBounds.isEmpty()) {
                extent = null;
            } else {
                DirectPosition poi = objectivePOI;
                if (poi != null) {
                    poi = objectiveToDisplay.transform(objectivePOI, null);
                }
                extent = CanvasExtent.create(displayBounds, poi, axisTypes, gridToCRS.getSourceDimensions());
            }
            gridGeometry = new GridGeometry(extent, PixelInCell.CELL_CORNER, gridToCRS, augmentedObjectiveCRS);
        } catch (FactoryException | TransformException e) {
            throw new RenderException(errors().getString(Errors.Keys.CanNotCompute_1, GRID_GEOMETRY_PROPERTY), e);
        }
        return gridGeometry;
    }

    /**
     * Sets canvas properties from the given grid geometry. This convenience method converts the
     * coordinate reference system, "grid to CRS" transform and extent of the given grid geometry
     * to {@code Canvas} properties. If the given value is different than the previous value, then
     * change events are sent to all listeners registered for the {@value #DISPLAY_BOUNDS_PROPERTY},
     * {@value #OBJECTIVE_CRS_PROPERTY}, {@value #OBJECTIVE_TO_DISPLAY_PROPERTY}
     * (with {@link TransformChangeEvent.Reason#GRID_GEOMETRY_CHANGE} reason),
     * and/or {@value #POINT_OF_INTEREST_PROPERTY} properties, in that order.
     *
     * <p>The value given to this method will be returned by {@link #getGridGeometry()} as long as
     * none of above cited properties is changed. If one of those properties changes (for example
     * if the user zooms or pans the map), then a new grid geometry will be computed. There is no
     * guarantee that the recomputed grid geometry will be similar to the grid geometry specified
     * to this method. For example, the {@link GridExtent} in supplemental dimensions may be different.</p>
     *
     * @param  newValue  the grid geometry from which to get new canvas properties.
     * @throws RenderException if the given grid geometry cannot be converted to canvas properties.
     */
    public void setGridGeometry(final GridGeometry newValue) throws RenderException {
        ArgumentChecks.ensureNonNull(GRID_GEOMETRY_PROPERTY, newValue);
        if (!newValue.equals(gridGeometry)) try {
            /*
             * Do not test grid.isDefined(…) — we consider all elements as mandatory for this method.
             * First, get the dimensions to show in the canvas by searching dimensions having a span
             * larger than 1 grid cell. Those spans will become the sizes of display bounds.
             *
             * Result of this block: DISPLAY_BOUNDS_PROPERTY: newBounds
             */
            final GridExtent extent = newValue.getExtent();
            final int[] displayDimensions = extent.getSubspaceDimensions(getDisplayDimensions());
            final GeneralEnvelope newBounds = new GeneralEnvelope(getDisplayCRS());
            for (int i=0; i<displayDimensions.length; i++) {
                final int s = displayDimensions[i];
                newBounds.setRange(i, extent.getLow(s), Math.incrementExact(extent.getHigh(s)));
            }
            /*
             * Computes the point of interest in the Coordinate Reference System (CRS) of the given grid geometry.
             * This point will also contain the coordinates in supplemental dimensions (if any), such as vertical
             * and temporal positions of the slice shown in this canvas. Those supplemental coordinates should be
             * computed in cell centers. This suggests that we should use PixelInCell.CELL_CENTER transform, but
             * actually the coordinates returned by `extent.getPointOfInterest()` for [x … x] ranges (span of 1,
             * as required for supplemental dimensions) already includes a 0.5 fraction digit.
             *
             * Result of this block: POINT_OF_INTEREST_PROPERTY: newPOI
             */
            final MathTransform gridToCRS = newValue.getGridToCRS(PixelInCell.CELL_CORNER);
            final CoordinateReferenceSystem crs;
            final GeneralDirectPosition newPOI;
            if (newValue.isDefined(GridGeometry.CRS)) {
                crs = newValue.getCoordinateReferenceSystem();
                newPOI = new GeneralDirectPosition(crs);
            } else {
                crs = null;
                newPOI = new GeneralDirectPosition(gridToCRS.getTargetDimensions());
            }
            gridToCRS.transform(extent.getPointOfInterest(PixelInCell.CELL_CORNER), 0, newPOI.coordinates, 0, 1);
            /*
             * Get the CRS component in the dimensions shown by this canvas.
             *
             * Result of this block: OBJECTIVE_CRS_PROPERTY:        newObjectiveCRS
             *                       OBJECTIVE_TO_DISPLAY_PROPERTY: newObjToDisplay
             */
            final TransformSeparator analyzer = new TransformSeparator(gridToCRS, coordinateOperationFactory.getMathTransformFactory());
            analyzer.addSourceDimensions(displayDimensions);
            final LinearTransform           newObjectiveToDisplay = MathTransforms.tangent(analyzer.separate().inverse(), newPOI);
            final int[]                     objectiveDimensions   = analyzer.getTargetDimensions();
            final CoordinateReferenceSystem newObjectiveCRS       = CRS.selectDimensions(crs, objectiveDimensions);
            final MathTransform             dimensionSelect       = MathTransforms.linear(
                    Matrices.createDimensionSelect(newPOI.getDimension(), objectiveDimensions));
            /*
             * At this point we are ready to commit the new values. Before doing so, copy
             * the current property values in order to provide the old values to listeners.
             */
            final GeneralEnvelope           oldBounds             = new GeneralEnvelope(displayBounds);
            final DirectPosition            oldPOI                = pointOfInterest;
            final LinearTransform           oldObjectiveToDisplay = objectiveToDisplay;
            final CoordinateReferenceSystem oldObjectiveCRS       = objectiveCRS;
            /*
             * Set internal fields only after we successfully computed everything,
             * in order to have a "all or nothing" behavior.
             */
            displayBounds.setEnvelope(newBounds);
            setObjectiveToDisplayImpl(newObjectiveToDisplay);
            pointOfInterest       = newPOI;
            objectivePOI          = newPOI;
            objectiveCRS          = newObjectiveCRS;
            multidimToObjective   = dimensionSelect;
            augmentedObjectiveCRS = null;               // Will be recomputed when first needed.
            axisTypes             = null;
            gridGeometry          = newValue;
            /*
             * Notify listeners only after all properties have been updated. If a listener throws an exception,
             * other listeners will not be notified but this Canvas will not be corrupted since all the work to
             * do in this class is already completed. Order matter, it is documented in this method javadoc.
             */
            fireIfChanged(DISPLAY_BOUNDS_PROPERTY,    oldBounds,             newBounds);
            fireIfChanged(OBJECTIVE_CRS_PROPERTY,     oldObjectiveCRS,       newObjectiveCRS);
            fireIfChanged(/* OBJECTIVE_TO_DISPLAY */  oldObjectiveToDisplay, newObjectiveToDisplay, true);
            fireIfChanged(POINT_OF_INTEREST_PROPERTY, oldPOI,                newPOI);
        } catch (IncompleteGridGeometryException | CannotEvaluateException | FactoryException | TransformException e) {
            throw new RenderException(errors().getString(Errors.Keys.CanNotSetPropertyValue_1, GRID_GEOMETRY_PROPERTY), e);
        }
    }

    /**
     * Fires a property change event if the old and new values are not equal.
     *
     * @param  propertyName  name of the property that changed its value.
     * @param  oldValue      the old property value (may be {@code null}).
     * @param  newValue      the new property value.
     */
    private void fireIfChanged(final String propertyName, final Object oldValue, final Object newValue) {
        if (!Objects.equals(oldValue, newValue)) {
            firePropertyChange(propertyName, oldValue, newValue);
        }
    }

    /**
     * Fires a property change event if the old and new transforms are not equal.
     *
     * @param  oldValue  the old "objective to display" transform.
     * @param  newValue  the new transform, or {@code null} for lazy computation.
     * @param  grid      {@code true} if the reason is a grid geometry change, or {@code false} if only a CRS change.
     */
    private void fireIfChanged(final LinearTransform oldValue, final LinearTransform newValue, final boolean grid) {
        if (!Objects.equals(oldValue, newValue)) {
            firePropertyChange(new TransformChangeEvent(this, oldValue, newValue,
                    grid ? TransformChangeEvent.Reason.GRID_GEOMETRY_CHANGE
                         : TransformChangeEvent.Reason.CRS_CHANGE));
        }
    }

    /**
     * Returns the geographic bounding box encompassing the area shown on the display device.
     * If the {@linkplain #getObjectiveCRS() objective CRS} is not convertible to a geographic CRS,
     * then this method returns an empty value.
     *
     * @return geographic bounding box encompassing the viewed area.
     * @throws RenderException in an error occurred while computing the geographic area.
     *
     * @see #getDisplayBounds()
     */
    public Optional<GeographicBoundingBox> getGeographicArea() throws RenderException {
        try {
            return operationContext.getGeographicArea(this);
        } catch (TransformException e) {
            throw new RenderException(errors().getString(Errors.Keys.CanNotCompute_1, GEOGRAPHIC_AREA_PROPERTY), e);
        }
    }

    /**
     * Returns an estimation of the resolution (in metres) at the point of interest.
     * If the {@linkplain #getObjectiveCRS() objective CRS} is not convertible to a
     * geographic CRS, then this method returns an empty value.
     *
     * @return estimation of the resolution in metres at current point of interest.
     * @throws RenderException in an error occurred while computing the resolution.
     */
    public OptionalDouble getSpatialResolution() throws RenderException {
        try {
            return operationContext.getSpatialResolution(this);
        } catch (TransformException e) {
            throw new RenderException(errors().getString(Errors.Keys.CanNotCompute_1, SPATIAL_RESOLUTION_PROPERTY), e);
        }
    }

    /**
     * Computes the value for {@link #objectiveToGeographic}. The value is not stored by this method for
     * giving caller a chance to validate other properties before to write them in a "all or nothing" way.
     *
     * @param  crs  the new objective CRS in process of being set by the caller.
     * @return the conversion from given CRS to geographic CRS, or {@code null} if none.
     */
    private CoordinateOperation objectiveToGeographic(final CoordinateReferenceSystem crs) throws FactoryException {
        final GeographicCRS geoCRS = ReferencingUtilities.toNormalizedGeographicCRS(crs, false, false);
        return (geoCRS != null) ? coordinateOperationFactory.createOperation(crs, geoCRS) : null;
    }

    /**
     * Returns the transform from the given source CRS to the given target CRS with precedence for an operation
     * valid for the geographic area of this canvas. The transform returned by this method for the same pair of
     * CRS may differ depending on which area is currently visible in the canvas. All requests for a coordinate
     * operation should invoke this method instead of {@link CRS#findOperation(CoordinateReferenceSystem,
     * CoordinateReferenceSystem, GeographicBoundingBox)}.
     *
     * @param  allowDisplayCRS  whether the {@code sourceCRS} can be {@link #getDisplayCRS()}.
     */
    private MathTransform findTransform(CoordinateReferenceSystem source,
                                  final CoordinateReferenceSystem target,
                                  boolean allowDisplayCRS)
            throws FactoryException, TransformException, RenderException
    {
        if (allowDisplayCRS) {
            allowDisplayCRS = Utilities.equalsIgnoreMetadata(source, displayBounds.getCoordinateReferenceSystem());
        }
        if (allowDisplayCRS) {
            source = objectiveCRS;
        }
        operationContext.refresh(this);
        MathTransform tr = coordinateOperationFactory.createOperation(source, target, operationContext).getMathTransform();
        if (allowDisplayCRS) {
            tr = MathTransforms.concatenate(getObjectiveToDisplay().inverse(), tr);
        }
        return tr;
    }

    /**
     * Allocates a position which can hold a coordinates in objective CRS.
     * May be overridden by subclasses for a little bit more efficiency.
     */
    DirectPosition allocatePosition() {
        return new GeneralDirectPosition(objectiveCRS);
    }

    /**
     * Returns the resources bundle for error messages in the locale of this canvas.
     */
    private Errors errors() {
        return Errors.getResources(locale);
    }
}
