| /* |
| * 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.coverage; |
| |
| import java.util.Map; |
| import java.util.EnumMap; |
| import java.util.Objects; |
| import java.util.Optional; |
| import java.awt.image.RenderedImage; |
| import javafx.application.Platform; |
| import javafx.beans.DefaultProperty; |
| import javafx.scene.control.Control; |
| import javafx.scene.control.SplitPane; |
| import javafx.scene.control.Separator; |
| import javafx.scene.control.ToggleGroup; |
| import javafx.scene.control.Toggle; |
| import javafx.scene.control.TitledPane; |
| import javafx.scene.layout.Region; |
| import javafx.event.ActionEvent; |
| import javafx.beans.property.ObjectProperty; |
| import javafx.beans.property.SimpleObjectProperty; |
| import org.apache.sis.storage.DataStoreException; |
| import org.apache.sis.storage.GridCoverageResource; |
| import org.apache.sis.coverage.grid.GridCoverage; |
| import org.apache.sis.gui.Widget; |
| import org.apache.sis.gui.internal.FontGIS; |
| import org.apache.sis.gui.internal.DataStoreOpener; |
| import org.apache.sis.gui.internal.Resources; |
| import org.apache.sis.gui.internal.ToolbarButton; |
| import org.apache.sis.gui.internal.NonNullObjectProperty; |
| import org.apache.sis.gui.internal.PrivateAccess; |
| import org.apache.sis.portrayal.RenderException; |
| import org.apache.sis.gui.referencing.RecentReferenceSystems; |
| import org.apache.sis.gui.dataset.WindowHandler; |
| import org.apache.sis.gui.map.StatusBar; |
| |
| |
| /** |
| * An image or tabular view of {@link GridCoverage} together with controls for band selection and other operations. |
| * The class contains two properties: |
| * |
| * <ul> |
| * <li>A {@link GridCoverage} supplied by user. |
| * May be specified indirectly with an {@link ImageRequest} for loading the coverage.</li> |
| * <li>A {@link View} type which specify how to show the coverage: |
| * <ul> |
| * <li>using {@link GridView} for showing numerical values in a table, or</li> |
| * <li>using {@link CoverageCanvas} for showing the coverage as an image.</li> |
| * </ul> |
| * </li> |
| * </ul> |
| * |
| * Controls are provided for allowing user to customize map projection, number formats, <i>etc.</i>. |
| * The set of control depends on the view type. |
| * |
| * <h2>Limitations</h2> |
| * Current implementation is restricted to {@link GridCoverage} instances, but a future |
| * implementation may generalize to {@link org.opengis.coverage.Coverage} instances. |
| * |
| * @author Martin Desruisseaux (Geomatys) |
| * @version 1.5 |
| * |
| * @see CoverageCanvas |
| * @see GridView |
| * |
| * @since 1.1 |
| */ |
| @DefaultProperty("coverage") |
| public class CoverageExplorer extends Widget { |
| /** |
| * Type of view shown in the explorer. |
| * It may be either an image or a table of numerical values. |
| * |
| * @see #viewTypeProperty |
| */ |
| public enum View { |
| // Enumeration order is the order in which buttons will appear on the button bar. |
| |
| /** |
| * Shows the coverage visual as an image. This view uses {@link CoverageCanvas}. |
| */ |
| IMAGE(FontGIS.Code.WORLD_MAP_ALT, "\uD83D\uDDFA\uFE0F", Resources.Keys.TabularData), // 🗺 — World map. |
| |
| /** |
| * Shows the coverage numerical value in a table. This view uses {@link GridView}. |
| */ |
| TABLE(FontGIS.Code.GRID, "\uD83D\uDD22\uFE0F", Resources.Keys.Visualize); // 🔢 — Input symbol for numbers. |
| |
| /** |
| * Number of enumeration values. |
| */ |
| static final int COUNT = 2; |
| |
| /** |
| * The Unicode character to use as icon, as a {@link FontGIS} code. |
| */ |
| final char icon; |
| |
| /** |
| * The fallback text if the icon cannot be shown. |
| */ |
| final String fallback; |
| |
| /** |
| * Key from {@link Resources} bundle for the localized text to use as tooltip. |
| */ |
| final short tooltip; |
| |
| /** |
| * Creates a new enumeration value. |
| */ |
| private View(final char icon, final String fallback, short tooltip) { |
| this.icon = icon; |
| this.fallback = fallback; |
| this.tooltip = tooltip; |
| } |
| } |
| |
| /** |
| * The type of view (image or tabular data) shown in this explorer. |
| * |
| * @see #getViewType() |
| * @see #setViewType(View) |
| */ |
| public final ObjectProperty<View> viewTypeProperty; |
| |
| /** |
| * The source of coverage data shown in this explorer. If this property value is non-null, |
| * then {@link #coverageProperty} value will change at any time (potentially many times) |
| * depending on the zoom level or other user interaction. Conversely if a value is set |
| * explicitly on {@link #coverageProperty}, then this {@code resourceProperty} is cleared. |
| * |
| * <h4>Relationship with view properties</h4> |
| * This property is "weakly bound" to {@link CoverageCanvas#resourceProperty}: |
| * the two properties generally have the same value but are not necessarily updated in same time. |
| * After a value is set on one property, the other property may be updated only after some background process |
| * (e.g. loading) finished. If a view is not the {@linkplain #getViewType() currently visible view}, |
| * its property may be updated only when the view become visible. |
| * |
| * @see #getResource() |
| * @see #setResource(GridCoverageResource) |
| * @see CoverageCanvas#resourceProperty |
| * |
| * @since 1.2 |
| */ |
| public final ObjectProperty<GridCoverageResource> resourceProperty; |
| |
| /** |
| * The data shown in this canvas. This property value may be set implicitly or explicitly: |
| * <ul> |
| * <li>If the {@link #resourceProperty} value is non-null, then the value will change |
| * automatically at any time (potentially many times) depending on user interaction.</li> |
| * <li>Conversely if an explicit value is set on this property, |
| * then the {@link #resourceProperty} is cleared.</li> |
| * </ul> |
| * |
| * Note that a change in this property value may not modify the canvas content immediately. |
| * Instead, a background process will request the tiles and update the canvas content later, |
| * when data are ready. |
| * |
| * <p>Current implementation is restricted to {@link GridCoverage} instances, but a future |
| * implementation may generalize to {@link org.opengis.coverage.Coverage} instances.</p> |
| * |
| * <h4>Relationship with view properties</h4> |
| * This property is "weakly bound" to {@link CoverageCanvas#coverageProperty}: |
| * the two properties generally have the same value but are not necessarily updated in same time. |
| * After a value is set on one property, the other property may be updated only after some background process |
| * (e.g. loading) finished. If a view is not the {@linkplain #getViewType() currently visible view}, |
| * its property may be updated only when the view become visible. |
| * |
| * @see #getCoverage() |
| * @see #setCoverage(GridCoverage) |
| * @see #setCoverage(ImageRequest) |
| * @see CoverageCanvas#coverageProperty |
| */ |
| public final ObjectProperty<GridCoverage> coverageProperty; |
| |
| /** |
| * Whether the {@link #coverageProperty} is in process of being set, |
| * in which case some listeners should not react. |
| */ |
| private boolean isCoverageAdjusting; |
| |
| /** |
| * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for selecting a CRS. |
| */ |
| final RecentReferenceSystems referenceSystems; |
| |
| /** |
| * The different views we can provide on {@link #coverageProperty}, together with associated controls. |
| * Values in this map are initially null and created when first needed. |
| * Concrete classes are {@link GridControls} and {@link CoverageControls}. |
| * |
| * @see #getDataView(View) |
| * @see #getControls(View) |
| */ |
| private final EnumMap<View,ViewAndControls> views; |
| |
| /** |
| * The control that put everything together, created when first requested. |
| * The type of control may change in any future SIS version. |
| * |
| * @see #getView() |
| * @see #onViewTypeSet(View) |
| */ |
| private SplitPane content; |
| |
| /** |
| * Handler of the window showing this coverage view. This is used for creating new windows. |
| * Created when first needed for giving to subclasses a chance to complete initialization. |
| * |
| * @see #getWindowHandler() |
| */ |
| private WindowHandler window; |
| |
| /** |
| * Creates an initially empty explorer with the specified view type. |
| * |
| * @param type the way to show coverages in this explorer. |
| * |
| * @see #setViewType(View) |
| * |
| * @since 1.2 |
| */ |
| @SuppressWarnings("this-escape") |
| public CoverageExplorer(final View type) { |
| views = new EnumMap<>(View.class); |
| viewTypeProperty = new NonNullObjectProperty<>(this, "viewType", Objects.requireNonNull(type)); |
| resourceProperty = new SimpleObjectProperty<> (this, "resource"); |
| coverageProperty = new SimpleObjectProperty<> (this, "coverage"); |
| referenceSystems = new RecentReferenceSystems(); |
| referenceSystems.addUserPreferences(); |
| referenceSystems.addAlternatives("EPSG:4326", "EPSG:3395", "MGRS"); // WGS 84 / World Mercator |
| viewTypeProperty.addListener((p,o,n) -> onViewTypeSet(n)); |
| resourceProperty.addListener((p,o,n) -> onPropertySet(n, null, coverageProperty)); |
| coverageProperty.addListener((p,o,n) -> onPropertySet(null, n, resourceProperty)); |
| } |
| |
| /** |
| * Creates an explorer initialized with the same coverage or resource than the given explorer. |
| * |
| * @param source the source explorer from which to take the initial coverage or resource. |
| * |
| * @since 1.2 |
| */ |
| @SuppressWarnings("this-escape") |
| public CoverageExplorer(final CoverageExplorer source) { |
| this(source.getViewType()); |
| window = PrivateAccess.newWindowHandler.apply(source.window, this); |
| source.getImageRequest().ifPresent(this::setCoverage); |
| PrivateAccess.finishWindowHandler.accept(window); |
| if (getViewType() == View.IMAGE) { |
| getCoverageControls().copyStyling(source.getCoverageControls()); |
| } |
| } |
| |
| /** |
| * Returns the handler of the window showing this coverage view. |
| * Those windows are created when the user clicks on the "New window" button. |
| * Each window provides the area where data are shown and where the user interacts. |
| * The window can be a JavaFX top-level window ({@link javafx.stage.Stage}), but not necessarily. |
| * It may also be a tile in a mosaic of windows. |
| * |
| * @return the handler of the window showing this coverage view. |
| * |
| * @since 1.3 |
| */ |
| public final WindowHandler getWindowHandler() { |
| assert Platform.isFxApplicationThread(); |
| /* |
| * Created when first needed for giving to subclass constructors a chance to complete |
| * their initialization before `this` reference is passed to `WindowHandler` constructor. |
| */ |
| if (window == null) { |
| window = WindowHandler.create(this); |
| } |
| return window; |
| } |
| |
| /** |
| * Returns the canvas where the image is shown. |
| * |
| * @return the canvas where the image is shown. |
| * |
| * @since 1.2 |
| */ |
| public final CoverageCanvas getCanvas() { |
| return getCoverageControls().view; |
| } |
| |
| /** |
| * Returns the controls on the canvas where the image is shown. |
| */ |
| private CoverageControls getCoverageControls() { |
| return (CoverageControls) getViewAndControls(View.IMAGE, false); |
| } |
| |
| /** |
| * Returns the view-control pair for the given view type. |
| * The view-control pair is created when first needed. |
| * Invoking this method may cause data to be loaded in a background thread. |
| * |
| * @param type type of view to obtain. |
| * @param load whether to force loading of data in the new type. |
| */ |
| private ViewAndControls getViewAndControls(final View type, boolean load) { |
| ViewAndControls c = views.get(type); |
| if (c == null) { |
| switch (type) { |
| case TABLE: c = new GridControls(this); break; |
| case IMAGE: c = new CoverageControls(this, getWindowHandler()); break; |
| default: throw new AssertionError(type); |
| } |
| views.put(type, c); |
| load = true; |
| } |
| /* |
| * If this explorer is showing a coverage, load data in the newly created view. |
| * Data may also be loaded because the view was previously unselected (hidden) |
| * and became selected (visible). |
| */ |
| if (load) { |
| getImageRequest().ifPresent(c::load); |
| } |
| return c; |
| } |
| |
| /** |
| * Returns the region containing the grid or coverage view, band selector and any control managed by this |
| * {@code CoverageExplorer}. The {@link Region} subclass returned by this method is implementation dependent |
| * and may change in any future version. |
| * |
| * @return the region to show. |
| * |
| * @see #getDataView(View) |
| * @see #getControls(View) |
| */ |
| @Override |
| public final Region getView() { |
| assert Platform.isFxApplicationThread(); |
| /* |
| * We build when first requested because `ResourceExplorer` for example will never request this view. |
| * Instead, it will invoke `getDataView(View)` or `getControls(View)` and layout those regions itself. |
| */ |
| if (content == null) { |
| /* |
| * Prepare buttons to add on the toolbar. Those buttons are not managed by this class; |
| * they are managed by org.apache.sis.gui.dataset.WindowHandler. We only declare here |
| * the text and action for each button. |
| */ |
| final ToggleGroup group = new ToggleGroup(); |
| final Control[] buttons = new Control[View.COUNT + 1]; |
| final Resources localized = Resources.forLocale(getLocale()); |
| buttons[0] = new Separator(); |
| for (final View type : View.values()) { |
| buttons[1 + type.ordinal()] = new Selector(type).createButton(group, type.icon, type.fallback, localized, type.tooltip); |
| } |
| final View type = getViewType(); |
| final ViewAndControls c = getViewAndControls(type, false); |
| group.selectToggle(group.getToggles().get(type.ordinal())); |
| content = new SplitPane(c.controls(), c.viewAndNavigation); |
| ToolbarButton.insert(content, buttons); |
| /* |
| * The divider position is supposed to be a fraction between 0 and 1. A value of 1 would mean |
| * to give all the space to controls and no space to data, which is not what we want. However |
| * experience with JavaFX 14 shows that this setting gives just a reasonable space to controls |
| * and most space to data. I have not identified the cause of this surprising behavior. |
| * A smaller value results in too few space for the controls. |
| */ |
| content.setDividerPosition(0, 1); |
| } |
| return content; |
| } |
| |
| /** |
| * Returns the region containing the data visualization component, without controls other than navigation. |
| * This is a {@link GridView} or {@link CoverageCanvas} together with their {@link StatusBar} |
| * and navigation controls for selecting the slice in a <var>n</var>-dimensional data cube. |
| * The {@link Region} subclass returned by this method is implementation dependent and may change |
| * in any future version. |
| * |
| * @param type whether to obtain a {@link GridView} or {@link CoverageCanvas}. |
| * @return the requested view for the value of {@link #resourceProperty} or {@link #coverageProperty}. |
| */ |
| public final Region getDataView(final View type) { |
| assert Platform.isFxApplicationThread(); |
| return getViewAndControls(Objects.requireNonNull(type), false).viewAndNavigation; |
| } |
| |
| /** |
| * Returns the panes containing the controls, without data visualization component. |
| * The {@link TitledPane} contents are implementation dependent and may change in any future version. |
| * |
| * @param type whether to obtain controls for {@link GridView} or {@link CoverageCanvas}. |
| * @return the controls on specified data view. |
| */ |
| public final TitledPane[] getControls(final View type) { |
| assert Platform.isFxApplicationThread(); |
| return getViewAndControls(Objects.requireNonNull(type), false).controlPanes.clone(); |
| } |
| |
| /** |
| * The action to execute when the user selects a view. |
| * This is used by the toolbar buttons in the widget created by {@link #getView()}. |
| */ |
| private final class Selector extends ToolbarButton { |
| /** The view to select when the button is pressed. */ |
| private final View type; |
| |
| /** Creates a new action which will show the view at the given index. */ |
| Selector(final View type) { |
| this.type = type; |
| } |
| |
| /** Invoked when the user selects another view to show (tabular data or the image). */ |
| @Override public void handle(final ActionEvent event) { |
| final Toggle button = (Toggle) event.getSource(); |
| if (button.isSelected()) { |
| setViewType(type); |
| views.get(type).selector = button; // Should never be null. |
| } else { |
| button.setSelected(true); // Prevent situation where all buttons are unselected. |
| } |
| } |
| } |
| |
| /** |
| * Returns the type of view (image or tabular data) shown in this explorer. |
| * The default value is {@link View#TABLE}. |
| * |
| * @return the way to show coverages in this explorer. |
| * |
| * @see #viewTypeProperty |
| */ |
| public final View getViewType() { |
| return viewTypeProperty.get(); |
| } |
| |
| /** |
| * Sets the type of view to show in this explorer. |
| * |
| * @param type the new way to show coverages in this explorer. |
| * |
| * @see #viewTypeProperty |
| */ |
| public final void setViewType(final View type) { |
| viewTypeProperty.set(type); |
| } |
| |
| /** |
| * Invoked when a new view type has been specified. |
| * |
| * @param type the new way to show coverages in this explorer. |
| */ |
| private void onViewTypeSet(final View type) { |
| final ViewAndControls c = getViewAndControls(type, true); |
| if (content != null) { |
| content.getItems().setAll(c.controls(), c.viewAndNavigation); |
| final Toggle selector = c.selector; |
| if (selector != null) { |
| selector.setSelected(true); |
| } |
| } |
| } |
| |
| /** |
| * Returns the source of coverages for this explorer. |
| * This method, like all other methods in this class, shall be invoked from the JavaFX thread. |
| * |
| * @return the source of coverages shown in this explorer, or {@code null} if none. |
| * |
| * @see #resourceProperty |
| * |
| * @since 1.2 |
| */ |
| public final GridCoverageResource getResource() { |
| return resourceProperty.get(); |
| } |
| |
| /** |
| * Sets the source of coverages shown in this explorer. |
| * This method shall be invoked from JavaFX thread and returns immediately. |
| * The new data are loaded in a background thread and the {@link #coverageProperty} |
| * value will be updated after an undetermined amount of time. |
| * |
| * @param resource the source of data to show in this explorer, or {@code null} if none. |
| * |
| * @see #resourceProperty |
| * |
| * @since 1.2 |
| */ |
| public final void setResource(final GridCoverageResource resource) { |
| resourceProperty.set(resource); |
| /* |
| * `this.onPropertySet(…)` is indirectly invoked, |
| * which in turn invokes `setCoverage(ImageRequest)`. |
| */ |
| } |
| |
| /** |
| * Returns the source of sample values for this explorer. |
| * This method, like all other methods in this class, shall be invoked from the JavaFX thread. |
| * Note that this value may change at any time (depending on user interaction) |
| * if the {@link #resourceProperty} has a non-null value. |
| * |
| * @return the coverage shown in this explorer, or {@code null} if none. |
| * |
| * @see #coverageProperty |
| * @see CoverageCanvas#getCoverage() |
| * @see GridView#getImage() |
| */ |
| public final GridCoverage getCoverage() { |
| return coverageProperty.get(); |
| } |
| |
| /** |
| * Sets the coverage to show in this explorer. |
| * This method shall be invoked from JavaFX thread and returns immediately. |
| * The new data are loaded in a background thread and will appear after an |
| * undetermined amount of time. |
| * |
| * <p>Invoking this method sets the {@link #resourceProperty} value to {@code null}.</p> |
| * |
| * @param coverage the data to show in this explorer, or {@code null} if none. |
| * |
| * @see #coverageProperty |
| * @see CoverageCanvas#setCoverage(GridCoverage) |
| * @see GridView#setImage(RenderedImage) |
| */ |
| public final void setCoverage(final GridCoverage coverage) { |
| coverageProperty.set(coverage); |
| /* |
| * `this.onPropertySet(…)` is indirectly invoked, |
| * which in turn invokes `setCoverage(ImageRequest)`. |
| */ |
| } |
| |
| /** |
| * Loads coverage in a background thread from the given source. |
| * This method shall be invoked from JavaFX thread and returns immediately. |
| * The grid content may appear unmodified after this method returns; |
| * the modifications will appear after an undetermined amount of time. |
| * |
| * @param source the coverage or resource to load, or {@code null} if none. |
| * |
| * @see GridView#setImage(ImageRequest) |
| */ |
| public final void setCoverage(final ImageRequest source) { |
| assert Platform.isFxApplicationThread(); |
| final ViewAndControls current = getViewAndControls(getViewType(), false); |
| for (final ViewAndControls c : views.values()) { |
| c.load(c == current ? source : null); |
| } |
| // `notifyDataChanged(…)` will be invoked later after background thread finishes its work. |
| } |
| |
| /** |
| * Invoked when a new value has been set on {@link #resourceProperty} or {@link #coverageProperty}. |
| * This method sets the resource or coverage on the currently visible view, which in turn will load |
| * data in its own background thread. |
| * |
| * @param resource the new resource, or {@code null} if none. |
| * @param coverage the new coverage, or {@code null} if none. |
| * @param toClear the property which is an alternative to the property that has been set. |
| */ |
| private void onPropertySet(final GridCoverageResource resource, final GridCoverage coverage, |
| final ObjectProperty<?> toClear) |
| { |
| if (!isCoverageAdjusting) { |
| isCoverageAdjusting = true; |
| try { |
| toClear.set(null); |
| } finally { |
| isCoverageAdjusting = false; |
| } |
| // Indirectly start a background thread which will invoke `notifyDataChanged(…)` later. |
| setCoverage((resource != null || coverage != null) ? new ImageRequest(resource, coverage, null) : null); |
| } |
| } |
| |
| /** |
| * Invoked in JavaFX thread after the current view finished to load the new coverage. |
| * It is the responsibility of all {@link ViewAndControls} subclasses to listen to change events |
| * emitted by their views ({@link GridView} or {@link CoverageCanvas}) and to invoke this method. |
| * This method will then update the properties of this {@code CoverageExplorer} for the new data. |
| * |
| * <p>Note that view data may have been changed either by user changing directly a {@link GridView} |
| * or {@link CoverageCanvas} property, or indirectly by user changing {@link #resourceProperty} or |
| * {@link #coverageProperty}. In the latter case, the {@code resource} and {@code coverage} arguments |
| * given to this method may be the value that the properties already have.</p> |
| * |
| * @param resource the new source of coverage, or {@code null} if none. |
| * @param coverage the new coverage, or {@code null} if none. |
| */ |
| final void notifyDataChanged(final GridCoverageResource resource, final GridCoverage coverage) { |
| if (coverage != null) { |
| String name; |
| try { |
| name = DataStoreOpener.findLabel(resource, getLocale(), true); |
| } catch (DataStoreException e) { |
| name = e.getLocalizedMessage(); |
| if (name == null) { |
| name = e.getClass().getSimpleName(); |
| } |
| } |
| referenceSystems.setGridReferencing(true, Map.of(name, coverage.getGridGeometry())); |
| } |
| /* |
| * Following calls will NOT forward the new values to the views because this `notifyDataChanged(…)` |
| * method is invoked as a consequence of view properties being updated. Those views should already |
| * have the new property values at this moment. |
| */ |
| isCoverageAdjusting = true; |
| try { |
| setResource(resource); |
| setCoverage(coverage); |
| } finally { |
| isCoverageAdjusting = false; |
| } |
| } |
| |
| /** |
| * Returns a request which represent the coverage or resource currently shown in this explorer. |
| * This request can be used for showing the same data in another {@code CoverageExplorer} instance |
| * by invoking the {@link #setCoverage(ImageRequest)} method. |
| * |
| * @return the request to give to another explorer for showing the same coverage. |
| * |
| * @see #setCoverage(ImageRequest) |
| */ |
| private Optional<ImageRequest> getImageRequest() { |
| final GridCoverageResource resource = getResource(); |
| final GridCoverage coverage = getCoverage(); |
| if (resource != null || coverage != null) { |
| final ImageRequest request = new ImageRequest(resource, coverage, null); |
| final CoverageControls c = (CoverageControls) views.get(View.IMAGE); |
| if (c != null) try { |
| request.zoom = c.view.getGridGeometry(); |
| } catch (RenderException e) { |
| CoverageCanvas.unexpectedException("getGridGeometry", e); |
| } |
| return Optional.of(request); |
| } else { |
| return Optional.empty(); |
| } |
| } |
| } |