Issue #2185 Cache computed grid reprojections.
Former-commit-id: 891f2241dd238b2901f8c1daee8f2236558f42e0
This commit is contained in:
parent
08a90aa293
commit
ae8110acf6
5 changed files with 173 additions and 94 deletions
|
@ -20,13 +20,8 @@
|
||||||
package com.raytheon.uf.viz.kml.export.graphics.ext;
|
package com.raytheon.uf.viz.kml.export.graphics.ext;
|
||||||
|
|
||||||
import java.awt.image.RenderedImage;
|
import java.awt.image.RenderedImage;
|
||||||
import java.lang.ref.Reference;
|
|
||||||
import java.lang.ref.SoftReference;
|
|
||||||
import java.nio.FloatBuffer;
|
import java.nio.FloatBuffer;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.apache.commons.collections.keyvalue.MultiKey;
|
|
||||||
import org.eclipse.swt.graphics.RGB;
|
import org.eclipse.swt.graphics.RGB;
|
||||||
import org.geotools.coverage.grid.GridEnvelope2D;
|
import org.geotools.coverage.grid.GridEnvelope2D;
|
||||||
import org.geotools.coverage.grid.GridGeometry2D;
|
import org.geotools.coverage.grid.GridGeometry2D;
|
||||||
|
@ -41,6 +36,7 @@ import com.raytheon.uf.common.geospatial.interpolation.BilinearInterpolation;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.GridReprojection;
|
import com.raytheon.uf.common.geospatial.interpolation.GridReprojection;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.Interpolation;
|
import com.raytheon.uf.common.geospatial.interpolation.Interpolation;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.NearestNeighborInterpolation;
|
import com.raytheon.uf.common.geospatial.interpolation.NearestNeighborInterpolation;
|
||||||
|
import com.raytheon.uf.common.geospatial.interpolation.PrecomputedGridReprojection;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.data.DataSource;
|
import com.raytheon.uf.common.geospatial.interpolation.data.DataSource;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.data.FloatArrayWrapper;
|
import com.raytheon.uf.common.geospatial.interpolation.data.FloatArrayWrapper;
|
||||||
import com.raytheon.uf.viz.core.DrawableImage;
|
import com.raytheon.uf.viz.core.DrawableImage;
|
||||||
|
@ -62,7 +58,8 @@ import de.micromata.opengis.kml.v_2_2_0.LatLonBox;
|
||||||
*
|
*
|
||||||
* Date Ticket# Engineer Description
|
* Date Ticket# Engineer Description
|
||||||
* ------------ ---------- ----------- --------------------------
|
* ------------ ---------- ----------- --------------------------
|
||||||
* Jun 14, 2012 bsteffen Initial creation
|
* Jun 14, 2012 bsteffen Initial creation
|
||||||
|
* Jul 17, 2013 2185 bsteffen Cache computed grid reprojections.
|
||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
|
@ -72,8 +69,6 @@ import de.micromata.opengis.kml.v_2_2_0.LatLonBox;
|
||||||
|
|
||||||
public abstract class KmlGroundOverlayGenerator extends KmlFeatureGenerator {
|
public abstract class KmlGroundOverlayGenerator extends KmlFeatureGenerator {
|
||||||
|
|
||||||
private static Map<MultiKey, Reference<GridReprojection>> reprojCache = new HashMap<MultiKey, Reference<GridReprojection>>();
|
|
||||||
|
|
||||||
protected final double alpha;
|
protected final double alpha;
|
||||||
|
|
||||||
protected final DrawableImage[] images;
|
protected final DrawableImage[] images;
|
||||||
|
@ -133,27 +128,7 @@ public abstract class KmlGroundOverlayGenerator extends KmlFeatureGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected GridReprojection getReprojection(GridGeometry2D src,
|
protected GridReprojection getReprojection(GridGeometry2D src,
|
||||||
GridGeometry2D dest) throws FactoryException, TransformException {
|
GridGeometry2D dest) throws TransformException {
|
||||||
MultiKey key = new MultiKey(src, dest);
|
return PrecomputedGridReprojection.getReprojection(src, dest);
|
||||||
GridReprojection reproj = null;
|
|
||||||
boolean needsCompute = false;
|
|
||||||
synchronized (reprojCache) {
|
|
||||||
Reference<GridReprojection> reprojRef = reprojCache.get(key);
|
|
||||||
if (reprojRef != null) {
|
|
||||||
reproj = reprojRef.get();
|
|
||||||
}
|
|
||||||
if (reproj == null) {
|
|
||||||
reproj = new GridReprojection(src, dest);
|
|
||||||
needsCompute = true;
|
|
||||||
reprojCache.put(key,
|
|
||||||
new SoftReference<GridReprojection>(reproj));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
synchronized (reproj) {
|
|
||||||
if (needsCompute) {
|
|
||||||
reproj.computeTransformTable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return reproj;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ import com.raytheon.uf.common.geospatial.MapUtil;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.GridReprojection;
|
import com.raytheon.uf.common.geospatial.interpolation.GridReprojection;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.GridSampler;
|
import com.raytheon.uf.common.geospatial.interpolation.GridSampler;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.Interpolation;
|
import com.raytheon.uf.common.geospatial.interpolation.Interpolation;
|
||||||
|
import com.raytheon.uf.common.geospatial.interpolation.PrecomputedGridReprojection;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.data.FloatArrayWrapper;
|
import com.raytheon.uf.common.geospatial.interpolation.data.FloatArrayWrapper;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.data.FloatBufferWrapper;
|
import com.raytheon.uf.common.geospatial.interpolation.data.FloatBufferWrapper;
|
||||||
import com.vividsolutions.jts.geom.Coordinate;
|
import com.vividsolutions.jts.geom.Coordinate;
|
||||||
|
@ -56,7 +57,8 @@ import com.vividsolutions.jts.geom.Coordinate;
|
||||||
*
|
*
|
||||||
* Date Ticket# Engineer Description
|
* Date Ticket# Engineer Description
|
||||||
* ------------ ---------- ----------- --------------------------
|
* ------------ ---------- ----------- --------------------------
|
||||||
* Mar 9, 2011 bsteffen Initial creation
|
* Mar 09, 2011 bsteffen Initial creation
|
||||||
|
* Jul 17, 2013 2185 bsteffen Cache computed grid reprojections.
|
||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
|
@ -217,7 +219,8 @@ public class GeneralGridData {
|
||||||
Interpolation interpolation) throws FactoryException,
|
Interpolation interpolation) throws FactoryException,
|
||||||
TransformException {
|
TransformException {
|
||||||
GridGeometry2D newGeom = GridGeometry2D.wrap(newGridGeometry);
|
GridGeometry2D newGeom = GridGeometry2D.wrap(newGridGeometry);
|
||||||
GridReprojection reproj = new GridReprojection(gridGeometry, newGeom);
|
GridReprojection reproj = PrecomputedGridReprojection.getReprojection(
|
||||||
|
gridGeometry, newGeom);
|
||||||
GridSampler sampler = new GridSampler(interpolation);
|
GridSampler sampler = new GridSampler(interpolation);
|
||||||
if (isVector()) {
|
if (isVector()) {
|
||||||
sampler.setSource(new FloatBufferWrapper(getUComponent(),
|
sampler.setSource(new FloatBufferWrapper(getUComponent(),
|
||||||
|
|
|
@ -46,6 +46,7 @@ import com.raytheon.uf.common.geospatial.MapUtil;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.BilinearInterpolation;
|
import com.raytheon.uf.common.geospatial.interpolation.BilinearInterpolation;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.GridReprojection;
|
import com.raytheon.uf.common.geospatial.interpolation.GridReprojection;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.NearestNeighborInterpolation;
|
import com.raytheon.uf.common.geospatial.interpolation.NearestNeighborInterpolation;
|
||||||
|
import com.raytheon.uf.common.geospatial.interpolation.PrecomputedGridReprojection;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.data.ByteBufferWrapper;
|
import com.raytheon.uf.common.geospatial.interpolation.data.ByteBufferWrapper;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.data.DataSource;
|
import com.raytheon.uf.common.geospatial.interpolation.data.DataSource;
|
||||||
import com.raytheon.uf.common.geospatial.interpolation.data.FloatArrayWrapper;
|
import com.raytheon.uf.common.geospatial.interpolation.data.FloatArrayWrapper;
|
||||||
|
@ -60,9 +61,11 @@ import com.vividsolutions.jts.geom.Coordinate;
|
||||||
* SOFTWARE HISTORY
|
* SOFTWARE HISTORY
|
||||||
* Date Ticket# Engineer Description
|
* Date Ticket# Engineer Description
|
||||||
* ------------ ---------- ----------- --------------------------
|
* ------------ ---------- ----------- --------------------------
|
||||||
* 5/16/08 875 bphillip Initial Creation.
|
* May 16, 2008 875 bphillip Initial Creation.
|
||||||
* 10/10/12 #1260 randerso Added getters for source and destination glocs
|
* Oct 10, 2012 1260 randerso Added getters for source and destination
|
||||||
* 02/19/13 #1637 randerso Fixed remapping of byte grids
|
* glocs
|
||||||
|
* Feb 19, 2013 1637 randerso Fixed remapping of byte grids
|
||||||
|
* Jul 17, 2013 2185 bsteffen Cache computed grid reprojections.
|
||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
|
@ -462,8 +465,8 @@ public class RemapGrid {
|
||||||
GridGeometry2D destGeometry = MapUtil.getGridGeometry(destinationGloc);
|
GridGeometry2D destGeometry = MapUtil.getGridGeometry(destinationGloc);
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (interp == null) {
|
if (interp == null) {
|
||||||
interp = new GridReprojection(sourceGeometry, destGeometry);
|
interp = PrecomputedGridReprojection.getReprojection(
|
||||||
interp.computeTransformTable();
|
sourceGeometry, destGeometry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DataSource source = new ByteBufferWrapper(data, sourceGeometry);
|
DataSource source = new ByteBufferWrapper(data, sourceGeometry);
|
||||||
|
@ -540,8 +543,8 @@ public class RemapGrid {
|
||||||
.getGridGeometry(destinationGloc);
|
.getGridGeometry(destinationGloc);
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (interp == null) {
|
if (interp == null) {
|
||||||
interp = new GridReprojection(sourceGeometry, destGeometry);
|
interp = PrecomputedGridReprojection.getReprojection(
|
||||||
interp.computeTransformTable();
|
sourceGeometry, destGeometry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DataSource source = new FloatArrayWrapper(data, sourceGeometry);
|
DataSource source = new FloatArrayWrapper(data, sourceGeometry);
|
||||||
|
|
|
@ -46,7 +46,8 @@ import com.raytheon.uf.common.geospatial.interpolation.data.DataSource;
|
||||||
*
|
*
|
||||||
* Date Ticket# Engineer Description
|
* Date Ticket# Engineer Description
|
||||||
* ------------ ---------- ----------- --------------------------
|
* ------------ ---------- ----------- --------------------------
|
||||||
* Jun 18, 2012 bsteffen Initial creation
|
* Jun 18, 2012 bsteffen Initial creation
|
||||||
|
* Jul 17, 2013 2185 bsteffen Cache computed grid reprojections.
|
||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
|
@ -66,8 +67,6 @@ public class GridReprojection {
|
||||||
|
|
||||||
protected MathTransform transform;
|
protected MathTransform transform;
|
||||||
|
|
||||||
protected float[] transformTable = null;
|
|
||||||
|
|
||||||
public GridReprojection(GeneralGridGeometry sourceGeometry,
|
public GridReprojection(GeneralGridGeometry sourceGeometry,
|
||||||
GeneralGridGeometry targetGeometry) {
|
GeneralGridGeometry targetGeometry) {
|
||||||
this.sourceGeometry = sourceGeometry;
|
this.sourceGeometry = sourceGeometry;
|
||||||
|
@ -128,63 +127,11 @@ public class GridReprojection {
|
||||||
protected Point2D.Double getReprojectDataPoint(int x, int y)
|
protected Point2D.Double getReprojectDataPoint(int x, int y)
|
||||||
throws TransformException, FactoryException {
|
throws TransformException, FactoryException {
|
||||||
initTransforms();
|
initTransforms();
|
||||||
if (transformTable != null && x >= 0 && x < targetNx && y >= 0
|
|
||||||
&& y < targetNy) {
|
|
||||||
int index = (y * targetNx + x) * 2;
|
|
||||||
float xVal = transformTable[index];
|
|
||||||
float yVal = transformTable[index + 1];
|
|
||||||
if (!Float.isNaN(xVal) && !Float.isNaN(yVal)) {
|
|
||||||
return new Point2D.Double(xVal, yVal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DirectPosition2D dp = new DirectPosition2D(x, y);
|
DirectPosition2D dp = new DirectPosition2D(x, y);
|
||||||
transform.transform(dp, dp);
|
transform.transform(dp, dp);
|
||||||
return dp;
|
return dp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This function precomputes the math transform for all grid cells in the
|
|
||||||
* target grid range. This method is recommended when you are reprojecting
|
|
||||||
* multiple datasets using the same interpolation. Precalculating this table
|
|
||||||
* takes time and uses more memory but cuts the time to perform
|
|
||||||
* interpolation significantly.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* @return the size in bytes of the extra memory used by the transform
|
|
||||||
* table.
|
|
||||||
* @throws FactoryException
|
|
||||||
* @throws TransformException
|
|
||||||
*/
|
|
||||||
public int computeTransformTable() throws FactoryException,
|
|
||||||
TransformException {
|
|
||||||
initTransforms();
|
|
||||||
float[] transformTable = new float[targetNy * targetNx * 2];
|
|
||||||
int index = 0;
|
|
||||||
for (int j = 0; j < targetNy; j++) {
|
|
||||||
for (int i = 0; i < targetNx; i++) {
|
|
||||||
transformTable[index++] = i;
|
|
||||||
transformTable[index++] = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
transform.transform(transformTable, 0, transformTable, 0, targetNy
|
|
||||||
* targetNx);
|
|
||||||
} catch (ProjectionException e) {
|
|
||||||
;// Ignore, the points in the transformTable that are invalid are
|
|
||||||
// set to NaN, no other action is necessary.
|
|
||||||
}
|
|
||||||
this.transformTable = transformTable;
|
|
||||||
return transformTable.length * 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* delete the transform table, freeing up memory but slowing down any future
|
|
||||||
* reprojections
|
|
||||||
*/
|
|
||||||
public void clearTransformTable() {
|
|
||||||
transformTable = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public GeneralGridGeometry getSourceGeometry() {
|
public GeneralGridGeometry getSourceGeometry() {
|
||||||
return sourceGeometry;
|
return sourceGeometry;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* This software was developed and / or modified by Raytheon Company,
|
||||||
|
* pursuant to Contract DG133W-05-CQ-1067 with the US Government.
|
||||||
|
*
|
||||||
|
* U.S. EXPORT CONTROLLED TECHNICAL DATA
|
||||||
|
* This software product contains export-restricted data whose
|
||||||
|
* export/transfer/disclosure is restricted by U.S. law. Dissemination
|
||||||
|
* to non-U.S. persons whether in the United States or abroad requires
|
||||||
|
* an export license or other authorization.
|
||||||
|
*
|
||||||
|
* Contractor Name: Raytheon Company
|
||||||
|
* Contractor Address: 6825 Pine Street, Suite 340
|
||||||
|
* Mail Stop B8
|
||||||
|
* Omaha, NE 68106
|
||||||
|
* 402.291.0100
|
||||||
|
*
|
||||||
|
* See the AWIPS II Master Rights File ("Master Rights File.pdf") for
|
||||||
|
* further licensing information.
|
||||||
|
**/
|
||||||
|
package com.raytheon.uf.common.geospatial.interpolation;
|
||||||
|
|
||||||
|
import java.awt.geom.Point2D;
|
||||||
|
import java.lang.ref.Reference;
|
||||||
|
import java.lang.ref.SoftReference;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.apache.commons.collections.keyvalue.MultiKey;
|
||||||
|
import org.geotools.coverage.grid.GeneralGridGeometry;
|
||||||
|
import org.geotools.referencing.operation.projection.ProjectionException;
|
||||||
|
import org.opengis.referencing.FactoryException;
|
||||||
|
import org.opengis.referencing.operation.TransformException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GridReprojection which precomputes the coordinates of all the grid cells so
|
||||||
|
* that multiple reprojects will be much faster. This implements a memory/time
|
||||||
|
* tradeoff, using much more memory than an ordinary GridReprojection so that it
|
||||||
|
* is able to reproject much faster. Because of the high memory usage all
|
||||||
|
* instances are cached so they can be shared for identical reprojections.
|
||||||
|
*
|
||||||
|
* The current caching implementation uses soft references. When the
|
||||||
|
* reprojection is no longer referenced then the memory will be reclaimed by the
|
||||||
|
* JVM as needed.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
*
|
||||||
|
* SOFTWARE HISTORY
|
||||||
|
*
|
||||||
|
* Date Ticket# Engineer Description
|
||||||
|
* ------------ ---------- ----------- --------------------------
|
||||||
|
* Jul 17, 2013 2185 bsteffen Initial creation
|
||||||
|
*
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @author bsteffen
|
||||||
|
* @version 1.0
|
||||||
|
*/
|
||||||
|
public class PrecomputedGridReprojection extends GridReprojection {
|
||||||
|
|
||||||
|
protected float[] transformTable;
|
||||||
|
|
||||||
|
protected PrecomputedGridReprojection(GeneralGridGeometry sourceGeometry,
|
||||||
|
GeneralGridGeometry targetGeometry) {
|
||||||
|
super(sourceGeometry, targetGeometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function precomputes the math transform for all grid cells in the
|
||||||
|
* target grid range. Precalculating this table takes time and uses more
|
||||||
|
* memory but cuts the time to perform interpolation significantly.
|
||||||
|
*
|
||||||
|
* @throws TransformException
|
||||||
|
*/
|
||||||
|
protected void computeTransformTable() throws TransformException {
|
||||||
|
try {
|
||||||
|
initTransforms();
|
||||||
|
} catch (FactoryException e) {
|
||||||
|
throw new TransformException("Error preparing transform.", e);
|
||||||
|
}
|
||||||
|
float[] transformTable = new float[targetNy * targetNx * 2];
|
||||||
|
int index = 0;
|
||||||
|
for (int j = 0; j < targetNy; j++) {
|
||||||
|
for (int i = 0; i < targetNx; i++) {
|
||||||
|
transformTable[index++] = i;
|
||||||
|
transformTable[index++] = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
transform.transform(transformTable, 0, transformTable, 0, targetNy
|
||||||
|
* targetNx);
|
||||||
|
} catch (ProjectionException e) {
|
||||||
|
;// Ignore the points in the transformTable that are
|
||||||
|
// invalid are set to NaN, no other action is necessary.
|
||||||
|
}
|
||||||
|
this.transformTable = transformTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Point2D.Double getReprojectDataPoint(int x, int y)
|
||||||
|
throws TransformException, FactoryException {
|
||||||
|
if (x >= 0 && x < targetNx && y >= 0 && y < targetNy) {
|
||||||
|
int index = (y * targetNx + x) * 2;
|
||||||
|
float xVal = transformTable[index];
|
||||||
|
float yVal = transformTable[index + 1];
|
||||||
|
if (!Float.isNaN(xVal) && !Float.isNaN(yVal)) {
|
||||||
|
return new Point2D.Double(xVal, yVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.getReprojectDataPoint(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<MultiKey, Reference<PrecomputedGridReprojection>> cache = new HashMap<MultiKey, Reference<PrecomputedGridReprojection>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a shared GridReprojection. This reprojection will have the transform
|
||||||
|
* table computed.
|
||||||
|
*
|
||||||
|
* @param sourceGeometry
|
||||||
|
* @param targetGeometry
|
||||||
|
* @return
|
||||||
|
* @throws FactoryException
|
||||||
|
* @throws TransformException
|
||||||
|
*/
|
||||||
|
public static PrecomputedGridReprojection getReprojection(
|
||||||
|
GeneralGridGeometry sourceGeometry,
|
||||||
|
GeneralGridGeometry targetGeometry) throws TransformException {
|
||||||
|
PrecomputedGridReprojection reprojection = null;
|
||||||
|
boolean created = false;
|
||||||
|
MultiKey key = new MultiKey(sourceGeometry, targetGeometry);
|
||||||
|
synchronized (cache) {
|
||||||
|
Reference<PrecomputedGridReprojection> ref = cache.get(key);
|
||||||
|
if (ref != null) {
|
||||||
|
reprojection = ref.get();
|
||||||
|
}
|
||||||
|
if (reprojection == null) {
|
||||||
|
reprojection = new PrecomputedGridReprojection(sourceGeometry,
|
||||||
|
targetGeometry);
|
||||||
|
created = true;
|
||||||
|
cache.put(key, new SoftReference<PrecomputedGridReprojection>(
|
||||||
|
reprojection));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (reprojection) {
|
||||||
|
if (created) {
|
||||||
|
reprojection.computeTransformTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reprojection;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue