Spaces:
Sleeping
Sleeping
import { useEffect, useState } from "react"; | |
import { | |
Box, | |
Flex, | |
Heading, | |
Text, | |
Spinner, | |
SimpleGrid, | |
Badge, | |
Wrap, | |
Tag, | |
Divider, | |
Table, | |
Thead, | |
Tbody, | |
Tr, | |
Th, | |
Td, | |
Link, | |
Avatar, | |
Icon, | |
HStack, | |
} from "@chakra-ui/react"; | |
import { CheckIcon, CloseIcon, ExternalLinkIcon } from '@chakra-ui/icons'; | |
import { | |
ResponsiveContainer, | |
BarChart, | |
Bar, | |
Cell, | |
XAxis, | |
YAxis, | |
CartesianGrid, | |
Tooltip | |
} from "recharts"; | |
import { | |
MapContainer, | |
TileLayer, | |
Marker, | |
Popup, | |
useMap, | |
} from "react-leaflet"; | |
import L from "leaflet"; | |
import "leaflet/dist/leaflet.css"; | |
import type { ProjectDetailsProps, OrganizationLocation } from "../hooks/types"; | |
import markerIconPng from "leaflet/dist/images/marker-icon.png"; | |
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png"; | |
import markerShadow from "leaflet/dist/images/marker-shadow.png"; | |
const customIcon = new L.Icon({ | |
iconUrl: markerIconPng, | |
iconRetinaUrl: markerIcon2x, | |
shadowUrl: markerShadow, | |
iconSize: [25, 41], | |
iconAnchor: [12, 41], | |
popupAnchor: [1, -34], | |
}); | |
function ResizeMap({ count }: { count: number }) { | |
const map = useMap(); | |
useEffect(() => { | |
map?.invalidateSize(); | |
}, [count, map]); | |
return null; | |
} | |
export default function ProjectDetails({ | |
project,}: ProjectDetailsProps) { | |
// fetch organization locations | |
const [orgLocations, setOrgLocations] = useState<OrganizationLocation[]>([]); | |
const [loadingOrgs, setLoadingOrgs] = useState(true); | |
const [loadingPlot, setLoadingPlot] = useState(true); | |
useEffect(() => { | |
if (!project) return; | |
setLoadingOrgs(true); | |
fetch(`/api/project/${project.id}/organizations`) | |
.then((r) => r.json()) | |
.then((data) => Array.isArray(data) ? setOrgLocations(data) : console.error(data)) | |
.catch(console.error) | |
.finally(() => setLoadingOrgs(false)); | |
}, [project]); | |
if (!project) { | |
return ( | |
<Box p={6} textAlign="center"> | |
<Text color="gray.500">No project selected.</Text> | |
</Box> | |
); | |
} | |
const shapData = project.explanations; | |
const predicted = project.predicted_label; | |
const probability = project.predicted_prob; | |
// Map center fallback | |
const validOrgs = orgLocations.filter( | |
(o) => | |
typeof o.latitude === "number" && | |
!Number.isNaN(o.latitude) && | |
typeof o.longitude === "number" && | |
!Number.isNaN(o.longitude) | |
); | |
// pick a default center (or fallback to [0,0]) | |
const center: [number, number] = validOrgs.length | |
? [validOrgs[0].latitude, validOrgs[0].longitude] | |
: [51.505, -0.09]; | |
// format numbers with two decimals | |
const fmtNum = (num: number | null | undefined): string => | |
num != null | |
? num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) | |
: '-'; | |
return ( | |
<Flex direction={{ base: "column", md: "row" }} gap={8}> | |
{/* Left: Details */} | |
<Box flex={1} p={6} bg="white" borderRadius="md" boxShadow="sm"> | |
<HStack mb={4} align="baseline"> | |
<Heading size="l">{project.title}</Heading> | |
<Badge colorScheme={project.status === "CLOSED" ? "green" : "red"}> | |
{project.status} | |
</Badge> | |
</HStack> | |
<SimpleGrid columns={[1, 2]} spacing={4} mb={6}> | |
<Box> | |
<Text fontWeight="bold">ID</Text> | |
<Text>{project.id}</Text> | |
</Box> | |
<Box> | |
<Text fontWeight="bold">Acronym</Text> | |
<Text>{project.acronym}</Text> | |
</Box> | |
<Box><Text fontWeight="bold">Start Date</Text><Text>{project.startDate}</Text></Box> | |
<Box><Text fontWeight="bold">End Date</Text><Text>{project.endDate}</Text></Box> | |
<Box><Text fontWeight="bold">Funding (EC max)</Text><Text>€{fmtNum(project.ecMaxContribution)}</Text></Box> | |
<Box><Text fontWeight="bold">Total Cost</Text><Text>€{fmtNum(project.totalCost)}</Text></Box> | |
<Box><Text fontWeight="bold">Funding Scheme</Text><Text>{project.fundingScheme}</Text></Box> | |
<Box> | |
<Text fontWeight="bold">Legal Basis</Text> | |
<Text>{project.legalBasis}</Text> | |
</Box> | |
<Box gridColumn={["auto", "span 2"]}> | |
<Text fontWeight="bold">Framework Programme</Text> | |
<Text>{project.frameworkProgramme}</Text> | |
</Box> | |
</SimpleGrid> | |
<Box mb={6}> | |
<Heading size="md" mb={2}>Objective</Heading> | |
<Text whiteSpace="pre-wrap">{project.objective}</Text> | |
</Box> | |
{(project.list_euroSciVocTitle ?? []).length > 0 && ( | |
<Box mb={4}> | |
<Heading size="md" mb={2}>EuroSciVoc Titles</Heading> | |
<Wrap> | |
{(project.list_euroSciVocTitle ?? []).map((t) => ( | |
<Tag key={t} mb={2}>{t}</Tag> | |
))} | |
</Wrap> | |
</Box> | |
)} | |
{(project.list_euroSciVocPath ?? []).length > 0 && ( | |
<Box mb={4}> | |
<Heading size="md" mb={2}>EuroSciVoc Paths</Heading> | |
<Wrap> | |
{(project.list_euroSciVocPath ?? []).map((p) => ( | |
<Tag key={p} mb={2}>{p}</Tag> | |
))} | |
</Wrap> | |
</Box> | |
)} | |
<Divider my={6} /> | |
{project.publications && Object.keys(project.publications).length > 0 && ( | |
<Box mb={6}> | |
<Heading size="md" mb={2}>Publications</Heading> | |
<Table size="sm" variant="simple"> | |
<Thead> | |
<Tr> | |
<Th>Type</Th> | |
<Th isNumeric>Count</Th> | |
</Tr> | |
</Thead> | |
<Tbody> | |
{Object.entries(project.publications).map(([type, count]) => ( | |
<Tr key={type}> | |
<Td>{type}</Td> | |
<Td isNumeric>{count}</Td> | |
</Tr> | |
))} | |
</Tbody> | |
</Table> | |
</Box> | |
)} | |
{orgLocations.length > 0 && ( | |
<> | |
<Heading size="md" mb={3}>Participating Organizations</Heading> | |
{loadingOrgs ? ( | |
<Spinner /> | |
) : ( | |
<Table size="sm" variant="simple" mb={6}> | |
<Thead> | |
<Tr> | |
<Th whiteSpace="nowrap">Name</Th> | |
<Th whiteSpace="nowrap">Location</Th> | |
<Th whiteSpace="nowrap">SME</Th> | |
<Th whiteSpace="nowrap">Role</Th> | |
<Th isNumeric whiteSpace="nowrap">Contribution</Th> | |
<Th whiteSpace="nowrap">Activity Type</Th> | |
</Tr> | |
</Thead> | |
<Tbody> | |
{orgLocations.map((o, i) => ( | |
<Tr key={i}> | |
<Td> | |
{o.orgURL ? ( | |
<Link href={o.orgURL} isExternal> | |
{o.name} <ExternalLinkIcon mx="2px" /> | |
</Link> | |
) : ( | |
<Text>{o.name}</Text> | |
)} | |
</Td> | |
<Td whiteSpace="nowrap">{`${o.city || '-'}, ${o.country}`}</Td> | |
<Td>{o.sme ? <Icon as={CheckIcon} /> : <Icon as={CloseIcon} />}</Td> | |
<Td>{o.role}</Td> | |
<Td isNumeric>€{fmtNum(o.contribution)}</Td> | |
<Td>{o.activityType}</Td> | |
</Tr> | |
))} | |
</Tbody> | |
</Table> | |
)} | |
{!loadingOrgs && validOrgs.length > 0 &&( | |
<Box w="100%" h="300px" borderRadius="md" overflow="hidden"> | |
<MapContainer center={center} zoom={4} style={{ height: '100%', width: '100%' }}> | |
<ResizeMap count={validOrgs.length} /> | |
<TileLayer | |
attribution="© OpenStreetMap contributors" | |
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" | |
/> | |
{validOrgs.map((org, i) => ( | |
<Marker key={i} position={[org.latitude, org.longitude]} icon={customIcon}> | |
<Popup> | |
<Text fontWeight="bold">{org.name}</Text> | |
<Text>{org.country}</Text> | |
</Popup> | |
</Marker> | |
))} | |
</MapContainer> | |
</Box> | |
)} | |
</> | |
)} | |
</Box> | |
{/* Right: Model Explanation */} | |
<Box | |
flex={{ base: '1', md: '0.6' }} | |
bg="gray.50" | |
p={4} | |
borderRadius="md" | |
display="flex" | |
flexDirection="column" | |
maxH="700px" | |
> | |
<Heading size="sm" mb={4}>Model Prediction & Explanation</Heading> | |
{shapData?.length ? ( | |
<> | |
<Text mb={2}><strong>Predicted Label:</strong> {predicted === 1 ? 'Terminated' : 'Closed'}</Text> | |
<Text mb={4}><strong>Probability:</strong> {predicted === 1 ? (probability * 100).toFixed(2) : ((1-probability) * 100).toFixed(2) }%</Text> | |
<ResponsiveContainer width="100%" height={300}> | |
<BarChart data={shapData} margin={{ top: 10, right: 30, left: 0, bottom: 5 }}> | |
<CartesianGrid strokeDasharray="3 3" /> | |
<XAxis dataKey="feature" axisLine={false} tick={false} /> | |
<YAxis /> | |
<Tooltip /> | |
<Bar dataKey="shap" name="SHAP Value"> | |
{shapData.map((entry, index) => ( | |
<Cell | |
key={`cell-${index}`} | |
fill={entry.shap >= 0 ? "#003399" : "#FFCC00"} | |
/> | |
))} | |
</Bar> | |
</BarChart> | |
</ResponsiveContainer> | |
<Text fontSize="xs" color="gray.500" mt={2}> | |
Each bar shows how much that feature pushed the model's prediction. | |
Positive bars increase the chance of termination; Negative bars decrease it. | |
</Text> | |
</> | |
) : ( | |
<Spinner /> | |
)} | |
</Box> | |
</Flex> | |
); | |
} |