JavaScript and TypeScript course ยท Module 8: Events and the DOM

Final Project: Interactive Jurassic Park Control Panel

10 min read ยท
In this lesson4

"This is the moment of truth," announces Ray Arnold, chief systems engineer of Jurassic Park, pointing at the dark, disabled screen of the central control panel. "Our main monitoring system failed during the last storm. You need to rebuild the interactive control panel from scratch, using everything you've learned about events and interactions in JavaScript."

In this project, you'll create a complete, interactive Jurassic Park control panel. The panel will handle clicking, dragging, keyboard events, forms, event delegation, propagation, throttling, custom events, and proper memory management. This is a real test of all skills from this module - from basic event listeners to advanced architectural patterns.

Project Goal

You'll build a control panel with the following features:

  • Park map with clickable sectors (event delegation)
  • Alarm system responding to keyboard shortcuts
  • Incident registration form with validation
  • Dinosaur activity monitor with throttling
  • Event Bus for inter-module communication
  • Proper memory cleanup when closing panels

Functional Requirements

1. Park Map with Event Delegation and Propagation

Instead of adding an event listener to each sector individually, use event delegation - one listener on the map container handles clicks in all sectors. Also remember that a click on a dinosaur bubbles through the sector up to the map, so in the delegated listener check the dinosaur first and finish with return, so that the sector is not activated as well.

// HTML structure of the park map
// <div id="park-map">
//   <div class="sector" data-sector="A" data-danger="high">
//     <span class="dino" data-dino-id="1">T-Rex</span>
//     <span class="dino" data-dino-id="2">Velociraptor</span>
//   </div>
//   <div class="sector" data-sector="B" data-danger="low">
//     <span class="dino" data-dino-id="3">Triceratops</span>
//   </div>
// </div>

class ParkMap {
  constructor(mapElement) {
    this.mapElement = mapElement;
    this.selectedSector = null;
    this.setupDelegation();
  }

  setupDelegation() {
    // One listener handles all clicks on the map
    this.mapElement.addEventListener('click', (event) => {
      const dinoElement = event.target.closest('.dino');

      if (dinoElement) {
        // Clicked on a dinosaur - handle it and stop here so the sector is not selected
        // (stopPropagation() would not help: the event has already passed the sector)
        this.handleDinoClick(dinoElement);
        return;
      }

      const sectorElement = event.target.closest('.sector');
      if (sectorElement) {
        this.handleSectorClick(sectorElement);
      }
    });
  }

  handleSectorClick(sectorEl) {
    // Deselect previous sector
    if (this.selectedSector) {
      this.selectedSector.classList.remove('selected');
    }

    sectorEl.classList.add('selected');
    this.selectedSector = sectorEl;

    const sectorId = sectorEl.dataset.sector;
    const danger = sectorEl.dataset.danger;

    console.log(`Sector ${sectorId} selected [Danger: ${danger}]`);

    // Emit custom event with selected sector info
    const sectorEvent = new CustomEvent('sector:selected', {
      detail: { sectorId, danger },
      bubbles: true
    });
    sectorEl.dispatchEvent(sectorEvent);
  }

  handleDinoClick(dinoEl) {
    const dinoId = dinoEl.dataset.dinoId;
    const dinoName = dinoEl.textContent;

    console.log(`Selected dinosaur: ${dinoName} (ID: ${dinoId})`);

    // Custom event with dinosaur data
    const dinoEvent = new CustomEvent('dino:selected', {
      detail: { dinoId, dinoName },
      bubbles: true
    });
    dinoEl.dispatchEvent(dinoEvent);
  }

  // Dynamically adding new sectors - thanks to delegation
  // no need to add new listeners
  addSector(sectorId, danger) {
    const sector = document.createElement('div');
    sector.className = 'sector';
    sector.dataset.sector = sectorId;
    sector.dataset.danger = danger;
    sector.textContent = `Sector ${sectorId}`;
    this.mapElement.appendChild(sector);
    // Event listener already works automatically!
  }
}

