Skip to content

Writing

6 min read

Building an Electromagnetic Spectrum Explorer

Femtometers to kilometers on one screen: how I built an interactive spectrum explorer with React, D3, and NIST-exact constants behind every value.

I built an interactive explorer for the electromagnetic spectrum. The web stack is ordinary: React and D3. The hard part was fitting a range that runs from femtometers to kilometers onto a single screen without lying about the scale or the physics behind it.

The Educational Challenge of Electromagnetic Radiation

The spectrum is a first-year physics staple, and it’s still hard to picture. Wavelengths run from femtometers to kilometers; frequencies run from kilohertz to zettahertz. A textbook diagram flattens all of that into a handful of evenly spaced bands, which hides the logarithmic relationships and says nothing about where any of it turns up in daily life.

So the interface has to stay accurate while still being easy to poke at. Someone using it should walk away with the math linking wavelength, frequency, and energy, plus a feel for where those numbers land in practice, from medical imaging to radio communications.

Scientific Accuracy Requirements

A teaching tool that gets the numbers wrong is worse than none. I used the exact constants NIST publishes and carried their full precision through every calculation:

export const PHYSICS_CONSTANTS = {
  SPEED_OF_LIGHT: 299792458, // m/s (exact)
  PLANCK_CONSTANT: 6.62607015e-34, // J⋅s (exact)
  PLANCK_CONSTANT_EV: 4.135667696e-15, // eV⋅s (exact)
  ELECTRON_VOLT: 1.602176634e-19, // J (exact NIST 2018 value)
};

Every other value in the app is derived from these, so getting them right once keeps the rest of it honest.

Architecture Patterns for Scientific Visualization

Physics Calculation Engine

Every value on screen is derived from a wavelength, which puts the conversion functions at the center of the whole thing. They have to cover the full range without choking on bad input:

export function wavelengthToFrequency(wavelength) {
  if (!isFinite(wavelength) || wavelength <= 0) return NaN;
  return SPEED_OF_LIGHT / wavelength;
}

export function wavelengthToEnergyEV(wavelength) {
  if (!isFinite(wavelength) || wavelength <= 0) return NaN;
  return (PLANCK_CONSTANT_EV * SPEED_OF_LIGHT) / wavelength;
}

Note the guard on the first line of each. A zero or negative wavelength is meaningless, and worse, it produces a plausible-looking wrong number once it flows through a chain of conversions. Returning NaN early kills it at the source.

Logarithmic Scale Visualization

Electromagnetic spectrum visualization requires logarithmic scaling to represent the enormous range of wavelengths and frequencies. The implementation uses D3.js scaling functions combined with custom positioning algorithms:

export function getLogPosition(value, min, max) {
  if (value <= 0 || min <= 0 || max <= 0) return 0;
  const logValue = Math.log10(value);
  const logMin = Math.log10(min);
  const logMax = Math.log10(max);
  return (logValue - logMin) / (logMax - logMin);
}

That normalizes any value to a position between 0 and 1, which is what lets a single bar hold 20+ orders of magnitude, from gamma rays in femtometers to radio waves in kilometers.

A stylized representation of the D3.js logarithmic spectrum bar, illustrating the core visualization challenge discussed in the text.

Data Architecture for Spectrum Regions

Structured Spectrum Data

Each region of the spectrum is a plain object that carries its own boundaries, a color, and the human-readable content that goes with it:

export const SPECTRUM_REGIONS = [
  {
    id: 'gamma',
    name: 'Gamma Rays',
    color: '#B19CD9',
    wavelengthMin: 1e-15, // 1 fm
    wavelengthMax: 10e-12, // 10 pm
    frequencyMin: 3e19, // 30 EHz
    frequencyMax: 3e23, // 300 ZHz
    energyMin: 124000, // eV (124 keV)
    energyMax: 1e12, // eV (1 TeV)
    description: 'Gamma rays are the most energetic form of electromagnetic radiation.',
    applications: [
      'Cancer treatment (radiotherapy)',
      'Medical imaging (PET scans)',
      'Nuclear medicine'
    ],
    examples: [
      'Cobalt-60 therapy: 1.17 and 1.33 MeV',
      'PET scan tracers: 511 keV'
    ]
  }
  // ... additional regions
];

