mirror of
https://github.com/yldio/copilot.git
synced 2024-11-10 21:30:06 +02:00
Spike should use new chartjs box plot module
This commit is contained in:
parent
94ae410220
commit
0614839905
@ -1,7 +1,7 @@
|
||||
const buildArray = require('build-array');
|
||||
const Chart = require('chart.js');
|
||||
const React = require('react');
|
||||
const whisker = require('../whisker');
|
||||
const whisker = require('chartjs-chart-box-plot');
|
||||
whisker(Chart);
|
||||
|
||||
module.exports = React.createClass({
|
||||
|
@ -1,221 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = function(Chart) {
|
||||
|
||||
var globalOpts = Chart.defaults.global;
|
||||
|
||||
globalOpts.elements.rectangle = {
|
||||
backgroundColor: globalOpts.defaultColor,
|
||||
borderWidth: 0,
|
||||
borderColor: globalOpts.defaultColor,
|
||||
borderSkipped: 'bottom'
|
||||
};
|
||||
|
||||
function isVertical(bar) {
|
||||
return bar._view.width !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the bounds of the bar regardless of the orientation
|
||||
* @private
|
||||
* @param bar {Chart.Element.Rectangle} the bar
|
||||
* @return {Bounds} bounds of the bar
|
||||
*/
|
||||
function getBarBounds(bar) {
|
||||
var vm = bar._view;
|
||||
var x1, x2, y1, y2;
|
||||
|
||||
if (isVertical(bar)) {
|
||||
// vertical
|
||||
var halfWidth = vm.width / 2;
|
||||
x1 = vm.x - halfWidth;
|
||||
x2 = vm.x + halfWidth;
|
||||
y1 = Math.min(vm.y, vm.base);
|
||||
y2 = Math.max(vm.y, vm.base);
|
||||
} else {
|
||||
// horizontal bar
|
||||
var halfHeight = vm.height / 2;
|
||||
x1 = Math.min(vm.x, vm.base);
|
||||
x2 = Math.max(vm.x, vm.base);
|
||||
y1 = vm.y - halfHeight;
|
||||
y2 = vm.y + halfHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
left: x1,
|
||||
top: y1,
|
||||
right: x2,
|
||||
bottom: y2
|
||||
};
|
||||
}
|
||||
|
||||
Chart.elements.Whisker = Chart.Element.extend({
|
||||
draw: function() {
|
||||
var ctx = this._chart.ctx;
|
||||
var vm = this._view;
|
||||
|
||||
var halfWidth = vm.width / 2,
|
||||
leftX = vm.x - halfWidth,
|
||||
rightX = vm.x + halfWidth,
|
||||
top = vm.base - (vm.base - vm.y),
|
||||
halfStroke = vm.borderWidth / 2;
|
||||
|
||||
// Canvas doesn't allow us to stroke inside the width so we can
|
||||
// adjust the sizes to fit if we're setting a stroke on the line
|
||||
if (vm.borderWidth) {
|
||||
leftX += halfStroke;
|
||||
rightX -= halfStroke;
|
||||
top += halfStroke;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = vm.backgroundColor;
|
||||
ctx.strokeStyle = vm.borderColor;
|
||||
ctx.lineWidth = vm.borderWidth;
|
||||
|
||||
// Corner points, from bottom-left to bottom-right clockwise
|
||||
// | 1 2 |
|
||||
// | 0 3 |
|
||||
var corners = [
|
||||
[leftX, vm.base],
|
||||
[leftX, top],
|
||||
[rightX, top],
|
||||
[rightX, vm.base]
|
||||
];
|
||||
|
||||
// Find first (starting) corner with fallback to 'bottom'
|
||||
var borders = ['bottom', 'left', 'top', 'right'];
|
||||
var startCorner = borders.indexOf(vm.borderSkipped, 0);
|
||||
if (startCorner === -1) {
|
||||
startCorner = 0;
|
||||
}
|
||||
|
||||
function cornerAt(index) {
|
||||
return corners[(startCorner + index) % 4];
|
||||
}
|
||||
|
||||
// Draw rectangle from 'startCorner'
|
||||
var corner = cornerAt(0);
|
||||
ctx.moveTo(corner[0], corner[1]);
|
||||
|
||||
for (var i = 1; i < 4; i++) {
|
||||
corner = cornerAt(i);
|
||||
ctx.lineTo(corner[0], corner[1]);
|
||||
}
|
||||
|
||||
ctx.fill();
|
||||
if (vm.borderWidth) {
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.closePath();
|
||||
|
||||
// Median line
|
||||
ctx.beginPath();
|
||||
|
||||
ctx.moveTo(leftX, vm.median);
|
||||
ctx.lineTo(rightX, vm.median);
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
// set line color
|
||||
ctx.strokeStyle = 'rgb(54, 74, 205)';
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
|
||||
// Top Whisker
|
||||
// if (smaller than 5px then do not draw)
|
||||
if (vm.median - vm.maxV > 10) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo((rightX - leftX) / 2 + leftX, vm.median - 1);
|
||||
ctx.lineTo((rightX - leftX) / 2 + leftX, vm.maxV);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = 'rgb(245, 93, 93)';
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.beginPath();
|
||||
ctx.arc((rightX - leftX) / 2 + leftX, vm.maxV, 3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'rgb(245, 93, 93)';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Bottom Whisker
|
||||
// if (smaller than 5px then do not draw)
|
||||
if (vm.minV - vm.median > 10) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo((rightX - leftX) / 2 + leftX, vm.median + 1);
|
||||
ctx.lineTo((rightX - leftX) / 2 + leftX, vm.minV);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = 'rgb(245, 93, 93)';
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.beginPath();
|
||||
ctx.arc((rightX - leftX) / 2 + leftX, vm.minV, 3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'rgb(245, 93, 93)';
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
height: function() {
|
||||
var vm = this._view;
|
||||
return vm.base - vm.y;
|
||||
},
|
||||
inRange: function(mouseX, mouseY) {
|
||||
var inRange = false;
|
||||
|
||||
if (this._view) {
|
||||
var bounds = getBarBounds(this);
|
||||
inRange = mouseX >= bounds.left && mouseX <= bounds.right && mouseY >= bounds.top && mouseY <= bounds.bottom;
|
||||
}
|
||||
|
||||
return inRange;
|
||||
},
|
||||
inLabelRange: function(mouseX, mouseY) {
|
||||
var me = this;
|
||||
if (!me._view) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var inRange = false;
|
||||
var bounds = getBarBounds(me);
|
||||
|
||||
if (isVertical(me)) {
|
||||
inRange = mouseX >= bounds.left && mouseX <= bounds.right;
|
||||
} else {
|
||||
inRange = mouseY >= bounds.top && mouseY <= bounds.bottom;
|
||||
}
|
||||
|
||||
return inRange;
|
||||
},
|
||||
inXRange: function(mouseX) {
|
||||
var bounds = getBarBounds(this);
|
||||
return mouseX >= bounds.left && mouseX <= bounds.right;
|
||||
},
|
||||
inYRange: function(mouseY) {
|
||||
var bounds = getBarBounds(this);
|
||||
return mouseY >= bounds.top && mouseY <= bounds.bottom;
|
||||
},
|
||||
getCenterPoint: function() {
|
||||
var vm = this._view;
|
||||
var x, y;
|
||||
if (isVertical(this)) {
|
||||
x = vm.x;
|
||||
y = (vm.y + vm.base) / 2;
|
||||
} else {
|
||||
x = (vm.x + vm.base) / 2;
|
||||
y = vm.y;
|
||||
}
|
||||
|
||||
return {x: x, y: y};
|
||||
},
|
||||
getArea: function() {
|
||||
var vm = this._view;
|
||||
return vm.width * Math.abs(vm.y - vm.base);
|
||||
},
|
||||
tooltipPosition: function() {
|
||||
var vm = this._view;
|
||||
return {
|
||||
x: vm.x,
|
||||
y: vm.y
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
};
|
@ -1,276 +0,0 @@
|
||||
const whiskerElement = require('./element.whisker');
|
||||
|
||||
module.exports = function(Chart) {
|
||||
whiskerElement(Chart);
|
||||
|
||||
var helpers = Chart.helpers;
|
||||
|
||||
Chart.defaults.whisker = {
|
||||
hover: {
|
||||
mode: 'label'
|
||||
},
|
||||
|
||||
scales: {
|
||||
xAxes: [{
|
||||
type: 'category',
|
||||
|
||||
// Specific to Bar Controller
|
||||
categoryPercentage: 0.8,
|
||||
barPercentage: 0.9,
|
||||
|
||||
// grid line settings
|
||||
gridLines: {
|
||||
offsetGridLines: true
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
type: 'linear'
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
Chart.controllers.whisker = Chart.DatasetController.extend({
|
||||
|
||||
dataElementType: Chart.elements.Whisker,
|
||||
|
||||
initialize: function(chart, datasetIndex) {
|
||||
Chart.DatasetController.prototype.initialize.call(this, chart, datasetIndex);
|
||||
|
||||
// Use this to indicate that this is a bar dataset.
|
||||
this.getMeta().bar = true;
|
||||
},
|
||||
|
||||
// Get the number of datasets that display bars. We use this to correctly calculate the bar width
|
||||
getBarCount: function() {
|
||||
var me = this;
|
||||
var barCount = 0;
|
||||
helpers.each(me.chart.data.datasets, function(dataset, datasetIndex) {
|
||||
var meta = me.chart.getDatasetMeta(datasetIndex);
|
||||
if (meta.bar && me.chart.isDatasetVisible(datasetIndex)) {
|
||||
++barCount;
|
||||
}
|
||||
}, me);
|
||||
return barCount;
|
||||
},
|
||||
|
||||
update: function(reset) {
|
||||
var me = this;
|
||||
helpers.each(me.getMeta().data, function(rectangle, index) {
|
||||
me.updateElement(rectangle, index, reset);
|
||||
}, me);
|
||||
},
|
||||
|
||||
updateElement: function(rectangle, index, reset) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var xScale = me.getScaleForId(meta.xAxisID);
|
||||
var yScale = me.getScaleForId(meta.yAxisID);
|
||||
var scaleBase = yScale.getBasePixel();
|
||||
var rectangleElementOptions = me.chart.options.elements.rectangle;
|
||||
var custom = rectangle.custom || {};
|
||||
var dataset = me.getDataset();
|
||||
|
||||
rectangle._xScale = xScale;
|
||||
rectangle._yScale = yScale;
|
||||
rectangle._datasetIndex = me.index;
|
||||
rectangle._index = index;
|
||||
|
||||
var ruler = me.getRuler(index);
|
||||
rectangle._model = {
|
||||
x: me.calculateBarX(index, me.index, ruler),
|
||||
y: reset ? scaleBase : me.boxTopValue(index, me.index),
|
||||
|
||||
// Tooltip
|
||||
label: me.chart.data.labels[index],
|
||||
datasetLabel: dataset.label,
|
||||
|
||||
// Appearance
|
||||
median: reset ? scaleBase : me.medianValue(me.index, index),
|
||||
maxV: reset ? scaleBase : me.maxValue(me.index, index),
|
||||
minV: reset ? scaleBase : me.minValue(me.index, index),
|
||||
base: reset ? scaleBase : me.boxBottomValue(me.index, index),
|
||||
width: me.calculateBarWidth(ruler),
|
||||
backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(me.stddev(me.index, index) > 3 ? dataset.altBackgroundColor : dataset.backgroundColor, index, rectangleElementOptions.backgroundColor),
|
||||
borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped,
|
||||
borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor),
|
||||
borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth)
|
||||
};
|
||||
|
||||
rectangle.pivot();
|
||||
},
|
||||
|
||||
stddev: function(datasetIndex, index) {
|
||||
var me = this;
|
||||
var obj = me.getDataset().data[index];
|
||||
var value = Number(obj.stddev);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
minValue: function(datasetIndex, index) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var yScale = me.getScaleForId(meta.yAxisID);
|
||||
var obj = me.getDataset().data[index];
|
||||
var value = Number(obj.min);
|
||||
|
||||
return yScale.getPixelForValue(value);
|
||||
},
|
||||
|
||||
maxValue: function(datasetIndex, index) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var yScale = me.getScaleForId(meta.yAxisID);
|
||||
var obj = me.getDataset().data[index];
|
||||
var value = Number(obj.max);
|
||||
|
||||
return yScale.getPixelForValue(value);
|
||||
},
|
||||
|
||||
medianValue: function(datasetIndex, index) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var yScale = me.getScaleForId(meta.yAxisID);
|
||||
var obj = me.getDataset().data[index];
|
||||
var value = Number(obj.median);
|
||||
|
||||
return yScale.getPixelForValue(value);
|
||||
},
|
||||
|
||||
boxBottomValue: function(datasetIndex, index) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var yScale = me.getScaleForId(meta.yAxisID);
|
||||
var obj = me.getDataset().data[index];
|
||||
var value = Number(obj.firstQuartile);
|
||||
|
||||
return yScale.getPixelForValue(value);
|
||||
},
|
||||
|
||||
boxTopValue: function(index, datasetIndex) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var yScale = me.getScaleForId(meta.yAxisID);
|
||||
var obj = me.getDataset().data[index];
|
||||
var value = Number(obj.thirdQuartile);
|
||||
|
||||
return yScale.getPixelForValue(value);
|
||||
},
|
||||
|
||||
getRuler: function(index) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var xScale = me.getScaleForId(meta.xAxisID);
|
||||
var datasetCount = me.getBarCount();
|
||||
|
||||
var tickWidth;
|
||||
|
||||
if (xScale.options.type === 'category') {
|
||||
tickWidth = xScale.getPixelForTick(index + 1) - xScale.getPixelForTick(index);
|
||||
} else {
|
||||
// Average width
|
||||
tickWidth = xScale.width / xScale.ticks.length;
|
||||
}
|
||||
var categoryWidth = tickWidth * xScale.options.categoryPercentage;
|
||||
var categorySpacing = (tickWidth - (tickWidth * xScale.options.categoryPercentage)) / 2;
|
||||
var fullBarWidth = categoryWidth / datasetCount;
|
||||
|
||||
if (xScale.ticks.length !== me.chart.data.labels.length) {
|
||||
var perc = xScale.ticks.length / me.chart.data.labels.length;
|
||||
fullBarWidth = fullBarWidth * perc;
|
||||
}
|
||||
|
||||
var barWidth = fullBarWidth * xScale.options.barPercentage;
|
||||
var barSpacing = fullBarWidth - (fullBarWidth * xScale.options.barPercentage);
|
||||
|
||||
return {
|
||||
datasetCount: datasetCount,
|
||||
tickWidth: tickWidth,
|
||||
categoryWidth: categoryWidth,
|
||||
categorySpacing: categorySpacing,
|
||||
fullBarWidth: fullBarWidth,
|
||||
barWidth: barWidth,
|
||||
barSpacing: barSpacing
|
||||
};
|
||||
},
|
||||
|
||||
calculateBarWidth: function(ruler) {
|
||||
var xScale = this.getScaleForId(this.getMeta().xAxisID);
|
||||
if (xScale.options.barThickness) {
|
||||
return xScale.options.barThickness;
|
||||
}
|
||||
return ruler.barWidth;
|
||||
},
|
||||
|
||||
// Get bar index from the given dataset index accounting for the fact that not all bars are visible
|
||||
getBarIndex: function(datasetIndex) {
|
||||
var barIndex = 0;
|
||||
var meta;
|
||||
var j;
|
||||
|
||||
for (j = 0; j < datasetIndex; ++j) {
|
||||
meta = this.chart.getDatasetMeta(j);
|
||||
if (meta.bar && this.chart.isDatasetVisible(j)) {
|
||||
++barIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return barIndex;
|
||||
},
|
||||
|
||||
calculateBarX: function(index, datasetIndex, ruler) {
|
||||
var me = this;
|
||||
var meta = me.getMeta();
|
||||
var xScale = me.getScaleForId(meta.xAxisID);
|
||||
var barIndex = me.getBarIndex(datasetIndex);
|
||||
var leftTick = xScale.getPixelForValue(null, index, datasetIndex, me.chart.isCombo);
|
||||
leftTick -= me.chart.isCombo ? (ruler.tickWidth / 2) : 0;
|
||||
|
||||
return leftTick +
|
||||
(ruler.barWidth / 2) +
|
||||
ruler.categorySpacing +
|
||||
(ruler.barWidth * barIndex) +
|
||||
(ruler.barSpacing / 2) +
|
||||
(ruler.barSpacing * barIndex);
|
||||
},
|
||||
|
||||
draw: function(ease) {
|
||||
var me = this;
|
||||
var easingDecimal = ease || 1;
|
||||
var metaData = me.getMeta().data;
|
||||
var dataset = me.getDataset();
|
||||
var i, len;
|
||||
|
||||
for (i = 0, len = metaData.length; i < len; ++i) {
|
||||
var d = dataset.data[i];
|
||||
if (d !== null && d !== undefined && typeof d === 'object' && !isNaN(d.median)) {
|
||||
metaData[i].transition(easingDecimal).draw();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setHoverStyle: function(rectangle) {
|
||||
var dataset = this.chart.data.datasets[rectangle._datasetIndex];
|
||||
var index = rectangle._index;
|
||||
|
||||
var custom = rectangle.custom || {};
|
||||
var model = rectangle._model;
|
||||
model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : helpers.getValueAtIndexOrDefault(dataset.hoverBackgroundColor, index, helpers.getHoverColor(model.backgroundColor));
|
||||
model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : helpers.getValueAtIndexOrDefault(dataset.hoverBorderColor, index, helpers.getHoverColor(model.borderColor));
|
||||
model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : helpers.getValueAtIndexOrDefault(dataset.hoverBorderWidth, index, model.borderWidth);
|
||||
},
|
||||
|
||||
removeHoverStyle: function(rectangle) {
|
||||
var dataset = this.chart.data.datasets[rectangle._datasetIndex];
|
||||
var index = rectangle._index;
|
||||
var custom = rectangle.custom || {};
|
||||
var model = rectangle._model;
|
||||
var rectangleElementOptions = this.chart.options.elements.rectangle;
|
||||
|
||||
model.backgroundColor = custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor);
|
||||
model.borderColor = custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor);
|
||||
model.borderWidth = custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth);
|
||||
}
|
||||
|
||||
});
|
||||
};
|
@ -18,6 +18,7 @@
|
||||
"babel-runtime": "^6.11.6",
|
||||
"build-array": "^1.0.0",
|
||||
"chart.js": "^2.3.0",
|
||||
"chartjs-chart-box-plot": "^1.0.0-9",
|
||||
"classnames": "^2.2.5",
|
||||
"component-emitter": "^1.2.1",
|
||||
"cpu-percent": "^2.0.1",
|
||||
|
@ -947,6 +947,13 @@ chart.js@^2.3.0:
|
||||
chartjs-color "^2.0.0"
|
||||
moment "^2.10.6"
|
||||
|
||||
chartjs-chart-box-plot@^1.0.0-9:
|
||||
version "1.0.0-9"
|
||||
resolved "https://registry.yarnpkg.com/chartjs-chart-box-plot/-/chartjs-chart-box-plot-1.0.0-9.tgz#bd392c689301e71b13f602818629f5a0965eaddb"
|
||||
dependencies:
|
||||
react "^15.4.1"
|
||||
react-dom "^15.4.1"
|
||||
|
||||
chartjs-color-string@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.4.0.tgz#57748d4530ae28d8db0a5492182ba06dfdf2f468"
|
||||
@ -3785,6 +3792,14 @@ react-dom@^15.3.2:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.0"
|
||||
|
||||
react-dom@^15.4.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.4.1.tgz#d54c913261aaedb17adc20410d029dcc18a1344a"
|
||||
dependencies:
|
||||
fbjs "^0.8.1"
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.0"
|
||||
|
||||
react-hot-loader@^3.0.0-beta.6:
|
||||
version "3.0.0-beta.6"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-3.0.0-beta.6.tgz#463fac0bfc8b63a8385258af20c91636abce75f4"
|
||||
@ -3837,6 +3852,14 @@ react@^15.3.2:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.0"
|
||||
|
||||
react@^15.4.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-15.4.1.tgz#498e918602677a3983cd0fd206dfe700389a0dd6"
|
||||
dependencies:
|
||||
fbjs "^0.8.4"
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.0"
|
||||
|
||||
readable-stream@^1.0.27-1, readable-stream@^1.1.13:
|
||||
version "1.1.14"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
|
||||
|
Loading…
Reference in New Issue
Block a user