Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM

Projekt końcowy: Interaktywny panel kontrolny Parku Jurajskiego

10 min czytania ·
W tej lekcji4

"To jest moment prawdy," ogłasza Ray Arnold, główny inżynier systemów Parku Jurajskiego, wskazując na ciemny, wyłączony ekran centralnego panelu kontrolnego. "Nasz główny system monitoringu uległ awarii podczas ostatniej burzy. Musisz odbudować interaktywny panel kontrolny od zera, wykorzystując wszystko, czego nauczyłeś się o wydarzeniach i interakcjach w JavaScript."

W tym projekcie stworzysz kompletny, interaktywny panel kontrolny Parku Jurajskiego. Panel będzie obsługiwał klikanie, przeciąganie, zdarzenia klawiatury, formularze, delegację wydarzeń, propagację, throttling, custom events i odpowiednie zarządzanie pamięcią. To prawdziwy test wszystkich umiejętności z tego modułu - od podstawowych event listenerów po zaawansowane wzorce architektoniczne.

Cel projektu

Zbudujesz panel kontrolny z następującymi funkcjonalnościami:

  • Mapa parku z klikalnymi sektorami (delegacja wydarzeń)
  • System alarmowy reagujący na skróty klawiszowe
  • Formularz rejestracji incydentów z walidacją
  • Monitor aktywności dinozaurów z throttlingiem
  • Event Bus do komunikacji między modułami
  • Poprawne czyszczenie pamięci przy zamykaniu paneli

Wymagania funkcjonalne

1. Mapa parku z delegacją wydarzeń i propagacją

Zamiast dodawać event listener do każdego sektora osobno, wykorzystaj delegację wydarzeń - jeden listener na kontenerze mapy obsługuje kliknięcia we wszystkich sektorach. Pamiętaj też, że kliknięcie w dinozaura bąbelkuje przez sektor do mapy, więc w delegowanym listenerze najpierw sprawdź dinozaura i zakończ obsługę przez return, żeby nie aktywować jednocześnie sektora.

