= ({ label, url, iconName, iconColor }) => {
14 | if (label.trim() === '') {
15 | return null;
16 | }
17 |
18 | return (
19 |
20 | {iconName && (
21 |
30 | )}
31 |
32 | {url && (
33 |
39 | {label}
40 |
41 | )}
42 |
43 | {!url && (
44 |
45 | {label}
46 |
47 | )}
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/src/services/layers/Layer.ts:
--------------------------------------------------------------------------------
1 | import { Map } from 'azure-maps-control';
2 | import { Dispatch } from '@reduxjs/toolkit';
3 | import { indoor } from 'azure-maps-indoor';
4 |
5 | import { LocationData } from '../../models/locationsData';
6 |
7 | export enum LayerType {
8 | Floors = 'floors',
9 | Indoor = 'indoor',
10 | Markers = 'markers',
11 | Warnings = 'warnings',
12 | Weather = 'weather',
13 | Tracking = 'tracking'
14 | }
15 |
16 | export interface LayerChildItem {
17 | id: string;
18 | name: string;
19 | visible: boolean;
20 | }
21 |
22 | export interface ComponentAndProps {
23 | component: React.FC
;
24 | props: P;
25 | }
26 |
27 | export interface Layer {
28 | id: string;
29 | name: string;
30 | type: LayerType;
31 | isVisible: boolean;
32 | initialize(map: Map, indoorManager: indoor.IndoorManager, dispatch: Dispatch): void;
33 | setVisibility(isVisible: boolean): void;
34 | setLocation(location: LocationData): void;
35 | dispose(): void;
36 | onLayerVisibilityChange?(layer: Layer): void;
37 | getChildren?(): LayerChildItem[];
38 | setChildVisibility?(name: string, visible: boolean): void;
39 | getMapComponent?(): ComponentAndProps | undefined;
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/LocationBreadcrumb/LocationBreadcrumb.scss:
--------------------------------------------------------------------------------
1 | @import '../../css/colors.scss';
2 |
3 | .location-breadcrumb-container {
4 | flex: 1;
5 | }
6 |
7 | .location-breadcrumb {
8 | margin: 0;
9 |
10 | .overflow-button,
11 | .item-button,
12 | .divider {
13 | outline: none;
14 | color: $gray130;
15 | }
16 |
17 | .item-button {
18 | border-radius: 2px;
19 | caret-color: transparent;
20 |
21 | * {
22 | pointer-events: none;
23 | }
24 | }
25 |
26 | .divider {
27 | padding: 0;
28 | width: 16px;
29 | height: 100%;
30 | margin-left: 2px;
31 |
32 | .divider-icon {
33 | margin: 0;
34 | font-size: 12px;
35 | }
36 |
37 | * {
38 | pointer-events: none;
39 | }
40 | }
41 |
42 | .list-item {
43 | &:not(.final-level):last-child {
44 | display: none;
45 | }
46 |
47 | &:not(:last-child) {
48 | margin-right: 2px;
49 | }
50 |
51 | &:not(.final-level):nth-last-child(2) {
52 | margin-right: 0;
53 | }
54 |
55 | &:last-child,
56 | &:not(.final-level):nth-last-child(2) {
57 | .item-button {
58 | color: $gray130;
59 | font-weight: 600;
60 | }
61 | }
62 | }
63 | }
--------------------------------------------------------------------------------
/src/components/Legend/Legend.scss:
--------------------------------------------------------------------------------
1 | .legend-outer {
2 | position: absolute;
3 | align-self: flex-end;
4 | }
5 |
6 | .my-legend {
7 | display: flex;
8 | flex-direction: column;
9 |
10 | background-color: azure;
11 | border-style: groove;
12 | opacity: 0.8;
13 | padding: 5px;
14 | margin: 15px;
15 | border-radius: 5px;
16 |
17 | .legend-title {
18 | text-align: left;
19 | margin-bottom: 5px;
20 | font-weight: bold;
21 | font-size: 90%;
22 | }
23 |
24 | .legend-scale {
25 | ul {
26 | margin: 0;
27 | margin-bottom: 5px;
28 | padding: 0;
29 | float: left;
30 | list-style: none;
31 |
32 | li {
33 | font-size: 80%;
34 | list-style: none;
35 | margin-left: 0;
36 | line-height: 18px;
37 | margin-bottom: 2px;
38 | }
39 | }
40 | }
41 |
42 | ul.legend-labels li span {
43 | display: block;
44 | float: left;
45 | height: 16px;
46 | width: 30px;
47 | margin-right: 5px;
48 | margin-left: 0;
49 | border: 1px solid #999;
50 | }
51 |
52 | .legend-source {
53 | font-size: 70%;
54 | color: #999;
55 | clear: both;
56 | }
57 |
58 | a {
59 | color: #777;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/reducers/popover.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 |
3 | import { RootState } from '../store/store';
4 | import { PopoverType, PopoverData } from '../models/popoversData';
5 |
6 | export interface PopoversState {
7 | [PopoverType.Warning]: PopoverData;
8 | }
9 |
10 | const initialState: PopoversState = {
11 | [PopoverType.Warning]: {
12 | type: PopoverType.Warning,
13 | isVisible: false,
14 | }
15 | };
16 |
17 | export const popoversDataSlice = createSlice({
18 | name: 'popovers',
19 | initialState,
20 | reducers: {
21 | showPopover: (state: PopoversState, action: PayloadAction) => {
22 | const popoverType: PopoverType = action.payload.type;
23 | state[popoverType] = action.payload;
24 | },
25 | hidePopover: (state: PopoversState, action: PayloadAction) => {
26 | const popoverType = action.payload;
27 |
28 | state[popoverType].isVisible = false;
29 | state[popoverType].target = undefined;
30 | }
31 | },
32 | });
33 |
34 | export const {
35 | showPopover,
36 | hidePopover,
37 | } = popoversDataSlice.actions;
38 |
39 | export const selectWarningPopoverData = (state: RootState) => state.popovers[PopoverType.Warning];
40 |
41 | export default popoversDataSlice.reducer;
42 |
--------------------------------------------------------------------------------
/.github/workflows/deploy_production.yml:
--------------------------------------------------------------------------------
1 | name: Deploy app to production
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 |
8 | env:
9 | AZURE_WEBAPP_NAME: ssir
10 | NODE_VERSION: '12.x'
11 |
12 | jobs:
13 | build-and-deploy:
14 | name: Build and Deploy
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@master
18 | - name: Use Node.js ${{ env.NODE_VERSION }}
19 | uses: actions/setup-node@v1
20 | with:
21 | node-version: ${{ env.NODE_VERSION }}
22 | - name: npm install, build, and test
23 | env:
24 | REACT_APP_MAP_SUBSCRIPTION_KEY: ${{ secrets.REACT_APP_MAP_SUBSCRIPTION_KEY }}
25 | run: |
26 | # Build and test the project, then
27 | # deploy to Azure Web App.
28 | npm install
29 | npm run build
30 | npm run test --if-present
31 | - name: Archive build artifacts
32 | uses: actions/upload-artifact@v1
33 | with:
34 | name: build
35 | path: build
36 | - name: 'Cleanup before deployment'
37 | run: rm -rf node_modules
38 | - name: 'Deploy to Azure WebApp'
39 | uses: azure/webapps-deploy@v2
40 | with:
41 | app-name: ${{ env.AZURE_WEBAPP_NAME }}
42 | publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
43 |
--------------------------------------------------------------------------------
/livemaps-api/Models/FloorInfo.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace ssir.api.Models
6 | {
7 | public class FloorInfo
8 | {
9 | public string Region { get; set; }
10 | public string RegionId
11 | {
12 | get
13 | {
14 | return Region.ToLower().Replace(" ", "");
15 | }
16 | }
17 | public string Campus { get; set; }
18 | public string CampusId
19 | {
20 | get
21 | {
22 | return $"{RegionId}/{Campus}".ToLower().Replace(" ", "");
23 | }
24 | }
25 | public string Building { get; set; }
26 | public string BuildingId
27 | {
28 | get
29 | {
30 | return $"{CampusId}/{Building}".ToLower().Replace(" ", "");
31 | }
32 | }
33 |
34 | public double Area { get; set; }
35 | public double Latitude { get; set; }
36 | public double Longitude { get; set; }
37 | public string Floor { get; set; }
38 | public string FloorId
39 | {
40 | get
41 | {
42 | return $"{BuildingId}/{Floor}".ToLower().Replace(" ", "");
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/StatsSidebar/SliderChart/SliderChart.tsx:
--------------------------------------------------------------------------------
1 | import './SliderChart.scss';
2 |
3 | import React from 'react';
4 | import { Bar, BarChart as BarChartRecharts, ResponsiveContainer, XAxis, YAxis } from 'recharts';
5 |
6 | export interface SliderChartProps {
7 | value: number;
8 | unit?: string;
9 | minValue: number;
10 | maxValue: number;
11 | color: string;
12 | }
13 |
14 | const BAR_WIDTH = 14;
15 | const CHART_HEIGHT = 50;
16 |
17 | export const SliderChart: React.FC = (props) => {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
33 |
34 |
35 |
36 |
37 |
38 | {`${props.value} ${props.unit}`}
39 |
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/.github/workflows/deploy_staging.yml:
--------------------------------------------------------------------------------
1 | name: Deploy app to staging
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | env:
9 | AZURE_WEBAPP_NAME: ssir
10 | NODE_VERSION: '12.x'
11 |
12 | jobs:
13 | build-and-deploy:
14 | name: Build and Deploy
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@master
18 | - name: Use Node.js ${{ env.NODE_VERSION }}
19 | uses: actions/setup-node@v1
20 | with:
21 | node-version: ${{ env.NODE_VERSION }}
22 | - name: npm install, build, and test
23 | env:
24 | REACT_APP_MAP_SUBSCRIPTION_KEY: ${{ secrets.REACT_APP_MAP_SUBSCRIPTION_KEY }}
25 | run: |
26 | # Build and test the project, then
27 | # deploy to Azure Web App.
28 | npm install
29 | npm run build
30 | npm run test --if-present
31 | - name: Archive build artifacts
32 | uses: actions/upload-artifact@v1
33 | with:
34 | name: build
35 | path: build
36 | - name: 'Cleanup before deployment'
37 | run: rm -rf node_modules
38 | - name: 'Deploy to Azure WebApp'
39 | uses: azure/webapps-deploy@v2
40 | with:
41 | app-name: ${{ env.AZURE_WEBAPP_NAME }}
42 | publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE_STAGING }}
43 | slot-name: staging
44 |
--------------------------------------------------------------------------------
/livemaps-api/Models/SiteMapItem.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Text;
5 |
6 | namespace ssir.api.Models
7 | {
8 | public class SiteMapItem
9 | {
10 | public SiteMapItem(string name, string id, string type, double area, double latitude, double longitude, string parentId)
11 | {
12 | this.name = name;
13 | this.id = id.Replace(" ", "");
14 | this.parentId = parentId;
15 | this.type = type;
16 | this._area = area;
17 | this.latitude = latitude;
18 | this.longitude = longitude;
19 | this.items = new HashSet();
20 | }
21 | private double _area;
22 | public string name { get; set; }
23 | public string id { get; set; }
24 | public string parentId { get; set; }
25 | public string type { get; set; }
26 | public double latitude { get; set; }
27 | public double longitude { get; set; }
28 | public double area {
29 | get { return Math.Round(_area, 2); }
30 | set { _area = value; }
31 | }
32 | public HashSet items;
33 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
34 | public BuildingConfig config { get; set; }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/StatsSidebar/VerticalBarChart/VerticalBarChart.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Bar, BarChart as BarChartRecharts, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
3 |
4 | export interface VerticalBarChartProps {
5 | data: {
6 | name: string;
7 | value: number;
8 | }[];
9 | }
10 |
11 | const MIN_BAR_WIDTH = 10;
12 | const MAX_BAR_WIDTH = 30;
13 | const MIN_BAR_GAP = 10;
14 | const MIN_CHART_HEIGHT = 300;
15 | const BAR_COLOR = '#0078D4';
16 | const X_AXIS_HEIGHT = 20;
17 |
18 | export const VerticalBarChart: React.FC = ({ data }) => {
19 | if (!data.length) {
20 | return null;
21 | }
22 |
23 | const chartHeight: number = Math.max(
24 | (MIN_BAR_WIDTH + MIN_BAR_GAP) * data.length + X_AXIS_HEIGHT,
25 | MIN_CHART_HEIGHT
26 | );
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/reducers/simulation.ts:
--------------------------------------------------------------------------------
1 | import { AppThunk } from '../store/store';
2 | import { setCurrentLocationState } from './indoor';
3 |
4 | export const updateIndoorStateSimulation = (roomId: string, stateName: string, value: number): AppThunk =>
5 | async (dispatch, getState) => {
6 | const currentLocationId = getState().locationData.current.location?.id;
7 | if (!currentLocationId) {
8 | return;
9 | }
10 |
11 | const code = "xwSenBKMqgZsxbiKwo0hxzbzddQ81Qjjy4xSuCNaFHLBlJ95GFJ0aQ==";
12 | const url = `https://ssirapi.azurewebsites.net/api/state/${currentLocationId}/${roomId}?code=${code}`;
13 |
14 | const prevValue = getState().indoor.currentStates[stateName]?.value;
15 | const fail = (error: string) => {
16 | console.warn(`Failed to set indoor state ${stateName} for ${roomId}: ${error}`);
17 | dispatch(setCurrentLocationState([stateName, { value: prevValue, loaded: true }]));
18 | }
19 |
20 | dispatch(setCurrentLocationState([stateName, { value: prevValue, loaded: false }]));
21 | try {
22 | const res = await fetch(url, { method: "POST", body: JSON.stringify({ [stateName]: value }) });
23 | if (res.status === 200) {
24 | dispatch(setCurrentLocationState([stateName, { value, loaded: true }]));
25 | } else {
26 | fail(`HTTP${res.status} ${res.statusText}`);
27 | }
28 | } catch (error) {
29 | fail(error);
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/src/components/SearchCallout/SearchCallout.scss:
--------------------------------------------------------------------------------
1 | @import '../../css/scrollbar.scss';
2 | @import '../../css/colors.scss';
3 |
4 | .search-form {
5 | padding: 22px 0;
6 | width: 300px;
7 | max-height: 350px;
8 | display: flex;
9 | flex-direction: column;
10 |
11 | .search-box-container {
12 | padding: 0 12px;
13 |
14 | &:not(:last-child) {
15 | margin-bottom: 12px;
16 | }
17 | }
18 |
19 | .search-box {
20 | width: 100%;
21 | }
22 |
23 | .items-list {
24 | @include scrollbars(2px, #C4C4C4);
25 |
26 | list-style: none;
27 | padding-right: 8px;
28 | padding-left: 12px;
29 | margin: 0;
30 | margin-right: 4px;
31 | overflow-y: auto;
32 | overflow-y: overlay;
33 | flex: 1;
34 | min-height: 0;
35 |
36 | li {
37 | &:not(:last-child) {
38 | margin-bottom: 4px;
39 | }
40 | }
41 |
42 | .item-content {
43 | width: 100%;
44 | text-align: left;
45 | padding: 6px 8px;
46 | border: none;
47 |
48 | &:hover {
49 | background: #c4c4c426;
50 | }
51 |
52 | &:focus {
53 | outline: none;
54 | }
55 |
56 | &.selected {
57 | border: 1px solid $primaryBlue;
58 | border-radius: 2px;
59 |
60 | color: $primaryBlue;
61 | }
62 |
63 | .item-text {
64 | font-weight: 400;
65 | flex: 1;
66 | overflow: hidden;
67 | text-overflow: ellipsis;
68 | white-space: nowrap;
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/Map/Popover/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Callout, Separator, Text, Target, getTheme, FontWeights, mergeStyleSets } from '@fluentui/react';
3 |
4 | const theme = getTheme();
5 | const styles = mergeStyleSets({
6 | callout: {
7 | maxWidth: 300,
8 | pointerEvents: 'none',
9 | },
10 | header: {
11 | padding: '18px 24px 12px',
12 | },
13 | title: [
14 | theme.fonts.xLarge,
15 | {
16 | margin: 0,
17 | fontWeight: FontWeights.semilight,
18 | },
19 | ],
20 | inner: {
21 | height: '100%',
22 | padding: '0 24px 20px',
23 | },
24 | subtext: [
25 | theme.fonts.small,
26 | {
27 | margin: 0,
28 | fontWeight: FontWeights.semilight,
29 | },
30 | ],
31 | });
32 |
33 |
34 | interface PopoverProps {
35 | title: string;
36 | body: React.ReactElement;
37 | target: Target;
38 | onDismiss?: () => void;
39 | }
40 |
41 | const Popover: React.FC = ({ title, body, target, onDismiss }) => {
42 | return (
43 |
50 |
51 |
52 | {title}
53 |
54 |
55 |
56 |
57 |
58 | {body}
59 | ;
60 |
61 |
62 | );
63 | }
64 |
65 | export default Popover;
66 |
--------------------------------------------------------------------------------
/src/components/StatsSidebar/LineChart/LineChart.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | CartesianGrid,
4 | Line,
5 | LineChart as LineChartRecharts,
6 | ResponsiveContainer,
7 | Tooltip,
8 | XAxis,
9 | YAxis,
10 | } from 'recharts';
11 |
12 | import { getChartColorByIndex } from '../../../utils/colorUtils';
13 |
14 | export interface LineChartProps {
15 | data: {
16 | x: string;
17 | y: { [key: string]: number | undefined; };
18 | }[];
19 | names: { [key: string]: string; };
20 | minValue?: number | null;
21 | maxValue?: number | null;
22 | colors?: { [key: string]: string | undefined; };
23 | }
24 |
25 | export const LineChart: React.FC = ({ data, names, colors = {}, minValue, maxValue }) => {
26 | let domain: [number, number] | undefined;
27 |
28 | if (minValue != null && maxValue != null) {
29 | domain = [minValue, maxValue];
30 | }
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {Object.keys(names).map((key: string, index: number) => (
41 |
47 | ))}
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/src/components/Legend/Legend.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 |
4 | import { getZoomByLocationType } from '../../utils/locationsUtils';
5 | import { LocationType } from '../../models/locationsData';
6 | import { selectLayerVisibility } from '../../reducers/layersData';
7 | import { selectMapZoomLevel } from '../../reducers/map';
8 |
9 | import './Legend.scss';
10 |
11 | const DEFAULT_ZOOM_THRESHOLD = getZoomByLocationType(LocationType.Floor);
12 |
13 | interface LegendProps {
14 | layerId: string
15 | title: string;
16 | items: Record;
17 | zoomThreshold?: number;
18 | }
19 |
20 | const Legend: React.FC = ({
21 | layerId,
22 | title,
23 | items,
24 | zoomThreshold = DEFAULT_ZOOM_THRESHOLD,
25 | }) => {
26 | const zoomLevel = useSelector(selectMapZoomLevel);
27 | const isLayerVisible = useSelector(selectLayerVisibility(layerId));
28 |
29 | const isLegendVisible = isLayerVisible && zoomLevel && zoomLevel >= zoomThreshold;
30 | if (!isLegendVisible) {
31 | return null;
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 | {title}
39 |
40 |
41 |
42 |
43 | {
44 | Object.entries(items).map(([color, text]) => -
45 |
46 | {text}
47 |
48 | )
49 | }
50 |
51 |
52 |
53 |
54 | );
55 | };
56 |
57 | export default Legend;
58 |
--------------------------------------------------------------------------------
/src/components/LayersSwitcher/LayersSwitcher.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Callout, DirectionalHint } from '@fluentui/react';
4 |
5 | import { LayerChildrenMenu } from "./LayerChildrenMenu/LayerChildrenMenu";
6 | import { Switch } from "./Switch/Switch";
7 |
8 | import {
9 | LayersVisibilityState,
10 | selectLayersVisibility,
11 | setLayerVisibility,
12 | } from '../../reducers/layersData';
13 |
14 | import { mapService } from '../../services/mapService';
15 |
16 | import "./LayersSwitcher.scss";
17 |
18 | export interface LayerSwitcherProps {
19 | target: string | Element | MouseEvent | React.RefObject;
20 | onDismiss?: () => void;
21 | }
22 |
23 | export const LayersSwitcher: React.FC = ({ target, onDismiss }) => {
24 | const dispatch = useDispatch();
25 | const layersVisibility: LayersVisibilityState = useSelector(selectLayersVisibility);
26 |
27 | return (
28 |
35 |
36 | {
37 | mapService.getLayersInfo().map((layer) => {
38 | const { id, name } = layer;
39 | return dispatch(setLayerVisibility({ id, isVisible }))}
43 | >
44 | {layer.getChildren && }
45 |
46 | })
47 | }
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/models/sidebar.ts:
--------------------------------------------------------------------------------
1 | export interface LabelData {
2 | type: 'label';
3 | data: {
4 | name: string;
5 | value?: any;
6 | };
7 | }
8 |
9 | export interface LineChartData {
10 | type: 'line';
11 | data: {
12 | x: string;
13 | y: { [key: string]: number | undefined; };
14 | }[];
15 | colors?: { [key: string]: string | undefined; };
16 | names: { [key: string]: string; };
17 | minValue?: number | null;
18 | maxValue?: number | null;
19 | }
20 |
21 | export interface PieChartData {
22 | type: 'pie';
23 | data: {
24 | name: string;
25 | value: number;
26 | }[];
27 | }
28 |
29 | export interface BarChartData {
30 | type: 'bar' | 'verticalBar',
31 | data: {
32 | name: string;
33 | value: number;
34 | }[];
35 | }
36 |
37 | export interface SliderChartData {
38 | type: 'slider',
39 | data: [{
40 | value: number;
41 | unit?: string;
42 | minValue: number;
43 | maxValue: number;
44 | color: string;
45 | }];
46 | }
47 |
48 | export interface AlertData {
49 | type: 'alert';
50 | data: {
51 | name: string;
52 | url?: string;
53 | iconName?: string;
54 | iconColor?: string;
55 | }
56 | }
57 |
58 | export interface SliderControlData {
59 | type: 'slider_control',
60 | data: {
61 | name: string;
62 | value?: number;
63 | minValue: number;
64 | maxValue: number;
65 | disabled?: boolean;
66 | onSave?(newValue: number): void;
67 | }
68 | }
69 |
70 | export type SidebarItemData = BarChartData | LabelData | AlertData | PieChartData | SliderControlData | SliderChartData | LineChartData;
71 |
72 | export interface SidebarGroup {
73 | id: string;
74 | name: string;
75 | collapsed?: boolean
76 | items: SidebarItemData[];
77 | }
78 |
79 | export type SidebarData = SidebarGroup[];
80 |
81 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * EnsureEnv checks that environment variable with the given name exists and
3 | * returns its value otherwise throws an error. This is needed to make sure the
4 | * app is properly configured with required variables.
5 | * @param envName environment variable name
6 | */
7 | const ensureEnv = (envName: string): string => {
8 | const envValue = process.env[envName];
9 | if (envValue === undefined) {
10 | throw new Error(`Variable ${envName} is required and must be defined`);
11 | }
12 |
13 | return envValue;
14 | }
15 |
16 | export const subscriptionKey = ensureEnv("REACT_APP_MAP_SUBSCRIPTION_KEY");
17 |
18 | const baseUrl = process.env.REACT_APP_API_BASE_URL ?? "http://localhost:3001";
19 |
20 | /**
21 | * Reads URL from an environment and prepends with base URL if it starts with "/"
22 | * otherwise treats URL as full with schema and hostname and just returns it.
23 | * @param envName Environment variable name to read URL from
24 | * @param defaultVal default value if variable is not defined
25 | */
26 | const getUrl = (envName: string, defaultVal : string): string => {
27 | const url = process.env[envName] ?? defaultVal ;
28 | return url.startsWith("/") ? baseUrl + url : url;
29 | };
30 |
31 | // {locationPath} vill be replaced with location's path in sitemap, e.g. /europe/southcampus/bldg1
32 | export const sitemapUrl = getUrl("REACT_APP_SITEMAP_URL", "/sitemap");
33 | export const roomsDataUrl = getUrl("REACT_APP_ROOMS_DATA_URL", "/roomdata/{locationPath}");
34 | export const sensorsDataUrl = getUrl("REACT_APP_SENSORDATA_URL", "/state/{locationPath}");
35 | export const sidebarDataUrl = getUrl("REACT_APP_SIDEBAR_DATA_URL", "/sidebar/{locationPath}");
36 | export const warningsDataUrl = getUrl("REACT_APP_WARNINGS_DATA_URL", "/faults/{locationPath}");
37 |
38 | export const trackerHostname = process.env.REACT_APP_TRACKER_HOSTNAME ?? "localhost:3001";
39 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.scss';
2 |
3 | import React, { useEffect } from 'react';
4 | import { initializeIcons } from '@uifabric/icons';
5 | import { useDispatch, useSelector } from 'react-redux';
6 | import { useHistory, useLocation } from 'react-router-dom';
7 |
8 | import AppHeader from './components/AppHeader/AppHeader';
9 | import Map from './components/Map/Map';
10 | import SideNavBar from './components/SideNavBar/SideNavBar';
11 | import { StatsSidebar } from './components/StatsSidebar/StatsSidebar';
12 | import {
13 | fetchLocationsInfo,
14 | selectLocationsDataLoaded,
15 | updateCurrentLocation,
16 | } from './reducers/locationData';
17 | import { fetchUserInfo } from './reducers/user';
18 |
19 | initializeIcons();
20 |
21 | const App: React.FC = () => {
22 | const dispatch = useDispatch();
23 | const history = useHistory();
24 | const { pathname: path } = useLocation();
25 |
26 | useEffect(() => {
27 | dispatch(fetchUserInfo());
28 | dispatch(fetchLocationsInfo(path, history));
29 | // Don't add `path` to deps as we want to trigger this
30 | // effect only once, not on every location change.
31 | // eslint-disable-next-line react-hooks/exhaustive-deps
32 | }, [dispatch, history]);
33 |
34 |
35 | const isLoaded = useSelector(selectLocationsDataLoaded);
36 | useEffect(() => {
37 | if (isLoaded) {
38 | // Only parse path and update current location when locations data has
39 | // been loaded otherwise there is a race condition between this update
40 | // and update triggered by`fetchLocationsInfo`
41 | dispatch(updateCurrentLocation(path, history));
42 | }
43 | }, [dispatch, history, isLoaded, path])
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default App;
59 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
19 |
20 |
21 |
22 |
23 |
32 | Azure Live Map
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ssir",
3 | "version": "0.1.0",
4 | "private": true,
5 | "proxy": "https://energypcs.blob.core.windows.net/",
6 | "dependencies": {
7 | "express": "^4.17.1"
8 | },
9 | "devDependencies": {
10 | "@fluentui/react": "^7.113.0",
11 | "@reduxjs/toolkit": "^1.3.6",
12 | "@testing-library/jest-dom": "^5.7.0",
13 | "@testing-library/react": "^10.0.4",
14 | "@testing-library/user-event": "^10.1.2",
15 | "@turf/helpers": "^6.1.4",
16 | "@turf/transform-scale": "^5.1.5",
17 | "@types/jest": "^25.2.1",
18 | "@types/node": "^13.13.5",
19 | "@types/react": "^16.9.35",
20 | "@types/react-dom": "^16.9.8",
21 | "@types/react-redux": "^7.1.8",
22 | "@types/react-resize-detector": "^4.2.0",
23 | "@types/react-router-dom": "^5.1.5",
24 | "@types/recharts": "^1.8.10",
25 | "@types/yup": "^0.29.3",
26 | "azure-maps-control": "^2.0.26",
27 | "azure-maps-indoor": "^0.1.0",
28 | "css-animation-sync": "^0.2.0",
29 | "fuse.js": "^5.2.3",
30 | "http-proxy-middleware": "^1.0.3",
31 | "node-sass": "^4.14.1",
32 | "react": "^16.13.1",
33 | "react-dom": "^16.13.1",
34 | "react-redux": "^7.2.0",
35 | "react-resize-detector": "^4.2.3",
36 | "react-router-dom": "^5.2.0",
37 | "react-scripts": "^3.4.1",
38 | "recharts": "^1.8.5",
39 | "typescript": "^3.8.3",
40 | "yup": "^0.29.1"
41 | },
42 | "scripts": {
43 | "dev": "react-scripts start --no-cache",
44 | "build": "react-scripts build",
45 | "test": "react-scripts test --no-cache --passWithNoTests",
46 | "eject": "react-scripts eject"
47 | },
48 | "eslintConfig": {
49 | "extends": "react-app"
50 | },
51 | "browserslist": {
52 | "production": [
53 | ">0.2%",
54 | "not dead",
55 | "not op_mini all"
56 | ],
57 | "development": [
58 | "last 1 chrome version",
59 | "last 1 firefox version",
60 | "last 1 safari version"
61 | ]
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/livemaps-api/APIFunctions/AtlasUnits.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Mvc;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.AspNetCore.Http;
8 | using Microsoft.Extensions.Logging;
9 | using Newtonsoft.Json;
10 | using System.Net.Http;
11 | using ssir.api.Models.Atlas;
12 |
13 | namespace ssir.api.APIFunctions
14 | {
15 | public static class AtlasUnits
16 | {
17 | [FunctionName("AtlasUnits")]
18 | public static async Task Run(
19 | [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
20 | ILogger log)
21 | {
22 | var atlasSubscriptionKey = Environment.GetEnvironmentVariable("atlasSubscriptionKey");
23 | var atlasDataSetId = Environment.GetEnvironmentVariable("atlasDataSetId");
24 |
25 | using (var client = new HttpClient())
26 | {
27 | var baseUri = new System.Text.StringBuilder($"https://atlas.microsoft.com/wfs/datasets/{atlasDataSetId}/collections/unit/items?api-version=1.0&subscription-key={atlasSubscriptionKey}&limit=2");
28 | HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, baseUri.ToString());
29 | var response = await client.SendAsync(requestMessage);
30 | var result = await response.Content.ReadAsStringAsync();
31 |
32 | var featureCollection = JsonConvert.DeserializeObject(result);
33 | if(featureCollection != null && featureCollection.Features != null)
34 | {
35 | foreach(var feature in featureCollection.Features)
36 | {
37 | var a = feature.Id;
38 | }
39 | }
40 | }
41 |
42 |
43 | return new OkObjectResult("Ok");
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "clean",
6 | "command": "dotnet",
7 | "args": [
8 | "clean",
9 | "/property:GenerateFullPaths=true",
10 | "/consoleloggerparameters:NoSummary"
11 | ],
12 | "type": "process",
13 | "problemMatcher": "$msCompile",
14 | "options": {
15 | "cwd": "${workspaceFolder}/livemaps-api"
16 | }
17 | },
18 | {
19 | "label": "build",
20 | "command": "dotnet",
21 | "args": [
22 | "build",
23 | "/property:GenerateFullPaths=true",
24 | "/consoleloggerparameters:NoSummary"
25 | ],
26 | "type": "process",
27 | "dependsOn": "clean",
28 | "group": {
29 | "kind": "build",
30 | "isDefault": true
31 | },
32 | "problemMatcher": "$msCompile",
33 | "options": {
34 | "cwd": "${workspaceFolder}/livemaps-api"
35 | }
36 | },
37 | {
38 | "label": "clean release",
39 | "command": "dotnet",
40 | "args": [
41 | "clean",
42 | "--configuration",
43 | "Release",
44 | "/property:GenerateFullPaths=true",
45 | "/consoleloggerparameters:NoSummary"
46 | ],
47 | "type": "process",
48 | "problemMatcher": "$msCompile",
49 | "options": {
50 | "cwd": "${workspaceFolder}/livemaps-api"
51 | }
52 | },
53 | {
54 | "label": "publish",
55 | "command": "dotnet",
56 | "args": [
57 | "publish",
58 | "--configuration",
59 | "Release",
60 | "/property:GenerateFullPaths=true",
61 | "/consoleloggerparameters:NoSummary"
62 | ],
63 | "type": "process",
64 | "dependsOn": "clean release",
65 | "problemMatcher": "$msCompile",
66 | "options": {
67 | "cwd": "${workspaceFolder}/livemaps-api"
68 | }
69 | },
70 | {
71 | "type": "func",
72 | "dependsOn": "build",
73 | "options": {
74 | "cwd": "${workspaceFolder}/livemaps-api/bin/Debug/netcoreapp3.1"
75 | },
76 | "command": "host start",
77 | "isBackground": true,
78 | "problemMatcher": "$func-dotnet-watch"
79 | }
80 | ]
81 | }
--------------------------------------------------------------------------------
/src/components/UserControl/index.tsx:
--------------------------------------------------------------------------------
1 | import './index.scss';
2 |
3 | import React, { useCallback } from 'react';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { useHistory } from 'react-router-dom';
6 | import { ContextualMenu, IContextualMenuItem, Persona } from '@fluentui/react';
7 |
8 | import { selectUserName, logout } from '../../reducers/user';
9 |
10 | interface PersonaButtonProps {
11 | userName?: string;
12 | onClick?: () => void;
13 | }
14 |
15 | const PersonaButton: React.FC = ({
16 | userName = '',
17 | onClick,
18 | }) => {
19 | return (
20 |
30 | );
31 | };
32 |
33 | const UserControl: React.FC = () => {
34 | const userName = useSelector(selectUserName)
35 | const [isMenuVisible, setMenuVisible] = React.useState(false);
36 | const toggleIsCalloutVisibility = () => { setMenuVisible(!isMenuVisible) };
37 | const dispatch = useDispatch();
38 | const history = useHistory();
39 | const handleSignout = useCallback(() => { dispatch(logout(history)) }, [dispatch, history]);
40 |
41 | const menuItems: IContextualMenuItem[] = [
42 | {
43 | key: 'signout',
44 | text: 'Sign Out',
45 | onClick: handleSignout,
46 | iconProps: {
47 | iconName: 'SignOut',
48 | }
49 | }
50 | ];
51 |
52 | return (
53 |
54 |
58 |
59 |
66 |
67 | );
68 | };
69 |
70 | export default UserControl;
71 |
--------------------------------------------------------------------------------
/src/components/StatsSidebar/SliderControl/SliderControl.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { Slider as FluentSlider, Text, IconButton } from "@fluentui/react";
3 |
4 | import "./SliderControl.scss";
5 |
6 | interface SliderProps {
7 | name: string;
8 | value?: number;
9 | minValue: number;
10 | maxValue: number;
11 | disabled?: boolean;
12 | onSave?(newValue: number): void;
13 | }
14 |
15 | export const SliderControl: React.FC = ({ name, value, minValue, maxValue, disabled, onSave }) => {
16 | const initialValue = Math.floor(value ?? minValue + (maxValue - minValue) / 2);
17 | const [internalValue, setInternalValue] = useState(initialValue);
18 | useEffect(() => setInternalValue(initialValue), [initialValue]);
19 |
20 | return (
21 |
22 |
23 |
24 | {name}
25 |
26 | {disabled ? "-" : internalValue}
27 |
28 |
29 |
41 | onSave && onSave(internalValue)}
45 | title={`Save ${name}`}
46 | ariaLabel={`Save ${name}`}
47 | />
48 | setInternalValue(initialValue)}
52 | title={`Discard ${name} changes`}
53 | ariaLabel={`Discard ${name} changes`}
54 | />
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/livemaps-api/APIFunctions/Warnings.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Mvc;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.AspNetCore.Http;
8 | using Microsoft.Extensions.Logging;
9 | using Newtonsoft.Json;
10 | using System.Text;
11 | using Microsoft.WindowsAzure.Storage.Blob;
12 |
13 | namespace ssir.api
14 | {
15 | public static class Warnings
16 | {
17 | [FunctionName("Warnings")]
18 | public static async Task Run(
19 | [HttpTrigger(AuthorizationLevel.Function, "get", Route = "faults/{region}/{campus}/{building}")] HttpRequest req,
20 | [Blob("shared", Connection = "AzureWebJobsStorage")] CloudBlobContainer container,
21 | string region,
22 | string campus,
23 | string building,
24 | ILogger log)
25 | {
26 | string fileName;
27 | try
28 | {
29 | if (!string.IsNullOrEmpty(building))
30 | {
31 | fileName = $"{region}_{campus}_{building}_warnings.json".ToLower();
32 | }
33 | else
34 | {
35 | return new NotFoundObjectResult("Data not found!");
36 | }
37 |
38 | var devicestateref = container.GetBlockBlobReference(fileName);
39 | using (var ms = new MemoryStream())
40 | {
41 | await devicestateref.DownloadToStreamAsync(ms);
42 | ms.Position = 0;
43 | using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
44 | {
45 | var deviceStateData = reader.ReadToEnd();
46 | return new OkObjectResult(deviceStateData);
47 | }
48 | };
49 | }
50 | catch(Exception ex)
51 | {
52 | log.LogError(ex.Message);
53 | return new NotFoundObjectResult("Data not found!");
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/FavoritesSwitcher/FavoritesSwitcher.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { useHistory } from 'react-router-dom';
4 | import { DirectionalHint } from '@fluentui/react';
5 |
6 | import LocationSwitcher from '../LocationSwitcher/LocationSwitcher';
7 | import { Favorites, favoritesService } from '../../services/favoritesService';
8 | import { AllLocationsData, LocationData } from '../../models/locationsData';
9 | import { changeLocation, selectLocationsData, selectCurrentLocationId } from '../../reducers/locationData';
10 | import { NoFavorites } from './NoFavorites/NoFavorites';
11 | import { getFullLocationName } from '../../utils/locationsUtils';
12 |
13 | export interface FavoritesSwitcherProps {
14 | target: string | Element | MouseEvent | React.RefObject;
15 | onDismiss?: () => void;
16 | }
17 |
18 | export const FavoritesSwitcher: React.FC = ({
19 | target,
20 | onDismiss,
21 | }) => {
22 | const dispatch = useDispatch();
23 | const history = useHistory();
24 | const allLocationsData: AllLocationsData = useSelector(selectLocationsData);
25 | const currentLocationId: string | undefined = useSelector(selectCurrentLocationId);
26 | const favorites: Favorites = favoritesService.getFavorites();
27 | const favoriteLocations: string[] = Object.keys(favorites);
28 |
29 | const renderItemName = useCallback((locationId: string) => {
30 | return getFullLocationName(locationId, allLocationsData);
31 | }, [allLocationsData]);
32 |
33 | if (!favoriteLocations.length) {
34 | return (
35 |
36 | );
37 | }
38 |
39 | return (
40 | {
47 | dispatch(changeLocation(location.id, history));
48 |
49 | if (onDismiss) {
50 | onDismiss();
51 | }
52 | }}
53 | renderItemName={renderItemName}
54 | />
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/livemaps-api/APIFunctions/DeviceState.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Mvc;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.AspNetCore.Http;
8 | using Microsoft.Extensions.Logging;
9 | using Newtonsoft.Json;
10 | using System.Text;
11 | using Microsoft.WindowsAzure.Storage.Blob;
12 |
13 | namespace ssir.api
14 | {
15 | public static class DeviceState
16 | {
17 | [FunctionName("DeviceState")]
18 | public static async Task Run(
19 | [HttpTrigger(AuthorizationLevel.Function, "get", Route = "state/{region}/{campus}/{building}/{level?}")] HttpRequest req,
20 | [Blob("shared", Connection = "AzureWebJobsStorage")] CloudBlobContainer container,
21 | string region,
22 | string campus,
23 | string building,
24 | string level,
25 | ILogger log)
26 | {
27 | string fileName;
28 | try
29 | {
30 | if (string.IsNullOrEmpty(level))
31 | {
32 | fileName = $"{region}_{campus}_{building}_currentState.json";
33 | }
34 | else
35 | {
36 | fileName = $"{region}_{campus}_{building}_{level}_currentState.json";
37 | }
38 |
39 | fileName = fileName.ToLower();
40 |
41 | var devicestateref = container.GetBlockBlobReference(fileName);
42 | using (var ms = new MemoryStream())
43 | {
44 | await devicestateref.DownloadToStreamAsync(ms);
45 | ms.Position = 0;
46 | using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
47 | {
48 | var deviceStateData = reader.ReadToEnd();
49 | return new OkObjectResult(deviceStateData);
50 | }
51 | };
52 | }
53 | catch(Exception ex)
54 | {
55 | log.LogError(ex.Message);
56 | return new NotFoundObjectResult("Data not found!");
57 | }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/FavoriteLocationButton/FavoriteLocationButton.tsx:
--------------------------------------------------------------------------------
1 | import './FavoriteLocationButton.scss';
2 |
3 | import React, { useCallback, useState, useEffect } from 'react';
4 | import { IconButton, } from '@fluentui/react';
5 | import { useSelector } from 'react-redux';
6 |
7 | import { favoritesService } from '../../services/favoritesService';
8 | import { selectCurrentLocationId } from '../../reducers/locationData';
9 | import { LayersVisibilityState, selectLayersVisibility } from '../../reducers/layersData';
10 |
11 | export interface FavoriteLocationButtonProps {
12 | locationId: string;
13 | locationName: string;
14 | }
15 |
16 | const FavoriteLocationButton: React.FC = ({
17 | locationId,
18 | locationName,
19 | }) => {
20 | const currentLocationId: string | undefined = useSelector(selectCurrentLocationId);
21 | const layersVisibility: LayersVisibilityState = useSelector(selectLayersVisibility);
22 | const [isFavorite, makeFavorite] = useState(favoritesService.isFavorite(locationId));
23 |
24 | useEffect(() => {
25 | makeFavorite(favoritesService.isFavorite(locationId));
26 | }, [locationId]);
27 |
28 | const onFavoriteButtonClick = useCallback((event: React.MouseEvent) => {
29 | event.preventDefault();
30 | event.stopPropagation();
31 |
32 | if (isFavorite) {
33 | favoritesService.removeFromFavorites(locationId);
34 | } else {
35 | favoritesService.addToFavorites(locationId, currentLocationId === locationId, layersVisibility);
36 | }
37 |
38 | makeFavorite(!isFavorite);
39 | }, [isFavorite, locationId, currentLocationId, layersVisibility]);
40 |
41 | if (!locationId || !locationName) {
42 | return null;
43 | }
44 |
45 | let starButtonAriaLabel: string;
46 |
47 | if (isFavorite) {
48 | starButtonAriaLabel = `Remove ${locationName} from favorite list`;
49 | } else {
50 | starButtonAriaLabel = `Add ${locationName} to favorite list`;
51 | }
52 |
53 | return (
54 |
61 | );
62 | };
63 |
64 | export default FavoriteLocationButton;
65 |
--------------------------------------------------------------------------------
/src/reducers/user.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { History } from 'history';
3 |
4 | import { AppThunk, RootState } from '../store/store';
5 |
6 | type ClaimsData = { typ: string, val: string }[];
7 |
8 | const NAME_CLAIM = "name";
9 | const EMAIL_CLAIM = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";
10 |
11 | const USER_INFO_URL = `${process.env.PUBLIC_URL}/.auth/me`;
12 | const USER_LOGOUT_URL = `${process.env.PUBLIC_URL}/.auth/logout?post_logout_redirect_uri=${process.env.PUBLIC_URL}/`;
13 |
14 | interface UserState {
15 | name?: string,
16 | email?: string,
17 | }
18 |
19 | const initialState: UserState = {};
20 |
21 | const userSlice = createSlice({
22 | name: 'user',
23 | initialState,
24 | reducers: {
25 | setUser: (state, action: PayloadAction) => {
26 | action.payload.forEach(claim => {
27 | if (claim.typ === NAME_CLAIM) {
28 | state.name = claim.val;
29 | }
30 |
31 | if (claim.typ === EMAIL_CLAIM) {
32 | state.email = claim.val;
33 | }
34 | });
35 | },
36 | },
37 | });
38 |
39 | let fetchUserData = async () => {
40 | try {
41 | const resp = await fetch(USER_INFO_URL);
42 | const raw_data = await resp.json();
43 | return raw_data[0];
44 | } catch (err) {
45 | console.error("Failed to get current user info");
46 | return {}
47 | }
48 | }
49 |
50 | if (window.location.hostname === 'localhost') {
51 | fetchUserData = () => new Promise(resolve => {
52 | setTimeout(() => resolve({
53 | "user_claims": [
54 | {
55 | "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
56 | "val": "user@localhost"
57 | },
58 | {
59 | "typ": "name",
60 | "val": "Example"
61 | }
62 | ],
63 | "user_id": "user@localhost"
64 | }), 1000)
65 | })
66 | }
67 |
68 | export const fetchUserInfo = (): AppThunk => async dispatch => {
69 | const data = await fetchUserData();
70 | return dispatch(userSlice.actions.setUser(data.user_claims));
71 | };
72 |
73 | export const logout = (history: History): AppThunk => () => {
74 | history.push(USER_LOGOUT_URL);
75 | };
76 |
77 | export const selectUserName = (state: RootState) => state.user.name;
78 | export const selectUserEmail = (state: RootState) => state.user.email;
79 |
80 | export default userSlice.reducer;
81 |
--------------------------------------------------------------------------------
/src/reducers/layersData.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction, Action } from '@reduxjs/toolkit';
2 |
3 | import { RootState } from '../store/store';
4 | import { mapService } from "../services/mapService";
5 |
6 | export interface LayersVisibilityState {
7 | [id: string]: boolean;
8 | }
9 |
10 | export interface LayersState {
11 | visibilityState: LayersVisibilityState;
12 | }
13 |
14 | // Syncs visibility as map service can change visibility of other
15 | // layers if they are mutually exclusive
16 | const refreshVisibilityState = (): LayersVisibilityState => {
17 | const state: LayersVisibilityState = {};
18 | mapService.getLayersInfo().forEach(({ id }) => {
19 | state[id] = mapService.isLayerVisible(id)
20 | });
21 |
22 | return state;
23 | }
24 |
25 | const initialState: LayersState = {
26 | visibilityState: {},
27 | }
28 |
29 | interface LayerVisibilityPayload {
30 | id: string;
31 | isVisible: boolean;
32 | }
33 |
34 | export const layersDataSlice = createSlice({
35 | name: 'layersData',
36 | initialState,
37 | reducers: {
38 | refreshVisibility: (state: LayersState, action: Action) => ({
39 | visibilityState: refreshVisibilityState(),
40 | }),
41 | setLayerVisibility: (state: LayersState, action: PayloadAction) => {
42 | const { id, isVisible } = action.payload;
43 | try {
44 | mapService.setLayerVisibility(id, isVisible);
45 |
46 | return {
47 | visibilityState: refreshVisibilityState(),
48 | };
49 | } catch (err) {
50 | console.error(err);
51 | // Do nothing - assume visibility not changed
52 | }
53 | },
54 | setLayersVisibility: (state: LayersState, action: PayloadAction) => {
55 | const layersVisibility: LayersVisibilityState = action.payload;
56 | Object.keys(layersVisibility).forEach(
57 | id => mapService.setLayerVisibility(id, layersVisibility[id])
58 | );
59 |
60 | return {
61 | visibilityState: layersVisibility,
62 | }
63 | },
64 | },
65 | });
66 |
67 | export const {
68 | refreshVisibility,
69 | setLayerVisibility,
70 | setLayersVisibility,
71 | } = layersDataSlice.actions;
72 |
73 | export const selectLayersVisibility = (state: RootState) => state.layersData.visibilityState;
74 | export const selectLayerVisibility = (layerId: string) =>
75 | (state: RootState) => !!state.layersData.visibilityState[layerId];
76 |
77 | export default layersDataSlice.reducer;
78 |
--------------------------------------------------------------------------------
/src/components/LocationSwitcher/LocationSwitcher.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DirectionalHint } from '@fluentui/react';
3 | import { useSelector } from 'react-redux';
4 |
5 | import FavoriteLocationButton from '../FavoriteLocationButton/FavoriteLocationButton';
6 | import { AllLocationsData, LocationData } from '../../models/locationsData';
7 | import { selectLocationsData } from '../../reducers/locationData';
8 | import { SearchCallout } from '../SearchCallout/SearchCallout';
9 |
10 | export interface LocationSwitcherProps {
11 | target?: string | Element | MouseEvent | React.RefObject;
12 | currentLocationId: string;
13 | locations: string[];
14 | directionalHint?: DirectionalHint;
15 | onItemClick?: (location: LocationData) => void;
16 | onDismiss?: () => void;
17 | renderItemName?: (locationId: string) => string;
18 | }
19 |
20 | const LocationSwitcher: React.FC = ({
21 | target,
22 | currentLocationId,
23 | locations,
24 | directionalHint,
25 | onItemClick,
26 | onDismiss,
27 | renderItemName,
28 | }) => {
29 | const allLocationsData: AllLocationsData = useSelector(selectLocationsData);
30 |
31 | if (!locations?.length) {
32 | return null;
33 | }
34 |
35 | const items: LocationData[] = locations
36 | .map((locationId: string) => allLocationsData[locationId])
37 | .filter((location: LocationData | undefined) => !!location) as LocationData[];
38 |
39 | const selectedItem = items.find((item: LocationData) => item.id === currentLocationId);
40 |
41 | const getLocationName = (location: LocationData) => renderItemName ? renderItemName(location.id) : location.name;
42 |
43 | return (
44 | getLocationName(location)
52 | }}
53 | groupName="locations"
54 | directionalHint={directionalHint}
55 | getItemText={getLocationName}
56 | onItemClick={onItemClick}
57 | onDismiss={onDismiss}
58 | renderItem={(location: LocationData, defaultRender) => defaultRender({
59 | children: (
60 |
64 | )
65 | })}
66 | />
67 | );
68 | };
69 |
70 | export default LocationSwitcher;
71 |
--------------------------------------------------------------------------------
/livemaps-api/APIFunctions/SideBar.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Mvc;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.AspNetCore.Http;
8 | using Microsoft.Extensions.Logging;
9 | using Newtonsoft.Json;
10 | using System.Text;
11 | using Microsoft.WindowsAzure.Storage.Blob;
12 |
13 | namespace ssir.api
14 | {
15 | public static class SideBar
16 | {
17 | [FunctionName("SideBar")]
18 | public static async Task Run(
19 | [HttpTrigger(AuthorizationLevel.Function, "get", Route = "sidebar/{region}/{campus}/{building?}/{level?}/{unit?}")] HttpRequest req,
20 | [Blob("shared", Connection = "AzureWebJobsStorage")] CloudBlobContainer container,
21 | string region,
22 | string campus,
23 | string building,
24 | string level,
25 | ILogger log)
26 | {
27 | string fileName;
28 | try
29 | {
30 | if (string.IsNullOrEmpty(building))
31 | {
32 | fileName = $"{region}_{campus}_sidebar.json";
33 | }
34 | else
35 | {
36 | if (string.IsNullOrEmpty(level))
37 | {
38 | fileName = $"{region}_{campus}_{building}_sidebar.json";
39 | }
40 | else
41 | {
42 | fileName = $"{region}_{campus}_{building}_{level}_sidebar.json";
43 | }
44 | }
45 | fileName = fileName.ToLower();
46 |
47 | var devicestateref = container.GetBlockBlobReference(fileName);
48 | using (var ms = new MemoryStream())
49 | {
50 | await devicestateref.DownloadToStreamAsync(ms);
51 | ms.Position = 0;
52 | using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
53 | {
54 | var deviceStateData = reader.ReadToEnd();
55 | return new OkObjectResult(deviceStateData);
56 | }
57 | };
58 | }
59 | catch(Exception ex)
60 | {
61 | log.LogError(ex.Message);
62 | return new NotFoundObjectResult("Data not found!");
63 | }
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/livemaps-api/Services/BlobDataService.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.WindowsAzure.Storage.Blob;
2 | using Newtonsoft.Json;
3 | using System;
4 | using System.Collections.Generic;
5 | using System.IO;
6 | using System.Text;
7 | using System.Threading.Tasks;
8 |
9 | namespace ssir.api.Services
10 | {
11 | public class BlobDataService
12 | {
13 | public async Task GetContextData(CloudBlobContainer container)
14 | {
15 | using (var ms = new MemoryStream())
16 | {
17 | var datafileName = BuildContextDataFileName();
18 | var bacmapRef = container.GetBlockBlobReference(datafileName);
19 | await bacmapRef.DownloadToStreamAsync(ms);
20 | ms.Position = 0;
21 | using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
22 | {
23 | var data = reader.ReadToEnd();
24 | return data;
25 | }
26 | }
27 | }
28 |
29 | public async Task ReadBlobData(CloudBlobContainer container, string dataFileName)
30 | {
31 | using (var ms = new MemoryStream())
32 | {
33 | var dataFileRef = container.GetBlockBlobReference(dataFileName);
34 | await dataFileRef.DownloadToStreamAsync(ms);
35 | ms.Position = 0;
36 | using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
37 | {
38 | var data = reader.ReadToEnd();
39 | return JsonConvert.DeserializeObject(data);
40 | }
41 | }
42 | }
43 |
44 | //private static async Task> FetchAtlasConfig(CloudBlockBlob configRef)
45 | //{
46 | // BuildingConfig[] cfg;
47 | // using (var ms = new MemoryStream())
48 | // {
49 | // await configRef.DownloadToStreamAsync(ms);
50 | // ms.Position = 0;
51 | // using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
52 | // {
53 | // var featuresStr = reader.ReadToEnd();
54 | // cfg = JsonConvert.DeserializeObject(featuresStr);
55 | // }
56 | // }
57 |
58 | // return cfg;
59 | //}
60 |
61 | public string BuildContextDataFileName()
62 | {
63 | return "global.json";
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/StatsSidebar/PieChart/PieChart.tsx:
--------------------------------------------------------------------------------
1 | import './PieChart.scss';
2 |
3 | import React from 'react';
4 | import { Cell, Customized, Pie, PieChart as RechartPie } from 'recharts';
5 |
6 | import { LegendItem } from './LegendItem';
7 | import { getChartColorByIndex } from '../../../utils/colorUtils';
8 |
9 | const RADIAN = Math.PI / 180;
10 |
11 | interface PieChartProps {
12 | data: {
13 | name: string,
14 | value: number,
15 | }[]
16 | }
17 |
18 | const renderLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: any) => {
19 | const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
20 | const x = cx + radius * Math.cos(-midAngle * RADIAN);
21 | const y = cy + radius * Math.sin(-midAngle * RADIAN);
22 |
23 | return (
24 |
32 | {`${(percent * 100).toFixed(0)} %`}
33 |
34 | );
35 | };
36 |
37 | const renderCell = (entry: any, index: number) => {
38 | return (
39 | |
43 | );
44 | };
45 |
46 | export const PieChart: React.FC = ({ data }) => {
47 | const totalValue: number = data.reduce((accum, { value }) => accum + value, 0);
48 |
49 | const Total = (props: any) => {
50 | const { width, height } = props;
51 | return (
52 |
53 | {totalValue}
54 | Total
55 |
56 | );
57 | }
58 |
59 | const legendItems = data.map(({ name, value }, idx) => {
60 | return {
61 | key: idx,
62 | name,
63 | value,
64 | color: getChartColorByIndex(idx)
65 | }
66 | })
67 |
68 | return (
69 |
70 |
71 | {legendItems.map(item => )}
72 |
73 |
74 |
85 | {data.map(renderCell)}
86 |
87 |
88 |
89 |
90 | );
91 | };
92 |
93 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # ----------------------
4 | # KUDU Deployment Script
5 | # Version: 1.0.17
6 | # ----------------------
7 |
8 | # Helpers
9 | # -------
10 |
11 | exitWithMessageOnError () {
12 | if [ ! $? -eq 0 ]; then
13 | echo "An error has occurred during web site deployment."
14 | echo $1
15 | exit 1
16 | fi
17 | }
18 |
19 | # Prerequisites
20 | # -------------
21 |
22 | # Verify node.js installed
23 | hash node 2>/dev/null
24 | exitWithMessageOnError "Missing node.js executable, please install node.js, if already installed make sure it can be reached from current environment."
25 |
26 | # Setup
27 | # -----
28 |
29 | SCRIPT_DIR="${BASH_SOURCE[0]%\\*}"
30 | SCRIPT_DIR="${SCRIPT_DIR%/*}"
31 | ARTIFACTS=$SCRIPT_DIR/../artifacts
32 | KUDU_SYNC_CMD=${KUDU_SYNC_CMD//\"}
33 | NODE_EXE=node
34 | NPM_CMD=npm
35 |
36 | if [[ ! -n "$DEPLOYMENT_SOURCE" ]]; then
37 | DEPLOYMENT_SOURCE=$SCRIPT_DIR
38 | fi
39 |
40 | if [[ ! -n "$NEXT_MANIFEST_PATH" ]]; then
41 | NEXT_MANIFEST_PATH=$ARTIFACTS/manifest
42 |
43 | if [[ ! -n "$PREVIOUS_MANIFEST_PATH" ]]; then
44 | PREVIOUS_MANIFEST_PATH=$NEXT_MANIFEST_PATH
45 | fi
46 | fi
47 |
48 | if [[ ! -n "$DEPLOYMENT_TARGET" ]]; then
49 | DEPLOYMENT_TARGET=$ARTIFACTS/wwwroot
50 | else
51 | KUDU_SERVICE=true
52 | fi
53 |
54 | if [[ ! -n "$KUDU_SYNC_CMD" ]]; then
55 | # Install kudu sync
56 | echo Installing Kudu Sync
57 | npm install kudusync -g --silent
58 | exitWithMessageOnError "npm failed"
59 |
60 | if [[ ! -n "$KUDU_SERVICE" ]]; then
61 | # In case we are running locally this is the correct location of kuduSync
62 | KUDU_SYNC_CMD=kuduSync
63 | else
64 | # In case we are running on kudu service this is the correct location of kuduSync
65 | KUDU_SYNC_CMD=$APPDATA/npm/node_modules/kuduSync/bin/kuduSync
66 | fi
67 | fi
68 |
69 | ############
70 | # Deployment
71 | # ----------
72 |
73 | echo Handling node.js deployment.
74 |
75 | echo node.js version
76 | eval $NODE_EXE --version
77 |
78 | echo npm version
79 | eval $NPM_CMD --version
80 |
81 | # 1. KuduSync
82 | if [[ "$IN_PLACE_DEPLOYMENT" -ne "1" ]]; then
83 | "$KUDU_SYNC_CMD" -v 50 -f "$DEPLOYMENT_SOURCE" -t "$DEPLOYMENT_TARGET" -n "$NEXT_MANIFEST_PATH" -p "$PREVIOUS_MANIFEST_PATH" -i ".git;.hg;.deployment;deploy.sh"
84 | exitWithMessageOnError "Kudu Sync failed"
85 | fi
86 |
87 | # 2. Install npm packages
88 | if [ -e "$DEPLOYMENT_TARGET/package.json" ]; then
89 | cd "$DEPLOYMENT_TARGET"
90 | echo "Running $NPM_CMD install --production"
91 | eval $NPM_CMD install --production
92 | exitWithMessageOnError "npm failed"
93 | cd - > /dev/null
94 | fi
95 |
96 | ############
97 | echo "Finished successfully."
98 |
--------------------------------------------------------------------------------
/src/services/favoritesService.ts:
--------------------------------------------------------------------------------
1 | import { CameraBoundsOptions, CameraOptions } from 'azure-maps-control';
2 |
3 | import { MapPosition } from '../models/mapData';
4 | import { mapService } from './mapService';
5 | import { LayersVisibilityState } from '../reducers/layersData';
6 |
7 | export interface FavoriteItem {
8 | locationId: string;
9 | position?: MapPosition;
10 | zoom?: number;
11 | bearing?: number;
12 | pitch?: number;
13 | layersVisibility?: LayersVisibilityState;
14 | mapStyle?: string;
15 | };
16 |
17 | export interface Favorites {
18 | [locationId: string]: FavoriteItem;
19 | };
20 |
21 | export class FavoritesService {
22 | private favorites: Favorites = FavoritesService.loadFavorites();
23 |
24 | private static loadFavorites(): Favorites {
25 | const json: string | undefined = localStorage.favorites;
26 |
27 | return json ? JSON.parse(json) : {};
28 | }
29 |
30 | public getFavorites = (): Favorites => this.favorites;
31 |
32 | public getDataById = (locationId: string): FavoriteItem | undefined => this.favorites[locationId];
33 |
34 | private saveFavorites() {
35 | localStorage.favorites = JSON.stringify(this.favorites);
36 | }
37 |
38 | private addFavoriteItem(data: FavoriteItem) {
39 | this.favorites[data.locationId] = data;
40 |
41 | this.saveFavorites();
42 | }
43 |
44 | public addToFavorites(
45 | locationId: string,
46 | isCurrentLocation: boolean,
47 | layersVisibility: LayersVisibilityState
48 | ) {
49 | let favoriteItem: FavoriteItem = {
50 | locationId,
51 | };
52 |
53 | if (isCurrentLocation) {
54 | const mapCamera: CameraOptions & CameraBoundsOptions | undefined = mapService.getCamera();
55 | const mapStyle: string = mapService.getCurrentMapStyle();
56 |
57 | favoriteItem = {
58 | locationId,
59 | layersVisibility,
60 | mapStyle,
61 | };
62 |
63 | if (mapCamera) {
64 | const position: number[] | undefined = mapCamera.center;
65 | if (position) {
66 | favoriteItem.position = {
67 | longitude: position[0],
68 | latitude: position[1],
69 | }
70 | }
71 |
72 | favoriteItem.zoom = mapCamera.zoom;
73 | favoriteItem.bearing = mapCamera.bearing;
74 | favoriteItem.pitch = mapCamera.pitch;
75 | }
76 | }
77 |
78 | this.addFavoriteItem(favoriteItem);
79 | }
80 |
81 | public removeFromFavorites(locationId: string) {
82 | if (this.isFavorite(locationId)) {
83 | delete this.favorites[locationId];
84 | this.saveFavorites();
85 | }
86 | }
87 |
88 | public isFavorite(locationId: string): boolean {
89 | return !!this.favorites[locationId];
90 | }
91 | }
92 |
93 | export const favoritesService = new FavoritesService();
--------------------------------------------------------------------------------
/src/components/LayersSwitcher/LayerChildrenMenu/LayerChildrenMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | IconButton,
4 | IContextualMenuItem,
5 | IContextualMenuListProps,
6 | IRenderFunction,
7 | SearchBox,
8 | } from "@fluentui/react";
9 | import Fuse from 'fuse.js';
10 |
11 | import { Layer, LayerChildItem } from "../../../services/layers/Layer";
12 |
13 | import "./LayerChildrenMenu.scss";
14 |
15 | interface LayerChildrenMenuProps {
16 | layer: Layer;
17 | disabled?: boolean
18 | }
19 |
20 | export const LayerChildrenMenu: React.FC = ({ layer, disabled }) => {
21 | const [checkedItems, setCheckedItems] = useState<{ [name: string]: boolean }>({});
22 | const [searchValue, setSearchValue] = useState("");
23 | const [layerChildren, setlayerChildren] = useState([]);
24 |
25 | useEffect(() => {
26 | if (layer.getChildren === undefined) {
27 | return;
28 | }
29 |
30 | const interval = setInterval(() => setlayerChildren(layer.getChildren!()), 500);
31 | return () => clearInterval(interval);
32 | }, [layer]);
33 |
34 | if (!layer.getChildren) {
35 | return null;
36 | }
37 |
38 | const searchOptions = {
39 | findAllMatches: true,
40 | keys: ["name"],
41 | };
42 |
43 | let foundItems = layerChildren;
44 | if (searchValue !== "") {
45 | foundItems = new Fuse(layerChildren, searchOptions)
46 | .search(searchValue).map(({ item }) => item);
47 | }
48 |
49 | const menuItems: IContextualMenuItem[] = foundItems
50 | .map(({ id, name, visible }) => ({
51 | key: id,
52 | name,
53 | canCheck: true,
54 | checked: visible,
55 | onClick: (ev, item) => {
56 | ev?.preventDefault();
57 | if (item) {
58 | const visible = !item.checked;
59 | setCheckedItems({ ...checkedItems, [item.key]: visible });
60 | if (layer.setChildVisibility) {
61 | layer.setChildVisibility(item.key, visible);
62 | }
63 | }
64 | },
65 | }));
66 |
67 | const itemsPlural = layer.name.toLowerCase();
68 |
69 | const renderMenuList: IRenderFunction = (props, defaultRender) => {
70 | return (
71 |
72 |
73 | setSearchValue("")}
78 | onChange={(e: any, newValue?: string) => setSearchValue(newValue ?? "")}
79 | />
80 |
81 | {defaultRender && defaultRender(props)}
82 |
83 | );
84 | }
85 |
86 | return (
87 |
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/src/reducers/sidebar.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 |
3 | import { AppThunk, RootState } from '../store/store';
4 | import { SidebarData } from '../models/sidebar';
5 | import { DEFAULT_LOCATION, LocationData } from '../models/locationsData';
6 | import { LoadingState } from '../models/loadingState';
7 | import { sidebarDataUrl } from '../config';
8 |
9 | export interface SidebarState {
10 | data: SidebarData;
11 | context: LocationData;
12 | loadingState: LoadingState;
13 | };
14 |
15 | const initialState: SidebarState = {
16 | data: [],
17 | context: DEFAULT_LOCATION,
18 | loadingState: LoadingState.Loading,
19 | };
20 |
21 | interface SetSidebarDataPayload {
22 | data: any;
23 | loadingState: LoadingState;
24 | }
25 |
26 | const parseSidebarData = (rawData: any): SidebarData => {
27 | let result: SidebarData = [];
28 |
29 | if (rawData) {
30 | try {
31 | result = rawData.map((obj: any) => ({
32 | id: obj.id,
33 | name: obj.name,
34 | items: obj.items.map((item: any) => {
35 | if (!item) {
36 | throw new Error();
37 | }
38 |
39 | return item;
40 | }),
41 | }));
42 | } catch (error) {
43 | console.error("Failed to parse sidebar data")
44 | }
45 | }
46 |
47 | return result;
48 | }
49 |
50 | export const sidebarSlice = createSlice({
51 | name: 'temperature',
52 | initialState,
53 | reducers: {
54 | setSidebarData: (state: SidebarState, action: PayloadAction) => {
55 | const { data, loadingState } = action.payload;
56 |
57 | return {
58 | ...state,
59 | loadingState,
60 | data: parseSidebarData(data),
61 | };
62 | },
63 | setSidebarContext: (state: SidebarState, action: PayloadAction) => ({
64 | ...state,
65 | context: action.payload,
66 | }),
67 | },
68 | });
69 |
70 | const {
71 | setSidebarData,
72 | setSidebarContext,
73 | } = sidebarSlice.actions;
74 |
75 | export default sidebarSlice.reducer;
76 |
77 | export const selectSidebarLoadingState = (state: RootState) => state.sidebar.loadingState;
78 |
79 | export const selectSidebarContext = (state: RootState) => state.sidebar.context;
80 |
81 | export const selectCurrentSidebarData = (state: RootState): SidebarData => {
82 | return state.sidebar.data;
83 | }
84 |
85 | let fetchSidebarData = async (locationPath: string): Promise => {
86 | const url = sidebarDataUrl.replace("{locationPath}", locationPath);
87 | const response: Response = await fetch(url);
88 |
89 | if (response.ok) {
90 | const json = await response.json();
91 | return json ?? [];
92 | } else {
93 | throw new Error();
94 | }
95 | }
96 |
97 | export const fetchSidebar = (location: LocationData): AppThunk => async (dispatch, getState) => {
98 | const { sidebar: { context } } = getState();
99 |
100 | if (location.id === context.id) {
101 | return;
102 | }
103 |
104 | dispatch(setSidebarData({ data: [], loadingState: LoadingState.Loading }));
105 | dispatch(setSidebarContext(location));
106 |
107 | try {
108 | const data = await fetchSidebarData(location.id);
109 | dispatch(setSidebarData({ data, loadingState: LoadingState.Ready }));
110 | } catch {
111 | console.error("Failed to get current sidebar info");
112 | dispatch(setSidebarData({ data: [], loadingState: LoadingState.Error }));
113 | };
114 | };
115 |
--------------------------------------------------------------------------------
/src/services/layers/Navigator.ts:
--------------------------------------------------------------------------------
1 | import {
2 | data as atlasData,
3 | layer as atlasLayer,
4 | Map,
5 | source as atlasSource,
6 | } from 'azure-maps-control';
7 |
8 | import { MapObject } from '../../models/mapData';
9 |
10 | const DEFAULT_DURATION: number = 5000;
11 |
12 | export class Navigator {
13 | private readonly dataSource: atlasSource.DataSource;
14 | private layers: atlasLayer.Layer[] = [];
15 | private animationTimeout?: NodeJS.Timeout;
16 | private animationInterval?: NodeJS.Timeout;
17 | private opacity: number = 0.6;
18 | private mapObject?: MapObject;
19 |
20 | constructor(private readonly map: Map) {
21 | this.dataSource = new atlasSource.DataSource();
22 | this.map.sources.add(this.dataSource);
23 | }
24 |
25 | public showObject(mapObject: MapObject, delay: number = 0, duration?: number) {
26 | this.clear();
27 |
28 | this.mapObject = mapObject;
29 |
30 | if (delay > 0) {
31 | this.animationTimeout = setTimeout(() => {
32 | this.createLayer(mapObject, duration);
33 | }, delay);
34 | } else {
35 | this.createLayer(mapObject, duration);
36 | }
37 | }
38 |
39 | private createLayer(mapObject: MapObject, animationDuration?: number) {
40 | if (!mapObject.polygon) {
41 | return;
42 | }
43 |
44 | const layer = new atlasLayer.PolygonLayer(
45 | this.dataSource,
46 | undefined,
47 | {
48 | fillOpacity: this.opacity,
49 | }
50 | );
51 |
52 | const lineLayer = new atlasLayer.LineLayer(
53 | this.dataSource,
54 | undefined,
55 | {
56 | strokeOpacity: this.opacity === 0 ? 0 : 1,
57 | strokeWidth: 4,
58 | }
59 | );
60 |
61 | this.layers.push(layer);
62 | this.layers.push(lineLayer);
63 | this.map.layers.add(this.layers);
64 |
65 | const positions: atlasData.Position[] = mapObject.polygon.map(
66 | (position) => new atlasData.Position(position[0], position[1])
67 | );
68 |
69 | this.dataSource.add(new atlasData.Polygon(positions));
70 | this.addAnimation(animationDuration);
71 | }
72 |
73 | private addAnimation(duration: number = DEFAULT_DURATION) {
74 | if (!this.layers.length) {
75 | return;
76 | }
77 |
78 | if (this.mapObject?.polygon) {
79 | this.animationInterval = setInterval(() => {
80 | this.opacity = this.opacity === 0 ? 0.6 : 0;
81 |
82 | (this.layers[0] as atlasLayer.PolygonLayer)?.setOptions({
83 | fillOpacity: this.opacity,
84 | });
85 |
86 | (this.layers[1] as atlasLayer.LineLayer)?.setOptions({
87 | strokeOpacity: this.opacity === 0 ? 0 : 1,
88 | })
89 | }, 500);
90 |
91 | this.animationTimeout = setTimeout(
92 | () => this.clear(),
93 | duration
94 | );
95 | }
96 | }
97 |
98 | public clear() {
99 | if (this.animationTimeout) {
100 | clearTimeout(this.animationTimeout);
101 | this.animationTimeout = undefined;
102 | }
103 |
104 | if (this.animationInterval) {
105 | clearInterval(this.animationInterval);
106 | this.animationInterval = undefined;
107 | }
108 |
109 | this.layers.forEach((layer: atlasLayer.Layer) => {
110 | this.map.layers.remove(layer.getId());
111 | });
112 | this.layers = [];
113 |
114 | this.mapObject = undefined;
115 | this.dataSource.clear();
116 | }
117 |
118 | public dispose() {
119 | this.clear();
120 | this.dataSource.dispose();
121 | }
122 | }
--------------------------------------------------------------------------------
/livemaps-api/APIFunctions/SiteMap.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Mvc;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.AspNetCore.Http;
8 | using Microsoft.Extensions.Logging;
9 | using Newtonsoft.Json;
10 | using Microsoft.WindowsAzure.Storage.Blob;
11 | using System.Text;
12 | using System.Collections.Generic;
13 | using ssir.api.Models;
14 | using System.Data.SqlClient;
15 | using ssir.api.Services;
16 | using System.Linq;
17 |
18 | namespace ssir.api
19 | {
20 | public static class SiteMap
21 | {
22 | [FunctionName("SiteMap")]
23 | public static async Task Run(
24 | [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req,
25 | [Blob("shared", Connection = "AzureWebJobsStorage")] CloudBlobContainer container,
26 | ILogger log)
27 | {
28 | log.LogInformation("C# HTTP trigger function processed a request.");
29 | bool prerequisites = true;
30 |
31 | var errors = new StringBuilder();
32 | string siteMap = "sitemap";
33 |
34 | string rebuild = req.Query["rebuild"];
35 | bool full = rebuild == "full";
36 |
37 | var siteMapFile = Environment.GetEnvironmentVariable("SiteMapFile");
38 | if (string.IsNullOrEmpty(siteMapFile))
39 | {
40 | prerequisites = false;
41 | errors.Append("DataBase Connection String property {siteMapFile} is not defined!");
42 | }
43 |
44 | var atlasConfigFile = Environment.GetEnvironmentVariable("AtlasConfigFile") ?? "atlasConfig.json";
45 | var bds = new BlobDataService();
46 |
47 | if (prerequisites)
48 | {
49 | await container.CreateIfNotExistsAsync();
50 | var siteMapRef = container.GetBlockBlobReference(siteMapFile);
51 |
52 | try
53 | {
54 | if (!string.IsNullOrEmpty(rebuild))
55 | if (prerequisites)
56 | {
57 | // Custom Sitemap builder code
58 | }
59 | else
60 | {
61 | if (prerequisites)
62 | {
63 | using (var ms = new MemoryStream())
64 | {
65 | await siteMapRef.DownloadToStreamAsync(ms);
66 | ms.Position = 0;
67 | using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
68 | {
69 | var bacmapstr = reader.ReadToEnd();
70 | return new OkObjectResult(bacmapstr);
71 | }
72 | }
73 | }
74 | }
75 | }
76 | catch (Exception ex)
77 | {
78 | errors.Append(ex.Message);
79 | }
80 | }
81 |
82 | if (!prerequisites || errors.Length > 0)
83 | {
84 | log.LogError(errors.ToString());
85 | return new NotFoundResult();
86 | }
87 |
88 | return new OkObjectResult(siteMap);
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/reducers/rooms.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 |
3 | import { RoomDataSchema, RoomsByFloorId } from '../models/roomsData';
4 | import { LoadingState } from '../models/loadingState';
5 | import { AppThunk, RootState } from '../store/store';
6 | import { mapService } from '../services/mapService';
7 |
8 | import { roomsDataUrl } from '../config';
9 |
10 | export interface RoomsState {
11 | roomsByFloorId: RoomsByFloorId;
12 | loadingState: LoadingState;
13 | }
14 |
15 | const initialState: RoomsState = {
16 | roomsByFloorId: {},
17 | loadingState: LoadingState.Loading,
18 | };
19 |
20 | const isRoomsData = (data: any): data is RoomsByFloorId => {
21 | try {
22 | for (const floorId in data) {
23 | for (const roomId in data[floorId]) {
24 | const isValid = RoomDataSchema.isValidSync(data[floorId][roomId]);
25 |
26 | if (!isValid) {
27 | return false;
28 | }
29 | }
30 | }
31 |
32 | return true;
33 | } catch {
34 | console.error('Rooms data is not valid');
35 | return false;
36 | }
37 | }
38 |
39 | export const roomsSlice = createSlice({
40 | name: 'rooms',
41 | initialState,
42 | reducers: {
43 | setRooms: (state: RoomsState, action: PayloadAction) => {
44 | return {
45 | ...state,
46 | roomsByFloorId: action.payload,
47 | loadingState: LoadingState.Ready,
48 | }
49 | },
50 | setLoadingState: (state: RoomsState, action: PayloadAction) => {
51 | const loadingState: LoadingState = action.payload;
52 |
53 | if (loadingState === LoadingState.Loading) {
54 | return {
55 | ...state,
56 | roomsByFloorId: {},
57 | loadingState,
58 | };
59 | }
60 |
61 | return {
62 | ...state,
63 | loadingState,
64 | };
65 | }
66 | }
67 | });
68 |
69 | const {
70 | setRooms,
71 | setLoadingState,
72 | } = roomsSlice.actions;
73 |
74 | export const selectRoomsLoadingState = (state: RootState) => state.rooms.loadingState;
75 |
76 | export const selectRoomsData = (state: RootState) => state.rooms.roomsByFloorId;
77 |
78 | export const selectRoomsCount = (state: RootState) => {
79 | const roomsByFloorId = state.rooms.roomsByFloorId;
80 |
81 | return Object.values(roomsByFloorId).reduce(
82 | (roomsCount, floorData) => floorData ? roomsCount + Object.keys(floorData).length : roomsCount,
83 | 0
84 | );
85 | }
86 |
87 | export default roomsSlice.reducer;
88 |
89 | const fetchRoomsData = async (locationId: string) => {
90 | const url = roomsDataUrl.replace("{locationPath}", locationId);
91 | const response: Response = await fetch(url);
92 |
93 | if (response.ok) {
94 | const json = await response.json();
95 | return json ?? {};
96 | } else {
97 | throw new Error();
98 | }
99 | }
100 |
101 | export const fetchRoomsInfo = (locationId?: string): AppThunk => async (dispatch, getState) => {
102 | dispatch(setLoadingState(LoadingState.Loading));
103 | mapService.updateRoomsData({});
104 |
105 | if (!locationId) {
106 | return;
107 | }
108 |
109 | try {
110 | const roomsByFloorId: any = await fetchRoomsData(locationId);
111 |
112 | if (isRoomsData(roomsByFloorId)) {
113 | dispatch(setRooms(roomsByFloorId));
114 | mapService.updateRoomsData(roomsByFloorId);
115 | } else {
116 | throw new Error('Rooms data is not valid');
117 | }
118 | } catch (e) {
119 | console.error(e.message ?? 'Failed to get rooms info');
120 | dispatch(setLoadingState(LoadingState.Error));
121 | };
122 | };
123 |
--------------------------------------------------------------------------------
/public/static/images/empty-search.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/components/RoomsNavigator/RoomsNavigator.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState, useEffect } from 'react';
2 | import { DirectionalHint } from '@fluentui/react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { useHistory } from 'react-router-dom';
5 |
6 | import { SearchCallout } from '../SearchCallout/SearchCallout';
7 | import { RoomData, RoomsByFloorId } from '../../models/roomsData';
8 | import { selectRoomsData } from '../../reducers/rooms';
9 | import { selectCurrentIndoorLocation } from '../../reducers/indoor';
10 | import { changeLocation, selectCurrentLocationId } from '../../reducers/locationData';
11 | import { mapService } from '../../services/mapService';
12 |
13 | export interface RoomsNavigatorProps {
14 | target?: string | Element | MouseEvent | React.RefObject;
15 | onDismiss?: () => void;
16 | }
17 |
18 | interface RoomItem extends RoomData {
19 | id: string;
20 | locationId: string;
21 | }
22 |
23 | const processRoomData = (
24 | roomsData: RoomsByFloorId, selectedRoomId: string | undefined
25 | ): [RoomItem[], RoomItem | undefined] => {
26 | const rooms: RoomItem[] = [];
27 | let selectedRoom: RoomItem | undefined;
28 |
29 | for (const floorId in roomsData) {
30 | const floorData = roomsData[floorId];
31 |
32 | for (const roomId in floorData) {
33 | if (!floorData[roomId]) {
34 | continue;
35 | }
36 |
37 | const roomItem: RoomItem = {
38 | ...floorData[roomId]!,
39 | id: roomId,
40 | locationId: floorId,
41 | };
42 |
43 | rooms.push(roomItem);
44 |
45 | if (selectedRoomId === roomId) {
46 | selectedRoom = roomItem;
47 | }
48 | }
49 | }
50 |
51 | return [rooms, selectedRoom];
52 | };
53 |
54 | export const RoomsNavigator: React.FC = ({
55 | target,
56 | onDismiss,
57 | }) => {
58 | const dispatch = useDispatch();
59 | const history = useHistory();
60 |
61 | const currentLocationId: string | undefined = useSelector(selectCurrentLocationId);
62 | const roomsData: RoomsByFloorId = useSelector(selectRoomsData);
63 | const selectedRoomName: string | undefined = useSelector(selectCurrentIndoorLocation)?.name;
64 |
65 | const [clickedRoom, setClickedRoom] = useState(undefined);
66 |
67 | const selectedRoomId = selectedRoomName?.replace(new RegExp('-', 'g'), '');
68 | const [rooms, selectedRoom] = processRoomData(roomsData, selectedRoomId);
69 |
70 | const showRoom = useCallback((room?: RoomData) => {
71 | if (room) {
72 | mapService.showObject(room);
73 | setClickedRoom(undefined);
74 |
75 | if (onDismiss) {
76 | onDismiss();
77 | }
78 | }
79 | }, [onDismiss]);
80 |
81 | useEffect(() => {
82 | showRoom(clickedRoom);
83 | // here we need to show room after location change only
84 | // eslint-disable-next-line react-hooks/exhaustive-deps
85 | }, [currentLocationId]);
86 |
87 | const onRoomClick = useCallback((room: RoomItem) => {
88 | if (room.locationId !== currentLocationId) {
89 | // we need this here to await the reducer complete to avoid the animation reset on location change
90 | dispatch(changeLocation(room.locationId, history));
91 | setClickedRoom(room);
92 | } else {
93 | showRoom(room);
94 | }
95 | }, [currentLocationId, showRoom, dispatch, history]);
96 |
97 | return (
98 | (
109 | room.name ? `${room.type}: ${room.name} (${room.unitId})` : `${room.type}: ${room.unitId}`
110 | )}
111 | onItemClick={onRoomClick}
112 | onDismiss={onDismiss}
113 | />
114 | );
115 | };
116 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to [project-title]
2 |
3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a
4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
6 |
7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide
8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
9 | provided by the bot. You will only need to do this once across all repos using our CLA.
10 |
11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
14 |
15 | - [Code of Conduct](#coc)
16 | - [Issues and Bugs](#issue)
17 | - [Feature Requests](#feature)
18 | - [Submission Guidelines](#submit)
19 |
20 | ## Code of Conduct
21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
22 |
23 | ## Found an Issue?
24 | If you find a bug in the source code or a mistake in the documentation, you can help us by
25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can
26 | [submit a Pull Request](#submit-pr) with a fix.
27 |
28 | ## Want a Feature?
29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub
30 | Repository. If you would like to *implement* a new feature, please submit an issue with
31 | a proposal for your work first, to be sure that we can use it.
32 |
33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).
34 |
35 | ## Submission Guidelines
36 |
37 | ### Submitting an Issue
38 | Before you submit an issue, search the archive, maybe your question was already answered.
39 |
40 | If your issue appears to be a bug, and hasn't been reported, open a new issue.
41 | Help us to maximize the effort we can spend fixing issues and adding new
42 | features, by not reporting duplicate issues. Providing the following information will increase the
43 | chances of your issue being dealt with quickly:
44 |
45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps
46 | * **Version** - what version is affected (e.g. 0.1.2)
47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you
48 | * **Browsers and Operating System** - is this a problem with all browsers?
49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps
50 | * **Related Issues** - has a similar issue been reported before?
51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be
52 | causing the problem (line of code or commit)
53 |
54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new].
55 |
56 | ### Submitting a Pull Request (PR)
57 | Before you submit your Pull Request (PR) consider the following guidelines:
58 |
59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR
60 | that relates to your submission. You don't want to duplicate effort.
61 |
62 | * Make your changes in a new git fork:
63 |
64 | * Commit your changes using a descriptive commit message
65 | * Push your fork to GitHub:
66 | * In GitHub, create a pull request
67 | * If we suggest changes then:
68 | * Make the required updates.
69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request):
70 |
71 | ```shell
72 | git rebase master -i
73 | git push -f
74 | ```
75 |
76 | That's it! Thank you for your contribution!
77 |
--------------------------------------------------------------------------------
/src/components/SideNavBar/SideNavBar.tsx:
--------------------------------------------------------------------------------
1 | import './SideNavBar.scss';
2 |
3 | import React, { useState } from 'react';
4 | import { getId, IconButton, INavLink, Nav } from '@fluentui/react';
5 | import { useSelector } from 'react-redux';
6 |
7 | import { FavoritesSwitcher } from '../FavoritesSwitcher/FavoritesSwitcher';
8 | import { LayersSwitcher } from '../LayersSwitcher/LayersSwitcher';
9 | import { NavbarButton } from './NavbarButton/NavbarButton';
10 | import { selectRoomsCount } from '../../reducers/rooms';
11 | import { RoomsNavigator } from '../RoomsNavigator/RoomsNavigator';
12 |
13 | const links: INavLink[] = [
14 | {
15 | key: 'link1',
16 | name: 'Map',
17 | url: '/',
18 | icon: 'Globe',
19 | },
20 | {
21 | key: 'link2',
22 | name: 'Dashboard',
23 | url: '/',
24 | disabled: true,
25 | icon: 'BarChartVertical',
26 | },
27 | ];
28 |
29 | const SideNavBar: React.FC = () => {
30 | const roomsCount: number = useSelector(selectRoomsCount);
31 |
32 | const [isCollapsed, setCollapsed] = useState(true);
33 | const [isFavoritesVisible, setFavoritesVisibility] = useState(false);
34 | const [isLayersSwitcherVisible, setLayersSwitcherVisibility] = useState(false);
35 | const [isRoomSearchVisible, setRoomSearchVisibility] = useState(false);
36 |
37 | const toggleColapsed = () => setCollapsed(!isCollapsed);
38 | const favoritesButtonId: string = 'favorites-button';
39 | const layersButtonId = getId("layers-button");
40 | const searchRoomButtonId = getId('search-room-button');
41 |
42 | return (
43 |
44 |
45 |
53 |
54 | setFavoritesVisibility(!isFavoritesVisible)}
62 | ariaLabel="Favorites"
63 | title={isCollapsed ? 'Favorites' : undefined}
64 | />
65 |
66 | setRoomSearchVisibility(!isRoomSearchVisible)}
74 | ariaLabel="Search room"
75 | title={isCollapsed ? 'Search room' : undefined}
76 | />
77 |
78 |
79 |
setLayersSwitcherVisibility(!isLayersSwitcherVisible)}
84 | text={isCollapsed ? undefined : 'Settings'}
85 | title={isCollapsed ? 'Settings' : undefined}
86 | />
87 |
88 |
89 |
97 |
98 |
99 | {isFavoritesVisible && (
100 | setFavoritesVisibility(false)}
103 | />
104 | )}
105 |
106 | {isRoomSearchVisible && (
107 | setRoomSearchVisibility(false)}
110 | />
111 | )}
112 |
113 | {isLayersSwitcherVisible && (
114 | setLayersSwitcherVisibility(false)}
117 | />
118 | )}
119 |
120 | );
121 | };
122 |
123 | export default SideNavBar;
124 |
--------------------------------------------------------------------------------
/src/components/StatsSidebar/StatsSidebarShimmer/StatsSidebarShimmer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Shimmer, ShimmerElementsGroup, ShimmerElementType } from '@fluentui/react';
3 |
4 | const IndentShimmer: React.FC<{ height: number, width?: string }> = ({ height, width }) => {
5 | return (
6 |
12 | );
13 | };
14 |
15 | const LabelShimmer = () => {
16 | const labelWidth: number = Math.random() * 20 + 30;
17 | const valueWidth: number = Math.random() * 10 + 5;
18 | const gapWidth: number = 100 - labelWidth - valueWidth;
19 |
20 | return (
21 |
22 |
23 |
24 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | const GroupHeaderShimmer = () => {
40 | return (
41 |
42 |
43 |
44 |
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | const LegendItemShimmer = () => {
57 | return (
58 |
59 |
67 |
68 |
69 |
70 |
78 |
79 | );
80 | };
81 |
82 | const ChartLegendShimmer = () => {
83 | return (
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | );
96 | };
97 |
98 | const ChartShimmer = () => {
99 | return (
100 |
101 |
102 |
103 |
112 |
113 |
114 |
115 | )
116 | }
117 |
118 | export const StatsSidebarShimmer = React.memo(() => {
119 | const labels = [];
120 |
121 | for (let i = 0; i < 5; i++){
122 | labels.push();
123 | }
124 |
125 | return (
126 |
131 |
132 | {labels}
133 |
134 |
135 |
136 |
137 |
138 | }
139 | />
140 | );
141 | });
142 |
--------------------------------------------------------------------------------
/src/reducers/sensors.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 |
3 | import { AppThunk, RootState } from '../store/store';
4 | import { LoadingState } from '../models/loadingState';
5 | import { sensorsDataUrl } from '../config';
6 |
7 | interface SensorData {
8 | id: string;
9 | name: string;
10 | sensorClass: string;
11 | roomId: string;
12 | readings: {
13 | name: string;
14 | value: string;
15 | updatedAt: string;
16 | }[]
17 | }
18 |
19 | export interface SensorsState {
20 | byLocationAndRoom: { [locId: string]: SensorData[] | undefined };
21 | loadingState: LoadingState;
22 | };
23 |
24 | const initialState: SensorsState = {
25 | byLocationAndRoom: {},
26 | loadingState: LoadingState.Loading,
27 | };
28 |
29 | interface SetSensorsDataPayload {
30 | path: string;
31 | data: any;
32 | loadingState: LoadingState;
33 | }
34 |
35 | const parseSensorData = (path: string, rawData: any): { [locId: string]: SensorData[] } => {
36 | const result: { [locId: string]: SensorData[] } = {};
37 |
38 | try {
39 | const roomsDict = rawData?.Value?.Rooms;
40 | if (roomsDict) {
41 | Object.keys(roomsDict).forEach(roomId => {
42 | const locId = `${path}/${roomId}`;
43 | const sensors = Object.values(roomsDict[roomId].Devices)
44 | .map((rawDevice: any): SensorData => ({
45 | id: rawDevice.DeviceId,
46 | roomId,
47 | sensorClass: rawDevice.EquipmentClass,
48 | name: rawDevice.Equipment,
49 | readings: rawDevice.Tags.map((tag: any) => ({
50 | name: tag.Tag,
51 | value: tag.Value,
52 | updatedAt: tag.Timestamp,
53 | })),
54 | }));
55 |
56 | result[locId] = sensors;
57 | })
58 | }
59 | } catch (error) {
60 | // Do nothing, just return the default value
61 | console.error("Failed to parse sensor data");
62 | }
63 |
64 | return result;
65 | }
66 |
67 | export const sensorsSlice = createSlice({
68 | name: 'temperature',
69 | initialState,
70 | reducers: {
71 | setSensorsData: (state: SensorsState, action: PayloadAction) => {
72 | const { data, path, loadingState } = action.payload;
73 | const sensorData = parseSensorData(path, data);
74 |
75 | return {
76 | ...state,
77 | loadingState,
78 | byLocationAndRoom: {
79 | ...state.byLocationAndRoom,
80 | ...sensorData,
81 | },
82 | };
83 | },
84 | setLoadingState: (state: SensorsState, action: PayloadAction) => ({
85 | ...state,
86 | loadingState: action.payload,
87 | }),
88 | },
89 | });
90 |
91 | const { setSensorsData, setLoadingState } = sensorsSlice.actions;
92 |
93 | export default sensorsSlice.reducer;
94 |
95 | export const selectSensorsLoadingState = (state: RootState): LoadingState => state.sensors.loadingState;
96 |
97 | export const selectCurrentSensorsData = (state: RootState): SensorData[] | undefined => {
98 | const currentLocation = state.locationData.current.location;
99 | const currentIndoorLocation = state.indoor.currentLocation;
100 |
101 | if (currentLocation === undefined || currentIndoorLocation === undefined) {
102 | return;
103 | }
104 |
105 | const normalizedLocation = currentIndoorLocation.name.replace('-', '');
106 | const key = `${currentLocation.id}/${normalizedLocation}`;
107 | return state.sensors.byLocationAndRoom[key];
108 | }
109 |
110 | let fetchSensorsStateData = async (locationPath: string): Promise => {
111 | const url = sensorsDataUrl.replace("{locationPath}", locationPath);
112 | const response: Response = await fetch(url);
113 |
114 | if (response.ok) {
115 | const json = await response.json();
116 | return json ?? {};
117 | } else {
118 | throw new Error();
119 | }
120 | }
121 |
122 | export const fetchSensors = (path: string): AppThunk => async dispatch => {
123 | dispatch(setLoadingState(LoadingState.Loading));
124 |
125 | try {
126 | const data = await fetchSensorsStateData(path);
127 | dispatch(setSensorsData({ path, data, loadingState: LoadingState.Ready }));
128 | } catch {
129 | console.error("Failed to get sensors info");
130 | dispatch(setSensorsData({ path, data: {}, loadingState: LoadingState.Error }));
131 | };
132 | };
133 |
--------------------------------------------------------------------------------
/livemaps-api/APIFunctions/Config.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Mvc;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.AspNetCore.Http;
8 | using Microsoft.Extensions.Logging;
9 | using Newtonsoft.Json;
10 | using System.ComponentModel;
11 | using System.Text;
12 | using ssir.api.Services;
13 | using System.Collections.Generic;
14 | using ssir.api.Models;
15 | using Microsoft.WindowsAzure.Storage.Blob;
16 | using System.Linq.Expressions;
17 | using System.Linq;
18 | using System.Net.Http;
19 | using ssir.api.Models.Atlas;
20 | using System.Net;
21 |
22 | namespace ssir.api
23 | {
24 | public static class Config
25 | {
26 | [FunctionName("Config")]
27 | public static async Task Run(
28 | [HttpTrigger(AuthorizationLevel.Function, "get", Route = "config/{region}/{campus}/{building}")] HttpRequest req,
29 | [Blob("shared", Connection = "AzureWebJobsStorage")] CloudBlobContainer container,
30 | string region,
31 | string campus,
32 | string building,
33 | ILogger log)
34 | {
35 | bool prerequisites = true;
36 | var errors = new StringBuilder();
37 |
38 | var blobDataService = new BlobDataService();
39 |
40 | var atlasConfigFile = Environment.GetEnvironmentVariable("AtlasConfigFile") ?? "atlasConfig.json";
41 |
42 | if (string.IsNullOrEmpty(building))
43 | {
44 | prerequisites = false;
45 | errors.Append("Required query {building} was not defined");
46 | }
47 |
48 | var result = "";
49 | if (prerequisites)
50 | {
51 | try
52 | {
53 | var config = await blobDataService.ReadBlobData(container, atlasConfigFile);
54 | var buildingCfg = config.FirstOrDefault(cfg => cfg.BuildingId.ToLower() == $"{region}/{campus}/{building}".ToLower());
55 | if (buildingCfg != null)
56 | result = JsonConvert.SerializeObject(buildingCfg);
57 |
58 | }
59 | catch (Exception ex)
60 | {
61 | log.LogError(ex.Message);
62 | }
63 | }
64 | else
65 | {
66 | log.LogError(errors.ToString());
67 | return new NotFoundResult();
68 | }
69 |
70 | return new OkObjectResult(result);
71 | }
72 |
73 | private static async Task> FetchFeaturesFromAtlas(string atlasDataSetId, string atlasSubscriptionKey)
74 | {
75 | List features = new List();
76 | var limit = 50;
77 | string url = $"https://atlas.microsoft.com/wfs/datasets/{atlasDataSetId}/collections/unit/items?api-version=1.0&limit={limit}&subscription-key={atlasSubscriptionKey}";
78 | for (int i = 0; ; i++)
79 | {
80 | using (var client = new HttpClient())
81 | {
82 | HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, url);
83 | var response = await client.SendAsync(requestMessage);
84 |
85 | if (response.StatusCode != HttpStatusCode.OK)
86 | break;
87 |
88 | var result = await response.Content.ReadAsStringAsync();
89 |
90 | var featureCollection = JsonConvert.DeserializeObject(result);
91 | features.AddRange(featureCollection.Features);
92 |
93 | if (featureCollection.NumberReturned < limit)
94 | break;
95 | var nextLink = featureCollection.links.FirstOrDefault(f => f.rel == "next");
96 | if (nextLink == null)
97 | break;
98 | else
99 | url = nextLink.href + $"&subscription-key={atlasSubscriptionKey}";
100 | }
101 | }
102 |
103 | return features;
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/components/SearchCallout/SearchCallout.tsx:
--------------------------------------------------------------------------------
1 | import './SearchCallout.scss';
2 |
3 | import Fuse from 'fuse.js';
4 | import React from 'react';
5 | import {
6 | Callout,
7 | DefaultButton,
8 | DirectionalHint,
9 | IButtonProps,
10 | SearchBox,
11 | TooltipHost,
12 | TooltipOverflowMode,
13 | } from '@fluentui/react';
14 |
15 | import NoResults from '../NoResults/NoResults';
16 |
17 | type defaultItemRender = (props?: IButtonProps) => React.ReactNode;
18 |
19 | export interface SearchCalloutProps {
20 | items: T[];
21 | selectedItem?: T;
22 | target?: string | Element | MouseEvent | React.RefObject;
23 | searchOptions?: Fuse.IFuseOptions;
24 | groupName?: string;
25 | directionalHint?: DirectionalHint;
26 | onItemClick?: (item: T) => void;
27 | onDismiss?: () => void;
28 | getItemText?: (item: T) => string;
29 | renderItem?: (item: T, defaultRender: defaultItemRender) => React.ReactNode;
30 | }
31 |
32 | export const SearchCallout: (props: SearchCalloutProps) => React.ReactElement> | null = ({
33 | items,
34 | selectedItem,
35 | target,
36 | searchOptions,
37 | groupName,
38 | directionalHint,
39 | onItemClick,
40 | onDismiss,
41 | getItemText,
42 | renderItem,
43 | }) => {
44 | const [searchValue, setSearchValue] = React.useState('');
45 |
46 | if (!items?.length) {
47 | return null;
48 | }
49 |
50 | let filteredItems = items;
51 |
52 | if (searchValue) {
53 | const fuse = new Fuse(
54 | items,
55 | searchOptions,
56 | );
57 |
58 | filteredItems = fuse.search(searchValue).map((item: Fuse.FuseResult) => item.item);
59 | }
60 |
61 | const onFormSubmit = (event: React.FormEvent) => {
62 | event.preventDefault();
63 |
64 | if (!filteredItems) {
65 | return;
66 | }
67 |
68 | if (onItemClick) {
69 | onItemClick(filteredItems[0]);
70 | }
71 |
72 | setSearchValue('');
73 | };
74 |
75 | const renderDefaultItem = (item: any, props?: IButtonProps) => {
76 | const text = getItemText ? getItemText(item) : undefined;
77 |
78 | return (
79 | {
83 | if (onItemClick) {
84 | onItemClick(item);
85 | }
86 |
87 | setSearchValue('');
88 | }}
89 | {...props}
90 | >
91 |
96 | {text}
97 |
98 |
99 | {props?.children}
100 |
101 | );
102 | };
103 |
104 | const renderSearchItem = (item: any): React.ReactNode => {
105 | if (renderItem) {
106 | return renderItem(item, (props?: IButtonProps) => renderDefaultItem(item, props));
107 | }
108 |
109 | return renderDefaultItem(item);
110 | };
111 |
112 | return (
113 |
120 |
153 |
154 | )
155 | };
156 |
--------------------------------------------------------------------------------
/src/services/layers/FloorsLayer.ts:
--------------------------------------------------------------------------------
1 | import * as turf from '@turf/helpers';
2 | import transformScale from '@turf/transform-scale';
3 | import { data as atlasData, layer as atlasLayer, Map, source as atlasSource } from 'azure-maps-control';
4 |
5 | import { buildingPolygon } from '../../mockData/buildingData';
6 | import { getZoomByLocationType } from '../../utils/locationsUtils';
7 | import { LocationData, LocationType } from '../../models/locationsData';
8 | import { Layer, LayerType } from './Layer';
9 |
10 | const getOffsetByFloorNumber = (
11 | floorNumber: number,
12 | bearing: number = 0,
13 | pitch: number = 0
14 | ) => [pitch / 1000000 * 7 * floorNumber, pitch / 1000000 * 4 * floorNumber];
15 |
16 | export class FloorsLayer implements Layer {
17 | public readonly type: LayerType = LayerType.Floors;
18 |
19 | private polygonData: [number, number][] = buildingPolygon;
20 | private map?: Map;
21 | private dataSources: atlasSource.DataSource[] = [];
22 | private layers: atlasLayer.PolygonLayer[] = [];
23 | private isLayerOn: boolean = false;
24 | private location?: LocationData;
25 |
26 | constructor(
27 | public readonly id: string,
28 | public readonly name: string,
29 | ) {}
30 |
31 | get isVisible() {
32 | return this.isLayerOn;
33 | }
34 |
35 | initialize(map: Map) {
36 | this.map = map;
37 | this.update();
38 | }
39 |
40 | setVisibility(isVisible: boolean) {
41 | if (this.isLayerOn === isVisible) {
42 | return;
43 | }
44 |
45 | this.isLayerOn = isVisible;
46 | this.update();
47 | }
48 |
49 | setLocation(currentLocation: LocationData) {
50 | const newLocation = currentLocation.parent;
51 |
52 | if (this.location?.id === newLocation?.id) {
53 | return;
54 | }
55 |
56 | this.location = newLocation;
57 |
58 | if (this.isVisible) {
59 | this.update();
60 | }
61 | }
62 |
63 | update() {
64 | if (!this.map) {
65 | return;
66 | }
67 |
68 | this.removeLayers();
69 |
70 | if (!this.isVisible || this.location?.type !== LocationType.Building) {
71 | return;
72 | }
73 |
74 | const floorCount: number = this.location.items?.length ?? 0;
75 |
76 | if (floorCount < 1) {
77 | return;
78 | }
79 |
80 | const minZoom: number = getZoomByLocationType(LocationType.Floor);
81 |
82 | for (let ordinalNumber = 0; ordinalNumber < floorCount; ordinalNumber++) {
83 | const dataSource = new atlasSource.DataSource();
84 | this.dataSources.push(dataSource);
85 | this.map.sources.add(dataSource);
86 |
87 | const layer = new atlasLayer.PolygonLayer(
88 | dataSource,
89 | undefined,
90 | {
91 | fillColor: 'gray',
92 | minZoom,
93 | fillOpacity: 0.5,
94 | }
95 | );
96 |
97 | this.layers.push(layer);
98 | this.map.layers.add(layer);
99 | }
100 |
101 | this.updateCoordinates();
102 |
103 | this.map.events.add('pitch', this.updateCoordinates);
104 | }
105 |
106 | private removeLayers() {
107 | if (!this.map || !this.dataSources.length) {
108 | return;
109 | }
110 |
111 | this.map.events.remove('pitch', this.updateCoordinates);
112 | this.map.layers.remove(this.layers);
113 | this.map.sources.remove(this.dataSources);
114 | this.dataSources.forEach(dataSource => {
115 | dataSource.clear();
116 | dataSource.dispose();
117 | });
118 |
119 | this.dataSources = [];
120 | this.layers = [];
121 | }
122 |
123 | private updateCoordinates = () => {
124 | if (!this.map || !this.dataSources.length) {
125 | return;
126 | }
127 |
128 | const camera = this.map.getCamera();
129 |
130 | const polygon = turf.polygon([this.polygonData]);
131 |
132 | for (let ordinalNumber = 0; ordinalNumber < this.dataSources.length; ordinalNumber++) {
133 | const rotatedPolygon = transformScale(polygon, 1 + ordinalNumber * 0.16);
134 | const offset = getOffsetByFloorNumber(ordinalNumber, camera.bearing, camera.pitch);
135 | this.dataSources[ordinalNumber].clear();
136 | this.dataSources[ordinalNumber]?.add(new atlasData.Polygon(
137 | rotatedPolygon.geometry!.coordinates[0].map((position) => new atlasData.Position(
138 | position[0] + offset[0], position[1] + offset[1])
139 | )
140 | ));
141 | }
142 | }
143 |
144 | dispose() {
145 | this.removeLayers();
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/reducers/warnings.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 |
3 | import { AppThunk, RootState } from '../store/store';
4 | import { WarningData, WarningsByLayers, WarningsByLocation, WarningsByRooms } from '../models/warningsData';
5 | import { mapService } from '../services/mapService';
6 | import { LocationType } from '../models/locationsData';
7 | import { LoadingState } from '../models/loadingState';
8 | import { warningsDataUrl } from '../config';
9 |
10 | export const isWarningsDataValid = (data: any): data is WarningsByLocation => {
11 | try {
12 | for (const locationId in data) {
13 | const warningsByRooms: WarningsByRooms = data[locationId];
14 |
15 | for (const roomId in warningsByRooms) {
16 | const warningsByLayers: WarningsByLayers | undefined = warningsByRooms[roomId];
17 |
18 | for (const layerId in warningsByLayers) {
19 | const warnings: WarningData[] | undefined = warningsByLayers[layerId];
20 | const isValid: boolean = !!warnings && warnings.every((warning: WarningData) => (
21 | (!warning.title || typeof warning.title === 'string')
22 | && (!warning.description || typeof warning.description === 'string')
23 | && (!warning.url || typeof warning.url === 'string')
24 | && (!warning.position || (
25 | typeof warning.position.latitude === 'number' && typeof warning.position.longitude === 'number')
26 | )
27 | ));
28 |
29 | if (!isValid) {
30 | return false;
31 | }
32 | }
33 | }
34 | }
35 | return true;
36 | } catch {
37 | return false;
38 | }
39 | };
40 |
41 | export interface WarningsState {
42 | data: WarningsByLocation;
43 | loadingState: LoadingState;
44 | };
45 |
46 | const initialState: WarningsState = {
47 | data: {},
48 | loadingState: LoadingState.Loading,
49 | };
50 |
51 | interface SetWarningsPayload {
52 | data: WarningsByLocation;
53 | loadingState: LoadingState;
54 | }
55 |
56 | export const warningsSlice = createSlice({
57 | name: 'warnings',
58 | initialState,
59 | reducers: {
60 | setWarnings: (state: WarningsState, action: PayloadAction) => {
61 | const { data, loadingState } = action.payload;
62 |
63 | return {
64 | ...state,
65 | data,
66 | loadingState,
67 | };
68 | },
69 | },
70 | });
71 |
72 | const {
73 | setWarnings,
74 | } = warningsSlice.actions;
75 |
76 | export const selectWarningsLoadingState = (state: RootState) => state.warnings.loadingState;
77 |
78 | export const selectWarningsData = (state: RootState) => state.warnings.data;
79 |
80 | export default warningsSlice.reducer;
81 |
82 | const fetchWarningsData = async (locationId: string) => {
83 | const url = warningsDataUrl.replace("{locationPath}", locationId);
84 | const response: Response = await fetch(url);
85 |
86 | if (response.ok) {
87 | const json = await response.json();
88 | return json ?? {};
89 | } else {
90 | throw new Error();
91 | }
92 | }
93 |
94 | export const fetchWarningsInfo = (): AppThunk => async (dispatch, getState) => {
95 | const { locationData: { current: { location: currentLocation } } } = getState();
96 | mapService.updateWarningsData({});
97 |
98 | let locationId: string = '';
99 |
100 | if (currentLocation.type === LocationType.Building) {
101 | locationId = currentLocation.id;
102 | } else if (currentLocation.type === LocationType.Floor && currentLocation.parent?.id) {
103 | const parentLocation = currentLocation.parent;
104 |
105 | if (parentLocation) {
106 | locationId = parentLocation.id;
107 | }
108 | }
109 |
110 | if (!locationId) {
111 | dispatch(setWarnings({ data: {}, loadingState: LoadingState.Ready }));
112 | return;
113 | }
114 |
115 | dispatch(setWarnings({ data: {}, loadingState: LoadingState.Loading }));
116 |
117 | try {
118 | const warningsByLocation: any = await fetchWarningsData(locationId);
119 |
120 | if (isWarningsDataValid(warningsByLocation)) {
121 | dispatch(setWarnings({ data: warningsByLocation, loadingState: LoadingState.Ready }));
122 | mapService.updateWarningsData(warningsByLocation);
123 | } else {
124 | throw new Error('Warnings data is not valid');
125 | }
126 | } catch (e) {
127 | console.error(e.message ?? 'Failed to get warnings info');
128 | dispatch(setWarnings({ data: {}, loadingState: LoadingState.Error }));
129 | };
130 | };
131 |
--------------------------------------------------------------------------------
/src/mockData/buildingData.ts:
--------------------------------------------------------------------------------
1 | export const buildingPolygon: [number, number][] = [
2 | [
3 | -122.13686051087231,
4 | 47.64804650548146
5 | ],
6 | [
7 | -122.13686075903941,
8 | 47.648046501089375
9 | ],
10 | [
11 | -122.13686034067604,
12 | 47.648035706887065
13 | ],
14 | [
15 | -122.13670420411449,
16 | 47.648038470103195
17 | ],
18 | [
19 | -122.13670419761537,
20 | 47.64803830240588
21 | ],
22 | [
23 | -122.13669811437239,
24 | 47.648038410059343
25 | ],
26 | [
27 | -122.13669768954347,
28 | 47.64802744815912
29 | ],
30 | [
31 | -122.13669744137644,
32 | 47.648027452550849
33 | ],
34 | [
35 | -122.13668044836744,
36 | 47.647588976523053
37 | ],
38 | [
39 | -122.13668069653238,
40 | 47.647588972131345
41 | ],
42 | [
43 | -122.13668027820975,
44 | 47.647578177927514
45 | ],
46 | [
47 | -122.13652414300228,
48 | 47.647580940898827
49 | ],
50 | [
51 | -122.13652413650377,
52 | 47.647580773201483
53 | ],
54 | [
55 | -122.13651805331357,
56 | 47.64758088084541
57 | ],
58 | [
59 | -122.13651762852601,
60 | 47.647569918943638
61 | ],
62 | [
63 | -122.13651738036114,
64 | 47.647569923334977
65 | ],
66 | [
67 | -122.13650017661739,
68 | 47.64712596629402
69 | ],
70 | [
71 | -122.13650042478014,
72 | 47.647125961902709
73 | ],
74 | [
75 | -122.13649999999998,
76 | 47.647115
77 | ],
78 | [
79 | -122.13650608313615,
80 | 47.647114892357017
81 | ],
82 | [
83 | -122.13650607663777,
84 | 47.647114724659659
85 | ],
86 | [
87 | -122.13698867202518,
88 | 47.647106183955259
89 | ],
90 | [
91 | -122.13698867852511,
92 | 47.647106351652575
93 | ],
94 | [
95 | -122.13699476165927,
96 | 47.647106243983693
97 | ],
98 | [
99 | -122.13699518654288,
100 | 47.6471172058846
101 | ],
102 | [
103 | -122.13699543470555,
104 | 47.647117201492208
105 | ],
106 | [
107 | -122.1370078868719,
108 | 47.647438462478256
109 | ],
110 | [
111 | -122.1370157293816,
112 | 47.647438323668673
113 | ],
114 | [
115 | -122.13701555257127,
116 | 47.647433762076822
117 | ],
118 | [
119 | -122.13701588520317,
120 | 47.647433756189344
121 | ],
122 | [
123 | -122.13701587650758,
124 | 47.647433531312785
125 | ],
126 | [
127 | -122.13706997597257,
128 | 47.647432573757079
129 | ],
130 | [
131 | -122.13706998468527,
132 | 47.647432798533565
133 | ],
134 | [
135 | -122.13707031733794,
136 | 47.647432792645574
137 | ],
138 | [
139 | -122.13707545602652,
140 | 47.647565363568333
141 | ],
142 | [
143 | -122.13716873556244,
144 | 47.647563712468624
145 | ],
146 | [
147 | -122.13716874206301,
148 | 47.647563880165926
149 | ],
150 | [
151 | -122.1371748252499,
152 | 47.647563772487494
153 | ],
154 | [
155 | -122.13717525017491,
156 | 47.647574734386843
157 | ],
158 | [
159 | -122.13717549833974,
160 | 47.647574729994076
161 | ],
162 | [
163 | -122.13719249548743,
164 | 47.648013205949631
165 | ],
166 | [
167 | -122.13719224732051,
168 | 47.648013210342441
169 | ],
170 | [
171 | -122.13719266575211,
172 | 47.648024004543579
173 | ],
174 | [
175 | -122.13734880224474,
176 | 47.6480212406627
177 | ],
178 | [
179 | -122.13734880874593,
180 | 47.648021408359988
181 | ],
182 | [
183 | -122.13735489198559,
184 | 47.648021300672021
185 | ],
186 | [
187 | -122.13735531695198,
188 | 47.648032262569835
189 | ],
190 | [
191 | -122.13735556511898,
192 | 47.64803225817667
193 | ],
194 | [
195 | -122.13737277640907,
196 | 47.648476215018839
197 | ],
198 | [
199 | -122.13737252823998,
200 | 47.648476219412039
201 | ],
202 | [
203 | -122.13737295321374,
204 | 47.6484871813089
205 | ],
206 | [
207 | -122.13736686992003,
208 | 47.648487288997821
209 | ],
210 | [
211 | -122.13736687642134,
212 | 47.648487456695086
213 | ],
214 | [
215 | -122.13688426837177,
216 | 47.648495998988295
217 | ],
218 | [
219 | -122.13688426187201,
220 | 47.648495831291
221 | ],
222 | [
223 | -122.1368781785763,
224 | 47.648495938954014
225 | ],
226 | [
227 | -122.13687775370597,
228 | 47.648484977055325
229 | ],
230 | [
231 | -122.1368775055368,
232 | 47.64848498144746
233 | ],
234 | [
235 | -122.13686051087231,
236 | 47.64804650548146
237 | ]
238 | ];
--------------------------------------------------------------------------------
/livemaps-api/Services/MapsService.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json;
2 | using ssir.api.Models.Atlas;
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Linq;
6 | using System.Net;
7 | using System.Net.Http;
8 | using System.Net.Http.Headers;
9 | using System.Text;
10 | using System.Threading.Tasks;
11 |
12 | namespace ssir.api.Services
13 | {
14 | public class MapsService
15 | {
16 | private string subscriptionKey;
17 | private string statesetId;
18 | private string datasetId;
19 | private string azmapsRoot = "https://us.atlas.microsoft.com/featureState/state";
20 |
21 | public MapsService(string subscriptionKey, string datasetId, string statesetId)
22 | {
23 | this.subscriptionKey = subscriptionKey;
24 | this.datasetId = datasetId;
25 | this.statesetId = statesetId;
26 | }
27 |
28 | public async Task UpdateTagState(string stateSetName, string featureId, string newValue)
29 | {
30 | using (var client = new HttpClient())
31 | {
32 | var baseUri = new StringBuilder(azmapsRoot);
33 | if (azmapsRoot[azmapsRoot.Length - 1] != '?')
34 | {
35 | baseUri.Append('?');
36 | }
37 |
38 | var queryParams = new Dictionary();
39 | queryParams.Add("api-version", "1.0");
40 | queryParams.Add("statesetId", statesetId);
41 | queryParams.Add("datasetId", datasetId);
42 | queryParams.Add("featureId", featureId);
43 | queryParams.Add("subscription-key", subscriptionKey);
44 |
45 | foreach (var queryParam in queryParams)
46 | {
47 | baseUri.Append($"{queryParam.Key}={queryParam.Value}&");
48 | }
49 |
50 | baseUri.Remove(baseUri.Length - 1, 1);
51 |
52 |
53 | HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Post, baseUri.ToString());
54 |
55 | var bodymessage = new
56 | {
57 | states = new[] {
58 | new {
59 | keyName = stateSetName,
60 | value = newValue,
61 | eventTimestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss")
62 | }
63 | }
64 | };
65 |
66 | string content = JsonConvert.SerializeObject(bodymessage);
67 |
68 | requestMessage.Content = new StringContent(content);
69 | requestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
70 |
71 | var response = await client.SendAsync(requestMessage);
72 | var result = await response.Content.ReadAsStringAsync();
73 |
74 | return result;
75 | }
76 | }
77 |
78 | internal static async Task> FetchFeaturesFromAtlas(string atlasDataSetId, string atlasSubscriptionKey)
79 | {
80 | List features = new List();
81 | var limit = 50;
82 | string url = $"https://us.atlas.microsoft.com/wfs/datasets/{atlasDataSetId}/collections/unit/items?api-version=1.0&limit={limit}&subscription-key={atlasSubscriptionKey}";
83 | for (int i = 0; ; i++)
84 | {
85 | using (var client = new HttpClient())
86 | {
87 | HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, url);
88 | var response = await client.SendAsync(requestMessage);
89 |
90 | if (response.StatusCode != HttpStatusCode.OK)
91 | break;
92 |
93 | var result = await response.Content.ReadAsStringAsync();
94 |
95 | var featureCollection = JsonConvert.DeserializeObject(result);
96 | features.AddRange(featureCollection.Features);
97 |
98 | if (featureCollection.NumberReturned < limit)
99 | break;
100 | var nextLink = featureCollection.links.FirstOrDefault(f => f.rel == "next");
101 | if (nextLink == null)
102 | break;
103 | else
104 | url = nextLink.href.Replace("https://atlas", "https://us.atlas") + $"&subscription-key={atlasSubscriptionKey}";
105 | }
106 | }
107 |
108 | return features;
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/reducers/indoor.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { AppThunk, RootState } from '../store/store';
3 | import { subscriptionKey } from '../config';
4 |
5 | import { LocationData, LocationType } from "../models/locationsData";
6 |
7 | interface LocationState {
8 | value?: number;
9 | loaded: boolean;
10 | }
11 |
12 | export interface LocationStates {
13 | [name: string]: LocationState | undefined;
14 | }
15 |
16 | export interface IndoorLocation {
17 | id: string;
18 | name: string;
19 | type: string;
20 | floor?: string;
21 | }
22 |
23 | export interface IndoorState {
24 | currentLocation?: IndoorLocation;
25 | currentStates: LocationStates;
26 | };
27 |
28 | const initialState: IndoorState = {
29 | currentStates: {},
30 | };
31 |
32 | export const indoorSlice = createSlice({
33 | name: 'indoor',
34 | initialState,
35 | reducers: {
36 | setCurrentIndoorLocation: (state: IndoorState, action: PayloadAction) => ({
37 | ...state,
38 | currentLocation: action.payload,
39 | }),
40 | setCurrentLocationStates: (state: IndoorState, action: PayloadAction) => {
41 | return {
42 | ...state,
43 | currentStates: action.payload,
44 | }
45 | },
46 | setCurrentLocationState: (state: IndoorState, action: PayloadAction<[string, LocationState | undefined]>) => {
47 | const [stateName, stateValue] = action.payload;
48 | return {
49 | ...state,
50 | currentStates: {
51 | ...state.currentStates,
52 | [stateName]: stateValue,
53 | },
54 | }
55 | },
56 | resetCurrentIndoorLocation: (state: IndoorState, action: PayloadAction) => ({
57 | ...state,
58 | currentLocation: undefined,
59 | currentStates: {},
60 | }),
61 | },
62 | });
63 |
64 | export const {
65 | resetCurrentIndoorLocation,
66 | setCurrentLocationState,
67 | } = indoorSlice.actions;
68 |
69 | export const selectCurrentIndoorLocation = (state: RootState) => state.indoor.currentLocation;
70 | export const selectCurrentIndoorStates = (state: RootState) => state.indoor.currentStates;
71 |
72 | export const setCurrentIndoorLocation = (location: IndoorLocation): AppThunk =>
73 | async (dispatch, getState) => {
74 | dispatch(indoorSlice.actions.setCurrentIndoorLocation(location));
75 |
76 | const currentLocation = getState().locationData.current.location;
77 | const statesets = currentLocation ? getLocationStatesets(currentLocation) : [];
78 | if (statesets.length === 0) {
79 | return;
80 | }
81 |
82 | const states: LocationStates = zip(statesets.map(({ stateSetName }) => [stateSetName, { loaded: false }]));
83 | // Use a copy of states since redux seals the dispatched action's payload
84 | // to prevent store modification bu we want to reuse the states later
85 | dispatch(indoorSlice.actions.setCurrentLocationStates({ ...states }));
86 |
87 | const promises: Promise[] = statesets.map(
88 | ({ stateSetId, stateSetName }) =>
89 | fetchFeatureState(stateSetId, stateSetName, location.id)
90 | .then(value => {
91 | states[stateSetName] = { value, loaded: true };
92 | })
93 | );
94 |
95 | await Promise.all(promises);
96 | dispatch(indoorSlice.actions.setCurrentLocationStates(states));
97 | };
98 |
99 | export default indoorSlice.reducer;
100 |
101 | const fetchFeatureState = async (
102 | statesetId: string,
103 | statesetName: string,
104 | featureId: string
105 | ): Promise => {
106 | const url = `https://us.atlas.microsoft.com/featureState/state?api-version=1.0&statesetId=${statesetId}&featureId=${featureId}&subscription-key=${subscriptionKey}`;
107 |
108 | try {
109 | const res = await fetch(url);
110 | if (res.status !== 200) {
111 | throw new Error(`HTTP ${res.status}`);
112 | }
113 |
114 | const body = await res.json();
115 | return body.states.find((state: any) => state.keyName === statesetName)?.value;
116 | } catch (error) {
117 | console.warn(`Failed to fetch feature state for feature ${featureId}, stateset ${statesetId}: ${error}`);
118 | }
119 | };
120 |
121 | const getLocationStatesets = (location: LocationData) => {
122 | let statesets = location.config?.stateSets;
123 | if (statesets === undefined && location.type === LocationType.Floor) {
124 | statesets = location.parent?.config?.stateSets;
125 | }
126 |
127 | return statesets ?? [];
128 | };
129 |
130 | /**
131 | * Zip turns an array of 2-item tuples into a dictionary
132 | * @param list of items to be zipped into dictionary
133 | */
134 | const zip = (items: [string, T][]): { [k: string]: T } =>
135 | items.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {});
136 |
--------------------------------------------------------------------------------
/src/services/layers/MarkersLayer.ts:
--------------------------------------------------------------------------------
1 | import AnimationController from 'css-animation-sync';
2 | import { HtmlMarker, Map } from 'azure-maps-control';
3 | import { indoor } from 'azure-maps-indoor';
4 | import { Dispatch } from '@reduxjs/toolkit';
5 | import { getId } from '@fluentui/react';
6 |
7 | import { Layer, LayerType } from './Layer';
8 | import { LocationData, LocationType } from '../../models/locationsData';
9 | import { hidePopover, showPopover } from '../../reducers/popover';
10 | import { PopoverType } from '../../models/popoversData';
11 | import { MarkerData } from '../../models/mapData';
12 | import { getZoomByLocationType } from '../../utils/locationsUtils';
13 |
14 | export class MarkersLayer implements Layer {
15 | public readonly type = LayerType.Markers;
16 | private map?: Map;
17 | private data: MarkerData[] = [];
18 | private visible: boolean = false;
19 | private dispatch?: Dispatch;
20 | private animation?: AnimationController;
21 | private isMarkersVisible: boolean = false;
22 | private readonly minZoom: number = getZoomByLocationType(LocationType.Floor);
23 | private markers: HtmlMarker[] = [];
24 |
25 | constructor(
26 | public readonly id: string,
27 | public readonly name: string,
28 | ) {}
29 |
30 | public get isVisible(): boolean {
31 | return this.visible;
32 | }
33 |
34 | initialize(map: Map, indoorManager: indoor.IndoorManager, dispatch: Dispatch) {
35 | this.map = map;
36 | this.dispatch = dispatch;
37 | }
38 |
39 | setVisibility(isVisible: boolean) {
40 | if (!this.map || this.isVisible === isVisible) {
41 | return;
42 | }
43 |
44 | this.visible = isVisible;
45 | this.updateMarkers();
46 | }
47 |
48 | setLocation(location: LocationData) {}
49 |
50 | dispose() {
51 | this.removeAnimation();
52 | }
53 |
54 | updateData(data: MarkerData[]) {
55 | this.data = data;
56 |
57 | if (this.isVisible) {
58 | this.updateMarkers();
59 | }
60 | }
61 |
62 | private shouldMarkersBeVisible() {
63 | if (!this.map || !this.isVisible) {
64 | return false;
65 | }
66 |
67 | const currentZoom: number | undefined = this.map.getCamera().zoom;
68 | return currentZoom ? currentZoom >= this.minZoom : false;
69 | }
70 |
71 | private updateMarkersVisibility = () => {
72 | if (!this.map) {
73 | return;
74 | }
75 |
76 | const isVisible = this.shouldMarkersBeVisible() && this.markers.length > 1;
77 |
78 | if (isVisible === this.isMarkersVisible) {
79 | return;
80 | }
81 |
82 | this.isMarkersVisible = isVisible;
83 |
84 | this.markers.forEach((marker: HtmlMarker) => marker.setOptions({ visible: isVisible }));
85 | }
86 |
87 | private removeAnimation() {
88 | if (this.animation) {
89 | this.animation?.free();
90 | this.animation = undefined;
91 | }
92 | }
93 |
94 | private removeMarkers() {
95 | if (this.map) {
96 | this.markers.forEach((marker: HtmlMarker) => this.map!.markers.remove(marker));
97 | }
98 | }
99 |
100 | private updateMarkers() {
101 | this.removeMarkers();
102 | this.removeAnimation();
103 | this.map?.events.remove('zoom', this.updateMarkersVisibility);
104 | this.isMarkersVisible = false;
105 |
106 | if (!this.map || !this.isVisible) {
107 | return;
108 | }
109 |
110 | const isMarkersVisible = this.shouldMarkersBeVisible();
111 | this.data.forEach((markerData: MarkerData) => {
112 | this.createMarker(markerData, isMarkersVisible);
113 | });
114 |
115 | if (this.markers.length > 0) {
116 | this.isMarkersVisible = isMarkersVisible;
117 | this.animation = new AnimationController('pulse');
118 | this.map?.events.add('zoom', this.updateMarkersVisibility);
119 | }
120 | }
121 |
122 | private createMarker(data: MarkerData, isVisible: boolean) {
123 | if (!this.map || !data.position) {
124 | return;
125 | }
126 |
127 | const id: string = getId(`${this.id}-marker`);
128 |
129 | const htmlMarker: HtmlMarker = new HtmlMarker({
130 | htmlContent: ``,
131 | position: [data.position.longitude, data.position.latitude],
132 | visible: isVisible,
133 | });
134 |
135 | this.map.markers.add(htmlMarker);
136 | this.markers.push(htmlMarker);
137 |
138 | const marker = document.getElementById(id);
139 |
140 | if (!marker) {
141 | return;
142 | }
143 |
144 | marker.onmouseover = () => this.dispatch!(showPopover({
145 | type: PopoverType.Warning,
146 | isVisible: true,
147 | target: `#${id}`,
148 | title: data.title,
149 | description: data.description,
150 | }));
151 |
152 | marker.onmouseleave = () => this.dispatch!(hidePopover(PopoverType.Warning));
153 | }
154 | }
155 |
--------------------------------------------------------------------------------