File size: 3,618 Bytes
766d85d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import type { Place } from '../../types'

// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
// no equivalent, so these produce the same card as an HTML string for a
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.

type PlaceWithCategory = Place & {
  category_color?: string | null
  category_icon?: string | null
  category_name?: string | null
}

function esc(s: string | null | undefined): string {
  if (!s) return ''
  return String(s)
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

// Render a lucide category icon to an inline SVG string in the given colour.
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
  const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
  try {
    return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
  } catch {
    return ''
  }
}

// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
// into an <img src> — everything else is a fetch seed, not a displayable URL.
function isDisplayablePhoto(url: string | null | undefined): url is string {
  return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
}

const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'

/** Hover-popup card for a planned place: optional photo, name, category row, address. */
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
  const img = isDisplayablePhoto(photoUrl)
    ? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
    : ''
  const category =
    place.category_name && place.category_icon
      ? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
      : ''
  const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
  return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
}

/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
export function buildPoiPopupHtml(poi: Poi): string {
  const cat = POI_CATEGORY_BY_KEY[poi.category]
  const color = cat?.color || '#6b7280'
  const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
  const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
  const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
  return `${CARD_OPEN}${head}${address}</div>`
}