Skip to content
Snippets Groups Projects
BeeswarmChart.vue 14.7 KiB
Newer Older
Cee Nell's avatar
Cee Nell committed
<template>
  <section id="beeswarm">
    <div id="text1" class="text-container">
      <p>
        Everyone needs access to clean water. People may be more vulnerable to water insecurity due to  
        <span 
          :class="['highlight', 'Demographiccharacteristics', { checked: isChecked.Demographiccharacteristics }]" 
          @click="toggleCategory('Demographiccharacteristics')"
        >
          demographic characteristics
        </span>,
        <span 
          :class="['highlight', 'Health', { checked: isChecked.Health }]" 
          @click="toggleCategory('Health')"
        >
          health
        </span>, 
        <span 
          :class="['highlight', 'Livingconditions', { checked: isChecked.Livingconditions }]" 
          @click="toggleCategory('Livingconditions')"
        >
          living conditions
        </span>, 
        <span 
          :class="['highlight', 'Socioeconomicstatus', { checked: isChecked.Socioeconomicstatus }]" 
          @click="toggleCategory('Socioeconomicstatus')"
        >
          socioeconomic status
        </span>,
        <span 
          :class="['highlight', 'Riskperception', { checked: isChecked.Riskperception }]" 
          @click="toggleCategory('Riskperception')"
        >
          risk perception
        </span>,
        <span 
          :class="['highlight', 'Landtenure', { checked: isChecked.Landtenure }]" 
          @click="toggleCategory('Landtenure')"
        >
          land tenure
        </span>, and 
        <span 
          :class="['highlight', 'Exposure', { checked: isChecked.Exposure }]" 
          @click="toggleCategory('Exposure')"
        >
          exposure to stressors
        </span> (like drought or pollution).
      </p>
    </div>
    <div id="beeswarm-chart-container">
    </div>
    <!-- <div id="legend-container"></div> -->
    <div id="text2" class="text-container tooltip-width">
      <div id="tooltip" class="tooltip">Interact with the chart to explore evidence<br>for social vulnerability determinants.</div>
      <em>Many social vulnerability determinants have been studied. Some show positive <span class="legend-box positive"></span> relationships with water insecurity, some negative <span class="legend-box negative"></span>, and others unknown <span class="legend-box unknown"></span> (Drakes et al. 2024).</em>
Cee Nell's avatar
Cee Nell committed
    </div>
  </section>
</template>

<script setup>
import { onMounted, ref } from "vue";
import * as d3 from 'd3';

// Global variables 
const publicPath = import.meta.env.BASE_URL;
const dataSet1 = ref([]); 
const dataSet2 = ref([]); 
const selectedDataSet = ref('dataSet1');
const data = ref([]);
let simulation;

// Set up SVG
let svg;
const height = 600;
const width = 800;
const margin = { top: 50, right: 20, bottom: 50, left: 50 };

const isChecked = ref({
  Demographiccharacteristics: true,
  Health: true,
  Livingconditions: true,
  Socioeconomicstatus: true,
  Riskperception: true,
  Landtenure: true,
  Exposure: true
});

// Set colors for bubble charts
const dimensionColors = {
  Demographiccharacteristics: "#092836",
  Landtenure: "#1b695e",
  Livingconditions: "#7a5195",
  Socioeconomicstatus: "#2a468f",
  Health: "#ef5675",
  Riskperception: "#ff764a",
  Exposure: "#ffa600"
};

const patternId = 'pattern-stripe';

// bar chart patterning and fills
const legendData = [
Cee Nell's avatar
Cee Nell committed
  { name: 'Positive', color: dimensionColors['Demographiccharacteristics'] },
  { name: 'Negative', color: 'white', stroke: dimensionColors['Demographiccharacteristics'] },
  { name: 'Unknown', pattern: true, color: `url(#pattern-stripe)`, stroke: dimensionColors['Demographiccharacteristics'] }
];
function createLegend() {
  const keyW = 65;
  const keyH = 12;

  const svgDefs = d3.select('#text2')
Cee Nell's avatar
Cee Nell committed
    .append('svg')
    .attr('width', 0)
    .attr('height', 0)
    .append('defs');
Cee Nell's avatar
Cee Nell committed

  const pattern = svgDefs.append('pattern')
    .attr('id', 'pattern-stripe')
    .attr('patternUnits', 'userSpaceOnUse')
    .attr('width', 8)
    .attr('height', 8);

  pattern.append('rect')
    .attr('width', 8)
    .attr('height', 8)
    .attr('fill', 'white');

  pattern.append('path')
    .attr('d', 'M-2,2 l4,-4 M0,8 l8,-8 M6,10 l4,-4')
    .attr('stroke', dimensionColors['Demographiccharacteristics'])
    .attr('stroke-width', 3);

  legendData.forEach(d => {
    const legend = d3.select(`.${d.name.toLowerCase()}`)
      .append('svg')
      .attr('width', keyW + 5)
      .attr('height', keyH);

    legend.append('rect')
      .attr('x', 4)
      .attr('y', 0)
      .attr('width', keyW)
      .attr('height', keyH)
      .style('fill', d.pattern ? d.color : d.color)
      .style('stroke', d.stroke ? d.stroke : 'none')
      .style('stroke-width', d.stroke ? 3 : 0);
  });
Cee Nell's avatar
Cee Nell committed
}

