blob: 8da5616f77e1fb7f1f4a8948c73bc71890da7a02 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.sis.gui.metadata;
import java.util.Date;
import java.util.Set;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.StringJoiner;
import javafx.concurrent.Task;
import javafx.geometry.HPos;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import org.opengis.metadata.Metadata;
import org.opengis.metadata.Identifier;
import org.opengis.metadata.citation.Citation;
import org.opengis.metadata.citation.CitationDate;
import org.opengis.metadata.citation.DateType;
import org.opengis.metadata.extent.Extent;
import org.opengis.metadata.extent.GeographicBoundingBox;
import org.opengis.metadata.extent.GeographicDescription;
import org.opengis.metadata.extent.GeographicExtent;
import org.opengis.metadata.identification.Identification;
import org.opengis.metadata.distribution.Format;
import org.opengis.util.InternationalString;
import org.apache.sis.metadata.iso.citation.Citations;
import org.apache.sis.metadata.iso.extent.Extents;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.internal.referencing.Formulas;
import org.apache.sis.internal.gui.BackgroundThreads;
import org.apache.sis.measure.Latitude;
import org.apache.sis.measure.Longitude;
import org.apache.sis.storage.Aggregate;
import org.apache.sis.storage.Resource;
import org.apache.sis.storage.DataStoreException;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.Workaround;
import org.apache.sis.util.resources.Vocabulary;
import static org.apache.sis.internal.util.CollectionsExt.nonNull;
/**
* The pane where to show the values of {@link Identification} objects.
* The same pane can be used for an arbitrary amount of identifications.
* Each instance is identified by its title.
* The content is:
*
* <ol>
* <li>The title in bold font.</li>
* <li>Identifiers.</li>
* <li>Abstract, or purpose, or credit (in this preference order).</li>
* <li>Topic category.</li>
* <li>Release date, or publication date, or creation date, or any date (in this preference order).</li>
* <li>Type of resource.</li>
* <li>Resource format.</li>
* <li>Spatiotemporal extent as a textual description.</li>
* <li>Extent shown as a rectangle on a world map.</li>
* </ol>
*
* @author Smaniotto Enzo (GSoC)
* @author Martin Desruisseaux (Geomatys)
* @version 1.1
* @since 1.1
* @module
*/
final class IdentificationInfo extends Section<Identification> {
/**
* Size of the map. Current version uses a size of 360×180° so that the scale factor
* from the map to degrees is 1. Future version may use a different size, in which
* case the scale factor will need to be added in the code.
*/
private static final double MAP_WIDTH = Longitude.MAX_VALUE - Longitude.MIN_VALUE,
MAP_HEIGHT = Latitude.MAX_VALUE - Latitude.MIN_VALUE;
/**
* Minimal size of rectangles to be drawn by {@link IdentificationInfo#drawOnMap(GeographicBoundingBox)}.
* If a rectangle is smaller, it will be expanded to this size. We use a minimal size because otherwise
* small rectangles may be practically invisible.
*/
private static final double MIN_RECT_SIZE = 6;
/**
* The resource title, or if non the identifier as a fallback.
*/
private final Label title;
/**
* The canvas where to draw geographic bounding boxes over a world map.
* Shall never be null, but need to be recreated for each new map.
* A canvas of size (0,0) is available at initialization time for drawing a new map.
*
* @see #isWorldMapEmpty()
* @see #drawOnMap(GeographicBoundingBox)
*/
private Canvas extentOnMap;
/**
* Whether the geographic bounding box covers the world.
*/
private boolean isWorld;
/**
* Whether the map was visible with previous data, before {@link #buildContent(Identification)} call.
* We use this information for avoiding flicker effect when a map is removed, then added back after a
* slight delay by {@link #completeMissingGeographicBounds(Aggregate)}.
*/
private boolean mapWasVisible;
/**
* The task which is running in background thread for searching bounding boxes in {@link Aggregate} children.
* We use this reference for cancelling the task if a new resource is selected before the previous task finished.
*/
private Task<?> aggregateWalker;
/**
* Creates an initially empty view for identification information.
*/
IdentificationInfo(final MetadataSummary owner) {
super(owner);
title = new Label();
title.setFont(Font.font(null, FontWeight.BOLD, 15));
add(title, 0, 0, NUM_CHILD_PER_LINE, 1);
extentOnMap = new Canvas(); // Size of (0,0) by default.
add(extentOnMap, 0, 0, NUM_CHILD_PER_LINE, 1); // Will be moved to a different location by buildContent(…).
setHalignment(extentOnMap, HPos.CENTER);
finished();
}
/**
* If the world map contains a map from some previous metadata, discards the old canvas and create a new one.
* We do that because as of JavaFX 13, we found no way to clear the content of an existing {@link Canvas}.
*/
@Workaround(library = "JavaFX", version = "13")
private void clearWorldMap() {
// Do not clear `isWorld` because caller may want the clear the map because it is world map.
if (!isWorldMapEmpty()) {
final int p = linesStartIndex() - 1;
assert getChildren().get(p) == extentOnMap;
getChildren().set(p, extentOnMap = new Canvas());
setColumnSpan(extentOnMap, NUM_CHILD_PER_LINE);
setHalignment(extentOnMap, HPos.CENTER);
}
}
/**
* Returns whether {@link #extentOnMap} is considered empty and available for use.
*/
private boolean isWorldMapEmpty() {
return extentOnMap.getWidth() == 0 && extentOnMap.getHeight() == 0;
}
/**
* Returns {@code true} if this section contains no data.
*/
@Override
boolean isEmpty() {
return super.isEmpty() && isWorldMapEmpty();
}
/**
* If this pane has no geographic bounds information, search for geographic bounds in the child resources.
* This method is used as a fallback when {@link #buildContent(Identification)} did not find bounding box
* in the metadata directly provided. If bounds has been found, then this method does nothing.
*
* <p>The method does nothing if there is more than one {@link Identification} metadata element,
* because we would not know to which element to assign the extent of children resources.</p>
*/
final void completeMissingGeographicBounds(final Aggregate resource) {
if (!isWorld && isWorldMapEmpty() && !super.isEmpty() && numPages() == 1) {
/*
* If a map was visible previously, add back an empty map for avoiding flicking effect.
* If it appears that the map has no bounding box to show, it will be removed after the
* background thread finished its work.
*/
if (mapWasVisible) {
drawMapBackground();
}
BackgroundThreads.execute(aggregateWalker = new Task<Set<GeographicBoundingBox>>() {
/** Invoked in a background thread for fetching bounding boxes. */
@Override protected Set<GeographicBoundingBox> call() throws DataStoreException {
final Set<GeographicBoundingBox> boxes = new LinkedHashSet<>();
for (final Resource child : resource.components()) {
final Metadata metadata = child.getMetadata();
if (isCancelled()) break;
if (metadata != null) {
for (final Identification id : nonNull(metadata.getIdentificationInfo())) {
if (id != null) {
for (final Extent extent : id.getExtents()) {
final GeographicBoundingBox b = Extents.getGeographicBoundingBox(extent);
if (b != null) boxes.add(b);
}
}
}
}
}
return boxes;
}
/** Shows the result in JavaFX thread. */
@Override protected void succeeded() {
aggregateWalker = null;
drawOnMap(getValue());
}
/** Invoked in JavaFX thread if metadata loading failed. */
@Override protected void failed() {
aggregateWalker = null;
owner.setError(getException());
}
});
}
}
/**
* Sets the identification information from the given metadata.
*/
@Override
void setInformation(final Metadata metadata) {
if (aggregateWalker != null) {
aggregateWalker.cancel(BackgroundThreads.NO_INTERRUPT_DURING_IO);
aggregateWalker = null;
}
final Collection<? extends Identification> info;
if (metadata == null) {
clearWorldMap();
info = null;
} else {
info = metadata.getIdentificationInfo();
}
setInformation(nonNull(info), Identification[]::new);
}
/**
* Invoked when new identification information should be shown.
* This method updates all fields in this section with the content of given identification information.
* The content is summarized in {@linkplain IdentificationInfo class javadoc}.
*/
@Override
void buildContent(final Identification info) {
mapWasVisible = !isWorldMapEmpty();
isWorld = false;
clearWorldMap();
String text = null;
final Citation citation = info.getCitation();
if (citation != null) {
text = owner.string(citation.getTitle());
if (text == null) {
text = Citations.getIdentifier(citation);
}
}
if (text == null) {
text = owner.vocabulary.getString(Vocabulary.Keys.Untitled);
} else if (CharSequences.isUnicodeIdentifier(text)) {
text = CharSequences.camelCaseToSentence(text).toString();
}
title.setText(text);
/*
* Identifiers as a comma-separated list on a single line. Each identifier
* is formatted as "codespace:code" or only "code" if there is no codespace.
*/
if (citation != null) {
final StringJoiner buffer = new StringJoiner(", ");
for (final Identifier id : citation.getIdentifiers()) {
buffer.add(IdentifiedObjects.toString(id));
}
if (buffer.length() != 0) {
addLine(Vocabulary.Keys.Identifiers, buffer.toString());
}
}
/*
* The abstract, or if there is no abstract the purpose, or if no purpose the credit as a fallback.
* We use those fallback because they can provide some hints about the product.
* The topic category (climatology, health, etc.) follows.
*/
short label = Vocabulary.Keys.Abstract;
text = owner.string(info.getAbstract());
if (text == null) {
label = Vocabulary.Keys.Purpose;
text = owner.string(info.getPurpose());
if (text == null) {
for (final InternationalString c : nonNull(info.getCredits())) {
text = owner.string(c);
if (text != null) {
label = Vocabulary.Keys.Credit;
break;
}
}
}
}
addLine(label, text);
/*
* Topic category.
*/
addLine(Vocabulary.Keys.TopicCategory, owner.string(nonNull(info.getTopicCategories())));
/*
* Select a single, arbitrary date. We take the release or publication date if available.
* If no publication date is found, fallback on the creation date. If no creation date is
* found neither, fallback on the first date regardless its type.
*/
if (citation != null) {
Date date = null;
label = Vocabulary.Keys.Date;
for (final CitationDate c : nonNull(citation.getDates())) {
final Date cd = c.getDate();
if (cd != null) {
final DateType type = c.getDateType();
if (DateType.PUBLICATION.equals(type) || DateType.RELEASED.equals(type)) {
label = Vocabulary.Keys.PublicationDate;
date = cd;
break; // Take the first publication or release date.
}
final boolean isCreation = DateType.CREATION.equals(type);
if (date == null || isCreation) {
label = isCreation ? Vocabulary.Keys.CreationDate : Vocabulary.Keys.Date;
date = cd; // Fallback date: creation date, or the first date otherwise.
}
}
}
if (date != null) {
addLine(label, owner.getDateFormat().format(date));
}
}
/*
* Type of resource: vector, grid, table, tin, video, etc. It gives a slight overview
* of the next section, "Spatial representation". For that reason we put it close to
* that next section, i.e. last in this section but just before the map.
*/
addLine(Vocabulary.Keys.TypeOfResource, owner.string(nonNull(info.getSpatialRepresentationTypes())));
/*
* Resource format. Current implementation shows only the first format found.
*/
for (final Format format : nonNull(info.getResourceFormats())) {
final Citation c = format.getFormatSpecificationCitation();
if (c != null) {
text = owner.string(c.getTitle());
if (text != null) {
addLine(Vocabulary.Keys.Format, text);
break;
}
}
}
/*
* Write the first description about the spatio-temporal extent, then draw all geographic bounding boxes
* on a world map. If the bounding box encompasses the whole world, replace it by a "World" description.
* The reason is that drawing a box over the whole world is not very informative; it rather looks like a
* border around the image.
*/
text = null;
Identifier identifier = null;
for (final Extent extent : nonNull(info.getExtents())) {
if (extent != null) {
if (text == null) {
text = owner.string(extent.getDescription());
}
for (final GeographicExtent ge : nonNull(extent.getGeographicElements())) {
if (identifier == null && ge instanceof GeographicDescription) {
identifier = ((GeographicDescription) ge).getGeographicIdentifier();
}
if (!isWorld && ge instanceof GeographicBoundingBox) {
isWorld = drawOnMap((GeographicBoundingBox) ge);
}
}
}
}
if (text == null) {
text = IdentifiedObjects.toString(identifier);
}
if (isWorld) {
clearWorldMap();
if (text == null) {
text = owner.vocabulary.getString(Vocabulary.Keys.World);
}
}
addLine(Vocabulary.Keys.Extent, text);
setRowIndex(extentOnMap, nextRowIndex());
}
/**
* Draws all given geographic bounding boxes on the map.
*/
private void drawOnMap(final Set<GeographicBoundingBox> boxes) {
if (boxes.isEmpty()) {
clearWorldMap();
return;
}
for (final GeographicBoundingBox box : boxes) {
isWorld = drawOnMap(box);
if (isWorld) {
clearWorldMap();
return;
}
}
}
/**
* Draws the given geographic bounding box on the map. This method can be invoked many times
* if there is many bounding boxes on the same map.
*
* @param bbox the bounding box to draw.
* @return {@code true} if the given bounding box encompasses the whole world.
*/
private boolean drawOnMap(final GeographicBoundingBox bbox) {
double north = Latitude.clamp(bbox.getNorthBoundLatitude());
double south = Latitude.clamp(bbox.getSouthBoundLatitude());
double east = bbox.getEastBoundLongitude();
double west = bbox.getWestBoundLongitude();
if (Math.abs(east - west) >= (Longitude.MAX_VALUE - Longitude.MIN_VALUE - 2*Formulas.ANGULAR_TOLERANCE)) {
if (north >= Latitude.MAX_VALUE - Formulas.ANGULAR_TOLERANCE &&
south <= Latitude.MIN_VALUE + Formulas.ANGULAR_TOLERANCE)
{
return true; // Bounding box encompasses the whole world.
}
west = Longitude.MIN_VALUE;
east = Longitude.MAX_VALUE; // Whole world in longitudes, but not in latitude.
} else {
if (west != 180) west = Longitude.normalize(west);
if (east != 180) east = Longitude.normalize(east);
if (east < west) {
east += Longitude.MAX_VALUE - Longitude.MIN_VALUE; // Box crosses the anti-meridian.
}
}
if (north >= south && Double.isFinite(east) && Double.isFinite(west)) {
double x = MAP_WIDTH / 2 + west;
double y = MAP_HEIGHT / 2 - north;
double w = east - west;
double h = north - south;
if (w < MIN_RECT_SIZE) {
x -= (MIN_RECT_SIZE - w) / 2;
w = MIN_RECT_SIZE;
}
if (h < MIN_RECT_SIZE) {
y -= (MIN_RECT_SIZE - h) / 2;
h = MIN_RECT_SIZE;
}
final double wi = Math.min(w, MAP_WIDTH - x); // Width of part inside [-180 … +180]°.
w -= wi; // Width of part not drawn by `wi`.
final boolean crossAntimeridian = (w > 0);
/*
* At this point we got the coordinates of the rectangle to draw, adjusted for making sure
* that they are inside valid ranges. The `w` variable is usually 0, unless we had to cut
* the rectangle in two parts because of anti-meridian crossing.
*/
if (isWorldMapEmpty()) {
drawMapBackground();
}
final GraphicsContext gc = extentOnMap.getGraphicsContext2D();
gc.setStroke(Color.DARKBLUE);
gc.setGlobalAlpha(0.1);
gc.fillRect(x, y, wi, h);
if (crossAntimeridian) {
gc.fillRect(0, y, w, h); // Second half of rectangle crossing anti-meridian.
}
gc.setGlobalAlpha(1.0);
if (!crossAntimeridian) {
gc.strokeRect(x, y, wi, h);
} else {
double xw = x + wi;
double yh = y + h;
gc.strokePolyline(new double[] {xw, x, x, xw},
new double[] {y, y, yh, yh}, 4);
gc.strokePolyline(new double[] {0, w, w, 0},
new double[] {y, y, yh, yh}, 4);
}
}
return false;
}
/**
* Draws the map where bounding boxes will be overlay.
*/
private boolean drawMapBackground() {
final Image image = MetadataSummary.getWorldMap();
if (image == null) {
return false;
}
extentOnMap.setWidth (image.getWidth());
extentOnMap.setHeight(image.getHeight());
extentOnMap.getGraphicsContext2D().drawImage(image, 0, 0);
return true;
}
}