Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

# Google Maps instead of OpenStreetMap
# GOOGLE_MAPS_API_KEY= # server: Places API (New), Routes API, Elevation API
# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= # browser: Maps JavaScript API (restrict by HTTP referrer)
# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= # browser: Maps JavaScript API (restrict by HTTP referrer); also built into
# # the phone app for Google map tiles (Map Tiles API) – don't share that APK
# NEXT_PUBLIC_GOOGLE_MAP_ID=

# In-app AI buttons (not needed when planning through Claude/ChatGPT via MCP)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ Environment variables still work and take precedence over Settings: `GOOGLE_CLIE
- **Getting a trip onto the phone:** in a trip on the PC choose **Export → JSON**, then on the phone tap **Import trip file** and pick the `.tripdeck.json` from OneDrive, Google Drive, Downloads or an email. Importing a newer export of the same trip replaces it.
- **Day view:** opens on today's day during the trip. Swipe left or right to change days. It shows times, travel between stops, where you wake up and sleep, rentals in use and when they go back, booking references and prices, opening-hours and dive-safety warnings, plus the Ideas list.
- **🧭 Navigate:** opens the Google Maps app with directions to the card, in the trip's travel mode.
- **Map:** each day shows its pins and route above the plan; hide it or open it full screen. Tapping a pin finds its card and the other way round. Routes come from the trip file, so without network you still see pins and routes on a plain background.
- **Google or free map tiles:** when you build the APK with `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` in `.env.local` (and the Map Tiles API enabled for that key), the map shows Google's tiles; otherwise OpenFreeMap. The key is then inside the APK, so keep such a build to yourself and don't publish it.

The phone app only reads trips; you still plan on the PC.

Expand Down
78 changes: 54 additions & 24 deletions mobile/src/MapView.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?url";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { pinClass, type MapLayers, type Marker } from "@/lib/mapLayers";
import type { LatLng } from "@/lib/types";
import { chooseBasemap, offlineStyle, type Basemap } from "./basemap";

// MapLibre can't find its worker from inside a bundle; ship it as an asset of the app.
maplibregl.setWorkerUrl(workerUrl);

const dark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;
/** Free vector tiles, no key needed – the same maps as the desktop app without a Google key. */
const tilesStyle = () => `https://tiles.openfreemap.org/styles/${dark() ? "dark" : "liberty"}`;
/**
* Offline (or when the tile server can't be reached) the plan still has to be readable: pins and
* routes on a plain background. Route shapes come from the trip file, so they need no network.
*/
const offlineStyle = (): maplibregl.StyleSpecification => ({
version: 8,
sources: {},
layers: [{ id: "background", type: "background", paint: { "background-color": dark() ? "#0b1220" : "#e8eef3" } }],
});

const toLngLat = (p: LatLng): [number, number] => [p.lng, p.lat];

interface Props {
Expand All @@ -44,11 +32,14 @@ export function MapView({ layers, fitKey, highlightId, onPinTap }: Props) {
tapRef.current = onPinTap;
});

/** Set while Google's tiles are showing: their copyright for the area in view. */
const [googleCopyright, setGoogleCopyright] = useState<string>();

