mirror of
https://github.com/gsi-upm/soil
synced 2024-11-14 15:32:29 +00:00
Charts
This commit is contained in:
parent
563dc8dc4c
commit
c93f3fafc7
25
config_copy.yml
Normal file
25
config_copy.yml
Normal file
@ -0,0 +1,25 @@
|
||||
name: ControlModelM2_sim
|
||||
max_time: 100
|
||||
num_trials: 2
|
||||
network_params:
|
||||
generator: barabasi_albert_graph
|
||||
n: 100
|
||||
m: 2
|
||||
network_agents:
|
||||
- agent_type: ControlModelM2
|
||||
weight: 0.1
|
||||
state:
|
||||
id: 1
|
||||
- agent_type: SpreadModelM2
|
||||
weight: 0.9
|
||||
state:
|
||||
id: 0
|
||||
environment_params:
|
||||
prob_neutral_making_denier: 0.035
|
||||
prob_infect: 0.075
|
||||
prob_cured_healing_infected: 0.035
|
||||
prob_cured_vaccinate_neutral: 0.035
|
||||
prob_vaccinated_healing_infected: 0.035
|
||||
prob_vaccinated_vaccinate_neutral: 0.035
|
||||
prob_generate_anti_rumor: 0.035
|
||||
standard_variance: 0.055
|
@ -17,7 +17,16 @@ html, body {
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: 0px -2px 5px 3px rgba(0, 0, 0, .3);
|
||||
box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, .2)
|
||||
}
|
||||
|
||||
.nav.navbar-right {
|
||||
margin-right: 10px !important;
|
||||
}
|
||||
|
||||
.dropdown-menu > li > a:hover {
|
||||
background-color: #d4d3d3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node {
|
||||
@ -49,6 +58,10 @@ svg#graph, #configuration {
|
||||
padding: 15px;
|
||||
border-left: none !important;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: inherit;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
button {
|
||||
@ -86,8 +99,14 @@ button.pressed {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
hr {
|
||||
margin-top: 15px !important;
|
||||
margin-bottom: 15px !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#update .config-item {
|
||||
margin-top: 15px !important;
|
||||
}
|
||||
|
||||
/** LOADER **/
|
||||
@ -203,6 +222,16 @@ button.pressed {
|
||||
padding: 5px 2px;
|
||||
}
|
||||
|
||||
#percentTable .no-data-table {
|
||||
font-size: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 15px !important;
|
||||
margin-bottom: 15px !important;
|
||||
@ -304,6 +333,19 @@ table#link-distance .max {
|
||||
padding: 15px !important;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#wrapper-settings.none {
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#wrapper-settings.none:before {
|
||||
content: 'No configuration provided';
|
||||
}
|
||||
|
||||
#wrapper-settings .btn-group button:focus {
|
||||
@ -329,3 +371,30 @@ table#link-distance .max {
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/** CHARTS **/
|
||||
#charts {
|
||||
height: 100%;
|
||||
padding-left: 0 !important;
|
||||
padding-top: 15px !important;
|
||||
padding-bottom: 15px !important;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.chart.no-data:before {
|
||||
content: 'No data';
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
padding-bottom: 35px;
|
||||
}
|
||||
|
||||
.chart.no-data {
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
@ -20,6 +20,10 @@
|
||||
<script type="text/javascript" src="http://d3js.org/d3.v3.js"></script>
|
||||
<script type="text/javascript" src="http://labratrevenge.com/d3-tip/javascripts/d3.tip.v0.6.3.js"></script>
|
||||
|
||||
<!-- C3.js // D3-based reusable chart library -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/c3/0.4.18/c3.css" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/c3/0.4.18/c3.min.js"></script>
|
||||
|
||||
<!-- JAVASCRIPTS -->
|
||||
<script type="text/javascript" src="js/visualization.js"></script>
|
||||
<script type="text/javascript" src="js/timeline.js"></script>
|
||||
@ -41,6 +45,11 @@
|
||||
play,
|
||||
slider;
|
||||
|
||||
var width_chart = (window.innerWidth - 30) / 2 - 15,
|
||||
height_chart = (window.innerHeight - 230) / 2,
|
||||
chart_nodes,
|
||||
chart_attrs;
|
||||
|
||||
window.onload = function() {
|
||||
"use strict";
|
||||
|
||||
@ -56,6 +65,7 @@
|
||||
$('#update #file').change(function() {
|
||||
|
||||
var file = $('#file')[0].files[0];
|
||||
$('.console').append('<br/>');
|
||||
self.GraphVisualization.reset();
|
||||
$('#load').show();
|
||||
|
||||
@ -83,11 +93,6 @@
|
||||
|
||||
});
|
||||
|
||||
// Select 'trials'
|
||||
$('.config-item #trials').change(function() {
|
||||
_socket.send($(this).val(), 'get_trial');
|
||||
});
|
||||
|
||||
// Select 'attributes'
|
||||
$('.config-item #properties').change(function() {
|
||||
self.GraphVisualization.update_graph($(this).val(), slider.value(), function() {
|
||||
@ -95,8 +100,38 @@
|
||||
})
|
||||
});
|
||||
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
|
||||
chart_nodes = c3.generate({
|
||||
size: {
|
||||
width: width_chart,
|
||||
height: height_chart
|
||||
},
|
||||
data: {
|
||||
columns: [],
|
||||
type: 'area-spline'
|
||||
},
|
||||
axis: {
|
||||
x: { label: 'Time' },
|
||||
y: { label: 'Number of nodes' }
|
||||
},
|
||||
point: { show: false },
|
||||
bindto: '#chart_nodes'
|
||||
});
|
||||
chart_attrs = c3.generate({
|
||||
size: {
|
||||
width: width_chart,
|
||||
height: height_chart
|
||||
},
|
||||
data: {
|
||||
columns: [],
|
||||
type: 'area-spline'
|
||||
},
|
||||
axis: {
|
||||
x: { label: 'Time' },
|
||||
y: { label: 'Attributes' }
|
||||
},
|
||||
point: { show: false },
|
||||
bindto: '#chart_attrs'
|
||||
});
|
||||
}
|
||||
|
||||
///]]
|
||||
@ -113,7 +148,7 @@
|
||||
<div class="col-sm-9 console">
|
||||
Please, upload a YAML file that defines all the parameters of a simulation. <br/>
|
||||
If you don't know how to write the file, please visit this page:<br/>
|
||||
http://soilsim.readthedocs.io/en/latest/quickstart.html<br/><br/>
|
||||
http://soilsim.readthedocs.io/en/latest/quickstart.html<br/>
|
||||
</div>
|
||||
<!-- //CONSOLE -->
|
||||
|
||||
@ -127,13 +162,15 @@
|
||||
</form>
|
||||
<!-- //Load File -->
|
||||
|
||||
<!-- TRIALS -->
|
||||
<!-- Atributos -->
|
||||
<div class="config-item">
|
||||
Trials:
|
||||
<select id="trials" class="form-control form-control-sm">
|
||||
Attributes:
|
||||
<select id="properties" class="form-control form-control-sm">
|
||||
<optgroup id="properties-dynamic" label="Dynamics"></optgroup>
|
||||
<optgroup id="properties-static" label="Statics"></optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<!-- //TRIALS -->
|
||||
<!-- //Atributos -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -141,19 +178,23 @@
|
||||
<nav class="navbar navbar-default navbar-fixed-bottom">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="#">Brand</a>
|
||||
<a class="navbar-brand" href="#">{{ model_name }}</a>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li data-target="#myCarousel" data-slide-to="0" class="active"><a href='#'>Home</a></li>
|
||||
<li data-target="#myCarousel" data-slide-to="1"><a href="#" onclick="">Settings</a></li>
|
||||
<li data-target="#myCarousel" data-slide-to="0" class="active" id="home_menu"><a href='#'>Home</a></li>
|
||||
<li data-target="#myCarousel" data-slide-to="1" id="settings_menu"><a href="#">Settings & Charts</a></li>
|
||||
<li class="dropdown" id="trials">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Trials <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu"></ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li><a href="#">Run simulation</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
$('.nav li').click(function() {
|
||||
$('.nav li[data-target="#myCarousel"]').click(function() {
|
||||
$('.nav li').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
});
|
||||
@ -191,17 +232,9 @@
|
||||
<!-- //Graph Info -->
|
||||
|
||||
<!-- PROPIEDADES -->
|
||||
<div class="config-item">
|
||||
Propiedades:
|
||||
<select id="properties" class="form-control form-control-sm">
|
||||
<optgroup id="properties-dynamic" label="Dynamics"></optgroup>
|
||||
<optgroup id="properties-static" label="Statics"></optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="config-item">
|
||||
<table id="percentTable">
|
||||
<tbody><tr><th></th></tr></tbody>
|
||||
<tbody><tr><th class="no-data-table">No data</th></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr />
|
||||
@ -271,10 +304,13 @@
|
||||
<!-- //Wrapper Graph Container -->
|
||||
|
||||
<!-- Wrapper Settings -->
|
||||
<div class="item settings">
|
||||
<div class="item" id="settings">
|
||||
<div class="container-fluid">
|
||||
<div class="col-sm-6" id="charts"></div>
|
||||
<div class="col-sm-6" id="wrapper-settings"></div>
|
||||
<div class="col-sm-6" id="charts">
|
||||
<div id="chart_nodes" class="chart no-data"></div>
|
||||
<div id="chart_attrs" class="chart no-data"></div>
|
||||
</div>
|
||||
<div class="col-sm-6 none" id="wrapper-settings"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -284,7 +320,5 @@
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
@ -16,6 +16,7 @@ ws.onmessage = function(message) {
|
||||
switch(msg['type']) {
|
||||
case 'trials':
|
||||
$('#load').removeClass('loader');
|
||||
reset_trials();
|
||||
set_trials(msg['data']);
|
||||
break;
|
||||
|
||||
@ -25,13 +26,22 @@ ws.onmessage = function(message) {
|
||||
$('#load').hide();
|
||||
reset_configuration();
|
||||
set_configuration();
|
||||
$('#home_menu').click(function() {
|
||||
setTimeout(function() {
|
||||
reset_timeline();
|
||||
set_timeline(msg['data']['graph']);
|
||||
}, 1000);
|
||||
});
|
||||
reset_timeline();
|
||||
set_timeline(msg['data']['graph']);
|
||||
});
|
||||
$('#charts .chart').removeClass('no-data');
|
||||
set_chart_nodes(msg['data']['graph'], chart_nodes)
|
||||
set_chart_attrs(msg['data']['graph'], chart_attrs, $('.config-item #properties').val())
|
||||
break;
|
||||
|
||||
case 'settings':
|
||||
//console.log(msg['data']);
|
||||
$('#wrapper-settings').empty().removeClass('none');
|
||||
initGUI(msg['data']);
|
||||
break;
|
||||
|
||||
@ -67,12 +77,26 @@ var _socket = {
|
||||
|
||||
var set_trials = function(trials) {
|
||||
for ( i in trials ) {
|
||||
$('<option>').val(i).text(trials[i]).appendTo('select#trials');
|
||||
var list_item = $('<li>').appendTo('.dropdown#trials .dropdown-menu');
|
||||
$('<a>').val(i).text(trials[i]).appendTo(list_item);
|
||||
}
|
||||
// Select 'trials'
|
||||
$('.dropdown#trials li a').click(function() {
|
||||
var a = $('.dropdown-toggle .caret');
|
||||
$('.dropdown-toggle').text($(this).text() + ' ').append(a);
|
||||
_socket.send($(this).val(), 'get_trial');
|
||||
});
|
||||
// Request first trial as default
|
||||
_socket.send(0, 'get_trial')
|
||||
};
|
||||
|
||||
var reset_trials = function() {
|
||||
// 'Trials' selector
|
||||
$('.dropdown-menu').empty();
|
||||
var a = $('.dropdown-toggle .caret');
|
||||
$('.dropdown-toggle').text('Trials ').append(a);
|
||||
}
|
||||
|
||||
var convertJSON = function(json) {
|
||||
json.links.forEach(function(link) {
|
||||
link.source = json.nodes[link.source]
|
||||
@ -156,7 +180,6 @@ var reset_configuration = function() {
|
||||
|
||||
// 'Link Distance' slider
|
||||
$('#link-distance-slider').slider('disable').slider('setValue', 30);
|
||||
|
||||
}
|
||||
|
||||
var set_timeline = function(graph) {
|
||||
@ -278,3 +301,42 @@ var get_limits = function(graph) {
|
||||
})
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
var set_chart_nodes = function(graph, chart) {
|
||||
var [min, max] = get_limits(graph);
|
||||
var data = ['nodes']
|
||||
for (var i = min; i <= max; i++) {
|
||||
data.push(this.GraphVisualization.get_nodes(i));
|
||||
}
|
||||
chart.load({
|
||||
unload: true,
|
||||
columns: [data]
|
||||
});
|
||||
}
|
||||
|
||||
var set_chart_attrs = function(graph, chart, property) {
|
||||
var [min, max] = get_limits(graph);
|
||||
var data_tmp = {}
|
||||
for (var i = min; i <= max; i++) {
|
||||
this.GraphVisualization.get_attributes(property, i, function(object) {
|
||||
for (var value in object) {
|
||||
if (!data_tmp[value]) {
|
||||
var time = 0
|
||||
for (var done in data_tmp)
|
||||
time = (data_tmp[done].length > time) ? data_tmp[done].length - 1 : time
|
||||
data_tmp[value] = Array(time).fill(0);
|
||||
}
|
||||
data_tmp[value].push(object[value]);
|
||||
}
|
||||
});
|
||||
}
|
||||
var data = $.map(data_tmp, function(value, index) {
|
||||
value.splice(0,0,index);
|
||||
return [value];
|
||||
});
|
||||
chart.load({
|
||||
unload: true,
|
||||
columns: data
|
||||
});
|
||||
chart.axis.labels({y: property});
|
||||
}
|
||||
|
@ -37,14 +37,35 @@ var initGUI = function(model_params) {
|
||||
input.slider().on('change', function(slideEvt) {
|
||||
current_value.text(slideEvt.value.newValue);
|
||||
});
|
||||
button_down.click(function() {
|
||||
var timeout, interval;
|
||||
button_down.on('mousedown', function() {
|
||||
input.slider('setValue', input.slider('getValue') - 0.001);
|
||||
current_value.text(input.slider('getValue'));
|
||||
timeout = setTimeout(function() {
|
||||
interval = setInterval(function() {
|
||||
input.slider('setValue', input.slider('getValue') - 0.001);
|
||||
current_value.text(input.slider('getValue'));
|
||||
}, 30);
|
||||
}, 500);
|
||||
});
|
||||
button_up.click(function() {
|
||||
button_down.on('mouseup', function() {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
});
|
||||
button_up.on('mousedown', function() {
|
||||
input.slider('setValue', input.slider('getValue') + 0.001);
|
||||
current_value.text(input.slider('getValue'));
|
||||
})
|
||||
timeout = setTimeout(function() {
|
||||
interval = setInterval(function() {
|
||||
input.slider('setValue', input.slider('getValue') + 0.001);
|
||||
current_value.text(input.slider('getValue'));
|
||||
}, 30);
|
||||
}, 500);
|
||||
});
|
||||
button_up.on('mouseup', function() {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
});
|
||||
};
|
||||
|
||||
var addTextBox = function(param, obj) {
|
||||
|
@ -37,22 +37,26 @@
|
||||
node; // Circles for the nodes
|
||||
|
||||
Number.prototype.between = function(min, max) {
|
||||
var min = (min) ? min : Math.max(),
|
||||
max = (max) ? max : Math.min();
|
||||
var min = (min || min === 0) ? min : Math.max(),
|
||||
max = (max || max === 0) ? max : Math.min();
|
||||
|
||||
return this > min && this <= max;
|
||||
return ( this > min && this <= max ) || ( min === 0 && this === 0 );
|
||||
};
|
||||
|
||||
var lastFocusNode;
|
||||
var _helpers = {
|
||||
set_node: function(node, property) {
|
||||
set_node: function(node, property, time) {
|
||||
// Add nodes if data has more nodes than before
|
||||
node.enter().append('circle')
|
||||
.attr('class', 'node')
|
||||
.attr('r', radius)
|
||||
.style('fill', function (d) {
|
||||
if ( Array.isArray(d[property]) ) {
|
||||
return color(d[property][0][0]);
|
||||
var color_node = color(d[property][0][0]);
|
||||
d[property].forEach(function(p) {
|
||||
if ( time.between(p[1], p[2]) ) color_node = color(p[0]);
|
||||
});
|
||||
return color_node;
|
||||
} else {
|
||||
return color(d[property]);
|
||||
}
|
||||
@ -82,7 +86,11 @@
|
||||
.attr('r', radius)
|
||||
.style('fill', function (d) {
|
||||
if ( Array.isArray(d[property]) ) {
|
||||
return color(d[property][0][0]);
|
||||
var color_node = color(d[property][0][0]);
|
||||
d[property].forEach(function(p) {
|
||||
if ( time.between(p[1], p[2]) ) color_node = color(p[0]);
|
||||
});
|
||||
return color_node;
|
||||
} else {
|
||||
return color(d[property]);
|
||||
}
|
||||
@ -226,7 +234,7 @@
|
||||
|
||||
// Do the same with the circles for the nodes - no
|
||||
node = gnodes.selectAll('.node').data(data_node);
|
||||
_helpers.set_node(node, property);
|
||||
_helpers.set_node(node, property, time);
|
||||
|
||||
// Node Attributes
|
||||
var statistics = {}
|
||||
@ -234,10 +242,10 @@
|
||||
data_node.forEach(function(n) {
|
||||
// Count node properties
|
||||
if ( Array.isArray(n[property]) ) {
|
||||
statistics[n[property][0][0]] = (!statistics[n[property][0][0]]) ? 1 : statistics[n[property][0][0]] + 1;
|
||||
} else {
|
||||
statistics[n[property]] = (!statistics[n[property]]) ? 1 : statistics[n[property]] + 1;
|
||||
}
|
||||
n[property].forEach(function(p) {
|
||||
if ( time.between(p[1], p[2]) ) statistics[p[0]] = (!statistics[p[0]]) ? 1 : statistics[p[0]] + 1;
|
||||
});
|
||||
} else { statistics[n[property]] = (!statistics[n[property]]) ? 1 : statistics[n[property]] + 1; }
|
||||
});
|
||||
for ( i in statistics ) {
|
||||
statistics[i] = (statistics[i] / data_node.length * 100).toFixed(2);
|
||||
@ -396,6 +404,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attributes at one moment given.
|
||||
* A function that get the attributes of all nodes at a specific time.
|
||||
*
|
||||
* @param {object} time Instant of time.
|
||||
* @param {object} callback A function called at the end.
|
||||
* @return {object} object An object with the number of nodes.
|
||||
*/
|
||||
function get_attributes(property, time, callback) {
|
||||
var attrs = {}
|
||||
|
||||
graph.nodes.forEach(function(node) {
|
||||
|
||||
if (Array.isArray(node.spells)) {
|
||||
node.spells.forEach( function(d) {
|
||||
if ( time.between(d[0], d[1]) ) {
|
||||
|
||||
if (Array.isArray(node[property])) {
|
||||
node[property].forEach( function(p) {
|
||||
if ( time.between(p[1], p[2]) ) attrs[p[0]] = (!attrs[p[0]]) ? 1 : attrs[p[0]] + 1;
|
||||
});
|
||||
} else { attrs[node[property]] = (!attrs[node[property]]) ? 1 : attrs[node[property]] + 1; }
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
|
||||
if (Array.isArray(node[property])) {
|
||||
node[property].forEach( function(p) {
|
||||
if ( time.between(p[1], p[2]) ) attrs[p[0]] = (!attrs[p[0]]) ? 1 : attrs[p[0]] + 1;
|
||||
});
|
||||
} else { attrs[node[property]] = (!attrs[node[property]]) ? 1 : attrs[node[property]] + 1; }
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
if (callback) { callback(attrs); }
|
||||
else { return attrs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes at one moment given.
|
||||
* A function that get the number of nodes at a specific time.
|
||||
*
|
||||
* @param {object} time Instant of time.
|
||||
* @param {object} callback A function called at the end.
|
||||
* @return {object} number The number of nodes.
|
||||
*/
|
||||
function get_nodes(time, callback) {
|
||||
var total_nodes = 0;
|
||||
graph.nodes.forEach(function(node) {
|
||||
if (Array.isArray(node.spells)) {
|
||||
node.spells.forEach( function(d) {
|
||||
if ( time.between(d[0], d[1]) ) { total_nodes++; }
|
||||
});
|
||||
} else {
|
||||
total_nodes++;
|
||||
}
|
||||
});
|
||||
|
||||
if (callback) { callback(total_nodes); }
|
||||
else { return total_nodes }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Exporting
|
||||
@ -418,6 +491,8 @@
|
||||
|
||||
// Getters
|
||||
color: color,
|
||||
get_attributes: get_attributes,
|
||||
get_nodes: get_nodes,
|
||||
|
||||
// Statistics
|
||||
statistics: {},
|
||||
|
Loading…
Reference in New Issue
Block a user