Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

Projekt końcowy: Interaktywny panel kontrolny Parku Jurajskiego

"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ż o kontrolowaniu propagacji, aby kliknięcie w dinozaura nie aktywowało jednocześnie sektora.

1// Struktura HTML mapy parku
2// <div id="park-map">
3//   <div class="sector" data-sector="A" data-danger="high">
4//     <span class="dino" data-dino-id="1">T-Rex</span>
5//     <span class="dino" data-dino-id="2">Velociraptor</span>
6//   </div>
7//   <div class="sector" data-sector="B" data-danger="low">
8//     <span class="dino" data-dino-id="3">Triceratops</span>
9//   </div>
10// </div>
11
12class ParkMap {
13  constructor(mapElement) {
14    this.mapElement = mapElement;
15    this.selectedSector = null;
16    this.setupDelegation();
17  }
18
19  setupDelegation() {
20    // Jeden listener obsługuje wszystkie kliknięcia na mapie
21    this.mapElement.addEventListener('click', (event) => {
22      const dinoElement = event.target.closest('.dino');
23      
24      if (dinoElement) {
25        // Kliknięto w dinozaura - zatrzymaj propagację do sektora
26        event.stopPropagation();
27        this.handleDinoClick(dinoElement);
28        return;
29      }
30
31      const sectorElement = event.target.closest('.sector');
32      if (sectorElement) {
33        this.handleSectorClick(sectorElement);
34      }
35    });
36  }
37
38  handleSectorClick(sectorEl) {
39    // Odznacz poprzedni sektor
40    if (this.selectedSector) {
41      this.selectedSector.classList.remove('selected');
42    }
43    
44    sectorEl.classList.add('selected');
45    this.selectedSector = sectorEl;
46
47    const sectorId = sectorEl.dataset.sector;
48    const danger = sectorEl.dataset.danger;
49
50    console.log(`Sektor ${sectorId} wybrany [Zagrożenie: ${danger}]`);
51
52    // Emituj custom event z informacją o wybranym sektorze
53    const sectorEvent = new CustomEvent('sector:selected', {
54      detail: { sectorId, danger },
55      bubbles: true
56    });
57    sectorEl.dispatchEvent(sectorEvent);
58  }
59
60  handleDinoClick(dinoEl) {
61    const dinoId = dinoEl.dataset.dinoId;
62    const dinoName = dinoEl.textContent;
63    
64    console.log(`Wybrany dinozaur: ${dinoName} (ID: ${dinoId})`);
65
66    // Custom event z danymi dinozaura
67    const dinoEvent = new CustomEvent('dino:selected', {
68      detail: { dinoId, dinoName },
69      bubbles: true
70    });
71    dinoEl.dispatchEvent(dinoEvent);
72  }
73
74  // Dynamiczne dodawanie nowych sektorów - dzięki delegacji
75  // nie trzeba dodawać nowych listenerów
76  addSector(sectorId, danger) {
77    const sector = document.createElement('div');
78    sector.className = 'sector';
79    sector.dataset.sector = sectorId;
80    sector.dataset.danger = danger;
81    sector.textContent = `Sektor ${sectorId}`;
82    this.mapElement.appendChild(sector);
83    // Event listener już działa automatycznie!
84  }
85}

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.

