From b9e650eef489a79c687c66c9e8aa5e1b60bd980d Mon Sep 17 00:00:00 2001 From: pjanik Date: Thu, 8 Aug 2024 18:40:26 +0200 Subject: [PATCH] feat: sync My Location dropdown with CODAP state, sync Lat/Long between tabs, refactor app state related to location and lat/long [PT-188059223] [PT-188071551] --- src/components/App.tsx | 11 ++- src/components/location-tab.tsx | 19 ++--- src/components/simulation-tab.tsx | 20 +++-- src/grasp-seasons/3d-views/orbit-view.ts | 4 +- src/grasp-seasons/components/city-select.tsx | 73 ------------------- .../{city-select.scss => my-locations.scss} | 2 +- src/grasp-seasons/components/my-locations.tsx | 63 ++++++++++++++++ src/grasp-seasons/components/seasons.tsx | 65 +++++++++++++---- src/grasp-seasons/index.tsx | 8 -- src/hooks/useCodapData.ts | 22 ++---- src/types.ts | 1 - src/utils/daylight-utils.ts | 22 +++++- 12 files changed, 172 insertions(+), 138 deletions(-) delete mode 100644 src/grasp-seasons/components/city-select.tsx rename src/grasp-seasons/components/{city-select.scss => my-locations.scss} (73%) create mode 100644 src/grasp-seasons/components/my-locations.tsx delete mode 100644 src/grasp-seasons/index.tsx diff --git a/src/components/App.tsx b/src/components/App.tsx index dc2bb4d..1f73ef2 100755 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -12,10 +12,9 @@ import "../assets/scss/App.scss"; export const App: React.FC = () => { const [activeTab, setActiveTab] = useState<"location" | "simulation">("location"); - const [latitude, setLatitude] = useState(""); - const [longitude, setLongitude] = useState(""); + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); const [dayOfYear, setDayOfYear] = useState("280"); - const [location, setLocation] = useState(null); const [locations, setLocations] = useState([]); const [locationSearch, setLocationSearch] = useState(""); const [selectedAttrs, setSelectedAttributes] = useState(kDefaultOnAttributes); @@ -75,13 +74,11 @@ export const App: React.FC = () => { { diff --git a/src/components/location-tab.tsx b/src/components/location-tab.tsx index d6ab6e4..140b0f1 100644 --- a/src/components/location-tab.tsx +++ b/src/components/location-tab.tsx @@ -3,20 +3,19 @@ import { useCodapData } from "../hooks/useCodapData"; import { kChildCollectionAttributes } from "../constants"; import { ILocation } from "../types"; import { LocationPicker } from "./location-picker"; +import { locationsEqual } from "../utils/daylight-utils"; import "../assets/scss/location-tab.scss"; interface LocationTabProps { latitude: string; longitude: string; - location: ILocation | null; locations: ILocation[]; locationSearch: string; selectedAttrs: string[]; dataContext: any; // TODO the type setLatitude: (latitude: string) => void; setLongitude: (longitude: string) => void; - setLocation: (location: ILocation | null) => void; setLocationSearch: (search: string) => void; setSelectedAttributes: (attrs: string[]) => void; setDataContext: (context: any) => void; // TODO the type @@ -26,13 +25,11 @@ interface LocationTabProps { export const LocationTab: React.FC = ({ latitude, longitude, - location, locationSearch, selectedAttrs, locations, setLatitude, setLongitude, - setLocation, setLocationSearch, setSelectedAttributes, setLocations @@ -58,18 +55,15 @@ export const LocationTab: React.FC = ({ const handleLatChange = (event: React.ChangeEvent) => { setLatitude(event.target.value); - setLocation(null); setLocationSearch(""); }; const handleLongChange = (event: React.ChangeEvent) => { setLongitude(event.target.value); - setLocation(null); setLocationSearch(""); }; const handleLocationSelect = (selectedLocation: ILocation) => { - setLocation(selectedLocation); setLatitude(selectedLocation.latitude.toString()); setLongitude(selectedLocation.longitude.toString()); setLocationSearch(selectedLocation.name); @@ -77,9 +71,6 @@ export const LocationTab: React.FC = ({ const handleLocationSearchChange = (searchString: string) => { setLocationSearch(searchString); - if (searchString === "") { - setLocation(null); - } }; const handleTokenClick = (attribute: string) => { @@ -96,13 +87,13 @@ export const LocationTab: React.FC = ({ }; const handleGetDataClick = async () => { - const locationExists = locations.some(item => - item.latitude === location?.latitude && item.longitude === location.longitude - ); + const name = locationSearch || `(${latitude}, ${longitude})`; + const currentLocation: ILocation = { name, latitude: Number(latitude), longitude: Number(longitude) }; + const locationExists = locations.some(item => locationsEqual(item, currentLocation)); if (locationExists || !latitude || !longitude) return; // if the location does not already exist, and we have params, get the data - const tableCreated = await getDayLengthData(Number(latitude), Number(longitude), location); + const tableCreated = await getDayLengthData(currentLocation); if (tableCreated?.success) { const uniqeLocations = await getUniqueLocationsInCodapData(); if (uniqeLocations) setLocations(uniqeLocations); diff --git a/src/components/simulation-tab.tsx b/src/components/simulation-tab.tsx index 68c803f..f7ea10c 100644 --- a/src/components/simulation-tab.tsx +++ b/src/components/simulation-tab.tsx @@ -8,24 +8,34 @@ interface SimulationTabProps { latitude: string; longitude: string; dayOfYear: string; - location: ILocation | null; locations: ILocation[]; + setLatitude: (latitude: string) => void; + setLongitude: (longitude: string) => void; + setLocationSearch: (search: string) => void; setDayOfYear: (day: string) => void; } export const SimulationTab: React.FC = ({ latitude, longitude, + locations, dayOfYear, setDayOfYear, - location, - locations + setLatitude, + setLongitude, + setLocationSearch }) => { - return (
- +
); diff --git a/src/grasp-seasons/3d-views/orbit-view.ts b/src/grasp-seasons/3d-views/orbit-view.ts index dc600e2..4e072be 100644 --- a/src/grasp-seasons/3d-views/orbit-view.ts +++ b/src/grasp-seasons/3d-views/orbit-view.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import BaseView from "./base-view"; import EarthDraggingInteraction from "./orbit-view-interaction"; +import LatLongMarkerDraggingInteraction from "./earth-view-interaction"; import LatitudeLine from "../3d-models/latitude-line"; import LatLongMarker from "../3d-models/lat-long-marker"; import models from "../3d-models/common-models"; @@ -36,6 +37,7 @@ export default class OrbitView extends BaseView { latLongMarker!: LatLongMarker; monthLabels!: THREE.Object3D[]; earthDraggingInteraction: EarthDraggingInteraction = new EarthDraggingInteraction(this); + latLogDraggingInteraction: LatLongMarkerDraggingInteraction = new LatLongMarkerDraggingInteraction(this); startingCameraPos?: THREE.Vector3; desiredCameraPos?: THREE.Vector3; @@ -86,7 +88,7 @@ export default class OrbitView extends BaseView { } setupEarthCloseUpView() { - this.registerInteractionHandler(null); // disable dragging interaction in close-up view + this.registerInteractionHandler(this.latLogDraggingInteraction); this.sunEarthLine.rootObject.visible = false; this.monthLabels.forEach((label) => label.visible = false); } diff --git a/src/grasp-seasons/components/city-select.tsx b/src/grasp-seasons/components/city-select.tsx deleted file mode 100644 index a831c83..0000000 --- a/src/grasp-seasons/components/city-select.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { Component } from "react"; -import CITY_DATA from "../utils/city-data"; -import t, { Language } from "../translation/translate"; - -import "./city-select.scss"; - -interface IProps { - lang: Language; - lat: number; - long: number; - onCityChange: (lat: number, long: number, name: string) => void; -} -interface ILocation { - name: string; - lat?: number; - long?: number; - disabled?: boolean; -} -interface IState { - locations: ILocation[]; -} -export default class CitySelect extends Component { - state: IState; - constructor(props: IProps) { - super(props); - this.state = { - locations: [{ name: t("~CUSTOM_LOCATION", props.lang), disabled: true } as ILocation].concat(CITY_DATA), - }; - - this.selectChange = this.selectChange.bind(this); - } - - selectChange(event: any) { - const { onCityChange } = this.props; - const { locations } = this.state; - const city = locations[event.target.value]; - if (city.lat && city.long) { - onCityChange(city.lat, city.long, city.name); - } - } - - getOptions() { - const { locations } = this.state; - const options = []; - for (let i = 0; i < locations.length; i++) { - const loc = locations[i]; - options.push(); - } - return options; - } - - get selectedCity() { - const { lat, long } = this.props; - const { locations } = this.state; - for (let i = 0; i < locations.length; i++) { - if (lat === locations[i].lat && long === locations[i].long) { - return i; - } - } - return 0; // custom location - } - - render() { - return ( -
- - -
- ); - } -} diff --git a/src/grasp-seasons/components/city-select.scss b/src/grasp-seasons/components/my-locations.scss similarity index 73% rename from src/grasp-seasons/components/city-select.scss rename to src/grasp-seasons/components/my-locations.scss index 237fe78..e2642dc 100644 --- a/src/grasp-seasons/components/city-select.scss +++ b/src/grasp-seasons/components/my-locations.scss @@ -1,4 +1,4 @@ -.city-select { +.my-locations { display: flex; flex-direction: column; } diff --git a/src/grasp-seasons/components/my-locations.tsx b/src/grasp-seasons/components/my-locations.tsx new file mode 100644 index 0000000..3584aaf --- /dev/null +++ b/src/grasp-seasons/components/my-locations.tsx @@ -0,0 +1,63 @@ +import React, { Component } from "react"; +import t, { Language } from "../translation/translate"; +import { ILocation } from "../../types"; +import { locationsEqual } from "../../utils/daylight-utils"; + +import "./my-locations.scss"; + +interface IProps { + lang: Language; + lat: number; + long: number; + onLocationChange: (lat: number, long: number, name: string) => void; + locations: ILocation[]; +} + +export default class MyLocations extends Component { + constructor(props: IProps) { + super(props); + this.selectChange = this.selectChange.bind(this); + } + + selectChange(event: any) { + const { onLocationChange, locations } = this.props; + const location = locations[event.target.value]; + if (location.latitude && location.longitude) { + onLocationChange(location.latitude, location.longitude, location.name); + } + } + + getOptions() { + const { locations } = this.props; + const options = this.selectedCity === "" ? [ + + ] : []; + for (let i = 0; i < locations.length; i++) { + const loc = locations[i]; + options.push(); + } + return options; + } + + get selectedCity() { + const { lat, long, locations } = this.props; + const currentLocation: ILocation = { latitude: lat, longitude: long, name: "" }; + for (let i = 0; i < locations.length; i++) { + if (locationsEqual(currentLocation, locations[i])) { + return i; + } + } + return ""; // custom location + } + + render() { + return ( +
+ + +
+ ); + } +} diff --git a/src/grasp-seasons/components/seasons.tsx b/src/grasp-seasons/components/seasons.tsx index 569bf05..e36e195 100644 --- a/src/grasp-seasons/components/seasons.tsx +++ b/src/grasp-seasons/components/seasons.tsx @@ -1,14 +1,15 @@ import React, { useState, useEffect, useRef, ChangeEvent, useCallback } from "react"; import Slider from "./slider/slider"; -import { getSolarNoonIntensity } from "../../utils/daylight-utils"; +import { getSolarNoonIntensity, isValidLatitude, isValidLongitude } from "../../utils/daylight-utils"; import InfiniteDaySlider from "./slider/infinite-day-slider"; -import CitySelect from "./city-select"; +import MyLocations from "./my-locations"; import getURLParam from "../utils/utils"; import OrbitViewComp from "./orbit-view-comp"; import RaysViewComp from "./rays-view-comp"; import t, { Language } from "../translation/translate"; import { ISimState } from "../types"; import { useAnimation } from "../hooks/use-animation"; +import { ILocation } from "../../types"; import "./seasons.scss"; @@ -41,13 +42,25 @@ function capitalize(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } +function formatLatLongNumber(value: number) { + return value.toFixed(5); +} + interface IProps { lang?: Language; initialState?: Partial; log?: (action: string, data?: any) => void; + + latitude: string; + longitude: string; + locations: ILocation[]; + setLatitude: (latitude: string) => void; + setLongitude: (longitude: string) => void; + setLocationSearch: (name: string) => void; } -const Seasons: React.FC = ({ lang = "en_us", initialState = {}, log = (action: string, data?: any) => {} }) => { +const Seasons: React.FC = ({ lang = "en_us", initialState = {}, log = (action: string, data?: any) => {}, + latitude, longitude, locations, setLatitude, setLongitude, setLocationSearch }) => { const orbitViewRef = useRef(null); // State @@ -87,6 +100,19 @@ const Seasons: React.FC = ({ lang = "en_us", initialState = {}, log = (a } }, [rafCallback]); + // Handle props coming from the parent and update local state accordingly + useEffect(() => { + if (isValidLatitude(latitude)) { + setSimState(prevState => ({ ...prevState, lat: Number(latitude) })); + } + }, [latitude]); + + useEffect(() => { + if (isValidLongitude(longitude)) { + setSimState(prevState => ({ ...prevState, long: Number(longitude) })); + } + }, [longitude]); + // Derived state const simLang = simState.lang; const playStopLabel = mainAnimationStarted ? t("~STOP", simLang) : t("~ORBIT_BUTTON", simLang); @@ -120,8 +146,8 @@ const Seasons: React.FC = ({ lang = "en_us", initialState = {}, log = (a const lat = simState.lat; if (lat > 0) dir = t("~DIR_NORTH", simLang); else if (lat < 0) dir = t("~DIR_SOUTH", simLang); - const latitude = Math.abs(lat).toFixed(2); - return `${latitude}°${dir}`; + const _latitude = Math.abs(lat).toFixed(2); + return `${_latitude}°${dir}`; }; const getFormattedLong = () => { @@ -136,6 +162,15 @@ const Seasons: React.FC = ({ lang = "en_us", initialState = {}, log = (a // Event handlers const handleSimStateChange = (newState: Partial) => { setSimState(prevState => ({ ...prevState, ...newState })); + // Latitude and longitude can be changed when user drags the Earth in the Orbit view + if (newState.lat !== undefined) { + setLatitude(formatLatLongNumber(newState.lat)); + setLocationSearch(""); + } + if (newState.long !== undefined) { + setLongitude(formatLatLongNumber(newState.long)); + setLocationSearch(""); + } }; const handleDaySliderChange = (event: any, ui: any) => { @@ -149,21 +184,22 @@ const Seasons: React.FC = ({ lang = "en_us", initialState = {}, log = (a const handleLatSliderChange = (event: any, ui: any) => { setSimState(prevState => ({ ...prevState, lat: ui.value })); + setLatitude(formatLatLongNumber(ui.value)); + setLocationSearch(""); }; const handleLongSliderChange = (event: any, ui: any) => { setSimState(prevState => ({ ...prevState, long: ui.value })); + setLongitude(formatLatLongNumber(ui.value)); + setLocationSearch(""); }; - const handleCitySelectChange = (lat: number, long: number, city: string) => { + const handleMyLocationChange = (lat: number, long: number, name: string) => { const rot = -long * Math.PI / 180; setSimState(prevState => ({ ...prevState, lat, long, earthRotation: rot })); - - log("CityPulldownChanged", { - value: city, - lat, - long - }); + setLatitude(formatLatLongNumber(lat)); + setLongitude(formatLatLongNumber(long)); + setLocationSearch(name); }; const handleViewChange = (event: ChangeEvent) => { @@ -231,11 +267,12 @@ const Seasons: React.FC = ({ lang = "en_us", initialState = {}, log = (a { solarIntensityValue } { t("~SOLAR_INTENSITY_UNIT", simLang) } -
diff --git a/src/grasp-seasons/index.tsx b/src/grasp-seasons/index.tsx deleted file mode 100644 index 2c87740..0000000 --- a/src/grasp-seasons/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; -import Seasons from "./components/seasons"; - -const container = document.getElementById("app"); -if (!container) throw new Error("App container not found"); -const root = createRoot(container); -root.render(); diff --git a/src/hooks/useCodapData.ts b/src/hooks/useCodapData.ts index 64882f4..8892c2e 100644 --- a/src/hooks/useCodapData.ts +++ b/src/hooks/useCodapData.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import { kDataContextName, kChildCollectionName, kParentCollectionName, kParentCollectionAttributes, kChildCollectionAttributes } from "../constants"; import { DaylightCalcOptions, ILocation } from "../types"; -import { getDayLightInfo } from "../utils/daylight-utils"; +import { getDayLightInfo, locationsEqual } from "../utils/daylight-utils"; import { getAllItems, getAttribute, @@ -32,16 +32,11 @@ export const useCodapData = () => { } }; - const getDayLengthData = async (latitude: number, longitude: number, location: any) => { - if (!latitude || !longitude) { - alert("Please enter both latitude and longitude."); - return; - } - + const getDayLengthData = async (location: ILocation) => { let createDC; const calcOptions: DaylightCalcOptions = { - latitude: Number(latitude), - longitude: Number(longitude), + latitude: location.latitude, + longitude: location.longitude, year: 2024 // NOTE: If data are to be historical, add dynamic year attribute }; @@ -68,9 +63,9 @@ export const useCodapData = () => { const completeSolarRecords = solarEvents.map(solarEvent => { const record: Record = { - latitude: Number(latitude), - longitude: Number(longitude), - location: location?.name, + latitude: location.latitude, + longitude: location.longitude, + location: location.name, dayNumber: solarEvent.dayAsInteger, date: solarEvent.day, sunrise: solarEvent.sunrise, @@ -113,10 +108,9 @@ export const useCodapData = () => { name: c.values.location, latitude: c.values.latitude, longitude: c.values.longitude, - coordinatePair: `(${c.values.latitude},${c.values.longitude})` }; - if (!uniqueLocations.some((l) => l.coordinatePair === locationObj.coordinatePair)) { + if (!uniqueLocations.some((l) => locationsEqual(l, locationObj))) { uniqueLocations.push(locationObj); } }); diff --git a/src/types.ts b/src/types.ts index 23f84e5..38215c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,6 @@ export interface ILocation { name: string; latitude: number; longitude: number; - coordinatePair?: string; } export interface DaylightCalcOptions { diff --git a/src/utils/daylight-utils.ts b/src/utils/daylight-utils.ts index 994d164..797b6b2 100644 --- a/src/utils/daylight-utils.ts +++ b/src/utils/daylight-utils.ts @@ -5,7 +5,7 @@ import timezone from "dayjs/plugin/timezone"; import tzlookup from "tz-lookup"; import { getSunrise, getSunset } from "sunrise-sunset-js"; import { Seasons } from "astronomy-engine"; -import { DaylightInfo, DaylightCalcOptions } from "../types"; +import { DaylightInfo, DaylightCalcOptions, ILocation } from "../types"; import { kBasicSummerSolstice, kEarthTilt } from "../constants"; extend(utc); @@ -114,3 +114,23 @@ export function getDayLightInfo(options: DaylightCalcOptions): DaylightInfo[] { return results; } + +// Tolerance is currently used mostly to account for floating point errors. However, we can also use it to match +// locations with some degree of error, e.g. when user is manually entering a location and hoping to match one of the +// saved locations. +export function locationsEqual(a?: ILocation | null, b?: ILocation | null, tolerance: number = 1e-5): boolean { + if (!a || !b) return false; + const latitudeEqual = Math.abs(a.latitude - b.latitude) < tolerance; + const longitudeEqual = Math.abs(a.longitude - b.longitude) < tolerance; + return latitudeEqual && longitudeEqual; +} + +export function isValidLongitude(longitude: string): boolean { + const parsed = Number(longitude); + return !isNaN(parsed) && parsed >= -180 && parsed <= 180; +} + +export function isValidLatitude(latitude: string): boolean { + const parsed = Number(latitude); + return !isNaN(parsed) && parsed >= -90 && parsed <= 90; +}