feat(planner): open places and day routes in CoMaps

Add CoMaps, the offline OpenStreetMap navigator, alongside the existing
map integrations. A geolocated place gains a CoMaps entry in its
navigation menu, and a day's route toolbar gains a button that hands the
day's bookended stops to CoMaps in that day's travel mode.

CoMaps' route link carries only a start and a destination, so a two-stop
day goes as a route and a longer one as named pins rather than silently
losing the stops in between. Links use the https form so a device without
CoMaps installed lands on comaps.at instead of a dead scheme.

The hotel-bookend logic behind the Google Maps export was duplicated
between desktop and mobile; both now share dayExportStops so the two
links cannot disagree about which hotel legs are real.

Addresses discussion #1904
This commit is contained in:
Konstantinos Thermos
2026-08-14 10:24:14 +03:00
parent 813b9886b3
commit 8065af28ef
59 changed files with 429 additions and 52 deletions
@@ -9,6 +9,7 @@ import {
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
generateCoMapsUrl,
parsePluginProfile,
withHotelBookends,
} from './RouteCalculator'
@@ -572,3 +573,43 @@ describe('calculateRouteWithLegs cache eviction', () => {
expect(hits).toBe(1)
})
})
// ── generateCoMapsUrl ─────────────────────────────────────────────────────────
describe('generateCoMapsUrl', () => {
const eiffel = { lat: 48.8584, lng: 2.2945, name: 'Eiffel Tower' }
const louvre = { lat: 48.8606, lng: 2.3376, name: 'Louvre' }
const notre = { lat: 48.8530, lng: 2.3499, name: 'Notre-Dame' }
it('FE-COMP-ROUTECALCULATOR-016: no stops, no link', () => {
expect(generateCoMapsUrl([])).toBeNull()
})
it('FE-COMP-ROUTECALCULATOR-017: two stops build a real route, mode included', () => {
expect(generateCoMapsUrl([eiffel, louvre], 'walking')).toBe(
'https://comaps.at/route?sll=48.8584,2.2945&saddr=Eiffel%20Tower'
+ '&dll=48.8606,2.3376&daddr=Louvre&type=pedestrian',
)
})
it('FE-COMP-ROUTECALCULATOR-018: TREK profiles map onto CoMaps travel modes', () => {
expect(generateCoMapsUrl([eiffel, louvre], 'driving')).toContain('type=vehicle')
expect(generateCoMapsUrl([eiffel, louvre], 'cycling')).toContain('type=bicycle')
// A plugin router has no CoMaps equivalent, so it falls back rather than
// sending a mode CoMaps would reject.
expect(generateCoMapsUrl([eiffel, louvre], 'plugin:ev/fast')).toContain('type=vehicle')
})
it('FE-COMP-ROUTECALCULATOR-019: three stops go as pins, because a route link would drop the middle', () => {
const url = generateCoMapsUrl([eiffel, louvre, notre])!
expect(url).toBe(
'https://comaps.at/map?v=1&ll=48.8584,2.2945&n=Eiffel%20Tower'
+ '&ll=48.8606,2.3376&n=Louvre&ll=48.853,2.3499&n=Notre-Dame',
)
})
it('FE-COMP-ROUTECALCULATOR-020: a single stop is a pin, and a nameless one is labelled by position', () => {
expect(generateCoMapsUrl([{ lat: 48.85, lng: 2.35 }]))
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
})
})
@@ -116,6 +116,42 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
return `https://www.google.com/maps/dir/${stops}`
}
/** A stop that can carry its name into a deep link that has somewhere to put one. */
export type NamedWaypoint = Waypoint & { name?: string | null }
/** TREK's route profiles in CoMaps' vocabulary; a plugin profile has no equivalent and drives. */
function coMapsRouteType(profile: RouteProfileKey): string {
if (profile === 'walking') return 'pedestrian'
if (profile === 'cycling') return 'bicycle'
return 'vehicle'
}
/**
* Open a day's stops in CoMaps for offline navigation (#1904).
*
* CoMaps has two links and they trade against each other. `route` builds real
* turn-by-turn in the given travel mode but takes a start and a destination and
* nothing between them; `map` takes any number of named pins but routes nothing.
* So a two-stop day goes as a route — everything it has fits, mode included —
* and a longer one goes as pins, because handing over the whole day and letting
* CoMaps route leg by leg beats quietly dropping the middle of someone's plan.
* A day that needs the full itinerary as one navigable track has the GPX export.
*
* https rather than `cm://` for the same reason as `getCoMapsUrlForPlace`.
*/
export function generateCoMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null {
const valid = places.filter((p) => p.lat != null && p.lng != null)
if (valid.length === 0) return null
const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || `${p.lat},${p.lng}`)
if (valid.length === 2) {
const [from, to] = valid
return `https://comaps.at/route?sll=${from.lat},${from.lng}&saddr=${label(from)}`
+ `&dll=${to.lat},${to.lng}&daddr=${label(to)}&type=${coMapsRouteType(profile)}`
}
const pins = valid.map((p) => `ll=${p.lat},${p.lng}&n=${label(p)}`).join('&')
return `https://comaps.at/map?v=1&${pins}`
}
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine.
function sqDist(a: Waypoint, b: Waypoint): number {
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2
@@ -14,7 +14,7 @@ import {
buildUser, buildTrip, buildDay, buildPlace, buildCategory, buildAssignment, buildDayNote, buildReservation,
} from '../../../tests/helpers/factories'
import type { Accommodation, Reservation } from '../../types'
import { calculateRouteWithLegs, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import { calculateRouteWithLegs, generateCoMapsUrl, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import DayPlanSidebar from './DayPlanSidebar'
import { makeMarkerDraggable } from '../Map/markerDrag'
@@ -60,6 +60,7 @@ vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(un
vi.mock('../Map/RouteCalculator', () => ({
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
optimizeRoute: vi.fn().mockImplementation((places) => places),
// One leg per waypoint gap; the connector between two stops reads distanceText.
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) => Promise.resolve({
@@ -190,6 +191,7 @@ beforeEach(() => {
})),
}))
vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...')
vi.mocked(generateCoMapsUrl).mockReturnValue('https://comaps.at/...')
sessionStorage.clear()
localStorage.clear()
// Cost totals fetch FX rates for their base currency; keep the suite hermetic.
@@ -3354,15 +3356,48 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
await user.click(screen.getByRole('button', { name: 'Open in Google Maps' }))
expect(vi.mocked(generateGoogleMapsUrl)).toHaveBeenCalledWith([
{ lat: 48.85, lng: 2.35 },
{ lat: 48.86, lng: 2.34 },
{ lat: 48.87, lng: 2.33 },
{ lat: 48.85, lng: 2.35 },
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
])
expect(openSpy).toHaveBeenCalledWith('https://maps.google.com/...', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('FE-PLANNER-DAYPLAN-168b: the CoMaps export hands over the same stops in the day travel mode', async () => {
const user = userEvent.setup()
const { generateCoMapsUrl } = await import('../Map/RouteCalculator')
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2', default_transport_mode: 'walking' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const accommodations: Accommodation[] = [{
id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12,
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
}]
const assignments = {
'11': [
buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }),
buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
await user.click(screen.getByRole('button', { name: 'Open in CoMaps' }))
// Same bookended stop list as the Google export — one source, so the two
// cannot drift — plus the day's own mode, which CoMaps can actually route in.
expect(vi.mocked(generateCoMapsUrl)).toHaveBeenCalledWith([
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
], 'walking')
expect(openSpy).toHaveBeenCalledWith('https://comaps.at/...', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => {
const user = userEvent.setup()
const { daysApi } = await import('../../api/client')
@@ -4,9 +4,9 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@@ -1382,6 +1382,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
(transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng)
)
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer
/**
* The day's located stops in planned order, bookended by the accommodation
* the same way the drawn map route is (routeBookends is null when "optimize
* from accommodation" is off), so hotels aren't dropped from an exported
* route (#1372) — but only when the leg is real: no hotel prepended before an
* early check-in-day stop, none appended after a post-check-out stop (#1465).
* Names ride along for the deep links that can label a pin with one.
*/
const dayExportStops = (): NamedWaypoint[] => {
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
const lastAssignment = dayStops[dayStops.length - 1]
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
}
// Is this day's inline route currently on? Mobile toggles it per day (its
// own expandedRouteDayIds entry); desktop uses the global Route toggle on
// the selected day (#1374).
@@ -2468,24 +2490,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
// Bookend the Google Maps route with the day's accommodation the
// same way the drawn map route does (routeBookends is null when
// "optimize from accommodation" is off), so hotels aren't dropped
// from the exported route (#1372) — but only when the leg is real:
// no hotel prepended before an early check-in-day stop, none appended
// after a post-check-out stop (#1465).
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! }))
const firstStop = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
const lastAssignment = dayStops[dayStops.length - 1]
const lastStop = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, firstStop)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, lastStop)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
@@ -2504,6 +2509,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
</button>
{/* The same day, handed to CoMaps for offline navigation (#1904). The
day's own travel mode rides along, so the route it builds walks
when the plan walks. */}
<button
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
title={t('planner.openCoMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<Compass size={14} strokeWidth={2} />
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 8, border: 'none',
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import type { Place } from '../../types'
// FE-PLANNER-COMAPS-001 to FE-PLANNER-COMAPS-004
function place(overrides: Partial<Place> = {}): Place {
return { name: 'Stephansdom', lat: 48.2038, lng: 16.3616, ...overrides } as unknown as Place
}
describe('getCoMapsUrlForPlace', () => {
it('FE-PLANNER-COMAPS-001: drops a named pin at the place', () => {
expect(getCoMapsUrlForPlace(place()))
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616&n=Stephansdom')
})
it('FE-PLANNER-COMAPS-002: links over https, so an install-less device lands somewhere', () => {
// The cm:// scheme opens the app but dead-ends in a browser without it;
// comaps.at is both the app link and the install page.
expect(getCoMapsUrlForPlace(place())).toMatch(/^https:\/\/comaps\.at\//)
})
it('FE-PLANNER-COMAPS-003: a nameless place still gets its pin', () => {
expect(getCoMapsUrlForPlace(place({ name: '' })))
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616')
})
it('FE-PLANNER-COMAPS-004: without coordinates there is nothing to pin', () => {
expect(getCoMapsUrlForPlace(place({ lat: null, lng: null }))).toBeNull()
expect(getCoMapsUrlForPlace(null)).toBeNull()
})
})
@@ -0,0 +1,22 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
/**
* Open a place in CoMaps, the privacy-focused offline OpenStreetMap navigator.
* Requested in discussion #1904.
*
* The https form rather than the `cm://` scheme CoMaps also answers to: both open
* the app when it is installed, but only this one has somewhere to land when it is
* not — comaps.at, which is the install page. A PWA cannot ask whether an Android
* app exists, so the fallback has to be the link itself.
*
* Coordinates are required: CoMaps places the pin from `ll` and uses `n` only to
* label it, so a name on its own has nothing to attach to.
*/
export function getCoMapsUrlForPlace(place: PlaceLike | null | undefined): string | null {
if (!place || place.lat == null || place.lng == null) return null
const name = place.name?.trim()
const n = name ? `&n=${encodeURIComponent(name)}` : ''
return `https://comaps.at/map?v=1&ll=${place.lat},${place.lng}${n}`
}
@@ -1,4 +1,3 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
import type { Place } from '../../types'
@@ -31,7 +30,7 @@ afterEach(() => { vi.restoreAllMocks() })
describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
expect(targets[0].label).toBe('Google Maps')
})
@@ -59,7 +58,7 @@ describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm'])
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm', 'comaps'])
} finally { restore() }
})
@@ -67,7 +66,7 @@ describe('getNavigationTargets', () => {
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
try {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
// q next to ll labels the pin rather than searching blindly.
expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616')
} finally { restore() }
@@ -88,6 +87,13 @@ describe('getNavigationTargets', () => {
expect(targets.map(t => t.id)).toEqual(['google', 'osm'])
})
it('FE-PLANNER-NAV-007b: CoMaps needs the position, so a place without one loses it', () => {
// CoMaps drops a pin from `ll` and only labels it with `n` — a name alone
// has nothing to attach to, the same reason Waze and Apple Maps drop out.
const targets = getNavigationTargets(place({ lat: null, lng: null, name: 'Stephansdom' }))
expect(targets.map(t => t.id)).not.toContain('comaps')
})
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
expect(getNavigationTargets(null)).toEqual([])
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
@@ -95,7 +101,7 @@ describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => {
const targets = getNavigationTargets(place({ name: '' }))
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
})
})
@@ -1,10 +1,11 @@
import type { AssignmentPlace, Place } from '../../types'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm'
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps'
export interface NavigationTarget {
id: NavigationAppId
@@ -86,6 +87,11 @@ export function getNavigationTargets(
const osmUrl = getOpenStreetMapUrlForPlace(place)
if (osmUrl) targets.push({ id: 'osm', label: 'OpenStreetMap', url: osmUrl })
// Last, beside the OSM entry it shares a map source with: CoMaps is the offline
// end of this list, the one that still works with no signal.
const coMapsUrl = getCoMapsUrlForPlace(place)
if (coMapsUrl) targets.push({ id: 'comaps', label: 'CoMaps', url: coMapsUrl })
return targets
}