feat: enhance map functionality with search and zoom features

- Updated availableViews in collection page to include map view based on lodging and transportation locations.
- Added search functionality to the map page, allowing users to filter pins by name and category.
- Implemented auto-zoom feature to adjust the map view based on filtered search results.
- Introduced a search bar with a clear button for better user experience.
This commit is contained in:
Sean Morley
2026-01-03 23:20:33 -05:00
parent 0bf29b72b5
commit 4de2b7ba2d
4 changed files with 1828 additions and 1201 deletions

View File

@@ -3,32 +3,201 @@
import { GeoJSON, LineLayer, Marker } from 'svelte-maplibre';
import { goto } from '$app/navigation';
import { getActivityColor } from '$lib';
import SearchIcon from '~icons/mdi/magnify';
import type { Collection } from '$lib/types';
import { onMount } from 'svelte';
export let collection: Collection;
// Allow disabling/enabling clustering for markers
export let clusterEnabled: boolean = false;
export let clusterOptions: any = { radius: 300, maxZoom: 8, minPoints: 2 };
// Build marker features from collection.locations
function locationToFeature(loc: any) {
const lat = loc?.latitude !== undefined && loc?.latitude !== null ? Number(loc.latitude) : null;
const lon =
loc?.longitude !== undefined && loc?.longitude !== null ? Number(loc.longitude) : null;
type MarkerType = 'location' | 'lodging' | 'transportation';
type VisitStatus = 'visited' | 'planned';
type MarkerProperties = {
id: string;
name: string;
visitStatus?: VisitStatus;
categoryIcon?: string;
categoryName?: string | null;
type: MarkerType;
date?: string | null;
transportRole?: 'origin' | 'destination';
};
type MarkerFeature = {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: MarkerProperties;
};
type MarkerFeatureCollection = {
type: 'FeatureCollection';
features: MarkerFeature[];
};
// Filter state
let showFilters = false;
let showLocations = true;
let showLodging = true;
let showTransportation = true;
let showVisited = true;
let showPlanned = true;
let startDateFilter = '';
let endDateFilter = '';
let selectedCategories: Set<string> = new Set();
let searchQuery = '';
// Map state for zoom control
let mapZoom = 8;
let mapCenterCoords: [number, number] = [0, 0];
const defaultClusterOptions = { radius: 300, maxZoom: 8, minPoints: 2 };
$: resolvedClusterOptions = clusterOptions || defaultClusterOptions;
// Helper functions
function parseNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const num = typeof value === 'number' ? value : Number(value);
return Number.isFinite(num) ? num : null;
}
function parseDate(value: string | null | undefined): number | null {
if (!value) return null;
const ts = Date.parse(value);
return Number.isFinite(ts) ? ts : null;
}
function formatShortDate(value: string | null | undefined): string {
if (!value) return '';
const parsed = parseDate(value);
if (!parsed) return '';
return new Date(parsed).toISOString().split('T')[0];
}
type FilterConfig = {
showLocations: boolean;
showLodging: boolean;
showTransportation: boolean;
showVisited: boolean;
showPlanned: boolean;
startDate: string;
endDate: string;
categories: Set<string>;
};
function isWithinDateRange(
value: string | null | undefined,
startDate: string,
endDate: string
): boolean {
const hasRange = Boolean(startDate || endDate);
if (!hasRange) return true;
const ts = parseDate(value ?? null);
if (!ts) return false;
const startTs = startDate ? Date.parse(startDate) : -Infinity;
const endTs = endDate ? Date.parse(endDate) + 24 * 60 * 60 * 1000 - 1 : Infinity;
return ts >= startTs && ts <= endTs;
}
function getLocationPrimaryDate(loc: any): string | null {
if (Array.isArray(loc?.visits) && loc.visits.length) {
const dates = loc.visits
.map((v: any) => v?.start_date || v?.end_date)
.filter((d: string | null | undefined) => Boolean(d))
.sort();
return dates[0] || null;
}
return null;
}
function getTransportationDate(t: any): string | null {
return t?.date || t?.start_date || t?.end_date || null;
}
function getTransportIcon(type?: string | null): string {
if (!type) return '✈️';
const normalized = String(type).toLowerCase();
if (normalized.includes('flight') || normalized.includes('plane') || normalized.includes('air'))
return '✈️';
if (normalized.includes('train')) return '🚆';
if (normalized.includes('bus')) return '🚌';
if (normalized.includes('car') || normalized.includes('drive') || normalized.includes('taxi'))
return '🚗';
if (normalized.includes('boat') || normalized.includes('ferry')) return '⛴️';
return '🚐';
}
function locationToFeature(loc: any): MarkerFeature | null {
const lat = parseNumber(loc?.latitude);
const lon = parseNumber(loc?.longitude);
if (lat === null || lon === null) return null;
return {
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [lon, lat] as [number, number] },
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
id: loc.id,
id: String(loc.id),
name: loc.name,
visitStatus: loc.is_visited ? 'visited' : 'planned',
categoryIcon: loc.category?.icon || '📍'
categoryIcon: loc.category?.icon || '📍',
categoryName: loc.category?.display_name || null,
type: 'location',
date: getLocationPrimaryDate(loc)
}
};
}
function lodgingToFeature(l: any): MarkerFeature | null {
const lat = parseNumber(l?.latitude);
const lon = parseNumber(l?.longitude);
if (lat === null || lon === null) return null;
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
id: String(l.id),
name: l.name || 'Lodging',
categoryIcon: '🏨',
categoryName: l.type || 'Lodging',
type: 'lodging',
date: l.check_in || l.check_out || null
}
};
}
function transportationToFeatures(t: any): MarkerFeature[] {
const features: MarkerFeature[] = [];
const icon = getTransportIcon(t?.type || t?.name);
const date = getTransportationDate(t);
const baseName = t?.name || t?.type || 'Transportation';
const pushPoint = (lat: number | null, lon: number | null, role: 'origin' | 'destination') => {
if (lat === null || lon === null) return;
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
id: `${t.id}:${role}`,
name: `${baseName} ${role === 'origin' ? 'Start' : 'End'}`,
categoryIcon: icon,
categoryName: t?.type || 'Transportation',
type: 'transportation',
date,
transportRole: role
}
});
};
pushPoint(parseNumber(t?.origin_latitude), parseNumber(t?.origin_longitude), 'origin');
pushPoint(
parseNumber(t?.destination_latitude),
parseNumber(t?.destination_longitude),
'destination'
);
return features;
}
// Merge attachments/activity geojson into a single feature collection
function collectLinesGeojson(coll: Collection) {
if (!coll) return null;
@@ -107,118 +276,468 @@
return { type: 'FeatureCollection', features };
}
// Marker GeoJSON for FullMap
// Build features and apply filters
$: categoryOptions = Array.from(
new Set(
(collection?.locations || [])
.map((loc: any) => loc?.category?.display_name)
.filter((name: string | null | undefined) => Boolean(name))
)
).sort();
$: locationFeatures = (collection?.locations || [])
.map(locationToFeature)
.filter(Boolean) as MarkerFeature[];
$: lodgingFeatures = (collection?.lodging || [])
.map(lodgingToFeature)
.filter(Boolean) as MarkerFeature[];
$: transportationFeatures = (collection?.transportations || [])
.flatMap(transportationToFeatures)
.filter(Boolean) as MarkerFeature[];
$: allFeatures = [...locationFeatures, ...lodgingFeatures, ...transportationFeatures];
function matchesFilters(
feature: MarkerFeature,
filters: FilterConfig & { search: string }
): boolean {
const props = feature.properties;
// Search filter
if (filters.search) {
const query = filters.search.toLowerCase();
const nameMatch = props.name?.toLowerCase().includes(query);
const categoryMatch = props.categoryName?.toLowerCase().includes(query);
if (!nameMatch && !categoryMatch) return false;
}
if (props.type === 'location') {
if (!filters.showLocations) return false;
if (props.visitStatus === 'visited' && !filters.showVisited) return false;
if (props.visitStatus === 'planned' && !filters.showPlanned) return false;
if (filters.categories.size) {
if (!props.categoryName || !filters.categories.has(props.categoryName)) return false;
}
} else if (props.type === 'lodging') {
if (!filters.showLodging) return false;
} else if (props.type === 'transportation') {
if (!filters.showTransportation) return false;
}
if (!isWithinDateRange(props.date ?? null, filters.startDate, filters.endDate)) return false;
return true;
}
$: filteredFeatures = allFeatures.filter((feature) =>
matchesFilters(feature, {
showLocations,
showLodging,
showTransportation,
showVisited,
showPlanned,
startDate: startDateFilter,
endDate: endDateFilter,
categories: selectedCategories,
search: searchQuery.trim()
})
);
// Auto-zoom when search results change
$: if (searchQuery.trim() && filteredFeatures.length > 0) {
zoomToFilteredFeatures();
}
$: markerGeoJson = {
type: 'FeatureCollection' as const,
features: (collection?.locations || []).map(locationToFeature).filter((f) => f !== null)
} as FullMapFeatureCollection;
type: 'FeatureCollection',
features: filteredFeatures
} as MarkerFeatureCollection;
$: linesGeoJson = collectLinesGeojson(collection) as {
type: 'FeatureCollection';
features: any[];
} | null;
// Stats
$: visiblePinCount = filteredFeatures.length;
$: totalPinCount = allFeatures.length;
$: totalLocations = locationFeatures.length;
$: visitedCount = locationFeatures.filter((f) => f.properties.visitStatus === 'visited').length;
$: plannedCount = locationFeatures.filter((f) => f.properties.visitStatus === 'planned').length;
$: filteredVisitedCount = filteredFeatures.filter(
(f) => f.properties.type === 'location' && f.properties.visitStatus === 'visited'
).length;
$: filteredPlannedCount = filteredFeatures.filter(
(f) => f.properties.type === 'location' && f.properties.visitStatus === 'planned'
).length;
$: hasActiveCategoryFilter = selectedCategories.size > 0;
$: hasActiveDateFilter = Boolean(startDateFilter || endDateFilter);
$: hasActiveSearchFilter = Boolean(searchQuery.trim());
$: filtersPristine =
showLocations &&
showLodging &&
showTransportation &&
showVisited &&
showPlanned &&
!hasActiveCategoryFilter &&
!hasActiveDateFilter &&
!hasActiveSearchFilter;
// Return gradient classes matching map page markers for visit status
function getVisitStatusClass(status: string | null | undefined) {
if (!status) return 'bg-gray-200';
if (status === 'visited') return 'bg-gradient-to-br from-emerald-400 to-emerald-600';
if (status === 'planned') return 'bg-gradient-to-br from-blue-400 to-blue-600';
function zoomToFilteredFeatures() {
if (filteredFeatures.length === 0) return;
const coords = filteredFeatures.map((f) => f.geometry.coordinates);
const lngs = coords.map((c) => c[0]);
const lats = coords.map((c) => c[1]);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
if (filteredFeatures.length === 1) {
// Single marker - center on it with a nice zoom level
mapCenterCoords = [lngs[0], lats[0]];
mapZoom = 12;
} else {
// Multiple markers - fit bounds with padding
const centerLng = (minLng + maxLng) / 2;
const centerLat = (minLat + maxLat) / 2;
mapCenterCoords = [centerLng, centerLat];
// Calculate appropriate zoom level based on bounds
const lngDiff = maxLng - minLng;
const latDiff = maxLat - minLat;
const maxDiff = Math.max(lngDiff, latDiff);
if (maxDiff > 50) mapZoom = 3;
else if (maxDiff > 20) mapZoom = 4;
else if (maxDiff > 10) mapZoom = 5;
else if (maxDiff > 5) mapZoom = 6;
else if (maxDiff > 2) mapZoom = 7;
else if (maxDiff > 1) mapZoom = 8;
else if (maxDiff > 0.5) mapZoom = 9;
else mapZoom = 10;
}
}
$: mapKey = `${visiblePinCount}-${startDateFilter}-${endDateFilter}-${showLocations}-${showLodging}-${showTransportation}-${showVisited}-${showPlanned}-${Array.from(
selectedCategories
)
.sort()
.join('|')}`;
$: mapCenter =
mapCenterCoords[0] !== 0 || mapCenterCoords[1] !== 0
? mapCenterCoords
: markerGeoJson.features.length
? markerGeoJson.features[0].geometry.coordinates
: ([0, 0] as [number, number]);
function toggleCategory(name: string) {
const next = new Set(selectedCategories);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
selectedCategories = next;
}
function resetFilters() {
showLocations = true;
showLodging = true;
showTransportation = true;
showVisited = true;
showPlanned = true;
startDateFilter = '';
endDateFilter = '';
selectedCategories = new Set();
searchQuery = '';
}
function getMarkerColorClass(props: MarkerProperties) {
if (props.type === 'lodging') return 'bg-gradient-to-br from-purple-400 to-purple-600';
if (props.type === 'transportation') return 'bg-gradient-to-br from-amber-400 to-amber-600';
if (props.visitStatus === 'visited') return 'bg-gradient-to-br from-emerald-400 to-emerald-600';
if (props.visitStatus === 'planned') return 'bg-gradient-to-br from-blue-400 to-blue-600';
return 'bg-gray-200';
}
// Compute bounds from geojson features
function computeBoundsFromGeoJSON(geo: any) {
if (!geo || !geo.features || !geo.features.length) return null;
let minLon = Infinity,
minLat = Infinity,
maxLon = -Infinity,
maxLat = -Infinity;
const pushPoint = (c: number[]) => {
if (!Array.isArray(c) || c.length < 2) return;
const lon = Number(c[0]);
const lat = Number(c[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
};
for (const feat of geo.features) {
const geom = feat.geometry;
if (!geom) continue;
const type = geom.type;
const coords = geom.coordinates;
if (!coords) continue;
if (type === 'Point') pushPoint(coords as number[]);
else if (type === 'LineString' || type === 'MultiPoint') {
for (const c of coords as any[]) pushPoint(c);
} else if (type === 'MultiLineString' || type === 'Polygon') {
for (const part of coords as any[]) {
for (const c of part) pushPoint(c);
}
} else if (type === 'MultiPolygon') {
for (const poly of coords as any[])
for (const ring of poly) for (const c of ring) pushPoint(c);
}
function getTypeLabel(props: MarkerProperties) {
if (props.type === 'lodging') return 'Lodging';
if (props.type === 'transportation') {
return props.transportRole === 'origin'
? 'Transport • Start'
: props.transportRole === 'destination'
? 'Transport • End'
: 'Transport';
}
if (minLon === Infinity) return null;
return [
[minLon, minLat],
[maxLon, maxLat]
];
return props.visitStatus === 'visited' ? 'Visited' : 'Planned';
}
// Fit bounds when map and lines are available
let mapRef: any = null;
$: if (mapRef && linesGeoJson) {
const b = computeBoundsFromGeoJSON(linesGeoJson);
if (b) {
try {
mapRef.fitBounds(b, { padding: 40 });
} catch (e) {
// ignore
}
function canNavigate(props: MarkerProperties) {
return true;
}
function getNavigationUrl(props: MarkerProperties): string {
if (props.type === 'location') {
return `/locations/${props.id}`;
} else if (props.type === 'lodging') {
return `/lodging/${props.id}`;
} else if (props.type === 'transportation') {
// Extract the base ID (remove the :origin or :destination suffix)
const baseId = props.id.split(':')[0];
return `/transportations/${baseId}`;
}
return '#';
}
</script>
<!-- Filter Header -->
<div class="card bg-base-100 shadow-lg mb-4">
<div class="card-body p-4">
<!-- Toggle filter visibility -->
<div class="flex items-center justify-between gap-4">
<button
type="button"
class="btn btn-sm btn-ghost gap-2 flex-1 justify-start"
on:click={() => (showFilters = !showFilters)}
>
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<span class="font-medium">{showFilters ? 'Hide Filters' : 'Show Filters'}</span>
<svg
class="w-4 h-4 transition-transform {showFilters ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<div class="flex items-center gap-2">
<div class="badge badge-ghost badge-sm">
{visiblePinCount}/{totalPinCount} pins
</div>
{#if !filtersPristine}
<button type="button" class="btn btn-xs btn-ghost" on:click={resetFilters}>
Reset
</button>
{/if}
</div>
</div>
<!-- Expanded Filter UI -->
{#if showFilters}
<div class="divider my-2"></div>
<div class="space-y-4">
<!-- Search Bar -->
<label class="input input-bordered input-sm flex items-center gap-2">
<SearchIcon class="h-4 w-4 opacity-70" />
<input
type="text"
class="grow"
placeholder="Search locations, lodging, transport..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
type="button"
class="btn btn-ghost btn-xs btn-circle"
on:click={() => (searchQuery = '')}
aria-label="Clear search"
>
</button>
{/if}
</label>
<!-- Visit Status -->
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="flex items-center gap-3 rounded-box border border-base-300 p-3">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 grid place-items-center text-base-100"
>
</div>
<div class="flex flex-col">
<span class="text-xs uppercase text-base-content/60">Visited</span>
<span class="font-semibold text-sm">{filteredVisitedCount}</span>
</div>
<label class="label cursor-pointer gap-2 p-0 ml-auto">
<input type="checkbox" bind:checked={showVisited} class="toggle toggle-sm" />
</label>
</div>
<div class="flex items-center gap-3 rounded-box border border-base-300 p-3">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 grid place-items-center text-base-100"
>
</div>
<div class="flex flex-col">
<span class="text-xs uppercase text-base-content/60">Planned</span>
<span class="font-semibold text-sm">{filteredPlannedCount}</span>
</div>
<label class="label cursor-pointer gap-2 p-0 ml-auto">
<input type="checkbox" bind:checked={showPlanned} class="toggle toggle-sm" />
</label>
</div>
</div>
<!-- Pin Types -->
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="flex items-center gap-3 rounded-box border border-base-300 p-3">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 grid place-items-center text-base-100"
>
📍
</div>
<div class="flex flex-col">
<span class="text-xs uppercase text-base-content/60">Locations</span>
<span class="font-semibold text-sm">{locationFeatures.length}</span>
</div>
<label class="label cursor-pointer gap-2 p-0 ml-auto">
<input
type="checkbox"
bind:checked={showLocations}
class="toggle toggle-sm toggle-primary"
/>
</label>
</div>
{#if lodgingFeatures.length}
<div class="flex items-center gap-3 rounded-box border border-base-300 p-3">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-purple-400 to-purple-600 grid place-items-center text-base-100"
>
🏨
</div>
<div class="flex flex-col">
<span class="text-xs uppercase text-base-content/60">Lodging</span>
<span class="font-semibold text-sm">{lodgingFeatures.length}</span>
</div>
<label class="label cursor-pointer gap-2 p-0 ml-auto">
<input
type="checkbox"
bind:checked={showLodging}
class="toggle toggle-sm toggle-secondary"
/>
</label>
</div>
{/if}
{#if transportationFeatures.length}
<div class="flex items-center gap-3 rounded-box border border-base-300 p-3">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 grid place-items-center text-base-100"
>
✈️
</div>
<div class="flex flex-col">
<span class="text-xs uppercase text-base-content/60">Transport</span>
<span class="font-semibold text-sm">{transportationFeatures.length / 2}</span>
</div>
<label class="label cursor-pointer gap-2 p-0 ml-auto">
<input
type="checkbox"
bind:checked={showTransportation}
class="toggle toggle-sm toggle-accent"
/>
</label>
</div>
{/if}
</div>
<!-- Category Filter -->
{#if categoryOptions.length}
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Categories</span>
{#if hasActiveCategoryFilter}
<button
type="button"
class="btn btn-ghost btn-xs"
on:click={() => (selectedCategories = new Set())}
>
Clear
</button>
{/if}
</div>
<div class="flex flex-wrap gap-2">
{#each categoryOptions as category}
<button
type="button"
class="badge {selectedCategories.has(category)
? 'badge-primary'
: 'badge-ghost'} cursor-pointer hover:scale-105 transition-transform"
on:click={() => toggleCategory(category)}
>
{category}
</button>
{/each}
</div>
</div>
{/if}
<!-- Date Range Filter -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="form-control">
<span class="label label-text text-xs">Start Date</span>
<input
type="date"
bind:value={startDateFilter}
class="input input-sm input-bordered w-full"
min={collection.start_date}
max={collection.end_date}
/>
</label>
<label class="form-control">
<span class="label label-text text-xs">End Date</span>
<input
type="date"
bind:value={endDateFilter}
class="input input-sm input-bordered w-full"
min={collection.start_date}
max={collection.end_date}
/>
</label>
</div>
<!-- Show Lines Toggle -->
</div>
{/if}
</div>
</div>
<!-- Map -->
<div class="w-full" style="min-height:600px; height:600px;">
<FullMap
geoJson={markerGeoJson}
center={markerGeoJson.features.length ? markerGeoJson.features[0].geometry.coordinates : [0, 0]}
zoom={8}
center={mapCenter}
zoom={mapZoom}
mapClass="w-full h-[600px]"
{clusterEnabled}
{clusterOptions}
clusterOptions={resolvedClusterOptions}
>
<svelte:fragment slot="overlays" let:map>
{#if linesGeoJson}
{@const _ = mapRef = map}
<GeoJSON data={linesGeoJson}>
<LineLayer
id="collection-lines"
paint={{
// read per-feature baked color `_color`, fallback to blue
'line-color': ['coalesce', ['get', '_color'], '#60a5fa'],
'line-width': 3,
'line-opacity': 0.9
}}
/>
</GeoJSON>
{/if}
</svelte:fragment>
<svelte:fragment slot="marker" let:markerProps let:markerLngLat let:isActive let:setActive>
{#if markerProps && markerLngLat}
<Marker lngLat={markerLngLat} class={isActive ? 'map-pin-active' : 'map-pin'}>
<div class="relative group z-[1000] group-hover:z-[10000] focus-within:z-[10000]">
<div
class="map-pin-hit grid place-items-center w-8 h-8 rounded-full border-2 border-white shadow-lg text-base cursor-pointer group-hover:scale-110 transition-all duration-200 {getVisitStatusClass(
markerProps.visitStatus
class="map-pin-hit grid place-items-center w-8 h-8 rounded-full border-2 border-white shadow-lg text-base group-hover:scale-110 transition-all duration-200 {getMarkerColorClass(
markerProps
)}"
class:scale-110={isActive}
class:cursor-pointer={canNavigate(markerProps)}
class:cursor-default={!canNavigate(markerProps)}
role="button"
tabindex="0"
on:mouseenter={() => setActive(true)}
@@ -227,19 +746,20 @@
on:blur={() => setActive(false)}
on:click={(e) => {
e.stopPropagation();
goto(`/locations/${markerProps.id}`);
if (canNavigate(markerProps)) goto(getNavigationUrl(markerProps));
}}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if ((e.key === 'Enter' || e.key === ' ') && canNavigate(markerProps)) {
e.preventDefault();
e.stopPropagation();
goto(`/locations/${markerProps.id}`);
goto(getNavigationUrl(markerProps));
}
}}
>
{markerProps.categoryIcon || '📍'}
</div>
<!-- Marker Popup -->
<div
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-all duration-200 z-[9999]"
class:opacity-100={isActive}
@@ -252,27 +772,43 @@
<div class="space-y-2">
<div class="min-w-0">
<h3 class="card-title text-sm leading-tight truncate">{markerProps.name}</h3>
<div class="mt-1 flex items-center gap-2">
<div class="mt-1 flex flex-wrap items-center gap-2">
<div
class="badge badge-sm {markerProps.visitStatus === 'visited'
? 'badge-success'
: 'badge-info'}"
class="badge badge-sm {markerProps.type === 'lodging'
? 'badge-secondary'
: markerProps.type === 'transportation'
? 'badge-accent'
: markerProps.visitStatus === 'visited'
? 'badge-success'
: 'badge-info'}"
>
{markerProps.visitStatus === 'visited' ? 'Visited' : 'Planned'}
{getTypeLabel(markerProps)}
</div>
{#if markerProps.categoryIcon}
<div class="badge badge-ghost badge-sm">{markerProps.categoryIcon}</div>
{#if markerProps.categoryName}
<div class="badge badge-ghost badge-sm">{markerProps.categoryName}</div>
{/if}
{#if markerProps.date}
<div class="badge badge-ghost badge-sm">
{formatShortDate(markerProps.date)}
</div>
{/if}
</div>
</div>
</div>
<div class="card-actions">
<button
class="btn btn-xs"
on:click|stopPropagation={() => goto(`/locations/${markerProps.id}`)}
>Open</button
>
</div>
{#if canNavigate(markerProps)}
<div class="card-actions">
<button
class="btn btn-xs btn-primary"
on:click|stopPropagation={() => goto(getNavigationUrl(markerProps))}
>
Open {markerProps.type === 'location'
? 'Location'
: markerProps.type === 'lodging'
? 'Lodging'
: 'Transportation'}
</button>
</div>
{/if}
</div>
</div>
</div>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -114,7 +114,15 @@
$: availableViews = {
all: true, // Always available
itinerary: !isFolderView, // Only for collections with dates
map: collection?.locations?.some((l) => l.latitude && l.longitude) || false,
map:
collection?.locations?.some((l) => l.latitude && l.longitude) ||
collection?.lodging?.some((l) => l.latitude && l.longitude) ||
collection?.transportations?.some(
(t) =>
(t.origin_latitude && t.origin_longitude) ||
(t.destination_latitude && t.destination_longitude)
) ||
false,
calendar: !isFolderView,
recommendations: true // may be overridden by permission check below
};

View File

@@ -19,6 +19,7 @@
import LocationIcon from '~icons/mdi/crosshairs-gps';
import NewLocationModal from '$lib/components/locations/LocationModal.svelte';
import ActivityIcon from '~icons/mdi/run-fast';
import SearchIcon from '~icons/mdi/magnify';
import FullMap from '$lib/components/map/FullMap.svelte';
export let data;
@@ -47,6 +48,7 @@
let showVisited: boolean = true;
let showPlanned: boolean = true;
let searchQuery: string = '';
let newMarker: { lngLat: any } | null = null;
let newLongitude: number | null = null;
@@ -161,11 +163,27 @@
// Get unique categories for filtering
$: categories = [...new Set(pins.map((pin) => pin.category?.display_name).filter(Boolean))];
// Updates the filtered pins based on the checkboxes
// Updates the filtered pins based on the checkboxes and search query
$: {
filteredPins = pins.filter(
(pin) => (showVisited && pin.is_visited === true) || (showPlanned && pin.is_visited !== true)
);
const query = searchQuery.toLowerCase().trim();
filteredPins = pins.filter((pin) => {
// Filter by visited/planned status
const statusMatch =
(showVisited && pin.is_visited === true) || (showPlanned && pin.is_visited !== true);
if (!statusMatch) return false;
// Filter by search query
if (!query) return true;
return (
pin.name?.toLowerCase().includes(query) ||
pin.category?.display_name?.toLowerCase().includes(query)
);
});
// Auto-zoom to search results when search query changes
if (query && filteredPins.length > 0 && typeof window !== 'undefined') {
zoomToFilteredPins();
}
}
// Reset the longitude and latitude when the newMarker is set to null
@@ -356,6 +374,49 @@
newMarker = null;
}
function zoomToFilteredPins() {
if (filteredPins.length === 0) return;
const lngs = filteredPins
.map((pin) => parseCoordinate(pin.longitude))
.filter((lng): lng is number => lng !== null);
const lats = filteredPins
.map((pin) => parseCoordinate(pin.latitude))
.filter((lat): lat is number => lat !== null);
if (lngs.length === 0 || lats.length === 0) return;
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
if (filteredPins.length === 1) {
// Single pin - center on it with a nice zoom level
mapCenter = [lngs[0], lats[0]];
mapZoom = 12;
} else {
// Multiple pins - fit bounds with padding
const centerLng = (minLng + maxLng) / 2;
const centerLat = (minLat + maxLat) / 2;
mapCenter = [centerLng, centerLat];
// Calculate appropriate zoom level based on bounds
const lngDiff = maxLng - minLng;
const latDiff = maxLat - minLat;
const maxDiff = Math.max(lngDiff, latDiff);
if (maxDiff > 50) mapZoom = 3;
else if (maxDiff > 20) mapZoom = 4;
else if (maxDiff > 10) mapZoom = 5;
else if (maxDiff > 5) mapZoom = 6;
else if (maxDiff > 2) mapZoom = 7;
else if (maxDiff > 1) mapZoom = 8;
else if (maxDiff > 0.5) mapZoom = 9;
else mapZoom = 10;
}
}
function updateUrlParams(lat: number, lng: number, zoom: number) {
if (updateUrlTimeout) clearTimeout(updateUrlTimeout);
updateUrlTimeout = setTimeout(() => {
@@ -470,6 +531,27 @@
<!-- Controls -->
<div class="mt-4 flex flex-wrap items-center gap-4">
<!-- Search Bar -->
<label class="input input-bordered input-sm flex items-center gap-2 flex-1 max-w-md">
<SearchIcon class="h-4 w-4 opacity-70" />
<input
type="text"
class="grow"
placeholder={$t('map.search_locations')}
bind:value={searchQuery}
/>
{#if searchQuery}
<button
type="button"
class="btn btn-ghost btn-xs btn-circle"
on:click={() => (searchQuery = '')}
aria-label="Clear search"
>
</button>
{/if}
</label>
<!-- Action Buttons -->
<div class="flex items-center gap-2">
{#if newMarker}