Skip to content
Snippets Groups Projects
BeeswarmChart.vue 18.1 KiB
Newer Older
Cee Nell's avatar
Cee Nell committed
<template>
  <section id="beeswarm">
    <div id="text1" class="text-container">
      <p v-html="bubbleCheckboxText"></p>
      <!-- Render the translated labels within the span elements -->
      <p>
        <span 
          :class="['highlight', 'Demographiccharacteristics', { checked: isChecked.Demographiccharacteristics }]" 
          @click="toggleCategory('Demographiccharacteristics')"
        >
          {{ demographicText }}
        </span>,
        <span 
          :class="['highlight', 'Health', { checked: isChecked.Health }]" 
          @click="toggleCategory('Health')"
        >
          {{ healthText }}
        </span>, 
        <span 
          :class="['highlight', 'Livingconditions', { checked: isChecked.Livingconditions }]" 
          @click="toggleCategory('Livingconditions')"
        >
          {{ livingConditionsText }}
        </span>, 
        <span 
          :class="['highlight', 'Socioeconomicstatus', { checked: isChecked.Socioeconomicstatus }]" 
          @click="toggleCategory('Socioeconomicstatus')"
        >
          {{ socioeconomicStatusText }}
        </span>,
        <span 
          :class="['highlight', 'Riskperception', { checked: isChecked.Riskperception }]" 
          @click="toggleCategory('Riskperception')"
        >
          {{ riskPerceptionText }}
        </span>,
        <span 
          :class="['highlight', 'Landtenure', { checked: isChecked.Landtenure }]" 
          @click="toggleCategory('Landtenure')"
        >
          {{ landTenureText }}
        </span>, & 
        <span 
          :class="['highlight', 'Exposure', { checked: isChecked.Exposure }]" 
          @click="toggleCategory('Exposure')"
        >
          {{ exposureText }} 
        </span>
        &nbsp;
        <span v-html="bubbleCheckboxEndText" />
      </p>
    <div id="text2" class="text-container tooltip-width">
      <div id="tooltip" class="tooltip">
        <p v-html="bubbleTooltipText"></p>
      </div>
      <em><p v-html="bubbleLegendText"></p></em>
    </div>
    <div id="beeswarm-chart-container"></div>
    <div id="text2" class="text-container tooltip-width">
      <em><p v-html="bubbleYAxisText"></p></em>
Cee Nell's avatar
Cee Nell committed
    </div>
  </section>
</template>

<script setup>
Cee Nell's avatar
Cee Nell committed
import { ref, computed, onMounted, watch } from "vue";
import * as d3 from 'd3';
import { useI18n } from 'vue-i18n';

const { t, locale } = useI18n();
const userLang = navigator.language || navigator.userLanguage;
const isSpanish = userLang.startsWith('es');
// Global variables 
const publicPath = import.meta.env.BASE_URL;
const data = ref([]);

// Set up SVG
let svg;
const height = 600;
Cee Nell's avatar
Cee Nell committed
const margin = { top: 40, right: 20, bottom: 40, left: 40 };
Cee Nell's avatar
Cee Nell committed
// Watch for changes in the locale and update the chart labels when the language is toggled
watch(locale, () => {
  updateYAxisLabels();
});

// Function to update Y-axis labels when language changes
function updateYAxisLabels() {
  if (svg) {
    // Update y-axis max label
    svg.select('.yLabelMax')
      .text(bubbleYlabelMax.value);

    // Update y-axis min label
    svg.select('.yLabelMin')
      .text(bubbleYlabelMin.value);
  }
}

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: "#092734", 
  Landtenure: "#0C5048", 
  Livingconditions: "#45A196", 
  Socioeconomicstatus: "#FF5F33",
Cee Nell's avatar
Cee Nell committed
  Health: "#4B1B1B",
Cee Nell's avatar
Cee Nell committed
  Riskperception: "#365EB5",
  Exposure: "#CC8500" //C08A16
}; 

// Computed properties for translations
const bubbleCheckboxText = computed(() => t('text.components.chartText.bubbleCheckbox'));
const bubbleCheckboxEndText = computed(() => t('text.components.chartText.bubbleCheckboxEnd'));
const bubbleTooltipText = computed(() => t('text.components.chartText.bubbleText'));
const bubbleLegendText = computed(() => t('text.components.chartText.bubbleLegend'));
const bubbleYAxisText = computed(() => t('text.components.chartText.bubbleYaxis'));
const bubbleYlabelMax = computed(() => t('text.components.chartText.bubbleYlabelMax'));
const bubbleYlabelMin = computed(() => t('text.components.chartText.bubbleYlabelMin'));

// Labels for each category
const demographicText = computed(() => t('text.components.chartText.bubbleLabels.Demographiccharacteristics'));
const healthText = computed(() => t('text.components.chartText.bubbleLabels.Health'));
const livingConditionsText = computed(() => t('text.components.chartText.bubbleLabels.Livingconditions'));
const socioeconomicStatusText = computed(() => t('text.components.chartText.bubbleLabels.Socioeconomicstatus'));
const riskPerceptionText = computed(() => t('text.components.chartText.bubbleLabels.Riskperception'));
const landTenureText = computed(() => t('text.components.chartText.bubbleLabels.Landtenure'));
const exposureText = computed(() => t('text.components.chartText.bubbleLabels.Exposure'));

