diff --git a/src/frontend/app/components/Map/feature.js b/src/frontend/app/components/Map/feature.js index 636b1b46..5365f1e9 100644 --- a/src/frontend/app/components/Map/feature.js +++ b/src/frontend/app/components/Map/feature.js @@ -12,6 +12,8 @@ export default class RideFeature extends Feature { super(...args); const props = args[0] || {}; + this.set('cursor', props.cursor || 'pointer'); + if (props.style) { this.normal = Styles.pin[props.style].normal; this.active = Styles.pin[props.style].active; @@ -24,7 +26,7 @@ export default class RideFeature extends Feature { this.action = props.action; this.on('propertychange', this.propertyChanged) this.set('visible', props.isVisible); - this.set('isPreview', false) + this.set('isPreview', false); } // used to update available styles based on the underlying event changing diff --git a/src/frontend/app/components/Map/helpers.js b/src/frontend/app/components/Map/helpers.js index 7f66479b..e024342c 100644 --- a/src/frontend/app/components/Map/helpers.js +++ b/src/frontend/app/components/Map/helpers.js @@ -38,22 +38,37 @@ proj4.defs([ export const MapContext = createContext(); -// handles toggling the hover state over OpenLayers features +// handles toggling the hover state over OpenLayers features and displaying +// their preferred cursor export function pointerMove(e) { + const mapEl = e.map.getTargetElement(); + const currentCursor = mapEl.style.cursor; + const feature = e.map.getFeaturesAtPixel(e.pixel, { layerFilter: (layer) => layer.listenForHover, })[0]; - if (feature?.noHover || feature?.get('isPreview')) { return; } + if (feature?.noHover || feature?.get('isPreview')) { + return; + } if (!feature?.styleState) { if (e.map.hoveredFeature) { e.map.hoveredFeature.set('hovered', false); e.map.hoveredFeature = null; } + if (currentCursor !== 'default') { + mapEl.style.cursor = 'default'; + } return; } + if (e.map.hoveredFeature && e.map.hoveredFeature !== feature) { e.map.hoveredFeature.set('hovered', false); } + + if (currentCursor === 'default') { + mapEl.style.cursor = feature.get('cursor'); + } + e.map.hoveredFeature = feature; if (!feature.get('hovered')) { feature.set('hovered', true); @@ -68,10 +83,14 @@ export function click(evt, dispatch) { const feature = evt.map.getFeaturesAtPixel(evt.pixel,{ layerFilter: (layer) => layer.listenForClicks, })[0]; - + console.log(feature); if (feature?.noSelect || feature?.get('noSelect')) { return; } - if (feature?.styleState) { // new selection + console.log(feature.get('type')); + if (feature.get('type') === 'sign') { + console.log(feature.get('raw')); + } + else if (feature?.styleState) { // new selection selectFeature(evt.map, feature); const raw = feature.pointFeature.get('raw'); dispatch({ type: 'reset form', value: raw, showPreview: true, showForm: false }); @@ -252,7 +271,6 @@ export class Drag extends PointerInteraction { super({ handleDownEvent: handleDownEvent, handleDragEvent: handleDragEvent, - handleMoveEvent: handleMoveEvent, handleUpEvent: handleUpEvent, }); options = options || {}; @@ -267,12 +285,6 @@ export class Drag extends PointerInteraction { */ this.coordinate_ = null; - /** - * @type {string|undefined} - * @private - */ - this.cursor_ = 'pointer'; - /** * @type {Feature} * @private @@ -306,8 +318,8 @@ function handleDownEvent(evt) { layerFilter: (layer) => layer.canDragFeatures, })[0]; - if (feature) { - if (feature.noSelect || feature.get('isPreview') || feature.get('noSelect')) { return false; } + const canDrag = feature?.get('canDrag'); + if (canDrag) { map.route?.clear(); this.coordinate_ = evt.coordinate; feature.set('dragStartCoordinate', [...feature.getGeometry().getCoordinates()]); @@ -315,50 +327,35 @@ function handleDownEvent(evt) { this.feature_ = feature; } - return !!feature; + return canDrag; } /** * @param {import('ol/MapBrowserEvent.js').default} evt Map browser event. */ function handleDragEvent(evt) { + const mapEl = evt.map.getTargetElement(); + if (mapEl.style.cursor !== 'grabbing') { + this.previousCursor_ = mapEl.style.cursor; + mapEl.style.cursor = 'grabbing'; + } this.feature_.getGeometry().setCoordinates(evt.coordinate); this.coordinate_ = evt.coordinate; } -/** - * @param {import('ol/MapBrowserEvent.js').default} evt Event. - */ -function handleMoveEvent(evt) { - if (this.cursor_) { - const map = evt.map; - const feature = map.getFeaturesAtPixel(evt.pixel, { - layerFilter: (layer) => layer.canDragFeatures, - })[0]; - const element = evt.map.getTargetElement(); - if (feature) { - if (feature.noHover || feature.get('isPreview')) { return; } - if (element.style.cursor != this.cursor_) { - this.previousCursor_ = element.style.cursor; - element.style.cursor = this.cursor_; - } - } else if (this.previousCursor_ !== undefined) { - element.style.cursor = this.previousCursor_; - this.previousCursor_ = undefined; - } - } -} - /** * @return {boolean} `false` to stop the drag sequence. */ function handleUpEvent(e) { + e.map.getTargetElement().style.cursor = this.previousCursor_; + this.previousCursor_ = undefined; if (e.originalEvent?.button === 0) { // ignore right or middle click this.endHandler(e, this.feature_, this.dispatch); this.coordinate_ = null; if (this.feature_.upHandler) { this.feature_.upHandler(e) } this.feature_ = null; } + return false; } diff --git a/src/frontend/app/components/Map/index.jsx b/src/frontend/app/components/Map/index.jsx index 7bc7b043..f9d2be49 100644 --- a/src/frontend/app/components/Map/index.jsx +++ b/src/frontend/app/components/Map/index.jsx @@ -4,7 +4,7 @@ import AsyncSelect from 'react-select/async'; import { DebuggingContext, MapContext } from '../../contexts'; -import { createMap, ll2g } from './helpers'; +import { createMap, ll2g, pointerMove } from './helpers'; import { get } from '../../shared/helpers'; import { GEOCODER_CLIENT_ID, GEOCODER_HOST } from '../../env'; @@ -89,6 +89,7 @@ export default function Map({ children, dispatch, event, clickHandler }) { const map = createMap(); map.setTarget(elementRef.current); map.on('click', click); + map.on('pointermove', pointerMove); map.on('propertychange', (e) => { if (e.key !== 'base') { return; } if (e.target.get('base') === 'vector') { diff --git a/src/frontend/app/components/Map/BoundariesLayer.jsx b/src/frontend/app/components/Map/layers/Boundaries.jsx similarity index 97% rename from src/frontend/app/components/Map/BoundariesLayer.jsx rename to src/frontend/app/components/Map/layers/Boundaries.jsx index b5937d15..d3624369 100644 --- a/src/frontend/app/components/Map/BoundariesLayer.jsx +++ b/src/frontend/app/components/Map/layers/Boundaries.jsx @@ -5,10 +5,10 @@ import { useContext, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { selectAllServiceAreaBoundaries, selectServiceAreaBoundariesStatus, -} from '../../slices/serviceAreaBoundaries'; +} from '../../../slices/serviceAreaBoundaries'; import { selectAllDistrictBoundaries, selectDistrictBoundariesStatus, -} from '../../slices/districtBoundaries'; +} from '../../../slices/districtBoundaries'; // Openlayers import { Polygon } from 'ol/geom'; @@ -19,7 +19,7 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; // Internal imports -import { MapContext } from '../../contexts.js'; +import { MapContext } from '../../../contexts'; function layerStyle(feature, visibleLayers) { return feature.get('visible') ? feature.get('style') : null ; diff --git a/src/frontend/app/components/Map/layers/Dms.jsx b/src/frontend/app/components/Map/layers/Dms.jsx new file mode 100644 index 00000000..f633cc51 --- /dev/null +++ b/src/frontend/app/components/Map/layers/Dms.jsx @@ -0,0 +1,277 @@ +import { useContext, useEffect, useState, useRef } from 'react'; +import { useSelector, useDispatch, useStore } from 'react-redux'; + +import GeoJSON from 'ol/format/GeoJSON.js'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { Point } from 'ol/geom'; +import { Icon, Style } from 'ol/style'; + +import { MapContext } from '../../../contexts'; + +import { ll2g } from '../helpers.js'; +import RideFeature from '../feature.js'; +import ContextMenu from '../../../events/ContextMenu.jsx'; + +import { refreshDms } from '../../../slices/dms'; +import { EVENT_POLLING_REFRESH } from '../../../env'; + + +import dmsEastIconActive from '../../../events/icons/dms-east-active.png'; +import dmsEastIconHover from '../../../events/icons/dms-east-hover.png'; +import dmsEastIconStatic from '../../../events/icons/dms-east-static.png'; + +import dmsSouthIconActive from '../../../events/icons/dms-south-active.png'; +import dmsSouthIconHover from '../../../events/icons/dms-south-hover.png'; +import dmsSouthIconStatic from '../../../events/icons/dms-south-static.png'; + +import dmsWestIconActive from '../../../events/icons/dms-west-active.png'; +import dmsWestIconHover from '../../../events/icons/dms-west-hover.png'; +import dmsWestIconStatic from '../../../events/icons/dms-west-static.png'; + +import dmsNorthIconActive from '../../../events/icons/dms-north-active.png'; +import dmsNorthIconHover from '../../../events/icons/dms-north-hover.png'; +import dmsNorthIconStatic from '../../../events/icons/dms-north-static.png'; + +export const dmsEastStyles = { + static: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsEastIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsEastIconHover, + }), + }), + active: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsEastIconActive, + }), + }), +}; + +export const dmsSouthStyles = { + static: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsSouthIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsSouthIconHover, + }), + }), + active: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsSouthIconActive, + }), + }), +}; + +export const dmsWestStyles = { + static: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsWestIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsWestIconHover, + }), + }), + active: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsWestIconActive, + }), + }), +}; + +export const dmsNorthStyles = { + static: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsNorthIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsNorthIconHover, + }), + }), + active: new Style({ + image: new Icon({ + scale: 0.25, + src: dmsNorthIconActive, + }), + }), +}; + +function getStyle(sign, state) { + switch (sign.roadway_direction) { + case 'Eastbound': + return dmsEastStyles[state]; + case 'Northbound': + return dmsNorthStyles[state]; + case 'Westbound': + return dmsWestStyles[state]; + case 'Southbound': + return dmsSouthStyles[state]; + } +} + +export function addSign(sign, map, visibleLayers) { + sign = structuredClone(sign); + const source = map.get('dms').getSource(); + const existing = source.get(sign.id); + + if (existing) { + if (sign.updated_datetime_utc === existing.updated_datetime_utc) { return; } + + source.unset(sign.id, true); + source.removeFeature(existing); + } + + let coords = sign.location.coordinates; + + const styles = {}; + ['static', 'hover', 'active'].forEach((state) => { + styles[state] = getStyle(sign, state); + }); + + const feature = new RideFeature({ + styles, + type: 'sign', + raw: sign, + geometry: new Point(ll2g(coords)), + }); + feature.setId(sign.id); + feature.set('visible', visibleLayers.dms); + + source.set(sign.id, feature, true); + source.addFeature(feature); +} + +function layerStyle(feature, resolution) { + if (!feature.get('visible')) { return null; } + if (feature.get('selected')) { return feature.active; } + return feature.get('hovered') ? feature.hover : feature.normal; +} + +function addDmsLayer(map) { + if (!map.get('dms')) { + const layer = new VectorLayer({ + classname: 'dms', + visible: true, + source: new VectorSource({ format: new GeoJSON() }), + style: layerStyle, + renderBuffer: 30, + }); + layer.listenForHover = true; + layer.listenForClicks = true; + map.addLayer(layer); + map.set('dms', layer); + } +} + +export default function DmsLayer({ sign }) { + const { map } = useContext(MapContext); + const [ contextMenu, setContextMenu ] = useState([]); + const [ fetchInterval, setFetchInterval ] = useState(); + const menuRef = useRef(); + const signRef = useRef(); // necessary for early-bound handler to read current prop + signRef.current = sign; + + const status = useSelector(state => state.dms.status); + const storeDispatch = useDispatch() + const store = useStore(); + const visibleLayers = useSelector(state => state.visibleLayers); + + const contextHandler = (e) => { + e.preventDefault(); + const sign = signRef.current; // updated event prop + + const feature = e.map.getFeaturesAtPixel(e.pixel, { + layerFilter: (layer) => layer.listenForClicks, + })[0]; + + menuRef.current.style.left = (e.pixel[0] - 1) + 'px'; + menuRef.current.style.top = (e.pixel[1] - 1) + 'px'; + menuRef.current.style.visibility = undefined; + + const coordinate = e.coordinate; + const pixel = e.pixel; + const items = []; + + if (feature && feature.get('type') === 'sign') { + e.stopPropagation(); + const map = e.map; // necessary to bind map for callback below + + const raw = feature.get('raw'); + + items.push( + { + label: 'Dump feature to console', + debugging: true, + action: (e) => { + console.log(feature); + setContextMenu([]); + } + }, + { + label: 'Dump sign to console', + debugging: true, + action: (e) => { + console.log(feature.get('raw')); + setContextMenu([]); + } + }, + ); + } + + setContextMenu(items); + }; + + const updateSignsOnMap = () => { + const state = store.getState() + const signIds = {}; + + state.dms.ids.forEach((id) => { + const sign = state.dms.entities[id]; + addSign(sign, map, visibleLayers); + signIds[id] = true; + }) + } + + useEffect(() => { + map?.get('dms').getSource().getFeatures().forEach((feature) => { + feature.set('visible', visibleLayers.dms); + }); + }, [visibleLayers]); + + useEffect(() => { + if (!map) { return; } + map.on('contextmenu', contextHandler); + addDmsLayer(map); + updateSignsOnMap() + store.subscribe(updateSignsOnMap); + storeDispatch(refreshDms()); + if (!fetchInterval) { + setFetchInterval(setInterval(() => storeDispatch(refreshDms()), EVENT_POLLING_REFRESH || 10000)); + } + }, [map]); + + return ( + + ); +} diff --git a/src/frontend/app/components/Map/Layer.jsx b/src/frontend/app/components/Map/layers/Events.jsx similarity index 93% rename from src/frontend/app/components/Map/Layer.jsx rename to src/frontend/app/components/Map/layers/Events.jsx index 8329c031..eb371c61 100644 --- a/src/frontend/app/components/Map/Layer.jsx +++ b/src/frontend/app/components/Map/layers/Events.jsx @@ -12,21 +12,21 @@ import { linear } from 'ol/easing'; import { Point, LineString, GeometryCollection, MultiPolygon, Polygon } from 'ol/geom'; import { Icon, Style } from 'ol/style'; -import { AlertContext, AuthContext, MapContext } from '../../contexts'; +import { AlertContext, AuthContext, MapContext } from '../../../contexts.js'; -import { g2ll, ll2g, selectFeature, pointerMove } from './helpers.js'; -import RideFeature, { PinFeature } from './feature.js'; -import ContextMenu from '../../events/ContextMenu'; -import { getInitialEvent } from '../../events/forms'; +import { g2ll, ll2g, selectFeature } from '../helpers.js'; +import RideFeature, { PinFeature } from '../feature.js'; +import ContextMenu from '../../../events/ContextMenu.jsx'; +import { getInitialEvent } from '../../../events/forms/index.jsx'; -import { API_HOST, EVENT_POLLING_REFRESH } from '../../env.js'; -import { getIconAndStroke } from '../../events/icons'; -import { getNextUpdate, getPendingNextUpdate } from '../../shared/helpers.js'; -import { applyPinLocationUpdate } from './PinLayer'; -import { patch } from '../../shared/helpers'; +import { API_HOST, EVENT_POLLING_REFRESH } from '../../../env.js'; +import { getIconAndStroke } from '../../../events/icons/index.js'; +import { getNextUpdate, getPendingNextUpdate } from '../../../shared/helpers.js'; +import { applyPinLocationUpdate } from './Pins'; +import { patch } from '../../../shared/helpers.js'; -import { refreshEvents } from '../../slices/events'; -import { selectAllServiceAreaBoundaries } from '../../slices/serviceAreaBoundaries'; +import { refreshEvents } from '../../../slices/events.js'; +import { selectAllServiceAreaBoundaries } from '../../../slices/serviceAreaBoundaries.js'; export function addEvent(event, map, dispatch, visibleLayers) { @@ -158,7 +158,7 @@ function addEventsLayer(map) { } } -export default function Layer({ event, dispatch }) { +export default function EventsLayer({ event, dispatch }) { const { setAlertContext } = useContext(AlertContext); const { authContext } = useContext(AuthContext); @@ -220,6 +220,8 @@ export default function Layer({ event, dispatch }) { geometry: new Point(coordinate), action: 'set start', isVisible: true, + cursor: 'grab', + canDrag: true, }); map.pins.getSource().addFeature(map.start); map.getView().animate({ center: coordinate, duration: 250, easing: linear }); @@ -236,6 +238,8 @@ export default function Layer({ event, dispatch }) { geometry: new Point(coordinate), action: 'set start', isVisible: true, + cursor: 'grab', + canDrag: true, }); map.pins.getSource().addFeature(map.start); map.getView().animate({ center: coordinate, duration: 250, easing: linear }); @@ -252,6 +256,8 @@ export default function Layer({ event, dispatch }) { geometry: new Point(coordinate), action: 'set start', isVisible: true, + cursor: 'grab', + canDrag: true, }); map.pins.getSource().addFeature(map.start); map.getView().animate({ center: coordinate, duration: 250, easing: linear }); @@ -270,6 +276,8 @@ export default function Layer({ event, dispatch }) { geometry: new Point(coordinate), action: 'set end', isVisible: true, + cursor: 'grab', + canDrag: true, }); map.pins.getSource().addFeature(map.end); map.getView().animate({ center: coordinate, duration: 250, easing: linear }); @@ -435,7 +443,6 @@ export default function Layer({ event, dispatch }) { useEffect(() => { if (!map) { return; } map.on('contextmenu', contextHandler); - map.on('pointermove', pointerMove); addEventsLayer(map); store.subscribe(updateEventsOnMap); storeDispatch(refreshEvents()); diff --git a/src/frontend/app/components/Map/PinLayer.jsx b/src/frontend/app/components/Map/layers/Pins.jsx similarity index 93% rename from src/frontend/app/components/Map/PinLayer.jsx rename to src/frontend/app/components/Map/layers/Pins.jsx index 9cfb309e..08c6175b 100644 --- a/src/frontend/app/components/Map/PinLayer.jsx +++ b/src/frontend/app/components/Map/layers/Pins.jsx @@ -7,13 +7,13 @@ import GeoJSON from 'ol/format/GeoJSON'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; -import { AlertContext, AuthContext, MapContext } from '../../contexts'; -import { getRoute } from '../../shared'; -import { getNearby } from '../../events/forms/Location/helpers'; -import { getDRA, ll2g, g2ll, getSnapped, Drag, pointerMove } from './helpers'; -import { PinFeature } from './feature'; -import { transform_road_abbreviations } from "../shared/helper"; -import ContextMenu from '../../events/ContextMenu'; +import { AlertContext, AuthContext, MapContext } from '../../../contexts'; +import { getRoute } from '../../../shared'; +import { getNearby } from '../../../events/forms/Location/helpers'; +import { getDRA, ll2g, g2ll, getSnapped, Drag } from '../helpers'; +import { PinFeature } from '../feature'; +import { transform_road_abbreviations } from "../../shared/helper"; +import ContextMenu from '../../../events/ContextMenu'; globalThis.ol = ol; @@ -180,7 +180,7 @@ export const updateRoute = async (map) => { map.route.getGeometry().setCoordinates(route); } -export default function PinLayer({ event, dispatch }) { +export default function PinsLayer({ event, dispatch }) { const { authContext } = useContext(AuthContext); const { setAlertContext } = useContext(AlertContext); const { map } = useContext(MapContext); @@ -199,7 +199,6 @@ export default function PinLayer({ event, dispatch }) { map.pins = layer; map.set('pins', layer); map.on('contextmenu', contextHandler); - map.on('pointermove', pointerMove); map.getInteractions().extend([ new Drag({ endHandler: (e, point, dispatch) => guardedEndHandler( @@ -216,7 +215,12 @@ export default function PinLayer({ event, dispatch }) { }) ]); - map.route = new PinFeature({ style: 'route', geometry: new LineString([]), isVisible: true, noSelect: true }) + map.route = new PinFeature({ + style: 'route', + geometry: new LineString([]), + isVisible: true, + noSelect: true + }); layer.getSource().addFeature(map.route); // pin for search result @@ -243,7 +247,11 @@ export default function PinLayer({ event, dispatch }) { map.start.getGeometry().setCoordinates(coords); } else { map.start = new PinFeature({ - style: 'start', geometry: new Point(coords), action: 'set start', isVisible: true + style: 'start', + geometry: new Point(coords), + action: 'set start', + isVisible: true, + canDrag: true, }); map.start.dra = { properties: evt.location.start } map.pins.getSource().addFeature(map.start); @@ -261,8 +269,11 @@ export default function PinLayer({ event, dispatch }) { map.end.getGeometry().setCoordinates(coords); } else { map.end = new PinFeature({ - style: 'end', geometry: new Point(coords), action: 'set end', isVisible: true, - + style: 'end', + geometry: new Point(coords), + action: 'set end', + isVisible: true, + canDrag: true, }); map.end.dra = { properties: evt.location.end } map.pins.getSource().addFeature(map.end); @@ -387,6 +398,7 @@ export default function PinLayer({ event, dispatch }) { geometry: new Point(coordinate), action: 'set end', isVisible: true, + canDrag: true, }); map.pins.getSource().addFeature(map.end); map.getView().animate({ center: coordinate, duration: 250, easing: linear }); diff --git a/src/frontend/app/components/Map/layers/index.js b/src/frontend/app/components/Map/layers/index.js new file mode 100644 index 00000000..997ac425 --- /dev/null +++ b/src/frontend/app/components/Map/layers/index.js @@ -0,0 +1,4 @@ +export { default as DmsLayer } from './Dms'; +export { default as BoundariesLayer } from './Boundaries'; +export { default as EventsLayer } from './Events'; +export { default as PinsLayer } from './Pins'; diff --git a/src/frontend/app/env.js b/src/frontend/app/env.js index 573dcab4..70bf353d 100644 --- a/src/frontend/app/env.js +++ b/src/frontend/app/env.js @@ -12,4 +12,5 @@ export const DEPLOYMENT_TAG = `${getEnv('DEPLOYMENT_TAG')}`; export const BRANCH = `${getEnv('BRANCH')}`; export const RELEASE = `${getEnv('RELEASE')}`; export const ALLOW_LOCAL_ACCOUNTS = `${getEnv('ALLOW_LOCAL_ACCOUNTS')}`; -export const EVENT_POLLING_REFRESH = `${getEnv('EVENT_POLLING_REFRESH')}`; \ No newline at end of file +export const EVENT_POLLING_REFRESH = `${getEnv('EVENT_POLLING_REFRESH')}`; +export const DMS_API_URL = `${getEnv('DMS_API_URL')}`; \ No newline at end of file diff --git a/src/frontend/app/events/Dms.jsx b/src/frontend/app/events/Dms.jsx new file mode 100644 index 00000000..246aed64 --- /dev/null +++ b/src/frontend/app/events/Dms.jsx @@ -0,0 +1,82 @@ +// React +import 'react'; + +// External imports +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faXmark } from '@fortawesome/pro-regular-svg-icons'; +import { format } from 'date-fns'; + +// Styling +import './Dms.scss'; + +const formatDate = (date) => { + if (!date) return ''; + date = new Date(date); + const tz = date.toLocaleString(['en-CA'], { timeZoneName: 'short' }).slice(-3); + return format(date, `E, MMM do, y, h:mm a '${tz}'`); +} + +import dmsEastIconStatic from './icons/dms-east-static.png'; +import dmsSouthIconStatic from './icons/dms-south-static.png'; +import dmsWestIconStatic from './icons/dms-west-static.png'; +import dmsNorthIconStatic from './icons/dms-north-static.png'; + +const DIRECTION = { + Eastbound: dmsEastIconStatic, + Northbound: dmsNorthIconStatic, + Westbound: dmsWestIconStatic, + Southbound: dmsSouthIconStatic, +} + + +function Message({ message, index }) { + const status = message.trim() ? 'Active' : 'No message'; + const lines = message.split('\n'); + return ( +
+
+
Display {index}
+
{status}
+
+
+ {lines.map((line, ii) =>
{line}
)} +
+
+ ) +} + + +export default function DmsPreview({ sign, close }) { + return ( +
+ +
+
+
+ +

Dynamic message sign

+ +
+
+
+ +
+

{sign.name}

+
{formatDate(sign.updated_datetime_utc)}
+ + + +
+ + { sign.id && +
console.log(sign)}> + Sign ID: { sign.id } +
+ } +
+ ) +} diff --git a/src/frontend/app/events/Dms.scss b/src/frontend/app/events/Dms.scss new file mode 100644 index 00000000..5d53cb47 --- /dev/null +++ b/src/frontend/app/events/Dms.scss @@ -0,0 +1,72 @@ +@use "../styles/variables.scss" as *; + +.preview { + &.sign { + .header { + background: #fef0d8; + + .icons { + img { + height: 24px; + } + + p { + flex: 1; + margin: 0 1rem; + font-weight: 700; + } + + button { + cursor: pointer; + } + } + } + .body { + .last-updated { + font-size: 0.75rem; + color: #605e5c; + } + .message { + .message-title { + display: flex; + justify-content: space-between; + align-items: baseline; + + .status { + background: #e2e8f0; + font-size: 12px; + font-weight: bold; + border-radius: 12px; + height: 24px; + padding: 0.2rem 0.75rem 0 0.75rem; + color: #4a5568; + + &.Active { + background: #2d7a45; + color: white; + } + } + } + + .blackboard { + background: black; + border: 1px solid #333; + border-radius: 8px; + min-height: 80px; + display: flex; + flex-direction: column; + color: #f7ce46; + font-family: Repetition Scrolling, monospace; + padding: 15px 20px; + justify-content: center; + align-items: center; + font-size: 22px; + line-height: 30px; + } + } + } + .footer { + cursor: help; + } + } +} diff --git a/src/frontend/app/events/Preview.jsx b/src/frontend/app/events/Preview.jsx index 2cfb57af..f07394d1 100644 --- a/src/frontend/app/events/Preview.jsx +++ b/src/frontend/app/events/Preview.jsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; // External imports import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faXmark } from '@fortawesome/pro-regular-svg-icons'; import { format } from 'date-fns'; import Skeleton from 'react-loading-skeleton'; import 'react-loading-skeleton/dist/skeleton.css'; @@ -119,7 +120,8 @@ export default function Preview({ event, dispatch, mapRef, segments }) {
- { if (displayed.showForm) { @@ -131,11 +133,7 @@ export default function Preview({ event, dispatch, mapRef, segments }) { dispatch({ type: 'reset form' }); } }} - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 640 640" width="14" height="14" fill="currentColor" - > - - + >

{ PHRASES_LOOKUP[displayed.details.situation] }

diff --git a/src/frontend/app/events/Preview.scss b/src/frontend/app/events/Preview.scss index 5ed83f9f..0ed48a7e 100644 --- a/src/frontend/app/events/Preview.scss +++ b/src/frontend/app/events/Preview.scss @@ -41,7 +41,7 @@ } h3, h5 { font-weight: normal; } - + &.minor { .header { background: $Gold30; @@ -184,13 +184,13 @@ color: $Grey10; cursor: pointer; - a { + a { color: $Grey10; text-decoration: underline; } } - a { + a { font-weight: bold; color: $ViewDetailsText; } diff --git a/src/frontend/app/events/forms/index.jsx b/src/frontend/app/events/forms/index.jsx index 1c4990a7..c65c1174 100644 --- a/src/frontend/app/events/forms/index.jsx +++ b/src/frontend/app/events/forms/index.jsx @@ -24,7 +24,7 @@ import { convertToDateTimeLocalString as convert, g2ll, } from "../../components/Map/helpers"; -import { addEvent } from '../../components/Map/Layer'; +import { addEvent } from '../../components/Map/layers/Events'; import { getCookie } from '../shared'; import { API_HOST } from '../../env'; import { AuthContext } from '../../contexts'; diff --git a/src/frontend/app/events/home.jsx b/src/frontend/app/events/home.jsx index 8abc63a6..df9eeed1 100644 --- a/src/frontend/app/events/home.jsx +++ b/src/frontend/app/events/home.jsx @@ -10,10 +10,10 @@ import { boundingExtent, getCenter } from 'ol/extent'; // Internal imports import Map from '../components/Map'; -import Layer from '../components/Map/Layer'; +import { + BoundariesLayer, DmsLayer, EventsLayer, PinsLayer +} from '../components/Map/layers'; import Layers from './Layers'; -import PinLayer from '../components/Map/PinLayer'; -import BoundariesLayer from '../components/Map/BoundariesLayer'; import { AlertContext, AuthContext, MapContext } from '../contexts'; import { ll2g, selectFeature } from '../components/Map/helpers.js'; import Tabs from '../shared/Tabs'; @@ -21,6 +21,7 @@ import Bubble from '../shared/Bubble'; import EventForm, { getInitialEvent } from './forms'; import reducer from './forms/reducer'; +import Dms from './Dms'; import Preview from './Preview'; import Queue from './Queue'; import Events from './Events'; @@ -53,6 +54,7 @@ export default function Home() { const [ map, setMap ] = useState(null); const [ preview, setPreview ] = useState(true); const [ event, dispatch ] = useReducer(reducer, getInitialEvent()); + const [ sign, setSign ] = useState(null); // Selectors const visibleLayers = useSelector(state => state.visibleLayers); @@ -111,18 +113,25 @@ export default function Home() { if (feature?.noSelect || feature?.get('noSelect')) { return; } - if (feature?.styleState) { // new selection + if (feature?.get('type') === 'sign') { + selectFeature(e.map, feature); + dispatch({ type: 'reset form' }); + setSign(feature.get('raw')); + e.map.route.getGeometry().setCoordinates([]); + } else if (feature?.styleState) { // new selection selectFeature(e.map, feature); const raw = feature.get('raw'); dispatch({ type: 'reset form', value: raw, showPreview: true, showForm: false }); } else if (e.map.selectedFeature) { // deselect existing selection selectFeature(e.map, null); dispatch({ type: 'reset form' }); + setSign(null); e.map.route.getGeometry().setCoordinates([]); } } - const showLayers = !(event.showPreview && (event.location.start.name || event.type === 'CHAIN_UP')); + const showPreview = event.showPreview && (event.location.start.name || event.type === 'CHAIN_UP'); + const showLayers = !showPreview && !sign; return authContext.loginStateKnown && authContext.username && (
@@ -160,20 +169,25 @@ export default function Home() { - + - + + { showLayers && } - {(event.location.start.name || event.type === 'CHAIN_UP') && event.showPreview && + { showPreview && setPreview(!preview)} mapRef={mapRef} /> } + + { !showPreview && sign && + { selectFeature(map); setSign(null) }} /> + }
); } diff --git a/src/frontend/app/events/icons/dms-east-active.png b/src/frontend/app/events/icons/dms-east-active.png new file mode 100644 index 00000000..0f3e69c1 Binary files /dev/null and b/src/frontend/app/events/icons/dms-east-active.png differ diff --git a/src/frontend/app/events/icons/dms-east-hover.png b/src/frontend/app/events/icons/dms-east-hover.png new file mode 100644 index 00000000..8c866e21 Binary files /dev/null and b/src/frontend/app/events/icons/dms-east-hover.png differ diff --git a/src/frontend/app/events/icons/dms-east-static.png b/src/frontend/app/events/icons/dms-east-static.png new file mode 100644 index 00000000..fc5a9253 Binary files /dev/null and b/src/frontend/app/events/icons/dms-east-static.png differ diff --git a/src/frontend/app/events/icons/dms-north-active.png b/src/frontend/app/events/icons/dms-north-active.png new file mode 100644 index 00000000..66ed9302 Binary files /dev/null and b/src/frontend/app/events/icons/dms-north-active.png differ diff --git a/src/frontend/app/events/icons/dms-north-hover.png b/src/frontend/app/events/icons/dms-north-hover.png new file mode 100644 index 00000000..57bf8a92 Binary files /dev/null and b/src/frontend/app/events/icons/dms-north-hover.png differ diff --git a/src/frontend/app/events/icons/dms-north-static.png b/src/frontend/app/events/icons/dms-north-static.png new file mode 100644 index 00000000..d912caf0 Binary files /dev/null and b/src/frontend/app/events/icons/dms-north-static.png differ diff --git a/src/frontend/app/events/icons/dms-south-active.png b/src/frontend/app/events/icons/dms-south-active.png new file mode 100644 index 00000000..62ff83d2 Binary files /dev/null and b/src/frontend/app/events/icons/dms-south-active.png differ diff --git a/src/frontend/app/events/icons/dms-south-hover.png b/src/frontend/app/events/icons/dms-south-hover.png new file mode 100644 index 00000000..a71b8502 Binary files /dev/null and b/src/frontend/app/events/icons/dms-south-hover.png differ diff --git a/src/frontend/app/events/icons/dms-south-static.png b/src/frontend/app/events/icons/dms-south-static.png new file mode 100644 index 00000000..ef9c2645 Binary files /dev/null and b/src/frontend/app/events/icons/dms-south-static.png differ diff --git a/src/frontend/app/events/icons/dms-west-active.png b/src/frontend/app/events/icons/dms-west-active.png new file mode 100644 index 00000000..9bcaa672 Binary files /dev/null and b/src/frontend/app/events/icons/dms-west-active.png differ diff --git a/src/frontend/app/events/icons/dms-west-hover.png b/src/frontend/app/events/icons/dms-west-hover.png new file mode 100644 index 00000000..f98f69c3 Binary files /dev/null and b/src/frontend/app/events/icons/dms-west-hover.png differ diff --git a/src/frontend/app/events/icons/dms-west-static.png b/src/frontend/app/events/icons/dms-west-static.png new file mode 100644 index 00000000..8983b304 Binary files /dev/null and b/src/frontend/app/events/icons/dms-west-static.png differ diff --git a/src/frontend/app/root.jsx b/src/frontend/app/root.jsx index 887ad7e6..bf1aeebb 100644 --- a/src/frontend/app/root.jsx +++ b/src/frontend/app/root.jsx @@ -18,6 +18,7 @@ import { refreshConditions, refreshDistricts, refreshDistrictBoundaries, + refreshDms, refreshRoutes, refreshSegments, refreshServiceAreas, @@ -67,6 +68,7 @@ let fetching = false; store.dispatch(refreshConditions()); store.dispatch(refreshDistricts()); +store.dispatch(refreshDms()); store.dispatch(refreshRoutes()); store.dispatch(refreshServiceAreas()); store.dispatch(refreshSegments()); @@ -74,6 +76,7 @@ store.dispatch(refreshTrafficImpacts()); // store.dispatch(refreshSituations()); store.dispatch(refreshServiceAreaBoundaries()); store.dispatch(refreshDistrictBoundaries()); +store.dispatch(refreshDms()); export default function App() { const [impacts, setImpacts] = useState(getImpacts); diff --git a/src/frontend/app/slices/client.js b/src/frontend/app/slices/client.js index d170f354..46a1481d 100644 --- a/src/frontend/app/slices/client.js +++ b/src/frontend/app/slices/client.js @@ -3,8 +3,9 @@ export default async function client( endpoint, { body, ...customConfig } = {}) const config = { method: body ? 'POST' : 'GET', - ...customConfig, + mode: 'cors', credentials: "include", + ...customConfig, headers: { ...headers, ...customConfig.headers, diff --git a/src/frontend/app/slices/dms.js b/src/frontend/app/slices/dms.js new file mode 100644 index 00000000..a1f51ce1 --- /dev/null +++ b/src/frontend/app/slices/dms.js @@ -0,0 +1,60 @@ +import { createAsyncThunk, createEntityAdapter, createSlice } from '@reduxjs/toolkit'; + +import { DMS_API_URL } from '../env'; +import client from './client'; + + +const adapter = createEntityAdapter({ + sortComparer: (a, b) => { return b.name < a.name ? 1 : -1; } +}) + +const refreshThunk = createAsyncThunk( + 'dms/refresh', + async () => { + const response = await client.get(DMS_API_URL, { credentials: 'omit' }); + return response.data; + }, + { + condition(arg, thunkApi) { + return selectStatus(thunkApi.getState()) === 'idle'; + } + } +); + +const selectStatus = (state) => state.dms.status; + +export const slice = createSlice({ + name: 'dms', + initialState: adapter.getInitialState({ + status: 'idle', + error: null, + }), + reducers: {}, + + extraReducers: (builder) => { + builder + .addCase(refreshThunk.pending, (state, action) => { + state.status = 'pending'; + }) + .addCase(refreshThunk.fulfilled, (state, action) => { + state.status = 'idle'; + adapter.setAll(state, action.payload); + }) + .addCase(refreshThunk.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message ?? 'Unknown Error'; + }); + } +}); + +export const { + selectAll: selectAllDms, + selectById: selectDmsById, + selectIds: selectDmsIds, +} = adapter.getSelectors((state) => state.dms); +export { + refreshThunk as refreshDms, + selectStatus as selectDmsStatus, +}; + +export default slice.reducer; diff --git a/src/frontend/app/slices/index.js b/src/frontend/app/slices/index.js index 29ed44ed..52dff61d 100644 --- a/src/frontend/app/slices/index.js +++ b/src/frontend/app/slices/index.js @@ -1,3 +1,4 @@ +export { default as dms, refreshDms } from './dms'; export { default as events } from './events'; export { default as pending } from './pending'; export { default as routes, refreshRoutes } from './routes'; diff --git a/src/frontend/app/store.js b/src/frontend/app/store.js index 2da33095..b1724229 100644 --- a/src/frontend/app/store.js +++ b/src/frontend/app/store.js @@ -4,6 +4,7 @@ import { conditions, districts, districtBoundaries, + dms, events, pending, routes, @@ -20,6 +21,7 @@ export default configureStore({ conditions, districts, districtBoundaries, + dms, events, pending, routes, diff --git a/src/frontend/app/styles/typography.scss b/src/frontend/app/styles/typography.scss index 4a12be5f..a9b140bf 100644 --- a/src/frontend/app/styles/typography.scss +++ b/src/frontend/app/styles/typography.scss @@ -42,6 +42,13 @@ src: url("/fonts/bc-sans/BCSans-LightItalic.woff2") format("woff2"); } +@font-face { + font-family: "Repetition Scrolling"; + font-style: normal; + font-weight: 400; + src: url("/fonts/repetition-scrolling/RepetitionScrolling.woff2") format("woff2"); +} + body, p, h1, h2, h3, h4, a, span { font-family: 'BC Sans', 'Noto Sans', Verdana, Arial, sans-serif; -webkit-font-smoothing: antialiased; diff --git a/src/frontend/public/fonts/repetition-scrolling/RepetitionScrolling.woff2 b/src/frontend/public/fonts/repetition-scrolling/RepetitionScrolling.woff2 new file mode 100644 index 00000000..41f8a222 Binary files /dev/null and b/src/frontend/public/fonts/repetition-scrolling/RepetitionScrolling.woff2 differ