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:
Sean Morley
2025-12-07 11:46:44 -05:00
committed by GitHub
parent 5d799ceacc
commit 037b45fc17
17 changed files with 998 additions and 240 deletions

View 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>

View File

@@ -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>

View File

@@ -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"