DEV Community

Alan Matthew
Alan Matthew

Posted on

Building a Reactive Hardy-Weinberg Equilibrium Calculator in Pure JS 🧬⚡

When studying population genetics or computational biology, calculating genotype frequencies using the Hardy-Weinberg Principle is a foundational exercise. However, many existing educational tools suffer from slow server-side re-renders, rigid input structures, or poor mobile responsiveness.

To solve this, I designed and built an interactive, zero-latency Hardy-Weinberg Equilibrium Calculator that calculates allele and genotype frequencies instantly on the client side.

In this post, I'll walk through the mathematics behind the Hardy-Weinberg model, how to implement its logic programmatically in JavaScript/TypeScript, and key UI decisions for building intuitive science calculators.


🧬 What is the Hardy-Weinberg Equilibrium?

The Hardy-Weinberg Principle states that allele and genotype frequencies in a population will remain constant from generation to generation in the absence of evolutionary influences (such as natural selection, mutation, migration, genetic drift, and non-random mating).

For a gene with two alleles—a dominant allele (A) and a recessive allele (a):

  • Let $p$ = frequency of the dominant allele (A)
  • Let $q$ = frequency of the recessive allele (a)

Core Equations:

  1. Allele Frequency Sum:
    $$p + q = 1$$

  2. Genotype Frequency Expansion (Binomial Theorem):
    $$(p + q)^2 = p^2 + 2pq + q^2 = 1$$

Where:

  • $p^2$: Frequency of homozygous dominant individuals (AA)
  • $2pq$: Frequency of heterozygous individuals (Aa)
  • $q^2$: Frequency of homozygous recessive individuals (aa)

⚙️ The Engineering Challenge

When converting algebraic biology models into interactive web tools, edge cases frequently arise:

  1. Flexible Inputs: Users might only know the count of homozygous recessive individuals ($q^2$), or they might only have raw allele frequencies ($p$ or $q$). A good calculator should handle multiple entry points seamlessly.
  2. Precision and Floating-Point Errors: Standard JavaScript floating-point arithmetic can introduce weird values like 0.30000000000000004. Clean rounding is mandatory.
  3. Instant Validation: If $p + q \neq 1$ or if counts are negative, immediate reactive error messaging is required before execution.

🎨 Front-End UX Best Practices for Math & Science ToolsBi-Directional State Binding: Allowing users to toggle between "I have frequencies ($p, q$)" and "I have raw population counts" without wiping their form state.Visual Breakdown (Bar Graphs / Distribution Diagrams): Numbers alone don't build immediate intuition. Render dynamic CSS bar charts or SVG charts representing $p^2$, $2pq$, and $q^2$ proportions in real time as values update.Copy-to-Clipboard & Export Options: Students and researchers often need to paste results into lab reports or spreadsheets. Providing a quick "Copy Summary" button enhances utility significantly.🚀 Try the Live ToolI built a production-ready version with live reactive updates and detailed step-by-step mathematical expansions:
👉 Hardy-Weinberg Equilibrium Calculator I’d love to know: How do you handle complex mathematical validation or state synchronization in your web applications? Let’s discuss in the comments below! 💬


💻 TypeScript Implementation

Here is a clean, modular TypeScript module that takes either raw allele frequencies or population counts and derives the full Hardy-Weinberg distribution:


typescript
interface HWInputByFrequency {
  p?: number;
  q?: number;
}

interface HWInputByCounts {
  countAA: number;
  countAa: number;
  countaa: number;
}

interface HWResult {
  p: number; // Dominant allele frequency
  q: number; // Recessive allele frequency
  p2: number; // AA genotype frequency
  twoPQ: number; // Aa genotype frequency
  q2: number; // aa genotype frequency
  isValid: boolean;
  errorMessage?: string;
}

/**
 * Calculates Hardy-Weinberg equilibrium from allele frequency p or q
 */
export function calculateHWFromFrequency(input: HWInputByFrequency): HWResult {
  let p = input.p;
  let q = input.q;

  if (p !== undefined && q === undefined) {
    q = 1 - p;
  } else if (q !== undefined && p === undefined) {
    p = 1 - q;
  }

  if (p === undefined || q === undefined || p < 0 || p > 1 || q < 0 || q > 1) {
    return {
      p: 0, q: 0, p2: 0, twoPQ: 0, q2: 0,
      isValid: false,
      errorMessage: 'Allele frequencies must be between 0 and 1.'
    };
  }

  const p2 = Math.pow(p, 2);
  const q2 = Math.pow(q, 2);
  const twoPQ = 2 * p * q;

  return {
    p: Number(p.toFixed(4)),
    q: Number(q.toFixed(4)),
    p2: Number(p2.toFixed(4)),
    twoPQ: Number(twoPQ.toFixed(4)),
    q2: Number(q2.toFixed(4)),
    isValid: true
  };
}

/**
 * Calculates observed vs expected frequencies from raw population counts
 */
export function calculateHWFromCounts(counts: HWInputByCounts): HWResult {
  const { countAA, countAa, countaa } = counts;
  const totalPopulation = countAA + countAa + countaa;

  if (totalPopulation <= 0) {
    return {
      p: 0, q: 0, p2: 0, twoPQ: 0, q2: 0,
      isValid: false,
      errorMessage: 'Total population count must be greater than zero.'
    };
  }

  // Total alleles = 2 * population size
  const totalAlleles = totalPopulation * 2;

  // Count of A alleles = (2 * AA) + Aa
  const p = ((2 * countAA) + countAa) / totalAlleles;
  const q = 1 - p;

  return calculateHWFromFrequency({ p });
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)