2. Alarm System with Keyboard and Mouse Events

Create a system that responds to keyboard shortcuts for quickly launching emergency procedures. The system must handle key combinations, distinguish context (is the user typing in a form or controlling the panel), and respond to different types of mouse events.

class AlarmSystem {
  constructor() {
    this.alarmLevel = 0; // 0-4 (0 = none, 4 = critical)
    this.isActive = true;
    this.keyHandlers = new Map();
    this.boundHandleKeyDown = this.handleKeyDown.bind(this);
    this.boundHandleKeyUp = this.handleKeyUp.bind(this);
    this.pressedKeys = new Set();

    this.registerShortcuts();
    this.attachListeners();
  }

  registerShortcuts() {
    // Ctrl+1 to Ctrl+4: Alarm levels
    this.keyHandlers.set('ctrl+1', () => this.setAlarmLevel(1));
    this.keyHandlers.set('ctrl+2', () => this.setAlarmLevel(2));
    this.keyHandlers.set('ctrl+3', () => this.setAlarmLevel(3));
    this.keyHandlers.set('ctrl+4', () => this.setAlarmLevel(4));

    // Escape: Disable alarm
    this.keyHandlers.set('escape', () => this.setAlarmLevel(0));

    // Ctrl+Shift+E: Evacuation
    this.keyHandlers.set('ctrl+shift+e', () => this.triggerEvacuation());

    // Ctrl+Shift+L: Lock all enclosures
    this.keyHandlers.set('ctrl+shift+l', () => this.lockAllEnclosures());
  }

  attachListeners() {
    document.addEventListener('keydown', this.boundHandleKeyDown);
    document.addEventListener('keyup', this.boundHandleKeyUp);
  }

  handleKeyDown(event) {
    // Ignore shortcuts when user is typing in a form
    if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
      return;
    }

    this.pressedKeys.add(event.key.toLowerCase());

    // Build key combination
    const combo = this.buildKeyCombo(event);

    const handler = this.keyHandlers.get(combo);
    if (handler) {
      event.preventDefault(); // Prevent default browser action
      handler();
    }
  }

  handleKeyUp(event) {
    this.pressedKeys.delete(event.key.toLowerCase());
  }

  buildKeyCombo(event) {
    const parts = [];
    if (event.ctrlKey || event.metaKey) parts.push('ctrl');
    if (event.shiftKey) parts.push('shift');
    if (event.altKey) parts.push('alt');

    const key = event.key.toLowerCase();
    if (!['control', 'shift', 'alt', 'meta'].includes(key)) {
      parts.push(key);
    }

    return parts.join('+');
  }

  setAlarmLevel(level) {
    const previousLevel = this.alarmLevel;
    this.alarmLevel = level;

    console.log(`Alarm level: ${previousLevel} โ†’ ${level}`);

    // Emit custom event about alarm level change
    document.dispatchEvent(new CustomEvent('alarm:levelChanged', {
      detail: { previousLevel, currentLevel: level }
    }));
  }

  triggerEvacuation() {
    console.log('EVACUATION TRIGGERED!');
    this.setAlarmLevel(4);
    document.dispatchEvent(new CustomEvent('alarm:evacuation'));
  }

  lockAllEnclosures() {
    console.log('Locking all enclosures...');
    document.dispatchEvent(new CustomEvent('alarm:lockdown'));
  }

  // Memory cleanup - CRITICAL
  destroy() {
    document.removeEventListener('keydown', this.boundHandleKeyDown);
    document.removeEventListener('keyup', this.boundHandleKeyUp);
    this.keyHandlers.clear();
    this.pressedKeys.clear();
    this.isActive = false;
    console.log('AlarmSystem: Listeners removed, memory cleaned');
  }
}

3. Incident Registration Form with Validation

