| /* |
| * 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.Set; |
| import java.util.List; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Arrays; |
| import java.util.concurrent.Future; |
| import java.util.concurrent.ExecutionException; |
| import java.awt.Shape; |
| import java.awt.Color; |
| import java.awt.Graphics2D; |
| import java.awt.geom.Rectangle2D; |
| import java.awt.image.RenderedImage; |
| import javafx.application.Platform; |
| import javafx.scene.control.TableView; |
| import javafx.beans.value.ChangeListener; |
| import javafx.beans.value.ObservableValue; |
| import javafx.collections.ObservableList; |
| import javafx.collections.ListChangeListener; |
| import org.opengis.referencing.operation.MathTransform; |
| import org.opengis.referencing.operation.TransformException; |
| import org.apache.sis.internal.gui.control.ColorRamp; |
| import org.apache.sis.internal.processing.image.Isolines; |
| import org.apache.sis.internal.coverage.j2d.ImageUtilities; |
| import org.apache.sis.internal.gui.control.ValueColorMapper.Step; |
| import org.apache.sis.internal.feature.j2d.EmptyShape; |
| import org.apache.sis.internal.feature.j2d.FlatShape; |
| import org.apache.sis.util.ArraysExt; |
| |
| |
| /** |
| * Caches and draws isoline shapes in a {@link CoverageCanvas}. This class is designed for interactive use |
| * in JavaFX widget; this is not a class for doing symbology e.g. in a web service. Most of the work done |
| * by {@code IsolineRenderer} is about listening to changes in {@link TableView}, managing data exchanges |
| * between JavaFX thread and background thread, and computes only isolines that are new compared to previous |
| * rendering. |
| * |
| * @author Martin Desruisseaux (Geomatys) |
| * @version 1.1 |
| * @since 1.1 |
| * @module |
| */ |
| final class IsolineRenderer { |
| /** |
| * The canvas where isolines are drawn. |
| */ |
| private final CoverageCanvas canvas; |
| |
| /** |
| * The list of isoline values and associated shapes for each band, or {@code null} if none. |
| */ |
| private Band[] bands; |
| |
| /** |
| * Creates an initially empty set of isolines. |
| * |
| * @param canvas the canvas where isolines are drawn. |
| */ |
| public IsolineRenderer(final CoverageCanvas canvas) { |
| if (canvas.isolines != null) { |
| throw new IllegalArgumentException(); |
| } |
| this.canvas = canvas; |
| canvas.isolines = this; |
| } |
| |
| /** |
| * Returns {@code true} if there are no isolines to show. |
| * This method shall be invoked in JavaFX thread. |
| */ |
| private boolean isEmpty() { |
| if (bands != null) { |
| for (final Band band : bands) { |
| for (final Step level : band.steps) { |
| if (level.visible.get()) { |
| return false; |
| } |
| } |
| } |
| } |
| return true; |
| } |
| |
| /** |
| * Clears the cache. This method shall be invoked when the image used for computing isolines has changed, |
| * or when the {@code gridToCRS} transform has changed. This method shall be invoked in JavaFX thread. |
| */ |
| final void clear() { |
| assert Platform.isFxApplicationThread(); |
| if (bands != null) { |
| for (final Band band : bands) { |
| band.clear(); |
| } |
| } |
| } |
| |
| /** |
| * Sets the isoline values for all bands from the content of tables edited by user. |
| * This method registers listener on the given lists for repainting the isolines |
| * when table content changed. |
| */ |
| public void setIsolineTables(final List<ObservableList<Step>> tables) { |
| if (bands != null) { |
| for (final Band band : bands) { |
| band.dispose(); |
| } |
| } |
| bands = null; |
| if (tables != null && !tables.isEmpty()) { |
| bands = new Band[tables.size()]; |
| for (int i=0; i<bands.length; i++) { |
| bands[i] = new Band(tables.get(i)); |
| } |
| } |
| } |
| |
| /** |
| * List of isoline values and associated shapes for a single band. |
| */ |
| private final class Band implements ListChangeListener<Step>, ChangeListener<Object> { |
| /** |
| * The isoline levels to draw, together with their color. |
| * Considered as read-only (in JavaFX thread) by this class. |
| */ |
| final ObservableList<Step> steps; |
| |
| /** |
| * Cache of Java2D shapes for each isoline level. Shall be read in written in JavaFX thread. |
| * New isolines are computed in background thread and results stored later in JavaFX thread. |
| * |
| * @see Snapshot#isolines |
| */ |
| private Map<Double,Shape> isolines; |
| |
| /** |
| * Creates an initially empty set of isolines. |
| * |
| * @param steps the list of isoline levels to render. |
| */ |
| @SuppressWarnings("ThisEscapedInObjectConstruction") |
| Band(final ObservableList<Step> steps) { |
| this.steps = steps; |
| addListeners(steps); |
| steps.addListener(this); |
| } |
| |
| /** |
| * Clears the cache. This method shall be invoked when the image used for computing isolines has changed, |
| * or when the {@code gridToCRS} transform has changed. This method shall be invoked in JavaFX thread. |
| * |
| * @see IsolineRenderer#clear() |
| */ |
| final void clear() { |
| // Force new instance instead of `Map.clear()` because previous instance may be used by `Snapshot`. |
| isolines = null; |
| } |
| |
| /** |
| * Unregisters all listeners. This method should be invoked before this {@code Band} instance is discarded. |
| */ |
| final void dispose() { |
| clear(); |
| removeListeners(steps); |
| } |
| |
| /** |
| * Invoked when steps are added or removed from the observable list. |
| * This method is public as an implementation side-effect and should not be invoked directly. |
| * |
| * @param change set of steps which have been added or removed. |
| */ |
| @Override |
| public void onChanged(final Change<? extends Step> change) { |
| while (change.next()) { |
| if (!change.wasPermutated() && !change.wasUpdated()) { |
| removeListeners(change.getRemoved()); |
| addListeners(change.getAddedSubList()); |
| } |
| } |
| canvas.requestRepaint(); |
| } |
| |
| /** |
| * Unregisters listeners form all properties in the given list. |
| */ |
| private void removeListeners(final List<? extends Step> list) { |
| for (final Step level : list) { |
| level.value .removeListener(this); |
| level.color .removeListener(this); |
| level.visible.removeListener(this); |
| } |
| } |
| |
| /** |
| * Registers listeners on all properties in the given list. |
| */ |
| private void addListeners(final List<? extends Step> list) { |
| for (final Step level : list) { |
| level.value .addListener(this); |
| level.color .addListener(this); |
| level.visible.addListener(this); |
| } |
| } |
| |
| /** |
| * Invoked when an isoline value, color or visibility has changed. |
| * This method is public as an implementation side-effect and should not be invoked directly. |
| * |
| * @param property one of the properties defined in the {@link Step} class. |
| * @param oldValue the old property value, or {@code null} if none. |
| * @param newValue the new property value, or {@code null} if none. |
| */ |
| @Override |
| public void changed(final ObservableValue<?> property, final Object oldValue, final Object newValue) { |
| canvas.requestRepaint(); |
| } |
| |
| /** |
| * Creates a snapshot of the list of isolines to draw, together with lists of cached shapes |
| * and shapes that need to be computed. This snapshot is created in JavaFX thread and used |
| * in a background thread. |
| * |
| * @param keep an empty set used by this method for listing the levels to keep in the cache. |
| * @return a snapshot of current {@code Band} state for use by a background thread. |
| * |
| * @see IsolineRenderer#prepare() |
| */ |
| final Snapshot prepare(final Set<Double> keep) { |
| if (isolines == null) { |
| isolines = new HashMap<>(); |
| } |
| final Snapshot s = new Snapshot(isolines, steps.size()); |
| for (final Step level : steps) { |
| final Double value = level.value.get(); |
| if (!value.isNaN()) { |
| keep.add(value); |
| if (level.visible.get()) { |
| final ColorRamp cr = level.color.get(); |
| if (cr != null && !cr.isTransparent()) { |
| s.add(value, cr.colors[0]); |
| } |
| } |
| } |
| } |
| isolines.keySet().retainAll(keep); // Discard shapes that are no longer in use. |
| keep.clear(); |
| return s; |
| } |
| } |
| |
| /** |
| * Prepares a list of isolines to draw for each bands, initially populated with shapes that are already available. |
| * This method shall be invoked in JavaFX thread for having consistent information. The snapshots returned by this |
| * method will be completed and used in a background thread. |
| * |
| * @return snapshots of information about isolines in each bands, or {@code null} if none. |
| */ |
| final Snapshot[] prepare() { |
| assert Platform.isFxApplicationThread(); |
| if (isEmpty()) { |
| return null; |
| } |
| final Snapshot[] snapshots = new Snapshot[bands.length]; |
| final Set<Double> keep = new HashSet<>(); |
| for (int i=0; i < snapshots.length; i++) { |
| snapshots[i] = bands[i].prepare(keep); |
| } |
| return snapshots; |
| } |
| |
| /** |
| * Continues isoline preparation by computing the missing Java2D shapes. |
| * This method shall be invoked in a background thread. After this call, |
| * {@link #complete(Snapshot[], Future)} needs to be invoked before |
| * isolines can be painted with {@link Snapshot#paint(Graphics2D, Rectangle2D)}. |
| * |
| * @param snapshots value of {@link #prepare()}. Shall not be {@code null}. |
| * @param data the source of data. Used only if there is new isolines to compute. |
| * @param gridToCRS transform from pixel coordinates to geometry coordinates, or {@code null} if none. |
| * Integer source coordinates are located at pixel centers. |
| * @return result of isolines generation, or {@code null} if there are no isolines to compute. |
| * @throws TransformException if an interpolated point can not be transformed using the given transform. |
| */ |
| @SuppressWarnings("UseOfSystemOutOrSystemErr") // Used only for debugging. |
| static Future<Isolines[]> generate(final Snapshot[] snapshots, final RenderedImage data, final MathTransform gridToCRS) |
| throws TransformException |
| { |
| assert !Platform.isFxApplicationThread(); |
| double[][] levels = null; |
| final int numBands = ImageUtilities.getNumBands(data); |
| final int numViews = Math.min(numBands, snapshots.length); |
| for (int i=0; i<numViews; i++) { |
| final Snapshot s = snapshots[i]; |
| int n = s.missingLevels.size(); |
| if (n != 0) { |
| if (levels == null) { |
| levels = new double[numBands][]; |
| Arrays.fill(levels, ArraysExt.EMPTY_DOUBLE); |
| } |
| final double[] values = new double[n]; |
| for (final Double value : s.missingLevels.keySet()) { |
| values[--n] = value; |
| } |
| assert n == 0 : n; |
| levels[i] = values; |
| } |
| } |
| /* |
| * Compute only the isolines that are not already available. For performance reasons, we need to process |
| * all bands in one single call to `Isolines.generate(…)`. Results are written in empty slots of `shapes`. |
| */ |
| if (levels != null) { |
| if (CoverageCanvas.TRACE) { |
| System.out.println("IsolineRenderer.complete(…):"); |
| for (int i=0; i<levels.length; i++) { |
| System.out.printf("\tFor band %d: %s%n", i, Arrays.toString(levels[i])); |
| } |
| } |
| return Isolines.parallelGenerate(data, levels, gridToCRS); |
| } |
| return null; |
| } |
| |
| /** |
| * Waits for completion of isolines generation if not already finished, then stores the result. |
| * The {@code isolines} argument is the {@link #generate(Snapshot[], RenderedImage, MathTransform)} |
| * return value. Caller shall verify that {@code snapshots} is non-null. |
| * |
| * @param snapshots where to store the result of isoline computations. |
| * @param newIsolines the result of isolines generation, or {@code null} if none. |
| */ |
| static void complete(final Snapshot[] snapshots, final Future<Isolines[]> newIsolines) |
| throws ExecutionException, InterruptedException |
| { |
| final Isolines[] isolines = newIsolines.get(); |
| final int n = Math.min(snapshots.length, isolines.length); |
| int i; |
| for (i=0; i<n; i++) { |
| snapshots[i].complete(isolines[i]); |
| } |
| while (i < snapshots.length) { // Clear remaining snapshots if any. |
| snapshots[i++].clear(); |
| } |
| } |
| |
| /** |
| * Snapshot of {@link Band} information before rendering. Life cycle is: |
| * |
| * <ol> |
| * <li>Created in JavaFX thread with shapes already available.</li> |
| * <li>Missing Java2D shapes completed in a background thread.</li> |
| * <li>Painting done in background thread.</li> |
| * <li>New shapes cached in JavaFX thread.</li> |
| * </ol> |
| */ |
| static final class Snapshot { |
| /** |
| * Isolines available before snapshot, and where to store new isolines after completion. |
| * This map shall be read and written in JavaFX thread only. This is initially the same |
| * reference than {@link Band#isolines}, but may become different if {@link Band#clear()} |
| * is invoked while isolines computation or painting is in progress. |
| * |
| * @see Band#isolines |
| */ |
| private final Map<Double,Shape> isolines; |
| |
| /** |
| * The isoline levels that are missing in the {@link #isolines} map. This map is populated |
| * in JavaFX thread and consumed in the background thread. |
| */ |
| private final Map<Double,Integer> missingLevels; |
| |
| /** |
| * Isolines that have been created, or {@code null} if none. They are the shapes to store in |
| * the {@link #isolines} map after the isoline painting is completed. Stored in a separated map |
| * because we must wait to be back to JavaFX thread before we can write in {@link #isolines}. |
| */ |
| private Map<Double,Shape> newIsolines; |
| |
| /** |
| * The isoline shapes to draw. May contain {@code null} elements if some shapes are missing. |
| * Those missing shapes will be computed in a background thread. |
| */ |
| private final Shape[] shapes; |
| |
| /** |
| * Shape colors, obtained in JavaFX thread. |
| */ |
| private final int[] colors; |
| |
| /** |
| * Number of valid elements in {@link #shapes} and {@link #colors} array. |
| */ |
| private int count; |
| |
| /** |
| * Creates a new snapshot of {@link Band} information. |
| * |
| * @param isolines value of {@link Band#isolines} reference. |
| * @param capacity maximal amount of isoline levels that can be {@linkplain #add added}. |
| */ |
| private Snapshot(final Map<Double,Shape> isolines, final int capacity) { |
| this.isolines = isolines; |
| missingLevels = new HashMap<>(); |
| shapes = new Shape[capacity]; |
| colors = new int[capacity]; |
| } |
| |
| /** |
| * Removes all isolines. |
| */ |
| private void clear() { |
| isolines.clear(); |
| missingLevels.clear(); |
| newIsolines = null; |
| Arrays.fill(shapes, null); |
| count = 0; |
| } |
| |
| /** |
| * Adds an isoline level. This method shall be invoked in JavaFX thread. |
| * |
| * @param value the level value. |
| * @param color color of the isolines to draw for the specified value. |
| */ |
| private void add(final Double value, final int color) { |
| final Shape shape = isolines.putIfAbsent(value, EmptyShape.INSTANCE); |
| if (shape == null) { |
| missingLevels.put(value, count); |
| } |
| shapes[count] = shape; |
| colors[count] = color; |
| count++; |
| } |
| |
| /** |
| * Completes the {@link #shapes} array by assigning a shapes to null elements. |
| * |
| * @param isolines missing isolines computed for the band of this snapshot. |
| */ |
| private void complete(final Isolines isolines) { |
| newIsolines = isolines.polylines(); |
| for (final Map.Entry<Double,Shape> entry : newIsolines.entrySet()) { |
| final Integer j = missingLevels.get(entry.getKey()); |
| if (j != null) shapes[j] = entry.getValue(); |
| } |
| } |
| |
| /** |
| * Paints all isolines in the given graphics. |
| * This method should be invoked in a background thread. |
| * |
| * @param target where to draw isolines. |
| * @param areaOfInterest the area where isolines will be drawn, or {@code null} if unknown. |
| */ |
| final void paint(final Graphics2D target, final Rectangle2D areaOfInterest) { |
| for (int i=0; i<count; i++) { |
| Shape shape = shapes[i]; |
| if (shape != null) { |
| if (areaOfInterest != null && shape instanceof FlatShape) { |
| shape = ((FlatShape) shape).fastClip(areaOfInterest); |
| if (shape == null) continue; |
| } |
| target.setColor(new Color(colors[i], true)); |
| target.draw(shape); |
| } |
| } |
| } |
| |
| /** |
| * Invoked in JavaFX thread after successful rendering for caching the new isolines. |
| */ |
| final void commit() { |
| assert Platform.isFxApplicationThread(); |
| if (newIsolines != null) { |
| isolines.putAll(newIsolines); |
| } |
| } |
| } |
| } |