Skip to content

Commit

Permalink
feat(UI/BaseMaps): Allow user to change base layer types using the ba…
Browse files Browse the repository at this point in the history
…se layer nav bar [2024-10-25]
  • Loading branch information
CHRISCARLON committed Oct 25, 2024
1 parent e04ee93 commit 8799970
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 118 deletions.
164 changes: 164 additions & 0 deletions gridwalk-ui/src/app/project/components/mapInit/mapInit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";

// Constants
const REFRESH_THRESHOLD = 30; // Refresh 30 seconds before expiry
const DEFAULT_CENTER: [number, number] = [-0.1278, 51.5074];
const DEFAULT_ZOOM = 11;
const MIN_ZOOM = 6;
const defaultStyleUrl = "/OS_VTS_3857_Light.json";

export interface TokenData {
access_token: string;
issued_at: number;
expires_in: number;
}

export interface MapConfig {
center?: [number, number];
zoom?: number;
styleUrl?: string;
}

export interface UseMapInitResult {
mapContainer: React.RefObject<HTMLDivElement>;
map: React.RefObject<maplibregl.Map | null>;
mapError: string | null;
}

// Helper functions
const getToken = (): TokenData => {
const xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:3001/os-token", false);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send();
if (xhr.status !== 200) {
throw new Error(`HTTP error! status: ${xhr.status}`);
}
return JSON.parse(xhr.responseText);
};

const isTokenValid = (tokenData: TokenData | null): boolean => {
if (!tokenData) return false;
const currentTime = Date.now();
const issuedAt = Number(tokenData.issued_at);
const expiresIn = Number(tokenData.expires_in) * 1000;
const expirationTime = issuedAt + expiresIn - REFRESH_THRESHOLD * 1000;
return currentTime < expirationTime;
};