1class AlarmSystem {
2  constructor() {
3    this.alarmLevel = 0; // 0-4 (0 = brak, 4 = krytyczny)
4    this.isActive = true;
5    this.keyHandlers = new Map();
6    this.boundHandleKeyDown = this.handleKeyDown.bind(this);
7    this.boundHandleKeyUp = this.handleKeyUp.bind(this);
8    this.pressedKeys = new Set();
9
10    this.registerShortcuts();
11    this.attachListeners();
12  }
13
14  registerShortcuts() {
15    // Ctrl+1 do Ctrl+4: Poziomy alarmu
16    this.keyHandlers.set('ctrl+1', () => this.setAlarmLevel(1));
17    this.keyHandlers.set('ctrl+2', () => this.setAlarmLevel(2));
18    this.keyHandlers.set('ctrl+3', () => this.setAlarmLevel(3));
19    this.keyHandlers.set('ctrl+4', () => this.setAlarmLevel(4));
20    
21    // Escape: Wyłącz alarm
22    this.keyHandlers.set('escape', () => this.setAlarmLevel(0));
23    
24    // Ctrl+Shift+E: Ewakuacja
25    this.keyHandlers.set('ctrl+shift+e', () => this.triggerEvacuation());
26    
27    // Ctrl+Shift+L: Zablokuj wszystkie zagrody
28    this.keyHandlers.set('ctrl+shift+l', () => this.lockAllEnclosures());
29  }
30
31  attachListeners() {
32    document.addEventListener('keydown', this.boundHandleKeyDown);
33    document.addEventListener('keyup', this.boundHandleKeyUp);
34  }
35
36  handleKeyDown(event) {
37    // Ignoruj skróty, gdy użytkownik pisze w formularzu
38    if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
39      return;
40    }
41
42    this.pressedKeys.add(event.key.toLowerCase());
43
44    // Buduj kombinację klawiszy
45    const combo = this.buildKeyCombo(event);
46    
47    const handler = this.keyHandlers.get(combo);
48    if (handler) {
49      event.preventDefault(); // Zapobiegaj domyślnej akcji przeglądarki
50      handler();
51    }
52  }
53
54  handleKeyUp(event) {
55    this.pressedKeys.delete(event.key.toLowerCase());
56  }
57
58  buildKeyCombo(event) {
59    const parts = [];
60    if (event.ctrlKey || event.metaKey) parts.push('ctrl');
61    if (event.shiftKey) parts.push('shift');
62    if (event.altKey) parts.push('alt');
63    
64    const key = event.key.toLowerCase();
65    if (!['control', 'shift', 'alt', 'meta'].includes(key)) {
66      parts.push(key);
67    }
68    
69    return parts.join('+');
70  }
71
72  setAlarmLevel(level) {
73    const previousLevel = this.alarmLevel;
74    this.alarmLevel = level;
75    
76    console.log(`⚠️ Poziom alarmu: ${previousLevel}${level}`);
77
78    // Emituj custom event o zmianie poziomu alarmu
79    document.dispatchEvent(new CustomEvent('alarm:levelChanged', {
80      detail: { previousLevel, currentLevel: level }
81    }));
82  }
83
84  triggerEvacuation() {
85    console.log('🚨 EWAKUACJA URUCHOMIONA!');
86    this.setAlarmLevel(4);
87    document.dispatchEvent(new CustomEvent('alarm:evacuation'));
88  }
89
90  lockAllEnclosures() {
91    console.log('🔒 Blokowanie wszystkich zagród...');
92    document.dispatchEvent(new CustomEvent('alarm:lockdown'));
93  }
94
95  // Czyszczenie pamięci - KRYTYCZNE
96  destroy() {
97    document.removeEventListener('keydown', this.boundHandleKeyDown);
98    document.removeEventListener('keyup', this.boundHandleKeyUp);
99    this.keyHandlers.clear();
100    this.pressedKeys.clear();
101    this.isActive = false;
102    console.log('AlarmSystem: Listenery usunięte, pamięć wyczyszczona');
103  }
104}

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.

