Join the Shiny Community every month at Shiny Gatherings

PickerInput Shiny R programming Blog banner

Introducing the Weighted pickerInput Module for Shiny


In data analysis, dashboards are essential tools for making sense of complex data. Dashboards help to derive insights that drive better decision-making. However, as the volume and complexity of data increases, so does the challenge of presenting it in a clear and meaningful way.

Sometimes the variety of Shiny inputs is not enough. And there is a need to allow the user to set up inputs in a descriptive way; here come the new libraries or manual approaches.

In this article, we will introduce an improvement to the “shinyWidgets::pickerInput” function. The new feature adds to this input a possibility to assign weights to each of the selection’s options. We’ll explore how this new feature can be used to customize dashboards, and identify trends and patterns in the data more easily.

TOC:


Run the Weighted Picker Input Shiny Application

Clone the full code from the Github repository.

Here is the folder structure you need to start the demo application.


├── app.R
├── weightedPickerInput.R
└── www
    ├── css
    │   └── styles.scss
    └── js
        └── index.js

Ensure that you have installed all the necessary libraries on your local machine.


library(sass)
library(shiny)
library(bslib)
library(glue)
library(shinyWidgets)
library(dplyr)
library(tibble)
library(stats)
library(scales)

Finally, run the application and let’s explore it!

shiny::runApp()

Exploring the Weighted Picker Input Shiny Demo

Now you are able to assign the most important criterias that you take into consideration when choosing the car, and select the best car from the `mtcars` dataset. 

For instance, let’s imagine that you are already subscribed to our Data4Good Newsletter, and optimal fuel consumption is the most important metric for you. Let’s assign the score of mpg to the maximum level, with adding other metrics you would like to consider.

pickerInput in Shiny mtcars dataset

You can try to assign higher values for some of the options to see how it affects the results. Please note that the dataset is normalized; it prevents some features from dominating others.

weighted pickerInput in Shiny

You may also want to group choices, and our code sharpened for such use cases, see the example below. Developers can also specify such arguments like min, max, default numbers of range, step of the change, etc.


weightedPickerInput(
	"months",
	label = "Options",
	list(
  	Winter = month.name[1:3],
  	Spring = month.name[4:6],
  	Summer = month.name[7:9],
  	Fall = month.name[10:12]
	),
	min_weight = 0,
	max_weight = 1,
	default_weight = 0.5,
	step = 0.1
  ),

pickerInput in Shiny for month select

If it’s not possible for you to run the application on your own machine. You can go to the deployed version of the Shiny Weighted Select Input example.

Import pickerInput module to Shiny

Then let’s consider the creation of the current module in R. We create the JS function with all the logic explained above and wrap the typical pickerInput with a div block that has a defined class for styling.


weightedPickerInput <- function(
	id,
	choices,
	label = NULL,
	selected = NULL,
	min_weight = 1,
	max_weight = 5,
	default_weight = 3,
	step = 1
  ) {

  constants <- glue(
	"{
  	minWeight: {{ min_weight }},
  	maxWeight: {{ max_weight }},
  	defaultWeight: {{ default_weight }},
  	step: {{ step }},
	}",
	.open = "{{",
	.close = "}}"
  )

  # 1 dimension vector should be wrapped into the list
  if (is.null(names(choices))) {
	choices = list(` ` = choices)
  }

  div(
	class = "weighted-stats-selector",
	pickerInput(
  		inputId = id,
  		label = label,
  		choices = choices,
  		multiple = TRUE,
  		selected = selected
	),
	tags$script(
  		glue("initStatsWeightModifier('{id}', {constants})")
	)
  )
}

From here, you can easily add your module to the code, but do not forget about the adding styles and JS to your UI.


css <- sass(sass_file("www/css/styles.scss"))
# Define UI for application that draws a histogram
ui <- fluidPage(
  theme = bs_theme(5),

  # make sure that js and css are set and added
  tags$head(tags$style(css)),
  tags$head(tags$script(src = "js/index.js")),
  
  weightedPickerInput(
	"car_options",
	c("mpg", "disp", "hp", "drat", "wt", "qsec"),
	selected = NULL
  )
)