export const useMapInit = (config?: MapConfig): UseMapInitResult => {
const mapContainer = useRef<HTMLDivElement | null>(null);
const map = useRef<maplibregl.Map | null>(null);
const [mapError, setMapError] = useState<string | null>(null);
const tokenRef = useRef<TokenData | null>(null);

// Initial map setup
useEffect(() => {
if (map.current || !mapContainer.current) return;

const initializeMap = async () => {
try {
// Explicitly define the style URL as a string
const styleUrl =
typeof config?.styleUrl === "string"
? config.styleUrl
: defaultStyleUrl;
const response = await fetch(styleUrl);
if (!response.ok) {
throw new Error(`Failed to load style: ${response.status}`);
}
const styleJson = await response.json();

const mapInstance = new maplibregl.Map({
container: mapContainer.current!,
style: styleJson,
center: config?.center ?? DEFAULT_CENTER,
zoom: config?.zoom ?? DEFAULT_ZOOM,
minZoom: MIN_ZOOM,
maxBounds: [
[-10.7, 49.5],
[1.9, 61.0],
],
transformRequest: (url) => {
if (url.startsWith("https://api.os.uk")) {
if (!isTokenValid(tokenRef.current)) {
try {
tokenRef.current = getToken();
} catch (error) {
console.error("Failed to fetch token:", error);
return {
url: url,
headers: {},
};
}
}
return {
url: url,
headers: {
Authorization: `Bearer ${tokenRef.current?.access_token}`,
"Content-Type": "application/json",
},
};
} else if (url.startsWith(window.location.origin)) {
return {
url: url,
credentials: "same-origin" as const,
};
}
},
});

mapInstance.addControl(new maplibregl.NavigationControl(), "top-right");
map.current = mapInstance;

mapInstance.on("load", () => {
console.log("Map loaded successfully");
});
} catch (error) {
console.error("Error initializing map:", error);
setMapError(
error instanceof Error
? error.message
: "Unknown error initializing map",
);
}
};

initializeMap();

return () => {
if (map.current) {
map.current.remove();
}
};
}, [config?.center, config?.zoom]);

// Handle style changes
useEffect(() => {
if (!map.current || typeof config?.styleUrl !== "string") return;

const updateMapStyle = async () => {
try {
const response = await fetch(config.styleUrl as string);
if (!response.ok) {
throw new Error(`Failed to load style: ${response.status}`);
}
const styleJson = await response.json();
map.current?.setStyle(styleJson);
} catch (error) {
console.error("Error updating map style:", error);
setMapError(
error instanceof Error
? error.message
: "Unknown error updating map style",
);
}
};

updateMapStyle();
}, [config?.styleUrl]);

return { mapContainer, map, mapError };
};
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const BaseLayerNav: React.FC<BaseLayerNavProps> = ({
const getSelectedStyle = (id: string) => {
switch (id) {
case "light":
return "bg-blue-300 text-white"; // Light blue
return "bg-blue-400 text-white"; // Light blue
case "dark":
return "bg-blue-800 text-white"; // Dark blue
case "car":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const MapEdits: React.FC<MapEditsProps> = ({
p-2 rounded-md transition-colors
${
selectedEditItem?.id === item.id
? "bg-blue-500 text-white"
? "bg-blue-400 text-white"
: "text-white-600 hover:bg-blue-400"
}
`}
Expand Down
150 changes: 34 additions & 116 deletions gridwalk-ui/src/app/project/page.tsx
Original file line number Diff line number Diff line change
@@ -1,133 +1,45 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import React, { useState } from "react";
import { useMapInit } from "./components/mapInit/mapInit";
import MainMapNavigation from "./components/navBars/mainMapNavigation";
import { MainMapNav } from "./components/navBars/types";
import MapEditNavigation from "./components/navBars/mapEditNavigation";
import { MapEditNav } from "./components/navBars/types";
import BaseLayerNavigation from "./components/navBars/baseLayerNavigation";
import { BaseEditNav } from "./components/navBars/types";

export interface TokenData {
access_token: string;
issued_at: number;
expires_in: number;
}

const REFRESH_THRESHOLD = 30; // Refresh 30 seconds before expiry

// Synchronous token fetching
const getToken = (): TokenData => {
const xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:3001/os-token", false); // false makes the request synchronous
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send();

if (xhr.status !== 200) {
throw new Error(`HTTP error! status: ${xhr.status}`);
}

return JSON.parse(xhr.responseText);
};

const isTokenValid = (tokenData: TokenData | null): boolean => {
if (!tokenData) return false;

const currentTime = Date.now();
const issuedAt = Number(tokenData.issued_at);
const expiresIn = Number(tokenData.expires_in) * 1000;
const expirationTime = issuedAt + expiresIn - REFRESH_THRESHOLD * 1000;

return currentTime < expirationTime;
// Map styles configuration
const MAP_STYLES = {
light: "/OS_VTS_3857_Light.json",
dark: "/OS_VTS_3857_Dark.json",
car: "/OS_VTS_3857_Road.json",
} as const;

// Initial map configuration
const INITIAL_MAP_CONFIG = {
center: [-0.1278, 51.5074] as [number, number],
zoom: 11,
};

export default function Project() {
const mapContainer = useRef<HTMLDivElement | null>(null);
const map = useRef<maplibregl.Map | null>(null);
// State management
const [selectedItem, setSelectedItem] = useState<MainMapNav | null>(null);
const [selectedEditItem, setSelectedEditItem] = useState<MapEditNav | null>(
null,
);
const [selectedBaseItem, setSelectedBaseItem] = useState<BaseEditNav | null>(
null,
);
const [mapError, setMapError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const tokenRef = useRef<TokenData | null>(null);

// Initialize map
useEffect(() => {
if (map.current || !mapContainer.current) return;
const [currentStyle, setCurrentStyle] = useState<string>(MAP_STYLES.light);

const initializeMap = async () => {
try {
const styleUrl = "http://localhost:3000/OS_VTS_3857_Light.json";
const response = await fetch(styleUrl);
if (!response.ok) {
throw new Error(`Failed to load style: ${response.status}`);
}
const styleJson = await response.json();

const mapInstance = new maplibregl.Map({
container: mapContainer.current!,
style: styleJson,
center: [-0.1278, 51.5074],
zoom: 11,
transformRequest: (url) => {
if (url.startsWith("https://api.os.uk")) {
// Check if we need a new token
if (!isTokenValid(tokenRef.current)) {
try {
tokenRef.current = getToken();
} catch (error) {
console.error("Failed to fetch token:", error);
return {
url: url,
headers: {}, // Return empty headers if token fetch fails
};
}
}

return {
url: url,
headers: {
Authorization: `Bearer ${tokenRef.current?.access_token}`,
"Content-Type": "application/json",
},
};
} else if (url.startsWith(window.location.origin)) {
return {
url: url,
credentials: "same-origin" as const,
};
}
},
});

mapInstance.addControl(new maplibregl.NavigationControl(), "top-right");
map.current = mapInstance;

mapInstance.on("load", () => {
console.log("Map loaded successfully");
});
} catch (error) {
console.error("Error initializing map:", error);
setMapError(
error instanceof Error
? error.message
: "Unknown error initializing map",
);
}
};

initializeMap();

return () => {
map.current?.remove();
};
}, []);
// Map initialization
const { mapContainer, mapError } = useMapInit({
...INITIAL_MAP_CONFIG,
styleUrl: currentStyle,
});

// Event handlers
const handleNavItemClick = (item: MainMapNav) => {
setSelectedItem(item);
setIsModalOpen(true);
Expand All @@ -141,39 +53,45 @@ export default function Project() {
setSelectedBaseItem(item);
switch (item.id) {
case "light":
// Set light blue style
setCurrentStyle(MAP_STYLES.light);
break;
case "dark":
// Set dark blue style
setCurrentStyle(MAP_STYLES.dark);
break;
case "car":
// Set purple style
setCurrentStyle(MAP_STYLES.car);
break;
}
};

const handleModalClose = () => {
setIsModalOpen(false);
setSelectedItem(null);
};

return (
<div className="w-full h-screen relative">
{/* Error display */}
{mapError && (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-red-500 text-white px-4 py-2 rounded shadow-lg">
{mapError}
</div>
)}

{/* Map container */}
<div className="absolute inset-0 pl-10">
<div ref={mapContainer} className="h-full w-full" />
</div>

{/* Navigation components */}
<MapEditNavigation
onEditItemClick={handleEditItemClick}
selectedEditItem={selectedEditItem}
/>

<MainMapNavigation
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedItem(null);
}}
onClose={handleModalClose}
onNavItemClick={handleNavItemClick}
selectedItem={selectedItem}
>
Expand Down

0 comments on commit 8799970

Please sign in to comment.