/* * Geotoolkit - An Open Source Java GIS Toolkit * http://www.geotoolkit.org * * (C) 2015, Geomatys * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; * version 2.1 of the License. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. */ package org.geotoolkit.gui.javafx.filter; import java.awt.Color; import java.util.ArrayList; import java.util.Iterator; import java.util.Optional; import java.util.ServiceLoader; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.beans.binding.ObjectBinding; import javafx.beans.property.ObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.embed.swing.SwingFXUtils; import javafx.geometry.HPos; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ChoiceBox; import javafx.scene.control.ComboBox; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.util.StringConverter; import org.apache.sis.util.ArgumentChecks; import org.geotoolkit.display2d.GO2Utilities; import org.geotoolkit.font.FontAwesomeIcons; import org.geotoolkit.font.IconBuilder; import org.geotoolkit.gui.javafx.util.ComboBoxCompletion; import org.opengis.feature.PropertyType; import org.opengis.filter.Filter; /** * A panel to create and chain filters on properties. * To fill list of properties which can be filtered, use {@link #getAvailableProperties() }. * * @author Alexis Manin (Geomatys) */ public class FXFilterBuilder extends BorderPane { protected static enum JOIN_TYPE { AND, OR; } protected static final ObservableList<JOIN_TYPE> JOIN_TYPES = FXCollections.observableArrayList(JOIN_TYPE.values()); private static final Image ICON_PLUS = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_PLUS, 16, new Color(74,123,165)), null); private static final Image ICON_MINUS = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_MINUS, 16, Color.RED), null); protected static final ServiceLoader<FXFilterOperator> OPERATORS = ServiceLoader.load(FXFilterOperator.class); /** * A simple button to add a new filter in filter chain. */ protected final Button addFilter; /** * A grid pane which will contain filter editors bound by an AND or OR filter. * Columns are : * <ul> * <li>Type of link (AND, OR, or nothing for first row).</li> * <li>Property to apply filter upon,</li> * <li>Type of filter operator to apply,</li> * <li>Filter operator form,</li> * <li>Remove filter button.</li> * </ul> */ protected final GridPane filterEditors; /** * Properties which can be filtered. User will be able to choose which one to filter on using a combo-box. */ protected final ObservableList<PropertyType> availableProperties = FXCollections.observableArrayList(); /** * A simple string converter for property display in a combo-box. it uses method {@link #getPropertyTitle(org.opengis.filter.expression.PropertyName) }. */ private final StringConverter<PropertyType> propertyConverter; private final StringConverter<FXFilterOperator> operatorConverter; /** * Contains main information about edited filters. */ private final ObservableList<FilterBox> sandboxes = FXCollections.observableArrayList(); /** * A simple observable boolean to know if we have multiple filters (rows in * inner grid pane) active (true) or not (false). */ public final BooleanBinding multipleRows; /** * A property to keep track of first edited filter. */ protected final ObjectBinding<FilterBox> firstFilter = Bindings.valueAt(sandboxes, 0); public FXFilterBuilder() { super(); setMinSize(USE_PREF_SIZE, USE_PREF_SIZE); setPrefSize(USE_COMPUTED_SIZE, USE_COMPUTED_SIZE); // String converters propertyConverter = new StringConverter<PropertyType>() { @Override public String toString(PropertyType object) { return getTitle(object); } @Override public PropertyType fromString(String string) { for (final PropertyType property : availableProperties) { if (getTitle(property).equals(string)) return property; } return null; } }; operatorConverter = new StringConverter<FXFilterOperator>() { @Override public String toString(FXFilterOperator object) { return object.getTitle().toString(); } @Override public FXFilterOperator fromString(String string) { for (final FXFilterOperator op : OPERATORS) { if (op.getTitle().toString().equals(string)) return op; } return null; } }; // display rules filterEditors = new GridPane(); filterEditors.getColumnConstraints().addAll( new ColumnConstraints(USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, USE_PREF_SIZE, Priority.NEVER, HPos.CENTER, true), new ColumnConstraints(0, USE_COMPUTED_SIZE, USE_PREF_SIZE, Priority.NEVER, HPos.CENTER, true), new ColumnConstraints(USE_PREF_SIZE, USE_COMPUTED_SIZE, USE_PREF_SIZE, Priority.NEVER, HPos.CENTER, true), new ColumnConstraints(0, USE_COMPUTED_SIZE, Double.MAX_VALUE, Priority.ALWAYS, HPos.CENTER, true), new ColumnConstraints(USE_PREF_SIZE, USE_COMPUTED_SIZE, USE_PREF_SIZE, Priority.NEVER, HPos.CENTER, true) ); multipleRows = Bindings.size(sandboxes).greaterThan(1); addFilter = new Button(null, new ImageView(ICON_PLUS)); addFilter.visibleProperty().bind(Bindings.isNotEmpty(availableProperties)); addFilter.setOnAction(event -> addFilterRow()); setTop(addFilter); setCenter(filterEditors); // CSS rules getStylesheets().add(FXFilterBuilder.class.getResource("/org/geotoolkit/gui/javafx/filter/FXFilterBuilder.css").toExternalForm()); getStyleClass().add("filter-root"); addFilter.getStyleClass().add("header-button"); filterEditors.getStyleClass().add("grid"); } /** * @return A list of properties available for filtering. Use it to add new * filter possibilities. */ public ObservableList<PropertyType> getAvailableProperties() { return availableProperties; } /** * Build a filter which is the aggregation of all filters edited. * AND conditions are grouped first, then they are joined by or conditions, * which make the final filter to return. * @return A filter representing user input. Never null, but a runtime exception * can be thrown if user input is not wring or not sufficient to build a proper filter. */ public Filter getFilter() { if (sandboxes.isEmpty()) throw new IllegalStateException("No valid filter definition."); final ArrayList<Filter> andGroups = new ArrayList<>(); final ArrayList<Filter> orGroups = new ArrayList<>(); FilterBox box = sandboxes.get(0); andGroups.add(box.buildFilter()); for (int i = 1; i < sandboxes.size(); i++) { box = sandboxes.get(i); if (JOIN_TYPE.OR.equals(box.joinType.get())) { orGroups.add((andGroups.size()==1)? andGroups.get(0) : GO2Utilities.FILTER_FACTORY.and(andGroups)); andGroups.clear(); } andGroups.add(box.buildFilter()); } orGroups.add((andGroups.size()==1)? andGroups.get(0) : GO2Utilities.FILTER_FACTORY.and(andGroups)); return (orGroups.size() == 1)? orGroups.get(0) : GO2Utilities.FILTER_FACTORY.or(orGroups); } /** * Return a title to be displayed in combo-box for user. By default, raw name * is returned, * @param candidate The expression to get a title for. * @return A title or raw name of input property. */ protected String getTitle(PropertyType candidate) { if (candidate == null) return ""; return candidate.getName().head().toString(); } /** * Add a new filter, and display an editor to allow user to configure it. */ protected void addFilterRow() { // TODO : make international label for join types. final ChoiceBox<JOIN_TYPE> joinChoice = new ChoiceBox<>(JOIN_TYPES); final ComboBox<PropertyType> propertyChoice = createPropertyChoice(); final ObservableList<FXFilterOperator> operators = FXCollections.observableArrayList(); final ChoiceBox<FXFilterOperator> operatorChoice = new ChoiceBox<>(operators); final StackPane editorPane = new StackPane(); final Button removeButton = new Button(null, new ImageView(ICON_MINUS)); // CSS rules joinChoice.getStyleClass().add("join-choice"); propertyChoice.getStyleClass().add("property-choice"); operatorChoice.getStyleClass().add("operator-choice"); editorPane.getStyleClass().add("editor-pane"); removeButton.getStyleClass().add("remove-button"); operatorChoice.setConverter(operatorConverter); operatorChoice.disableProperty().bind(propertyChoice.valueProperty().isNull()); final FilterBox filterBox = new FilterBox(joinChoice.valueProperty(), propertyChoice.valueProperty(), operatorChoice.valueProperty(), editorPane); filterBox.propertyType.addListener(new PropertyChoiceListener(operators)); // bind remove button and join type visibility to number of rows (only one row left = no join or deletion). joinChoice.getSelectionModel().select(JOIN_TYPE.AND); joinChoice.visibleProperty().bind(firstFilter.isNotEqualTo(filterBox)); removeButton.visibleProperty().bind(multipleRows); removeButton.setOnAction(event -> { filterEditors.getChildren().removeAll(joinChoice, propertyChoice, operatorChoice, editorPane, removeButton); // Delete filter entry sandboxes.remove(filterBox); }); // To add a new line in the grid, we must get row indice of the last added pane. final int newRowIndice; if (sandboxes.isEmpty()) { newRowIndice = 0; } else { final FilterBox box = sandboxes.get(sandboxes.size()-1); newRowIndice = GridPane.getRowIndex(box.filterEditorContainer) + 1; } filterEditors.addRow(newRowIndice, joinChoice, propertyChoice, operatorChoice, editorPane, removeButton); sandboxes.add(filterBox); } /** * Create a combo-box (by default its editable with auto-completion) to allow * user to pick a property as filter target. * @return ComboBox */ protected ComboBox<PropertyType> createPropertyChoice() { final ComboBox<PropertyType> cBox = new ComboBox<>(availableProperties); cBox.setConverter(propertyConverter); cBox.setEditable(true); new ComboBoxCompletion(cBox); return cBox; } /** * Update list of compatible operators when target property is changed. */ private class PropertyChoiceListener implements ChangeListener<PropertyType> { private final ObservableList operatorList; public PropertyChoiceListener(final ObservableList operators) { ArgumentChecks.ensureNonNull("operator list", operators); operatorList = operators; } @Override public void changed(ObservableValue<? extends PropertyType> observable, PropertyType oldValue, PropertyType newValue) { if (newValue == null) { operatorList.clear(); } else { // Do not clear and re-fill operator list to keep selected operator if possible. // Remove filters which are not valid for current property. final Iterator<FXFilterOperator> it = operatorList.iterator(); while (it.hasNext()) { if (!it.next().canHandle(newValue)) { it.remove(); } } // Check if new filters can be applied to data for (final FXFilterOperator operator : OPERATORS) { if (!operatorList.contains(operator) && operator.canHandle(newValue)) { operatorList.add(operator); } } } } } /** * A simple POJO to keep references of a grid line row, which describes a filter edition rule. */ private static class FilterBox { final ObjectProperty<JOIN_TYPE> joinType; final ObjectProperty<PropertyType> propertyType; final ObjectProperty<FXFilterOperator> operator; final Pane filterEditorContainer; public FilterBox( ObjectProperty<JOIN_TYPE> joinType, ObjectProperty<PropertyType> propertyType, ObjectProperty<FXFilterOperator> operator, Pane filterEditorContainer) { this.joinType = joinType; this.propertyType = propertyType; this.operator = operator; this.filterEditorContainer = filterEditorContainer; this.propertyType.addListener(this::updateOperator); this.operator.addListener(this::updateOperator); } /** * Build filter described by current attributes. * @return Never null. * @throws IllegalStateException If neither property name nor operator has a valid value. */ public Filter buildFilter() { if (propertyType.get() == null) { throw new IllegalStateException("Cannot build filter : target property is not set"); } else if (operator.get() == null) { throw new IllegalStateException("Cannot build filter : operator is not set"); } final ObservableList<Node> paneChildren = filterEditorContainer.getChildren(); return operator.get().getFilterOver( GO2Utilities.FILTER_FACTORY.property(propertyType.get().getName()), paneChildren.isEmpty()? null : paneChildren.get(0)); } private void updateOperator(ObservableValue observable, Object oldValue, Object newValue) { final FXFilterOperator op = operator.get(); final PropertyType property = propertyType.get(); if (op == null || property == null) { filterEditorContainer.getChildren().clear(); } else { // Update editor only if old one is not compatible with the new editor. if (filterEditorContainer.getChildren().isEmpty() || !op.canExtractSettings(property, filterEditorContainer.getChildren().get(0))) { final Optional<Node> filterEditor = op.createFilterEditor(property); if (filterEditor.isPresent()) { filterEditorContainer.getChildren().setAll(filterEditor.get()); } else { filterEditorContainer.getChildren().clear(); } } } } } }