From 234ed8ae947ad4866533627bbb0e13b6c5097299 Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Wed, 1 Apr 2026 12:06:32 +0100 Subject: [PATCH] Initial commit for new projection support. --- backend/app/server.js | 4 +- frontend/e2e/app.spec.js | 3 + frontend/src/App.jsx | 479 ++++++++++++++++------------ frontend/src/__tests__/App.test.jsx | 3 + frontend/src/styles/App.css | 31 ++ 5 files changed, 308 insertions(+), 212 deletions(-) diff --git a/backend/app/server.js b/backend/app/server.js index 6845e24..903cd2b 100644 --- a/backend/app/server.js +++ b/backend/app/server.js @@ -53,8 +53,8 @@ app.get('/api/line-of-sight', async (req, res) => { try { // Generate path points for visualization and spatial query const pathPoints = []; - const totalDistance = 20000; - const steps = 80; // More steps for smoother speed transition + const totalDistance = 40074; // Full Earth circumference (km) + const steps = 160; for (let i = 0; i <= steps; i++) { const dist = (totalDistance * i) / steps; diff --git a/frontend/e2e/app.spec.js b/frontend/e2e/app.spec.js index cd14b93..526d282 100644 --- a/frontend/e2e/app.spec.js +++ b/frontend/e2e/app.spec.js @@ -33,6 +33,9 @@ class Map { flyTo() {} jumpTo() {} setStyle() { Promise.resolve().then(() => this._emit('style.load')); } + setProjection() {} + hasImage() { return false; } + addImage() {} getCanvas() { return document.createElement('canvas'); } project() { return { x: 0, y: 0 }; } unproject() { return { lng: 0, lat: 0 }; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 18ec4dc..f7f24ad 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,30 +12,90 @@ const APP = () => { const cityMarkersRef = useRef([]); const animationRef = useRef(null); const popupRef = useRef(null); + // Ref mirrors mapProjection state so stale closures (style.load) read the current value + const mapProjectionRef = useRef('globe'); + const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 }); const [direction, setDirection] = useState(45); const [lineOfSightData, setLineOfSightData] = useState(null); const [loading, setLoading] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [flightSpeed, setFlightSpeed] = useState(1.0); - const [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark' + const [mapStyle, setMapStyle] = useState('light'); + const [mapProjection, setMapProjection] = useState('globe'); const [tolerance, setTolerance] = useState(50); const [selectedCity, setSelectedCity] = useState(null); const [isLocked, setIsLocked] = useState(false); + // Keep ref in sync with state + useEffect(() => { + mapProjectionRef.current = mapProjection; + }, [mapProjection]); + + // --- Helpers --- + + // Build GeoJSON for the line, splitting at the antimeridian for flat projections + const buildLineGeoJSON = (lineCoords, projection) => { + const coords = lineCoords.map(c => [c.lon, c.lat]); + if (projection === 'globe') { + return { + type: 'Feature', + properties: {}, + geometry: { type: 'LineString', coordinates: coords } + }; + } + // Split segments wherever consecutive points cross the antimeridian (±180°) + const segments = []; + let current = [coords[0]]; + for (let i = 1; i < coords.length; i++) { + const diff = coords[i][0] - coords[i - 1][0]; + if (Math.abs(diff) > 180) { + segments.push(current); + current = [coords[i]]; + } else { + current.push(coords[i]); + } + } + segments.push(current); + if (segments.length === 1) { + return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: segments[0] } }; + } + return { type: 'Feature', properties: {}, geometry: { type: 'MultiLineString', coordinates: segments } }; + }; + + // Register a subtle chevron arrow image for the symbol layer + const addArrowImage = (map) => { + const size = 20; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + ctx.strokeStyle = 'rgba(255, 107, 107, 0.75)'; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + // Chevron pointing right; MapLibre rotates it to follow line direction + ctx.moveTo(size * 0.2, size * 0.22); + ctx.lineTo(size * 0.78, size * 0.5); + ctx.lineTo(size * 0.2, size * 0.78); + ctx.stroke(); + map.addImage('arrow-icon', canvas); + }; + + // --- Map initialisation (runs once) --- + useEffect(() => { - // Initialize MapLibre map - ONLY ONCE mapRef.current = new maplibregl.Map({ container: mapContainerRef.current, style: getMapStyle(mapStyle), - center: [-0.1278, 51.5074], // London + center: [-0.1278, 51.5074], zoom: 2, pitch: 0, - projection: 'globe' // Enable 3D Globe + projection: 'globe' }); mapRef.current.on('style.load', () => { - console.log('Map style loaded or changed'); setupMapLayers(); }); @@ -43,69 +103,62 @@ const APP = () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); if (mapRef.current) mapRef.current.remove(); }; - }, []); // Run only once + }, []); // eslint-disable-line react-hooks/exhaustive-deps const setupMapLayers = () => { const map = mapRef.current; if (!map) return; - // 1. Add atmosphere/sky effects - map.setSky({ - 'sky-color': '#199EF3', - 'sky-horizon-blend': 0.5, - 'horizon-color': '#ffffff', - 'horizon-fog-blend': 0.5, - 'fog-color': '#add8e6', - 'fog-ground-blend': 0.5 - }); - - // 2. Add terrain source for 3D effect - if (!map.getSource('terrain')) { - map.addSource('terrain', { - type: 'raster-dem', - tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'], - encoding: 'terrarium', - tileSize: 256, - maxzoom: 15 - }); - } - map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); + const isGlobe = mapProjectionRef.current === 'globe'; - // 3. Initialize start marker + // 1. Register arrow image (cleared on style reload) + addArrowImage(map); + + // 2. Sky and terrain — globe only + if (isGlobe) { + map.setSky({ + 'sky-color': '#199EF3', + 'sky-horizon-blend': 0.5, + 'horizon-color': '#ffffff', + 'horizon-fog-blend': 0.5, + 'fog-color': '#add8e6', + 'fog-ground-blend': 0.5 + }); + if (!map.getSource('terrain')) { + map.addSource('terrain', { + type: 'raster-dem', + tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'], + encoding: 'terrarium', + tileSize: 256, + maxzoom: 15 + }); + } + map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); + } + + // 3. Start marker if (startMarkerRef.current) startMarkerRef.current.remove(); startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' }) .setLngLat([selectedPoint.lon, selectedPoint.lat]) .addTo(map); - // 4. Initialize preview line source and layer + // 4. Preview line source and layer if (!map.getSource('preview-line')) { map.addSource('preview-line', { type: 'geojson', - data: { - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: [] - } - } + data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } }); - map.addLayer({ id: 'preview-line', type: 'line', source: 'preview-line', - paint: { - 'line-color': '#FF6B6B', - 'line-width': 2, - 'line-dasharray': [2, 2] - } + paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] } }); } - // 5. Restore results line and city markers if they existed + // 5. Restore results line and markers if they existed if (lineOfSightData) { renderLineOnMap(lineOfSightData); - // Hide preview if results are shown if (map.getLayer('preview-line')) { map.setLayoutProperty('preview-line', 'visibility', 'none'); } @@ -114,26 +167,56 @@ const APP = () => { } }; - // Separate effect for the click listener that respects isLocked + // --- Projection change effect --- + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + map.setProjection(mapProjection); + + if (mapProjection === 'globe') { + map.setSky({ + 'sky-color': '#199EF3', + 'sky-horizon-blend': 0.5, + 'horizon-color': '#ffffff', + 'horizon-fog-blend': 0.5, + 'fog-color': '#add8e6', + 'fog-ground-blend': 0.5 + }); + if (map.getSource('terrain')) { + map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); + } + } else { + map.setTerrain(null); + } + + // Re-render line with correct antimeridian handling for this projection + if (lineOfSightData && map.getSource('line-of-sight')) { + map.getSource('line-of-sight').setData( + buildLineGeoJSON(lineOfSightData.line_coordinates, mapProjection) + ); + } + }, [mapProjection]); // eslint-disable-line react-hooks/exhaustive-deps + + // --- Click handler (respects isLocked) --- + useEffect(() => { if (!mapRef.current) return; const handleClick = (e) => { - // Don't allow moving start point if locked if (isLocked) return; - const { lng, lat } = e.lngLat; setSelectedPoint({ lat, lon: lng }); - if (startMarkerRef.current) { startMarkerRef.current.setLngLat([lng, lat]); } - - // Clear previous final results when moving start point clearCityMarkers(); - if (mapRef.current.getSource('line-of-sight')) { - mapRef.current.removeLayer('line-of-sight'); - mapRef.current.removeSource('line-of-sight'); + const map = mapRef.current; + if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows'); + if (map.getSource('line-of-sight')) { + map.removeLayer('line-of-sight'); + map.removeSource('line-of-sight'); } }; @@ -141,7 +224,24 @@ const APP = () => { return () => { if (mapRef.current) mapRef.current.off('click', handleClick); }; - }, [isLocked]); + }, [isLocked]); + + // Update preview line when direction or point changes + useEffect(() => { + if (!isLocked) { + updatePreviewLine(selectedPoint, direction); + } + }, [direction, selectedPoint, isLocked]); // eslint-disable-line react-hooks/exhaustive-deps + + // --- Map style change --- + + useEffect(() => { + if (mapRef.current) { + mapRef.current.setStyle(getMapStyle(mapStyle)); + } + }, [mapStyle]); // eslint-disable-line react-hooks/exhaustive-deps + + // --- Handlers --- const handleStartAgain = () => { if (animationRef.current) { @@ -149,34 +249,25 @@ const APP = () => { setIsPlaying(false); setFlightSpeed(1.0); } - if (popupRef.current) { popupRef.current.remove(); popupRef.current = null; } - setIsLocked(false); setLineOfSightData(null); setSelectedCity(null); clearCityMarkers(); - if (mapRef.current) { - if (mapRef.current.getSource('line-of-sight')) { - mapRef.current.removeLayer('line-of-sight'); - mapRef.current.removeSource('line-of-sight'); + const map = mapRef.current; + if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows'); + if (map.getSource('line-of-sight')) { + map.removeLayer('line-of-sight'); + map.removeSource('line-of-sight'); } - // Re-enable preview line if it was hidden - if (mapRef.current.getLayer('preview-line')) { - mapRef.current.setLayoutProperty('preview-line', 'visibility', 'visible'); + if (map.getLayer('preview-line')) { + map.setLayoutProperty('preview-line', 'visibility', 'visible'); } - - // Reset map pitch and zoom - mapRef.current.flyTo({ - center: [selectedPoint.lon, selectedPoint.lat], - zoom: 3, - pitch: 0, - bearing: 0 - }); + map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 }); } }; @@ -198,13 +289,10 @@ const APP = () => { const showCityPopup = (city) => { if (!mapRef.current) return; - if (popupRef.current) popupRef.current.remove(); - const popupNode = document.createElement('div'); popupNode.className = 'map-info-popup'; const countryName = getCountryName(city.country); - popupNode.innerHTML = ` `; - - popupRef.current = new maplibregl.Popup({ - closeButton: true, + popupRef.current = new maplibregl.Popup({ + closeButton: true, closeOnClick: false, maxWidth: '300px', offset: [0, -20] @@ -225,15 +312,12 @@ const APP = () => { .setLngLat([city.lon, city.lat]) .setDOMContent(popupNode) .addTo(mapRef.current); - - popupRef.current.on('close', () => { - setSelectedCity(null); - }); + popupRef.current.on('close', () => setSelectedCity(null)); }; const startFlyOver = () => { if (!lineOfSightData || !mapRef.current) return; - + if (isPlaying) { if (animationRef.current) cancelAnimationFrame(animationRef.current); setIsPlaying(false); @@ -244,11 +328,10 @@ const APP = () => { setIsPlaying(true); setSelectedCity(null); if (popupRef.current) popupRef.current.remove(); - + const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]); const route = turf.lineString(coordinates); const routeDistance = turf.length(route); - const startTime = performance.now(); let lastNearestCityId = null; let lastFetchedDist = 0; @@ -259,12 +342,11 @@ const APP = () => { async function animate(currentTime) { if (!mapRef.current) return; - + const deltaTime = currentTime - lastTimestamp; lastTimestamp = currentTime; - const baseKmPerMs = 0.1; // 100km per second - + const baseKmPerMs = 0.1; // 100 km/s const pathPoints = lineOfSightData.line_coordinates; const pointIndex = Math.min( Math.floor((currentProgress / routeDistance) * pathPoints.length), @@ -272,7 +354,7 @@ const APP = () => { ); const isOverWater = pathPoints[pointIndex]?.is_over_water; - // Predictive land detection (look ahead 2000km) + // Predictive land detection (look ahead 2000 km) let futureIsOverWater = true; const lookAheadDistance = 2000; const lookAheadSteps = 10; @@ -290,7 +372,6 @@ const APP = () => { const targetMultiplier = (isOverWater && futureIsOverWater) ? 20.0 : 1.0; const acceleration = 0.005; - if (currentSpeedMultiplier < targetMultiplier) { currentSpeedMultiplier = Math.min(currentSpeedMultiplier + acceleration * deltaTime, targetMultiplier); } else if (currentSpeedMultiplier > targetMultiplier) { @@ -298,39 +379,27 @@ const APP = () => { } frameCount++; - if (frameCount % 10 === 0) { - setFlightSpeed(currentSpeedMultiplier); - } + if (frameCount % 10 === 0) setFlightSpeed(currentSpeedMultiplier); currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier; - const phase = Math.min(currentProgress / routeDistance, 1); - + if (phase >= 1) { setIsPlaying(false); setFlightSpeed(1.0); return; } - + const eye = turf.along(route, currentProgress).geometry.coordinates; const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates; - const bearing = turf.bearing(turf.point(eye), turf.point(target)); - mapRef.current.jumpTo({ - center: eye, - zoom: 6, - pitch: 65, - bearing: bearing - }); + mapRef.current.jumpTo({ center: eye, zoom: 6, pitch: 65, bearing }); if (currentProgress - lastFetchedDist > 2000) { lastFetchedDist = currentProgress; try { - const response = await apiService.getLineOfSight( - eye[1], eye[0], bearing, tolerance - ); - + const response = await apiService.getLineOfSight(eye[1], eye[0], bearing, tolerance); if (response.data.success) { const newCities = response.data.data.conurbations; setLineOfSightData(prev => { @@ -353,29 +422,26 @@ const APP = () => { } } - const nearestCity = lineOfSightData.conurbations.find(city => - Math.abs(city.distance_km - currentProgress) < 100 + const nearestCity = lineOfSightData.conurbations.find(city => + Math.abs(city.distance_km - currentProgress) < 100 ); - if (nearestCity && nearestCity.id !== lastNearestCityId) { lastNearestCityId = nearestCity.id; setSelectedCity(nearestCity); showCityPopup(nearestCity); } - + animationRef.current = requestAnimationFrame(animate); } - + animationRef.current = requestAnimationFrame(animate); }; const addMarkersToMap = (cities, startIndex = 0) => { const map = mapRef.current; if (!map) return; - cities.forEach((city, index) => { const displayIndex = startIndex + index + 1; - const el = document.createElement('div'); el.className = 'city-marker'; el.innerHTML = ` @@ -384,17 +450,12 @@ const APP = () => {
${city.name}
${(city.population / 1000).toFixed(0)}k
`; - - const marker = new maplibregl.Marker(el) - .setLngLat([city.lon, city.lat]) - .addTo(map); - + const marker = new maplibregl.Marker(el).setLngLat([city.lon, city.lat]).addTo(map); marker.getElement().addEventListener('click', (e) => { e.stopPropagation(); setSelectedCity(city); showCityPopup(city); }); - cityMarkersRef.current.push(marker); }); }; @@ -404,71 +465,76 @@ const APP = () => { if (!map) return; clearCityMarkers(); + + // Remove existing layers/source (arrow layer must go before line layer) + if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows'); if (map.getSource('line-of-sight')) { map.removeLayer('line-of-sight'); map.removeSource('line-of-sight'); } + const projection = mapProjectionRef.current; + map.addSource('line-of-sight', { type: 'geojson', - data: { - type: 'Feature', - properties: {}, - geometry: { - type: 'LineString', - coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat]) - } - } + data: buildLineGeoJSON(data.line_coordinates, projection) }); map.addLayer({ id: 'line-of-sight', type: 'line', source: 'line-of-sight', + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': '#FF6B6B', 'line-width': 4, 'line-opacity': 0.8 } + }); + + // Subtle periodic directional arrows along the line + map.addLayer({ + id: 'line-of-sight-arrows', + type: 'symbol', + source: 'line-of-sight', layout: { - 'line-join': 'round', - 'line-cap': 'round' - }, - paint: { - 'line-color': '#FF6B6B', - 'line-width': 4, - 'line-opacity': 0.8 + 'symbol-placement': 'line', + 'symbol-spacing': 300, + 'icon-image': 'arrow-icon', + 'icon-size': 1.0, + 'icon-allow-overlap': true, + 'icon-ignore-placement': true } }); addMarkersToMap(data.conurbations); - const bounds = new maplibregl.LngLatBounds(); - data.line_coordinates.forEach(coord => { - bounds.extend([coord.lon, coord.lat]); + // Zoom to show the full path from the start point + map.flyTo({ + center: [data.line_coordinates[0].lon, data.line_coordinates[0].lat], + zoom: 2, + pitch: 0, + bearing: 0 }); - map.fitBounds(bounds, { padding: 50 }); }; - const updatePreviewLine = (point, bearing) => { + const updatePreviewLine = (point, brng) => { const map = mapRef.current; if (!map || !map.getSource('preview-line')) return; const path = []; - const steps = 50; - const totalDistance = 20000; + const steps = 160; + const totalDistance = 40074; // Full Earth circumference (km) for (let i = 0; i <= steps; i++) { const dist = (totalDistance * i) / steps; - path.push(calculateDestination(point.lat, point.lon, bearing, dist)); + path.push(calculateDestination(point.lat, point.lon, brng, dist)); } - + map.getSource('preview-line').setData({ type: 'Feature', - geometry: { - type: 'LineString', - coordinates: path.map(p => [p.lon, p.lat]) - } + geometry: { type: 'LineString', coordinates: path.map(p => [p.lon, p.lat]) } }); }; const calculateDestination = (lat, lon, bearing, distance) => { - const R = 6371; + const R = 6371; const brng = (bearing * Math.PI) / 180; const φ1 = (lat * Math.PI) / 180; const λ1 = (lon * Math.PI) / 180; @@ -491,12 +557,6 @@ const APP = () => { }; }; - useEffect(() => { - if (mapRef.current) { - mapRef.current.setStyle(getMapStyle(mapStyle)); - } - }, [mapStyle]); - const getMapStyle = (style) => { if (style === 'satellite') { return { @@ -509,20 +569,16 @@ const APP = () => { attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' } }, - layers: [ - { - id: 'satellite-layer', - type: 'raster', - source: 'esri-satellite', - minzoom: 0, - maxzoom: 20 - } - ] + layers: [{ + id: 'satellite-layer', + type: 'raster', + source: 'esri-satellite', + minzoom: 0, + maxzoom: 20 + }] }; } - return style === 'dark' - ? 'https://demotiles.maplibre.org/style.json' - : 'https://demotiles.maplibre.org/style.json'; + return 'https://demotiles.maplibre.org/style.json'; }; const handleShowLineOfSight = async () => { @@ -530,19 +586,16 @@ const APP = () => { setSelectedCity(null); try { const response = await apiService.getLineOfSight( - selectedPoint.lat, - selectedPoint.lon, - direction, + selectedPoint.lat, + selectedPoint.lon, + direction, tolerance ); - setLineOfSightData(response.data.data); - setIsLocked(true); - + setIsLocked(true); if (mapRef.current && mapRef.current.getLayer('preview-line')) { mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none'); } - renderLineOnMap(response.data.data); } catch (error) { console.error('Error fetching line of sight:', error); @@ -551,15 +604,22 @@ const APP = () => { } }; + const PROJECTIONS = [ + { id: 'globe', label: 'Globe' }, + { id: 'mercator', label: 'Mercator' }, + { id: 'equalEarth', label: 'Equal Earth' }, + { id: 'naturalEarth', label: 'Natural Earth' }, + { id: 'winkelTripel', label: 'Winkel Tripel' } + ]; + return (
-
-
- +
+

Line of Sight Settings

- +
{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)} @@ -567,11 +627,11 @@ const APP = () => {
- setDirection(parseInt(e.target.value))} /> @@ -580,9 +640,9 @@ const APP = () => {
- setTolerance(parseInt(e.target.value))} min="10" @@ -593,41 +653,40 @@ const APP = () => {
- - - + + + +
+ +
+ +
+ {PROJECTIONS.map(p => ( + + ))} +
{!isLocked ? ( - ) : ( <> - - @@ -649,8 +708,8 @@ const APP = () => { {lineOfSightData.conurbations.map((city, index) => ( - { setSelectedCity(city); showCityPopup(city); diff --git a/frontend/src/__tests__/App.test.jsx b/frontend/src/__tests__/App.test.jsx index cd812e3..3b7da5d 100644 --- a/frontend/src/__tests__/App.test.jsx +++ b/frontend/src/__tests__/App.test.jsx @@ -23,6 +23,9 @@ vi.mock('maplibre-gl', () => { isStyleLoaded: vi.fn(() => true), setSky: vi.fn(), setTerrain: vi.fn(), + setProjection: vi.fn(), + hasImage: vi.fn(() => false), + addImage: vi.fn(), }; return { diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css index 4d635e2..08aac9b 100644 --- a/frontend/src/styles/App.css +++ b/frontend/src/styles/App.css @@ -411,6 +411,37 @@ padding: 15px !important; } +.projection-row { + align-items: flex-start; +} + +.projection-buttons { + display: flex; + flex-wrap: wrap; + gap: 4px; + flex: 2; +} + +.projection-buttons button { + padding: 4px 8px; + font-size: 11px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.projection-buttons button:hover { + background: #e9ecef; +} + +.projection-buttons button.active-style { + background: #34495e; + color: white; + border-color: #2c3e50; +} + @media (max-width: 768px) { .app-container { flex-direction: column;