// Load data and then make chart
onMounted(async () => {
  try {
    await loadDatasets();
    data.value = selectedDataSet.value === 'dataSet1' ? dataSet1.value : dataSet2.value;
    if (data.value.length > 0) {
      createBeeswarmChart();
    } else {
      console.error('Error loading data');
Cee Nell's avatar
Cee Nell committed
    }
  } catch (error) {
    console.error('Error during component mounting', error);
});

async function loadDatasets() {
  try {
    dataSet1.value = await loadData('determinant_uncertainty.csv');
    dataSet2.value = await loadData('indicator_uncertainty.csv');
    console.log('data in');
  } catch (error) {
    console.error('Error loading datasets', error);
}

async function loadData(fileName) {
  try {
    const data = await d3.csv(publicPath + fileName, d => { 
      d.level_agreement = +(+d.level_agreement).toFixed(2); 
      d.evidence_val = +d.evidence_val; 
      d.sig_value = +d.sig_value; 
      return d;
    });
    return data;
  } catch (error) {
    console.error(`Error loading data from ${fileName}`, error);
    return [];
  }
}

function createBeeswarmChart() {
  svg = d3
    .select('#beeswarm-chart-container')
    .append('svg')
    .attr('class', 'beeswarmSvg')
    .attr('width', width)
    .attr('height', height);

  const yScale = d3.scaleLinear()
    .domain([40, d3.max(data.value, d => d.level_agreement)])
    .range([height - margin.bottom, margin.top]);

  // Set radius based on evidence value
  const radiusScale = d3.scaleLinear()
    .domain([d3.min(data.value, d => d.evidence_val), d3.max(data.value, d => d.evidence_val)])
    .range([10, 70]);

  const yAxis = svg.append('g')
    .attr("transform", "translate(100, 0)")
    .call(d3.axisLeft(yScale).ticks(5))
    .attr("stroke-width", 2)
    .attr("font-size", 20);

  // Add label to y axis
  svg.append('text')
    .attr("class", "yLabel")
    .attr("text-anchor", "left")
Cee Nell's avatar
Cee Nell committed
    .attr("font-weight", 700)
    .attr("transform", `translate(${margin.left}, ${margin.top / 2})`)
Cee Nell's avatar
Cee Nell committed
    .text("Consensus");
  svg.append('text')
    .attr("class", "yLabel")
    .attr("text-anchor", "left")
    .attr("font-weight", 700)
    .attr("transform", `translate(${margin.left}, ${height - (margin.bottom / 2) + 10})`)
    .text("Inconclusive");
  // Set forces
  const forceY = d3.forceY(d => yScale(d.level_agreement)).strength(0.7);
  const forceX = d3.forceX(margin.left + (width / 2)).strength(0.2);
  const forceCollide = d3.forceCollide(d => radiusScale(d.evidence_val) + 2).iterations(20);
  const forceManyBody = d3.forceManyBody().strength(1);

  const bubbles = svg
Cee Nell's avatar
Cee Nell committed
    .selectAll('.bubble')
    .data(data.value)
    .enter()
    .append('circle')
    .attr('class', 'bubble')
    .attr('r', d => radiusScale(d.evidence_val))
    .style('fill', d => dimensionColors[d.dimension.replace(' ', '')])
    .on('mouseover', handleMouseOver)
    .on('mouseout', handleMouseOut);

  // Run simulation
  simulation = d3.forceSimulation()
    .force('x', forceX)
    .force('y', forceY)
    .force('collide', forceCollide)
    .force('charge', forceManyBody)
    .nodes(data.value)
    .on('tick', ticked)
    .alpha(0.75)
    .alphaDecay(0.03);

  function ticked() {
    bubbles
      .attr("cx", d => Math.max(margin.left + radiusScale(d.evidence_val), Math.min(width - margin.right - radiusScale(d.evidence_val), d.x)))
      .attr("cy", d => Math.max(radiusScale(d.evidence_val), Math.min(height - radiusScale(d.evidence_val), d.y)));

  createLegend(); // add legend to caption
}

function toggleCategory(category) {
  isChecked.value[category] = !isChecked.value[category];
  console.log(`Category toggled: ${category}, new value: ${isChecked.value[category]}`);
  updateChart();
}

Cee Nell's avatar
Cee Nell committed
function updateChart() {
Cee Nell's avatar
Cee Nell committed
  console.log('Update chart called');
Cee Nell's avatar
Cee Nell committed
  // Set the y scale
  const yScale = d3.scaleLinear()
    .domain([40, d3.max(data.value, d => d.level_agreement)])
    .range([height - margin.bottom, margin.top]);
Cee Nell's avatar
Cee Nell committed
  // Set the radius scale based on evidence value
  const radiusScale = d3.scaleLinear()
    .domain([d3.min(data.value, d => d.evidence_val), d3.max(data.value, d => d.evidence_val)])
    .range([10, 70]);
Cee Nell's avatar
Cee Nell committed
  // Filter data based on active categories
  const activeCategories = Object.keys(isChecked.value).filter(category => isChecked.value[category]);
  console.log('Active categories:', activeCategories);

  // Select all bubbles and bind data
  const bubbles = svg.selectAll(".bubble")
    .data(data.value, d => d.id);

  // Update existing bubbles
  bubbles
    .attr('r', d => radiusScale(d.evidence_val))
    .style('fill', d => activeCategories.includes(d.dimension.replace(' ', '')) ? dimensionColors[d.dimension.replace(' ', '')] : 'rgba(217, 217, 217, 0.95)');
Cee Nell's avatar
Cee Nell committed

  // Add new bubbles
  bubbles.enter()
    .append('circle')
    .attr('class', 'bubble')
    .attr('r', d => radiusScale(d.evidence_val))
    .style('fill', d => activeCategories.includes(d.dimension.replace(' ', '')) ? dimensionColors[d.dimension.replace(' ', '')] : 'rgba(217, 217, 217, 0.95)')
Cee Nell's avatar
Cee Nell committed
    .attr('cx', d => d.x)  // Use existing x position
    .attr('cy', d => d.y)
Cee Nell's avatar
Cee Nell committed
    .on('mouseover', handleMouseOver)
    .on('mouseout', handleMouseOut)
Cee Nell's avatar
Cee Nell committed
    .merge(bubbles);

  // Ensure all bubbles are handled correctly
  bubbles.exit().remove();
}
Cee Nell's avatar
Cee Nell committed

// Tooltip functions
Cee Nell's avatar
Cee Nell committed
function handleMouseOver(event, d) {
  const activeCategories = Object.keys(isChecked.value).filter(category => isChecked.value[category]);

  // Check if the category of the data point is toggled
  if (!activeCategories.includes(d.dimension.replace(' ', ''))) {
    return;  // Do nothing if the category is untoggled
  }

Cee Nell's avatar
Cee Nell committed
  const tooltip = d3.select('#tooltip');
  tooltip.html('')
    .append('div')
    .html(`<strong>${d.determinant}</strong> appeared in ${d.evidence_val} ${d.evidence_val === 1 ? 'study' : 'studies'}.`);
Cee Nell's avatar
Cee Nell committed

  // Add stacked bar chart
  const barData = [
    { name: 'positive', value: d.pos_related_total, stroke: dimensionColors[d.dimension.replace(' ', '')], fill: dimensionColors[d.dimension.replace(' ', '')] },
    { name: 'negative', value: d.neg_related_total, stroke: dimensionColors[d.dimension.replace(' ', '')], fill: 'white' },
Cee Nell's avatar
Cee Nell committed
    { name: 'unknown', value: d.unk_direction_total, pattern: true, stroke: dimensionColors[d.dimension.replace(' ', '')], fill: `url(#pattern-stripe)` }
Cee Nell's avatar
Cee Nell committed
  ];

  // Set dimensions for the bar chart
  const barWidth = 200;
  const barHeight = 15;
Cee Nell's avatar
Cee Nell committed

  // Create an SVG element for the bar chart
  const svgBar = tooltip.append('svg')
    .attr('width', barWidth + 10)
    .attr('height', barHeight + 10);

  const defs = svgBar.append('defs');

  const pattern = defs.append('pattern')
    .attr('id', 'pattern-stripe')
    .attr('patternUnits', 'userSpaceOnUse')
    .attr('width', 8)  // Adjusted to make pattern larger
    .attr('height', 8);

  pattern.append('rect')
    .attr('width', 8)
    .attr('height', 8)
    .attr('fill', 'white');

  pattern.append('path')
    .attr('d', 'M-2,2 l4,-4 M0,8 l8,-8 M6,10 l4,-4') 
Cee Nell's avatar
Cee Nell committed
    .attr('stroke', dimensionColors[d.dimension.replace(' ', '')])
    .attr('stroke-width', 4); 
Cee Nell's avatar
Cee Nell committed

  const g = svgBar.append('g')
    .attr('transform', 'translate(8, 8)');

  // Create a scale for the x-axis
  const xBar = d3.scaleLinear()
    .domain([0, d3.sum(barData, d => d.value)])
    .range([0, barWidth]);

  // Create groups for each bar segment
  const barGroups = g.selectAll('g')
    .data(barData)
    .enter()
    .append('g');

  // Add the rectangles
  barGroups.append('rect')
    .attr('x', (d, i) => i > 0 ? xBar(d3.sum(barData.slice(0, i), d => d.value)) : 0)
    .attr('y', 0)
    .attr('width', d => xBar(d.value))
    .attr('height', barHeight)
Cee Nell's avatar
Cee Nell committed
    .style('fill', d => d.pattern ? `url(#${patternId})` : d.fill)
Cee Nell's avatar
Cee Nell committed
    .style('stroke', d => d.stroke ? d.stroke : 'none');

  // Highlight the circle
  d3.select(this)
    .attr('stroke', dimensionColors[d.dimension.replace(' ', '')])
    .attr('stroke-width', 8);
Cee Nell's avatar
Cee Nell committed

  d3.select('.positive')
Cee Nell's avatar
Cee Nell committed
    .style('fill', dimensionColors[d.dimension.replace(' ', '')]);

  d3.select('#text2').select('.negative')
Cee Nell's avatar
Cee Nell committed
    .style('stroke', dimensionColors[d.dimension.replace(' ', '')]);

  d3.select('#text2').select('.unknown')
Cee Nell's avatar
Cee Nell committed
    .style('stroke', dimensionColors[d.dimension.replace(' ', '')]);
Cee Nell's avatar
Cee Nell committed
}

function handleMouseOut() {
Cee Nell's avatar
Cee Nell committed
  const tooltip = d3.select('#tooltip');
  tooltip.html('Interact with the chart to explore evidence for<br>social vulnerability determinants.');
Cee Nell's avatar
Cee Nell committed
  d3.select(this)
    .attr('stroke', null)
    .attr('stroke-width', null);
}
</script>
<style scoped lang="scss">
$switchWidth: 12rem;
$Demographiccharacteristics: #092836;
$Landtenure: #1b695e;
$Livingconditions: #7a5195;
$Socioeconomicstatus: #2a468f;
$Health: #ef5675;
$Riskperception: #ff764a;
$Exposure: #ffa600;
$ThemeGrey: rgba(217, 217, 217, 0.95);

#beeswarm-chart-container {
  text-align: center;
  position: relative;
}

#beeswarm-chart-container svg {
  max-width: 100%;
  max-height: 100%;
  height: auto; /* Maintain aspect ratio */
  display: inline-block;
  font-weight: bold;
}

.tooltip-width {
  width: 100%; 
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.tooltip {
  opacity: 1;
  background: $ThemeGrey;
  padding: 8px;
  border-radius: 5px;
  pointer-events: none; 
  width: 100%; 
  text-align: center;
}

.hidden {
  display: none;
}

.highlight {
  color: white;
  padding: 0.25px 5px;
  border-radius: 10px;
  white-space: nowrap;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.1s;

  &.Demographiccharacteristics {
    background-color: $Demographiccharacteristics;

  &.Landtenure {
    background-color: $Landtenure;

  &.Livingconditions {
    background-color: $Livingconditions;

  &.Socioeconomicstatus {
    background-color: $Socioeconomicstatus;

  &.Health {
    background-color: $Health;

  &.Riskperception {
    background-color: $Riskperception;

  &.Exposure {
    background-color: $Exposure;

  &:not(.checked) {
    color: black;
    background-color: $ThemeGrey;

.legend-item {
  display: inline-block;
  margin-right: 10px;
  position: relative;
  padding-left: 20px; /* Adjust padding for spacing between text and rectangle */
}

.legend-item::before {
  content: '';
  display: inline-block;
  width: 15px;
  height: 10px;
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
}
</style>