Build a form handling submit, input, change, focus, and blur events. The form should validate data in real time and prevent submission of invalid data.

class IncidentForm {
  constructor(formElement) {
    this.form = formElement;
    this.errors = new Map();
    this.boundHandlers = [];

    this.setupValidation();
    this.setupSubmit();
  }

  setupValidation() {
    // Validate "description" field on every typed character
    const descriptionInput = this.form.querySelector('#description');
    const descHandler = (event) => {
      const value = event.target.value;
      if (value.length < 10) {
        this.setError('description', 'Description must be at least 10 characters');
      } else if (value.length > 500) {
        this.setError('description', 'Description cannot exceed 500 characters');
      } else {
        this.clearError('description');
      }
    };
    descriptionInput.addEventListener('input', descHandler);
    this.boundHandlers.push({ element: descriptionInput, type: 'input', handler: descHandler });

    // Validate "sector" select on change
    const sectorSelect = this.form.querySelector('#sector');
    const sectorHandler = (event) => {
      if (!event.target.value) {
        this.setError('sector', 'Select a sector');
      } else {
        this.clearError('sector');
      }
    };
    sectorSelect.addEventListener('change', sectorHandler);
    this.boundHandlers.push({ element: sectorSelect, type: 'change', handler: sectorHandler });

    // Focus/blur visual effects
    const allInputs = this.form.querySelectorAll('input, select, textarea');
    allInputs.forEach(input => {
      const focusHandler = () => input.parentElement.classList.add('focused');
      const blurHandler = () => {
        input.parentElement.classList.remove('focused');
        // Validate field after leaving
        input.dispatchEvent(new Event('input'));
      };

      input.addEventListener('focus', focusHandler);
      input.addEventListener('blur', blurHandler);
      this.boundHandlers.push(
        { element: input, type: 'focus', handler: focusHandler },
        { element: input, type: 'blur', handler: blurHandler }
      );
    });
  }

  setupSubmit() {
    const submitHandler = (event) => {
      event.preventDefault(); // Prevent default form submission

      if (this.errors.size > 0) {
        console.log('Form contains errors:', [...this.errors.values()]);
        this.highlightErrors();
        return;
      }

      const formData = new FormData(this.form);
      const incident = Object.fromEntries(formData.entries());

      console.log('Incident reported:', incident);

      // Custom event with incident data
      document.dispatchEvent(new CustomEvent('incident:reported', {
        detail: incident
      }));

      this.form.reset();
    };

    this.form.addEventListener('submit', submitHandler);
    this.boundHandlers.push({ element: this.form, type: 'submit', handler: submitHandler });
  }

  setError(field, message) {
    this.errors.set(field, message);
    const errorEl = this.form.querySelector(`#${field}-error`);
    if (errorEl) errorEl.textContent = message;
  }

  clearError(field) {
    this.errors.delete(field);
    const errorEl = this.form.querySelector(`#${field}-error`);
    if (errorEl) errorEl.textContent = '';
  }

  highlightErrors() {
    this.errors.forEach((message, field) => {
      const input = this.form.querySelector(`#${field}`);
      if (input) {
        input.classList.add('error');
        setTimeout(() => input.classList.remove('error'), 2000);
      }
    });
  }

  // Cleanup of ALL listeners
  destroy() {
    this.boundHandlers.forEach(({ element, type, handler }) => {
      element.removeEventListener(type, handler);
    });
    this.boundHandlers = [];
    this.errors.clear();
    console.log('IncidentForm: All listeners removed');
  }
}

4. Activity Monitor with Throttling and Event Bus

Create a dinosaur activity monitoring system that uses throttling to limit update frequency and Event Bus for communication between all panel modules.

// Event Bus - central communication system
class EventBus {
  constructor() {
    this.listeners = new Map();
  }

