first step at scatterplot facet selector
git-svn-id: http://google-refine.googlecode.com/svn/trunk@442 7d457c2a-affb-35e4-300a-418c747d4874
This commit is contained in:
parent
ed0778f18d
commit
81fb2f1740
@ -18,18 +18,18 @@ import com.metaweb.gridworks.commands.edit.AnnotateRowsCommand;
|
|||||||
import com.metaweb.gridworks.commands.edit.ApplyOperationsCommand;
|
import com.metaweb.gridworks.commands.edit.ApplyOperationsCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.CreateProjectCommand;
|
import com.metaweb.gridworks.commands.edit.CreateProjectCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.DeleteProjectCommand;
|
import com.metaweb.gridworks.commands.edit.DeleteProjectCommand;
|
||||||
|
import com.metaweb.gridworks.commands.edit.EditOneCellCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.ExportProjectCommand;
|
import com.metaweb.gridworks.commands.edit.ExportProjectCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.ExtendDataCommand;
|
import com.metaweb.gridworks.commands.edit.ExtendDataCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.ImportProjectCommand;
|
import com.metaweb.gridworks.commands.edit.ImportProjectCommand;
|
||||||
|
import com.metaweb.gridworks.commands.edit.JoinMultiValueCellsCommand;
|
||||||
|
import com.metaweb.gridworks.commands.edit.MassEditCommand;
|
||||||
|
import com.metaweb.gridworks.commands.edit.RemoveColumnCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.RemoveRowsCommand;
|
import com.metaweb.gridworks.commands.edit.RemoveRowsCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.RenameColumnCommand;
|
import com.metaweb.gridworks.commands.edit.RenameColumnCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.TextTransformCommand;
|
|
||||||
import com.metaweb.gridworks.commands.edit.EditOneCellCommand;
|
|
||||||
import com.metaweb.gridworks.commands.edit.MassEditCommand;
|
|
||||||
import com.metaweb.gridworks.commands.edit.JoinMultiValueCellsCommand;
|
|
||||||
import com.metaweb.gridworks.commands.edit.RemoveColumnCommand;
|
|
||||||
import com.metaweb.gridworks.commands.edit.SaveProtographCommand;
|
import com.metaweb.gridworks.commands.edit.SaveProtographCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.SplitMultiValueCellsCommand;
|
import com.metaweb.gridworks.commands.edit.SplitMultiValueCellsCommand;
|
||||||
|
import com.metaweb.gridworks.commands.edit.TextTransformCommand;
|
||||||
import com.metaweb.gridworks.commands.edit.UndoRedoCommand;
|
import com.metaweb.gridworks.commands.edit.UndoRedoCommand;
|
||||||
import com.metaweb.gridworks.commands.info.ComputeClustersCommand;
|
import com.metaweb.gridworks.commands.info.ComputeClustersCommand;
|
||||||
import com.metaweb.gridworks.commands.info.ComputeFacetsCommand;
|
import com.metaweb.gridworks.commands.info.ComputeFacetsCommand;
|
||||||
@ -42,6 +42,7 @@ import com.metaweb.gridworks.commands.info.GetOperationsCommand;
|
|||||||
import com.metaweb.gridworks.commands.info.GetProcessesCommand;
|
import com.metaweb.gridworks.commands.info.GetProcessesCommand;
|
||||||
import com.metaweb.gridworks.commands.info.GetProjectMetadataCommand;
|
import com.metaweb.gridworks.commands.info.GetProjectMetadataCommand;
|
||||||
import com.metaweb.gridworks.commands.info.GetRowsCommand;
|
import com.metaweb.gridworks.commands.info.GetRowsCommand;
|
||||||
|
import com.metaweb.gridworks.commands.info.GetScatterplotCommand;
|
||||||
import com.metaweb.gridworks.commands.recon.ReconDiscardJudgmentsCommand;
|
import com.metaweb.gridworks.commands.recon.ReconDiscardJudgmentsCommand;
|
||||||
import com.metaweb.gridworks.commands.recon.ReconJudgeOneCellCommand;
|
import com.metaweb.gridworks.commands.recon.ReconJudgeOneCellCommand;
|
||||||
import com.metaweb.gridworks.commands.recon.ReconJudgeSimilarCellsCommand;
|
import com.metaweb.gridworks.commands.recon.ReconJudgeSimilarCellsCommand;
|
||||||
@ -82,6 +83,7 @@ public class GridworksServlet extends HttpServlet {
|
|||||||
_commands.put("get-processes", new GetProcessesCommand());
|
_commands.put("get-processes", new GetProcessesCommand());
|
||||||
_commands.put("get-history", new GetHistoryCommand());
|
_commands.put("get-history", new GetHistoryCommand());
|
||||||
_commands.put("get-operations", new GetOperationsCommand());
|
_commands.put("get-operations", new GetOperationsCommand());
|
||||||
|
_commands.put("get-scatterplot", new GetScatterplotCommand());
|
||||||
|
|
||||||
_commands.put("undo-redo", new UndoRedoCommand());
|
_commands.put("undo-redo", new UndoRedoCommand());
|
||||||
_commands.put("apply-operations", new ApplyOperationsCommand());
|
_commands.put("apply-operations", new ApplyOperationsCommand());
|
||||||
|
@ -0,0 +1,152 @@
|
|||||||
|
package com.metaweb.gridworks.browsing.charting;
|
||||||
|
|
||||||
|
import java.awt.BasicStroke;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.geom.AffineTransform;
|
||||||
|
import java.awt.geom.Rectangle2D;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.awt.image.RenderedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import com.metaweb.gridworks.browsing.Engine;
|
||||||
|
import com.metaweb.gridworks.browsing.FilteredRows;
|
||||||
|
import com.metaweb.gridworks.browsing.RowVisitor;
|
||||||
|
import com.metaweb.gridworks.browsing.facets.NumericBinIndex;
|
||||||
|
import com.metaweb.gridworks.expr.Evaluable;
|
||||||
|
import com.metaweb.gridworks.expr.MetaParser;
|
||||||
|
import com.metaweb.gridworks.expr.ParsingException;
|
||||||
|
import com.metaweb.gridworks.model.Cell;
|
||||||
|
import com.metaweb.gridworks.model.Column;
|
||||||
|
import com.metaweb.gridworks.model.Project;
|
||||||
|
import com.metaweb.gridworks.model.Row;
|
||||||
|
|
||||||
|
public class ScatterplotCharter {
|
||||||
|
|
||||||
|
private static final Color COLOR = Color.black;
|
||||||
|
|
||||||
|
public String getContentType() {
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void draw(OutputStream output, Project project, Engine engine, JSONObject options) throws IOException, JSONException {
|
||||||
|
|
||||||
|
DrawingRowVisitor drawingVisitor = new DrawingRowVisitor(project, options);
|
||||||
|
FilteredRows filteredRows = engine.getAllFilteredRows(false);
|
||||||
|
filteredRows.accept(project, drawingVisitor);
|
||||||
|
|
||||||
|
ImageIO.write(drawingVisitor.getImage(), "png", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DrawingRowVisitor implements RowVisitor {
|
||||||
|
|
||||||
|
private static final double px = 0.5f;
|
||||||
|
|
||||||
|
boolean process = true;
|
||||||
|
|
||||||
|
int width = 50;
|
||||||
|
int height = 50;
|
||||||
|
|
||||||
|
int col_x;
|
||||||
|
int col_y;
|
||||||
|
double w;
|
||||||
|
double h;
|
||||||
|
double min_x;
|
||||||
|
double min_y;
|
||||||
|
double max_x;
|
||||||
|
double max_y;
|
||||||
|
|
||||||
|
NumericBinIndex index_x;
|
||||||
|
NumericBinIndex index_y;
|
||||||
|
|
||||||
|
BufferedImage image;
|
||||||
|
Graphics2D g2;
|
||||||
|
|
||||||
|
public DrawingRowVisitor(Project project, JSONObject o) throws JSONException {
|
||||||
|
String col_x_name = o.getString("cx");
|
||||||
|
Column column_x = project.columnModel.getColumnByName(col_x_name);
|
||||||
|
if (column_x != null) {
|
||||||
|
col_x = column_x.getCellIndex();
|
||||||
|
index_x = getBinIndex(project, column_x);
|
||||||
|
min_x = index_x.getMin() * 1.1d;
|
||||||
|
max_x = index_x.getMax() * 1.1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
String col_y_name = o.getString("cy");
|
||||||
|
Column column_y = project.columnModel.getColumnByName(col_y_name);
|
||||||
|
if (column_y != null) {
|
||||||
|
col_y = column_y.getCellIndex();
|
||||||
|
index_y = getBinIndex(project, column_y);
|
||||||
|
min_y = index_y.getMin() * 1.1d;
|
||||||
|
max_y = index_y.getMax() * 1.1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
width = o.getInt("w");
|
||||||
|
height = o.getInt("h");
|
||||||
|
|
||||||
|
w = (double) width;
|
||||||
|
h = (double) height;
|
||||||
|
|
||||||
|
if (index_x.isNumeric() && index_y.isNumeric()) {
|
||||||
|
image = new BufferedImage(width, height, BufferedImage.TYPE_4BYTE_ABGR);
|
||||||
|
g2 = (Graphics2D) image.getGraphics();
|
||||||
|
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g2.setStroke(new BasicStroke(1.0f));
|
||||||
|
AffineTransform t = AffineTransform.getTranslateInstance(0,h);
|
||||||
|
t.concatenate(AffineTransform.getScaleInstance(1.0d, -1.0d));
|
||||||
|
g2.setTransform(t);
|
||||||
|
g2.setColor(COLOR);
|
||||||
|
g2.setPaint(COLOR);
|
||||||
|
} else {
|
||||||
|
image = new BufferedImage(1, 1, BufferedImage.TYPE_4BYTE_ABGR);
|
||||||
|
process = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NumericBinIndex getBinIndex(Project project, Column column) {
|
||||||
|
String key = "numeric-bin:value";
|
||||||
|
Evaluable eval = null;
|
||||||
|
try {
|
||||||
|
eval = MetaParser.parse("value");
|
||||||
|
} catch (ParsingException e) {
|
||||||
|
// this should never happen
|
||||||
|
}
|
||||||
|
NumericBinIndex index = (NumericBinIndex) column.getPrecompute(key);
|
||||||
|
if (index == null) {
|
||||||
|
index = new NumericBinIndex(project, column.getName(), column.getCellIndex(), eval);
|
||||||
|
column.setPrecompute(key, index);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean visit(Project project, int rowIndex, Row row, boolean includeContextual, boolean includeDependent) {
|
||||||
|
if (process) {
|
||||||
|
Cell cellx = row.getCell(col_x);
|
||||||
|
Cell celly = row.getCell(col_y);
|
||||||
|
if ((cellx != null && cellx.value != null && cellx.value instanceof Number) &&
|
||||||
|
(celly != null && celly.value != null && celly.value instanceof Number))
|
||||||
|
{
|
||||||
|
double xv = ((Number) cellx.value).doubleValue();
|
||||||
|
double yv = ((Number) celly.value).doubleValue();
|
||||||
|
|
||||||
|
double x = (xv - min_x) * w / max_x;
|
||||||
|
double y = (yv - min_y) * h / max_y;
|
||||||
|
g2.fill(new Rectangle2D.Double(x, y, px, px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RenderedImage getImage() {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -22,6 +22,9 @@ import com.metaweb.gridworks.model.Row;
|
|||||||
* as the user interacts with the facet.
|
* as the user interacts with the facet.
|
||||||
*/
|
*/
|
||||||
public class NumericBinIndex {
|
public class NumericBinIndex {
|
||||||
|
|
||||||
|
private int _total_count;
|
||||||
|
private int _number_count;
|
||||||
private double _min;
|
private double _min;
|
||||||
private double _max;
|
private double _max;
|
||||||
private double _step;
|
private double _step;
|
||||||
@ -45,21 +48,28 @@ public class NumericBinIndex {
|
|||||||
if (value.getClass().isArray()) {
|
if (value.getClass().isArray()) {
|
||||||
Object[] a = (Object[]) value;
|
Object[] a = (Object[]) value;
|
||||||
for (Object v : a) {
|
for (Object v : a) {
|
||||||
|
_total_count++;
|
||||||
if (v instanceof Number) {
|
if (v instanceof Number) {
|
||||||
processValue(((Number) v).doubleValue(), allValues);
|
processValue(((Number) v).doubleValue(), allValues);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (value instanceof Collection<?>) {
|
} else if (value instanceof Collection<?>) {
|
||||||
for (Object v : ExpressionUtils.toObjectCollection(value)) {
|
for (Object v : ExpressionUtils.toObjectCollection(value)) {
|
||||||
|
_total_count++;
|
||||||
if (v instanceof Number) {
|
if (v instanceof Number) {
|
||||||
processValue(((Number) v).doubleValue(), allValues);
|
processValue(((Number) v).doubleValue(), allValues);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (value instanceof Number) {
|
} else {
|
||||||
|
_total_count++;
|
||||||
|
if (value instanceof Number) {
|
||||||
processValue(((Number) value).doubleValue(), allValues);
|
processValue(((Number) value).doubleValue(), allValues);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_number_count = allValues.size();
|
||||||
|
|
||||||
if (_min >= _max) {
|
if (_min >= _max) {
|
||||||
_step = 1;
|
_step = 1;
|
||||||
@ -105,6 +115,14 @@ public class NumericBinIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isNumeric() {
|
||||||
|
return _number_count > _total_count / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNumberCount() {
|
||||||
|
return _number_count;
|
||||||
|
}
|
||||||
|
|
||||||
public double getMin() {
|
public double getMin() {
|
||||||
return _min;
|
return _min;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
package com.metaweb.gridworks.commands.info;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.ServletOutputStream;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import com.metaweb.gridworks.browsing.Engine;
|
||||||
|
import com.metaweb.gridworks.browsing.charting.ScatterplotCharter;
|
||||||
|
import com.metaweb.gridworks.commands.Command;
|
||||||
|
import com.metaweb.gridworks.model.Project;
|
||||||
|
|
||||||
|
public class GetScatterplotCommand extends Command {
|
||||||
|
|
||||||
|
final private ScatterplotCharter charter = new ScatterplotCharter();
|
||||||
|
|
||||||
|
public void doGet(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
|
try {
|
||||||
|
//long start = System.currentTimeMillis();
|
||||||
|
Project project = getProject(request);
|
||||||
|
Engine engine = getEngine(request, project);
|
||||||
|
JSONObject conf = getJsonParameter(request,"plotter");
|
||||||
|
|
||||||
|
response.setHeader("Content-Type", charter.getContentType());
|
||||||
|
|
||||||
|
ServletOutputStream sos = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sos = response.getOutputStream();
|
||||||
|
charter.draw(sos, project, engine, conf);
|
||||||
|
} finally {
|
||||||
|
sos.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
//Gridworks.log("drawn scatterplot in " + (System.currentTimeMillis() - start) + "ms");
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondException(response, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
80
src/main/webapp/scripts/dialogs/scatterplot-dialog.js
Normal file
80
src/main/webapp/scripts/dialogs/scatterplot-dialog.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
function ScatterplotDialog() {
|
||||||
|
this._createDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
ScatterplotDialog.prototype._createDialog = function() {
|
||||||
|
var self = this;
|
||||||
|
var frame = DialogSystem.createDialog();
|
||||||
|
frame.width("900px");
|
||||||
|
|
||||||
|
var header = $('<div></div>').addClass("dialog-header").text('Scatterplot Matrix').appendTo(frame);
|
||||||
|
var body = $('<div></div>').addClass("dialog-body").appendTo(frame);
|
||||||
|
var footer = $(
|
||||||
|
'<div class="dialog-footer">' +
|
||||||
|
'<table width="100%"><tr>' +
|
||||||
|
'<td class="left" style="text-align: left"></td>' +
|
||||||
|
'<td class="right" style="text-align: right"></td>' +
|
||||||
|
'</tr></table>' +
|
||||||
|
'</div>'
|
||||||
|
).appendTo(frame);
|
||||||
|
|
||||||
|
var html = $(
|
||||||
|
'<div class="grid-layout layout-normal">' +
|
||||||
|
'<div bind="tableContainer" class="scatterplot-dialog-table-container"></div>' +
|
||||||
|
'</div>'
|
||||||
|
).appendTo(body);
|
||||||
|
|
||||||
|
this._elmts = DOM.bind(html);
|
||||||
|
|
||||||
|
var left_footer = footer.find(".left");
|
||||||
|
|
||||||
|
var right_footer = footer.find(".right");
|
||||||
|
$('<button></button>').text("Close").click(function() { self._dismiss(); }).appendTo(right_footer);
|
||||||
|
|
||||||
|
this._renderMatrix(theProject.columnModel.columns);
|
||||||
|
this._level = DialogSystem.showDialog(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
ScatterplotDialog.prototype._renderMatrix = function(columns) {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
var container = this._elmts.tableContainer;
|
||||||
|
|
||||||
|
if (columns.length > 0) {
|
||||||
|
var table = $('<table></table>').addClass("scatterplot-matrix-table")[0];
|
||||||
|
|
||||||
|
for (var i = 0; i < columns.length; i++) {
|
||||||
|
var tr = table.insertRow(table.rows.length);
|
||||||
|
for (var j = 0; j < i; j++) {
|
||||||
|
var url = "/command/get-scatterplot?" + $.param({
|
||||||
|
project: theProject.id,
|
||||||
|
engine: JSON.stringify(ui.browsingEngine.getJSON()),
|
||||||
|
plotter: JSON.stringify({
|
||||||
|
'cx' : columns[i].name,
|
||||||
|
'cy' : columns[j].name,
|
||||||
|
'w' : 20,
|
||||||
|
'h' : 20
|
||||||
|
})
|
||||||
|
});
|
||||||
|
$(tr.insertCell(j)).html('<img class="scatterplot" title="' + columns[i].name + ' vs. ' + columns[j].name + '" src="' + url + '" />');
|
||||||
|
}
|
||||||
|
$(tr.insertCell(i)).text(columns[i]);
|
||||||
|
for (var j = i + 1; j < columns.length; j++) {
|
||||||
|
$(tr.insertCell(j)).text(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.empty().append(table);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
container.html(
|
||||||
|
'<div style="margin: 2em;"><div style="font-size: 130%; color: #333;">There are no columns in this dataset</div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
ScatterplotDialog.prototype._dismiss = function() {
|
||||||
|
DialogSystem.dismissUntil(this._level - 1);
|
||||||
|
};
|
||||||
|
|
@ -62,6 +62,12 @@ MenuBar.prototype._initializeUI = function() {
|
|||||||
click: function() {}
|
click: function() {}
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
this._createTopLevelMenuItem("Scatterplots", [
|
||||||
|
{
|
||||||
|
label: "Show scatterplot matrix ...",
|
||||||
|
click: function() { self._showScatterplotMatrix(); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
this._wireAllMenuItemsInactive();
|
this._wireAllMenuItemsInactive();
|
||||||
};
|
};
|
||||||
@ -217,3 +223,7 @@ MenuBar.prototype._doAutoSchemaAlignment = function() {
|
|||||||
MenuBar.prototype._doEditSchemaAlignment = function(reset) {
|
MenuBar.prototype._doEditSchemaAlignment = function(reset) {
|
||||||
new SchemaAlignmentDialog(reset ? null : theProject.protograph, function(newProtograph) {});
|
new SchemaAlignmentDialog(reset ? null : theProject.protograph, function(newProtograph) {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
MenuBar.prototype._showScatterplotMatrix = function() {
|
||||||
|
new ScatterplotDialog();
|
||||||
|
};
|
||||||
|
19
src/main/webapp/styles/dialogs/scatterplot-dialog.css
Normal file
19
src/main/webapp/styles/dialogs/scatterplot-dialog.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.scatterplot-dialog-table-container {
|
||||||
|
height: 500px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.scatterplot-matrix-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.scatterplot-matrix-table > tbody > tr > td {
|
||||||
|
padding: 2px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.scatterplot-matrix-table img.scatterplot {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user