Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@turf/turf": "^7.2.0",
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
Expand Down
580 changes: 580 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

37 changes: 32 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import { useEffect, useState } from 'react';
import { Header } from '@/components/Layout/Header';
import { MapContainer } from '@/components/Map/MapContainer';
import { MapLegend } from '@/components/Map/MapLegend';
import { LayerControls } from '@/components/Map/LayerControls';
import { ProjectDetailView } from '@/components/Project/ProjectDetailView';
import '@/styles/globals.css';
import '@/styles/map.css';
import { FixtureReader } from './data/fixture-reader';
import { getProjectData } from './types/project';
import type { LayerVisibilityMap } from './types/map';
import type { FeatureCollection } from './types/geometry';
import type { ProjectData } from './types/project';

/**
* Main application component that composes the entire UI
* Manages the map state and renders the map with its controls
* Manages the map state, layer visibility, and project detail view
* @component
* @returns {JSX.Element} The complete application layout with header and map interface
* @returns {JSX.Element} The complete application layout
*/
function App() {
const [layers, setLayers] = useState<FeatureCollection[]>([])
const [layerVisibility, setLayerVisibility] = useState<LayerVisibilityMap>({})
const [selectedProject, setSelectedProject] = useState<ProjectData | null>(null)

useEffect(() => {
FixtureReader.collections()
Expand All @@ -33,17 +36,41 @@ function App() {

const layersToRender = layers.filter((fc) => layerVisibility[fc.name])

/**
* Handle map marker/feature click — opens the project detail view
*/
const handleFeatureClick = (featureName: string) => {
const projectData = getProjectData(featureName);
setSelectedProject(projectData);
};

/**
* Close the project detail view and restore full map
*/
const handleCloseDetail = () => {
setSelectedProject(null);
};

return (
<div className="app-container">
<Header />
<main className="main-content">
<MapContainer layers={layersToRender} />
<main className={`main-content ${selectedProject ? 'main-content--detail-open' : ''}`}>
<MapContainer
layers={layersToRender}
onFeatureClick={handleFeatureClick}
/>
{/*<MapLegend />*/}
<LayerControls
visibilityMap={layerVisibility}
onLayerChange={setLayerVisibility}
/>
</main>
{selectedProject && (
<ProjectDetailView
project={selectedProject}
onClose={handleCloseDetail}
/>
)}
</div>
);
}
Expand Down
94 changes: 41 additions & 53 deletions src/components/Map/MapContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,58 @@ import React from 'react';
import {
MapContainer as LeafletMapContainer,
TileLayer,
GeoJSON,
} from 'react-leaflet';
import { Layer } from './Layer'
import { Icon } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { FeatureCollection } from '@/types/geometry';

/*
// Fix for default markers in react-leaflet
delete (Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl:
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl:
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl:
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
*/
import type { LeafletEvent } from 'leaflet';
import type { Feature } from 'geojson';

/**
* Props for the MapContainer component
* @interface MapContainerProps
* @property {MapPoint[]} points - Array of map points to display (landmarks, animals, etc.)
* @property {MapArea[]} areas - Array of map areas to display (zones, water bodies, etc.)
* @property {FeatureCollection[]} layers - Array of feature collections to render
* @property {(featureName: string) => void} [onFeatureClick] - Callback when a feature is clicked
*/
interface MapContainerProps {
layers: FeatureCollection[]
layers: FeatureCollection[];
onFeatureClick?: (featureName: string) => void;
}

/**
* Creates a custom map marker icon based on the point type
* @param {string} type - The type of point ('landmark', 'animal', 'insect', 'plant')
* @returns {Icon} A Leaflet Icon instance with custom styling
*/
/*
const createCustomIcon = (type: string): Icon => {
const colors: Record<string, string> = {
landmark: '#e74c3c',
animal: '#3498db',
insect: '#f39c12',
plant: '#27ae60',
};

const color = colors[type] || colors.landmark;

return new Icon({
iconUrl: `data:image/svg+xml;base64,${btoa(`
<svg width="25" height="41" viewBox="0 0 25 41" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 0C5.6 0 0 5.6 0 12.5C0 19.4 12.5 41 12.5 41S25 19.4 25 12.5C25 5.6 19.4 0 12.5 0Z" fill="${color}"/>
<circle cx="12.5" cy="12.5" r="6" fill="white"/>
</svg>
`)}`,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [0, -41],
});
};
*/

/**
* The main map component that displays geographical data using react-leaflet
* The main map component that displays geographical data using react-leaflet.
* Supports click interaction on GeoJSON features to trigger the project detail view.
* @component
* @param {MapContainerProps} props - The properties that define the map's data
* @returns {JSX.Element} A Leaflet map with markers and polygons
*/
export const MapContainer: React.FC<MapContainerProps> = ({
layers
layers,
onFeatureClick,
}) => {
/**
* Attach click handler to each GeoJSON feature
*/
const onEachFeature = (feature: Feature, layer: L.Layer) => {
// Use the feature's name property, or the layer collection name, as the label
const name =
feature.properties?.name ||
feature.properties?.Name ||
feature.properties?.title ||
feature.id ||
'Unknown Project';

// Bind a popup with the feature name
if ('bindPopup' in layer && typeof layer.bindPopup === 'function') {
layer.bindPopup(`<strong>${name}</strong>`);
}

// Fire the click callback
if ('on' in layer && typeof layer.on === 'function') {
layer.on('click', (_e: LeafletEvent) => {
onFeatureClick?.(String(name));
});
}
};

return (
<div className="map-wrapper">
<LeafletMapContainer
Expand All @@ -86,7 +70,11 @@ export const MapContainer: React.FC<MapContainerProps> = ({
/>

{layers.map((layer, idx) => (
<Layer key={idx} featureCollection={layer} />
<GeoJSON
key={`${layer.name}-${idx}`}
data={layer}
onEachFeature={onEachFeature}
/>
))}
</LeafletMapContainer>
</div>
Expand Down
45 changes: 45 additions & 0 deletions src/components/Project/ProjectDetailView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import Box from '@mui/material/Box';
import { ProjectTitle } from './ProjectTitle';
import { ProjectInfo } from './ProjectInfo';
import type { ProjectData } from '@/types/project';

/**
* Props for the ProjectDetailView component
*/
interface ProjectDetailViewProps {
project: ProjectData;
onClose: () => void;
}

/**
* Container component that orchestrates the project detail panels.
* Rendered below the map when a marker is clicked. Combines
* ProjectTitle (header bar) and ProjectInfo (left/right data panels).
*
* @component
*/
export const ProjectDetailView: React.FC<ProjectDetailViewProps> = ({ project, onClose }) => {
return (
<Box
sx={{
width: '100%',
backgroundColor: '#FFFFFF',
borderTop: '2px solid #E8E8E8',
animation: 'slideUp 0.3s ease-out',
'@keyframes slideUp': {
from: { opacity: 0, transform: 'translateY(20px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
}}
data-testid="project-detail-view"
>
<ProjectTitle
title={project.title}
onClose={onClose}
onSave={() => console.log(`Bookmarked: ${project.title}`)}
/>
<ProjectInfo project={project} />
</Box>
);
};
131 changes: 131 additions & 0 deletions src/components/Project/ProjectInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import LocationOnIcon from '@mui/icons-material/LocationOn';
import WaterDropIcon from '@mui/icons-material/WaterDrop';
import LandscapeIcon from '@mui/icons-material/Landscape';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import GroupIcon from '@mui/icons-material/Group';
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
import CategoryIcon from '@mui/icons-material/Category';
import type { ProjectData } from '@/types/project';

/**
* Props for the ProjectInfo component
*/
interface ProjectInfoProps {
project: ProjectData;
}

/**
* Status color mapping
*/
const statusColors: Record<string, 'success' | 'warning' | 'info' | 'default'> = {
Active: 'success',
Planning: 'warning',
Completed: 'info',
'On Hold': 'default',
};

/**
* A single metadata row with icon, label, and value
*/
const InfoRow: React.FC<{ icon: React.ReactNode; label: string; value: string | number }> = ({
icon,
label,
value,
}) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '12px', py: '8px' }}>
<Box sx={{ color: '#667eea', display: 'flex', alignItems: 'center', minWidth: '24px' }}>
{icon}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ color: '#2c3e50', fontWeight: 500, fontSize: '14px' }}>
{typeof value === 'number' ? value.toLocaleString() : value}
</Typography>
</Box>
</Box>
);

/**
* ProjectInfo component with two-column layout (left filters + right metadata).
* Displays structured project data below the ProjectTitle.
*
* Left panel (300px): filter/layer info (watershed, acreage, project type)
* Right panel (remaining): project metadata (status, partner, location, dates, funding)
*
* @component
*/
export const ProjectInfo: React.FC<ProjectInfoProps> = ({ project }) => {
return (
<Box
sx={{
display: 'flex',
width: '100%',
maxWidth: '1188px',
backgroundColor: '#FFFFFF',
borderBottom: '1px solid #E8E8E8',
boxSizing: 'border-box',
}}
data-testid="project-info"
>
{/* Left Panel — Filters / Layer Info */}
<Box
sx={{
width: '300px',
minWidth: '300px',
borderRight: '1px solid #E8E8E8',
padding: '20px 24px',
}}
>
<Typography
variant="subtitle2"
sx={{ color: '#64748b', fontWeight: 600, fontSize: '13px', mb: '16px', textTransform: 'uppercase', letterSpacing: '0.5px' }}
>
Project Filters
</Typography>

<InfoRow icon={<WaterDropIcon fontSize="small" />} label="Watershed" value={project.watershed} />
<Divider sx={{ my: '4px' }} />
<InfoRow icon={<LandscapeIcon fontSize="small" />} label="Acreage" value={`${project.acreage.toLocaleString()} acres`} />
<Divider sx={{ my: '4px' }} />
<InfoRow icon={<CategoryIcon fontSize="small" />} label="Project Type" value={project.projectType} />
</Box>

{/* Right Panel — Project Metadata */}
<Box
sx={{
flex: 1,
padding: '20px 24px',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: '16px' }}>
<Typography
variant="subtitle2"
sx={{ color: '#64748b', fontWeight: 600, fontSize: '13px', textTransform: 'uppercase', letterSpacing: '0.5px' }}
>
Project Details
</Typography>
<Chip
label={project.status}
color={statusColors[project.status] ?? 'default'}
size="small"
sx={{ fontWeight: 600, fontSize: '12px' }}
/>
</Box>

<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
<InfoRow icon={<GroupIcon fontSize="small" />} label="Partner" value={project.partner} />
<InfoRow icon={<LocationOnIcon fontSize="small" />} label="Location" value={project.location} />
<InfoRow icon={<CalendarMonthIcon fontSize="small" />} label="Start Date" value={project.startDate} />
<InfoRow icon={<AccountBalanceIcon fontSize="small" />} label="Funding Source" value={project.fundingSource} />
</Box>
</Box>
</Box>
);
};
Loading