Skip to content
Snippets Groups Projects
BeeswarmChart.vue 16 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="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> <a href='https://www.sciencebase.gov/catalog/item/63f79d49d34e4f7eda456572' target='_blank'>(Hines and others, 2023)</a>.</em>
Cee Nell's avatar
Cee Nell committed
    </div>
  </section>
</template>

<script setup>
import { onMounted, ref } from "vue";
import * as d3 from 'd3';
Cee Nell's avatar
Cee Nell committed
import { isMobile } from 'mobile-device-detect';

// 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;
Cee Nell's avatar
Cee Nell committed
const margin = { top: 40, right: 20, bottom: 40, left: 40 };

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() {
Cee Nell's avatar
Cee Nell committed
  // Remove any existing SVG element
  d3.select('#beeswarm-chart-container').select('svg').remove();
  
  // Get dynamic dimensions to draw chart
  const containerWidth = document.getElementById('beeswarm-chart-container').offsetWidth;
  const containerHeight = window.innerWidth <= 700 ? window.innerHeight * 0.5 : 600;
Cee Nell's avatar
Cee Nell committed
  const margin = window.innerWidth <= 700 ? { top: 30, right: 10, bottom: 20, left: 10 } : { top: 40, right: 20, bottom: 40, left: 40 };
  const width = containerWidth - margin.left - margin.right;
  const height = containerHeight - margin.top - margin.bottom;
  
  svg = d3
    .select('#beeswarm-chart-container')
    .append('svg')
    .attr('class', 'beeswarmSvg')
Cee Nell's avatar
Cee Nell committed
    .attr('viewBox', `0 0 ${containerWidth} ${containerHeight}`)
    .attr('preserveAspectRatio', 'xMidYMid meet')
    .style('width', '100%')
    .style('height', 'auto');

  const yScale = d3.scaleLinear()
    .domain([40, d3.max(data.value, d => d.level_agreement)])
Cee Nell's avatar
Cee Nell committed
    .range([containerHeight - 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(window.innerWidth <= 700 ? [8, 30] : [10, 70]);

  const yAxis = svg.append('g')
Cee Nell's avatar
Cee Nell committed
    .attr("transform", "translate(50, 0)")
    .call(d3.axisLeft(yScale).ticks(5))
    .attr("stroke-width", 2)
Cee Nell's avatar
Cee Nell committed
    .attr("font-size", 18);

  // 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)
Cee Nell's avatar
Cee Nell committed
    .attr("transform", `translate(${margin.left}, ${containerHeight - (margin.bottom / 2) + 10})`)
    .text("Inconclusive");
  // Set forces
Cee Nell's avatar
Cee Nell committed
  const forceY = d3.forceY(d => yScale(d.level_agreement)).strength(0.9);
  const forceX = d3.forceX(margin.left + (width / 2)).strength(0.1);
  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)))
Cee Nell's avatar
Cee Nell committed
      .attr("cy", d => Math.max(radiusScale(d.evidence_val), Math.min(containerHeight - 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
  // Adjust height based on screen size
  const isMobile = window.innerWidth <= 700;
  const containerHeight = isMobile ? window.innerHeight * 0.50 : height;
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)])
Cee Nell's avatar
Cee Nell committed
    .range([containerHeight - 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(window.innerWidth <= 700 ? [10, 40] : [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');
Cee Nell's avatar
Cee Nell committed
    d3.select('#text2').select('.positive').select('rect')
Cee Nell's avatar
Cee Nell committed
    .style('fill', dimensionColors[d.dimension.replace(' ', '')]);

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

Cee Nell's avatar
Cee Nell committed
  d3.select('#text2').select('.unknown').select('rect')
    .style('fill', `url(#pattern-stripe)`)
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">
$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;
Cee Nell's avatar
Cee Nell committed
  max-width: 800px;
  margin: auto;
}

#beeswarm-chart-container svg {
Cee Nell's avatar
Cee Nell committed
  //width: 90vw;
  max-width: 700px;
  max-height: 100%;
  height: auto; /* Maintain aspect ratio */
Cee Nell's avatar
Cee Nell committed
  margin: auto;
  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%; 
Cee Nell's avatar
Cee Nell committed
  height: 70px;
  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;
Cee Nell's avatar
Cee Nell committed
@media (max-width: 700px) {
    .yLabel {
      font-size: 12px; /* Adjusted font size for mobile devices */
    }
  
    #beeswarm-chart-container {
      width: 100vw; /* Make the chart container take up 100% of the viewport width on mobile */
      height: auto; /* Make the chart container take up the full height of the screen */
    }
  
    #beeswarm-chart-container svg {
      height: 100%; /* Make the SVG fill the container height */
    }
  
    #mobile-tooltip {
      display: block; /* Show the mobile tooltip only on mobile devices */
      margin: 10px;
    }
  }


</style>