change neighborhood filtering
This commit is contained in:
parent
b9b591d325
commit
1a8b85de81
|
@ -1 +1 @@
|
|||
VITE_USE_MOCK=true
|
||||
VITE_USE_MOCK=false
|
||||
|
|
|
@ -2,9 +2,8 @@
|
|||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<title>Berlin Picnic Spot Finder</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -13,11 +13,12 @@
|
|||
"dev:mock": "VITE_USE_MOCK=true npm run dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-leaflet": "^1.0.50"
|
||||
"vue-leaflet": "^0.1.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
89
src/App.vue
89
src/App.vue
|
@ -1,85 +1,16 @@
|
|||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<!-- src/App.vue -->
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<div id="app">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/* src/assets/style.css */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&family=Quicksand:wght@300;400;500;600;700&family=Poppins:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Leaflet marker fixes */
|
||||
.custom-marker {
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* Popup styling */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Custom utility classes */
|
||||
@layer utilities {
|
||||
.hover\:scale-102:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,192 @@
|
|||
<!-- src/components/ParkCard.vue -->
|
||||
<template>
|
||||
<div class="park-card">
|
||||
<!-- Header -->
|
||||
<div class="park-header">
|
||||
<h3 class="park-title">{{ park.name }}</h3>
|
||||
<p class="park-description">{{ park.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Score -->
|
||||
<div class="score-container">
|
||||
<div :class="getScoreClasses(park.currentScore)">
|
||||
Score: {{ park.currentScore }}/100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Why it's good -->
|
||||
<div class="why-good">
|
||||
<h4 class="why-good-title">Why it's perfect for you:</h4>
|
||||
<p class="why-good-text">{{ park.whyGood }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Amenities -->
|
||||
<div class="amenities">
|
||||
<h4 class="amenities-title">Amenities:</h4>
|
||||
<div class="amenities-list">
|
||||
<template v-for="(value, amenity) in park.amenities" :key="amenity">
|
||||
<span v-if="getAmenityIcon(amenity, value)"
|
||||
class="amenity-icon"
|
||||
:title="amenity.replace('_', ' ')">
|
||||
{{ getAmenityIcon(amenity, value) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Park Info -->
|
||||
<div class="park-info">
|
||||
<div>Size: {{ formatArea(park.area) }}</div>
|
||||
<div>Wildlife: {{ park.amenities.wildlife_spotting }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Props
|
||||
defineProps({
|
||||
park: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
personality: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const getScoreClasses = (score) => {
|
||||
let classes = 'score-badge '
|
||||
if (score >= 80) classes += 'score-excellent'
|
||||
else if (score >= 60) classes += 'score-good'
|
||||
else classes += 'score-okay'
|
||||
return classes
|
||||
}
|
||||
|
||||
const getAmenityIcon = (amenity, value) => {
|
||||
const icons = {
|
||||
toilets: '🚻',
|
||||
playgrounds: '🛝',
|
||||
water_features: '⛲',
|
||||
cafes: '☕',
|
||||
monuments: '🏛️'
|
||||
}
|
||||
|
||||
return value ? icons[amenity] : null
|
||||
}
|
||||
|
||||
const formatArea = (area) => {
|
||||
if (area >= 1000000) {
|
||||
return `${(area / 1000000).toFixed(1)}km²`
|
||||
}
|
||||
return `${(area / 10000).toFixed(1)}ha`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.park-card {
|
||||
max-width: 400px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.park-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.park-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.park-description {
|
||||
color: #4b5563;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.score-container {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.score-excellent {
|
||||
color: #166534;
|
||||
background-color: #dcfce7;
|
||||
}
|
||||
|
||||
.score-good {
|
||||
color: #a16207;
|
||||
background-color: #fef3c7;
|
||||
}
|
||||
|
||||
.score-okay {
|
||||
color: #b91c1c;
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.why-good {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #eff6ff;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.why-good-title {
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.why-good-text {
|
||||
color: #1d4ed8;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.amenities {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.amenities-title {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.amenities-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.amenity-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.park-info {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.park-info > div {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,44 @@
|
|||
<!-- src/components/PersonalitySelector.vue -->
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<button
|
||||
v-for="personality in personalities"
|
||||
:key="personality.id"
|
||||
@click="$emit('change', personality.id)"
|
||||
:class="[
|
||||
'px-4 py-3 rounded-xl font-semibold transition-all duration-200 font-playful',
|
||||
'flex items-center space-x-2 min-w-0 text-sm md:text-base',
|
||||
'backdrop-blur-sm border border-white/20 shadow-lg',
|
||||
selected === personality.id
|
||||
? `${personality.color} text-gray-800 transform scale-105`
|
||||
: 'bg-white/90 hover:bg-white/95 text-gray-700 hover:scale-102'
|
||||
]"
|
||||
>
|
||||
<span class="text-lg md:text-xl">{{ personality.icon }}</span>
|
||||
<span class="whitespace-nowrap">{{ personality.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Props
|
||||
defineProps({
|
||||
personalities: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
selected: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
defineEmits(['change'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hover\:scale-102:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,236 @@
|
|||
<!-- src/components/PicnicMap.vue -->
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<!-- Loading Overlay -->
|
||||
<div v-if="loading" class="absolute inset-0 bg-white bg-opacity-75 z-[1000] flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<div class="text-xl font-semibold text-gray-700">
|
||||
Finding perfect spots...
|
||||
</div>
|
||||
<div class="text-gray-500">
|
||||
Analyzing {{ personality.replace('_', ' ') }} compatibility
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div ref="mapContainer" class="h-full w-full z-0" style="min-height: 400px; background-color: #f0f0f0;"></div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import ParkCard from './ParkCard.vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
parks: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
personality: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// Refs
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let markersLayer = null
|
||||
|
||||
// Initialize map
|
||||
const initMap = () => {
|
||||
if (!mapContainer.value || map) return
|
||||
|
||||
try {
|
||||
// Create map with custom zoom control position
|
||||
map = L.map(mapContainer.value, {
|
||||
zoomControl: false // Disable default zoom control
|
||||
}).setView([52.5200, 13.4050], 11)
|
||||
|
||||
// Add custom zoom control to bottom right
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(map)
|
||||
|
||||
// Use CartoDB Voyager for modern, colorful look with excellent green area visibility
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 20
|
||||
}).addTo(map)
|
||||
|
||||
// Create markers layer group
|
||||
markersLayer = L.layerGroup().addTo(map)
|
||||
|
||||
// Add markers
|
||||
updateMarkers()
|
||||
} catch (error) {
|
||||
console.error('Error initializing map:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom icon
|
||||
const createCustomIcon = (score) => {
|
||||
let color = '#ef4444' // red for low scores
|
||||
if (score >= 80) color = '#22c55e' // green for high scores
|
||||
else if (score >= 60) color = '#eab308' // yellow for medium scores
|
||||
|
||||
return L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
background-color: ${color};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
">
|
||||
${score}
|
||||
</div>
|
||||
`,
|
||||
className: 'custom-marker',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
})
|
||||
}
|
||||
|
||||
// Update markers
|
||||
const updateMarkers = () => {
|
||||
if (!map || !markersLayer) return
|
||||
|
||||
// Clear existing markers
|
||||
markersLayer.clearLayers()
|
||||
|
||||
// Add new markers
|
||||
props.parks.forEach(park => {
|
||||
const marker = L.marker([park.lat, park.lng], {
|
||||
icon: createCustomIcon(park.currentScore)
|
||||
})
|
||||
|
||||
// Create popup content
|
||||
const popupContent = document.createElement('div')
|
||||
popupContent.innerHTML = `
|
||||
<div class="park-card" style="max-width: 400px; font-family: system-ui, -apple-system, sans-serif;">
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700; color: #1f2937; margin: 0 0 0.25rem 0;">${park.name}</h3>
|
||||
<p style="color: #4b5563; font-size: 0.875rem; margin: 0;">${park.description}</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<div style="display: inline-flex; align-items: center; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; ${getScoreStyles(park.currentScore)}">
|
||||
Score: ${park.currentScore}/100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 0.75rem; padding: 0.75rem; background-color: #eff6ff; border-radius: 0.5rem;">
|
||||
<h4 style="font-weight: 600; color: #1e40af; font-size: 0.875rem; margin: 0 0 0.25rem 0;">Why it's perfect for you:</h4>
|
||||
<p style="color: #1d4ed8; font-size: 0.875rem; margin: 0;">${park.whyGood}</p>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.75rem; color: #6b7280;">
|
||||
<div style="margin-bottom: 0.25rem;">Size: ${formatArea(park.area)}</div>
|
||||
<div>Wildlife: ${park.amenities.wildlife_spotting}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
marker.bindPopup(popupContent, {
|
||||
maxWidth: 400,
|
||||
className: 'custom-popup'
|
||||
})
|
||||
|
||||
markersLayer.addLayer(marker)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const getScoreStyles = (score) => {
|
||||
if (score >= 80) return 'color: #166534; background-color: #dcfce7;'
|
||||
else if (score >= 60) return 'color: #a16207; background-color: #fef3c7;'
|
||||
else return 'color: #b91c1c; background-color: #fee2e2;'
|
||||
}
|
||||
|
||||
const formatArea = (area) => {
|
||||
if (area >= 1000000) {
|
||||
return `${(area / 1000000).toFixed(1)}km²`
|
||||
}
|
||||
return `${(area / 10000).toFixed(1)}ha`
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
initMap()
|
||||
})
|
||||
|
||||
// Watch for parks changes
|
||||
watch(() => props.parks, () => {
|
||||
updateMarkers()
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.custom-marker) {
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
:deep(.leaflet-popup-content-wrapper) {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
:deep(.leaflet-popup-content) {
|
||||
margin: 16px !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Custom zoom control styling */
|
||||
:deep(.leaflet-control-zoom) {
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
:deep(.leaflet-control-zoom a) {
|
||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
color: #374151 !important;
|
||||
font-weight: 600 !important;
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
line-height: 38px !important;
|
||||
font-size: 18px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
:deep(.leaflet-control-zoom a:hover) {
|
||||
background-color: rgba(255, 255, 255, 1) !important;
|
||||
color: #1f2937 !important;
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
|
||||
:deep(.leaflet-control-zoom-in) {
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
:deep(.leaflet-control-zoom-out) {
|
||||
border-radius: 0 0 12px 12px !important;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,195 @@
|
|||
// src/data/mockParks.js
|
||||
export const mockParks = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Tiergarten - Shaded Meadow near Playground",
|
||||
lat: 52.5145,
|
||||
lng: 13.3501,
|
||||
area: 5000, // m² - specific area size
|
||||
description: "Large shaded grassy area with mature oak trees, 50m from the main playground",
|
||||
amenities: {
|
||||
toilets: true,
|
||||
playgrounds: true,
|
||||
water_features: false,
|
||||
cafes: true,
|
||||
monuments: false,
|
||||
wildlife_spotting: "good"
|
||||
},
|
||||
scores: {
|
||||
little_adventurers: 95,
|
||||
romantic_couples: 65,
|
||||
social_butterflies: 85,
|
||||
nature_lovers: 80,
|
||||
fitness_enthusiasts: 70,
|
||||
culture_seekers: 75
|
||||
},
|
||||
why_good: {
|
||||
little_adventurers: "Perfect spot with natural shade for families. Kids can play safely while parents relax under old oak trees. Playground equipment visible from picnic area.",
|
||||
romantic_couples: "Peaceful but not secluded enough for intimate moments. Better for casual dates.",
|
||||
social_butterflies: "Great for family gatherings with easy playground access. Enough space for multiple families.",
|
||||
nature_lovers: "Mature trees provide habitat for squirrels and birds. Natural shade creates cooler microclimate.",
|
||||
fitness_enthusiasts: "Close to main jogging paths but not ideal for active picnics.",
|
||||
culture_seekers: "Near Victory Column but focus is more on family recreation than history."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Tempelhofer Feld - Central Runway Area",
|
||||
lat: 52.4736,
|
||||
lng: 13.4019,
|
||||
area: 8000,
|
||||
description: "Wide open space on the former airport runway with 360° views and constant breeze",
|
||||
amenities: {
|
||||
toilets: true,
|
||||
playgrounds: false,
|
||||
water_features: false,
|
||||
cafes: false,
|
||||
monuments: false,
|
||||
wildlife_spotting: "moderate"
|
||||
},
|
||||
scores: {
|
||||
little_adventurers: 70,
|
||||
romantic_couples: 90,
|
||||
social_butterflies: 95,
|
||||
nature_lovers: 60,
|
||||
fitness_enthusiasts: 95,
|
||||
culture_seekers: 70
|
||||
},
|
||||
why_good: {
|
||||
little_adventurers: "Huge space for kids to run free and fly kites, but no shade or playground equipment nearby.",
|
||||
romantic_couples: "Stunning sunset views and endless sky. Perfect for romantic evening picnics with city skyline backdrop.",
|
||||
social_butterflies: "Massive space for large group activities, BBQs, and sports. Very popular weekend gathering spot.",
|
||||
nature_lovers: "Open sky attracts birds of prey, but limited vegetation and wildlife diversity.",
|
||||
fitness_enthusiasts: "Perfect for active picnics - cycling, running, kite flying, and outdoor sports.",
|
||||
culture_seekers: "Historic aviation site with fascinating airport history and unique urban landscape."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Volkspark Friedrichshain - Fairy Tale Fountain Area",
|
||||
lat: 52.5275,
|
||||
lng: 13.4372,
|
||||
area: 3000,
|
||||
description: "Charming area around the famous Märchenbrunnen with stone seating and fairy tale sculptures",
|
||||
amenities: {
|
||||
toilets: true,
|
||||
playgrounds: true,
|
||||
water_features: true,
|
||||
cafes: true,
|
||||
monuments: true,
|
||||
wildlife_spotting: "good"
|
||||
},
|
||||
scores: {
|
||||
little_adventurers: 95,
|
||||
romantic_couples: 85,
|
||||
social_butterflies: 75,
|
||||
nature_lovers: 70,
|
||||
fitness_enthusiasts: 60,
|
||||
culture_seekers: 90
|
||||
},
|
||||
why_good: {
|
||||
little_adventurers: "Magical fairy tale fountain captivates children. Stone seating around fountain, playground 100m away.",
|
||||
romantic_couples: "Enchanting fairy tale atmosphere with beautiful architecture. Stone benches perfect for intimate conversations.",
|
||||
social_butterflies: "Popular photo spot but can get crowded. Good for smaller groups who enjoy the artistic atmosphere.",
|
||||
nature_lovers: "Fountain attracts birds and creates pleasant microclimate, but area is quite developed.",
|
||||
fitness_enthusiasts: "Beautiful but not ideal for active recreation. Better for post-workout relaxation.",
|
||||
culture_seekers: "Historic 1913 fountain with intricate fairy tale sculptures. Rich artistic and cultural significance."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Treptower Park - Riverside Meadow by Spree",
|
||||
lat: 52.4917,
|
||||
lng: 13.4664,
|
||||
area: 4500,
|
||||
description: "Grassy slope overlooking the Spree River with willow trees and water access",
|
||||
amenities: {
|
||||
toilets: true,
|
||||
playgrounds: false,
|
||||
water_features: true,
|
||||
cafes: true,
|
||||
monuments: false,
|
||||
wildlife_spotting: "excellent"
|
||||
},
|
||||
scores: {
|
||||
little_adventurers: 80,
|
||||
romantic_couples: 95,
|
||||
social_butterflies: 85,
|
||||
nature_lovers: 90,
|
||||
fitness_enthusiasts: 75,
|
||||
culture_seekers: 70
|
||||
},
|
||||
why_good: {
|
||||
little_adventurers: "Safe riverside location with gentle slope. Kids can watch boats while staying away from water edge.",
|
||||
romantic_couples: "Most romantic spot in Berlin - riverside setting with weeping willows and boat watching. Perfect for sunset picnics.",
|
||||
social_butterflies: "Scenic riverside location great for group photos and boat watching. Space for medium-sized gatherings.",
|
||||
nature_lovers: "Excellent bird watching - herons, kingfishers, and waterfowl. Willow trees create natural privacy.",
|
||||
fitness_enthusiasts: "Nice for post-run relaxation but limited space for active sports.",
|
||||
culture_seekers: "Near Soviet War Memorial but this specific area focuses on natural riverside beauty."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Schlosspark Charlottenburg - Rose Garden Terrace",
|
||||
lat: 52.5209,
|
||||
lng: 13.2957,
|
||||
area: 2000,
|
||||
description: "Formal terrace garden with geometric rose beds and palace views",
|
||||
amenities: {
|
||||
toilets: true,
|
||||
playgrounds: false,
|
||||
water_features: true,
|
||||
cafes: true,
|
||||
monuments: true,
|
||||
wildlife_spotting: "moderate"
|
||||
},
|
||||
scores: {
|
||||
little_adventurers: 40,
|
||||
romantic_couples: 95,
|
||||
social_butterflies: 50,
|
||||
nature_lovers: 70,
|
||||
fitness_enthusiasts: 45,
|
||||
culture_seekers: 95
|
||||
},
|
||||
why_good: {
|
||||
little_adventurers: "Beautiful but formal setting requires quiet behavior. Not suitable for active children.",
|
||||
romantic_couples: "Ultimate romantic setting - palace backdrop, manicured rose gardens, and baroque architecture. Perfect for proposals.",
|
||||
social_butterflies: "Stunning for photos but formal atmosphere limits group activities. Better for sophisticated small gatherings.",
|
||||
nature_lovers: "Beautiful roses and formal gardens attract some birds, but very manicured environment.",
|
||||
fitness_enthusiasts: "Peaceful for meditation but no space for physical activities.",
|
||||
culture_seekers: "Baroque palace gardens with 300+ years of history. Museum-quality landscape architecture."
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Viktoriapark - Hilltop Meadow by Waterfall",
|
||||
lat: 52.4897,
|
||||
lng: 13.3765,
|
||||
area: 2500,
|
||||
description: "Elevated grassy area next to artificial waterfall with panoramic Berlin views",
|
||||
amenities: {
|
||||
toilets: true,
|
||||
playgrounds: true,
|
||||
water_features: true,
|
||||
cafes: false,
|
||||
monuments: true,
|
||||
wildlife_spotting: "moderate"
|
||||
},
|
||||
scores: {
|
||||
little_adventurers: 85,
|
||||
romantic_couples: 90,
|
||||
social_butterflies: 75,
|
||||
nature_lovers: 65,
|
||||
fitness_enthusiasts: 90,
|
||||
culture_seekers: 80
|
||||
},
|
||||
why_good: {
|
||||
little_adventurers: "Kids love the waterfall and city views. Playground nearby but requires climb up hill.",
|
||||
romantic_couples: "Spectacular city views and soothing waterfall sounds. Perfect for sunset picnics with urban backdrop.",
|
||||
social_butterflies: "Great views for photos but limited flat space for large groups. Better for smaller gatherings.",
|
||||
nature_lovers: "Artificial waterfall attracts some birds, but urban hilltop setting limits wildlife.",
|
||||
fitness_enthusiasts: "Perfect post-hike reward with amazing views. Great for active couples who enjoy the climb.",
|
||||
culture_seekers: "Historic monument and waterfall with panoramic views of Berlin's architectural evolution."
|
||||
}
|
||||
}
|
||||
]
|
22
src/main.js
22
src/main.js
|
@ -1,14 +1,30 @@
|
|||
import './assets/main.css'
|
||||
|
||||
// src/main.js
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// Leaflet CSS and setup
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import L from 'leaflet'
|
||||
|
||||
// Fix Leaflet default markers
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
// Styles
|
||||
import './assets/style.css'
|
||||
|
||||
// Create app
|
||||
const app = createApp(App)
|
||||
|
||||
// Use plugins
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
// Mount app
|
||||
app.mount('#app')
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
// src/services/api.js
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
const BERLIN_PICNIC_API_URL = import.meta.env.VITE_BERLIN_PICNIC_API_URL || 'http://localhost:8000'
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
class ApiService {
|
||||
async getParks(personality) {
|
||||
if (USE_MOCK) {
|
||||
const { mockParks } = await import('../data/mockParks')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
// Return mock data with personality scoring
|
||||
return mockParks
|
||||
.map(park => ({
|
||||
...park,
|
||||
currentScore: park.scores[personality],
|
||||
whyGood: park.why_good[personality] || "Great spot for this activity!"
|
||||
}))
|
||||
.sort((a, b) => b.currentScore - a.currentScore)
|
||||
}
|
||||
|
||||
// Real API call
|
||||
const response = await fetch(`${API_BASE_URL}/parks/${personality}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch parks: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.parks
|
||||
}
|
||||
|
||||
async getPersonalities() {
|
||||
if (USE_MOCK) {
|
||||
return [
|
||||
{ id: 'little_adventurers', name: 'Little Adventurers', icon: '🧒' },
|
||||
{ id: 'date_night', name: 'Romantic Couples', icon: '💕' },
|
||||
{ id: 'squad_goals', name: 'Social Butterflies', icon: '🦋' },
|
||||
{ id: 'zen_masters', name: 'Nature Lovers', icon: '🌿' },
|
||||
{ id: 'wildlife_lover', name: 'Wildlife Lover', icon: '🦌' }
|
||||
]
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/personalities`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch personalities: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async getGreenSpaceRecommendations(personalityType, options = {}) {
|
||||
const { limit = 10, minScore = 50, neighborhood } = options
|
||||
|
||||
if (USE_MOCK) {
|
||||
const { mockParks } = await import('../data/mockParks')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
// Filter and return mock data based on personality
|
||||
let filteredParks = mockParks
|
||||
.map(park => ({
|
||||
...park,
|
||||
currentScore: park.scores[personalityType] || 0,
|
||||
whyGood: park.why_good[personalityType] || "Great spot for this activity!"
|
||||
}))
|
||||
.filter(park => park.currentScore >= minScore)
|
||||
.sort((a, b) => b.currentScore - a.currentScore)
|
||||
.slice(0, limit)
|
||||
|
||||
return {
|
||||
recommendations: filteredParks,
|
||||
total: filteredParks.length,
|
||||
filters: { personality: personalityType, limit, minScore, neighborhood }
|
||||
}
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams({
|
||||
limit: limit.toString(),
|
||||
min_score: minScore.toString()
|
||||
})
|
||||
|
||||
if (neighborhood) {
|
||||
params.append('neighborhood', neighborhood)
|
||||
}
|
||||
|
||||
// Real API call to Berlin Picnic API
|
||||
const response = await fetch(`${BERLIN_PICNIC_API_URL}/green-spaces/recommendations/${personalityType}?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch green space recommendations: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async getNeighborhoods() {
|
||||
if (USE_MOCK) {
|
||||
return [
|
||||
{ name: 'mitte', display_name: 'Mitte', green_space_count: 21 },
|
||||
{ name: 'pankow', display_name: 'Pankow', green_space_count: 17 },
|
||||
{ name: 'charlottenburg_wilmersdorf', display_name: 'Charlottenburg-Wilmersdorf', green_space_count: 13 },
|
||||
{ name: 'tempelhof_schöneberg', display_name: 'Tempelhof-Schöneberg', green_space_count: 12 },
|
||||
{ name: 'reinickendorf', display_name: 'Reinickendorf', green_space_count: 8 }
|
||||
]
|
||||
}
|
||||
|
||||
const response = await fetch(`${BERLIN_PICNIC_API_URL}/neighborhoods`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch neighborhoods: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.neighborhoods
|
||||
}
|
||||
|
||||
async healthCheck() {
|
||||
if (USE_MOCK) {
|
||||
return { status: 'mock', timestamp: new Date().toISOString() }
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/health`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async berlinPicnicHealthCheck() {
|
||||
if (USE_MOCK) {
|
||||
return { status: 'mock', service: 'berlin-picnic-api', timestamp: new Date().toISOString() }
|
||||
}
|
||||
|
||||
const response = await fetch(`${BERLIN_PICNIC_API_URL}/health`)
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export default new ApiService()
|
|
@ -0,0 +1,116 @@
|
|||
// src/stores/parks.js
|
||||
import { defineStore } from 'pinia'
|
||||
import ApiService from '../services/api'
|
||||
|
||||
export const useParksStore = defineStore('parks', {
|
||||
state: () => ({
|
||||
parks: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
neighborhoods: [],
|
||||
filters: {
|
||||
limit: 5,
|
||||
minScore: 50,
|
||||
neighborhood: null
|
||||
}
|
||||
}),
|
||||
|
||||
getters: {
|
||||
parksByScore: (state) => {
|
||||
return state.parks.sort((a, b) => b.currentScore - a.currentScore)
|
||||
},
|
||||
|
||||
topParks: (state) => {
|
||||
return state.parks.filter(park => park.currentScore >= 80)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchParks(personality) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
// Import mock data dynamically
|
||||
const { mockParks } = await import('../data/mockParks')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
// Process parks with scoring for the selected personality
|
||||
const scoredParks = mockParks
|
||||
.map(park => ({
|
||||
...park,
|
||||
currentScore: park.scores[personality],
|
||||
whyGood: park.why_good[personality] || "Great spot for this activity!"
|
||||
}))
|
||||
.sort((a, b) => b.currentScore - a.currentScore)
|
||||
|
||||
this.parks = scoredParks
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('Error fetching parks:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchGreenSpaceRecommendations(personalityType, customFilters = {}) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const options = { ...this.filters, ...customFilters }
|
||||
const result = await ApiService.getGreenSpaceRecommendations(personalityType, options)
|
||||
|
||||
// Normalize the data structure to match what components expect
|
||||
if (result.recommendations && Array.isArray(result.recommendations)) {
|
||||
this.parks = result.recommendations.map(rec => ({
|
||||
...rec.green_space,
|
||||
lat: rec.green_space.coordinates?.lat,
|
||||
lng: rec.green_space.coordinates?.lng,
|
||||
currentScore: rec.score,
|
||||
whyGood: rec.explanation,
|
||||
bestFeatures: rec.best_features || [],
|
||||
visitRecommendation: rec.visit_recommendation,
|
||||
area: rec.green_space.area_sqm,
|
||||
amenities: {
|
||||
wildlife_spotting: rec.green_space.environmental?.wildlife_diversity_score || 'Unknown'
|
||||
}
|
||||
}))
|
||||
} else if (Array.isArray(result)) {
|
||||
this.parks = result
|
||||
} else {
|
||||
this.parks = []
|
||||
}
|
||||
|
||||
this.filters = result.filters || options
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
this.parks = []
|
||||
console.error('Error fetching green space recommendations:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchNeighborhoods() {
|
||||
try {
|
||||
const neighborhoods = await ApiService.getNeighborhoods()
|
||||
this.neighborhoods = Array.isArray(neighborhoods) ? neighborhoods : []
|
||||
} catch (error) {
|
||||
console.error('Error fetching neighborhoods:', error)
|
||||
this.neighborhoods = []
|
||||
}
|
||||
},
|
||||
|
||||
updateFilters(newFilters) {
|
||||
this.filters = { ...this.filters, ...newFilters }
|
||||
},
|
||||
|
||||
clearParks() {
|
||||
this.parks = []
|
||||
this.error = null
|
||||
}
|
||||
}
|
||||
})
|
|
@ -0,0 +1,53 @@
|
|||
// src/stores/personality.js
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const usePersonalityStore = defineStore('personality', {
|
||||
state: () => ({
|
||||
personalities: [
|
||||
{
|
||||
id: 'little_adventurers',
|
||||
name: 'Little Adventurers',
|
||||
icon: '🧒',
|
||||
description: 'Perfect spots for families with young children'
|
||||
},
|
||||
{
|
||||
id: 'date_night',
|
||||
name: 'Romantic Couples',
|
||||
icon: '💕',
|
||||
description: 'Intimate and scenic locations for couples'
|
||||
},
|
||||
{
|
||||
id: 'squad_goals',
|
||||
name: 'Social Butterflies',
|
||||
icon: '🦋',
|
||||
description: 'Lively areas perfect for group gatherings'
|
||||
},
|
||||
{
|
||||
id: 'zen_masters',
|
||||
name: 'Nature Lovers',
|
||||
icon: '🌿',
|
||||
description: 'Peaceful green spaces away from the crowds'
|
||||
},
|
||||
{
|
||||
id: 'wildlife_lover',
|
||||
name: 'Wildlife Lover',
|
||||
icon: '🦌',
|
||||
description: 'Natural habitats perfect for wildlife observation'
|
||||
}
|
||||
]
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getPersonalityById: (state) => {
|
||||
return (id) => state.personalities.find(p => p.id === id)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async loadPersonalities() {
|
||||
// Personalities are already loaded in state
|
||||
// This method exists for consistency with the API pattern
|
||||
return Promise.resolve(this.personalities)
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,9 +1,149 @@
|
|||
<script setup>
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import PersonalitySelector from '../components/PersonalitySelector.vue'
|
||||
import PicnicMap from '../components/PicnicMap.vue'
|
||||
import { useParksStore } from '../stores/parks'
|
||||
import { usePersonalityStore } from '../stores/personality'
|
||||
|
||||
// Stores
|
||||
const parksStore = useParksStore()
|
||||
const personalityStore = usePersonalityStore()
|
||||
|
||||
// Reactive data
|
||||
const selectedPersonality = ref('wildlife_lover')
|
||||
const loading = ref(false)
|
||||
const showNeighborhoods = ref(false)
|
||||
const selectedNeighborhood = ref(null)
|
||||
const filters = ref({
|
||||
limit: 5,
|
||||
minScore: 50
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const parks = computed(() => parksStore.parks)
|
||||
const personalities = computed(() => personalityStore.personalities)
|
||||
const neighborhoods = computed(() => parksStore.neighborhoods)
|
||||
const currentPersonality = computed(() =>
|
||||
personalities.value.find(p => p.id === selectedPersonality.value)
|
||||
)
|
||||
|
||||
// Methods
|
||||
const handlePersonalityChange = async (personalityId) => {
|
||||
selectedPersonality.value = personalityId
|
||||
await fetchParksForPersonality(personalityId)
|
||||
}
|
||||
|
||||
const fetchParksForPersonality = async (personalityId) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const filterOptions = {
|
||||
...filters.value,
|
||||
neighborhood: selectedNeighborhood.value?.name
|
||||
}
|
||||
await parksStore.fetchGreenSpaceRecommendations(personalityId, filterOptions)
|
||||
} catch (error) {
|
||||
console.error('Error fetching parks:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNeighborhoods = () => {
|
||||
showNeighborhoods.value = !showNeighborhoods.value
|
||||
}
|
||||
|
||||
const selectNeighborhood = async (neighborhood) => {
|
||||
selectedNeighborhood.value = neighborhood
|
||||
showNeighborhoods.value = false
|
||||
await fetchParksForPersonality(selectedPersonality.value)
|
||||
}
|
||||
|
||||
const clearNeighborhood = async () => {
|
||||
selectedNeighborhood.value = null
|
||||
await fetchParksForPersonality(selectedPersonality.value)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await personalityStore.loadPersonalities()
|
||||
await parksStore.fetchNeighborhoods()
|
||||
await fetchParksForPersonality(selectedPersonality.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
<div class="h-screen relative font-playful">
|
||||
<!-- Full Screen Map -->
|
||||
<PicnicMap
|
||||
:parks="parks"
|
||||
:personality="selectedPersonality"
|
||||
:loading="loading"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
|
||||
<!-- Left Sidebar -->
|
||||
<div class="absolute top-6 left-6 z-[1000] space-y-3">
|
||||
<!-- Current Selection Info -->
|
||||
<div v-if="currentPersonality && !loading" class="bg-white/95 backdrop-blur-sm rounded-2xl shadow-lg border border-white/20 px-4 py-3 flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ currentPersonality.icon }}</span>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-800">{{ currentPersonality.name }}</div>
|
||||
<div class="text-sm text-gray-600">{{ parks?.length || 0 }} spots found</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neighborhoods Button -->
|
||||
<div>
|
||||
<button
|
||||
@click="toggleNeighborhoods"
|
||||
class="w-full bg-white/95 backdrop-blur-sm rounded-2xl shadow-lg border border-white/20 px-4 py-3 text-left hover:bg-white/100 transition-all duration-200 min-w-[200px]"
|
||||
>
|
||||
<div class="text-lg font-bold text-gray-800">
|
||||
{{ selectedNeighborhood?.display_name || 'All Neighborhoods' }}
|
||||
</div>
|
||||
<div v-if="selectedNeighborhood" class="text-xs text-gray-500">
|
||||
Click to change
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Neighborhoods Dropdown -->
|
||||
<div v-if="showNeighborhoods" class="mt-2 bg-white/95 backdrop-blur-sm rounded-2xl shadow-lg border border-white/20 p-4">
|
||||
<div class="grid grid-cols-1 gap-2 text-sm">
|
||||
<div
|
||||
@click="clearNeighborhood"
|
||||
class="p-2 hover:bg-gray-100 rounded-lg cursor-pointer font-medium"
|
||||
:class="{ 'bg-blue-50 text-blue-700': !selectedNeighborhood }"
|
||||
>
|
||||
All Neighborhoods
|
||||
</div>
|
||||
<div
|
||||
v-for="neighborhood in neighborhoods"
|
||||
:key="neighborhood.name"
|
||||
@click="selectNeighborhood(neighborhood)"
|
||||
class="p-2 hover:bg-gray-100 rounded-lg cursor-pointer"
|
||||
:class="{ 'bg-blue-50 text-blue-700': selectedNeighborhood?.name === neighborhood.name }"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>{{ neighborhood.display_name }}</span>
|
||||
<span class="text-xs text-gray-500">{{ neighborhood.green_space_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personality Selector - Floating at Bottom (no background) -->
|
||||
<div class="absolute bottom-6 left-1/2 transform -translate-x-1/2 z-[1000] w-full max-w-4xl px-4">
|
||||
<PersonalitySelector
|
||||
:personalities="personalities"
|
||||
:selected="selectedPersonality"
|
||||
@change="handlePersonalityChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Remove the floating selector background styles since we don't want the white box */
|
||||
</style>
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'playful': ['Nunito', 'Quicksand', 'Poppins', 'system-ui', 'sans-serif'],
|
||||
'clean': ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue