Skip to content

Commit

Permalink
Add google maps support (#1)
Browse files Browse the repository at this point in the history
* create types.ts

* store api keys in the AppContext

* Add "clear all keys" button to ApiKeysDialog

* define google map sources

* Update Navbar.tsx

* render google maps
  • Loading branch information
caspg authored Nov 17, 2024
1 parent a210911 commit fb2a856
Show file tree
Hide file tree
Showing 10 changed files with 296 additions and 129 deletions.
22 changes: 21 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@vis.gl/react-google-maps": "^1.4.0",
"@vis.gl/react-maplibre": "^1.0.0-alpha.4",
"autoprefixer": "^10.4.20",
"lodash.throttle": "^4.1.1",
Expand Down
39 changes: 28 additions & 11 deletions src/components/ApiKeysDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
import { useState } from "react";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { useModalClose } from "../hooks/useModalClose";
import { useApp } from "../contexts/AppContext";

interface ApiKeysDialogProps {
onClose: () => void;
}

interface ApiKeys {
googleMaps?: string;
}

export function ApiKeysDialog({ onClose }: ApiKeysDialogProps) {
const [apiKeys, setApiKeys] = useLocalStorage<ApiKeys>("apiKeys", {});
const [googleMapsKey, setGoogleMapsKey] = useState(apiKeys.googleMaps || "");
const { state, dispatch } = useApp();
const [googleMapsKey, setGoogleMapsKey] = useState(
state.apiKeys?.googleMaps || ""
);
const { handleOverlayClick } = useModalClose(onClose);

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiKeys((prev) => ({
...prev,
googleMaps: googleMapsKey,
}));
dispatch({
type: "UPDATE_API_KEYS",
payload: {
...state.apiKeys,
googleMaps: googleMapsKey,
},
});
onClose();
};

const handleClearKeys = () => {
dispatch({
type: "UPDATE_API_KEYS",
payload: {},
});
setGoogleMapsKey("");
};

return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
Expand Down Expand Up @@ -59,6 +68,7 @@ export function ApiKeysDialog({ onClose }: ApiKeysDialogProps) {
Google Maps API Key
<input
type="password"
autoComplete="off"
value={googleMapsKey}
onChange={(e) => setGoogleMapsKey(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 shadow-sm p-2"
Expand All @@ -71,6 +81,13 @@ export function ApiKeysDialog({ onClose }: ApiKeysDialogProps) {
</div>

<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleClearKeys}
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded"
>
Clear All Keys
</button>
<button
type="button"
onClick={onClose}
Expand Down
111 changes: 84 additions & 27 deletions src/components/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {
Layer,
Source,
} from "@vis.gl/react-maplibre";
import { Map as GoogleMap, APIProvider } from "@vis.gl/react-google-maps";
import { MapState } from "../types";
import { MAP_SOURCES } from "../constants/mapSources";
import { GOOGLE_SOURCES, MAP_SOURCES } from "../constants/mapSources";
import { useApp } from "../contexts/AppContext";
import { SourceSpecification } from "maplibre-gl";
import { MapContextMenu } from "./MapContextMenu";
Expand Down Expand Up @@ -35,7 +36,10 @@ export function Map({
onViewStateChange,
}: MapProps) {
const { state } = useApp();
const source = MAP_SOURCES[sourceId] || state.customSources[sourceId];
const source =
MAP_SOURCES[sourceId] ||
state.customSources[sourceId] ||
GOOGLE_SOURCES[sourceId];
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
show: false,
x: 0,
Expand Down Expand Up @@ -78,6 +82,50 @@ export function Map({
});
}

// Handle Google Maps source
if (source.type === "google") {
if (!state.apiKeys?.googleMaps) {
return (
<div className="flex items-center justify-center w-full h-full">
Google Maps API key is required
</div>
);
}

return (
<APIProvider apiKey={state.apiKeys.googleMaps}>
<div className="relative w-full h-full">
<GoogleMap
disableDefaultUI={true}
center={{
lat: effectiveMapState.center[1],
lng: effectiveMapState.center[0],
}}
zoom={effectiveMapState.zoom}
mapTypeId={source.mapType}
heading={effectiveMapState.bearing}
tilt={effectiveMapState.pitch}
gestureHandling="greedy"
onCameraChanged={({ detail: { center, zoom, heading, tilt } }) => {
const newState: Partial<MapState> = {
center: [center.lng, center.lat],
zoom,
bearing: heading ?? effectiveMapState.bearing,
pitch: tilt ?? effectiveMapState.pitch,
};

if (synchronized) {
onMapChange(newState);
} else {
onViewStateChange(newState);
}
}}
/>
</div>
</APIProvider>
);
}

if (source.type === "raster") {
const overlayUrls = source.overlayUrls || [];
const overlaySources = overlayUrls.reduce(
Expand Down Expand Up @@ -147,20 +195,20 @@ export function Map({
);
}

return (
<div className="relative w-full h-full">
<MapGL
style={{ width: "100%", height: "100%" }}
maxZoom={20}
onMove={handleMove}
onContextMenu={handleContextMenu}
{...effectiveMapState}
longitude={effectiveMapState.center[0]}
latitude={effectiveMapState.center[1]}
mapStyle={source.style}
>
{source.type === "vector" &&
source.overlays?.map((overlay) => (
if (source.type === "vector") {
return (
<div className="relative w-full h-full">
<MapGL
style={{ width: "100%", height: "100%" }}
maxZoom={20}
onMove={handleMove}
onContextMenu={handleContextMenu}
{...effectiveMapState}
longitude={effectiveMapState.center[0]}
latitude={effectiveMapState.center[1]}
mapStyle={source.style}
>
{source.overlays?.map((overlay) => (
<Source
key={overlay.sourceId}
id={overlay.sourceId}
Expand All @@ -172,17 +220,26 @@ export function Map({
))}
</Source>
))}
{contextMenu.show && (
<MapContextMenu
x={contextMenu.x}
y={contextMenu.y}
lat={contextMenu.lat}
lng={contextMenu.lng}
zoom={effectiveMapState.zoom}
onClose={() => setContextMenu((prev) => ({ ...prev, show: false }))}
/>
)}
</MapGL>
{contextMenu.show && (
<MapContextMenu
x={contextMenu.x}
y={contextMenu.y}
lat={contextMenu.lat}
lng={contextMenu.lng}
zoom={effectiveMapState.zoom}
onClose={() =>
setContextMenu((prev) => ({ ...prev, show: false }))
}
/>
)}
</MapGL>
</div>
);
}

return (
<div className="flex items-center justify-center w-full h-full">
Unknown source type
</div>
);
}
18 changes: 11 additions & 7 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,17 @@ export function Navbar() {
<div className="flex flex-col md:flex-row">
{/* Title Row (mobile) / Left Section (desktop) */}
<div className="h-12 md:h-14 px-4 flex items-center justify-between md:justify-start md:gap-4 border-b md:border-b-0 border-slate-100">
<h1 className="text-lg font-semibold text-slate-900">MapMatrix</h1>
<a
className="bg-brand/5 text-brand rounded-full px-3 py-1 text-sm"
href="https://veloplanner.com"
>
by VeloPlanner
</a>
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-slate-900">
MapMatrix
</h1>
<a
className="bg-brand/5 text-brand rounded-full px-3 py-1 text-sm"
href="https://veloplanner.com"
>
by VeloPlanner
</a>
</div>

<a
className="p-1 rounded hover:opacity-80"
Expand Down
36 changes: 28 additions & 8 deletions src/components/SourceSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MAP_SOURCES } from "../constants/mapSources";
import { MAP_SOURCES, GOOGLE_SOURCES } from "../constants/mapSources";
import { useApp } from "../contexts/AppContext";

interface SourceSelectorProps {
Expand All @@ -11,6 +11,7 @@ export function SourceSelector({
currentSourceId,
}: SourceSelectorProps) {
const { state, dispatch } = useApp();
const isGoogleMapsKeyMissing = !state.apiKeys?.googleMaps;

return (
<div className="flex items-center gap-2">
Expand All @@ -24,13 +25,6 @@ export function SourceSelector({
}
className="text-sm border border-slate-200 rounded px-2 py-1 bg-white hover:bg-slate-50"
>
<optgroup label="Built-in Sources">
{Object.values(MAP_SOURCES).map((source) => (
<option key={source.id} value={source.id}>
{source.name}
</option>
))}
</optgroup>
{Object.keys(state.customSources).length > 0 && (
<optgroup label="Custom Sources">
{Object.values(state.customSources).map((source) => (
Expand All @@ -40,6 +34,32 @@ export function SourceSelector({
))}
</optgroup>
)}

<optgroup label="Built-in Sources">
{Object.values(MAP_SOURCES).map((source) => (
<option key={source.id} value={source.id}>
{source.name}
</option>
))}
</optgroup>

<optgroup
label={
isGoogleMapsKeyMissing
? "Google Maps (add key to select)"
: "Google Maps"
}
>
{Object.values(GOOGLE_SOURCES).map((source) => (
<option
key={source.id}
value={source.id}
disabled={isGoogleMapsKeyMissing}
>
{source.name}
</option>
))}
</optgroup>
</select>
</div>
);
Expand Down
27 changes: 27 additions & 0 deletions src/constants/mapSources.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import { MapSource } from "../types";
import { VELO_ROUTES_OVERLAY } from "./veloRoutesLayers";

export const GOOGLE_SOURCES: Record<string, MapSource> = {
googleRoadmap: {
id: "googleRoadmap",
name: "Google Maps",
type: "google",
mapType: "roadmap",
},
googleSatellite: {
id: "googleSatellite",
name: "Google Satellite",
type: "google",
mapType: "satellite",
},
googleHybrid: {
id: "googleHybrid",
name: "Google Hybrid",
type: "google",
mapType: "hybrid",
},
googleTerrain: {
id: "googleTerrain",
name: "Google Terrain",
type: "google",
mapType: "terrain",
},
} as const;

export const MAP_SOURCES: Record<string, MapSource> = {
veloplanner: {
id: "veloplanner",
Expand Down
Loading

0 comments on commit fb2a856

Please sign in to comment.