  on(event, callback) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event).add(callback);

    // Return a function to unsubscribe
    return () => this.off(event, callback);
  }

  off(event, callback) {
    const callbacks = this.listeners.get(event);
    if (callbacks) {
      callbacks.delete(callback);
      if (callbacks.size === 0) {
        this.listeners.delete(event);
      }
    }
  }

  emit(event, data) {
    const callbacks = this.listeners.get(event);
    if (callbacks) {
      callbacks.forEach(cb => {
        try {
          cb(data);
        } catch (error) {
          console.error(`Error in listener "${event}":`, error);
        }
      });
    }
  }

  // Clear all subscriptions
  clear() {
    this.listeners.clear();
    console.log('EventBus: All subscriptions cleared');
  }
}

// Throttle utility
function throttle(fn, delay) {
  let lastCall = 0;
  let timeoutId = null;

  const throttled = function(...args) {
    const now = Date.now();
    const remaining = delay - (now - lastCall);

    if (remaining <= 0) {
      if (timeoutId) {
        clearTimeout(timeoutId);
        timeoutId = null;
      }
      lastCall = now;
      fn.apply(this, args);
    } else if (!timeoutId) {
      timeoutId = setTimeout(() => {
        lastCall = Date.now();
        timeoutId = null;
        fn.apply(this, args);
      }, remaining);
    }
  };

  // Method for cleanup
  throttled.cancel = () => {
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
  };

  return throttled;
}

// Activity monitor with throttling
class ActivityMonitor {
  constructor(eventBus, displayElement) {
    this.eventBus = eventBus;
    this.display = displayElement;
    this.unsubscribers = [];
    this.throttledFunctions = [];

    this.setupMonitoring();
  }

  setupMonitoring() {
    // Throttled mouse position updates on map
    const updatePosition = throttle((data) => {
      this.display.querySelector('.position').textContent =
        `Cursor: (${data.x}, ${data.y})`;
    }, 100); // Maximum 10 updates per second
    this.throttledFunctions.push(updatePosition);

    // Listen for events from EventBus
    const unsub1 = this.eventBus.on('dino:moved', (data) => {
      this.logActivity(`Dinosaur ${data.name} moved to (${data.x}, ${data.y})`);
    });

    const unsub2 = this.eventBus.on('alarm:levelChanged', (data) => {
      this.logActivity(`Alarm: level ${data.previousLevel} โ†’ ${data.currentLevel}`);
    });

    const unsub3 = this.eventBus.on('incident:reported', (data) => {
      this.logActivity(`New incident in sector ${data.sector}: ${data.description}`);
    });

    this.unsubscribers.push(unsub1, unsub2, unsub3);

    // Mousemove on map with throttling
    const mapElement = document.getElementById('park-map');
    if (mapElement) {
      const mouseMoveHandler = (event) => {
        const rect = mapElement.getBoundingClientRect();
        updatePosition({
          x: Math.round(event.clientX - rect.left),
          y: Math.round(event.clientY - rect.top)
        });
      };
      mapElement.addEventListener('mousemove', mouseMoveHandler);
      this.unsubscribers.push(() => {
        mapElement.removeEventListener('mousemove', mouseMoveHandler);
      });
    }
  }

  logActivity(message) {
    const entry = document.createElement('div');
    entry.className = 'log-entry';
    const time = new Date().toLocaleTimeString();
    entry.textContent = `[${time}] ${message}`;

    this.display.querySelector('.log').prepend(entry);

    // Limit the number of log entries
    const entries = this.display.querySelectorAll('.log-entry');
    if (entries.length > 50) {
      entries[entries.length - 1].remove();
    }
  }

  // Cleanup EVERYTHING
  destroy() {
    // Unsubscribe from EventBus
    this.unsubscribers.forEach(unsub => unsub());
    this.unsubscribers = [];

    // Cancel throttled functions
    this.throttledFunctions.forEach(fn => fn.cancel());
    this.throttledFunctions = [];

    console.log('ActivityMonitor: Full cleanup completed');
  }
}

Implementation Tips

