package uk.ac.rhul.cs.cl1.ui; import info.clearthought.layout.TableLayout; import java.awt.Component; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.text.DecimalFormat; import java.text.ParseException; import java.util.TreeMap; import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JFormattedTextField; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JSpinner; import javax.swing.JTextField; import javax.swing.SpinnerModel; import javax.swing.SpinnerNumberModel; import javax.swing.JFormattedTextField.AbstractFormatter; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import uk.ac.rhul.cs.cl1.ClusterONEAlgorithmParameters; /** * Component that lets the user adjust the algorithm parameters of ClusterONE * * @author ntamas */ public class ClusterONEAlgorithmParametersPanel extends JPanel { private static final String AUTO = "Auto"; /** Sections used in this panel */ public enum Section { BASIC("Basic parameters", true), ADVANCED("Advanced parameters", false); /** Title of the section */ protected String title; /** Whether the section should be expanded by default */ protected boolean expanded; Section(String title, boolean expanded) { this.title = title; this.expanded = expanded; } /** Returns the title of the section */ public String getTitle() { return this.title; } /** Returns whether the section is expanded by default */ public boolean isExpanded() { return this.expanded; } } /** Subpanel components corresponding to each section */ protected TreeMap<Section, JPanel> subpanels = null; /** Layouts of the subpanel components corresponding to each section */ protected TreeMap<Section, TableLayout> layouts = null; /** Spinner component for adjusting the minimum cluster size */ protected JSpinner minimumClusterSizeSpinner; /** Spinner component for adjusting the minimum cluster density */ protected JSpinner minimumClusterDensitySpinner; /** Spinner component for selecting the amount of node penalty */ protected JSpinner nodePenaltySpinner; /** Spinner component for selecting the haircut threshold */ protected JSpinner haircutThresholdSpinner; /** Combobox for selecting the cluster merging method */ protected JComboBox mergingMethodCombo; /** Spinner component for adjusting the overlap threshold of the merging step */ protected JSpinner overlapThresholdSpinner; /** Combobox for selecting the seeding method */ protected JComboBox seedMethodCombo; /** Combobox for selecting the similarity function */ protected JComboBox similarityCombo; /** Checkbox for storing whether the user wants to keep seed nodes in the cluster */ protected JCheckBox keepInitialSeedsCheckBox; /** Merging methods */ protected String[] mergingMethods = {"Single-pass", "Multi-pass"}; /** Seeding methods */ protected String[] seedMethods = {"From unused nodes", "From every node", "From every edge"}; /** Similarity functions */ protected String[] similarityFunctions = {"Match coefficient", "Simpson coefficient", "Jaccard similarity", "Dice similarity"}; /** Internal class to provide a number formatter that does not freak out from strings */ private class LenientNumberFormatter extends JFormattedTextField.AbstractFormatter { private DecimalFormat format = new DecimalFormat("0.##"); public Object stringToValue(String text) throws ParseException { try { return Double.parseDouble(text); } catch (NumberFormatException ex) { return text; } } public String valueToString(Object value) throws ParseException { try { return format.format(value); } catch (IllegalArgumentException ex) { return value.toString(); } } } /** Internal class to listen for change and action events from subcomponents */ private class PropertyChangeManager extends PropertyChangeSupport implements ActionListener, ChangeListener { public PropertyChangeManager(Object sourceBean) { super(sourceBean); } /** * Called when one of the comboboxes in the panel change their values. * * @param event the actual event which describes what changed exactly. * Unfortunately we cannot simply forward it outside as * we don't want to expose the internal widgets directly, * so we simply call {@link fire()}. */ public void actionPerformed(ActionEvent event) { fireParametersChanged(); } /** * Called when one of the spinners in the panel change their values. * * @param event the actual event which describes what changed exactly. * Unfortunately we cannot simply forward it outside as * we don't want to expose the internal widgets directly, * so we simply call {@link fire()}. */ public void stateChanged(ChangeEvent event) { fireParametersChanged(); } } /** Private class to ease the firing of PropertyChangeEvents when something changed */ private final PropertyChangeManager changeManager = new PropertyChangeManager(this); public ClusterONEAlgorithmParametersPanel() { super(); ClusterONEAlgorithmParameters defaultParams = new ClusterONEAlgorithmParameters(); subpanels = new TreeMap<Section, JPanel>(); layouts = new TreeMap<Section, TableLayout>(); this.setLayout(new BoxLayout(this, BoxLayout.PAGE_AXIS)); /* Minimum cluster size spinner */ minimumClusterSizeSpinner = addSpinner(Section.BASIC, "Minimum size:", new SpinnerNumberModel(defaultParams.getMinSize(), 1, Integer.MAX_VALUE, 1) ); /* Minimum cluster density spinner */ Object minDensity = defaultParams.getMinDensity(); if (minDensity == null) minDensity = AUTO; minimumClusterDensitySpinner = addSpinner(Section.BASIC, "Minimum density:", new ExtendedSpinnerNumberModel(minDensity, 0.0, 1.0, 0.05, AUTO) ); ((JSpinner.DefaultEditor)minimumClusterDensitySpinner.getEditor()).getTextField().setFormatterFactory( new JFormattedTextField.AbstractFormatterFactory() { private AbstractFormatter formatter = new LenientNumberFormatter(); public AbstractFormatter getFormatter(JFormattedTextField tf) { return formatter; } } ); /* Node penalty spinner */ nodePenaltySpinner = addSpinner(Section.ADVANCED, "Node penalty:", new SpinnerNumberModel(defaultParams.getNodePenalty(), 0.0, 10.0, 0.2) ); /* Haircut threshold spinner */ haircutThresholdSpinner = addSpinner(Section.ADVANCED, "Haircut threshold:", new SpinnerNumberModel(defaultParams.getHaircutThreshold(), 0.0, 1.0, 0.05) ); /* Merging method combobox */ mergingMethodCombo = addComboBox(Section.ADVANCED, "Merging method:", mergingMethods); /* Similarity function combobox */ similarityCombo = addComboBox(Section.ADVANCED, "Similarity:", similarityFunctions); /* Overlap threshold spinner */ overlapThresholdSpinner = addSpinner(Section.ADVANCED, "Overlap threshold:", new SpinnerNumberModel(defaultParams.getOverlapThreshold(), 0.0, 1.0, 0.05) ); /* Seed selection method */ seedMethodCombo = addComboBox(Section.ADVANCED, "Seeding method:", seedMethods); /* Keep initial seed nodes */ keepInitialSeedsCheckBox = addCheckBox(Section.ADVANCED, "Keep initial seeds:"); } /** * Expands all the panels. */ public void expandAll() { for (JPanel panel: subpanels.values()) { if (panel instanceof CollapsiblePanel) { ((CollapsiblePanel)panel).setExpanded(true); } } } /** * Returns a {@link ClusterONEAlgorithmParameters} object from the current state * of the panel */ public ClusterONEAlgorithmParameters getParameters() { ClusterONEAlgorithmParameters result = new ClusterONEAlgorithmParameters(); Object minimumDensity = minimumClusterDensitySpinner.getValue(); if (minimumDensity.equals(AUTO)) result.setMinDensity(null); else result.setMinDensity((Double)minimumDensity); result.setMinSize((Integer)minimumClusterSizeSpinner.getValue()); result.setHaircutThreshold((Double)haircutThresholdSpinner.getValue()); result.setOverlapThreshold((Double)overlapThresholdSpinner.getValue()); result.setNodePenalty((Double)nodePenaltySpinner.getValue()); result.setKeepInitialSeeds(keepInitialSeedsCheckBox.isSelected()); try { if (seedMethodCombo.getSelectedIndex() == 0) { result.setSeedGenerator("nodes"); result.setRejectSeedsWithOnlyUsedNodes(true); } else if (seedMethodCombo.getSelectedIndex() == 1) result.setSeedGenerator("nodes"); else if (seedMethodCombo.getSelectedIndex() == 2) result.setSeedGenerator("edges"); else return null; } catch (InstantiationException ex) { ex.printStackTrace(); return null; } try { if (similarityCombo.getSelectedIndex() == 0) result.setSimilarityFunction("match"); else if (similarityCombo.getSelectedIndex() == 1) result.setSimilarityFunction("simpson"); else if (similarityCombo.getSelectedIndex() == 2) result.setSimilarityFunction("jaccard"); else if (similarityCombo.getSelectedIndex() == 3) result.setSimilarityFunction("dice"); else return null; } catch (InstantiationException ex) { return null; } if (mergingMethodCombo.getSelectedIndex() == 0) result.setMergingMethodName("single"); else if (mergingMethodCombo.getSelectedIndex() == 1) result.setMergingMethodName("multi"); return result; } /** * Returns the subpanel corresponding to the given section * * If the section has not been used yet, this method also creates the * corresponding section and sets an appropriate layout on it. */ public JPanel getSubpanel(Section section) { if (subpanels.containsKey(section)) return subpanels.get(section); CollapsiblePanel newPanel = constructNewSubpanel(section.getTitle()); newPanel.setExpanded(section.isExpanded()); double sizes[][] = { {TableLayout.PREFERRED, 10, TableLayout.PREFERRED}, {TableLayout.PREFERRED} }; layouts.put(section, new TableLayout(sizes)); newPanel.setLayout(layouts.get(section)); this.add(newPanel); this.add(Box.createVerticalStrut(10)); subpanels.put(section, newPanel); return newPanel; } /** * Constructs a new subpanel with the given title */ protected CollapsiblePanel constructNewSubpanel(String title) { CollapsiblePanel newPanel = new CollapsiblePanel(title); return newPanel; } /** * Adds a new component to the end of the parameters panel. * * The panel will <em>not</em> register itself to the component to listen to * change events, you have to do it manually from the caller. * * @param section the section to add the component to * @param caption caption of the component in the left column * @param component the component itself that should go in the right column */ public void addComponent(Section section, String caption, Component component) { JLabel label = new JLabel(caption); JPanel subpanel = this.getSubpanel(section); TableLayout layout = this.layouts.get(section); int numRows = layout.getNumRow(); layout.insertRow(numRows, TableLayout.PREFERRED); subpanel.add(label, "0, "+numRows+", r, c"); subpanel.add(component, "2, "+numRows+", l, c"); } /** * Adds a checkbox to the parameters panel. * * This method will also register the panel to listen for changes of the * state of the checkbox and fire a {@link PropertyChangeEvent} if needed. * * @param section the section to add the component to * @param caption caption of the component in the left column */ public JCheckBox addCheckBox(Section section, String caption) { JCheckBox checkBox = new JCheckBox(); this.addComponent(section, caption, checkBox); checkBox.addActionListener(changeManager); return checkBox; } /** * Adds a combobox to the parameters panel. * * This method will also register the panel to listen for changes of the * selected item in the combobox and fire a {@link PropertyChangeEvent} * if needed. * * @param section the section to add the component to * @param caption caption of the component in the left column * @param items the array of items in the combo box */ public JComboBox addComboBox(Section section, String caption, String[] items) { JComboBox combo = new JComboBox(items); this.addComponent(section, caption, combo); combo.addActionListener(changeManager); return combo; } /** * Adds a spinner to the parameters panel. * * This method will also register the panel to listen for changes of the spinner * value and fire a {@link PropertyChangeEvent} if needed. * * @param section the section to add the component to * @param caption caption of the component in the left column * @param model the model of the spinner value */ public JSpinner addSpinner(Section section, String caption, SpinnerModel model) { JSpinner spinner = new JSpinner(); JSpinner.DefaultEditor editor; spinner.setModel(model); editor = (JSpinner.DefaultEditor)spinner.getEditor(); editor.getTextField().setColumns(5); editor.getTextField().setHorizontalAlignment(JTextField.RIGHT); this.addComponent(section, caption, spinner); spinner.addChangeListener(changeManager); return spinner; } /** * Notifies registered {@link PropertyChangeListener}s that one of the algorithm * properties have changed. * * Currently this is a very dumb method, it will fire a simple * {@link PropertyChangeEvent} with null old and new values and * "algorithm_parameters" as source, so the caller would not know which * property changed exactly. However, this is enough for our purposes for * the time being. */ protected void fireParametersChanged() { firePropertyChange("parameters", null, null); } /** * Registers a component to be monitored by the panel. * * Whenever the component fires an {@link ActionEvent} or a {@link ChangeEvent}, * the algorithm panel will assume that the algorithm parameters depend on the * value of that component and therefore will fire a {@link PropertyChangeEvent}. * This method is used by {@link addComponent}. * * @param component the component to be monitored. We will use reflection to * figure out whether the component supports the following * methods (in the following order of priority): * <tt>addChangeListener</tt>, <tt>addActionListener</tt>. */ public void monitorComponent(Component component) throws IllegalArgumentException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { Class<?> cls = component.getClass(); Method method = null; // First, try with addChangeListener() try { method = cls.getMethod("addChangeListener", ChangeListener.class); } catch (SecurityException e) { } catch (NoSuchMethodException e) { } // If unsuccessful, try with addActionListener() if (method == null) { try { method = cls.getMethod("addActionListener", ActionListener.class); } catch (SecurityException e) { } catch (NoSuchMethodException e) { } } // If we found at least one suitable method, invoke it if (method != null) { method.invoke(component, changeManager); } else { // No suitable method was found, throw an InvocationTargetException throw new NoSuchMethodException(); } } }