@@ -58,6 +72,7 @@ export default connect(
width={'100%'}
year={year}
data={Object.values(productAvailability[year])}
+ onclick={handleHeatMapClick}
/>
);
})}
diff --git a/src/app-pages/products/product-details/water-year-heatmap.js b/src/app-pages/products/product-details/water-year-heatmap.js
index aaba731..fcb0163 100644
--- a/src/app-pages/products/product-details/water-year-heatmap.js
+++ b/src/app-pages/products/product-details/water-year-heatmap.js
@@ -7,7 +7,7 @@ import { mergeRefs } from '../../../utils';
// width to height ratio to make our cells square
const ratio = 640 / 112;
-export default function WaterYearHeatMap({ year = 2022, data }) {
+export default function WaterYearHeatMap({ year = 2022, data, onclick }) {
const ticks = useMemo(() => {
const waterYearStart = new Date(`10-01-${year - 1}`);
const waterYearEnd = new Date(`10-01-${year}`);
@@ -52,11 +52,30 @@ export default function WaterYearHeatMap({ year = 2022, data }) {
x: (d) => d.x,
y: (d) => d.y,
fill: (d) => (d.count ? d.count : -1),
- title: (d, i) => {
- return `${d.date.toLocaleDateString()} - ${d.count} files`;
- },
- text: (d) => d.count,
inset: 0.6,
+ channels: {
+ date: (d) => d.date.toLocaleDateString(),
+ files: 'count',
+ },
+ tip: {
+ format: {
+ x: false,
+ y: false,
+ fill: false,
+ date: true,
+ files: true,
+ },
+ },
+ render(index, scales, values, dimensions, context, next) {
+ const g = next(index, scales, values, dimensions, context);
+ for (let i = 0; i < index.length; i++) {
+ g.childNodes[i].onclick = () => {
+ let date = values.channels.date.value[i];
+ onclick(date);
+ };
+ }
+ return g;
+ },
}),
// Possibly turn this on via config?
// Plot.text(data, {
@@ -68,7 +87,7 @@ export default function WaterYearHeatMap({ year = 2022, data }) {
});
elRef.current.append(Chart);
return () => Chart.remove();
- }, [elRef, data, ticks, width]);
+ }, [elRef, data, ticks, width, onclick]);
return (
diff --git a/src/app-pages/products/products-map/map.js b/src/app-pages/products/products-map/map.js
deleted file mode 100644
index cafc5a3..0000000
--- a/src/app-pages/products/products-map/map.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { connect } from 'redux-bundler-react';
-
-export default connect(function Map() {
- return (
-
-
-
-
- Coming Soon
-
-
- View product footprints and create spatial queries for downloads
- here soon.
-
-
-
-
- );
-});
diff --git a/src/app-pages/products/products-table/products-table-row.js b/src/app-pages/products/products-table/products-table-row.js
index 27512ac..ec956ec 100644
--- a/src/app-pages/products/products-table/products-table-row.js
+++ b/src/app-pages/products/products-table/products-table-row.js
@@ -135,6 +135,14 @@ export default connect(
addSuffix: true,
})}
+ {product.last_forecast_version != null ? (
+
+ Latest Forecast:{' '}
+ {formatDistanceToNow(new Date(product.last_forecast_version), {
+ addSuffix: true,
+ })}
+
+ ) : null}
>
) : (
Coming Soon
diff --git a/src/app-pages/products/products.js b/src/app-pages/products/products.js
index 9d43873..c7b8d10 100644
--- a/src/app-pages/products/products.js
+++ b/src/app-pages/products/products.js
@@ -1,14 +1,26 @@
-import { useCallback, useState } from 'react';
+import { useCallback, useState, useRef, useEffect } from 'react';
+import {
+ ChevronDoubleLeftIcon,
+ ChevronDoubleRightIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ ArrowPathIcon,
+} from '@heroicons/react/24/outline';
+import { CalendarIcon } from '@heroicons/react/24/solid';
import { connect } from 'redux-bundler-react';
import DateRangeSlider from './date-range-slider';
import ProductsTable from './products-table/products-table';
-import ProductsMap from './products-map/map';
+import CumulusMap from '../../app-components/cumulus-map';
import ButtonGroup from '../../app-components/button-group/button-group';
import ButtonGroupButton from '../../app-components/button-group/button-group-button';
import FilterPanel from './filter-panel';
import TagFilter from './tag-filter';
import ParameterFilter from './parameter-filter';
import DownloadModal from './download-modal';
+import DatePicker from 'react-date-picker';
+import 'react-date-picker/dist/DatePicker.css';
+import 'react-calendar/dist/Calendar.css';
+import './DatePickerOverrides.css';
export default connect(
'selectAuthIsLoggedIn',
@@ -18,11 +30,13 @@ export default connect(
'selectProductDateRangeFrom',
'selectProductDateRangeTo',
'selectProductSelectSelected',
+ 'selectProductFilterRequiredTags',
'doModalOpen',
'doProductFilterSetFilterString',
'doProductFilterSetDateFrom',
'doProductFilterSetDateTo',
'doProductFilterSetApplyDateFilter',
+ 'doProductFilterSetRequiredTags',
({
authIsLoggedIn,
productFilterResults: products,
@@ -31,17 +45,71 @@ export default connect(
productDateRangeFrom: rangeFrom,
productDateRangeTo: rangeTo,
productSelectSelected: selectedProducts,
+ productFilterRequiredTags: requiredTags,
doModalOpen,
doProductFilterSetFilterString: setFilterString,
doProductFilterSetDateFrom: setFilterDateFrom,
doProductFilterSetDateTo: setFilterDateTo,
doProductFilterSetApplyDateFilter: setApplyDateFilter,
+ doProductFilterSetRequiredTags: setRequiredTags,
}) => {
+ // Tab state - 'primary' or 'all'
+ const [activeTab, setActiveTab] = useState('primary');
+
// show / hide the filter panel
const [filtersActive, setFiltersActive] = useState(false);
// could use a boolean here, but just in case we'll use a string key so we could add more options later
- const [activeView, setActiveView] = useState('table');
+ // const [activeView, setActiveView] = useState('table');
+ const activeView = 'table';
+
+ // Show/hide region definition map
+ const [showRegionMap, setShowRegionMap] = useState(false);
+ const [mapHeight, setMapHeight] = useState(500);
+ const mapContainerRef = useRef(null);
+ const isResizing = useRef(false);
+
+ // Handle mouse down on resize handle
+ const handleMouseDown = useCallback((e) => {
+ isResizing.current = true;
+ e.preventDefault();
+ }, []);
+
+ // Handle mouse move for resizing
+ useEffect(() => {
+ const handleMouseMove = (e) => {
+ if (!isResizing.current || !mapContainerRef.current) return;
+
+ const containerTop = mapContainerRef.current.getBoundingClientRect().top;
+ const newHeight = e.clientY - containerTop;
+
+ // Constrain height between min and max
+ const constrainedHeight = Math.min(Math.max(newHeight, 400), window.innerHeight * 0.9);
+ setMapHeight(constrainedHeight);
+ };
+
+ const handleMouseUp = () => {
+ isResizing.current = false;
+ };
+
+ if (showRegionMap) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }
+ }, [showRegionMap]);
+
+ const [startDate, setStartDate] = useState(rangeFrom);
+ const [endDate, setEndDate] = useState(rangeTo);
+
+ useEffect(() => {
+ setStartDate(rangeFrom);
+ setEndDate(rangeTo);
+ }, [rangeFrom, rangeTo]);
const dateUpdateCallback = useCallback(
(e) => {
@@ -50,12 +118,38 @@ export default connect(
},
[setFilterDateFrom, setFilterDateTo]
);
+
// open the download modal
const handleDownloadClick = useCallback(() => {
doModalOpen(DownloadModal);
}, [doModalOpen]);
+ // Handle tab switching and apply tag filter accordingly
+ const handleTabChange = useCallback((tab) => {
+ setActiveTab(tab);
+ const primarySourceTagId = '8a7f4e6b-3c2d-4a9f-b1e5-9d8c7a6f5e4d';
+
+ if (tab === 'primary') {
+ // Require "primary source" tag (exclusive filter)
+ if (!requiredTags.includes(primarySourceTagId)) {
+ setRequiredTags([...requiredTags, primarySourceTagId]);
+ }
+ } else {
+ // Remove "primary source" from required tags to show all sources
+ setRequiredTags(requiredTags.filter(tag => tag !== primarySourceTagId));
+ }
+ }, [setRequiredTags, requiredTags]);
+
+ // Apply the initial filter when component mounts
+ useEffect(() => {
+ const primarySourceTagId = '8a7f4e6b-3c2d-4a9f-b1e5-9d8c7a6f5e4d';
+ // Only initialize once
+ if (activeTab === 'primary' && requiredTags.length === 0) {
+ setRequiredTags([primarySourceTagId]);
+ }
+ }, [activeTab, requiredTags, setRequiredTags]);
+
return (
<>
@@ -83,24 +177,6 @@ export default connect(
-
- {
- setActiveView('table');
- }}
- >
- Table
-
- {
- setActiveView('map');
- }}
- >
- Map
-
-
-
+
+
+
+
+
{applyDateFilter ? (
-
+
+
}
+ calendarProps={{
+ prevLabel:
,
+ nextLabel:
,
+ prev2Label:
,
+ next2Label:
,
+ }}
+ />
+
}
+ calendarProps={{
+ prevLabel:
,
+ nextLabel:
,
+ prev2Label:
,
+ next2Label:
,
+ }}
+ />
+
+
+ ) : null}
+
+ {applyDateFilter ? (
+
) : null}
+
+ {showRegionMap ? (
+
+
+
+ {/* Resize handle */}
+
+
+
+ ) : null}
+
+
+ {/* Tab Navigation */}
+
+
+
@@ -233,7 +422,10 @@ export default connect(
{activeView === 'table' ? (
) : (
-
+
)}
diff --git a/src/index.css b/src/index.css
index a35b8f5..dc8311f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -9,3 +9,35 @@
.thumb-pointer-events-auto::-webkit-slider-thumb {
pointer-events: auto;
}
+
+.leaflet-container {
+ width: 100%;
+ min-height: 400px;
+ height: 100%;
+}
+
+.map-container {
+ height: 100%;
+ width: 100%;
+ position: relative;
+}
+
+.map-container .leaflet-container {
+ height: 100%;
+ width: 100%;
+}
+
+/* Custom cursor for resize handle */
+.cursor-ns-resize {
+ cursor: ns-resize !important;
+}
+
+.leaflet-marker-pane {
+ z-index: 999 !important;
+}
+
+.easy-button-button .button-state {
+ display: flex !important;
+ justify-content: center;
+ align-items: center;
+}
diff --git a/src/index.js b/src/index.js
index 5027b74..19d432a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -9,6 +9,9 @@ import getStore from './app-bundles';
import App from './App';
import cache from './cache';
+import 'leaflet-easybutton/src/easy-button.js';
+import 'leaflet-easybutton/src/easy-button.css';
+
const root = ReactDOM.createRoot(document.getElementById('root'));
cache.getAll().then((initialData) => {