jedmund-svelte/src/lib/components/GeoCard.svelte
Devin AI 8cbbd6d89c lint: fix undefined variables by adding missing type imports (22 fixes)
Co-Authored-By: Justin Edmund <justin@jedmund.com>
2025-11-24 05:32:40 +00:00

274 lines
5.9 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import type { GeoLocation } from '@prisma/client'
import type * as L from 'leaflet'
interface Props {
location: GeoLocation
height?: number
interactive?: boolean
showPopup?: boolean
class?: string
}
let {
location,
height = 400,
interactive = true,
showPopup = true,
class: className = ''
}: Props = $props()
let mapContainer: HTMLDivElement
let map: L.Map | null = null
let marker: L.Marker | null = null
let leaflet: typeof L | null = null
// Load Leaflet dynamically
async function loadLeaflet() {
if (typeof window === 'undefined') return
// Check if already loaded
if (window.L) {
leaflet = window.L
return
}
// Load Leaflet CSS
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='
link.crossOrigin = ''
document.head.appendChild(link)
// Load Leaflet JS
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo='
script.crossOrigin = ''
await new Promise((resolve, reject) => {
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
leaflet = window.L
}
// Initialize map
async function initMap() {
if (!mapContainer || !leaflet) return
// Create map
map = leaflet.map(mapContainer, {
center: [location.latitude, location.longitude],
zoom: 15,
scrollWheelZoom: interactive,
dragging: interactive,
touchZoom: interactive,
doubleClickZoom: interactive,
boxZoom: interactive,
keyboard: interactive,
zoomControl: interactive
})
// Add tile layer (using OpenStreetMap)
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
})
.addTo(map)
// Create custom marker icon if color is specified
let markerOptions = {}
if (location.markerColor) {
const markerIcon = leaflet.divIcon({
className: 'custom-marker',
html: `<div style="background-color: ${location.markerColor}; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15]
})
markerOptions = { icon: markerIcon }
}
// Add marker
marker = leaflet.marker([location.latitude, location.longitude], markerOptions).addTo(map)
// Add popup if enabled
if (showPopup && (location.title || location.description)) {
const popupContent = `
<div class="location-popup">
${location.title ? `<h3>${location.title}</h3>` : ''}
${location.description ? `<p>${location.description}</p>` : ''}
</div>
`
marker.bindPopup(popupContent, {
autoPan: true,
keepInView: true
})
// Open popup by default on non-interactive maps
if (!interactive) {
marker.openPopup()
}
}
}
// Cleanup
function cleanup() {
if (map) {
map.remove()
map = null
}
marker = null
}
onMount(async () => {
try {
await loadLeaflet()
await initMap()
} catch (error) {
console.error('Failed to load map:', error)
}
})
onDestroy(() => {
cleanup()
})
// Reinitialize if location changes
$effect(() => {
if (map && location) {
cleanup()
initMap()
}
})
</script>
<div class="geo-card {className}">
<div
bind:this={mapContainer}
class="map-container"
style="height: {height}px"
role="img"
aria-label="Map showing {location.title ||
'location'} at coordinates {location.latitude}, {location.longitude}"
>
<noscript>
<div class="map-fallback">
<div class="fallback-content">
<h3>{location.title}</h3>
{#if location.description}
<p>{location.description}</p>
{/if}
<p class="coordinates">
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
</p>
<a
href="https://www.openstreetmap.org/?mlat={location.latitude}&mlon={location.longitude}#map=15/{location.latitude}/{location.longitude}"
target="_blank"
rel="noopener noreferrer"
>
View on OpenStreetMap
</a>
</div>
</div>
</noscript>
</div>
</div>
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.geo-card {
width: 100%;
border-radius: $image-corner-radius;
overflow: hidden;
box-shadow: 0 2px 8px $shadow-light;
}
.map-container {
width: 100%;
position: relative;
background: $gray-95;
:global(.leaflet-container) {
font-family: inherit;
}
:global(.location-popup h3) {
margin: 0 0 $unit-half;
font-size: 1rem;
font-weight: 600;
color: $gray-10;
}
:global(.location-popup p) {
margin: 0;
font-size: 0.875rem;
color: $gray-30;
line-height: 1.4;
}
:global(.leaflet-popup-content-wrapper) {
border-radius: $corner-radius-md;
box-shadow: 0 2px 8px $shadow-medium;
}
:global(.leaflet-popup-content) {
margin: $unit-2x;
}
}
.map-fallback {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: $gray-95;
padding: $unit-3x;
text-align: center;
}
.fallback-content {
h3 {
margin: 0 0 $unit;
font-size: 1.25rem;
color: $gray-10;
}
p {
margin: 0 0 $unit;
color: $gray-40;
line-height: 1.5;
}
.coordinates {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.875rem;
color: $gray-60;
margin-bottom: $unit-2x;
}
a {
color: $red-60;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
/* Global styles for Leaflet */
:global(.leaflet-control-attribution) {
font-size: 0.75rem;
background: $overlay-light !important;
}
</style>