// Struktura HTML mapy parku
// <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() {
    // Jeden listener obsługuje wszystkie kliknięcia na mapie
    this.mapElement.addEventListener('click', (event) => {
      const dinoElement = event.target.closest('.dino');

      if (dinoElement) {
        // Kliknięto w dinozaura - obsłuż go i zakończ, żeby nie zaznaczać sektora
        // (stopPropagation() nic by tu nie dało: zdarzenie przeszło już przez sektor)
        this.handleDinoClick(dinoElement);
        return;
      }

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

  handleSectorClick(sectorEl) {
    // Odznacz poprzedni sektor
    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(`Sektor ${sectorId} wybrany [Zagrożenie: ${danger}]`);

    // Emituj custom event z informacją o wybranym sektorze
    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(`Wybrany dinozaur: ${dinoName} (ID: ${dinoId})`);

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

  // Dynamiczne dodawanie nowych sektorów - dzięki delegacji
  // nie trzeba dodawać nowych listenerów
  addSector(sectorId, danger) {
    const sector = document.createElement('div');
    sector.className = 'sector';
    sector.dataset.sector = sectorId;
    sector.dataset.danger = danger;
    sector.textContent = `Sektor ${sectorId}`;
    this.mapElement.appendChild(sector);
    // Event listener już działa automatycznie!
  }
}

2. System alarmowy z wydarzeniami klawiatury i myszy

Stwórz system reagujący na skróty klawiszowe do szybkiego uruchamiania procedur awaryjnych. System musi obsługiwać kombinacje klawiszy, rozróżniać kontekst (czy użytkownik pisze w formularzu, czy steruje panelem) i reagować na różne typy wydarzeń myszy.

class AlarmSystem {
  constructor() {
    this.alarmLevel = 0; // 0-4 (0 = brak, 4 = krytyczny)
    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 do Ctrl+4: Poziomy alarmu
    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: Wyłącz alarm
    this.keyHandlers.set('escape', () => this.setAlarmLevel(0));

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

    // Ctrl+Shift+L: Zablokuj wszystkie zagrody
    this.keyHandlers.set('ctrl+shift+l', () => this.lockAllEnclosures());
  }

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

  handleKeyDown(event) {
    // Ignoruj skróty, gdy użytkownik pisze w formularzu
    if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
      return;
    }

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

    // Buduj kombinację klawiszy
    const combo = this.buildKeyCombo(event);

    const handler = this.keyHandlers.get(combo);
    if (handler) {
      event.preventDefault(); // Zapobiegaj domyślnej akcji przeglądarki
      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(`Poziom alarmu: ${previousLevel} → ${level}`);

    // Emituj custom event o zmianie poziomu alarmu
    document.dispatchEvent(new CustomEvent('alarm:levelChanged', {
      detail: { previousLevel, currentLevel: level }
    }));
  }

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

  lockAllEnclosures() {
    console.log('Blokowanie wszystkich zagród...');
    document.dispatchEvent(new CustomEvent('alarm:lockdown'));
  }

  // Czyszczenie pamięci - KRYTYCZNE
  destroy() {
    document.removeEventListener('keydown', this.boundHandleKeyDown);
    document.removeEventListener('keyup', this.boundHandleKeyUp);
    this.keyHandlers.clear();
    this.pressedKeys.clear();
    this.isActive = false;
    console.log('AlarmSystem: Listenery usunięte, pamięć wyczyszczona');
  }
}

3. Formularz rejestracji incydentów z walidacją

Zbuduj formularz obsługujący zdarzenia submit, input, change, focus i blur. Formularz powinien walidować dane w czasie rzeczywistym i zapobiegać wysłaniu niepoprawnych danych.

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

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

  setupValidation() {
    // Walidacja pola "opis" przy każdym wpisywanym znaku
    const descriptionInput = this.form.querySelector('#description');
    const descHandler = (event) => {
      const value = event.target.value;
      if (value.length < 10) {
        this.setError('description', 'Opis musi mieć minimum 10 znaków');
      } else if (value.length > 500) {
        this.setError('description', 'Opis nie może przekraczać 500 znaków');
      } else {
        this.clearError('description');
      }
    };
    descriptionInput.addEventListener('input', descHandler);
    this.boundHandlers.push({ element: descriptionInput, type: 'input', handler: descHandler });

    // Walidacja selecta "sektor" przy zmianie
    const sectorSelect = this.form.querySelector('#sector');
    const sectorHandler = (event) => {
      if (!event.target.value) {
        this.setError('sector', 'Wybierz sektor');
      } else {
        this.clearError('sector');
      }
    };
    sectorSelect.addEventListener('change', sectorHandler);
    this.boundHandlers.push({ element: sectorSelect, type: 'change', handler: sectorHandler });

    // Focus/blur efekty wizualne
    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');
        // Waliduj pole po opuszczeniu
        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(); // Zapobiegaj domyślnemu wysłaniu formularza

      if (this.errors.size > 0) {
        console.log('Formularz zawiera błędy:', [...this.errors.values()]);
        this.highlightErrors();
        return;
      }

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

      console.log('Incydent zgłoszony:', incident);

      // Custom event z danymi incydentu
      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);
      }
    });
  }

  // Czyszczenie WSZYSTKICH listenerów
  destroy() {
    this.boundHandlers.forEach(({ element, type, handler }) => {
      element.removeEventListener(type, handler);
    });
    this.boundHandlers = [];
    this.errors.clear();
    console.log('IncidentForm: Wszystkie listenery usunięte');
  }
}

4. Monitor aktywności z throttlingiem i Event Bus

Stwórz system monitorowania aktywności dinozaurów, który wykorzystuje throttling do ograniczania częstotliwości aktualizacji oraz Event Bus do komunikacji między wszystkimi modułami panelu.

// Event Bus - centralny system komunikacji
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);

    // Zwróć funkcję do odsubskrybowania
    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(`Błąd w listenerze "${event}":`, error);
        }
      });
    }
  }

  // Czyszczenie wszystkich subskrypcji
  clear() {
    this.listeners.clear();
    console.log('EventBus: Wszystkie subskrypcje wyczyszczone');
  }
}

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

  // Metoda do czyszczenia
  throttled.cancel = () => {
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
  };

  return throttled;
}

