diff --git a/src/components/App.scss b/src/components/App.scss index f947f4a..fcb1f10 100755 --- a/src/components/App.scss +++ b/src/components/App.scss @@ -1,10 +1,6 @@ -.body { - width: 360px; -} - .App { padding: 12px; - box-sizing: border-box; + box-sizing: content-box; display: flex; flex-direction: column; align-items: center; diff --git a/src/components/App.tsx b/src/components/App.tsx index 8e8686a..655b531 100755 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -6,9 +6,8 @@ import { AttributesSelector } from "./attribute-selector"; import { AttributeFilter } from "./attribute-filter"; import { InfoModal } from "./info-modal"; import { useStateContext } from "../hooks/use-state"; -import { adjustStationDataset } from "../utils/getWeatherStations"; +import { adjustStationDataset, getWeatherStations } from "../utils/getWeatherStations"; import { createStationsDataset } from "../utils/codapHelpers"; -import weatherStations from "../assets/data/weather-stations.json"; import InfoIcon from "../assets/images/icon-info.svg"; import { useCODAPApi } from "../hooks/use-codap-api"; import { dataTypeStore } from "../utils/noaaDataTypes"; @@ -30,12 +29,16 @@ export const App = () => { const [statusMessage, setStatusMessage] = useState(""); const [isFetching, setIsFetching] = useState(false); const { showModal } = state; + const weatherStations = getWeatherStations(); useEffect(() => { initializePlugin({pluginName: kPluginName, version: kVersion, dimensions: kInitialDimensions}); + }, []); + + useEffect(() => { adjustStationDataset(weatherStations); //change max data to "present" createStationsDataset(weatherStations); //send weather station data to CODAP - }, []); + },[weatherStations]); const handleOpenInfo = () => { setState(draft => { diff --git a/src/components/location-picker.scss b/src/components/location-picker.scss index d5de205..8369a43 100644 --- a/src/components/location-picker.scss +++ b/src/components/location-picker.scss @@ -7,8 +7,8 @@ .location-header { display: flex; flex-direction: row; - align-items: center; justify-content: space-between; + flex-wrap: nowrap; height: 16px; margin: 9px 12px 0 0; @@ -16,18 +16,87 @@ font-weight: 600; } - .selected-weather-station { - padding: 2px 2px 2px 8px; - background-color: $teal-light-25; - color: $teal-dark; - font-size: 10px; - font-weight: 500; - .station-distance { - font-style: italic; + .weather-station-wrapper { + display: flex; + flex-direction: column; + align-items: flex-end; + position: relative; + right: 40px; + max-width: 311px; + + .selected-weather-station { + padding: 2px 2px 2px 8px; + background-color: $teal-light-25; + color: $teal-dark; + font-size: 10px; + font-weight: 500; + display: flex; + flex-direction: row; + align-items: center; + max-width: 245px; + + .station-distance { + font-style: italic; + margin-right: 4px; + } + + .station-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 175px; + } + svg { + height: 12px; + width: 12px; + margin-left: 6px; + } } - .location-edit-icon { - height: 12px; - width: 12px; + + .station-selection-list { + height: 86px; + padding: 8px 13px; + box-shadow: 0 5px 8px 0 rgba(0, 0, 0, 0.5); + background-color: #fff; + list-style-type: none; + margin-top: 0; + visibility: hidden; + max-width: 311px; + + &.show { + visibility: visible; + z-index: 5; + } + + .station-selection { + font-family: "Montserrat", sans-serif; + font-size: 10px; + font-weight: 500; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 2px 0; + gap: 4px; + + .station-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #000; + max-width: 160px; + } + .station-distance { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: $teal-dark-75; + min-width: 120px; + } + &.selected-station { + background-color: $teal-medium; + } + } } } } diff --git a/src/components/location-picker.tsx b/src/components/location-picker.tsx index 1ed1a0f..9a86307 100644 --- a/src/components/location-picker.tsx +++ b/src/components/location-picker.tsx @@ -4,31 +4,56 @@ 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"; +import { IPlace, IStation } from "../types"; +import { convertDistanceToStandard, findNearestActiveStations } from "../utils/getWeatherStations"; import OpenMapIcon from "../assets/images/icon-map.svg"; -// import EditIcon from "../assets/images/icon-edit.svg"; +import EditIcon from "../assets/images/icon-edit.svg"; import LocationIcon from "../assets/images/icon-location.svg"; import CurrentLocationIcon from "../assets/images/icon-current-location.svg"; import "./location-picker.scss"; export const LocationPicker = () => { - const { state, setState } = useStateContext(); - const { location, units, weatherStation, weatherStationDistance } = state; + const {state, setState} = useStateContext(); + const {units, location, weatherStation, weatherStationDistance, startDate, endDate} = state; const [showMapButton, setShowMapButton] = useState(false); const [isEditing, setIsEditing] = useState(false); const [locationPossibilities, setLocationPossibilities] = useState([]); const [showSelectionList, setShowSelectionList] = useState(false); + const [stationPossibilities, setStationPossibilities] = useState([]); + const [showStationSelectionList, setShowStationSelectionList] = useState(false); const [hoveredIndex, setHoveredIndex] = useState(null); const [arrowedIndex, setArrowedIndex] = useState(-1); + const [distanceWidth, setDistanceWidth] = useState(0); const locationDivRef = useRef(null); const locationInputEl = useRef(null); - const locationSelectionListEl = useRef(null); + const locationSelectionListElRef = useRef(null); + const stationSelectionListElRef = useRef(null); + const firstStationListedRef = useRef(null); const unitDistanceText = units === "standard" ? "mi" : "km"; - const stationDistance = weatherStationDistance && units === "standard" - ? Math.round((weatherStationDistance * 0.6 * 10) / 10) - : weatherStationDistance && Math.round(weatherStationDistance * 10) / 10; + const stationDistance = weatherStationDistance && units === "standard" ? convertDistanceToStandard(weatherStationDistance) : weatherStationDistance; + + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (event.target) { + if (stationSelectionListElRef.current && !stationSelectionListElRef.current.contains(event.target as Node)) { + setShowStationSelectionList(false); + } + if (locationSelectionListElRef.current && !locationSelectionListElRef.current.contains(event.target as Node)) { + setShowSelectionList(false); + setIsEditing(false); + } + } + } + + // Bind the event listener + document.addEventListener("mousedown", handleClickOutside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); useEffect(() => { if (locationInputEl.current?.value === "") { @@ -43,45 +68,58 @@ export const LocationPicker = () => { }, [isEditing]); useEffect(() => { - 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); - } + const _startDate = startDate ? startDate : new Date( -5364662060); // 1/1/1750 + const _endDate = endDate ? endDate : new Date(Date.now()); + if (location) { + findNearestActiveStations(location.latitude, location.longitude, _startDate, _endDate) + .then((stationList: IStation[]) => { + if (stationList) { + setStationPossibilities(stationList); + (isEditing && stationList.length > 0) && setShowSelectionList(true); + } + setState((draft) => { + draft.weatherStation = stationList[0].station; + draft.weatherStationDistance = stationList[0].distance; }); - } else { - setState((draft) => { - draft.timezone = undefined; - }); - } + }); + 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 - }, [location]); + },[endDate, isEditing, location, startDate]); + + useEffect(() => { + if (showStationSelectionList) { + const listItems = stationSelectionListElRef.current?.children; + if (listItems && firstStationListedRef.current) { + const firstStationWidth = firstStationListedRef.current?.getBoundingClientRect().width; + setDistanceWidth(firstStationWidth ? firstStationWidth : 120); + } + } + },[showStationSelectionList]); const getLocationList = () => { if (locationInputEl.current) { @@ -107,16 +145,25 @@ export const LocationPicker = () => { setArrowedIndex(-1); }; + const stationSelected = (station: IWeatherStation | undefined) => { + setState(draft => { + draft.weatherStation = station; + }); + setShowStationSelectionList(false); + setHoveredIndex(null); + setArrowedIndex(-1); + }; + const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" && locationPossibilities.length > 0) { setHoveredIndex(0); setArrowedIndex(0); - locationSelectionListEl.current?.focus(); + locationSelectionListElRef.current?.focus(); } }; const handleListKeyDown = (e: React.KeyboardEvent) => { - const listItems = locationSelectionListEl.current?.children; + const listItems = locationSelectionListElRef.current?.children; if (e.key === "Enter") { placeNameSelected(locationPossibilities[arrowedIndex-1]); } else @@ -177,6 +224,34 @@ export const LocationPicker = () => { } }; + const handleStationSelection = (ev: React.MouseEvent) => { + const target = ev.currentTarget; + if (target.dataset.ix !== undefined) { + const selectedLocIdx = parseInt(target.dataset.ix, 10); + if (selectedLocIdx >= 0) { + const selectedStation = stationPossibilities[selectedLocIdx].station; + stationSelected(selectedStation); + setState(draft => { + draft.weatherStation = selectedStation; + draft.weatherStationDistance = stationPossibilities[selectedLocIdx].distance; + }); + } + setShowStationSelectionList(false); + } + }; + + const handleStationSelectionKeyDown = (e: React.KeyboardEvent, index: number) => { + if (e.key === "Enter") { + const selectedStation = stationPossibilities[index].station; + stationSelected(selectedStation); + setState(draft => { + draft.weatherStation = selectedStation; + draft.weatherStationDistance = stationPossibilities[index].distance; + }); + setShowStationSelectionList(false); + } + }; + const handleFindCurrentLocation = async() => { navigator.geolocation.getCurrentPosition((position: GeolocationPosition) => { const lat = position.coords.latitude; @@ -214,15 +289,33 @@ export const LocationPicker = () => {
Location { location && !isEditing && -
- { weatherStation && - <> - {weatherStationDistance && - ({stationDistance} {unitDistanceText}) } - {weatherStation?.name} - {/* hide this for now until implemented*/} - - } +
+
setShowStationSelectionList(true)}> + { weatherStation && + <> + ({stationDistance?.toFixed(1)} {unitDistanceText}) + {weatherStation?.name} + + + } +
+
    + {stationPossibilities.map((station: IStation, idx: number) => { + if (station) { + const stationDistanceText = units === "standard" ? convertDistanceToStandard(station.distance) : station.distance; + return ( +
  • handleLocationHover(idx)} onClick={(e)=>handleStationSelection(e)} onKeyDown={(e)=>handleStationSelectionKeyDown(e,idx)}> + + {stationDistanceText.toFixed(1)} {unitDistanceText} {idx === 0 && `from ${state.location?.name}`} + + {station.station.name} +
  • + ); + } + })} +
}
@@ -242,7 +335,7 @@ export const LocationPicker = () => {
{ isEditing &&
    setHoveredIndex(null)}>
  • { const datasetArr = Array.from(dataset); - let maxDate: dayjs.Dayjs | null = null; if (dataset) { @@ -32,23 +31,47 @@ export const adjustStationDataset = (dataset: IWeatherStation[]) => { }); } } + return dataset; }; -export const findNearestActiveStation = async(targetLat: number, targetLong: number, fromDate: number | string, - toDate: number | string) => { - // TODO: filter out weather stations that are active - let nearestStation: IWeatherStation | null = null; - let minDistance = Number.MAX_VALUE; - - for (const station of weatherStations) { - const distance = calculateDistance(targetLat, targetLong, station.latitude, station.longitude); - if (distance < minDistance) { - minDistance = distance; - nearestStation = station; +export const findNearestActiveStations = async(targetLat: number, targetLong: number, fromDate: Date, + toDate: Date) => { + const adjustedStationDataset = adjustStationDataset(weatherStations as IWeatherStation[]); + const fromMSecs = fromDate.getTime() ; + const toMSecs = toDate.getTime(); + const nearestStations: IStation[] = []; + + const insertStation = (station: IWeatherStation, distance: number) => { + const newStation = {station, distance}; + // Insert the new station into the sorted array at the correct position + const index = nearestStations.findIndex(s => s.distance > distance); + if (index === -1) { + nearestStations.push(newStation); + } else { + nearestStations.splice(index, 0, newStation); + } + }; + + for (const station of adjustedStationDataset) { + let shouldInsert = false; + if (station.maxdate === "present") { // If the station is still active (maxdate === "present") then we can use it + shouldInsert = true; + } else { // If the station is not active, we need to check if it has data in the date range + const stationMinMSecs = new Date(station.mindate).getTime(); + const stationMaxMSecs = new Date(station.maxdate).getTime(); + if (stationMinMSecs <= toMSecs && stationMaxMSecs >= fromMSecs) { + shouldInsert = true; + } + } + + if (shouldInsert) { + const distance = calculateDistance(targetLat, targetLong, station.latitude, station.longitude); + const newStation = {station, distance}; + insertStation(newStation.station, newStation.distance); } } - return {station: nearestStation, distance: minDistance}; + return nearestStations.slice(0, 5); }; function degreesToRadians(degrees: number): number { @@ -76,3 +99,11 @@ export function calculateDistance(point1Lat: number, point1Long: number, point2L return distance; //in km } + +export function getWeatherStations() { + return weatherStations as IWeatherStation[]; +} + +export function convertDistanceToStandard(distance: number) { + return distance * 0.621371; +} diff --git a/src/utils/noaaDataTypes.ts b/src/utils/noaaDataTypes.ts index e86c284..f8eabb6 100644 --- a/src/utils/noaaDataTypes.ts +++ b/src/utils/noaaDataTypes.ts @@ -136,7 +136,7 @@ const dataTypes = [ ["daily-summaries", "global-summary-of-the-month"]), new NoaaType("SNOW", "snow", kUnitTypePrecip, "Snowfall", ["daily-summaries", "global-summary-of-the-month"]), - new NoaaType("AWND", "avgWind", kUnitTypeSpeed, "Average windspeed", + new NoaaType("AWND", "avgWind", kUnitTypeSpeed, "Average wind speed", ["daily-summaries", "global-summary-of-the-month"], { "GHCND" (v: number) {return v/10;}, "GSOM" (v) {return v/10;} }), @@ -145,13 +145,13 @@ const dataTypes = [ new NoaaType("SLP", "pressure", kUnitTypePressure, "Barometric Pressure at sea level", ["global-hourly"], {"global-hourly": extractHourlyPressure}), - new NoaaType("TMP", "temp", kUnitTypeTemp, "Air Temperature", + new NoaaType("TMP", "temp", kUnitTypeTemp, "Air temperature", ["global-hourly"], {"global-hourly": extractHourlyTemp}), new NoaaType("VIS", "vis", kUnitTypeDistance, "Visibility", ["global-hourly"], {"global-hourly": extractHourlyVisibility}), - new NoaaType("WND", "WDir", kUnitTypeAngle, "Wind Direction", + new NoaaType("WND", "WDir", kUnitTypeAngle, "Wind direction", ["global-hourly"], {"global-hourly": extractHourlyWindDirection}), - new NoaaType("WND", "wSpeed", kUnitTypeSpeed, "Wind Speed", + new NoaaType("WND", "wSpeed", kUnitTypeSpeed, "Wind speed", ["global-hourly"], {"global-hourly": extractHourlyWindspeed}), new NoaaType("AA1", "precip", kUnitTypePrecip, "Precipitation in last hour", ["global-hourly"], {"global-hourly": extractHourlyPrecipitation}),