change neighborhood filtering

This commit is contained in:
Gal 2025-06-21 22:59:22 +02:00
parent b9b591d325
commit 1a8b85de81
Signed by: gal
GPG Key ID: F035BC65003BC00B
17 changed files with 1224 additions and 2402 deletions

View File

@ -1 +1 @@
VITE_USE_MOCK=true VITE_USE_MOCK=false

View File

@ -2,9 +2,8 @@
<html lang=""> <html lang="">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Berlin Picnic Spot Finder</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

2338
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,11 +13,12 @@
"dev:mock": "VITE_USE_MOCK=true npm run dev" "dev:mock": "VITE_USE_MOCK=true npm run dev"
}, },
"dependencies": { "dependencies": {
"@vue-leaflet/vue-leaflet": "^0.10.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0", "vue-leaflet": "^0.1.0",
"vue3-leaflet": "^1.0.50" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,85 +1,16 @@
<script setup> <!-- src/App.vue -->
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
</script>
<template> <template>
<header> <div id="app">
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> <RouterView />
</div>
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
</template> </template>
<script setup>
import { RouterView } from 'vue-router'
</script>
<style scoped> <style scoped>
header { #app {
line-height: 1.5; height: 100vh;
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;
}
} }
</style> </style>

25
src/assets/style.css Normal file
View File

@ -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);
}
}

192
src/components/ParkCard.vue Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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>

195
src/data/mockParks.js Normal file
View File

@ -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."
}
}
]

View File

@ -1,14 +1,30 @@
import './assets/main.css' // src/main.js
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' 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) const app = createApp(App)
// Use plugins
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
// Mount app
app.mount('#app') app.mount('#app')

142
src/services/api.js Normal file
View File

@ -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()

116
src/stores/parks.js Normal file
View File

@ -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
}
}
})

53
src/stores/personality.js Normal file
View File

@ -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)
}
}
})

View File

@ -1,9 +1,149 @@
<script setup> <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> </script>
<template> <template>
<main> <div class="h-screen relative font-playful">
<TheWelcome /> <!-- Full Screen Map -->
</main> <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> </template>
<style scoped>
/* Remove the floating selector background styles since we don't want the white box */
</style>

View File

@ -1,9 +1,19 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [], content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: { theme: {
extend: {}, extend: {
fontFamily: {
'playful': ['Nunito', 'Quicksand', 'Poppins', 'system-ui', 'sans-serif'],
'clean': ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
backdropBlur: {
xs: '2px',
}
},
}, },
plugins: [], plugins: [],
} }