yetanotheraprsc/yetanotheraprsc-code/.svn/pristine/07/0712dfe2818ae8cd7e95bd6017d...

519 lines
23 KiB
Plaintext

package org.ka2ddo.yaac.gui.rastermap;
/*
* Copyright (C) 2011-2021 Andrew Pavlin, KA2DDO
* This file is part of YAAC (Yet Another APRS Client).
*
* YAAC 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, either version 3 of the License, or
* (at your option) any later version.
*
* YAAC 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* and GNU Lesser General Public License along with YAAC. If not,
* see <http://www.gnu.org/licenses/>.
*/
import org.apache.commons.imaging.FormatCompliance;
import org.apache.commons.imaging.ImagingException;
import org.apache.commons.imaging.common.bytesource.ByteSourceInputStream;
import org.apache.commons.imaging.formats.tiff.TiffContents;
import org.apache.commons.imaging.formats.tiff.TiffDirectory;
import org.apache.commons.imaging.formats.tiff.TiffReader;
import org.apache.commons.imaging.formats.tiff.constants.GeoTiffTagConstants;
import org.ka2ddo.util.DebugCtl;
import org.ka2ddo.util.ReschedulableTimer;
import org.ka2ddo.util.ReschedulableTimerTask;
import org.ka2ddo.yaac.YAAC;
import org.ka2ddo.yaac.core.ErrorLogger;
import org.ka2ddo.yaac.core.provider.CoreProvider;
import org.ka2ddo.yaac.gui.GeographicalMap;
import org.ka2ddo.util.NonshareableBufferedDataInputStream;
import org.ka2ddo.yaac.io.NonshareableDataInputStream;
import javax.swing.SwingUtilities;
import javax.swing.table.AbstractTableModel;
import java.awt.Color;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.prefs.Preferences;
import java.util.zip.GZIPInputStream;
/**
* This class maintains a catalog of registered raster map images that can be superimposed
* over the MapBean. Each image has its RasterMapEntry describing the image.
* @see RasterMapEntry
* @author Andrew Pavlin, KA2DDO
*/
public final class RasterMapCatalog extends AbstractTableModel {
private static final Class[] COLUMN_CLASSES = { String.class, Boolean.class, Integer.class, Boolean.class };
private static final String[] COLUMN_NAMES = new String[COLUMN_CLASSES.length];
/**
* Prefix of URLs used to download radar mosaics from US National Weather Service.
*/
public static final String HTTPS_RADAR_WEATHER_PREFIX = "https://mrms.ncep.noaa.gov/data/RIDGEII/L2/";
/**
* Name of Java Preferences node containing RasterMapEntry persistence information.
*/
public static final String RASTER_MAP_PREFS_NODE = "RasterMaps";
static final int MIN_DELAY_BEFORE_REFRESH_MSEC = 300000;
/**
* Collection of raster images to overlay in the containing RasterMapOverlay.
*/
public ArrayList<RasterMapEntry> imageList = new ArrayList<RasterMapEntry>();
final RasterMapOverlay rasterMapOverlay;
final GeographicalMap geoMap;
private static final ReschedulableTimer reloadTimer = new ReschedulableTimer("Raster Image reload timer");
static class MapBounds {
final double minLat, maxLat, minLon, maxLon;
final String dirName;
MapBounds(double minLat, double maxLat, double minLon, double maxLon, String dirName) {
this.minLat = minLat;
this.maxLat = maxLat;
this.minLon = minLon;
this.maxLon = maxLon;
this.dirName = dirName;
}
boolean intersects(double minLat, double maxLat, double minLon, double maxLon) {
return (maxLon > this.minLon) &&
(maxLat > this.minLat) &&
(this.maxLon > minLon) &&
(this.maxLat > minLat);
}
}
static final MapBounds[] MAP_REGIONS = {
new MapBounds(24, 50, -128, -64, "CONUS"),
new MapBounds(50, 72, -176, -126, "ALASKA"),
new MapBounds(13, 15, 144, 146, "GUAM"),
new MapBounds(15, 32, -180, -154, "HAWAII"),
new MapBounds(10, 25, -90, -60, "CARIB")
};
/**
* TimerTask for reloading updated NWS weather raster.
*/
public transient ReschedulableTimerTask WXRASTER_TIMERTASK = new ReschedulableTimerTask() {
public void run() {
RasterMapEntry rme = null;
try {
if (CoreProvider.getInstance().isNonencryptedHTTPForced()) {
throw new IOException("unable to access secure website, YAAC is configured to prohibit encryption");
}
// search for any pre-existing copy of the image
long now = System.currentTimeMillis();
Date expireTime = new Date(now);
for (int idx = 0; idx < imageList.size(); idx++) {
RasterMapEntry testRme = imageList.get(idx);
if (testRme.enabled && testRme.pathname.startsWith(HTTPS_RADAR_WEATHER_PREFIX)) {
rme = testRme;
rme.pinpointList.clear();
if (rme.imgCreationDate != null && expireTime.before(rme.imgCreationDate)) {
expireTime.setTime(rme.imgCreationDate.getTime());
} else {
Date tmpExpireTime = loadReloadableWebMap(rme, now); // note completion of this enables the raster
if (expireTime.before(tmpExpireTime)) {
expireTime.setTime(tmpExpireTime.getTime());
}
}
// have to keep looping as there might be more than one of them
}
}
if (rme == null) {
// see which ones we need based on the bounding box of our map
final int oldNumImgs = imageList.size();
double minLat = geoMap.getBottomLatitude();
double maxLat = geoMap.getTopLatitude();
double minLon = geoMap.getLeftLongitude();
double maxLon = geoMap.getRightLongitude();
for (MapBounds mb : MAP_REGIONS) {
if (mb.intersects(minLat, maxLat, minLon, maxLon)) {
rme = new RasterMapEntry();
rme.pathname = HTTPS_RADAR_WEATHER_PREFIX + mb.dirName + "/CREF_QCD/";
rme.isLinear = true;
rme.keepImg = true;
rme.enabled = true;
rme.persist = true;
imageList.add(rme);
rme.writeToPreferences(YAAC.getPreferences().node(RASTER_MAP_PREFS_NODE));
Date tmpExpireTime = loadReloadableWebMap(rme, now); // note completion of this enables the raster
if (expireTime.before(tmpExpireTime)) {
expireTime.setTime(tmpExpireTime.getTime());
}
}
}
final int newNumImgs = imageList.size();
if (newNumImgs > oldNumImgs) {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
fireTableRowsInserted(oldNumImgs, newNumImgs-1);
}
});
}
}
// in case we need to cancel it
if (rme != null) {
rme.reloadTimerTask = this;
}
// add the image to the raster list
rasterMapOverlay.setShowRasterMaps(true);
rasterMapOverlay.startRegenerate();
geoMap.refresh();
// schedule fetching an update of the map
if (expireTime.getTime() > now) {
int delay = Math.max((int) (expireTime.getTime() - now + 5000 + Math.random() * 10000), MIN_DELAY_BEFORE_REFRESH_MSEC); // wait at least 3 minutes before trying
if (DebugCtl.isDebug("raster")) System.out.println("scheduling NWS radar overlay reload of " + rme.pathname + " in " + delay + " millisec");
resched(reloadTimer, delay);
} else if (rme != null) {
System.out.println(new Date().toString() + ": somehow reloadable maps are expired before download (" +
expireTime.toString() + ')');
}
} catch (Exception e1) {
if (rme != null) {
ErrorLogger.reportError(geoMap, e1, MessageFormat.format(YAAC.getMsg("RasterMap.UnableToLoadImage"), rme.pathname));
}
}
}
};
/**
* Create a RasterMapCatalog instance for a given GeographicalMap window's RasterMapOverlay.
* @param geoMap GeographicalMap using this instance of RasterMapCatalog
* @param rasterMapOverlay RasterMapOverlay using this instance of RasterMapCatalog
*/
public RasterMapCatalog(GeographicalMap geoMap, final RasterMapOverlay rasterMapOverlay) {
this.geoMap = geoMap;
this.rasterMapOverlay = rasterMapOverlay;
rasterMapOverlay.rasterMapCatalog = this;
boolean hasEnabledRasters = false;
Preferences rootNode = YAAC.getPreferences();
try {
if (rootNode.nodeExists(RASTER_MAP_PREFS_NODE)) {
Preferences rasterNode = rootNode.node(RASTER_MAP_PREFS_NODE);
for (String key : rasterNode.keys()) {
byte[] data = rasterNode.getByteArray(key, null);
if (data != null && data.length > 0) {
final RasterMapEntry rme = new RasterMapEntry();
rme.pathname = key;
NonshareableDataInputStream dis = new NonshareableDataInputStream(new ByteArrayInputStream(data));
rme.read(dis);
dis.close();
if (rme.pathname.startsWith("https:") ||
rme.pathname.startsWith("http:")) {
imageList.add(rme);
if (rme.enabled) {
hasEnabledRasters = true;
// run this in the background in case remote webserver is slow when YAAC is starting up
if (!WXRASTER_TIMERTASK.isActive()) {
WXRASTER_TIMERTASK.resched(reloadTimer, 5000); // wait 5 seconds to avoid collisions
}
}
} else if (new File(rme.pathname).exists()) {
imageList.add(rme);
hasEnabledRasters |= rme.enabled;
} else {
System.out.println("saved RasterMapEntry cannot be restored because file is missing: " + key);
rasterNode.remove(key);
continue;
}
System.out.println(new Date().toString() + ": RasterMapCatalog: reloading persisted raster image " + rme);
}
}
}
} catch (Exception e) {
e.printStackTrace(System.out);
}
if (hasEnabledRasters) {
rasterMapOverlay.setShowRasterMaps(true);
rasterMapOverlay.startRegenerate();
geoMap.refresh();
}
COLUMN_NAMES[0] = YAAC.getMsg("RasterMap.table.ImageName");
COLUMN_NAMES[1] = YAAC.getMsg("RasterMap.table.Enable");
COLUMN_NAMES[2] = YAAC.getMsg("RasterMap.table.Transparency");
COLUMN_NAMES[3] = YAAC.getMsg("RasterMap.table.Persist");
}
/**
* Load a fresh gzipped raster GeoTIFF image from a web source.
* @param rme RasterMapEntry for the web-sourced overlay layer
* @param now current time in milliseconds since Jan 1970 UTC
* @return the Date when the loaded raster image will expire and should be loaded again
* @throws IOException if image file cannot be downloaded
*/
public static Date loadReloadableWebMap(RasterMapEntry rme, long now) throws IOException {
// temporarily show this as disabled to prevent render attempts before everything is loaded
rme.enabled = false; // prevent loading race condition when map is re-rendered while still reading the world file
Date expireTime;
HttpURLConnection urlConnection = null;
try {
// load the directory listing to find the most recent image file
long startTime = System.currentTimeMillis();
URL dirUrl = new URL(rme.pathname);
urlConnection = (HttpURLConnection) dirUrl.openConnection();
urlConnection.setRequestProperty("User-Agent", CoreProvider.getInstance().getUserAgent());
NonshareableBufferedDataInputStream dis = null;
String preferredFile = null;
byte[] buf = new byte[65536];
try {
if (urlConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new IOException("unable to read image directory");
}
if (!urlConnection.getContentType().startsWith("text/html")) {
throw new IOException("unable to get directory listing of images");
}
dis = new NonshareableBufferedDataInputStream(urlConnection.getInputStream(), buf);
String line;
while ((line = dis.readLine()) != null) {
int hrefPos;
if ((hrefPos = line.indexOf("href=\"")) > 0) {
int closeQuotePos = line.indexOf("\"", hrefPos + 8);
preferredFile = line.substring(hrefPos+6, closeQuotePos);
}
}
} finally {
if (dis != null) {
dis.close();
}
urlConnection.disconnect();
urlConnection = null;
}
// load the image into memory
long afterDir = System.currentTimeMillis();
URL url = new URL(rme.pathname + preferredFile);
int gzPos = preferredFile.lastIndexOf(".gz");
urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setRequestProperty("User-Agent", CoreProvider.getInstance().getUserAgent());
GZIPInputStream gzis = null;
try {
gzis = new GZIPInputStream(new NonshareableBufferedDataInputStream(urlConnection.getInputStream(), buf), 32768);
ByteSourceInputStream bsis = new ByteSourceInputStream(gzis, preferredFile.substring(0, gzPos));
TiffReader tiffReader = new TiffReader(false);
HashMap<String, Object> imgParams = new HashMap<>();
FormatCompliance formatCompliance = new FormatCompliance("reading NWS GeoTIFF");
TiffContents tiffContents = tiffReader.readContents(bsis, imgParams, formatCompliance);
long afterRead = System.currentTimeMillis();
TiffDirectory tiffDirectory = tiffContents.directories.get(0);
if (tiffDirectory.hasTiffImageData()) {
rme.img = tiffDirectory.getTiffImage();
rme.transparentColor = Color.BLACK;
rme.keepImg = true;
rme.pinpointList.clear();
int width = rme.img.getWidth();
int height = rme.img.getHeight();
// get the GeoTIFF parameters for where this image is displayed on the world
// tiepoints, [3]= UL X coordinate (deg), [4] = UL Y coordinate (deg)
double[] tiepoints = tiffDirectory.getFieldValue(GeoTiffTagConstants.EXIF_TAG_MODEL_TIEPOINT_TAG, true);
// degrees/pixel, [0] for X coord, [1] for Y coord
double[] pixelScaleXYZ = tiffDirectory.getFieldValue(GeoTiffTagConstants.EXIF_TAG_MODEL_PIXEL_SCALE_TAG, true);
double sLat = tiepoints[4] - height * pixelScaleXYZ[1]; // note we need to negate yDim
double eLon = tiepoints[3] + width * pixelScaleXYZ[0];
rme.pinpointList.add(new RasterMapEntry.PinPoint(0, 0, tiepoints[4], tiepoints[3]));
rme.pinpointList.add(new RasterMapEntry.PinPoint(width, height, sLat, eLon));
rme.isLinear = true;
rme.enabled = true;
long done = System.currentTimeMillis();
System.out.println(new Date(done).toString() + ": downloading " + preferredFile + " took " + (afterDir-startTime) + "d+" + (afterRead-afterDir)
+ "r+" + (done-afterRead) + "e msec");
} else {
throw new IOException("TIFF file doesn't contain any images");
}
// headers of interest: Last-Modified (when image was created), Expires (when next image will be created)
rme.imgCreationDate = new Date(urlConnection.getHeaderFieldDate("Last-Modified", now));
expireTime = new Date(urlConnection.getHeaderFieldDate("Expires", now + 30000));
System.out.println("at " + new Date().toString() + ", update time of NWS radar image is " + rme.imgCreationDate.toString() + ", expire=" + expireTime.toString());
} catch (ImagingException ie) {
throw new IOException("unable to read GeoTIFF image", ie);
} finally {
if (gzis != null) {
gzis.close();
}
}
} finally {
if (urlConnection != null) {
urlConnection.disconnect();
}
}
return expireTime;
}
/**
* Add the specified RasterMapEntry to the persisted list of raster map overlays.
* @param rme RasterMapEntry to add
* @return int index of newly added entry
*/
public int add(RasterMapEntry rme) {
imageList.add(rme);
if (rme.persist) {
rme.writeToPreferences(YAAC.getPreferences().node(RASTER_MAP_PREFS_NODE));
}
return imageList.size() - 1;
}
/**
* Remove the indexed record from the persisted RasterMapCatalog.
* @param index zero-based int index of the record to remove
*/
public void remove(int index) {
RasterMapEntry rme = imageList.remove(index);
if (rme.reloadTimerTask != null) {
rme.reloadTimerTask.cancel();
rme.reloadTimerTask = null;
}
YAAC.getPreferences().node(RASTER_MAP_PREFS_NODE).remove(rme.pathname);
}
/**
* Returns the data Class for the specified column.
*
* @param columnIndex the column being queried
* @return the Class object for the column's data
*/
@Override
public Class<?> getColumnClass(int columnIndex) {
return COLUMN_CLASSES[columnIndex];
}
/**
* Returns the localized name for the column.
*
* @param column the column being queried
* @return a string containing the name of <code>column</code>
*/
@Override
public String getColumnName(int column) {
return COLUMN_NAMES[column];
}
/**
* Indicate which columns can be edited.
*
* @param rowIndex the row being queried
* @param columnIndex the column being queried
* @return false
*/
@Override
public boolean isCellEditable(int rowIndex, int columnIndex) {
return 1 <= columnIndex;
}
/**
* Store a new value for an editable table cell.
*
* @param aValue value to assign to cell
* @param rowIndex row of cell
* @param columnIndex column of cell
*/
@Override
public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
RasterMapEntry rme = imageList.get(rowIndex);
switch (columnIndex) {
case 1:
Boolean aValue1 = (Boolean) aValue;
if (aValue1.booleanValue() != rme.enabled) {
rme.enabled = aValue1;
if (rme.persist) {
rme.writeToPreferences(YAAC.getPreferences().node(RASTER_MAP_PREFS_NODE));
}
fireTableCellUpdated(rowIndex, columnIndex);
rasterMapOverlay.repaint();
}
break;
case 2:
rme.transparency = ((Number)aValue).intValue();
if (rme.persist) {
rme.writeToPreferences(YAAC.getPreferences().node(RASTER_MAP_PREFS_NODE));
}
fireTableCellUpdated(rowIndex, columnIndex);
rasterMapOverlay.repaint();
break;
case 3:
rme.persist = ((Boolean)aValue).booleanValue();
if (rme.persist) {
rme.writeToPreferences(YAAC.getPreferences().node(RASTER_MAP_PREFS_NODE));
} else {
rme.removePersistence(YAAC.getPreferences().node(RASTER_MAP_PREFS_NODE));
}
break;
default:
throw new InternalError("attempt to set value on readonly column#" + columnIndex);
}
}
/**
* Returns the number of columns in the model.
*
* @return the number of columns in the model
* @see #getRowCount
*/
public int getColumnCount() {
return COLUMN_CLASSES.length;
}
/**
* Returns the number of rows in the model. A
* <code>JTable</code> uses this method to determine how many rows it
* should display. This method should be quick, as it
* is called frequently during rendering.
*
* @return the number of rows in the model
* @see #getColumnCount
*/
public int getRowCount() {
return imageList.size();
}
/**
* Returns the value for the cell at <code>columnIndex</code> and
* <code>rowIndex</code>.
*
* @param rowIndex the row whose value is to be queried
* @param columnIndex the column whose value is to be queried
* @return the value Object at the specified cell
*/
public Object getValueAt(int rowIndex, int columnIndex) {
RasterMapEntry rme = imageList.get(rowIndex);
switch (columnIndex) {
case 0:
return rme.pathname;
case 1:
return rme.enabled;
case 2:
return rme.transparency;
case 3:
return rme.persist;
default:
throw new IllegalArgumentException("columnIndex=" + columnIndex + " not between 0 and " + (getColumnCount()-1));
}
}
/**
* Delete an entire RasterMapEntry from the catalog.
* @param rowIndex zero-based row index into the catalog table model
*/
public void deleteRow(int rowIndex) {
remove(rowIndex);
fireTableRowsDeleted(rowIndex, rowIndex);
}
}