Files
AdventureLog/frontend/src/lib/components/collections/CollectionMap.svelte

980 lines
30 KiB
Svelte

<script lang="ts">
import FullMap, { type FullMapFeatureCollection } from '$lib/components/map/FullMap.svelte';
import { Marker } from 'svelte-maplibre';
import { goto } from '$app/navigation';
import { getActivityColor } from '$lib';
import SearchIcon from '~icons/mdi/magnify';
import Plus from '~icons/mdi/plus';
import PinIcon from '~icons/mdi/map-marker';
import Clear from '~icons/mdi/close';
import NewLocationModal from '$lib/components/locations/LocationModal.svelte';
import type { Collection, Location, User } from '$lib/types';
export let collection: Collection;
export let user: User | null = null;
// Allow disabling/enabling clustering for markers
export let clusterEnabled: boolean = false;
export let clusterOptions: any = { radius: 300, maxZoom: 8, minPoints: 2 };
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];
// Creation state
let createModalOpen = false;
let initialLatLng: { lat: number; lng: number } | null = null;
let newMarker: { lngLat: { lng: number; lat: number } } | null = null;
let newLatitude: number | null = null;
let newLongitude: number | null = null;
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',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
id: String(loc.id),
name: loc.name,
visitStatus: loc.is_visited ? 'visited' : 'planned',
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;
const features: any[] = [];
// Locations: attachments and visits -> activities
for (const loc of coll.locations || []) {
if (Array.isArray(loc.attachments)) {
for (const a of loc.attachments) {
if (!a || !a.geojson) continue;
if (a.geojson.type === 'FeatureCollection' && Array.isArray(a.geojson.features)) {
features.push(...a.geojson.features);
} else if (a.geojson.type === 'Feature') {
features.push(a.geojson);
}
}
}
if (Array.isArray(loc.visits)) {
for (const visit of loc.visits) {
if (!visit.activities) continue;
for (const activity of visit.activities) {
if (activity && activity.geojson) {
// normalize features and inject activity-type color
const color = getActivityColor(activity.sport_type || (activity as any).type || '');
if (
activity.geojson.type === 'FeatureCollection' &&
Array.isArray(activity.geojson.features)
) {
for (const f of activity.geojson.features) {
if (f && typeof f === 'object') {
f.properties = f.properties || {};
f.properties._color = color;
f.properties.activity_type =
activity.sport_type || (activity as any).type || null;
features.push(f);
}
}
} else if (activity.geojson.type === 'Feature') {
const f = activity.geojson as any;
f.properties = f.properties || {};
f.properties._color = color;
f.properties.activity_type = activity.sport_type || (activity as any).type || null;
features.push(f);
}
}
}
}
}
}
// Transportations: attachments
for (const t of coll.transportations || []) {
if (!t || !Array.isArray(t.attachments)) continue;
for (const a of t.attachments) {
if (!a || !a.geojson) continue;
if (a.geojson.type === 'FeatureCollection' && Array.isArray(a.geojson.features)) {
for (const f of a.geojson.features) {
if (f && typeof f === 'object') {
f.properties = f.properties || {};
// default transport attachments to a neutral blue color
f.properties._color = f.properties._color || '#60a5fa';
features.push(f);
}
}
} else if (a.geojson.type === 'Feature') {
const f = a.geojson as any;
f.properties = f.properties || {};
f.properties._color = f.properties._color || '#60a5fa';
features.push(f);
}
}
}
if (features.length === 0) return null;
return { type: 'FeatureCollection', features };
}
// 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',
features: filteredFeatures
} as MarkerFeatureCollection;
// 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;
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 handleMapClick(e: CustomEvent<{ lngLat: { lng: number; lat: number } }>) {
newMarker = { lngLat: e.detail.lngLat };
newLongitude = e.detail.lngLat.lng;
newLatitude = e.detail.lngLat.lat;
}
function openCreateModal() {
initialLatLng = newMarker ? { lat: newMarker.lngLat.lat, lng: newMarker.lngLat.lng } : null;
createModalOpen = true;
}
function clearNewMarker() {
newMarker = null;
newLatitude = null;
newLongitude = null;
}
function upsertLocation(newLocation: Location) {
const existingLocations = collection?.locations || [];
const idx = existingLocations.findIndex((loc) => loc.id === newLocation.id);
const nextLocations =
idx === -1
? [...existingLocations, newLocation]
: existingLocations.map((loc, i) => (i === idx ? { ...loc, ...newLocation } : loc));
collection = { ...collection, locations: nextLocations };
}
function handleLocationCreated(event: CustomEvent<Location>) {
upsertLocation(event.detail);
const lat = parseNumber(event.detail.latitude);
const lon = parseNumber(event.detail.longitude);
if (lat !== null && lon !== null) {
mapCenterCoords = [lon, lat];
mapZoom = 12;
}
createModalOpen = false;
clearNewMarker();
}
function handleLocationSaved(event: CustomEvent<Location>) {
upsertLocation(event.detail);
const lat = parseNumber(event.detail.latitude);
const lon = parseNumber(event.detail.longitude);
if (lat !== null && lon !== null) {
mapCenterCoords = [lon, lat];
mapZoom = 12;
}
createModalOpen = false;
clearNewMarker();
}
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';
}
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';
}
return props.visitStatus === 'visited' ? 'Visited' : 'Planned';
}
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>
<!-- Add to Collection CTA (compact) -->
<div class="card bg-base-100 shadow-sm mb-3 border border-base-200">
<div class="card-body py-3 px-4 gap-2">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary"
>
<Plus class="w-4 h-4" />
</span>
<div class="min-w-0">
<p class="text-sm font-semibold leading-tight truncate">Add to this collection</p>
<p class="text-xs text-base-content/60 leading-tight truncate">
Click the map to drop a marker, then add it here.
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" class="btn btn-primary btn-xs" on:click={openCreateModal}>
<Plus class="w-4 h-4" />
Add
</button>
{#if newMarker}
<button type="button" class="btn btn-ghost btn-xs" on:click={clearNewMarker}>
<Clear class="w-4 h-4" />
Clear
</button>
{/if}
</div>
</div>
{#if newMarker}
<div
class="alert alert-info alert-sm flex flex-col sm:flex-row sm:items-center gap-2 py-2 px-3"
>
<div class="flex items-center gap-2 text-xs sm:text-sm">
<PinIcon class="w-4 h-4" />
<span class="truncate">
{newLatitude?.toFixed(4)}, {newLongitude?.toFixed(4)}
</span>
</div>
<div class="flex gap-2 sm:ml-auto">
<button
type="button"
class="btn btn-primary btn-xxs sm:btn-xs"
on:click={openCreateModal}
>
<Plus class="w-3 h-3 sm:w-4 sm:h-4" />
Add here
</button>
<button type="button" class="btn btn-ghost btn-xxs sm:btn-xs" on:click={clearNewMarker}>
<Clear class="w-3 h-3 sm:w-4 sm:h-4" />
</button>
</div>
</div>
{:else}
<p class="text-xs text-base-content/60">Tip: click the map to place a marker.</p>
{/if}
</div>
</div>
<!-- Map -->
<div class="w-full" style="min-height:600px; height:600px;">
<FullMap
geoJson={markerGeoJson}
center={mapCenter}
zoom={mapZoom}
mapClass="w-full h-[600px]"
{clusterEnabled}
clusterOptions={resolvedClusterOptions}
on:mapClick={handleMapClick}
>
<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 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)}
on:mouseleave={() => setActive(false)}
on:focus={() => setActive(true)}
on:blur={() => setActive(false)}
on:click={(e) => {
e.stopPropagation();
if (canNavigate(markerProps)) goto(getNavigationUrl(markerProps));
}}
on:keydown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && canNavigate(markerProps)) {
e.preventDefault();
e.stopPropagation();
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}
class:pointer-events-auto={isActive}
>
<div
class="card card-compact bg-base-100 shadow-xl border border-base-300 min-w-56 max-w-80"
>
<div class="card-body gap-3">
<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 flex-wrap items-center gap-2">
<div
class="badge badge-sm {markerProps.type === 'lodging'
? 'badge-secondary'
: markerProps.type === 'transportation'
? 'badge-accent'
: markerProps.visitStatus === 'visited'
? 'badge-success'
: 'badge-info'}"
>
{getTypeLabel(markerProps)}
</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>
{#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>
</div>
</Marker>
{/if}
</svelte:fragment>
<svelte:fragment slot="overlays">
{#if newMarker}
<Marker lngLat={[newMarker.lngLat.lng, newMarker.lngLat.lat]} class="map-pin">
<div
class="map-pin-hit grid place-items-center w-10 h-10 rounded-full bg-primary text-primary-content border-2 border-base-100 shadow-lg"
>
<Plus class="w-5 h-5" />
</div>
</Marker>
{/if}
</svelte:fragment>
</FullMap>
</div>
{#if createModalOpen}
<NewLocationModal
on:create={handleLocationCreated}
on:save={handleLocationSaved}
on:close={() => (createModalOpen = false)}
{initialLatLng}
{collection}
{user}
/>
{/if}
<style>
:global(.min-h-\[600px\]) {
min-height: 600px;
}
</style>