1class IncidentForm {
2  constructor(formElement) {
3    this.form = formElement;
4    this.errors = new Map();
5    this.boundHandlers = [];
6
7    this.setupValidation();
8    this.setupSubmit();
9  }
10
11  setupValidation() {
12    // Walidacja pola "opis" przy każdym wpisywanym znaku
13    const descriptionInput = this.form.querySelector('#description');
14    const descHandler = (event) => {
15      const value = event.target.value;
16      if (value.length < 10) {
17        this.setError('description', 'Opis musi mieć minimum 10 znaków');
18      } else if (value.length > 500) {
19        this.setError('description', 'Opis nie może przekraczać 500 znaków');
20      } else {
21        this.clearError('description');
22      }
23    };
24    descriptionInput.addEventListener('input', descHandler);
25    this.boundHandlers.push({ element: descriptionInput, type: 'input', handler: descHandler });
26
27    // Walidacja selecta "sektor" przy zmianie
28    const sectorSelect = this.form.querySelector('#sector');
29    const sectorHandler = (event) => {
30      if (!event.target.value) {
31        this.setError('sector', 'Wybierz sektor');
32      } else {
33        this.clearError('sector');
34      }
35    };
36    sectorSelect.addEventListener('change', sectorHandler);
37    this.boundHandlers.push({ element: sectorSelect, type: 'change', handler: sectorHandler });
38
39    // Focus/blur efekty wizualne
40    const allInputs = this.form.querySelectorAll('input, select, textarea');
41    allInputs.forEach(input => {
42      const focusHandler = () => input.parentElement.classList.add('focused');
43      const blurHandler = () => {
44        input.parentElement.classList.remove('focused');
45        // Waliduj pole po opuszczeniu
46        input.dispatchEvent(new Event('input'));
47      };
48      
49      input.addEventListener('focus', focusHandler);
50      input.addEventListener('blur', blurHandler);
51      this.boundHandlers.push(
52        { element: input, type: 'focus', handler: focusHandler },
53        { element: input, type: 'blur', handler: blurHandler }
54      );
55    });
56  }
57
58  setupSubmit() {
59    const submitHandler = (event) => {
60      event.preventDefault(); // Zapobiegaj domyślnemu wysłaniu formularza
61
62      if (this.errors.size > 0) {
63        console.log('Formularz zawiera błędy:', [...this.errors.values()]);
64        this.highlightErrors();
65        return;
66      }
67
68      const formData = new FormData(this.form);
69      const incident = Object.fromEntries(formData.entries());
70      
71      console.log('Incydent zgłoszony:', incident);
72
73      // Custom event z danymi incydentu
74      document.dispatchEvent(new CustomEvent('incident:reported', {
75        detail: incident
76      }));
77
78      this.form.reset();
79    };
80
81    this.form.addEventListener('submit', submitHandler);
82    this.boundHandlers.push({ element: this.form, type: 'submit', handler: submitHandler });
83  }
84
85  setError(field, message) {
86    this.errors.set(field, message);
87    const errorEl = this.form.querySelector(`#${field}-error`);
88    if (errorEl) errorEl.textContent = message;
89  }
90
91  clearError(field) {
92    this.errors.delete(field);
93    const errorEl = this.form.querySelector(`#${field}-error`);
94    if (errorEl) errorEl.textContent = '';
95  }
96
97  highlightErrors() {
98    this.errors.forEach((message, field) => {
99      const input = this.form.querySelector(`#${field}`);
100      if (input) {
101        input.classList.add('error');
102        setTimeout(() => input.classList.remove('error'), 2000);
103      }
104    });
105  }
106
107  // Czyszczenie WSZYSTKICH listenerów
108  destroy() {
109    this.boundHandlers.forEach(({ element, type, handler }) => {
110      element.removeEventListener(type, handler);
111    });
112    this.boundHandlers = [];
113    this.errors.clear();
114    console.log('IncidentForm: Wszystkie listenery usunięte');
115  }
116}

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.

