feat(renderer): interactive map with Leaflet + OpenStreetMap (M10.3)

- MapRenderer.vue: lazy-loads Leaflet, renders OSM tiles
- Orange marker pins for all places with coordinates
- Place list sidebar on desktop, popup on click
- "View on map" button in chat messages with places
- Integrated into content panel via openMapView()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:52:16 +00:00
co-authored by Claude Opus 4.6
parent 7a3d874f5e
commit aec87a347c
6 changed files with 189 additions and 2 deletions
@@ -242,6 +242,13 @@
>
View all {{ inlinePlaces.length }} places
</button>
<button
v-if="inlinePlaces.length > 0 && inlinePlaces.some(p => p.lat != null)"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openMapView(inlinePlaces)"
>
View on map
</button>
<button
v-else-if="inlineSongs.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@@ -371,7 +378,7 @@ function submitEdit() {
isEditing.value = false
editContent.value = ''
}
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, openLongFormArticle, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, openLongFormArticle, openMapView, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const codeContext = useCodeContext()
const isUser = computed(() => props.message.role === 'user')
@@ -99,6 +99,11 @@
:title="pdfUrl.title"
@back="closePdfViewer"
/>
<MapRenderer
v-else-if="mapPlaces.length > 0"
:places="mapPlaces"
@back="closeMapView"
/>
<!-- Grid views by active tab -->
<component
@@ -184,6 +189,7 @@ import NewsGrid from './NewsGrid.vue'
import ArticleDetail from './ArticleDetail.vue'
import ArticleReader from '@/components/renderers/ArticleReader.vue'
import PdfViewer from '@/components/renderers/PdfViewer.vue'
import MapRenderer from '@/components/renderers/MapRenderer.vue'
import MagazineGrid from './MagazineGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
@@ -236,11 +242,13 @@ const {
closeLongFormArticle,
pdfUrl,
closePdfViewer,
mapPlaces,
closeMapView,
closePanel,
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value)
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
)
const windowWidth = ref(window.innerWidth)
@@ -0,0 +1,139 @@
<template>
<div class="map-renderer h-full flex flex-col">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-4 py-2 bg-black/60 backdrop-blur-md border-b border-white/5 shrink-0">
<button
class="w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
title="Back"
@click="$emit('back')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="flex-1 text-xs text-white/40">{{ places.length }} place{{ places.length === 1 ? '' : 's' }}</span>
</div>
<!-- Map + place list -->
<div class="flex-1 flex overflow-hidden">
<!-- Map container -->
<div ref="mapContainer" class="flex-1 min-h-0" />
<!-- Place list sidebar (desktop only) -->
<aside
v-if="places.length > 1"
class="hidden md:flex flex-col w-56 border-l border-white/5 overflow-y-auto scrollbar-hide"
>
<button
v-for="(place, i) in places"
:key="place.id"
class="text-left px-3 py-2 border-b border-white/5 transition-colors hover:bg-white/5"
:class="selectedIdx === i ? 'bg-accent/10' : ''"
@click="selectPlace(i)"
>
<p class="text-xs text-white/80 truncate">{{ place.name }}</p>
<p v-if="place.address || place.city" class="text-[10px] text-white/40 truncate">
{{ place.address || place.city }}
</p>
</button>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue'
import type { Place } from '@aiui/core/types/content'
const props = defineProps<{
places: Place[]
}>()
defineEmits<{ back: [] }>()
const mapContainer = ref<HTMLElement | null>(null)
const selectedIdx = ref(-1)
type LeafletMap = import('leaflet').Map
const mapInstance = shallowRef<LeafletMap | null>(null)
async function initMap() {
if (!mapContainer.value || props.places.length === 0) return
const L = await import('leaflet')
// Import leaflet CSS
await import('leaflet/dist/leaflet.css')
// Calculate bounds from places with coordinates
const withCoords = props.places.filter((p) => p.lat != null && p.lng != null)
if (withCoords.length === 0) return
const map = L.map(mapContainer.value, {
zoomControl: true,
attributionControl: true,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map)
// Custom orange marker icon
const orangeIcon = L.divIcon({
html: `<div style="background:#F7931A;width:12px;height:12px;border-radius:50%;border:2px solid rgba(255,255,255,0.8);box-shadow:0 1px 4px rgba(0,0,0,0.4)"></div>`,
className: '',
iconSize: [12, 12],
iconAnchor: [6, 6],
})
const markers: import('leaflet').Marker[] = []
const bounds = L.latLngBounds([])
for (const place of withCoords) {
const latlng = L.latLng(place.lat!, place.lng!)
bounds.extend(latlng)
const marker = L.marker(latlng, { icon: orangeIcon }).addTo(map)
// Popup
let popupHtml = `<div style="font-family:system-ui;font-size:12px"><strong>${place.name}</strong>`
if (place.address) popupHtml += `<br><span style="color:#888">${place.address}</span>`
if (place.rating) popupHtml += `<br>Rating: ${place.rating}/5`
popupHtml += '</div>'
marker.bindPopup(popupHtml)
markers.push(marker)
}
if (withCoords.length === 1) {
map.setView([withCoords[0].lat!, withCoords[0].lng!], 14)
} else {
map.fitBounds(bounds, { padding: [40, 40] })
}
mapInstance.value = map
// Invalidate size after animation
setTimeout(() => map.invalidateSize(), 200)
}
function selectPlace(idx: number) {
selectedIdx.value = idx
const place = props.places[idx]
if (place.lat != null && place.lng != null && mapInstance.value) {
mapInstance.value.setView([place.lat, place.lng], 16, { animate: true })
}
}
onMounted(() => {
initMap()
})
watch(() => props.places, () => {
mapInstance.value?.remove()
mapInstance.value = null
initMap()
})
onBeforeUnmount(() => {
mapInstance.value?.remove()
})
</script>