297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import type { POICategoryGroup } from '../types';
|
|
|
|
interface POIPaneProps {
|
|
groups: POICategoryGroup[];
|
|
selectedCategories: Set<string>;
|
|
onCategoriesChange: (categories: Set<string>) => void;
|
|
poiCount: number;
|
|
onNavigateToSource?: (slug: string) => void;
|
|
}
|
|
|
|
export default function POIPane({
|
|
groups,
|
|
selectedCategories,
|
|
onCategoriesChange,
|
|
poiCount,
|
|
onNavigateToSource,
|
|
}: POIPaneProps) {
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
const [showInfo, setShowInfo] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const infoPopupRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setDropdownOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Close info popup when clicking outside
|
|
useEffect(() => {
|
|
if (!showInfo) return;
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
|
|
setShowInfo(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [showInfo]);
|
|
|
|
const allCategories = groups.flatMap((g) => g.categories);
|
|
|
|
const toggleCategory = (category: string) => {
|
|
const newSet = new Set(selectedCategories);
|
|
if (newSet.has(category)) {
|
|
newSet.delete(category);
|
|
} else {
|
|
newSet.add(category);
|
|
}
|
|
onCategoriesChange(newSet);
|
|
};
|
|
|
|
const selectAll = () => {
|
|
onCategoriesChange(new Set(allCategories));
|
|
};
|
|
|
|
const selectNone = () => {
|
|
onCategoriesChange(new Set());
|
|
};
|
|
|
|
const toggleGroup = useCallback(
|
|
(groupName: string) => {
|
|
const group = groups.find((g) => g.name === groupName);
|
|
if (!group) return;
|
|
const allSelected = group.categories.every((c) => selectedCategories.has(c));
|
|
const newSet = new Set(selectedCategories);
|
|
if (allSelected) {
|
|
group.categories.forEach((c) => newSet.delete(c));
|
|
} else {
|
|
group.categories.forEach((c) => newSet.add(c));
|
|
}
|
|
onCategoriesChange(newSet);
|
|
},
|
|
[groups, selectedCategories, onCategoriesChange]
|
|
);
|
|
|
|
const toggleCollapse = (groupName: string) => {
|
|
setCollapsedGroups((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(groupName)) {
|
|
next.delete(groupName);
|
|
} else {
|
|
next.add(groupName);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const lowerSearch = searchTerm.toLowerCase();
|
|
|
|
// Filter groups and categories by search term
|
|
const filteredGroups = groups
|
|
.map((group) => {
|
|
if (!searchTerm) return group;
|
|
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
|
|
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
|
|
if (groupMatches) return group;
|
|
if (matchingCats.length === 0) return null;
|
|
return { ...group, categories: matchingCats };
|
|
})
|
|
.filter(Boolean) as POICategoryGroup[];
|
|
|
|
const selectedCount = selectedCategories.size;
|
|
|
|
return (
|
|
<div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
|
<button
|
|
onClick={() => setShowInfo(true)}
|
|
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
|
title="Data source info"
|
|
>
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{showInfo && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
|
<div
|
|
ref={infoPopupRef}
|
|
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
|
|
>
|
|
<div className="flex items-start justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
|
|
Points of Interest
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowInfo(false)}
|
|
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
|
Points of interest are sourced from OpenStreetMap via Geofabrik extracts.
|
|
Categories include public transport stops, shops, restaurants, healthcare
|
|
facilities, leisure venues, and more. Data is filtered and mapped to
|
|
friendly names with exhaustive category coverage.
|
|
</p>
|
|
{onNavigateToSource && (
|
|
<button
|
|
onClick={() => {
|
|
onNavigateToSource('osm-pois');
|
|
setShowInfo(false);
|
|
}}
|
|
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
|
>
|
|
View data source
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-navy-700 rounded hover:border-warm-400 bg-white dark:bg-navy-800 dark:text-warm-200"
|
|
>
|
|
<span className="truncate text-left">
|
|
{selectedCount === 0
|
|
? 'Select categories...'
|
|
: selectedCount === allCategories.length
|
|
? 'All categories'
|
|
: `${selectedCount} selected`}
|
|
</span>
|
|
<svg
|
|
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{dropdownOpen && (
|
|
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
|
|
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
|
<button onClick={selectAll} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
|
|
All
|
|
</button>
|
|
<span className="text-xs text-warm-300 dark:text-warm-600">|</span>
|
|
<button onClick={selectNone} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
|
|
None
|
|
</button>
|
|
</div>
|
|
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
|
<input
|
|
type="text"
|
|
placeholder="Search categories..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500"
|
|
/>
|
|
</div>
|
|
<div className="max-h-96 overflow-y-auto py-1">
|
|
{filteredGroups.map((group) => {
|
|
const groupSelected = group.categories.filter((c) =>
|
|
selectedCategories.has(c)
|
|
).length;
|
|
const allInGroupSelected = groupSelected === group.categories.length;
|
|
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
|
|
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
|
|
|
|
return (
|
|
<div key={group.name}>
|
|
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
|
|
<button
|
|
onClick={() => toggleCollapse(group.name)}
|
|
className="p-0.5 text-warm-400 hover:text-warm-600"
|
|
>
|
|
<svg
|
|
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={allInGroupSelected}
|
|
ref={(el) => {
|
|
if (el) el.indeterminate = someInGroupSelected;
|
|
}}
|
|
onChange={() => toggleGroup(group.name)}
|
|
className="rounded accent-teal-600"
|
|
/>
|
|
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">{group.name}</span>
|
|
</label>
|
|
<span className="text-xs text-warm-400">
|
|
{groupSelected}/{group.categories.length}
|
|
</span>
|
|
</div>
|
|
{!isCollapsed &&
|
|
group.categories.map((category) => (
|
|
<label
|
|
key={category}
|
|
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCategories.has(category)}
|
|
onChange={() => toggleCategory(category)}
|
|
className="rounded accent-teal-600"
|
|
/>
|
|
<span className="text-sm flex-1">{category}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selectedCount > 0 && (
|
|
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm">
|
|
<div className="font-medium text-teal-900 dark:text-teal-300">
|
|
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
|
</div>
|
|
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1">
|
|
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
|
|
<p>Select categories to display POIs on the map.</p>
|
|
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|