1// Event Bus - centralny system komunikacji
2class EventBus {
3  constructor() {
4    this.listeners = new Map();
5  }
6
7  on(event, callback) {
8    if (!this.listeners.has(event)) {
9      this.listeners.set(event, new Set());
10    }
11    this.listeners.get(event).add(callback);
12    
13    // Zwróć funkcję do odsubskrybowania
14    return () => this.off(event, callback);
15  }
16
17  off(event, callback) {
18    const callbacks = this.listeners.get(event);
19    if (callbacks) {
20      callbacks.delete(callback);
21      if (callbacks.size === 0) {
22        this.listeners.delete(event);
23      }
24    }
25  }
26
27  emit(event, data) {
28    const callbacks = this.listeners.get(event);
29    if (callbacks) {
30      callbacks.forEach(cb => {
31        try {
32          cb(data);
33        } catch (error) {
34          console.error(`Błąd w listenerze "${event}":`, error);
35        }
36      });
37    }
38  }
39
40  // Czyszczenie wszystkich subskrypcji
41  clear() {
42    this.listeners.clear();
43    console.log('EventBus: Wszystkie subskrypcje wyczyszczone');
44  }
45}
46
47// Throttle utility
48function throttle(fn, delay) {
49  let lastCall = 0;
50  let timeoutId = null;
51
52  const throttled = function(...args) {
53    const now = Date.now();
54    const remaining = delay - (now - lastCall);
55
56    if (remaining <= 0) {
57      if (timeoutId) {
58        clearTimeout(timeoutId);
59        timeoutId = null;
60      }
61      lastCall = now;
62      fn.apply(this, args);
63    } else if (!timeoutId) {
64      timeoutId = setTimeout(() => {
65        lastCall = Date.now();
66        timeoutId = null;
67        fn.apply(this, args);
68      }, remaining);
69    }
70  };
71
72  // Metoda do czyszczenia
73  throttled.cancel = () => {
74    if (timeoutId) {
75      clearTimeout(timeoutId);
76      timeoutId = null;
77    }
78  };
79
80  return throttled;
81}
82
83// Monitor aktywności z throttlingiem
84class ActivityMonitor {
85  constructor(eventBus, displayElement) {
86    this.eventBus = eventBus;
87    this.display = displayElement;
88    this.unsubscribers = [];
89    this.throttledFunctions = [];
90
91    this.setupMonitoring();
92  }
93
94  setupMonitoring() {
95    // Throttlowane aktualizacje pozycji myszy na mapie
96    const updatePosition = throttle((data) => {
97      this.display.querySelector('.position').textContent =
98        `Kursor: (${data.x}, ${data.y})`;
99    }, 100); // Maksymalnie 10 aktualizacji na sekundę
100    this.throttledFunctions.push(updatePosition);
101
102    // Nasłuchuj na zdarzenia z EventBus
103    const unsub1 = this.eventBus.on('dino:moved', (data) => {
104      this.logActivity(`Dinozaur ${data.name} przemieścił się do (${data.x}, ${data.y})`);
105    });
106
107    const unsub2 = this.eventBus.on('alarm:levelChanged', (data) => {
108      this.logActivity(`Alarm: poziom ${data.previousLevel}${data.currentLevel}`);
109    });
110
111    const unsub3 = this.eventBus.on('incident:reported', (data) => {
112      this.logActivity(`Nowy incydent w sektorze ${data.sector}: ${data.description}`);
113    });
114
115    this.unsubscribers.push(unsub1, unsub2, unsub3);
116
117    // Mousemove na mapie z throttlingiem
118    const mapElement = document.getElementById('park-map');
119    if (mapElement) {
120      const mouseMoveHandler = (event) => {
121        const rect = mapElement.getBoundingClientRect();
122        updatePosition({
123          x: Math.round(event.clientX - rect.left),
124          y: Math.round(event.clientY - rect.top)
125        });
126      };
127      mapElement.addEventListener('mousemove', mouseMoveHandler);
128      this.unsubscribers.push(() => {
129        mapElement.removeEventListener('mousemove', mouseMoveHandler);
130      });
131    }
132  }
133
134  logActivity(message) {
135    const entry = document.createElement('div');
136    entry.className = 'log-entry';
137    const time = new Date().toLocaleTimeString();
138    entry.textContent = `[${time}] ${message}`;
139    
140    this.display.querySelector('.log').prepend(entry);
141    
142    // Ogranicz liczbę wpisów w logu
143    const entries = this.display.querySelectorAll('.log-entry');
144    if (entries.length > 50) {
145      entries[entries.length - 1].remove();
146    }
147  }
148
149  // Czyszczenie WSZYSTKIEGO
150  destroy() {
151    // Odsubskrybuj z EventBus
152    this.unsubscribers.forEach(unsub => unsub());
153    this.unsubscribers = [];
154
155    // Anuluj throttlowane funkcje
156    this.throttledFunctions.forEach(fn => fn.cancel());
157    this.throttledFunctions = [];
158
159    console.log('ActivityMonitor: Pełne czyszczenie zakończone');
160  }
161}

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// 1. Inicjalizacja Event Bus
2const eventBus = new EventBus();
3
4// 2. Mapa parku z delegacją
5const parkMap = new ParkMap(document.getElementById('park-map'));
6
7// 3. System alarmowy
8const alarmSystem = new AlarmSystem();
9
10// 4. Formularz incydentów
11const incidentForm = new IncidentForm(document.getElementById('incident-form'));
12
13// 5. Monitor aktywności
14const activityMonitor = new ActivityMonitor(
15  eventBus,
16  document.getElementById('activity-panel')
17);
18
19// 6. Połącz moduły przez EventBus
20// Nasłuchuj custom events z DOM i przekieruj do EventBus
21document.addEventListener('sector:selected', (e) => {
22  eventBus.emit('sector:selected', e.detail);
23});
24
25document.addEventListener('alarm:levelChanged', (e) => {
26  eventBus.emit('alarm:levelChanged', e.detail);
27});
28
29document.addEventListener('incident:reported', (e) => {
30  eventBus.emit('incident:reported', e.detail);
31});
32
33// 7. Czyszczenie przy zamykaniu (np. przy zmianie widoku w SPA)
34function destroyPanel() {
35  parkMap.destroy?.();
36  alarmSystem.destroy();
37  incidentForm.destroy();
38  activityMonitor.destroy();
39  eventBus.clear();
40  console.log('Panel kontrolny zamknięty - wszystkie zasoby zwolnione');
41}

Pamiętaj o kluczowych zasadach:

  • Delegacja wydarzeń zamiast wielu listenerów na pojedynczych elementach
  • event.stopPropagation()
    gdy kliknięcie nie powinno "bąbelkować" wyżej
  • 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!

Ir a CodeWorlds