Start by creating a central EventBus that will connect all modules. Then build individual components in order:

// 1. Initialize Event Bus
const eventBus = new EventBus();

// 2. Park map with delegation
const parkMap = new ParkMap(document.getElementById('park-map'));

// 3. Alarm system
const alarmSystem = new AlarmSystem();

// 4. Incident form
const incidentForm = new IncidentForm(document.getElementById('incident-form'));

// 5. Activity monitor
const activityMonitor = new ActivityMonitor(
  eventBus,
  document.getElementById('activity-panel')
);

// 6. Connect modules via EventBus
// Listen for custom DOM events and redirect to EventBus
document.addEventListener('sector:selected', (e) => {
  eventBus.emit('sector:selected', e.detail);
});

document.addEventListener('alarm:levelChanged', (e) => {
  eventBus.emit('alarm:levelChanged', e.detail);
});

document.addEventListener('incident:reported', (e) => {
  eventBus.emit('incident:reported', e.detail);
});

// 7. Cleanup on close (e.g., when changing views in SPA)
function destroyPanel() {
  parkMap.destroy?.();
  alarmSystem.destroy();
  incidentForm.destroy();
  activityMonitor.destroy();
  eventBus.clear();
  console.log('Control panel closed - all resources released');
}

Remember the key principles:

  • Event delegation instead of many listeners on individual elements
  • event.stopPropagation() only when a click really must not reach the ancestors (in delegation a return is usually enough)
  • event.preventDefault() for forms and keyboard shortcuts
  • Throttling for high-frequency events (mousemove, scroll)
  • Storing references to handlers so you can remove them later
  • destroy() method in every module for memory cleanup

Good Luck!

"Jurassic Park systems don't forgive mistakes," says Ray Arnold, lighting another cigarette. "A memory leak in the enclosure monitoring means the system will crash after a few hours. An unremoved event listener can block the garbage collector and consume more and more RAM. And poorly handled event propagation can cause a click on the evacuation button to open the T-Rex enclosure gate instead of closing it."

"Build this panel as if people's lives in the park depend on it. Because in a sense - they do."