useEffect(() => {
const online = navigator.onLine;
// Start on the plain background so pins show at once; the real map replaces it when it's ready.
const map = new maplibregl.Map({
container: container.current!,
style: online ? tilesStyle() : offlineStyle(),
style: offlineStyle(),
bounds: boundsOf(layersRef.current),
fitBoundsOptions: { padding: 40, maxZoom: 14 },
attributionControl: { compact: true },
Expand All @@ -57,21 +48,50 @@ export function MapView({ layers, fitKey, highlightId, onPinTap }: Props) {
touchPitch: false,
});
map.touchZoomRotate.disableRotation();
let styled = false;
// The route lines live in the style, so add them again whenever a style (re)loads.
let waiting = false;
map.on("style.load", () => {
styled = true;
waiting = false;
addLegLayers(map);
setLegs(map, layersRef.current);
});
map.on("error", () => {
if (!styled) map.setStyle(offlineStyle());
// A tile server that can't be reached, or never answers: keep the plain background.
const giveUp = () => {
if (!waiting) return;
waiting = false;
map.setStyle(offlineStyle(), { diff: false });
};
map.on("error", giveUp);
let timer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
let basemap: Basemap | undefined;

const updateCopyright = () => {
if (!basemap?.google) return;
const b = map.getBounds();
basemap.google
.copyright({ north: b.getNorth(), south: b.getSouth(), east: b.getEast(), west: b.getWest() }, map.getZoom())
.then((c) => !cancelled && setGoogleCopyright(c || "Google"))
.catch(() => undefined);
};
map.on("moveend", updateCopyright);

void chooseBasemap().then((chosen) => {
if (cancelled || typeof chosen.style !== "string" && !chosen.google) return; // offline: stay as is
basemap = chosen;
waiting = true;
map.setStyle(chosen.style, { diff: false });
timer = setTimeout(giveUp, 8000);
if (chosen.google) {
setGoogleCopyright("Google");
updateCopyright();
}
});
// A slow network that never answers: don't leave the plan blank.
const fallback = setTimeout(() => !styled && map.setStyle(offlineStyle()), 8000);

mapRef.current = map;
return () => {
clearTimeout(fallback);
cancelled = true;
clearTimeout(timer);
map.remove();
mapRef.current = null;
};
Expand Down Expand Up @@ -121,7 +141,17 @@ export function MapView({ layers, fitKey, highlightId, onPinTap }: Props) {
if (target) mapRef.current?.easeTo({ center: toLngLat(target.position), duration: 400 });
}, [highlightId]);

return <div ref={container} className="map" />;
return (
<div className="map-wrap">
<div ref={container} className="map" />
{googleCopyright && (
<div className="google-attribution">
<img src="https://maps.gstatic.com/mapfiles/api-3/images/google_white5.png" alt="Google" height={14} />
<span>{googleCopyright}</span>
</div>
)}
</div>
);
}

function boundsOf(layers: MapLayers): maplibregl.LngLatBounds | undefined {
Expand Down
27 changes: 27 additions & 0 deletions mobile/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -580,3 +580,30 @@ p {
overflow: hidden;
text-overflow: ellipsis;
}

.map-wrap {
position: relative;
width: 100%;
height: 100%;
}
/* Google's tiles must carry Google's logo and the data copyright. */
.google-attribution {
position: absolute;
left: 6px;
bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
max-width: calc(100% - 60px);
font-size: 10px;
color: #3c4043;
pointer-events: none;
}
.google-attribution span {
padding: 0 4px;
border-radius: 3px;
background: rgb(255 255 255 / 0.75);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
110 changes: 110 additions & 0 deletions mobile/src/basemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { StyleSpecification } from "maplibre-gl";

/**
* Which map is drawn under the pins: Google's tiles when the build has a Google Maps key (the
* desktop app's browser key, with Map Tiles API enabled), otherwise OpenFreeMap, and a plain
* background when neither can be reached – pins and routes come from the trip file either way.
*/

export const GOOGLE_KEY = import.meta.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() || undefined;

const TILES = "https://tile.googleapis.com";
const SESSION_KEY = "tripdeck.googleTiles";

export const isDark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;

/** Free vector tiles, no key needed – the same maps as the desktop app without a Google key. */
export const openFreeMapStyle = () => `https://tiles.openfreemap.org/styles/${isDark() ? "dark" : "liberty"}`;

export const offlineStyle = (): StyleSpecification => ({
version: 8,
sources: {},
layers: [{ id: "background", type: "background", paint: { "background-color": isDark() ? "#0b1220" : "#e8eef3" } }],
});

/** Google's own dark look for the tiles, close to the desktop map in dark mode. */
const DARK_STYLES = [
{ stylers: [{ saturation: -40 }] },
{ elementType: "geometry", stylers: [{ color: "#1f2937" }] },
{ elementType: "labels.text.fill", stylers: [{ color: "#cbd5e1" }] },
{ elementType: "labels.text.stroke", stylers: [{ color: "#111827" }] },
{ featureType: "road", elementType: "geometry", stylers: [{ color: "#374151" }] },
{ featureType: "road.highway", elementType: "geometry", stylers: [{ color: "#4b5563" }] },
{ featureType: "water", elementType: "geometry", stylers: [{ color: "#0b1220" }] },
{ featureType: "poi.park", elementType: "geometry", stylers: [{ color: "#14532d" }] },
];

interface Session {
session: string;
/** Unix seconds. */
expiry: string;
dark: boolean;
}

/** A Map Tiles API session lasts about two weeks; reuse it instead of opening one per map. */
async function googleSession(key: string): Promise<string> {
const dark = isDark();
try {
const saved = JSON.parse(localStorage.getItem(SESSION_KEY) ?? "null") as Session | null;
if (saved && saved.dark === dark && Number(saved.expiry) * 1000 > Date.now() + 3_600_000) return saved.session;
} catch {
// no saved session
}
const [language, region = "US"] = navigator.language.split("-");
const res = await fetch(`${TILES}/v1/createSession?key=${encodeURIComponent(key)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mapType: "roadmap", language, region, ...(dark ? { styles: DARK_STYLES } : {}) }),
});
if (!res.ok) throw new Error(`Google Map Tiles: ${res.status}`);
const { session, expiry } = (await res.json()) as Session;
try {
localStorage.setItem(SESSION_KEY, JSON.stringify({ session, expiry, dark }));
} catch {
// opens a new session next time
}
return session;
}

export interface Basemap {
style: StyleSpecification | string;
/** Google's tiles must show Google's logo and the copyright for the area in view. */
google?: { copyright: (bounds: { north: number; south: number; east: number; west: number }, zoom: number) => Promise<string> };
}

export async function chooseBasemap(): Promise<Basemap> {
if (!navigator.onLine) return { style: offlineStyle() };
if (GOOGLE_KEY) {
try {
const key = encodeURIComponent(GOOGLE_KEY);
const session = await googleSession(GOOGLE_KEY);
const base = offlineStyle();
return {
style: {
...base,
sources: {
google: {
type: "raster",
tiles: [`${TILES}/v1/2dtiles/{z}/{x}/{y}?session=${session}&key=${key}`],
tileSize: 256,
maxzoom: 22,
},
},
// The plain background stays underneath, so missing tiles (offline later) still show the plan.
layers: [...base.layers, { id: "google", type: "raster", source: "google" }],
},
google: {
copyright: async (b, zoom) => {
const q = new URLSearchParams({ session, key: GOOGLE_KEY!, zoom: String(Math.round(zoom)) });
for (const [k, v] of Object.entries(b)) q.set(k, v.toFixed(5));
const res = await fetch(`${TILES}/tile/v1/viewport?${q}`);
return res.ok ? ((await res.json()) as { copyright?: string }).copyright ?? "" : "";
},
},
};
} catch {
// key without Map Tiles API, quota, network hiccup: fall back to the free map
}
}
return { style: openFreeMapStyle() };
}
5 changes: 5 additions & 0 deletions mobile/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
/** The desktop app's Google Maps browser key, baked in at build time when present. */
readonly NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?: string;
}
4 changes: 4 additions & 0 deletions mobile/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export default defineConfig({
plugins: [react()],
// Shares the trip logic in lib/ with the desktop app, under the same "@/lib/..." imports.
resolve: { alias: { "@": here("../") } },
// The desktop app's browser key (.env.local) also gives the phone Google's map tiles. Only that one
// variable is exposed; without it the app uses OpenFreeMap.
envDir: here("../"),
envPrefix: ["VITE_", "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY"],
build: { outDir: here("./dist"), emptyOutDir: true, target: "es2022" },
// Reachable from the emulator (10.0.2.2) and phones on the same Wi-Fi for live reload.
server: { host: true, port: 5173, strictPort: true },
Expand Down
Loading