From 8ac6c2d11defb0aa84230f02cc2ee2f5bd866a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Jyrki=C3=A4inen?= Date: Tue, 12 Nov 2024 10:24:41 +0200 Subject: [PATCH 1/2] Dev (#812) * speeding area search --- .env.dev | 2 +- graphql.schema.json | 125 ++++++++++++ schema-types.d.ts | 13 ++ src/components/App.js | 2 + src/components/AppContent.js | 5 + src/components/AreaJourneys.js | 37 +++- src/components/MergedJourneys.js | 11 +- .../filterbar/AdditionalTimeSettings.js | 54 +++++- src/components/map/AreaSelect.js | 71 ++++--- src/components/map/CustomDrawingControl.js | 181 ++++++++++++++++++ src/components/map/Map.js | 12 +- src/components/map/MapEvents.js | 18 +- src/components/sidepanel/SidePanel.js | 24 ++- src/components/sidepanel/SidepanelList.js | 6 +- .../sidepanel/SpeedAreaJourneyList.js | 161 ++++++++++++++++ src/constants.js | 1 + src/icons/Speedlimit.js | 38 ++++ src/icons/Square.js | 26 +++ src/languages/ui/en.json | 18 +- src/languages/ui/fi.json | 14 ++ src/languages/ui/se.json | 13 ++ src/queries/AreaJourneysQuery.js | 84 +++++++- src/stores/TimeStore.js | 1 + src/stores/UIStore.js | 2 + src/stores/timeActions.js | 6 + src/stores/uiActions.js | 4 +- yarn.lock | 5 + 27 files changed, 850 insertions(+), 84 deletions(-) create mode 100644 src/components/map/CustomDrawingControl.js create mode 100644 src/components/sidepanel/SpeedAreaJourneyList.js create mode 100644 src/icons/Speedlimit.js create mode 100644 src/icons/Square.js diff --git a/.env.dev b/.env.dev index 70e03d7d..d24e4746 100644 --- a/.env.dev +++ b/.env.dev @@ -27,4 +27,4 @@ REACT_APP_FMI_APIKEY=4a22a195-10be-404f-8056-e8aaf5f7506a REACT_APP_DIGITRANSIT_URL=https://api.digitransit.fi/ REACT_APP_MAPILLARY_CLIENT_TOKEN=MLY|4648891498509284|8e5e3395c54d64ca14b62c79ceb3e16c -REACT_APP_DIGITRANSIT_API_KEY=ca9e1c9b7fcf49358a0f0e2c2e599cd4 \ No newline at end of file +REACT_APP_DIGITRANSIT_API_KEY=ca9e1c9b7fcf49358a0f0e2c2e599cd4 diff --git a/graphql.schema.json b/graphql.schema.json index 553c9cea..668941f9 100644 --- a/graphql.schema.json +++ b/graphql.schema.json @@ -1051,6 +1051,131 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "journeysByBboxAndRouteId", + "description": null, + "args": [ + { + "name": "minTime", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "maxTime", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "bbox", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "PreciseBBox", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "date", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Date", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "routeId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "speedFilter", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AreaEventsFilterInput", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "unsignedEvents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Journey", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "unsignedVehicleEvents", "description": null, diff --git a/schema-types.d.ts b/schema-types.d.ts index d460d028..7acb3b7e 100644 --- a/schema-types.d.ts +++ b/schema-types.d.ts @@ -627,6 +627,7 @@ export type Query = { vehicleJourneys: Array>; driverEvents: Array>; journeysByBbox: Array>; + journeysByBboxAndRouteId: Array>; unsignedVehicleEvents: Array>; alerts: Array; cancellations: Array; @@ -760,6 +761,18 @@ export type QueryJourneysByBboxArgs = { }; +export type QueryJourneysByBboxAndRouteIdArgs = { + minTime: Scalars['DateTime']; + maxTime: Scalars['DateTime']; + bbox: Scalars['PreciseBBox']; + date: Scalars['Date']; + routeId: Scalars['String']; + speedFilter: Scalars['String']; + filters?: Maybe; + unsignedEvents?: Maybe; +}; + + export type QueryUnsignedVehicleEventsArgs = { uniqueVehicleId: Scalars['VehicleId']; date: Scalars['Date']; diff --git a/src/components/App.js b/src/components/App.js index 66ada082..142cd0af 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -51,6 +51,7 @@ function App({route, state, UI}) { {({ selectedJourney: currentJourney, areaJourneys, + areaSpeeds, currentJourneys: allJourneys, areaJourneysLoading, selectedJourneyLoading, @@ -65,6 +66,7 @@ function App({route, state, UI}) { currentJourneyPositions={currentJourneyPositions} areaJourneysLoading={areaJourneysLoading} areaJourneys={areaJourneys} + areaSpeeds={areaSpeeds} route={route} selectedJourneyLoading={selectedJourneyLoading} currentJourney={currentJourney} diff --git a/src/components/AppContent.js b/src/components/AppContent.js index c4801ebf..df74a00c 100644 --- a/src/components/AppContent.js +++ b/src/components/AppContent.js @@ -43,6 +43,7 @@ let AppContent = decorate( currentJourneyPositions, areaJourneysLoading, areaJourneys, + areaSpeeds, route, selectedJourneyLoading, currentJourney, @@ -54,6 +55,7 @@ let AppContent = decorate( selectedJourney, journeyDetailsOpen, currentMapillaryMapLocation, + speedSearch, } = state; const selectedJourneyId = getJourneyId(selectedJourney); @@ -96,6 +98,8 @@ let AppContent = decorate( areaJourneysLoading={!live && areaJourneysLoading} journeyLoading={selectedJourneyLoading} areaEvents={areaJourneys} + areaSpeeds={areaSpeeds} + speedSearch={speedSearch} journey={currentJourney} route={route} detailsOpen={detailsAreOpen} @@ -106,6 +110,7 @@ let AppContent = decorate( journeys={allJourneys} journeyPositions={currentJourneyPositions} route={route} + speedSearch={speedSearch} /> {currentJourney && ( { time, date, selectedBounds, + speedSearch, areaEventsRouteFilter, + route, + speedFilter, } = state; const [minTime, setMinTime] = useState(null); @@ -48,13 +51,24 @@ const AreaJourneys = decorate((props) => { } // Constrain search time span to 1 minute when auto-polling. - const timespan = isLiveAndCurrent ? 0.5 : Math.round(areaSearchRangeMinutes / 2); - - const timeMoment = getMomentFromDateTime(date, time); - const min = timeMoment.clone().subtract(timespan, "minutes"); - const max = isLiveAndCurrent - ? timeMoment - : timeMoment.clone().add(timespan, "minutes"); + const timespan = speedSearch + ? isLiveAndCurrent + ? 0.5 + : Math.round((24 * 60) / 2) + : isLiveAndCurrent + ? 0.5 + : Math.round(areaSearchRangeMinutes / 2); + + let timeMoment = getMomentFromDateTime(date, time); + let min = timeMoment.clone().subtract(timespan, "minutes"); + let max = isLiveAndCurrent ? timeMoment : timeMoment.clone().add(timespan, "minutes"); + + if (speedSearch) { + const odayStart = getMomentFromDateTime(date, "01:00:00"); + const odayEnd = odayStart.clone().add(1, "days"); + min = odayStart; + max = odayEnd; + } setMinTime(min); setMaxTime(max); @@ -71,8 +85,11 @@ const AreaJourneys = decorate((props) => { minTime={minTime} maxTime={maxTime} date={queryDate} - bbox={queryBbox}> - {({journeys = [], loading}) => { + bbox={queryBbox} + routeId={route.routeId} + speedSearch={speedSearch} + speedFilter={speedFilter}> + {({journeys = [], areaSpeeds = [], loading}) => { let areaJourneys = journeys; if (areaEventsRouteFilter) { @@ -87,7 +104,7 @@ const AreaJourneys = decorate((props) => { }); } - return children({journeys: areaJourneys, loading}); + return children({journeys: areaJourneys, areaSpeeds: areaSpeeds, loading}); }} ); diff --git a/src/components/MergedJourneys.js b/src/components/MergedJourneys.js index a09f3194..69ef776c 100644 --- a/src/components/MergedJourneys.js +++ b/src/components/MergedJourneys.js @@ -4,10 +4,15 @@ import compact from "lodash/compact"; // Use memoization to prevent the combined journeys from updating too often. -const MergedJourneys = ({children, areaJourneys = [], selectedJourney}) => { +const MergedJourneys = ({ + children, + areaJourneys = [], + areaSpeeds = [], + selectedJourney, +}) => { const currentJourneys = useMemo(() => { - return mergeJourneys(compact([selectedJourney, ...areaJourneys])); - }, [areaJourneys, selectedJourney]); + return mergeJourneys(compact([selectedJourney, ...areaJourneys, ...areaSpeeds])); + }, [areaJourneys, areaSpeeds, selectedJourney]); return children({currentJourneys}); }; diff --git a/src/components/filterbar/AdditionalTimeSettings.js b/src/components/filterbar/AdditionalTimeSettings.js index 9004dbf7..d025206a 100644 --- a/src/components/filterbar/AdditionalTimeSettings.js +++ b/src/components/filterbar/AdditionalTimeSettings.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, {useState, useRef, useEffect} from "react"; import {observer} from "mobx-react-lite"; import {text, Text} from "../../helpers/text"; import {ControlGroup, InputLabel} from "../Forms"; @@ -7,7 +7,6 @@ import Input from "../Input"; import flow from "lodash/flow"; import {inject} from "../../helpers/inject"; import {useTooltip} from "../../hooks/useTooltip"; - const SettingsWrapper = styled.div` padding-top: 0.5rem; `; @@ -20,7 +19,25 @@ const IncrementValueInput = styled(Input)` const decorate = flow(observer, inject("Time")); const AdditionalTimeSettings = decorate(({state, Time}) => { - const {timeIncrement, areaSearchRangeMinutes} = state; + const {timeIncrement, areaSearchRangeMinutes, speedFilter} = state; + const [tempSpeedFilter, setTempSpeedFilter] = useState(speedFilter); + const tempSpeedFilterRef = useRef(tempSpeedFilter); + + useEffect(() => { + tempSpeedFilterRef.current = tempSpeedFilter; + }, [tempSpeedFilter]); + + const handleChange = (e) => { + const value = e.target.value; + setTempSpeedFilter(value); + tempSpeedFilterRef.current = value; + }; + + const handleMouseUp = () => { + const value = Number(tempSpeedFilterRef.current); + Time.setSpeedFilter(value); + setTempSpeedFilter(value); + }; return ( @@ -51,6 +68,7 @@ const AdditionalTimeSettings = decorate(({state, Time}) => { maxLength={2} value={areaSearchRangeMinutes} animatedLabel={false} + disabled={true} onChange={(e) => Time.setAreaSearchMinutes(e.target.value)} /> { onChange={(e) => Time.setAreaSearchMinutes(e.target.value)} /> + + + filterpanel.speed_search_limit + + + + + + ); }); diff --git a/src/components/map/AreaSelect.js b/src/components/map/AreaSelect.js index 1935111b..45dd2f98 100644 --- a/src/components/map/AreaSelect.js +++ b/src/components/map/AreaSelect.js @@ -1,27 +1,37 @@ import React, {useRef, useCallback, useEffect} from "react"; import "leaflet-draw/dist/leaflet.draw.css"; import {FeatureGroup, Rectangle} from "react-leaflet"; -import {EditControl} from "react-leaflet-draw"; import {observer} from "mobx-react-lite"; import {setResetListener} from "../../stores/FilterStore"; -import CancelControl from "./CancelControl"; +import CustomDrawingControl from "./CustomDrawingControl"; import flow from "lodash/flow"; import {inject} from "../../helpers/inject"; +import {SidePanelTabs} from "../../constants"; // Leaflet path style -const rectangleStyle = { +const hfpRectangleStyle = { weight: 2, dashArray: "10 4", opacity: 1, color: "white", fillColor: "var(--blue)", - fillOpacity: 0.1, + fillOpacity: 0.2, +}; + +const speedRectangleStyle = { + weight: 2, + dashArray: "10 4", + opacity: 1, + color: "white", + fillColor: "var(--red)", + fillOpacity: 0.2, }; const decorate = flow(observer, inject("UI")); const AreaSelect = decorate(({UI, state, enabled}) => { - const {selectedBounds} = state; + const {route, user, selectedBounds} = state; + const routeSelected = !!route.routeId; const featureLayer = useRef(null); const clearAreas = useCallback(() => { @@ -29,15 +39,20 @@ const AreaSelect = decorate(({UI, state, enabled}) => { if (featureLayer.current) { featureLayer.current.leafletElement.clearLayers(); } - - UI.setSelectedBounds(null); + UI.setSelectedBounds({bounds: null, speedSearch: false}); }, [featureLayer.current]); const onCreated = useCallback((e) => { const {layer} = e; - - const layerBounds = layer.getBounds(); - UI.setSelectedBounds(layerBounds); + const rectangleColor = layer.options.fillColor; + if (layer && layer.getBounds() && rectangleColor === "var(--red)") { + UI.setSelectedBounds({bounds: layer.getBounds(), speedSearch: true}); + UI.setSidePanelTab(SidePanelTabs.AreaSpeeds); + } + if (layer && layer.getBounds() && rectangleColor === "var(--blue)") { + UI.setSelectedBounds({bounds: layer.getBounds(), speedSearch: false}); + UI.setSidePanelTab(SidePanelTabs.AreaSpeeds); + } }, []); useEffect(() => { @@ -46,38 +61,20 @@ const AreaSelect = decorate(({UI, state, enabled}) => { return () => { resetListener(); }; - }, []); + }, [user, selectedBounds, routeSelected]); + const rectangleStyles = state.speedSearch ? speedRectangleStyle : hfpRectangleStyle; return ( - - {selectedBounds && ( - <> - - - - )} + {selectedBounds && } ); }); diff --git a/src/components/map/CustomDrawingControl.js b/src/components/map/CustomDrawingControl.js new file mode 100644 index 00000000..19f94fe8 --- /dev/null +++ b/src/components/map/CustomDrawingControl.js @@ -0,0 +1,181 @@ +import React from "react"; +import {withLeaflet} from "react-leaflet"; +import {renderToStaticMarkup} from "react-dom/server"; +import L from "leaflet"; +import "leaflet-draw"; +import Cross from "../../icons/Cross"; +import Square from "../../icons/Square"; +import Speedlimit from "../../icons/Speedlimit"; +import {text} from "../../helpers/text"; + +const buttonStyles = { + backgroundColor: "white", + cursor: "pointer", + width: "26px", + height: "26px", + padding: "0", + margin: "2px", + textAlign: "center", + fontWeight: "bold", + fontSize: "18px", + border: "none", + fontSize: "10px", +}; + +class CustomDrawingControl extends React.Component { + componentDidUpdate(prevProps) { + const {routeSelected, user, selectedBounds} = this.props; + + if ( + prevProps.routeSelected !== routeSelected || + prevProps.user !== user || + prevProps.selectedBounds !== selectedBounds + ) { + if (this.cancelButton) { + if (!selectedBounds) { + this.cancelButton.style.display = "none"; + } else { + this.cancelButton.style.display = "block"; + } + } + + if (this.speedButton) { + if (!routeSelected || !user) { + this.speedButton.style.display = "none"; + } else { + this.speedButton.style.display = "block"; + } + } + } + } + + componentDidMount() { + const {map} = this.props.leaflet; + this.map = map; + + const customControl = L.Control.extend({ + options: { + position: "bottomright", + }, + onAdd: () => { + const container = L.DomUtil.create("div", "custom-drawing-control"); + + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.alignItems = "center"; + container.style.justifyContent = "center"; + container.style.padding = "5px"; + + function applyStyles(element, styles) { + Object.assign(element.style, styles); + } + + this.cancelButton = L.DomUtil.create("button", "leaflet-bar", container); + this.speedButton = L.DomUtil.create("button", "leaflet-bar", container); + this.hfpButton = L.DomUtil.create("button", "leaflet-bar", container); + + this.cancelButton.innerHTML = renderToStaticMarkup( + + ); + this.speedButton.innerHTML = renderToStaticMarkup( + + ); + this.hfpButton.innerHTML = renderToStaticMarkup( + + ); + + applyStyles(this.cancelButton, buttonStyles); + applyStyles(this.hfpButton, buttonStyles); + applyStyles(this.speedButton, buttonStyles); + this.speedButton.style.display = "none"; + this.cancelButton.style.display = "none"; + + this.speedButton.title = `${text("map.area_speed_search")}`; + + L.DomEvent.on(this.cancelButton, "click", this.handleCancel, this); + L.DomEvent.on(this.hfpButton, "click", this.drawBlueRectangle, this); + L.DomEvent.on(this.speedButton, "click", this.drawRedRectangle, this); + + return container; + }, + }); + + this.control = new customControl(); + this.map.addControl(this.control); + + this.blueRectangleDrawer = new L.Draw.Rectangle(this.map, { + shapeOptions: { + weight: 2, + dashArray: "10 4", + opacity: 1, + color: "white", + fillColor: "var(--blue)", + fillOpacity: 0.1, + }, + test: { + test: "test", + }, + }); + + this.redRectangleDrawer = new L.Draw.Rectangle(this.map, { + shapeOptions: { + weight: 2, + dashArray: "10 4", + opacity: 1, + color: "white", + fillColor: "var(--red)", + fillOpacity: 0.1, + }, + }); + + this.map.on(L.Draw.Event.CREATED, this.props.onCreated); + } + + componentWillUnmount() { + if (this.control) { + this.map.removeControl(this.control); + } + if (this.blueRectangleDrawer) { + this.blueRectangleDrawer.disable(); + } + if (this.redRectangleDrawer) { + this.redRectangleDrawer.disable(); + } + if (this.map) { + this.map.off(L.Draw.Event.CREATED, this.props.onCreated); + } + } + + drawBlueRectangle = () => { + this.props.onClear(); + this.redRectangleDrawer.disable(); + this.blueRectangleDrawer.enable(); + }; + + drawRedRectangle = () => { + this.props.onClear(); + this.blueRectangleDrawer.disable(); + this.redRectangleDrawer.enable(); + }; + + handleCancel = () => { + this.blueRectangleDrawer.disable(); + this.redRectangleDrawer.disable(); + + this.map.eachLayer((layer) => { + if (layer instanceof L.Rectangle && !layer._latlngs) { + this.map.removeLayer(layer); + } + }); + + if (this.props.onClear) { + this.props.onClear(); + } + }; + + render() { + return null; + } +} + +export default withLeaflet(CustomDrawingControl); diff --git a/src/components/map/Map.js b/src/components/map/Map.js index c232ef48..a01b63b3 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -133,7 +133,13 @@ const Map = decorate(({state, UI, children, className, detailsOpen}) => { // De-observed initial map state, because an update loop would be created otherwise. const initialViewport = useMemo(() => { const {mapView, mapZoom} = state; - return [mapView, mapZoom]; + let center = mapView; + if (center instanceof LatLngBounds) { + center = center.getCenter(); + } + const defaultCenter = [60.1699, 24.9384]; + const defaultZoom = 13; + return [center || defaultCenter, mapZoom || defaultZoom]; }, []); // Copy the internal state of the map into our app state. @@ -153,7 +159,7 @@ const Map = decorate(({state, UI, children, className, detailsOpen}) => { return reaction( () => state.mapView, (currentView) => { - if (leafletMap && state.objectCenteringAllowed) { + if (leafletMap && state.objectCenteringAllowed && currentView) { if ( currentView instanceof LatLngBounds && validBounds(currentView) && @@ -169,6 +175,8 @@ const Map = decorate(({state, UI, children, className, detailsOpen}) => { // If mapView is a latLng which does NOT equal the current map center, // set the map center to mapView. leafletMap.setView(currentView, state.mapZoom, {animate: false}); + } else { + console.warn("Invalid currentView in map view reaction:", currentView); } } }, diff --git a/src/components/map/MapEvents.js b/src/components/map/MapEvents.js index c75a1c26..9b9ba907 100644 --- a/src/components/map/MapEvents.js +++ b/src/components/map/MapEvents.js @@ -10,7 +10,12 @@ const decorate = flow(observer); const MapEvents = decorate(({children}) => { return ( - {({journeys: areaJourneysResult = [], loading: areaJourneysLoading}) => { + {({ + journeys: areaJourneysResult = [], + areaSpeeds: areaSpeedsResult = [], + loading: areaJourneysLoading, + speedSearch: speedSearchResult = false, + }) => { return ( {({ @@ -19,16 +24,19 @@ const MapEvents = decorate(({children}) => { }) => ( - {({currentJourneys = []}) => - children({ + {({currentJourneys = []}) => { + return children({ selectedJourney, areaJourneys: areaJourneysResult, + areaSpeeds: areaSpeedsResult, + speedSearch: speedSearchResult, currentJourneys, areaJourneysLoading, selectedJourneyLoading: selectedJourneyLoading, - }) - } + }); + }} )} diff --git a/src/components/sidepanel/SidePanel.js b/src/components/sidepanel/SidePanel.js index b81fdd21..111f05e0 100644 --- a/src/components/sidepanel/SidePanel.js +++ b/src/components/sidepanel/SidePanel.js @@ -7,6 +7,7 @@ import StopDepartures from "./StopDepartures"; import VehicleJourneys from "./VehicleJourneys"; import {text} from "../../helpers/text"; import AreaJourneyList from "./AreaJourneyList"; +import SpeedAreaJourneyList from "./SpeedAreaJourneyList"; import JourneyPanel from "../journeypanel/JourneyPanel"; import Info from "../../icons/Info"; import Chart from "../../icons/Chart"; @@ -116,6 +117,7 @@ const SidePanel = decorate((props) => { const { UI: {toggleSidePanel, toggleJourneyDetails, toggleJourneyGraph, setSidePanelTab}, areaEvents = [], + areaSpeeds = [], journey = null, journeyLoading = false, areaJourneysLoading = false, @@ -132,21 +134,26 @@ const SidePanel = decorate((props) => { sidePanelVisible, showInstructions = false, selectedBounds, + speedSearch, user, sidePanelTab, }, } = props; - const areaSearchActive = !!selectedBounds; + const speedAreaSearchActive = !!speedSearch; + const areaSearchActive = !!selectedBounds && !speedSearch; const hasRoute = (stateRoute && stateRoute.routeId) || (route && route.routeId); const routeId = createRouteId(route); const allTabsHidden = - !hasRoute && !areaSearchActive && !vehicle && !stateStop && !stateTerminal; - + !hasRoute && + !areaSearchActive && + !vehicle && + !stateStop && + !stateTerminal && + !speedAreaSearchActive; const detailsCanOpen = getJourneyId(selectedJourney) || route; - return ( @@ -168,6 +175,15 @@ const SidePanel = decorate((props) => { label={text("sidepanel.tabs.area_events")} /> )} + {speedAreaSearchActive && ( + + )} {hasRoute && ( {}, loading = false, testIdPrefix = "sidepanel", + disableScrollOffset = false, }) => { const scrollElementRef = useRef(null); const scrollPositionRef = useRef(null); @@ -86,7 +86,7 @@ const SidepanelList = decorate( const nextOffset = scrollPositionRef.current.offsetTop; const currentOffset = scrollElementRef.current.scrollTop; - if (nextOffset && nextOffset !== currentOffset) { + if (nextOffset && nextOffset !== currentOffset && !disableScrollOffset) { scrollElementRef.current.scrollTop = nextOffset - listHeight.current / 2; } } diff --git a/src/components/sidepanel/SpeedAreaJourneyList.js b/src/components/sidepanel/SpeedAreaJourneyList.js new file mode 100644 index 00000000..257ed599 --- /dev/null +++ b/src/components/sidepanel/SpeedAreaJourneyList.js @@ -0,0 +1,161 @@ +import React, {useCallback, useMemo} from "react"; +import {observer} from "mobx-react-lite"; +import SidepanelList from "./SidepanelList"; +import styled from "styled-components"; +import flow from "lodash/flow"; +import {inject} from "../../helpers/inject"; +import EmptyView from "../EmptyView"; +import {text} from "../../helpers/text"; +import Tooltip from "../Tooltip"; + +const ListHeader = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding: 5px; + font-size: 1rem; +`; + +const ListWrapper = styled.div``; + +const TableRow = styled.div` + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--lightest-grey); + flex-wrap: nowrap; + background-color: #f6fcff; +`; + +const TableBody = styled.div` + padding-top: 2rem; +`; + +const TableCell = styled.div` + width: 4.125rem; + padding: 0.5rem 0.25rem; + flex: 1 1 auto; + text-align: center; + border: 0; + border-right: 1px solid var(--lightest-grey); + font-size: 0.75rem; + position: relative; + + &:last-child { + border-right: 0; + } +`; + +const TableHeader = styled(TableRow)` + font-weight: bold; + border-bottom-width: 1px; + border-color: var(--alt-grey); + + ${TableCell} { + border-color: var(--alt-grey); + } +`; + +const decorate = flow(observer, inject("Journey", "Time", "UI")); + +const SpeedAreaJourneyList = decorate( + ({areaSpeeds, loading, state: {language, speedFilter}}) => { + const vehiclepositions = areaSpeeds.flatMap((journey) => { + return journey.vehiclePositions.map((vehiclePosition) => { + const speed = Math.round(vehiclePosition.velocity * 3.6 * 100) / 100; + return { + ...vehiclePosition, + departureTime: journey.departureTime, + routeId: journey.routeId, + operatorId: journey.operatorId, + vehicleId: journey.vehicleId, + speed: speed, + }; + }); + }); + const headerText = { + fi: `${text("filterpanel.speed_search_text_1")} ${speedFilter} Km/h ${text( + "filterpanel.speed_search_text_2" + )}`, + se: `${text("filterpanel.speed_search_text_1")} ${speedFilter} Km/h`, + en: `${text("filterpanel.speed_search_text_1")} ${speedFilter} Km/h`, + }; + vehiclepositions.sort((a, b) => b.speed - a.speed); + return ( + + {headerText[language] ? `${headerText[language]}` : `${headerText["en"]}`} + + } + floatingListHeader={ + + + Km/h + + + {text("map.stops.depart")} + + + {text("vehicle.operator_short")} + + + {text("vehicle.identifier_short")} + + + {text("sidepanel.tabs.timestamp")} + + + }> + {(scrollRef) => + (!areaSpeeds || areaSpeeds.length === 0) && !loading ? ( + + ) : ( + + + {vehiclepositions.map((vehicleposition, index) => { + const { + departureTime, + routeId, + speed, + recordedTime, + operatorId, + vehicleId, + } = vehicleposition; + + return ( + + + {speed} + + + {departureTime} + + + {operatorId} + + + {vehicleId} + + + {recordedTime} + + + ); + })} + + + ) + } + + ); + } +); + +export default SpeedAreaJourneyList; diff --git a/src/constants.js b/src/constants.js index c224534c..75b9362e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -11,6 +11,7 @@ export const ENV_NAME = process.env.REACT_APP_ENV_NAME || ""; // Faux-enum for validating sidepanel tab changes export const SidePanelTabs = { AreaJourneys: "area-journeys", + AreaSpeeds: "area-speeds", Journeys: "journeys", WeekJourneys: "week-journeys", VehicleJourneys: "vehicle-journeys", diff --git a/src/icons/Speedlimit.js b/src/icons/Speedlimit.js new file mode 100644 index 00000000..5e6a6c53 --- /dev/null +++ b/src/icons/Speedlimit.js @@ -0,0 +1,38 @@ +import React from "react"; +import {Svg, Path} from "react-primitives-svg"; +import PropTypes from "prop-types"; +import {svgSize} from "../helpers/svg"; + +export default function Icon({fill, height, width, ...rest}) { + return ( + + + + + + ); +} + +Icon.propTypes = { + fill: PropTypes.string, + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +Icon.displayName = "Icons.Speedlimit"; diff --git a/src/icons/Square.js b/src/icons/Square.js new file mode 100644 index 00000000..8c617afe --- /dev/null +++ b/src/icons/Square.js @@ -0,0 +1,26 @@ +import React from "react"; +import {Svg, Path} from "react-primitives-svg"; +import PropTypes from "prop-types"; +import {svgSize} from "../helpers/svg"; + +export default function Icon({fill, height, width, ...rest}) { + return ( + + + + + ); +} + +Icon.propTypes = { + fill: PropTypes.string, + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +Icon.displayName = "Icons.Square"; diff --git a/src/languages/ui/en.json b/src/languages/ui/en.json index 4c3ec974..fa8c9e04 100644 --- a/src/languages/ui/en.json +++ b/src/languages/ui/en.json @@ -6,6 +6,8 @@ "filterpanel.choose_time": "Choose time", "filterpanel.time_increment": "Time increment (sec.)", "filterpanel.area_search_range": "Area search range (min.)", + "filterpanel.speed_search_limit": "Speed search limit (Km/h)", + "filterpanel.speed_search_text_1": "Events exceeding the speed of", "filterpanel.simulate.start": "Simulate", "filterpanel.simulate.stop": "Stop simulation", "filterpanel.filter_by_vehicle": "Filter by vehicle", @@ -38,8 +40,16 @@ "sidepanel.tabs.timetables": "Stop", "sidepanel.tabs.vehicle_journeys": "Vehicle", "sidepanel.tabs.area_events": "Area", + "sidepanel.tabs.area_speeds": "Area Speeds", + "sidepanel.tabs.timestamp": "Timestamp", "sidepanel.area_events.show_lines": "Show as heatmap", "sidepanel.area_events.show_markers": "Show as markers", + "sidepanel.tabs.speed": "Vehicle speed (km/h) in the HFP VP event.", + "sidepanel.tabs.speed_limit": "Speed filter (km/h)", + "sidepanel.tabs.departure": "Planned departure time", + "sidepanel.tabs.operator": "Operator", + "sidepanel.tabs.vehicle_number": "Vehicle number", + "sidepanel.tabs.timestamp_description": "HFP VP-event timestamp", "map.stops.arrive": "Arrival", "map.stops.depart": "Departure", @@ -58,14 +68,17 @@ "map.route_segment.distance_from_prev": "Distance from previous stop", "map.route_segment.distance_from_start": "Distance from start", "map.route_segment.duration": "Planned duration", + "map.area_speed_search": "Area speed search", "vehicle.next_stop": "Next stop", "vehicle.speed": "Speed", "vehicle.registry_nr": "Registry number", "vehicle.age": "Vehicle age", "vehicle.identifier": "Vehicle identifier", + "vehicle.identifier_short": "Veh. Id.", "vehicle.train_number": "Train number", "vehicle.operator": "Operator", + "vehicle.operator_short": "Op.", "vehicle.subcontractor": "Subcontractor", "vehicle.delay.late": "Late", "vehicle.delay.early": "Early", @@ -201,11 +214,6 @@ "apc.fewStandingSeats": "Few standing seats", "apc.full": "Full", - "apc.totalPassengersIn": "Passengers in", - "apc.totalPassengersOut": "Passengers out", - "apc.vehicleLoad": "Vehicle load", - "apc.vehicleLoadRatio": "Vehicle load ratio", - "auth.login": "Log in (HSL ID)", "auth.logout": "Log out", "auth.create_account": "Create Reittiloki account", diff --git a/src/languages/ui/fi.json b/src/languages/ui/fi.json index 3aa100bf..30e225bb 100644 --- a/src/languages/ui/fi.json +++ b/src/languages/ui/fi.json @@ -6,6 +6,9 @@ "filterpanel.choose_time": "Valitse aika", "filterpanel.time_increment": "Aikamuutos (sek.)", "filterpanel.area_search_range": "Aluehaun aikaraja (min.)", + "filterpanel.speed_search_limit": "Nopeushaun raja (Km/h)", + "filterpanel.speed_search_text_1": "Nopeusrajan", + "filterpanel.speed_search_text_2": "ylittävät tapahtumat", "filterpanel.simulate.start": "Simuloi", "filterpanel.simulate.stop": "Pysäytä simulaatio", "filterpanel.filter_by_vehicle": "Hae ajoneuvoa", @@ -38,8 +41,16 @@ "sidepanel.tabs.timetables": "Aikataulut", "sidepanel.tabs.vehicle_journeys": "Lähtöketju", "sidepanel.tabs.area_events": "Alueen matkat", + "sidepanel.tabs.timestamp": "Aikaleima", "sidepanel.area_events.show_lines": "Näytä viivoina", "sidepanel.area_events.show_markers": "Näytä merkkeinä", + "sidepanel.tabs.area_speeds": "Alueen nopeudet", + "sidepanel.tabs.speed": "Km/h: Ajoneuvon nopeus (km/h) aikaleiman mukaisessa HFP VP-eventissä.", + "sidepanel.tabs.speed_limit": "Nopeusrajoitus (km/h)", + "sidepanel.tabs.departure": "Lähdön suunniteltu lähtöaika", + "sidepanel.tabs.operator": "Liikennöitsijä", + "sidepanel.tabs.vehicle_number": "Ajoneuvon kylkinumero", + "sidepanel.tabs.timestamp_description": "HFP VP-eventin aikaleima", "map.stops.arrive": "Saapumisaika", "map.stops.depart": "Lähtöaika", @@ -58,14 +69,17 @@ "map.route_segment.distance_from_prev": "Pituus ed. pysäkiltä", "map.route_segment.distance_from_start": "Pituus alusta", "map.route_segment.duration": "Suunniteltu ajoaika", + "map.area_speed_search": "Nopeuksien aluehaku", "vehicle.next_stop": "Seuraava pysäkki", "vehicle.speed": "Nopeus", "vehicle.registry_nr": "Rekisterinumero", "vehicle.age": "Ajoneuvon ikä", "vehicle.identifier": "Kylkinumero", + "vehicle.identifier_short": "Kylkinro.", "vehicle.train_number": "Junanumero", "vehicle.operator": "Liikennöitsijä", + "vehicle.operator_short": "Liik.", "vehicle.subcontractor": "Ali-hankkija", "vehicle.delay.late": "Myöhässä", "vehicle.delay.early": "Etuajassa", diff --git a/src/languages/ui/se.json b/src/languages/ui/se.json index 90ff2363..8830dc85 100644 --- a/src/languages/ui/se.json +++ b/src/languages/ui/se.json @@ -6,6 +6,8 @@ "filterpanel.choose_time": "Välj tid", "filterpanel.time_increment": "Tidsändring (sek.)", "filterpanel.area_search_range": "Områdssökningens tidslängd (min.)", + "filterpanel.speed_search_limit": "Gräns för hastighetssökning (Km/h)", + "filterpanel.speed_search_text_1":"Händelser som överskrider hastighetsbegränsningen på", "filterpanel.simulate.start": "Simulera", "filterpanel.simulate.stop": "Sluta simulera", "filterpanel.filter_by_vehicle": "Sök fordon", @@ -38,8 +40,16 @@ "sidepanel.tabs.timetables": "Tidtabeller", "sidepanel.tabs.vehicle_journeys": "Fordonsresor", "sidepanel.tabs.area_events": "Områdets resor", + "sidepanel.tabs.area_speeds": "Områdets hastigheter", + "sidepanel.tabs.timestamp": "Tidsstämpel", "sidepanel.area_events.show_lines": "Visa som streck", "sidepanel.area_events.show_markers": "Visa som markörer", + "sidepanel.tabs.speed": "Fordonets hastighet (km/h) i HFP VP-händelsen vid tidsstämpeln.", + "sidepanel.tabs.speed_limit": "Hastighetsgräns (km/h)", + "sidepanel.tabs.departure": "Planerad avgångstid", + "sidepanel.tabs.operator": "Operatör", + "sidepanel.tabs.vehicle_number": "Fordonsnummer", + "sidepanel.tabs.timestamp_description": "HFP VP-event tidsstämpel för HFP VP-händelse", "map.stops.arrive": "Ankomst", "map.stops.depart": "Avgång", @@ -58,14 +68,17 @@ "map.route_segment.distance_from_prev": "Distans från föregående hållplats", "map.route_segment.distance_from_start": "Distans från början", "map.route_segment.duration": "Planerad körtid", + "map.area_speed_search": "Områdessökning av hastigheter", "vehicle.next_stop": "Nästa hållplats", "vehicle.speed": "Hastighet", "vehicle.registry_nr": "Registernummer", "vehicle.age": "Fordonets ålder", "vehicle.identifier": "Fordonets ID", + "vehicle.identifier_short": "Veh. Id.", "vehicle.train_number": "Tågnummer", "vehicle.operator": "Operatör", + "vehicle.operator_short": "Op.", "vehicle.subcontractor": "Underleverantör", "vehicle.delay.late": "Försenad", "vehicle.delay.early": "Tidig", diff --git a/src/queries/AreaJourneysQuery.js b/src/queries/AreaJourneysQuery.js index 5e1fd796..a0dfdef0 100644 --- a/src/queries/AreaJourneysQuery.js +++ b/src/queries/AreaJourneysQuery.js @@ -41,29 +41,93 @@ const areaJourneysQuery = gql` } `; +const areaSpeedsQuery = gql` + query areaSpeedsQuery( + $minTime: DateTime! + $maxTime: DateTime! + $bbox: PreciseBBox! + $date: Date! + $routeId: String! + $speedFilter: String! + ) { + journeysByBboxAndRouteId( + minTime: $minTime + maxTime: $maxTime + bbox: $bbox + date: $date + routeId: $routeId + speedFilter: $speedFilter + ) { + id + journeyType + routeId + direction + departureDate + departureTime + uniqueVehicleId + operatorId + vehicleId + headsign + mode + vehiclePositions { + id + recordedAt + recordedAtUnix + recordedTime + stop + lat + lng + loc + doorStatus + velocity + delay + heading + } + } + } +`; + const AreaJourneysQuery = observer((props) => { - const {minTime, maxTime, bbox, date, skip, children} = props; + const { + minTime, + maxTime, + bbox, + date, + skip, + children, + speedSearch, + routeId, + speedFilter, + } = props; const queryParamsValid = minTime && maxTime && bbox && date; const shouldSkip = skip || !queryParamsValid; + const variables = { + minTime, + maxTime, + bbox, + date, + }; + const areaQuery = speedSearch ? areaSpeedsQuery : areaJourneysQuery; + const resultSelector = speedSearch ? "journeysByBboxAndRouteId" : "journeysByBbox"; + if (speedSearch) { + variables.routeId = routeId; + variables.speedFilter = speedFilter; + } return ( + variables={variables} + query={areaQuery}> {({loading, data, error}) => { if (!data || loading) { return children({journeys: [], loading, error}); } - const journeys = get(data, "journeysByBbox", []); - return children({journeys, loading, error}); + const journeys = get(data, resultSelector, []); + const areaSpeeds = get(data, resultSelector, []); + return children({journeys, areaSpeeds, loading, error}); }} ); diff --git a/src/stores/TimeStore.js b/src/stores/TimeStore.js index e00692df..dfc88842 100644 --- a/src/stores/TimeStore.js +++ b/src/stores/TimeStore.js @@ -35,6 +35,7 @@ export default (state, initialState) => { }, timeIncrement: parseInt(get(initialState, "time_increment", "5"), 10), areaSearchRangeMinutes: parseInt(get(initialState, "area_search_minutes", 60), 10), + speedFilter: get(initialState, "speed_filter", "30"), }); const actions = timeActions(state); diff --git a/src/stores/UIStore.js b/src/stores/UIStore.js index 7e02fe9f..62453ec9 100644 --- a/src/stores/UIStore.js +++ b/src/stores/UIStore.js @@ -59,6 +59,7 @@ export default (state) => { areaEventsStyle: getUrlValue("areaEventsStyle", areaEventsStyles.MARKERS), areaEventsRouteFilter: getUrlValue("areaEventsRouteFilter", ""), selectedBounds: urlBounds ? boundsFromBBoxString(urlBounds) : null, + speedSearch: getUrlValue("speedSearch", ""), weeklyObservedTimes: getUrlValue( "weeklyObservedTimes", weeklyObservedTimeTypes.FIRST_STOP_DEPARTURE @@ -81,6 +82,7 @@ export default (state) => { }, { selectedBounds: observable.ref, + speedSearch: observable.ref, currentMapillaryViewerLocation: observable.ref, currentMapillaryMapLocation: observable.ref, mapView: observable.ref, diff --git a/src/stores/timeActions.js b/src/stores/timeActions.js index 74fadcde..4ff76e1e 100644 --- a/src/stores/timeActions.js +++ b/src/stores/timeActions.js @@ -48,12 +48,18 @@ const timeActions = (state) => { setUrlValue("live", state.live); }); + const setSpeedFilter = action("Set speed increment", (speedIncrement = 0) => { + state.speedFilter = "" + Math.max(Math.min(intval(speedIncrement || 0), 60 * 60), 1); + setUrlValue("speed_filter", state.speedFilter); + }); + return { setTime, setSeconds, setTimeIncrement, setAreaSearchMinutes, toggleLive, + setSpeedFilter, }; }; diff --git a/src/stores/uiActions.js b/src/stores/uiActions.js index 0cf84cf1..8628fe11 100644 --- a/src/stores/uiActions.js +++ b/src/stores/uiActions.js @@ -176,7 +176,9 @@ export default (state) => { state.highlightedStop = stopId; }); - const setSelectedBounds = action((bounds) => { + const setSelectedBounds = action(({bounds, speedSearch}) => { + setUrlValue("speedSearch", speedSearch); + state.speedSearch = speedSearch; state.selectedBounds = !bounds || (typeof bounds.isValid === "function" && !bounds.isValid) ? null diff --git a/yarn.lock b/yarn.lock index 5d05e4da..6718ba3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1866,6 +1866,11 @@ native-url "^0.2.6" schema-utils "^2.6.5" +"@react-leaflet/core@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@react-leaflet/core/-/core-2.1.0.tgz#383acd31259d7c9ae8fb1b02d5e18fe613c2a13d" + integrity sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg== + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.1" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" From 5339b3bf4af8293f172345466739f52c69f7ca17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Jyrki=C3=A4inen?= Date: Thu, 19 Dec 2024 15:47:07 +0200 Subject: [PATCH 2/2] use tags --- .github/workflows/production.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index ec457821..6cbf5fca 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -2,8 +2,8 @@ name: Deploy and test production on: push: - branches: - - production + tags: + - 'v*' jobs: deploy-production: @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v2 with: - ref: production + ref: ${{ github.ref }} - name: Build UI and publish Docker image uses: elgohr/Publish-Docker-Github-Action@master with: @@ -19,7 +19,7 @@ jobs: username: ${{ secrets.TRANSITLOG_DOCKERHUB_USER }} password: ${{ secrets.TRANSITLOG_DOCKERHUB_TOKEN }} buildargs: BUILD_ENV=production - tags: production + tags: ${{ github.ref_name }} - name: Checkout server uses: actions/checkout@v2 with: @@ -47,6 +47,7 @@ jobs: with: time: "5m" # Wait until all servers are restarted - name: Cypress tests + if: false uses: cypress-io/github-action@v2 # with: # record: true # Only production records to Cypress.io