Open the editor and create your interactive control panel!

Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Control Panel - Jurassic Park</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: Arial, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
        h1 { text-align: center; color: #e94560; margin-bottom: 20px; }
        .panel { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; max-width: 900px; margin: 0 auto; }
        .section { background: rgba(255,255,255,0.05); padding: 20px; border-radius: 10px; border: 1px solid #333; }
        .section h2 { color: #0f3460; font-size: 16px; margin-bottom: 10px; color: #e94560; }
        .sector { padding: 10px; margin: 5px 0; border-radius: 6px; cursor: pointer; background: rgba(255,255,255,0.05); border: 1px solid #444; }
        .sector:hover { background: rgba(233,69,96,0.2); }
        .sector .dino { display: inline-block; background: #0f3460; padding: 3px 8px; border-radius: 4px; margin: 2px; cursor: pointer; font-size: 12px; }
        .dino:hover { background: #e94560; }
        .log { background: #000; padding: 10px; border-radius: 6px; height: 150px; overflow-y: auto; font-family: monospace; font-size: 12px; }
        .log-entry { padding: 2px 0; border-bottom: 1px solid #222; }
        input, select, textarea { padding: 8px; border-radius: 4px; border: 1px solid #444; background: #16213e; color: #eee; width: 100%; margin: 5px 0; }
        button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; background: #e94560; color: white; margin: 5px 2px; }
        button:hover { background: #c0392b; }
        .alert { padding: 8px; margin: 5px 0; border-radius: 4px; font-size: 13px; }
        .alert-high { background: rgba(231,76,60,0.3); border-left: 3px solid #e74c3c; }
        .alert-low { background: rgba(46,204,113,0.3); border-left: 3px solid #2ecc71; }
    </style>
</head>
<body>
    <h1>Jurassic Park Control Panel</h1>
    <div class="panel">
        <!-- SECTION 1: Park map with event delegation -->
        <div class="section">
            <h2>Park Map</h2>
            <div id="park-map">
                <!-- TODO: Add an event listener on #park-map (delegation) -->
                <!-- Click on .sector -> show the sector info -->
                <!-- Click on .dino -> show the dinosaur info and finish with return, so the sector is not selected -->
                <div class="sector" data-sector="A" data-danger="high">
                    Sector A (High danger)
                    <div><span class="dino" data-dino-id="1">T-Rex</span> <span class="dino" data-dino-id="2">Velociraptor</span></div>
                </div>
                <div class="sector" data-sector="B" data-danger="low">
                    Sector B (Low danger)
                    <div><span class="dino" data-dino-id="3">Triceratops</span> <span class="dino" data-dino-id="4">Brachio</span></div>
                </div>
                <div class="sector" data-sector="C" data-danger="medium">
                    Sector C (Medium danger)
                    <div><span class="dino" data-dino-id="5">Stegosaurus</span></div>
                </div>
            </div>
        </div>

        <!-- SECTION 2: Incident form -->
        <div class="section">
            <h2>Incident Report</h2>
            <form id="incident-form">
                <!-- TODO: Add a submit event listener with preventDefault -->
                <!-- TODO: Validate the fields before sending -->
                <input type="text" id="incident-title" name="title" placeholder="Incident title" required>
                <textarea id="description" name="description" placeholder="Incident description (at least 10 characters)"></textarea>
                <div class="error" id="description-error"></div>
                <select id="sector" name="sector">
                    <option value="">Choose a sector</option>
                    <option value="A">Sector A</option>
                    <option value="B">Sector B</option>
                    <option value="C">Sector C</option>
                </select>
                <div class="error" id="sector-error"></div>
                <select id="incident-severity" name="severity">
                    <option value="">Priority</option>
                    <option value="high">High</option>
                    <option value="medium">Medium</option>
                    <option value="low">Low</option>
                </select>
                <button type="submit">Report incident</button>
            </form>
            <div id="alerts"></div>
        </div>

        <!-- SECTION 3: Event log -->
        <div class="section" style="grid-column: 1 / -1;">
            <h2>Event log (shortcuts: Ctrl+1-4 = alarm level, Esc = alarm off, Ctrl+Shift+E = evacuation, Ctrl+Shift+L = lockdown)</h2>
            <div class="log" id="event-log"></div>
        </div>

        <!-- SECTION 4: Activity monitor (ActivityMonitor from the lesson) -->
        <div class="section" id="activity-panel" style="grid-column: 1 / -1;">
            <h2>Activity monitor</h2>
            <div class="position">Cursor: (0, 0)</div>
            <div class="log"></div>
        </div>
    </div>

    <script>
        // === FINAL PROJECT: An interactive control panel ===

        // TODO 1: EVENT DELEGATION on the park map
        // Add ONE event listener on #park-map
        // Use event.target.closest() to identify the clicked element

        // TODO 2: A FORM with validation
        // Add a listener for the form submit
        // Use preventDefault()
        // Validate the fields and add an alert to #alerts

        // TODO 3: KEYBOARD SHORTCUTS
        // Add a keydown listener
        // Ctrl+1-4 = alarm level, Esc = alarm off, Ctrl+Shift+E = evacuation, Ctrl+Shift+L = lockdown

        // TODO 4: LOGGING FUNCTION
        // Create a function logEvent(message) that adds an entry to #event-log

        function logEvent(message) {
            // TODO: Add a log entry with a timestamp
        }

        // TODO 5: CLEANING UP MEMORY
        // Create a function cleanup() that removes all listeners
        // Call it e.g. when the panel is closed

        console.log("Control panel ready to configure!");
    </script>
</body>
</html>

Docs entries used in this lesson

Spotted a mistake in this lesson?

Useful articles