diff --git a/src/components/App.scss b/src/components/App.scss index 04a5e4e..f947f4a 100755 --- a/src/components/App.scss +++ b/src/components/App.scss @@ -24,6 +24,10 @@ background-color: #fff; } + .info-icon { + cursor: pointer; + } + .header-divider { width: 313px; border: solid 1px #979797; diff --git a/src/components/App.tsx b/src/components/App.tsx index 55156aa..731d053 100755 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -26,7 +26,7 @@ const kInitialDimensions = { export const App = () => { const { state, setState } = useStateContext(); - const { createNOAAItems } = useCODAPApi(); + const { filterItems, createNOAAItems } = useCODAPApi(); const [statusMessage, setStatusMessage] = useState(""); const [isFetching, setIsFetching] = useState(false); const { showModal } = state; @@ -52,11 +52,15 @@ export const App = () => { }; const fetchSuccessHandler = async (data: any) => { - const {stationTimezoneOffset, weatherStation, selectedFrequency, startDate, endDate, units} = state; - if (data && weatherStation) { + const {startDate, endDate, units, selectedFrequency, + weatherStation, timezone} = state; + const allDefined = (startDate && endDate && units && selectedFrequency && + weatherStation && timezone); + + if (data && allDefined) { const formatDataProps = { data, - stationTimezoneOffset, + timezone, weatherStation, frequency: selectedFrequency, startDate, @@ -64,11 +68,13 @@ export const App = () => { units }; const dataRecords = formatData(formatDataProps); + const items = Array.isArray(dataRecords) ? dataRecords : [dataRecords]; + const filteredItems = filterItems(items); setStatusMessage("Sending weather records to CODAP"); - await createNOAAItems(dataRecords, getSelectedDataTypes()).then( + await createNOAAItems(filteredItems, getSelectedDataTypes()).then( function (result: any) { setIsFetching(false); - setStatusMessage(`Retrieved ${dataRecords.length} cases`); + setStatusMessage(`Retrieved ${filteredItems.length} cases`); return result; }, function (msg: string) { @@ -100,9 +106,12 @@ export const App = () => { }; const handleGetData = async () => { - const { location, startDate, endDate, selectedFrequency, weatherStation, stationTimezoneOffset } = state; - const attributes = state.frequencies[selectedFrequency].attrs.map(attr => attr.name); - if (location && attributes && startDate && endDate && weatherStation && selectedFrequency) { + const { location, startDate, endDate, weatherStation, frequencies, + selectedFrequency, timezone } = state; + const attributes = frequencies[selectedFrequency].attrs.map(attr => attr.name); + const allDefined = (startDate && endDate && location && weatherStation && timezone); + + if (allDefined) { const isEndDateAfterStartDate = endDate.getTime() >= startDate.getTime(); if (isEndDateAfterStartDate) { setStatusMessage("Fetching weather records from NOAA"); @@ -112,7 +121,7 @@ export const App = () => { frequency: selectedFrequency, weatherStation, attributes, - stationTimezoneOffset + gmtOffset: timezone.gmtOffset }); try { const tRequest = new Request(tURL); @@ -145,7 +154,7 @@ export const App = () => {
Retrieve weather data from observing stations. - +
diff --git a/src/components/attribute-filter.scss b/src/components/attribute-filter.scss index 743d2bd..8ed96a5 100644 --- a/src/components/attribute-filter.scss +++ b/src/components/attribute-filter.scss @@ -3,7 +3,7 @@ $filter-background-green: rgba(90, 249, 90, 0.25); $filter-green: #2dbe5e; .attribute-filter-container { - + padding-bottom: 16px; .table-header { color: rgba(0, 0, 0, 0.48); font-size: 10px; @@ -26,6 +26,7 @@ $filter-green: #2dbe5e; &.units-header { min-width: 36px; background-color: rgba(126, 126, 126, 0.3); + cursor: pointer; } &.filter-header { width: 41px; @@ -60,6 +61,7 @@ $filter-green: #2dbe5e; color: #177991; font-size: 10px; box-sizing: border-box; + cursor: pointer; &.filtering { background-color: $filter-background-green; @@ -86,7 +88,7 @@ $filter-green: #2dbe5e; } } -table tr:nth-child(odd) { +table tr:nth-child(even) { background-color: rgba(216, 216, 216, 0.3); } @@ -130,6 +132,7 @@ table tr:nth-child(odd) { text-align: right; color: #177991; margin: 0 3px; + cursor: pointer; } svg { margin-right: 3px; diff --git a/src/components/attribute-selector.scss b/src/components/attribute-selector.scss index 55ab762..d12147a 100644 --- a/src/components/attribute-selector.scss +++ b/src/components/attribute-selector.scss @@ -78,6 +78,7 @@ font-size: 12px; font-weight: 500; color: #a2a2a2; + cursor: pointer; &:hover { border: solid 1px rgba(0, 144, 164, 0.25); diff --git a/src/components/attribute-selector.tsx b/src/components/attribute-selector.tsx index 4995f1e..1723933 100644 --- a/src/components/attribute-selector.tsx +++ b/src/components/attribute-selector.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import classnames from "classnames"; import { useStateContext } from "../hooks/use-state"; import { dailyMonthlyAttrMap, hourlyAttrMap } from "../types"; @@ -15,6 +15,14 @@ export const AttributesSelector = () => { const attributeNamesList = selectedFrequency === "hourly" ? hourlyAttributeNames : dailyMonthlyAttributeNames; const selectedAttrsAndFiltersForFrequency = frequencies[selectedFrequency]; + useEffect(() => { + if (frequencies[selectedFrequency].attrs.length === attributeList.length) { + setAllSelected(true); + } else { + setAllSelected(false); + } + }, [attributeList.length, frequencies, selectedFrequency]); + const handleUnitsClicked = () => { setState(draft => { draft.units = draft.units === "standard" ? "metric" : "standard"; diff --git a/src/components/location-picker.scss b/src/components/location-picker.scss index 2674a4b..d5de205 100644 --- a/src/components/location-picker.scss +++ b/src/components/location-picker.scss @@ -123,6 +123,9 @@ margin-top: 2px; margin-left: -37px; width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; &.geoname-candidate { background-color: #ddeff1; diff --git a/src/components/location-picker.tsx b/src/components/location-picker.tsx index da3b11a..1ed1a0f 100644 --- a/src/components/location-picker.tsx +++ b/src/components/location-picker.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useRef, useState } from "react"; import classnames from "classnames"; +import { createMap, selectStations } from "../utils/codapHelpers"; import { autoComplete, geoLocSearch } from "../utils/geonameSearch"; +import { kStationsCollectionName, geonamesUser, kOffsetMap, timezoneServiceURL } from "../constants"; import { useStateContext } from "../hooks/use-state"; import { IPlace } from "../types"; import { findNearestActiveStation } from "../utils/getWeatherStations"; @@ -12,7 +14,8 @@ import CurrentLocationIcon from "../assets/images/icon-current-location.svg"; import "./location-picker.scss"; export const LocationPicker = () => { - const {state, setState} = useStateContext(); + const { state, setState } = useStateContext(); + const { location, units, weatherStation, weatherStationDistance } = state; const [showMapButton, setShowMapButton] = useState(false); const [isEditing, setIsEditing] = useState(false); const [locationPossibilities, setLocationPossibilities] = useState([]); @@ -22,21 +25,14 @@ export const LocationPicker = () => { const locationDivRef = useRef(null); const locationInputEl = useRef(null); const locationSelectionListEl = useRef(null); - const selectedLocation = state.location; - const unit = state.units; - const unitDistanceText = unit === "standard" ? "mi" : "km"; - const stationDistance = state.weatherStationDistance && unit === "standard" - ? Math.round((state.weatherStationDistance * 0.6 * 10) / 10) - :state.weatherStationDistance && Math.round(state.weatherStationDistance * 10) / 10; - - const handleOpenMap = () => { - //send request to CODAP to open map with available weather stations - }; + const unitDistanceText = units === "standard" ? "mi" : "km"; + const stationDistance = weatherStationDistance && units === "standard" + ? Math.round((weatherStationDistance * 0.6 * 10) / 10) + : weatherStationDistance && Math.round(weatherStationDistance * 10) / 10; useEffect(() => { if (locationInputEl.current?.value === "") { setShowSelectionList(false); - // setSelectedLocation(undefined); } }, [locationInputEl.current?.value]); @@ -47,19 +43,45 @@ export const LocationPicker = () => { }, [isEditing]); useEffect(() => { - if (selectedLocation) { - findNearestActiveStation(selectedLocation.latitude, selectedLocation.longitude, 80926000, "present") + if (location) { + findNearestActiveStation(location.latitude, location.longitude, 80926000, "present") .then(({station, distance}) => { if (station) { setState((draft) => { draft.weatherStation = station; draft.weatherStationDistance = distance; }); + + const fetchTimezone = async (lat: number, long: number) => { + let url = `${timezoneServiceURL}?lat=${lat}&lng=${long}&username=${geonamesUser}`; + let res = await fetch(url); + if (res) { + if (res.ok) { + const timezoneData = await res.json(); + const { gmtOffset } = timezoneData as { gmtOffset: keyof typeof kOffsetMap }; + setState((draft) => { + draft.timezone = { + gmtOffset, + name: kOffsetMap[gmtOffset] + }; + }); + } else { + console.warn(res.statusText); + } + } else { + console.warn(`Failed to fetch timezone data for ${location}`); + } + }; + fetchTimezone(location.latitude, location.longitude); } }); + } else { + setState((draft) => { + draft.timezone = undefined; + }); } // eslint-disable-next-line react-hooks/exhaustive-deps - },[selectedLocation]); + }, [location]); const getLocationList = () => { if (locationInputEl.current) { @@ -178,17 +200,26 @@ export const LocationPicker = () => { setIsEditing(true); }; + const handleOpenMap = () => { + if (weatherStation) { + createMap(kStationsCollectionName, {width: 500, height: 350}, [weatherStation.latitude, weatherStation.longitude], 7); + selectStations([weatherStation.name]); + } else if (location) { + createMap(kStationsCollectionName, {width: 500, height: 350}, [location.latitude, location.longitude], 7); + } + }; + return (
Location - { selectedLocation && !isEditing && + { location && !isEditing &&
- { state.weatherStation && + { weatherStation && <> - {state.weatherStationDistance && + {weatherStationDistance && ({stationDistance} {unitDistanceText}) } - {state.weatherStation?.name} + {weatherStation?.name} {/* hide this for now until implemented*/} } @@ -200,10 +231,10 @@ export const LocationPicker = () => {
- { selectedLocation && !isEditing + { location && !isEditing ?
Stations near - {state.location?.name} + {location?.name}
: diff --git a/src/constants.ts b/src/constants.ts index 72cee98..b833808 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -145,3 +145,13 @@ export const kWeatherStationCollectionAttrs = [ }, } ]; + +export const kOffsetMap = { + "-4": "AST", + "-5": "EST", + "-6": "CST", + "-7": "MST", + "-8": "PST", + "-9": "AKST", + "-10": "HST" +}; diff --git a/src/hooks/use-codap-api.tsx b/src/hooks/use-codap-api.tsx index a1348f2..3cc3073 100644 --- a/src/hooks/use-codap-api.tsx +++ b/src/hooks/use-codap-api.tsx @@ -1,4 +1,4 @@ -import { Attribute, Collection, DataContext, IDataType } from "../types"; +import { Attribute, Collection, DataContext, IDataType, IItem } from "../types"; import { IResult, codapInterface, createItems, getDataContext } from "@concord-consortium/codap-plugin-api"; import { DSCollection1, DSCollection2, DSName } from "../constants"; import { useStateContext } from "./use-state"; @@ -138,17 +138,55 @@ export const useCODAPApi = () => { return Promise.all(promises); }; - const arrayify = (value: any) => { - return Array.isArray(value) ? value : [value]; + const filterItems = (items: IItem[]) => { + const { selectedFrequency, frequencies } = state; + const { attrs, filters } = frequencies[selectedFrequency]; + const filteredItems = items.filter((item: IItem) => { + const allFiltersMatch: boolean[] = []; + filters.forEach((filter) => { + const { attribute, operator } = filter; + const attrKey = attrs.find((attr) => attr.name === attribute)?.abbr; + if (attrKey) { + const itemValue = Number(item[attrKey]); + if (operator === "equals") { + allFiltersMatch.push(itemValue === filter.value); + } else if (operator === "doesNotEqual") { + allFiltersMatch.push(itemValue !== filter.value); + } else if (operator === "greaterThan") { + allFiltersMatch.push(itemValue > filter.value); + } else if (operator === "lessThan") { + allFiltersMatch.push(itemValue < filter.value); + } else if (operator === "greaterThanOrEqualTo") { + allFiltersMatch.push(itemValue >= filter.value); + } else if (operator === "lessThanOrEqualTo") { + allFiltersMatch.push(itemValue <= filter.value); + } else if (operator === "between") { + const { lowerValue, upperValue } = filter; + allFiltersMatch.push(itemValue > lowerValue && itemValue < upperValue); + } else if (operator === "top" || operator === "bottom") { + const sortedItems = items.sort((a, b) => { + return Number(b[attrKey]) - Number(a[attrKey]); + }); + const end = operator === "top" ? filter.value : sortedItems.length; + const itemsToCheck = sortedItems.slice(end - filter.value, end); + allFiltersMatch.push(itemsToCheck.includes(item)); + } else if (operator === "aboveMean" || operator === "belowMean") { + const mean = items.reduce((acc, i) => acc + Number(i[attrKey]), 0) / items.length; + const expression = operator === "aboveMean" ? itemValue > mean : itemValue < mean; + allFiltersMatch.push(expression); + } + } + }); + return allFiltersMatch.every((match) => match === true); + }); + return filteredItems; }; - const createNOAAItems = async (dataRecords: any, dataTypes: IDataType[]) => { + const createNOAAItems = async (items: IItem[], dataTypes: IDataType[]) => { await updateWeatherDataset(dataTypes); - const items = arrayify(dataRecords); // eslint-disable-next-line no-console - console.log("noaa-cdo ... createNOAAItems with " + dataRecords.length + " case(s)"); + console.log("noaa-cdo ... createNOAAItems with " + items.length + " case(s)"); await createItems(DSName, items); - await codapInterface.sendRequest({ "action": "create", "resource": "component", @@ -161,6 +199,7 @@ export const useCODAPApi = () => { }; return { + filterItems, createNOAAItems }; }; diff --git a/src/types.ts b/src/types.ts index 3093a1c..15274a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,6 +100,11 @@ interface IWeatherStationID { id: string; } +export interface ITimeZone { + gmtOffset: string; + name: string; +} + export interface IState { location?: IPlace; weatherStation?: IWeatherStation; @@ -112,8 +117,7 @@ export interface IState { endDate?: Date; units: IUnits; showModal?: "info" | "data-return-warning"; - stationTimezoneOffset?: number; - stationTimezoneName?: string; + timezone?: ITimeZone; didUserSelectDate: boolean; } @@ -133,7 +137,7 @@ export const dailyMonthlyAttrMap: AttrType[] = [ {name: "Average temperature", abbr: "tAvg", unit: unitMap.temperature}, {name: "Precipitation", abbr: "precip", unit: unitMap.precipitation}, {name: "Snowfall", abbr: "snow", unit: unitMap.precipitation}, - {name: "Average windspeed", abbr: "avgWind", unit: unitMap.speed} + {name: "Average wind speed", abbr: "avgWind", unit: unitMap.speed} ]; export const hourlyAttrMap: AttrType[] = [ @@ -148,9 +152,9 @@ export const hourlyAttrMap: AttrType[] = [ export const DefaultState: IState = { selectedFrequency: "daily", - frequencies: {hourly: {attrs: [], filters: []}, + frequencies: {hourly: {attrs: hourlyAttrMap, filters: []}, daily: {attrs: dailyMonthlyAttrMap, filters: []}, - monthly: {attrs: [], filters: []}}, + monthly: {attrs: dailyMonthlyAttrMap, filters: []}}, units: "standard", didUserSelectDate: false, }; @@ -243,3 +247,7 @@ export interface UnitMap { export interface IRecord { [key: string]: number | string | Date | IWeatherStation | IFrequency; } + +export interface IItem { + [key: string]: string; +} diff --git a/src/utils/noaaApiHelper.ts b/src/utils/noaaApiHelper.ts index 2e5cb96..1d5aa59 100644 --- a/src/utils/noaaApiHelper.ts +++ b/src/utils/noaaApiHelper.ts @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { IFrequency, IRecord, IUnits, IWeatherStation } from "../types"; +import { IFrequency, IRecord, ITimeZone, IUnits, IWeatherStation } from "../types"; import { frequencyToReportTypeMap, nceiBaseURL } from "../constants"; import { dataTypeStore } from "./noaaDataTypes"; @@ -19,23 +19,22 @@ export const convertUnits = (fromUnitSystem: IUnits, toUnitSystem: IUnits, data: interface IFormatData { data: IRecord[]; - stationTimezoneOffset?: number; - stationTimezoneName?: string; units: IUnits; frequency: IFrequency; weatherStation: IWeatherStation; + timezone: ITimeZone; } export const formatData = (props: IFormatData) => { - const {data, stationTimezoneOffset, stationTimezoneName, units, frequency, weatherStation} = props; + const {data, timezone, units, frequency, weatherStation} = props; const database = frequencyToReportTypeMap[frequency]; let dataRecords: any[] = []; data.forEach((r: any) => { const aValue = convertNOAARecordToValue(r, weatherStation, database); aValue.latitude = weatherStation.latitude; aValue.longitude = weatherStation.longitude; - aValue["UTC offset"] = stationTimezoneOffset || ""; - aValue.timezone = stationTimezoneName || ""; + aValue["UTC offset"] = timezone.gmtOffset; + aValue.timezone = timezone.name; aValue.elevation = weatherStation.elevation; aValue["report type"] = frequency; dataRecords.push(aValue); @@ -51,7 +50,7 @@ export const decodeData = (iField: string, iValue: any, database: string) => { }; export const convertNOAARecordToValue = (iRecord: IRecord, weatherStation: IWeatherStation, database: string) => { - let out: IRecord = {}; // to-do: add interface / type + let out: IRecord = {}; Object.keys(iRecord).forEach(function (key: any) { let value = iRecord[key]; let dataTypeName; @@ -84,19 +83,20 @@ interface IComposeURL { frequency: IFrequency; attributes: string[]; weatherStation: IWeatherStation; - stationTimezoneOffset?: number; + gmtOffset: string; } export const composeURL = (props: IComposeURL) => { - const { startDate, endDate, frequency, attributes, weatherStation, stationTimezoneOffset } = props; + const { startDate, endDate, frequency, attributes, weatherStation, gmtOffset } = props; const database = frequencyToReportTypeMap[frequency]; const format = "YYYY-MM-DDThh:mm:ss"; let sDate = dayjs(startDate); let eDate = dayjs(endDate); + // adjust for local station time - if (database === "global-hourly" && stationTimezoneOffset) { - sDate = dayjs(startDate).subtract(stationTimezoneOffset, "hour"); - eDate = dayjs(endDate).subtract(stationTimezoneOffset, "hour").add(1, "day"); + if (database === "global-hourly") { + sDate = dayjs(startDate).subtract(Number(gmtOffset), "hour"); + eDate = dayjs(endDate).subtract(Number(gmtOffset), "hour").add(1, "day"); } const startDateString = dayjs(sDate).format(format); const endDateString = dayjs(eDate).format(format);