519 lines
23 KiB
Plaintext
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);
|
|
}
|
|
}
|