// Monitor aktywności z throttlingiem
class ActivityMonitor {
  constructor(eventBus, displayElement) {
    this.eventBus = eventBus;
    this.display = displayElement;
    this.unsubscribers = [];
    this.throttledFunctions = [];

    this.setupMonitoring();
  }

  setupMonitoring() {
    // Throttlowane aktualizacje pozycji myszy na mapie
    const updatePosition = throttle((data) => {
      this.display.querySelector('.position').textContent =
        `Kursor: (${data.x}, ${data.y})`;
    }, 100); // Maksymalnie 10 aktualizacji na sekundę
    this.throttledFunctions.push(updatePosition);

    // Nasłuchuj na zdarzenia z EventBus
    const unsub1 = this.eventBus.on('dino:moved', (data) => {
      this.logActivity(`Dinozaur ${data.name} przemieścił się do (${data.x}, ${data.y})`);
    });

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

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

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

    // Mousemove na mapie z throttlingiem
    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);

    // Ogranicz liczbę wpisów w logu
    const entries = this.display.querySelectorAll('.log-entry');
    if (entries.length > 50) {
      entries[entries.length - 1].remove();
    }
  }

  // Czyszczenie WSZYSTKIEGO
  destroy() {
    // Odsubskrybuj z EventBus
    this.unsubscribers.forEach(unsub => unsub());
    this.unsubscribers = [];

    // Anuluj throttlowane funkcje
    this.throttledFunctions.forEach(fn => fn.cancel());
    this.throttledFunctions = [];

    console.log('ActivityMonitor: Pełne czyszczenie zakończone');
  }
}

Wskazówki implementacyjne

Zacznij od stworzenia centralnego EventBus, który będzie łączył wszystkie moduły. Następnie buduj poszczególne komponenty w kolejności:

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

// 2. Mapa parku z delegacją
const parkMap = new ParkMap(document.getElementById('park-map'));

// 3. System alarmowy
const alarmSystem = new AlarmSystem();

// 4. Formularz incydentów
const incidentForm = new IncidentForm(document.getElementById('incident-form'));

// 5. Monitor aktywności
const activityMonitor = new ActivityMonitor(
  eventBus,
  document.getElementById('activity-panel')
);

// 6. Połącz moduły przez EventBus
// Nasłuchuj custom events z DOM i przekieruj do 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. Czyszczenie przy zamykaniu (np. przy zmianie widoku w SPA)
function destroyPanel() {
  parkMap.destroy?.();
  alarmSystem.destroy();
  incidentForm.destroy();
  activityMonitor.destroy();
  eventBus.clear();
  console.log('Panel kontrolny zamknięty - wszystkie zasoby zwolnione');
}

Pamiętaj o kluczowych zasadach:

  • Delegacja wydarzeń zamiast wielu listenerów na pojedynczych elementach
  • event.stopPropagation() tylko wtedy, gdy kliknięcie naprawdę nie powinno dotrzeć do przodków (w delegacji zwykle wystarczy return)
  • event.preventDefault() dla formularzy i skrótów klawiszowych
  • Throttling dla wydarzeń wysokiej częstotliwości (mousemove, scroll)
  • Przechowywanie referencji do handlerów, aby móc je później usunąć
  • Metoda destroy() w każdym module do czyszczenia pamięci

Powodzenia!

"Systemy Parku Jurajskiego nie wybaczają błędów," mówi Ray Arnold, zapalając kolejnego papierosa. "Wyciek pamięci w monitoringu zagród oznacza, że po kilku godzinach system się zawiesi. Nieusunięty event listener może blokować garbage collector i zużywać coraz więcej RAM-u. A źle obsłużona propagacja wydarzeń może sprawić, że kliknięcie przycisku ewakuacji otworzy bramę zagrody z T-Rexem zamiast ją zamknąć."

"Zbuduj ten panel tak, jakby od niego zależało życie ludzi w parku. Bo w pewnym sensie - tak właśnie jest."

Otwórz edytor i stwórz swój interaktywny panel kontrolny!

