diff --git a/.github/workflows/codetest-sonarcloud.yml b/.github/workflows/codetest-sonarcloud.yml index e0691acb0..95a0725af 100644 --- a/.github/workflows/codetest-sonarcloud.yml +++ b/.github/workflows/codetest-sonarcloud.yml @@ -111,6 +111,9 @@ jobs: sed -i "s/\/home\/runner\/work\/DriveBC.ca\/DriveBC.ca<\/source>/\/github\/workspace<\/source>/g" /home/runner/work/DriveBC.ca/DriveBC.ca/coverage.xml - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v8.2.1 + with: + args: > + -Dsonar.exclusions=src/frontend/src/index.js env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/src/frontend/src/Components/data/featureStyleDefinitions.js b/src/frontend/src/Components/data/featureStyleDefinitions.js index 94bbd7696..4ecb2db16 100644 --- a/src/frontend/src/Components/data/featureStyleDefinitions.js +++ b/src/frontend/src/Components/data/featureStyleDefinitions.js @@ -7,6 +7,9 @@ import cameraIconHover from '../../images/mapIcons/camera-hover.png'; import cameraIconStatic from '../../images/mapIcons/camera-static.png'; import cameraIconHoverUnread from '../../images/mapIcons/camera-hover-unread.png'; import cameraIconStaticUnread from '../../images/mapIcons/camera-static-unread.png'; +import cameraGroupedIconActive from '../../images/mapIcons/camera-grouped-active.png'; +import cameraGroupedIconHover from '../../images/mapIcons/camera-grouped-hover.png'; +import cameraGroupedIconStatic from '../../images/mapIcons/camera-grouped-static.png'; // Ferries import ferryIconActive from '../../images/mapIcons/ferry-active.png'; @@ -209,6 +212,28 @@ export const cameraStyles = { }), }; +// Camera icon styles +export const cameraGroupedStyles = { + static: new Style({ + image: new Icon({ + scale: 0.25, + src: cameraGroupedIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + scale: 0.25, + src: cameraGroupedIconHover, + }), + }), + active: new Style({ + image: new Icon({ + scale: 0.25, + src: cameraGroupedIconActive, + }), + }), +}; + export const unreadCameraStyles = { static: new Style({ image: new Icon({ diff --git a/src/frontend/src/Components/map/handlers/click.js b/src/frontend/src/Components/map/handlers/click.js index b681c6402..3c8d81c02 100644 --- a/src/frontend/src/Components/map/handlers/click.js +++ b/src/frontend/src/Components/map/handlers/click.js @@ -188,7 +188,7 @@ export const resetClickedStates = ( updateClickedFeature(null); break; } - if (isCamDetail && targetFeature && targetFeature.get('type') === 'camera') { + if (isCamDetail && targetFeature?.get('type') === 'camera') { if (highlighted_camera_list.length > 0) { highlighted_camera_list[0].setCameraStyle('static'); highlighted_camera_list[0].set('clicked', false); @@ -232,8 +232,7 @@ const camClickHandler = ( updateReferenceFeature, mapContext ) => { - if ((clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera') - || (clickedFeatureRef.current && (clickedFeatureRef.current.values_.type != feature.values_.type))) { + if (clickedFeatureRef.current?.values_?.type === 'camera' || clickedFeatureRef.current?.values_?.type !== feature.values_?.type) { resetClickedStates( feature, clickedFeatureRef, @@ -257,6 +256,7 @@ const camClickHandler = ( feature.setCameraStyle('active'); feature.set('clicked', true, true); feature.set('unread', false); + feature.set('hovered', false); if (isCamDetail) { // Do not auto center on feature on cam details page @@ -295,7 +295,7 @@ export const eventClickHandler = ( isCamDetail, ) => { // reset previous clicked feature - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, @@ -330,7 +330,7 @@ export const ferryClickHandler = ( isCamDetail, ) => { // reset previous clicked feature - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, @@ -356,7 +356,7 @@ const weatherClickHandler = ( updateClickedFeature, isCamDetail, ) => { - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { // reset previous clicked feature resetClickedStates( feature, @@ -382,7 +382,7 @@ const regionalClickHandler = ( updateClickedFeature, isCamDetail, ) => { - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { // reset previous clicked feature resetClickedStates( feature, @@ -411,7 +411,7 @@ const hefClickHandler = ( isCamDetail, ) => { // reset previous clicked feature - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, @@ -438,7 +438,7 @@ const restStopClickHandler = ( isCamDetail, ) => { // reset previous clicked feature - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, @@ -477,7 +477,7 @@ const routeClickHandler = ( updateClickedFeature, ) => { // reset previous clicked feature - if (!(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, @@ -501,7 +501,7 @@ const borderCrossingClickHandler = ( isCamDetail, ) => { // reset previous clicked feature - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, @@ -543,7 +543,7 @@ export const wildfireClickHandler = ( isCamDetail, ) => { // reset previous clicked feature - if (!isCamDetail || !(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (!isCamDetail || clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, @@ -580,8 +580,36 @@ export const pointerClickHandler = ( mapContext ) => { if (features.length) { - const clickedFeature = features[0]; - switch (clickedFeature.getProperties()['type']) { + let clickedFeature = features[0]; + + const clusterFeatures = clickedFeature.get('features'); + + if (clusterFeatures) { + if (clusterFeatures.length > 1) { + clusterFeatures.forEach(feature => { + feature.set('hovered', false); + feature.set('clicked', false); + feature.setCameraStyle('static'); + }); + + mapView.current.animate({ + center: clickedFeature.getGeometry().getCoordinates(), + zoom: mapView.current.getZoom() + 1, + duration: 300, + }); + + return; + } + + clickedFeature = clusterFeatures[0]; + } + + if (!clickedFeature.get('type')) { + clickedFeature = features[0].values_?.features[0]; + } + + + switch (clickedFeature?.getProperties()['type']) { case 'camera': trackEvent( 'click', @@ -793,7 +821,7 @@ export const dmsClickHandler = ( isCamDetail, ) => { // reset previous clicked feature - if (!(clickedFeatureRef.current && clickedFeatureRef.current.values_.type == 'camera')) { + if (clickedFeatureRef.current?.values_?.type !== 'camera') { resetClickedStates( feature, clickedFeatureRef, diff --git a/src/frontend/src/Components/map/handlers/hover.js b/src/frontend/src/Components/map/handlers/hover.js index d117d36db..169ae52d5 100644 --- a/src/frontend/src/Components/map/handlers/hover.js +++ b/src/frontend/src/Components/map/handlers/hover.js @@ -30,9 +30,22 @@ export const resetHoveredStates = (targetFeature, hoveredFeatureRef) => { // Reset feature if target isn't clicked if (hoveredFeature && targetFeature != hoveredFeature) { + + const clusterFeatures = hoveredFeature.get('features'); + if (clusterFeatures && clusterFeatures.length > 1) { + clusterFeatures.forEach((f) => { + if (!f.get('clicked')) { + f.set('hovered', false); + } + }); + } else + + + if (!hoveredFeature.getProperties().clicked) { switch (hoveredFeature.getProperties()['type']) { case 'camera': + hoveredFeature.set('hovered', false); hoveredFeature.setCameraStyle('static'); break; case 'event': { @@ -148,15 +161,34 @@ export const pointerMoveHandler = (e, mapRef, hoveredFeature) => { }); if (features.length) { - const targetFeature = features[0]; - resetHoveredStates(targetFeature, hoveredFeature); - hoveredFeature.current = targetFeature; + let targetFeature = features[0]; + let clusterFeatures = targetFeature?.get('features'); + if(targetFeature && !targetFeature.get('type')) { - // Set hover style if feature isn't clicked - switch (targetFeature.getProperties()['type']) { + if (clusterFeatures?.length === 1) { + targetFeature = clusterFeatures[0]; + clusterFeatures = null; + } + } + + const realFeature = targetFeature; + resetHoveredStates(realFeature, hoveredFeature); + hoveredFeature.current = realFeature; + + if (clusterFeatures?.length > 1) { + clusterFeatures.forEach((f) => { + if (!f.get('clicked')) { + f.set('hovered', true); + } + }); + return; + } + + switch (realFeature?.get('type')) { case 'camera': - if (!targetFeature.get('clicked')) { - targetFeature.setCameraStyle('hover'); + if (!realFeature.get('clicked')) { + realFeature.setCameraStyle('hover'); + realFeature.set('hovered', true); } return; case 'event': diff --git a/src/frontend/src/Components/map/layers/camerasLayer.js b/src/frontend/src/Components/map/layers/camerasLayer.js index 6cf3b9f32..89f17f456 100644 --- a/src/frontend/src/Components/map/layers/camerasLayer.js +++ b/src/frontend/src/Components/map/layers/camerasLayer.js @@ -1,14 +1,16 @@ // OpenLayers import { Point } from 'ol/geom'; -import { Style } from 'ol/style'; +import {Style, Fill, Text, Icon} from 'ol/style'; import * as ol from 'ol'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; +import Cluster from 'ol/source/Cluster.js'; +// import CircleStyle from 'ol/style/Circle.js'; // Styling -import { cameraStyles, unreadCameraStyles } from '../../data/featureStyleDefinitions'; +import { cameraStyles, cameraGroupedStyles, unreadCameraStyles } from '../../data/featureStyleDefinitions'; -export function getCamerasLayer(cameras, projectionCode, mapContext, referenceData, updateReferenceFeature, setLoadingLayers) { +export function getCamerasLayer(cameras, projectionCode, mapContext, referenceData, updateReferenceFeature, setLoadingLayers, pixelDistance) { const vectorSource = new VectorSource(); cameras.forEach(camera => { @@ -47,12 +49,69 @@ export function getCamerasLayer(cameras, projectionCode, mapContext, referenceDa } }); - return new VectorLayer({ + const clusterSource = new Cluster({ + distance: pixelDistance, + source: vectorSource, + }); + + const styleCache = {}; + + const layer = new VectorLayer({ classname: 'webcams', visible: mapContext.visible_layers.highwayCams, - source: vectorSource, - style: () => null + source: clusterSource, + + style: function (feature) { + const features = feature.get('features'); + const size = features.length; + + // single camera + if (size === 1) { + const cam = features[0]; + if (cam.get('clicked')) return cameraStyles.active; + if (cam.get('hovered')) return cameraStyles.hover; + return cameraStyles.static; + } + + const cam = features[0]; + let state = 'static'; + if (cam.get('clicked')) { + state = 'active'; + } else if (cam.get('hovered')) { + state = 'hover'; + } + + const cacheKey = `${state}_${size}`; + let style = styleCache[cacheKey]; + + if (!style) { + const textColor = state === 'static' ? '#255A90' : '#fff'; + + style = new Style({ + image: new Icon({ + scale: 0.25, + src: cameraGroupedStyles[state].getImage().getSrc(), + }), + text: new Text({ + text: String(size), + font: 'bold 12px BC Sans', + fill: new Fill({ color: textColor }), + textAlign: 'center', + textBaseline: 'middle', + offsetX: 4, + offsetY: 1, + }), + }); + styleCache[cacheKey] = style; + } + + return style; + }, }); + + layer.getClusterSource = () => clusterSource; + + return layer; } export function updateCamerasLayer(cameras, layer, setLoadingLayers) { @@ -62,7 +121,11 @@ export function updateCamerasLayer(cameras, layer, setLoadingLayers) { }, {}); for (const feature of layer.getSource().getFeatures()) { - const camera = camerasLookup[feature.getId()]; + let camera = camerasLookup[feature.getId()]; + if (!camera) { + camera = camerasLookup[feature.values_.features[0].getId()]; + } + if (!camera) { // camera no longer in list from API feature.setStyle(new Style(null)); @@ -76,12 +139,16 @@ export function updateCamerasLayer(cameras, layer, setLoadingLayers) { feature.setProperties(camera); // update feature with latest API data. if (feature.get('clicked')) { continue; } - - feature.setCameraStyle('static'); + try { + feature.setCameraStyle('static'); + } + catch { + feature.values_.features[0].setCameraStyle('static'); + } } setLoadingLayers(prevState => ({ ...prevState, cameras: false })); -} +} \ No newline at end of file diff --git a/src/frontend/src/Components/map/layers/index.js b/src/frontend/src/Components/map/layers/index.js index 7a81400ca..f412eabe0 100644 --- a/src/frontend/src/Components/map/layers/index.js +++ b/src/frontend/src/Components/map/layers/index.js @@ -47,6 +47,7 @@ export const loadLayer = (mapLayers, mapRef, mapContext, key, dataList, filtered mapRef.current.removeLayer(mapLayers.current[key]); } + if (dataList) { if (!mapLayers.current[key] || key == 'routeLayer') { // Generate and add layer if it doesn't exist @@ -56,7 +57,8 @@ export const loadLayer = (mapLayers, mapRef, mapContext, key, dataList, filtered mapContext, referenceData, updateReferenceFeature, - setLoadingLayers + setLoadingLayers, + 50 ); mapRef.current.addLayer(mapLayers.current[key]); @@ -70,6 +72,8 @@ export const loadLayer = (mapLayers, mapRef, mapContext, key, dataList, filtered } } + + export const enableReferencedLayer = (referenceData, mapContext) => { // Do nothing if no reference data if (!referenceData) return; diff --git a/src/frontend/src/images/mapIcons/camera-grouped-active.png b/src/frontend/src/images/mapIcons/camera-grouped-active.png new file mode 100644 index 000000000..8fef3c533 Binary files /dev/null and b/src/frontend/src/images/mapIcons/camera-grouped-active.png differ diff --git a/src/frontend/src/images/mapIcons/camera-grouped-hover.png b/src/frontend/src/images/mapIcons/camera-grouped-hover.png new file mode 100644 index 000000000..a775d6faf Binary files /dev/null and b/src/frontend/src/images/mapIcons/camera-grouped-hover.png differ diff --git a/src/frontend/src/images/mapIcons/camera-grouped-static.png b/src/frontend/src/images/mapIcons/camera-grouped-static.png new file mode 100644 index 000000000..4b6b11caa Binary files /dev/null and b/src/frontend/src/images/mapIcons/camera-grouped-static.png differ