If you want to get the state of this input inside the Shiny application, the only thing you need to do is:


observeEvent(input$`car_options-stats_weights`, {
  state <- input$`car_options-stats_weights`
  weights <- state$weights
})

Styling the control buttons are described in the sass file. You can adjust the styling that suits your needs or customize the colors of pickerInput.

JavaScript Customization of pickerInput

For creating such a solution, we needed to use some custom JavaScript techniques. The most important actions taken are described below, but you can also explore the full code on our weighted pickerInput repo

Get the current pickerInput element, and then add listeners for each of the selector options.


const select = document.getElementById(id);
select.addEventListener('change', () => {
	const options = select.parentNode.querySelectorAll('.dropdown-item.opt');
	[...options].forEach((option) => handleWeightsVisibility(option));
	sendStateToShiny();
  });

const sendStateToShiny = () => {
  Shiny.setInputValue(`${id}-stats_weights`, { weights: weightsState });
};

Add pickerInput Weight Modifiers

`handleWeightsVisibility` controls whether to show the weight selection, because it should be visible only for the selected options. The `addWeightModifiers` and `removeWeightModifiers` functions manage adding and removing modifiers elements to the actual DOM structure accordingly.


const addWeightModifiers = (option, step) => {
  const modifier = option.querySelector('.weight-modifier');
  const statName = getStatName(option);
  if (weightsState[statName] === undefined) weightsState[statName] = defaultWeight;
  if (!modifier) option.appendChild(weightModifier(statName, step));
};
const removeWeightModifiers = (option) => {
  const modifier = option.querySelector('.weight-modifier');
  const statName = getStatName(option);
  if (modifier) option.removeChild(modifier);
  if (weightsState[statName] !== undefined) delete weightsState[statName];
};
const handleWeightsVisibility = (option, initial = false) => {
  if (option.classList.contains('selected') || initial) {
	addWeightModifiers(option, step);
  } else {
	removeWeightModifiers(option);
  }
};

So if the user selects some option, `weightModifier` creates a `controlButton` element and adds it to the current option with the default weight value.


const weightModifier = (statName, step) => {
  const modifier = document.createElement('div');
  modifier.className = 'weight-modifier';
  modifier.addEventListener('click', (e) => e.stopPropagation());
  modifier.append(
	controlButton(statName, 'decrease', step),
	weightValue(statName),
	controlButton(statName, 'increase', step),
  );
  return modifier;
};

And `updateWeight` function changes the value of the current `controlButton` modifier, by the user’s click on increase/decrease buttons.


const controlButton = (statName, action, step) => {
  const button = document.createElement('button');
  button.className = 'btn btn-outline-primary control-btn';
  button.classList.add(action);
  button.textContent = action === 'increase' ? '+' : '-';
  button.addEventListener(
	'click',
	(e) => {
  	const newWeightValue = updateWeight(e, statName, action, step);
  	updateControlButtons(button, action, newWeightValue);
	},
  );
  return button;
};

The JS tracks changes to the controlButton element, and it immediately sends the current state of the pickerInput to Shiny when any changes are made through an updateWeight call.


const updateWeight = (e, statName, action, step = 1) => {
  e.stopPropagation();
  const currentValue = weightsState[statName];
  const valueNode = e.target.parentNode.querySelector('.weight-value');
  let newValue;
  if (action === 'increase') {
	newValue = currentValue < maxWeight ? currentValue + step : maxWeight; } else if (action === 'decrease') { newValue = currentValue > minWeight ? currentValue - step : minWeight;
  }
  weightsState[statName] = newValue;
  valueNode.textContent = parseFloat(newValue.toFixed(2));
  sendStateToShiny();
  return newValue;
};

Conclusions on pickerInput in Shiny

Usage of this approach can enhance the user experience in dashboards. It provides more control over the way options affect the data, so users can easily specify the information they want to explore. It allows users to rank and filter data, based on weighted criteria and improve the decision-making process.

We hope that you like this solution. You can integrate weighted selector in areas like finance, risk assessment, healthcare etc.