mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-12-23 22:58:17 -05:00
Add ClusterMap integration for regions and cities with fit-to-bounds functionality
This commit is contained in:
@@ -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`}
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
clusterOptions={countryClusterOptions}
|
||||
mapStyle={getBasemapUrl()}
|
||||
mapClass="aspect-[16/10] w-full rounded-lg"
|
||||
fitLevel="country"
|
||||
on:markerSelect={handleMarkerSelect}
|
||||
{getMarkerProps}
|
||||
markerClass={markerClassResolver}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user