blob: d816ab68b8a4f8b6c9a6f6b478856134ffe20883 [file] [log] [blame]
/*
* 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.isoline.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);
}
}
}
}