diff --git a/config/pmd-suppressions.properties b/config/pmd-suppressions.properties index a22f2263faba8628c510bb2b0f9cde2bb32e2e57..8f5ce8b2fb93589a2736f1b5043d69127e0ce32d 100644 --- a/config/pmd-suppressions.properties +++ b/config/pmd-suppressions.properties @@ -25,6 +25,7 @@ fr.agrometinfo.www.shared.dto.SummaryDTOBeanJsonDeserializerImpl=UnnecessaryImpo fr.agrometinfo.www.shared.dto.SummaryDTOBeanJsonSerializerImpl=UnnecessaryImport fr.agrometinfo.www.shared.dto.SummaryDTO_MapperImpl=UnnecessaryImport fr.agrometinfo.www.shared.service.ApplicationServiceFactory=UnnecessaryImport +fr.agrometinfo.www.shared.service.GeometryServiceFactory=UnnecessaryImport fr.agrometinfo.www.shared.service.IndicatorServiceFactory=UnnecessaryImport fr.agrometinfo.www.shared.service.SurveyFormServiceFactory=UnnecessaryImport fr.agrometinfo.www.shared.service.SurveyQuestionDTO_MapperImpl=UnnecessaryImport diff --git a/pom.xml b/pom.xml index b522238141af8ee204601d46c878f2b2ba0a1e6a..602cb75c0dd180966575f035f8ae3423752bac98 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ <modelVersion>4.0.0</modelVersion> <groupId>fr.agrometinfo</groupId> <artifactId>www</artifactId> - <version>2.0.1</version> + <version>2.0.2-SNAPSHOT</version> <packaging>pom</packaging> <name>AgroMetInfo web app</name> <description>Web application for AgroMetInfo.</description> diff --git a/www-client/pom.xml b/www-client/pom.xml index 2a510777411ea54976ec1d5a678edd6cd636cc83..9788bc5496809ec36260502d1ba4e97cc2166c94 100644 --- a/www-client/pom.xml +++ b/www-client/pom.xml @@ -7,7 +7,7 @@ <parent> <groupId>fr.agrometinfo</groupId> <artifactId>www</artifactId> - <version>2.0.1</version> + <version>2.0.2-SNAPSHOT</version> </parent> <artifactId>www-client</artifactId> diff --git a/www-client/src/main/java/fr/agrometinfo/www/client/i18n/MapMessages.java b/www-client/src/main/java/fr/agrometinfo/www/client/i18n/MapMessages.java new file mode 100644 index 0000000000000000000000000000000000000000..31e435a28b1427405ffc18183a8294353587298e --- /dev/null +++ b/www-client/src/main/java/fr/agrometinfo/www/client/i18n/MapMessages.java @@ -0,0 +1,25 @@ +package fr.agrometinfo.www.client.i18n; + +import java.util.Date; + +import com.google.gwt.i18n.client.Messages; + +/** + * Internationalization messages. + * + * @author Olivier Maury + */ +public interface MapMessages extends Messages { + + /** + * @param indicator indicator name + * @param value indicator value + * @param unit unit + * @param pra Petite région agricole + * @param startDate start date of period + * @param endDate date of last computation + * @return translation + */ + @DefaultMessage("{0}: {1,number} {2}<br/>PRA: {3}<br/>Period: {4,date,medium} − {5,date,medium}") + String popupContent(String indicator, Double value, String unit, String pra, Date startDate, Date endDate); +} diff --git a/www-client/src/main/java/fr/agrometinfo/www/client/presenter/LayoutPresenter.java b/www-client/src/main/java/fr/agrometinfo/www/client/presenter/LayoutPresenter.java index bde9471faf24eb0f18a51e5b4b91b8f1bd91435f..8f82944a02e1c59c488589a27a56bd429654c179 100644 --- a/www-client/src/main/java/fr/agrometinfo/www/client/presenter/LayoutPresenter.java +++ b/www-client/src/main/java/fr/agrometinfo/www/client/presenter/LayoutPresenter.java @@ -101,5 +101,6 @@ public final class LayoutPresenter implements Presenter { rightPanelPresenter.setLayoutView(view); rightPanelPresenter.start(); + GWT.log("LayoutPresenter.start() end"); } } diff --git a/www-client/src/main/java/fr/agrometinfo/www/client/presenter/MapPresenter.java b/www-client/src/main/java/fr/agrometinfo/www/client/presenter/MapPresenter.java index 58bb65c008598b41fadde55a36460da63ea4d32a..46bb0c246e482c45f9e7746b686f9290f35cba9e 100644 --- a/www-client/src/main/java/fr/agrometinfo/www/client/presenter/MapPresenter.java +++ b/www-client/src/main/java/fr/agrometinfo/www/client/presenter/MapPresenter.java @@ -2,6 +2,7 @@ package fr.agrometinfo.www.client.presenter; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.dominokit.domino.ui.utils.DominoElement; import org.dominokit.rest.JsRestfulRequestFactory; @@ -23,6 +24,7 @@ import fr.agrometinfo.www.client.view.MapView; import fr.agrometinfo.www.shared.dto.ChoiceDTO; import fr.agrometinfo.www.shared.dto.FeatureLevel; import fr.agrometinfo.www.shared.dto.IndicatorDTO; +import fr.agrometinfo.www.shared.service.GeometryServiceFactory; import fr.agrometinfo.www.shared.service.IndicatorService; import fr.agrometinfo.www.shared.service.IndicatorServiceFactory; @@ -47,6 +49,11 @@ public final class MapPresenter implements Presenter { */ void setGeoJson(String geoJSON, IndicatorDTO indicator); + /** + * @param values PRA code ⮕ PRA name. + */ + void setPraNames(Map<String, String> values); + /** * @param lines the title splitted in lines */ @@ -150,6 +157,9 @@ public final class MapPresenter implements Presenter { IndicatorServiceFactory.INSTANCE.getLastModification() .onSuccess(date -> lastModification = MSGS.computedOn(date)).send(); view = new MapView(container); + GeometryServiceFactory.INSTANCE.getPraAsJSON() // + .onSuccess(view::setPraNames) // + .send(); view.setPresenter(this); view.init(); } diff --git a/www-client/src/main/java/fr/agrometinfo/www/client/ui/map/CanvasWidget.java b/www-client/src/main/java/fr/agrometinfo/www/client/ui/map/CanvasWidget.java index e56dd761911681f293685693ac5e8fdadba943a4..ede6f9bc65510cfe950b452dbc84d0b844bb029e 100644 --- a/www-client/src/main/java/fr/agrometinfo/www/client/ui/map/CanvasWidget.java +++ b/www-client/src/main/java/fr/agrometinfo/www/client/ui/map/CanvasWidget.java @@ -3,6 +3,7 @@ package fr.agrometinfo.www.client.ui.map; import com.google.gwt.dom.client.CanvasElement; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.NodeList; +import com.google.gwt.user.client.Timer; import ol.Map; @@ -16,6 +17,10 @@ abstract class CanvasWidget { * The same font-family as in style.css. */ protected static final String FONT_FAMILY = "\"Roboto\", Arial, Tahoma, sans-serif"; + /** + * Delay (milliseconds) before drawing. + */ + private static final int DRAWING_TIMER_DELAY = 200; /** * OpenLayers map. @@ -27,6 +32,11 @@ abstract class CanvasWidget { */ private CanvasElement canvas; + /** + * Timer to handle draw and avoid multiple drawings on map changes. + */ + private Timer drawingTimer; + /** * Constructor. * @@ -39,8 +49,17 @@ abstract class CanvasWidget { // tiles have finished loading for the current viewport, and all tiles are faded // in. openLayersMap.on("rendercomplete", e -> { - canvas = null; - this.draw(); + if (drawingTimer != null) { + drawingTimer.cancel(); + } + drawingTimer = new Timer() { + @Override + public void run() { + CanvasWidget.this.canvas = null; + CanvasWidget.this.draw(); + } + }; + drawingTimer.schedule(DRAWING_TIMER_DELAY); }); } diff --git a/www-client/src/main/java/fr/agrometinfo/www/client/ui/map/ToolTip.java b/www-client/src/main/java/fr/agrometinfo/www/client/ui/map/ToolTip.java new file mode 100644 index 0000000000000000000000000000000000000000..7a00393cbb98033d12e7e7f74c00af408eef34ed --- /dev/null +++ b/www-client/src/main/java/fr/agrometinfo/www/client/ui/map/ToolTip.java @@ -0,0 +1,123 @@ +package fr.agrometinfo.www.client.ui.map; + +import com.google.gwt.dom.client.Element; +import com.google.gwt.user.client.DOM; + +import ol.Coordinate; +import ol.Extent; +import ol.Map; +import ol.Overlay; +import ol.OverlayOptions; +import ol.Pixel; +import ol.Size; + +/** + * Tooltip widget + * + * At bottom right of over PRA. + * + * @see http://openlayers.org/en/latest/examples/popup.html + * @author Olivier Maury + */ +public final class ToolTip { + /** + * DIV element for HTML content of tooltip, displayed in the overlay. + */ + private final Element content = DOM.createDiv(); + + /** + * If tooltip can be displayed. + */ + private boolean enabled = true; + + /** + * map where the tooltip will be added. + */ + private final Map olMap; + + /** + * Element displayed over the map and attached to a single map location, at + * bottom right of PRA. + */ + private final Overlay overlay; + + /** + * Constructor. + * + * @param map the map where the tooltip will be added. + */ + public ToolTip(final Map map) { + this.olMap = map; + // Create tooltip element in a new popup layer. + final OverlayOptions overlayOptions = new OverlayOptions(); + overlayOptions.setElement(content); + overlayOptions.setAutoPan(true); + overlayOptions.setId("toolTipOverlay"); + overlayOptions.setAutoPan(false); + overlay = new Overlay(overlayOptions); + olMap.addOverlay(overlay); + } + + public void disable() { + enabled = false; + } + + public void enable() { + enabled = true; + } + + /** + * Hide tooltip. + */ + public void hide() { + overlay.setPosition(null); + } + + /** + * + * @param message + */ + public void setContent(final String message) { + content.setInnerHTML(message); + } + + /** + * + * @param extent if null the popup is hidden + */ + public void setPosition(final Extent extent) { + if (enabled) { + final Coordinate position = new Coordinate(// + extent.getLowerLeftX() + extent.getWidth(), // + extent.getUpperRightY() - extent.getHeight()// + ); + final Pixel posPixel = olMap.getPixelFromCoordinate(position); + final int posX = posPixel.getX(); + final int posY = posPixel.getY(); + final Size mapSize = olMap.getSize(); + final int width = content.getClientWidth(); + final int height = content.getClientHeight(); + final String xClassName; + if (posX + width > mapSize.getWidth()) { + xClassName = "left"; + position.setX(extent.getLowerLeftX()); + } else { + xClassName = "right"; + } + final String yClassName; + if (posY + height > mapSize.getHeight()) { + yClassName = "top"; + position.setY(extent.getUpperRightY()); + } else { + yClassName = "bottom"; + } + content.setClassName("map-tooltip"); + content.addClassName(xClassName); + content.addClassName(yClassName); + + overlay.setPosition(position); + } else { + hide(); + } + } +} diff --git a/www-client/src/main/java/fr/agrometinfo/www/client/util/DateUtils.java b/www-client/src/main/java/fr/agrometinfo/www/client/util/DateUtils.java index 459d8b8c0bac957e69634910adf2ec95e36a22db..f49f56bd09c6b6f6513255d61f36302d9e7a5de9 100644 --- a/www-client/src/main/java/fr/agrometinfo/www/client/util/DateUtils.java +++ b/www-client/src/main/java/fr/agrometinfo/www/client/util/DateUtils.java @@ -10,6 +10,11 @@ import java.util.Date; * @author Olivier Maury */ public interface DateUtils { + /** + * ISO-8601 date format. + */ + String ISO_8601_DATE_FORMAT = "yyyy-MM-dd"; + /** * @return the current year. */ diff --git a/www-client/src/main/java/fr/agrometinfo/www/client/view/MapView.java b/www-client/src/main/java/fr/agrometinfo/www/client/view/MapView.java index 5c82b53e2eb6cc7f26ff28bbb8d5f58a94fde68c..9bb1d36561af407ef2fbd809e1d49e3b6ac6f267 100644 --- a/www-client/src/main/java/fr/agrometinfo/www/client/view/MapView.java +++ b/www-client/src/main/java/fr/agrometinfo/www/client/view/MapView.java @@ -1,6 +1,8 @@ package fr.agrometinfo.www.client.view; import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -12,6 +14,7 @@ import org.jboss.elemento.HtmlContentBuilder; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.JavaScriptObject; import com.google.gwt.core.client.JsonUtils; +import com.google.gwt.i18n.client.DateTimeFormat; import elemental2.dom.HTMLDivElement; import elemental2.dom.HTMLElement; @@ -20,16 +23,21 @@ import fr.agrometinfo.www.client.event.FeatureSelectEvent; import fr.agrometinfo.www.client.event.FeatureSelectHandler; import fr.agrometinfo.www.client.event.MapClickEvent; import fr.agrometinfo.www.client.i18n.MapConstants; +import fr.agrometinfo.www.client.i18n.MapMessages; import fr.agrometinfo.www.client.presenter.MapPresenter; import fr.agrometinfo.www.client.ui.map.CanvasAttributions; import fr.agrometinfo.www.client.ui.map.CanvasTitle; import fr.agrometinfo.www.client.ui.map.ControlSuppliers; import fr.agrometinfo.www.client.ui.map.TileSuppliers; +import fr.agrometinfo.www.client.ui.map.ToolTip; import fr.agrometinfo.www.client.util.ApplicationUtils; +import fr.agrometinfo.www.client.util.DateUtils; import fr.agrometinfo.www.client.util.color.ColorInterval; import fr.agrometinfo.www.client.util.color.ColorSequenceManager; import fr.agrometinfo.www.shared.dto.FeatureLevel; +import fr.agrometinfo.www.shared.dto.FeatureProperty; import fr.agrometinfo.www.shared.dto.IndicatorDTO; +import jsinterop.base.Any; import ol.Collection; import ol.Coordinate; import ol.Extent; @@ -40,6 +48,7 @@ import ol.OLFactory; import ol.View; import ol.ViewOptions; import ol.color.Color; +import ol.event.EventListener; import ol.events.condition.Condition; import ol.format.GeoJson; import ol.format.GeoJsonFeatureOptions; @@ -97,6 +106,11 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe */ public static final String MAP_CONTAINER_ID = "mapContainer"; + /** + * I18N messages. + */ + private static final MapMessages MSGS = GWT.create(MapMessages.class); + /** * z-index for overlays. */ @@ -191,21 +205,75 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe return vectorLayer; } - private static double getValue(final Feature f) { - return Double.parseDouble(f.getProperties().getAsAny("value").asString()); + /** + * @param feature feature + * @param property feature property + * @return string representation of a feature property + */ + private static String getProperty(final Feature feature, final FeatureProperty property) { + final String propertyName = property.toCamelCase(); + if (feature.getProperties().has(propertyName)) { + return feature.getProperties().get(propertyName).toString(); + } + return ""; } - private static Feature[] parseGeoJson(final String geoJson) { - GWT.log("MapView.parseGeoJson()"); - final JavaScriptObject dataGeoJson = JsonUtils.unsafeEval(geoJson); - // create Feature and after geojson layer (Vector layer) - final GeoJson geoJsonFormat = OLFactory.createGeoJSON(); + /** + * @param feature feature + * @param property feature property + * @return string representation of a feature property + */ + private static String getProperty(final org.geojson.Feature feature, final FeatureProperty property) { + return feature.getProperties().getOrDefault(property.toCamelCase(), ""); + } - final ProjectionOptions projOpt = new ProjectionOptions(); - projOpt.setCode(EPSG_3857); - final GeoJsonFeatureOptions geoJsonFeatureOptions = new GeoJsonFeatureOptions(); - geoJsonFeatureOptions.setFeatureProjection(new Projection(projOpt)); - return geoJsonFormat.readFeatures(dataGeoJson, geoJsonFeatureOptions); + /** + * @param feature feature + * @param property feature property + * @return date representation of a feature property + */ + private static Date getPropertyAsDate(final Feature feature, final FeatureProperty property) { + final String str = getProperty(feature, property); + if (str == null) { + return null; + } + final DateTimeFormat dtf = DateTimeFormat.getFormat(DateUtils.ISO_8601_DATE_FORMAT); + return dtf.parse(str); + } + + /** + * @param feature feature + * @param property feature property + * @return date representation of a feature property + */ + private static Date getPropertyAsDate(final org.geojson.Feature feature, final FeatureProperty property) { + final String str = getProperty(feature, property); + if (str == null) { + return null; + } + final DateTimeFormat dtf = DateTimeFormat.getFormat(DateUtils.ISO_8601_DATE_FORMAT); + return dtf.parse(str); + } + + /** + * @param feature feature + * @param property feature property + * @return double representation of a feature property + */ + private static Double getPropertyAsDouble(final Feature feature, final FeatureProperty property) { + final String str = getProperty(feature, property); + if (str == null) { + return null; + } + return Double.valueOf(str); + } + + private static double getValue(final Feature f) { + final Any value = f.getProperties().getAsAny(FeatureProperty.VALUE.toCamelCase()); + if (value == null || value.asString() == null) { + return 0d; + } + return Double.parseDouble(value.asString()); } /** @@ -227,6 +295,21 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe */ private List<ColorInterval> colorIntervals; + /** + * Name of selected indicator. + */ + private String indicatorName; + + /** + * Start date of selected period. + */ + private Date periodStartDate; + + /** + * Unit of selected indicator. + */ + private String indicatorUnit; + /** * map. */ @@ -237,6 +320,11 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe */ private MapPresenter presenter; + /** + * Tooltip displayed over PRA. + */ + private ToolTip tooltip; + /** * Layer with cells. */ @@ -252,6 +340,11 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe */ private String selectedFeatureId = null; + /** + * PRA code ⮕ PRA name. + */ + private final java.util.Map<String, String> praNames = new HashMap<>(); + /** * Constructor. * @@ -278,6 +371,43 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe map.on("singleclick", event -> App.getEventBus().fireEvent(MapClickEvent.of())); } + /** + * Mouse move interaction to display values in a popup. + */ + private void addMouseMoveInteraction() { + final SelectOptions selectOptions = new SelectOptions(); + selectOptions.setCondition(Condition.getPointerMove()); + final Select featureSelect = new Select(selectOptions); + map.addInteraction(featureSelect); + + tooltip = new ToolTip(map); + + final EventListener<Select.Event> selectListener = event -> { + if (featureSelect.getFeatures() != null // + && !featureSelect.getFeatures().isEmpty() // + && featureSelect.getFeatures().item(0) != null) { + + final Feature feature = featureSelect.getFeatures().item(0); + final Date date = getPropertyAsDate(feature, FeatureProperty.DATE); + final Double value = getPropertyAsDouble(feature, FeatureProperty.VALUE); + final String praName = praNames.getOrDefault(feature.getId(), feature.getId() + "/" + praNames.size()); + final String content = MSGS.popupContent(indicatorName, value, indicatorUnit, praName, periodStartDate, + date); + + final Extent extent = feature.getGeometry().getExtent(); + tooltip.setPosition(extent); + tooltip.setContent(content); + } else { + tooltip.hide(); + } + + }; + + featureSelect.on("select", selectListener); + map.on("movestart", e -> tooltip.disable()); + map.on("moveend", e -> tooltip.enable()); + } + private Feature[] colorizeFeatures(final Feature[] features) { GWT.log("MapView.colorizeFeatures()"); for (final Feature f : features) { @@ -302,6 +432,7 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe * @return style */ private Style[] createCellStyleForSelection(final Feature feature) { + GWT.log("MapView.createCellStyleForSelection()"); final int strokeWidth = 3; final Double value = getValue(feature); final String color = "#" + ColorSequenceManager.getColorForValue(colorIntervals, value); @@ -368,6 +499,7 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe // add some interactions removeContextMenuRightClick(); addClickInteractions(); + addMouseMoveInteraction(); App.getEventBus().addHandler(FeatureSelectEvent.TYPE, this); } @@ -390,6 +522,33 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe } } + private Feature[] parseGeoJson(final String geoJson) { + GWT.log("MapView.parseGeoJson()"); + final JavaScriptObject dataGeoJson = JsonUtils.unsafeEval(geoJson); + // create Feature and after geojson layer (Vector layer) + final GeoJson geoJsonFormat = OLFactory.createGeoJSON(); + + final ProjectionOptions projOpt = new ProjectionOptions(); + projOpt.setCode(EPSG_3857); + final GeoJsonFeatureOptions geoJsonFeatureOptions = new GeoJsonFeatureOptions(); + geoJsonFeatureOptions.setFeatureProjection(new Projection(projOpt)); + final Feature[] allFeatures = geoJsonFormat.readFeatures(dataGeoJson, geoJsonFeatureOptions); + final Feature propertiesFeature = allFeatures[0]; + if (propertiesFeature.getId() == null) { + indicatorName = getProperty(propertiesFeature, FeatureProperty.INDICATOR_NAME); + indicatorUnit = getProperty(propertiesFeature, FeatureProperty.INDICATOR_UNIT); + periodStartDate = getPropertyAsDate(propertiesFeature, FeatureProperty.PERIOD_FIRST_DAY); + + final Feature[] filtered = new Feature[allFeatures.length - 1]; + for (int i = 1; i < allFeatures.length; i++) { + filtered[i - 1] = allFeatures[i]; + } + return filtered; + } else { + return allFeatures; + } + } + /** * Remove context menu on right click on the map. */ @@ -399,12 +558,19 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe @Override public void setFeatureCollection(final FeatureCollection collection) { + GWT.log("MapView.setFeatureCollection()"); final List<org.geojson.Feature> list = collection.getFeatures(); + final org.geojson.Feature fakeFeature = list.remove(0); + indicatorName = getProperty(fakeFeature, FeatureProperty.INDICATOR_NAME); + indicatorUnit = getProperty(fakeFeature, FeatureProperty.INDICATOR_UNIT); + periodStartDate = getPropertyAsDate(fakeFeature, FeatureProperty.PERIOD_FIRST_DAY); final Feature[] features = list.toArray(new Feature[list.size()]); setFeatures(features); } + private void setFeatures(final Feature[] features) { GWT.log("MapView.setFeatures()"); + tooltip.hide(); if (vectorLayer != null) { map.removeLayer(vectorLayer); } @@ -451,13 +617,22 @@ public final class MapView extends HtmlContentBuilder<HTMLElement> implements Fe setFeatures(features); } + @Override + public void setPraNames(final java.util.Map<String, String> values) { + GWT.log("MapView.setPraNames() " + values.size()); + praNames.clear(); + praNames.putAll(values); + } + @Override public void setPresenter(final MapPresenter p) { + GWT.log("MapView.setPresenter()"); presenter = p; } @Override public void setTitle(final List<String> lines) { + GWT.log("MapView.setTitle()"); canvasTitle.setTitle(lines, colorIntervals); } diff --git a/www-client/src/main/resources/fr/agrometinfo/www/client/i18n/MapMessages_fr.properties b/www-client/src/main/resources/fr/agrometinfo/www/client/i18n/MapMessages_fr.properties new file mode 100644 index 0000000000000000000000000000000000000000..0fb4df9ee9fbc886b6226dc31a40f8a04e1f5e5a --- /dev/null +++ b/www-client/src/main/resources/fr/agrometinfo/www/client/i18n/MapMessages_fr.properties @@ -0,0 +1,2 @@ +# Ce fichier est encodé en UTF-8. +popupContent={0}\u00a0: {1,number}\u00a0{2}<br/>PRA\u00a0: {3}<br/>Période\u00a0: {4,date,medium} − {5,date,medium} diff --git a/www-client/src/main/resources/fr/agrometinfo/www/client/public/style.css b/www-client/src/main/resources/fr/agrometinfo/www/client/public/style.css index 7e88ddb8240adcc42c8c27b8ccd2e0b1b9b38087..6fd7ba6f463695871a09ac3708b9db1c7dea99bb 100644 --- a/www-client/src/main/resources/fr/agrometinfo/www/client/public/style.css +++ b/www-client/src/main/resources/fr/agrometinfo/www/client/public/style.css @@ -306,9 +306,43 @@ body > .modal-backdrop { top: auto; bottom: 2em; } + +.map-tooltip { + background-color: rgba(255, 255, 255, 0.7); + border: 1px solid #818181; + color: #1b1b1b; + font-family: "Roboto",sans-serif; + height: var(--map-tooltip-height); + padding: 0.5em; + width: var(--map-tooltip-width); +} +.map-tooltip.left { + transform: translate(calc(-1 * var(--map-tooltip-width)), 0); +} +.map-tooltip.top { + transform: translate(0, calc(-1 * var(--map-tooltip-height))); +} +.map-tooltip.left.top { + transform: translate(calc(-1 * var(--map-tooltip-width)), calc(-1 * var(--map-tooltip-height))); +} +.map-tooltip.bottom.left { + border-radius: 1em 0 1em 1em; +} +.map-tooltip.bottom.right { + border-radius: 0 1em 1em 1em; +} +.map-tooltip.top.left { + border-radius: 1em 1em 0 1em; +} +.map-tooltip.top.right { + border-radius: 1em 1em 1em 0; +} + :root { --footer-logo-height: 32px; --logo-height: 50px; + --map-tooltip-height: 8em; + --map-tooltip-width: 22em; } @media screen and (max-width: 380px) { .navbar-brand > .version { diff --git a/www-server/pom.xml b/www-server/pom.xml index 3feb066b99e4b934bb9233267a6290b7e413ee82..617c3f10710583d8428ad36eb99a41a5ae8ce709 100644 --- a/www-server/pom.xml +++ b/www-server/pom.xml @@ -7,7 +7,7 @@ <parent> <groupId>fr.agrometinfo</groupId> <artifactId>www</artifactId> - <version>2.0.1</version> + <version>2.0.2-SNAPSHOT</version> </parent> <artifactId>www-server</artifactId> diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/rs/GeometryResource.java b/www-server/src/main/java/fr/agrometinfo/www/server/rs/GeometryResource.java index e72872240258a149c20cc098437728eb4a1152fa..8332567afe53b5f1d92097e9c81295c944fe1c2e 100644 --- a/www-server/src/main/java/fr/agrometinfo/www/server/rs/GeometryResource.java +++ b/www-server/src/main/java/fr/agrometinfo/www/server/rs/GeometryResource.java @@ -1,36 +1,35 @@ package fr.agrometinfo.www.server.rs; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.geojson.Feature; import org.geojson.FeatureCollection; import fr.agrometinfo.www.server.dao.PraDao; import fr.agrometinfo.www.server.model.Pra; +import fr.agrometinfo.www.server.service.CacheService; import fr.agrometinfo.www.server.util.GeometryUtils; +import fr.agrometinfo.www.shared.service.GeometryService; import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Request; /** * Resource to expose geometries. * * @author Olivier Maury */ -@Path(GeometryResource.PATH) +@Path(GeometryService.PATH) @RequestScoped -public class GeometryResource { - /** - * Service base path. - */ - public static final String PATH = "geometry"; - - /** - * Path for PRA geometries. - */ - public static final String PATH_PRA = "pra"; +public class GeometryResource implements GeometryService { private static Feature toFeature(final Pra pra) { final Feature feature = GeometryUtils.toFeature(pra); @@ -39,19 +38,37 @@ public class GeometryResource { return feature; } + /** + * Cache service for server-side and browser-side. + */ + @Inject + private CacheService cacheService; + /** * DAO for {@link Pra}. */ @Inject private PraDao praDao; + /** + * JAX-RS request. + */ + @Context + private Request request; + + /** + * HTTP headers for response. + */ + @Context + private HttpHeaders httpHeaders; + /** * @return Geometry of PRA with name and code */ @GET - @Path(PATH_PRA) + @Path(GeometryService.PATH_PRA) @Produces("application/geo+json") - public FeatureCollection getPra() { + public FeatureCollection getPraAsGeoJSON() { final List<Pra> values = praDao.findAll(); final FeatureCollection collection = new FeatureCollection(); values.stream()// @@ -59,4 +76,31 @@ public class GeometryResource { .forEach(collection::add); return collection; } + + /** + * @return PRA code ⮕ PRA name + */ + @SuppressWarnings("unchecked") + @Override + @GET + @Path(GeometryService.PATH_PRA_NAMES) + @Produces(MediaType.APPLICATION_JSON) + public Map<String, String> getPraAsJSON() { + // HTTP cache headers + cacheService.setCacheKey(GeometryService.PATH, GeometryService.PATH_PRA); + cacheService.setHeaders(httpHeaders); + if (!cacheService.needsResponse(request)) { + return Map.of(); + } + // cached response + if (cacheService.isCached()) { + return (Map<String, String>) cacheService.getCache(); + } + // + final List<Pra> values = praDao.findAll(); + final var response = values.stream()// + .collect(Collectors.toMap(Pra::getCode, Pra::getName)); + cacheService.setCache(response); + return response; + } } diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java b/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java index 9c66f37d9c53db2f13092260628272c5fd2011ee..6ca575c9873f2dba0524db738480c19a19c7f2d2 100644 --- a/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java +++ b/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java @@ -14,6 +14,7 @@ import java.util.TreeMap; import org.geojson.Feature; import org.geojson.FeatureCollection; +import org.geojson.MultiPolygon; import fr.agrometinfo.www.server.dao.IndicatorDao; import fr.agrometinfo.www.server.dao.PraDailyValueDao; @@ -29,6 +30,7 @@ import fr.agrometinfo.www.server.util.LocaleUtils; import fr.agrometinfo.www.shared.dto.ChoiceDTO; import fr.agrometinfo.www.shared.dto.ErrorResponseDTO; import fr.agrometinfo.www.shared.dto.FeatureLevel; +import fr.agrometinfo.www.shared.dto.FeatureProperty; import fr.agrometinfo.www.shared.dto.IndicatorDTO; import fr.agrometinfo.www.shared.dto.PeriodDTO; import fr.agrometinfo.www.shared.dto.SimpleFeature; @@ -171,6 +173,26 @@ public class IndicatorResource implements IndicatorService { @Context private HttpHeaders httpHeaders; + /** + * @param collection collection to annotate + * @param indicator indicator with metadata to add + * @param year to get the first day of indicator period + * @param locale locale of descriptions + */ + private void addMetadata(final FeatureCollection collection, final Indicator indicator, + final Integer year, final Locale locale) { + final var periodFirstDay = getDate(year, indicator.getPeriod().getFirstDay()); + final Feature feature = new Feature(); + final MultiPolygon multiPolygon = new MultiPolygon(); + feature.setGeometry(multiPolygon); + feature.setProperty(FeatureProperty.INDICATOR_NAME.toCamelCase(), + getTranslation(indicator.getDescriptions(), locale)); + feature.setProperty(FeatureProperty.INDICATOR_UNIT.toCamelCase(), indicator.getUnit()); + feature.setProperty(FeatureProperty.PERIOD_FIRST_DAY.toCamelCase(), + DateUtils.toIso8601Date(periodFirstDay)); + collection.add(feature); + } + /** * Ensure the value of query parameter is not null and not blank. * @@ -313,47 +335,47 @@ public class IndicatorResource implements IndicatorService { final SimpleFeature parentFeature; final String featureName; switch (level) { - case REGION -> { - Integer regionId; - final Region region; - try { - regionId = Integer.valueOf(id); - } catch (final NumberFormatException e) { - regionId = null; - } - if (regionId != null) { - region = regionDao.find(regionId); - } else { - region = null; - } - if (region == null) { - // Metropolitan France - featureName = null; - averageValue = praDailyValueDao.findAverageComputedValue(indicator, date); - comparedValue = praDailyValueDao.findAverageComparedValue(indicator, date); - tmpDailyValues = praDailyValueDao.findDailyValues(indicator, firstDay, lastDay); - parentFeature = null; - } else { - averageValue = praDailyValueDao.findAverageComputedValue(indicator, date, regionId); - comparedValue = praDailyValueDao.findAverageComparedValue(indicator, date, regionId); - tmpDailyValues = praDailyValueDao.findDailyValues(indicator, firstDay, lastDay, region); - featureName = region.getName(); - parentFeature = null; - } + case REGION -> { + Integer regionId; + final Region region; + try { + regionId = Integer.valueOf(id); + } catch (final NumberFormatException e) { + regionId = null; + } + if (regionId != null) { + region = regionDao.find(regionId); + } else { + region = null; } - case PRA -> { - final Pra pra = praDao.findByCode(id); - final PraDailyValue value = praDailyValueDao.find(indicator, date, pra); - averageValue = value.getComputedValue().doubleValue(); - comparedValue = value.getComparedValue().doubleValue(); - tmpDailyValues = praDailyValueDao.findDailyValues(indicator, firstDay, lastDay, pra); - featureName = pra.getName(); - parentFeature = new SimpleFeature(); - parentFeature.setId(String.valueOf(pra.getDepartment().getRegion().getId())); - parentFeature.setLevel(FeatureLevel.REGION); - parentFeature.setName(pra.getDepartment().getRegion().getName()); + if (region == null) { + // Metropolitan France + featureName = null; + averageValue = praDailyValueDao.findAverageComputedValue(indicator, date); + comparedValue = praDailyValueDao.findAverageComparedValue(indicator, date); + tmpDailyValues = praDailyValueDao.findDailyValues(indicator, firstDay, lastDay); + parentFeature = null; + } else { + averageValue = praDailyValueDao.findAverageComputedValue(indicator, date, regionId); + comparedValue = praDailyValueDao.findAverageComparedValue(indicator, date, regionId); + tmpDailyValues = praDailyValueDao.findDailyValues(indicator, firstDay, lastDay, region); + featureName = region.getName(); + parentFeature = null; } - default -> throw new UnsupportedOperationException("Level not handled: " + level); + } + case PRA -> { + final Pra pra = praDao.findByCode(id); + final PraDailyValue value = praDailyValueDao.find(indicator, date, pra); + averageValue = value.getComputedValue().doubleValue(); + comparedValue = value.getComparedValue().doubleValue(); + tmpDailyValues = praDailyValueDao.findDailyValues(indicator, firstDay, lastDay, pra); + featureName = pra.getName(); + parentFeature = new SimpleFeature(); + parentFeature.setId(String.valueOf(pra.getDepartment().getRegion().getId())); + parentFeature.setLevel(FeatureLevel.REGION); + parentFeature.setName(pra.getDepartment().getRegion().getName()); + } + default -> throw new UnsupportedOperationException("Level not handled: " + level); } tmpDailyValues.forEach((d, v) -> dailyValues.put(DateUtils.toDate(d), v)); @@ -394,9 +416,10 @@ public class IndicatorResource implements IndicatorService { checkRequired(indicatorUid, "indicator"); checkRequired(periodCode, "period"); checkRequired(year, "year"); - // HTTP cache headers + final var locale = LocaleUtils.getLocale(httpServletRequest); cacheService.setCacheKey(IndicatorService.PATH, IndicatorService.PATH_VALUES, indicatorUid, periodCode, - regionId, year, comparison); + regionId, year, comparison, locale); + // HTTP cache headers cacheService.setHeaders(httpHeaders); if (!cacheService.needsResponse(request)) { return null; @@ -408,6 +431,7 @@ public class IndicatorResource implements IndicatorService { // final FeatureCollection collection = new FeatureCollection(); final Indicator indicator = indicatorDao.findByCodeAndPeriod(indicatorUid, periodCode); + addMetadata(collection, indicator, year, locale); final LocalDate date = praDailyValueDao.findLastDate(indicator, year); final Region region; if (regionId == null) { diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/util/DateUtils.java b/www-server/src/main/java/fr/agrometinfo/www/server/util/DateUtils.java index 39153e6bbae0c8796f60dbe6499ce3be2f980913..c7c35af4085a284b79ffb31ea13fc8e40f3e70fa 100644 --- a/www-server/src/main/java/fr/agrometinfo/www/server/util/DateUtils.java +++ b/www-server/src/main/java/fr/agrometinfo/www/server/util/DateUtils.java @@ -2,6 +2,8 @@ package fr.agrometinfo.www.server.util; import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.Date; import lombok.NonNull; @@ -12,6 +14,11 @@ import lombok.NonNull; * @author Olivier Maury */ public interface DateUtils { + /** + * ISO-8601 date format. + */ + String ISO_8601_DATE_FORMAT = "yyyy-MM-dd"; + /** * @param localDate LocalDate to convert * @return converted Date @@ -20,6 +27,16 @@ public interface DateUtils { return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant()); } + /** + * @param localDate LocalDate to format + * @return formatted date as ISO 8601 + * @see https://en.wikipedia.org/wiki/ISO_8601 + */ + static String toIso8601Date(@NonNull final LocalDate localDate) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(ISO_8601_DATE_FORMAT); + return localDate.atStartOfDay().atOffset(ZoneOffset.UTC).format(formatter); + } + /** * @param date Date * @return Java8 LocalDate diff --git a/www-server/src/main/resources/log4j2.xml b/www-server/src/main/resources/log4j2.xml index eca62907518f74ac1dc6321212feed0ac1af04a2..15540a5b8e7ab55f291c88c76c3fc140ac259ab6 100644 --- a/www-server/src/main/resources/log4j2.xml +++ b/www-server/src/main/resources/log4j2.xml @@ -29,6 +29,7 @@ <AppenderRef ref="file" level="trace" /> </Root> <Logger name="fr.agrometinfo" level="trace" /> + <Logger name="fr.agrometinfo.www.server.dao" level="info" /> <Logger name="org.hibernate" level="warn" /> <Logger name="org.jboss" level="warn" /> </Loggers> diff --git a/www-server/src/test/java/fr/agrometinfo/www/server/rs/GeometryResourceTest.java b/www-server/src/test/java/fr/agrometinfo/www/server/rs/GeometryResourceTest.java index 36082e13736038c5e76773f6675644f0c9575ec7..4759b00604777e6eeb867235dc7a2f7379442b36 100644 --- a/www-server/src/test/java/fr/agrometinfo/www/server/rs/GeometryResourceTest.java +++ b/www-server/src/test/java/fr/agrometinfo/www/server/rs/GeometryResourceTest.java @@ -3,15 +3,24 @@ package fr.agrometinfo.www.server.rs; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + import org.geojson.FeatureCollection; import org.glassfish.hk2.utilities.binding.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.test.JerseyTest; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import fr.agrometinfo.www.server.dao.PraDao; import fr.agrometinfo.www.server.dao.PraDaoHibernate; import fr.agrometinfo.www.server.dao.PraDaoHibernateTest; +import fr.agrometinfo.www.server.dao.SimulationDaoHibernateTest; +import fr.agrometinfo.www.server.service.CacheService; import jakarta.ws.rs.core.Application; /** @@ -24,13 +33,27 @@ public class GeometryResourceTest extends JerseyTest { * Path separator. */ private static final String SEP = "/"; + /** + * Temporary directory for cache. + */ + private static Path cacheDir; + + @BeforeAll + static void createCacheDir() throws IOException { + cacheDir = Files.createTempDirectory(IndicatorResourceTest.class.getName()); + } + @Override protected final Application configure() { + final CacheService cacheService = new CacheService(); + cacheService.setLastModification(SimulationDaoHibernateTest.LAST_MODIFICATION); + cacheService.setCacheDirectory(cacheDir.toString()); final PraDao praDao = new PraDaoHibernate(); return new ResourceConfig(GeometryResource.class).register(new AbstractBinder() { @Override public void configure() { + bind(cacheService).to(CacheService.class); bind(praDao).to(PraDao.class); } }); diff --git a/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java b/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java index 6c56b7df2f6fba5d59dbf7e5889d65f6229940e1..ffa0ece7c19df0578542c57a8dbb95a54b83e3a2 100644 --- a/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java +++ b/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java @@ -121,9 +121,10 @@ class IndicatorResourceTest extends JerseyTest { .request()// .get(FeatureCollection.class); assertNotNull(actual); - final Integer expected = 1; + // first feature is an empty feature with metadata properties + final Integer expected = 2; assertEquals(expected, actual.getFeatures().size()); - assertEquals("59325", actual.getFeatures().get(0).getId()); - assertEquals("2023-01-04", actual.getFeatures().get(0).getProperty("date")); + assertEquals("59325", actual.getFeatures().get(1).getId()); + assertEquals("2023-01-04", actual.getFeatures().get(1).getProperty("date")); } } diff --git a/www-shared/pom.xml b/www-shared/pom.xml index ad792519e0b82c73a4e66dd76f7065164f6ffeda..0cbdbc9b475bd2afcba28890c87f43845390af4c 100644 --- a/www-shared/pom.xml +++ b/www-shared/pom.xml @@ -7,7 +7,7 @@ <parent> <groupId>fr.agrometinfo</groupId> <artifactId>www</artifactId> - <version>2.0.1</version> + <version>2.0.2-SNAPSHOT</version> </parent> <artifactId>www-shared</artifactId> diff --git a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/FeatureProperty.java b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/FeatureProperty.java new file mode 100644 index 0000000000000000000000000000000000000000..04ea6ca294365df5db95bdff597d3f53a5e5e599 --- /dev/null +++ b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/FeatureProperty.java @@ -0,0 +1,41 @@ +package fr.agrometinfo.www.shared.dto; + +/** + * Keys for feature properties, some of them are used in the first feature as + * metadata for FeatureCollection. + * + * @author Olivier Maury + */ +public enum FeatureProperty { + /** + * Feature property. + */ + DATE, + /** + * Feature property. + */ + VALUE, + /** + * Collection property. + */ + INDICATOR_NAME, + /** + * Collection property. + */ + INDICATOR_UNIT, + /** + * Collection property. + */ + PERIOD_FIRST_DAY; + + /** + * @return enum name as camel case format + */ + public String toCamelCase() { + String str = name().toLowerCase(); + while (str.contains("_")) { + str = str.replaceFirst("_[a-z]", String.valueOf(Character.toUpperCase(str.charAt(str.indexOf("_") + 1)))); + } + return str; + } +} diff --git a/www-shared/src/main/java/fr/agrometinfo/www/shared/service/GeometryService.java b/www-shared/src/main/java/fr/agrometinfo/www/shared/service/GeometryService.java new file mode 100644 index 0000000000000000000000000000000000000000..3c90c4a6159e1bf21480406986bca91c100d4481 --- /dev/null +++ b/www-shared/src/main/java/fr/agrometinfo/www/shared/service/GeometryService.java @@ -0,0 +1,42 @@ +package fr.agrometinfo.www.shared.service; + +import java.util.Map; + +import org.dominokit.rest.shared.request.service.annotations.RequestFactory; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +/** + * Interface for client and server resource. + * + * @author Olivier Maury + */ +@RequestFactory +@Path(GeometryService.PATH) +public interface GeometryService { + /** + * Service base path. + */ + String PATH = "geometry"; + + /** + * Path for PRA geometries. + */ + String PATH_PRA = "pra"; + + /** + * Path for PRA names. + */ + String PATH_PRA_NAMES = "pra_names"; + + /** + * @return PRA id ⮕ PRA name + */ + @GET + @Path(GeometryService.PATH_PRA_NAMES) + @Produces(MediaType.APPLICATION_JSON) + Map<String, String> getPraAsJSON(); +} diff --git a/www-shared/src/test/java/fr/agrometinfo/www/shared/dto/FeaturePropertyTest.java b/www-shared/src/test/java/fr/agrometinfo/www/shared/dto/FeaturePropertyTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e6130cbd54d4c77624b79a0a5a49391149a69ba3 --- /dev/null +++ b/www-shared/src/test/java/fr/agrometinfo/www/shared/dto/FeaturePropertyTest.java @@ -0,0 +1,27 @@ +package fr.agrometinfo.www.shared.dto; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Test methods of {@link FeatureProperty}. + * + * @author Olivier Maury + */ +class FeaturePropertyTest { + + /** + * Test camel case formatting. + */ + @Test + void toCamelCase() { + var actual = FeatureProperty.INDICATOR_NAME.toCamelCase(); + var expected = "indicatorName"; + assertEquals(expected, actual); + + actual = FeatureProperty.PERIOD_FIRST_DAY.toCamelCase(); + expected = "periodFirstDay"; + assertEquals(expected, actual); + } +}