/* * Geotoolkit.org - An Open Source Java GIS Toolkit * http://www.geotoolkit.org * * (C) 2010-2012, Open Source Geospatial Foundation (OSGeo) * (C) 2010-2012, 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.swing.coverage; import java.awt.Component; import java.awt.Dimension; import java.text.NumberFormat; import java.util.List; import java.util.Locale; import java.util.ArrayList; import java.util.concurrent.Callable; import javax.swing.JComboBox; import javax.swing.ComboBoxModel; import javax.swing.JTable; import javax.swing.JSpinner; import javax.swing.SpinnerNumberModel; import javax.swing.DefaultCellEditor; import javax.swing.JFormattedTextField; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.table.TableColumnModel; import javax.swing.table.DefaultTableCellRenderer; import javax.swing.table.TableColumn; import org.opengis.metadata.content.TransferFunctionType; import org.apache.sis.measure.NumberRange; import org.apache.sis.util.logging.Logging; import org.geotoolkit.coverage.Category; import org.geotoolkit.gui.swing.ListTableModel; import org.geotoolkit.gui.swing.image.PaletteComboBox; import org.geotoolkit.internal.coverage.ColorPalette; import org.geotoolkit.image.palette.PaletteFactory; import org.geotoolkit.resources.Vocabulary; import org.geotoolkit.lang.Debug; import static org.geotoolkit.gui.swing.coverage.CategoryRecord.*; import static org.apache.sis.util.collection.Containers.isNullOrEmpty; /** * An editable table model for a list of {@link Category} items. Instances of this class * are typically created as below: * * {@preformat java * GridSampleDimension band = ...; * CategoryTable model = new CategoryTable(Locale.FRENCH); * model.setCategories(band.getCategories()); * * JTable table = ...; * model.configure(table); * } * * The default implementation provides the following columns (implementors can subclass this * model if they want to provides additional columns): * <p> * <ul> * <li>The category name.</li> * <li>Range of sample values: * <ul> * <li>The minimal sample value.</li> * <li>The maximal sample value.</li> * </ul></li> * <li>Range of geophysics values: * <ul> * <li>The minimal geophysics value.</li> * <li>The maximal geophysics value.</li> * </ul></li> * <li>Transfert function: * <ul> * <li>The {@linkplain TransferFunctionType transfer function type}.</li> * <li>The offset term in the transfer function.</li> * <li>The scale term in the transfer function.</li> * </ul></li> * <li>The color palette name, or a single color.</li> * </ul> * <p> * Some of the above-cited columns are inter-dependents. For example if the value of the * offset or scale factor is modified, then the minimal and maximal geophysics values will * be automatically recomputed. Or conversely, if the minimal or maximal geophysics values * is modified, then the offset and scale factors will be recomputed. * * @author Martin Desruisseaux (Geomatys) * @version 3.15 * * @since 3.13 * @module */ public class CategoryTable extends ListTableModel<CategoryRecord> { /** * For cross-version compatibility. */ private static final long serialVersionUID = -7923217651480496097L; /** * Sets to {@code true} for sending debugging information to the console output. */ @Debug private static final boolean DEBUG = false; /** * The row height used by {@link #configure(JTable)}. The {@link JTable} default value * is 16 pixels, but we use a higher value in order to have room for superscripts in * exponential notation. */ private static final int ROW_HEIGHT = 20; /** * Columns index. */ private static final int NAME=0, SAMPLE_MIN=1, SAMPLE_MAX=2, MINIMUM=3, MAXIMUM=4, TYPE=5, OFFSET=6, SCALE=7, COLORS=8; /** * The type of the columns. */ private static final Class<?>[] TYPES = new Class<?>[9]; static { for (int i=0; i<TYPES.length; i++) { final Class<?> type; switch (i) { case COLORS: // A palette name as a String. case NAME: type = String.class; break; case SAMPLE_MIN: case SAMPLE_MAX: type = Integer.class; break; case TYPE: type = TransferFunctionType.class; break; case OFFSET: case SCALE: case MINIMUM: case MAXIMUM: type = Double.class; break; default: type = Object.class; break; } TYPES[i] = type; } }; /** * The locale to use for column headers and category descriptions. */ final Locale locale; /** * The column headers. */ private final String[] headers; /** * {@code true} if the table is editable. * Every {@code CategoryTable} instances are editable by default. */ private boolean editable = true; /** * The factory to use for creating color palettes, or {@code null} for the default one. */ final PaletteFactory paletteFactory; /** * The collection of {@link org.geotoolkit.internal.swing.table.ColorRampChoice}s. * We opportunistly keep this information after {@link #configure(JTable)} has been * invoked, in order leverage the cached colors. * <p> * This collection depends only on {@link #paletteFactory}, which is final. So it is * not a big deal if the field is computed more than once. It can also be {@code null}, * in which case a temporary list will be created when needed (may be costly). */ private transient ComboBoxModel<ColorPalette> paletteChoices; /** * Creates a new, initially empty, table. * * @param locale The locale to use for the column headers. */ public CategoryTable(final Locale locale) { this(locale, null); } /** * Creates a new, initially empty, table. * * @param locale The locale to use for the column headers. * @param paletteFactory The factory to use for loading colors from a palette name, * or {@code null} for the {@linkplain PaletteFactory#getDefault() default}. */ CategoryTable(final Locale locale, final PaletteFactory paletteFactory) { super(CategoryRecord.class); this.locale = locale; this.paletteFactory = paletteFactory; final Vocabulary resources = Vocabulary.getResources(locale); headers = new String[TYPES.length]; for (int i=0; i<TYPES.length; i++) { final short key; switch (i) { case NAME: key = Vocabulary.Keys.Name; break; case SAMPLE_MIN: case MINIMUM: key = Vocabulary.Keys.Minimum; break; case SAMPLE_MAX: case MAXIMUM: key = Vocabulary.Keys.Maximum; break; case TYPE: key = Vocabulary.Keys.Type; break; case OFFSET: key = Vocabulary.Keys.Offset; break; case SCALE: key = Vocabulary.Keys.Scale; break; case COLORS: key = Vocabulary.Keys.Colors; break; default: throw new AssertionError(i); } headers[i] = resources.getString(key); } } /** * Creates a new table initialized to the value of the given table. * * @param table The table from which to copy the rows. */ public CategoryTable(final CategoryTable table) { super(CategoryRecord.class); locale = table.locale; headers = table.headers; paletteFactory = table.paletteFactory; for (final CategoryRecord record : table.elements) { elements.add(record.clone()); } } /** * Returns all categories currently defined in this table. This is a convenience * which invoke {@link CategoryRecord#getCategory()} for each element returned by * {@link #getElements()}. * * @return The categories, or an empty list if none. */ public List<Category> getCategories() { final List<Category> categories = new ArrayList<>(elements.size()); for (final CategoryRecord record : elements) { categories.add(record.getCategory(paletteFactory)); } return categories; } /** * Sets the categories to be shown in the table. This method removes every rows from this * table, then adds all categories from the given list wrapped in {@link CategoryRecord}s. * <p> * Alternatively, user can create {@link CategoryRecord} themself and invoke one of the * {@link #add add} or {@link #insert insert} methods directly. * * @param categories The categories to show, or {@code null} for clearing the table. */ public void setCategories(final List<Category> categories) { elements.clear(); if (!isNullOrEmpty(categories)) { for (final Category category : categories) { elements.add(new CategoryRecord(category, locale, paletteFactory, paletteChoices)); } } fireTableDataChanged(); } /** * Returns the number of columns in the table. */ @Override public int getColumnCount() { return headers.length; } /** * Returns the name of the given column. * * @param column The index of the column being queried. * @return The column name, localized in the local given to the constructor. */ @Override public String getColumnName(final int column) { return headers[column]; } /** * Returns the type of the given column. The default implementation returns the class * of {@link String}, {@link Integer}, {@link Double}, {@link TransferFunctionType} or * {@link Object} depending on the argument value. * * @param column The index of the column being queried. * @return The column type. */ @Override public Class<?> getColumnClass(int column) { return TYPES[column]; } /** * Returns the minimal value of the given range, or {@code null} if the range is null. */ private static Object getMinValue(final NumberRange<?> range) { return (range != null) ? range.getMinValue() : null; } /** * Returns the minimal value of the given range, or {@code null} if the range is null. */ private static Object getMaxValue(final NumberRange<?> range) { return (range != null) ? range.getMaxValue() : null; } /** * Returns the value in the given cell. * * @param row The index of the row being queried. * @param column The index of the column being queried. * @return The value in the given cell, or {@code null} if none. */ @Override public Object getValueAt(final int row, final int column) { final CategoryRecord record = elements.get(row); switch (column) { case NAME: return record.getName(); case SAMPLE_MIN: return getMinValue(record.getSampleRange()); case SAMPLE_MAX: return getMaxValue(record.getSampleRange()); case MINIMUM: return getMinValue(record.getValueRange()); case MAXIMUM: return getMaxValue(record.getValueRange()); case TYPE: return record.getTransferFunctionType(); case OFFSET: return record.getCoefficient(0); case SCALE: return record.getCoefficient(1); case COLORS: return record.getPaletteName(); default: return null; } } /** * Sets the value in the given cell. * * @param value The new value. * @param row The index of the row being modified. * @param column The index of the column being modified. */ @Override public void setValueAt(final Object value, final int row, final int column) { final CategoryRecord record = elements.get(row); final boolean changed; switch (column) { case NAME: changed = record.setName((String) value); break; case SAMPLE_MIN: changed = record.setSampleRange((Integer) value, null); break; case SAMPLE_MAX: changed = record.setSampleRange(null, (Integer) value); break; case MINIMUM: changed = record.setValueRange((Number) value, null); break; case MAXIMUM: changed = record.setValueRange(null, (Number) value); break; case TYPE: changed = record.setTransferFunctionType((TransferFunctionType) value); break; case OFFSET: changed = record.setCoefficient(0, (Number) value); break; case SCALE: changed = record.setCoefficient(1, (Number) value); break; case COLORS: changed = record.setPaletteName((String) value); break; default: changed = false; break; } if (changed) { // Consider that the whole row has been updated, not only the cell, // because the change in one cell may impact the value in other cells. fireTableRowsUpdated(row, row); if (DEBUG) { System.out.println(record); } } } /** * Returns {@code true} if the given cell is editable. The default implementation * returns the same value than {@link #isEditable()} for every cells. * * @param row The index of the row being queried. * @param column The index of the column being queried. * @return {@code true} if the given cell is editable. */ @Override public boolean isCellEditable(int row, int column) { return editable; } /** * Returns {@code true} if this table is editable. * Every {@code CategoryTable} instances are editable by default. * * @return {@code true} if this table is editable. */ public boolean isEditable() { return editable; } /** * Sets whatever edition should be allowed for any cell in this table. * Editions are enabled by default, like most <cite>Swing</cite> components. * * @param editable {@code false} for disabling edition, or {@code true} for re-enabling it. */ public void setEditable(final boolean editable) { this.editable = editable; } /** * Configures the given model before to allow the edition of a number in a cell. * * {@note This method can configure the spinner for any column, but is currently used only * for the sample values. Spinners were used for the other columns in a older version. The * code has been kept just in case.} */ @SuppressWarnings("fallthrough") final void configure(final SpinnerNumberModel model, final int row, final int column) { final CategoryRecord record = elements.get(row); Comparable<?> minimum = null; Comparable<?> maximum = null; Number step = null; int extremum = -1; switch (column) { case SAMPLE_MAX: extremum = +1; // Fall through case SAMPLE_MIN: { final NumberRange<Integer> range = record.getValidSamples(extremum); minimum = range.getMinValue(); maximum = range.getMaxValue(); // Fall through } case SCALE: { step = 1; break; } case MAXIMUM: extremum = +1; // Fall through case MINIMUM: { final NumberRange<Double> range = record.getValidValues(extremum); minimum = range.getMinValue(); maximum = range.getMaxValue(); break; } } model.setMinimum(minimum); model.setMaximum(maximum); if (step == null) { step = record.getCoefficient(1); // Use the scale factor as the step. if (step != null) { step = Math.abs(step.doubleValue()); } else { step = 1; } } model.setStepSize(step); } /** * Configures the given formatter before to render or edit a cell. */ final void configure(final NumberFormat format, final int row) { elements.get(row).configure(format); } /** * Configures the given {@link JTable} for use with this model. This method performs * the following steps: * <p> * <ul> * <li>{@linkplain JTable#setDefaultRenderer Install the cell renderers}.</li> * <li>{@linkplain JTable#setDefaultEditor Install the cell editors}.</li> * <li>{@linkplain JTable#setRowHeight(int) Modify the row height}.</li> * <li>{@linkplain TableColumn#setPreferredWidth(int) Modify the preferred column width}.</li> * <li>{@linkplain JTable#setPreferredSize Set the table preferred size}.</li> * </ul> * * @param table The table in which to install the cell renderer and editors. */ @SuppressWarnings("unchecked") public void configure(final JTable table) { final NumberEditor numberEditor = new NumberEditor(false); final CellRenderer renderer = new CellRenderer(numberEditor.getFormat()); table.setDefaultRenderer(Double.class, renderer); table.setDefaultRenderer(TransferFunctionType.class, renderer); table.setDefaultEditor (TransferFunctionType.class, new FunctionEditor(renderer.functionLabels)); table.setDefaultEditor (Integer.class, new NumberEditor(true)); table.setDefaultEditor (Double.class, numberEditor); TableColumn column = table.getColumnModel().getColumn(COLORS); final PaletteComboBox palettesChoice = new PaletteComboBox(paletteFactory); palettesChoice.addDefaultColors(); palettesChoice.useAsTableCellEditor(column); try { paletteChoices = ((Callable<ComboBoxModel<ColorPalette>>) column.getCellEditor()).call(); } catch (Exception e) { // Should never happen. If it happen anyway, this is not a fatal error. // But log a complete warning with full stack trace so we can fix. Logging.unexpectedException(null, CategoryTable.class, "configure", e); } table.setRowHeight(ROW_HEIGHT); final TableColumnModel columns = table.getColumnModel(); final int n = columns.getColumnCount(); int total = 0; for (int i=0; i<n; i++) { final int width; switch (i) { case NAME: width = 120; break; case TYPE: width = 110; break; case COLORS: width = 80; break; default: width = 70; break; } column = columns.getColumn(i); column.setPreferredWidth(width); total += width; } table.setPreferredSize(new Dimension(total, ROW_HEIGHT*4)); } /** * A cell renderer for the enclosing {@link CategoryTable}. This renderer replaces the * {@link TransferFunctionType} enumeration by its formula. * <p> * <b>Note:</b> If the formulas are modified, then the {@link CategoryRecord#getValueRange()} * method (and related methods) implementation should be modified accordingly. * * @author Martin Desruisseaux (Geomatys) * @version 3.13 * * @since 3.13 * @module */ @SuppressWarnings("serial") private final class CellRenderer extends DefaultTableCellRenderer { /** * The choices to display in the function type combo box. */ final String[] functionLabels; /** * The format to use for formatting real numbers (not integers). This is the same format, * than the one used by {@link NumberEditor}, in order to use the same format pattern. */ private final NumberFormat format; /** * Creates a new editor for the given locale. * * @param format The format to use for formatting real numbers (not integers). */ CellRenderer(final NumberFormat format) { functionLabels = new String[4]; functionLabels[NONE] = Vocabulary.getResources(locale).getString(Vocabulary.Keys.None); functionLabels[LINEAR] = "<html><var>y</var> = A + B·<var>x</var></html>"; functionLabels[LOGARITHMIC] = "<html><var>y</var> = A + B·log(<var>x</var>)</html>"; functionLabels[EXPONENTIAL] = "<html><var>y</var> = 10<sup>A + B·<var>x</var></sup></html>"; this.format = format; } /** * Returns the cell renderer to use for rendering the given cell value. This method * modifies the value argument (if needed) before to pass them to the default renderer. */ @Override public Component getTableCellRendererComponent(final JTable table, Object value, final boolean isSelected, final boolean hasFocus, final int row, final int column) { final int alignment; switch (column) { case TYPE: { int code; if (TransferFunctionType.LINEAR.equals(value)) { code = LINEAR; } else if (TransferFunctionType.LOGARITHMIC.equals(value)) { code = LOGARITHMIC; } else if (TransferFunctionType.EXPONENTIAL.equals(value)) { code = EXPONENTIAL; } else { code = NONE; } value = functionLabels[code]; alignment = CENTER; break; } case MINIMUM: case MAXIMUM: case OFFSET: case SCALE: { if (value != null) { configure(format, row); value = format.format(((Number) value).doubleValue()); } alignment = TRAILING; break; } default: { alignment = LEADING; break; } } setHorizontalAlignment(alignment); return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); } } /** * A cell editor for the transfer function in the enclosing {@link CategoryTable}. * This editor uses a {@link JComboBox} with the same choices than the ones created * by {@link CellRenderer}. * * @author Martin Desruisseaux (Geomatys) * @version 3.13 * * @since 3.13 * @module */ @SuppressWarnings("serial") private static final class FunctionEditor extends DefaultCellEditor { /** * The choices to display in the function type combo box. * This is the same array than {@link CellRenderer#functionLabels}. */ private final String[] functionLabels; /** * Creates a new editor for the given labels. */ FunctionEditor(final String[] functionLabels) { super(new JComboBox<>(functionLabels)); this.functionLabels = functionLabels; } /** * Converts the given value from {@link TransferFunctionType} to {@link String} * before to set that value in the combo box. */ @Override public Component getTableCellEditorComponent(final JTable table, Object value, final boolean isSelected, final int row, final int column) { final int code; if (TransferFunctionType.LINEAR.equals(value)) { code = LINEAR; } else if (TransferFunctionType.LOGARITHMIC.equals(value)) { code = LOGARITHMIC; } else if (TransferFunctionType.EXPONENTIAL.equals(value)) { code = EXPONENTIAL; } else { code = NONE; } value = functionLabels[code]; return super.getTableCellEditorComponent(table, value, isSelected, row, column); } /** * Gets the selected value from the {@link JComboBox}, and converts * it from {@link String} to {@link TransferFunctionType}. */ @Override public Object getCellEditorValue() { Object value = super.getCellEditorValue(); for (int i=0; i<functionLabels.length; i++) { if (functionLabels[i].equals(value)) { switch (i) { case LINEAR: return TransferFunctionType.LINEAR; case LOGARITHMIC: return TransferFunctionType.LOGARITHMIC; case EXPONENTIAL: return TransferFunctionType.EXPONENTIAL; default: break; } } } return null; } } /** * A cell editor for the numbers in the enclosing {@link CategoryTable}. This editor uses * a {@link JSpinner} for sample values, and {@link JFormattedTextField} for other values. * * {@note An older version used a <code>JSpinner</code> for every values, but the spinners have * been replaced by text fields since the spinners were consuming too much space in the table.} * * @author Martin Desruisseaux (Geomatys) * @version 3.15 * * @since 3.13 * @module */ @SuppressWarnings("serial") private final class NumberEditor extends org.geotoolkit.internal.swing.table.NumberEditor implements ChangeListener { /** * The row and column currently in process of being edited. */ private transient int row, column; /** * Creates a new editor. */ NumberEditor(final boolean sampleValues) { super(locale, sampleValues); } /** * Sets the value together with the minimal and maximal allowed values for the * {@link JSpinner}, then return that component. */ @Override public Component getTableCellEditorComponent(final JTable table, Object value, final boolean isSelected, final int row, final int column) { if (editorComponent instanceof JSpinner) { final SpinnerNumberModel model = (SpinnerNumberModel) ((JSpinner) editorComponent).getModel(); model.removeChangeListener(this); this.row = row; this.column = column; configure(model, row, column); if (value == null) { value = Double.valueOf(column == SCALE ? 1 : 0); } model.setValue(value); model.addChangeListener(this); } else { ((JFormattedTextField) editorComponent).setValue(value); configure(getFormat(), row); // Must be after setValue. } return editorComponent; } /** * Invoked when the {@link JSpinner} value changed. */ @Override public void stateChanged(final ChangeEvent event) { setValueAt(getCellEditorValue(), row, column); } } }