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:
co-authored by
Claude Opus 4.6
parent
7a3d874f5e
commit
aec87a347c
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@aiui/core": "workspace:*",
|
||||
"@tanstack/vue-virtual": "^3.13.19",
|
||||
"leaflet": "^1.9.4",
|
||||
"markdown-it": "^14.1.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -32,6 +33,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"duck-duck-scrape": "^2.2.7",
|
||||
|
||||
@@ -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: '© 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>
|
||||
@@ -52,6 +52,7 @@ const availableTabs = ref<ContentTab[]>([])
|
||||
const selectedDesignSystemItem = ref<DesignSystemItem | null>(null)
|
||||
const longFormArticle = ref<{ content: string; title?: string } | null>(null)
|
||||
const pdfUrl = ref<{ url: string; title?: string } | null>(null)
|
||||
const mapPlaces = ref<Place[]>([])
|
||||
|
||||
export interface DesignSystemItem {
|
||||
id: string
|
||||
@@ -275,6 +276,7 @@ export function useContentPanel() {
|
||||
selectedDesignSystemItem.value = null
|
||||
longFormArticle.value = null
|
||||
pdfUrl.value = null
|
||||
mapPlaces.value = []
|
||||
}
|
||||
|
||||
function openFilmDetail(film: Film) { clearAllSelections(); selectedFilm.value = film }
|
||||
@@ -301,6 +303,9 @@ export function useContentPanel() {
|
||||
function openPdfViewer(url: string, title?: string) { clearAllSelections(); pdfUrl.value = { url, title }; panelOpen.value = true }
|
||||
function closePdfViewer() { pdfUrl.value = null }
|
||||
|
||||
function openMapView(places: Place[]) { clearAllSelections(); mapPlaces.value = places; panelOpen.value = true }
|
||||
function closeMapView() { mapPlaces.value = [] }
|
||||
|
||||
function openMagazineSectionDetail(section: MagazineSection, index: number) {
|
||||
clearAllSelections()
|
||||
selectedMagazineSection.value = section
|
||||
@@ -451,6 +456,9 @@ export function useContentPanel() {
|
||||
pdfUrl,
|
||||
openPdfViewer,
|
||||
closePdfViewer,
|
||||
mapPlaces,
|
||||
openMapView,
|
||||
closeMapView,
|
||||
enterDesignSystemMode,
|
||||
closePanel,
|
||||
showAllFilms,
|
||||
|
||||
Generated
+23
@@ -27,6 +27,9 @@ importers:
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.19
|
||||
version: 3.13.19(vue@3.5.29(typescript@5.8.3))
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
@@ -55,6 +58,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@types/leaflet':
|
||||
specifier: ^1.9.21
|
||||
version: 1.9.21
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
@@ -1267,9 +1273,15 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/leaflet@1.9.21':
|
||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
@@ -2162,6 +2174,9 @@ packages:
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
leaflet@1.9.4:
|
||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||
|
||||
leven@3.1.0:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4215,8 +4230,14 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/leaflet@1.9.21':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
@@ -5276,6 +5297,8 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
levn@0.4.1:
|
||||
|
||||
Reference in New Issue
Block a user