Keeping the numeric bounds and the descriptive text in one record means a lookup by wavelength hands back the region and everything I need to render its panel. No second join, no separate content file drifting out of sync.

Region Detection Algorithms

Finding the region for a given wavelength is a linear scan behind the same input guard as before:

export function getRegionByWavelength(wavelength) {
  if (!isFinite(wavelength) || wavelength <= 0) {
    return null;
  }

  return SPECTRUM_REGIONS.find(region =>
    wavelength >= region.wavelengthMin && wavelength <= region.wavelengthMax
  ) || null;
}

There are only a handful of regions, so a linear find is plenty. I’d rather keep it obvious than shave microseconds off a loop that runs once per click.

A conceptual diagram of the real-time unit conversion logic, showing how changing one input updates the others.

Interactive Conversion Interface Design

Real-time Unit Conversion

The conversion panel has three inputs (wavelength, frequency, energy) that all describe the same photon. Change one and the other two should follow:

function SimpleConversionPanel({ selectedWavelength, onWavelengthChange }) {
  const [wavelengthInput, setWavelengthInput] = useState('');
  const [frequencyInput, setFrequencyInput] = useState('');
  const [energyInput, setEnergyInput] = useState('');

  const handleWavelengthChange = (value) => {
    const wavelength = parseWavelength(value);
    if (!isNaN(wavelength) && wavelength > 0) {
      onWavelengthChange(wavelength);
    }
  };

  // Similar handlers for frequency and energy...
}

The trick is keeping the three in sync without setting off an update loop: wavelength updates frequency, which updates energy, which updates wavelength again. Treating the selected wavelength as the single source of truth and deriving the rest from it avoids that.

Input Parsing and Validation

People type units every way you can imagine, so the parser has to handle unit suffixes and scientific notation:

export function parseWavelength(input) {
  const value = safeParseFloat(input);
  if (isNaN(value)) return NaN;

  const unit = input.toLowerCase().replace(/[0-9.\-+e\s]/g, '');

  switch (unit) {
    case 'nm': return value * 1e-9;
    case 'μm': case 'um': return value * 1e-6;
    case 'mm': return value * 1e-3;
    case 'cm': return value * 1e-2;
    case 'm': return value;
    case 'km': return value * 1e3;
    default: return value; // assume meters if no unit
  }
}

It accepts the common spellings, including both μm and um, and falls back to meters when there’s no unit rather than rejecting the input outright.

Educational Interface Patterns

Progressive Disclosure

The educational panel implements progressive disclosure patterns that reveal information based on user interaction and current context:

