Add ClusterMap integration for regions and cities with fit-to-bounds functionality

This commit is contained in:
Sean Morley
2025-12-13 10:59:55 -05:00
parent 725fec30eb
commit c96b13ea8d
4 changed files with 291 additions and 58 deletions

View File

@@ -41,6 +41,22 @@
export let mapClass = '';
export let zoom = 2;
export let standardControls = true;
export let fitToBounds: boolean = true;
export let fitPadding: number = 40;
export let fitMaxZoom: number = 8;
// Optional level context (e.g. 'country' | 'region' | 'city'). When provided,
// `fitMaxZooms` can supply level-specific maximum zoom values used when
// fitting bounds. This lets callers choose different fit zooms for country,
// region, and city views.
export let fitLevel: string = '';
export let fitMaxZooms: Record<string, number> = { country: 4, region: 7, city: 12 };
// Effective fit max zoom (prefers level-specific value if available)
let effectiveFitMaxZoom: number = fitMaxZoom;
$: effectiveFitMaxZoom =
fitLevel && fitMaxZooms && fitMaxZooms[fitLevel] !== undefined
? fitMaxZooms[fitLevel]
: fitMaxZoom;
export let getMarkerProps: (feature: unknown) => MarkerProps = (feature) =>
feature && typeof feature === 'object' && feature !== null && 'properties' in (feature as any)
@@ -80,6 +96,75 @@
let resolvedClusterCirclePaint: Record<string, unknown> = clusterCirclePaint;
$: resolvedClusterCirclePaint = clusterCirclePaint as Record<string, unknown>;
// Map instance (bound from MapLibre) and bounding state
let map: any = undefined;
let _lastBoundsKey: string | null = null;
// When `geoJson` changes, compute bounding box and fit map to bounds (only when changed)
$: if (
map &&
fitToBounds &&
geoJson &&
Array.isArray(geoJson.features) &&
geoJson.features.length > 0
) {
let minLon = 180;
let minLat = 90;
let maxLon = -180;
let maxLat = -90;
for (const f of geoJson.features) {
const coords = (f && f.geometry && f.geometry.coordinates) || null;
if (!coords || !Array.isArray(coords) || coords.length < 2) continue;
const lon = Number(coords[0]);
const lat = Number(coords[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
minLon = Math.min(minLon, lon);
minLat = Math.min(minLat, lat);
maxLon = Math.max(maxLon, lon);
maxLat = Math.max(maxLat, lat);
}
if (minLon <= maxLon && minLat <= maxLat) {
const boundsKey = `${minLon},${minLat},${maxLon},${maxLat}`;
if (boundsKey !== _lastBoundsKey) {
_lastBoundsKey = boundsKey;
// If bounds represent effectively a single point, use easeTo with a sensible zoom
const lonDelta = Math.abs(maxLon - minLon);
const latDelta = Math.abs(maxLat - minLat);
const isSinglePoint = lonDelta < 1e-6 && latDelta < 1e-6;
try {
if (isSinglePoint) {
const center = [(minLon + maxLon) / 2, (minLat + maxLat) / 2];
map.easeTo({ center, zoom: Math.max(zoom, effectiveFitMaxZoom), duration: 1000 });
} else {
const bounds: [[number, number], [number, number]] = [
[minLon, minLat],
[maxLon, maxLat]
];
// Use fitBounds to contain all points with padding and a max zoom
if (typeof map.fitBounds === 'function') {
map.fitBounds(bounds, {
padding: fitPadding,
maxZoom: effectiveFitMaxZoom,
duration: 1000
});
} else {
// Fallback: center and set zoom if fitBounds not available
const center = [(minLon + maxLon) / 2, (minLat + maxLat) / 2];
map.easeTo({ center, duration: 1000 });
}
}
} catch (err) {
// If something fails (map not ready), ignore — it will re-run when map is available.
console.error('ClusterMap: fitBounds failed', err);
}
}
}
}
function handleClusterClick(event: CustomEvent<LayerClickInfo>) {
const { clusterId, features, map, source } = event.detail;
if (!clusterId || !features?.length) {
@@ -131,7 +216,7 @@
}
</script>
<MapLibre style={mapStyle} class={mapClass} {standardControls} {zoom}>
<MapLibre bind:map style={mapStyle} class={mapClass} {standardControls} {zoom}>
<GeoJSON id={sourceId} data={geoJson} cluster={clusterOptions} generateId>
<CircleLayer
id={`${sourceId}-clusters`}

View File

@@ -433,6 +433,7 @@
clusterOptions={countryClusterOptions}
mapStyle={getBasemapUrl()}
mapClass="aspect-[16/10] w-full rounded-lg"
fitLevel="country"
on:markerSelect={handleMarkerSelect}
{getMarkerProps}
markerClass={markerClassResolver}

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import RegionCard from '$lib/components/RegionCard.svelte';
import type { Region, VisitedRegion } from '$lib/types';
import { MapLibre, Marker } from 'svelte-maplibre';
import ClusterMap from '$lib/components/ClusterMap.svelte';
import type { ClusterOptions } from 'svelte-maplibre';
import type { PageData } from './$types';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
@@ -129,6 +130,100 @@
addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`);
}
}
// ClusterMap integration for regions
type VisitStatus = 'visited' | 'not_visited';
type RegionFeatureProperties = {
id: string | number;
name: string;
visitStatus: VisitStatus;
};
type RegionFeature = {
type: 'Feature';
geometry: {
type: 'Point';
coordinates: [number, number];
};
properties: RegionFeatureProperties;
};
type RegionFeatureCollection = {
type: 'FeatureCollection';
features: RegionFeature[];
};
function parseCoordinate(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) return null;
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function regionToFeature(region: Region): RegionFeature | null {
const lat = parseCoordinate(region.latitude);
const lon = parseCoordinate(region.longitude);
if (lat === null || lon === null) return null;
const isVisited = visitedRegions.some((vr) => vr.region === region.id);
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
id: region.id,
name: region.name,
visitStatus: isVisited ? 'visited' : 'not_visited'
}
};
}
const REGION_SOURCE_ID = 'worldtravel-regions';
const regionClusterOptions: ClusterOptions = { radius: 300, maxZoom: 8, minPoints: 1 };
let regionsGeoJson: RegionFeatureCollection = { type: 'FeatureCollection', features: [] };
$: regionsGeoJson = {
type: 'FeatureCollection',
features: regions.map((r) => regionToFeature(r)).filter((f): f is RegionFeature => f !== null)
};
function getMarkerProps(feature: any): RegionFeatureProperties | null {
return feature && feature.properties ? feature.properties : null;
}
function getVisitStatusClass(status: VisitStatus): string {
switch (status) {
case 'visited':
return 'bg-green-200';
case 'not_visited':
default:
return 'bg-red-200';
}
}
function markerClassResolver(props: { visitStatus?: string } | null): string {
if (!props?.visitStatus) return '';
return getVisitStatusClass(props.visitStatus as VisitStatus);
}
function markerLabelResolver(props: { name?: string } | null): string {
// Toggle label visibility while keeping marker visible
if (!props) return '';
return showGeo ? (props.name ?? '') : '';
}
function handleMarkerSelect(event: CustomEvent<any>) {
const id = event.detail?.markerProps?.id as string | number | undefined;
if (id === undefined || id === null) return;
const region = regions.find((r) => String(r.id) === String(id));
if (!region) return;
// Toggle visit on click
const isVisited = visitedRegions.some((vr) => vr.region === region.id);
if (isVisited) {
removeVisit(region);
} else {
markVisited(region);
}
}
</script>
<svelte:head>
@@ -303,31 +398,18 @@
</div>
</div>
</div>
<MapLibre
style={getBasemapUrl()}
class="aspect-[16/10] w-full rounded-lg"
standardControls
center={[regions[0]?.longitude || 0, regions[0]?.latitude || 0]}
zoom={6}
>
{#each regions as region}
{#if region.latitude && region.longitude && showGeo}
<Marker
lngLat={[region.longitude, region.latitude]}
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 {visitedRegions.some(
(visitedRegion) => visitedRegion.region === region.id
)
? 'bg-green-200'
: 'bg-red-200'} text-black focus:outline-6 focus:outline-black"
on:click={togleVisited(region)}
>
<span class="text-xs">
{region.name}
</span>
</Marker>
{/if}
{/each}
</MapLibre>
<ClusterMap
geoJson={regionsGeoJson}
sourceId={REGION_SOURCE_ID}
clusterOptions={regionClusterOptions}
mapStyle={getBasemapUrl()}
mapClass="aspect-[16/10] w-full rounded-lg"
fitLevel="region"
on:markerSelect={handleMarkerSelect}
{getMarkerProps}
markerClass={markerClassResolver}
markerLabel={markerLabelResolver}
/>
</div>
</div>
</div>

View File

@@ -5,7 +5,8 @@
import type { City, VisitedCity } from '$lib/types';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre';
import ClusterMap from '$lib/components/ClusterMap.svelte';
import type { ClusterOptions } from 'svelte-maplibre';
// Icons
import MapMarker from '~icons/mdi/map-marker';
@@ -125,6 +126,87 @@
addToast('info', `${$t('worldtravel.visit_to')} ${city.name} ${$t('worldtravel.removed')}`);
}
}
// ClusterMap integration for cities
type VisitStatus = 'visited' | 'not_visited';
type CityFeatureProperties = {
id: string | number;
name: string;
visitStatus: VisitStatus;
};
type CityFeature = {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: CityFeatureProperties;
};
type CityFeatureCollection = { type: 'FeatureCollection'; features: CityFeature[] };
function parseCoordinate(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) return null;
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function cityToFeature(city: City): CityFeature | null {
const lat = parseCoordinate(city.latitude);
const lon = parseCoordinate(city.longitude);
if (lat === null || lon === null) return null;
const isVisited = visitedCities.some((vc) => vc.city === city.id);
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
id: city.id,
name: city.name,
visitStatus: isVisited ? 'visited' : 'not_visited'
}
};
}
const CITY_SOURCE_ID = 'worldtravel-cities';
const cityClusterOptions: ClusterOptions = { radius: 300, maxZoom: 12, minPoints: 1 };
let citiesGeoJson: CityFeatureCollection = { type: 'FeatureCollection', features: [] };
$: citiesGeoJson = {
type: 'FeatureCollection',
features: filteredCities
.map((c) => cityToFeature(c))
.filter((f): f is CityFeature => f !== null)
};
function getMarkerProps(feature: any): CityFeatureProperties | null {
return feature && feature.properties ? feature.properties : null;
}
function getVisitStatusClass(status: VisitStatus): string {
return status === 'visited' ? 'bg-green-200' : 'bg-red-200';
}
function markerClassResolver(props: { visitStatus?: string } | null): string {
if (!props?.visitStatus) return '';
return getVisitStatusClass(props.visitStatus as VisitStatus);
}
function markerLabelResolver(props: { name?: string } | null): string {
if (!props) return '';
return showGeo ? (props.name ?? '') : '';
}
function handleMarkerSelect(event: CustomEvent<any>) {
const id = event.detail?.markerProps?.id as string | number | undefined;
if (id === undefined || id === null) return;
const city = allCities.find((c) => String(c.id) === String(id));
if (!city) return;
const isVisited = visitedCities.some((vc) => vc.city === city.id);
if (isVisited) {
removeVisit(city);
} else {
markVisited(city);
}
}
</script>
<svelte:head>
@@ -216,7 +298,7 @@
on:click={() => (filterOption = 'all')}
>
<MapMarker class="w-3 h-3" />
All
{$t('adventures.all')}
</button>
<button
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
@@ -299,35 +381,18 @@
</div>
</div>
</div>
<MapLibre
style={getBasemapUrl()}
class="aspect-[16/10] w-full rounded-lg"
standardControls
center={allCities[0] &&
allCities[0].longitude !== null &&
allCities[0].latitude !== null
? [allCities[0].longitude, allCities[0].latitude]
: [0, 0]}
zoom={8}
>
{#each filteredCities as city}
{#if city.latitude && city.longitude && showGeo}
<Marker
lngLat={[city.longitude, city.latitude]}
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 {visitedCities.some(
(visitedCity) => visitedCity.city === city.id
)
? 'bg-green-200'
: 'bg-red-200'} text-black focus:outline-6 focus:outline-black"
on:click={toggleVisited(city)}
>
<span class="text-xs">
{city.name}
</span>
</Marker>
{/if}
{/each}
</MapLibre>
<ClusterMap
geoJson={citiesGeoJson}
sourceId={CITY_SOURCE_ID}
clusterOptions={cityClusterOptions}
mapStyle={getBasemapUrl()}
mapClass="aspect-[16/10] w-full rounded-lg"
fitLevel="city"
on:markerSelect={handleMarkerSelect}
{getMarkerProps}
markerClass={markerClassResolver}
markerLabel={markerLabelResolver}
/>
</div>
</div>
</div>