diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cc3418f..17b4c0f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,6 @@ on: branches-ignore: - main - staging - - staging-debug pull_request: jobs: build: diff --git a/Eplant/config.tsx b/Eplant/config.tsx index 6c1d0d13..48a6daa9 100644 --- a/Eplant/config.tsx +++ b/Eplant/config.tsx @@ -10,7 +10,7 @@ import InteractionsViewer from './views/InteractionsViewer' import NavigatorView from './views/NavigatorView' import PlantEFP from './views/PlantEFP' import PublicationViewer from './views/PublicationViewer' -// import WorldEFP from './views/WorldEFP' +import WorldEFP from './views/WorldEFP' import { type ViewMetadata } from './View' export type EplantConfig = { @@ -34,7 +34,7 @@ const userViewMetadata = [ PlantEFP, CellEFP, ExperimentEFP, - // WorldEFP, + WorldEFP, ChromosomeViewerObject, NavigatorView, InteractionsViewer, diff --git a/Eplant/main.tsx b/Eplant/main.tsx index f7f1b3ae..a0a29803 100644 --- a/Eplant/main.tsx +++ b/Eplant/main.tsx @@ -16,58 +16,68 @@ import { InteractionsViewObject } from './views/InteractionsViewer/InteractionsV import { NavigatorViewObject } from './views/NavigatorView/NavigatorView' import { PlantEFPView } from './views/PlantEFP/PlantEFP' import { PublicationsViewer } from './views/PublicationViewer/PublicationsView' +import { WorldEFPView } from './views/WorldEFP/WorldEFP' import { Config, defaultConfig } from './config' import Eplant from './Eplant' import './css/index.css' -const router = createBrowserRouter([ +const router = createBrowserRouter( + [ + { + path: '/', + element: , + children: [ + { + element: , + }, + { + path: 'cell-efp/:geneid?', + element: , + }, + { + path: 'publications/:geneid?', + element: , + }, + { + path: 'chromosome/:geneid?', + element: , + }, + { + path: 'plant-efp/:geneid?', + element: , + }, + { + path: 'experiment-efp/:geneid?', + element: , + }, + { + path: 'gene-info/:geneid?', + element: , + }, + { + path: 'get-started/:geneid?', + element: , + }, + { + path: 'navigator-view/:geneid?', + element: , + }, + { + path: 'interactions-view/:geneid?', + element: , + }, + { + path: 'world-efp/:geneid?', + element: , + }, + ], + errorElement: , + }, + ], { - path: '/', - element: , - children: [ - { - element: , - }, - { - path: 'cell-efp/:geneid?', - element: , - }, - { - path: 'publications/:geneid?', - element: , - }, - { - path: 'chromosome/:geneid?', - element: , - }, - { - path: 'plant-efp/:geneid?', - element: , - }, - { - path: 'experiment-efp/:geneid?', - element: , - }, - { - path: 'gene-info/:geneid?', - element: , - }, - { - path: 'get-started/:geneid?', - element: , - }, - { - path: 'navigator-view/:geneid?', - element: , - }, - { - path: 'interactions-view/:geneid?', - element: , - }, - ], - errorElement: , - }, -]) + basename: import.meta.env.BASE_URL ?? '/', + } +) export const queryClient = new QueryClient() diff --git a/Eplant/views/WorldEFP/ClimateOverlay.tsx b/Eplant/views/WorldEFP/ClimateOverlay.tsx new file mode 100644 index 00000000..d6f7dc09 --- /dev/null +++ b/Eplant/views/WorldEFP/ClimateOverlay.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef } from 'react' + +import { useQuery } from '@tanstack/react-query' +import { useMap } from '@vis.gl/react-google-maps' + +import { fetchOverlayTiles, OverlayTileData } from './overlayTiles' +import { OverlayType } from './types' + +const PLACEHOLDER_TILE = '/temp_world_efp/tile-placeholder.png' + +/** + * Normalizes tile coordinates per Google Maps conventions. + * - Does NOT repeat across the y-axis (returns null for out-of-bounds y) + * - Wraps across the x-axis (world repeats horizontally) + * + * Ported from the original WorldView implementation. + */ +function normalizeTileCoord( + coord: google.maps.Point, + zoom: number +): { x: number; y: number } | null { + const tileRange = 1 << zoom + const y = coord.y + let x = coord.x + + if (y < 0 || y >= tileRange) return null + if (x < 0 || x >= tileRange) { + x = ((x % tileRange) + tileRange) % tileRange + } + return { x, y } +} + +interface ClimateOverlayProps { + overlay: OverlayType +} + +const ClimateOverlay = ({ overlay }: ClimateOverlayProps) => { + const map = useMap('WorldEFP') + + // Tile data is fetched independently — doesn't block the map from rendering. + const { data: tileData } = useQuery({ + queryKey: ['overlay-tiles', overlay], + queryFn: () => + fetchOverlayTiles(overlay as Exclude), + enabled: overlay !== OverlayType.None, + }) + + // Keep a ref to the latest tile data so the getTileUrl closure always reads + // the most recent data without needing to recreate the ImageMapType layer. + const tileDataRef = useRef(null) + useEffect(() => { + tileDataRef.current = tileData ?? null + }, [tileData]) + + // Keep a ref to the active layer so we can force a tile refresh when data loads. + const layerRef = useRef(null) + + // Register / unregister the ImageMapType layer whenever the overlay or map changes. + useEffect(() => { + if (!map || !window.google?.maps || overlay === OverlayType.None) return + + const layer = new window.google.maps.ImageMapType({ + getTileUrl: (coord: google.maps.Point, zoom: number): string | null => { + if (zoom > 8) return null + + const normalized = normalizeTileCoord(coord, zoom) + if (!normalized) return null + + const { x, y } = normalized + const tileMap = tileDataRef.current?.tileMap + const url = tileMap?.[`${zoom}_${x}_${y}`] + return url ?? PLACEHOLDER_TILE + }, + tileSize: new window.google.maps.Size(256, 256), + opacity: 0.7, + name: overlay, + }) + + layerRef.current = layer + map.overlayMapTypes.push(layer) + + return () => { + const index = map.overlayMapTypes.getArray().indexOf(layer) + if (index !== -1) map.overlayMapTypes.removeAt(index) + layerRef.current = null + } + }, [map, overlay]) + + // When tile data finishes loading, force a tile refresh by removing and + // re-inserting the layer at the same position so grey placeholders are replaced. + useEffect(() => { + if (!tileData || !map || !layerRef.current) return + const layer = layerRef.current + const index = map.overlayMapTypes.getArray().indexOf(layer) + if (index !== -1) { + map.overlayMapTypes.removeAt(index) + map.overlayMapTypes.insertAt(index, layer) + } + }, [tileData, map]) + + return null +} + +export default ClimateOverlay diff --git a/Eplant/views/WorldEFP/InfoContent.tsx b/Eplant/views/WorldEFP/InfoContent.tsx index 534e57a2..217aed8d 100644 --- a/Eplant/views/WorldEFP/InfoContent.tsx +++ b/Eplant/views/WorldEFP/InfoContent.tsx @@ -11,7 +11,14 @@ const InfoContent = ({ id, mean, std, sampleSize }: InfoContentProps) => { return (

