1. Wprowadzenie i Zakres Projektu
Niniejszy raport szczegółowo opisuje proces tworzenia prototypu aplikacji wirtualnego muzeum, wykorzystując nowoczesne technologie frontendowe: React, React Three Fiber (R3F) do wizualizacji 3D oraz React Flow do tworzenia interaktywnych grafów powiązań. Celem jest zbudowanie aplikacji prezentującej eksponaty (np. szybowce) i powiązane z nimi postacie (np. pilotów) w dwóch zsynchronizowanych widokach: trójwymiarowej osi czasu oraz dwuwymiarowej mapy relacji.
Raport obejmuje kluczowe etapy implementacji, począwszy od konfiguracji środowiska deweloperskiego, poprzez implementację poszczególnych widoków i ich komponentów, zarządzanie modelami 3D, synchronizację stanu, obsługę interakcji użytkownika, aż po stylizację i podstawowe aspekty dostępności. Przedstawione zostaną również przykładowe struktury danych oraz finalna integracja komponentów w działający prototyp. Projekt ten demonstruje możliwości połączenia wizualizacji 3D i grafów w celu stworzenia angażującego doświadczenia użytkownika w kontekście prezentacji danych historycznych lub muzealnych.
2. Konfiguracja Projektu i Zależności
2.1. Wybór Narzędzia Budowania: Vite
Współczesny rozwój aplikacji React często opiera się na narzędziach budowania, które optymalizują proces deweloperski i produkcyjny. Analiza dostępnych opcji wskazuje na silną preferencję dla Vite jako nowoczesnej alternatywy dla tradycyjnych narzędzi, takich jak Create React App (CRA). Vite wyróżnia się szybkością działania, szczególnie podczas uruchamiania serwera deweloperskiego i stosowania Hot Module Replacement (HMR), co znacząco przyspiesza cykl tworzenia oprogramowania. Osiąga to dzięki wykorzystaniu natywnych modułów ES (ESM) przeglądarki podczas developmentu, co eliminuje potrzebę czasochłonnego bundlowania całej aplikacji przy każdej zmianie.
Chociaż CRA nadal jest solidnym wyborem dla początkujących lub mniejszych projektów ze względu na swoją stabilność i brak konieczności konfiguracji , Vite oferuje lepszą wydajność i elastyczność konfiguracji (vite.config.js), co jest korzystne w bardziej złożonych projektach, takich jak ten. Vite jest również wspierany przez rosnącą społeczność i ekosystem wtyczek, w tym oficjalne integracje dla React (@vitejs/plugin-react).
2.2. Inicjalizacja Projektu React z Vite
Aby zainicjować nowy projekt React przy użyciu Vite, należy wykonać następujące polecenie w terminalu (zakładając użycie npm i Node.js w wersji 18+ lub 20+ ):
npm create vite@latest wirtualne-muzeum --template react
Alternatywnie, można użyć innych menedżerów pakietów jak Yarn, pnpm czy Bun. Polecenie to tworzy podstawową strukturę projektu z konfiguracją Vite i React. Po utworzeniu projektu należy przejść do katalogu projektu i zainstalować zależności:
cd wirtualne-muzeum
npm install
Następnie można uruchomić serwer deweloperski poleceniem npm run dev.
Wybór Vite jako narzędzia budowania wpływa na strukturę projektu i proces konfiguracji. Plik index.html znajduje się w głównym katalogu projektu i służy jako punkt wejściowy aplikacji podczas developmentu , w przeciwieństwie do CRA, gdzie jest on bardziej ukryty. Konfiguracja odbywa się poprzez plik vite.config.js, co umożliwia łatwe dodawanie aliasów ścieżek czy konfigurację wtyczek.
2.3. Instalacja Kluczowych Bibliotek i Struktura Folderów
Następnie instalujemy podstawowe biblioteki wymagane przez projekt:
* React Three Fiber (R3F) i Three.js: Rdzeń wizualizacji 3D.
npm install three @react-three/fiber
* Drei: Kolekcja użytecznych helperów i abstrakcji dla R3F, ułatwiająca zadania takie jak ładowanie modeli, dodawanie kontrolek czy środowisk. Jest to praktycznie niezbędny dodatek do R3F w realnych projektach.
npm install @react-three/drei
* React Flow: Biblioteka do tworzenia interaktywnych grafów.
npm install @xyflow/react
* Zustand: Lekka biblioteka do zarządzania stanem globalnym (zostanie omówiona w Sekcji 6).
npm install zustand
Proponowana struktura folderów, wspierająca separację odpowiedzialności i skalowalność:
wirtualne-muzeum/
/public/
/models/ # Przechowywanie statycznych modeli 3D (glTF/glb)
/images/ # Przechowywanie statycznych obrazów
/src/
/assets/ # Inne statyczne zasoby (czcionki, ikony)
/components/ # Komponenty UI wielokrotnego użytku (Przyciski, Layouty, itp.)
/features/ # Komponenty/logika specyficzne dla funkcjonalności
/timeline/ # Komponenty widoku osi czasu, hooki
/map/ # Komponenty widoku mapy, hooki, niestandardowe węzły/krawędzie
/museum/ # Współdzielona logika/komponenty muzeum
/hooks/ # Ogólne niestandardowe hooki
/lib/ # Funkcje pomocnicze, stałe
/state/ # Zarządzanie stanem (np. konfiguracja store Zustand)
/styles/ # Globalne style, konfiguracja motywu
/views/ # Komponenty stron najwyższego poziomu (jeśli potrzebne)
App.jsx # Główny komponent aplikacji
main.jsx # Punkt wejściowy aplikacji (konfiguracja Vite)
vite.config.js # Konfiguracja Vite
package.json
Taka struktura ułatwia zarządzanie kodem w miarę rozwoju aplikacji. Folder /public jest standardowym miejscem w Vite na zasoby statyczne, które mają być serwowane bezpośrednio. Folder /src/features pozwala grupować logikę związaną z konkretnymi częściami aplikacji (oś czasu, mapa).
2.4. Początkowa Konfiguracja Aplikacji (main.jsx, App.jsx)
Plik src/main.jsx jest punktem wejściowym aplikacji w konfiguracji Vite. Renderuje on główny komponent App w elemencie DOM o id root.
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css'; // Globalne style
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
Minimalny komponent src/App.jsx będzie służył jako kontener dla głównych widoków aplikacji (Oś Czasu i Mapa Powiązań), które zostaną zintegrowane w późniejszych krokach.
// src/App.jsx
import React from 'react';
import './App.css'; // Style specyficzne dla App
function App() {
return (
<div className="app-container">
{/* Tutaj zostaną zintegrowane komponenty TimelineView i MapView */}
<h1>Wirtualne Muzeum Szybowców (Prototyp)</h1>
</div>
);
}
export default App;
Ta początkowa konfiguracja stanowi solidną podstawę do dalszej rozbudowy aplikacji, wykorzystując zalety szybkości i elastyczności Vite oraz jasno zdefiniowaną strukturę projektu.
3. Implementacja Widoku Osi Czasu (React Three Fiber)
3.1. Tworzenie Komponentu TimelineView
Pierwszym krokiem jest stworzenie dedykowanego komponentu React, który będzie odpowiedzialny za renderowanie trójwymiarowej osi czasu. Zgodnie z przyjętą strukturą folderów, komponent ten zostanie umieszczony w src/features/timeline/TimelineView.jsx.
// src/features/timeline/TimelineView.jsx
import React from 'react';
import { Canvas } from '@react-three/fiber';
import './TimelineView.css'; // Do stylizacji kontenera
function TimelineView() {
return (
<div className="timeline-container"> {/* Należy nadać wymiary przez CSS */}
<Canvas>
{/* Zawartość sceny 3D zostanie dodana tutaj */}
</Canvas>
</div>
);
}
export default TimelineView;
3.2. Konfiguracja Komponentu <Canvas> R3F
Komponent <Canvas> z biblioteki @react-three/fiber jest kluczowym elementem, inicjującym środowisko Three.js. Automatycznie tworzy on scenę (THREE.Scene), domyślną kamerę (THREE.PerspectiveCamera) oraz pętlę renderowania, co znacząco upraszcza konfigurację w porównaniu do czystego Three.js.
Niezwykle istotne jest, aby element nadrzędny komponentu <Canvas> (w tym przypadku div z klasą timeline-container) miał zdefiniowane wymiary (szerokość i wysokość) za pomocą CSS. W przeciwnym razie <Canvas> nie będzie widoczny, ponieważ domyślnie dopasowuje się do rozmiaru rodzica.
3.3. Podstawowa Konfiguracja Sceny R3F
Aby scena była użyteczna, należy dodać do niej podstawowe elementy, takie jak kamera i oświetlenie.
* Kamera: R3F dostarcza domyślną kamerę perspektywiczną. Jej parametry, takie jak początkowa pozycja (position), pole widzenia (fov), czy zakres widoczności (near, far), można skonfigurować za pomocą prop camera przekazanego do <Canvas>. Dobór odpowiednich parametrów kamery jest kluczowy dla właściwej prezentacji osi czasu.
* Oświetlenie: Jest niezbędne do poprawnego wyświetlania modeli z materiałami niestandardowymi (nieemisyjnymi). Zaleca się użycie kombinacji:
* <ambientLight>: Zapewnia ogólne, rozproszone oświetlenie sceny, rozjaśniając cienie. Prop intensity kontroluje jego jasność.
* <directionalLight> lub <pointLight>: Dodają kierunkowe lub punktowe źródła światła, tworząc cienie i podkreślając kształty obiektów. Propy position i intensity określają ich położenie i moc.
// Wewnątrz komponentu TimelineView
<div className="timeline-container">
<Canvas camera={{ position: , fov: 50 }}>
<ambientLight intensity={0.6} />
<directionalLight position={[10, 10, 5]} intensity={1.5} castShadow /> {/* castShadow włącza cienie */}
{/* Można dodać więcej świateł dla lepszego efektu */}
{/*... reszta sceny (modele, oś)... */}
</Canvas>
</div>
Abstrakcja tworzenia sceny, kamery i pętli renderowania przez R3F pozwala deweloperom skupić się na deklaratywnym budowaniu zawartości sceny za pomocą komponentów React, co jest bardziej intuicyjne dla osób znających ekosystem React niż imperatywne podejście Three.js.
3.4. Reprezentacja Osi Czasu
Sama oś czasu może być reprezentowana wizualnie na różne sposoby w przestrzeni 3D. Może to być długi, cienki obiekt Mesh stworzony z BoxGeometry, linia narysowana za pomocą komponentu <Line> z biblioteki @react-three/drei, lub po prostu konceptualne wykorzystanie jednej z osi (np. osi Z) do pozycjonowania wydarzeń. Dla uproszczenia prototypu, początkowo wykorzystamy oś Z jako oś czasu.
3.5. Pozycjonowanie Modeli 3D na Osi Czasu
Kluczowym zadaniem jest dynamiczne umieszczanie modeli 3D (szybowców, pilotów) w odpowiednich punktach osi czasu na podstawie danych.
* Mapowanie Danych: Należy przetłumaczyć dane czasowe (np. rok wydarzenia) z przygotowanego zestawu danych (omówionego w Sekcji 10) na pozycję w przestrzeni 3D wzdłuż wybranej osi (np. Z). Wymaga to zdefiniowania współczynnika skali, np. 1 jednostka w przestrzeni 3D odpowiada 1 rokowi.
* Implementacja:
* Zaimportuj lub pobierz dane dotyczące wydarzeń na osi czasu.
* Użyj metody .map() na tablicy danych, aby dla każdego wydarzenia wyrenderować komponent odpowiedzialny za wyświetlenie modelu 3D (np. TimelineEventModel).
* Przekaż obliczoną pozycję (na podstawie roku i skali) oraz ścieżkę do modelu jako propsy do komponentu TimelineEventModel. Podejście to jest analogiczne do mapowania danych na komponenty w standardowym React, ale zastosowane w kontekście R3F.
// Wewnątrz komponentu TimelineView
import { Suspense } from 'react';
import { OrbitControls } from '@react-three/drei';
import { useMuseumStore } from '../../state/museumStore'; // Załóżmy store Zustand
import TimelineEventModel from './TimelineEventModel'; // Komponent do ładowania modelu
function TimelineView() {
const timelineEvents = useMuseumStore((state) => state.timelineEvents);
const START_YEAR = 1890; // Przykładowy rok początkowy osi
const TIME_SCALE_FACTOR = 1; // 1 jednostka = 1 rok
return (
<div className="timeline-container">
<Canvas camera={{ position: , fov: 50 }} shadows> {/* Włączenie cieni na Canvas */}
<ambientLight intensity={0.6} />
<directionalLight position={[10, 10, 5]} intensity={1.5} castShadow />
<OrbitControls /> {/* Dodanie kontrolek nawigacji */}
{/* Renderowanie modeli na osi czasu */}
<Suspense fallback={null}> {/* Obsługa ładowania modeli */}
{timelineEvents.map(event => (
<TimelineEventModel
key={event.id}
modelPath={event.modelPath} // Ścieżka do modelu glTF/glb
position={}
eventData={event} // Przekazanie danych wydarzenia
/>
))}
</Suspense>
{/* Opcjonalnie: Wizualna reprezentacja osi */}
<mesh position={[0, -0.1, (timelineEvents[timelineEvents.length - 1]?.year - START_YEAR + 10) * TIME_SCALE_FACTOR / 2]} rotation={}>
<boxGeometry args={[0.1, 0.1, (timelineEvents[timelineEvents.length - 1]?.year - START_YEAR + 20) * TIME_SCALE_FACTOR]} />
<meshStandardMaterial color="gray" />
</mesh>
</Canvas>
</div>
);
}
export default TimelineView;
* Komponent TimelineEventModel: Należy zdefiniować osobny komponent, który będzie odpowiedzialny za załadowanie (omówione w Sekcji 5) i wyświetlenie pojedynczego modelu 3D w przekazanej pozycji (position). Prop position jest stosowany bezpośrednio do komponentu <mesh> lub <group> zawierającego załadowany model.
Konieczność pozycjonowania wielu różnych obiektów (szybowców/pilotów) w oparciu o dane (czas) wymusza zastosowanie strategii mapowania danych w połączeniu z modelem komponentów R3F i możliwościami pozycjonowania. Statyczne umieszczanie obiektów byłoby niewystarczające. Należy jednak pamiętać, że renderowanie dużej liczby złożonych modeli 3D może wpłynąć na wydajność. W przypadku rozbudowanych osi czasu konieczne mogą okazać się techniki optymalizacyjne, takie jak instancing czy Level of Detail (LOD), które wykraczają poza zakres tego prototypu. Obecne podejście, mapujące dane na indywidualne komponenty modeli, jest proste w implementacji, ale może wymagać optymalizacji przy dużej skali.
4. Implementacja Widoku Mapy Powiązań (React Flow)
4.1. Tworzenie Komponentu MapView
Analogicznie do widoku osi czasu, tworzymy komponent dla mapy powiązań: src/features/map/MapView.jsx. Będzie on zawierał instancję React Flow.
4.2. Konfiguracja Komponentu React Flow
Podstawowa konfiguracja React Flow obejmuje import niezbędnych komponentów i stylów oraz zarządzanie stanem węzłów i krawędzi.
* Importy: Zaimportuj ReactFlow, Background, Controls oraz hooki i funkcje pomocnicze do zarządzania stanem (useState, useCallback, applyNodeChanges, applyEdgeChanges, addEdge) z @xyflow/react.
* Import CSS: Kluczowe jest zaimportowanie domyślnych stylów React Flow: import '@xyflow/react/dist/style.css';. Bez tego biblioteka nie będzie działać poprawnie.
* Stan Węzłów i Krawędzi: Zdefiniuj stan dla nodes (węzły) i edges (krawędzie) przy użyciu useState. Początkowo mogą być puste lub zawierać dane tymczasowe; docelowo będą zasilane z globalnego magazynu stanu (Sekcja 6).
* Handlery Zmian: Zaimplementuj funkcje zwrotne onNodesChange, onEdgesChange i onConnect przy użyciu useCallback i funkcji pomocniczych applyNodeChanges, applyEdgeChanges, addEdge. Są one niezbędne do obsługi interakcji użytkownika, takich jak przeciąganie węzłów, zaznaczanie czy tworzenie nowych połączeń.
* Renderowanie <ReactFlow>: Umieść komponent <ReactFlow> w JSX, przekazując do niego stan (nodes, edges) oraz handlery (onNodesChange, onEdgesChange, onConnect). Dodaj również komponenty <Background /> dla tła i <Controls /> dla kontrolek zoom/pan.
* Kontener z Wymiarami: Upewnij się, że element div bezpośrednio otaczający <ReactFlow> ma zdefiniowaną szerokość i wysokość w CSS, ponieważ React Flow dopasowuje się do wymiarów rodzica.
// src/features/map/MapView.jsx
import React, { useState, useCallback, useEffect } from 'react';
import {
ReactFlow, Background, Controls, applyNodeChanges, applyEdgeChanges, addEdge, useReactFlow
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useMuseumStore } from '../../state/museumStore'; // Import store Zustand
import { shallow } from 'zustand/shallow'; // Do płytkiego porównania
import PilotNode from './PilotNode'; // Import niestandardowego węzła
import GliderNode from './GliderNode'; // Import niestandardowego węzła
import './MapView.css'; // Style dla kontenera i węzłów
// Definicja niestandardowych typów węzłów poza komponentem
const nodeTypes = {
pilot: PilotNode,
glider: GliderNode,
};
function MapViewInternal() {
// Pobieranie danych i akcji ze store Zustand
const { mapNodes, mapEdges, setSelectedItem } = useMuseumStore(
(state) => ({
mapNodes: state.mapNodes,
mapEdges: state.mapEdges,
setSelectedItem: state.setSelectedItem,
}),
shallow // Użycie shallow do porównania obiektu
);
// Lokalny stan dla React Flow, synchronizowany ze storem
const [nodes, setNodes] = useState(mapNodes);
const [edges, setEdges] = useState(mapEdges);
const { fitView } = useReactFlow(); // Hook do kontroli widoku
// Synchronizacja stanu lokalnego ze storem globalnym
useEffect(() => {
setNodes(mapNodes);
setEdges(mapEdges);
}, [mapNodes, mapEdges]);
// Efekt do dopasowania widoku po zmianie węzłów (z opóźnieniem)
useEffect(() => {
if (nodes.length > 0) {
// Opóźnienie, aby dać czas na renderowanie węzłów przed fitView
const timer = setTimeout(() => {
fitView({ padding: 0.1, duration: 300 });
}, 100);
return () => clearTimeout(timer);
}
}, [nodes, fitView]);
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[setNodes]
);
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[setEdges]
);
const onConnect = useCallback(
(connection) => setEdges((eds) => addEdge(connection, eds)),
[setEdges]
);
// Handler kliknięcia węzła - aktualizuje globalny stan
const handleNodeClick = useCallback((event, node) => {
setSelectedItem(node.id);
},);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick} // Dodanie handlera kliknięcia
nodeTypes={nodeTypes} // Rejestracja niestandardowych węzłów
fitView // Początkowe dopasowanie widoku
className="react-flow-map" // Klasa do stylizacji
>
<Background />
<Controls />
</ReactFlow>
);
}
// Komponent opakowujący z ReactFlowProvider (jeśli używamy hooków jak useReactFlow)
function MapView() {
// ReactFlowProvider jest potrzebny, jeśli używamy hooków useReactFlow, useNodesState, useEdgesState wewnątrz
// Jeśli stan jest zarządzany całkowicie zewnętrznie (np. tylko przez Zustand), może nie być konieczny.
// Jednak użycie useReactFlow (np. dla fitView) go wymaga.
return (
<div className="map-container"> {/* Kontener z wymiarami */}
<ReactFlowProvider>
<MapViewInternal />
</ReactFlowProvider>
</div>
);
}
export default MapView;
4.3. Projektowanie Niestandardowych Węzłów
Aby wizualizacja była czytelna i semantyczna, konieczne jest stworzenie niestandardowych węzłów reprezentujących różne typy encji (np. Piloci, Szybowce). Wykorzystanie niestandardowych węzłów jest kluczowe dla nadania mapie znaczenia wykraczającego poza proste prostokąty.
* Implementacja:
* Stwórz osobne komponenty React dla każdego typu węzła (np. PilotNode.jsx, GliderNode.jsx).
* Wewnątrz komponentu użyj standardowego JSX do zdefiniowania struktury wizualnej węzła (np. div, img, span). Dane specyficzne dla węzła (np. nazwa, URL obrazka) są dostępne poprzez prop data przekazywany przez React Flow.
* Zaimportuj i użyj komponentu <Handle> z @xyflow/react, aby zdefiniować punkty połączeń (uchwyty). Określ type ('source' lub 'target') oraz position (Position.Top, Position.Bottom, etc.). Jeśli węzeł ma wiele uchwytów tego samego typu, nadaj im unikalne id.
* Ostyluj komponenty węzłów za pomocą wybranej metody (CSS, Styled Components, Tailwind).
* Rejestracja: Stwórz obiekt nodeTypes, mapujący nazwy typów (np. 'pilot') na komponenty (np. PilotNode). Przekaż ten obiekt jako prop nodeTypes do <ReactFlow>. Ważne jest, aby obiekt nodeTypes był zdefiniowany poza komponentem renderującym <ReactFlow>, aby uniknąć niepotrzebnych re-renderów przy każdej zmianie stanu.
Przykład PilotNode.jsx (zakładając istnienie GliderNode.jsx):
// src/features/map/PilotNode.jsx
import React from 'react';
import { Handle, Position } from '@xyflow/react';
import './PilotNode.css'; // Dedykowane style
function PilotNode({ data, selected }) { // Prop 'selected' jest dostarczany przez React Flow
return (
<div className={`pilot-node ${selected? 'selected' : ''}`}>
<Handle type="target" position={Position.Top} id="t" /> {/* Uchwyt docelowy */}
<img src={data.imageUrl |
| '/images/default-pilot.png'} alt={data.label} className="node-image" />
<div className="node-label">Pilot: <strong>{data.label}</strong></div>
{/* Można dodać więcej informacji z 'data' */}
<Handle type="source" position={Position.Bottom} id="s" /> {/* Uchwyt źródłowy */}
</div>
);
}
export default PilotNode;
4.4. Dostosowywanie Krawędzi
Krawędzie (połączenia) również można dostosowywać, np. poprzez zmianę koloru w zależności od typu relacji.
* Stylizacja Inline: Najprostszym sposobem jest dodanie obiektu style do definicji krawędzi w tablicy edges. Można tam ustawić np. stroke (kolor) czy strokeWidth.
const initialEdges = [
{ id: 'e1-2', source: 'pilot-1', target: 'glider-A', type: 'smoothstep', animated: true, style: { stroke: 'blue', strokeWidth: 2 }, label: 'Latał na' },
//...
];
* Niestandardowe Krawędzie: Dla bardziej złożonych stylów lub interakcji można tworzyć niestandardowe komponenty krawędzi i rejestrować je za pomocą prop edgeTypes, analogicznie do nodeTypes.
* Typy Krawędzi: React Flow oferuje wbudowane typy krawędzi ('default', 'step', 'smoothstep', 'straight'), które wpływają na ich kształt. Prop animated dodaje animację przepływu.
4.5. Zasilanie Grafu Danymi
Dane o relacjach (Sekcja 10) muszą zostać przekształcone do formatu wymaganego przez React Flow: tablic nodes i edges.
* Węzły (nodes): Każdy obiekt musi mieć unikalne id, obiekt position (x, y), obiekt data (zawierający informacje do wyświetlenia, np. label, imageUrl) oraz type (odpowiadający zarejestrowanemu typowi niestandardowemu, np. 'pilot'). Początkowe pozycje mogą być losowe lub obliczone przez algorytm layoutu.
* Krawędzie (edges): Każdy obiekt musi mieć unikalne id, source (ID węzła źródłowego) i target (ID węzła docelowego). Opcjonalnie można dodać label, type, style, animated.
Logika transformacji danych powinna znaleźć się w akcji store'u Zustand (np. deriveMapData w Sekcji 6.4) lub w selektorze.
Chociaż ręczne pozycjonowanie węzłów jest możliwe, w przypadku złożonych grafów z wieloma węzłami staje się to niepraktyczne i prowadzi do nakładania się elementów. Zastosowanie algorytmów automatycznego layoutu znacząco poprawia czytelność i organizację grafu. Biblioteki takie jak Dagre (dla układów hierarchicznych/drzewiastych) , ELK (bardziej zaawansowany, obsługuje podgrafy) czy d3-force (dla układów siłowych, organicznych) mogą być zintegrowane z React Flow. Implementacja takiego algorytmu wymagałaby dodatkowego kroku obliczenia pozycji węzłów po załadowaniu danych (lub na żądanie użytkownika) i zaktualizowania stanu nodes przed renderowaniem. Chociaż nie jest to bezpośrednio wymagane w zapytaniu, warto wspomnieć o tej możliwości jako potencjalnym ulepszeniu prototypu.
5. Ładowanie i Zarządzanie Modelami 3D
5.1. Wybór Formatu Modelu: glTF
Standardem de facto dla przesyłania i renderowania modeli 3D w aplikacjach webowych jest format glTF (GL Transmission Format), wraz z jego binarną wersją GLB. Format ten jest zoptymalizowany pod kątem efektywnego ładowania i przetwarzania w czasie rzeczywistym, minimalizując rozmiar zasobów i obciążenie procesora/GPU. Wersja .glb jest często preferowana, ponieważ może zawierać wszystkie zasoby (geometrię, materiały, tekstury) w jednym pliku, co redukuje liczbę żądań sieciowych. Modele w tym formacie można eksportować z popularnych programów do modelowania 3D (np. Blender) lub konwertować za pomocą narzędzi takich jak gltf-pipeline.
5.2. Ładowanie Modeli za pomocą useLoader
Podstawowym mechanizmem ładowania zasobów w R3F jest hook useLoader.
* Użycie: Hook przyjmuje jako argumenty klasę loadera z Three.js (np. GLTFLoader) oraz ścieżkę do pliku modelu. Zwraca załadowany obiekt.
import { useLoader } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; // Lub 'three/addons/...'
function SimpleModel({ modelPath }) {
const gltf = useLoader(GLTFLoader, modelPath);
// gltf.scene zawiera załadowaną scenę Three.js
return <primitive object={gltf.scene} />;
}
* Asynchroniczność: useLoader obsługuje asynchroniczny proces ładowania.
* Integracja ze Sceną: Załadowany obiekt sceny (gltf.scene) jest dodawany do sceny R3F za pomocą komponentu <primitive>. Komponent <primitive> służy do włączania istniejących obiektów Three.js do drzewa komponentów R3F.
5.3. Zoptymalizowane Ładowanie za pomocą useGLTF (Drei)
Biblioteka @react-three/drei oferuje bardziej zaawansowany i wygodny hook useGLTF, który jest zalecaną metodą ładowania modeli glTF w R3F.
* Zalety:
* Automatyczne Cache'owanie: Modele są ładowane tylko raz i przechowywane w pamięci podręcznej.
* Kompresja Draco: Obsługuje modele skompresowane za pomocą Draco (wymaga odpowiedniego przygotowania modelu).
* Deklaratywna Struktura: Zamiast zwracać cały obiekt gltf, useGLTF zwraca obiekt z właściwościami nodes i materials, co ułatwia dostęp do poszczególnych siatek (meshes) i materiałów modelu w sposób bardziej "reactowy".
* Generowanie Komponentów: Narzędzie gltfjsx (uruchamiane przez npx gltfjsx model.glb) automatycznie generuje komponent React wykorzystujący useGLTF, co znacznie upraszcza integrację modelu z aplikacją.
* Przepływ Pracy z gltfjsx:
* Wygeneruj komponent: npx gltfjsx public/models/nazwa_modelu.glb --output src/features/timeline/NazwaModeluComponent.jsx
* Zaimportuj i użyj wygenerowanego komponentu w scenie R3F.
Przykład wygenerowanego komponentu (uproszczony):
// src/features/timeline/NazwaModeluComponent.jsx
import React, { useRef } from 'react';
import { useGLTF } from '@react-three/drei';
export function NazwaModeluComponent(props) {
const { nodes, materials } = useGLTF('/models/nazwa_modelu.glb'); // Ścieżka względem /public
return (
<group {...props} dispose={null}> {/* dispose={null} zapobiega automatycznemu usuwaniu zasobów przez R3F, jeśli model jest reużywany */}
{/* Dostęp do konkretnych siatek i materiałów */}
<mesh
geometry={nodes.NazwaSiatki1.geometry}
material={materials.NazwaMaterialu1}
castShadow // Włączanie rzucania cieni
receiveShadow // Włączanie odbierania cieni
/>
{/*... inne części modelu... */}
</group>
);
}
useGLTF.preload('/models/nazwa_modelu.glb'); // Opcjonalne wstępne ładowanie modelu
Funkcja useGLTF.preload() może być użyta do rozpoczęcia ładowania modelu w tle, zanim komponent zostanie zamontowany, co potencjalnie skraca czas oczekiwania użytkownika.
5.4. Obsługa Asynchronicznego Ładowania za pomocą <Suspense>
Ładowanie modeli 3D jest operacją asynchroniczną. React wymaga mechanizmu do obsługi stanu, gdy zasób nie jest jeszcze dostępny. Do tego służy wbudowany komponent <Suspense>.
* Użycie: Komponenty ładujące modele (lub komponenty je wykorzystujące) należy opakować w <Suspense>.
* Prop fallback: Określa, co ma być wyświetlane podczas ładowania. Może to być null (nic nie jest wyświetlane), prosty tekst, spinner, lub dedykowany komponent loadera. Biblioteka @react-three/drei oferuje komponent <Loader> oraz hook useProgress w połączeniu z <Html>, które można wykorzystać do stworzenia wskaźnika postępu ładowania.
// Wewnątrz <Canvas> w TimelineView.jsx
import { Suspense } from 'react';
import { Loader } from '@react-three/drei'; // Opcjonalny domyślny loader z Drei
<Suspense fallback={<Loader /> /* lub null lub własny loader */}>
<TimelineEventModel modelPath="/models/glider1.glb" position={[...]} eventData={...} />
<TimelineEventModel modelPath="/models/pilot1.glb" position={[...]} eventData={...} />
{/* Inne modele */}
</Suspense>
Wykorzystanie <Suspense> jest standardową praktyką w nowoczesnym React do obsługi asynchroniczności podczas renderowania, nie tylko dla modeli 3D, ale także np. dla dynamicznego ładowania kodu (code splitting). Integracja loaderów R3F z Suspense pozwala na płynne działanie interfejsu użytkownika podczas ładowania zasobów w tle.
5.5. Zarządzanie Zasobami Modeli
Zaleca się umieszczanie statycznych plików modeli (.glb, .gltf oraz powiązanych tekstur, jeśli nie są osadzone) w katalogu /public projektu Vite. Zasoby w tym folderze są kopiowane do katalogu wynikowego podczas budowania aplikacji i serwowane bezpośrednio przez serwer. Odwołania do modeli w kodzie powinny używać ścieżek względnych do katalogu głównego, np. /models/nazwa_modelu.glb.
Złożoność i rozmiar modeli 3D mają bezpośredni wpływ na czas ładowania i wydajność aplikacji. Optymalizacja modeli (redukcja liczby polygonów, kompresja tekstur) przed ich integracją jest kluczowa. Narzędzia takie jak gltf-pipeline mogą w tym pomóc. Wybór między useLoader a useGLTF wpływa na sposób dostępu do danych modelu; useGLTF (poprzez gltfjsx) ułatwia dostęp do poszczególnych części modelu, co może być przydatne przy implementacji interakcji lub dynamicznej stylizacji.
6. Zarządzanie Stanem dla Synchronizacji
6.1. Potrzeba Współdzielonego Stanu
Aplikacja składa się z dwóch niezależnych, ale powiązanych wizualnie komponentów: widoku osi czasu (R3F) i mapy powiązań (React Flow). Aby zapewnić spójne doświadczenie użytkownika, interakcje w jednym widoku muszą być odzwierciedlone w drugim. Na przykład, kliknięcie modelu pilota na osi czasu powinno podświetlić odpowiadający mu węzeł na mapie, i odwrotnie. Wymaga to mechanizmu zarządzania współdzielonym stanem globalnym, który będzie synchronizował te dwa widoki.
6.2. Ocena Opcji: Context API vs. Zustand
W ekosystemie React istnieje kilka podejść do zarządzania stanem globalnym. Dwa popularne wybory to wbudowane Context API oraz lekka biblioteka Zustand.
* React Context API:
* Zalety: Jest częścią Reacta, nie wymaga dodatkowych zależności. Prosty w konfiguracji dla podstawowych przypadków, takich jak przekazywanie motywu, danych uwierzytelniających czy preferencji językowych, które nie zmieniają się często. Podstawowa struktura obejmuje createContext, komponent Provider opakowujący drzewo komponentów oraz hook useContext do odczytu wartości.
* Wady: Głównym problemem jest wydajność przy częstych aktualizacjach stanu. Zmiana dowolnej części wartości w kontekście powoduje ponowne renderowanie wszystkich komponentów konsumujących ten kontekst, nawet jeśli interesuje je tylko niezmieniona część danych. Może to prowadzić do problemów z wydajnością, szczególnie w złożonych aplikacjach. Zarządzanie bardziej skomplikowaną logiką stanu może stać się uciążliwe, a ilość kodu "boilerplate" (Provider, useContext) rośnie.
* Zustand:
* Zalety: Minimalna ilość kodu "boilerplate" dzięki prostemu API opartemu na hookach. Wysoka wydajność dzięki selektywnym subskrypcjom – komponenty renderują się ponownie tylko wtedy, gdy zmieni się wybrana przez nie część stanu (za pomocą selektorów). Dobrze skaluje się od małych do dużych aplikacji. Ułatwia obsługę akcji asynchronicznych. Posiada wsparcie dla middleware (np. do debugowania z Redux DevTools, persystencji stanu w localStorage , czy tworzenia własnych rozszerzeń). Jest nieopiniodawczy, dając dużą elastyczność.
* Wady: Jest zewnętrzną zależnością, którą trzeba zainstalować i zarządzać. Ekosystem jest mniejszy niż w przypadku Reduxa (choć dynamicznie rośnie). Brak narzuconych konwencji może prowadzić do niespójności w dużych zespołach, jeśli nie zostaną ustalone wewnętrzne standardy. Domyślnie tworzy globalne singletony, co może być problemem przy wielokrotnym instancjonowaniu komponentów z własnym stanem, ale można to obejść, tworząc instancje store'u wewnątrz komponentów i przekazując je przez Context.
* Tabela Porównawcza:
| Cecha | React Context API | Zustand | Odniesienia |
|---|---|---|---|
| Konfiguracja | Wbudowane, proste dla małych potrzeb | Zewn. biblioteka, minimalny boilerplate | |
| Wydajność | Re-renderuje wszystkich konsumentów | Selektywne re-renderowanie (selektory) | |
| Skalowalność | Mniej optymalne dla złożonego stanu | Dobre dla małych i dużych aplikacji | |
| Boilerplate | Może rosnąć (Provider, useContext) | Bardzo niski | |
| Obsługa Async | Wymaga ręcznej implementacji | Wbudowana (akcje mogą być asynchroniczne) | |
| Middleware | Brak wbudowanego wsparcia | Tak (devtools, persist, niestandardowe) | |
| Główne Zastosowanie | Dane statyczne, motywy, auth | Stan globalny, częste aktualizacje, wydajność | |
6.3. Rekomendacja: Zustand
Dla tego projektu Zustand jest rekomendowanym rozwiązaniem. Głównym powodem jest potrzeba wydajnej synchronizacji między dwoma złożonymi i potencjalnie często aktualizowanymi komponentami wizualizacyjnymi (R3F i React Flow). Selektywne subskrypcje Zustanda zapobiegną niepotrzebnym ponownym renderowaniom jednego widoku, gdy zmienia się stan istotny tylko dla drugiego widoku, co jest kluczowe dla utrzymania płynności działania, zwłaszcza w scenie R3F. Prostota API i minimalny boilerplate dodatkowo przyspieszą rozwój.
6.4. Implementacja Magazynu Stanu (Store) Zustand
Tworzymy plik store'u, np. src/state/museumStore.js.
* Import create: Zaimportuj funkcję create z biblioteki zustand.
* Definicja Store'u: Wywołaj create, przekazując funkcję definiującą stan początkowy i akcje. Funkcja ta otrzymuje argumenty set (do aktualizacji stanu) i get (do odczytu aktualnego stanu wewnątrz akcji).
* Stan Początkowy: Zdefiniuj pola stanu, np. selectedItemId, tablice na dane (timelineEvents, pilots, gliders, relationships) oraz tablice na pochodne dane dla React Flow (mapNodes, mapEdges).
* Akcje: Zdefiniuj funkcje (akcje) do modyfikacji stanu. Akcje wywołują funkcję set, przekazując do niej obiekt z nowymi wartościami stanu lub funkcję, która otrzymuje poprzedni stan i zwraca nowy. set domyślnie wykonuje płytkie scalanie (shallow merge).
// src/state/museumStore.js
import { create } from 'zustand';
import { devtools } from 'zustand/middleware'; // Opcjonalnie: dla Redux DevTools
// Funkcja pomocnicza do transformacji danych
const transformDataForMap = (pilots, gliders, relationships) => {
const nodes =;
const edges = relationships.map(r => ({
id: `rel-${r.id}`,
source: r.sourceId, // ID węzła źródłowego (np. 'pilot-1')
target: r.targetId, // ID węzła docelowego (np. 'glider-A')
label: r.type, // Etykieta krawędzi (np. 'flew')
type: 'smoothstep', // Typ linii krawędzi
animated: true, // Animacja krawędzi
style: { stroke: r.type === 'flew'? '#2a9d8f' : '#e76f51', strokeWidth: 2 } // Stylizacja na podstawie typu
}));
return { nodes, edges };
};
// Definicja store'u z opcjonalnym middleware devtools
export const useMuseumStore = create(devtools((set, get) => ({
// === Stan ===
selectedItemId: null,
timelineEvents:,
pilots:,
gliders:,
relationships:,
mapNodes:,
mapEdges:,
isLoading: false,
error: null,
// === Akcje ===
loadData: async (dataUrl) => {
set({ isLoading: true, error: null });
try {
// W realnej aplikacji: fetch(dataUrl).then(res => res.json())
// Tutaj symulujemy ładowanie z przekazanego obiektu/pliku
const data = await import(/* @vite-ignore */ dataUrl).then(module => module.default);
const { nodes, edges } = transformDataForMap(data.pilots, data.gliders, data.relationships);
set({
timelineEvents: data.events,
pilots: data.pilots,
gliders: data.gliders,
relationships: data.relationships,
mapNodes: nodes,
mapEdges: edges,
isLoading: false
});
} catch (err) {
console.error("Failed to load data:", err);
set({ isLoading: false, error: 'Failed to load data' });
}
},
setSelectedItem: (itemId) => {
// Prosta walidacja lub logika przed ustawieniem stanu
if (itemId!== get().selectedItemId) {
set({ selectedItemId: itemId });
console.log("Selected Item ID:", itemId); // Do debugowania
}
},
// Można dodać więcej akcji, np. do aktualizacji pozycji węzłów po layoucie
updateNodePositions: (layoutedNodes) => {
set(state => ({
mapNodes: state.mapNodes.map(node => {
const layoutedNode = layoutedNodes.find(ln => ln.id === node.id);
return layoutedNode? {...node, position: layoutedNode.position } : node;
})
}));
}
}), { name: "MuseumStore" })); // Nazwa dla DevTools
6.5. Łączenie Komponentów ze Storem
Aby komponenty mogły reagować na zmiany w store Zustand i wywoływać akcje, należy użyć wygenerowanego hooka (useMuseumStore).
* Import Hooka: Zaimportuj useMuseumStore w komponentach TimelineView, MapView i innych, które potrzebują dostępu do stanu lub akcji.
* Selektywne Subskrypcje: Używaj funkcji selektora przekazywanej do hooka, aby wybrać tylko te części stanu, które są potrzebne w danym komponencie. Zapobiega to niepotrzebnym re-renderom.
// W komponencie MapView.jsx
const nodes = useMuseumStore((state) => state.mapNodes);
const edges = useMuseumStore((state) => state.mapEdges);
const setSelectedItem = useMuseumStore((state) => state.setSelectedItem);
// Lub wybieranie wielu wartości z użyciem shallow compare
import { shallow } from 'zustand/shallow';
const { nodes, edges, setSelectedItem } = useMuseumStore(
(state) => ({
nodes: state.mapNodes,
edges: state.mapEdges,
setSelectedItem: state.setSelectedItem,
}),
shallow // Ważne przy wybieraniu obiektu!
);
// Wywołanie akcji w handlerze zdarzenia
const handleNodeClick = useCallback((event, node) => {
setSelectedItem(node.id);
},);
* Unikanie Subskrypcji Całego Stanu: Należy unikać pobierania całego stanu (const state = useMuseumStore()), chyba że jest to absolutnie konieczne, ponieważ spowoduje to re-render komponentu przy każdej zmianie w store.
* shallow: Przy wybieraniu wielu wartości w jednym obiekcie, jak w drugim przykładzie powyżej, zaleca się użycie shallow z zustand/shallow jako drugiego argumentu hooka. Zapobiega to re-renderom, jeśli sam obiekt selektora jest nową referencją, ale wybrane wartości się nie zmieniły.
Struktura danych (Sekcja 10) bezpośrednio wpływa na kształt stanu w Zustandzie i logikę potrzebną w akcjach do transformacji tych danych (np. transformDataForMap). Dobrze zaprojektowany stan i akcje są kluczowe dla utrzymania przejrzystości i łatwości zarządzania aplikacją.
7. Implementacja Interakcji Użytkownika
7.1. Interakcje w Widoku Osi Czasu (R3F)
* Nawigacja w Scenie 3D: Najprostszym sposobem umożliwienia użytkownikowi eksploracji sceny 3D jest użycie gotowych kontrolek.
* OrbitControls: Komponent z @react-three/drei pozwala na orbitowanie (obracanie kamery wokół punktu centralnego), przesuwanie (pan) i przybliżanie/oddalanie (zoom) sceny za pomocą myszy lub gestów dotykowych. Wystarczy dodać <OrbitControls /> jako dziecko komponentu <Canvas>.
// Wewnątrz <Canvas> w TimelineView.jsx
import { OrbitControls } from '@react-three/drei';
//...
<OrbitControls enablePan={true} enableZoom={true} enableRotate={true} />
//...
* Interakcja z Obiektami (Kliknięcie/Najazd): R3F umożliwia przypisywanie handlerów zdarzeń bezpośrednio do komponentów <mesh> lub <group> reprezentujących obiekty w scenie.
* Obsługiwane Zdarzenia: onClick, onContextMenu, onDoubleClick, onPointerUp, onPointerDown, onPointerOver (najazd kursora), onPointerOut (opuszczenie kursora), onPointerEnter, onPointerLeave, onPointerMove, onWheel.
* Dane Zdarzenia: Handler otrzymuje obiekt zdarzenia, który zawiera informacje o interakcji, w tym:
* event.object: Obiekt Three.js, który został trafiony.
* event.point: Punkt przecięcia promienia z obiektem w przestrzeni 3D.
* event.distance: Odległość od kamery do punktu przecięcia.
* Oryginalne zdarzenie przeglądarki (event.nativeEvent).
* Implementacja:
* W handlerze onClick dla modelu na osi czasu, wywołaj akcję ze store'u Zustand, aby zaktualizować selectedItemId, przekazując ID powiązanego wydarzenia/pilota/szybowca.
* Użyj onPointerOver i onPointerOut do implementacji efektów wizualnych przy najechaniu kursorem (np. zmiana koloru materiału, lekkie powiększenie skali). Stan hover można zarządzać lokalnie w komponencie modelu lub poprzez sprawdzanie globalnego selectedItemId.
// W komponencie TimelineEventModel.jsx
import React, { useState } from 'react';
import { useGLTF } from '@react-three/drei';
import { useMuseumStore } from '../../state/museumStore';
function TimelineEventModel({ modelPath, position, eventData }) {
const { nodes, materials } = useGLTF(modelPath);
const setSelectedItem = useMuseumStore((state) => state.setSelectedItem);
const selectedItemId = useMuseumStore((state) => state.selectedItemId);
const [isHovered, setIsHovered] = useState(false);
const isSelected = selectedItemId === `event-${eventData.id}`; // Przykładowe ID
const handleClick = (event) => {
event.stopPropagation(); // Zapobiega propagacji do innych obiektów
setSelectedItem(`event-${eventData.id}`);
};
const handlePointerOver = (event) => {
event.stopPropagation();
setIsHovered(true);
document.body.style.cursor = 'pointer'; // Zmiana kursora
};
const handlePointerOut = (event) => {
setIsHovered(false);
document.body.style.cursor = 'default';
};
// Przykład: użycie pierwszego mesha z załadowanego modelu
const meshName = Object.keys(nodes).find(key => nodes[key].type === 'Mesh');
const geometry = meshName? nodes[meshName].geometry : null;
const materialName = meshName? nodes[meshName].material.name : null;
const material = materialName? materials[materialName] : null;
if (!geometry ||!material) return null; // Obsługa braku siatki/materiału
return (
<mesh
position={position}
geometry={geometry}
material={material}
scale={isHovered |
| isSelected? 1.1 : 1} // Powiększenie przy hover/select
onClick={handleClick}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
castShadow
receiveShadow
>
{/* Można dynamicznie zmieniać materiał lub dodawać efekty */}
<meshStandardMaterial
{...material} // Rozszerz oryginalne właściwości materiału
color={isSelected? 'yellow' : (isHovered? 'lightgreen' : material.color)}
emissive={isSelected? 'orange' : material.emissive} // Efekt podświetlenia
emissiveIntensity={isSelected? 0.5 : material.emissiveIntensity}
/>
</mesh>
);
}
useGLTF.preload(modelPath); // Preload
export default TimelineEventModel;
```
* Propagacja Zdarzeń: Domyślnie zdarzenia w R3F propagują się – jeśli obiekty się nakładają, zdarzenie może trafić do wielu z nich. Użycie event.stopPropagation() w handlerze zatrzymuje dalszą propagację do obiektów znajdujących się dalej od kamery oraz do elementów nadrzędnych w hierarchii sceny.
7.2. Interakcje w Widoku Mapy (React Flow)
React Flow oferuje wbudowane mechanizmy interakcji oraz propsy do ich konfiguracji.
* Nawigacja Widoku:
* Przesuwanie (pan) i powiększanie/oddalanie (zoom) są zazwyczaj aktywne domyślnie, gdy używany jest komponent <Controls />. Można je kontrolować za pomocą propsów na <ReactFlow>, takich jak panOnDrag, zoomOnScroll, zoomOnDoubleClick.
* Zaznaczanie i Przeciąganie Węzłów/Krawędzi:
* Wymaga implementacji handlerów onNodesChange i onEdgesChange z użyciem applyNodeChanges i applyEdgeChanges, jak pokazano w Sekcji 4.2. Te handlery aktualizują stan węzłów/krawędzi w odpowiedzi na akcje użytkownika.
* Propsy takie jak nodesDraggable, nodesConnectable, elementsSelectable pozwalają włączać/wyłączać te interakcje.
* Zdarzenia Kliknięcia:
* Do obsługi kliknięć na węzły i krawędzie służą propsy onNodeClick i onEdgeClick komponentu <ReactFlow>.
* Handlery te otrzymują obiekt zdarzenia React oraz kliknięty obiekt węzła lub krawędzi.
* W handlerze onNodeClick należy wywołać akcję ze store'u Zustand, aby zaktualizować selectedItemId, przekazując node.id.
* Do obsługi efektów hover służą propsy onNodeMouseEnter, onNodeMouseLeave, onEdgeMouseEnter, onEdgeMouseLeave.
// W komponencie MapView.jsx (fragment z Sekcji 4.2)
//... importy i definicja nodeTypes...
function MapViewInternal() {
//... pobieranie stanu ze store'u...
const { mapNodes, mapEdges, setSelectedItem, selectedItemId } = useMuseumStore(
(state) => ({
mapNodes: state.mapNodes,
mapEdges: state.mapEdges,
setSelectedItem: state.setSelectedItem,
selectedItemId: state.selectedItemId, // Pobranie aktualnie zaznaczonego ID
}),
shallow
);
const [nodes, setNodes] = useState(mapNodes);
const [edges, setEdges] = useState(mapEdges);
const { fitView } = useReactFlow();
useEffect(() => {
// Aktualizacja węzłów z uwzględnieniem zaznaczenia z globalnego stanu
setNodes(mapNodes.map(n => ({
...n,
// React Flow używa wewnętrznego stanu 'selected', ale możemy go nadpisać
// lub użyć tej informacji do dodania niestandardowej klasy/stylu
// Tutaj dodajemy niestandardową klasę, jeśli ID pasuje
className: n.id === selectedItemId? 'selected-externally' : ''
})));
setEdges(mapEdges.map(e => ({
...e,
// Podświetlenie krawędzi powiązanych z zaznaczonym węzłem
style: (e.source === selectedItemId |
| e.target === selectedItemId)
? {...e.style, stroke: 'orange', strokeWidth: 3 }
: e.style,
animated: (e.source === selectedItemId |
| e.target === selectedItemId)
})));
}, [mapNodes, mapEdges, selectedItemId]); // Reaguj na zmiany danych i zaznaczenia
//... handlery onNodesChange, onEdgesChange, onConnect...
const handleNodeClick = useCallback((event, node) => {
setSelectedItem(node.id);
},);
const handlePaneClick = useCallback(() => {
setSelectedItem(null); // Odznaczanie po kliknięciu tła
},);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick} // Dodanie handlera kliknięcia tła
nodeTypes={nodeTypes}
fitView
className="react-flow-map"
>
<Background />
<Controls />
</ReactFlow>
);
}
//... komponent MapView z Providerem...
export default MapView;
// Dodatkowy CSS dla klasy 'selected-externally'
// w pliku MapView.css lub globalnym
/*
.react-flow__node.selected-externally {
border: 2px solid orange!important;
box-shadow: 0 0 10px orange;
}
*/
7.3. Synchronizacja Interakcji poprzez Stan
Kluczem do synchronizacji jest współdzielony stan (selectedItemId w store Zustand).
* Aktualizacja Stanu: Kliknięcie obiektu w R3F (onClick na <mesh>) lub węzła w React Flow (onNodeClick na <ReactFlow>) wywołuje akcję setSelectedItem w store Zustand, zapisując ID klikniętego elementu.
* Reakcja na Zmianę Stanu:
* Timeline (R3F): Komponenty renderujące modele (TimelineEventModel) subskrybują selectedItemId. Jeśli ID ich wydarzenia/obiektu pasuje do selectedItemId, stosują wizualne podświetlenie (np. zmiana koloru materiału, dodanie obrysu za pomocą efektu Outline z Drei, zmiana skali).
* Mapa (React Flow): Komponent MapView subskrybuje selectedItemId. Kiedy się zmienia, aktualizuje stan nodes i edges, dodając np. specjalną klasę CSS (selected-externally) do odpowiedniego węzła lub zmieniając styl krawędzi powiązanych z tym węzłem. Niestandardowe komponenty węzłów (PilotNode, GliderNode) mogą również bezpośrednio odczytać selectedItemId i dostosować swoje renderowanie.
W ten sposób interakcja w jednym widoku propaguje się przez centralny magazyn stanu do drugiego widoku, zapewniając spójność wizualną i informacyjną. Wydajność efektów hover, szczególnie w R3F przy dużej liczbie obiektów, wymaga uwagi. Częste aktualizacje stanu na hover mogą być kosztowne. Raycasting w R3F , będący podstawą wykrywania interakcji, również zużywa zasoby i powinien być używany rozważnie.
8. Stylizacja Aplikacji
8.1. Wybór Strategii Stylizacji
Istnieje kilka popularnych metod stylizacji aplikacji React:
* Czysty CSS / CSS Modules: Standardowe podejście, zapewnia dobrą izolację stylów (CSS Modules) i nie wymaga dodatkowych bibliotek.
* CSS-in-JS (Styled Components, Emotion): Pozwala pisać style bezpośrednio w komponentach JavaScript, umożliwiając dynamiczną stylizację opartą na propsach i stanie. Wymaga dodatkowych zależności.
* Frameworki Utility-First (Tailwind CSS): Umożliwia szybkie prototypowanie i budowanie interfejsów poprzez stosowanie predefiniowanych klas użytkowych bezpośrednio w JSX. Wymaga konfiguracji (PostCSS, tailwind.config.js).
Wybór zależy od preferencji zespołu i wymagań projektu. Tailwind CSS jest dobrym wyborem dla szybkiego rozwoju, jeśli zespół jest z nim zaznajomiony. CSS Modules oferują prostotę i dobrą izolację bez dodatkowych narzędzi.
8.2. Stylizacja Podstawowych Komponentów React
Standardowe elementy UI (kontenery widoków, nagłówki, stopki) są stylizowane za pomocą wybranej metody, tak jak w każdej innej aplikacji React. Należy zadbać o spójny wygląd tych elementów.
8.3. Stylizacja Elementów React Flow
React Flow oferuje kilka poziomów dostosowywania wyglądu :
* Niestandardowe Węzły/Krawędzie: Komponenty niestandardowe (Sekcja 4.3, 4.4) mogą być stylizowane dowolną metodą (CSS, Styled Components, klasy Tailwind). Style mogą być dynamicznie zmieniane na podstawie prop data lub selected.
* Elementy Wbudowane:
* Nadpisywanie Klas CSS: Można nadpisać domyślne style React Flow, celując w jego wbudowane klasy CSS (np. .react-flow__node, .react-flow__edge, .react-flow__handle, .react-flow__controls button). Pełną listę klas można znaleźć w dokumentacji lub inspekcji DOM.
* Style Inline: Można przekazać obiekt style bezpośrednio w definicji węzła lub krawędzi w tablicach nodes i edges.
* Komponenty Pomocnicze: Wygląd tła (<Background>) i kontrolek (<Controls>) można modyfikować za pomocą ich propsów oraz nadpisywania klas CSS lub zmiennych CSS.
* Tematyzacja (Theming):
* Zmienne CSS: React Flow używa zmiennych CSS do definiowania domyślnych kolorów, grubości linii itp. (np. --xy-node-background-color-default, --xy-edge-stroke-default). Można je globalnie nadpisać we własnym pliku CSS, aby dostosować motyw.
* colorMode Prop: Prop colorMode na <ReactFlow> ('dark', 'light', 'system') dodaje odpowiednią klasę (.dark lub .light) do elementu root React Flow, umożliwiając łatwe tworzenie wariantów ciemnych/jasnych za pomocą CSS.
8.4. Stylizacja Elementów R3F
Stylizacja w R3F polega głównie na manipulacji właściwościami obiektów 3D, a nie na stosowaniu CSS do elementów wewnątrz <Canvas>. Kluczowe aspekty to:
* Materiały: Wygląd obiektu definiuje jego materiał (<meshStandardMaterial>, <meshPhongMaterial> itp.). Propsy takie jak color, metalness, roughness, map (tekstura), emissive (kolor emisyjny dla efektu świecenia) pozwalają kontrolować wygląd. Te propsy można dynamicznie zmieniać w odpowiedzi na stan (np. podświetlenie zaznaczonego elementu przez zmianę color lub emissive).
* Oświetlenie: Konfiguracja i rozmieszczenie świateł (Sekcja 3.3) ma fundamentalny wpływ na wygląd sceny, cienie i postrzeganie materiałów.
* Tekstury: Nakładanie obrazów (tekstur) na powierzchnie obiektów za pomocą prop map materiału.
* Shadery: Dla zaawansowanych efektów wizualnych można pisać niestandardowe shadery GLSL.
8.5. Osiąganie Spójności Estetycznej
Aby aplikacja wyglądała profesjonalnie, należy zadbać o spójność wizualną między widokiem 2D (React Flow) a 3D (R3F). Obejmuje to:
* Użycie spójnej palety kolorów i typografii.
* Zachowanie podobnego stylu wizualnego dla powiązanych elementów w obu widokach (np. kolor podświetlenia).
* Zapewnienie czystego i przejrzystego układu interfejsu.
Stylizacja w R3F jest fundamentalnie inna niż stylizacja DOM, ponieważ operuje na właściwościach materiałów i świateł w środowisku WebGL, a nie na regułach CSS. Osiągnięcie spójnego wyglądu wymaga świadomego projektowania i koordynacji stylów stosowanych do standardowych elementów React, komponentów React Flow oraz obiektów w scenie R3F.
9. Zapewnienie Dostępności (Accessibility)
Tworzenie dostępnych aplikacji internetowych (a11y) jest kluczowe, aby umożliwić korzystanie z nich wszystkim użytkownikom, w tym osobom korzystającym z technologii wspomagających, takich jak czytniki ekranu czy nawigacja klawiaturą.
9.1. Ogólna Dostępność Webowa (WAI-ARIA)
Podstawą dostępności są standardowe praktyki HTML i ARIA:
* Semantyczny HTML: Używanie odpowiednich znaczników HTML (<main>, <nav>, <article>, <aside>, <button>) pomaga technologiom wspomagającym zrozumieć strukturę strony.
* Atrybuty ARIA: Accessible Rich Internet Applications (ARIA) dostarcza dodatkowych atrybutów (role, aria-label, aria-describedby, aria-hidden, aria-required itp.), które wzbogacają semantykę elementów, szczególnie w przypadku złożonych, niestandardowych komponentów UI. Wszystkie atrybuty aria-* są w pełni obsługiwane w JSX, ale należy je pisać z myślnikami (kebab-case), a nie camelCase. Należy stosować je do standardowych elementów UI oraz kontenerów głównych widoków.
* Zarządzanie Focusem: Programowe zarządzanie focusem klawiatury jest ważne, gdy DOM jest dynamicznie modyfikowany, aby zapewnić logiczną nawigację.
* Nawigacja Klawiaturą: Zapewnienie możliwości obsługi wszystkich interaktywnych elementów za pomocą klawiatury (Tab, Shift+Tab, Enter, Spacja, klawisze strzałek) jest fundamentalne.
9.2. Dostępność React Flow
React Flow zawiera wbudowane funkcje ułatwiające dostępność :
* Fokus i Nawigacja Klawiaturą:
* Węzły i krawędzie są domyślnie fokusowalne za pomocą klawisza Tab (tabIndex={0} i role="button").
* Zaznaczanie/odznaczanie odbywa się za pomocą Enter/Spacji i Escape.
* Węzły można przesuwać za pomocą klawiszy strzałek (Shift przyspiesza ruch).
* ARIA:
* Węzły i krawędzie otrzymują atrybut aria-describedby informujący o sterowaniu klawiaturą.
* Krawędzie mają domyślny aria-label (np. "from nodeA to nodeB"), który można nadpisać za pomocą prop ariaLabel w definicji krawędzi.
* Węzły nie mają domyślnego aria-label, ale można go dodać za pomocą prop ariaLabel w definicji węzła (zalecane, jeśli węzeł nie ma czytelnej treści tekstowej).
* Komponenty pomocnicze (MiniMap, Controls, Attribution) również posiadają odpowiednie atrybuty ARIA.
* Konfiguracja: Propsy nodesFocusable, edgesFocusable (domyślnie true) oraz disableKeyboardA11y pozwalają kontrolować te zachowania.
9.3. Dostępność React Three Fiber (@react-three/a11y)
Dostępność w środowisku WebGL, takim jak R3F, wymaga specjalistycznych rozwiązań, ponieważ renderowana grafika nie jest bezpośrednio interpretowana przez technologie wspomagające. Biblioteka @react-three/a11y została stworzona, aby wypełnić tę lukę. Działa ona poprzez synchronizację semantycznych elementów DOM (niewidocznych wizualnie, ale dostępnych dla czytników ekranu) z obiektami 3D w scenie.
* Instalacja i Konfiguracja:
* Zainstaluj bibliotekę: npm install @react-three/a11y.
* Dodaj komponent <A11yAnnouncer /> w drzewie komponentów React, obok <Canvas>. Jest on niezbędny do komunikacji z czytnikami ekranu.
* Udostępnianie Obiektów 3D:
* Owiń interaktywne lub ważne semantycznie obiekty 3D (np. komponenty modeli na osi czasu) komponentem <A11y>.
* Przypisz odpowiednią role:
* role="content": Dla elementów statycznych, które wymagają opisu (alt-text). Użyj prop description. Odpowiada elementowi <p> w DOM.
* role="button": Dla elementów klikalnych. Użyj description (etykieta przycisku), actionCall (funkcja wywoływana przy aktywacji - kliknięcie, Enter, itp.) oraz opcjonalnie activationMsg (komunikat dla czytnika po aktywacji). Odpowiada <button>.
* Inne role: link, togglebutton.
* Użyj focusCall, aby wywołać funkcję, gdy element 3D otrzyma fokus (np. przez nawigację Tab).
* Użyj tabIndex (ostrożnie, najlepiej tylko 0), aby włączyć element do sekwencji nawigacji Tab, jeśli jest to uzasadnione.
* Integracja z Czytnikami Ekranu: Prop description w <A11y> dostarcza tekst alternatywny. Komponent A11ySection pozwala grupować powiązane elementy <A11y> w sekcję HTML z własną etykietą i opisem, co poprawia orientację użytkowników czytników ekranu.
* Preferencje Użytkownika: Komponent <A11yUserPreferences> i hook useUserPreferences umożliwiają odczytanie preferencji systemowych prefers-reduced-motion i prefers-color-scheme. Pozwala to dostosować animacje (ograniczyć ruch) i kolorystykę (tryb ciemny/jasny) aplikacji. Jeśli <A11yUserPreferences> jest używany poza <Canvas>, konieczne może być użycie useContextBridge z Drei do przekazania kontekstu do wnętrza sceny R3F.
9.4. Strategia Nawigacji Klawiaturą
Należy zapewnić logiczną kolejność przechodzenia fokusem (Tab/Shift+Tab) przez główne elementy interfejsu, następnie do interaktywnych elementów wewnątrz React Flow (węzły, krawędzie), a potencjalnie także do wybranych, udostępnionych elementów w scenie R3F (za pomocą <A11y> z tabIndex). Kluczowe jest testowanie nawigacji wyłącznie za pomocą klawiatury.
Implementacja dostępności wymaga świadomego wysiłku na każdym etapie. Dla R3F kluczowe jest użycie @react-three/a11y i dostarczenie opisów , a dla React Flow - nadanie odpowiednich ariaLabel. Niezbędne jest testowanie za pomocą narzędzi (np. aXe ) i technologii wspomagających (np. NVDA, JAWS ).
10. Przykładowa Struktura Danych (JSON)
10.1. Wymagania Dotyczące Danych
Aplikacja potrzebuje danych o następujących encjach:
* Szybowce (Gliders): Informacje o modelach szybowców, w tym ścieżki do ich modeli 3D.
* Piloci (Pilots): Informacje o postaciach historycznych.
* Wydarzenia na Osi Czasu (Timeline Events): Punkty w czasie powiązane z konkretnymi szybowcami lub pilotami.
* Relacje (Relationships): Powiązania między pilotami a szybowcami (np. "latał na", "zaprojektował").
10.2. Proponowana Struktura JSON
Dane można zorganizować w jednym pliku JSON lub kilku oddzielnych plikach. Struktura powinna być przejrzysta i łatwa do przetworzenia. Kluczowe jest używanie spójnych identyfikatorów (ID) do tworzenia powiązań.
* Piloci (pilots): Tablica obiektów, każdy z id (unikalny numer lub string), name, bio, imageUrl itp.
* Szybowce (gliders): Tablica obiektów, każdy z id (unikalny numer lub string), name, description, modelPath (ścieżka do pliku .glb w /public/models), thumbnailUrl itp.
* Wydarzenia (events): Tablica obiektów, każdy z id, year (rok wydarzenia), title, description, opcjonalnie linkedPilotId i linkedGliderId (referencje do powiązanych pilotów/szybowców), modelPath (może nadpisywać domyślny model szybowca, jeśli wydarzenie ma specyficzną wizualizację).
* Relacje (relationships): Tablica obiektów, każdy z id, sourceId (ID elementu źródłowego, np. pilot-1), targetId (ID elementu docelowego, np. glider-A), type (rodzaj relacji, np. 'flew', 'designed'), description. Ważne: sourceId i targetId powinny odpowiadać ID węzłów używanych w React Flow (często z prefiksem, np. pilot-${pilot.id}, glider-${glider.id}).
10.3. Przykładowe Fragmenty Danych
Poniżej znajdują się przykładowe fragmenty danych w formacie JSON, które mogą posłużyć jako podstawa dla prototypu.
// Przykład: data/sampleData.json (połączony plik)
{
"pilots":,
"gliders":,
"events":,
"relationships":
}
10.4. Ładowanie Danych
W prototypie dane te mogą być zaimportowane bezpośrednio z pliku JSON. W pełnej aplikacji byłyby one prawdopodobnie pobierane z zewnętrznego API. Po załadowaniu dane powinny zostać przekazane do store'u Zustand za pomocą odpowiedniej akcji (np. loadData z Sekcji 6.4), która następnie przetworzy je i udostępni komponentom.
Struktura danych jest fundamentem aplikacji. Jej przemyślane zaprojektowanie, w tym spójne stosowanie identyfikatorów, bezpośrednio wpływa na łatwość zarządzania stanem, logikę renderowania komponentów oraz możliwość poprawnego wizualizowania relacji w obu widokach. Niespójności lub błędy w danych lub ich strukturze utrudnią implementację logiki transformacji (np. transformDataForMap) i synchronizacji.
11. Integracja i Prototypowanie
11.1. Główna Struktura Aplikacji (App.jsx)
Komponent App.jsx pełni rolę centralnego punktu integracji, łącząc poszczególne widoki i inicjując ładowanie danych.
* Integracja Widoków: Importuje i renderuje komponenty TimelineView i MapView.
* Layout: Definiuje ogólny układ aplikacji, w tym ewentualne nagłówki, stopki czy paski boczne, używając standardowych technik layoutu React/CSS.
* Inicjalizacja Danych: Wykorzystuje hook useEffect, aby po pierwszym zamontowaniu komponentu wywołać akcję ze store'u Zustand (np. loadData) w celu załadowania danych z pliku JSON lub API. Po załadowaniu danych podstawowych, wywołuje akcję pochodną (np. deriveMapData lub wykonuje transformację w loadData), aby przygotować dane w formacie wymaganym przez React Flow.
// src/App.jsx
import React, { useEffect } from 'react';
import TimelineView from './features/timeline/TimelineView';
import MapView from './features/map/MapView';
import { useMuseumStore } from './state/museumStore';
import './App.css'; // Główne style aplikacji
// Ścieżka do pliku JSON z danymi
const DATA_URL = '/data/sampleData.json';
function App() {
// Pobranie akcji loadData i stanu isLoading/error ze store'u
const loadData = useMuseumStore((state) => state.loadData);
const isLoading = useMuseumStore((state) => state.isLoading);
const error = useMuseumStore((state) => state.error);
// Ładowanie danych przy pierwszym renderowaniu
useEffect(() => {
loadData(DATA_URL);
}, [loadData]); // Zależność tylko od funkcji loadData
return (
<div className="app-container">
<header className="app-header">
<h1>Wirtualne Muzeum Szybowców</h1>
</header>
{isLoading && <div className="loading-indicator">Ładowanie danych...</div>}
{error && <div className="error-message">Błąd ładowania danych: {error}</div>}
{!isLoading &&!error && (
<main className="main-content">
<section className="timeline-section" aria-labelledby="timeline-heading">
<h2 id="timeline-heading">Oś Czasu</h2>
<TimelineView />
</section>
<section className="map-section" aria-labelledby="map-heading">
<h2 id="map-heading">Mapa Powiązań</h2>
<MapView />
</section>
</main>
)}
<footer className="app-footer">
<p>Prototyp aplikacji wirtualnego muzeum - {new Date().getFullYear()}</p>
</footer>
</div>
);
}
export default App;
11.2. Zapewnienie Przepływu Danych
Przepływ danych w aplikacji jest następujący:
* Inicjalizacja: Komponent App montuje się i wywołuje akcję loadData w store Zustand.
* Ładowanie i Transformacja: Akcja loadData pobiera dane (z pliku/API) i zapisuje je w stanie store'u, jednocześnie transformując dane o pilotach, szybowcach i relacjach do formatu mapNodes i mapEdges wymaganego przez React Flow.
* Subskrypcja i Renderowanie: Komponenty TimelineView i MapView subskrybują odpowiednie fragmenty stanu w store Zustand za pomocą hooka useMuseumStore i selektorów. Gdy dane stają się dostępne (po zakończeniu loadData), komponenty otrzymują je i renderują odpowiednie wizualizacje (modele 3D na osi, węzły i krawędzie na mapie).
* Interakcja: Użytkownik wchodzi w interakcję z elementami w jednym z widoków (np. klika model w R3F lub węzeł w React Flow).
* Aktualizacja Stanu: Handler zdarzenia w odpowiednim komponencie wywołuje akcję w store Zustand (np. setSelectedItem), aktualizując współdzielony stan.
* Synchronizacja: Komponenty subskrybujące zmieniony fragment stanu (np. selectedItemId) otrzymują nową wartość i aktualizują swoje renderowanie, aby odzwierciedlić zmianę (np. podświetlając odpowiedni element).
11.3. Uruchomienie Prototypu
Aby uruchomić prototyp w trybie deweloperskim, należy wykonać polecenie w głównym katalogu projektu:
npm run dev
Vite uruchomi serwer deweloperski (zwykle na http://localhost:5173 lub podobnym adresie), który będzie automatycznie odświeżał aplikację w przeglądarce po każdej zmianie w kodzie (HMR). Użytkownik powinien zobaczyć oba widoki (oś czasu i mapę) wypełnione danymi z pliku sampleData.json oraz móc korzystać z podstawowych interakcji (nawigacja w 3D, przesuwanie/zoom mapy, klikanie elementów w celu ich zaznaczenia/podświetlenia w obu widokach).
11.4. Przegląd Finalnej Struktury Kodu
Finalna struktura kodu powinna odzwierciedlać podział na funkcjonalności (features), komponenty reużywalne, zarządzanie stanem, zasoby statyczne i konfigurację, jak zdefiniowano w Sekcji 2.3. Taka organizacja ułatwia nawigację po kodzie, testowanie i przyszłą rozbudowę aplikacji.
Pomyślna integracja wszystkich części składowych zależy od poprawnego działania każdego z poprzednich etapów: konfiguracji projektu, implementacji komponentów widoków, ładowania modeli, zarządzania stanem i obsługi interakcji. Błąd na którymkolwiek etapie uniemożliwi działanie finalnego prototypu zgodnie z założeniami. Układ wizualny komponentów TimelineView i MapView w App.jsx jest zarządzany przez standardowe techniki CSS/React, niezależnie od wewnętrznej logiki R3F czy React Flow, co podkreśla separację odpowiedzialności.
12. Podsumowanie i Dalsze Kroki
12.1. Podsumowanie
Niniejszy raport przedstawił kompleksowy proces tworzenia prototypu aplikacji w