World Travel Improvements (#925)
* Security Patch Django 5.2.8 * Fix Menus on Safari Browser * Enhance touch support and event handling for emoji picker and dropdown * Add touch and pointer event handling to category selection for better mobile support * Add PWA support for iOS/Safari with touch icons * Refactor event listener for dropdown to use non-capturing 'click' for improved compatibility on Safari * Enhance country and region description fetching from Wikipedia - Refactor `generate_description_view.py` to improve candidate page selection and description retrieval. - Update `CategoryDropdown.svelte` to simplify emoji selection handling and improve dropdown behavior. - Add new translation keys in `en.json` for UI elements related to country descriptions. - Modify `+page.svelte` and `+page.server.ts` in world travel routes to fetch and display country and region descriptions. - Implement a toggle for showing full descriptions in the UI. * Update Unraid installation documentation with improved variable formatting and additional resources * Implement cache invalidation for visited regions and cities to ensure updated visit lists * Add ClusterMap component for enhanced geographical data visualization
This commit is contained in:
171
frontend/src/lib/components/ClusterMap.svelte
Normal file
171
frontend/src/lib/components/ClusterMap.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { CircleLayer, GeoJSON, MapLibre, MarkerLayer, SymbolLayer } from 'svelte-maplibre';
|
||||
import type { ClusterOptions, LayerClickInfo } from 'svelte-maplibre';
|
||||
import { getBasemapUrl } from '$lib';
|
||||
|
||||
type PointGeometry = {
|
||||
type: 'Point';
|
||||
coordinates: [number, number];
|
||||
};
|
||||
|
||||
type MarkerProps = {
|
||||
name?: string;
|
||||
visitStatus?: string;
|
||||
country_code?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
type ClusterFeature<P = Record<string, unknown>> = {
|
||||
type: 'Feature';
|
||||
geometry: PointGeometry;
|
||||
properties: P;
|
||||
};
|
||||
|
||||
type ClusterFeatureCollection<P = Record<string, unknown>> = {
|
||||
type: 'FeatureCollection';
|
||||
features: ClusterFeature<P>[];
|
||||
};
|
||||
|
||||
type ClusterSource = {
|
||||
getClusterExpansionZoom: (
|
||||
clusterId: number,
|
||||
callback: (error: unknown, zoom: number) => void
|
||||
) => void;
|
||||
};
|
||||
|
||||
export let geoJson: ClusterFeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||
export let clusterOptions: ClusterOptions = { radius: 300, maxZoom: 5, minPoints: 1 };
|
||||
export let sourceId = 'cluster-source';
|
||||
export let mapStyle: string = getBasemapUrl();
|
||||
export let mapClass = '';
|
||||
export let zoom = 2;
|
||||
export let standardControls = true;
|
||||
|
||||
export let getMarkerProps: (feature: unknown) => MarkerProps = (feature) =>
|
||||
feature && typeof feature === 'object' && feature !== null && 'properties' in (feature as any)
|
||||
? ((feature as any).properties as MarkerProps)
|
||||
: null;
|
||||
|
||||
export let markerBaseClass =
|
||||
'grid px-2 py-1 place-items-center rounded-full border border-gray-200 text-black focus:outline-6 focus:outline-black cursor-pointer whitespace-nowrap';
|
||||
|
||||
export let markerClass: (props: MarkerProps) => string = (props) =>
|
||||
props && typeof props.visitStatus === 'string' ? props.visitStatus : '';
|
||||
|
||||
export let markerTitle: (props: MarkerProps) => string = (props) =>
|
||||
props && typeof props.name === 'string' ? props.name : '';
|
||||
|
||||
export let markerLabel: (props: MarkerProps) => string = markerTitle;
|
||||
|
||||
export let clusterCirclePaint = {
|
||||
'circle-color': ['step', ['get', 'point_count'], '#60a5fa', 20, '#facc15', 60, '#f472b6'],
|
||||
'circle-radius': ['step', ['get', 'point_count'], 24, 20, 34, 60, 46],
|
||||
'circle-opacity': 0.85
|
||||
};
|
||||
|
||||
export let clusterSymbolLayout = {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 12
|
||||
};
|
||||
|
||||
export let clusterSymbolPaint = { 'text-color': '#1f2937' };
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
markerSelect: { feature: unknown; markerProps: MarkerProps; countryCode?: string };
|
||||
clusterClick: LayerClickInfo;
|
||||
}>();
|
||||
|
||||
let resolvedClusterCirclePaint: Record<string, unknown> = clusterCirclePaint;
|
||||
$: resolvedClusterCirclePaint = clusterCirclePaint as Record<string, unknown>;
|
||||
|
||||
function handleClusterClick(event: CustomEvent<LayerClickInfo>) {
|
||||
const { clusterId, features, map, source } = event.detail;
|
||||
if (!clusterId || !features?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clusterFeature = features[0] as {
|
||||
geometry?: { type?: string; coordinates?: [number, number] };
|
||||
};
|
||||
|
||||
const coordinates =
|
||||
clusterFeature?.geometry?.type === 'Point' ? clusterFeature.geometry.coordinates : undefined;
|
||||
if (!coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geoJsonSource = map.getSource(source) as ClusterSource | undefined;
|
||||
if (!geoJsonSource || typeof geoJsonSource.getClusterExpansionZoom !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
geoJsonSource.getClusterExpansionZoom(
|
||||
Number(clusterId),
|
||||
(error: unknown, zoomLevel: number) => {
|
||||
if (error) {
|
||||
console.error('Failed to expand cluster', error);
|
||||
return;
|
||||
}
|
||||
|
||||
map.easeTo({
|
||||
center: coordinates,
|
||||
zoom: zoomLevel
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
dispatch('clusterClick', event.detail);
|
||||
}
|
||||
|
||||
function handleMarkerClick(event: CustomEvent<any>) {
|
||||
const feature = event.detail?.feature;
|
||||
const markerProps = getMarkerProps(feature);
|
||||
const countryCode =
|
||||
markerProps && typeof markerProps.country_code === 'string'
|
||||
? markerProps.country_code
|
||||
: undefined;
|
||||
|
||||
dispatch('markerSelect', { feature, markerProps, countryCode });
|
||||
}
|
||||
</script>
|
||||
|
||||
<MapLibre style={mapStyle} class={mapClass} {standardControls} {zoom}>
|
||||
<GeoJSON id={sourceId} data={geoJson} cluster={clusterOptions} generateId>
|
||||
<CircleLayer
|
||||
id={`${sourceId}-clusters`}
|
||||
applyToClusters
|
||||
hoverCursor="pointer"
|
||||
paint={resolvedClusterCirclePaint}
|
||||
on:click={handleClusterClick}
|
||||
/>
|
||||
<SymbolLayer
|
||||
id={`${sourceId}-cluster-count`}
|
||||
applyToClusters
|
||||
layout={clusterSymbolLayout}
|
||||
paint={clusterSymbolPaint}
|
||||
/>
|
||||
<MarkerLayer applyToClusters={false} on:click={handleMarkerClick} let:feature={featureData}>
|
||||
{@const markerProps = getMarkerProps(featureData)}
|
||||
<slot name="marker" {featureData} {markerProps}>
|
||||
{#if markerProps}
|
||||
<button
|
||||
type="button"
|
||||
class={`${markerBaseClass} ${markerClass(markerProps)}`.trim()}
|
||||
title={markerTitle(markerProps)}
|
||||
aria-label={markerLabel(markerProps)}
|
||||
>
|
||||
<span class="text-xs font-medium">{markerLabel(markerProps)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</slot>
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
</MapLibre>
|
||||
|
||||
<style>
|
||||
:global(.mapboxgl-canvas) {
|
||||
border-radius: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,13 @@
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-left">
|
||||
<div tabindex="0" role="button" class="btn btn-sm btn-ghost gap-2 min-h-0 h-8 px-3">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
class="btn btn-sm btn-ghost gap-2 min-h-0 h-8 px-3"
|
||||
>
|
||||
<MapIcon class="w-4 h-4" />
|
||||
<span class="text-xs font-medium">{getBasemapLabel(basemapType)}</span>
|
||||
<svg class="w-3 h-3 fill-none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -22,7 +28,13 @@
|
||||
option.value
|
||||
? 'bg-primary/10 font-medium'
|
||||
: ''}"
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
basemapType = option.value;
|
||||
}}
|
||||
on:click={() => (basemapType = option.value)}
|
||||
role="menuitem"
|
||||
>
|
||||
<span class="text-lg">{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
let dropdownOpen = false;
|
||||
let searchQuery = '';
|
||||
let searchInput: HTMLInputElement | null = null;
|
||||
let rootRef: HTMLElement | null = null;
|
||||
const timezones = Intl.supportedValuesOf('timeZone');
|
||||
|
||||
// Filter timezones based on search query
|
||||
@@ -42,18 +43,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown if clicked outside
|
||||
// Close dropdown if clicked/touched outside. Use composedPath and pointer events
|
||||
onMount(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const dropdown = document.getElementById(instanceId);
|
||||
if (dropdown && !dropdown.contains(e.target as Node)) dropdownOpen = false;
|
||||
const handlePointerDownOutside = (e: Event) => {
|
||||
const ev: any = e as any;
|
||||
const path: EventTarget[] = ev.composedPath ? ev.composedPath() : ev.path || [];
|
||||
if (!rootRef) return;
|
||||
if (Array.isArray(path)) {
|
||||
if (!path.includes(rootRef)) dropdownOpen = false;
|
||||
} else {
|
||||
if (!(e.target instanceof Node) || !(rootRef as HTMLElement).contains(e.target as Node))
|
||||
dropdownOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDownOutside, true);
|
||||
document.addEventListener('touchstart', handlePointerDownOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDownOutside, true);
|
||||
document.removeEventListener('touchstart', handlePointerDownOutside, true);
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="form-control w-full max-w-xs relative" id={instanceId}>
|
||||
<div class="form-control w-full max-w-xs relative" bind:this={rootRef} id={instanceId}>
|
||||
<label class="label" for={`timezone-display-${instanceId}`}>
|
||||
<span class="label-text">{$t('adventures.timezone')}</span>
|
||||
</label>
|
||||
@@ -66,6 +79,11 @@
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={dropdownOpen}
|
||||
class="input input-bordered flex justify-between items-center cursor-pointer"
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropdownOpen = !dropdownOpen;
|
||||
}}
|
||||
on:click={() => (dropdownOpen = !dropdownOpen)}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
@@ -108,6 +126,11 @@
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full text-left truncate ${tz === selectedTimezone ? 'active font-bold' : ''}`}
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectTimezone(tz);
|
||||
}}
|
||||
on:click={() => selectTimezone(tz)}
|
||||
on:keydown={(e) => handleKeydown(e, tz)}
|
||||
role="option"
|
||||
|
||||
Reference in New Issue
Block a user