Despite receiving various pieces of information and signals, some participants might not have a clear picture of what the income distribution is. Perception is often more important than reality, and in some experimental economics contexts, we need to assess whether these perceptions do indeed match reality.
Asking participants to report their perceived inequality level can be tricky, to improve on this measurement I constructed the interactive graph below. Participants can adjust the slope, curvature, and overall height of the bars through three sliders. This approach is more interactive and visual and could possibly result in more accurate responses
You can test the example below before moving on to learning how to apply it in your experiments.
This code defines a page class in oTree. The `form_model` refers to the player model, and the `form_fields` list specifies the fields that will be included in the form (in this case, 'adjustment_values'). The `is_displayed` function determines if the page should be shown to the player, based on whether they have given consent and haven't been removed.
class SecondTaxRateVoting(Page):
form_model = 'player'
form_fields = ['adjustment_values']
def is_displayed(player):
return player.consent and not player.remove
This function prepares data for use in the HTML template. It gathers players from the opposite group and sorts them based on their `test_score2` values. It returns the number of bars (players) to be displayed in the graph, which corresponds to the players from the opposite group.
You don't need to perform this extensive sorting, the important part here is to create a variable that holds the participants in the other group so we can match the number of bars we want to display. If you want to have a fixed number of bars, no sorting is needed. Nevertheless, keeping the sorting can be useful if you'd like to reveal the distribution to participants at some point in the page (see redistribution tutorial).
def vars_for_template(player: Player):
current_player = player
player_group_type = current_player.group_type
# Get the players from the opposite group and filter out players with None test_score2
opposite_group_players = sorted(
[p for p in player.group.get_players() if
p.group_type != player_group_type and p.field_maybe_none('test_score2') is not None],
key=lambda p: p.field_maybe_none('test_score2')
)
num_bars = len(opposite_group_players)
# Create the predefined distribution for the interactive graph (using integers from 0 to 15)
return {
'num_bars': num_bars # Send the number of sliders needed
}
This function handles player actions before moving to the next page. It processes the player's adjustments, checks for errors, and handles timeout scenarios. In this case, if the player times out, their timeout count is incremented, and if they reach three timeouts, they are removed from the experiment.
Note that adjustment values are lists of numbers which you can later use to perform calculations, such as finding the GINI coefficient, that allow for comparison between participants.
def before_next_page(player: Player, timeout_happened):
# Handle adjustments only if there's no timeout
if not timeout_happened:
try:
# Convert the comma-separated string back into a list of floats
adjustment_values = [float(value) for value in player.adjustment_values.split(',')]
except ValueError:
player.participant.vars['adjustment_error'] = True
return
# Check if the length of adjustment values matches the number of players
opposite_group_players = [p for p in player.group.get_players() if p.group_type != player.group_type]
if len(adjustment_values) != len(opposite_group_players):
player.participant.vars['adjustment_error'] = True
else:
player.participant.vars['adjustment_error'] = False
# Handle the timeout scenario
if timeout_happened:
# Increment the timeout count stored in participant.vars
player.timeout_count += 1
# If the participant times out 3 times, mark them for exclusion
if player.timeout_count >= 3:
player.remove = True # Mark player for removal
This block defines the basic structure and style of the histogram for the interactive graph. It includes the title and wrapper for the graph, which will later hold the dynamically generated bars This is only a short sample of the styles, there are many others you should include. You can always use a .css file instead. If you would like to see all styles, you can download the corresponding HTML code through the button above.
<style>
body {
font-family: 'Arial', sans-serif;
background-color: #f5f5f5;
color: #333;
}
.histogram-wrapper {
width: 100%;
height: auto;
}
.histogram-title {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
color: #0056b3;
}
</style>
This HTML section defines the structure of the dynamic bar graph. It includes sliders for adjusting slope, curvature, and height, as well as a container for the bars representing each player's score. The bars' heights will be dynamically updated based on player scores. The dynamic relationship is specified in the Javascript below.
<!-- Interactive section -->
<div class="sliders-container">
<p>Adjust Slope</p>
<input type="range" id="slopeSlider" class="slider" min="0" max="10" step="1" value="0">
<p>Adjust Curvature</p>
<input type="range" id="curveSlider" class="slider" min="0" max="20" step="1" value="0">
<p>Adjust Overall Height</p>
<input type="range" id="heightSlider" class="slider" min="0" max="15" step="1" value="2">
</div>
<div class="histogram-wrapper">
<div class="histogram-container" id="interactiveHistogram">
<div class="horizontal-line"></div>
{% for value in predefined_distribution %}
<div class="bar" style="flex: 1 1 calc((100% / {{ predefined_distribution|length }}) - 4px);">
<div class="bar-graph" style="height: {{ value.money_height }}px;" data-index="{{ forloop.counter0 }}">
<span class="score-value" style="color: #0a53be; position: absolute; top: -20px; width: 100%; text-align: center;">>{{ value.score }}</span>
<div class="money-earned" style="color: white; position: absolute; top: 10px; font-size: 8pt; bottom: 5px; width: 100%; text-align: center;">>€{{ value.money }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
<form id="adjustmentForm" method="post">
<input type="hidden" id="adjustmentValues" name="adjustment_values" value="">
<button type="submit" id="submitButton" class="btn btn-primary" disabled>Submit</button>
</form>
This JavaScript code handles updating the bar graph in real-time as the user interacts with the sliders. It calculates new heights for the bars based on the slope, curvature, and height values, and updates both the scores and the earnings shown for each player.
Here you can also adjust the strength of the slope and curvature sliders if you would like them to be more or less sensitive.
<script>
document.addEventListener('DOMContentLoaded', function() {
const bars = document.querySelectorAll('#interactiveHistogram .bar-graph');
const heightSlider = document.getElementById('heightSlider');
const slopeSlider = document.getElementById('slopeSlider');
const curveSlider = document.getElementById('curveSlider');
const adjustmentValues = document.getElementById('adjustmentValues'); // Hidden input to store money-earned values
const submitButton = document.getElementById('submitButton'); // Standard submit button
const maxScore = 15;
const maxHeightPx = document.querySelector('#interactiveHistogram').clientHeight;
// Flags to track whether each slider has been moved
let heightMoved = false;
let slopeMoved = false;
let curveMoved = false;
function updateBars() {
const baseScore = parseInt(heightSlider.value);
const slope = slopeSlider.value / 10;
const curvature = curveSlider.value / 10;
const numBars = bars.length;
const moneyValues = [];
bars.forEach((bar, index) => {
const positionFactor = index / (numBars - 1);
const curveFactor = Math.pow(positionFactor, 1 + curvature * 2);
let newScore = baseScore + slope * positionFactor * maxScore * curveFactor;
newScore = Math.max(0, Math.min(newScore, maxScore));
newScore = Math.round(newScore);
const newHeightPx = (newScore / maxScore) * maxHeightPx;
bar.style.height = `${newHeightPx}px`;
const scoreValue = bar.querySelector('.score-value');
const moneyEarned = bar.querySelector('.money-earned');
const moneyValue = (newScore / maxScore * 2.5).toFixed(2);
scoreValue.innerText = newScore;
moneyEarned.innerText = `€${moneyValue}`;
// Add the money value to the array
moneyValues.push(moneyValue);
});
// Store the money-earned values in the hidden input field
adjustmentValues.value = moneyValues.join(',');
}
This section adds additional functionality to the sliders. It enables the submit button once all sliders have been moved, and prevents the form from being submitted unless the sliders have been adjusted. The function ensures that the correct adjustments are applied before submission.
// Function to enable the submit button if all sliders have been moved
function enableSubmitIfNeeded() {
if (heightMoved && slopeMoved && curveMoved) {
submitButton.disabled = false; // Enable the submit button
}
}
updateBars();
// Mark sliders as "moved" when interacted with
heightSlider.addEventListener('input', function() {
heightMoved = true;
updateBars();
enableSubmitIfNeeded();
});
slopeSlider.addEventListener('input', function() {
slopeMoved = true;
updateBars();
enableSubmitIfNeeded();
});
curveSlider.addEventListener('input', function() {
curveMoved = true;
updateBars();
enableSubmitIfNeeded();
});
// Prevent form submission if all sliders haven't been moved (as an extra safeguard)
document.getElementById('adjustmentForm').addEventListener('submit', function(event) {
if (!heightMoved || !slopeMoved || !curveMoved) {
event.preventDefault();
alert("Please move all sliders before submitting.");
}
});
});
</script>
Thank you for reading this tutorial, if you have any questions, please don't hesitate to contact me.
More tutorials: