feat: Implement layer control and custom zoom functionality in ProjectsMapPage and LeafletMap components
This commit is contained in:
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
|
import { mapLayers } from "@/components/ui/mapLayers";
|
||||||
|
|
||||||
// Dynamically import the map component to avoid SSR issues
|
// Dynamically import the map component to avoid SSR issues
|
||||||
const DynamicMap = dynamic(() => import("@/components/ui/LeafletMap"), {
|
const DynamicMap = dynamic(() => import("@/components/ui/LeafletMap"), {
|
||||||
@@ -29,6 +30,9 @@ export default function ProjectsMapPage() {
|
|||||||
in_progress_construction: true,
|
in_progress_construction: true,
|
||||||
fulfilled: true,
|
fulfilled: true,
|
||||||
});
|
});
|
||||||
|
const [activeBaseLayer, setActiveBaseLayer] = useState("Polish Geoportal Orthophoto");
|
||||||
|
const [activeOverlays, setActiveOverlays] = useState([]);
|
||||||
|
const [showLayerPanel, setShowLayerPanel] = useState(true);
|
||||||
|
|
||||||
// Status configuration with colors and labels
|
// Status configuration with colors and labels
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
@@ -68,6 +72,7 @@ export default function ProjectsMapPage() {
|
|||||||
);
|
);
|
||||||
setStatusFilters(newState);
|
setStatusFilters(newState);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle status filter
|
// Toggle status filter
|
||||||
const toggleStatusFilter = (status) => {
|
const toggleStatusFilter = (status) => {
|
||||||
setStatusFilters((prev) => ({
|
setStatusFilters((prev) => ({
|
||||||
@@ -75,6 +80,26 @@ export default function ProjectsMapPage() {
|
|||||||
[status]: !prev[status],
|
[status]: !prev[status],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Layer control functions
|
||||||
|
const handleBaseLayerChange = (layerName) => {
|
||||||
|
setActiveBaseLayer(layerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOverlay = (layerName) => {
|
||||||
|
setActiveOverlays((prev) => {
|
||||||
|
if (prev.includes(layerName)) {
|
||||||
|
return prev.filter((name) => name !== layerName);
|
||||||
|
} else {
|
||||||
|
return [...prev, layerName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLayerPanel = () => {
|
||||||
|
setShowLayerPanel(!showLayerPanel);
|
||||||
|
};
|
||||||
|
|
||||||
// Update URL with current map state (debounced to avoid too many updates)
|
// Update URL with current map state (debounced to avoid too many updates)
|
||||||
const updateURL = (center, zoom) => {
|
const updateURL = (center, zoom) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -96,7 +121,9 @@ export default function ProjectsMapPage() {
|
|||||||
window.mapUpdateTimeout = setTimeout(() => {
|
window.mapUpdateTimeout = setTimeout(() => {
|
||||||
updateURL(center, zoom);
|
updateURL(center, zoom);
|
||||||
}, 500); // Wait 500ms after the last move to update URL
|
}, 500); // Wait 500ms after the last move to update URL
|
||||||
};// Hide navigation and ensure full-screen layout
|
};
|
||||||
|
|
||||||
|
// Hide navigation and ensure full-screen layout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for URL parameters for coordinates and zoom
|
// Check for URL parameters for coordinates and zoom
|
||||||
const lat = searchParams.get('lat');
|
const lat = searchParams.get('lat');
|
||||||
@@ -127,6 +154,7 @@ export default function ProjectsMapPage() {
|
|||||||
// Prevent scrolling on body
|
// Prevent scrolling on body
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
document.documentElement.style.overflow = "hidden";
|
document.documentElement.style.overflow = "hidden";
|
||||||
|
|
||||||
// Cleanup when leaving page
|
// Cleanup when leaving page
|
||||||
return () => {
|
return () => {
|
||||||
if (nav) {
|
if (nav) {
|
||||||
@@ -141,6 +169,7 @@ export default function ProjectsMapPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/projects")
|
fetch("/api/projects")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@@ -182,6 +211,7 @@ export default function ProjectsMapPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Convert projects to map markers with filtering
|
// Convert projects to map markers with filtering
|
||||||
const markers = projects
|
const markers = projects
|
||||||
.filter((project) => project.coordinates)
|
.filter((project) => project.coordinates)
|
||||||
@@ -258,7 +288,7 @@ export default function ProjectsMapPage() {
|
|||||||
{project.plot}
|
{project.plot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>{" "}
|
</div>
|
||||||
{project.project_status && (
|
{project.project_status && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-gray-700">Status:</span>
|
<span className="font-medium text-gray-700">Status:</span>
|
||||||
@@ -303,6 +333,7 @@ export default function ProjectsMapPage() {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((marker) => marker !== null);
|
.filter((marker) => marker !== null);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
||||||
@@ -317,99 +348,54 @@ export default function ProjectsMapPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-50 overflow-hidden">
|
<div
|
||||||
{" "}
|
className="fixed inset-0 bg-gray-50 overflow-hidden"
|
||||||
{/* Floating Header with Controls */}
|
>
|
||||||
<div className="absolute top-4 left-4 right-4 z-[1000] flex items-center justify-between">
|
{/* Floating Header - Left Side */}
|
||||||
<div className="flex gap-3">
|
<div className="absolute top-4 left-4 z-[1000]">
|
||||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
{/* Title Box */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||||
<h1 className="text-lg font-semibold text-gray-900">
|
<div className="flex items-center gap-3">
|
||||||
Projects Map
|
<h1 className="text-lg font-semibold text-gray-900">
|
||||||
</h1>
|
Projects Map
|
||||||
<div className="text-sm text-gray-600">
|
</h1>
|
||||||
{markers.length} of {projects.length} projects with coordinates
|
<div className="text-sm text-gray-600">
|
||||||
</div>
|
{markers.length} of {projects.length} projects with coordinates
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> </div>
|
||||||
{/* Status Filter Panel */}
|
</div>
|
||||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700 mr-2">
|
|
||||||
Filters:
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Toggle All Button */}
|
{/* Zoom Controls - Below Title */}
|
||||||
<button
|
<div className="absolute top-20 left-4 z-[1000]">
|
||||||
onClick={toggleAllFilters}
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 flex flex-col">
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
<button
|
||||||
title="Toggle all filters"
|
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 border-b border-gray-200 text-gray-700 font-medium text-lg"
|
||||||
>
|
onClick={() => {
|
||||||
<svg
|
// This will be handled by the map component
|
||||||
className="w-3 h-3"
|
const event = new CustomEvent('mapZoomIn');
|
||||||
fill="none"
|
window.dispatchEvent(event);
|
||||||
stroke="currentColor"
|
}}
|
||||||
viewBox="0 0 24 24"
|
title="Zoom In"
|
||||||
>
|
>
|
||||||
<path
|
+
|
||||||
strokeLinecap="round"
|
</button>
|
||||||
strokeLinejoin="round"
|
<button
|
||||||
strokeWidth={2}
|
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 text-gray-700 font-medium text-lg"
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
onClick={() => {
|
||||||
/>
|
// This will be handled by the map component
|
||||||
</svg>
|
const event = new CustomEvent('mapZoomOut');
|
||||||
<span className="text-gray-600">
|
window.dispatchEvent(event);
|
||||||
{Object.values(statusFilters).every((v) => v)
|
}}
|
||||||
? "Hide All"
|
title="Zoom Out"
|
||||||
: "Show All"}
|
>
|
||||||
</span>
|
−
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Individual Status Filters */}
|
|
||||||
{Object.entries(statusConfig).map(([status, config]) => {
|
|
||||||
const isActive = statusFilters[status];
|
|
||||||
const projectCount = projects.filter(
|
|
||||||
(p) => p.project_status === status && p.coordinates
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
onClick={() => toggleStatusFilter(status)}
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
|
||||||
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
|
||||||
}`}
|
|
||||||
title={`Toggle ${config.label} (${projectCount} projects)`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
|
||||||
isActive ? "border-white shadow-sm" : "border-gray-300"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: isActive ? config.color : "#e5e7eb",
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<span
|
|
||||||
className={`transition-colors duration-200 ${
|
|
||||||
isActive ? "text-gray-700" : "text-gray-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{config.shortLabel}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`ml-1 text-xs transition-colors duration-200 ${
|
|
||||||
isActive ? "text-gray-500" : "text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
({projectCount})
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layer Control Panel - Right Side */}
|
||||||
|
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
||||||
|
{/* Action Buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href="/projects">
|
<Link href="/projects">
|
||||||
<Button
|
<Button
|
||||||
@@ -452,80 +438,292 @@ export default function ProjectsMapPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>{" "}
|
|
||||||
{/* Stats Panel - Bottom Left */}
|
|
||||||
{markers.length > 0 && (
|
|
||||||
<div className="absolute bottom-4 left-4 z-[1000] bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200 max-w-xs">
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
||||||
<span className="font-medium">
|
|
||||||
{markers.length} of{" "}
|
|
||||||
{projects.filter((p) => p.coordinates).length} projects shown
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status breakdown */}
|
{/* Layer Control Panel */}
|
||||||
<div className="space-y-1 mb-2">
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 layer-panel-container">
|
||||||
{Object.entries(statusConfig).map(([status, config]) => {
|
{/* Layer Control Header */}
|
||||||
const totalCount = projects.filter(
|
<div className="px-4 py-3 border-b border-gray-200">
|
||||||
(p) => p.project_status === status && p.coordinates
|
<button
|
||||||
).length;
|
onClick={toggleLayerPanel}
|
||||||
const visibleCount = markers.filter((m) => {
|
className="flex items-center justify-between w-full text-left layer-toggle-button"
|
||||||
const project = projects.find(
|
title="Toggle Layer Controls"
|
||||||
(p) =>
|
>
|
||||||
p.coordinates &&
|
<div className="flex items-center gap-2">
|
||||||
p.coordinates
|
<svg
|
||||||
.split(",")
|
className="w-4 h-4 text-gray-600"
|
||||||
.map((c) => parseFloat(c.trim()))
|
fill="none"
|
||||||
.toString() === m.position.toString()
|
stroke="currentColor"
|
||||||
);
|
viewBox="0 0 24 24"
|
||||||
return project && project.project_status === status;
|
>
|
||||||
}).length;
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
if (totalCount === 0) return null;
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
return (
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||||
<div key={status} className="flex items-center gap-2 text-xs">
|
/>
|
||||||
<div
|
</svg>
|
||||||
className="w-2 h-2 rounded-full"
|
<span className="text-sm font-medium text-gray-700">
|
||||||
style={{ backgroundColor: config.color }}
|
Map Layers
|
||||||
></div>
|
</span>
|
||||||
<span
|
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||||
className={
|
{1 + activeOverlays.length} active
|
||||||
statusFilters[status]
|
|
||||||
? "text-gray-600"
|
|
||||||
: "text-gray-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{config.shortLabel}:{" "}
|
|
||||||
{statusFilters[status] ? visibleCount : 0}/{totalCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{projects.length > projects.filter((p) => p.coordinates).length && (
|
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
|
|
||||||
<div className="w-2 h-2 bg-amber-500 rounded-full"></div>
|
|
||||||
<span className="text-amber-600 text-xs">
|
|
||||||
{projects.length -
|
|
||||||
projects.filter((p) => p.coordinates).length}{" "}
|
|
||||||
missing coordinates
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<svg
|
||||||
|
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${
|
||||||
|
showLayerPanel ? "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>
|
||||||
|
</div> {/* Layer Control Content */}
|
||||||
|
<div className={`transition-all duration-300 ease-in-out ${
|
||||||
|
showLayerPanel ? "max-h-[70vh] opacity-100 overflow-visible" : "max-h-0 opacity-0 overflow-hidden"
|
||||||
|
}`}>
|
||||||
|
<div className="p-4 min-w-80 max-w-96 max-h-[60vh] overflow-y-auto">
|
||||||
|
{/* Base Layers Section */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Base Maps
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mapLayers.base.map((layer, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="baseLayer"
|
||||||
|
checked={activeBaseLayer === layer.name}
|
||||||
|
onChange={() => handleBaseLayerChange(layer.name)}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 flex-1">
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay Layers Section */}
|
||||||
|
{mapLayers.overlays && mapLayers.overlays.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Overlay Layers
|
||||||
|
</h3> <div className="space-y-2">
|
||||||
|
{mapLayers.overlays.map((layer, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeOverlays.includes(layer.name)}
|
||||||
|
onChange={() => toggleOverlay(layer.name)}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 flex-1">
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div> </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter Panel - Bottom Left */}
|
||||||
|
<div className="absolute bottom-4 left-4 z-[1000]">
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700 mr-2">
|
||||||
|
Filters:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Toggle All Button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleAllFilters}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||||||
|
title="Toggle all filters"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{Object.values(statusFilters).every((v) => v)
|
||||||
|
? "Hide All"
|
||||||
|
: "Show All"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Individual Status Filters */}
|
||||||
|
{Object.entries(statusConfig).map(([status, config]) => {
|
||||||
|
const isActive = statusFilters[status];
|
||||||
|
const projectCount = projects.filter(
|
||||||
|
(p) => p.project_status === status && p.coordinates
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => toggleStatusFilter(status)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||||||
|
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||||||
|
}`}
|
||||||
|
title={`Toggle ${config.label} (${projectCount} projects)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||||||
|
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
className={`transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-700" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.shortLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1 text-xs transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-500" : "text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
({projectCount})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})} </div>
|
||||||
|
</div>
|
||||||
|
</div> {/* Status Panel - Bottom Left */}
|
||||||
|
{markers.length > 0 && ( <div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700 mr-2">
|
||||||
|
Filters:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Toggle All Button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleAllFilters}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||||||
|
title="Toggle all filters"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{Object.values(statusFilters).every((v) => v)
|
||||||
|
? "Hide All"
|
||||||
|
: "Show All"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Individual Status Filters */}
|
||||||
|
{Object.entries(statusConfig).map(([status, config]) => {
|
||||||
|
const isActive = statusFilters[status];
|
||||||
|
const projectCount = projects.filter(
|
||||||
|
(p) => p.project_status === status && p.coordinates
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => toggleStatusFilter(status)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||||||
|
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||||||
|
}`}
|
||||||
|
title={`Toggle ${config.label} (${projectCount} projects)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||||||
|
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
className={`transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-700" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.shortLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1 text-xs transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-500" : "text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
({projectCount})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} {/* Full Screen Map */}
|
||||||
{/* Help Panel - Bottom Right */}
|
|
||||||
<div className="absolute bottom-4 right-4 z-[1000] bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-3 py-2 border border-gray-200">
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Click markers for details • Use 📚 to switch layers
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Full Screen Map */}
|
|
||||||
{markers.length === 0 ? (
|
{markers.length === 0 ? (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-gray-100">
|
<div className="h-full w-full flex items-center justify-center bg-gray-100">
|
||||||
<div className="text-center max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg">
|
<div className="text-center max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg">
|
||||||
@@ -559,17 +757,18 @@ export default function ProjectsMapPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : ( <div className="absolute inset-0">
|
) : (
|
||||||
|
<div className="absolute inset-0">
|
||||||
<DynamicMap
|
<DynamicMap
|
||||||
center={mapCenter}
|
center={mapCenter}
|
||||||
zoom={mapZoom}
|
zoom={mapZoom}
|
||||||
markers={markers}
|
markers={markers}
|
||||||
showLayerControl={true}
|
showLayerControl={false}
|
||||||
defaultLayer="Polish Geoportal Orthophoto"
|
defaultLayer={activeBaseLayer}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
onViewChange={handleMapViewChange}
|
onViewChange={handleMapViewChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} </div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
Popup,
|
Popup,
|
||||||
LayersControl,
|
LayersControl,
|
||||||
useMapEvents,
|
useMapEvents,
|
||||||
|
useMap,
|
||||||
} from "react-leaflet";
|
} from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { mapLayers } from "./mapLayers";
|
import { mapLayers } from "./mapLayers";
|
||||||
@@ -70,26 +72,55 @@ function MapEventHandler({ onViewChange }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom zoom control component that handles external events
|
||||||
|
function CustomZoomHandler() {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
map.zoomIn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
map.zoomOut();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for custom zoom events
|
||||||
|
window.addEventListener('mapZoomIn', handleZoomIn);
|
||||||
|
window.addEventListener('mapZoomOut', handleZoomOut);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mapZoomIn', handleZoomIn);
|
||||||
|
window.removeEventListener('mapZoomOut', handleZoomOut);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function EnhancedLeafletMap({
|
export default function EnhancedLeafletMap({
|
||||||
center,
|
center,
|
||||||
zoom = 13,
|
zoom = 13,
|
||||||
markers = [],
|
markers = [],
|
||||||
showLayerControl = true,
|
showLayerControl = true,
|
||||||
defaultLayer = "OpenStreetMap",
|
defaultLayer = "OpenStreetMap",
|
||||||
|
activeOverlays = [],
|
||||||
onViewChange,
|
onViewChange,
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fixLeafletIcons();
|
fixLeafletIcons();
|
||||||
}, []);
|
}, []); const { BaseLayer, Overlay } = LayersControl;
|
||||||
const { BaseLayer, Overlay } = LayersControl;
|
return (
|
||||||
return (
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
scrollWheelZoom={true}
|
scrollWheelZoom={true}
|
||||||
|
zoomControl={false}
|
||||||
>
|
>
|
||||||
{/* Handle map view changes */}
|
<CustomZoomHandler />
|
||||||
{onViewChange && <MapEventHandler onViewChange={onViewChange} />}
|
{onViewChange && <MapEventHandler onViewChange={onViewChange} />}
|
||||||
|
|
||||||
{showLayerControl ? (
|
{showLayerControl ? (
|
||||||
@@ -135,13 +166,52 @@ export default function EnhancedLeafletMap({
|
|||||||
)}
|
)}
|
||||||
</Overlay>
|
</Overlay>
|
||||||
))}
|
))}
|
||||||
</LayersControl>
|
</LayersControl> ) : (
|
||||||
) : (
|
// Custom layer rendering when no layer control
|
||||||
// Default layer when no layer control
|
<>
|
||||||
<TileLayer
|
{/* Base Layer */}
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
{(() => {
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
const baseLayer = mapLayers.base.find(layer => layer.name === defaultLayer) || mapLayers.base[0];
|
||||||
/>
|
return (
|
||||||
|
<TileLayer
|
||||||
|
attribution={baseLayer.attribution}
|
||||||
|
url={baseLayer.url}
|
||||||
|
maxZoom={baseLayer.maxZoom}
|
||||||
|
tileSize={baseLayer.tileSize || 256}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Active Overlay Layers */}
|
||||||
|
{mapLayers.overlays && mapLayers.overlays
|
||||||
|
.filter(layer => activeOverlays.includes(layer.name))
|
||||||
|
.map((layer, index) => {
|
||||||
|
if (layer.type === "wms") {
|
||||||
|
return (
|
||||||
|
<WMSTileLayer
|
||||||
|
key={`custom-overlay-${index}`}
|
||||||
|
attribution={layer.attribution}
|
||||||
|
url={layer.url}
|
||||||
|
params={layer.params}
|
||||||
|
format={layer.params.format}
|
||||||
|
transparent={layer.params.transparent}
|
||||||
|
opacity={layer.opacity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<TileLayer
|
||||||
|
key={`custom-overlay-${index}`}
|
||||||
|
attribution={layer.attribution}
|
||||||
|
url={layer.url}
|
||||||
|
maxZoom={layer.maxZoom}
|
||||||
|
opacity={layer.opacity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
{markers.map((marker, index) => (
|
{markers.map((marker, index) => (
|
||||||
<Marker
|
<Marker
|
||||||
|
|||||||
Reference in New Issue
Block a user