- {id} + + {id.split(//i).map((line, i, arr) => ( + + {line} + {i < arr.length - 1 &&
} +
+ ))} +

Mean: {mean.toFixed(2)}

Standard error: {std.toFixed(2)}

diff --git a/Eplant/views/WorldEFP/MapContainer.tsx b/Eplant/views/WorldEFP/MapContainer.tsx index 2b405eca..4a3aa7dc 100644 --- a/Eplant/views/WorldEFP/MapContainer.tsx +++ b/Eplant/views/WorldEFP/MapContainer.tsx @@ -1,9 +1,8 @@ -import { useCallback } from 'react' +import { useEffect } from 'react' -import { ViewDispatch } from '@eplant/View' -import { useTheme } from '@mui/material' +import { Box, useTheme } from '@mui/material' +import { alpha } from '@mui/material/styles' import { - APIProvider, Map, MapCameraChangedEvent, MapEvent, @@ -14,48 +13,53 @@ import { getColor } from '../eFP/svg' import GeneDistributionChart from '../eFP/Viewer/GeneDistributionChart' import Legend from '../eFP/Viewer/legend' +import ClimateOverlay from './ClimateOverlay' import MapMarker from './MapMarker' -import { WorldEFPAction, WorldEFPData, WorldEFPState } from './types' +import MapTypeSelector from './MapTypeSelector' +import OverlayLegend from './OverlayLegend' +import OverlaySelector from './OverlaySelector' +import { ColorMode, WorldEFPData, WorldEFPState } from './types' interface MapContainerProps { activeData: WorldEFPData state: WorldEFPState - dispatch: ViewDispatch + setState: (state: WorldEFPState) => void } -const MapContainer = ({ activeData, state, dispatch }: MapContainerProps) => { +const MapContainer = ({ activeData, state, setState }: MapContainerProps) => { const theme = useTheme() const map = useMap('WorldEFP') + // set map state on load from cache, url or default + useEffect(() => { + map?.moveCamera({ zoom: state.zoom, center: state.position }) + }, [map]) + const hangleDragEnd = (event: MapEvent) => { const mapPos = map?.getCenter() if (!mapPos) return const coords = { lat: mapPos.lat(), lng: mapPos.lng() } - dispatch({ - type: 'set-map-position', - position: coords, - }) + setState({ ...state, position: coords }) } const handleZoom = (event: MapCameraChangedEvent) => { - dispatch({ - type: 'set-map-zoom', - zoom: event.detail.zoom, - }) + setState({ ...state, zoom: event.detail.zoom }) } return ( + {activeData.positions.map((pos, index) => { const color = getColor( activeData.efpData.groups[index].mean, @@ -77,17 +81,66 @@ const MapContainer = ({ activeData, state, dispatch }: MapContainerProps) => { > ) })} - ({ + position: 'absolute', + left: theme.spacing(2), + top: theme.spacing(2), + zIndex: 10, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + })} + > + setState({ ...state, mapTypeId })} + /> + setState({ ...state, overlay })} + /> + + ({ position: 'absolute', left: theme.spacing(2), bottom: theme.spacing(4), zIndex: 10, + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-end', + gap: theme.spacing(1), })} - colorMode={'absolute'} - data={activeData.efpData} - > - + > + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(1), + padding: theme.spacing(1), + borderRadius: theme.spacing(1), + backgroundColor: alpha(theme.palette.background.active, 0.4), + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.18)', + border: `1px solid ${alpha(theme.palette.background.edge, 0.7)}`, + backdropFilter: 'blur(8px)', + WebkitBackdropFilter: 'blur(8px)', + })} + > + + + + + ) } diff --git a/Eplant/views/WorldEFP/MapTypeSelector.tsx b/Eplant/views/WorldEFP/MapTypeSelector.tsx new file mode 100644 index 00000000..3fac5a1d --- /dev/null +++ b/Eplant/views/WorldEFP/MapTypeSelector.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' + +import MapIcon from '@mui/icons-material/Map' +import { Box, Button, Collapse } from '@mui/material' +import { alpha } from '@mui/material/styles' + +import { MapTypeId, WorldEFPState } from './types' + +const MAP_TYPES = Object.values(MapTypeId) + +type MapTypeSelectorProps = { + mapTypeId: WorldEFPState['mapTypeId'] + onSelect: (mapTypeId: WorldEFPState['mapTypeId']) => void +} + +const MapTypeSelector = ({ mapTypeId, onSelect }: MapTypeSelectorProps) => { + const [isOpen, setIsOpen] = useState(false) + + const handleMapTypeSelect = (nextType: MapTypeId) => { + onSelect(nextType) + setIsOpen(false) + } + + const formatMapTypeLabel = (type: MapTypeId) => + type.charAt(0).toUpperCase() + type.slice(1) + + return ( + ({ + width: isOpen ? theme.spacing(15) : theme.spacing(5), + borderRadius: theme.spacing(1), + backgroundColor: alpha(theme.palette.background.active, 0.7), + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.18)', + border: `1px solid ${alpha(theme.palette.background.active, 0.7)}`, + backdropFilter: 'blur(8px)', + WebkitBackdropFilter: 'blur(8px)', + overflow: 'hidden', + transition: 'width 220ms ease, box-shadow 220ms ease', + })} + > + + + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + padding: theme.spacing(0.5, 1, 1), + })} + > + {MAP_TYPES.map((type) => ( + + ))} + + + + ) +} + +export default MapTypeSelector diff --git a/Eplant/views/WorldEFP/OverlayLegend.tsx b/Eplant/views/WorldEFP/OverlayLegend.tsx new file mode 100644 index 00000000..242f9bd6 --- /dev/null +++ b/Eplant/views/WorldEFP/OverlayLegend.tsx @@ -0,0 +1,107 @@ +import { Box, Typography } from '@mui/material' +import { alpha } from '@mui/material/styles' + +import { OverlayType } from './types' + +interface OverlayLegendConfig { + title: [string, string] + imageSrc: string + max: string + min: string +} + +const LEGEND_CONFIG: Record< + Exclude, + OverlayLegendConfig +> = { + [OverlayType.Precipitation]: { + title: ['Annual', 'Precipitation'], + imageSrc: '/img/climateLegend.png', + max: '10577 mm', + min: '13 mm', + }, + [OverlayType.HistoricalMinTemp]: { + title: ['Minimum', 'Temperature'], + imageSrc: '/img/climateLegendFlip.png', + max: '31.9 °C', + min: '-27.8 °C', + }, + [OverlayType.HistoricalMaxTemp]: { + title: ['Maximum', 'Temperature'], + imageSrc: '/img/climateLegendFlip.png', + max: '31.9 °C', + min: '-27.8 °C', + }, +} + +interface OverlayLegendProps { + overlay: OverlayType +} + +const OverlayLegend = ({ overlay }: OverlayLegendProps) => { + if (overlay === OverlayType.None) return null + + const { title, imageSrc, max, min } = LEGEND_CONFIG[overlay] + + return ( + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(0.5), + padding: theme.spacing(1), + borderRadius: theme.spacing(1), + backgroundColor: alpha(theme.palette.background.active, 0.4), + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.18)', + border: `1px solid ${alpha(theme.palette.background.edge, 0.7)}`, + backdropFilter: 'blur(8px)', + WebkitBackdropFilter: 'blur(8px)', + })} + > + {/* Title */} + + {title.map((line) => ( + ({ + display: 'block', + color: theme.palette.text.primary, + fontWeight: 600, + lineHeight: 1.2, + })} + > + {line} + + ))} + + + {/* Scale image with max/min labels */} + + + + + {max} + + + {min} + + + + + ) +} + +export default OverlayLegend diff --git a/Eplant/views/WorldEFP/OverlaySelector.tsx b/Eplant/views/WorldEFP/OverlaySelector.tsx new file mode 100644 index 00000000..d8a11f62 --- /dev/null +++ b/Eplant/views/WorldEFP/OverlaySelector.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' + +import LayersIcon from '@mui/icons-material/Layers' +import { Box, Button, Collapse } from '@mui/material' +import { alpha } from '@mui/material/styles' + +import { OverlayType, WorldEFPState } from './types' + +const OVERLAY_LABELS: Record = { + [OverlayType.None]: 'None', + [OverlayType.Precipitation]: 'Annual Precip.', + [OverlayType.HistoricalMinTemp]: 'Min. Temp.', + [OverlayType.HistoricalMaxTemp]: 'Max. Temp.', +} + +const OVERLAY_TYPES = Object.values(OverlayType) + +type OverlaySelectorProps = { + overlay: WorldEFPState['overlay'] + onSelect: (overlay: WorldEFPState['overlay']) => void +} + +const OverlaySelector = ({ overlay, onSelect }: OverlaySelectorProps) => { + const [isOpen, setIsOpen] = useState(false) + + const handleSelect = (next: OverlayType) => { + onSelect(next) + setIsOpen(false) + } + + return ( + ({ + width: isOpen ? theme.spacing(20) : theme.spacing(5), + borderRadius: theme.spacing(1), + backgroundColor: alpha(theme.palette.background.active, 0.7), + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.18)', + border: `1px solid ${alpha(theme.palette.background.active, 0.7)}`, + backdropFilter: 'blur(8px)', + WebkitBackdropFilter: 'blur(8px)', + overflow: 'hidden', + transition: 'width 220ms ease, box-shadow 220ms ease', + })} + > + + + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + padding: theme.spacing(0.5, 1, 1), + minWidth: theme.spacing(20), + })} + > + {OVERLAY_TYPES.map((type) => ( + + ))} + + + + ) +} + +export default OverlaySelector diff --git a/Eplant/views/WorldEFP/WorldEFP.tsx b/Eplant/views/WorldEFP/WorldEFP.tsx new file mode 100644 index 00000000..2d9664ad --- /dev/null +++ b/Eplant/views/WorldEFP/WorldEFP.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react' +import { useOutletContext } from 'react-router-dom' + +import GeneticElement from '@eplant/GeneticElement' +import { useURLState } from '@eplant/state/URLStateProvider' +import LoadingPage from '@eplant/UI/Layout/ViewContainer/LoadingPage' +import { ViewContext } from '@eplant/UI/Layout/ViewContainer/types' +import { ViewDataError } from '@eplant/View' +import { useQuery } from '@tanstack/react-query' +import { APIProvider } from '@vis.gl/react-google-maps' + +import { EFPData, EFPGroup } from '../eFP/types' +import GeneDistributionChart from '../eFP/Viewer/GeneDistributionChart' +import MaskModal from '../eFP/Viewer/MaskModal' + +import MapContainer from './MapContainer' +import { + Coordinates, + WorldEFPData, + WorldEFPMicroArrayResponse, + WorldEFPState, + WorldEFPStateSchema, +} from './types' +import WorldEFP from '.' + +export const WorldEFPView = () => { + const { geneticElement } = useOutletContext() + const { state, setState, initializeState } = useURLState() + const [loadAmount, setLoadAmount] = useState(0) + + const { data, isLoading, isError, error } = useQuery< + WorldEFPData, + ViewDataError + >({ + queryKey: [`world-efp-${geneticElement?.id}`], + queryFn: async () => { + return worldEFPLoader(geneticElement, setLoadAmount) + }, + }) + useEffect(() => { + // On mount, initialize state + initializeState(WorldEFPStateSchema) + }, []) + + if (!geneticElement) { + return ( + + ) + } else if (isError) { + return ( + + ) + } else if (isLoading && loadAmount < 100) { + return ( + + ) + } else if (!data || !state) return <> + + return ( + <> + + + + { + setState({ ...state, maskModalVisible: !state.maskModalVisible }) + }} + onSubmit={(threshold) => { + setState({ + ...state, + maskThreshold: threshold, + maskingEnabled: !state.maskingEnabled, + maskModalVisible: !state.maskModalVisible, + }) + }} + /> + + ) +} + +export const worldEFPLoader = async ( + geneticElement: GeneticElement | null, + loadEvent: (loaded: number) => void +) => { + if (!geneticElement) throw ViewDataError.UNSUPPORTED_GENE + + const microArrayDataURL = `https://bar.utoronto.ca/api_dev/microarray_gene_expression/world_efp/arabidopsis/${geneticElement.id}` + const microArrayData: WorldEFPMicroArrayResponse = await fetch( + microArrayDataURL + ) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) + } + return response.json() + }) + .catch((error) => { + console.error('Error fetching map marker data:', error) + throw error + }) + + const positions: Coordinates[] = [] + const groupData: EFPGroup[] = [] + Object.entries(microArrayData.data).forEach(([key, marker]) => { + // Coordinates + positions.push({ + lat: parseFloat(marker.position.lat), + lng: parseFloat(marker.position.lng), + }) + const samples = Object.values(marker.values) + // Sample data + const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length + const efpGroupData = { + name: marker.id, + tissues: [], + mean: mean, + min: Math.min(...samples), + max: Math.max(...samples), + std: Math.sqrt( + samples.reduce((sum, value) => Math.pow(value - mean, 2)) / + (samples.length - 1) + ), + samples: samples.length, + } + + groupData.push(efpGroupData) + }) + + const totalSamples = groupData.reduce((sum, group) => sum + group.samples, 0) + const totalMean = + groupData.reduce((sum, group) => sum + group.mean * group.samples, 0) / + totalSamples + + const efpData = { + groups: groupData, + mean: totalMean, + min: Math.min(...groupData.map((group) => group.min)), + max: Math.max(...groupData.map((group) => group.max)), + std: 0, // This isn't needed, just set to 0 for convenience + samples: totalSamples, + } as EFPData + + return { + positions: positions, + efpData: efpData, + } +} diff --git a/Eplant/views/WorldEFP/index.tsx b/Eplant/views/WorldEFP/index.tsx index 08aa7000..0d0a117d 100644 --- a/Eplant/views/WorldEFP/index.tsx +++ b/Eplant/views/WorldEFP/index.tsx @@ -1,196 +1,46 @@ -// import GeneticElement from '@eplant/GeneticElement' -// import { ViewDataError } from '@eplant/View/viewData' -// import { APIProvider } from '@vis.gl/react-google-maps' +import { StateAction, ViewMetadata } from '@eplant/View' +import BuildRoundedIcon from '@mui/icons-material/BuildRounded' +import ColorLensIcon from '@mui/icons-material/ColorLens' +import PublicIcon from '@mui/icons-material/Public' -// import { EFPData, EFPGroup } from '../eFP/types' -// import MaskModal from '../eFP/Viewer/MaskModal' - -// import WorldEFPIcon from './icon' -// import MapContainer from './MapContainer' -// import { -// Coordinates, -// WorldEFPAction, -// WorldEFPData, -// WorldEFPMicroArrayResponse, -// WorldEFPState, -// } from './types' -// import { ViewMetadata } from '@eplant/View' -// const WorldEFP: ViewMetadata = { -// name: 'World-EFP', -// id: 'World-EFP', -// getInitialState() { -// return { -// position: { lat: 25, lng: 0 }, -// zoom: 2, -// mapTypeId: 'roadmap', -// maskingEnabled: false, -// maskModalVisible: false, -// maskThreshold: 100, -// colorMode: 'absolute', -// } -// }, -// async getInitialData( -// gene: GeneticElement | null, -// loadEvent: (progress: number) => void -// ) { -// if (!gene) throw ViewDataError.UNSUPPORTED_GENE -// const microArrayDataURL = `https://bar.utoronto.ca/api_dev/microarray_gene_expression/world_efp/arabidopsis/${gene.id}` -// const microArrayData: WorldEFPMicroArrayResponse = await fetch( -// microArrayDataURL -// ) -// .then((response) => { -// if (!response.ok) { -// throw new Error(`HTTP error! Status: ${response.status}`) -// } -// return response.json() -// }) -// .catch((error) => { -// console.error('Error fetching map marker data:', error) -// throw error -// }) - -// const positions: Coordinates[] = [] -// const groupData: EFPGroup[] = [] -// Object.entries(microArrayData.data).forEach(([key, marker]) => { -// // Coordinates -// positions.push({ -// lat: parseFloat(marker.position.lat), -// lng: parseFloat(marker.position.lng), -// }) -// const samples = Object.values(marker.values) -// // Sample data -// const mean = -// samples.reduce((sum, value) => sum + value, 0) / samples.length -// const efpGroupData = { -// name: marker.id, -// tissues: [], -// mean: mean, -// min: Math.min(...samples), -// max: Math.max(...samples), -// std: Math.sqrt( -// samples.reduce((sum, value) => Math.pow(value - mean, 2)) / -// (samples.length - 1) -// ), -// samples: samples.length, -// } - -// groupData.push(efpGroupData) -// }) - -// const totalSamples = groupData.reduce( -// (sum, group) => sum + group.samples, -// 0 -// ) -// const totalMean = -// groupData.reduce((sum, group) => sum + group.mean * group.samples, 0) / -// totalSamples - -// const efpData = { -// groups: groupData, -// mean: totalMean, -// min: Math.min(...groupData.map((group) => group.min)), -// max: Math.max(...groupData.map((group) => group.max)), -// std: 0, // This isn't needed, just set to 0 for convenience -// samples: totalSamples, -// } as EFPData - -// return { -// positions: positions, -// efpData: efpData, -// } -// }, -// component({ -// geneticElement, -// dispatch, -// activeData, -// state, -// }: ViewProps) { -// if (!geneticElement) return <> -// return ( -// <> -// -// -// -// dispatch({ type: 'toggle-mask-modal' })} -// onSubmit={(threshold) => -// dispatch({ -// type: 'set-mask-threshold', -// threshold: threshold, -// }) -// } -// /> -// -// ) -// }, -// icon: () => , -// description: '', -// // TODO: If dark theme is active, use ThumbnailDark -// citation({ gene }) { -// return
-// }, -// reducer: (state: WorldEFPState, action: WorldEFPAction) => { -// switch (action.type) { -// case 'toggle-color-mode': -// return { -// ...state, -// colorMode: -// state.colorMode == 'absolute' -// ? ('relative' as const) -// : ('absolute' as const), -// } -// case 'toggle-mask-modal': -// if (state.maskingEnabled) { -// return { -// ...state, -// maskingEnabled: !state.maskingEnabled, -// } -// } else { -// return { -// ...state, -// maskModalVisible: !state.maskModalVisible, -// } -// } -// case 'set-mask-threshold': -// return { -// ...state, -// maskThreshold: action.threshold, -// maskingEnabled: !state.maskingEnabled, -// maskModalVisible: !state.maskModalVisible, -// } -// case 'set-map-position': -// return { -// ...state, -// position: action.position, -// } -// case 'set-map-zoom': -// return { -// ...state, -// zoom: action.zoom, -// } -// default: -// return state -// } -// }, -// actions: [ -// { -// action: { type: 'reset-transform' }, -// render: () => <>Reset pan/zoom, -// }, -// { -// action: { type: 'toggle-color-mode' }, -// render: (props) => <>Toggle data mode: {props.state.colorMode}, -// }, -// { -// action: { type: 'toggle-mask-modal' }, -// render: () => <>Mask data, -// }, -// ], -// } -// export default WorldEFP +import { WorldEFPData, WorldEFPState } from './types' +const WorldEFP: ViewMetadata = { + name: 'World-EFP', + id: 'world-efp', + icon: () => , + description: '', + // TODO: If dark theme is active, use ThumbnailDark + citation({ gene }) { + return
+ }, + actions: [ + { + name: 'Toggle Color Mode', + description: 'Toggle between absolute and relative color modes', + icon: , + mutation: (prevState) => ({ + ...prevState, + colorMode: prevState.colorMode == 'absolute' ? 'relative' : 'absolute', + }), + }, + { + name: 'Toggle Masking', + description: 'Toggle colour masking', + icon: , + mutation: (prevState) => { + if (prevState.maskingEnabled) { + return { + ...prevState, + maskingEnabled: !prevState.maskingEnabled, + } + } else { + return { + ...prevState, + maskModalVisible: !prevState.maskModalVisible, + } + } + }, + }, + ] as StateAction[], +} +export default WorldEFP diff --git a/Eplant/views/WorldEFP/overlayTiles.ts b/Eplant/views/WorldEFP/overlayTiles.ts new file mode 100644 index 00000000..36ff0e8a --- /dev/null +++ b/Eplant/views/WorldEFP/overlayTiles.ts @@ -0,0 +1,53 @@ +import { OverlayType } from './types' + +export type TileMap = Record // "zoom_x_y" -> URL + +export type OverlayTileData = { + tileMap: TileMap + maxZoom: number +} + +const BASE_PATH = '/temp_world_efp' + +/** + * Builds a tileMap by constructing public URLs for all tiles in a grid up to maxZoom. + * At each zoom level z the grid is 2^z x 2^z. + */ +function buildTileMap( + dir: string, + prefix: string, + maxZoom: number +): OverlayTileData { + const tileMap: TileMap = {} + for (let zoom = 0; zoom <= maxZoom; zoom++) { + const count = 1 << zoom + for (let x = 0; x < count; x++) { + for (let y = 0; y < count; y++) { + tileMap[`${zoom}_${x}_${y}`] = + `${BASE_PATH}/${dir}/${prefix}&zoom=${zoom}&x=${x}&y=${y}.png` + } + } + } + return { tileMap, maxZoom } +} + +/** + * Fetches overlay tile data for the given overlay type. + * + * Currently a mock backed by files in public/temp_world_efp. + * Replace each case body with a fetch() call to the real tile API endpoint + * when available — the return type stays the same. + */ +export async function fetchOverlayTiles( + overlay: Exclude +): Promise { + switch (overlay) { + case OverlayType.Precipitation: + return buildTileMap('AnnualPrecip', 'Annual_Precipitation', 2) + case OverlayType.HistoricalMinTemp: + // No tiles yet — empty map causes getTileUrl to fall back to placeholder + return { tileMap: {}, maxZoom: 2 } + case OverlayType.HistoricalMaxTemp: + return { tileMap: {}, maxZoom: 2 } + } +} diff --git a/Eplant/views/WorldEFP/types.tsx b/Eplant/views/WorldEFP/types.tsx index 9c7ba1e3..89434f2d 100644 --- a/Eplant/views/WorldEFP/types.tsx +++ b/Eplant/views/WorldEFP/types.tsx @@ -1,19 +1,44 @@ -import { ColorMode, EFPData } from '../eFP/types' +import { z } from 'zod' + +import { EFPData } from '../eFP/types' export type Coordinates = { lat: number; lng: number } -type MapTypeId = 'roadmap' | 'satellite' | 'hybrid' | 'terrain' +export enum MapTypeId { + Roadmap = 'roadmap', + Satellite = 'satellite', + Hybrid = 'hybrid', + Terrain = 'terrain', +} + +export enum ColorMode { + Absolute = 'absolute', + Relative = 'relative', +} -export type WorldEFPState = { - position: Coordinates - zoom: number - mapTypeId: MapTypeId - maskModalVisible: boolean - maskingEnabled: boolean - maskThreshold: number - colorMode: ColorMode +export enum OverlayType { + None = 'None', + Precipitation = 'Precipitation', + HistoricalMinTemp = 'HistoricalMinTemp', + HistoricalMaxTemp = 'HistoricalMaxTemp', } +export const WorldEFPStateSchema = z.object({ + position: z.object({ + lat: z.number().default(25), + lng: z.number().default(0), + }), + zoom: z.number().min(0).max(8).default(2), + mapTypeId: z.nativeEnum(MapTypeId).default(MapTypeId.Roadmap), + maskModalVisible: z.boolean().default(false), + maskingEnabled: z.boolean().default(false), + maskThreshold: z.number().min(0).max(100).default(100), + colorMode: z.nativeEnum(ColorMode).default(ColorMode.Absolute), + overlay: z.nativeEnum(OverlayType).default(OverlayType.None), +}) + +export type WorldEFPState = z.infer + export type WorldEFPData = { positions: Coordinates[] efpData: EFPData @@ -23,7 +48,6 @@ export interface WorldEFPMicroArrayResponse { wasSuccessful: boolean data: { [key: string]: WorldEFPMicroArrayData } } - export interface WorldEFPMicroArrayData { source: string id: string @@ -34,10 +58,3 @@ export interface WorldEFPMicroArrayData { values: { [key: string]: number } code: string } -export type WorldEFPAction = - | { type: 'toggle-color-mode' } - | { type: 'toggle-masking' } - | { type: 'toggle-mask-modal' } - | { type: 'set-mask-threshold'; threshold: number } - | { type: 'set-map-position'; position: Coordinates } - | { type: 'set-map-zoom'; zoom: number } diff --git a/Eplant/views/eFP/Viewer/EFPViewer.tsx b/Eplant/views/eFP/Viewer/EFPViewer.tsx index 42b815d5..c9bf6e4a 100644 --- a/Eplant/views/eFP/Viewer/EFPViewer.tsx +++ b/Eplant/views/eFP/Viewer/EFPViewer.tsx @@ -208,7 +208,7 @@ export const EFPViewer = ({ /> setViewState({ ...state, maskModalVisible: false }) diff --git a/Eplant/views/eFP/Viewer/GeneDistributionChart.tsx b/Eplant/views/eFP/Viewer/GeneDistributionChart.tsx index b6afbc1c..4513794d 100644 --- a/Eplant/views/eFP/Viewer/GeneDistributionChart.tsx +++ b/Eplant/views/eFP/Viewer/GeneDistributionChart.tsx @@ -1,13 +1,19 @@ import * as React from 'react' -import { SVGProps, useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' -import { useDarkMode } from '@eplant/state' -import { Mail } from '@mui/icons-material' import { useTheme } from '@mui/material' import { EFPData } from '../types' -const GeneDistributionChart = ({ data }: { data: EFPData }) => { +type GeneDistributionChartProps = { + data: EFPData + containerStyle?: React.CSSProperties +} + +const GeneDistributionChart = ({ + data, + containerStyle, +}: GeneDistributionChartProps) => { const theme = useTheme() const [geneRanking, setGeneRanking] = useState<{ [key: string]: string @@ -41,10 +47,11 @@ const GeneDistributionChart = ({ data }: { data: EFPData }) => { style={{ display: 'flex', flexDirection: 'column', - position: 'relative', + position: 'absolute', zIndex: 10, width: '100%', height: '10%', + ...containerStyle, }} > {geneRanking ? ( diff --git a/Eplant/views/eFP/Viewer/MaskModal.tsx b/Eplant/views/eFP/Viewer/MaskModal.tsx index 56efaacf..bd90b2ac 100644 --- a/Eplant/views/eFP/Viewer/MaskModal.tsx +++ b/Eplant/views/eFP/Viewer/MaskModal.tsx @@ -10,25 +10,28 @@ import { useTheme, } from '@mui/material' -import { EFPViewerState } from './types' - // Modal component with a slider interface MaskModalProps { isVisible: boolean - state: EFPViewerState + threshold: number onClose: () => void onSubmit: (threshhold: number) => void } -const MaskModal = ({ isVisible, state, onClose, onSubmit }: MaskModalProps) => { - const [sliderValue, setSliderValue] = useState(state.maskThreshold) +const MaskModal = ({ + isVisible, + threshold, + onClose, + onSubmit, +}: MaskModalProps) => { + const [sliderValue, setSliderValue] = useState(threshold) const theme = useTheme() const handleSliderChange = (event: Event, newValue: number | number[]) => { setSliderValue(newValue as number) } const handleClose = () => { - setSliderValue(state.maskThreshold) + setSliderValue(threshold) onClose() } diff --git a/index.html b/index.html index a00036c6..dc22a37e 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,30 @@ - + - + Eplant diff --git a/package.json b/package.json index 571621d0..9ee5b673 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Eplant", "main": "index.ts", "type": "module", + "homepage": "https://bioanalyticresource.github.io/ePlant/", "scripts": { "test": "jest --passWithNoTests", "dev": "vite", diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000..2a3ccbdd --- /dev/null +++ b/public/404.html @@ -0,0 +1,35 @@ + + + + + + Redirecting… + + + + diff --git a/public/img/climateLegend.png b/public/img/climateLegend.png new file mode 100644 index 00000000..69f06122 Binary files /dev/null and b/public/img/climateLegend.png differ diff --git a/public/img/climateLegendFlip.png b/public/img/climateLegendFlip.png new file mode 100644 index 00000000..3842c0da Binary files /dev/null and b/public/img/climateLegendFlip.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=0&x=0&y=0.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=0&x=0&y=0.png new file mode 100644 index 00000000..ed5b940e Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=0&x=0&y=0.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=0&y=0.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=0&y=0.png new file mode 100644 index 00000000..05922f54 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=0&y=0.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=0&y=1.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=0&y=1.png new file mode 100644 index 00000000..c0390a39 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=0&y=1.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=1&y=0.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=1&y=0.png new file mode 100644 index 00000000..e2829bd0 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=1&y=0.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=1&y=1.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=1&y=1.png new file mode 100644 index 00000000..2e3f1c26 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=1&x=1&y=1.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=0.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=0.png new file mode 100644 index 00000000..c4990492 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=0.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=1.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=1.png new file mode 100644 index 00000000..06f4bfc8 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=1.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=2.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=2.png new file mode 100644 index 00000000..ba589a24 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=2.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=3.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=3.png new file mode 100644 index 00000000..409583ed Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=0&y=3.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=0.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=0.png new file mode 100644 index 00000000..8c629e25 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=0.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=1.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=1.png new file mode 100644 index 00000000..44e3a5b9 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=1.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=2.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=2.png new file mode 100644 index 00000000..e35543db Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=2.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=3.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=3.png new file mode 100644 index 00000000..409583ed Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=1&y=3.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=0.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=0.png new file mode 100644 index 00000000..db7bc420 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=0.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=1.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=1.png new file mode 100644 index 00000000..3835c650 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=1.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=2.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=2.png new file mode 100644 index 00000000..477b1095 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=2.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=3.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=3.png new file mode 100644 index 00000000..409583ed Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=2&y=3.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=0.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=0.png new file mode 100644 index 00000000..caebf89d Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=0.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=1.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=1.png new file mode 100644 index 00000000..8b5b0205 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=1.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=2.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=2.png new file mode 100644 index 00000000..d5846eb2 Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=2.png differ diff --git a/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=3.png b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=3.png new file mode 100644 index 00000000..409583ed Binary files /dev/null and b/public/temp_world_efp/AnnualPrecip/Annual_Precipitation&zoom=2&x=3&y=3.png differ diff --git a/public/temp_world_efp/tile-placeholder.png b/public/temp_world_efp/tile-placeholder.png new file mode 100644 index 00000000..fa0a6410 Binary files /dev/null and b/public/temp_world_efp/tile-placeholder.png differ