function SimpleEducationalPanel({ selectedWavelength }) {
  const region = getRegionByWavelength(selectedWavelength);

  if (!region) {
    return <div>Select a wavelength to explore its properties</div>;
  }

  return (
    <div className="educational-panel">
      <h3>{region.name}</h3>
      <p>{region.description}</p>

      <div className="applications">
        <h4>Applications:</h4>
        <ul>
          {region.applications.map((app, index) => (
            <li key={index}>{app}</li>
          ))}
        </ul>
      </div>

      <div className="examples">
        <h4>Real-world Examples:</h4>
        <ul>
          {region.examples.map((example, index) => (
            <li key={index}>{example}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

The panel only shows what’s relevant to the current selection. Pick a wavelength and you get that region’s description, applications, and examples. Pick nothing and you get a short prompt instead of an empty box.

Visual Feedback Systems

The spectrum visualization provides immediate visual feedback through color coding, positioning, and scale indicators:

function SimpleSpectrum({ selectedWavelength, onWavelengthChange }) {
  const position = getLogPosition(selectedWavelength, 1e-15, 1e4);
  const region = getRegionByWavelength(selectedWavelength);

  return (
    <div className="spectrum-container">
      <svg width="100%" height="100">
        {SPECTRUM_REGIONS.map(region => (
          <rect
            key={region.id}
            x={getLogPosition(region.wavelengthMin, 1e-15, 1e4) * 100 + '%'}
            width={(getLogPosition(region.wavelengthMax, 1e-15, 1e4) -
                   getLogPosition(region.wavelengthMin, 1e-15, 1e4)) * 100 + '%'}
            height="100%"
            fill={region.color}
            onClick={() => onWavelengthChange(
              (region.wavelengthMin + region.wavelengthMax) / 2
            )}
          />
        ))}

        <line
          x1={position * 100 + '%'}
          x2={position * 100 + '%'}
          y1="0"
          y2="100%"
          stroke="black"
          strokeWidth="2"
        />
      </svg>
    </div>
  );
}

Each region is a colored rectangle placed by its log position, with a vertical line marking the current selection. Clicking a region jumps to its midpoint, which is enough for browsing; the conversion panel is there when you need an exact value.

Testing Strategies for Scientific Applications

Physics Calculation Validation

Tests here check two things at once: that the code runs and that the answer matches the physics. I pin a few known conversions and assert against them within a tolerance:

export function testPhysicsCalculations() {
  const tests = [
    {
      name: 'Visible light wavelength to frequency',
      wavelength: 550e-9, // Green light
      expectedFrequency: 5.45e14, // Hz
      tolerance: 1e12
    },
    {
      name: 'X-ray energy calculation',
      wavelength: 1e-10, // 0.1 nm
      expectedEnergy: 12400, // eV
      tolerance: 100
    }
  ];

  tests.forEach(test => {
    const frequency = wavelengthToFrequency(test.wavelength);
    const energy = wavelengthToEnergyEV(test.wavelength);

    assert(
      Math.abs(frequency - test.expectedFrequency) < test.tolerance,
      `Frequency calculation failed for ${test.name}`
    );
  });
}

Green light at 550 nm should come out near 5.45e14 Hz, and a 0.1 nm X-ray should land around 12400 eV. If either drifts, something in the constants or the math broke, and the test says so before a user ever sees it.

Performance Optimization for Large-Scale Data

Efficient Range Calculations

Showing a wavelength means picking a unit so the number reads cleanly instead of as a wall of exponent. The formatter switches units by magnitude:

export function formatWavelength(wavelength) {
  if (!isFinite(wavelength) || wavelength <= 0) {
    return 'Invalid wavelength';
  }

  if (wavelength >= 1e-3) {
    return wavelength >= 1 ?
      `${wavelength.toExponential(2)} m` :
      `${(wavelength * 1000).toFixed(2)} mm`;
  } else if (wavelength >= 1e-6) {
    return `${(wavelength * 1e6).toFixed(2)} μm`;
  } else if (wavelength >= 1e-9) {
    return `${(wavelength * 1e9).toFixed(2)} nm`;
  } else {
    return `${wavelength.toExponential(2)} m`;
  }
}

Millimeter-scale values read in mm, micron-scale in μm, and nanometer-scale in nm, with exponential notation held back for the extremes.

Deployment and Distribution Patterns

Automated GitHub Pages Deployment

Deployment is a GitHub Actions workflow that builds and pushes to GitHub Pages on every commit to main:

name: Deploy to GitHub Pages
on:
  push:
    branches: [ main ]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      - name: Install dependencies
        run: npm install
      - name: Build
        run: npm run build
      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist

Push to main and the live site updates itself. Nothing to babysit.

Future Directions in Scientific Web Applications

A few things I’d like to add when I get back to it:

  • Wiring in a physics engine so the visuals can move instead of sitting still.
  • Shared sessions, so two people can explore and annotate the same view.
  • Content that adapts to what a learner has already looked at.
  • Real parity on mobile and VR, not a shrunk-down desktop layout.

None of the groundwork here is exotic. The value was in the discipline: exact constants, log scaling done once and reused, tests that assert physics and not just code paths. That combination carries over to any tool where the numbers have to be right.

Getting Started

Want to explore the electromagnetic spectrum yourself? The application is available at cameronrye.github.io/electromagnetic-spectrum-explorer, and the complete source code can be found at github.com/cameronrye/electromagnetic-spectrum-explorer.

That’s the whole thing. The math is exact, the layout is honest about scale, and it all runs in the browser.


The full source, including everything shown here, is on GitHub.

Subscribe

Was this helpful?

Discuss

Ask can help explain concepts, provide context, or point you to related content.