// Computed function for generating tooltip text
const generateTooltipText = (dataPoint) => {
  return computed(() => {
    const translatedDeterminant = locale.value === 'es' ? dataPoint.determinant_es : dataPoint.determinant_wrapped;
    const determinant = `<strong>${translatedDeterminant}</strong>`;
    const count = dataPoint.evidence_val;
    const studyLabel = dataPoint.evidence_val === 1 ? t('text.components.chartText.singleStudy') : t('text.components.chartText.multipleStudies');
    const appeared = t('text.components.chartText.appeared');

    return `${determinant} ${appeared} ${count} ${studyLabel}`;
  });
};
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'] }
];
// create legend for stack bar charts
function createPattern() {
  const svgDefs = d3.select('body')
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', patternId)
    .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);
}
function createLegend() {
  const keyW = 35;
  const keyH = 12;

  createPattern();

  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 ? 2 : 0);
Cee Nell's avatar
Cee Nell committed
}

// Load data and then make chart
onMounted(async () => {
  try {
    data.value = await loadJsonData('determinant_uncertainty.json');
    if (data.value.length > 0) {
      createBeeswarmChart();
      createLegend();
    } else {
      console.error('Error loading data');
Cee Nell's avatar
Cee Nell committed
    }
  } catch (error) {
    console.error('Error during component mounting', error);
async function loadJsonData(fileName) {
  try {
    const loadedData = await d3.json(publicPath + fileName);
    return loadedData;
  } 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.70 : 650;
  const margin = window.innerWidth <= 700 ? { top: 40, right: 10, bottom: 40, left: 10 } : { top: 40, right: 20, bottom: 40, left: 40 };
Cee Nell's avatar
Cee Nell committed
  const width = containerWidth - margin.left - margin.right;
  
  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', '90%')
Cee Nell's avatar
Cee Nell committed
    .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, 40] : [10, 70]);
Hayley Corson-Dosch's avatar
Hayley Corson-Dosch committed
  // append y axis
  svg.append('g')
    .attr("transform", "translate(60, 0)")
Hayley Corson-Dosch's avatar
Hayley Corson-Dosch committed
    .call(d3.axisLeft(yScale)
      .ticks(5)
      .tickFormat(d => d + '%'))
    .attr("stroke-width", 2)
    .attr("class", "yTicks")
    .attr("font-size", "18px");

  // Add label to y axis
  svg.append('text')
Cee Nell's avatar
Cee Nell committed
    .attr("class", "yLabel yLabelMax")
    .attr("text-anchor", "left")
Cee Nell's avatar
Cee Nell committed
    .attr("font-weight", 700)
    .attr("transform", `translate(${margin.left}, ${margin.top / 2})`)
    .text(bubbleYlabelMax.value);
Cee Nell's avatar
Cee Nell committed

  svg.append('text')
Cee Nell's avatar
Cee Nell committed
    .attr("class", "yLabel yLabelMin")
    .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(bubbleYlabelMin.value);
  // 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
Hayley Corson-Dosch's avatar
Hayley Corson-Dosch committed
  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)));
}

function toggleCategory(category) {
  isChecked.value[category] = !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
Hayley Corson-Dosch's avatar
Hayley Corson-Dosch committed
  d3.scaleLinear()
Cee Nell's avatar
Cee Nell committed
    .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 ? [8, 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]);

  // 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

function updatePatternColor(color) {
  d3.select(`#${patternId} path`)
    .attr('stroke', color);
}

// 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
  }

    /// Select the tooltip element
Cee Nell's avatar
Cee Nell committed
  const tooltip = d3.select('#tooltip');
  
  // Generate the tooltip text using the computed property
  const tooltipText = generateTooltipText(d).value;

  // Update the tooltip with the constructed text
Cee Nell's avatar
Cee Nell committed
  tooltip.html('')
    .append('div')
    .html(tooltipText);
Cee Nell's avatar
Cee Nell committed

  // Define categories in stacked bar chart
Cee Nell's avatar
Cee Nell committed
  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 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 for the bars
Cee Nell's avatar
Cee Nell committed
  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');

  updatePatternColor(dimensionColors[d.dimension.replace(' ', '')]);

  // add emphasis to bubbles
  d3.select(this)
    .attr('stroke', dimensionColors[d.dimension.replace(' ', '')])
    .attr('stroke-width', 8);
  
  // update colors in legend
  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(' ', '')]);

    d3.select('#text2').select(`#${patternId} path`)
    .attr('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');
  const tooltipText = t('text.components.chartText.bubbleText');
  // add starting text
  tooltip.html(tooltipText);
  
  // remove emphasis on bubbles
Cee Nell's avatar
Cee Nell committed
  d3.select(this)
    .attr('stroke', null)
    .attr('stroke-width', null);
}
</script>
<style scoped lang="scss">
Cee Nell's avatar
Cee Nell committed
$Demographiccharacteristics: var(--color-Demographiccharacteristics);
$Landtenure: var(--color-Landtenure);
$Livingconditions: var(--color-Livingconditions);
$Socioeconomicstatus: var(--color-Socioeconomicstatus);
$Health: var(--color-Health);
$Riskperception: var(--color-Riskperception);
$Exposure: var(--color-Exposure);
$ThemeGrey: var(--color-themegrey);

#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
  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;
}
.yTicks {
  font-size: 16px;
}

.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: 16px; 
    }
    .yTicks {
      font-size: 12px;
Cee Nell's avatar
Cee Nell committed
    }
  
    #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;
    }
  }

  #water-insecurity-tooltip {
    margin-left: -160px;
  }
</style>