Kod do tej lekcji: index.html
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Panel Kontrolny - Park Jurajski</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>Panel Kontrolny Parku Jurajskiego</h1>
    <div class="panel">
        <!-- SEKCJA 1: Mapa parku z delegacją wydarzeń -->
        <div class="section">
            <h2>Mapa Parku</h2>
            <div id="park-map">
                <!-- TODO: Dodaj event listener na #park-map (delegacja) -->
                <!-- Kliknięcie w .sector -> pokaż info o sektorze -->
                <!-- Kliknięcie w .dino -> pokaż info o dinozaurze i zakończ obsługę przez return, żeby nie zaznaczać sektora -->
                <div class="sector" data-sector="A" data-danger="high">
                    Sektor A (Wysokie zagrożenie)
                    <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">
                    Sektor B (Niskie zagrożenie)
                    <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">
                    Sektor C (Średnie zagrożenie)
                    <div><span class="dino" data-dino-id="5">Stegosaurus</span></div>
                </div>
            </div>
        </div>

        <!-- SEKCJA 2: Formularz incydentów -->
        <div class="section">
            <h2>Rejestracja Incydentu</h2>
            <form id="incident-form">
                <!-- TODO: Dodaj event listener na submit z preventDefault -->
                <!-- TODO: Walidacja pól przed wysłaniem -->
                <input type="text" id="incident-title" name="title" placeholder="Tytuł incydentu" required>
                <textarea id="description" name="description" placeholder="Opis incydentu (min. 10 znaków)"></textarea>
                <div class="error" id="description-error"></div>
                <select id="sector" name="sector">
                    <option value="">Wybierz sektor</option>
                    <option value="A">Sektor A</option>
                    <option value="B">Sektor B</option>
                    <option value="C">Sektor C</option>
                </select>
                <div class="error" id="sector-error"></div>
                <select id="incident-severity" name="severity">
                    <option value="">Priorytet</option>
                    <option value="high">Wysoki</option>
                    <option value="medium">Średni</option>
                    <option value="low">Niski</option>
                </select>
                <button type="submit">Zgłoś incydent</button>
            </form>
            <div id="alerts"></div>
        </div>

        <!-- SEKCJA 3: Log zdarzeń -->
        <div class="section" style="grid-column: 1 / -1;">
            <h2>Log zdarzeń (skróty: Ctrl+1-4 = poziom alarmu, Esc = wyłącz alarm, Ctrl+Shift+E = ewakuacja, Ctrl+Shift+L = blokada)</h2>
            <div class="log" id="event-log"></div>
        </div>

        <!-- SEKCJA 4: Monitor aktywności (ActivityMonitor z lekcji) -->
        <div class="section" id="activity-panel" style="grid-column: 1 / -1;">
            <h2>Monitor aktywności</h2>
            <div class="position">Kursor: (0, 0)</div>
            <div class="log"></div>
        </div>
    </div>

    <script>
        // === PROJEKT KOŃCOWY: Interaktywny panel kontrolny ===

        // TODO 1: DELEGACJA WYDARZEŃ na mapie parku
        // Dodaj JEDEN event listener na #park-map
        // Użyj event.target.closest() do identyfikacji klikniętego elementu

        // TODO 2: FORMULARZ z walidacją
        // Dodaj listener na submit formularza
        // Użyj preventDefault()
        // Waliduj pola i dodaj alert do #alerts

        // TODO 3: SKRÓTY KLAWISZOWE
        // Dodaj listener na keydown
        // Ctrl+1-4 = poziom alarmu, Esc = wyłącz alarm, Ctrl+Shift+E = ewakuacja, Ctrl+Shift+L = blokada

        // TODO 4: FUNKCJA LOGOWANIA
        // Stwórz funkcję logEvent(message), która dodaje wpis do #event-log

        function logEvent(message) {
            // TODO: Dodaj wpis do logu z timestamp
        }

        // TODO 5: CZYSZCZENIE PAMIĘCI
        // Stwórz funkcję cleanup(), która usuwa wszystkie listenery
        // Wywołaj ją np. przy zamknięciu panelu

        console.log("Panel kontrolny gotowy do konfiguracji!");
    </script>
</body>
</html>

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Przydatne artykuły