From 1dd385c1a3c0b72da853702a531b532e229f58f5 Mon Sep 17 00:00:00 2001 From: Justin Johnson Date: Wed, 3 Jun 2026 14:38:34 -0700 Subject: [PATCH] DBC22-6491 DMS signs --- src/frontend/app/components/Map/feature.js | 4 +- src/frontend/app/components/Map/helpers.js | 71 +++-- src/frontend/app/components/Map/index.jsx | 3 +- .../Boundaries.jsx} | 6 +- .../app/components/Map/layers/Dms.jsx | 277 ++++++++++++++++++ .../Map/{Layer.jsx => layers/Events.jsx} | 35 ++- .../Map/{PinLayer.jsx => layers/Pins.jsx} | 38 ++- .../app/components/Map/layers/index.js | 4 + src/frontend/app/env.js | 1 + src/frontend/app/events/Dms.jsx | 82 ++++++ src/frontend/app/events/Dms.scss | 72 +++++ src/frontend/app/events/Preview.jsx | 10 +- src/frontend/app/events/Preview.scss | 6 +- src/frontend/app/events/forms/index.jsx | 2 +- src/frontend/app/events/home.jsx | 30 +- .../app/events/icons/dms-east-active.png | Bin 0 -> 1010 bytes .../app/events/icons/dms-east-hover.png | Bin 0 -> 854 bytes .../app/events/icons/dms-east-static.png | Bin 0 -> 843 bytes .../app/events/icons/dms-north-active.png | Bin 0 -> 1195 bytes .../app/events/icons/dms-north-hover.png | Bin 0 -> 1037 bytes .../app/events/icons/dms-north-static.png | Bin 0 -> 1039 bytes .../app/events/icons/dms-south-active.png | Bin 0 -> 1314 bytes .../app/events/icons/dms-south-hover.png | Bin 0 -> 1125 bytes .../app/events/icons/dms-south-static.png | Bin 0 -> 1116 bytes .../app/events/icons/dms-west-active.png | Bin 0 -> 1570 bytes .../app/events/icons/dms-west-hover.png | Bin 0 -> 1297 bytes .../app/events/icons/dms-west-static.png | Bin 0 -> 1283 bytes src/frontend/app/root.jsx | 3 + src/frontend/app/slices/client.js | 3 +- src/frontend/app/slices/dms.js | 60 ++++ src/frontend/app/slices/index.js | 1 + src/frontend/app/store.js | 2 + src/frontend/app/styles/typography.scss | 7 + .../RepetitionScrolling.woff2 | Bin 0 -> 9828 bytes 34 files changed, 629 insertions(+), 88 deletions(-) rename src/frontend/app/components/Map/{BoundariesLayer.jsx => layers/Boundaries.jsx} (97%) create mode 100644 src/frontend/app/components/Map/layers/Dms.jsx rename src/frontend/app/components/Map/{Layer.jsx => layers/Events.jsx} (93%) rename src/frontend/app/components/Map/{PinLayer.jsx => layers/Pins.jsx} (93%) create mode 100644 src/frontend/app/components/Map/layers/index.js create mode 100644 src/frontend/app/events/Dms.jsx create mode 100644 src/frontend/app/events/Dms.scss create mode 100644 src/frontend/app/events/icons/dms-east-active.png create mode 100644 src/frontend/app/events/icons/dms-east-hover.png create mode 100644 src/frontend/app/events/icons/dms-east-static.png create mode 100644 src/frontend/app/events/icons/dms-north-active.png create mode 100644 src/frontend/app/events/icons/dms-north-hover.png create mode 100644 src/frontend/app/events/icons/dms-north-static.png create mode 100644 src/frontend/app/events/icons/dms-south-active.png create mode 100644 src/frontend/app/events/icons/dms-south-hover.png create mode 100644 src/frontend/app/events/icons/dms-south-static.png create mode 100644 src/frontend/app/events/icons/dms-west-active.png create mode 100644 src/frontend/app/events/icons/dms-west-hover.png create mode 100644 src/frontend/app/events/icons/dms-west-static.png create mode 100644 src/frontend/app/slices/dms.js create mode 100644 src/frontend/public/fonts/repetition-scrolling/RepetitionScrolling.woff2 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 d2f300f1..c8d7a881 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; @@ -184,7 +184,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); @@ -203,7 +203,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( @@ -220,7 +219,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 @@ -247,7 +251,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); @@ -265,8 +273,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); @@ -391,6 +402,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 6f2d3d00..31001109 100644 --- a/src/frontend/app/env.js +++ b/src/frontend/app/env.js @@ -13,6 +13,7 @@ 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')}`; +export const DMS_API_URL = `${getEnv('DMS_API_URL')}`; // set to true to remove geometries from service areas so that data is small // enough for redux-devtools to work with the store export const ELIDE_SERVICE_AREA_GEOMETRIES = `${getEnv('ELIDE_SERVICE_AREA_GEOMETRIES')}`; 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 c703467f..5b4a8b94 100644 --- a/src/frontend/app/events/forms/index.jsx +++ b/src/frontend/app/events/forms/index.jsx @@ -26,7 +26,7 @@ import { 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 0000000000000000000000000000000000000000..0f3e69c1435e1062790fda144c733ea5db3af3da GIT binary patch literal 1010 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..57bf8a92199a8eba7c860942b3f072ec84498919 GIT binary patch literal 1037 zcmV+o1oHcdP)@5KurMSwj_9Llsy-62 zT2452Tp+WMOIu1Ibz?#@Eh6u*8pf3z#*`dDIW4n~OYgB7GcF>KZyrrVGv>Q#?ZI%p zm{EXbEn-U>KshelvSZt_V~==DgJvISQy?-gDAKKFs*P7{R3U6rA!AD$s*P4+N*hi@ zGEPAvo_HRfcpXhZBCCNN+_Pibvty8WOm$oztAZWWof{NRaa{la02_2tPE!D2pPwKg z5bxjL5D?FhpfE6y&(DvKV33fYkJDI+a{vGWt4TybRA}Dqn%8!lKoEu(Ng!FpcHC0X zq5@I1u~i*eR$Su5PEX$dWsHDXRyGiHm`N_8{|Y#J{%@BV;4rg{328@}y?iyb@_SiD zW6za)gJ3R{%{{T#4y(vISXo7GtBS5IX%RaF{tjV>a{lrfG$Mb8;KkvvN~Qz%1_kF+ zd!(nPKEXjrD%X(V?G86OL1nB37xVnW#XSU`unvXTPA z#>WD@@%(*KK-l;Qz<6%)R2tsJ0vhjlYWblE2O#)6i--nXa}>=Z0iSRHRwRHU>AwRk z-g;`w7rXAI1qz^Mcsf0kE;ijubyR@yKn*bNsR75-0HaL}sGku7i~$*--&;*pbil8H zw}h`$fa7j$CC>N&zF0|Bbii-U`ySyZ4ZyK#%daE~0RJqdDhZ(Lr)rbrL0cXTq&SlR zVGPfM5El+NT1*_oqh&XiFbB5&)zs z9iT}KfL9V708*6<0I5m`5OKxZ|}0;GM}SjrS#E?u99CedqXGvWenMVpJJQD+B@x*`PO`~uJa;v)bw#$Mt# zOKstwVT`FkbM|(Eu!@mA4II^FL-_?=!@A;j#ES(g|3b-Y>Z?SAf(qLbc1Z|@-C$z{ z#TtV7yKz8|bfxrUcZ<4>=$?P2hV0KNDcfa??LXy=BF+901a|9K|N1_F00000NkvXX Hu0mjf{s6Xg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d912caf07343edf864d8b277e095505533872324 GIT binary patch literal 1039 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ef9c2645bfabcde918495a5354731fac35ad3fa7 GIT binary patch literal 1116 zcmV-i1f%YGBI3RTH&V~*{J$rIW*@DstwBIP@39*3@$vo3Q+8b-GA$x@ zTp*r!9ZpS5)SesDog2A}9sS{&l97@A^3$t<9q+Ij{_)fOzbE{@Cr(XG*4EYk^3(p{ zns!|u{lhl=!!~tXApi5!o_HQlPEFLE8~xy#{`caNl9B)R;{NvHkZ&IT_Tx#3XTksg z02OpnPE!EyU=Sc6klzrW&(DuApkVLsFp!U-ASHbO00Q($L_t(&-tC%ebDBUDhF3)c zYGQA|f&!w(YrRIT_q1u#CcR((|6i#J3zPuC@tn?dn)eerXWob11G~$fCBzY*6tW5N zXeF{jk|%eSeg^s>)oGP7mEk zpc=6;gXHY11}tD{+h)ll`|2eu;5$nKVVxxbi}?lvN~)q%O7YzS;v)eiMc(Q>HX$fJ z5>S!%QsV-G;v)f8ysX3p1jUB{hTl~AD824Q0!rud(Xvw!3;^)oHXl!1-yU( zsG$HENdGZFn=WR>qQ3ZP8W@1?rDwgcs6zv&?pu=73{Zek^sjkz*cNQ=EUMmt0w@cc zcRF*chhTH?-U^yg>uG8h#gDLnrWHh;KTHY>=-iiA7(fjVV0V#*2IyKrgE|&q%)&lv zNQ2$feSkZHx)$!qbRB8)eG$AO;j?PIeJ3tV#dE zo;e2w62N($+Gxubd)%m_0Vq6RT=#akcHhu?IDm?m8`n?wPj3)_-P=1g+ti?@?O&6j z0hr5{0Dvyz0%is%VC-KwwhZXeRBO#0_hO5pd>x3zQ6(+PLb%0 zl^J0Hw@#VMDb&41Dby7R&ysMphV#F6ikUni8FqaI)&{p&DiA_AcS(EO04Ue}sE%Am zZdXvw;1#Sh*d_OrRn+IO4h?YZSi&jtu?L(sxc9%$19+x0Dc|W9?hMd9@stZVpOY$_ iC0002qP)t-sM{rD7 zLKRp-6<9+RSV$OHKp0R^AX!2cSV9(9LKR>@5KurMSwj_9Llsy-6BqPR@99v2ukZ&HVf*n9NB7*GcF>Ycpc%V8{wxKxr-g1c^#{P9Zo?aO+g`bTp-h(8@Y=dc3mH6 zQy|ox8);J@bzC2jZyvOT7u};6V@ejag&1v77J*3fm1uLN)u+*@DQzpQ-~fU}^mT+hF>eFcC4Y#*eF zj(>Uk3IGS_0zsw&Aa5@PNTQzrfEZt34m6Bu-=9oJ_-dyjGIs5*L}Yp*MB_Q7zyE{*CW3f3HnIwk2#y6{oCucaNM-~85B0D# zq;TOYl}JDb0ACM~UsESNN(4Y60j)rU#yS*8fI9sU@qTP566U3DlJfH(c>s$H0W8Yg z0Q!gr%Fs>>0ik<8&;QfaZZ@$?ZpQsl{`4i7uoP&;-cYX-#pcNtif2 z6YO2J09I^lTVYjZ%#1Ik63_%l+5ITT>oTW1oR)80bq}Q()2$`e{)!%)m0SY4069Ie zG4bCi-Q!#?0bKw4&5b4Fga;ThP5EMes=>0c`-(%>m)1Pmj)&+=+&!Z%#AfP_m?Z z3Gm8KRT9t#2=RWDvYRvB4nXPo)d&E<>H_TXBtwG}swAKffJAVBl-_3#VW(Jw77`R0 z8u%G|M4h9-S*j#Z0|2e?IO7iK#pp~@r37jKAQ7A-+zGW9`7uyRpiYhmiXi9jJmKhs zqLhgd04Rbbf0qJd^H-$=>HskJLSx~LqBPrC17J=&nUp3mR1&C_Crrv72yyu$xddtf zP#FRGwj=h1*#d(AeW3)XgpoYqT?c>?K_q$*ph$Sv$`vOf3D6=KSW8w%zA!nO`ogTC zK>$Jo$HF9r;+e<4pROO;^`9yMfL#Ef1enleVt{FeuK4KQx1#w0*TuJUa}AV5T2$CGFeIs0C;N z{v7~V;&J2F|1B!<*lnW9$M>&Mk;?~I=H(XP5|#NhQ|NSu3!S#12);Wk^tt^40eYVI z4*vb!*%;g*|7)J-b#d|C<+X!RxVgB(9=m@F4qr4^@VIwhak;(Kl>e=rhoaMNLj+r0 zSa$oo5rVDfxbV1%F~I%5*MV~-?(Hzu=iR3cy>Gtnmek+xbe`_IDF5GG>kz&F4Lf%R U6Ru)AcK`qY07*qoM6N<$g0_6IMgRZ+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f98f69c3187fb15ce81eb1eff32de4acfe6d993d GIT binary patch literal 1297 zcmV+s1@8KZP)@5KurMSwj_9Llsy-6ATuv0 z@30#0u^L)VIL4G5#+4j0Eh3O_9y2Z?KsO?uc^$KmOSy|3xr-ciV?wKf9Zf+YKshb5 zk4v6-9n+l~O++(JK_ce6Y3;#qfMqScm{DR&8$dZO;insQTp--CW81Q0k9bVfog31v zW~z->Y*Zm^Qz7A}8moaFPDCk4X;UEFvt!${V~}`E zMZ3CV0000RbW%=J0AQb=ARrL$-`@}r&yb)nFp$sBkB?xGkf4v#Sc-E100W&#L_t(& z-tC%eU)nGfhXcw*#mk)TR%pu#SQsLrhy!t6r_N31bldm;Cc~t;v@~7Qo@_7lylT%c zoF_T|G$}bnh*|zscC}DWZH3jDOdd_!>^p$q%w}c-;q&WdU|aKzVs@?!ZgavQmNmqm z5SDfJUmQbTQ~U{m=&)YaW@|V>{QjUnG*a8i_>xC_OwtU!IqVof*7-(bE+RC~;-45` zc0vh-Jjzf&#{jerjhbN@x->xTzzds2CGTns3+Q`cUn081{e%S!sR)QB%K$bSHyluH zIL>x8ezcJGk$|dWPHZ_m01$>18G^79%$es+K`5s%Fd)mbFAz9{zQmJB}(rPYpXZ69Oy}?<2k6sd&~7c&vngCh4CYkU;R* zp%TQOrmjEbT?*Lpdo~h32Q7cfYZOrTi?W#*VEIyx6J8gS-VT=_KEM;wG84av1-@w7K35~!@EM1%)E z8ZT-~z^7WS1rb~?-vgHSJ&6P=0Gq{VQq&&Hn(Ke869GsT9yMjTzh{Y;Nuac&)o2jb zaW)hyxU?n$xKvY~dh9wE)0YNdd#hWMt4+t!l}Vrk*zxipOn-Vg@b4B$-zta*ZJSSd zvO?KSsRU{Ol8A?{kZ&x)rsdxn5(7xr?SZiB;Ai^WmP()oAR_d{DUbVJ?@A?5e6zzD zuT5`lM^XtC0X@1=@+t3DrgrBP0J|c4nV8ru0HDpHS%GarCV}ePb*&J%uf5E z;4>iRp`fXFC}=(d-~f&yz)=NwhqK#p0b%VU0iSV~oY;rsVeS8Ryl*-UrQ`jn_W$ld zqa~IhZ2VuW#8T2xWky)Q6)SV;Db)3Op>CdvaCizA|MWWr00000NkvXX Hu0mjfn364C literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8983b30441cc262937465c0f5a03ad6e05e1e810 GIT binary patch literal 1283 zcmV+e1^oJnP)H3ATu&C@30#0u^L=kTE>(d#+4j0Eh6LN;xfZM!sCh znW-&TDd!FJ&{kdn1ZP%W357SRCa_flqgYur!EIF-#ET5#FA3{e_Fo)>kx~35f#^^* zGqWmA5I^4^|Ej09lf^ZUc%P&)J^6H02eR=y8nd)ifW^P4gV_lsJU39D0*>l{Hl|VY zEJN3IP}`?#23qrv7O;RLKWqg(^N&8j0)A5w5KUGc*l1iipzFErRyTe$N&84Z*R__m z9h)F)9|_p9uz7I-vi6Yx(%y380%YwKfW04{mC8QPBLUrQE4BGj$N>QHmxE9P<`#$@ zD*^9e0G<+H0okJgbY5c{aL<5v-b8tH0OUfCdk%@`ihFv*lMGD8s}6JCJgm@TQpbfn0+40AGYFA>pS7f(WxS?-|Sc zR3U*5z+o{u6g6d8YX@)ZL;#Y7drevHZ&~715@UYp zp&fiQBnFVKJ2PR`!T0p3qm)1oKtz~`Qy%xdIZ{fX`DTYR-gx+EJ5WlX37D|0lUDHN zP)eW)a7@+9#L_MS00S1y4m~DR66n5NH%!F~9edWL1WX?0dlV^wF5oaM0TCSQ7c10#&7@FQLk1P$`~)uk1uJG62$ksZIoKF@v9yd3D)Qp& z%>tlY_h-D%BNp!q%BAcI)}^dV?gdiRFJ|@8K#_iyu%7W}57~@?_aEy3u5_so`nsjK t0T?~^vMBjJr%>BigZTVsty~j&{|_|R8+_Coai;(P002ovPDHLkV1h^RUdsRg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..41f8a22274b4d4501e0aa93fbe89a289534425e3 GIT binary patch literal 9828 zcmV-qCY#xJPew8T0RR91048Jr5C8xG0QXb?044zd0RR9100000000000000000000 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