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 }) {

-
+ >
{ 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() {
- {(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